/*! Amber Console 1.0.0 | MIT | https://github.com/DutchDiederik/amber-console
 *  Generated by scripts/build.mjs — edit files in src/, not this one.
 *  Fonts in ../fonts/ are SIL OFL 1.1; see fonts/OFL-*.txt.
 */
/* ===========================================================================
   Amber Console — a monochrome amber-terminal CSS framework
   MIT licensed. Fonts in fonts/ are SIL OFL 1.1; see fonts/OFL-*.txt.
   https://github.com/DutchDiederik/amber-console

   NO VERSION NUMBER LIVES IN src/. package.json is the single source of truth;
   scripts/build.mjs stamps it into the dist/ banners. A version written here
   would be inlined into the bundle beside the generated one and drift on the
   first release that forgets it.

   This file is an @import list and nothing else. Every rule lives in one of the
   files below, so you can drop any single component you do not need.

   To embed inside an existing app rather than owning the page, swap
   base/reset.css for base/reset-scoped.css and wrap your markup in .ac-root.
   To skip the vendored webfonts, swap tokens/fonts.css for tokens/fonts-cdn.css.
   Either way, rebuild: `npm run build`.
   =========================================================================== */

/* --- Tokens: the entire API. Override any of these at runtime. --- */
/* >>> src/amber-console.css -> tokens/fonts.css */
/* ===========================================================================
   Amber Console — self-hosted webfonts

   Two bitmap faces, both SIL Open Font License 1.1 (see fonts/OFL-*.txt):

     VT323       everything at 18px and up  -> --font-terminal
     Silkscreen  8-10px micro labels ONLY   -> --font-micro

   These are era-correct SUBSTITUTES, not the original face. The source hardware
   used a mask-ROM bitmap font with no digital release; VT323 is a digitization of
   the DEC VT320 terminal ROM. To swap in a licensed face closer to the hardware,
   replace --font-terminal in tokens/typography.css and nothing else.

   GENERATED by scripts/fetch-fonts.mjs — do not edit by hand.

   Paths are relative and resolve without a server (file:// works):
     from src/tokens/fonts.css  ->  ../fonts/
     from dist/amber-console.css ->  ../fonts/     (rewritten by scripts/build.mjs)

   Prefer not to vendor the binaries? Swap this import for tokens/fonts-cdn.css
   in amber-console.css.
   =========================================================================== */

/* VT323 400 — vietnamese */
@font-face {
  font-family: "VT323";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("../fonts/VT323-Regular-vietnamese.woff2") format("woff2");
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}

/* VT323 400 — latin-ext */
@font-face {
  font-family: "VT323";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("../fonts/VT323-Regular-latin-ext.woff2") format("woff2");
  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;
}

/* VT323 400 — latin */
@font-face {
  font-family: "VT323";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("../fonts/VT323-Regular.woff2") 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;
}

/* Silkscreen 400 — latin-ext */
@font-face {
  font-family: "Silkscreen";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("../fonts/Silkscreen-Regular-latin-ext.woff2") format("woff2");
  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;
}

/* Silkscreen 400 — latin */
@font-face {
  font-family: "Silkscreen";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("../fonts/Silkscreen-Regular.woff2") 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;
}

/* >>> src/amber-console.css -> tokens/colors.css */
/* TWO GASES, ONE RAMP.
 *
 * The system ships two palettes and the switch between them is `data-ac-gas` on
 * the root element. They are not a light/dark pair and not a theme — they are
 * two different pieces of hardware:
 *
 *   data-ac-gas="neon"   24deg. The default. A monochrome AC plasma panel, where
 *                        the light is neon emitting directly and there is no
 *                        phosphor anywhere in the stack. This is what law 1
 *                        describes and what the rest of the system simulates.
 *
 *   data-ac-gas="amber"  38deg. The classic amber CRT phosphor (P3), a broad
 *                        ~590nm band with real green content. Historically this
 *                        is a DIFFERENT display technology — but it is the look
 *                        most people mean by "amber terminal", it is the ramp the
 *                        source design system was drawn against, and it pairs
 *                        naturally with the .ac-crt simulation.
 *
 * Everything below is defined twice and only twice: the five discharge stops,
 * the three surfaces, --on-fill, and the four glow triples. Every other token in
 * the system is an alias or is built from those, so adding a third gas means
 * adding one block and nothing else.
 *
 * BOTH PALETTES ARE GATED. scripts/contrast.mjs runs the full pair table against
 * each block independently — a ratio that passes under neon and fails under
 * amber is a failure. Do not add a stop to one block without the other.
 */

:root,
[data-ac-gas="neon"] {
  /* Surfaces — warm blacks (the unlit gas panel) */
  --screen: #100600;        /* base panel */
  --screen-raised: #1b0c02; /* subtly lifted region */
  --screen-well: #060200;   /* recessed input wells */

  /* Discharge scale — ONE hue, five intensities. Never introduce a second hue
     WITHIN a palette; the two blocks here are hardware, not decoration.

     THE HUE IS 24deg AND IT IS DERIVED, NOT PICKED. Weight the Ne I visible
     lines by the CIE 1931 observer and the discharge lands at x=0.631 y=0.369 —
     just outside sRGB, gamut-mapping to hue 19deg. That is a DC neon tube. This
     panel is not one: the sustain is a short high-field pulse, which enriches
     the 585.2nm line (the highest-energy of the 2p->1s group, and already the
     strongest) against the 640nm red group, and the hue walks up with it. At the
     ~2x enrichment a pulsed cell plausibly sees, 24deg. The band 19-31deg is the
     defensible range; 24 sits in the middle of it.

     Saturation is held at 100% for every stop the gas emits, because a
     near-monochromatic emitter has no way to be pale. --amber-100 is the sole
     exception and the exception is also physics: no fully-saturated colour at
     this hue can exceed L=0.318, so a HOTTER cell can only get there by
     whitening — which is exactly what an overdriven neon cell does.

     Luminance is not free-floating either. Each stop is solved to a contrast
     ratio against --screen (10.5 / 7.0 / 5.2 / 3.4), because rotating hue at
     constant lightness drops --amber-50 to 2.79:1 and silently fails the 3:1
     non-text gate. Re-solve, never re-tint. scripts/contrast.mjs is the check.

     The --amber-* prefix is historical and now names the ramp, not the hue —
     under this palette it is not amber. Renaming is a public API break; see the
     token table in README.md. */
  --amber-100: #ffa86d; /* hot highlight / focus — 10.57:1 */
  --amber-90:  #ff6b08; /* primary discharge    —  7.02:1 */
  --amber-70:  #dd5800; /* secondary            —  5.22:1 */
  --amber-50:  #ab4500; /* dim / disabled       —  3.42:1 */
  --amber-30:  #5b2500; /* trace / ghost        —  1.63:1, decorative */
  --on-fill:   #1e0c00;

  /* THE GLOW SOURCE. Four points on the discharge hue, as bare "r, g, b" triples
     so tokens/effects.css can build every halo from them with its own alphas —
     rgba(var(--gas-1), 0.4). That indirection is the whole reason the palettes
     switch cleanly: the halo is the same photons the cell emits, scattered in
     the glass, so a glow left behind at the other hue puts an amber corona
     around neon text. Twelve hardcoded rgba() used to live in effects.css and
     every one of them was a place to forget. */
  --gas-1: 255, 120, 30; /* core halo */
  --gas-2: 255, 109, 12; /* mid */
  --gas-3: 255, 102, 0;  /* deep / outermost */
  --gas-4: 255, 140, 64; /* pale — hot cores and the retrace band */
}

/* --- AMBER CRT PHOSPHOR (P3) — the source design system's ramp, kept as an
       option. Same structure as the block above, same contrast gate. --- */
[data-ac-gas="amber"] {
  --screen: #0d0700;
  --screen-raised: #170e02;
  --screen-well: #060200;

  --amber-100: #ffd052; /* hot highlight / focus — 13.74:1 */
  --amber-90:  #ffae1e; /* primary discharge    — 10.81:1 */
  --amber-70:  #cd8817; /* secondary            —  6.81:1 */
  --amber-50:  #8d5b10; /* dim / disabled       —  3.47:1 */
  --amber-30:  #4a2f08; /* trace / ghost        —  1.62:1, decorative */
  --on-fill:   #1a0e00;

  --gas-1: 255, 174, 30;
  --gas-2: 255, 150, 12;
  --gas-3: 255, 120, 0;
  --gas-4: 255, 186, 64;
}

/* Aliases and structural tokens — defined ONCE, for every palette. Anything
   that is the same shape regardless of which gas is in the panel belongs here;
   anything that is a colour belongs in a palette block above. */
:root {
  --ink: var(--amber-90);
  --ink-bright: var(--amber-100);
  --ink-dim: var(--amber-70);
  --ink-faint: var(--amber-50);
  --ink-trace: var(--amber-30);

  /* Inverse video (solid discharge block, dark text) */
  --fill: var(--amber-90);
  --fill-bright: var(--amber-100);

  --stroke: var(--amber-90);
  --stroke-dim: var(--amber-50);

  /* No red, no green. Alarm/danger = inverse video + blink, never a new hue. */
}

/* >>> src/amber-console.css -> tokens/typography.css */
:root {
  --font-terminal: "VT323", "Courier New", ui-monospace, monospace;
  --font-micro: "Silkscreen", "VT323", ui-monospace, monospace;

  /* VT323 is a bitmap face: it needs LARGE sizes. Never below 18px. */
  --type-display: 44px; /* page titles, banners */
  --type-title: 30px;   /* panel/soft-key headers */
  --type-body: 22px;    /* default UI text */
  --type-small: 18px;   /* field labels, legends */
  --type-micro: 8px;    /* Silkscreen only — bit labels, badges */

  --tracking-display: 0.1em;
  --tracking-body: 0.04em;
  --tracking-micro: 0.08em;

  --leading: 1.15; /* terminal rows sit tight */
}

/* >>> src/amber-console.css -> tokens/spacing.css */
:root {
  /* Character-cell grid: everything snaps to 4px half-cells */
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-6: 24px;
  --space-8: 32px;
  --space-12: 48px;

  --border-w: 2px;      /* every stroke is 2px — no hairlines */
  --radius: 8px;        /* touch pads, panels (CRT-rounded, not modern-pill) */
  --radius-sm: 4px;     /* inputs, small chips */
}

/* >>> src/amber-console.css -> tokens/effects.css */
:root {
  /* The halo is the same photons the cell emits, scattered in the glass, so it
     tracks the discharge hue exactly. NOTHING in this file names a colour: every
     rgba() is built from the --gas-N triples in tokens/colors.css, which is what
     makes `data-ac-gas` switch the panel and its glow together. Hardcode one and
     it will keep an amber corona around neon text, which is the one place the
     mismatch is legible side by side. */
  --glow-text: 0 0 2px rgba(var(--gas-1), 0.6), 0 0 9px rgba(var(--gas-2), 0.26), 0 0 24px rgba(var(--gas-3), 0.1);
  --glow-box: 0 0 8px rgba(var(--gas-1), 0.32), inset 0 0 10px rgba(var(--gas-1), 0.07);

  /* Plasma decay. A gas gap that stops being driven does not go dark on the next
     refresh — it relaxes: the discharge leaves excited and metastable neon behind,
     and that keeps emitting while it recombines. No phosphor is involved; see
     law 1 in the system guide. The curve is the shape of that relaxation: most of the
     light is gone in the first third, then a long dim tail. Override to retune
     the panel; --ac-decay-fast is for text that is rewritten continuously (a
     clock, a frame counter) where the full tail would smear into the next value.

     These are deliberately near the floor of what registers as a decay at all:
     much under ~80ms and the eye stops reading a relaxation and starts reading a
     switch. That is the intended side of the line to be on — the effect should
     be felt rather than seen. */
  --ac-decay: 105ms;
  --ac-decay-fast: 60ms;
  --ac-decay-ease: cubic-bezier(0.1, 0.72, 0.22, 1);

  /* The cell matrix. A plasma panel is not a lit surface, it is a grid of gas
     gaps at wire intersections, and the barrier ribs between them do not emit.
     --ac-mesh-pitch is the cell spacing; --ac-mesh-wire is the per-axis alpha of
     one rib.

     THE ALPHA IS A BUDGET, NOT A TASTE SETTING. Two crossed 1px wires on a pitch
     p lose a mean 1 - (1 - a/3)^2 of everything under them, and 0.075 is the
     value that lands that at 0.050 — exactly the mean loss of the CRT scanlines
     it sits beside, so turning plasma on costs the same legibility the tube
     already does. Crossings land at 0.144, near enough to double, which is what
     makes an intersection read as an intersection.

     scripts/contrast.mjs computes from colors.css and cannot see an overlay, so
     nothing will fail if this is raised. Redo the arithmetic first. */
  --ac-mesh-pitch: 3px;
  --ac-mesh-wire: 0.075;
}

