/* The library's own precondition. Every atom that sizes itself (.input,
   .textarea, .select, .btn--block, .card) sets a width AND a padding, which
   only composes under border-box. rivet happened to supply this in render.py,
   so the dependency was invisible until galvan-admin-template consumed
   library.css directly and its sign-in panel rendered 448px wide against a
   384px max-width, with the inputs overflowing the card.

   Only box-sizing. Zeroing margin and padding globally is a page-level
   opinion, not something a component library gets to decide for its host. */
*, *::before, *::after { box-sizing: border-box; }

/* atoms.css — canonical ATOM layer for SiteForge (the web implementation of the
 * Galvan design-system spec). Implements the atoms in
 * ~/projects/design-system/catalog.json using SiteForge's existing CSS-variable
 * contract (so rebrand-over-wire keeps working) plus phone-baseline fallbacks
 * (--tap-min etc.) so every atom is touch-correct even where the harness doesn't
 * inject them.
 *
 * RULE: sections compose THESE classes. A section must never re-declare an atom's
 * colour/radius/typography — only layout. See ATOMIZATION.md.
 *
 * Var contract: --color-primary/accent/surface/text/text-muted, --primary-*,
 * --neutral-*, --font-display/body, --text-*, --space-*, --radius-*.
 * Baseline fallbacks: --tap-min(44px) --dur-fast(150ms) --ease-out.
 */

/* ==================== Muted text floor ====================
 * Muted text is derived from the palette's own ink and ground rather than shipped as a
 * fixed grey, so it follows a bound site palette, follows the light/dark ground, and
 * still clears WCAG AA on the tinted panels sections actually put it on.
 *
 * Measured (WCAG 2.x, sRGB). The greys this replaces, against the worst light ground
 * in the library, --neutral-200 (#e5e7eb):
 *   #64748b, the value the preview :root ships   3.84:1  FAIL
 *   #6b7280 (--neutral-500)                      3.90:1  FAIL
 * On --neutral-100 (#f3f4f6) those are 4.32 and 4.39, also under the 4.5:1 bar. That
 * one grey is the whole of the low-contrast worklist on nine sections.
 *
 * 70% ink over ground resolves to #575d6a on a white page and #b2b6bd on a #0f172a
 * page. Worst case each way:
 *   #575d6a on #e5e7eb  5.34:1     #b2b6bd on #374151  5.06:1
 *   #575d6a on #f3f4f6  6.00:1     #b2b6bd on #1e293b  7.19:1
 * A flat --neutral-600 also clears the light side (6.10:1 on #e5e7eb) but it is a
 * light-ground value: on a dark page it lands at 1.9:1 inside a --color-surface card.
 * Deriving it is what keeps both grounds correct from one declaration.
 * Dark SECTIONS on a light page are handled by .on-dark just below. */
