SCIUI · SLIDERS

Single

One handle. Toggle "Snap to whole numbers" to compare a continuous value against one snapped to a `step` grid — then cycle the accent and container controls to see the fill hold up against every page accent and every real background context.

Snap to whole numbers
Accent
Container
Grind42.0

With no step, the value is an exact float, not snapped to a grid — the same control the "Ticks & format" subpage demos with a fixed step instead of this toggle.

Live demo · sciui Slider, mode="single" (libs/sciui)
Comments & Suggestions
  • The fill accent tracks whatever the <Configurator> accent picker is set to, live — because sciui's Slider reads the page --ac directly (its default is --sci-accent: var(--ac, …)). accentVars() sets only --ac on the wrapper, and the canvas picks it up with no per-Space override. The old cv question — whether the .cv-slider specificity override was still needed — is resolved: it was DELETED 2026-07-16, sciui reads --ac for everyone.
  • First subpage refactored onto the shared <Configurator> (Phase 5 pilot) — accent + container switching is now free chrome instead of something each subpage would have to hand-build; the demo-specific control (the snap toggle) is the only bespoke part.
SCIUI · BETA DIAL

Overview

A whole Beta distribution shaped by its 90% credible interval. Drag the interval's ends to set how sure you are (narrow = many draws, wide = few); slide the band to move the success rate. α and β are solved from the two bounds and stay continuous.

Dial height260
Accent
Container
6
successes
3
fails
9
draws
64%
est. rate

The interval's width is the number of draws: pinch the ends together and the curve pulls tall and narrow; pull them apart and it flattens toward the flat prior (α=β=1). All the Beta math runs in log-space Lanczos log-Γ, so α+β stays exact into the billions.

Live demo · sciui BetaDial (libs/sciui)
Comments & Suggestions
  • Dragging the CI band and reading the CR%/stat readouts stayed in sync in this demo; no feedback-loop glitch observed. Worth a dedicated check of the direct CR% edit path specifically (typing a value rather than dragging), since that surface wasn't exercised by this static writeup and needs a live interaction pass.
  • Now on the shared <Configurator>, so the "side gauges span the full panel height" requirement can be checked directly inside every real container (card/disclosure/tabs panel), not just the plain page — flagging for a browser check across all four, at a couple of the height slider's extremes.
SCIUI · SELECTOR

API

The whole contract, and nothing else. Usage is on the subpages that follow.

Selector

  options         SelectorOption[]                { label, value, disabled? }
  groups          SelectorGroup[]                 { label?, options } — instead
                                                  of options; a run of chosen
                                                  options never spans two

  value           T[]                             THE CHOSEN SET — always a set
  onChange        (value: T[]) => void            always a set, for the same
                                                  reason
  max             number = 1                      how many may be chosen at
                                                  once; choosing past it evicts
                                                  the OLDEST. Infinity = no cap
  min             number = max === 1 ? 1 : 0      how few may remain

  layout          'row' | 'column' = 'row'
  collapse        false | 'overlay' | 'inline' = false
                                                  shown whole, or behind a
                                                  trigger: over the page, or
                                                  in place
  density         'default' | 'compact' = 'default'
  size            'xs'|'sm'|'md'|'lg'|'xl' = 'md'
  scroll          boolean = false                 overflow along the main axis
                                                  instead of squeezing
  frost           boolean = false                 see-through backdrop; stable
                                                  backdrops only
  shadow          'inset' | 'outset' | 'none' = 'inset'

  bare            boolean = false                 no shell surface at all; with
                                                  collapse="inline" it strips
                                                  the PANEL only
  push            boolean = true                  inline only: open pushes what
                                                  follows down, or overlays it
  open            boolean?                        controlled; omit for internal
  onOpenChange    (open: boolean) => void

  label           ReactNode?
  labelPlacement  'outside' | 'inside' = 'outside'
  placeholder     ReactNode?                      trigger text for an empty set
  summary         ReactNode?                      override the derived summary

  className       string?
  style           CSSProperties?
  aria-label      string?


THE FOUR SHAPES, out of two numbers

  max=1  min=1    a switcher — exactly one, always      (the sliding pill)
  max=1  min=0    a toggle — one or nothing
  max=∞  min=0    a checklist — any number, none included
  max=3  min=1    up to three, never fewer than one


CSS

  --sel-q            length    the quantum                    = --sciui-q
  --sel-thick        length    cross-axis size                = 9 * --sel-q
  --sel-r            length    corner radius                  = --sel-thick / 2
  --sel-pad          length    shell inset, and the pill's gap= --sel-q
  --sel-pill-r       length    the pill's own radius          = --sel-r - --sel-pad
  --sel-motion       duration  the pill's glide               = 190ms
  --sel-fill         color     the shell's surface            = --sel-surface
  --sel-pill-bg      color                                    = --sel-accent
  --sel-rim-w        length    rim width; :focus-visible doubles it
  --sel-rim-color    color                                    = ink 8%
  --sel-inset-color  color     inner band                     = transparent
  --sel-cast-color   color     outset cast                    = --sciui-cast
  --sel-pop-surface  color     the floating list, OPAQUE      = ink 6% over base


LAWS

  every surface is an <Sq> — track, pill, trigger, popup, inline panel
  a PILL IS A RUN, not an option: adjacent chosen options are ONE surface
    (<Sq.Slide follow={[first, last]}>), so there is no seam to hide
  the pill is keyed by POSITION, so a change of choice glides
  the chosen label changes COLOUR only — never weight: switching must not
    reflow the text. The pill is the signal
  where the engine has anchor positioning, the floating list is a popover in
    the top layer, tethered by CSS — nothing is measured and no scroll is
    listened to. Elsewhere it falls back to a measured portal


NOT COVERED YET

  RTL — the row mixes physical direction with logical padding
  the concentric radius channel does not reach a nested <Sq> (a --sq-radius /
    --sq-r cycle), so the pill states --sel-pill-r rather than inheriting it
sciui Selector · libs/sciui/src/selector.tsx
SCIUI · TABS

API

The whole contract, and nothing else. Usage is on the subpages that follow.

