/* ff-theme.css — the shared stylesheet.

   Before this file there were fourteen copies of the token block across seventeen
   pages, in four mutually different variants, and .card / .btn / .topbar / .rail
   redefined in ten to thirteen places. Only forty-eight CSS lines were byte-identical
   across all ten app screens: the copies had already drifted, which is the proof that
   they drift. Everything below is declared once.

   Load order matters and is the whole contract: this file is linked ABOVE each page's
   <style> block, so a page-local rule still wins on order alone. What is left in a
   page block after extraction is genuinely page-specific and stays there.

   The visual language is design-language.html v2 — measured off foreflux.ai for
   palette, type and voice, with frost surfaces, ambient canvas wash, far-throw
   elevation, mono numerals and visible key caps ported as posture from
   lineaprompt.com. No new hex enters the system. Every difference from v1 is alpha,
   blur, shadow throw, texture, radius laddering and typeface role. */

/* ── IBM Plex Mono, self-hosted ───────────────────────────────────────────────
   Latin subset only (U+0000–00FF), three weights, ~45 KB total. The mono face
   only ever sets numerals, units, keys and caps labels, so the rest of the Google
   subsets are weight this app already paid for once in 4ee6378 perf(boot) and is
   not paying again. Self-hosted rather than CDN for the same reason: a cold start
   here does not get to spend a DNS lookup and a TLS handshake on a third party.

   font-display:optional, not swap. This read `swap` so the numerals would render in the
   fallback stack immediately — right about not blocking, wrong about what happens next.
   `swap` guarantees a second layout: this face sets every numeral in every table, and its
   advance is 0.600em against Consolas' 0.550em, so on Windows each column re-measures ~9%
   narrower the moment the woff2 lands. `optional` keeps the immediate paint and drops the
   swap-in, so a cold load never reflows; the browser fetches the file in the background and
   every load after this one has it. The trade is one uncached, slow first pageview rendering
   in the fallback mono, which for a 15 KB same-origin subset in a tool people open daily is
   the cheaper half. The exact answer is a size-adjust fallback @font-face per platform
   default (Menlo / Consolas / DejaVu Sans Mono), which needs those metrics measured on each
   platform rather than guessed. docs/layout-stability-audit.md L6. */
