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,.sectionand the section bandsdata-bg="tinted|alt|accent"(insrc/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 afterassets/js/scroll-reveal.jssetsdata-reveal-ready, and never under reduced motion; a bare.sectionis 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; sourcesrc/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), plusassets/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 withassets/js/toc-build.mjs(export./toc-build,tocHtml(headings, options); Astro's{ depth, slug, text }headings withid: slug), so crawlers, AI and no-JS readers get plain nested links, placed after the lede and before the body inside.article__body.data-tocwords 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), orrail dock(the article default);endputs 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-surfacein light,--color-surface-raisedin 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#fragmentwhile it loaded); on a phone each neighbour of the centred section is capped to the room beside it (--_toc-side, written bytoc.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__bodyends with--space-sectionin every mode. Named presets (editorial, numbered, serif, swiss, gallery, quiet, end rail) are copy-paste snippets in thesrc/components/toc.cssheader. On a rail page, full bleed stops at the article column; an article that needs full-bleed images usesdata-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.