Tabs

  labels          ReactNode[]                     one per panel, in order; ANY
                                                  node — badges, counts, icons
  children        ReactNode                       each direct child is one panel

  index           number?                         controlled active index
  defaultIndex    number = 0                      uncontrolled initial
  onIndexChange   (index: number) => void

  side            'top' | 'right' | 'bottom' | 'left' = 'top'
                                                  strip edge AND the weld side
  size            'xs' | 'sm' | 'md' | 'lg' | 'xl' = 'md'
  lines           number = 1                      vertical only; 1 = no wrap at
                                                  all, strip = longest label.
                                                  N = narrowest width at which
                                                  no label exceeds N lines
  shadow          'inset' | 'outset' | 'none' = 'inset'
  cast            'near' | 'far' = 'near'
  rim             number = 0                      ring width in px, 0 = off

  disabled        number[]?                       tab indices; unreachable,
                                                  still occupy their place
  mount           'active' | 'all' = 'active'     'all' keeps every panel in the
                                                  document, hidden — for SEO,
                                                  in-page find, kept state
  start           ReactNode?                      any content at the strip's
  end             ReactNode?                      two ends; not tabs, not in
                                                  the tab order

  className       string?
  style           CSSProperties?
  aria-label      string?                         lands on the tablist
  …rest           forwarded to the root element


CSS

  --tabs-r          length      corner + ear + panel radius   = --sciui-r ?? --r
  --tabs-q          length      spacing quantum               = --g
  --tabs-strip-h    length      tab depth                     = 2 * --tabs-r
  --tabs-strip-t    length      strip thickness               = 2R | measured
  --tabs-pad        length      end clearance                 = 2 * --tabs-r
  --tabs-alpha      number      panel tint                    = 0.06
  --tabs-under      color       what the panel is mixed over  = --sq-under
  --tabs-ink        color                                     = --ink
  --tabs-accent     color                                     = --ac
  --tabs-rim-color  color       the ring on the union         = ink 22%
  --tabs-cast-color color       outset cast (never the ink)   = --sciui-cast
  --tabs-motion     duration    weld glide                    = 220ms


LAWS

  the panel is one <Sq>; the active tab is a guest welded onto it
  2R clearance — every tab keeps 2R from a panel corner (structural: equal
    tabs + 2R strip padding)
  the tab is 2R deep on every side; only the strip's thickness turns
  keyboard follows the axis — ←→ horizontal, ↑↓ vertical
  the panel is OPAQUE — an outset cast lands behind its silhouette
  a cast never follows the ink; the inner band always does


NOT COVERED YET

  RTL — the strip mixes physical row/row-reverse with logical padding
  labels[] and children[] are paired BY POSITION; a length mismatch is
    tolerated silently (extra labels get an empty panel)
sciui Tabs · libs/sciui/src/tabs.tsx
SCIUI · SWITCH

Standalone

Used on its own as a plain settings toggle — deliberately paired with two labels of very different length ("Off" vs "Enabled") so the no-layout-shift behaviour is visible: the button's width is fixed by the wider label from the start, since both labels are always in the DOM (one just invisible).

Accent
Container
Notifications

Toggling the switch flips between "Off" and "Enabled" — watch the button's own width as the label changes: it should not resize or reflow anything around it.

Live demo · sciui Switch, standalone (libs/sciui)
Comments & Suggestions
No issues found for this mode — the two labels of unequal length render as intended, with the inactive one kept in the DOM (aria-hidden) purely to reserve width.
SCIUI · DISCLOSURE

Basic

A single expand/collapse panel — click the header to reveal its content. Unlike an accordion, one <Disclosure> is exactly one region; a consumer wanting several independent panels composes multiple instances (there is no built-in exclusive-open group behaviour).

Accent
Container
What is this?

This is the panel content. It's revealed by a pure-CSS height transition (grid-template-rows: 0fr → 1fr) — no JavaScript measures the content's height, so there's nothing to keep in sync when the content itself changes shape (e.g. on a language switch elsewhere in the app).

A second, independent panel

Opening this one has no effect on the panel above — each instance owns its own state.

Live demo · the new sciui Disclosure (libs/sciui)
Comments & Suggestions
  • This is a brand-new component built for the playground itself — no prior production usage to compare against. Its chevron/height-transition behavior should be checked in real Firefox as well as Chrome during the verification pass, per the workspace rule that Chrome's corner-shape fallback path is not a reliable proxy for Firefox's clip-path polyfill.
  • As of 2026-07-16 the Disclosure has a SINGLE radius mode — it always uses the plain, undoubled template radius. Radius doubling is reserved for cards without a visible container (FCard, sciui Card); a collapsible block can't animate coherently in the doubled composition, so it no longer has a nested prop.
SCIUI · INPUT

API

The whole contract, and nothing else. Usage is on the subpage that follows.

Input

  value           string?                         controlled
  defaultValue    string?                         uncontrolled start
  onValueChange   (value: string) => void         every keystroke, as the VALUE
  onSubmit        (value: string) => void         Enter, trimmed. Shift+Enter is
                                                  the newline, so it never submits
  placeholder     string?

  multiline       boolean = true                  false = one line, never wraps
  rows            number = 1                      lines tall at rest — the floor
  maxRows         number = 6                      lines it grows to before the
                                                  text scrolls inside it.
                                                  Infinity = never stops growing

  before          ReactNode?                      inside the surface, beside the
  after           ReactNode?                      text — a glyph, a unit, a send
                                                  button. Centred, one gap away

  disabled        boolean?                        not editable, reads unavailable
  readOnly        boolean?                        not editable, reads ordinary
  autoFocus       boolean?

  rim             number | string = 1             the rim's width AT REST; focus
                                                  thickens it to the focus width
  shadow          'none' | 'inset' | 'outset' = 'none'
  size            'xs'|'sm'|'md'|'lg'|'xl' = 'md'

  name · id · maxLength · spellCheck · autoComplete · inputMode
  onKeyDown · onFocus · onBlur                    onKeyDown runs FIRST — call
                                                  preventDefault() to own Enter
  className · style · aria-label · aria-labelledby · aria-describedby
  aria-invalid

  ref             the editable element itself (<textarea> or <input>)


