Docs

The knobs

What the core is now, and every knob a site or a child theme sets. (From the theme's status page.)

How a site uses it

<link rel="stylesheet" href="/fluid.min.css">   <!-- dist/fluid.min.css -->
<link rel="stylesheet" href="/site.css">        <!-- the child theme -->
/* site.css: the child theme. Knobs go on :root, in the themes layer. */
@layer themes {
  :root {
    --accent: oklch(55% 0.2 25);
    --neutral-tint: oklch(50% 0.01 60);
    --font-heading: "Fraunces", Georgia, serif;
    --radius-card: 4px;
    --shadow-style: 2;
  }
}

Dark mode follows the visitor's OS. <html data-theme="light"> or "dark" forces one; data-theme is read on <html> only. Set knobs on :root: the derived colour roles are computed there, so a knob set on a nested element does custom properties from sites.tokens_json.

Knobs change colour, type, radius, borders, shadows, spacing and motion. A look looks (2026-10-27) are 420 to 560 lines in @layer themes. That goes past "the knobs are the contract" above; no decision yet says which rule holds for such looks. A child theme like that owns every state of what it restyles, because themes outranks base and components (fluid-agent-kit/skills/accessibility.md, Keyboard). The honeypot and conditional-field rules are the exception: they sit in @layer utilities (form.css), which outranks a child theme in @layer themes. A site on a its own copies until it re-vendors (_docs/SHOWCASE-SITES.md, known scripts/render-look.mjs): the gates below load the core stylesheet, and form-check.mjs adds only a made-up @layer themes child theme of its own.

The knobs

All in src/core/tokens.css, in the first :root rule of @layer tokens (that rule holds knobs only). The defaults below are base v2 (branch base-v2-strong, spec _docs/plans/base-v2.md; not on main until the maintainer's taste calls). A site that wants the 1.x look links dist/fluid-legacy-1x.css after core: it sets these knobs back to their 1.x values.

Knob v2 default (1.x) What it does
--accent oklch(42% 0.15 22), deep oxblood (oklch(52% 0.16 245)) The brand colour as a fill: primary button, accent-strong band, active marks. A spice, never a default background
--accent-ink derived The accent as text and focus ring: L clamped to ≤ 0.45 in light mode; in dark, L ≥ 0.78 with chroma ≤ 0.08 (a dusty tint, not salmon). Lives in the role scope, so it flips on dark bands
--on-accent derived Text on an accent fill: white below L 0.575, near-black above. Accents between L 0.55 and 0.60 clear 4.5:1 with neither for some hues: avoid that band or set this yourself
--neutral-tint oklch(50% 0.01 75), warm paper (oklch(50% 0.008 230)) The colour the greys lean toward (only its hue and chroma are used)
--font-heading Newsreader stack (system) The main face knob: display, h1-h3, lede, card and stat titles, nav brand. Newsreader when dist/fluid-fonts.min.css is linked; a system serif without it
--font-display var(--font-heading) (new) The display face (.display, .hero__headline). Set only when it differs from the heading face
--font-body Schibsted Grotesk stack (system) Text, h4-h6, labels, nav links, buttons, forms, tables
--text-min, --text-max 1.0625, 1.25 (new; rem, unitless) Body size at 360px and at 1440px; every step of the scale is built on them
--type-ratio 1.414 (new) The desktop type-scale ratio; phones use min(1.25, ratio). Drives --step-1…--step-6 through pow()
--weight-display 400 (new) Weight of display, h1 and h2 (h3 is +100, at most 700 unless the display is heavier). Dark mode and dark bands add 30
--weight-heading no default (new) One weight for h2 and h3 instead of the display ladder; legacy-1x.css sets 600
--h1-size, --h2-size, --h3-size no default (new) A heading's size outright instead of its step (--step-4, -2, -1); legacy-1x.css sets the 1.x clamps, so the steps (nav brand, lede, card title) keep their own values
--display-max 10rem (new) The cap on a display headline's size, also at most 15svh (a three-line headline leaves the actions above a 900px fold). --fit 8 × 10rem = the 80rem column; a display straight in a .content-grid fits the reading column
--measure 58ch (new; was 65ch on p only) The reading width of prose, about 72 characters of the body face; --container-prose aliases it. No automatic hyphens
--hero-headline-size no default (clamp(2rem, 1rem + 5vw, 3.5rem)) Unset, .display and .hero__headline fit their column (100cqi / --fit, floor --step-4, cap --display-max). Set on :root, it still wins
--radius-card 0 (16px) Cards, panels, alerts, toasts, dialogs, menus, popovers; --radius-lg/xl alias it
--radius-control 0 (10px) Buttons, inputs, selects, nav links, tabs, badges; --radius-sm/md alias it
--radius-pill 9999px Chips, data-pill buttons (circles keep --radius-full)
--border-width 1px Every border (--border-width-thick is twice it)
--shadow-style 0 (1) 0 none, 1 soft, 2 hard. In v2 it reaches only --shadow-md (the elevated card, compare): the other shadow steps and every resting and hover shadow are gone, so legacy-1x.css cannot bring them back
--shadow-offset 4px How far a hard shadow sits from its box
--space-section var(--space-2xl), 72 → 160px (var(--space-20)) Block padding of a band (.section and the section components)
--space-gutter clamp(2.5rem, …, 7rem), 40 → 112px, both sides together as in 1.x (var(--space-6)) Page edge. --page-edge = max(gutter / 2, 3vi) per side, shared by .container, .content-grid, the hero, nav, footer and section components. The 3vi floor means a gutter under ~86px total has no effect at 1440px
--container-content 80rem Width of .container and the content edges
--rail 0rem The start-side label track of .content-grid; widen it to hang labels (swiss: 13rem)
--motion-base 320ms (467ms) Every duration derives from it; data-motion="reduced|slow|fast" scales it per element

Below the knobs sit the internal scales (--step--2…--step-6, the fluid --space-xs…--space-2xl, --page-edge, --rail, radius steps, durations, z-index) and the colour roles (--color-text, --color-surface, the status colours and their -fill, -on and -subtle forms). The roles are declared once, on :root and on each dark band (section[data-bg="inverse"], .hero[data-theme="dark"], .site-footer[data-dark]), so they flip there. Sites should not need to touch those.

Component hooks a child theme may set on :root (read with a fallback, never declared, not knobs): --section-rule (the section head's rule width; 0 removes it), --_btn-color-hover (button text on hover), --fit (per headline, the longest line in em; assets/js/fit-text.js measures it for [data-fit]), --gallery-min (the gallery's tile width, default 24rem), --toc-sub-size (TOC H3 entries). The private --_feat-*, --_stat-* (except --_stat-border), --_testi-*, --_member-*, --_cta-* and --_btn-shadow hooks were never set by anything and are gone (critique round 2): their fallbacks are written in.

Derived roles a site rarely touches: --accent-fill / --on-accent-fill / --accent-fill-hover (the primary-action fill: the accent in light mode; in dark mode an accent darker than L 0.5 is lifted to 0.5 and any other keeps its own fill. Its text is --on-accent in both schemes: white clears 4.5:1 at L 0.5 on every hue, and a theme's own --on-accent is never overridden. The 0.62 lift of round 1 turned oxblood salmon with black text and moved --focus-ring-width (2px; every focus outline is var(--focus-ring-width) solid var(--color-focus)). On an accent-strong band --color-error is --on-accent (the red was 1.1 to 2.9:1 on the accent); an invalid field always carries a 4× start edge as well as the colour (form.css), so the state never rests on colour alone.

Bands (data-bg): plain, tinted (alt), accent (a 7% wash, warm paper rather than pink), inverse, accent-strong (at most one a page). Never put an accent band next to accent-strong: two reds touching take the punch out of the strong one. The paint is in the layouts layer only; each band sets --_band-bg, which .cta reads, so a band always repaints a CTA. On a dark band --color-background is the band's paint, so fields and rings match the surface they sit on.

What is in the bundle

dist/fluid.min.css: base v2 (branch base-v2-strong) is 137,253 B raw, 23,827 B gzip after critique round 2 (2026-10-29; the never-set private hooks, the label-column section head, prose hyphenation, the last per-component transition: none blocks and a one-rule data-motion paid for the round's additions); round 1 left 138,465 B raw, 23,941 B gzip; 1.1.4 was 150,157 B raw and 23,853 B gzip. npm run build fails above 24,500 B gzip (24,000 until the nav defaults of 2026-10-29 and their audit fixes took core to 24,395 B; 24,400 until the gallery's Firefox fallback of 2026-10-30), and if dist loses the pow(var(--type-ratio) scale generator. Optional files beside it: dist/fluid-fonts.min.css + dist/fonts/ (Newsreader and Schibsted Grotesk, latin woff2, SIL OFL; 598 B gzip of CSS with the bold and italic fallback faces, 129 KB + 46 KB for the two romans; the italics load only when used), dist/fluid-legacy-1x.css (the 1.x look as knob values), dist/fluid-1x.min.css (core and the 1.x look in one file under the core banner, package export ./1x: the drop-in re-vendor for a consumer that serves exactly one stylesheet) and dist/fluid-zero.min.css. Fluid Zero (src/zero.css, package export ./zero) is a tiny alternate base, not the default: the reset, system fonts, the same knob and role names, and readable native elements, with no components, sections, content grid, web fonts or JS. 7,990 B raw, 2,396 B gzip; the build fails above 4,096 B gzip. Same layer order, so a child theme works on either base. Pass 0 left 149,312 B raw (145.8 KiB), 23,690 B gzip, 20,087 B brotli. Before Pass 0: 243,698 B (238.0 KiB), 35,623 B gzip, 29,355 B brotli. Every rule is inside a cascade layer: @layer reset, tokens, base, layouts, components, themes, utilities. The themes layer is empty in core; it is where a child theme goes.

  • Core: reset.css, tokens.css, base.css (native elements).
  • Layouts: .container, .stack, .cluster, .grid, .section and the section bands data-bg="tinted|alt|accent" (in src/fluid.css); layouts/positioning.css (attribute layout helpers; slated to shrink in v1).
  • Components (21): accordion, alert + toast, avatar, badge, button, card, chip, compare, dropdown, empty-state, form, gallery + lightbox, hero, indicator, loading, modal, nav (+ breadcrumb, tabs, pagination), search-input, step-indicator, table, tooltip + popover.
  • Sections (6): cta, features, footer, stats, team, testimonials.
  • Opt-in motion: scroll reveal animates only elements with [data-reveal], only after assets/js/scroll-reveal.js sets data-reveal-ready, and never under reduced motion; a bare .section is never hidden. Cross-document view transitions are off; a site that wants them adds @view-transition { navigation: auto } inside @media (prefers-reduced-motion: no-preference) in its own CSS.
  • JS (optional, progressive): lightbox.js, compare.js, scroll-reveal.js, fit-text.js, toc.js.
  • TOC module (spec _docs/plans/base-v2.md §13, base v2): a table of contents with scroll spy for every article with 3 or more H2s. Its own opt-in file, dist/fluid-toc.min.css (package export ./toc; source src/components/toc.css, 1.5 KB gzip, budget 1,792 B; in core it would put core over its budget, 24,500 B since 2026-10-30), plus assets/js/toc.js. Link it after core; every built file states the layer order first, so link order cannot demote its layer. The server writes the list with assets/js/toc-build.mjs (export ./toc-build, tocHtml(headings, options); Astro's { depth, slug, text } headings with id: slug), so crawlers, AI and no-JS readers get plain nested links, placed after the lede and before the body inside .article__body. data-toc words pick the placement: inline, rail (sticky side column at 70rem and up), dock (a two-row bar pinned to the bottom: sections, the current one centred, then its subsections; JS only), or rail dock (the article default); end puts the rail after the article; data-toc-depth="2" lists H2s only. Look hooks a child theme sets on :root: --toc-font, --toc-dock-font, --toc-size, --toc-sub-size, --toc-color (resting entries, muted; the active entry and its section's H2 rise to the text colour, spec §13.3), --toc-active (default --accent-ink, so the mark keeps 3:1 in dark mode), --toc-rule, --toc-marker (a counter style for H2 entries, e.g. decimal-leading-zero), --toc-dock-bg (the phone dock; default --color-surface in light, --color-surface-raised in dark). In forced colours the active entry keeps a Highlight bar and an underline; the resting bars are Canvas. The dock rows do not scroll-snap (snapping cancelled the page's scroll to a #fragment while it loaded); on a phone each neighbour of the centred section is capped to the room beside it (--_toc-side, written by toc.js), so it ends in an ellipsis on screen. A clicked or hashed heading stays lit until a wheel, touch or scrolling key, or any scroll that moves the page a quarter of a screen from where the jump settled (scrollbar, Tab, find, a screen reader, script). .article__body ends with --space-section in every mode. Named presets (editorial, numbered, serif, swiss, gallery, quiet, end rail) are copy-paste snippets in the src/components/toc.css header. On a rail page, full bleed stops at the article column; an article that needs full-bleed images uses data-toc="dock". The dock is never hidden from assistive tech: off screen it is only translated away, and a link reached by Tab slides it in. Row two shows one section's subsections at a time (the current one, or the one holding keyboard focus); the others stay in the tree, clipped. A viewport under 32rem tall (a phone at 200% zoom) gets the inline list.