@font-face {
  font-family: "IBM Plex Mono"; font-style: normal; font-weight: 400; font-display: optional;
  src: url("fonts/plex-mono-400.woff2?v=08949f72") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "IBM Plex Mono"; font-style: normal; font-weight: 500; font-display: optional;
  src: url("fonts/plex-mono-500.woff2?v=01d28544") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "IBM Plex Mono"; font-style: normal; font-weight: 600; font-display: optional;
  src: url("fonts/plex-mono-600.woff2?v=0d1f0b8d") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* ─── tokens — the one canonical block ────────────────────────────────────────
   Reconciled from the four drifted variants by taking the widest of each: --fs-3xl
   and --fs-4xl were on some screens and not others, --accent-soft only on the
   dashboard, --well only on the app shell. Nothing was dropped.

   Three things this block corrects outright:

   1. --meta was rgba(28,28,28,0.4). On the old cream #f7f4ed canvas that composites to
      rgb(111,110,107) → 2.46:1, and the AA floor for body and UI text is 4.5:1.
      It is what every section label and every piece of metadata on the app is set
      in, 53 times over. At 0.62 it composites to 4.65:1 and passes. This is not a
      style change, it was a failing contrast ratio on thirteen pages.

   2. Elevation throws far and lands soft. The old --elev-panel ended at 28px of
      blur with -20px spread; the measured v2 value runs to 70px at -46px. The
      negative spread and the blue-slate ink are unchanged — only the falloff
      carries further, and that distance is what makes a panel read as hovering
      over paper rather than glued to it.

   3. --font-mono leads with a real face. It was a fallback stack with no webfont
      behind it, so the single loudest signal in the language — every quantity,
      delta, SKU and timestamp set in mono — was rendering in whatever the OS had.

   --well and --inset-control stay. The v2 specimen has no recessed-control tier;
   the third plane is a ForeFlux invention, documented on the dashboard, and it is
   not a v1 leftover to delete. */
:root {
  /* palette — the seven roles, unchanged */
  --bg: #ffffff;
  --surface: #ffffff;
  --fg: #1c1c1c;
  --fg-2: rgba(28, 28, 28, 0.83);
  --muted: #5f5f5d;
  --meta: rgba(28, 28, 28, 0.62);
  --border: rgba(37, 50, 72, 0.055);
  --border-weak: rgba(61, 77, 99, 0.13);
  /* The logo blue measured off the official ver-2 marks. #2563eb was a near-miss
     carried by the app shell alone — index.html and the design system both
     reconciled to #156bff on 2026-08-08 and label the old value explicitly so an
     audit can find it. --accent-legacy is kept for exactly that reason. */
  --accent: #156bff;
  --accent-legacy: #2563eb;
  /* #156bff is 4.18:1 on the old cream canvas — below the 4.5:1 text floor. Same
     fill-vs-type split the status hues already carry below: fills, strokes and
     rings keep --accent, anything setting `color` takes --accent-ink. */
  --accent-ink: #1260e5;   /* 4.99:1 on --bg, 5.30:1 on --surface */
  --accent-dark: color-mix(in oklab, var(--accent), black 30%);
  /* The second brand hue. Violet carries the mark, the gradient and the brand
     surface, and it has taken over every DECORATIVE wash and splash from amber
     so each surface reads blue in one corner and violet in the other. It is
     still never a UI accent, a chart series or a status colour — --warn keeps
     its semantic status job unchanged. */
  --violet: #b100ff;
  --spark: #19f2f2;
  --violet-ink: #9f00e5;   /* 5.30:1 on the old cream --bg; higher on white */
  /* Brand names, unused in the app today (§8) — kept for parity with the
     design system so the next audit is a grep, not a re-measure. */
  --bg-brand: #ffffff;
  --grad-brand: linear-gradient(135deg, #b100ff 0%, #156bff 100%);
  --grad-brand-mesh:
    radial-gradient(120% 120% at 0% 0%, #7e23ff 0%, transparent 62%),
    radial-gradient(120% 120% at 100% 0%, #9b0fff 0%, transparent 62%),
    radial-gradient(120% 120% at 0% 100%, #2263ff 0%, transparent 62%),
    radial-gradient(120% 120% at 100% 100%, #7529ff 0%, transparent 62%),
    #5b1fff;
  --success: #16a34a;
  --warn: #eab308;
  --danger: #dc2626;
  --btn-fg: #fcfbf8;
  --tint-4: rgba(28, 28, 28, 0.04);
  /* The focus ring was rgba(59,130,246,0.5) — 1.78:1 on the canvas, against a 3:1
     floor for non-text UI. WCAG 1.4.11 and 2.4.7 both fail on that value, and 2.4.13
     needs the focused and unfocused states to differ by 3:1 as well. Solid accent
     measures 4.71:1. index.html shipped this fix in its own --focus-ring; the app
     shell never got it. */
  --ring: var(--accent);
  /* Status hues are correct as FILLS at the 3:1 non-text floor and wrong as TYPE at
     4.5:1: --warn is 1.75:1 on the canvas, --success 3.00:1, --danger 4.40:1. The
     ink variants are the same hue carried far enough to be read. Fill keeps the raw
     token, type takes the ink. Ratios measured on the old cream --bg #f7f4ed, sRGB
     relative luminance, WCAG 2.2 AA; the canvas is now #ffffff, so these are the
     floor — the same three values index.html already ships. */
  --warn-ink:    color-mix(in oklab, var(--warn), black 38%);     /* 1.75:1 → 5.7:1 */
  --success-ink: color-mix(in oklab, var(--success), black 22%);  /* 3.00:1 → 5.4:1 */
  --danger-ink:  color-mix(in oklab, var(--danger), black 12%);   /* 4.40:1 → 5.9:1 */
  --scrim: rgba(28, 28, 28, 0.28);
  --accent-soft: color-mix(in oklab, var(--accent) 10%, transparent);

  /* frost, not paint — three weights, so stacked surfaces read as depth
     instead of as the same fill repeated */
  --frost-strong: rgba(255, 255, 255, 0.72);
  --frost: rgba(255, 255, 255, 0.62);
  --frost-soft: rgba(255, 255, 255, 0.52);
  --frost-promoted: rgba(255, 255, 255, 0.94);  /* the promoted row only */
  /* blur and saturation rise together: blur without saturate turns the warm
     white grey and dead */
  --blur-chrome: blur(8px) saturate(1.06);
  --blur: blur(20px) saturate(1.1);
  --blur-over: blur(30px) saturate(1.2);

  /* texture. The page grain is tinted to the SHADOW INK rgb(37,50,72) and
     multiplied — grey noise on a warm off-white canvas turns it muddy, blue-slate
     noise reads as paper fibre lit from the same direction as the shadows. The
     panel tooth is white, finer (higher baseFrequency) and lighter; that
     inversion is what stops frost from reading as flat plastic. */
  --grain-page: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.145 0 0 0 0 0.196 0 0 0 0 0.282 0 0 0 .12 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  --grain-panel: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.6 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)' opacity='.075'/%3E%3C/svg%3E");

  /* the ambient canvas wash, named so body's background is a one-line lookup */
  --wash-page:
    radial-gradient(820px 520px at 14% -6%, rgba(255, 255, 255, 0.85), transparent 62%),
    radial-gradient(680px 460px at 88% 8%, rgba(177, 0, 255, 0.05), transparent 64%),
    radial-gradient(760px 560px at 46% 108%, rgba(21, 107, 255, 0.09), transparent 60%);

  /* splash grammar — three tiers, sized and placed by what carries them.
     Never a mid-size splash floating in the middle of a surface. */
  --sheen: linear-gradient(180deg, rgba(255, 255, 255, 0.86), transparent 38%);
  --splash-card:
    radial-gradient(520px 260px at 82% 14%, rgba(21, 107, 255, 0.11), transparent 64%),
    radial-gradient(440px 280px at 12% 100%, rgba(177, 0, 255, 0.07), transparent 66%);
  --splash-ctl: radial-gradient(260px 160px at 90% 20%, rgba(21, 107, 255, 0.16), transparent 70%);

  /* depth — negative spread throughout, so the shadow tucks under the panel and
     contacts the page at its base instead of haloing all four sides */
  --inset:
    rgba(255, 255, 255, 0.2) 0 0.5px 0 0 inset,
    rgba(0, 0, 0, 0.2) 0 0 0 0.5px inset,
    rgba(0, 0, 0, 0.05) 0 1px 2px 0;
  --float:
    0 1px 2px -1px rgba(37, 50, 72, 0.20),
    0 6px 14px -10px rgba(37, 50, 72, 0.34);
  --elev-ring: 0 0 0 1px var(--border);
  --elev-panel:
    0 1px 2px -1px rgba(37, 50, 72, 0.16),
    0 26px 70px -46px rgba(37, 50, 72, 0.46);
  --elev-raised:
    0 2px 5px -2px rgba(37, 50, 72, 0.20),
    0 34px 96px -48px rgba(37, 50, 72, 0.55);
  --elev-promoted: 0 22px 58px -46px rgba(37, 50, 72, 0.62);
  --elev-active: 0 18px 44px -30px rgba(21, 107, 255, 0.74);
  --elev-overlay: 0 18px 40px -28px rgba(28, 28, 28, 0.45);
  --bevel-top: inset 0 1px 0 rgba(255, 255, 255, 0.9);
  /* The third plane. --bg is the canvas, --surface is a panel raised off it, and
     --well is a control cut INTO whichever of those it sits in. */
  --well: #ffffff;
  --inset-control: inset 0 1px 2px rgba(37, 50, 72, 0.055);

  --font-display: "Camera Plain Variable", "Camera Plain", ui-sans-serif, system-ui, sans-serif;
  --font-body: "Camera Plain Variable", "Camera Plain", ui-sans-serif, system-ui, sans-serif;
  --font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Monaco, Consolas, monospace;

  --fw-body: 400;
  --fw-display: 480;   /* hero headlines only — at wordmark size it goes soft */
  --fw-heading: 600;   /* the ceiling. 700 exists nowhere in the system */

  --fs-xs: 12px; --fs-sm: 14px; --fs-body: 16px; --fs-lg: 18px;
  --fs-xl: 20px; --fs-2xl: 36px; --fs-3xl: 48px; --fs-4xl: 60px;
  --fs-display: clamp(44px, 9vw, 104px);
  --fs-h2: clamp(32px, 5vw, 72px);
  --fs-figure: clamp(56px, 7vw, 88px);
  --lead-tight: 1.1; --lead-body: 1.5; --lead-figure: 0.95;
  --track-display: -0.025em; --track-figure: -0.03em; --track-caps: 0.08em;

  --s1: 4px; --s2: 8px; --s3: 12px; --s4: 16px; --s5: 20px; --s6: 24px; --s8: 32px; --s12: 48px;
  --tap-min: 44px;
  /* radius steps down concentrically: shell 16 → card 12 → row 10 → control 6.
     A child that shares its parent's radius reads as a misaligned overlay. */
  --r-sm: 6px; --r-md: 12px; --r-lg: 16px; --r-row: 10px; --r-pill: 9999px;
  --rail: 232px;
  --ease-standard: cubic-bezier(0.2, 0, 0, 1);
  /* --ease-move is the travel curve already load-bearing everywhere something
     slides/crossfades in place (the rail-pill view-transition morph and
     qfIndicator's travelling tab/chip underline, both in ff-data.js). Every one
     of those sites already reads var(--ease-move, cubic-bezier(0.77,0,0.175,1))
     — this names the value instead of leaving it an orphaned fallback default. */
  --ease-move: cubic-bezier(0.77, 0, 0.175, 1);
  /* Named durations — kit.css already ships against these, and every transition
     in this file now reads one of them instead of an inline .15s/.2s/.24s. Same
     numbers, so nothing moved on screen; what changed is that the next audit is
     a grep. The one-off timings that have no name (.16s, .18s, .3s, .5s and the
     stagger delays) are left as literals rather than invented into tokens. */
  --dur-fast: 150ms; --dur-base: 200ms; --dur-panel: 240ms; --dur-crossfade: 280ms;
  /* --dur-enter/--dur-exit name the asymmetric reveal/collapse pair that already
     recurs, unnamed, as ff-data.js's own var(--motion-base,.22s)/
     var(--motion-exit,.13s) fallback vocabulary (qfText, qfSwap, .ff-reveal) and
     as hardcoded .22s/.13s literals in support.html, auth.html and
     onboarding.html. A disclosure/toast/message opens slower than it closes on
     purpose; these are for that pair specifically — not a substitute for
     --dur-panel (dialog/drawer open) or --dur-crossfade (value crossfade). */
  --dur-enter: 220ms; --dur-exit: 130ms;
  /* --delay-stagger is the 60ms unit the section-rise stagger below already
     uses (as does kit.css's own copy, and index.html's independent
     `calc(var(--d,0)*60ms)` reveal stagger) without ever naming it. */
  --delay-stagger: 60ms;

  /* ── accordion (disclosure) — component mapping ─────────────────────────
     The accordion owns NAMES, not numbers. Every value below resolves to a
     token already declared above, so a change to the brand surface reaches the
     component for free and an audit of "what colour is an accordion" is a grep
     of this block rather than a read of the component's rules.

     Source of the values, in the order they were read:
       new-design-system-foreflux/brand-surface.html   the brand surface itself
       new-design-system-foreflux/colors_and_type.css  palette, ink, type scale
       new-design-system-foreflux/tokens.css           spacing, radius, elevation, motion
     Those three files are the extracted statement of this :root block (they say
     so in their own headers), so mapping onto the tokens above IS mapping onto
     the brand surface — there is no second set of numbers to reconcile.

     index.html carries the same --accordion-* names mapped onto the landing's
     own --space-*/--radius-*/--motion-* vocabulary, next to its FAQ rules. Two
     vocabularies, one contract, which is the split this repo already runs (see
     the @font-face note at the top of index.html) rather than a new one.

     Three choices worth stating, because they are not arbitrary:
       radius   follows the concentric ladder — the group is a card (12), an
                item inside it is a row (10). Equal radii read as a misaligned
                overlay, which is the note on --r-* above.
       motion   is the asymmetric disclosure pair --dur-enter/--dur-exit: a
                panel opens slower than it closes. Because both are duration
                tokens, the reduced-motion block below collapses them without
                the accordion needing its own media query.
       leading  --accordion-title-leading is the one number here with no token
                behind it. A wrapping question wants more air than --lead-tight
                (1.1, sized for display type) and less than --lead-body (1.5,
                sized for prose); 1.35 is that gap, and it is named here so no
                accordion rule ever carries a bare line-height. */
  --accordion-bg: var(--surface);
  --accordion-bg-hover: var(--tint-4);
  --accordion-border: var(--border);
  --accordion-border-hover: var(--border-weak);
  --accordion-radius: var(--r-md);
  --accordion-item-radius: var(--r-row);
  --accordion-gap: var(--s2);
  --accordion-padding: var(--s4) var(--s5);
  --accordion-title-gap: var(--s4);
  --accordion-panel-padding: 0 var(--s5) var(--s5);
  --accordion-shadow: var(--float);
  --accordion-ring: var(--ring);
  --accordion-min-target: var(--tap-min);
  --accordion-title-font: var(--font-display);
  --accordion-title-size: var(--fs-body);
  --accordion-title-weight: var(--fw-heading);
  --accordion-title-leading: 1.35;
  --accordion-title-color: var(--fg);
  --accordion-title-color-muted: var(--fg-2);
  --accordion-panel-size: var(--fs-sm);
  --accordion-panel-leading: var(--lead-body);
  --accordion-panel-color: var(--muted);
  --accordion-link-color: var(--fg);   /* a link inside the panel, against --accordion-panel-color */
  --accordion-marker-color: var(--meta);
  --accordion-marker-font: var(--font-mono);
  --accordion-transition-duration: var(--dur-enter);
  --accordion-transition-duration-exit: var(--dur-exit);
  --accordion-easing: var(--ease-standard);

  /* ── dropdown (single-select listbox) — component mapping ───────────────
     Names, not numbers, the same arrangement as the accordion block above.
     Every value here was read off one of the three overlay controls this repo
     already ships — .branchpick and .qfmenu (this file) and .ff-pop
     (ff-data.js) — so the dropdown is the fourth member of that family rather
     than a fourth look. Spec and the reason behind each choice:
     docs/dropdown-spec.md. Component: qfSelect() in ff-data.js.

     Two values carry no token behind them and are documented in place:
       --dropdown-trigger-border-hover is the accent-at-24% tint that
         .qfmenu-trigger, .branchpick-trigger, .ff-pop-trigger, .searchbox and
         input.ctl[type=search] all already write as a literal rgba;
       --dropdown-menu-max-height is calc()'d from --tap-min, so it is a token
         expression rather than a number: seven 44px rows fill and the eighth
         is cut mid-row, which is what tells a reader the list scrolls. */
  --dropdown-trigger-bg: var(--well);
  --dropdown-trigger-border: var(--border);
  --dropdown-trigger-border-hover: rgba(21, 107, 255, 0.24);
  --dropdown-trigger-shadow: var(--inset-control);
  --dropdown-trigger-radius: var(--r-sm);
  --dropdown-trigger-padding: 7px var(--s3);
  --dropdown-trigger-gap: var(--s2);
  --dropdown-trigger-size: var(--fs-sm);
  --dropdown-trigger-color: var(--fg);
  --dropdown-caret-color: var(--meta);
  --dropdown-caret-size: 16px;
  --dropdown-min-target: var(--tap-min);

  --dropdown-offset: 6px;               /* trigger → menu gap, as all three panels use */
  --dropdown-menu-bg: var(--surface);
  --dropdown-menu-border: var(--border-weak);
  --dropdown-menu-radius: var(--r-md);
  --dropdown-menu-shadow: var(--elev-overlay);
  --dropdown-menu-padding: var(--s2);
  --dropdown-menu-max-height: calc(var(--tap-min) * 7);
  --dropdown-menu-z: 20;

  --dropdown-option-radius: var(--r-row);
  --dropdown-option-padding: var(--s2) var(--s3);
  --dropdown-option-gap: var(--s3);
  --dropdown-option-size: var(--fs-sm);
  --dropdown-option-color: var(--fg);
  --dropdown-option-bg-hover: var(--tint-4);
  --dropdown-option-bg-selected: var(--accent-soft);
  --dropdown-option-tick-color: var(--accent-ink);
  --dropdown-option-note-font: var(--font-mono);   /* a code or a quantity beside the label */
  --dropdown-option-note-size: 11px;
  --dropdown-option-note-color: var(--meta);
  --dropdown-option-disabled-opacity: 0.4;

  --dropdown-ring: var(--ring);
  --dropdown-easing: var(--ease-standard);
  --dropdown-duration: var(--dur-fast);
}

/* Reduced motion as policy, not per-page opt-in: collapsing the shared duration
   tokens here means any surface built from var(--dur-*)/var(--delay-stagger) —
   present or future — inherits the reduction for free, without needing its own
   blanket `*{transition-duration:.01ms!important}` rule. This does not touch
   ff-data.js's own reduced-motion block (qfText, qfSwap, .ff-reveal, the
   rail-pill group, toasts, etc.) — that block makes its own considered
   per-component choice between a hard cut and a shortened-but-present crossfade
   (qfText keeps its opacity fade at a hardcoded 120ms and only drops the width
   interpolation) and applies via !important, so it always wins regardless of
   what these tokens resolve to. */
@media (prefers-reduced-motion: reduce) {
  :root {
    --dur-fast: 1ms; --dur-base: 1ms; --dur-panel: 1ms; --dur-crossfade: 1ms;
    --dur-enter: 1ms; --dur-exit: 1ms; --delay-stagger: 0ms;
  }
}

/* ─── base ───────────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }

/* The app is a full-page vertical shell; it should contain its own overscroll
   rather than handing a pull at the top to the browser's pull-to-refresh /
   rubber-band, which fights the sticky header. */
html, body { overscroll-behavior-y: none; }
/* The canvas carries an ambient wash, not a gradient — four wide radials pinned
   off-centre at 6–10% over flat --bg. Never a linear gradient, never a mesh,
   never enough to be nameable as a colour. background-attachment:fixed so it
   belongs to the viewport and does not scroll with a long inventory list. */
body {
  margin: 0; color: var(--fg);
  font-family: var(--font-body); font-size: var(--fs-body); font-weight: 400;
  line-height: var(--lead-body); -webkit-font-smoothing: antialiased;
  background: var(--wash-page), var(--bg);
  background-attachment: fixed;
}
/* Two composited layers, viewport-sized, pointer-events:none, and neither of them
   scrolls. The field fades out rather than ending at an edge and takes a 0.2px
   blur so it never reads as a crisp CSS shape; the grain multiplies, so it
   darkens into the canvas instead of hazing over it. */
body::before {
  content: ""; position: fixed; inset: 0; z-index: -2; pointer-events: none;
  background: radial-gradient(900px 620px at 50% 46%, rgba(21, 107, 255, 0.05), transparent 70%);
  -webkit-mask-image: radial-gradient(circle at 50% 52%, #000, #0000 73%);
  mask-image: radial-gradient(circle at 50% 52%, #000, #0000 73%);
  filter: blur(0.2px);
}
body::after {
  content: ""; position: fixed; inset: 0; z-index: -1; pointer-events: none;
  background-image: var(--grain-page);
  opacity: 0.16; mix-blend-mode: multiply;
}

svg { display: block; }
a { color: inherit; text-decoration: none; }
button, input, select { font: inherit; color: inherit; }
button { cursor: pointer; }
p { margin: 0; text-wrap: pretty; }
strong { font-weight: 600; }
h1, h2, h3 { margin: 0; font-family: var(--font-display); font-weight: 600; text-wrap: balance; }
h1 { font-size: var(--fs-2xl); line-height: var(--lead-tight); letter-spacing: var(--track-display); }
h2 { font-size: var(--fs-xl); line-height: 1.25; }
h3 { font-size: var(--fs-body); }
:where(button, select, input, a, summary):focus-visible { outline: none; box-shadow: 0 0 0 2px var(--ring); border-radius: var(--r-sm); }
/* box-shadow is one property, so the ring rule above replaces the well rather than
   joining it — a focused field lost its inset and popped back to flat. Both layers,
   inset first so the ring sits outside it. */
:where(.ctl, .field input, .field select, .field textarea, .fld input, .fld select, .toggle):focus-visible { box-shadow: var(--inset-control), 0 0 0 2px var(--ring); }

/* ─── shell ──────────────────────────────────────────────────────────────── */
/* The skip link (ff-nav.js builds it). position:fixed in BOTH states on purpose: body is a
   flex column when it holds the app shell (see below), so a statically-positioned link
   would be a flex item pushing the banner and the rail down by its own height while it is
   supposed to be invisible. No transition — a control you only ever see for one keypress
   should be there on the keypress. */
a.skip { position: fixed; top: 0; left: 0; z-index: 300; transform: translateY(-120%);
  padding: 10px 16px; background: var(--fg); color: var(--btn-fg);
  border-radius: 0 0 var(--r-sm) 0; font-size: var(--fs-sm); text-decoration: none; }
a.skip:focus { transform: none; outline: none; box-shadow: 0 0 0 2px var(--ring); }
.app { display: grid; grid-template-columns: var(--rail) 1fr; min-height: 100vh; min-height: 100dvh; }
/* The footer sits at the bottom of the viewport on a short page and after the content on a
   long one — `.appfoot{margin-top:auto}` inside the flex .mainpane does that, but only if
   .app is exactly as tall as the space it has. It is not: sb-guard.js's demo / trial-lapsed
   banners are position:sticky body children in normal flow, so `.app{min-height:100dvh}`
   overshoots by the banner's ~38px and a short screen scrolls that far with the footer
   hanging just under the fold. Hang the 100dvh off the body instead and let .app take
   whatever the banner leaves. :has scopes it to the app shell — the marketing pages share
   this stylesheet and lay their own body out — and a browser without :has keeps the
   .app min-height above, i.e. today's behaviour, not a broken one. */
body:has(> .app) { min-height: 100vh; min-height: 100dvh; display: flex; flex-direction: column; }
body:has(> .app) > .app { flex: 1; min-height: 0; grid-template-rows: minmax(0, 1fr); }
/* And the rail's own `height:100dvh` is the other half of the same overshoot: it is a grid
   item, so a viewport-tall rail sizes the row — and therefore .app — to a full viewport no
   matter what is above it. Filling the row instead (capped at a viewport, so a long screen's
   rail stays the sticky viewport-tall column it is today) lets the row be the leftover. */
body:has(> .app) > .app > .rail { height: 100%; max-height: 100vh; max-height: 100dvh; }
/* Chrome takes the strongest frost and the lightest blur. One composited layer
   each, and both are sticky already, so nothing new is being promoted. */
.rail {
  border-right: 1px solid var(--border); padding: var(--s6) var(--s4);
  display: flex; flex-direction: column; gap: var(--s6);
  position: sticky; top: 0; height: 100vh; height: 100dvh;
  background: var(--frost-strong);
  -webkit-backdrop-filter: var(--blur-chrome); backdrop-filter: var(--blur-chrome);
}
.brand { display: flex; align-items: center; gap: var(--s2); padding-inline: var(--s3); }
.brand img { width: 24px; height: 24px; }
.brand b { font-family: var(--font-display); font-weight: 600; font-size: var(--fs-lg); letter-spacing: -0.01em; }
/* brand separator — pipe, never an em dash or middot. DESIGN.md §11.4 */
.brand .sep { color: var(--muted); opacity: .5; }
.railnav { display: flex; flex-direction: column; gap: 2px; }
.railnav a {
  display: flex; align-items: center; gap: var(--s2);
  min-height: var(--tap-min);
  padding: 9px var(--s3); border-radius: var(--r-sm); font-size: var(--fs-sm); color: var(--muted);
}
.railnav a svg { width: 18px; height: 18px; flex-shrink: 0; }
.railnav a .txt { flex: 1; }
.railnav a:hover { background: var(--tint-4); color: var(--fg); }
.railnav a[aria-current="page"] { background: var(--fg); color: var(--btn-fg); box-shadow: var(--inset); }
.railfoot { margin-top: auto; padding-inline: var(--s3); }

.mainpane { min-width: 0; display: flex; flex-direction: column; }
.topbar {
  position: sticky; top: 0; z-index: 210;
  background: var(--frost-strong);
  -webkit-backdrop-filter: var(--blur-chrome); backdrop-filter: var(--blur-chrome);
  border-bottom: 1px solid var(--border);
  padding: var(--s4) var(--s8);
  display: flex; align-items: center; gap: var(--s5); flex-wrap: wrap;
}
.topbar-id { min-width: 0; }
/* On phones the demo/trial-lapsed banners (sb-guard.js) are position:sticky;top:0;
   z-index:200 — the topbar has to out-rank them or it slides under on scroll. */
.topbar-id .eyebrow { font-family: var(--font-mono); font-size: 11px; letter-spacing: var(--track-caps); text-transform: uppercase; color: var(--meta); margin-bottom: 2px; }
.topbar-id h1 { font-size: var(--fs-xl); letter-spacing: -0.01em; }
/* The topbar subtext is a breadcrumb trail: the business name is the root crumb
   and links back to the dashboard; the current page is the non-link tail
   (aria-current). Same trail grammar as the marketing pages' .eyebrow.crumbs,
   at the h1's weight. Shared across the app screens since the trail spread. */
.topbar-id h1.crumbs { display: flex; align-items: baseline; min-width: 0; }
.topbar-id h1.crumbs a { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); text-decoration: none; cursor: pointer; transition: color var(--dur-fast) var(--ease-standard); }
.topbar-id h1.crumbs a:hover, .topbar-id h1.crumbs a:focus-visible { color: var(--accent-ink); text-decoration: underline; }
.topbar-id h1.crumbs .sep { color: var(--muted); opacity: .45; padding-inline: 8px; flex: none; }
/* A three-deep trail ({business} > Inventory > Full Summary) has a middle crumb that is
   not a link and not the current page. Without this it inherits the h1's colour and reads
   exactly as loud as the tail, so the trail has two "you are here"s. Subdue it with the
   links: everything before the tail is context, the tail is the page. */
.topbar-id h1.crumbs > span:not([aria-current="page"]) { color: var(--muted); }
.topbar-id h1.crumbs [aria-current="page"] { color: var(--fg); }
/* And it is the crumb that ellipsises when the trail still does not fit. The h1 carries
   text-overflow:ellipsis under 700px, but .crumbs is display:flex and a flex container
   clips its items rather than ellipsising them — so the declaration up there does nothing
   and the tail used to cut mid-word ("Stock levels" → "Stocl"). Put it on the item that
   runs out of room. The root crumb already has its own copy of this a few lines above. */
.topbar-id h1.crumbs [aria-current="page"] { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* One-line page footer: the "data as of" line that used to sit in the topbar, plus
   the legal/help sublinks. Sits under the content column (right of the rail). */
.appfoot {
  margin-top: auto;
  padding: var(--s4) var(--s8);
  border-top: 1px solid var(--border);
  display: flex; align-items: center; justify-content: space-between; gap: var(--s4); flex-wrap: wrap;
  font-size: var(--fs-sm); color: var(--meta);
}
.appfoot .footlinks { display: inline-flex; gap: var(--s4); }
.appfoot a { color: var(--muted); text-decoration: none; }
.appfoot a:hover, .appfoot a:focus-visible { color: var(--fg); }
/* The as-of line reads as the footer's right-hand stamp, but it is written first in the
   markup so it precedes the legal links for a screen reader (and so the four screens that
   have no as-of still start their footer with the links). `order` moves it visually without
   touching eight files' markup or the reading order. `margin-left:auto` rather than leaning
   on the row's space-between: auto margins resolve per flex LINE, so once the footer wraps
   on a narrow screen the as-of is still hard right on its own line, where space-between —
   which has nothing to space out on a one-item line — would drop it back to the left. */
.appfoot #asOf { order: 1; margin-left: auto; }
/* The links' own spacing measured fine — 16px between all three at every width — so the
   only part of it that was wrong rather than a matter of taste is the target: a 21px line
   box, under WCAG 2.2 (2.5.8)'s 24px minimum. Grown with an overlay rather than block
   padding because padding is 8px of extra footer height, and 8px is exactly what makes a
   just-fits screen (locations at 1024x768 measured a 768px document) start to scroll —
   i.e. it would spend the sibling task's whole sticky-footer margin on a hit area. The
   pseudo-element is inside the <a>, so a press on it is a press on the link. */
.appfoot .footlinks a { position: relative; }
/* -12px vertical takes the 21px line box past --tap-min (45px); -8px horizontal gives `Help`
   (29px wide) a 45px target without moving the 16px gap between the three links. Still an
   overlay, still zero added footer height — that is the whole reason this is not padding. */
.appfoot .footlinks a::after { content: ""; position: absolute; inset: -12px -8px; }
/* One right-alignment mechanism per topbar, and it is `.ff-sync{margin-left:auto}`
   (ff-data.js). The `<span class="topbar-spacer">{flex:1}` that used to sit in all twelve
   headers was inert: an auto margin on a flex item absorbs the free space BEFORE any flex
   grow factor is applied, so the spacer resolved to 0px on every screen carrying a sync
   pill — which is every screen. Removing it is pixel-identical and stops the next control
   added to a topbar from landing on the wrong side of a rule that never fired. */

/* ─── the branch picker ──────────────────────────────────────────────────────
   Phase 6b. One control in the topbar, replacing the per-screen <select>. It is an
   overlay, not an in-flow reveal, so opening it must not push the page down — but it
   still UNFOLDS rather than appearing: grid-template-rows 0fr→1fr animates the panel's
   own height, which is the same mechanism a disclosure uses to slide its neighbours,
   applied to a box that has no neighbours to slide.

   Selection is colour and a tick. No padding, no weight, no border-width — the row you
   are pointing at must not resize under your finger, and there is no transition that can
   hide a reflow. */
.branchpick { position: relative; display: inline-flex; }
.branchpick-trigger {
  display: inline-flex; align-items: center; gap: var(--s2);
  min-height: 44px; padding: 7px var(--s3);
  border: 1px solid var(--border-weak); border-radius: var(--r-sm);
  background: var(--well); box-shadow: var(--inset-control);
  color: var(--fg); font: inherit; font-size: var(--fs-sm); cursor: pointer;
  transition: border-color var(--dur-fast) var(--ease-standard), background var(--dur-fast) var(--ease-standard);
}
.branchpick-trigger:hover { border-color: rgba(21, 107, 255, 0.24); }
.branchpick-trigger:focus-visible { outline: none; box-shadow: var(--inset-control), 0 0 0 2px var(--ring); }
.branchpick-label { white-space: nowrap; }
.branchpick-arrow { display: inline-flex; width: 14px; height: 14px; color: var(--meta); transition: transform var(--dur-fast) var(--ease-standard); }
.branchpick-arrow svg { width: 100%; height: 100%; }
.branchpick.is-open .branchpick-arrow { transform: rotate(180deg); }

.branchpick-panel {
  position: absolute; top: calc(100% + 6px); left: 0; z-index: 20;
  display: grid; grid-template-rows: 0fr; min-width: 280px;
  opacity: 0; visibility: hidden;
  transition: grid-template-rows var(--dur-panel) var(--ease-standard),
              opacity var(--dur-fast) var(--ease-standard),
              visibility 0s linear var(--dur-panel);
}
.branchpick.is-open .branchpick-panel {
  grid-template-rows: 1fr; opacity: 1; visibility: visible;
  transition: grid-template-rows var(--dur-panel) var(--ease-standard),
              opacity var(--dur-fast) var(--ease-standard),
              visibility 0s;
}
.branchpick-in { min-height: 0; overflow: hidden; }
.branchpick-card {
  background: var(--surface); border: 1px solid var(--border-weak); border-radius: var(--r-md);
  box-shadow: var(--elev-overlay); padding: var(--s2);
}
.branchpick-acts { display: flex; gap: var(--s1); padding: var(--s1) var(--s1) var(--s2); }
.branchpick-act {
  border: 1px solid var(--border-weak); border-radius: var(--r-pill);
  background: transparent; color: var(--fg-2);
  font: inherit; font-size: 12px; padding: 4px var(--s3); cursor: pointer;
  transition: background var(--dur-fast) var(--ease-standard), color var(--dur-fast) var(--ease-standard);
}
.branchpick-act:hover:not(:disabled) { background: var(--tint-4); }
.branchpick-act:disabled { opacity: 0.4; cursor: default; }
.branchpick-act:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--ring); }

.branchpick-list { display: flex; flex-direction: column; }
.branchpick-row {
  position: relative; display: flex; align-items: center; gap: var(--s3);
  min-height: 44px; padding: var(--s2) var(--s3); border-radius: var(--r-row);
  cursor: pointer; background: transparent;
  transition: background var(--dur-fast) var(--ease-standard);
}
.branchpick-row:hover { background: var(--tint-4); }
.branchpick-row.is-on { background: var(--accent-soft); }
.branchpick-row.is-locked { cursor: default; }
/* The native box carries the state and the keyboard; the drawn tick carries the look.
   Kept in the layout (opacity, not display) so ticking crossfades in place — a tick that
   is inserted has nothing to fade from. */
.branchpick-box { position: absolute; opacity: 0; width: 1px; height: 1px; margin: 0; }
.branchpick-tick {
  display: inline-flex; align-items: center; justify-content: center;
  width: 18px; height: 18px; flex: none;
  border: 1px solid var(--border-weak); border-radius: var(--r-sm); background: var(--surface);
  color: var(--btn-fg);
  transition: background var(--dur-fast) var(--ease-standard), border-color var(--dur-fast) var(--ease-standard);
}
.branchpick-tick svg { width: 12px; height: 12px; opacity: 0; transition: opacity var(--dur-fast) var(--ease-standard); }
.branchpick-row.is-on .branchpick-tick { background: var(--accent); border-color: var(--accent); }
.branchpick-row.is-on .branchpick-tick svg { opacity: 1; }
.branchpick-box:focus-visible + .branchpick-tick { box-shadow: 0 0 0 2px var(--ring); }
.branchpick-names { display: flex; align-items: baseline; gap: var(--s2); min-width: 0; }
.branchpick-name { font-size: var(--fs-sm); color: var(--fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.branchpick-short { font-family: var(--font-mono); font-size: 11px; color: var(--meta); flex: none; }
.branchpick-floor { margin: var(--s1) var(--s3) var(--s2); font-size: 11px; color: var(--meta); }

/* ─── the account menu ───────────────────────────────────────────────────────
   S02, qfMenu's first consumer. Same overlay shape as the branch picker just
   above — an absolutely-positioned panel so opening it never pushes the topbar
   — with none of the checkbox/tick machinery, since a menu item is just a row. */
.qfmenu { position: relative; display: inline-flex; }
/* The topbar copy is the account menu, and above 700px it is a second Profile button for a
   rail that is already showing Profile and Sign out — so it is gone there. Below 700px the
   rail is display:none and the tab bar has no Profile tab, which makes this the only route
   to Profile and the only way to sign out; that is why it is a media query and not a
   deletion. ponytail: one rule, no JS — restore Profile/Sign out to the mobile tab bar or
   to profile.html itself and this menu can go entirely. */
@media (min-width: 701px) { .topbar > .qfmenu { display: none; } }
.qfmenu-trigger {
  display: inline-flex; align-items: center; gap: var(--s2);
  min-height: 44px; padding: 7px var(--s3);
  border: 1px solid var(--border-weak); border-radius: var(--r-sm);
  background: var(--well); box-shadow: var(--inset-control);
  color: var(--fg); font: inherit; font-size: var(--fs-sm); cursor: pointer;
  max-width: 160px;
}
.qfmenu-trigger svg { width: 16px; height: 16px; flex: none; }
.qfmenu-trigger:hover { border-color: rgba(21, 107, 255, 0.24); }
.qfmenu-trigger:focus-visible { outline: none; box-shadow: var(--inset-control), 0 0 0 2px var(--ring); }
.qfmenu-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.qfmenu-panel {
  position: absolute; top: calc(100% + 6px); right: 0; z-index: 20;
  min-width: 160px; padding: var(--s2);
  background: var(--surface); border: 1px solid var(--border-weak); border-radius: var(--r-md);
  box-shadow: var(--elev-overlay);
  opacity: 0; visibility: hidden; transform: translateY(-4px);
  transition: opacity var(--dur-fast) var(--ease-standard), transform var(--dur-fast) var(--ease-standard),
              visibility 0s linear var(--dur-fast);
}
.qfmenu.is-open .qfmenu-panel {
  opacity: 1; visibility: visible; transform: translateY(0);
  transition: opacity var(--dur-fast) var(--ease-standard), transform var(--dur-fast) var(--ease-standard), visibility 0s;
}
.qfmenu-item {
  display: flex; align-items: center; min-height: 44px; padding: var(--s2) var(--s3);
  border-radius: var(--r-row); color: var(--fg); text-decoration: none; font-size: var(--fs-sm);
}
.qfmenu-item:hover, .qfmenu-item:focus-visible { background: var(--tint-4); outline: none; }
.qfmenu-item svg { width: 16px; height: 16px; flex: none; margin-right: var(--s2); color: var(--meta); }

/* ─── the dropdown (single-select listbox) ───────────────────────────────────
   The fourth member of this family — qfSelect() in ff-data.js. Every number below
   is a --dropdown-* token (see :root), and every token resolves to a value the three
   overlays above it already agreed on. Spec: docs/dropdown-spec.md. */
.ff-dd { position: relative; display: inline-flex; }
/* The native <select> stays in the DOM as the value/change carrier — kept 1px and
   opacity 0 rather than display:none, the .branchpick-box precedent, so .value,
   focus and form submission all keep working. */
/* select.ff-dd-native, not .ff-dd-native: the fallback also carries .ctl, and `.ctl` at :1104
   declares min-height/padding/border at the same (0,1,0) from further down the file — so every
   zeroing declaration here lost on source order, and the "1x1" box measured 26x44 in a browser
   (border-box, 12px of padding a side, the 44px tap floor). The tag takes it to (0,1,1), which
   is also what FF_CSS's select rule needed excluding to stop forcing page-level scroll.
   clip-path is the belt to those braces — paint-only, so it never mattered on its own. */
select.ff-dd-native { position: absolute; opacity: 0; width: 1px; height: 1px; min-height: 0; overflow: hidden; clip-path: inset(50%); margin: 0; padding: 0; border: 0; pointer-events: none; }
.ff-dd-trigger {
  display: inline-flex; align-items: center; gap: var(--dropdown-trigger-gap);
  min-height: var(--dropdown-min-target); padding: var(--dropdown-trigger-padding);
  border: 1px solid var(--dropdown-trigger-border); border-radius: var(--dropdown-trigger-radius);
  background: var(--dropdown-trigger-bg); box-shadow: var(--dropdown-trigger-shadow);
  font: inherit; font-size: var(--dropdown-trigger-size); color: var(--dropdown-trigger-color); cursor: pointer;
  transition: border-color var(--dropdown-duration) var(--dropdown-easing);
}
.ff-dd-trigger svg {
  width: var(--dropdown-caret-size); height: var(--dropdown-caret-size); flex: none;
  color: var(--dropdown-caret-color); transition: transform var(--dropdown-duration) var(--dropdown-easing);
}
.ff-dd-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ff-dd-trigger:hover { border-color: var(--dropdown-trigger-border-hover); }
.ff-dd.is-open .ff-dd-trigger, .ff-dd-trigger[aria-expanded="true"] { border-color: var(--dropdown-trigger-border-hover); }
.ff-dd.is-open .ff-dd-trigger svg, .ff-dd-trigger[aria-expanded="true"] svg { transform: rotate(180deg); }
.ff-dd-trigger:focus-visible { outline: none; box-shadow: var(--dropdown-trigger-shadow), 0 0 0 2px var(--dropdown-ring); }
.ff-dd-trigger:disabled, .ff-dd-trigger[disabled] { opacity: .4; cursor: not-allowed; }

.ff-dd-panel {
  position: absolute; top: calc(100% + var(--dropdown-offset)); left: 0; z-index: var(--dropdown-menu-z);
  display: flex; flex-direction: column;
  min-width: 100%; max-height: var(--dropdown-menu-max-height); overflow-y: auto;
  background: var(--dropdown-menu-bg); border: 1px solid var(--dropdown-menu-border);
  border-radius: var(--dropdown-menu-radius); box-shadow: var(--dropdown-menu-shadow);
  padding: var(--dropdown-menu-padding);
  opacity: 0; visibility: hidden; transform: translateY(-4px);
  transition: opacity var(--dropdown-duration) var(--dropdown-easing),
              transform var(--dropdown-duration) var(--dropdown-easing),
              visibility 0s linear var(--dropdown-duration);
}
.ff-dd.is-open .ff-dd-panel {
  opacity: 1; visibility: visible; transform: translateY(0);
  transition: opacity var(--dropdown-duration) var(--dropdown-easing),
              transform var(--dropdown-duration) var(--dropdown-easing), visibility 0s;
}

.ff-dd-opt {
  display: flex; align-items: center; gap: var(--dropdown-option-gap); width: 100%;
  min-height: var(--dropdown-min-target); padding: var(--dropdown-option-padding);
  border: 0; border-radius: var(--dropdown-option-radius); text-align: left;
  font: inherit; font-size: var(--dropdown-option-size); color: var(--dropdown-option-color);
  background: transparent; cursor: pointer;
  transition: background var(--dropdown-duration) var(--dropdown-easing);
}
.ff-dd-opt:hover, .ff-dd-opt:focus-visible { background: var(--dropdown-option-bg-hover); outline: none; }
/* Selected wins over hover — one background, never stacked tints. */
.ff-dd-opt[aria-selected="true"] { background: var(--dropdown-option-bg-selected); }
.ff-dd-opt:disabled { opacity: var(--dropdown-option-disabled-opacity); cursor: default; }
.ff-dd-opt-note { font-family: var(--dropdown-option-note-font); font-size: var(--dropdown-option-note-size); color: var(--dropdown-option-note-color); flex: none; }
/* Kept in the layout at opacity 0 rather than inserted on select — the .branchpick-tick
   precedent — so the tick crossfades in place instead of appearing from nothing. */
.ff-dd-check { flex: none; width: 16px; height: 16px; margin-left: auto; color: var(--dropdown-option-tick-color); opacity: 0; transition: opacity var(--dropdown-duration) var(--dropdown-easing); }
.ff-dd-opt[aria-selected="true"] .ff-dd-check { opacity: 1; }

/* ─── the combobox: qfCombo ──────────────────────────────────────────────────
   Same wrapper, same panel, same option rows as qfSelect — one skin, so a searchable field
   and a closed enum on the same screen are indistinguishable. What differs is the trigger:
   the INPUT is the trigger, and its affordance is the text caret, not a chevron.
   Spec: docs/superpowers/specs/2026-09-03-combobox-picker-design.md § Visual. */
/* display:block beats .ff-dd's inline-flex by source order, deliberately — both land on the
   same element (class="ff-dd ff-combo") with one class each, so the later rule wins and the
   field fills its column instead of shrink-wrapping. Moving this block above .ff-dd would
   silently undo it. */
.ff-combo { width: 100%; display: block; position: relative; }
.ff-combo .ff-combo-input { width: 100%; }
.ff-combo .ff-dd-panel { right: 0; }

/* The clear affordance on an optional pick. Absolutely positioned rather than in flow so
   showing and hiding it cannot reflow the field under the pointer — the same rule the option
   rows keep. Right-padding goes on the input so the caret never runs under it.
   --tap-min on coarse pointers because it is a real target on a phone; on a mouse it is the
   16px icon box the caret family already uses. */
.ff-combo .ff-combo-clear {
  position: absolute; top: 50%; right: var(--s2); transform: translateY(-50%);
  display: inline-flex; align-items: center; justify-content: center;
  width: 24px; height: 24px; padding: 0;
  border: 0; background: transparent; cursor: pointer;
  color: var(--meta); border-radius: var(--r-sm);
  transition: color var(--dur-fast) var(--ease-standard),
              background var(--dur-fast) var(--ease-standard);
}
.ff-combo .ff-combo-clear svg { width: 14px; height: 14px; }
.ff-combo .ff-combo-clear:hover { color: var(--fg); background: var(--tint-4); }
.ff-combo .ff-combo-clear:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--ring); }
/* Room for the button, so a long supplier name does not run under it. */
.ff-combo:has(.ff-combo-clear:not([hidden])) .ff-combo-input { padding-right: 32px; }
@media (pointer: coarse) {
  .ff-combo .ff-combo-clear { width: var(--tap-min); height: var(--tap-min); right: 0; }
  .ff-combo:has(.ff-combo-clear:not([hidden])) .ff-combo-input { padding-right: var(--tap-min); }
}
/* The matched span. Type, not highlighter: a background would fight the row's own
   hover/selected tint and stack two tints, which the option rules above forbid. */
.ff-dd-opt mark { background: none; color: var(--accent-ink); font-weight: 600; }
/* Keyboard-active row (aria-activedescendant) shares the hover tint — one tint, never two.
   Selected still wins, as it does for hover. */
.ff-dd-opt.is-active { background: var(--dropdown-option-bg-hover); }
.ff-dd-opt[aria-selected="true"].is-active { background: var(--dropdown-option-bg-selected); }
/* Unit beside the label, code right-aligned in mono — the slot .ff-dd-opt-note already is. */
.ff-dd-meta { color: var(--meta); font-size: var(--fs-sm); }
.ff-dd-opt .ff-dd-opt-note { margin-left: auto; }
.ff-dd-empty { padding: var(--dropdown-option-padding); color: var(--meta); font-size: var(--dropdown-option-size); }
.ff-dd-create {
  display: flex; align-items: center; gap: var(--s2); width: 100%;
  min-height: var(--dropdown-min-target); padding: var(--dropdown-option-padding);
  border: 0; border-top: 1px solid var(--border-weak);
  border-radius: 0 0 var(--dropdown-option-radius) var(--dropdown-option-radius);
  background: #fbfcff; color: var(--accent-ink); font: inherit; font-size: var(--dropdown-option-size); font-weight: 600;
  text-align: left; cursor: pointer;
  /* The same fade .ff-dd-opt carries. Without it the create row's hover snaps while every
     row above it eases, which is exactly the tell that gives away "one skin" as two. */
  transition: background var(--dropdown-duration) var(--dropdown-easing);
}
.ff-dd-create:hover, .ff-dd-create.is-active { background: var(--dropdown-option-bg-hover); }
.ff-dd-create .ff-dd-plus {
  flex: none; width: 20px; height: 20px; border-radius: 6px; display: grid; place-items: center;
  background: var(--accent); color: #fff; font-size: 14px; line-height: 1;
}
.ff-dd-kbd {
  display: flex; gap: var(--s3); padding: var(--s1) var(--s3);
  border-top: 1px solid var(--border); background: var(--well);
  font-family: var(--font-mono); font-size: 10px; color: var(--meta);
}
.ff-dd-kbd b { font-weight: 600; color: var(--muted); }
@media (pointer: coarse) { .ff-dd-kbd { display: none; } }
/* The create mini-form, inside the panel. .fld markup, the editors' own, so validation and
   spacing are theirs; only the frame is new. */
.ff-dd-form { display: flex; flex-direction: column; gap: var(--s2); padding: var(--s3); }
.ff-dd-form .fld { display: flex; flex-direction: column; gap: 4px; }
.ff-dd-form .fld > label { font-family: var(--font-mono); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; color: var(--meta); }
.ff-dd-form-acts { display: flex; gap: var(--s2); justify-content: flex-end; margin-top: var(--s1); }

/* ─── the table actions bar ──────────────────────────────────────────────────
   qfTableActions: a kebab beside the table for the table-level actions (CSV
   download, import, export), or — when there is only one — that action as a
   single square icon button. Both are 44px so the touch target never shrinks
   below the same floor every other control here keeps; only the label goes. */
.ff-tableactions { display: inline-flex; align-items: center; }
.qfmenu-trigger.is-icon {
  justify-content: center; gap: 0; width: 44px; min-width: 44px; max-width: 44px; padding: 0;
}
.ff-tableactions-one {
  display: inline-flex; align-items: center; justify-content: center;
  width: 44px; min-width: 44px; height: 44px; padding: 0;
  border: 1px solid var(--border-weak); border-radius: var(--r-sm);
  background: var(--well); box-shadow: var(--inset-control);
  color: var(--fg); font: inherit; font-size: var(--fs-sm); cursor: pointer;
}
.ff-tableactions-one svg { width: 16px; height: 16px; flex: none; }
.ff-tableactions-one:hover { border-color: rgba(21, 107, 255, 0.24); }
.ff-tableactions-one:focus-visible { outline: none; box-shadow: var(--inset-control), 0 0 0 2px var(--ring); }

/* The write-surface chip. Accent-tinted and pinned at the top of the drawer, because it
   answers the question you must not have to ask: where is this about to land. Two moods —
   a statement when one branch is in play, a required picker when several are — and the
   picker's untouched state is deliberately styled as unfinished business rather than as a
   neutral control, since the confirm below it is disabled until it is answered. */
.ff-branch-chip {
  display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap;
  margin-bottom: var(--s4); padding: var(--s2) var(--s3);
  border: 1px solid color-mix(in oklab, var(--accent) 22%, transparent);
  border-radius: var(--r-row); background: var(--accent-soft);
  font-size: var(--fs-sm);
  transition: border-color var(--dur-fast) var(--ease-standard), background var(--dur-fast) var(--ease-standard);
}
.ff-branch-chip.is-inline { margin-bottom: 0; padding: 4px var(--s3); }
.ff-branch-chip-label {
  font-family: var(--font-mono); font-size: 11px; letter-spacing: var(--track-caps);
  text-transform: uppercase; color: var(--meta);
}
.ff-branch-chip strong { color: var(--fg); }
.ff-branch-chip select { min-height: 36px; }
.ff-branch-chip[data-mode="pick"]:not([data-chosen]) {
  border-color: color-mix(in oklab, var(--warn) 45%, transparent);
  background: color-mix(in oklab, var(--warn) 8%, transparent);
}

main { padding: var(--s6) var(--s8) var(--s12); display: flex; flex-direction: column; gap: var(--s8); min-width: 0; }
section { min-width: 0; animation: rise 0.5s var(--ease-standard) both; }
section:nth-child(1) { animation-delay: 0ms; }
section:nth-child(2) { animation-delay: calc(var(--delay-stagger) * 1); }
section:nth-child(3) { animation-delay: calc(var(--delay-stagger) * 2); }
section:nth-child(4) { animation-delay: calc(var(--delay-stagger) * 3); }
section:nth-child(5) { animation-delay: calc(var(--delay-stagger) * 4); }
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }

/* ─── primitives ─────────────────────────────────────────────────────────── */
/* The frost panel. Layer order is load-bearing and reads bottom-up: the surface
   token underneath so the panel keeps its plane, the two card-tier splashes over
   it in opposite corners, the top-down glass sheen above those, and the white
   tooth on top. Drop the base fill and the card becomes a gradient swatch. */
.card {
  background:
    var(--grain-panel) repeat 0 0 / 160px 160px,
    var(--sheen),
    var(--splash-card),
    var(--frost);
  -webkit-backdrop-filter: var(--blur); backdrop-filter: var(--blur);
  border: 1px solid var(--border); border-radius: var(--r-md); padding: var(--s5);
  box-shadow: var(--bevel-top), var(--elev-panel);
  transition: border-color var(--dur-base) var(--ease-standard), box-shadow var(--dur-base) var(--ease-standard);
}
/* Mandatory, not optional: without blur support the frost is a 62% white film over
   whatever moves behind it, and the text on it stops being legible. The fallback
   is an opaque --surface, which is exactly the v1 look. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .card { background: var(--grain-panel) repeat 0 0 / 160px 160px, var(--surface); }
  .rail, .topbar { background: var(--surface); }
}
.card-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--s3); margin-bottom: var(--s4); flex-wrap: wrap; }
/* A card inside a card keeps the fill family, drops to the weaker border, and
   carries NO shadow of its own — a drop shadow inside an already-floating panel
   double-counts depth and the whole stack goes to mush. */
.card .card { background: var(--frost-soft); border-color: var(--border-weak); box-shadow: none; border-radius: var(--r-row); backdrop-filter: none; -webkit-backdrop-filter: none; }
.label { font-family: var(--font-mono); font-size: 11px; letter-spacing: var(--track-caps); text-transform: uppercase; color: var(--meta); display: flex; align-items: center; gap: 6px; }
.label svg { width: 14px; height: 14px; flex-shrink: 0; }
.meta { font-family: var(--font-mono); font-size: var(--fs-xs); color: var(--meta); }
.num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.dim { color: var(--muted); }
.small { font-size: var(--fs-sm); }
.grid { display: grid; gap: var(--s5); }
.row { display: flex; align-items: center; gap: var(--s3); }
.between { display: flex; align-items: center; justify-content: space-between; gap: var(--s3); }
.rule { border: 0; border-top: 1px solid var(--border); margin: 0; }

/* Every quantity, percentage, delta, SKU, unit and timestamp is mono with tabular
   figures, so columns align down a table and a figure does not jitter as it
   updates. This is the single loudest signal in the language: a number in the body
   face reads as marketing, a number in mono reads as a reading. */
.num, .meta, .ff-id, .ff-code, .ff-val, .ag-by, .ag-what, .cal-date,
table.ff-table td.r, .ff-kv > span:last-child,
.lines td.n, .lines th.n, .big, .figure, .unit, kbd {
  font-variant-numeric: tabular-nums;
  font-feature-settings: "tnum" 1;
}
/* The figure tier — one number per card, at the size where the reading is the
   content rather than a label on it. */
.figure { font-family: var(--font-mono); font-size: var(--fs-figure); line-height: 0.95; letter-spacing: -0.03em; font-weight: 600; }
.unit { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--meta); }
/* Keyboard-first affordances are visible, not hidden. The product is operated,
   not browsed. */
kbd {
  font-family: var(--font-mono); font-size: 11px; line-height: 1;
  padding: 4px 6px; border: 1px solid var(--border); border-radius: 2px;
  background: var(--frost-strong); color: var(--fg-2); box-shadow: var(--bevel-top);
}

/* S04/S15: the UA stylesheet's [hidden]{display:none} loses to any later same-specificity
   display rule — .btn's inline-flex among them — so a hidden .btn stayed visible/clickable
   at rest (ledger Clear, profile Save). !important restores `hidden` as the one thing that
   always wins; .ff-reveal panels that are also `hidden` (S01 import cluster, items.html's
   newStockFld) collapse the same way until the attribute is removed. */
[hidden] { display: none !important; }

.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: var(--s2);
  min-height: var(--tap-min);
  padding: var(--s2) var(--s4); border-radius: var(--r-sm); border: 1px solid transparent;
  background: transparent; font-size: var(--fs-body); line-height: var(--lead-body);
  transition: opacity var(--dur-fast) var(--ease-standard), background var(--dur-fast) var(--ease-standard);
  transition-property: opacity, background, transform;
}
/* Press is a confirmation, not a decoration — 3% scale, same as the landing page. */
.btn:active { opacity: .8; transform: scale(.97); }
@media (hover: hover) { .btn.btn-primary:hover { opacity: .92; } }

/* Every variant below is written .btn.btn-x, not .btn-x, and the doubled class is
   load-bearing rather than stylistic.

   Twelve pages still carry their own `.btn { … background: transparent }` base rule,
   because each differs from this one by its transition and so survived extraction.
   Those blocks are inline <style>, which loads AFTER this file, and a bare .btn-primary
   is specificity (0,1,0) — exactly the same as .btn. Later wins, so the page's
   transparent background overrode the ink fill while --btn-fg stayed near-white, and
   every primary button on those screens rendered as a white ghost box on a card.
   The first-run checklist's "Start" was the visible symptom.

   .btn.btn-primary is (0,2,0) and cannot be reached by a later single-class rule, so
   the variants win wherever they are declared. This costs one class in the selector and
   removes the whole category of failure — including padding on .btn-sm, which the same
   page-local .btn was quietly resetting. */
.btn.btn-primary {
  /* ink-filled, never blue: the control-tier splash rides ON the ink at low alpha,
     so it picks up the light without becoming a blue button */
  background: radial-gradient(240px 120px at 100% 12%, rgba(21, 107, 255, 0.34), transparent 68%), var(--fg);
  color: var(--btn-fg); box-shadow: var(--inset);
}
.btn.btn-ghost { border-color: var(--meta); }
.btn.btn-ghost:hover { background: var(--tint-4); }
.btn.btn-quiet { color: var(--muted); padding-inline: var(--s2); }
.btn.btn-quiet:hover { color: var(--fg); background: var(--tint-4); }
.btn.btn-sm { font-size: var(--fs-sm); padding: 6px var(--s3); min-height: 36px; }
/* The floor held for chrome and broke at the write action: .btn.btn-sm is (0,2,0), so the
   `.btn { min-height: 44px }` that dashboard.html and sales.html declare at ≤700px never reached
   it — Create order ×10 on supply, the forecast row pencil and every control on Profile all
   measured 36px. Media queries add no specificity, so this must stay AFTER the line above.
   Coarse pointer rather than a width query: a 36px button is wrong for a thumb on a tablet too. */
@media (pointer: coarse) { .btn.btn-sm { min-height: var(--tap-min); } }
.btn.btn-pill { border-radius: var(--r-pill); }
/* Icon buttons: actionIcon()'s <svg> carries a viewBox and no width/height, which as a flex
   item of .btn resolves to 0×0 — measured in Chromium, not assumed — so an icon-only button
   paints an empty box. Sized here rather than at each call site: .railnav a svg (:329) and
   .qfmenu-trigger svg (:455) already do the same for their own glyphs, and both are more
   specific than this, so neither moves. 18px matches the rail. */
.btn > svg { width: 18px; height: 18px; flex: none; }
.btn[disabled] { opacity: .4; cursor: not-allowed; }
/* Nothing translates on hover except the arrow. */
.btn .arrow { display: inline-block; transition: transform var(--dur-fast) var(--ease-standard); }
.btn:hover .arrow { transform: translateX(3px); }

/* ff-validate.js: shared invalid-field and blocked-control styling. See docs/error-copy-
   validation-audit.md and ff-validate.js's own header for why this exists — three screens
   were about to grow three copies of "what does wrong look like". */

/* A field the user got wrong (.ff-invalid, toggled by qfInvalid). Red alone is not the
   signal — qfInvalid also sets aria-invalid — but the outline is what a sighted user scans
   the form for, so it has to beat whatever border-color the base input/select/textarea rule
   already carries. */
.ff-invalid, input.ff-invalid, select.ff-invalid, textarea.ff-invalid {
  border-color: var(--danger);
  background: color-mix(in oklab, var(--danger), transparent 96%);
}
/* The inline message under a field, placed by the caller — qfWhy() supplies the text,
   qfInvalid() does not render it. font-size matches .ctl's own so the message reads as part
   of the same control rather than a caption on the form. */
.ff-fielderr {
  display: block; margin-top: var(--s1); font-size: var(--fs-sm); color: var(--danger-ink);
}
/* The touch/AT twin of the [data-tip] tooltip further down: ff-blocked.js renders a
   blocked control's own tip text into one of these on click, because a ::after on :hover
   reaches neither a phone nor a screen reader — docs/ui-ux-audit-2026-09-10.md B-03,
   B-13, C-26. flex-basis so it drops to its own line inside the wrapping flex rows these
   controls sit in (profile.html's .frow) instead of squeezing the row it follows. */
.ff-blocked-why {
  flex-basis: 100%; margin-top: var(--s1);
  font-size: var(--fs-sm); color: var(--danger-ink);
}

/* A control that cannot be used yet (.is-blocked, toggled by qfBlock). aria-disabled, not
   the disabled attribute — see ff-validate.js's header — so this has to read as "not yet"
   through style alone: hollow, not filled, because a filled-but-faded button (.btn[disabled])
   already means something else on these screens. Transparent background and an outline-only
   border are the whole trick; the hover rules below exist only to cancel .btn-primary's
   ink fill and .btn-ghost's tint hover so a blocked button never looks pressable. */
/* !important on the skin, not just on the cursor, and for the same reason the cursor
   already had it: this rule has to beat whatever skin the control is already wearing, and
   some of those skins live in a page's own <style>, which is always later in source order
   than this file. support.html's .auth-cta (support.html:57) ties this at (0,1,0) and wins
   on order, so a blocked Send message rendered pixel-identical to a live ink primary —
   docs/ui-ux-audit-2026-09-10.md B-03. opacity is pinned too because
   .auth-cta:hover:not([disabled]) (support.html:59) is (0,3,0); a hollow button that fades
   on hover is still claiming to be pressable.
   ponytail: a state class carrying !important, rather than a per-page-skin rule here
   (.auth-cta.is-blocked, .some-next-cta.is-blocked, …) that would need a new line every
   time a screen grows a CTA class and would still lose the :hover tie. */
.is-blocked {
  background: transparent !important; color: var(--meta) !important;
  border-color: var(--meta) !important; opacity: 1 !important;
  cursor: not-allowed !important;
}
.btn.is-blocked, .btn.is-blocked:hover, .btn.is-blocked:active {
  background: transparent; color: var(--meta); border-color: var(--meta);
  opacity: 1; transform: none; box-shadow: none;
}

/* [data-tip] fallback for pages that carry no ff-data.js — auth.html, support.html — so
   qfHelp()'s tooltip still renders with no JS beyond ff-validate.js itself. Copied from
   ff-data.js's injected style block (its own [data-tip]:hover::after, ~line 3283) rather
   than shared, because that block ships only to screens which load ff-data.js and this file
   is the one both kinds of page load. Keep the two in sync by hand; ff-tip-check.js and
   ff-validate-check.js each assert their own copy is still there. */
[data-tip] { position: relative; }
[data-tip]:hover::after, [data-tip]:focus-visible::after {
  content: attr(data-tip);
  position: absolute; left: 50%; bottom: calc(100% + 8px); transform: translateX(-50%);
  z-index: 6; white-space: pre-wrap; max-width: min(280px, 80vw); text-align: center;
  font-size: var(--fs-sm); line-height: 1.35;
  background: var(--fg); color: var(--btn-fg);
  padding: 6px 9px; border-radius: var(--r-sm);
  box-shadow: var(--float, 0 4px 12px -4px rgba(37, 50, 72, .28));
  pointer-events: none;
}

/* .ff-reveal fallback, same reason and same contract as the [data-tip] block above:
   copied from ff-data.js's injected style block (~line 3886) because that block ships only
   to screens which load ff-data.js, and auth.html / support.html — the two pages carrying
   the most appearing error text in the product — deliberately do not. Without this,
   ff-fieldwatch.js's inline message had nowhere shift-free to appear: it was unhidden on
   focusout, which browsers fire on the MOUSEDOWN of the click aimed at the submit button
   below it, so the button moved out from under the pointer between mousedown and mouseup and
   the click never fired. See docs/layout-stability-audit.md L1.

   One deliberate difference from the original, and the only one: --motion-base/--motion-exit
   are declared inside ff-data.js's own block (:3926) as aliases of --dur-enter/--dur-exit, so
   on a page without ff-data.js they resolve to nothing and the whole transition is dropped —
   a snap-cut, which is the thing this rule exists to prevent. The var() falls through to the
   --dur-* token this file already declares, and whose reduced-motion block already collapses
   it. Keep the rest in sync with ff-data.js by hand; ff-flowshift-check.js asserts both
   copies are still there. */
.ff-reveal { display: grid; grid-template-rows: 0fr;
  transition: grid-template-rows var(--motion-exit, var(--dur-exit)) var(--ease-standard, ease); }
.ff-reveal[data-open] { grid-template-rows: 1fr;
  transition: grid-template-rows var(--motion-base, var(--dur-enter)) var(--ease-standard, ease); }
.ff-reveal > * { min-height: 0; overflow: hidden;
  opacity: 0; visibility: hidden;
  transition: opacity var(--motion-exit, var(--dur-exit)) var(--ease-standard, ease),
              visibility 0s var(--motion-exit, var(--dur-exit)); }
/* visibility as well as opacity, so a closed reveal's controls leave the tab order and the
   accessibility tree - opacity:0 alone left them focusable. Mirrors ff-data.js. */
/* The clip is released once the track has stopped growing, and the delay is the whole point.
   overflow:hidden is what keeps the content inside a box that is still opening, but it also
   makes that box a SCROLL CONTAINER with no scrollbar — and a panel opened inside it (any
   qfSelect, qfCombo or tooltip in a reveal) reaches below the box's edge, so the browser
   scrolls the box to bring the focused row into view and nothing can scroll it back. That is
   how items.html's recipe rows scrolled themselves out of sight behind an open item dropdown
   and stayed gone until reload. Settled open, the track is exactly content height: there is
   nothing left to clip, so not clipping costs nothing and buys back both the panel and the
   unreachable scroll. overflow is a discrete property, hence `0s <delay> allow-discrete` — it
   flips when the track finishes rather than on the first frame, which would put the spill
   straight back. Closing needs no delay: [data-open] stops matching and the rule above
   re-clips immediately, which is what the collapse wants. ff-reveal-clip-check.js gates it. */
.ff-reveal[data-open] > * { opacity: 1; visibility: visible; overflow: visible;
  transition: opacity var(--motion-base, var(--dur-enter)) var(--ease-standard, ease) var(--delay-stagger),
              visibility 0s,
              overflow 0s var(--motion-base, var(--dur-enter)) allow-discrete; }
/* The track stops animating — height is movement — but the content still fades, so an error
   message does not blink into existence. Mirrors ff-data.js's own split at :4013. */
@media (prefers-reduced-motion: reduce) {
  .ff-reveal { transition-duration: .01ms; }
  .ff-reveal > * { transition-duration: 120ms; transition-delay: 0s; }
}

.tag {
  display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px;
  border: 1px solid var(--border); border-radius: var(--r-pill);
  font-size: 11px; font-family: var(--font-mono); letter-spacing: .04em; color: var(--meta);
  background: var(--frost-strong);
}
.dot { width: 7px; height: 7px; border-radius: var(--r-pill); background: var(--muted); flex: none; transition: background 0.3s var(--ease-standard); }
.is-ok .dot, .dot.ok { background: var(--success); }
.is-low .dot, .dot.low { background: var(--warn); }
.is-bad .dot, .dot.bad { background: var(--danger); }
/* the dots above are fills and keep the raw hue; these are type and take the ink */
.is-ok { color: var(--success-ink); }
.is-low { color: var(--warn-ink); }
.is-bad { color: var(--danger-ink); }
.flag-accent { color: var(--accent-ink); }

/* The control tier: one splash, top-right, plus a border tinted to the same accent
   at 24%. One splash only — two would make it a card. */
.ctl {
  min-height: var(--tap-min);
  padding: 7px var(--s3); border: 1px solid var(--border); border-radius: var(--r-sm);
  background: var(--well); box-shadow: var(--inset-control); font-size: var(--fs-sm); max-width: 100%;
}
input.ctl { font-family: var(--font-mono); }

/* A control that is not yours to type in. The stock drawer's Unit cost goes `disabled` the
   moment a case pack is filled — from then on it is the case price divided down — and until
   this rule existed it did that with no visual change at all: same white well, same black
   ink, opacity 1, pixel-identical to the two live fields beside it. cursor:not-allowed is
   the pair .btn[disabled] already uses, so "off" reads the same on a control as on a button;
   the flat well and the meta ink are what say the box itself has stopped being an input.
   Not opacity: these fields sit next to live ones and a faded-but-white box reads as a
   rendering artefact rather than a state.
   -webkit-text-fill-color because iOS Safari greys disabled text with its own value and
   ignores `color` — the same reason the autofill block below sets it.
   (0,2,0) deliberately: ff-data.js injects `select.ctl,input.ctl,textarea.ctl` (0,1,1) into
   <head> at runtime, which lands after this file, so specificity rather than source order is
   what keeps this skin. ff-stock-drawer-check.js asserts the rule is here. */
.ctl:disabled {
  background: var(--tint-4); box-shadow: none;
  color: var(--meta); -webkit-text-fill-color: var(--meta);
  cursor: not-allowed;
}

/* Autofilled fields. Chrome paints its own #E8F0FE over anything we set, and it paints it
   with `background-color`, which no cascade of ours outranks — the one property it does not
   own is the inset box-shadow, so a 1000px inset spread is the standard way to put our own
   surface back. -webkit-text-fill-color goes with it because `color` is overridden too.
   The transition is the second half of the same trick: Chrome re-applies its blue on blur
   and on re-fill, and a background-color transition delayed past any real session means that
   repaint never arrives.
   :autofill is the standard selector (Firefox, and Chrome from 108); :-webkit-autofill is
   what Safari and older Chrome answer to. They are SEPARATE rules on purpose — one unknown
   selector voids the whole list it sits in, and Safari does not know :autofill.
   No :where() here either, deliberately: auth.html styles `.field input` in its own page CSS
   at (0,1,1), and a :where()-wrapped rule scores 0 for its list and loses the box-shadow to
   it. The class has to be in the selector to outrank the page.
   Mobile: nothing here touches height or padding, so the field an autofill lands in is the
   same tap target it was empty — .ctl keeps its var(--tap-min). */
.ctl:autofill, .field input:autofill, .fld input:autofill,
.ctl:autofill:hover, .field input:autofill:hover, .fld input:autofill:hover,
.ctl:autofill:focus, .field input:autofill:focus, .fld input:autofill:focus {
  box-shadow: var(--inset-control), 0 0 0 1000px var(--well) inset;
  -webkit-text-fill-color: var(--fg);
  caret-color: var(--fg);
  transition: background-color 600000s 0s, border-color .15s var(--ease-standard);
}
.ctl:-webkit-autofill, .field input:-webkit-autofill, .fld input:-webkit-autofill,
.ctl:-webkit-autofill:hover, .field input:-webkit-autofill:hover, .fld input:-webkit-autofill:hover,
.ctl:-webkit-autofill:focus, .field input:-webkit-autofill:focus, .fld input:-webkit-autofill:focus {
  box-shadow: var(--inset-control), 0 0 0 1000px var(--well) inset;
  -webkit-text-fill-color: var(--fg);
  caret-color: var(--fg);
  transition: background-color 600000s 0s, border-color .15s var(--ease-standard);
}
/* The focus ring survives: the :focus-visible rule near the top of the control tier also sets
   box-shadow, and without these the autofill rules would outrank it and swallow the ring. */
.ctl:autofill:focus-visible, .field input:autofill:focus-visible, .fld input:autofill:focus-visible {
  box-shadow: var(--inset-control), 0 0 0 1000px var(--well) inset, 0 0 0 2px var(--ring);
}
.ctl:-webkit-autofill:focus-visible, .field input:-webkit-autofill:focus-visible, .fld input:-webkit-autofill:focus-visible {
  box-shadow: var(--inset-control), 0 0 0 1000px var(--well) inset, 0 0 0 2px var(--ring);
}
input.ctl[type="search"], .searchbox {
  border-color: rgba(21, 107, 255, 0.24);
  background: var(--splash-ctl), var(--frost-soft);
  box-shadow: none;
}
.searchbox {
  display: flex; align-items: center; gap: var(--s2);
  height: 36px; padding: 0 var(--s2) 0 var(--s3);
  border: 1px solid rgba(21, 107, 255, 0.24); border-radius: var(--r-sm);
  color: var(--meta); font-family: var(--font-mono); font-size: var(--fs-xs);
}
/* ↑↓ / ↵ / esc under any keyboard-navigable list */
.keyhints { display: flex; align-items: center; gap: var(--s2); font-family: var(--font-mono); font-size: 11px; color: var(--meta); margin-top: var(--s3); }

/* ─── row state ladder ───────────────────────────────────────────────────────
   Rest is translucent frost with a weak border and no shadow. Promoted goes to
   near-solid warm white, picks up an accent-tinted border, and only THEN earns a
   shadow plus a 1px lift — that combination is what makes a row read as chosen
   rather than merely different. Active inverts to a solid accent fill with a
   shadow tinted to the SAME accent: the one sanctioned exception to the
   blue-slate rule, because the light source is now the element itself.

   Frost is deliberately absent here. A backdrop-filter per row is a repaint per
   row, and inventory.html and items.html run to hundreds of them. */
.ff-agenda .ag-row, .listrow {
  background: var(--frost-soft);
  border: 1px solid var(--border-weak);
  border-radius: var(--r-row);
  transition: background var(--dur-fast) var(--ease-standard), border-color var(--dur-fast) var(--ease-standard),
              box-shadow var(--dur-fast) var(--ease-standard), transform var(--dur-fast) var(--ease-standard);
  /* §0.3 mobile: the CSS half of ffTapGuard. The guard cancels the tap once a drag has
     started, but without pan-y the browser can still treat the row as the gesture target
     and eat the scroll. Both halves or neither. */
  touch-action: pan-y;
}
.ff-agenda .ag-row:hover, .listrow:hover, .listrow.is-promoted {
  background: var(--frost-promoted);
  border-color: rgba(21, 107, 255, 0.24);
  box-shadow: var(--elev-promoted);
  transform: translateY(-1px);
}
.listrow.is-active {
  background: var(--accent); border-color: rgba(21, 107, 255, 0.28);
  box-shadow: var(--elev-active);
  transform: translateY(-1px);
  color: var(--bg);
}

/* ─── responsive ─────────────────────────────────────────────────────────── */
@media (max-width: 900px) {
  :root { --rail: 68px; }
  .rail { padding: var(--s5) var(--s2); align-items: center; }
  .brand { padding-inline: 0; justify-content: center; }
  .brand b, .brand .sep, .railnav a span.txt, .railnav a .tag, .railfoot { display: none; }
  .railnav { width: 100%; }
  .railnav a { justify-content: center; padding: 11px 0; }
  main, .topbar { padding-inline: var(--s5); }
}
@media (max-width: 700px) {
  .topbar { flex-wrap: nowrap; }
  /* flex-basis auto, not 0 (audit §2.2 asked for `flex:1`, which is basis 0): from 0 the
     trail only ever got what the branch picker left over, so between 481px — where S02's
     wrap stops applying — and ~660px it was handed 170px for 226px of crumbs. The root
     crumb ellipsised to 0px wide on Inventory at 500px, i.e. the way back to the dashboard
     vanished, and the tail cut mid-word. From auto the trail asks for its content first and
     the picker, which already ellipsises against its own min-width:0 below, is what yields.
     Still one row, which is what §2.2 is about. */
  .topbar-id { flex: 1 1 auto; overflow: hidden; }
  .topbar-id .eyebrow, .topbar-id h1 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  /* The footer's bottom clearance matches the fixed tabbar (the tabbar is the last
     thing a thumb reaches, so the legal line must not slide under it). */
  .appfoot { padding-bottom: calc(var(--s4) + 104px); }
  /* M03: the demo banner keeps its verdict and its exit, loses its essay. The full
     explainer wraps to three lines here (89px, measured); "Demo data." + the sign-in
     link says the same thing in one. sb-guard.js marks the cut. */
  [data-od-id="demo-mode-banner"] [data-demo-sub] { display: none; }
  /* M01: the account menu goes icon-only. The trigger's business-name label is the third
     copy of it in a 390px topbar (root crumb, branch picker, this) and it is what pushed
     the menu onto a wrap row of its own — a 157px topbar on dashboard, measured. The label
     is clipped, not display:none, so it stays the trigger's accessible name. */
  .topbar > .qfmenu .qfmenu-trigger { justify-content: center; gap: 0; width: var(--tap-min); min-width: var(--tap-min); padding: 0; }
  .topbar > .qfmenu .qfmenu-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
  /* And the branch picker may shrink: a long branch name ellipsises inside the trigger
     instead of shoving the row into a wrap. */
  .topbar [data-od-id="branch-pick-mount"] { min-width: 0; }
  .topbar .branchpick { min-width: 0; max-width: 100%; }
  .topbar .branchpick-trigger { max-width: 100%; min-width: 0; }
  .topbar .branchpick-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
  /* M02: the topbar yields on the way down. Reading direction is downward and the phone
     viewport is already paying for Safari's own chrome, so once the reader is past the
     header the bar slides off (exit) and any upward scroll brings it back (enter) —
     ff-data.js's chromeElevation owns the class. `html .topbar` outranks ff-data.js's
     later-injected `.topbar,.tabbar` box-shadow transition, which this restates so the
     lift keeps animating too. Reduced motion: both durations are tokens, so the :root
     collapse makes the hide instant without a rule here. */
  html .topbar {
    transition: box-shadow var(--dur-panel) var(--ease-standard),
                transform var(--dur-enter) var(--ease-move);
  }
  html.ff-chrome-hide .topbar {
    transform: translateY(calc(-100% - 1px));   /* -1px: the border-bottom goes too */
    transition: box-shadow var(--dur-panel) var(--ease-standard),
                transform var(--dur-exit) var(--ease-move);
  }
}
/* S02: below 480px even the trimmed nowrap row (topbar-id + branch picker + alert
   button + sync pill + account menu, dashboard's worst case) overflows and pushes
   the account-menu trigger off-screen — Sign out becomes unreachable on a phone.
   Wrapping to a second row keeps every control in the viewport; the other eleven
   screens already fit in one row at this width, so wrap is a no-op for them.
   Stays at 480: the 481–700 band is fixed by the flex-basis above, in one row, which is
   what §2.2 wants. This rule is for the width where one row genuinely cannot hold it. */
@media (max-width: 480px) {
  .topbar { flex-wrap: wrap; }
  /* And the trail takes the whole first row. `.topbar-id{flex:1}` above is flex-basis:0,
     so the crumbs only got the space the branch picker left over — at 390px that ellipsised
     "Tita Belen's Burgers" down to "Tit…", which is not a business name and not a link
     anyone would aim at. A full-width basis pushes the picker onto the wrap row instead. */
  .topbar-id { flex-basis: 100%; }
  /* M01b: only the branch picker earns that second row. On the eight screens without one
     the wrap row held a lone 44px account icon, so the trail keeps flex:1 and shares the
     row with it — a one-row topbar. A browser without :has keeps the two-row layout,
     which is today's behaviour, not a broken one. */
  .topbar:not(:has(.branchpick)) .topbar-id { flex-basis: 0; }
}
/* M04: between S02's wrap and the rail, one row is the rule and the root crumb is the crumb
   to lose. Measured on Inventory at 500px: the trail wants 381px (business 156 + Inventory
   77 + tail 92 + two 28px separators) and the row can spare 248 beside the branch picker, so
   something has to give. Dropping the business name costs least — the picker next to it
   already says which branch, the account menu carries the business, and Dashboard, the only
   thing the root crumb ever linked to, is a tab away at this width. What is left,
   "Inventory > Stock levels", is 197px and fits whole, which is the half a reader needs.
   Below 481px S02 hands the trail its own row and the whole thing fits, so this stops. */
@media (min-width: 481px) and (max-width: 700px) {
  .topbar-id h1.crumbs > a:first-child,
  .topbar-id h1.crumbs > a:first-child + .sep { display: none; }
  /* What is left of the trail then holds its ground and the picker is what ellipsises —
     "Stock levels" beats a fuller branch name that the row already repeats in the location
     it filters. Capped at 70% so a long page name cannot walk the account menu off the
     right edge, which is the failure S02 exists to prevent. */
  .topbar-id { flex-shrink: 0; max-width: 70%; }
}
/* Frost goes opaque on phones. A low-end Android pays for backdrop-filter in
   scroll frames, and the fallback is the same surface the @supports block already
   ships, so nothing else has to change. */
@media (max-width: 720px) {
  .card, .rail, .topbar, .tabbar {
    -webkit-backdrop-filter: none; backdrop-filter: none;
  }
  .card { background: var(--grain-panel) repeat 0 0 / 160px 160px, var(--sheen), var(--splash-card), var(--surface); }
  .rail, .topbar, .tabbar { background: var(--surface); }
  /* Scroll-frame cost trio (mobile audit §2.3): fixed background-attachment repaints the
     gradient on every scroll frame, and the two fixed filtered/blended layers recomposite
     every frame too — all decoration, none of it worth paying for on a phone. */
  body { background-attachment: scroll; }
  body::before, body::after { filter: none; mix-blend-mode: normal; opacity: 1; }
}

/* ─── reduced motion ─────────────────────────────────────────────────────────
   Strips the 1px row lift and the arrow translate; the colour, border and opacity
   halves of every state change survive, so state is still conveyed without motion. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
  .ff-agenda .ag-row:hover, .listrow:hover, .listrow.is-promoted, .listrow.is-active { transform: none; }
  .btn:hover .arrow { transform: none; }
  .btn:active { transform: none; }
}

/* ─── scroll-edge affordance ────────────────────────────────────────────────
   Six findings, one missing signal: at 390px the stock table is 898px in a 316px window with
   STATUS off-screen (A-04), items hides its whole action column the same way (B-16), supply's
   order plan is 1,171px with SUGGESTED ORDER off-screen (C-13), forecast's matrix shows 4 of 15
   columns (C-23) and onboarding's CSV preview cuts QTY SOLD (C-34). Every one of them measured
   `mask-image: none` and `box-shadow: none`. The mask rides on the scroll container, so it stays
   pinned to the right edge of the box while the content moves under it.
   ponytail: the veil is constant, not driven by scroll position — at the very end of a sideways
   scroll the last 24px is still at 55% alpha (legible, and this is one declaration). The
   conditional version is `animation-timeline: scroll(self inline)`, which needs a keyframe, an
   @supports fence and an answer for the inactive-timeline case on tables that do not overflow.
   Scoped to ≤700px for the same reason: every one of these wraps demonstrably overflows there,
   and on a desktop where they do not, a veil would be a lie. */
@media (max-width: 700px) {
  .ff-table-wrap, .mx-wrap, .ff-igrid-wrap {
    mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 24px), rgba(0, 0, 0, .55) 100%);
    box-shadow: inset -14px 0 14px -14px rgba(0, 0, 0, .22);
  }
}