CSS

  --in-q            length    the quantum                     = --sciui-q
  --in-thick        length    the box's minimum cross size    = 9 * --in-q
  --in-r            length    corner radius                   = --in-thick / 2
  --in-pad          length    the text's inset, all four sides= 1.75 * --in-q
  --in-gap          length    text ↔ before/after             = 1.5 * --in-q
  --in-font         length                                    = 2.5 * --in-q
  --in-line         number    line height                     = 1.4
  --in-min-h        length    from rows      (state it to override the line math)
  --in-max-h        length    from maxRows   (   "   )
  --in-fill         color     the surface                     = --sciui-wash
  --in-rim-w        length    composed; focus swaps it        = --in-rim-rest
  --in-rim-color    color                                     = ink 8%
  --in-ink          color                                     = --ink
  --in-accent       color                                     = --ac


LAWS

  the box is ONE <Sq> — shape, fill and rim are the primitive's, not this
    component's
  FOCUS IS THE RIM, THICKER — never a second ring. A ring outside the rim is
    two edges on a control that has one, and only the inner one is a true
    Lamé curve
  it GROWS, it never shows a scrollbar (owner): the height is written from the
    content's own scrollHeight between the two line counts, and past the
    ceiling the text scrolls with no bar drawn, in every engine
  the geometry is the library's, not this control's — 9q tall, R = 4.5q,
    1.75q text inset: an Input beside a Selector or a Dialog composer is the
    same object
  a shadow, if asked for, is the APPLICATION's — the ink comes from
    --sq-inset-color / --sq-cast-color (f-ish theme.css) and the recipe from
    the primitive, so no component owns a depth of its own


NOT COVERED YET

  a leading/trailing label INSIDE the rim that is not a slot (a currency prefix
    that the caret must not enter) — pass it as "before" and it is a slot
  validation state beyond aria-invalid: no rim tone of its own yet
sciui Input · libs/sciui/src/input.tsx
SCIUI · DIALOG

Conversation interface

A bottom-anchored chat surface: a scrolling history, a status plate of live metrics, and a composer whose input and send button are visually identical to a Selector track and pill.

History
Status plate
Plate position
History background
Accent
Container
Show me revenue by region for last quarter.
Here is Q3 revenue split across the four regions — EMEA leads at 38%, followed by AMER at 34%.
Which region grew fastest year over year?
Context
2 133/ 20 000
Requests / min
3/ 5
resets in 15 s
Session
12
min elapsed
Tokens
84 213
Connected
us-east · 24 ms

Content rendering is external: every child is opaque to the Dialog. DialogMessage is the default text bubble. The status plate takes a segments array — each entry is either a template dict (title, value, optional max → bar, optional message → caption) or any custom node. The plate above shows every combination side by side, ending with a fully custom segment. Turn History off to see the landing state — just the composer, centred — then send a message and watch it glide to the bottom.

Live demo · sciui Dialog (libs/sciui) — composer geometry mirrors Selector exactly
SCIUI · TOOLTIP

Sides

Wrap any single element — <Tooltip content="…">{child}</Tooltip> — and it shows on hover or keyboard focus, after a short delay so it never flashes on a quick mouse pass-through. side is a static choice, not an auto-flipping engine. The panel is measured and portaledonce per showing — never re-synced to scroll — so it can escape a clipping container without ever "moving" under JavaScript; see the Anchoring subpage for what that buys and what it costs.

Side
Disabled
Accent
Container

The four sides share one geometry rule: the merged corner always sits dead-centre on the trigger's own edge, exactly touching it — never a gap, never an overlap. That's measured, not guessed: the arrow's own rendered size (a square rotated 45deg) gives its exact centre-to-tip distance, and the panel is placed precisely that far from the trigger, once, when it opens. Only opacity transitions on entry/exit — never transform/filter, the one motion law every sciui/f-ish surface holds (the arrow's rotate(45deg) is static, set once, never animated).

Live demo · the new sciui Tooltip (libs/sciui)
Comments & Suggestions
  • Brand-new component, no prior production usage to compare against — its Firefox/Safari squircle-clip fallback (the panel AND the small rotated arrow both go through useSquircleClip) should get the same real-Firefox check every other sciui corner does; Chrome's native corner-shape path is not a reliable proxy for it.
  • The trigger is a plain wrapping <span> (display: inline-block), not a cloned child — simplest, works with anything, but it does add one extra box to the DOM, which is occasionally visible in a strict flex/grid layout that cares about direct children.
  • No viewport-collision auto-flip — a deliberate scope cut for a component whose brief is "minimalist", not a Popper/Floating-UI-class engine: side is a static author choice, and a badly-chosen one near a viewport edge stays exactly wherever the author put it. There IS a portal (to the nearest .fish ancestor, not document.body) specifically so a tooltip inside a clipping container (a Card, a scrolled panel) is never cut off — see the Anchoring subpage.
F-ISH · TYPOGRAPHY

Headings & kicker

The three standalone heading elements, then FHeading — the canonical icon/title/kicker block used to build a page's rich rail menu item.

A standalone kicker

A standalone title

A standalone subhead

FHeading combines an optional icon, a title and a kicker into one canonical block:

Combined headingTITLE + KICKER

A Space never places this itself any more — export subtitle from a page's first subpage and the site loader (create-site.ts) builds this exact block for the rail's menu item automatically: title = the page's resolved kicker label, kicker = the subpage's subtitle, icon = the subpage's icon export. No placement flag — subtitle's mere presence is the trigger (the "subtitle-set" menu mode). This very Page does exactly that (see this subpage's own subtitle export) — check the rail: "Typography" now reads as a two-line block, not the plain pill every other Page here uses.

Live demo · f-ish FKicker, FTitle, FSubhead, FHeading (libs/f-ish)
Comments & Suggestions
  • The old heading/menu={false} export + placement-flag mechanism this subpage used to demo is GONE (2026-07-15 header/menu rework) — replaced by the inferred kicker/subtitle/icon resolution described above, and this Page's own menu item now demonstrates the subtitle-set case live (see Squircle for the icon slot, Cards & Stats for a fully custom menu export — between the three, every menu-item mode this template supports has a real, end-to-end example somewhere on this Space).
  • Deliberately NOT moved onto the shared <Configurator> (Phase 5 step 6): these are structural markup elements (kicker/title/subhead/heading), not components with tunable props — there's no real "knob" to expose beyond the text content itself, and this Page's own menu item is a live subtitle-set example that shouldn't be disturbed by wrapping its subpage content in unrelated demo chrome. Flagging this call explicitly rather than forcing it.
F-ISH · ACTIONS

Buttons

FButton — a soft pill: accent fill, crust-dark label. Like every kit element it takes an optional size step (xs·sm·md·lg·xl) via data-fsize.

Size
Accent
Container

Every step is the same component and the same markup — only the size prop changes, which rescales the button (and its label) proportionally via the shared --gt/--g scaling unit.

Live demo · f-ish FButton (libs/f-ish)
Comments & Suggestions
  • FButton has no dedicated variant prop in content.tsx — only the generic size (from the shared Sized type) plus whatever className/native button props are passed through. So the "couple of use cases" the spec asks for are shown here purely as size steps and differing labels/context, not as invented variant props.
F-ISH · CARDS & STATS

Cards

FCard is a surface-toned, soft-cornered block — plain, or in a media chip layout, or broken out to full panel width with wide. The two are independent boolean modifiers, so toggle them together to see whether the media chip layout and the full-width breakout actually compose.

Media
Wide
Accent
Container
Plain card

A default FCard with an FCardBody inside — the baseline shape, no modifiers.

Live demo · f-ish FCard/FCardBody, plain / media / wide (libs/f-ish)
Comments & Suggestions
  • content.tsx documents media and wide as independent boolean props applied as separate class modifiers (fish-card--media and fish-wide), with nothing in the source suggesting they're mutually exclusive — so combining both should just stack the classes. Now on the shared <Configurator> with both as independent toggles, so "media + wide together" is directly reachable (it wasn't in the old three-fixed-card layout) — worth a live check that the full-width breakout and the fixed-width media chip actually compose cleanly rather than assuming it from the source alone.
  • No real media asset exists in this Space yet, so the "media" slot above is a plain colour block labelled as a placeholder rather than a card thumbnail — flagging in case a future pass wants to swap in a real icon/image demo.
  • This subpage's menu export (a hand-rolled metric badge) is this Space's only live demo of fully custom menu content — see this file's own comment above it. "3" counts the card variants the two boolean toggles can reach (plain/media/wide, not counting the media+wide combination as a distinct "style") — a hand-typed number that would silently go stale if a variant is ever added or removed here.