/* Hard on/off blink — no fades on a plasma panel */
@keyframes ac-blink { 0%, 54% { opacity: 1; } 55%, 100% { opacity: 0.1; } }
.ac-blink { animation: ac-blink 1.1s steps(1) infinite; }

/* Block cursor */
.ac-cursor::after { content: "█"; margin-left: 2px; animation: ac-blink 1s steps(1) infinite; }

/* ---------------------------------------------------------------------------
   Three independent screen simulations, all applied to the outermost frame:

   .ac-bloom      PLASMA — the panel itself. Amplifies the glow tokens for every
                  descendant, lays a soft bleed layer over it, and rules it into
                  the cell matrix the gas actually sits in.
   .ac-crt        CRT — the tube itself: scanline texture, vignette, slow line
                  drift, retrace sweep, faint mains flicker.
   .ac-afterglow  PERSISTENCE — the panel does not go dark instantly. Anything
                  that disappears decays, live text leaves a ghost of the value
                  it just replaced, the glass holds faint uneven patches, and the
                  image smears while you scroll.

   Three classes, but the demos drive them from two switches: .ac-afterglow rides
   with .ac-crt, because persistence is a property of the same glass the
   scanlines are on. Each still works alone if you wire it yourself. All stop
   under prefers-reduced-motion.

   THE STACK. Every overlay is absolutely positioned to the frame, so the order
   they paint in is the whole design. It has to be stated in one place, because
   a sticky bar carrying its own z-index will otherwise punch straight through
   the simulations and sit on top of them — which is exactly what .ac-nav--sticky
   (z-index 20) used to do:

     1   frame content
     20  sticky chrome — .ac-nav--sticky, and anything else that outranks content
     40  .ac-bloom::before   plasma bleed
     45  .ac-retrace         CRT retrace band
     45  .ac-persist         afterglow ghosts and residual patches
     48  .ac-mesh            plasma cell mesh
     50  .ac-crt::after      scanlines and vignette

   Everything from 40 up is above the sticky bar on purpose: a screen simulation
   that stops at the edge of the menu bar is not simulating a screen.
   --------------------------------------------------------------------------- */

/* Static scanline texture only (no motion) — for print, thumbnails, cards */
.ac-scanlines { position: relative; }
.ac-scanlines::after {
  content: "";
  position: absolute; inset: 0; z-index: 6;
  pointer-events: none;
  background: repeating-linear-gradient(0deg, rgba(0,0,0,0.17) 0 1px, transparent 1px 3px);
}