/* ─── public marketing chrome ────────────────────────────────────────────────
   The canonical header and footer (brand/chrome-*.html, pinned by
   public-chrome-check.js) are pasted into all twenty public pages. Eight of them
   already carry their own chrome CSS in their own <style>; four — contact, privacy,
   terms and support — carried a minimal bespoke bar, or in support's case nothing at
   all, and so have no rules for it. These are those rules.

   Everything in the first group is wrapped in :where(), which drops its specificity to
   the bare class. That is the whole point: this is a default for the four bare pages,
   and any page that already styles its own chrome keeps winning on source order
   (ff-theme.css is <link>ed before every page's <style>). Without it, adding
   `.topnav .container` here at (0,2,0) would silently override about/case-studies/guides'
   own `.container` and move their nav gutters — a shared stylesheet reaching into eight
   pages that never asked.
   ponytail: shared here rather than pasted a fourth time because these four pages all
   link ff-theme.css already. index.html does not, and keeps its own copy. */
:where(.topnav) { position: sticky; top: 0; z-index: 20; border-bottom: 1px solid var(--border);
  background: color-mix(in oklab, var(--bg) 92%, transparent);
  -webkit-backdrop-filter: blur(12px) saturate(1.25); backdrop-filter: blur(12px) saturate(1.25); }