SCIUI · CARD & CARDGRID

CardGrid

A full-width, fluid, self-balancing grid of <Card>s. Children with no expand stay a zero-cost static grid; any child carrying expand turns the whole grid into a hover/tap/focus-activated reveal — the exact mechanism cv's Home focus tiles and Portfolio hub run on.

Plain grid

No expand on any card — no state, no listeners, no measurement.

Expand grid — the accent channel

Each card's own accent sets --ac on its root — everything inside inherits it. The expand block below lives OUTSIDE the card in the DOM, so the grid forwards the same card's accent onto its own reveal set. The THIRD card sets no accent at all — open it and its chips take the page's own ambient accent instead, proving the fallback holds.

Column cap — maxCols

Long, multi-paragraph card bodies read too narrow once a wide screen fits 4 columns — the fix is a column CAP the author names directly, not a min-width to guess at. maxCols={2} below never lets more than two columns form, at any viewport width, so these long bodies keep a readable measure instead of squeezing into a fourth of the row.

Data platform strategy

A durable data platform earns trust in stages: first by being correct, then by being fast, and only then by being convenient. Skipping ahead to convenience — nice dashboards over ungoverned, unvalidated pipelines — is the most common way teams end up re-litigating "which number is right" in every planning meeting.

The unglamorous work is schema contracts, tested transformations, and a single source of truth for each metric's definition, published somewhere every stakeholder can actually find it before they build their own spreadsheet version.

Experimentation culture

Running more A/B tests is not the same as learning faster. A team that ships ten underpowered tests a quarter and calls every marginal p-value a win is optimizing for the appearance of rigor, not for decisions that would have gone differently without the data.

The higher-leverage habit is pre-registering what would change your mind, sizing the test to actually detect that effect, and being willing to report a clean null result as a genuine, useful outcome rather than a failure to be quietly shelved.

Onboarding new analysts

The fastest ramp isn't a wiki page, it's a first real ticket paired with someone who already knows where the landmines are — which tables are deprecated but still queried by legacy dashboards, which metric names mean two different things in two different teams, which joins silently fan out rows.

Write that landmine list down as you go, even informally; the second new hire should never have to rediscover the same trap the first one just fell into.

maxCols is pure CSS — it sets --card-grid-maxcols inline, no JS layout logic, so the mobile single-column mode below the container breakpoint is completely untouched; the cap only ever governs the WIDE-mode column count.

Corner decorators — the Icon system

A <CardDecorator corner="tl|tr|bl|br" icon={…} background={…} /> is a slot CHILD — declared inside a card's own children but never itself rendered; the card reads it off the tree and positions it, radius concentric with its own corner in every case, symmetric at all four. The icon is sized as a FRACTION of the decorator's own box (60%, bold on purpose — never a fixed em/px), and each one below is sciui's own <Icon id="ph:caret-right-fill" /> — a Phosphor glyph resolved against the generated registry (see NOTES.md § Icon system); it paints currentColor, so it inherits the decorator's own accent automatically. An href card shows the built-in "go" chevron bottom-right automatically (see the Pipelines card above) — an explicit decorator prop or a corner="br" child, like below, overrides it; none of these four cards has an href, so nothing here is a default, every decorator below was asked for.

Top-left

Inset one gap from the card's own top-left corner.

Top-right

Top-right.

Bottom-left

Bottom-left.

Bottom-right

Bottom-right — where the built-in nav default and the legacy decorator prop both land.

Bare decorators & hover FX