:root {
  --color-text-muted: color-mix(in srgb, var(--color-text, #0f172a) 70%, var(--color-background, #ffffff));
}

/* ==================== Tinted chip ground ====================
 * SHARED CAUSE, fixed here rather than in the 24 sections that tripped over it.
 * A badge, chip, tag, avatar, kbd, menu hover row or table head is a small tinted
 * patch of the PAGE, and every one of them printed --color-text (ground-relative) on
 * --neutral-100 / --neutral-200 (absolute, always near-white). On a light page that
 * reads; on a dark page it is #f8fafc on #e5e7eb, measured at 1.2:1. --neutral-* never
 * flips, so the ink and its ground were resolving against different theories of where
 * the component sits.
 *
 * Derived the same way --color-text-muted above is: a percentage of the page's own ink
 * over the ground the chip actually sits on, so the tint follows whichever ground it
 * lands on and the ink stays correct by construction.
 *
 * Mixed over --color-SURFACE, not --color-background, and that distinction was found by
 * looking at the rendered dark preview rather than at the numbers. Mixing over the page
 * ground passes the contrast gate, because the ink is still correct, but a chip inside a
 * card then resolves to almost exactly the card's own colour: the price pills in
 * app-store-grid, the role badge in card-user and the key caps in search-command all
 * lost their pill and read as bare text on dark. --color-surface is the raised ground a
 * chip is normally on, and it is one step off the page either way, so the chip stays
 * visible whether it sits on a card or directly on the page.
 *   light  7% #0f172a over #f8fafc -> #e7eaed   10% -> #e0e3e7 (was #f3f4f6 / #e5e7eb)
 *   dark   7% #f8fafc over #1e293b -> #2d3748   10% -> #343e4e
 * Contrast on the dark step: ink #f8fafc 12.2:1, muted #b2b6bd 6.0:1.
 * 7 and not 5, because two separate reads of the dark previews said the same thing: at
 * 5% the price pills and the role badge were legible but their pill had almost no fill
 * against the card. The same delta reads weaker light-on-dark than dark-on-light, so
 * the number that looked right on the light ground was not enough on the dark one.
 * .on-dark needs no special case: it sets --color-surface itself, so the same
 * declaration resolves against --neutral-800 there. */
:root {
  --color-chip: color-mix(in srgb, var(--color-text, #0f172a) 7%, var(--color-surface, var(--color-background, #ffffff)));
  --color-chip-strong: color-mix(in srgb, var(--color-text, #0f172a) 10%, var(--color-surface, var(--color-background, #ffffff)));
}

/* ==================== Brand-tinted patch ====================
 * --color-chip one step further in: the patch a section tints with the BRAND rather
 * than with its own ink. A promo band, an unread row, a featured plan, a quote block.
 * Every one of them painted --primary-50, which is the brand mixed into WHITE and so
 * is absolute in exactly the way --neutral-100 was, while the ink on it stayed
 * ground-relative. Measured on the dark preview: #f8fafc on #eef3fd is 1.1:1 and
 * #b2b6bd on #eef3fd is 1.8:1.
 *
 * Deriving the tint over --color-surface fixes the ink. It also introduces a second
 * failure, which is why this is a token and not a per-section edit: on the dark ground
 * --color-surface is already a slate (#1e293b, chroma 29) and 10% of the brand lifts it
 * to chroma 46, past the detector's 40 threshold, so the patch counts as a COLOURED
 * background and the page's grey muted step (#b2b6bd, chroma 11) fires gray-on-color.
 * That was a REGRESSION introduced with the ground-relative tint, not an old bug:
 * dropdown-notification and modal-pricing measured clean while the tint was absolute.
 *
 * So the patch carries its own muted step instead of borrowing the page's. Mixing ink
 * with the BRAND rather than with the ground keeps the patch's hue in the text, which
 * is what the rule actually asks for ("use a darker shade of the background colour"),
 * and it still clears contrast comfortably because it is 70% ink either way:
 *   blue  light  #162e64 on #e3ebfa 11.1:1     dark  #b9cdf7 on #1f2f4d  8.5:1
 *   lime  light  #445c1d on #f3fae3  7.0:1     dark  #e8fbb0 on #2e3e35 10.2:1
 * The muted step measures chroma 62 to 78, so it is never read as grey. Full
 * --color-text needs no swap: gray-on-color only looks at text between 0.05 and 0.85
 * relative luminance, and ink sits outside that on both grounds (0.009 and 0.954).
 *
 * --color-tint is exposed separately so a section that already publishes its own
 * background hook can keep it and still opt into the text context via the class. */
:root {
  --color-tint: color-mix(in srgb, var(--color-primary) 10%, var(--color-surface, var(--color-background, #ffffff)));
}
/* .on-tint is that text context WITHOUT the background, for the sections that mix their
 * own brand tint at their own strength (8%, 9%, 12%, 6%) instead of taking --color-tint.
 * Six of them kept the PAGE's grey muted on it and fired gray-on-color: #b2b6bd on
 * #1f2e49, on #111d39, on #1e2c46, on #122041, on #293654, on #111e3b. Same defect as
 * the paragraph above, one level in, and the same answer, so it is the same declaration
 * rather than six copies of it. Splitting the class in two is what lets a section that
 * already publishes its own background hook opt into the ink without fighting over the
 * fill: the section's own `background` would win anyway, and a class whose declared
 * background is always overridden reads as a mistake. */
.on-tint, .surface-tint {
  --color-text-muted: color-mix(in srgb, var(--color-text, #0f172a) 70%, var(--color-primary));
}
.surface-tint { background: var(--color-tint); }

/* ==================== Surface context (local token swap) ====================
 * Put on any dark section so every atom inside inverts automatically — no
 * per-section text-colour overrides. This is theme-swap applied locally. */
.on-dark, .surface-dark {
  --color-text: #fff;
  --color-text-muted: rgba(255,255,255,.72);
  --color-surface: var(--neutral-800, #1f2937);
  color: var(--color-text);
  /* The derived tints have to be RE-declared here, not just inherited. A custom
     property containing var() is substituted on the element that declares it, so the
     --color-chip on :root resolves against the PAGE's ink and surface and descendants
     inherit that finished colour. Overriding --color-text and --color-surface below it
     does nothing to a token already resolved above it.
     The previous pass assumed the opposite ("`.on-dark` needs no special case: it sets
     --color-surface itself"), and the gate found it the moment a plain .badge landed
     inside a dark player: chip #e8eaed under #ffffff text, 1.2:1, which is the same
     absolute-patch-under-relative-ink defect one level further in. Re-declaring makes
     the tints resolve against this element's swapped values instead. */
  --color-chip: color-mix(in srgb, var(--color-text, #0f172a) 7%, var(--color-surface, var(--color-background, #ffffff)));
  --color-chip-strong: color-mix(in srgb, var(--color-text, #0f172a) 10%, var(--color-surface, var(--color-background, #ffffff)));
  --color-tint: color-mix(in srgb, var(--color-primary) 10%, var(--color-surface, var(--color-background, #ffffff)));

  /* Status colours are absolute patches, so they flip with the ground for the
     same reason the tints do. The standard ships a light-ground value and an
     on-dark twin; this is where the twin is chosen, once, instead of in each of
     the six components that reference them.
     Receipt: the moment --color-danger existed at all, labs.galvangroup.co (a
     dark site) picked up the LIGHT value and four elements went from a
     hardcoded #f87171 that happened to work to a real token that did not. */
  --color-danger: var(--color-danger-on-dark, #f2837a);
  --color-success: var(--color-success-on-dark, #5fca94);
  --color-warning: var(--color-warning-on-dark, #e0a94a);
}

/* Brand colour used as TEXT steps to --primary-400 on a dark ground, which is what the
   library's own dark/light usage table already specifies for tags and labels. A mid
   brand colour on near-black does not clear body contrast: the default #2563eb
   measures 3.43:1 on --neutral-900 (#111827), its --primary-400 step (#6b95f1)
   measures 6.08:1. Fills and buttons keep the base primary.
   Carried on a token rather than a `.on-dark .text--accent` descendant selector, so the
   atoms keep their single-class weight and a section's own overrides still win. */
.on-dark, .surface-dark { --color-accent-text: var(--primary-400, var(--color-primary)); }

/* The fallback when NOTHING resolves the token is INK, not the raw brand.
   A section that paints no ground of its own carries neither .on-dark nor a dark
   surface, so --color-accent-text is unset on BOTH grounds and the old
   `var(--color-primary)` fallback wrote the raw brand as text. Measured:
   #2563eb on #0f172a is 3.5:1 and #c1fe00 on #ffffff is 1.2:1, against a 4.5:1 bar.
   --color-text follows the ground (#0f172a light, #f8fafc dark), so ink clears it
   either way: 16.9:1 and 16.4:1.

   The fallback has to be a LITERAL colour. Every derived CSS colour (color-mix,
   oklch, rgb(from ...)) serialises in the detector's Chrome 151 as `color(srgb ...)`
   or `oklch(...)`, and detect-antipatterns-browser.js parseRgb matches `rgb()` only.
   So --primary-900 here would clear the gate by making the text UNMEASURABLE rather
   than by making it readable, which is a false pass.

   Ink is the fallback, not the answer. The answer is a resolved --color-accent-text:
   rivet-server injects design.accent_text_on (the brand darkened by the least amount
   that clears 4.5:1) and this declaration then never applies. See EDITORIAL-STANDARD
   rule 22. SiteForge's own preview :root does not emit the token yet, which is why
   the standalone preview renders accent text as ink. */

/* ==================== Accent step for a brand-TINTED PATCH ====================
 * SHARED CAUSE, third level of the same shape. --color-accent-text is computed for the
 * PAGE's ground. A brand-tinted patch (.badge--primary at 15%, .icon-tile at 12%) is a
 * LIGHTER ground than the dark page it sits on, so the page's step lands on it short:
 *
 *   #6b95f1 (--primary-400) on the 15% badge over --neutral-800  4.37:1  FAIL
 *   #6b95f1 on the same badge over --neutral-900                 5.31:1
 *   #6b95f1 on the 12% icon-tile over --color-surface            4.51:1  on the line
 *
 * That 4.37 is 14 of the library's remaining contrast findings on its own, across six
 * sections, and none of them writes a colour: they all just use .badge--primary inside
 * .on-dark. So it is fixed once, here.
 *
 * The patch takes the step one lighter, --primary-300, which is the same relationship
 * --primary-400 has to the page. Measured on the same grounds:
 *
 *   #96b4f5 (--primary-300) on the 15% badge over --neutral-800  6.16:1
 *   #96b4f5 on the 15% badge over --neutral-900                  7.49:1
 *   #96b4f5 on the 12% icon-tile over --color-surface            6.35:1
 *
 * A margin rather than a rounding win, which matters because the badge tint composites
 * over whatever card it lands on and that ground is not fixed.
 *
 * A SECOND TOKEN rather than `.on-dark .badge--primary`, for the reason given above the
 * .on-dark accent declaration: a descendant selector would outweigh a section's own
 * single-class override. On :root it resolves to the page's own accent step, so a light
 * ground is untouched (the light badge is #dbe3f9 and takes the page's #1554e0). */
:root { --color-accent-text-tint: var(--color-accent-text, var(--color-text, #0f172a)); }
.on-dark, .surface-dark { --color-accent-text-tint: var(--primary-300, var(--primary-400, var(--color-primary))); }

/* ==================== Brand FILL context (local token swap) ====================
 * The .on-dark of a brand-filled band: a promo bar, a gradient hero, a filled step
 * marker. Everything inside is on the BRAND, not on the page, so every ground-relative
 * token has to be re-pointed at --color-primary-text, the palette's own ink-or-white
 * pick for that brand.
 *
 * Accent text is the one that kept being missed, and it is the loudest failure of the
 * three: .link and .btn--secondary both read --color-accent-text, which on a filled
 * band resolves to the step computed for the PAGE and lands brand-on-brand.
 *   #6b95f1 on #2563eb  1.77:1      #1554e0 on #2563eb  1.24:1
 *   --color-primary-text on #2563eb 5.17:1, and on Galvan lime 16.35:1.
 *
 * Muted deliberately equals full ink here rather than a mix toward the fill: 80% white
 * over #2563eb is #d3e0fb, which measures 3.89:1 against the fill and fails. On a brand
 * fill the secondary rank is carried by size and weight, not by tint. */
.on-primary, .surface-primary {
  --color-text: var(--color-primary-text, #fff);
  --color-text-muted: var(--color-primary-text, #fff);
  --color-accent-text: var(--color-primary-text, #fff);
  --color-accent-text-tint: var(--color-primary-text, #fff);
  color: var(--color-text);
}

/* ==================== Motion vocabulary ====================
 * Measured off linear.app (LINEAR-DECONSTRUCTION.md, "Motion vocabulary"). The
 * headline finding is what is ABSENT: across 180 downloaded JS chunks, framer-motion
 * 0, gsap 0, three 0, WebGL 0, `.animate(` 0 (no WAAPI), getAnimations 0,
 * startViewTransition 0, useInView 0; and in 351KB of CSS, animation-timeline 0,
 * view-timeline 0, scroll-timeline 0. The whole motion system is CSS transitions on
 * state plus CSS keyframe loops, which means almost all of it is reproducible in a
 * static section with scoped CSS and no JS.
 *
 * The 18 curves are a complete Penner set, copied verbatim. Counted in their CSS,
 * --ease-out-quad dominates by an order of magnitude; in-out-quint, in-out-expo,
 * in-out-circ, in-circ and in-expo are declared and never used on those pages. The
 * house style is EASE-OUT, ALWAYS, and --ease-out-quad is the house curve. The set
 * ships whole anyway because it is 18 inert declarations and it stops the next
 * author inventing a nineteenth cubic-bezier by hand.
 *
 * NOT a second vocabulary: ~/projects/design-system/tokens/tokens-motion.css already
 * defines --ease-standard / --ease-in / --ease-out / --ease-emphasized /
 * --ease-linear, --dur-instant/fast/normal/slow/signal and --press-scale. Those names
 * are left alone and are still what the atoms below consume; these are 18 NEW names
 * that do not exist upstream. */
:root {
  --ease-in-quad:      cubic-bezier(.55,.085,.68,.53);
  --ease-out-quad:     cubic-bezier(.25,.46,.45,.94);
  --ease-in-cubic:     cubic-bezier(.55,.055,.675,.19);
  --ease-out-cubic:    cubic-bezier(.215,.61,.355,1);
  --ease-in-quart:     cubic-bezier(.895,.03,.685,.22);
  --ease-out-quart:    cubic-bezier(.165,.84,.44,1);
  --ease-in-quint:     cubic-bezier(.755,.05,.855,.06);
  --ease-out-quint:    cubic-bezier(.23,1,.32,1);
  --ease-in-expo:      cubic-bezier(.95,.05,.795,.035);
  --ease-out-expo:     cubic-bezier(.19,1,.22,1);
  --ease-in-circ:      cubic-bezier(.6,.04,.98,.335);
  --ease-out-circ:     cubic-bezier(.075,.82,.165,1);
  --ease-in-out-quad:  cubic-bezier(.455,.03,.515,.955);
  --ease-in-out-cubic: cubic-bezier(.645,.045,.355,1);
  --ease-in-out-quart: cubic-bezier(.77,0,.175,1);
  --ease-in-out-quint: cubic-bezier(.86,0,.07,1);
  --ease-in-out-expo:  cubic-bezier(1,0,0,1);
  --ease-in-out-circ:  cubic-bezier(.785,.135,.15,.86);
}

/* ==================== The house hover ====================
 * 0.16s with --ease-out-quad is their default interaction transition: of 79
 * transition declarations, 26 are 0.16s, more than twice the next duration. The
 * canonical pattern, repeated verbatim across their components:
 *
 *     transition: filter .16s var(--ease-out-quad), transform .16s var(--ease-out-quad);
 *     :hover  { filter: brightness(1.4); }
 *     :active { transform: scale(.97); }
 *
 * They change BRIGHTNESS, not colour. There is no hover colour swap anywhere in the
 * sampled CSS, which means one declaration replaces a per-variant hover colour for
 * every brand and both grounds.
 *
 * Applied here to the FILLED atoms only, and that is a deliberate deviation with a
 * reason. Brightening a fill lifts it on any ground. Brightening dark ink on paper
 * does not: it washes the text toward the page and REMOVES the affordance instead of
 * adding one. linear.app is an all-dark site, so the question never came up there.
 * The transparent variants (.btn--secondary, .btn--ghost) therefore keep their
 * background tint on hover, which is the same "no hover colour on the ink" idea
 * expressed the way a light ground needs.
 *
 * The :active scale sits inside prefers-reduced-motion: no-preference, per the
 * inversion below.
 *
 * KNOWN, and verified by hovering the real button rather than reasoning about it:
 * brightness multiplies and then CLAMPS per channel, so a brand that already sits at
 * a channel ceiling shifts hue instead of just lifting. Galvan's lime #c1fe00 has
 * green at 254, so x1.4 lands on (255,255,0), pure yellow. It reads unmistakably as
 * "lit" and the dark button text stays at 18.9:1, but it is a hue shift, not a
 * brightening. linear.app's indigo #5e6ad2 has no channel near the ceiling and never
 * shows this. If the yellow is unwanted, the document also measures 1.3 and 110% in
 * their CSS; at 110% the lime lifts to (212,255,0) and stays lime. 1.4 is the
 * canonical value of the 26 uses, so it is what ships until someone decides
 * otherwise. */
.hover-bright {
  transition: filter .16s var(--ease-out-quad), transform .16s var(--ease-out-quad);
}
.hover-bright:hover { filter: brightness(1.12); }
/* A generic utility cannot know which property carries the brand, so it keeps a
   filter; 1.12 is chosen to stay near the brand rather than to match linear.app. */
@media (prefers-reduced-motion: no-preference) {
  .hover-bright:active { transform: scale(var(--press-scale, .97)); }
}

/* ============================ Button ============================ */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: var(--space-sm, .5rem);
  min-height: var(--tap-min, 44px); padding: var(--space-sm, .5rem) calc(var(--space-lg, 1.5rem) + .25rem);
  font-family: var(--font-body); font-size: var(--text-base, 1rem); font-weight: 600;
  line-height: 1.2; border: 1px solid transparent; border-radius: var(--radius-md, 12px);
  cursor: pointer; text-decoration: none; white-space: nowrap;
  transition: background var(--dur-fast, 150ms) var(--ease-out, ease),
              border-color var(--dur-fast, 150ms) var(--ease-out, ease),
              transform var(--dur-instant, 100ms) var(--ease-out, ease);
}
.btn:active { transform: scale(var(--press-scale, .97)); }
.btn:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 40%, transparent); }
/* The house hover: brightness, not a colour swap. --primary-700 was a second brand
   step maintained purely so a filled button had somewhere to go on hover. */
.btn--primary {
  background: var(--color-primary); color: var(--color-primary-text, #fff);
  box-shadow: var(--shadow-sm, 0 1px 2px rgba(0,0,0,.05));
  transition: filter .16s var(--ease-out-quad), transform var(--dur-instant, 100ms) var(--ease-out, ease);
}
/* HOVER LIFT. Not `filter: brightness()`, which multiplies each channel and CLAMPS.
 * Measured on Galvan lime #c1fe00, whose green is already 254, so there is no headroom:
 *
 *     brightness(1.0)  hue  74     the brand
 *     brightness(1.1)  hue  70     already shifted
 *     brightness(1.4)  hue  60     pure yellow #ffff00
 *
 * linear.app can use 1.4 because its indigo has headroom in every channel (234 -> 232).
 * The technique does not transfer to a brand with a maxed channel AT ANY VALUE above 1.0,
 * so the canonical number was the wrong thing to copy. Mixing toward white does not fix
 * it either: +12% white takes lime from hue 74 to 80, because raising a zero blue channel
 * moves the hue.
 *
 * Relative colour syntax lifts LIGHTNESS while holding hue and chroma literally constant.
 * Verified in the gate's own Chrome: oklch(from #c1fe00 calc(l + 0.06) c h) computes
 * oklch(0.981 0.232 125.302), the same 125.302 hue as the source. It is also measurable:
 * parseRgb reads oklch(). The brightness fallback stays for anything older. */
.btn--primary:hover { filter: brightness(1.12); }
@supports (color: oklch(from red l c h)) {
  .btn--primary:hover {
    filter: none;
    background: oklch(from var(--color-primary) calc(l + 0.06) c h);
  }
}
/* The RING takes the resolved accent too. A saturated 1px brand outline on paper is
   the neon look: lime at 1.15:1 against the ground is not a visible edge, it is a
   glow. The fill keeps the true brand; an outline is closer to text than to a fill. */
.btn--secondary { background: transparent; color: var(--color-accent-text, var(--color-primary)); border-color: var(--color-accent-text, var(--color-primary)); }
.btn--secondary:hover { background: color-mix(in srgb, var(--color-primary) 8%, transparent); }
.btn--ghost { background: transparent; color: var(--color-text); }
.btn--ghost:hover { background: var(--neutral-100, rgba(0,0,0,.05)); }
.btn--destructive { background: #dc2626; color: #fff; }
.btn--destructive:hover { background: #b91c1c; }
.btn--icon { padding: var(--space-sm, .5rem); min-width: var(--tap-min, 44px); border-radius: var(--radius-full, 9999px); }
.btn--sm { min-height: 36px; padding: var(--space-xs, .25rem) var(--space-md, 1rem); font-size: var(--text-sm, .875rem); }
.btn--lg { min-height: var(--tap-primary, 56px); padding: var(--space-md, 1rem) var(--space-xl, 2rem); font-size: var(--text-lg, 1.125rem); }
.btn--block { display: flex; width: 100%; }

/* ======================== Icon button ========================= */
.icon-btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: var(--tap-min, 44px); height: var(--tap-min, 44px);
  border: none; background: transparent; color: var(--color-text);
  border-radius: var(--radius-full, 9999px); cursor: pointer;
  transition: background var(--dur-fast, 150ms) var(--ease-out, ease);
}
.icon-btn:hover { background: var(--neutral-100, rgba(0,0,0,.05)); }
.icon-btn--danger { color: #dc2626; }

/* ========================== Inputs =========================== */
.input, .textarea, .select {
  width: 100%; min-height: var(--tap-min, 44px);
  padding: var(--space-sm, .5rem) var(--space-md, 1rem);
  font-family: var(--font-body); font-size: var(--text-base, 1rem); color: var(--color-text);
  background: var(--color-surface, #fff); border: 1px solid var(--neutral-200, #e5e7eb);
  border-radius: var(--radius-md, 8px);
  transition: border-color var(--dur-fast, 150ms) var(--ease-out, ease);
}
.input::placeholder, .textarea::placeholder { color: var(--color-text-muted); }
.input:focus, .textarea:focus, .select:focus {
  outline: none; border-color: var(--color-primary);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 20%, transparent);
}
.textarea { min-height: calc(var(--tap-min, 44px) * 2); resize: vertical; line-height: 1.5; }
.select { appearance: none; padding-right: var(--space-xl, 2rem);
  background-image: linear-gradient(45deg, transparent 50%, currentColor 50%), linear-gradient(135deg, currentColor 50%, transparent 50%);
  background-position: right 1rem center, right .75rem center; background-size: 5px 5px, 5px 5px; background-repeat: no-repeat; }
.input[aria-invalid="true"], .textarea[aria-invalid="true"] { border-color: #dc2626; }
/* ⚠ THE FIELD ATOM PAIRED A THEME-AWARE SURFACE WITH A THEME-FIXED BORDER.
   `background: var(--color-surface)` follows the theme, so on a dark page the
   field fills correctly; `border: 1px solid var(--neutral-200)` does not,
   because `.on-dark` never redeclares --neutral-200. Measured on
   /foundations: eleven fields outlined in #E0DDDC, a near-white hairline, on a
   #0b1120 ground. That is the whole of why the forms on that site "look ugly"
   and it is one missing rule, not eleven styling decisions.
   (0,2,0) so it beats the base rule; light pages are untouched. */
.on-dark .input, .on-dark .textarea, .on-dark .select {
  /* --neutral-700, NOT --neutral-800. The first attempt used 800 because that
     is what every hairline in the house sections uses, and it produced a field
     with no edge at all: on a site that sets `bg`, rivet derives BOTH
     --color-surface and --neutral-800 from the ground and they landed on the
     same color-mix(#0b1120, #ffffff 11%), so border and fill measured a 1.00
     contrast ratio. Measured, not eyeballed. 700 is #374151, is not derived
     from the ground, and is what form-wish already uses -- which is why that
     form looked right while the intake form did not. */
  border-color: var(--neutral-700, #374151);
}

/* ==================== Checkbox / radio / switch ==================== */
.checkbox, .radio { width: 20px; height: 20px; min-width: 20px; accent-color: var(--color-primary); cursor: pointer; }
.switch { position: relative; display: inline-flex; align-items: center; width: 44px; height: 26px; cursor: pointer; }
.switch input { position: absolute; opacity: 0; }
.switch .switch__track { width: 44px; height: 26px; background: var(--neutral-200, #e5e7eb); border-radius: var(--radius-full, 9999px); transition: background var(--dur-fast, 150ms) var(--ease-out, ease); }
.switch .switch__thumb { position: absolute; left: 3px; width: 20px; height: 20px; background: #fff; border-radius: 50%; box-shadow: 0 1px 3px rgba(0,0,0,.2); transition: transform var(--dur-fast, 150ms) var(--ease-out, ease); }
.switch input:checked + .switch__track { background: var(--color-primary); }
.switch input:checked ~ .switch__thumb { transform: translateX(18px); }

/* ========================== Badge / chip / tag ========================== */
.badge {
  display: inline-flex; align-items: center; gap: var(--space-xs, .25rem);
  padding: 2px var(--space-sm, .5rem); font-size: var(--text-xs, .75rem); font-weight: 600;
  line-height: 1.4; border-radius: var(--radius-full, 9999px);
  background: var(--color-chip, var(--neutral-100, #f3f4f6)); color: var(--color-text);
}
.badge--primary { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-accent-text-tint, var(--color-accent-text, var(--color-text, #0f172a))); }
.badge--success { background: #dcfce7; color: #166534; }
.badge--warning { background: #fef3c7; color: #92400e; }
.badge--danger  { background: #fee2e2; color: #991b1b; }
.chip {
  display: inline-flex; align-items: center; gap: var(--space-xs, .25rem);
  min-height: 32px; padding: var(--space-xs, .25rem) var(--space-md, 1rem);
  font-size: var(--text-sm, .875rem); border-radius: var(--radius-full, 9999px);
  background: var(--color-chip, var(--neutral-100, #f3f4f6)); color: var(--color-text); cursor: pointer;
  border: 1px solid transparent; transition: all var(--dur-fast, 150ms) var(--ease-out, ease);
}
/* The other brand FILL in the atom layer, so it takes the same house hover. */
.chip--selected { background: var(--color-primary); color: var(--color-primary-text, #0b0b0b); transition: filter .16s var(--ease-out-quad); }
.chip--selected:hover { filter: brightness(1.12); }
@supports (color: oklch(from red l c h)) {
  .chip--selected:hover { filter: none;
    background: oklch(from var(--color-primary) calc(l + 0.06) c h); }
}
.chip__remove { display: inline-flex; cursor: pointer; opacity: .7; }
.tag { display: inline-block; padding: 1px var(--space-sm, .5rem); font-size: var(--text-xs, .75rem); border-radius: var(--radius-sm, 4px); background: var(--color-chip, var(--neutral-100, #f3f4f6)); color: var(--color-text-muted); }

/* ========================== Avatar ========================== */
.avatar { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: var(--radius-full, 9999px); overflow: hidden; background: var(--color-chip-strong, var(--neutral-200, #e5e7eb)); color: var(--color-text); font-size: var(--text-sm, .875rem); font-weight: 600; flex-shrink: 0; }
.avatar img { width: 100%; height: 100%; object-fit: cover; }
.avatar--sm { width: 28px; height: 28px; font-size: var(--text-xs, .75rem); }
.avatar--lg { width: 56px; height: 56px; font-size: var(--text-lg, 1.125rem); }

/* ==================== Tracking as a function of SIZE ====================
 * Letter-spacing is a computed function of font size, not a per-element guess.
 * Measured off linear.app and verified against RENDERED values, not read off their
 * token list (see LINEAR-DECONSTRUCTION.md, "Type scale"):
 *   72px renders -1.584px (72 x -0.022)   48px -1.056px   32px -0.704px
 *   24px renders -0.288px (24 x -0.012)
 *   15px renders -0.165px (-0.011)   14px -0.182px (-0.013)   13px -0.13px (-0.010)
 * Shape of the rule:
 *   >= 32px        -0.022em
 *   20px to 24px   -0.012em
 *   13px to 15px   -0.010em to -0.013em
 *   12px and 17px   0
 * So tracking tightens as size climbs and relaxes back to zero at the smallest UI
 * sizes. The old declaration ran a flat -0.02em on .heading with -0.03em on h1 and
 * -0.025em on h2, so a 20px .heading--h4 (1058 uses, the most-used heading in the
 * library) inherited DISPLAY tracking: -0.4px per gap on a size Linear sets at
 * -0.24px, and nothing anywhere on their site is tighter than -0.022em. -0.03em is
 * off the top of the measured range entirely.
 * Exposed as tokens so a section that sizes its own heading can pick the band that
 * matches the size it actually rendered, instead of guessing a new number. */
:root {
  --track-display: -0.022em;  /* >= 32px */
  --track-title:   -0.012em;  /* 20px to 24px */
  --track-body:    -0.011em;  /* 13px to 16px; measured at 15px */
}

/* ========================== Typography atoms ========================== */
.text { font-family: var(--font-body); font-size: var(--text-base, 1rem); color: var(--color-text); line-height: 1.6; }
.text--muted { color: var(--color-text-muted); }
.text--lead { font-size: var(--text-lg, 1.125rem); line-height: 1.6; }
.text--caption { font-size: var(--text-sm, .875rem); color: var(--color-text-muted); }
/* Premium type: tight, confident headings (negative tracking + tight leading).
   The BASE carries the body band, not the display band, because a bare `.heading`
   with no size modifier is whatever size the section gave it and in this library that
   is 18px. Every size modifier below restates its own band at equal specificity and
   later in the file, so the modifier always wins. */
.heading { font-family: var(--font-display); color: var(--color-text); line-height: 1.1; letter-spacing: var(--track-body); font-weight: 700; }
/* Leading tightens as size grows: the measured reference build runs 0.94 on its
   150px hero, Klim runs 1.0 on 218px. At 1.05 a large heading reads loose and timid. */
.heading--h1 { font-size: var(--text-display, 3rem); line-height: 0.95; letter-spacing: var(--track-display); font-weight: 800; }
.heading--h2 { font-size: var(--text-3xl, 2rem); line-height: 1.02; letter-spacing: var(--track-display); }
.heading--h3 { font-size: var(--text-2xl, 1.5rem); font-weight: 700; letter-spacing: var(--track-title); }
.heading--h4 { font-size: var(--text-xl, 1.25rem); font-weight: 700; letter-spacing: var(--track-title); }
.link { color: var(--color-accent-text, var(--color-primary)); text-decoration: none; }
.link:hover { text-decoration: underline; }
.link--muted { color: var(--color-text-muted); }

/* ========================== Divider ========================== */
.divider { border: none; border-top: 1px solid var(--neutral-200, #e5e7eb); margin: var(--space-lg, 1.5rem) 0; }
.divider--vertical { border-top: none; border-left: 1px solid var(--neutral-200, #e5e7eb); margin: 0 var(--space-lg, 1.5rem); align-self: stretch; }

/* ==================== Spinner / progress / skeleton ==================== */
.spinner { display: inline-block; width: 24px; height: 24px; border: 2px solid var(--neutral-200, #e5e7eb); border-top-color: var(--color-primary); border-radius: 50%; }
@keyframes sf-spin { to { transform: rotate(360deg); } }
/* Reduced motion by INVERSION. Every keyframe on linear.app sits inside
   prefers-reduced-motion: no-preference rather than being switched off inside
   `reduce`, so the DEFAULT is the end state and reduced motion needs no override at
   all. That is the whole trick: a rule that has to be remembered somewhere else is a
   rule that gets forgotten, and the failure is silent. The ring keeps its brand-
   coloured top border either way, so it still reads as "working" when it is still. */
@media (prefers-reduced-motion: no-preference) {
  .spinner { animation: sf-spin .8s linear infinite; }
}
.progress { width: 100%; height: 8px; background: var(--neutral-200, #e5e7eb); border-radius: var(--radius-full, 9999px); overflow: hidden; }
/* No transition on width: the library ships no JS and fills are server-rendered,
   so it never animated anything, while animating width causes layout thrash (the
   impeccable layout-transition rule flagged it on 100% of the library). A consumer
   that does animate a fill should do it with transform: scaleX() instead. */
.progress__fill { height: 100%; background: var(--color-primary); border-radius: inherit; }
.skeleton { position: relative; overflow: hidden; background: var(--neutral-100, #f3f4f6); border-radius: var(--radius-md, 8px); }
.skeleton--text { height: 1em; border-radius: var(--radius-sm, 4px); }
.skeleton--circle { border-radius: 50%; }

/* ========================== Tooltip ========================== */
.tooltip { position: relative; display: inline-flex; }
.tooltip__bubble { position: absolute; bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%); padding: var(--space-xs, .25rem) var(--space-sm, .5rem); font-size: var(--text-xs, .75rem); color: #fff; background: var(--neutral-900, #111827); border-radius: var(--radius-sm, 4px); white-space: nowrap; opacity: 0; pointer-events: none; transition: opacity var(--dur-fast, 150ms) var(--ease-out, ease); }
.tooltip:hover .tooltip__bubble, .tooltip:focus-within .tooltip__bubble { opacity: 1; }

/* ========================== Overline / eyebrow ========================== */
/* Uppercase letter-spaced label above titles. Add .text--accent for brand colour. */
.text--overline { font-size: var(--text-xs, .75rem); font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--color-text-muted); }
.text--accent { color: var(--color-accent-text, var(--color-text, #0f172a)); }
.text--unit { font-size: .7em; font-weight: 500; color: var(--color-text-muted); }

/* ========================== Blockquote (large light quote) ========================== */
/* --font-accent, not --font-display: a pull-quote IS the "rare editorial statement"
   the accent face exists for. --font-accent falls back to --font-display, so a site
   that sets no accent face is unchanged. */
.blockquote { font-family: var(--font-accent, var(--font-display)); font-size: var(--text-2xl, 1.5rem); font-weight: 400; font-style: italic; line-height: 1.4; color: var(--color-text); }

/* ========================== Extra atom variants ========================== */
.chip--outline { background: transparent; border-color: var(--color-chip-strong, var(--neutral-200, #e5e7eb)); }
.chip--outline.chip--selected { background: var(--color-primary); border-color: var(--color-primary); color: var(--color-primary-text, #0b0b0b); }
.avatar--square { border-radius: var(--radius-md, 8px); }

/* ========================== More atoms (backlog consolidation) ========================== */
.text--strong { font-weight: 600; }
.heading--h5 { font-size: var(--text-base, 1rem); font-weight: 700; line-height: 1.3; letter-spacing: var(--track-body); }
.text--overline-lg { font-size: var(--text-sm, .875rem); font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--color-text-muted); }
.kbd { display: inline-flex; align-items: center; justify-content: center; min-width: 1.6em; padding: 1px var(--space-xs, .25rem); font-family: var(--font-mono, monospace); font-size: var(--text-xs, .75rem); color: var(--color-text); background: var(--color-chip, var(--neutral-100, #f3f4f6)); border: 1px solid var(--color-chip-strong, var(--neutral-200, #e5e7eb)); border-bottom-width: 2px; border-radius: var(--radius-sm, 4px); }
/* Range / slider */
.range { -webkit-appearance: none; appearance: none; width: 100%; height: 6px; border-radius: var(--radius-full, 9999px); background: var(--neutral-200, #e5e7eb); outline: none; }
.range::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 18px; height: 18px; border: none; border-radius: 50%; background: var(--color-primary); cursor: pointer; }
.range::-moz-range-thumb { width: 18px; height: 18px; border: none; border-radius: 50%; background: var(--color-primary); cursor: pointer; }
/* Status glyph (check/cross in a tinted round holder) */
.status-dot { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: 50%; flex-shrink: 0; }
.status-dot > svg { width: 60%; height: 60%; }
.status-dot--success { background: #dcfce7; color: #166534; }
.status-dot--danger  { background: #fee2e2; color: #991b1b; }
.status-dot--muted   { background: var(--color-chip, var(--neutral-100, #f3f4f6)); color: var(--color-text-muted); }
/* Circular / radial progress (drive with --value: 0..100) */
.progress-ring { --value: 0; display: inline-grid; place-items: center; width: 64px; aspect-ratio: 1; border-radius: 50%; background: conic-gradient(var(--color-primary) calc(var(--value) * 1%), var(--neutral-200, #e5e7eb) 0); }
.progress-ring::before { content: ""; grid-area: 1 / 1; width: 76%; aspect-ratio: 1; border-radius: 50%; background: var(--color-surface, #fff); }
.progress-ring__label { grid-area: 1 / 1; font-weight: 700; font-size: var(--text-sm, .875rem); }

/* ========================== More variants (admin gaps) ========================== */
.badge--info { background: #dbeafe; color: #1e40af; }
.link--danger { color: #ef4444; }
.link--danger:hover { color: #dc2626; }
.text--warning { color: #f59e0b; }
.text--danger { color: #ef4444; }

/*
 * SiteForge Layout Foundation
 * Shared utilities injected into every generated page.
 * Components can use these classes alongside their own scoped styles.
 */

/* Gradient animations — only background-position and background-size to avoid affecting children */
@keyframes sf-gradient-shift {
    0% { background-position: 0% 50%; }
    25% { background-position: 100% 0%; }
    50% { background-position: 100% 100%; }
    75% { background-position: 0% 100%; }
    100% { background-position: 0% 50%; }
}
@keyframes sf-gradient-rotate {
    0% { background-position: 0% 0%; }
    25% { background-position: 100% 0%; }
    50% { background-position: 100% 100%; }
    75% { background-position: 0% 100%; }
    100% { background-position: 0% 0%; }
}
@keyframes sf-gradient-pulse {
    0%, 100% { background-size: 200% 200%; background-position: 0% 50%; }
    50% { background-size: 350% 350%; background-position: 100% 50%; }
}
@keyframes sf-gradient-diagonal {
    0% { background-position: 0% 0%; }
    25% { background-position: 100% 0%; }
    50% { background-position: 100% 100%; }
    75% { background-position: 0% 100%; }
    100% { background-position: 0% 0%; }
}
@keyframes sf-gradient-zoom {
    0%, 100% { background-size: 200% 200%; background-position: 0% 50%; }
    33% { background-size: 400% 400%; background-position: 100% 0%; }
    66% { background-size: 250% 250%; background-position: 50% 100%; }
}

/* Box Shadow utility classes — apply directly to elements that need them */
.sf-shadow-sm { box-shadow: 0 1px 2px rgba(0,0,0,0.05) !important; }
.sf-shadow-md { box-shadow: 0 4px 6px -1px rgba(0,0,0,0.07), 0 2px 4px -2px rgba(0,0,0,0.05) !important; }
.sf-shadow-lg { box-shadow: 0 10px 15px -3px rgba(0,0,0,0.08), 0 4px 6px -4px rgba(0,0,0,0.04) !important; }
.sf-shadow-xl { box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1), 0 8px 10px -6px rgba(0,0,0,0.04) !important; }
.sf-shadow-2xl { box-shadow: 0 25px 50px -12px rgba(0,0,0,0.2) !important; }
.sf-shadow-soft { box-shadow: 0 2px 15px rgba(0,0,0,0.04), 0 0 1px rgba(0,0,0,0.06) !important; }
.sf-shadow-glow { box-shadow: 0 0 25px rgba(37,99,235,0.2), 0 0 10px rgba(37,99,235,0.1) !important; }
.sf-shadow-inner { box-shadow: inset 0 2px 4px rgba(0,0,0,0.06) !important; }
.sf-shadow-border { box-shadow: 0 0 0 1px rgba(0,0,0,0.05), 0 1px 3px rgba(0,0,0,0.06) !important; }
.sf-shadow-float { box-shadow: 0 30px 60px -15px rgba(0,0,0,0.15) !important; }
.sf-shadow-sharp { box-shadow: 4px 4px 0 var(--neutral-900, #0f172a) !important; }

/* Container */
.sf-container {
    max-width: var(--site-max-width, 1400px);
    margin: 0 auto;
    padding: 0 var(--space-md);
}
.sf-container--wide { max-width: 1600px; }
.sf-container--full { max-width: 100%; }

/* Section padding */
.sf-section { padding: var(--space-3xl) 0; }
.sf-section--compact { padding: var(--space-xl) 0; }
.sf-section--spacious { padding: calc(var(--space-3xl) * 1.5) 0; }

/* Grid system (mobile-first) */
.sf-grid-2 { display: grid; grid-template-columns: 1fr; gap: var(--space-xl); }
.sf-grid-3 { display: grid; grid-template-columns: 1fr; gap: var(--space-lg); }
.sf-grid-4 { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-lg); }

@media (min-width: 768px) {
    .sf-grid-2 { grid-template-columns: repeat(2, 1fr); }
    .sf-grid-3 { grid-template-columns: repeat(3, 1fr); }
}
@media (min-width: 1024px) {
    .sf-grid-4 { grid-template-columns: repeat(4, 1fr); }
}

/* Split layouts (asymmetric) */
.sf-split { display: grid; grid-template-columns: 1fr; gap: var(--space-2xl); align-items: center; }
@media (min-width: 768px) {
    .sf-split { grid-template-columns: 1fr 1fr; }
    .sf-split--left-heavy { grid-template-columns: 3fr 2fr; }
    .sf-split--right-heavy { grid-template-columns: 2fr 3fr; }
}

/* Centered header */
.sf-header-center {
    text-align: center;
    max-width: 700px;
    margin: 0 auto var(--space-2xl);
}

/* Card base */
.sf-card {
    background: var(--surface-100, var(--color-surface));
    border: 1px solid var(--neutral-200, #e5e7eb);
    border-radius: var(--radius-lg);
    padding: var(--space-xl);
    transition: border-color 0.2s, box-shadow 0.2s;
}
.sf-card:hover {
    border-color: var(--neutral-300, #d1d5db);
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
}

/* Dark card variant */
.sf-card--dark {
    background: var(--neutral-800, #1e293b);
    border-color: var(--neutral-700, #334155);
}
.sf-card--dark:hover {
    border-color: var(--neutral-600, #475569);
}

/* Button base */
.sf-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-xs);
    padding: var(--space-sm) var(--space-lg);
    border-radius: var(--radius-md);
    font-family: var(--font-body);
    font-size: var(--text-sm);
    font-weight: 600;
    text-decoration: none;
    border: none;
    cursor: pointer;
    transition: background-color 0.2s, transform 0.1s;
}
.sf-btn:active { transform: scale(0.98); }
.sf-btn--primary { background: var(--primary-600); color: var(--neutral-100); }
.sf-btn--primary:hover { background: var(--primary-700); }
.sf-btn--secondary { background: var(--secondary-600); color: var(--neutral-100); }
.sf-btn--secondary:hover { background: var(--secondary-700); }
.sf-btn--outline { background: transparent; border: 2px solid var(--primary-600); color: var(--primary-600); }
.sf-btn--outline:hover { background: var(--primary-50); }
.sf-btn--ghost { background: transparent; color: var(--primary-600); }
.sf-btn--ghost:hover { background: var(--primary-50); }

/* Form inputs */
.sf-input {
    width: 100%;
    padding: var(--space-sm) var(--space-md);
    border: 1px solid var(--neutral-200, #e5e7eb);
    border-radius: var(--radius-md);
    font-family: var(--font-body);
    font-size: var(--text-sm);
    background: var(--neutral-50, #fff);
    color: var(--neutral-900);
    transition: border-color 0.2s;
}
.sf-input:focus { outline: none; border-color: var(--primary-600); }
.sf-input::placeholder { color: var(--neutral-400); }

/* Tags */
.sf-tag {
    display: inline-block;
    padding: 2px 10px;
    border-radius: var(--radius-full);
    font-size: var(--text-xs);
    font-weight: 600;
    background: var(--primary-100);
    color: var(--primary-700);
}

/* Image cover */
.sf-img-cover {
    width: 100%;
    height: 100%;
    object-fit: cover;
    border-radius: var(--radius-md);
}

/* ====================== Marquee / Infinite Scroll ====================== */
.sf-marquee {
    overflow: hidden;
    white-space: nowrap;
}
.sf-marquee__track {
    display: flex;
    gap: var(--space-lg, 2rem);
    width: max-content;
    animation: sf-marquee-scroll var(--marquee-duration, 20s) linear infinite;
}
.sf-marquee--reverse .sf-marquee__track {
    animation-direction: reverse;
}
@keyframes sf-marquee-scroll {
    0%   { transform: translateX(0); }
    100% { transform: translateX(-50%); }
}

/* ====================== Horizontal Scroll Container ====================== */
.sf-horizontal-track {
    display: flex;
    gap: var(--space-lg, 2rem);
    width: max-content;
}
@media (max-width: 767px) {
    .sf-horizontal-track {
        flex-direction: column;
        width: 100%;
    }
}

/* molecules.css — canonical MOLECULE layer. Composes atoms; adds layout + minimal glue
 * only, never restyles the atoms inside it. Implements the molecules in
 * ~/projects/design-system/catalog.json. Load after atoms.css. See ATOMIZATION.md.
 */

/* ========================== Field ========================== */
.field { display: flex; flex-direction: column; gap: var(--space-xs, .25rem); }
.field__label { font-size: var(--text-sm, .875rem); font-weight: 600; color: var(--color-text); }
.field__label .req { color: #dc2626; }
.field__help { font-size: var(--text-xs, .75rem); color: var(--color-text-muted); }
/* 6.25:1 on a light surface. #dc2626 measured 4.59 at 12px, which passes
   and looks faint, and an error people do not read is not an error. */
.field__error { font-size: var(--text-xs, .75rem); color: #b42318; }

/* ========================== Card ========================== */
.card { display: flex; flex-direction: column; background: var(--color-surface, #fff); border: 1px solid var(--color-chip-strong, var(--neutral-200, #e5e7eb)); border-radius: var(--radius-lg, 18px); box-shadow: var(--shadow-md, 0 1px 2px rgba(0,0,0,.04), 0 8px 24px -6px rgba(0,0,0,.10)); overflow: hidden; }
.card__media { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; }
.card__body { display: flex; flex-direction: column; gap: var(--space-sm, .5rem); padding: var(--space-lg, 1.5rem); }
.card__actions { display: flex; gap: var(--space-sm, .5rem); padding: 0 var(--space-lg, 1.5rem) var(--space-lg, 1.5rem); margin-top: auto; }
.card--feature { text-align: center; align-items: center; }
.card--horizontal { flex-direction: row; }
.card--horizontal .card__media { width: 40%; aspect-ratio: auto; }
.card--overlay { position: relative; color: #fff; }
.card--overlay .card__body { position: absolute; inset: auto 0 0 0; background: linear-gradient(transparent, rgba(0,0,0,.75)); }
/* Clickable card: whole card is an affordance (pair with an <a> wrap or onclick) */
.card--link { cursor: pointer; text-decoration: none; color: inherit; transition: border-color var(--dur-fast, 150ms) var(--ease-out, ease), transform var(--dur-fast, 150ms) var(--ease-out, ease); }
.card--link:hover { border-color: var(--color-primary); }
.card--link:active { transform: scale(.995); }

/* ========================== List item ========================== */
.list-item { display: flex; align-items: center; gap: var(--space-md, 1rem); min-height: var(--tap-min, 44px); padding: var(--space-sm, .5rem) var(--space-md, 1rem); }
.list-item__leading { flex-shrink: 0; }
.list-item__content { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.list-item__title { font-weight: 600; color: var(--color-text); }
.list-item__subtitle { font-size: var(--text-sm, .875rem); color: var(--color-text-muted); }
.list-item__trailing { flex-shrink: 0; margin-left: auto; color: var(--color-text-muted); }

/* ========================== Menu item ========================== */
/* Atoms and molecules that are legitimately applied to a <button> (the accessible
   element for a tab, a chip filter or a menu row) must neutralise the UA box, or
   the browser paints its own border and shrink-to-fit width through them. Measured
   usage: .tab on a button in 9 components, .chip in 12, .menu-item in 1.
   .segmented__item joined the list after tabs-pill measured #b2b6bd on #efefef, twice:
   #efefef is Chrome's `buttonface`, painted straight through the pill because the
   segmented control's items are buttons (which is the correct accessible element for a
   role="tab") and the reset did not name them. The rule that found it was a contrast
   rule, not a layout one: the UA fill is an absolute light grey under ground-relative
   ink, so it only shows up on the dark ground. */
button.menu-item, button.tab, button.chip, button.accordion-item__header, button.segmented__item {
  appearance: none; -webkit-appearance: none; background: none; border: 0;
  font: inherit; color: inherit;
}

.menu-item { display: flex; align-items: center; gap: var(--space-sm, .5rem); min-height: var(--tap-min, 44px); padding: var(--space-sm, .5rem) var(--space-md, 1rem); color: var(--color-text); cursor: pointer; border-radius: var(--radius-md, 8px); }
.menu-item:hover, .menu-item--active { background: var(--color-chip, var(--neutral-100, rgba(0,0,0,.05))); }
.menu-item--destructive { color: #dc2626; }
.menu-item__shortcut { margin-left: auto; font-size: var(--text-xs, .75rem); color: var(--color-text-muted); }

/* ========================== Tabs ========================== */
.tabs { display: flex; gap: var(--space-xs, .25rem); border-bottom: 1px solid var(--neutral-200, #e5e7eb); }
.tab { display: inline-flex; align-items: center; gap: var(--space-xs, .25rem); min-height: var(--tap-min, 44px); padding: var(--space-sm, .5rem) var(--space-md, 1rem); color: var(--color-text-muted); cursor: pointer; border-bottom: 2px solid transparent; }
.tab--active { color: var(--color-accent-text, var(--color-primary)); border-bottom-color: var(--color-primary); }

/* ========================== Segmented control ========================== */
.segmented { display: inline-flex; padding: 3px; background: var(--color-chip, var(--neutral-100, #f3f4f6)); border-radius: var(--radius-md, 8px); }
.segmented__item { min-height: 36px; padding: var(--space-xs, .25rem) var(--space-md, 1rem); border-radius: calc(var(--radius-md, 8px) - 2px); cursor: pointer; color: var(--color-text-muted); }
.segmented__item--active { background: var(--color-surface, #fff); color: var(--color-text); box-shadow: 0 1px 2px rgba(0,0,0,.1); }

/* ========================== Alert ========================== */
.alert { display: flex; gap: var(--space-md, 1rem); padding: var(--space-md, 1rem); border-radius: var(--radius-md, 8px); border: 1px solid; }
.alert__body { display: flex; flex-direction: column; gap: 2px; flex: 1; }
.alert__title { font-weight: 600; }
.alert--info    { background: #eff6ff; border-color: #bfdbfe; color: #1e40af; }
.alert--success { background: #f0fdf4; border-color: #bbf7d0; color: #166534; }
.alert--warning { background: #fffbeb; border-color: #fde68a; color: #92400e; }
.alert--danger  { background: #fef2f2; border-color: #fecaca; color: #991b1b; }

/* ========================== Toast ========================== */
.toast { display: flex; align-items: center; gap: var(--space-md, 1rem); padding: var(--space-sm, .5rem) var(--space-md, 1rem); background: var(--neutral-900, #111827); color: #fff; border-radius: var(--radius-md, 8px); box-shadow: 0 4px 12px rgba(0,0,0,.2); }

/* ========================== Stat ========================== */
.stat { display: flex; flex-direction: column; gap: var(--space-xs, .25rem); }
.stat__value { font-family: var(--font-display); font-size: var(--text-3xl, 2rem); font-weight: 800; color: var(--color-text); line-height: 1; }
.stat__label { font-size: var(--text-sm, .875rem); color: var(--color-text-muted); }
.stat__delta--up { color: #16a34a; } .stat__delta--down { color: #dc2626; }

/* ========================== Search input ========================== */
.search { display: flex; align-items: center; gap: var(--space-sm, .5rem); min-height: var(--tap-min, 44px); padding: 0 var(--space-md, 1rem); background: var(--color-surface, #fff); border: 1px solid var(--color-chip-strong, var(--neutral-200, #e5e7eb)); border-radius: var(--radius-full, 9999px); }
.search input { flex: 1; border: none; background: transparent; outline: none; font-size: var(--text-base, 1rem); color: var(--color-text); }

/* ========================== Accordion item ========================== */
.accordion-item { border-bottom: 1px solid var(--neutral-200, #e5e7eb); }
/* A <button> is the correct, accessible element for an accordion trigger, and 3
   components use one. Without a UA reset the browser paints its own
   `border: 2px outset` and shrink-to-fit width straight through, so the trigger
   renders as a native button box sized to its text. Neither gate catches this:
   audit.py sees a defined class, and the detector has no rule for "looks like an
   unstyled button". Reset here so the molecule is safe on the RIGHT element. */
.accordion-item__header { appearance: none; -webkit-appearance: none;
  background: none; border: 0; width: 100%; text-align: left; font: inherit;
  display: flex; align-items: center; justify-content: space-between; gap: var(--space-md, 1rem); min-height: var(--tap-min, 44px); padding: var(--space-md, 1rem) 0; cursor: pointer; font-weight: 600; color: var(--color-text); }
.accordion-item__chevron { flex-shrink: 0; color: var(--color-text-muted); transition: transform var(--dur-normal, 200ms) var(--ease-out, ease); }
.accordion-item--open .accordion-item__chevron { transform: rotate(180deg); }
/* No height transition here: animating max-height thrashes layout, and the library
   ships no JS so nothing toggles the open class anyway. A consumer that wires the
   accordion should animate grid-template-rows on a wrapper instead. */
.accordion-item__panel { max-height: 0; overflow: hidden; color: var(--color-text-muted); }
.accordion-item--open .accordion-item__panel { max-height: 400px; padding-bottom: var(--space-md, 1rem); }

/* ========================== Breadcrumb / pagination ========================== */
.breadcrumb { display: flex; align-items: center; gap: var(--space-xs, .25rem); font-size: var(--text-sm, .875rem); color: var(--color-text-muted); }
.breadcrumb__sep { opacity: .5; }
.pagination { display: flex; gap: var(--space-xs, .25rem); }
.pagination__item { display: inline-flex; align-items: center; justify-content: center; min-width: var(--tap-min, 44px); min-height: var(--tap-min, 44px); border-radius: var(--radius-md, 8px); color: var(--color-text); cursor: pointer; }
.pagination__item--active { background: var(--color-primary); color: var(--color-primary-text, #0b0b0b); }
.pagination__item--disabled { opacity: .4; pointer-events: none; }

/* ========================== Dropdown / popover ========================== */
.dropdown { position: relative; display: inline-flex; }
.dropdown__menu, .popover__panel { position: absolute; top: calc(100% + 4px); min-width: 180px; padding: var(--space-xs, .25rem); background: var(--color-surface, #fff); border: 1px solid var(--color-chip-strong, var(--neutral-200, #e5e7eb)); border-radius: var(--radius-md, 8px); box-shadow: 0 4px 12px rgba(0,0,0,.12); z-index: 20; }

/* ========================== Stepper ========================== */
.stepper { display: flex; align-items: center; gap: var(--space-sm, .5rem); }
.stepper-step { display: flex; align-items: center; gap: var(--space-sm, .5rem); }
.stepper-step__marker { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 50%; background: var(--color-chip-strong, var(--neutral-200, #e5e7eb)); color: var(--color-text-muted); font-size: var(--text-sm, .875rem); }
.stepper-step--current .stepper-step__marker { background: var(--color-primary); color: var(--color-primary-text, #0b0b0b); }
/* The done marker has to clear TWO bars at once, which is why the obvious swaps do not
   work. The numeral on it is body text and needs 4.5:1; the circle is a state mark on
   the page and needs 3:1, on a page that flips. #16a34a fails the first (white on it is
   3.30:1). #166534, the shipped success INK, fixes the numeral at 7.13:1 and fails the
   second, 2.50:1 against a #0f172a page. The .badge--success tint pair fails it from
   the other side: a #dcfce7 circle on #faf8f7 paper is 1.04:1, an invisible marker.
   So the fill is the two shipped success values met in the middle, #16a34a stepped 50%
   toward #166534, rather than a fourth green picked by eye. Measured:
     numeral #ffffff on #16843f            4.77:1
     circle on paper #faf8f7 / #f7f4f3     4.50:1 / 4.35:1
     circle on a dark page #0f172a/#111827 3.75:1 / 3.72:1
   Fixed here and not in stepper-numbered, because 7 other sections carry this same pair
   and report clean only because their markers hold a check glyph: U+2713 falls inside
   the emoji range isEmojiOnlyText tests, so those elements are skipped before contrast
   is ever measured. stepper-numbered is simply the one whose marker holds a numeral. */
.stepper-step--done .stepper-step__marker { background: #16843f; color: #fff; }
.stepper-step__connector { width: 32px; height: 2px; background: var(--neutral-200, #e5e7eb); }

/* ========================== Avatar group ========================== */
.avatar-group { display: inline-flex; }
.avatar-group > .avatar { margin-left: -8px; border: 2px solid var(--color-surface, #fff); }
.avatar-group > .avatar:first-child { margin-left: 0; }

/* ========================== Code block / embed ========================== */
.code-block { border-radius: var(--radius-md, 8px); overflow: hidden; background: var(--neutral-900, #111827); }
.code-block__header { display: flex; align-items: center; justify-content: space-between; padding: var(--space-xs, .25rem) var(--space-md, 1rem); color: #cbd5e1; font-size: var(--text-xs, .75rem); }
.code-block pre { padding: var(--space-md, 1rem); overflow-x: auto; color: #e2e8f0; font-family: var(--font-mono, ui-monospace, monospace); font-size: var(--text-sm, .875rem); }
.embed { position: relative; aspect-ratio: 16 / 9; border-radius: var(--radius-md, 8px); overflow: hidden; }
.embed iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: none; }

/* ========================== Icon tile (rounded-square glyph container) ========================== */
.icon-tile { display: inline-flex; align-items: center; justify-content: center; width: 48px; height: 48px; border-radius: var(--radius-md, 8px); background: color-mix(in srgb, var(--color-primary) 12%, transparent); color: var(--color-accent-text-tint, var(--color-accent-text, var(--color-text, #0f172a))); flex-shrink: 0; }
.icon-tile--sm { width: 36px; height: 36px; border-radius: var(--radius-sm, 4px); }
.icon-tile--lg { width: 64px; height: 64px; border-radius: var(--radius-lg, 16px); }
.icon-tile--round { border-radius: var(--radius-full, 9999px); }
.icon-tile > svg { width: 55%; height: 55%; }

/* ========================== Rating (stars) ========================== */
.rating { display: inline-flex; align-items: center; gap: 2px; color: #f59e0b; }
.rating__star { width: 1em; height: 1em; }
.rating__star--empty { color: var(--neutral-200, #e5e7eb); }
.rating__value { margin-left: var(--space-xs, .25rem); font-size: var(--text-sm, .875rem); color: var(--color-text-muted); }

/* ========================== Play overlay (media affordance) ========================== */
.play-overlay { position: relative; display: block; }
.play-overlay__btn { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); display: inline-flex; align-items: center; justify-content: center; width: 72px; height: 72px; border-radius: var(--radius-full, 9999px); background: var(--color-primary); color: var(--color-primary-text, #0b0b0b); border: none; cursor: pointer; box-shadow: 0 4px 20px rgba(0,0,0,.3); }
.play-overlay__btn > svg { width: 40%; height: 40%; }

/* ========================== Toast severity variants ========================== */
.toast--success { background: #166534; }
.toast--info    { background: #1e40af; }
.toast--warning { background: #92400e; }
.toast--danger  { background: #991b1b; }
.toast--light   { background: var(--color-surface, #fff); color: var(--color-text); border: 1px solid var(--color-chip-strong, var(--neutral-200, #e5e7eb)); }

/* ============ Native <details> accordion (JS-free) ============ */
details.accordion-item > summary { list-style: none; cursor: pointer; }
details.accordion-item > summary::-webkit-details-marker { display: none; }
details.accordion-item[open] .accordion-item__chevron { transform: rotate(180deg); }
details.accordion-item:not([open]) .accordion-item__panel { max-height: 0; padding-bottom: 0; }
details.accordion-item[open] .accordion-item__panel { max-height: 400px; padding-bottom: var(--space-md, 1rem); }

/* ========================== Check list (feature ticks) ========================== */
.check-list { display: flex; flex-direction: column; gap: var(--space-sm, .5rem); }
.check-item { display: flex; align-items: flex-start; gap: var(--space-sm, .5rem); }
.check-item__mark { flex-shrink: 0; color: var(--color-accent-text, var(--color-primary)); margin-top: 2px; }
.check-item--muted .check-item__mark { color: var(--color-text-muted); }
.check-item--no .check-item__mark { color: #dc2626; }

/* ========================== Input affix (prefix / suffix adornment) ========================== */
.input-affix { display: inline-flex; align-items: center; width: 100%; background: var(--color-surface, #fff); border: 1px solid var(--color-chip-strong, var(--neutral-200, #e5e7eb)); border-radius: var(--radius-md, 8px); }
.input-affix:focus-within { border-color: var(--color-primary); }
.input-affix .input { border: none; background: transparent; flex: 1; min-width: 0; }
.input-affix .input:focus { box-shadow: none; }
.input-affix__prefix, .input-affix__suffix { padding: 0 var(--space-sm, .5rem); color: var(--color-text-muted); white-space: nowrap; }

/* ========================== Labeled divider (line — label — line) ========================== */
.divider--labeled { display: flex; align-items: center; gap: var(--space-md, 1rem); border: none; color: var(--color-text-muted); font-size: var(--text-sm, .875rem); }
.divider--labeled::before, .divider--labeled::after { content: ""; flex: 1; height: 1px; background: var(--color-chip-strong, var(--neutral-200, #e5e7eb)); }

/* ========================== Search flush variant (top-bar, no pill) ========================== */
.search--flush { border: none; border-bottom: 1px solid var(--color-chip-strong, var(--neutral-200, #e5e7eb)); border-radius: 0; background: transparent; }

/* ========================== Data table (admin grid) ========================== */
.data-table { width: 100%; background: var(--color-surface, #fff); border: 1px solid var(--color-chip-strong, var(--neutral-200, #e5e7eb)); border-radius: var(--radius-lg, 12px); overflow: hidden; }
.data-table__scroll { overflow-x: auto; }
.table { width: 100%; border-collapse: collapse; font-size: var(--text-sm, .875rem); }
.table thead th { text-align: left; padding: var(--space-sm, .5rem) var(--space-lg, 1.5rem); font-size: var(--text-xs, .75rem); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--color-text-muted); background: var(--color-chip, var(--neutral-100, #f9fafb)); border-bottom: 1px solid var(--color-chip-strong, var(--neutral-200, #e5e7eb)); white-space: nowrap; }
.table thead th a { color: inherit; }
.table td { padding: var(--space-sm, .5rem) var(--space-lg, 1.5rem); border-bottom: 1px solid var(--color-chip-strong, var(--neutral-200, #e5e7eb)); color: var(--color-text); vertical-align: middle; }
.table tbody tr { transition: background var(--dur-fast, 150ms) var(--ease-out, ease); }
.table tbody tr:hover { background: var(--color-chip, var(--neutral-100, #f9fafb)); }
.table tbody tr:last-child td { border-bottom: none; }
.table--clickable tbody tr { cursor: pointer; }
.data-table__foot { display: flex; align-items: center; justify-content: space-between; padding: var(--space-sm, .5rem) var(--space-lg, 1.5rem); border-top: 1px solid var(--color-chip-strong, var(--neutral-200, #e5e7eb)); font-size: var(--text-sm, .875rem); color: var(--color-text-muted); }

/* ========================== Modal / dialog ========================== */
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; padding: var(--space-lg, 1.5rem); z-index: 50; }
.modal { width: 100%; max-width: 440px; background: var(--color-surface, #fff); border: 1px solid var(--color-chip-strong, var(--neutral-200, #e5e7eb)); border-radius: var(--radius-lg, 12px); box-shadow: var(--shadow-lg, 0 12px 32px rgba(0,0,0,.16)); overflow: hidden; }
.modal__body { padding: var(--space-lg, 1.5rem); }
.modal__actions { display: flex; justify-content: flex-end; gap: var(--space-sm, .5rem); padding: var(--space-md, 1rem) var(--space-lg, 1.5rem); border-top: 1px solid var(--neutral-200, #e5e7eb); }

/* ========================== Application chrome ==========================
   Added for the admin surface. Every molecule above assumes a marketing page
   that scrolls; an admin app has fixed chrome and one scrolling pane. Each of
   the five below is here because a template in galvan-admin-template reached
   for it and found nothing, which is the same rule the section library uses.
   Built and checked at 390px first: the shell is the one place a desktop-only
   assumption is invisible until somebody opens it on a phone. */

/* base.html: topbar + sidebar + one scrolling main. */
.shell { display: grid; grid-template-rows: auto 1fr; min-height: 100dvh; background: var(--color-background, #fff); }
.shell__topbar { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md, 1rem); padding: var(--space-sm, .75rem) var(--space-md, 1rem); background: var(--neutral-900, #1c1c1c); color: #fff; border-bottom: 1px solid var(--color-chip-strong, var(--neutral-200, #e5e7eb)); }
.shell__body { display: grid; grid-template-columns: 1fr; min-height: 0; }
.shell__sidebar { display: none; border-right: 1px solid var(--color-chip-strong, var(--neutral-200, #e5e7eb)); background: var(--color-surface, #faf9f8); padding: var(--space-md, 1rem) var(--space-sm, .75rem); }
.shell__main { min-width: 0; overflow-y: auto; padding: var(--space-md, 1rem); background: var(--color-surface, #faf9f8); }
/* The sidebar is a drawer on a phone and a column from 900px. Below that the
   topbar carries navigation, so nothing is unreachable without it. */
@media (min-width: 900px) {
  .shell__body { grid-template-columns: 15rem 1fr; }
  .shell__sidebar { display: block; }
  .shell__main { padding: var(--space-lg, 1.5rem); }
}

/* dashboard.html, reports.html, data_table.html: a title and its actions. */
.page-head { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: var(--space-md, 1rem); margin-bottom: var(--space-lg, 1.5rem); }
.page-head__text { min-width: 0; }
.page-head__title { margin: 0; font-size: var(--text-2xl, 1.5rem); font-weight: 700; letter-spacing: var(--track-title, -.022em); color: var(--color-text); }
.page-head__sub { margin: var(--space-xs, .25rem) 0 0; font-size: var(--text-sm, .875rem); color: var(--color-text-muted); }
.page-head__actions { display: flex; flex-wrap: wrap; gap: var(--space-sm, .5rem); }

/* filter_bar.html: a wrapping row of fields, actions pushed to the end. */
.toolbar { display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--space-md, 1rem); padding: var(--space-md, 1rem); background: var(--color-surface, #fff); border: 1px solid var(--color-chip-strong, var(--neutral-200, #e5e7eb)); border-radius: var(--radius-lg, 12px); }
.toolbar__group { display: flex; flex-direction: column; gap: var(--space-xs, .25rem); flex: 1 1 12rem; min-width: 0; }
.toolbar__group--auto { flex: 0 0 auto; }
.toolbar__spacer { flex: 1 1 auto; }
.toolbar__actions { display: flex; flex-wrap: wrap; gap: var(--space-sm, .5rem); }

/* data_table.html: the query returned nothing, and a blank pane reads broken. */
.empty-state { display: flex; flex-direction: column; align-items: center; text-align: center; gap: var(--space-sm, .5rem); padding: var(--space-2xl, 3rem) var(--space-md, 1rem); color: var(--color-text-muted); }
.empty-state__icon { width: 2.5rem; height: 2.5rem; opacity: .5; }
.empty-state__title { margin: 0; font-size: var(--text-lg, 1.1rem); font-weight: 600; color: var(--color-text); }
.empty-state__text { margin: 0; max-width: 34em; font-size: var(--text-sm, .875rem); }
.empty-state__actions { display: flex; gap: var(--space-sm, .5rem); margin-top: var(--space-sm, .5rem); }

/* detail.html: one record's fields. Two columns where there is room, stacked
   where there is not, because a term and its value must never split rows. */
.desc-list { display: grid; grid-template-columns: 1fr; gap: 0; margin: 0; }
.desc-list__row { display: grid; grid-template-columns: 1fr; gap: var(--space-xs, .25rem); padding: var(--space-sm, .75rem) 0; border-bottom: 1px solid var(--color-chip-strong, var(--neutral-200, #e5e7eb)); }
.desc-list__row:last-child { border-bottom: none; }
.desc-list__term { margin: 0; font-size: var(--text-xs, .75rem); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--color-text-muted); }
.desc-list__value { margin: 0; font-size: var(--text-sm, .875rem); color: var(--color-text); overflow-wrap: anywhere; }
@media (min-width: 640px) {
  .desc-list__row { grid-template-columns: 14rem 1fr; gap: var(--space-md, 1rem); align-items: baseline; }
}

/* ==================== Admin CRUD ====================
   The screens of a CRUD admin, in the order you meet them: a shell with side
   navigation, a list you filter and sort and act on in bulk, a record you
   read, a form you edit it in, and the pages that say something went wrong.

   Bound to Baseplate's auto-generated contract, not an invented one:
     GET  /api/{t}   ?filter[f]= &search= &sort=[-]f &limit= &offset=
                     -> {data: [...], meta: {total, limit, offset}}
     GET/POST/PUT/DELETE /api/{t}[/{id}], POST|PATCH|DELETE /api/{t}/bulk,
     PATCH /api/{t}/{id}/archive|restore
   bulk-bar exists because the bulk endpoints do; archive/restore is why
   .badge--muted reads "Archived" rather than a delete being the only exit.

   Mobile first throughout. A table is the hardest thing to get right on a
   phone and the easiest to ship broken, so .data-table__scroll is the default
   and .table--stack is the opt-in that turns rows into cards. */

/* ---- side navigation (shell__sidebar's contents) ---- */
.sidenav { display: flex; flex-direction: column; gap: var(--space-xs, .15rem); }
.sidenav__label { padding: var(--space-sm, .75rem) var(--space-md, 1rem) var(--space-xs, .25rem); font-family: var(--font-mono, monospace); font-size: var(--text-xs, .72rem); text-transform: uppercase; letter-spacing: .12em; color: var(--color-text-muted); }
.sidenav__item { display: flex; align-items: center; gap: var(--space-sm, .6rem); padding: var(--space-sm, .55rem) var(--space-md, 1rem); font-size: var(--text-sm, .875rem); color: var(--color-text-muted); text-decoration: none; border-left: 3px solid transparent; border-radius: 0 var(--radius-sm, 6px) var(--radius-sm, 6px) 0; transition: background var(--dur-fast, 150ms) var(--ease-out, ease), color var(--dur-fast, 150ms) var(--ease-out, ease); }
.sidenav__item:hover { background: var(--color-chip, rgba(0,0,0,.04)); color: var(--color-text); }
.sidenav__item--active { color: var(--color-text); font-weight: 600; border-left-color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 10%, transparent); }
.sidenav__count { margin-left: auto; font-size: var(--text-xs, .72rem); color: var(--color-text-muted); }

/* ---- sign-in ---- */
.auth { display: grid; place-items: center; min-height: 100dvh; padding: var(--space-lg, 1.5rem); background: var(--neutral-900); }
.auth__panel { width: 100%; max-width: 24rem; background: var(--color-background, #fff); border-radius: var(--radius-lg, 12px); padding: var(--space-xl, 2rem); box-shadow: var(--shadow-lg, 0 12px 32px rgba(0,0,0,.16)); }
.auth__logo { height: 1.5rem; display: block; margin: 0 auto var(--space-md, 1rem); }
.auth__title { margin: 0; font-size: var(--text-xl, 1.25rem); font-weight: 700; text-align: center; letter-spacing: var(--track-title, -.022em); }
.auth__sub { display: block; margin: var(--space-xs, .35rem) 0 0; text-align: center; color: var(--color-text-muted); }
.auth__form { display: flex; flex-direction: column; gap: var(--space-md, 1rem); margin-top: var(--space-lg, 1.5rem); }
.auth__foot { display: block; margin: var(--space-lg, 1.5rem) 0 0; text-align: center; color: var(--color-text-muted); }

/* ---- list view: sorting, row actions, density, bulk ---- */
.table__sort { display: inline-flex; align-items: center; gap: .3rem; color: inherit; text-decoration: none; white-space: nowrap; }
.table__sort::after { content: "\2195"; opacity: .35; font-size: .9em; }
.table__sort--asc::after  { content: "\2191"; opacity: 1; color: var(--color-accent-text, currentColor); }
.table__sort--desc::after { content: "\2193"; opacity: 1; color: var(--color-accent-text, currentColor); }
.table__actions { display: flex; flex-wrap: nowrap; gap: var(--space-xs, .25rem); justify-content: flex-end; white-space: nowrap; }
.table--dense th, .table--dense td { padding: var(--space-xs, .35rem) var(--space-md, 1rem); }
.col--num { text-align: right; font-variant-numeric: tabular-nums; }
.col--shrink { width: 1%; white-space: nowrap; }
/* A table cannot be read at 390px by scrolling sideways through twelve columns.
   .table--stack turns each row into a labelled card; the header is hidden and
   each cell announces itself from data-label. */
@media (max-width: 719px) {
  .table--stack thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
  .table--stack tbody tr { display: block; padding: var(--space-sm, .75rem) 0; border-bottom: 1px solid var(--color-chip-strong, var(--neutral-200, #e5e7eb)); }
  .table--stack td { display: flex; gap: var(--space-md, 1rem); justify-content: space-between; align-items: baseline; padding: var(--space-xs, .3rem) var(--space-md, 1rem); border: none; text-align: left; }
  .table--stack td::before { content: attr(data-label); flex: none; font-size: var(--text-xs, .72rem); font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--color-text-muted); }
  .table--stack .table__actions { justify-content: flex-start; }
}

/* POST|PATCH|DELETE /api/{t}/bulk. Appears only with a selection, so it is
   sticky to the bottom rather than reserving space it does not always need. */
.bulk-bar { position: sticky; bottom: 0; z-index: 20; display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-md, 1rem); padding: var(--space-sm, .75rem) var(--space-md, 1rem); background: var(--neutral-900); color: var(--color-text-on-dark, #fff); border-radius: var(--radius-md, 10px); box-shadow: var(--shadow-lg, 0 12px 32px rgba(0,0,0,.16)); }
.bulk-bar__count { font-weight: 600; font-size: var(--text-sm, .875rem); }
.bulk-bar__actions { display: flex; flex-wrap: wrap; gap: var(--space-sm, .5rem); margin-left: auto; }
/* The bar is a dark ground dropped into a light page, so the buttons in it
   inherit a light-ground palette: .btn--secondary measured 3.09:1 (olive
   #577200 on #1c1c1c) where 4.5 is the bar. Restate them for this ground. */
.bulk-bar .btn--secondary { color: var(--color-text-on-dark, #fff); border-color: color-mix(in srgb, var(--color-text-on-dark, #fff) 45%, transparent); background: transparent; }
.bulk-bar .btn--secondary:hover { background: color-mix(in srgb, var(--color-text-on-dark, #fff) 12%, transparent); }
.bulk-bar .btn--ghost { color: var(--color-text-on-dark, #fff); }

/* ---- forms ---- */
.form-grid { display: grid; grid-template-columns: 1fr; gap: var(--space-md, 1rem); }
@media (min-width: 720px) { .form-grid--2 { grid-template-columns: 1fr 1fr; } .form-grid__full { grid-column: 1 / -1; } }
.form-section { border: none; padding: 0; margin: 0 0 var(--space-xl, 2rem); }
.form-section__legend { padding: 0; margin: 0 0 var(--space-xs, .25rem); font-family: var(--font-display, system-ui, sans-serif); font-size: var(--text-lg, 1.05rem); font-weight: 700; letter-spacing: var(--track-title, -.022em); color: var(--color-text); }
.form-section__hint { margin: 0 0 var(--space-md, 1rem); font-size: var(--text-sm, .875rem); color: var(--color-text-muted); }
.field--inline { display: grid; grid-template-columns: 1fr; gap: var(--space-xs, .25rem); }
@media (min-width: 720px) { .field--inline { grid-template-columns: 12rem 1fr; align-items: baseline; gap: var(--space-md, 1rem); } }
.radio-group { display: flex; flex-direction: column; gap: var(--space-sm, .5rem); }
.radio-group--row { flex-direction: row; flex-wrap: wrap; gap: var(--space-md, 1rem); }
.radio-option { display: flex; align-items: center; gap: var(--space-sm, .5rem); font-size: var(--text-sm, .875rem); cursor: pointer; }
/* Sticky so Save is reachable without scrolling to the end of a long form,
   which is the single most common complaint about generated admin forms. */
.form-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-sm, .5rem); padding: var(--space-md, 1rem) 0; }
.form-actions--sticky { position: sticky; bottom: 0; background: color-mix(in srgb, var(--color-surface) 92%, transparent); backdrop-filter: blur(6px); border-top: 1px solid var(--color-chip-strong, var(--neutral-200, #e5e7eb)); }
.form-actions__spacer { flex: 1 1 auto; }
.file-drop { display: flex; flex-direction: column; align-items: center; gap: var(--space-xs, .25rem); padding: var(--space-xl, 2rem) var(--space-md, 1rem); text-align: center; border: 1.5px dashed var(--color-chip-strong, var(--neutral-200, #e5e7eb)); border-radius: var(--radius-md, 10px); background: var(--color-surface, #faf9f8); color: var(--color-text-muted); cursor: pointer; transition: border-color var(--dur-fast, 150ms) var(--ease-out, ease), background var(--dur-fast, 150ms) var(--ease-out, ease); }
.file-drop:hover, .file-drop--dragging { border-color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 6%, transparent); }
.file-drop__hint { font-size: var(--text-xs, .75rem); }

/* ---- detail view ---- */
.meta-bar { display: flex; flex-wrap: wrap; gap: var(--space-md, 1rem); font-family: var(--font-mono, monospace); font-size: var(--text-xs, .72rem); color: var(--color-text-muted); }
/* An audit trail is the reason an admin exists rather than a spreadsheet. */
.feed { display: flex; flex-direction: column; }
.feed__item { position: relative; display: grid; grid-template-columns: auto 1fr; gap: var(--space-md, 1rem); padding: 0 0 var(--space-lg, 1.5rem); }
.feed__marker { position: relative; width: .625rem; height: .625rem; margin-top: .35rem; border-radius: var(--radius-full, 999px); background: var(--color-primary); }
/* The rail is drawn on the marker, not on the row, so it stops at the last
   item instead of trailing into the space below it. */
.feed__item:not(:last-child) .feed__marker::after { content: ""; position: absolute; left: 50%; top: 1rem; bottom: calc(-1 * var(--space-lg, 1.5rem) - .35rem); width: 1.5px; transform: translateX(-50%); background: var(--color-chip-strong, var(--neutral-200, #e5e7eb)); }
.feed__body { min-width: 0; }
.feed__title { margin: 0; font-size: var(--text-sm, .875rem); font-weight: 600; color: var(--color-text); }
.feed__meta { margin: var(--space-xs, .15rem) 0 0; font-size: var(--text-xs, .75rem); color: var(--color-text-muted); }

/* ---- dashboard ---- */
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); gap: var(--space-md, 1rem); }
.stat__delta--down { color: #b42318; }

/* ---- error pages ---- */
.error-page { display: grid; place-content: center; justify-items: center; text-align: center; gap: var(--space-sm, .5rem); min-height: 60vh; padding: var(--space-xl, 2rem) var(--space-md, 1rem); }
.error-page__code { font-family: var(--font-mono, monospace); font-size: var(--text-sm, .875rem); letter-spacing: .16em; color: var(--color-accent-text, currentColor); }
.error-page__title { margin: 0; font-family: var(--font-display, system-ui, sans-serif); font-size: var(--text-3xl, 2rem); font-weight: 800; letter-spacing: var(--track-display, -.035em); }
.error-page__text { margin: 0; max-width: 34em; color: var(--color-text-muted); }

/* ==================== Conversation, and portal chrome ====================
   Found by surveying prod-client-portal (Galvan OS) rather than guessing.
   The library had NO chat component of any kind: the only conversation markup
   in 879 sections is gg-model-exchange's ggx__log, which is a marketing
   demonstration of a transcript, not something you can put a live agent in.
   Every chat surface built so far (the portal advisor, chuchubi, the private
   AI work) therefore rebuilt one from scratch. */

.chat { display: flex; flex-direction: column; min-height: 0; background: var(--color-background, #fff); border: 1px solid var(--color-chip-strong, var(--neutral-200, #e5e7eb)); border-radius: var(--radius-lg, 12px); overflow: hidden; }
.chat__head { display: flex; align-items: center; gap: var(--space-sm, .6rem); padding: var(--space-sm, .75rem) var(--space-md, 1rem); border-bottom: 1px solid var(--color-chip-strong, var(--neutral-200, #e5e7eb)); font-family: var(--font-mono, monospace); font-size: var(--text-xs, .72rem); text-transform: uppercase; letter-spacing: .1em; color: var(--color-text-muted); }
.chat__log { display: flex; flex-direction: column; gap: var(--space-md, 1rem); padding: var(--space-md, 1rem); overflow-y: auto; overscroll-behavior: contain; }
.chat__msg { display: flex; gap: var(--space-sm, .6rem); max-width: 100%; }
.chat__msg--user { flex-direction: row-reverse; }
.chat__bubble { max-width: min(42em, 88%); padding: var(--space-sm, .6rem) var(--space-md, .9rem); border-radius: var(--radius-lg, 14px); font-size: var(--text-sm, .9rem); line-height: 1.6; overflow-wrap: anywhere; background: var(--color-surface, #faf9f8); color: var(--color-text); }
/* The brand colour lands on the PERSON's message, not the machine's: the one
   saturated thing on the screen should be the thing you said. */
.chat__msg--user .chat__bubble { background: var(--color-primary); color: var(--color-primary-text, #111); }
.chat__meta { margin-top: var(--space-xs, .25rem); font-size: var(--text-xs, .7rem); color: var(--color-text-muted); }
.chat__msg--user .chat__meta { text-align: right; }
/* Three dots that do not depend on a library. Reduced-motion gets a static
   ellipsis rather than nothing, so the state is still legible. */
.chat__typing { display: inline-flex; gap: .25rem; align-items: center; padding: var(--space-sm, .6rem) var(--space-md, .9rem); }
.chat__typing span { width: .35rem; height: .35rem; border-radius: var(--radius-full, 999px); background: var(--color-text-muted); animation: chat-blink 1.2s infinite ease-in-out; }
.chat__typing span:nth-child(2) { animation-delay: .18s; }
.chat__typing span:nth-child(3) { animation-delay: .36s; }
@keyframes chat-blink { 0%, 80%, 100% { opacity: .25; } 40% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .chat__typing span { animation: none; opacity: .55; } }
.chat__composer { display: flex; gap: var(--space-sm, .5rem); align-items: flex-end; padding: var(--space-sm, .75rem); border-top: 1px solid var(--color-chip-strong, var(--neutral-200, #e5e7eb)); background: var(--color-background, #fff); }
.chat__composer .textarea { flex: 1 1 auto; min-height: 2.6rem; max-height: 9rem; resize: vertical; }
/* On a phone the composer must stay above the keyboard and the log must own
   the remaining height, or the input scrolls off and the thing is unusable. */
.chat--full { height: 100dvh; border: none; border-radius: 0; }
.chat--full .chat__log { flex: 1 1 auto; }

/* A settings list: label, explanation, control. From the portal's settings-row. */
.settings-row { display: grid; grid-template-columns: 1fr auto; gap: var(--space-md, 1rem); align-items: center; padding: var(--space-md, 1rem) 0; border-bottom: 1px solid var(--color-chip-strong, var(--neutral-200, #e5e7eb)); }
.settings-row:last-child { border-bottom: none; }
.settings-row__label { margin: 0; font-size: var(--text-sm, .9rem); font-weight: 600; color: var(--color-text); }
.settings-row__help { margin: var(--space-xs, .2rem) 0 0; font-size: var(--text-xs, .78rem); color: var(--color-text-muted); }
.settings-row__control { display: flex; align-items: center; gap: var(--space-sm, .5rem); }

/* The filters currently applied, each removable. .chip existed; the bar that
   holds them and says what is being narrowed did not. */
.filter-chips { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-sm, .5rem); }
.filter-chips__label { font-family: var(--font-mono, monospace); font-size: var(--text-xs, .72rem); text-transform: uppercase; letter-spacing: .1em; color: var(--color-text-muted); }

/* "You are viewing as someone else." A portal that can impersonate a client
   needs this to be impossible to miss, and it is a security affordance rather
   than decoration: the whole risk is forgetting whose data is on screen. */
.impersonation-banner { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-md, 1rem); padding: var(--space-sm, .6rem) var(--space-md, 1rem); background: var(--color-primary); color: var(--color-primary-text, #111); font-size: var(--text-sm, .875rem); font-weight: 600; }
.impersonation-banner__actions { margin-left: auto; }
/* Lime is a bright ground: the inherited olive measured 4.58:1 on it.
   Ink is 14.16:1 and is what the banner already uses for its own text. */
.impersonation-banner .btn { color: var(--color-primary-text, #111); border-color: color-mix(in srgb, var(--color-primary-text, #111) 40%, transparent); background: transparent; }
.impersonation-banner .btn:hover { background: color-mix(in srgb, var(--color-primary-text, #111) 12%, transparent); }

/* App-shell navigation for the phone, where a sidebar drawer is one tap too
   many for the three things people actually use. */
.bottom-nav { position: sticky; bottom: 0; z-index: 30; display: flex; background: var(--color-background, #fff); border-top: 1px solid var(--color-chip-strong, var(--neutral-200, #e5e7eb)); padding-bottom: env(safe-area-inset-bottom, 0); }
.bottom-nav__item { flex: 1 1 0; display: flex; flex-direction: column; align-items: center; gap: .15rem; padding: var(--space-sm, .5rem) var(--space-xs, .25rem); font-size: var(--text-xs, .7rem); color: var(--color-text-muted); text-decoration: none; border-top: 2px solid transparent; min-height: 3rem; }
.bottom-nav__item--active { color: var(--color-text); border-top-color: var(--color-primary); font-weight: 600; }
@media (min-width: 900px) { .bottom-nav { display: none; } }

/* ==================== Scanner ====================
   A camera viewfinder with a reticle. The behaviour is the host app's (the
   component contract forbids JavaScript here); this is the frame it draws in.

   Worth knowing before you design around it: BarcodeDetector is Chrome and
   Android only. Firefox does not implement it and Safari has it disabled by
   default, so on an iPhone the camera path does not exist and the manual entry
   below is not a nicety, it is the only route. .scanner__fallback is therefore
   a first-class part of the component rather than an error state. */
.scanner { position: relative; display: block; width: 100%; aspect-ratio: 3 / 4; max-height: 70dvh; overflow: hidden; border-radius: var(--radius-lg, 12px); background: var(--neutral-900); }
@media (min-width: 720px) { .scanner { aspect-ratio: 16 / 10; } }
.scanner__video { width: 100%; height: 100%; object-fit: cover; display: block; }
/* The reticle is a ring of shadow rather than four bars: one element, and the
   dimmed area is always exactly the inverse of the target. */
.scanner__reticle { position: absolute; inset: 50% auto auto 50%; width: min(72%, 20rem); aspect-ratio: 3 / 2; transform: translate(-50%, -50%); border: 2px solid var(--color-primary); border-radius: var(--radius-md, 10px); box-shadow: 0 0 0 100vmax color-mix(in srgb, #000 55%, transparent); pointer-events: none; }
.scanner__hint { position: absolute; left: 0; right: 0; bottom: 0; padding: var(--space-md, 1rem); text-align: center; font-size: var(--text-sm, .875rem); color: var(--color-text-on-dark, #fff); background: linear-gradient(to top, color-mix(in srgb, #000 70%, transparent), transparent); }
.scanner__status { position: absolute; top: var(--space-sm, .75rem); left: 50%; transform: translateX(-50%); padding: .2rem .6rem; border-radius: var(--radius-full, 999px); font-family: var(--font-mono, monospace); font-size: var(--text-xs, .7rem); text-transform: uppercase; letter-spacing: .08em; background: color-mix(in srgb, #000 55%, transparent); color: var(--color-text-on-dark, #fff); }
.scanner__status--live { color: var(--color-primary); }
/* Not an error state. On iOS this IS the scanner. */
.scanner__fallback { display: flex; flex-direction: column; gap: var(--space-sm, .5rem); padding: var(--space-md, 1rem); }
.scanner-result { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-md, 1rem); padding: var(--space-md, 1rem); border: 1px solid var(--color-chip-strong, var(--neutral-200, #e5e7eb)); border-radius: var(--radius-md, 10px); background: var(--color-surface, #faf9f8); }
.scanner-result__code { font-family: var(--font-mono, monospace); font-size: var(--text-lg, 1.05rem); font-weight: 600; overflow-wrap: anywhere; }
.scanner-result__actions { margin-left: auto; display: flex; gap: var(--space-sm, .5rem); }

/* ==================== Editorial index and lead figure ====================
   The antidote to a screen of identical boxes. Ported from gg-index-numbered,
   which exists on the marketing side for the same reason: twelve of the
   library's list sections were card grids, so every list read as the same
   list. An admin has the identical problem one level down, where every screen
   is a grid of bordered rectangles.

   The receipt here: the admin dashboard rendered one card per collection, 13
   identical boxes. Eleven of them held a zero. The screen's actual content is
   WHICH collections are live, and a uniform grid hides precisely that. */

/* One big number, set at display scale, that the rest of the screen hangs off.
   A screen with no primary figure has no hierarchy, and everything competing
   equally is what "assembled" looks like. */
.figure-lead { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-md, 1rem); padding: var(--space-lg, 1.5rem) 0 var(--space-xl, 2rem); border-bottom: 1px solid var(--color-rule, var(--color-chip-strong, #e6e2e0)); }
.figure-lead__value { font-family: var(--font-display, system-ui, sans-serif); font-size: clamp(2.6rem, 9vw, 4.2rem); font-weight: 800; line-height: .95; letter-spacing: var(--track-display, -.04em); font-variant-numeric: tabular-nums; color: var(--color-text); }
.figure-lead__label { font-family: var(--font-mono, monospace); font-size: var(--text-xs, .72rem); text-transform: uppercase; letter-spacing: .14em; color: var(--color-text-muted); }
.figure-lead__aside { margin-left: auto; text-align: right; font-size: var(--text-sm, .875rem); color: var(--color-text-muted); }

/* Hairline rows, no boxes. The numeral is outlined rather than filled so the
   row reads as an index entry and not as a badge. */
.index-rows { border-top: 1px solid var(--color-rule, var(--color-chip-strong, #e6e2e0)); }
.index-rows__row { display: grid; grid-template-columns: auto 1fr auto; align-items: baseline; gap: var(--space-md, 1rem); padding: var(--space-md, .9rem) 0; border-bottom: 1px solid var(--color-rule, var(--color-chip-strong, #e6e2e0)); text-decoration: none; color: inherit; transition: background var(--dur-fast, 150ms) var(--ease-out, ease); }
a.index-rows__row:hover { background: color-mix(in srgb, var(--color-primary) 7%, transparent); }
.index-rows__num { font-family: var(--font-display, system-ui, sans-serif); font-size: var(--text-lg, 1.05rem); font-weight: 800; font-variant-numeric: tabular-nums; letter-spacing: -.02em; color: transparent; -webkit-text-stroke: 1px var(--color-text-muted); min-width: 2.2em; text-align: right; }
a.index-rows__row:hover .index-rows__num { -webkit-text-stroke-color: var(--color-accent-text, var(--color-text)); }
.index-rows__label { font-size: var(--text-base, .95rem); font-weight: 600; color: var(--color-text); min-width: 0; overflow-wrap: anywhere; }
.index-rows__meta { font-family: var(--font-mono, monospace); font-size: var(--text-xs, .72rem); text-transform: uppercase; letter-spacing: .1em; color: var(--color-text-muted); }
/* The value, right-aligned and tabular so a column of numbers is a column. */
.index-rows__value { font-family: var(--font-display, system-ui, sans-serif); font-size: var(--text-xl, 1.2rem); font-weight: 700; font-variant-numeric: tabular-nums; color: var(--color-text); }
/* A zero is information, not an error. Dimmed, never hidden. */
.index-rows__row--empty .index-rows__value,
.index-rows__row--empty .index-rows__label { color: var(--color-text-muted); font-weight: 500; }
.index-rows__divider { display: flex; align-items: center; gap: var(--space-md, 1rem); padding: var(--space-lg, 1.5rem) 0 var(--space-sm, .5rem); font-family: var(--font-mono, monospace); font-size: var(--text-xs, .72rem); text-transform: uppercase; letter-spacing: .14em; color: var(--color-text-muted); }
.index-rows__divider::after { content: ""; flex: 1 1 auto; height: 1px; background: var(--color-rule, var(--color-chip-strong, #e6e2e0)); }
@media (max-width: 599px) {
  .index-rows__row { grid-template-columns: auto 1fr; row-gap: var(--space-xs, .2rem); }
  .index-rows__value { grid-column: 2; justify-self: start; }
}

/* ==================== Bands and panels ====================
   Direction C alternates dark and light. The admin was one dark topbar and
   then unbroken light for the whole scroll, which is the flat-fill problem the
   `special` skill's phase 4 names: a band change is what gives a long page a
   pulse, roughly one every three to four sections and never two adjacent.

   The panel head is also a restoration. The template this replaced had a dark
   card header with a mono title and a lime badge, and it was the best thing in
   it; it went out with Tailwind and should not have. */

/* Full-bleed inside shell__main, which has its own padding, so the band has to
   pull back out to the edges and repay the padding itself. */
.band { margin-inline: calc(-1 * var(--space-md, 1rem)); padding: var(--space-lg, 1.5rem) var(--space-md, 1rem); }
@media (min-width: 900px) { .band { margin-inline: calc(-1 * var(--space-lg, 1.5rem)); padding: var(--space-xl, 2rem) var(--space-lg, 1.5rem); } }
.band--dark { background: var(--neutral-900); color: var(--color-text-on-dark, #fff); }
/* A band is a ground change, so everything that resolved against the light
   ground has to be restated. Muted text at 72% white measures ~7:1 on #1c1c1c;
   the light-ground muted (#454545) would be 1.6:1 and unreadable. */
.band--dark .figure-lead__label,
.band--dark .figure-lead__aside,
.band--dark .text--muted,
.band--dark .index-rows__meta { color: color-mix(in srgb, var(--color-text-on-dark, #fff) 72%, transparent); }
.band--dark .figure-lead__value,
.band--dark .page-head__title { color: var(--color-text-on-dark, #fff); }
.band--dark .figure-lead { border-bottom-color: color-mix(in srgb, var(--color-text-on-dark, #fff) 18%, transparent); padding-bottom: 0; border-bottom: none; }
/* Lime on the dark band is the one saturated thing on the screen. On the light
   ground the same number stays ink, or the accent stops meaning anything. */
.band--dark .figure-lead__value { color: var(--color-primary); }

/* A card with a dark head. The head carries the label, the body carries the
   content, and the join is where the eye lands. */
.panel { background: var(--color-background, #fff); border: 1px solid var(--color-chip-strong, var(--neutral-200, #e5e7eb)); border-radius: var(--radius-lg, 12px); overflow: hidden; }
.panel__head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-sm, .5rem); padding: var(--space-sm, .7rem) var(--space-md, 1rem); background: var(--neutral-900); color: var(--color-text-on-dark, #fff); }
.panel__title { margin: 0; font-family: var(--font-mono, monospace); font-size: var(--text-xs, .72rem); font-weight: 600; text-transform: uppercase; letter-spacing: .12em; }
.panel__badge { padding: .15rem .5rem; border-radius: var(--radius-full, 999px); font-family: var(--font-mono, monospace); font-size: var(--text-xs, .66rem); background: color-mix(in srgb, var(--color-primary) 18%, transparent); color: var(--color-primary); }
.panel__actions { margin-left: auto; display: flex; gap: var(--space-sm, .5rem); }
.panel__body { padding: var(--space-md, 1rem); }
.panel__body--flush { padding: 0; }
/* A panel wrapping a table: the head replaces the table's own header chrome. */
.panel .table thead th { background: var(--color-chip, var(--neutral-100, #f9fafb)); }

/* The conversation reads better on the dark ground, which is also where
   gg-model-exchange puts it on the marketing site. */
.chat--dark { background: var(--neutral-900); border-color: color-mix(in srgb, var(--color-text-on-dark, #fff) 16%, transparent); color: var(--color-text-on-dark, #fff); }
.chat--dark .chat__head { border-bottom-color: color-mix(in srgb, var(--color-text-on-dark, #fff) 16%, transparent); color: color-mix(in srgb, var(--color-text-on-dark, #fff) 72%, transparent); }
.chat--dark .chat__bubble { background: color-mix(in srgb, var(--color-text-on-dark, #fff) 10%, transparent); color: var(--color-text-on-dark, #fff); }
.chat--dark .chat__msg--user .chat__bubble { background: var(--color-primary); color: var(--color-primary-text, #111); }
.chat--dark .chat__meta { color: color-mix(in srgb, var(--color-text-on-dark, #fff) 60%, transparent); }
.chat--dark .chat__composer { background: transparent; border-top-color: color-mix(in srgb, var(--color-text-on-dark, #fff) 16%, transparent); }
.chat--dark .textarea { background: color-mix(in srgb, var(--color-text-on-dark, #fff) 8%, transparent); border-color: color-mix(in srgb, var(--color-text-on-dark, #fff) 20%, transparent); color: var(--color-text-on-dark, #fff); }

/* interactions.css — the BEHAVIOUR layer: reusable micro-interactions & effects.
 * Implements the `interaction` entries in ~/projects/design-system/catalog.json,
 * grounded in design-knowledge-base/synthesis/interaction.md. Load after atoms/molecules.
 * Motion tokens have baseline fallbacks so effects work even without tokens-motion.css.
 */

/* Motion token defaults (mirrors design-system/tokens/tokens-motion.css). */
:root {
  --dur-instant: 100ms; --dur-fast: 150ms; --dur-normal: 200ms; --dur-slow: 320ms; --dur-signal: 800ms;
  --delay-secondary: 300ms;
  --ease-standard: cubic-bezier(.2,0,0,1); --ease-in: cubic-bezier(.4,0,1,1);
  /* --ease-emphasized previously used an overshoot curve (a control point above 1),
     which DESIGN-PRINCIPLES bans and the impeccable detector flagged across the
     whole library. It is now an ease-out-quint: still emphatic, decelerates like a
     real object, no bounce. Do NOT quote the old value here; the detector scans
     stylesheet text, so a banned literal in a comment reports as a real finding. */
  --ease-out: cubic-bezier(0,0,.2,1); --ease-emphasized: cubic-bezier(.22,1,.36,1);
  --press-scale: .97; --shimmer-dur: 1.2s;
}

/* press-feedback / hover-raise / focus-ring — utility hooks any element can adopt */
.fx-press:active { transform: scale(var(--press-scale)); }
.fx-hover-raise { transition: transform var(--dur-instant) var(--ease-out), box-shadow var(--dur-instant) var(--ease-out); }
@media (hover: hover) { .fx-hover-raise:hover { transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,.12); } }
.fx-focus-ring:focus-visible { outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 40%, transparent); }

/* ripple */
.fx-ripple { position: relative; overflow: hidden; }
.fx-ripple::after { content: ""; position: absolute; inset: 0; background: radial-gradient(circle, currentColor 10%, transparent 10.01%) center/15000%; opacity: 0; transition: opacity var(--dur-slow) var(--ease-out); }
.fx-ripple:active::after { background-size: 100%; opacity: .18; transition: background-size 0s; }

/* skeleton-shimmer */
.skeleton::before { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(255,255,255,.6), transparent); transform: translateX(-100%); animation: sf-shimmer var(--shimmer-dur) infinite; }
@keyframes sf-shimmer { 100% { transform: translateX(100%); } }

/* enter transitions: toast / sheet / drawer / modal */
.fx-toast-enter   { animation: sf-toast-in var(--dur-normal) var(--ease-out); }
.fx-sheet-enter   { animation: sf-sheet-up var(--dur-slow) var(--ease-out); }
.fx-drawer-enter  { animation: sf-drawer-in var(--dur-slow) var(--ease-out); }
.fx-modal-enter   { animation: sf-modal-in var(--dur-normal) var(--ease-out); }
@keyframes sf-toast-in  { from { opacity: 0; transform: translateY(12px); } }
@keyframes sf-sheet-up  { from { transform: translateY(100%); } }
@keyframes sf-drawer-in { from { transform: translateX(-100%); } }
@keyframes sf-modal-in  { from { opacity: 0; transform: scale(.96); } }

/* list-stagger: children enter in sequence to direct attention */
.fx-stagger > * { opacity: 0; animation: sf-fade-up var(--dur-fast) var(--ease-out) forwards; }
.fx-stagger > *:nth-child(1){animation-delay:0ms} .fx-stagger > *:nth-child(2){animation-delay:60ms}
.fx-stagger > *:nth-child(3){animation-delay:120ms} .fx-stagger > *:nth-child(4){animation-delay:180ms}
.fx-stagger > *:nth-child(5){animation-delay:240ms} .fx-stagger > *:nth-child(n+6){animation-delay:300ms}
@keyframes sf-fade-up { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }

/* success-flourish */
.fx-flourish { animation: sf-pop var(--dur-signal) var(--ease-emphasized); }
@keyframes sf-pop { 0%{transform:scale(1)} 30%{transform:scale(1.08)} 100%{transform:scale(1)} }

/* Accessibility: honour reduced-motion — strip movement, keep feedback. Non-negotiable. */
@media (prefers-reduced-motion: reduce) {
  .fx-hover-raise:hover, .fx-press:active, .fx-flourish { transform: none; }
  .skeleton::before, .fx-stagger > *, .fx-toast-enter, .fx-sheet-enter, .fx-drawer-enter, .fx-modal-enter { animation: none; }
  .fx-stagger > * { opacity: 1; }
  *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; }
}