:where(.topnav .container) { max-width: 1200px; margin-inline: auto; padding: 14px var(--s6);
  display: flex; align-items: center; justify-content: space-between; gap: var(--s4); }
:where(.topnav .logo) { display: inline-flex; align-items: center; min-height: var(--tap-min); text-decoration: none; }
:where(.topnav nav) { display: flex; align-items: center; gap: var(--s8); }
:where(.topnav nav a) { font-size: var(--fs-sm); color: var(--muted); text-decoration: none;
  transition: color var(--dur-fast) var(--ease-standard); }
:where(.topnav nav a:hover, .topnav nav a:focus-visible, .topnav nav a[aria-current="page"]) { color: var(--fg); }
:where(.topnav nav a.nav-login) { padding: 7px 15px; border-radius: 9999px;
  background: color-mix(in oklab, var(--accent) 10%, transparent);
  border: 1px solid color-mix(in oklab, var(--accent) 28%, transparent); color: var(--accent); }
/* Same breakpoint the other eight pages use: below 780px the six section links fold away
   and the one auth door stays. */
@media (max-width: 780px) { :where(.topnav) nav a:not(.nav-login) { display: none; } }

:where(.pagefoot) { border-top: 1px solid var(--border); text-align: left;
  font-size: var(--fs-sm); color: var(--muted); }