bare renders NO squircle plate at all — icon only, same position, same size box, just no fill at rest or on hover (cv's Home tiles use this for their six meaning-icons). hoverFx picks the hover motion, author-selectable, default 'grow': 'grow' transitions the ICON's own width/height inside its fixed, centred box; 'shift-right' is a ± margin pair sliding the icon rightward (the box itself never resizes); 'none' is static. Both motion variants are compositor-legal — never transform/filter — gated to real pointer hover with prefers-reduced-motion: no-preference. Hover each card below to feel the difference (mouse only — try each on a real pointer device).

Body lists

A plain <ul>/<li> in a card body needs no classes — compact spacing, a marker in the same muted tone as the body text.

What this covers
  • Compact spacing on the card's own flow rhythm — no browser-default oversized gaps.
  • A marker colour that matches the muted body tone, not full-strength ink.
  • The same inline text-inset a body <p> gets, so it aligns identically.

Disabled

disabled renders a plain, non-interactive element regardless of href/onClick — no hover lift, no cursor affordance, and no automatic nav chevron either. cv's Portfolio uses this for demos that exist but aren't public, wearing a static <Icon id="ph:lock-fill" /> decorator instead (see "No motion" above).

Not available

This card fronts nothing reachable — it never lifts, never gets a pointer cursor.

Live demo · sciui Card/CardGrid — accent, corner decorators, bare mode, hover FX, the Icon system, body lists, disabled (libs/sciui)
Comments & Suggestions
  • The four corner-decorator squircle clips (Firefox fallback path) should be checked in real headless Firefox per the workspace's standing rule — Chrome's native corner-shape path can't stand in as a proxy for the clip-path polyfill.
  • The "no accent set" card's expand chips should visibly render TEAL (this page's own accent) — worth confirming live, since it's the one behaviour that's easy to get backwards (accidentally always forwarding SOME colour rather than truly setting nothing).
  • Not demoed here: a card that is BOTH href and disabled (cv's Portfolio never combines them) should show no chevron at all — Card's default-nav check is gated on !disabled, but worth a live confirmation since it's an easy corner to miss.
  • Not wrapped in the shared <Configurator> — this page is several distinct fixed illustrations (plain / expand / decorators / disabled), not one configurable instance, the same call the Chips & Stats page made for its own multi-accent chip row.
F-ISH · SEGMENTED

FSegmented

A self-contained segmented switcher — pass options, a controlled value, and onValueChange; the sliding pill and its settle-recoil come for free.

Accent
Container
Roastmedium

Under the hood FSegmented mounts its own Tabs.Root and hands the bare SegmentedList the activeValue it needs for the recoil — see the "Bare list & shadows" subpage for the same visual composed manually.

Live demo · f-ish FSegmented (libs/f-ish)
Comments & Suggestions
  • No issues found for this mode — options/value/onValueChange is a small, ordinary controlled-component surface, and the pill's settle-recoil direction is derived internally from the value change, so there's nothing for a consumer to get wrong here.
SCIUI · SQ

API

The whole contract of <Sq>, <Sq.Dock> and <Sq.Slide> — and, below it, every mode each prop names, shown rather than described. Usage prose is on the subpages that follow; all the knobs at once on one surface are the constructor at the end.

Sq — the surface primitive

  as              ElementType = 'div'             the element rendered. A crop
                                                  wrapper follows a phrasing
                                                  host (span/button/a/label/
                                                  summary/legend) with a span

  radius          step | number | CSS length      the scale is none xs sm md lg
                                                  xl = 0, .25, .5, 1, 1.5, 2 of
                                                  the AMBIENT --sq-radius, which
                                                  a parent surface republishes
                                                  to its children. md IS that
                                                  radius. Omit to inherit it
  squircle        number = 1.5                    the exponent; the Lame exponent
                                                  is 2 ** squircle. Each distinct
                                                  value costs ONE extra rule set
                                                  for the whole document
  pad             number | CSS = R / 2            the inner inset — and the
                                                  concentric law: pad + a child's
                                                  radius = this radius, at any
                                                  depth

  shadow          'none'|'inset'|'outset' = 'none'
                                                  ONE shadow. The two are
                                                  mutually exclusive, and that is
                                                  the whole shadow model
  cast            'near' | 'far' = 'near'         how far whichever was chosen
                                                  reaches: near rests on the
                                                  page, far floats above it
  rim             number | CSS = 0                the hairline's WIDTH, lying ON
                                                  the curve, inside it. The width
                                                  IS the switch — there is no
                                                  separate boolean. Interrupted
                                                  along a welded side, where a
                                                  line across the joint would
                                                  betray the seam

  bg              CSS colour?                     the fill. Unset paints none
  fg              CSS colour?                     content colour, set on the host
  rimColor        CSS colour?                     unset paints no rim even with a
                                                  width
  shadowColor     CSS colour?                     THE SHADOW'S INK, never its
                                                  finished tone — each recipe
                                                  takes its own fraction of it.
                                                  Unset takes the ambient
                                                  --sq-inset-color / --sq-cast-
                                                  color, which is how a whole
                                                  application gets ONE inner
                                                  shadow and ONE cast with no
                                                  component naming a colour.
                                                  NOTHING is ever derived from
                                                  currentColor: with no ink,
                                                  nothing is painted

  weld            side | side[]?                  which of THIS surface's sides
                                                  are welded to a neighbour. A
                                                  welded side COLLAPSES BOTH ITS
                                                  CORNERS TO SQUARE: the radius
                                                  is multiplied by the corner's
                                                  own factor (1 - d_h)(1 - d_v),
                                                  so one welded side squares the
                                                  two corners it owns and a
                                                  second welded side squares its
                                                  own. THERE ARE NO EARS HERE —
                                                  a concave ear is the geometry
                                                  of a GUEST on a host (Sq.Dock),
                                                  and nothing else. The rim and
                                                  the inset band fade to a band
                                                  of zero width along a welded
                                                  side, by the same (1 - d)
                                                  factor, and a welded surface
                                                  draws NO CAST at all — a row's
                                                  cast belongs to whatever
                                                  contains the row.
                                                  The opposite direction of
                                                  Sq.Dock, which is a guest
                                                  welded onto THIS surface —
                                                  which is why the two no longer
                                                  share the word "dock"
  crop            boolean = false                 wrap the children in ONE
                                                  service box and cut them to the
                                                  exact silhouette, a docked
                                                  guest's bump included
  motion          number(ms) | CSS = 0            the joint's clock: how long a
                                                  welded guest takes to follow
                                                  when its numbers change. 0
                                                  jumps. A guest may override it

  type            'button'|'submit'|'reset'?      for as="button"
  className · style · ref · children · …rest      forwarded to the host element


CROP — the contract, in full

  The one observable difference from a plain div: the children become
  GRANDCHILDREN of the host. So no structural selector may cross the host
  boundary — .my-host > .child, :scope > *, firstElementChild — and the children
  have to be styled by their own classes instead. Everything else behaves exactly
  as a div does: an absolutely positioned child still resolves against the HOST
  (the wrapper is unpositioned), percentages, sticky and scrolling are unchanged.

  Off by default not out of caution: a surface paints BEHIND its content, so a
  child reaching past the curve is the ordinary state — focus rings, overlays,
  corner decorators, and every docked guest, which hangs outside by construction.
  What must be cut is what SCROLLS. The cut cannot be taken on the host, which is
  why asking for it adds an element rather than a declaration: the host is also
  where the paint hangs, and no CSS tells paint from content inside one subtree.
  Measured, on the polygon path: a clip on the host cost an outset surface its
  whole cast and a docked one its guest.


Sq.Dock — one guest welded ONTO a surface

  side            'top'|'right'|'bottom'|'left'   required. Every guest on one
                                                  surface names the SAME side
  at              number | CSS = 0                offset ALONG that side from its
                                                  NEAR end — left for a
                                                  horizontal side, top for a
                                                  vertical one, so a guest does
                                                  not reverse when it moves from
                                                  the top edge to the bottom.
                                                  Ignored when follow is set
  size            number | CSS | 'auto'           extent along the side. Never
                                                  less than 2R — that is the
                                                  narrowest bump the corner rule
                                                  can express: two ears meeting,
                                                  no crown between them. 'auto'
                                                  takes that one axis back off
                                                  the generated rule, so the
                                                  guest is an ordinary
                                                  shrink-to-fit box and the
                                                  measured number travels ONE WAY
                                                  to the patch. The bump SNAPS to
                                                  each new content width rather
                                                  than gliding to it: a guest
                                                  that grew its own label is
                                                  layout, and nobody asked for
                                                  layout to be animated
  depth           number | CSS                    how far the guest stands OUT of
                                                  the host. 0 means the guest is
                                                  ABSENT — the ear radius is
                                                  min(R, depth / 2) and collapses
                                                  with it — which is how a guest
                                                  animates in and out rather than
                                                  appearing at full height
  follow          element | ref | null            take at and size from that
                                                  element's box instead of
                                                  stating two numbers. The
                                                  measurement joins the ONE batch
                                                  this layer already runs: no
                                                  observer of its own, no rect
                                                  read per placement
  motion          number(ms) | CSS                this guest's own clock,
                                                  overriding the host's
  as · className · style · ref · children · …rest


Sq.Slide — a surface that slides INSIDE another (every Sq prop, and)

  axis            'x' | 'y' = 'x'                 which way it travels; the cross
                                                  axis is stretched to the host's
                                                  padding box
  follow          el | ref | [from, to] | null    the box — or the two ends of a
                                                  RUN of boxes — it takes its
                                                  offset and extent from. A RANGE
                                                  spans the union of the two, so
                                                  adjacent chosen options read as
                                                  ONE pill: not several surfaces
                                                  with their shared corners
                                                  squared off, which can never
                                                  meet exactly, but one surface
                                                  with no shared edge to miss.
                                                  null renders nothing, which is
                                                  how a selection of nothing is
                                                  drawn
  gap             number | CSS = 0                the inset it keeps from the
                                                  host on the CROSS axis, both
                                                  sides. The host's own padding
                                                  cannot stand in for it:
                                                  absolute offsets resolve
                                                  against the padding box, which
                                                  is inside the border and
                                                  OUTSIDE the padding


CSS — every knob is also a custom property, and five have no prop at all

  --sq-radius          length    what a parent publishes to its children
  --sq-r               length    this surface's radius          = --sq-radius
  --sq-pad-in          length    the inner inset                = --sq-r / 2
  --sq-exp             number    the exponent                   = 1.5
  --sq-bg              colour    the fill
  --sq-rim-w           length    rim width
  --sq-rim-color       colour
  --sq-inset-color     colour    the INK of an inset shadow
  --sq-cast-color      colour    the INK of an outset cast
  --sq-motion          duration  the joint's clock              = 0s
  --sq-motion-ease     easing                    = cubic-bezier(.2, .7, .1, 1)
  --sq-slide-gap       length    Sq.Slide's cross-axis inset    = 0px

  --sq-inset-strength  percent   the inner band's tone          = 5%
  --sq-inset-depth     number    its blur, as a multiplier      = 1
  --sq-shadow-strength number    the cast's tone, as a multiplier = 1
  --sq-dpr / --sq-u              the device grid — published BY the layer

  those last three are THEME level and deliberately do not become props: they are
  what an application tunes once, not what a call site states

  .sq-row / .sq-col              the gap law as a utility, clamping the gap to
                                 --sq-r1 + --sq-r2 with nothing imported
  [data-sq-fallback]             force the polygon path in an engine that has
                                 the native one — so the fallback is compared
                                 without a second browser


LAWS

  ONE SHADOW per surface; inset and outset are mutually exclusive
  NO COLOUR IS EVER DERIVED — an unset colour paints nothing rather than
    inventing a tone out of currentColor
  a guest is a CONTENT BOX, never a second surface: the host paints the entire
    union silhouette, so an <Sq> inside a dock draws a second outline inside the
    first and doubles the line at every ear
  a dock is a DIRECT CHILD of its <Sq> — the host reads these props to build the
    contour, and the contour names every guest before a stylesheet for it can be
    chosen. A wrapper around the dock breaks the relation
  every guest on one surface names the SAME side
  size >= 2R, and 2R of clearance from a host corner AND from the next guest.
    There is no clamp in CSS and that is a decision, not an omission: the clip is
    written against a box two blur margins wider than the host, so a clamp would
    have to hang off each of ~90 occurrences of at. The CALLER holds the law;
    follow holds it by construction, because it takes real layout numbers
  the host needs a straight run of 2 * (R + 3 sigma) on the perpendicular axis —
    shorter than that there is nothing for the patch to hand back to
  bg MAY BE TRANSLUCENT WHERE A GUEST IS DOCKED: the host's fill stops at the cut
    and the patch draws on from there, so the two abut instead of overlapping and
    the seam never shows. WELDED NEIGHBOURS ARE THE OPPOSITE CASE — they must
    share one OPAQUE fill. There is no skirt to hide the seam any more: it is a
    plain butt between two boxes layout puts flush, and two antialiased edges on
    one line multiply their coverage instead of sharing it. What keeps it clean
    is the grid, not an overhang — both sides derive the boundary from one real
    number through the same rounding
  two surfaces either WELD or stand R1 + R2 apart — between those states the
    facing curves pinch into an unreadable channel
  the native branch is taken where corner-shape exists, the surface carries no
    guest, and — since a squared corner IS natively expressible — a WELDED one
    goes native too. The two exceptions are weld + rim and weld + shadow, which
    stay on the polygon because box-shadow is not per-side and would draw the
    ring or the band straight across the seam. The branch is @supports, never
    JS, so prerendered markup is engine-independent and hydration cannot
    desynchronize
  nothing measures anything per frame: the contour is one clip-path whose
    coordinates are calc() over the surface's own properties, so a resize, a
    fluid radius, a width transition and a dock switch all re-resolve inside the
    CSS engine


NOT COVERED YET

  Safari — never checked, in any mode
  a NESTED <Sq> does not inherit the concentric radius: --sq-radius is produced
    from --sq-r and consumed as --sq-r, so on a child that redeclares both the
    chain is a cycle and computes to nothing. A nested surface states its radius
  a welded surface's cast is still a drop-shadow on the skin, with a known defect
    over a translucent page background
  RTL — at is physical (from the left, or the top), not logical

radius — the six-step scale, and any length beside it. Every step is a multiplier of the ambient --sq-radius, so one knob rescales a whole screen. There is no pill step: a capsule is not a mode, it is what the geometry produces when a surface's smaller side reaches twice its radius.

none
xs
sm
md
lg
xl
radius={22}, height 44 — the capsule falls out on its own
radius="clamp(6px, 4vw, 28px)" — any CSS length, fluid included

squircle — the exponent. The Lamé exponent is 2 ** squircle: 1 is a plain circular arc, 1.5 the default superellipse, higher is boxier. Each distinct value on a page costs one extra rule set for the whole document, which is the reason it is a prop and not a per-instance number.

squircle={1}
squircle={1.5}
squircle={2}
squircle={3}

pad, and the concentric law. The default is half the radius, and that is not a taste: a child surface inherits parent R − pad as its own ambient radius, so pad + child R = parent R and the two curves stay concentric at any depth. State a pad and the children re-derive from it.

pad default (R / 2) — nested three deep, still concentric
pad={0} — the child's radius is the parent's
pad={24} — a deeper inset, a flatter child

shadow and cast — one field, four pictures. Inset and outset are mutually exclusive, because a surface has one shadow; cast scales whichever was chosen. The inset is a band of width σ centred ON the curve and cut by the fill; the outset is the silhouette blurred and cut by an evenodd ring OUTSIDE it, which is why a translucent fill is not tinted by its own shadow.

shadow="none" (default)
shadow="inset" · cast="near"
shadow="inset" · cast="far"
shadow="outset" · cast="near"
shadow="outset" · cast="far"
shadowColor unset — the ambient --sq-cast-color, whatever the app set

rim — a width, not a boolean. Zero is off; there is no second prop to forget. The hairline lies ON the curve rather than outside it, so it is a true Lamé ring and not a rounded rectangle approximating one — and rimColor is separate, because a width with no ink paints nothing.

rim={0}
rim={1}
rim={2}
rim={4}
rim={2} · rimColor unset — nothing is painted
rim={2} · rimColor accent

The four colours, and the one rule they share. bg, fg, rimColor and shadowColor derive nothing: unset means the effect is not painted at all, never that a tone is invented out of currentColor. shadowColor is the shadow's INK and not its finished tone — each recipe takes its own fraction of it, so the same ink reads as a 5% inner band and a 30% cast.

bg unset — geometry only, no service node renders at all
bg translucent — a wash over whatever is behind
fg lands here
bg + fg — the content colour is set on the host
one ink, two recipes — the same shadowColor inset and outset

crop — asked for, and rarely. A surface paints behind its content, so a child over the edge is the ordinary state; what must be cut is what scrolls. The cut is taken on a content box of the surface's own (.sq-crop), never on the host — the host is where the paint hangs, and a clip there took the cast, the joint's bump and every docked guest with it. The whole price is that the children become grandchildren, so a structural selector may not cross the host boundary: style them by their own classes.

no crop — the child runs past the curve
crop — the child stops at it
crop, and the cast is untouched by it
crop, and the guest still hangs outside

weld — which of THIS surface's sides are welded to a neighbour. A welded side COLLAPSES BOTH OF ITS CORNERS TO SQUARE, and that is the whole corner rule: each corner carries a factor (1 − d_h)(1 − d_v) that multiplies its radius, so a side going welded takes the two corners it owns to zero and leaves the other two alone. There are no ears here — a concave ear is the geometry of a guest standing on a host, which is Sq.Dock below, and it never belonged to the corner rule at all. Along a welded side the rim and the inset band fade to a band of zero width by the same factor, and a welded surface draws no cast whatsoever: a row of welded panels casts as a row, which is its container's business, not each panel's.

weld="top" — that side's two corners go square
weld="right" — that side's two corners go square
weld="bottom" — that side's two corners go square
weld="left" — that side's two corners go square
weld={["top", "left"]} — three corners square: each side takes its own two
all four — a plain rectangle, which is what a fully surrounded panel is

And the seam is a butt joint now, not an overlap. A welded surface used to carry its fill past the welded edge on a skirt, so the join lay inside one polygon and never had to be rasterized as a boundary. That is gone, along with the layer inflation that held it — nothing this primitive draws leaves the host's border box any more. The price is that welded neighbours must share one opaque fill and land on the device grid: two antialiased edges on the same line multiply their coverage rather than sharing it, so a boundary off the grid shows the page through it. Both sides derive that boundary from one real number through the same rounding, which is what makes the pair exact.

two panels welded into a column — one opaque fill, no seam to see
the same pair, welded left/right
weld + rim — the one combination held on the polygon, so the ring can stop at the seam

The two directions of one relation — and the ears are the whole difference. On the left the small surface declares weld="bottom": it is its own <Sq>, with its own fill and its own contour, its bottom two corners squared, and the two shapes meet along a flush edge. No ears. On the right the panel declares <Sq.Dock side="top">: there is ONE contour, the guest is a bare content box, and where the bump rises out of the panel the host grows a concave EAR on each side of it. Ears, both of them.

That is the rule to carry away: an ear exists only where a guest stands on a host, because an ear is what a silhouette does when it has to swallow something. Two panels laid against each other are not swallowing anything — they are a flat join, and a flat join wants square corners. Use weld when the neighbour is a surface in its own right; use Sq.Dock for a bump on somebody else's panel — an <Sq> inside a dock draws a second outline inside the first and doubles the line at every ear.

weld="bottom" — two surfaces, two contours, SQUARE corners at the join
Sq.Dock — one contour, the guest paints nothing, and an EAR on each flank

Sq.Dockside, and the two axes that are not width and height. A guest on the left side has no width along the side, so the props are size (along) and depth (out), side-agnostic like the rest of the layer. at is measured from the NEAR end of the side — left for a horizontal one, top for a vertical one — so a guest does not reverse when it moves from the top edge to the bottom.

side="top" · at={30} · size={70} · depth={24}
side="right" · at={30} · size={70} · depth={24}
side="bottom" · at={30} · size={70} · depth={24}
side="left" · at={30} · size={70} · depth={24}
size={28} = 2R — the narrowest legal bump: two ears, no crown
two guests on one side, each 2R from the other and from the corner
a guest
the guest carries content — it is a box, and the host paints around it
measured
size="auto" — the bump is as wide as what the guest holds
depth animates the guest in and out — min(R, depth/2) collapses with it
follow — the guest takes at and size from a real box, at 260ms

Sq.Slide — a surface that slides INSIDE another. The opposite of a dock in every way that matters: it does not stand out of its host, so the host's contour never learns about it, so there is no patch, no notch and no generated stylesheet — and where corner-shape is native it costs no polygon at all. A range spans the union of two boxes, which is how a run of adjacent chosen options reads as ONE pill rather than several surfaces whose shared edges can never meet exactly.

One
Two
Three
Four
follow one box, follow a run of two, follow nothing
sciui sq · libs/sciui/src/surface/surface.tsx · geometry: surface/shape.ts
PLAYGROUND · PALETTE LAB

Composition

Pick the page's own accent — including "None," the template's real accent-less mode — then a container and an inner element (each independently accented) to check any nesting/coloring combination the template can produce. "Nested container" as the element reveals a second, inner container + element pair — exactly two levels, no further nesting.

Page accent
Container
Container accent
Element
Element accent

Every container is a real, reusable primitive (FCard, sciui Disclosure, sciui Tabs) — none of this composition logic is a new component; it only decides which existing one to render and how to accent it. The page accent above is the real thing (useFPageAccent()), so "None (plain page)" as the container reads exactly as it would on any real page of that accent.

Live demo · a Space-local composition harness (playground/_container-lab.tsx)
Comments & Suggestions
  • Nesting is fixed at exactly two levels (outer + one inner container/element pair, per the owner's brief) — all controls for both layers live together in the controls panel above the preview, never inside the rendered container itself.
  • The page-accent picker is a genuine template extension, not a Space-local trick: `useFPageAccent()` is exported from `f-ish` itself (`stage.tsx`'s new `FPageAccentContext`, backed by a per-page override registry on the ROOT context, `shell.tsx`), one provider per page, so only this page's accent changes. Choosing an accent (including "None") recolors this page's background AND the rail's own docking indicator — they share the same registry the indicator already read from a page's static configured accent.
  • sciui controls don't pick up the page's --ac accent on their own (they read their own --sci-accent) — this lab's CONTAINER/ELEMENT accent pickers set both together per subtree (`accentVars()`), but the page-accent change above only sets the real `--ac` (matching what a real Page does) and does not also drive `--sci-accent` — so any bare sciui control on this page outside `ContainerLab` (e.g. these picker rows themselves) won't visibly track the page accent. Consistent with how every other Space's sciui usage already works, but worth flagging since this page makes accent-tracking gaps very visible by design.
F-ISH · FLOW

Container-owned rhythm

Kit prose owns no block margins; the CONTAINER spaces its children by one step (--flow), with a few tight exceptions (kicker→title, title→lead, text→note). The same mixed document below reads with identical rhythm in every container — the reading column, a card, a disclosure, a tabs panel — because the container, not the element, owns it.

Default everywhere (Phase 7c) — no .fish-flow wrapper needed; every content container is a rhythm owner.

In the page reading column

DESIGN SYSTEM · RHYTHM

Container-owned spacing

A short standfirst, sized as a lead paragraph — the intro line under the title.

First body paragraph. Prose primitives (FText) and sciui components interleave below so the vertical rhythm between unlike neighbours is directly visible.

A second paragraph, so paragraph-to-paragraph spacing is on show too.

A note annotating the paragraph directly above it — a tight pair.
A Selector between prose

Body copy sitting between two sciui controls — each gets the same gap a paragraph would.

Inside the four containers

The identical document, dropped into each surface — the rhythm does not change with the container.

FCard · .fish-card-body
DESIGN SYSTEM · RHYTHM

Container-owned spacing

A short standfirst, sized as a lead paragraph — the intro line under the title.

First body paragraph. Prose primitives (FText) and sciui components interleave below so the vertical rhythm between unlike neighbours is directly visible.

A second paragraph, so paragraph-to-paragraph spacing is on show too.

A note annotating the paragraph directly above it — a tight pair.
A Selector between prose

Body copy sitting between two sciui controls — each gets the same gap a paragraph would.

Disclosure · .sciui-disclosure-panel-inner
Open the mixed document
DESIGN SYSTEM · RHYTHM

Container-owned spacing

A short standfirst, sized as a lead paragraph — the intro line under the title.

First body paragraph. Prose primitives (FText) and sciui components interleave below so the vertical rhythm between unlike neighbours is directly visible.

A second paragraph, so paragraph-to-paragraph spacing is on show too.

A note annotating the paragraph directly above it — a tight pair.
A Selector between prose

Body copy sitting between two sciui controls — each gets the same gap a paragraph would.

Tabs · .sciui-tabs-content
DESIGN SYSTEM · RHYTHM

Container-owned spacing

A short standfirst, sized as a lead paragraph — the intro line under the title.

First body paragraph. Prose primitives (FText) and sciui components interleave below so the vertical rhythm between unlike neighbours is directly visible.

A second paragraph, so paragraph-to-paragraph spacing is on show too.

A note annotating the paragraph directly above it — a tight pair.
A Selector between prose

Body copy sitting between two sciui controls — each gets the same gap a paragraph would.

Nested FCard · rhythm at depth

An outer card paragraph.

DESIGN SYSTEM · RHYTHM

Container-owned spacing

A short standfirst, sized as a lead paragraph — the intro line under the title.

First body paragraph. Prose primitives (FText) and sciui components interleave below so the vertical rhythm between unlike neighbours is directly visible.

A second paragraph, so paragraph-to-paragraph spacing is on show too.

A note annotating the paragraph directly above it — a tight pair.
A Selector between prose

Body copy sitting between two sciui controls — each gets the same gap a paragraph would.

Each container owns its own rhythm — nesting does not compound the step.

Re-tuning the step on a scope

.fish-flow is an explicit rhythm scope: set --flow on it to retune. Below, the same document runs at a looser step (1.4·--gt) — the tight pairs open up proportionally, because --flow-tight is derived from --flow.

DESIGN SYSTEM · RHYTHM

Container-owned spacing

A short standfirst, sized as a lead paragraph — the intro line under the title.

First body paragraph. Prose primitives (FText) and sciui components interleave below so the vertical rhythm between unlike neighbours is directly visible.

A second paragraph, so paragraph-to-paragraph spacing is on show too.

A note annotating the paragraph directly above it — a tight pair.
A Selector between prose

Body copy sitting between two sciui controls — each gets the same gap a paragraph would.

Live demo · f-ish Flow (libs/f-ish styles/flow.css)
Comments & Suggestions
  • Rhythm owners are the CONTENT wrappers, one per container: .fish-col, .fish-card-body, .sciui-card-body, .sciui-disclosure-panel-inner, .sciui-tabs-content, plus the explicit .fish-flow scope. sciui is styled host-side from f-ish (same direction as --sciui-text-inset) — the sciui repo is untouched.
  • One knob: --flow (base step, ≈ 0.7·--gt); the tight-pair step is --flow / 2, so retuning the base carries the exceptions with it. Tight pairs: kicker→title, title→lead, text→note. Everything else, prose or component, takes the base step.
  • Check in real Firefox as well as Chrome. Watch the flush behaviour at each container edge: Flow zeroes the first child's top margin and the last child's bottom margin, so content sits flush to the container padding.
  • A custom wrapper (a plain <div> the author adds) becomes a rhythm island — only rhythm owners space their children. Add fish-flow to such a wrapper to opt it back into rhythm. Docs (DESIGN.md/NOTES) land in Phase 7d, after the visual pass.