/* Achroma — an achromatic design system.
 *
 * Black, white and greys, with hue reserved for meaning. Plain CSS custom
 * properties: no build step, no runtime, zero dependencies.
 *
 * Two rules hold this together:
 *
 *   1. The ramp is absolute. Every --n-* is chroma 0, hue 0, identical in both
 *      modes. test/contrast.mjs fails if that stops being true, because a stray
 *      0.01 is invisible by eye and would reach every site that installs this.
 *
 *   2. Aliases are what flip. Each mode re-points 26 aliases at different ramp
 *      steps — it is not a second palette to keep in sync.
 *
 * Colour is never decoration, and there are exactly two kinds of it:
 *
 *   - STATUS, in --danger/--warn/--ok. What happened.
 *   - AFFORDANCE, in --accent. What you can do.
 *
 * --info is the fourth status and it is deliberately achromatic: the state that
 * says nothing is wrong does not earn a hue. Neither kind ever governs content a
 * user chose or a document contains.
 *
 * Cascade, which is as load-bearing as the values and far easier to get wrong:
 *
 *   - The four alias blocks are all specificity 0,1,0 so that source order
 *     decides, and a consumer's own `.dark {}` — written after this file —
 *     always wins. An earlier draft used `:root:not([data-theme='light'])`
 *     (0,3,0) and silently beat consumer overrides, but only when the OS was
 *     in dark mode, which is the worst possible way to be wrong.
 *   - Token blocks are deliberately UNLAYERED; the base rules live in
 *     `@layer base`. Unlayered declarations outrank every layered one, so an
 *     unlayered `body { font-weight }` would defeat all of Tailwind's
 *     utilities. The layer must be named exactly `base` to land in Tailwind's
 *     `@layer theme, base, components, utilities` order.
 *
 * The @achroma marker comments are parsed by test/contrast.mjs — four of them,
 * one per alias block. Do not remove them, and keep all component CSS after the
 * last one.
 *
 * "Chroma is exactly 0" is a SOURCE-level guarantee. A minifier targeting old
 * browsers rewrites the ramp out of oklch() entirely (Lightning CSS at
 * `defaults` emits `lab(96.288% -.0000298023 0)`), so package.json pins a
 * browserslist modern enough that oklch() passes through untouched.
 */

/* ── fonts ────────────────────────────────────────────────────────────
 *
 * Self-hosted, not Google Fonts: consumers of this system claim to work
 * offline, and a CDN webfont would break that claim quietly.
 *
 * Family names are exact. 'Geist Variable' and 'Geist Mono Variable' — the word
 * order on the mono is easy to reverse, and getting it wrong falls back to the
 * system monospace silently.
 *
 * Coverage is latin, latin-ext and cyrillic, and nothing else. Two consequences
 * worth stating plainly rather than discovering:
 *
 *   - Vietnamese precomposed forms (U+1EC7, U+1EBF, …) and box-drawing glyphs
 *     (U+250C, …) are in no declared range. They fall back per-glyph, so
 *     `Việt` renders V-i-t in Geist with ệ from the system sans — a font
 *     change MID-WORD. Fontsource ships `vietnamese` and `symbols2` subsets if
 *     that becomes a problem; this package deliberately does not vendor them.
 *   - Devanagari, Arabic, Greek, Korean, Tamil, Telugu and Thai are likewise
 *     uncovered, and the fallback chains on --font-sans/--font-mono handle
 *     them per-glyph.
 *
 * Rule ORDER matters and is Fontsource's, not alphabetical: cyrillic, then
 * latin-ext, then latin. U+0304, U+0308 and U+0329 are declared by BOTH the
 * latin and latin-ext ranges, and where ranges overlap the LAST matching rule
 * wins — so latin must come last. Do not reorder these.
 *
 * What that order does and does not buy, measured in Chrome 150 off the network
 * log, one subset per row unless stated:
 *
 *   text contains              latin-first (before)   latin-last (now)
 *   -------------------------  ---------------------  ----------------
 *   plain ASCII                latin                  latin
 *   U+0304 combining macron    latin + latin-ext      latin
 *   U+0308 combining diaeresis latin + latin-ext      latin
 *   U+0329 combining v. line   latin + latin-ext      latin + latin-ext
 *   U+0301 combining acute     cyrillic + latin       cyrillic + latin
 *   precomposed U+00E9         latin                  latin
 *   precomposed U+0113         latin + latin-ext      latin + latin-ext
 *
 * So the reorder removes a redundant latin-ext fetch for U+0304 and U+0308, and
 * changes nothing else. Two facts worth stating rather than rediscovering:
 *
 *   - U+0301 is declared ONLY by the cyrillic range, so any NFD text still
 *     pulls the ~15 KB cyrillic subset. Fixing that would mean editing a
 *     unicode-range to drop U+0301 from cyrillic, which would break Ukrainian
 *     stress marks — a worse trade, so it stands.
 *   - U+0329 still fetches latin-ext even though latin declares it and is
 *     matched last. The reason was not established; the fonts were not
 *     decompressed to check their glyph coverage. Recorded as an observation.
 */

@font-face {
  font-family: 'Geist Variable';
  font-style: normal;
  font-display: swap;
  font-weight: 100 900;
  src: url('./fonts/geist-cyrillic-wght-normal.woff2') format('woff2-variations');
  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}