:where(.pagefoot .container) { max-width: 1200px; margin-inline: auto; padding: var(--s12) var(--s6); }
:where(.pagefoot .footlinks) { list-style: none; display: flex; flex-wrap: wrap;
  column-gap: var(--s5); margin: 0 0 var(--s6); padding: 0; }
:where(.pagefoot .footlinks a) { color: var(--muted); text-decoration: none;
  transition: color var(--dur-fast) var(--ease-standard); }
:where(.pagefoot .footlinks a:hover, .pagefoot .footlinks a:focus-visible,
       .pagefoot .footlinks a[aria-current="page"]) { color: var(--fg); }
:where(.pagefoot .row-between) { display: flex; align-items: center; justify-content: space-between;
  gap: var(--s5); flex-wrap: wrap; margin-top: var(--s6); }
:where(.pagefoot .pill) { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px;
  border: 1px solid var(--border); border-radius: 9999px; color: var(--muted);
  font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase; }

/* DESIGN.md:252-253 — the 44px floor holds "including … footer links (grown by
   pseudo-element overlay, not padding)". It was never shipped on the public chrome: the
   audit measured header links at 21-22px and footer links at 18-19px, with no overlay at
   all on eight pages and a -6px one on the landing. These two rules are NOT in :where():
   they are meant to reach every page that links this file, overriding the undersized local
   copies, because a target below the floor is the finding.

   The arithmetic is the reason the row-gap moves with the inset. 14px type in a single nav
   row is a 21px line box → 11.5px each side → -12px lands at 45px. 14px type in a wrapping
   footer list is 18-19px → -13px. But 13px of bleed on both neighbours overlaps unless the
   row gap clears 26px, and a target that overlaps its neighbour is worse than a small one,
   because the press lands on the wrong link — so the gap goes to --s8 (32px) in the same
   breath. Horizontal bleed is smaller than the column gaps it sits in (--s8 = 32px in the
   nav, --s5 = 20px in the list), so nothing overlaps sideways either. */
.topnav nav a { position: relative; }
.topnav nav a::after { content: ""; position: absolute; inset: -12px -10px; }
.pagefoot .footlinks { row-gap: var(--s8); }
.pagefoot .footlinks a { position: relative; }
.pagefoot .footlinks a::after { content: ""; position: absolute; inset: -13px -9px; }