/* --- PLASMA --- */
.ac-bloom {
  position: relative;
  --glow-text: 0 0 2px rgba(var(--gas-4), 0.75), 0 0 9px rgba(var(--gas-2), 0.4), 0 0 26px rgba(var(--gas-3), 0.2), 0 0 56px rgba(var(--gas-3), 0.1);
  --glow-box: 0 0 11px rgba(var(--gas-1), 0.4), 0 0 34px rgba(var(--gas-2), 0.16), inset 0 0 14px rgba(var(--gas-1), 0.1);
}
.ac-bloom::before {
  content: "";
  position: absolute; inset: 0; z-index: 40;
  pointer-events: none;
  backdrop-filter: blur(3.5px) brightness(1.22) saturate(1.08);
  -webkit-backdrop-filter: blur(3.5px) brightness(1.22) saturate(1.08);
  mix-blend-mode: screen;
  opacity: 0.34;
  mask-image: radial-gradient(125% 125% at 50% 48%, #000 50%, rgba(0,0,0,0.4) 100%);
  -webkit-mask-image: radial-gradient(125% 125% at 50% 48%, #000 50%, rgba(0,0,0,0.4) 100%);
  animation: ac-bloom-breathe 9s ease-in-out infinite;
}
/* Mains-hum breathing — barely perceptible, just enough to feel alive */
@keyframes ac-bloom-breathe {
  0%, 100% { opacity: 0.31; }
  50%      { opacity: 0.37; }
}

/* THE SCREEN DOOR.

   The one structural fact about this hardware, and the thing that most clearly
   is not a CRT. A cell lights at the intersection of a row wire and a column
   wire, boxed in on four sides by barrier ribs that carry no discharge and emit
   nothing. So a lit pixel is a neon dot trapped in a square aperture, and a
   crossed mesh — not a stack of horizontal lines — is what you see when you put
   your eye close to the glass. The dark horizontal blanking gaps between raster
   lines belong to .ac-crt and to the tube it simulates; there is no scan here,
   so there is nothing to blank.

   The ribs are pure black rather than tinted, and that is the whole trick: over
   the unlit panel (--screen is a near-black under either gas) black alpha has
   nothing to take away and
   the mesh is simply not there, while over lit amber it occludes. The grid
   appears only where the panel is lit, which is exactly the behaviour of a real
   screen door and costs nothing to get.

   z-index 48 puts it ABOVE the bleed, which is upside down physically — the ribs
   sit behind the front glass, so the bleed ought to soften them. It is worth the
   inaccuracy. .ac-bloom::before is a viewport-sized backdrop-filter, this layer
   jitters ~24 times a second (below), and anything inside that filter's backdrop
   drags a full-screen re-blur along on every one of those frames. Today that
   happens once per 9s breathe. Above the bleed the mesh is compositor-only,
   costs nothing, and stays sharp — which is what it is for.

   A CHILD NODE, not .ac-bloom::after, and the reason is worth stating because
   ::after looks free and is not. .ac-bloom and .ac-crt are both classes on the
   SAME frame, an element has exactly one ::after, and .ac-crt::after — equal
   specificity, later in this file — already owns it for the scanlines. A mesh
   declared there is silently replaced the moment both simulations are on, which
   is the default. Same contract as .ac-retrace and .ac-persist instead: one span,
   mounted and removed with the switch by amber-console.js. */
.ac-bloom > .ac-mesh {
  position: absolute; inset: 0; z-index: 48;
  pointer-events: none;
  background:
    repeating-linear-gradient(0deg,  rgba(0,0,0,var(--ac-mesh-wire)) 0 1px, transparent 1px var(--ac-mesh-pitch)),
    repeating-linear-gradient(90deg, rgba(0,0,0,var(--ac-mesh-wire)) 0 1px, transparent 1px var(--ac-mesh-pitch));
  animation:
    ac-mesh-buzz 0.29s steps(1, end) infinite,
    ac-mesh-hum 0.37s ease-in-out infinite;
}

/* THE PLASMA SHIMMER.

   The sustain voltage is AC, so the gas is never held — it is struck, and struck
   again, tens of thousands of times a second. None of that is visible directly;
   what is visible is the residue, a microscopic buzz in the cells that reads as
   the panel being energized rather than merely bright.

   Two animations because it is two phenomena, and DETUNED because that is the
   only thing standing between a buzz and a pulse. 0.29 and 0.37 are coprime, so
   the pair does not repeat for 10.7s, and neither divides into ac-crt-hum (5.5s),
   ac-bloom-breathe (9s), ac-crt-drift (11s) or ac-retrace (13s). Retune them and
   all four of those relationships have to survive, or the whole panel finds a
   beat and starts throbbing.

   Both rest at their zero frame — translate 0, opacity 1. The screenshot harness
   freezes animation, so whatever sits at 0% is what gets baselined. */

/* Sub-pixel vibration of the mesh itself. steps(1, end) so each position is held
   and then jumped, never interpolated: a wire that eases between two places is a
   wobble, and a wobble is a much bigger, much worse effect than this one. The
   0.35px ceiling is what keeps the wires reading as sharp — past about half a
   pixel the resampling turns the mesh grey and the screen door is gone. */
@keyframes ac-mesh-buzz {
  0%   { transform: translate3d(0, 0, 0); }
  14%  { transform: translate3d(0.28px, -0.16px, 0); }
  28%  { transform: translate3d(-0.19px, 0.31px, 0); }
  43%  { transform: translate3d(0.33px, 0.22px, 0); }
  57%  { transform: translate3d(-0.3px, -0.27px, 0); }
  71%  { transform: translate3d(0.15px, 0.34px, 0); }
  86%  { transform: translate3d(-0.34px, 0.11px, 0); }
  100% { transform: translate3d(0, 0, 0); }
}
/* …and the discharge itself is not perfectly even. A 1.8% swing on a layer whose
   own mean darkening is 5% moves panel luminance by about 0.09%, three orders
   below the WCAG 2.3.1 general flash threshold (>3/sec at >=10% relative
   luminance over >25% of the field). That is a ceiling, not a starting point. */
@keyframes ac-mesh-hum {
  0%, 100% { opacity: 1; }
  23%      { opacity: 0.988; }
  47%      { opacity: 1; }
  61%      { opacity: 0.982; }
  79%      { opacity: 0.994; }
}

/* --- CRT --- */
.ac-crt { position: relative; }
/* Scanline texture + vignette, drifting one cell every 11s */
.ac-crt::after {
  content: "";
  position: absolute; inset: 0; z-index: 50;
  pointer-events: none;
  background:
    radial-gradient(135% 125% at 50% 50%, transparent 58%, rgba(0,0,0,0.34) 100%),
    repeating-linear-gradient(0deg, rgba(0,0,0,0.15) 0 1px, transparent 1px 3px);
  animation: ac-crt-drift 11s linear infinite, ac-crt-hum 5.5s ease-in-out infinite;
}
@keyframes ac-crt-drift {
  from { background-position: 0 0, 0 0; }
  to   { background-position: 0 0, 0 -3px; }
}
/* Mains flicker: ±2% brightness, plus one shallow single-frame dip per cycle */
@keyframes ac-crt-hum {
  0%, 100% { opacity: 1; }
  31%      { opacity: 0.965; }
  32%      { opacity: 1.0; }
  68%      { opacity: 0.985; }
}
/* Vertical retrace band — a slow, almost invisible lightening sweep */
.ac-crt > .ac-retrace {
  position: absolute; left: 0; right: 0; top: 0; height: 26%;
  z-index: 45; pointer-events: none; mix-blend-mode: screen; opacity: 0.32;
  background: linear-gradient(180deg, transparent 0%, rgba(var(--gas-1), 0.028) 50%, rgba(var(--gas-4), 0.05) 66%, transparent 100%);
  animation: ac-retrace 13s linear infinite;
}
@keyframes ac-retrace {
  0%   { transform: translateY(-110%); }
  100% { transform: translateY(500%); }
}
/* --- PLASMA AFTERGLOW ---

   Three things, because "persistence" is three different phenomena and only one
   of them is expressible without JavaScript:

     1. DECAY-OUT. Anything that disappears relaxes instead of switching off.
        Pure CSS, below. This is the one that carries the effect on its own.
     2. GHOSTING. Text that is rewritten leaves the previous value behind for a
        beat. Needs the old value, which only JS has — amber-console.js mounts
        those into .ac-persist. The effect degrades to (1) and (3) without it.
     3. RESIDUAL PATCHES. The glass never sits perfectly uniform; charge relaxes
        unevenly and slowly. Pure CSS, on .ac-persist::before.

   Note the direction: this decays OUT and never in. Nothing here fades a thing
   into existence. A cell lights on the next refresh — instantly — and it is only
   the switching OFF that hardware cannot do sharply. That asymmetry is the whole
   reason this file may hold a `transition` at all while src/components/ may not;
   see the header of stylelint.config.mjs. */
.ac-afterglow { position: relative; }

/* (1) DECAY-OUT.

   The transition lives on the DESTINATION state, which is the whole trick:
   transition properties are read from the after-change style, so declaring them
   only on the hidden half means hiding decays and showing snaps back instantly.

   `display` is transitioned with allow-discrete so `display: none` is deferred
   to the end of the decay rather than cutting it off on the first frame. The
   blur is the gap losing definition as it drains — a discharge dies soft, not
   sharp. */
.ac-afterglow [hidden],
.ac-afterglow dialog:not([open]) {
  opacity: 0;
  filter: blur(0.4px) brightness(1.16);
  transition:
    opacity var(--ac-decay) var(--ac-decay-ease),
    filter var(--ac-decay) var(--ac-decay-ease),
    display var(--ac-decay) allow-discrete,
    overlay var(--ac-decay) allow-discrete;
}

/* (2) + (3) The persistence layer. One child element hosts all of it, so a frame
   needs exactly one extra node — same contract as .ac-crt and .ac-retrace.

   `screen` is the physically right blend: emitted light adds, it never
   occludes, so a ghost can only ever brighten what is under it. Note it is set
   on each child rather than on this element, and that is not a style choice —
   mix-blend-mode makes an element a BACKDROP ROOT, and a backdrop root resets
   what `backdrop-filter` inside it can see. Put the blend here and the scroll
   smear below samples an empty backdrop and silently does nothing. */
.ac-afterglow > .ac-persist {
  position: absolute; inset: 0;
  z-index: 45;
  pointer-events: none;
  overflow: hidden;
}

/* (3) RESIDUAL PATCHES — four slow, overlapping pools of charge that never
   quite line up, so the panel is never evenly lit. At 3% over the amber this is
   below the threshold of noticing directly; you only see it when it stops.

   The drift is background-position over an oversized background rather than a
   transform on an oversized box, deliberately: a box larger than its parent adds
   scrollable overflow, and the frame is exactly where a stray 288px of it would
   go unnoticed. A background cannot overflow anything. */
.ac-afterglow > .ac-persist::before {
  content: "";
  position: absolute; inset: 0;
  background:
    radial-gradient(38% 30% at 22% 28%, rgba(var(--gas-1), 0.032), transparent 70%),
    radial-gradient(46% 34% at 78% 62%, rgba(var(--gas-2), 0.026), transparent 72%),
    radial-gradient(30% 44% at 58% 12%, rgba(var(--gas-4), 0.022), transparent 68%),
    radial-gradient(52% 40% at 34% 84%, rgba(var(--gas-3), 0.028), transparent 74%);
  background-size: 150% 150%;
  background-repeat: no-repeat;
  mix-blend-mode: screen;
  animation: ac-persist-drift 47s ease-in-out infinite;
}
@keyframes ac-persist-drift {
  0%, 100% { background-position: 22% 34%; opacity: 0.85; }
  33%      { background-position: 36% 20%; opacity: 1; }
  66%      { background-position: 14% 46%; opacity: 0.9; }
}

/* (4) THE BLINK OFF-EDGE.

   A blinking alarm is the most visible thing on the panel that stops being
   driven, over and over, once a second — so it is the last place the decay
   should be missing. .ac-blink is steps(1) by default and snaps both ways; with
   persistence on, only the ON edge stays hard. A cell lights on the next
   refresh and then relaxes, so: instant up, curve down.

   Swapping animation-NAME rather than the shorthand is what lets one rule cover
   every blink site — the cursor runs a 1s cycle and the rest run 1.1s, and each
   keeps its own duration. Keyframe stops cannot read a custom property, so the
   window below is a percentage of whatever cycle it lands in rather than a
   duration: 54%->62% is ~88ms at 1.1s and ~80ms on the cursor's 1s. Retuning
   --ac-decay does not move it; the stops themselves are the only handle. */
.ac-afterglow .ac-blink,
.ac-afterglow .ac-cursor::after,
.ac-afterglow .ac-field--invalid .ac-input,
.ac-afterglow .ac-input[aria-invalid="true"],
.ac-afterglow .ac-meter--alarm .ac-meter__bar {
  animation-name: ac-blink-decay;
  animation-timing-function: linear;
}
/* The stops are spread wider than a literal decay curve would be. A true
   exponential loses most of its light in the first ~80ms, which is close enough
   to the flicker-fusion threshold that the eye files it as a hard switch — the
   decay measures correctly and still looks instant. This holds, then drains over
   the 54%->62% window — ~88ms of the 1.1s cycle — which sits right at the
   threshold described above: the alarm still reads as a hard blink, with the edge
   taken off it rather than a visible fade. The opacity ramp carries it;
   the blur only stops the edge staying razor-sharp on the way down. The ON edge
   is still a single-frame snap. */
@keyframes ac-blink-decay {
  0%, 54%   { opacity: 1; filter: blur(0); }
  56%       { opacity: 0.72; filter: blur(0.08px); }
  58%       { opacity: 0.48; filter: blur(0.16px); }
  60%       { opacity: 0.30; filter: blur(0.22px); }
  61%       { opacity: 0.17; filter: blur(0.27px); }
  62%, 100% { opacity: 0.1; filter: blur(0.3px); }
}

/* (5) DE-ENERGIZING — a lit cell that goes unlit.

   The single most common light-off event on a control panel is not a node
   disappearing, it is a lamp going out: a pressed button releasing, a tab
   deselecting, an interlock unchecking. Those elements never hide, so the
   decay-out rule above never saw them, and they were snapping off in one frame
   while everything around them relaxed.

   Same mechanism as decay-out, same direction rule: the transition is declared
   on the UNLIT state, so de-energizing decays and energizing is instant.

   On --ac-decay-fast rather than --ac-decay, and that is the difference between
   subtle and distracting. Hover lands here too — moving off a control is a
   de-energization — which means this fires on every control the pointer crosses,
   not just on the ones you press. At the full decay a sweep across a dense panel
   leaves a wake behind the cursor. At the fast one it reads as the glow simply
   taking a moment to let go.

   These live here rather than in the component files on purpose: components stay
   free of `transition`, which is what scripts/check-prohibitions.mjs enforces. */
.ac-afterglow .ac-btn,
.ac-afterglow .ac-tab,
.ac-afterglow .ac-nav__link,
.ac-afterglow .ac-badge,
.ac-afterglow .ac-banner,
.ac-afterglow .ac-check input,
.ac-afterglow .ac-radio input,
.ac-afterglow .ac-toggle__track,
.ac-afterglow .ac-toggle__thumb,
.ac-afterglow .ac-table__row--active td {
  transition:
    background-color var(--ac-decay-fast) var(--ac-decay-ease),
    border-color var(--ac-decay-fast) var(--ac-decay-ease),
    color var(--ac-decay-fast) var(--ac-decay-ease),
    box-shadow var(--ac-decay-fast) var(--ac-decay-ease),
    text-shadow var(--ac-decay-fast) var(--ac-decay-ease);
}

/* Lighting up is instant — a cell energizes on the next refresh. Declared on the
   lit half so it wins only in that direction. */
.ac-afterglow .ac-btn--filled,
.ac-afterglow .ac-btn[aria-pressed="true"],
.ac-afterglow .ac-btn:hover,
.ac-afterglow .ac-tab--active,
.ac-afterglow .ac-tab[aria-selected="true"],
.ac-afterglow .ac-tab:hover,
.ac-afterglow .ac-nav__link[aria-current="page"],
.ac-afterglow .ac-nav__link:hover,
.ac-afterglow .ac-badge--filled,
.ac-afterglow .ac-banner--filled,
.ac-afterglow .ac-check input:checked,
.ac-afterglow .ac-radio input:checked,
.ac-afterglow .ac-toggle--on .ac-toggle__thumb,
.ac-afterglow .ac-toggle[aria-pressed="true"] .ac-toggle__thumb {
  transition: none;
}

/* (6) SCROLL SMEAR.

   Scrolling is the largest light-off event there is: every cell on the panel is
   handed a new value at once. Real gas cannot keep up, and the image
   trails.

   This is a blurred, additive copy of the backdrop, faded in with scroll speed
   and drained when the scroll stops — amber-console.js drives --ac-smear from
   the actual per-frame delta. Two details are load-bearing: it is `fixed`, so
   the cost is one viewport rather than one document (the guide page is 15,000px
   tall), and it filters the BACKDROP rather than blurring the content itself,
   so the text you are reading stays sharp while its afterimage does not. */
/* The image itself loses focus. This is the part you actually see, and it has to
   be a filter on the CONTENT — an additive copy of the backdrop cannot do it, no
   matter how hard it is blurred, because `screen` leaves the live text perfectly
   sharp underneath and you get a halo instead of a smear. Displacing that copy
   does not rescue it either: a filtered backdrop is sampled in the element's own
   transformed space, so translating the layer slides the window over the
   backdrop rather than moving the image.

   Only the frame's real children are filtered, never the overlay nodes. The
   scanline mask, the vignette and the cell mesh are the glass, not the image —
   the glass does not go out of focus when the picture moves. */
.ac-afterglow[data-ac-scrolling] > *:not(.ac-persist, .ac-retrace, .ac-mesh) {
  filter: blur(calc(var(--ac-smear, 0) * 0.9px));
}

/* …and the panel blooms while it cannot keep up: a blurred, additive copy of
   the backdrop, fixed to the viewport so the cost is one screen rather than one
   document — the guide page is 15,000px tall. */
.ac-afterglow > .ac-persist::after {
  content: "";
  position: fixed; inset: 0;
  pointer-events: none;
  opacity: calc(var(--ac-smear, 0) * 0.32);
  mix-blend-mode: screen;
  backdrop-filter: blur(calc(var(--ac-smear, 0) * 4px)) brightness(1.12);
  -webkit-backdrop-filter: blur(calc(var(--ac-smear, 0) * 4px)) brightness(1.12);
}
/* Nothing to composite when still — and a permanently live backdrop-filter over
   the viewport is exactly the kind of thing that costs a frame budget for free.
   The attribute is set by amber-console.js only while the smear is above zero. */
.ac-afterglow:not([data-ac-scrolling]) > .ac-persist::after { display: none; }

/* (2) GHOSTS — a copy of what was just there, parked at the rect it occupied
   and left to drain. Mounted and removed by amber-console.js; nothing in the
   DOM when the effect is off or the module is absent. */
.ac-afterglow .ac-ghost {
  position: absolute;
  margin: 0;
  pointer-events: none;
  mix-blend-mode: screen;
  /* Invisible until the animation drives it. A ghost is only ever legible mid-
     decay, so if animations are off — a screenshot harness, a print, a browser
     that never ran the keyframes — the right thing to show is nothing at all,
     not an opaque duplicate of live text sitting on top of it. */
  opacity: 0;
  animation: ac-ghost-decay var(--ac-decay) var(--ac-decay-ease) forwards;
}
.ac-afterglow .ac-ghost--fast { animation-duration: var(--ac-decay-fast); }
@keyframes ac-ghost-decay {
  from { opacity: 0.38; filter: blur(0); }
  to   { opacity: 0; filter: blur(0.8px); }
}

.ac-bloom > *, .ac-crt > *, .ac-afterglow > * { position: relative; z-index: 1; }
.ac-crt > .ac-retrace, .ac-bloom > .ac-retrace { z-index: 45; }
.ac-bloom > .ac-mesh, .ac-crt > .ac-mesh { z-index: 48; }

/* --- THE GLASS IS THE VIEWPORT, NOT THE DOCUMENT ---

   Every overlay above is positioned against the frame, and on a full-screen
   frame the frame is the whole document — 15,105px on the system guide. That is
   wrong twice over.

   Physically: the bleed, the scanline mask, the vignette and the retrace are all
   properties of the glass. Glass does not scroll. A vignette anchored to the
   document darkens the top and bottom of the PAGE instead of the edges of the
   SCREEN, which is not an effect any hardware has.

   Practically: it banded. A backdrop-filter stretched over a document-height
   layer is rasterised in tiles, and the tile seams show up as static horizontal
   and vertical steps across the background — the bars this fixes. The enormous
   mask and vignette gradients quantise into visible steps for the same reason,
   8-bit alpha spread over thousands of pixels.

   Anchoring to the viewport fixes the look, the physics and the cost — the
   backdrop-filter now covers one screen instead of one document.

   Scoped to .ac-screen on purpose: .ac-bloom and .ac-crt also go on small tiles
   (the guide documents itself with them), and those must stay local to the tile.
   .ac-persist itself stays document-absolute — ghosts are pinned to the rect
   their source occupied, which is a document coordinate. */
.ac-screen.ac-bloom::before,
.ac-screen.ac-crt::after,
.ac-screen.ac-afterglow > .ac-persist::before {
  position: fixed;
  inset: 0;
}
.ac-screen.ac-crt > .ac-retrace {
  position: fixed;
}
.ac-screen.ac-bloom > .ac-mesh {
  position: fixed;
  inset: 0;
}

@media (prefers-reduced-motion: reduce) {
  .ac-blink, .ac-cursor::after,
  .ac-bloom::before, .ac-crt::after,
  .ac-persist::before { animation: none; }
  .ac-retrace { display: none; }

  /* .ac-mesh is the one overlay here that keeps rendering. Note it is in an
     animation rule and NOT in the display rule beside .ac-retrace: the screen
     door is not an effect playing over the panel, it is the shape of the panel,
     and hiding it would change what the hardware IS in response to a motion
     preference. Only the buzz is motion.

     Restated with the child combinator rather than folded into the bare list
     above, and that is the third time this file has had to say it: a media query
     adds no specificity, so `.ac-mesh` (0,1,0) loses to the `.ac-bloom > .ac-mesh`
     (0,2,0) that declares the animation, and the buzz goes on running for exactly
     the users who asked it not to. It has to be matched at its own weight. */
  .ac-bloom > .ac-mesh,
  .ac-crt > .ac-mesh { animation: none; }

  /* The afterglow blink override is a descendant selector and therefore beats a
     bare `.ac-blink { animation: none }` on specificity. Restated at matching
     weight, or turning the simulation on would quietly reinstate every blink
     that this block just switched off. Same reason it is restated in print.css
     and a11y.css. */
  .ac-afterglow .ac-blink,
  .ac-afterglow .ac-cursor::after,
  .ac-afterglow .ac-field--invalid .ac-input,
  .ac-afterglow .ac-input[aria-invalid="true"],
  .ac-afterglow .ac-meter--alarm .ac-meter__bar { animation: none; filter: none; }

  /* Every decay in the simulation is a descendant rule under .ac-afterglow, so
     each one outranks a plain reset here and has to be caught. Restating the
     selector list one by one is how the blink override slipped through twice —
     a blanket is the only version that also covers the next rule added. */
  .ac-afterglow *,
  .ac-afterglow *::before,
  .ac-afterglow *::after { transition: none !important; }

  /* Smearing the whole viewport in response to scrolling is the single most
     motion-sick-making thing in here. */
  .ac-afterglow > .ac-persist::after { display: none; }
  .ac-afterglow[data-ac-scrolling] > *:not(.ac-persist, .ac-retrace, .ac-mesh) { filter: none; }

  /* Persistence is motion by definition: a decaying ghost is a moving image.
     There is nothing to soften here, so the whole thing goes. */
  .ac-ghost { display: none; }
  .ac-afterglow [hidden],
  .ac-afterglow dialog:not([open]) { transition: none; filter: none; }
}


/* --- Base --- */
/* >>> src/amber-console.css -> base/reset.css */
/* ===========================================================================
   Reset — minimal and deliberately opinionated.

   This framework owns the look of a whole screen, so it is assertive: it styles
   `body` directly. Embedding Amber Console inside an existing app instead?
   Import base/reset-scoped.css, which applies the same rules under .ac-root.
   =========================================================================== */

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

body {
  margin: 0;
  background: var(--screen);
  color: var(--ink);
  font-family: var(--font-terminal);
  font-size: var(--type-body);
  line-height: var(--leading);
  letter-spacing: var(--tracking-body);
  /* Antialiasing softens the bitmap grid and kills the effect. Keep this. */
  -webkit-font-smoothing: none;
}

/* There is no bold in the terminal face — weight carries no meaning here.
   Hierarchy comes from size, intensity and inverse video. */
h1,
h2,
h3,
h4 {
  margin: 0;
  font-weight: 400;
  text-transform: uppercase;
}

/* Undefined links render browser-default blue and instantly break the illusion. */
a {
  color: var(--ink-bright);
  text-decoration: underline;
  text-underline-offset: 3px;
}
a:hover {
  color: var(--fill-bright);
  text-shadow: var(--glow-text);
}

::selection {
  background: var(--fill);
  color: var(--on-fill);
}

/* Form controls do not inherit type by default. On a character grid they must. */
button,
input,
select,
textarea {
  font-family: inherit;
  font-size: inherit;
  line-height: inherit;
  letter-spacing: inherit;
  color: inherit;
}

/* The shared focus ring. Every interactive ac-* class repeats this so it still
   works when a consumer imports components without the reset. */
:focus-visible {
  outline: 2px dashed var(--ink-dim);
  outline-offset: 3px;
}

/* >>> src/amber-console.css -> base/layout.css */
/* ===========================================================================
   Layout utilities

   Flex and grid with `gap` everywhere — never margins between siblings, which
   would fight the 4px half-cell grid. Every container that can hold a
   long string carries min-width: 0, because .ac-btn--pad has a 130px min-width
   floor that will otherwise push a grid track wide and overflow the page.
   =========================================================================== */

/* --- Screen: the full-height frame that hosts .ac-bloom / .ac-crt --- */
.ac-screen {
  display: flex;
  /* SMALL viewport height, not large and not dynamic.
     `100vh` is the URL-bar-retracted height, so on a phone with the bar showing
     the frame is taller than the glass and a short screen picks up 60-100px of
     scroll containing nothing. `100dvh` would track the bar exactly — and
     re-resolve every time it slides, reflowing a frame that is dragging a
     viewport-sized backdrop-filter (.ac-bloom::before) behind it. `100svh` is
     static and can never overflow. It leaves a gap when the bar retracts, and
     that gap is invisible: this element paints no background of its own, `body`
     owns --screen, and the overlays are fixed to the real viewport regardless.
     Declared after the 100vh fallback, which older engines keep. */
  min-height: 100vh;
  min-height: 100svh;
  flex-direction: column;
  /* The simulation overlays are absolutely positioned to this box, so the frame
     has to clip. `clip` rather than `hidden`, and the difference is load-bearing:
     `hidden` makes this box a scroll container, and a scroll container is what
     `position: sticky` resolves against. The frame never scrolls — the page does
     — so .ac-nav--sticky inside an overflow:hidden frame silently did not stick
     at all. `clip` clips without becoming a scrollport, so the bar sticks to the
     viewport and the overlays still stop at the frame edge. */
  overflow: clip;
}

.ac-screen__body {
  display: flex;
  flex: 1;
  flex-direction: column;
  /* 20px 24px — five and six half-cells. Override with --ac-screen-pad. */
  padding: var(--ac-screen-pad, 20px var(--space-6));
  gap: var(--space-4);
  min-width: 0;
}

/* --- Stack / Row: one axis, gap driven by a --gap custom property --- */
.ac-stack {
  display: flex;
  flex-direction: column;
  gap: var(--gap, var(--space-4));
  min-width: 0;
}

.ac-row {
  display: flex;
  flex-direction: row;
  gap: var(--gap, var(--space-4));
  min-width: 0;
}
.ac-row--wrap { flex-wrap: wrap; }
.ac-row--center { align-items: center; }
.ac-row--baseline { align-items: baseline; }
.ac-row--between { justify-content: space-between; }
.ac-row--end { justify-content: flex-end; }

/* Push everything after it to the far edge. */
.ac-spacer { flex: 1; }

/* These set `display`, which would otherwise beat the [hidden] attribute. */
.ac-stack[hidden],
.ac-row[hidden],
.ac-grid[hidden],
.ac-screen[hidden] {
  display: none;
}

/* Children of a stack/row that should absorb the leftover space. */
.ac-grow { flex: 1; min-width: 0; }

/* Drop to the bottom of a flex column — the "pinned bottom-right" idiom. */
.ac-push { margin-top: auto; }

/* Available to assistive tech, invisible on the panel. For the <legend> of a
   bit group whose visible label is already the panel's own title. */
.ac-sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* --- Grid --- */
.ac-grid {
  display: grid;
  grid-template-columns: repeat(var(--cols, 1), minmax(0, 1fr));
  gap: var(--gap, var(--space-6));
  min-width: 0;
}

/* The canonical console split: operate left, instrument right. */
.ac-grid--console {
  grid-template-columns: minmax(0, 1fr) minmax(280px, 380px);
}

.ac-col-2 { grid-column: span 2; }
.ac-col-3 { grid-column: span 3; }
.ac-col-4 { grid-column: span 4; }
.ac-col-6 { grid-column: span 6; }

/* Below this the instrument column can no longer hold its 280px floor beside a
   usable operating column, so both stack. Matches the source console screen. */
@media (max-width: 900px) {
  .ac-grid--console {
    grid-template-columns: minmax(0, 1fr);
  }
  .ac-col-2,
  .ac-col-3,
  .ac-col-4,
  .ac-col-6 {
    grid-column: auto;
  }
}


/* --- Components --- */
/* >>> src/amber-console.css -> components/button.css */
/* ===========================================================================
   Button — the soft key.

   Works on <button>, <a> and <input type="submit">. State hooks are both the
   --filled class and [aria-pressed="true"], so a framework binding an ARIA
   attribute needs no class juggling.

   Ration inverse video: two or three filled surfaces per screen, no more.
   =========================================================================== */

.ac-btn {
  display: inline-block;
  font-family: var(--font-terminal);
  font-size: var(--type-body);
  line-height: 1;
  text-transform: uppercase;
  text-decoration: none;
  letter-spacing: var(--tracking-body);
  color: var(--ink);
  background: transparent;
  border: var(--border-w) solid var(--stroke);
  border-radius: var(--radius);
  padding: 10px 22px;
  cursor: pointer;
  text-shadow: var(--glow-text);
}

.ac-btn:hover {
  color: var(--ink-bright);
  border-color: var(--amber-100);
  box-shadow: var(--glow-box);
}

/* Press snaps to inverse video. No transition — a redrawn screen has no
   in-between frames. */
.ac-btn:active {
  background: var(--fill);
  color: var(--on-fill);
  text-shadow: none;
}

.ac-btn--filled,
.ac-btn[aria-pressed="true"] {
  background: var(--fill);
  color: var(--on-fill);
  text-shadow: none;
  box-shadow: var(--glow-box);
}
.ac-btn--filled:hover,
.ac-btn[aria-pressed="true"]:hover {
  background: var(--fill-bright);
  color: var(--on-fill);
}

/* Dim never glows — glow is the signal of energization. */
.ac-btn--dim {
  color: var(--ink-faint);
  border-color: var(--stroke-dim);
  text-shadow: none;
}

.ac-btn--lg {
  font-size: var(--type-title);
  padding: 14px 28px;
}

/* NOTE: --sm computes to roughly 30px tall, below the 44px hit-target floor.
   The value is fixed by the source design system, so it is documented rather
   than changed — see "Known constraints" in README.md. Reserve it for dense,
   mouse-driven tooling; never use it for a primary control on a touch panel. */
.ac-btn--sm {
  font-size: var(--type-small);
  padding: 6px 14px;
  border-radius: var(--radius-sm);
}

/* The gloved-finger touch pad: label parked top-left, body deliberately empty. */
.ac-btn--pad {
  display: flex;
  align-items: flex-start;
  justify-content: flex-start;
  text-align: left;
  min-height: 96px;
  min-width: 130px;
  padding: 10px 16px;
}

.ac-btn--block {
  display: block;
  width: 100%;
}
.ac-btn--pad.ac-btn--block {
  display: flex;
}

.ac-btn:disabled,
.ac-btn[aria-disabled="true"] {
  color: var(--ink-faint);
  border-color: var(--stroke-dim);
  background: transparent;
  text-shadow: none;
  box-shadow: none;
  cursor: default;
}

.ac-btn:focus-visible {
  outline: 2px dashed var(--ink-dim);
  outline-offset: 3px;
}

/* >>> src/amber-console.css -> components/nav.css */
/* ===========================================================================
   Nav — the screen/site menu bar.

   Link labels are Title Case: they are operator soft keys, not system text.
   The active link is [aria-current="page"] — inverse video, no underline.

   MOBILE: the links wrap to a second row. There is deliberately no hamburger —
   law 6 forbids an icon set, and a three-line glyph is an icon. A control
   panel shows you every key it has.
   =========================================================================== */

.ac-nav {
  box-sizing: border-box;
  display: flex;
  align-items: stretch;
  gap: var(--space-4);
  flex-wrap: wrap;
  border-bottom: var(--border-w) solid var(--stroke);
  background: var(--screen);
  padding: 0 var(--space-6);
}

.ac-nav--sticky {
  position: sticky;
  top: 0;
  z-index: 20;
}

.ac-nav__mark {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 0;
  margin-right: auto;
  font-family: var(--font-terminal);
  font-size: var(--type-body);
  line-height: 1;
  letter-spacing: 0.22em;
  color: var(--ink-bright);
  text-transform: uppercase;
  text-shadow: var(--glow-text);
}

/* A typeset block, not a logo. There is no logo — see law 6 in README.md. */
.ac-nav__mark::before {
  content: "█";
  font-size: 16px;
  color: var(--fill);
}

.ac-nav__links {
  display: flex;
  align-items: stretch;
  gap: var(--space-2);
  flex-wrap: wrap;
}

.ac-nav__link {
  display: flex;
  align-items: center;
  padding: 0 var(--space-4);
  margin: 6px 0;
  font-family: var(--font-terminal);
  font-size: var(--type-body);
  line-height: 1;
  color: var(--ink);
  text-decoration: none;
  letter-spacing: var(--tracking-body);
  border: var(--border-w) solid transparent;
  border-radius: var(--radius-sm);
  text-shadow: var(--glow-text);
}

.ac-nav__link:hover {
  color: var(--ink-bright);
  border-color: var(--stroke);
  box-shadow: var(--glow-box);
}

.ac-nav__link[aria-current="page"],
.ac-nav__link--active {
  background: var(--fill);
  color: var(--on-fill);
  border-color: var(--fill);
  text-shadow: none;
}

.ac-nav__link:focus-visible {
  outline: 2px dashed var(--ink-dim);
  outline-offset: 3px;
}

.ac-nav__meta {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-4);
  font-family: var(--font-micro);
  font-size: var(--type-micro);
  letter-spacing: var(--tracking-micro);
  color: var(--ink-faint);
}

@media (max-width: 720px) {
  .ac-nav {
    padding: 0 var(--space-4);
  }
  /* Wrap to a second row rather than hiding anything behind a menu glyph. */
  .ac-nav__mark {
    margin-right: 0;
    width: 100%;
  }
}

/* >>> src/amber-console.css -> components/tabs.css */
/* ===========================================================================
   Tabs — soft keys, Title Case.

   Casing is semantic: these are operator-facing, so they are NOT uppercased
   like system text. text-transform: none is load-bearing, not an oversight.

   Markup: role="tablist" > role="tab" + aria-selected, and aria-controls
   pointing at a role="tabpanel". src/amber-console.js adds the arrow-key model;
   the component looks and reads correctly without it.
   =========================================================================== */

.ac-tabs {
  display: flex;
  gap: var(--space-2);
  /* Three title-size soft keys need ~455px. Wrap to a second row rather than
     be clipped — same rule as .ac-nav, and for the same reason: a control panel
     shows you every key it has. */
  flex-wrap: wrap;
}

.ac-tab {
  font-family: var(--font-terminal);
  font-size: var(--type-title);
  line-height: 1;
  color: var(--ink);
  background: transparent;
  text-transform: none;
  border: var(--border-w) solid var(--stroke);
  border-radius: var(--radius);
  /* The 20px bottom padding is dead space that makes a soft key read as a
     physical key. It is intentional. Do not snap it to the 4/8px grid. */
  padding: 8px 24px 20px;
  cursor: pointer;
  text-shadow: var(--glow-text);
}

.ac-tab:hover {
  color: var(--ink-bright);
  border-color: var(--amber-100);
}

.ac-tab--active,
.ac-tab[aria-selected="true"] {
  background: var(--fill);
  color: var(--on-fill);
  text-shadow: none;
  box-shadow: var(--glow-box);
}

.ac-tab:disabled {
  color: var(--ink-faint);
  border-color: var(--stroke-dim);
  text-shadow: none;
  cursor: default;
}

.ac-tab:focus-visible {
  outline: 2px dashed var(--ink-dim);
  outline-offset: 3px;
}

/* >>> src/amber-console.css -> components/panel.css */
/* ===========================================================================
   Panel — the framed region. Borders do the layout work; there is no elevation.

   Two title treatments:
     .ac-panel__title     legend chip breaking the top border
     .ac-panel--bar +
     .ac-panel__barTitle  full-width inverse strip (the machine's own heading)

   The legend chip paints --screen to mask the border behind it. On a panel
   sitting over a different background, override --ac-panel-title-bg.
   =========================================================================== */

.ac-panel {
  position: relative;
  box-sizing: border-box;
  border: var(--border-w) solid var(--stroke);
  border-radius: var(--radius);
  padding: var(--space-6) var(--space-4) var(--space-4);
  min-width: 0;
}

/* Read-only / secondary regions recede. */
.ac-panel--dim {
  border-color: var(--stroke-dim);
}

.ac-panel__title {
  position: absolute;
  top: -10px;
  left: 12px;
  padding: 0 8px;
  background: var(--ac-panel-title-bg, var(--screen));
  font-family: var(--font-terminal);
  font-size: var(--type-small);
  line-height: 1;
  color: var(--ink);
  text-transform: uppercase;
  letter-spacing: 0.12em;
  text-shadow: var(--glow-text);
}

.ac-panel--dim .ac-panel__title {
  color: var(--ink-dim);
}

.ac-panel--bar {
  padding-top: 0;
  overflow: hidden;
}

.ac-panel__barTitle {
  margin: 0 calc(-1 * var(--space-4)) var(--space-3);
  padding: 4px 16px;
  text-align: center;
  background: var(--fill);
  color: var(--on-fill);
  font-family: var(--font-terminal);
  font-size: var(--type-body);
  text-transform: uppercase;
  letter-spacing: 0.1em;
}

/* >>> src/amber-console.css -> components/input.css */
/* ===========================================================================
   Field / Input / Textarea — recessed data wells.

   Input text is --ink-bright on --screen-well: the operator's own entry is the
   brightest thing in the region, because it is the one part of the panel a
   human wrote.

   Markup: <label class="ac-field">
             <span class="ac-field__label">Title:</span>
             <input class="ac-input" type="text">
           </label>

   Invalid state is blink + inverse video. Never a red border — there is no red.
   =========================================================================== */

.ac-field {
  display: inline-flex;
  flex-direction: column;
  gap: var(--space-1);
  font-family: var(--font-terminal);
  min-width: 0;
}

.ac-field--block {
  display: flex;
  width: 100%;
}

.ac-field__label {
  font-size: var(--type-small);
  line-height: 1;
  color: var(--ink-dim);
  text-transform: uppercase;
  letter-spacing: var(--tracking-body);
}

.ac-input {
  box-sizing: border-box;
  font-family: var(--font-terminal);
  font-size: var(--type-body);
  color: var(--ink-bright);
  background: var(--screen-well);
  border: var(--border-w) solid var(--stroke-dim);
  border-radius: var(--radius-sm);
  padding: 6px 12px;
  letter-spacing: var(--tracking-body);
  caret-color: var(--ink-bright);
  text-transform: uppercase;
}

.ac-input::placeholder {
  color: var(--ink-faint);
}

.ac-input:focus {
  outline: none;
  border-color: var(--stroke);
  box-shadow: var(--glow-box);
}

.ac-input:disabled {
  color: var(--ink-faint);
  border-color: var(--ink-trace);
}

.ac-input:read-only {
  color: var(--ink-dim);
}

.ac-input--block {
  display: block;
  width: 100%;
}

/* textarea keeps the terminal grid: no resize handle chrome, tight leading. */
textarea.ac-input {
  display: block;
  width: 100%;
  line-height: var(--leading);
  resize: vertical;
}

/* Invalid = the machine rejecting the entry. Inverse video carries it; the
   blink is the alarm on top. Pair with aria-invalid for assistive tech. */
.ac-field--invalid .ac-field__label {
  color: var(--ink-bright);
  text-shadow: var(--glow-text);
}
.ac-field--invalid .ac-input,
.ac-input[aria-invalid="true"] {
  background: var(--fill);
  color: var(--on-fill);
  border-color: var(--stroke);
  caret-color: var(--on-fill);
  animation: ac-blink 1.1s steps(1) infinite;
}
.ac-field--invalid .ac-input::placeholder,
.ac-input[aria-invalid="true"]::placeholder {
  color: var(--on-fill);
}

@media (prefers-reduced-motion: reduce) {
  .ac-field--invalid .ac-input,
  .ac-input[aria-invalid="true"] {
    animation: none;
  }
}

/* >>> src/amber-console.css -> components/select.css */
/* ===========================================================================
   Select — an .ac-input plus a typeset ▼.

   The wrapper exists only to position the glyph. That glyph is a Unicode
   character in the terminal font, not an SVG: law 6, ornament is typographic.

   Markup: <span class="ac-select"><select class="ac-input">…</select></span>
   =========================================================================== */

.ac-select {
  position: relative;
  display: inline-flex;
  min-width: 0;
}

.ac-select--block {
  display: flex;
  width: 100%;
}

.ac-select .ac-input {
  appearance: none;
  -webkit-appearance: none;
  padding-right: 38px;
  cursor: pointer;
  width: 100%;
}

.ac-select::after {
  content: "▼";
  position: absolute;
  right: 12px;
  top: 50%;
  transform: translateY(-50%);
  pointer-events: none;
  color: var(--ink);
  font-size: 14px;
  font-family: var(--font-terminal);
}

/* The dropdown list is painted by the OS; this is as far as we can reach. */
.ac-select .ac-input option {
  background: var(--screen);
  color: var(--ink);
}

.ac-select:has(.ac-input:disabled)::after {
  color: var(--ink-faint);
}

/* >>> src/amber-console.css -> components/checkbox.css */
/* ===========================================================================
   Checkbox — a status bit, not a to-do tick.

   :checked fills the square solid, exactly like a hardware indicator lamp.
   There is deliberately NO check glyph: the panel lights a cell or it does not.

   Markup: <label class="ac-check"><input type="checkbox"><span>Label</span></label>
   Group them in a <fieldset> with a <legend>.

   The drawn square stays 20px. The clickable LABEL carries min-height: 44px to
   meet the hit-target floor — see README, "Known constraints".
   =========================================================================== */

.ac-check {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  min-height: 44px;
  font-family: var(--font-terminal);
  font-size: var(--type-body);
  line-height: 1;
  color: var(--ink);
  text-transform: uppercase;
  letter-spacing: var(--tracking-body);
  cursor: pointer;
}

.ac-check input {
  appearance: none;
  -webkit-appearance: none;
  width: 20px;
  height: 20px;
  margin: 0;
  flex: none;
  border: var(--border-w) solid var(--stroke);
  border-radius: 0;
  background: transparent;
  cursor: pointer;
}

.ac-check input:checked {
  background: var(--fill);
  box-shadow: var(--glow-box);
}

.ac-check--disabled,
.ac-check:has(input:disabled) {
  color: var(--ink-faint);
  cursor: default;
}
.ac-check--disabled input,
.ac-check input:disabled {
  border-color: var(--stroke-dim);
  cursor: default;
}
.ac-check--disabled input:checked,
.ac-check input:disabled:checked {
  background: var(--ink-faint);
  box-shadow: none;
}

.ac-check input:focus-visible {
  outline: 2px dashed var(--ink-dim);
  outline-offset: 3px;
}

/* >>> src/amber-console.css -> components/radio.css */
/* ===========================================================================
   Radio — the same indicator lamp, round.

   :checked punches the ring gap with an inset --screen shadow rather than
   drawing a dot, so the lit area reads as one solid gas cell.

   Markup: <label class="ac-radio"><input type="radio" name="mode"><span>Label</span></label>
   =========================================================================== */

.ac-radio {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  min-height: 44px;
  font-family: var(--font-terminal);
  font-size: var(--type-body);
  line-height: 1;
  color: var(--ink);
  text-transform: uppercase;
  letter-spacing: var(--tracking-body);
  cursor: pointer;
}

.ac-radio input {
  appearance: none;
  -webkit-appearance: none;
  width: 20px;
  height: 20px;
  margin: 0;
  flex: none;
  border: var(--border-w) solid var(--stroke);
  border-radius: 50%;
  background: transparent;
  cursor: pointer;
}

.ac-radio input:checked {
  background: var(--fill);
  box-shadow: inset 0 0 0 3px var(--screen), var(--glow-box);
}

.ac-radio--disabled,
.ac-radio:has(input:disabled) {
  color: var(--ink-faint);
  cursor: default;
}
.ac-radio--disabled input,
.ac-radio input:disabled {
  border-color: var(--stroke-dim);
  cursor: default;
}
.ac-radio--disabled input:checked,
.ac-radio input:disabled:checked {
  background: var(--ink-faint);
  box-shadow: inset 0 0 0 3px var(--screen);
}

.ac-radio input:focus-visible {
  outline: 2px dashed var(--ink-dim);
  outline-offset: 3px;
}

/* >>> src/amber-console.css -> components/toggle.css */
/* ===========================================================================
   Toggle — a two-position switch.

   The literal ON / OFF text is MANDATORY. On a monochrome panel a thumb
   position alone is ambiguous: there is no colour to say "this one is live".

   The thumb jumps. No transition.

   Two equivalent forms, both shipped:

     button    <button class="ac-toggle" aria-pressed="false">
                 <span>Plasma</span>
                 <span class="ac-toggle__track"><span class="ac-toggle__thumb"></span></span>
                 <span class="ac-toggle__state">OFF</span>
               </button>
               State hook: aria-pressed (or .ac-toggle--on). Needs JS to flip.

     checkbox  <label class="ac-toggle ac-toggle--input">
                 <span>Plasma</span>
                 <input type="checkbox">
                 <span class="ac-toggle__track"><span class="ac-toggle__thumb"></span></span>
                 <span class="ac-toggle__state"></span>
               </label>
               Pure CSS, no JS. The state text is generated from :checked.
   =========================================================================== */

.ac-toggle {
  display: inline-flex;
  align-items: center;
  gap: var(--space-3);
  min-height: 44px;
  font-family: var(--font-terminal);
  font-size: var(--type-body);
  line-height: 1;
  color: var(--ink);
  text-transform: uppercase;
  letter-spacing: var(--tracking-body);
  background: none;
  border: none;
  padding: 0;
  cursor: pointer;
}

.ac-toggle__track {
  position: relative;
  width: 64px;
  height: 28px;
  flex: none;
  border: var(--border-w) solid var(--stroke);
  border-radius: var(--radius-sm);
}

.ac-toggle__thumb {
  position: absolute;
  top: 3px;
  left: 3px;
  width: 26px;
  height: 18px;
  background: var(--ink-faint);
}

.ac-toggle--on .ac-toggle__thumb,
.ac-toggle[aria-pressed="true"] .ac-toggle__thumb {
  left: auto;
  right: 3px;
  background: var(--fill);
  box-shadow: var(--glow-box);
}

.ac-toggle__state {
  min-width: 2.2em;
  text-align: left;
  color: var(--ink-dim);
}

.ac-toggle--on .ac-toggle__state,
.ac-toggle[aria-pressed="true"] .ac-toggle__state {
  color: var(--ink-bright);
  text-shadow: var(--glow-text);
}

.ac-toggle:disabled {
  color: var(--ink-faint);
  cursor: default;
}
.ac-toggle:disabled .ac-toggle__track {
  border-color: var(--stroke-dim);
}
.ac-toggle:disabled .ac-toggle__thumb {
  background: var(--ink-trace);
  box-shadow: none;
}

.ac-toggle:focus-visible {
  outline: 2px dashed var(--ink-dim);
  outline-offset: 3px;
}

/* --- CSS-only variant: <label> wrapping a real checkbox --- */
.ac-toggle--input input {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.ac-toggle--input .ac-toggle__state::after {
  content: "OFF";
}

.ac-toggle--input input:checked ~ .ac-toggle__track .ac-toggle__thumb {
  left: auto;
  right: 3px;
  background: var(--fill);
  box-shadow: var(--glow-box);
}

.ac-toggle--input input:checked ~ .ac-toggle__state {
  color: var(--ink-bright);
  text-shadow: var(--glow-text);
}
.ac-toggle--input input:checked ~ .ac-toggle__state::after {
  content: "ON";
}

.ac-toggle--input input:disabled ~ .ac-toggle__track {
  border-color: var(--stroke-dim);
}
.ac-toggle--input input:disabled ~ .ac-toggle__track .ac-toggle__thumb {
  background: var(--ink-trace);
  box-shadow: none;
}

.ac-toggle--input input:focus-visible ~ .ac-toggle__track {
  outline: 2px dashed var(--ink-dim);
  outline-offset: 3px;
}

/* >>> src/amber-console.css -> components/statusbar.css */
/* ===========================================================================
   Status bar — the machine's voice.

   Inverse video, terse KEY:VALUE strings, no pleasantries: "STATUS:AUTO MODE",
   not "Your show is currently running".

   Put role="status" on it so a screen reader announces changes.

   --line is the quiet sibling: transparent, ruled top and bottom, no glow, for
   data rows that report rather than assert.
   =========================================================================== */

.ac-statusbar {
  box-sizing: border-box;
  display: flex;
  gap: var(--space-8);
  align-items: baseline;
  flex-wrap: wrap;
  background: var(--fill);
  color: var(--on-fill);
  font-family: var(--font-terminal);
  font-size: var(--type-body);
  line-height: 1;
  text-transform: uppercase;
  letter-spacing: var(--tracking-body);
  padding: 6px var(--space-4);
  box-shadow: var(--glow-box);
  min-width: 0;
}

.ac-statusbar--line {
  background: transparent;
  color: var(--ink);
  box-shadow: none;
  border-top: var(--border-w) solid var(--stroke-dim);
  border-bottom: var(--border-w) solid var(--stroke-dim);
}

/* >>> src/amber-console.css -> components/readout.css */
/* ===========================================================================
   Readout — an instrument value.

   The number is the largest thing on the panel and the only --ink-bright text
   in its region. Units ride at body size so the digits keep the eye.

   Markup: <div class="ac-readout">
             <div class="ac-readout__label">Lamp</div>
             <div class="ac-readout__value">41.0<span class="ac-readout__unit">V</span></div>
           </div>
   =========================================================================== */

.ac-readout {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  font-family: var(--font-terminal);
  text-align: center;
  min-width: 0;
}

.ac-readout__label {
  background: var(--fill);
  color: var(--on-fill);
  padding: 3px 12px;
  font-size: var(--type-small);
  line-height: 1.2;
  text-transform: uppercase;
  letter-spacing: 0.1em;
}

.ac-readout__label--plain {
  background: transparent;
  color: var(--ink-dim);
  padding: 0;
}

.ac-readout__value {
  font-size: var(--type-display);
  line-height: 1;
  color: var(--ink-bright);
  text-shadow: var(--glow-text);
  letter-spacing: 0.06em;
  font-variant-numeric: tabular-nums;
}

.ac-readout__unit {
  font-size: var(--type-body);
  color: var(--ink);
  margin-left: 6px;
}

/* Hero value — one per screen at most. */
.ac-readout--lg .ac-readout__value {
  font-size: 64px;
}

/* Dense form: label left, value right, on one row. For instrument tables. */
.ac-readout--inline {
  flex-direction: row;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-4);
  text-align: left;
}
.ac-readout--inline .ac-readout__value {
  font-size: var(--type-title);
}

/* >>> src/amber-console.css -> components/badge.css */
/* ===========================================================================
   Badge — a micro tag.

   The one place in this system with a 1px border: a badge is a chip, not
   structure, and 2px on something 8px tall reads as a solid block.

   Silkscreen at 8px. This is the ONLY size range where VT323 must not be used —
   it is a bitmap face and turns to mud below 18px.
   =========================================================================== */

.ac-badge {
  display: inline-block;
  font-family: var(--font-micro);
  font-size: var(--type-micro);
  line-height: 1.4;
  letter-spacing: var(--tracking-micro);
  text-transform: uppercase;
  padding: 4px 8px;
  border: 1px solid var(--stroke);
  border-radius: 2px;
  color: var(--ink);
}

.ac-badge--filled {
  background: var(--fill);
  color: var(--on-fill);
  border-color: var(--fill);
}

.ac-badge--dim {
  color: var(--ink-faint);
  border-color: var(--stroke-dim);
}

/* >>> src/amber-console.css -> components/banner.css */
/* ===========================================================================
   Banner — the loudest element the system allows. One per screen.

   The ✳✳ glyphs are injected by ::before / ::after, so the markup stays clean
   text. Combine with .ac-blink for a live, unacknowledged alarm — and note that
   the glyphs and the inverse-video pairing are what make it still read as an
   alarm when the blink is switched off by prefers-reduced-motion.

   Put role="alert" on it when it announces a state change.
   =========================================================================== */

.ac-banner {
  box-sizing: border-box;
  display: flex;
  justify-content: center;
  align-items: center;
  gap: var(--space-6);
  border: var(--border-w) solid var(--stroke);
  border-radius: 2px;
  padding: 10px 24px;
  font-family: var(--font-terminal);
  font-size: var(--type-display);
  line-height: 1;
  letter-spacing: 0.14em;
  color: var(--ink-bright);
  text-transform: uppercase;
  text-shadow: var(--glow-text);
}

.ac-banner::before,
.ac-banner::after {
  content: "✳✳";
  letter-spacing: 0;
}

/* At display size a two-word banner has a min-content width around 400px, which
   does not fit a 390px phone. Step down to title size and tighten the gap
   rather than let the frame clip it. The glyphs and the inverse-video pairing —
   the parts that carry the alarm — are untouched. */
@media (max-width: 480px) {
  .ac-banner {
    gap: var(--space-3);
    padding: 10px var(--space-3);
    font-size: var(--type-title);
    flex-wrap: wrap;
  }
}

/* Inverse video for an alarm that must survive a static screenshot. */
.ac-banner--filled {
  background: var(--fill);
  color: var(--on-fill);
  text-shadow: none;
  box-shadow: var(--glow-box);
}

.ac-banner--dim {
  color: var(--ink-faint);
  border-color: var(--stroke-dim);
  text-shadow: none;
}

/* >>> src/amber-console.css -> components/rule.css */
/* ===========================================================================
   Rule — a 2px divider. Use <hr class="ac-hr">.
   =========================================================================== */

.ac-hr {
  border: none;
  border-top: var(--border-w) solid var(--stroke-dim);
  margin: var(--space-4) 0;
}

/* Full-strength divider, for separating two regions rather than two rows. */
.ac-hr--bright {
  border-top-color: var(--stroke);
}

/* >>> src/amber-console.css -> components/table.css */
/* ===========================================================================
   Table — the data grid this UI genre lives on.

   Inverse-video <thead> in Silkscreen micro caps, --ink-trace row separators,
   zero rounding. Numeric columns get .ac-table__num for tabular alignment.

   __row--active is the live row: inverse video. Add .ac-blink for an
   unacknowledged alarm — the inverse fill is what carries it when the blink is
   suppressed by prefers-reduced-motion.

   Wrap wide tables in <div class="ac-table-scroll"> so the page body never
   scrolls sideways.
   =========================================================================== */

.ac-table {
  width: 100%;
  border-collapse: collapse;
  font-family: var(--font-terminal);
  font-size: var(--type-small);
}

.ac-table th {
  text-align: left;
  font-weight: 400;
  text-transform: uppercase;
  font-family: var(--font-micro);
  font-size: var(--type-micro);
  letter-spacing: var(--tracking-micro);
  background: var(--fill);
  color: var(--on-fill);
  padding: 6px 12px;
  white-space: nowrap;
}

.ac-table td {
  padding: 6px 12px;
  color: var(--ink);
  border-bottom: var(--border-w) solid var(--ink-trace);
  text-transform: uppercase;
}

.ac-table tbody tr:last-child td {
  border-bottom: none;
}

.ac-table__row--active td {
  background: var(--fill);
  color: var(--on-fill);
}

.ac-table--zebra tbody tr:nth-child(even) td {
  background: var(--screen-raised);
}

.ac-table--dense td,
.ac-table--dense th {
  padding: 2px 10px;
}

.ac-table__num {
  text-align: right;
  font-variant-numeric: tabular-nums;
}

.ac-table-scroll {
  overflow-x: auto;
  max-width: 100%;
}

/* >>> src/amber-console.css -> components/list.css */
/* ===========================================================================
   List — a keyed log. KEY:VALUE rows joined by leader dots.

   Derived from law 4 (the character grid) and law 6 (ornament is typographic):
   the filler is a 2px dotted rule, which at 2px renders as square cells rather
   than round dots — the same grid the rest of the panel sits on.

   Markup: <dl class="ac-list">
             <div class="ac-list__item">
               <dt class="ac-list__key">Lamp Hours</dt>
               <span class="ac-list__fill"></span>
               <dd class="ac-list__value">1284</dd>
             </div>
           </dl>

   WHEN NOT TO USE: more than one value per key, or anything sortable — that is
   a table. This is for a fixed set of named readings.
   =========================================================================== */

.ac-list {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  margin: 0;
  padding: 0;
  list-style: none;
  font-family: var(--font-terminal);
  font-size: var(--type-body);
  line-height: 1;
  text-transform: uppercase;
  letter-spacing: var(--tracking-body);
  min-width: 0;
}

.ac-list__item {
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  margin: 0;
  min-width: 0;
}

.ac-list__key {
  color: var(--ink-dim);
  white-space: nowrap;
}

/* An empty flex item's baseline is its bottom margin edge, so the dotted rule
   lands exactly on the row's text baseline. */
.ac-list__fill {
  flex: 1;
  min-width: var(--space-6);
  align-self: baseline;
  border-bottom: var(--border-w) dotted var(--ink-trace);
}

.ac-list__value {
  margin: 0;
  color: var(--ink);
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

/* Values that are the point of the panel rather than context. */
.ac-list--bright .ac-list__value {
  color: var(--ink-bright);
  text-shadow: var(--glow-text);
}

.ac-list--dense {
  gap: 0;
  font-size: var(--type-small);
}

.ac-list--dim .ac-list__key {
  color: var(--ink-faint);
}
.ac-list--dim .ac-list__value {
  color: var(--ink-dim);
}

/* >>> src/amber-console.css -> components/dialog.css */
/* ===========================================================================
   Dialog — built on the native <dialog> element.

   It appears. There is no scale, no fade, no backdrop blur: law 3 says no
   elevation, and the animation policy says state changes have no in-between
   frames. The backdrop is flat black so the panel behind it reads as powered
   down, not as a blurred photograph.

   Markup: <dialog class="ac-dialog">
             <form method="dialog">
               <h2 class="ac-dialog__title">Confirm</h2>
               <div class="ac-dialog__body">…</div>
               <div class="ac-dialog__actions">
                 <button class="ac-btn" value="cancel">Cancel</button>
                 <button class="ac-btn ac-btn--filled" value="ok">Confirm</button>
               </div>
             </form>
           </dialog>

   Open it with dialog.showModal() — or let src/amber-console.js wire
   [data-ac-dialog-open] / [data-ac-dialog-close] for you.

   WHEN NOT TO USE: anything the operator must be able to see the panel behind.
   A modal on a control surface hides machine state; prefer an .ac-banner.
   =========================================================================== */

.ac-dialog {
  box-sizing: border-box;
  max-width: min(90vw, 560px);
  padding: 0;
  border: var(--border-w) solid var(--stroke);
  border-radius: var(--radius);
  background: var(--screen);
  color: var(--ink);
  font-family: var(--font-terminal);
  box-shadow: var(--glow-box);
  overflow: hidden;
}

.ac-dialog::backdrop {
  /* Flat, no blur. Overridable, but keep it opaque enough to kill the panel. */
  background: var(--ac-backdrop, rgba(0, 0, 0, 0.72));
}

/* The machine's own heading — same inverse strip as .ac-panel--bar. */
.ac-dialog__title {
  margin: 0;
  padding: 4px 16px;
  text-align: center;
  background: var(--fill);
  color: var(--on-fill);
  font-family: var(--font-terminal);
  font-size: var(--type-body);
  font-weight: 400;
  text-transform: uppercase;
  letter-spacing: 0.1em;
}

.ac-dialog__body {
  padding: var(--space-4);
  font-size: var(--type-body);
  line-height: var(--leading);
}

.ac-dialog__actions {
  display: flex;
  gap: var(--space-4);
  justify-content: flex-end;
  flex-wrap: wrap;
  padding: 0 var(--space-4) var(--space-4);
}

.ac-dialog--wide {
  max-width: min(94vw, 860px);
}

/* >>> src/amber-console.css -> components/meter.css */
/* ===========================================================================
   Meter — a horizontal bargraph in discrete cells.

   The fill is a repeating-linear-gradient of 8px blocks with 4px gaps: a
   stepped bar, never a smooth one. A continuous gradient would imply a
   precision the panel does not have, and law 3 forbids decorative gradients.

   Set the value as a 0-100 number on --ac-meter-value:

     <div class="ac-meter">
       <div class="ac-meter__track" style="--ac-meter-value: 72"
            role="progressbar" aria-valuenow="72" aria-valuemin="0" aria-valuemax="100">
         <div class="ac-meter__bar"></div>
       </div>
       <div class="ac-meter__scale"><span>0</span><span>50</span><span>100</span></div>
     </div>

   WHEN NOT TO USE: a single precise reading. That is an .ac-readout — a number
   the operator can write down beats a bar they have to estimate.
   =========================================================================== */

.ac-meter {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  font-family: var(--font-terminal);
  min-width: 0;
}

.ac-meter__track {
  position: relative;
  height: 24px;
  border: var(--border-w) solid var(--stroke-dim);
  border-radius: var(--radius-sm);
  background: var(--screen-well);
  overflow: hidden;
}

.ac-meter__bar {
  height: 100%;
  width: calc(var(--ac-meter-value, 0) * 1%);
  background: repeating-linear-gradient(
    90deg,
    var(--fill) 0 8px,
    transparent 8px 12px
  );
  box-shadow: var(--glow-box);
}

.ac-meter__scale {
  display: flex;
  justify-content: space-between;
  font-family: var(--font-micro);
  font-size: var(--type-micro);
  letter-spacing: var(--tracking-micro);
  color: var(--ink-faint);
}

.ac-meter--lg .ac-meter__track {
  height: 32px;
}

.ac-meter--dim .ac-meter__bar {
  background: repeating-linear-gradient(
    90deg,
    var(--ink-faint) 0 8px,
    transparent 8px 12px
  );
  box-shadow: none;
}

/* Over-range. Inverse-video the whole track and blink it — there is no red. */
.ac-meter--alarm .ac-meter__track {
  border-color: var(--stroke);
}
.ac-meter--alarm .ac-meter__bar {
  animation: ac-blink 1.1s steps(1) infinite;
}

@media (prefers-reduced-motion: reduce) {
  .ac-meter--alarm .ac-meter__bar {
    animation: none;
  }
}

/* >>> src/amber-console.css -> components/keypad.css */
/* ===========================================================================
   Keypad — a 3-column numeric entry grid.

   These panels are operated with gloves, so the keys are large and square-ish
   and the digit is centred rather than parked top-left: on a key with one
   character there is no label/value split for the top-left rule to serve.

   Keys are ordinary .ac-btn elements, so every button state applies unchanged.

   Markup: <div class="ac-keypad">
             <button class="ac-btn ac-keypad__key">7</button>  … 1-9 …
             <button class="ac-btn ac-keypad__key ac-keypad__key--fn">Clr</button>
             <button class="ac-btn ac-keypad__key">0</button>
             <button class="ac-btn ac-keypad__key ac-keypad__key--fn">Ent</button>
           </div>

   Clr / Ent are Title Case: they are operator soft keys, like tabs.

   WHEN NOT TO USE: free text, or anything a hardware keyboard is present for.
   An on-screen keypad exists because there is no keyboard in a projection booth.
   =========================================================================== */

.ac-keypad {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--space-2);
  max-width: 420px;
  min-width: 0;
}

.ac-keypad__key {
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  /* Comfortably past the 44px floor without four rows running off a phone. */
  min-height: 72px;
  min-width: 0;
  padding: 10px 16px;
  font-size: var(--type-title);
}

/* CLR / ENT read as commands rather than digits. */
.ac-keypad__key--fn {
  font-size: var(--type-body);
}

.ac-keypad__key--wide {
  grid-column: span 2;
}

.ac-keypad--dense .ac-keypad__key {
  min-height: 56px;
}

/* >>> src/amber-console.css -> components/spinner.css */
/* ===========================================================================
   Spinner — a wait indicator made of characters.

   Not a rotating circle: a circle is an icon, and this system has no icon set.
   This is the terminal's own idiom — one cell cycling through | / — \ on a hard
   steps() clock, so it lands on whole characters the way a real one did.

   Implemented as a 4-line block scrolled inside a 1-cell window by a transform,
   which every browser animates. (Animating the `content` property directly is
   not portable.)

   Markup: <span class="ac-spinner" role="status" aria-label="Working"></span>

   WHEN NOT TO USE: anything whose duration you know. A machine that can measure
   progress should show an .ac-meter; a spinner admits it cannot.
   =========================================================================== */

.ac-spinner {
  display: inline-block;
  overflow: hidden;
  width: 1ch;
  height: 1em;
  line-height: 1;
  vertical-align: -0.1em;
  font-family: var(--font-terminal);
  font-size: var(--type-body);
  color: var(--ink-bright);
  text-shadow: var(--glow-text);
}

.ac-spinner::before {
  content: "|\A/\A—\A\\";
  display: block;
  white-space: pre;
  line-height: 1;
  animation: ac-spinner-turn 0.8s steps(4) infinite;
}

@keyframes ac-spinner-turn {
  to {
    transform: translateY(-4em);
  }
}

.ac-spinner--dim {
  color: var(--ink-faint);
  text-shadow: none;
}

.ac-spinner--lg {
  font-size: var(--type-title);
}

/* Static, but still unmistakably "working". */
@media (prefers-reduced-motion: reduce) {
  .ac-spinner::before {
    animation: none;
  }
}


/* --- Environment overrides. Last, so they win. --- */
/* >>> src/amber-console.css -> base/a11y.css */
/* ===========================================================================
   Forced colors

   This whole design is a colour trick, so high-contrast mode needs a real
   fallback rather than a broken one. Per the Forced Colors Mode spec the UA
   forces text-shadow and box-shadow to none, which kills the discharge glow —
   but ONLY while an element keeps forced-color-adjust: auto. What is left to do:

     1. Remove the simulation overlays entirely — they are decoration.
     2. Restate every border in CanvasText, since our borders carry the layout.
     3. Restate inverse video in Highlight / HighlightText, because "this block
        is the machine speaking" is information, not styling, and it would
        otherwise flatten into the page.
     4. Kill the glow again on everything (3) opts out.

   POINT 4 IS NOT BOOKKEEPING. `forced-color-adjust: none` opts an element out
   of forced colors WHOLESALE, and that hands it back its box-shadow — so the
   very rules that preserve inverse video also reinstate an amber discharge halo
   around it, which is an off-palette colour bleeding into the one mode that
   exists to have no off-palette colours. Every block below that sets
   forced-color-adjust: none must also restate its shadows.

   The inverse-video list is the whole set of components that fill with
   var(--fill). Grep for it before adding a component: a state that reads as
   "the machine is speaking" and flattens to an outline here has lost its
   meaning, not just its styling.
   =========================================================================== */

@media (forced-colors: active) {
  .ac-bloom::before,
  .ac-crt::after,
  .ac-scanlines::after,
  .ac-retrace,
  .ac-persist,
  .ac-mesh {
    display: none;
  }

  /* A decaying ghost is a low-contrast duplicate of live text, which is the one
     thing high-contrast mode exists to prevent. Hiding goes back to instant. */
  .ac-afterglow [hidden],
  .ac-afterglow dialog:not([open]) {
    transition: none;
    filter: none;
  }

  /* Blanket, for the same reason as the reduced-motion block in
     tokens/effects.css: every decay is a descendant rule under .ac-afterglow and
     outranks a plain reset, so catching them by name means missing the next one. */
  .ac-afterglow *,
  .ac-afterglow *::before,
  .ac-afterglow *::after {
    transition: none !important;
  }

  /* And the scroll smear, in case the page is captured mid-scroll. The :not()
     list mirrors the one in tokens/effects.css — keep them identical. */
  .ac-afterglow[data-ac-scrolling] > *:not(.ac-persist, .ac-retrace, .ac-mesh) {
    filter: none;
  }

  /* Blink survives forced colors — it carries the alarm — but it goes back to a
     hard steps(1) edge. Every frame the decay adds is a partly-transparent one,
     and a user who asked for maximum contrast did not ask for an alarm that
     spends a third of its cycle sliding through it. */
  .ac-afterglow .ac-blink,
  .ac-afterglow .ac-cursor::after,
  .ac-afterglow .ac-field--invalid .ac-input,
  .ac-afterglow .ac-input[aria-invalid="true"],
  .ac-afterglow .ac-meter--alarm .ac-meter__bar {
    animation-name: ac-blink;
    animation-timing-function: steps(1);
    filter: none;
  }

  .ac-btn,
  .ac-tab,
  .ac-panel,
  .ac-input,
  .ac-banner,
  .ac-dialog,
  .ac-toggle__track,
  .ac-meter__track,
  .ac-check input,
  .ac-radio input {
    border-color: CanvasText;
  }

  .ac-nav {
    border-bottom-color: CanvasText;
  }

  .ac-hr {
    border-top-color: CanvasText;
  }

  .ac-table td {
    border-bottom-color: CanvasText;
  }

  .ac-list__fill {
    border-bottom-color: CanvasText;
  }

  /* Inverse video — the machine's voice. Must survive. */
  .ac-btn--filled,
  .ac-btn[aria-pressed="true"],
  .ac-tab--active,
  .ac-tab[aria-selected="true"],
  .ac-nav__link[aria-current="page"],
  .ac-statusbar,
  .ac-panel__barTitle,
  .ac-dialog__title,
  .ac-readout__label,
  .ac-badge--filled,
  /* The loudest element the system has. README says the inverse fill and the
     ✳✳ glyphs are what carry the alarm once blink is switched off — so this is
     the last thing that may flatten into an outlined box here. */
  .ac-banner--filled,
  .ac-table th,
  .ac-table__row--active td {
    forced-color-adjust: none;
    background: Highlight;
    color: HighlightText;
    box-shadow: none;
    text-shadow: none;
  }

  /* Rejected entry. Invalid is inverse video PLUS blink, and the blink is
     restated above — without the fill, an invalid field is a normal field that
     flickers, which reads as a rendering fault rather than a rejection. */
  .ac-field--invalid .ac-input,
  .ac-input[aria-invalid="true"] {
    forced-color-adjust: none;
    background: Highlight;
    color: HighlightText;
    caret-color: HighlightText;
    box-shadow: none;
    text-shadow: none;
  }
  .ac-field--invalid .ac-input::placeholder,
  .ac-input[aria-invalid="true"]::placeholder {
    color: HighlightText;
  }

  .ac-statusbar--line,
  .ac-readout__label--plain {
    forced-color-adjust: auto;
    background: Canvas;
    color: CanvasText;
  }

  /* Lit indicator lamps and meter fill. The --input toggle is the CSS-only
     variant and carries no aria-pressed at all, so it has to be matched through
     :checked or an ON switch renders with the GrayText thumb below — i.e. it
     reads as OFF. (The mandatory ON/OFF text is why that was survivable rather
     than fatal, which is the rule earning its keep.) */
  .ac-check input:checked,
  .ac-toggle--on .ac-toggle__thumb,
  .ac-toggle[aria-pressed="true"] .ac-toggle__thumb,
  .ac-toggle--input input:checked ~ .ac-toggle__track .ac-toggle__thumb,
  .ac-meter__bar {
    forced-color-adjust: none;
    background: Highlight;
    box-shadow: none;
  }

  /* The radio keeps its ring: the gap is punched with an inset shadow, so this
     one cannot take `box-shadow: none` with the rest — the lamp would fill in
     solid and stop reading as a radio. Restate the gap in Canvas instead. */
  .ac-radio input:checked {
    forced-color-adjust: none;
    background: Highlight;
    box-shadow: inset 0 0 0 3px Canvas;
  }

  .ac-toggle__thumb {
    forced-color-adjust: none;
    background: GrayText;
    box-shadow: none;
  }

  :focus-visible {
    outline-color: CanvasText;
  }
}

/* >>> src/amber-console.css -> base/print.css */
/* ===========================================================================
   Print

   A console screenshot has to print legibly on a booth wall, so the whole
   palette inverts to black on white by re-pointing the semantic tokens. Every
   component keys off those tokens, so nothing else needs restating.

   The 2px rules stay — they are the layout. Glow, bloom, scanlines and every
   animation go, because they describe energized hardware and paper is not.

   This is the one file besides tokens/colors.css and tokens/effects.css that is
   allowed to contain literal hex colours; scripts/check-prohibitions.mjs
   enforces that.
   =========================================================================== */

@media print {
  :root {
    --screen: #ffffff;
    --screen-raised: #f2f2f2;
    --screen-well: #ffffff;

    --ink: #000000;
    --ink-bright: #000000;
    --ink-dim: #333333;
    --ink-faint: #666666;
    --ink-trace: #999999;

    --fill: #000000;
    --fill-bright: #000000;
    --on-fill: #ffffff;

    --stroke: #000000;
    --stroke-dim: #666666;

    --glow-text: none;
    --glow-box: none;
  }

  /* .ac-bloom redefines the glow tokens on the frame itself, which is a closer
     ancestor than :root and so wins for every descendant. Reset it here or the
     whole page prints with an amber halo around the text. */
  .ac-bloom {
    --glow-text: none;
    --glow-box: none;
  }

  body {
    background: #ffffff;
    color: #000000;
    -webkit-font-smoothing: auto;
  }

  .ac-bloom::before,
  .ac-crt::after,
  .ac-scanlines::after,
  .ac-retrace,
  .ac-persist,
  .ac-mesh {
    display: none;
  }

  /* Paper holds an image indefinitely, which is the opposite problem. A hidden
     element must print as absent, not as a 40%-opacity smudge over the live one. */
  .ac-afterglow [hidden],
  .ac-afterglow dialog:not([open]) {
    transition: none;
    filter: none;
  }

  /* Blanket, for the same reason as the reduced-motion block in
     tokens/effects.css: every decay is a descendant rule under .ac-afterglow and
     outranks a plain reset, so catching them by name means missing the next one. */
  .ac-afterglow *,
  .ac-afterglow *::before,
  .ac-afterglow *::after {
    transition: none !important;
  }

  /* And the scroll smear, in case the page is captured mid-scroll. The :not()
     list mirrors the one in tokens/effects.css — keep them identical. */
  .ac-afterglow[data-ac-scrolling] > *:not(.ac-persist, .ac-retrace, .ac-mesh) {
    filter: none;
  }

  .ac-blink,
  .ac-cursor::after,
  .ac-spinner::before,
  .ac-meter--alarm .ac-meter__bar,
  .ac-field--invalid .ac-input,
  /* Restated at the afterglow override's own weight — see tokens/effects.css. */
  .ac-afterglow .ac-blink,
  .ac-afterglow .ac-cursor::after,
  .ac-afterglow .ac-field--invalid .ac-input,
  .ac-afterglow .ac-input[aria-invalid="true"],
  .ac-afterglow .ac-meter--alarm .ac-meter__bar {
    animation: none;
    filter: none;
  }

  /* EVERY FILLED SURFACE NEEDS THIS, and the reason is that browsers print with
     "background graphics" OFF by default: a background-color without
     print-color-adjust: exact is simply dropped. So this list is not a
     nice-to-have, it is the difference between a page that carries its state to
     paper and one that does not — a set interlock bit prints as an EMPTY
     square, a thrown switch as an empty box, a meter as an empty track.
     Anything whose meaning is "this cell is lit" belongs here.

     The visual suite cannot catch a gap in this list. Playwright's
     emulateMedia({ media: "print" }) renders to a screen surface, which honours
     backgrounds regardless — so console-1280-print.png shows filled interlocks
     that a real printer would not. Check it by hand against a print preview. */

  /* Inverse video — a blinking alarm is invisible on paper, so the ✳✳ glyphs
     and the fill are the only things left carrying it. */
  .ac-statusbar,
  .ac-panel__barTitle,
  .ac-dialog__title,
  .ac-readout__label,
  .ac-table th,
  .ac-table__row--active td,
  .ac-btn--filled,
  .ac-btn[aria-pressed="true"],
  .ac-tab--active,
  .ac-tab[aria-selected="true"],
  .ac-nav__link[aria-current="page"],
  .ac-badge--filled,
  .ac-banner--filled,
  .ac-field--invalid .ac-input,
  .ac-input[aria-invalid="true"],
  /* Lit lamps and fills — the bit field is the densest state on the panel.
     The thumb is listed unconditionally, not just when thrown: an OFF thumb
     that prints as nothing leaves an empty track with no readable position. */
  .ac-check input:checked,
  .ac-radio input:checked,
  .ac-toggle__thumb,
  .ac-meter__bar {
    -webkit-print-color-adjust: exact;
    print-color-adjust: exact;
  }

  .ac-screen {
    min-height: 0;
    overflow: visible;
  }

  .ac-nav--sticky {
    position: static;
  }
}


/*# sourceMappingURL=amber-console.css.map */