@font-face {
  font-family: 'Geist Variable';
  font-style: normal;
  font-display: swap;
  font-weight: 100 900;
  src: url('./fonts/geist-latin-ext-wght-normal.woff2') format('woff2-variations');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
    U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020,
    U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

@font-face {
  font-family: 'Geist Variable';
  font-style: normal;
  font-display: swap;
  font-weight: 100 900;
  src: url('./fonts/geist-latin-wght-normal.woff2') format('woff2-variations');
  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: 'Geist Mono Variable';
  font-style: normal;
  font-display: swap;
  font-weight: 100 900;
  src: url('./fonts/geist-mono-cyrillic-wght-normal.woff2') format('woff2-variations');
  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}

@font-face {
  font-family: 'Geist Mono Variable';
  font-style: normal;
  font-display: swap;
  font-weight: 100 900;
  src: url('./fonts/geist-mono-latin-ext-wght-normal.woff2') format('woff2-variations');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
    U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020,
    U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

@font-face {
  font-family: 'Geist Mono Variable';
  font-style: normal;
  font-display: swap;
  font-weight: 100 900;
  src: url('./fonts/geist-mono-latin-wght-normal.woff2') format('woff2-variations');
  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;
}

/* @achroma light */
:root {
  color-scheme: light dark;

  /* ── the ramp — absolute, achromatic, mode-independent ─────────────
   *
   * Chroma and hue are exactly 0, which collapses the OKLCH-to-luminance
   * chain to Y = L cubed for the whole ladder. That is why these values can
   * be reasoned about by hand.
   */
  --n-0: oklch(1.000 0 0);
  --n-25: oklch(0.985 0 0);
  --n-50: oklch(0.968 0 0);
  --n-100: oklch(0.945 0 0);
  --n-150: oklch(0.922 0 0);
  --n-200: oklch(0.900 0 0);
  --n-300: oklch(0.840 0 0);
  --n-400: oklch(0.720 0 0);
  --n-500: oklch(0.620 0 0);
  --n-600: oklch(0.520 0 0);
  --n-700: oklch(0.400 0 0);
  --n-800: oklch(0.300 0 0);
  --n-850: oklch(0.220 0 0);
  --n-900: oklch(0.170 0 0);
  --n-950: oklch(0.130 0 0);
  --n-1000: oklch(0.090 0 0);

  /* ── aliases — light ──────────────────────────────────────────────── */
  --bg: var(--n-25);
  --bg-raised: var(--n-0);
  --bg-sunken: var(--n-50);
  --fg: var(--n-950);
  --fg-dim: var(--n-600);
  --fg-faint: var(--n-500);
  --hairline: var(--n-150);
  --rule: var(--n-300);
  --ring: var(--fg);

  /* ── semantics — the only colour in the system ─────────────────────
   *
   * Three tokens each, because one cannot do three jobs. -text clears 4.5:1
   * on --bg, -line clears 3:1, -bg is a subtle fill. Amber is the proof:
   * oklch(0.62 0.13 75) on paper is 3.57:1, so an amber bright enough to
   * read as a border can never also be legible body text.
   *
   * Every value below was measured, not estimated. Three earlier drafts did
   * not survive: warn-text was outside the sRGB gamut, warn-line had no
   * margin at 3.03:1, and dark danger-line failed at 2.95:1.
   */
  --danger-text: oklch(0.50 0.19 27);
  --danger-line: oklch(0.62 0.17 27);
  --danger-bg: oklch(0.965 0.015 27);
  --warn-text: oklch(0.48 0.10 75);
  --warn-line: oklch(0.62 0.12 78);
  --warn-bg: oklch(0.968 0.022 85);
  --ok-text: oklch(0.48 0.12 150);
  --ok-line: oklch(0.58 0.12 150);
  --ok-bg: oklch(0.965 0.018 150);

  /* ── info — the fourth state, and it carries no hue ─────────────────
   *
   * danger, warn and ok earn their hue because each one asks the reader to do
   * something. Info does not: it is the state that says "nothing is wrong".
   * In a system whose whole claim is that colour means something, spending a
   * fourth hue on the absence of a problem would devalue the other three.
   *
   * So info is neutral, and that is the statement rather than an omission. It
   * holds the same three-token shape as the others, and clears the same floors:
   * -text >= 4.5:1, -line >= 3:1.
   *
   *              light   dark
   *   -text      5.28    7.71   (>= 4.5, on --bg)
   *   -line      3.49    5.25   (>= 3.0, on --bg)
   *   -text/-bg  5.02    8.11   (>= 4.5)
   *
   * --info-line is --fg-faint and not --rule, which is the one non-obvious
   * choice here. --rule is 1.56:1 in light, so an info border built from it
   * would be four times fainter than a warn border and would break the "-line
   * clears 3:1" contract that every other semantic keeps.
   *
   * These are var() indirections, not literals, and they are still repeated in
   * all four blocks. A custom property's var() is substituted where it is
   * DECLARED, not where it is used, so a single `--info-text: var(--fg-dim)` on
   * :root would compute to light's --fg-dim and inherit that value into a
   * `<section class="dark">` unchanged. --ring has the same shape for the same
   * reason. */
  --info-text: var(--fg-dim);
  --info-line: var(--fg-faint);
  --info-bg: var(--bg-sunken);

  /* ── accent — the one hue that is not a status ───────────────────────
   *
   * Indigo, hue 285. It marks AFFORDANCE: this is the thing you can act on.
   *
   * That is what keeps "colour is never decoration" true rather than bending it.
   * danger, warn and ok say what happened; accent says what you can do. All four
   * are meaning. A greyscale system has to carry "clickable" on weight and
   * position alone, which is why almost every achromatic system quietly smuggles
   * one accent back in — this one does it on the record.
   *
   * Hue 285 was chosen against three others on measured grounds, not taste
   * alone. Max in-gamut chroma at the shipped L of 0.520:
   *
   *   indigo 285   0.200      <- chosen
   *   magenta 340  0.196
   *   blue 250     0.128
   *   teal 200     0.077      <- sRGB has almost no chroma in the blue-green
   *                              band at mid lightness, so teal can never be
   *                              assertive here without losing legibility
   *
   * It also sits far from all three status hues — 285 against 27, 78 and 152 —
   * so an accent border can never be misread as a warning.
   *
   * --accent-fill and --accent-on-fill do NOT flip between modes, and that is
   * deliberate: white ink on this fill clears 4.5:1 on paper AND on ink, so one
   * value serves both. They are still repeated in all four blocks, exactly as
   * --fg-faint is for the same reason — the block is the contract.
   *
   * The chroma on each token is NOT a number anyone would pick by eye. It is
   * whatever sRGB allows at that lightness and hue, times 0.86 for margin — and
   * it drops fast as lightness rises. Two of the five were written wrong on the
   * first pass and the gamut assertion caught both: dark --accent-text was 0.160
   * and can only be 0.110 (blue channel 1.174), light --accent-bg was 0.020 and
   * can only be 0.014. An out-of-gamut colour is clamped on its way to the screen
   * and then reports a BETTER ratio than it paints, which is exactly the false
   * pass this file exists to prevent. Do not raise a chroma here by hand.
   *
   * Dark --accent-text is at L 0.670, NOT up at 0.760 where the other dark
   * semantics sit, and the reason is cohesion. Chroma and contrast pull opposite
   * ways here, hard:
   *
   *   L      max chroma   ratio on dark --bg
   *   0.670  0.157        6.11    <- shipped
   *   0.760  0.110        8.69
   *   0.840  0.071        11.55
   *
   * At 0.760 the text is a pale lavender sitting beside a vivid --accent-fill, and
   * the accent reads as two different colours on one page. 0.670 buys 43% more
   * chroma for contrast this system does not need — 6.11:1 still clears AA with
   * room, and matches the light side's 6.26 almost exactly. Red and amber do not
   * face this because sRGB is generous with chroma at high lightness in the warm
   * hues and stingy in the blues.
   *
   *              light   dark
   *   -text      6.26    6.11   (>= 4.5, on --bg)
   *   -line      4.00    3.59   (>= 3.0, on --bg)
   *   -text/-bg  5.91    5.36   (>= 4.5)
   *   fill+ink   5.98    5.98   (>= 4.5, --accent-on-fill on --accent-fill)
   *
   * Use it for about 1-3% of the pixels on a page. An accent on everything is
   * the same as no accent, and the greys are still the system. */
  --accent-text: oklch(0.500 0.200 285);
  --accent-line: oklch(0.600 0.170 285);
  --accent-bg: oklch(0.966 0.014 285);
  --accent-fill: oklch(0.520 0.200 285);
  --accent-on-fill: oklch(1.000 0 0);

  /* ── elevation ──────────────────────────────────────────────────────
   *
   * Achromatic design gives up hue as a hierarchy channel, which leaves
   * lightness, weight, space, line and shadow. Shadow was the one this system
   * was missing, and it is not optional in light mode — see the number below.
   *
   * Shadow is BLACK AT ALPHA, never a tinted grey. Most systems tint their
   * shadows blue by accident (a #64748b at low alpha is the usual culprit) and
   * nobody ever notices; test/contrast.mjs asserts chroma and hue are literally
   * 0 here.
   *
   * The alphas are per-mode and the gap is far larger than it looks. Browsers
   * composite in gamma-encoded sRGB, so the alpha needed to darken --bg by one
   * ramp step is:
   *
   *   light  --bg n-25  -> n-50    alpha 0.023
   *   dark   --bg n-900 -> n-950   alpha 0.528     <- 23x more
   *
   * That is why a shared shadow set has an invisible dark mode. It is also why
   * dark shadows here look absurdly heavy written down and read as subtle.
   *
   * Dark mode has a hard ceiling that light does not: there are only about two
   * ramp steps of room below --bg before pure black, so shadow can never carry
   * dark-mode elevation on its own. The two modes are complementary, which the
   * surface numbers already show:
   *
   *                        raised vs --bg
   *   light                1.044:1   <- invisible; shadow or hairline required
   *   dark                 1.104:1   <- carries the step itself
   *
   * So: light gets its elevation from --shadow-*, dark gets it from the tonal
   * step plus --hairline. Neither mode should rely on tone alone.
   *
   * 1 = resting lift (button, input). 2 = card, popover, dropdown.
   * 3 = modal, command palette. Nothing above 3; a fourth level is a sign the
   * layout, not the shadow, needs fixing. */
  --shadow-1: 0 1px 2px oklch(0 0 0 / 0.05);
  --shadow-2:
    0 1px 2px oklch(0 0 0 / 0.04),
    0 4px 8px -1px oklch(0 0 0 / 0.06);
  --shadow-3:
    0 1px 2px oklch(0 0 0 / 0.04),
    0 8px 16px -2px oklch(0 0 0 / 0.08),
    0 24px 48px -8px oklch(0 0 0 / 0.10);

  /* The backdrop behind a modal. Measured as the contrast between the scrimmed
   * page and the dialog surface (--bg-raised), which is the job it is doing:
   *
   *   alpha     0.35   0.45   0.55   0.65   0.72   0.80
   *   light     2.54   3.48   4.92   7.17   9.44   12.82
   *   dark      1.14   1.15   1.16   1.17   1.18   1.19   <- flat
   *
   * The dark row is the finding. Darkening a near-black page cannot separate it
   * from anything, so no alpha makes a dark scrim do this job — 0.80 buys 0.05
   * over 0.35. A dark dialog gets its separation from --bg-raised, --shadow-3
   * and a --hairline; the scrim is only there to mute the content behind it and
   * to catch the click that dismisses it.
   *
   * Not measurable by test/contrast.mjs: it composites, and the suite reads bare
   * token values. Do not add it to TARGETS — parseOklch throws on an alpha
   * component by design, rather than silently measuring the wrong thing. */
  --scrim: oklch(0 0 0 / 0.55);

  /* ── type ─────────────────────────────────────────────────────────── */
  --font-sans: 'Geist Variable', ui-sans-serif, system-ui, -apple-system, sans-serif;
  --font-mono: 'Geist Mono Variable', ui-monospace, 'SF Mono', Menlo, monospace;

  --text-2xs: 0.6875rem;
  --text-xs: 0.75rem;
  --text-sm: 0.8125rem;
  --text-base: 0.9375rem;
  --text-md: 1.0625rem;
  --text-lg: 1.25rem;
  --text-xl: 1.5rem;
  --text-2xl: 1.875rem;
  --text-display: clamp(2.5rem, 6vw, 5rem);

  --w-thin: 200;
  --w-light: 300;
  --w-regular: 400;
  --w-medium: 500;

  /* Huge-and-thin against tiny-and-wide. This tension is the signature. */
  --track-display: -0.035em;
  --track-tight: -0.015em;
  --track-normal: 0;
  --track-label: 0.14em;

  --lh-display: 1.02;
  --lh-tight: 1.25;
  --lh-body: 1.6;

  /* ── space — 4px base ─────────────────────────────────────────────── */
  --s-1: 0.25rem;
  --s-2: 0.5rem;
  --s-3: 0.75rem;
  --s-4: 1rem;
  --s-5: 1.25rem;
  --s-6: 1.5rem;
  --s-8: 2rem;
  --s-10: 2.5rem;
  --s-12: 3rem;
  --s-16: 4rem;
  --s-20: 5rem;
  --s-24: 6rem;

  /* ── line weight ────────────────────────────────────────────────────
   *
   * The look is built out of fine lines, so the width is a token and not a
   * hardcoded 1px in every rule. Mode-independent — widths do not flip.
   *
   * --line-1 stays at 1px rather than a hairline fraction. A 0.5px border is a
   * real option on a 2x display, but on a 1x display it rounds to either 0 or 1
   * unpredictably per edge, so a four-sided box loses one or two of its sides.
   * If you want true hairlines, do it behind a min-resolution query in your own
   * CSS; this file will not ship a border that vanishes. */
  --line-1: 1px;
  --line-2: 2px;

  /* ── radius — near-sharp on purpose ───────────────────────────────── */
  --r-0: 0;
  --r-sm: 2px;
  --r-md: 4px;
  --r-lg: 8px;
  /* Not 50%: on a non-square box 50% gives an ellipse, which is wrong for a
   * pill-shaped badge or a switch track. A large fixed length clamps to a
   * semicircle on the short axis at any aspect ratio. */
  --r-full: 999px;

  /* ── motion ───────────────────────────────────────────────────────── */
  --ease-spring: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-out: cubic-bezier(0.33, 1, 0.68, 1);
  --dur-1: 120ms;
  --dur-2: 180ms;
  --dur-3: 280ms;
  --dur-4: 420ms;

  /* --stagger is a step, not a delay, and CSS cannot supply the index it needs
   * to multiply. There is no class to ship here — the index has to come from
   * your markup or template:
   *
   *     <li style="--i: 0">  <li style="--i: 1">  ...
   *     li { animation-delay: calc(var(--i) * var(--stagger)) }
   *
   * It goes to 0ms under prefers-reduced-motion with the durations below, so a
   * list that staggers in does not become a list that appears one item at a
   * time in silence. */
  --stagger: 40ms;

  /* ── texture ──────────────────────────────────────────────────────── */
  --grain-opacity: 0.10;

  /* A single top-light wash, one step of the ramp, fading out by 55% of the
   * viewport. Achromatic systems read flat, and this is the cheapest depth
   * available that introduces no hue.
   *
   * It is one ramp step and no more, for the same reason --grain-opacity is
   * capped: a wash changes --bg locally, so every ratio computed from the bare
   * token becomes optimistic, and contrast.mjs cannot see it. In LIGHT the wash
   * lightens the page, which pushes dark text further clear — harmless. In DARK
   * it also lightens, which pushes light text TOWARD failing, so dark is the
   * side that constrains the value. Measured on painted pixels at the top of
   * the page, where the wash is strongest, decoded from real pixels:
   *
   *              bg Y      --fg     --fg-dim   (floors: 7.0 / 4.5)
   *   light top  0.97345   19.63    5.35   <- wash HELPS light
   *   light base 0.95597   19.30    5.26
   *   dark  top  0.00700   16.75    7.39   <- wash costs dark 0.30
   *   dark  base 0.00478   17.43    7.69
   *
   * One ramp step is the most that fits. Two would put dark --fg-dim near its
   * floor, and contrast.mjs would still report 7.71 because it reads the bare
   * token and never sees the wash.
   *
   * Note the two numbers for the same token: 7.71 is what contrast.mjs computes
   * from the OKLCH values, 7.69 is what the painted pixels measure at page base.
   * The 0.02 gap is the computed-versus-8-bit-quantized error that oklch.mjs
   * documents, not a discrepancy — but the tables above are painted-pixel
   * figures throughout, so do not compare them against suite output directly.
   *
   * Opt in with `background-image: var(--wash)` on body. Not applied by
   * default — a design system should not decide that every consumer's page has
   * atmosphere. */
  --wash: radial-gradient(120% 70% at 50% -15%, var(--n-0) 0%, transparent 55%);
}

/* @achroma dark */
/* The :not() arguments are wrapped in :where() to hold this at 0,1,0. Without
 * it, :not() contributes its most specific argument and the selector is 0,3,0 —
 * which outranks a consumer's own `.dark {}` (0,1,0) and reverts their override,
 * but ONLY when the OS is set to dark. Measured before the fix: a consumer
 * setting --bg on .dark got their value under OS-light and ours under OS-dark. */
@media (prefers-color-scheme: dark) {
  :root:not(:where([data-theme='light'], .light)) {
    --bg: var(--n-900);
    --bg-raised: var(--n-850);
    --bg-sunken: var(--n-950);
    --fg: var(--n-50);
    --fg-dim: var(--n-400);
    --fg-faint: var(--n-500);
    --hairline: var(--n-800);
    --rule: var(--n-700);
    --ring: var(--fg);
    --danger-text: oklch(0.72 0.16 25);
    --danger-line: oklch(0.53 0.15 27);
    --danger-bg: oklch(0.240 0.045 27);
    --warn-text: oklch(0.82 0.13 82);
    --warn-line: oklch(0.55 0.11 78);
    --warn-bg: oklch(0.240 0.040 80);
    --ok-text: oklch(0.78 0.13 155);
    --ok-line: oklch(0.52 0.11 152);
    --ok-bg: oklch(0.230 0.040 152);
    --info-text: var(--fg-dim);
    --info-line: var(--fg-faint);
    --info-bg: var(--bg-sunken);
    --accent-text: oklch(0.670 0.157 285);
    --accent-line: oklch(0.540 0.150 285);
    --accent-bg: oklch(0.235 0.042 285);
    --accent-fill: oklch(0.520 0.200 285);
    --accent-on-fill: oklch(1.000 0 0);
    /* Alphas are ~11x light's, and that is measured, not nervousness: one ramp
     * step of darkening costs alpha 0.023 in light and 0.528 in dark. */
    --shadow-1: 0 1px 2px oklch(0 0 0 / 0.55);
    --shadow-2:
      0 1px 2px oklch(0 0 0 / 0.50),
      0 4px 8px -1px oklch(0 0 0 / 0.60);
    --shadow-3:
      0 1px 2px oklch(0 0 0 / 0.50),
      0 8px 16px -2px oklch(0 0 0 / 0.65),
      0 24px 48px -8px oklch(0 0 0 / 0.70);
    /* Cannot separate the backdrop from the dialog at any alpha — 1.17:1 here
     * versus 4.92:1 in light. It mutes content and catches the dismiss click;
     * --bg-raised and --shadow-3 do the actual separating. */
    --scrim: oklch(0 0 0 / 0.65);
    --grain-opacity: 0.07;
    /* One ramp step, as in light. Dark is the side that constrains it: the wash
     * lightens the page, which moves light-on-dark text toward its floor. */
    --wash: radial-gradient(120% 70% at 50% -15%, var(--n-850) 0%, transparent 55%);
  }
}

/* @achroma dark-class */
/* Written twice on purpose. The media query serves consumers that follow the
 * OS; the class serves consumers with a toggle (next-themes sets .dark).
 * test/contrast.mjs asserts the two blocks are declaration-identical, because
 * nothing else would catch them drifting.
 *
 * No `:root` prefix: `[data-theme='dark']` alone is 0,1,0, so this ties with a
 * consumer's own rule and source order — their file comes after ours — decides.
 * It also lets the class work on any element, not only <html>. */
[data-theme='dark'],
.dark {
  color-scheme: dark;
  --bg: var(--n-900);
  --bg-raised: var(--n-850);
  --bg-sunken: var(--n-950);
  --fg: var(--n-50);
  --fg-dim: var(--n-400);
  --fg-faint: var(--n-500);
  --hairline: var(--n-800);
  --rule: var(--n-700);
  --ring: var(--fg);
  --danger-text: oklch(0.72 0.16 25);
  --danger-line: oklch(0.53 0.15 27);
  --danger-bg: oklch(0.240 0.045 27);
  --warn-text: oklch(0.82 0.13 82);
  --warn-line: oklch(0.55 0.11 78);
  --warn-bg: oklch(0.240 0.040 80);
  --ok-text: oklch(0.78 0.13 155);
  --ok-line: oklch(0.52 0.11 152);
  --ok-bg: oklch(0.230 0.040 152);
  --info-text: var(--fg-dim);
  --info-line: var(--fg-faint);
  --info-bg: var(--bg-sunken);
  --accent-text: oklch(0.670 0.157 285);
  --accent-line: oklch(0.540 0.150 285);
  --accent-bg: oklch(0.235 0.042 285);
  --accent-fill: oklch(0.520 0.200 285);
  --accent-on-fill: oklch(1.000 0 0);
  /* Alphas are ~11x light's, and that is measured, not nervousness: one ramp
   * step of darkening costs alpha 0.023 in light and 0.528 in dark. */
  --shadow-1: 0 1px 2px oklch(0 0 0 / 0.55);
  --shadow-2:
    0 1px 2px oklch(0 0 0 / 0.50),
    0 4px 8px -1px oklch(0 0 0 / 0.60);
  --shadow-3:
    0 1px 2px oklch(0 0 0 / 0.50),
    0 8px 16px -2px oklch(0 0 0 / 0.65),
    0 24px 48px -8px oklch(0 0 0 / 0.70);
  /* Cannot separate the backdrop from the dialog at any alpha — 1.17:1 here
   * versus 4.92:1 in light. It mutes content and catches the dismiss click;
   * --bg-raised and --shadow-3 do the actual separating. */
  --scrim: oklch(0 0 0 / 0.65);
  --grain-opacity: 0.07;
  /* One ramp step, as in light. Dark is the side that constrains it: the wash
   * lightens the page, which moves light-on-dark text toward its floor. */
  --wash: radial-gradient(120% 70% at 50% -15%, var(--n-850) 0%, transparent 55%);
}

/* @achroma light-class */
/* The light counterpart, and it must exist for two reasons that both bite only
 * when the OS is dark:
 *
 *   1. `color-scheme: light dark` on :root resolves to DARK under OS-dark no
 *      matter which tokens won, so form controls and scrollbars rendered dark
 *      on a near-white page — measured, an <input> at rgb(59,59,59) with white
 *      text on oklch(0.985 0 0). Only `color-scheme: light` pins it.
 *   2. The dark media query's :not() merely SUPPRESSES itself on a light-themed
 *      root; nothing declared light tokens. `<body class="light">` under
 *      OS-dark therefore inherited the dark --bg from :root.
 *
 * Last of the four blocks so that source order favours it if both a light and
 * a dark marker somehow end up on the same element. */
[data-theme='light'],
.light {
  color-scheme: light;
  --bg: var(--n-25);
  --bg-raised: var(--n-0);
  --bg-sunken: var(--n-50);
  --fg: var(--n-950);
  --fg-dim: var(--n-600);
  --fg-faint: var(--n-500);
  --hairline: var(--n-150);
  --rule: var(--n-300);
  --ring: var(--fg);
  --danger-text: oklch(0.50 0.19 27);
  --danger-line: oklch(0.62 0.17 27);
  --danger-bg: oklch(0.965 0.015 27);
  --warn-text: oklch(0.48 0.10 75);
  --warn-line: oklch(0.62 0.12 78);
  --warn-bg: oklch(0.968 0.022 85);
  --ok-text: oklch(0.48 0.12 150);
  --ok-line: oklch(0.58 0.12 150);
  --ok-bg: oklch(0.965 0.018 150);
  --info-text: var(--fg-dim);
  --info-line: var(--fg-faint);
  --info-bg: var(--bg-sunken);
  --accent-text: oklch(0.500 0.200 285);
  --accent-line: oklch(0.600 0.170 285);
  --accent-bg: oklch(0.966 0.014 285);
  --accent-fill: oklch(0.520 0.200 285);
  --accent-on-fill: oklch(1.000 0 0);
  --shadow-1: 0 1px 2px oklch(0 0 0 / 0.05);
  --shadow-2:
    0 1px 2px oklch(0 0 0 / 0.04),
    0 4px 8px -1px oklch(0 0 0 / 0.06);
  --shadow-3:
    0 1px 2px oklch(0 0 0 / 0.04),
    0 8px 16px -2px oklch(0 0 0 / 0.08),
    0 24px 48px -8px oklch(0 0 0 / 0.10);
  --scrim: oklch(0 0 0 / 0.55);
  --grain-opacity: 0.10;
  --wash: radial-gradient(120% 70% at 50% -15%, var(--n-0) 0%, transparent 55%);
}

/* ── base ─────────────────────────────────────────────────────────────
 *
 * Deliberately small. An opinionated reset in a shared package fights each
 * consumer's own base styles, so this sets only what the token system needs
 * to be true.
 *
 * `@layer base`, and the name is not negotiable. Unlayered declarations outrank
 * every layered one — layer order is compared before specificity — so an
 * unlayered `body { font-weight: 300 }` beat Tailwind's `font-bold` utility,
 * and `m-4`, `bg-white` and `text-2xl` with it. `base` is a name Tailwind
 * already declares in `@layer theme, base, components, utilities`; an
 * unrecognised name like `achroma.base` is appended AFTER utilities and fixes
 * nothing. Verified both ways.
 *
 * The :root token blocks above stay unlayered on purpose: they define custom
 * properties, which no utility competes for, and unlayered means a consumer
 * needs no layer machinery to override them.
 *
 * EVERY CLASS THIS FILE SHIPS IS PREFIXED `ac-`, and the reason is the same
 * @layer base that everything else here turns on. Being layered is what lets a
 * consumer override us — but it also means ANY unlayered rule of the same name
 * wins, silently, with no error and no warning. A plain `.label {}` in a
 * consumer's own CSS would simply defeat ours.
 *
 * The direction of that failure is the safe one, so the layer stays. The prefix
 * is what stops it happening by accident: `display` and `label` are ordinary
 * enough words that a collision is a matter of time, and Bootstrap already ships
 * `.display-1` through `.display-6`. Tokens are deliberately NOT prefixed — a
 * consumer re-pointing `--bg` is a feature, not a collision.
 */

@layer base {
  /* ══════════════════════════════════════════════════════════════════
   * IMPORT ORDER: Tailwind FIRST, achroma SECOND.
   * ══════════════════════════════════════════════════════════════════
   *
   *     @import "tailwindcss";
   *     @import "achroma/achroma.css";     <- must come second
   *
   * Tailwind's preflight puts its own `h1…h6 { font-size: inherit;
   * font-weight: inherit }` and `b, strong { font-weight: bolder }` in
   * `@layer base` — the same layer as the rules below. Within one layer,
   * specificity ties are broken by SOURCE ORDER, and `h1` versus `h1` is a
   * tie. So whichever file is imported last wins.
   *
   * Get it backwards and it FAILS SILENTLY. Nothing errors, no build warning:
   * headings simply render at the inherited body weight of 300, and <strong>
   * at 400, because preflight's `inherit`/`bolder` overwrote the weights.
   * Measured, achroma imported first: h1 300 instead of 500, strong 400
   * instead of 500, h1 15px instead of 30px.
   *
   * These rules deliberately use plain `h1`, not `:root h1` — a consumer must
   * be able to override headings from their own `@layer base`, and outranking
   * them would be a worse bug than the one it prevents.
   * ══════════════════════════════════════════════════════════════════ */

  *,
  *::before,
  *::after {
    box-sizing: border-box;
  }

  html {
    -webkit-text-size-adjust: 100%;
  }

  /* The `hidden` attribute needs !important, which is otherwise banned in this
   * file. The UA sheet's `[hidden] { display: none }` is a 0,1,0 author-defeatable
   * rule, so ANY author `display` beats it: `.results { display: grid }` plus
   * `<section id="results" hidden>` renders the section anyway.
   *
   * Found in naina, where two empty result panes had been painting on every
   * first load — the markup said hidden, the stylesheet said grid, the
   * stylesheet won, and nothing anywhere reported a problem. Every consumer
   * that toggles visibility through the attribute is exposed to the same thing,
   * which is why the guard belongs here rather than in each app. Tailwind's
   * preflight carries this rule for the same reason. */
  [hidden] {
    display: none !important;
  }

  body {
    margin: 0;
    background: var(--bg);
    color: var(--fg);
    font-family: var(--font-sans);
    font-size: var(--text-base);
    font-weight: var(--w-light);
    line-height: var(--lh-body);
  }

  /* Headings are pinned — size, weight and metrics — because the two consumers
   * disagree about every one of them otherwise. The UA sheet makes headings
   * bold and h1 2em; Tailwind's preflight resets them to `font-size: inherit`
   * and `font-weight: inherit`. Measured from this one file: h1 was 700/30px in
   * a plain page and 300/15px in a Tailwind one.
   *
   * Sizes map onto the existing --text-* scale, so there is one type scale and
   * not two. Note h5 lands on --text-base (body size) and h6 below it: past h4
   * a heading is a label, and weight plus tracking carry the hierarchy.
   *
   * The huge-and-thin display treatment stays opt-in through
   * --text-display/--track-display; it is not an h1 default. */
  h1,
  h2,
  h3,
  h4,
  h5,
  h6 {
    font-weight: var(--w-medium);
    letter-spacing: var(--track-tight);
    line-height: var(--lh-tight);
  }

  h1 { font-size: var(--text-2xl); }
  h2 { font-size: var(--text-xl); }
  h3 { font-size: var(--text-lg); }
  h4 { font-size: var(--text-md); }
  h5 { font-size: var(--text-base); }
  h6 { font-size: var(--text-sm); }

  /* The UA's `bolder` maps an inherited 300 to 400 — a 100-unit step, where
   * emphasis normally gets 300. Nearly imperceptible, so name the weight. */
  strong,
  b {
    font-weight: var(--w-medium);
  }

  /* ── links, and the one hue this system was leaking ──────────────────
   *
   * The UA paints <a> rgb(0, 0, 238) and a:visited rgb(85, 26, 139). Two hues,
   * on the most common inline element in HTML, in a file whose headline claim is
   * that hue appears in four semantics and nowhere else. Measured, not assumed —
   * every page with a link was breaking the thesis, and npm test could not see
   * it because it reads tokens and never renders an element.
   *
   * `color: inherit` fixes :visited too, with no :visited rule of its own: author
   * declarations beat the UA origin regardless of specificity, and origin is
   * compared before either layers or specificity.
   *
   * The underline is NOT decoration here, it is the affordance. Take colour away
   * as the link signal and something else has to carry it — that is WCAG 1.4.1,
   * and it is the only thing that makes an achromatic link legitimate rather than
   * merely quiet. So the underline is always on; only its weight changes on
   * hover. `from-font` uses the metrics Geist ships instead of a browser guess.
   */
  a {
    color: inherit;
    text-decoration-line: underline;
    text-decoration-thickness: from-font;
    text-decoration-color: var(--fg-faint);
    text-underline-offset: 0.15em;
    transition: text-decoration-color var(--dur-1) var(--ease-out);
  }

  a:hover {
    text-decoration-color: currentColor;
  }

  /* Browsers do not inherit font on form controls, and the gap is not subtle.
   * Measured under this file: <input>, <button> and <select> rendered Arial at
   * 13.333px while body was Geist at --text-base, and <textarea> rendered
   * monospace. The type system reached nothing a user types into.
   *
   * proof.html had been papering over this with `font: inherit` in its own
   * <style>, which is the tell — the reference page had to repair the package to
   * look right.
   *
   * The shorthand, not font-family alone: size, weight and line-height were all
   * wrong too. Colour is deliberately left to the UA, because `color-scheme` is
   * already set correctly for both modes and the cascade suite asserts a control
   * paints dark on a dark surface. Overriding it here would replace a working
   * mechanism with a second one to keep in sync. */
  button,
  input,
  select,
  textarea {
    font: inherit;
  }

  /* --font-mono exists and no element was using it. 0.9375em because a monospace
   * face at an identical px size reads visibly larger than the sans beside it;
   * em keeps the correction relative to whatever context the code sits in. */
  code,
  kbd,
  samp,
  pre {
    font-family: var(--font-mono);
    font-size: 0.9375em;
  }

  /* The UA's inset border renders rgb(128, 128, 128) — a mid grey off the ramp,
   * and the wrong weight in both modes. Margin is left alone: that is layout. */
  hr {
    border: 0;
    border-top: var(--line-1) solid var(--hairline);
  }

  /* Selection was the last hue leak, and the least obvious: it comes from the OS
   * accent colour, so it is whatever blue or pink the user happens to have set in
   * System Settings, and it differs machine to machine.
   *
   * It gets --accent-fill rather than an inverted --fg, and this is the single
   * highest-leverage place the accent appears. A visitor triggers it by reflex,
   * dozens of times a session, on every page — no other element gets that much
   * unprompted attention for so little ink. --accent-on-fill on --accent-fill is
   * already asserted at 5.98:1, so the selected text stays legible. */
  ::selection {
    background: var(--accent-fill);
    color: var(--accent-on-fill);
  }

  /* --ring rather than --fg directly, so an inverted surface can flip the ring
   * in one declaration: `.inverted { --ring: var(--bg) }`.
   *
   * Escape hatch: outline-offset puts the ring 4px outside the border box and
   * outlines are clipped by an ancestor's overflow clip, so a full-width item
   * inside `overflow: hidden` loses its ring on the left and right edges —
   * shadcn's DropdownMenuItem, SelectItem, CommandItem and Card are all this
   * shape. Set `outline-offset: -2px` on those to draw the ring inside. */
  :focus-visible {
    outline: 2px solid var(--ring);
    outline-offset: 2px;
  }

  /* ── the grain overlay ──────────────────────────────────────────────
   *
   * One fixed element, no JavaScript. This is what makes an achromatic
   * surface read as material rather than flat — the single highest-leverage
   * detail in the system.
   *
   * --grain-opacity HAS A CEILING, and it is an accessibility one. The overlay
   * sits above everything, so it composites over foreground and background
   * alike and compresses every contrast ratio in the file. contrast.mjs
   * computes from the bare token values and cannot see this. Measured from
   * painted pixels, light mode, --fg-dim on --bg against its 4.5:1 floor:
   *
   *   opacity   off     0.05    0.10    0.12    0.15    0.18    0.25
   *   ratio     5.260   5.019   4.807   4.722   4.598   4.487   4.229
   *                                                     ^ breaches 4.5
   *
   * So 0.18 — which reads as "clearly grainy" — puts --fg-dim below WCAG AA.
   * The shipped 0.10 leaves 0.307 of margin. Do not raise it past ~0.15, and if
   * you do, re-measure rather than assume: the failure is body text quietly
   * dropping below 4.5:1, which no assertion here will catch.
   *
   * The data URI stays inline here and uses the comma form on purpose. Moving
   * it into a custom property, or switching to the ;base64 or ;utf8 form,
   * would truncate it at the semicolon when test/contrast.mjs parses this
   * file — with no error, just a grain that quietly stops working. That rule
   * is enforced by assertions now, not only by this comment.
   *
   * Usage: <div class="ac-grain" aria-hidden="true"></div> as the last child of
   * body.
   *
   * background-size stays at 180px, matching the SVG's own viewport, and the
   * reason is counter-intuitive enough to be worth writing down.
   *
   * Chrome RE-RASTERISES the filter at device resolution; it does not upscale a
   * 180x180 bitmap. Measured: at 2x only 0.5% of horizontally adjacent device
   * pixels are identical, where nearest-neighbour upscaling would give ~100%.
   * So noise AMPLITUDE is untouched by DPI and by this value — per-pixel
   * luminance sd is ~1.16 (light) and ~1.38 (dark) across every combination of
   * 90px/180px and 1x/2x.
   *
   * What does change is the noise's spatial frequency, and so how much texture
   * survives at a fixed physical size. sd of 6-CSS-pixel block means, which is
   * equal physical area at both DPIs:
   *
   *              180px    90px
   *   light 1x   0.1402   0.1566   (+12%)
   *   light 2x   0.0860   0.0641   (-26%)
   *   dark  1x   0.1639   0.2317   (+41%)
   *   dark  2x   0.0951   0.0820   (-14%)
   *
   * Halving to 90px helps at 1x and HURTS at 2x — the wrong trade when most of
   * the audience is on a retina display — and it doubles the tile repeat, 16
   * across a 1440px viewport instead of 8. If the grain ever needs to read
   * stronger at 2x, the lever is a LARGER background-size, or a min-resolution
   * query raising the opacity. It is not a smaller tile.
   */
  .ac-grain {
    position: fixed;
    inset: 0;
    z-index: 9999;
    pointer-events: none;
    opacity: var(--grain-opacity);
    background-repeat: repeat;
    background-size: 180px 180px;
    background-image: 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='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)'/%3E%3C/svg%3E");
  }

  /* ── the display treatment ──────────────────────────────────────────
   *
   * One half of the type signature: huge, thin, tight. The other half is .ac-label
   * below — tiny, wide, uppercase, mono. The tension between the two IS the
   * look, and until now it lived in proof.html's <style> block instead of in
   * the package, so a consumer could install achroma and still have no way to
   * reproduce the thing the README advertises.
   *
   * Not an h1 default, and not a margin. A heading is structural; a display
   * treatment is editorial. Tying them together would make every h1 on a dense
   * page 5rem tall. Apply it explicitly: <h1 class="ac-display">.
   *
   * --w-thin is 200 and not Geist's available 100. --text-display clamps down to
   * 2.5rem on a narrow viewport, and hairline weights break up below roughly
   * 40px on a 1x display — 100 renders as broken strokes there.
   *
   * text-wrap: balance is outside the browserslist floor (Chrome 114, Safari
   * 17.5) and is here anyway: an unknown value is dropped, so old browsers get
   * normal wrapping, and a one-word orphan under a 5rem headline is the single
   * most common way display type looks unfinished.
   */
  .ac-display {
    font-size: var(--text-display);
    font-weight: var(--w-thin);
    letter-spacing: var(--track-display);
    line-height: var(--lh-display);
    text-wrap: balance;
  }

  /* Opt-in atmosphere, the counterpart to .ac-grain. background-image alone, so it
   * composes with whatever set the background colour instead of replacing it.
   *
   * It has to be a class rather than a default for a mechanical reason as well
   * as a taste one: `background: var(--bg)` on body is a shorthand and resets
   * background-image to none, so the token could never apply by itself. .ac-wash is
   * 0,1,0 against body's 0,0,1, so <body class="ac-wash"> wins either way. */
  .ac-wash {
    background-image: var(--wash);
  }

  /* ── the micro-label ────────────────────────────────────────────────
   *
   * The other half of the type signature: tiny, wide, uppercase, mono.
   */
  .ac-label {
    font-family: var(--font-mono);
    font-size: var(--text-2xs);
    font-weight: var(--w-medium);
    letter-spacing: var(--track-label);
    text-transform: uppercase;
    color: var(--fg-faint);
  }
}

/* The queries below stay unlayered: they are user-preference overrides, and
 * unlayered normal declarations outrank layered ones, so they reach consumers
 * without needing to know about our layer. */

@media (prefers-reduced-motion: reduce) {
  :root {
    --dur-1: 0ms;
    --dur-2: 0ms;
    --dur-3: 0ms;
    --dur-4: 0ms;
    --stagger: 0ms;
  }

  /* Both halves are needed: the tokens only reach CSS that consumes them, and
   * the universal reset catches third-party and Radix animations that never
   * heard of them. scroll-behavior goes on * rather than html so it reaches
   * nested scroll containers too.
   *
   * Deliberately NOT animation-iteration-count: 1 — that runs a
   * `spin 1s infinite` loading indicator for one 0.01ms iteration and freezes
   * it on its final frame, leaving a reduced-motion user unable to tell
   * "loading" from "hung". Non-decorative progress indicators are the standard
   * carve-out from reduced motion; removing the duration is enough. */
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

/* Not under prefers-reduced-motion: the grain is a static SVG background with
 * no animation, transition or transform, and "Reduce motion" is widely enabled
 * for battery reasons. Hiding it there cost those users the system's headline
 * detail for no motion benefit. These two queries are the ones that actually
 * mean "less visual noise". */
@media (prefers-reduced-transparency: reduce), (prefers-contrast: more) {
  .ac-grain {
    display: none;
  }
}

@media print {
  .ac-grain {
    display: none;
  }
}
