/* ==========================================================================
   ASH & EMBER — UI kit
   Dark-mode only. Three rules hold the whole thing together:

     1. Hairlines carry the structure. Not shadows, not fills.
     2. Bone is the ink. Ember is rare — target ~2% of lit pixels.
        Primary actions are BONE. Ember means live, critical, or destructive.
     3. Geometry is machined. Radius is 0–3px; scale comes from the
        notched corner (.cut), never from rounding.

   Everything is driven by custom properties, so a component can be
   re-skinned in place: <div class="meter" style="--fill:var(--jade)">
   ========================================================================== */

/* ── 0. FACES ──────────────────────────────────────────────────────────── */
/* Overpass is self-hosted from public/assets/fonts (SIL OFL, see
   OFL-Overpass.txt). These are variable files — one per subset covers the
   whole 100–900 range, so any weight is free once the file has loaded.
   Paths are relative to this stylesheet, so the kit survives being served
   from a subdirectory. IBM Plex is still linked from Google Fonts by the
   page — self-host it the same way before launch. */

@font-face {
  font-family: "Overpass";
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url("../fonts/overpass-v19-latin-var.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;
}
@font-face {
  font-family: "Overpass";
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url("../fonts/overpass-v19-latin-ext-var.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;
}

/* IBM Plex, self-hosted the same way — see OFL-IBM-Plex.txt. Nine faces the kit
   actually uses, each split into latin and latin-ext so a page that never shows
   an accented character never fetches the second file. Weights are static
   instances rather than a variable axis: Google Fonts serves Plex that way, and
   only three weights per family are in use. */
@font-face {
  font-family: "IBM Plex Sans";
  font-style: normal;
  font-weight: 400;
  font-stretch: 100%;
  font-display: swap;
  src: url("../fonts/ibm-plex-sans-v23-latin-400.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;
}
@font-face {
  font-family: "IBM Plex Sans";
  font-style: normal;
  font-weight: 400;
  font-stretch: 100%;
  font-display: swap;
  src: url("../fonts/ibm-plex-sans-v23-latin-ext-400.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;
}
@font-face {
  font-family: "IBM Plex Sans";
  font-style: normal;
  font-weight: 500;
  font-stretch: 100%;
  font-display: swap;
  src: url("../fonts/ibm-plex-sans-v23-latin-500.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;
}
@font-face {
  font-family: "IBM Plex Sans";
  font-style: normal;
  font-weight: 500;
  font-stretch: 100%;
  font-display: swap;
  src: url("../fonts/ibm-plex-sans-v23-latin-ext-500.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;
}
@font-face {
  font-family: "IBM Plex Sans";
  font-style: normal;
  font-weight: 600;
  font-stretch: 100%;
  font-display: swap;
  src: url("../fonts/ibm-plex-sans-v23-latin-600.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;
}
@font-face {
  font-family: "IBM Plex Sans";
  font-style: normal;
  font-weight: 600;
  font-stretch: 100%;
  font-display: swap;
  src: url("../fonts/ibm-plex-sans-v23-latin-ext-600.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;
}
@font-face {
  font-family: "IBM Plex Sans Condensed";
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url("../fonts/ibm-plex-sans-condensed-v15-latin-500.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;
}
@font-face {
  font-family: "IBM Plex Sans Condensed";
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url("../fonts/ibm-plex-sans-condensed-v15-latin-ext-500.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;
}
@font-face {
  font-family: "IBM Plex Sans Condensed";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("../fonts/ibm-plex-sans-condensed-v15-latin-600.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;
}
@font-face {
  font-family: "IBM Plex Sans Condensed";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("../fonts/ibm-plex-sans-condensed-v15-latin-ext-600.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;
}
@font-face {
  font-family: "IBM Plex Sans Condensed";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("../fonts/ibm-plex-sans-condensed-v15-latin-700.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;
}
@font-face {
  font-family: "IBM Plex Sans Condensed";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("../fonts/ibm-plex-sans-condensed-v15-latin-ext-700.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;
}
@font-face {
  font-family: "IBM Plex Mono";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("../fonts/ibm-plex-mono-v20-latin-400.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;
}
@font-face {
  font-family: "IBM Plex Mono";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("../fonts/ibm-plex-mono-v20-latin-ext-400.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;
}
@font-face {
  font-family: "IBM Plex Mono";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("../fonts/ibm-plex-mono-v20-latin-600.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;
}
@font-face {
  font-family: "IBM Plex Mono";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("../fonts/ibm-plex-mono-v20-latin-ext-600.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;
}
@font-face {
  font-family: "IBM Plex Mono";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("../fonts/ibm-plex-mono-v20-latin-700.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;
}
@font-face {
  font-family: "IBM Plex Mono";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("../fonts/ibm-plex-mono-v20-latin-ext-700.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;
}

/* ── 1. TOKENS ─────────────────────────────────────────────────────────── */

:root {
  color-scheme: dark;

  /* Surfaces — each step is a deliberate elevation, not a gradient ramp */
  --bg-0:  #0D0E11;   /* page */
  --bg-1:  #111318;   /* sunken: inputs, wells, terminal */
  --bg-2:  #16181C;   /* panel */
  --bg-3:  #1C1F25;   /* raised: hover, popovers */
  --bg-4:  #242832;   /* pressed / selected */

  /* Hairlines */
  --line-0: #1D2025;  /* barely there — internal dividers */
  --line-1: #2A2E36;  /* default border */
  --line-2: #3A404A;  /* emphasis / hover border */

  /* Ink */
  --ink-0: #E8E6E1;   /* bone — primary text */
  --ink-1: #B7BBC1;   /* secondary */
  --ink-2: #868C95;   /* muted, labels */
  --ink-3: #5B616A;   /* disabled */
  --ink-on-bone: #0D0E11;
  --ink-on-ember: #140503;
  --ink-on-ok: #06170F;
  --ink-on-warn: #1A1203;

  /* Signal — ember is the only saturated colour that gets to be loud */
  --ember:   #FF5A36;
  --ember-2: #C8401F;
  --ember-a: rgba(255, 90, 54, 0.14);
  --ember-b: rgba(255, 90, 54, 0.30);

  --jade:   #6FD3A0;  --jade-a:   rgba(111, 211, 160, 0.13);
  --brass:  #E8B84B;  --brass-a:  rgba(232, 184, 75, 0.13);
  --steel:  #6E8CB5;  --steel-a:  rgba(110, 140, 181, 0.13);
  /* Cyan joins the palette for one job: telling kinds of machine apart on the
     map. Mixed to the same register as the three above — mid-saturation and
     light, so it reads beside them rather than jumping out of them. It is
     DECORATION, like the rarity tiers: it says what a thing is and never what
     state it is in, which is why it has no semantic alias. */
  --cyan:   #5FC6CE;  --cyan-a:   rgba(95, 198, 206, 0.13);

  /* Semantic aliases. Components speak through these, never through the
     palette directly, so re-pointing --ok re-skins every success state at
     once. The palette names stay for things that are decoration rather
     than meaning: rarity tiers, the brand mark, focus rings. */
  --ok:   var(--jade);   --ok-a:   var(--jade-a);
  --warn: var(--brass);  --warn-a: var(--brass-a);
  --crit: var(--ember);  --crit-a: var(--ember-a);
  --info: var(--steel);  --info-a: var(--steel-a);

  /* Type */
  /* Three roles, three faces.
     --font-head  Overpass. Headings. Drawn from Highway Gothic, so it keeps
                  the signage/instrumentation register the kit wants while
                  staying a normal-width grotesk with real lowercase. Shipped
                  as a variable font: every weight from 100 to 900 is free.
     --font-label IBM Plex Sans Condensed. The tracked uppercase micro-labels
                  that make the UI read as instrumentation.
     --font-sans  IBM Plex Sans for prose, --font-mono for anything the
                  machine produced — timestamps, IDs, live values.
     Every stack falls back to system faces, so the kit still holds together
     if no webfont loads. */
  --font-head:  "Overpass", "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
  --font-label: "IBM Plex Sans Condensed", "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
  --font-sans:  "IBM Plex Sans", ui-sans-serif, system-ui, "Segoe UI", Roboto, sans-serif;
  --font-mono:  "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;

  --fs-xs: 0.75rem;    /* 12 — micro labels */
  --fs-sm: 0.875rem;   /* 14 — dense UI, table body */
  --fs-md: 1rem;       /* 16 — body */
  --fs-lg: 1.1875rem;  /* 19 */
  --fs-xl: 1.625rem;   /* 26 */
  --fs-2xl: clamp(1.875rem, 1.25rem + 2.9vw, 2.375rem);  /* 30→38 — readouts */
  --fs-3xl: 3.5rem;    /* 56 — hero numerals */

  --lh-tight: 1.15;
  --lh-body: 1.55;
  --tracking-caps: 0.14em;   /* micro labels */
  --tracking-head: -0.01em;  /* headings; tightens as size grows */

  /* Space — 4px base */
  --s-1: 4px;  --s-2: 8px;  --s-3: 12px; --s-4: 16px;
  --s-5: 24px; --s-6: 32px; --s-7: 48px; --s-8: 64px;

  /* Geometry */
  --r-0: 0;
  --r-1: 2px;
  --r-2: 3px;
  --cut: 10px;          /* default notch depth */
  --hair: 1px;

  /* Motion — mechanical, never bouncy */
  --t-fast: 90ms;
  --t-base: 140ms;
  --t-slow: 240ms;
  /* The one deliberately unhurried timing in the kit. A filled button INVERTS
     on hover — fill and ink trade places — and that is a bigger change than a
     shade, so it is given long enough to read as one thing turning into
     another rather than as a flicker. Still linear and mechanical; slow is not
     the same as bouncy. */
  --t-invert: 400ms;
  --ease: cubic-bezier(0.22, 0.61, 0.36, 1);

  /* Depth */
  --shadow-1: 0 1px 2px rgba(0,0,0,.5);
  --shadow-2: 0 8px 24px -8px rgba(0,0,0,.75);
  --shadow-3: 0 24px 64px -16px rgba(0,0,0,.85);

  --z-sticky: 100;
  --z-nav: 200;
  --z-popover: 300;
  --z-modal: 400;
  --z-toast: 500;
}

/* ── 2. RESET ──────────────────────────────────────────────────────────── */

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

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

body {
  margin: 0;
  background: var(--bg-0);
  color: var(--ink-0);
  font: var(--fs-md)/var(--lh-body) var(--font-sans);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

h1,h2,h3,h4,h5,h6,p,figure,blockquote,dl,dd { margin: 0; }
ul[class],ol[class] { list-style: none; margin: 0; padding: 0; }
img,svg,video,canvas { display: block; max-width: 100%; }
button,input,select,textarea { font: inherit; color: inherit; }
a { color: var(--ink-0); text-decoration-color: var(--line-2); text-underline-offset: 3px; }
a:hover { text-decoration-color: var(--ember); }
hr { border: 0; border-top: var(--hair) solid var(--line-1); margin: var(--s-5) 0; }

:focus-visible {
  outline: 2px solid var(--ember);
  outline-offset: 2px;
  border-radius: var(--r-1);
}
:focus:not(:focus-visible) { outline: none; }

::selection { background: var(--ember-b); color: var(--ink-0); }

/* Scrollbars — thin, structural, no chrome */
* { scrollbar-width: thin; scrollbar-color: var(--line-2) transparent; }
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb {
  background: var(--line-1);
  border: 3px solid transparent;
  background-clip: content-box;
}
*::-webkit-scrollbar-thumb:hover { background: var(--line-2); background-clip: content-box; }

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

/* ── 3. TYPOGRAPHY ─────────────────────────────────────────────────────── */

/* Headings — Overpass.
   A variable face, so weight is a free axis: the scale steps down in size
   AND in weight, which reads as hierarchy far better than size alone. Large
   sizes get negative tracking, small ones get none — optical sizing done by
   hand, since Overpass has no optical size axis. */
h1, h2, h3, h4, h5, h6,
.display, .h1, .h2, .h3 {
  font-family: var(--font-head);
  font-weight: 600;
  line-height: var(--lh-tight);
  letter-spacing: var(--tracking-head);
  text-wrap: balance;
}

h1, .display { font-size: var(--fs-2xl); font-weight: 700; letter-spacing: -0.025em; line-height: 1.05; }
h2, .h1      { font-size: var(--fs-xl);  font-weight: 700; letter-spacing: -0.02em; }
h3, .h2      { font-size: var(--fs-lg); }
h4, .h3      { font-size: var(--fs-md); }
h5, h6       { font-size: var(--fs-sm); letter-spacing: 0; }

/* The workhorse label. Uppercase + tracked + muted = "machine speaking". */
.micro {
  font-family: var(--font-label);
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: var(--tracking-caps);
  text-transform: uppercase;
  color: var(--ink-2);
  line-height: 1.4;
}
.micro--ember { color: var(--ember); }

.lede { font-size: var(--fs-lg); color: var(--ink-1); line-height: 1.5; }

/* Rendered Markdown. The reset takes every margin off headings and
   paragraphs, which is right for composed screens and wrong for text somebody
   wrote as a document — so the rhythm comes back here, and only here. */
.prose { color: var(--ink-1); line-height: 1.6; overflow-wrap: anywhere; }
.prose > * + * { margin-top: var(--s-3); }
.prose > * + :is(h1, h2, h3, h4) { margin-top: var(--s-5); }
.prose > :is(h1, h2, h3, h4) + * { margin-top: var(--s-2); }
.prose h1 { font-size: var(--fs-xl); }
.prose h2 { font-size: var(--fs-lg); }
.prose ul, .prose ol { padding-left: var(--s-5); }
.prose li + li { margin-top: var(--s-1); }
.prose a { text-decoration: underline; text-decoration-color: var(--line-2); text-underline-offset: 3px; }
.prose a:hover { text-decoration-color: var(--ember); }
.prose code { font-family: var(--font-mono); font-size: 0.92em; }
.prose pre { overflow-x: auto; background: var(--bg-1); border: var(--hair) solid var(--line-1); padding: var(--s-3); }
.prose blockquote { border-left: var(--hair) solid var(--line-2); padding-left: var(--s-3); color: var(--ink-2); }
.prose hr { border: 0; border-top: var(--hair) solid var(--line-1); }
.muted { color: var(--ink-2); }
.dim   { color: var(--ink-3); }

.mono { font-family: var(--font-mono); font-size: 0.92em; }
/* Numerals that don't jitter when they tick. Use on every live value. */
.num  { font-family: var(--font-mono); font-variant-numeric: tabular-nums; letter-spacing: -0.01em; }

/* A <button> that reads as a link. For in-text triggers — opening a modal,
   revealing a definition — where a real button box would be too heavy and an
   <a href="#"> would be a lie. Inherits type, so it sits inside any text. */
.link {
  background: none;
  border: 0;
  padding: 0;
  font: inherit;
  color: inherit;
  cursor: pointer;
  text-decoration: underline;
  text-decoration-color: var(--line-2);
  text-underline-offset: 3px;
  text-decoration-thickness: 1px;
}
.link:hover { color: var(--ink-0); text-decoration-color: var(--ember); }

/* AN ADDRESS YOU CAN TAKE WITH YOU.

   A button that looks like a link, because it acts rather than navigates —
   pressing it puts the text on the clipboard. Semantics follow the behaviour
   and the appearance follows the affordance, which is the one case where those
   two want to disagree.

   Monospace because an address is eight hex characters somebody is going to
   compare by eye, and the button reset is explicit: a <button> brings a font,
   a background and a border nobody asked for. */
.copy {
  font: inherit;
  font-family: var(--font-mono);
  color: inherit;
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
  text-decoration: underline;
  text-decoration-style: dotted;
  text-decoration-color: var(--line-2);
  text-underline-offset: 3px;
}
.copy:hover { color: var(--ink-0); text-decoration-color: var(--ember); }

.kbd {
  font: 600 var(--fs-xs)/1 var(--font-mono);
  padding: 4px 6px;
  border: var(--hair) solid var(--line-1);
  border-bottom-width: 2px;
  border-radius: var(--r-2);
  background: var(--bg-3);
  color: var(--ink-1);
  box-shadow: var(--shadow-1);
}

/* ── 4. THE NOTCH ──────────────────────────────────────────────────────── */
/* One machined corner is worth more than any amount of border-radius.
   Two clipped pseudo-elements fake a border the clip-path would eat:
   ::before is the line, ::after is the fill inset by 1px.
   Override --cut, --cut-line, --cut-fill per element.               */

.cut {
  --cut-line: var(--line-1);
  --cut-fill: var(--bg-2);
  position: relative;
  isolation: isolate;          /* keeps the z-index:-1 pseudos inside */
  background: transparent;
  border: 0;
}
.cut::before,
.cut::after {
  content: "";
  position: absolute;
  z-index: -1;
  clip-path: polygon(
    var(--cut) 0, 100% 0,
    100% calc(100% - var(--cut)), calc(100% - var(--cut)) 100%,
    0 100%, 0 var(--cut)
  );
}
.cut::before { inset: 0;   background: var(--cut-line); }
.cut::after  { inset: 1px; background: var(--cut-fill); }

/* Alternate diagonal — cut the other two corners */
.cut--alt::before, .cut--alt::after {
  clip-path: polygon(
    0 0, calc(100% - var(--cut)) 0, 100% var(--cut),
    100% 100%, var(--cut) 100%, 0 calc(100% - var(--cut))
  );
}
/* Single corner, top-left only — quieter, good for dense lists */
.cut--tl::before, .cut--tl::after {
  clip-path: polygon(var(--cut) 0, 100% 0, 100% 100%, 0 100%, 0 var(--cut));
}
/* Children with their own background paint over the notched corners.
   .cut--clip also clips the element itself so they follow the shape.
   Not the default: it would clip the focus outline too. */
.cut--clip {
  clip-path: polygon(
    var(--cut) 0, 100% 0,
    100% calc(100% - var(--cut)), calc(100% - var(--cut)) 100%,
    0 100%, 0 var(--cut)
  );
}

.cut--sm { --cut: 6px; }
.cut--lg { --cut: 16px; }

/* Corner brackets — an overlay, not a border. Frames portraits and maps.

   BOTH corners come out of ONE pseudo-element, drawn as four gradient arms,
   and that is not cleverness for its own sake. The first version used ::before
   for the top-left and ::after for the bottom-right, which meant .brackets
   could not be combined with any other utility that wanted ::after — and
   .scanlines wants ::after. Put them on the same element and the later rule
   won: the deck board, and the kit's own demo of the deck board, shipped with
   a single unexplained square in one corner and nothing in the other.

   One pseudo-element is one claim. .brackets now composes with anything. */
.brackets { position: relative; }
.brackets::before {
  content: "";
  position: absolute;
  inset: -1px;
  pointer-events: none;
  --arm: 14px;
  --thick: 2px;
  background:
    linear-gradient(var(--ember), var(--ember)) 0 0 / var(--arm) var(--thick) no-repeat,
    linear-gradient(var(--ember), var(--ember)) 0 0 / var(--thick) var(--arm) no-repeat,
    linear-gradient(var(--ember), var(--ember)) 100% 100% / var(--arm) var(--thick) no-repeat,
    linear-gradient(var(--ember), var(--ember)) 100% 100% / var(--thick) var(--arm) no-repeat;
}

/* ── 5. TEXTURE ────────────────────────────────────────────────────────── */

.bg-grid {
  background-image:
    linear-gradient(var(--line-0) var(--hair), transparent var(--hair)),
    linear-gradient(90deg, var(--line-0) var(--hair), transparent var(--hair));
  background-size: 32px 32px;
}
.scanlines { position: relative; }
.scanlines::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: repeating-linear-gradient(
    to bottom, rgba(255,255,255,.022) 0 1px, transparent 1px 3px
  );
}

/* ── 6. BUTTONS ────────────────────────────────────────────────────────── */
/* Hierarchy note: --primary is BONE, not ember. A white-hot button on
   near-black is the loudest thing on the page without spending the accent.
   Ember is held back for --danger and for live state. */

.btn {
  --btn-h: 34px;
  --btn-px: 14px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--s-2);
  height: var(--btn-h);
  padding-inline: var(--btn-px);
  border: var(--hair) solid var(--line-1);
  border-radius: var(--r-1);
  background: var(--bg-2);
  color: var(--ink-0);
  font-size: var(--fs-sm);
  font-weight: 550;
  letter-spacing: 0.02em;
  white-space: nowrap;
  text-decoration: none;   /* .btn is used on <a> as often as on <button> */
  cursor: pointer;
  transition: background var(--t-fast) var(--ease),
              border-color var(--t-fast) var(--ease),
              color var(--t-fast) var(--ease),
              transform var(--t-fast) var(--ease);
}
.btn:hover  { background-color: var(--bg-3); border-color: var(--line-2); }
.btn:active { transform: translateY(1px); background-color: var(--bg-4); }

.btn--primary {
  background: var(--ink-0);
  border-color: var(--ink-0);
  color: var(--ink-on-bone);
  font-weight: 650;
}
.btn--primary:active { background-color: var(--ink-1); border-color: var(--ink-1); }

/* Destructive / commit-to-it. The one button that gets to be ember. */
.btn--danger {
  background: transparent;
  border-color: var(--ember-2);
  color: var(--ember);
}
.btn--danger:hover {
  background-color: var(--ember);
  border-color: var(--ember);
  color: var(--ink-on-ember);
}

/* Reversible, but you will feel it. Brass is --warn, and it sits exactly where
   it belongs on the scale: ember is for what cannot be undone, bone is for the
   ordinary thing, and this is the button that undoes real work without
   destroying anything. Stripping a deck is the case it was drawn for — every
   module comes back, and you still have to put them all in again.

   FILLED, like .btn--go and unlike .btn--danger, and for the same reason: this
   is a thing you choose to do, not a thing you are warned away from. Ember
   stays an outline that only fills on hover, because filling it would put the
   accent on screen at rest, which rule 2 does not allow.

   So it follows go's rationing rather than ember's: at most one per screen, and
   never beside another filled button competing for the same click. */
.btn--warn {
  background: var(--warn);
  border-color: var(--warn);
  color: var(--ink-on-warn);
  font-weight: 650;
}
.btn--warn:active { background-color: color-mix(in srgb, var(--warn) 88%, #000); }

/* The go button. Green means "begin", not "succeeded" — it is the invitation
   into the game, and like ember it is rationed: at most one per screen, and
   never next to another filled button competing for the same click. */
.btn--go {
  background: var(--ok);
  border-color: var(--ok);
  color: var(--ink-on-ok);
  font-weight: 650;
}
.btn--go:active { background-color: color-mix(in srgb, var(--ok) 88%, #000); }

.btn--ghost {
  background: transparent;
  border-color: transparent;
  color: var(--ink-2);
}
.btn--ghost:hover { background-color: var(--bg-2); color: var(--ink-0); border-color: transparent; }

.btn--quiet { background: transparent; }
.btn--quiet:hover { background-color: var(--bg-2); }

.btn--sm { --btn-h: 26px; --btn-px: 10px; font-size: var(--fs-xs); }
.btn--lg { --btn-h: 44px; --btn-px: 22px; font-size: var(--fs-md); }
.btn--block { display: flex; width: 100%; }
.btn--icon { --btn-px: 0; width: var(--btn-h); padding: 0; }

/* ── SHEEN ─────────────────────────────────────────────────────────────
   A light reflection crossing the face of a button on hover.

   ON EVERY BUTTON. Each variant tints its own light: the reflection off a green
   button is green, off a brass one brass, and off an ordinary one white. That
   is what keeps it reading as a property of the surface rather than as a white
   bar that visits every control in turn.

   ONE TRAP, and it is the reason several rules below say `background-color`
   rather than `background`: the shorthand resets background-image, which would
   take the sheen away at exactly the moment it is meant to run. A state that
   changes a button's colour changes its COLOUR.

   IT IS A BACKGROUND LAYER, NOT A PSEUDO-ELEMENT, and that is the whole
   design. A notched button's face IS ::after and its hairline IS ::before, so
   an overlay drawn as a third pseudo-element does not exist — the same
   collision that silently ate one of .brackets' two corners. Layering the
   gradient onto the background that is already there works on both shapes with
   one rule each, and cannot be eaten by anything.

   Tunable per button, which is what the variations in the gallery are:
     --sheen    the colour and its alpha
     --sheen-w  half the band's width; 4px reads as about 8px of light
     --sheen-a  the angle of the gradient. 135deg is a stripe at 45 degrees.
     --sheen-t  how long the crossing takes. Its own token rather than one of
                the shared timings: this is a distance travelled, not a state
                changing, and it wants to be unhurried without dragging every
                other transition out with it. */
.btn {
  --sheen: rgba(255, 255, 255, 0.6);
  --sheen-w: 4px;
  --sheen-a: 135deg;
  --sheen-t: 520ms;
}

/* Each accent lights in its own colour.
   The three filled variants INVERT on hover — they are dark by the time the
   band has crossed — so the tint is what the button was, which is what a
   reflection off it would be. Ember is the exception in the other direction:
   .btn--danger fills on hover, so its light is the warm white that shows on a
   bright ground. And the transparent ones stay faint, because there is little
   there to reflect off. */
.btn--go      { --sheen: rgba(111, 211, 160, 0.6); }
.btn--warn    { --sheen: rgba(232, 184, 75, 0.6); }
.btn--danger  { --sheen: rgba(255, 236, 228, 0.5); }
.btn--primary { --sheen: rgba(232, 230, 225, 0.55); }
.btn--ghost,
.btn--quiet   { --sheen: rgba(255, 255, 255, 0.25); }

/* The band is soft-edged rather than a hard bar: a reflection has no outline,
   and a hard 8px rectangle reads as a graphic sliding past rather than as
   light. The peak is at the centre and it fades to nothing --sheen-w either
   side of it. */
.btn,
.btn.cut::after {
  background-image: linear-gradient(
    var(--sheen-a),
    transparent calc(50% - var(--sheen-w)),
    var(--sheen) 50%,
    transparent calc(50% + var(--sheen-w))
  );
  background-repeat: no-repeat;
  /* Wider than the button so the band starts and ends off the face — it has to
     arrive from outside and leave, not appear in the middle and stop. */
  background-size: 250% 100%;
  background-position: 130% 0;
  transition: background-position var(--sheen-t) var(--ease);
}

/* Left to right. Reverse the two positions to sweep the other way. */
.btn:hover,
.btn:focus-visible,
.btn:hover.cut::after,
.btn:focus-visible.cut::after {
  background-position: -30% 0;
}

/* The global reduced-motion rule collapses the duration, which would make the
   band jump across rather than not happen. Nothing is the right answer: this
   is decoration, and decoration that cannot move should be absent. */
@media (prefers-reduced-motion: reduce) {
  .btn,
  .btn.cut::after { background-image: none; }
}

/* Notched primary — the "commit" button of the kit */
/* EVERY .btn-- VARIANT NEEDS A .cut COMPANION BELOW, or it renders wrong.

   .cut paints the button's face with ::after and --cut-fill, and that pseudo
   sits ABOVE the element's own background — so a variant that sets `background`
   and stops has its colour painted over in --bg-2 and comes out looking like an
   ordinary button with tinted text. It fails silently and only where the notch
   is used, which is most places. Set --cut-line and --cut-fill, not background.
   ButtonCutCompanionTest is the guard. */
.btn.cut { --cut: 8px; --cut-line: var(--line-1); --cut-fill: var(--bg-2); border: 0; background: transparent; }
.btn.cut:hover { --cut-fill: var(--bg-3); --cut-line: var(--line-2); background: transparent; }
.btn--primary.cut { --cut-line: var(--ink-0); --cut-fill: var(--ink-0); background: transparent; }
.btn--danger.cut { --cut-line: var(--ember-2); --cut-fill: var(--bg-0); }
.btn--danger.cut:hover { --cut-line: var(--ember); --cut-fill: var(--ember); }
.btn--warn.cut { --cut-line: var(--warn); --cut-fill: var(--warn); background: transparent; }
.btn--go.cut { --cut-line: var(--ok); --cut-fill: var(--ok); background: transparent; }

/* FILLED BUTTONS INVERT ON HOVER.

   Fill and ink trade places: black-on-brass becomes brass-on-black, and the
   same for bone and for green. The border and the notch line hold the colour
   throughout, so the button keeps its shape and only its inside turns over.

   Applied here once for all three rather than in each variant, because it is
   one behaviour: a variant only has to say what its colour IS.

   The ground it inverts onto is --bg-0, the page, not `transparent`. These
   buttons sit on panels as often as on the page, and a transparent hover would
   make the same gesture read as two different colours depending on what was
   behind it. */
.btn--primary:hover,
.btn--go:hover,
.btn--warn:hover {
  background-color: var(--bg-0);
}
.btn--primary, .btn--go, .btn--warn {
  transition:
    background var(--t-invert) var(--ease),
    border-color var(--t-invert) var(--ease),
    color var(--t-invert) var(--ease),
    transform var(--t-fast) var(--ease);
}

/* A notched button's face is a PSEUDO-ELEMENT, so the fade has to be declared
   on the pseudo-element. --cut-fill itself cannot be transitioned — a custom
   property is not animatable without @property — but the `background` that
   reads it is, and changing the property re-evaluates it.
   --btn-fade carries the duration in, so the ordinary notched button keeps its
   quick 90ms and only the three that INVERT take the long one. */
.btn.cut { --btn-fade: var(--t-fast); }
.btn.cut::before,
.btn.cut::after { transition: background var(--btn-fade) var(--ease); }
.btn--primary.cut, .btn--go.cut, .btn--warn.cut { --btn-fade: var(--t-invert); }
.btn--primary:hover { color: var(--ink-0); border-color: var(--ink-0); }
.btn--go:hover { color: var(--ok); border-color: var(--ok); }
.btn--warn:hover { color: var(--warn); border-color: var(--warn); }

/* The notched versions say the same thing through --cut-fill, since .cut paints
   the face with a pseudo-element and the element's own background is not what
   you see. --cut-line keeps the colour: the outline is what survives. */
.btn--primary.cut:hover { --cut-fill: var(--bg-0); --cut-line: var(--ink-0); color: var(--ink-0); }
.btn--go.cut:hover      { --cut-fill: var(--bg-0); --cut-line: var(--ok);    color: var(--ok); }
.btn--warn.cut:hover    { --cut-fill: var(--bg-0); --cut-line: var(--warn);  color: var(--warn); }

.btn:disabled,
.btn[aria-disabled="true"] {
  opacity: .45;
  pointer-events: none;
}

/* Loading: swap the label for a sweeping hairline */
.btn[data-loading] { color: transparent; position: relative; pointer-events: none; }
.btn[data-loading]::after {
  content: "";
  position: absolute;
  inset: auto 10px 6px;
  height: 2px;
  background: currentColor;
  color: var(--ink-2);
  animation: sweep 900ms var(--ease) infinite;
}
@keyframes sweep {
  0%   { transform: scaleX(0); transform-origin: left; }
  50%  { transform: scaleX(1); transform-origin: left; }
  51%  { transform: scaleX(1); transform-origin: right; }
  100% { transform: scaleX(0); transform-origin: right; }
}

.btn-group { display: inline-flex; }
.btn-group .btn { border-radius: 0; margin-left: calc(var(--hair) * -1); }
.btn-group .btn:first-child { border-radius: var(--r-1) 0 0 var(--r-1); margin-left: 0; }
.btn-group .btn:last-child  { border-radius: 0 var(--r-1) var(--r-1) 0; }
.btn-group .btn[aria-pressed="true"] {
  background: var(--bg-4);
  border-color: var(--line-2);
  color: var(--ink-0);
  z-index: 1;
}

/* ── 7. FORMS ──────────────────────────────────────────────────────────── */

.field { display: flex; flex-direction: column; gap: var(--s-2); }
.field__label { font-family: var(--font-label); font-size: var(--fs-xs); font-weight: 600; letter-spacing: var(--tracking-caps);
                text-transform: uppercase; color: var(--ink-2); }
.field__hint  { font-size: var(--fs-xs); color: var(--ink-3); }
.field__error { font-size: var(--fs-xs); color: var(--ember); display: flex; gap: 6px; align-items: center; }
.field__error::before { content: "▲"; font-size: 8px; }
.field__req { color: var(--ember); }

.input,
.textarea,
.select {
  width: 100%;
  min-height: 34px;
  padding: 7px 11px;
  background: var(--bg-1);
  border: var(--hair) solid var(--line-1);
  border-radius: var(--r-1);
  color: var(--ink-0);
  font-size: var(--fs-sm);
  transition: border-color var(--t-fast) var(--ease), background var(--t-fast) var(--ease);
}
.input::placeholder, .textarea::placeholder { color: var(--ink-3); }
.input:hover, .textarea:hover, .select:hover { border-color: var(--line-2); }
.input:focus, .textarea:focus, .select:focus {
  outline: none;
  border-color: var(--ember-2);
  background: var(--bg-0);
  box-shadow: 0 0 0 3px var(--crit-a);
}
.textarea { min-height: 90px; resize: vertical; line-height: 1.5; }
.input--mono { font-family: var(--font-mono); }

.select {
  appearance: none;
  padding-right: 32px;
  background-image: linear-gradient(45deg, transparent 50%, var(--ink-2) 50%),
                    linear-gradient(135deg, var(--ink-2) 50%, transparent 50%);
  background-position: right 14px center, right 9px center;
  background-size: 5px 5px, 5px 5px;
  background-repeat: no-repeat;
  cursor: pointer;
}

.field[data-invalid] .input,
.field[data-invalid] .textarea,
.field[data-invalid] .select { border-color: var(--ember-2); }

.input:disabled, .textarea:disabled, .select:disabled {
  opacity: .5; cursor: not-allowed; background: var(--bg-2);
}

/* Prefixed / suffixed input */
.input-group { display: flex; align-items: stretch; }
/* The input takes the slack; min-width:0 stops its intrinsic size from
   pushing an attached control out of the box. */
.input-group > .input { border-radius: 0; flex: 1; min-width: 0; }

/* A trailing action — "suggest", "generate", "paste". Sits flush against the
   field and matches its height via align-items: stretch. */
.input-group > .btn {
  flex: none;
  height: auto;
  border-radius: 0;
  margin-left: calc(var(--hair) * -1);
}
.input-group > .btn:hover { z-index: 1; }
.input-group > :first-child { border-radius: var(--r-1) 0 0 var(--r-1); }
.input-group > :last-child  { border-radius: 0 var(--r-1) var(--r-1) 0; }
.input-group__addon {
  display: flex; align-items: center;
  padding-inline: 11px;
  background: var(--bg-2);
  border: var(--hair) solid var(--line-1);
  color: var(--ink-2);
  font: 600 var(--fs-xs)/1 var(--font-mono);
  letter-spacing: .08em;
  white-space: nowrap;
}
.input-group > .input + .input-group__addon,
.input-group > .input-group__addon + .input { margin-left: calc(var(--hair) * -1); }

/* Checkbox & radio — square, hairline, ember tick */
.check { display: inline-flex; align-items: flex-start; gap: var(--s-3); cursor: pointer; font-size: var(--fs-sm); }
.check input { position: absolute; opacity: 0; width: 0; height: 0; }
.check__box {
  flex: none;
  width: 16px; height: 16px;
  margin-top: 2px;
  border: var(--hair) solid var(--line-2);
  border-radius: var(--r-1);
  background: var(--bg-1);
  display: grid; place-items: center;
  transition: all var(--t-fast) var(--ease);
}
.check--radio .check__box { border-radius: 50%; }
.check:hover .check__box { border-color: var(--ink-2); }
.check input:checked + .check__box { background: var(--ember); border-color: var(--ember); }
.check input:checked + .check__box::after {
  content: "";
  width: 8px; height: 5px;
  border: 2px solid var(--ink-on-ember);
  border-top: 0; border-right: 0;
  transform: rotate(-45deg) translate(1px, -1px);
}
.check--radio input:checked + .check__box::after {
  width: 6px; height: 6px; border: 0; border-radius: 50%;
  background: var(--ink-on-ember); transform: none;
}
.check input:focus-visible + .check__box { outline: 2px solid var(--ember); outline-offset: 2px; }
.check input:disabled ~ * { opacity: .45; }

/* Switch — for things that are ON, i.e. live. Ember earns its place here. */
.switch { display: inline-flex; align-items: center; gap: var(--s-3); cursor: pointer; font-size: var(--fs-sm); }
.switch input { position: absolute; opacity: 0; width: 0; height: 0; }
.switch__track {
  position: relative;
  width: 38px; height: 20px;
  background: var(--bg-1);
  border: var(--hair) solid var(--line-2);
  border-radius: 999px;
  transition: all var(--t-base) var(--ease);
}
.switch__track::after {
  content: "";
  position: absolute; top: 2px; left: 2px;
  width: 14px; height: 14px;
  background: var(--ink-2);
  border-radius: 50%;
  transition: all var(--t-base) var(--ease);
}
.switch input:checked + .switch__track { background: var(--ember); border-color: var(--ember); }
.switch input:checked + .switch__track::after { left: 20px; background: var(--ink-on-ember); }
.switch input:focus-visible + .switch__track { outline: 2px solid var(--ember); outline-offset: 2px; }

/* Range */
.range { -webkit-appearance: none; appearance: none; width: 100%; height: 20px; background: transparent; cursor: pointer; }
.range::-webkit-slider-runnable-track { height: 2px; background: var(--line-2); }
.range::-moz-range-track { height: 2px; background: var(--line-2); }
.range::-webkit-slider-thumb {
  -webkit-appearance: none; appearance: none;
  width: 4px; height: 16px; margin-top: -7px;
  background: var(--ember); border: 0; border-radius: 1px;
}
.range::-moz-range-thumb { width: 4px; height: 16px; background: var(--ember); border: 0; border-radius: 1px; }

/* Captcha (Cap — trycap.dev).
   <div class="captcha"></div> is upgraded by ui-kit.js into <cap-widget>.

   The widget renders into an OPEN SHADOW ROOT, so none of the selectors above
   reach inside it: the --cap-* custom properties are the entire styling
   surface, and they inherit through the boundary. Everything below is one of
   those mapped onto a token, so the widget re-skins with the rest of the kit.

   The control geometry is given in px for the same reason .input is: the kit
   has no token for control height, and these have to line up with the fields
   they sit beside rather than with the spacing scale. */
.captcha { display: block; }

cap-widget {
  display: block;
  max-width: 100%;

  --cap-background: var(--bg-1);
  --cap-border-color: var(--line-1);
  --cap-border-radius: var(--r-1);   /* Cap ships 14px; radius here is 0–3 */
  --cap-widget-width: 100%;
  --cap-widget-height: 34px;         /* matches .input's min-height */
  --cap-widget-padding: 7px 11px;    /* matches .input's padding */
  --cap-gap: var(--s-3);
  --cap-color: var(--ink-0);
  --cap-font: var(--font-sans);

  --cap-checkbox-size: 16px;         /* matches .check__box */
  --cap-checkbox-border: var(--hair) solid var(--line-2);
  --cap-checkbox-border-radius: var(--r-1);
  --cap-checkbox-background: var(--bg-2);
  --cap-checkbox-margin: 2px;

  /* The solving spinner is progress, not alarm, so it stays bone. Ember here
     would spend the screen's whole colour budget on a checkbox. */
  --cap-spinner-color: var(--ink-1);
  --cap-spinner-background-color: var(--bg-3);
  --cap-spinner-thickness: 3px;
}


/* ── 8. SURFACES ───────────────────────────────────────────────────────── */

.panel {
  background: var(--bg-2);
  border: var(--hair) solid var(--line-1);
  border-radius: var(--r-2);
}
.panel.cut { border: 0; border-radius: 0; background: transparent; }

/* A PANEL WHOSE CONTENT IS NOT IN A BODY.

   `.panel__bd` is what normally holds a panel off its own border, and a panel
   that puts forms and rows directly inside it has nothing doing that job — the
   content ends up flush against the hairline, which reads as a box that has
   come apart rather than as a tight one.

   Inline only: the head and foot bring their own padding, and rows inside
   these panels carry their own vertical rhythm through the border-top that
   separates them. `padding-inline` rather than left and right so it follows
   the writing direction without a second rule. */
.panel--snug { padding-inline: 3px; }

.panel__hd {
  display: flex; align-items: center; flex-wrap: wrap; gap: var(--s-3);
  padding: var(--s-3) var(--s-4);
  border-bottom: var(--hair) solid var(--line-1);
  min-height: 42px;
}
.panel__hd > .micro { flex: 1; }
.panel__bd { padding: var(--s-4); }
.panel__ft {
  display: flex; align-items: center; gap: var(--s-3);
  padding: var(--s-3) var(--s-4);
  border-top: var(--hair) solid var(--line-1);
  background: rgba(0,0,0,.18);
}

.well {
  background: var(--bg-1);
  border: var(--hair) solid var(--line-0);
  border-radius: var(--r-1);
  padding: var(--s-4);
}

/* A labelled rule — cheap way to section a dense panel */
.rule {
  display: flex; align-items: center; gap: var(--s-3);
  margin: var(--s-5) 0 var(--s-4);
}
.rule::after { content: ""; flex: 1; height: var(--hair); background: var(--line-1); }

.divider { height: var(--hair); background: var(--line-1); margin: var(--s-4) 0; }

/* ── 8b. CARD ──────────────────────────────────────────────────────────── */
/* A general-purpose card. .item-card (in the inventory section) is the
   loot-specific one; this is the plain surface for a choice or a summary.
   .card--select turns it into a radio option: wrap it in a <label>, put a
   visually-hidden radio inside, and the whole card becomes the hit target. */

.card {
  display: flex;
  flex-direction: column;
  background: var(--bg-2);
  border: var(--hair) solid var(--line-1);
  border-radius: var(--r-2);
}
.card__hd {
  padding: var(--s-3) var(--s-4);
  border-bottom: var(--hair) solid var(--line-1);
}
.card__title { font-family: var(--font-head); font-size: var(--fs-lg); font-weight: 600; line-height: var(--lh-tight); }
.card__meta {
  font-family: var(--font-label);
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: var(--tracking-caps);
  text-transform: uppercase;
  color: var(--ink-2);
  margin-top: 2px;
}
.card__bd { padding: var(--s-4); font-size: var(--fs-sm); color: var(--ink-1); flex: 1; }
.card__ft {
  padding: var(--s-3) var(--s-4);
  border-top: var(--hair) solid var(--line-1);
  background: rgba(0, 0, 0, .18);
}

/* Effect rows: label left, value right. Same device as .item-card__stat —
   mono and tabular on the right so a column of values lines up, and the value
   stays bone rather than green/red, because the sign of a modifier does not
   tell you whether it is good ("-10% trace speed" is a gain). */
.card__stats {
  display: grid;
  gap: 6px;
  padding: var(--s-3) var(--s-4);
  border-top: var(--hair) solid var(--line-1);
}
.card__stat {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s-3);
  font-size: var(--fs-xs);
  color: var(--ink-2);
}
.card__stat > b {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-weight: 600;
  color: var(--ink-0);
  white-space: nowrap;
}

/* Selectable */
.card--select {
  position: relative;
  cursor: pointer;
  transition: border-color var(--t-fast) var(--ease), background var(--t-fast) var(--ease);
}
.card--select input { position: absolute; opacity: 0; width: 0; height: 0; }
.card--select:hover { border-color: var(--line-2); background: var(--bg-3); }

/* :has() rather than a sibling combinator, because the border belongs to the
   card itself and the input sits inside it. */
.card--select:has(input:checked) {
  border-color: var(--ember);
  background: var(--bg-3);
}
.card--select:has(input:checked) .card__title { color: var(--ink-0); }
.card--select:has(input:checked) .card__meta { color: var(--ember); }

/* The selected marker: a corner tick in the accent, same device as .slot */
.card--select:has(input:checked)::after {
  content: "";
  position: absolute;
  top: 0; right: 0;
  border-top: 12px solid var(--ember);
  border-left: 12px solid transparent;
}

/* Keyboard focus has to land on the card, since the input is hidden. */
.card--select:has(input:focus-visible) {
  outline: 2px solid var(--ember);
  outline-offset: 2px;
}

.card--select input:disabled ~ * { opacity: .45; }

/* ── 9. BADGES, TAGS, DOTS ─────────────────────────────────────────────── */

.tag {
  display: inline-flex; align-items: center; gap: 6px;
  height: 20px; padding-inline: 8px;
  border: var(--hair) solid var(--line-1);
  border-radius: var(--r-1);
  background: var(--bg-3);
  color: var(--ink-1);
  font: 600 var(--fs-xs)/1 var(--font-label);
  letter-spacing: .06em;
  text-transform: uppercase;
  white-space: nowrap;
}
.tag--ok    { color: var(--ok);  border-color: color-mix(in srgb, var(--ok) 35%, transparent);  background: var(--ok-a); }
.tag--warn  { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 35%, transparent); background: var(--warn-a); }
.tag--crit  { color: var(--crit); border-color: color-mix(in srgb, var(--crit) 40%, transparent); background: var(--crit-a); }
.tag--info  { color: var(--info); border-color: color-mix(in srgb, var(--info) 35%, transparent); background: var(--info-a); }
.tag--solid { background: var(--ink-0); border-color: var(--ink-0); color: var(--ink-on-bone); }

/* DECORATION, and the palette name says so. The semantic aliases carry meaning
   — .tag--crit is a thing going wrong — and this one carries emphasis: a scan
   finding is not critical, it is what the player spent an instrument to learn.
   Rationed like every other use of the accent: see the note on --ember. */
.tag--ember { color: var(--ember); border-color: var(--ember-2); background: var(--ember-a); }

/* THE TRACE, WALKING A CHAIN TOWARDS YOU.

   A line of nodes with a fill crossing it: the machine at one end, the runner
   at the other, and every backdoored relay routed through in between. What the
   fill says is how far the thing following you has got, so the whole widget is
   one question — is it time to leave.

   `--v` is the 0-1 ratio, set server-side like every other meter in the kit, so
   the bar needs no script and cannot disagree with the number beside it.

   THE LINE IS A BACKGROUND ON THE TRACK, not a pseudo-element, because the
   nodes sit on top of it and a pseudo-element would have to be positioned
   around them. Two layers: the unlit rail, and the lit part clipped to `--v`.
   The nodes carry their own background so the line appears to pass behind. */
.trace {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-2);
  position: relative;
  padding: var(--s-3) 0;
}
.trace::before,
.trace::after {
  content: "";
  position: absolute;
  left: 0; right: 0;
  top: 50%;
  height: var(--hair);
  transform: translateY(-50%);
}
.trace::before { background: var(--line-1); }
.trace::after {
  background: var(--crit);
  /* Clipped rather than scaled: a transform would blur the hairline and a
     width animation would relayout. */
  clip-path: inset(0 calc(100% - (var(--v, 0) * 100%)) 0 0);
}

/* A stop on the chain. Positioned so the line runs behind it, and carrying the
   page's own background so it reads as a node on a wire rather than a box with
   a line through it. */
.trace__node {
  position: relative;
  display: grid;
  place-items: center;
  width: 26px;
  height: 26px;
  background: var(--bg-1);
  border: var(--hair) solid var(--line-2);
  color: var(--ink-2);
  font-size: var(--fs-xs);
  /* The machined corner, at node scale. */
  border-radius: var(--r-1);
}

/* PASSED. The trace has come through here already, so it wears the accent the
   fill does — this is the one place a chain says what has been lost, and when
   routing lands these are the backdoors that are about to burn. */
.trace__node--passed { border-color: var(--crit); color: var(--crit); }

/* The two ends. The machine is where it starts; you are what it is walking
   towards, and you are drawn as bone because you are the thing at stake. */
.trace__node--target { border-color: var(--line-2); }
.trace__node--you { border-color: var(--ink-1); color: var(--ink-0); }

/* CAN YOU OPERATE IT. The mark beside a scanned operating system, and the one
   place a fact on that screen also answers a question about the READER.
   Semantic aliases rather than the palette, because these carry meaning: a
   cross is a machine nothing you do would work on. */
.mark--ok { color: var(--ok); }
.mark--no { color: var(--crit); }

.dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ink-3); flex: none; }
.dot--ok { background: var(--ok); }
.dot--warn { background: var(--warn); }
.dot--crit { background: var(--crit); }
.dot--live { background: var(--ember); animation: pulse-dot 1.6s ease-in-out infinite; }
@keyframes pulse-dot {
  0%, 100% { box-shadow: 0 0 0 0 var(--ember-b); }
  50%      { box-shadow: 0 0 0 4px transparent; }
}

.count {
  display: inline-grid; place-items: center;
  min-width: 18px; height: 18px; padding-inline: 5px;
  background: var(--ember); color: var(--ink-on-ember);
  font: 700 var(--fs-xs)/1 var(--font-mono);
  border-radius: 999px;
}

/* ── 10. TABLE ─────────────────────────────────────────────────────────── */

.table { width: 100%; border-collapse: collapse; font-size: var(--fs-sm); }
.table th {
  font-family: var(--font-label);
  text-align: left;
  padding: var(--s-2) var(--s-3);
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: var(--tracking-caps);
  text-transform: uppercase;
  color: var(--ink-2);
  border-bottom: var(--hair) solid var(--line-1);
  white-space: nowrap;
}
.table td {
  padding: var(--s-3);
  border-bottom: var(--hair) solid var(--line-0);
  color: var(--ink-1);
  vertical-align: middle;
}
.table tbody tr { transition: background var(--t-fast) var(--ease); }
.table tbody tr:hover { background: var(--bg-3); }
.table tbody tr[aria-selected="true"] { background: var(--crit-a); }
.table tbody tr[aria-selected="true"] td:first-child { box-shadow: inset 2px 0 0 var(--ember); }
.table--dense td { padding: 6px var(--s-3); }
.table--fixed { table-layout: fixed; }
.table .t-right { text-align: right; }
.table th[aria-sort] { cursor: pointer; color: var(--ink-1); }
.table th[aria-sort]::after { content: " ↕"; color: var(--ink-3); }
.table th[aria-sort="ascending"]::after  { content: " ↑"; color: var(--ember); }
.table th[aria-sort="descending"]::after { content: " ↓"; color: var(--ember); }

.table-wrap { overflow-x: auto; }

/* ── 11. NAV ───────────────────────────────────────────────────────────── */

.topbar {
  display: flex; align-items: center; flex-wrap: wrap; gap: var(--s-2) var(--s-4);
  min-height: 52px;
  padding: var(--s-2) var(--s-4);
  background: color-mix(in srgb, var(--bg-0) 82%, transparent);
  backdrop-filter: blur(12px);
  border-bottom: var(--hair) solid var(--line-1);
}
.brand {
  display: flex; align-items: center; gap: var(--s-2);
  font: 700 var(--fs-sm)/1 var(--font-mono);
  letter-spacing: .16em;
  text-transform: uppercase;
}
.brand__mark { width: 8px; height: 16px; background: var(--ember); clip-path: polygon(0 0, 100% 0, 100% 70%, 50% 100%, 0 70%); flex: none; }
/* The wordmark, as its own element so it can be dropped on a narrow screen
   while the mark stays. A bare text node cannot be hidden by CSS. */
.brand__name { white-space: nowrap; }

.nav { display: flex; gap: 2px; }
.nav__item {
  position: relative;
  display: flex; align-items: center;
  height: 30px; padding-inline: var(--s-3);
  border-radius: var(--r-1);
  color: var(--ink-2);
  font-size: var(--fs-sm);
  font-weight: 550;
  text-decoration: none;
  transition: color var(--t-fast) var(--ease), background var(--t-fast) var(--ease);
}
.nav__item:hover { color: var(--ink-0); background: var(--bg-2); }
.nav__item[aria-current] { color: var(--ink-0); }
.nav__item[aria-current]::after {
  content: ""; position: absolute; left: var(--s-3); right: var(--s-3); bottom: -12px;
  height: 2px; background: var(--ember);
}

/* Vertical rail */
.rail { display: flex; flex-direction: column; gap: 1px; }
.rail__item {
  display: flex; align-items: center; gap: var(--s-3);
  padding: 8px var(--s-3);
  color: var(--ink-2);
  font-size: var(--fs-sm);
  text-decoration: none;
  border-left: 2px solid transparent;
  transition: all var(--t-fast) var(--ease);
}
.rail__item:hover { color: var(--ink-0); background: var(--bg-2); }
.rail__item[aria-current] { color: var(--ink-0); border-left-color: var(--ember); background: var(--bg-2); }

/* A topbar pinned to the top of the page. Sticky rather than fixed: it stays
   in flow, so nothing below it has to reserve a matching strip of padding —
   which is the bug that appears the moment the bar wraps to two rows on a
   phone and the number nobody updated is still 52px. */
.topbar--stuck { position: sticky; top: 0; z-index: var(--z-nav); }

/* ── Dock: a vertical icon rail that opens to icon + label ─────────────
   Collapsed it is a column of --dock-w icons. Pointing at it (or pressing
   the toggle, which is the only way in on a phone) widens it to
   --dock-w-open and fades the labels in.

   .dock on its own is an ordinary column, which is what makes it
   demonstrable in the gallery. It is .shell--docked that pins it to the
   viewport — where widening has to overlay the page rather than push it,
   or every hover would reflow the whole screen. */
.dock {
  --dock-w: 48px;
  --dock-w-open: min(220px, calc(100vw - 48px));
  display: flex; flex-direction: column;
  width: var(--dock-w);
  padding-block: var(--s-2);
  background: var(--bg-1);
  border-right: var(--hair) solid var(--line-1);
  overflow-x: hidden; overflow-y: auto;
  transition: width var(--t-base) var(--ease), box-shadow var(--t-base) var(--ease);
}
.dock:is(:focus-within, [data-expanded]) {
  width: var(--dock-w-open);
  box-shadow: var(--shadow-2);
}
/* Only where a pointer can actually hover. A touch device keeps :hover stuck
   on the last thing tapped, which would leave the rail jammed open. */
@media (hover: hover) {
  .dock:hover { width: var(--dock-w-open); box-shadow: var(--shadow-2); }
  .dock:hover .dock__label { opacity: 1; }
}

.dock__item {
  position: relative;
  /* The icon column is --dock-w wide in BOTH states, so opening the rail
     never moves an icon sideways. */
  display: grid; grid-template-columns: var(--dock-w) minmax(0, 1fr);
  align-items: center;
  min-height: 44px;
  padding: 0; margin: 0; border: 0;
  font: inherit; text-align: left;
  background: none;
  color: var(--ink-2);
  text-decoration: none;
  cursor: pointer;
  transition: color var(--t-fast) var(--ease), background var(--t-fast) var(--ease);
}
.dock__item:hover { color: var(--ink-0); background: var(--bg-2); }
.dock__item[aria-current] { color: var(--ink-0); background: var(--bg-2); }
/* The active marker is a pseudo-element, not a border: a border would shift
   the icon by its own width every time the page changed. Ember, because an
   active nav marker is one of the few things the accent is for. */
.dock__item[aria-current]::before {
  content: ""; position: absolute; left: 0; top: 6px; bottom: 6px;
  width: 2px; background: var(--ember);
}
.dock__icon { display: grid; place-items: center; font-size: 17px; line-height: 1; }
.dock__label {
  white-space: nowrap;
  padding-right: var(--s-3);
  font-size: var(--fs-sm); font-weight: 550;
  opacity: 0;
  transition: opacity var(--t-fast) var(--ease);
}
/* Opacity rather than display:none, so the label is still the accessible name
   of the icon it belongs to while the rail is shut. */
.dock:is(:focus-within, [data-expanded]) .dock__label { opacity: 1; }

/* THE RAIL'S ONLY NUMBER, and the one thing in it that is not furniture.

   On the ICON, not beside the label: a count that only appeared once the rail
   was open would be a count nobody sees, and the icon column is --dock-w wide
   in both states, so nothing moves when the rail opens.

   FILLED EMBER, at rest, which rule 2 otherwise reserves for hover. The same
   licence .dot--live has and for the same reason — this is the accent's actual
   job — and it is spent carefully: 16px, only ever rendered when the count is
   above zero, and it does not pulse. A badge that pulsed would be a second
   animation competing with the one the map already owns. */
.dock__count {
  position: absolute;
  top: 7px; left: calc(var(--dock-w) / 2 + 3px);
  min-width: 16px; height: 16px; padding: 0 4px;
  display: grid; place-items: center;
  background: var(--ember); color: var(--ink-on-ember);
  font: 650 var(--fs-xs)/1 var(--font-mono);
  border-radius: 2px;
}

.dock__sep { height: var(--hair); margin: var(--s-2) 0; background: var(--line-0); flex: none; }

/* ── Shell: the docked page frame ─────────────────────────────────────── */
.shell { --dock-w: 0px; }
.shell--docked { --dock-w: 48px; }
.shell--docked .dock {
  position: fixed; inset: 0 auto 0 0;
  /* One above the topbar: the rail opens across it, not under it. */
  z-index: calc(var(--z-nav) + 1);
}
.shell__body { margin-left: var(--dock-w); min-width: 0; }

/* ── Gauge: one resource, small enough for a bar that is always on screen ─
   Label and value share a row; the meter spans both columns underneath, so
   three of them line up whatever the numbers are. A gauge with no meter is
   just a labelled number — which is what money is, having no maximum. */
.gauge {
  display: grid; grid-template-columns: auto minmax(0, 1fr);
  align-items: baseline;
  column-gap: var(--s-2); row-gap: 4px;
  min-width: min(104px, 100%);
}
.gauge__label {
  font-family: var(--font-label); font-size: var(--fs-xs); font-weight: 600;
  letter-spacing: var(--tracking-caps); text-transform: uppercase;
  color: var(--ink-2);
}
.gauge__value {
  font-family: var(--font-mono); font-variant-numeric: tabular-nums;
  font-size: var(--fs-xs); color: var(--ink-0); text-align: right;
}
.gauge > .meter { grid-column: 1 / -1; }

/* Dial — a meter bent into a ring.

   Same contract as .meter and driven the same way: --v is a 0–1 ratio, --fill
   is the colour. A bar is the better shape wherever there is room for one; the
   topbar is where there is not, and a ring spends 40px of width to say what a
   bar needs 104px to say. The value lives inside it, which is the width the
   ring bought back.

   The fill is a conic-gradient rather than a transform, so unlike .meter it
   does not animate. Deliberate: these are painted once per page load, and
   registering --v with @property to animate them would change how every meter
   on every screen inherits it. */
.dial {
  --v: 0;
  --fill: var(--ok);
  --d: 40px;
  --ring: 3px;
  position: relative;
  flex: none;
  width: var(--d); height: var(--d);
  display: grid; place-items: center;
  border-radius: 50%;
  background: conic-gradient(var(--fill) calc(var(--v) * 360deg), var(--bg-1) 0);
}
/* The hole. Its background is the PAGE, not transparent, or the gradient shows
   through the middle. */
.dial::before {
  content: "";
  position: absolute; inset: var(--ring);
  border-radius: 50%;
  background: var(--bg-0);
}
/* Hairlines carry structure here as everywhere else — the ring is a value, the
   border is the edge of the object. */
.dial::after {
  content: "";
  position: absolute; inset: 0;
  border-radius: 50%;
  border: var(--hair) solid var(--line-1);
  pointer-events: none;
}
.dial__in { position: relative; display: grid; justify-items: center; gap: 1px; line-height: 1; }
.dial__label {
  font-family: var(--font-label); font-size: 9px; font-weight: 600;
  letter-spacing: .08em; text-transform: uppercase; color: var(--ink-2);
}
.dial__value {
  font-family: var(--font-mono); font-size: 12px;
  font-variant-numeric: tabular-nums; color: var(--ink-0);
}
/* Set on the element by ui-kit.js, from the same threshold .meter--hot uses —
   one number in one place. The value goes with it, because a ring this small
   is read by its colour before its arc. */
.dial--hot { --fill: var(--crit); }
.dial--hot .dial__value { color: var(--crit); }

/* Status chips — a glyph and a number, for the things with no maximum.
   Money has none by design, and a count of running processes has none either,
   so neither gets a meter of any shape. */
.chip-stat {
  display: inline-flex; align-items: center; gap: var(--s-1);
  font-family: var(--font-mono); font-size: var(--fs-sm);
  font-variant-numeric: tabular-nums;
  color: var(--ink-0);
  background: none; border: 0; padding: 0; margin: 0;
  text-decoration: none;
}
.chip-stat__glyph { color: var(--ink-2); line-height: 1; flex: none; }
button.chip-stat { cursor: pointer; }
button.chip-stat:hover .chip-stat__glyph,
button.chip-stat:focus-visible .chip-stat__glyph { color: var(--ink-0); }
/* Nothing running is not a warning, it is the ordinary state — so it recedes
   rather than colouring. */
.chip-stat--idle { color: var(--ink-3); }
.chip-stat--idle .chip-stat__glyph { color: var(--ink-3); }

/* A picture of a thing — an archetype, an item, a system.

   16:9 AND A FIXED SHAPE, which is the whole point: a grid of cards whose
   images are each a different height is a grid that never lines up, and the
   card is a slot the picture fills rather than the other way round. object-fit
   crops; nothing is ever letterboxed or stretched.

   The source wants to be about 640×360 — twice what the widest card shows, so
   it is sharp on a dense screen and never scaled UP, which is the one thing a
   too-small image cannot be forgiven. */
.entity-image {
  display: block;
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  background: var(--bg-1);
  border: 0;
  border-bottom: var(--hair) solid var(--line-1);
  padding: 0; margin: 0;
}
.entity-image > img {
  display: block;
  width: 100%; height: 100%;
  object-fit: cover;
  /* Held back from full colour so a photograph cannot out-shout the interface
     in front of it. Lifts on hover, which is also the affordance. */
  filter: saturate(.85) contrast(1.02);
  transition: filter var(--t-base) var(--ease), transform var(--t-slow) var(--ease);
}
/* Sitting at the top of a card, it inherits the card's corners. .card cannot
   simply clip its overflow — .card--select's corner tick lives outside it. */
.card > .entity-image { border-radius: var(--r-2) var(--r-2) 0 0; }

/* A SQUARE picture, for a thing whose image is a badge rather than a scene —
   an achievement, and nothing else yet. 128px is what the source is drawn at
   and min() keeps it inside a narrow card rather than forcing one open; it
   centres rather than filling, because a badge blown up to card width stops
   reading as a badge. No bottom hairline: it is not the top of the card, it
   is a thing sitting on it. */
.entity-image--sq {
  width: min(128px, 100%);
  aspect-ratio: 1;
  margin: var(--s-4) auto 0;
  border-bottom: 0;
}

/* A PORTRAIT, for the one picture that is of a PERSON. 3:4 and 300px, because
   a face in a 16:9 slot is a face with its head cropped — the card shape is
   right for a thing and wrong for somebody. It keeps the hairline the square
   badge drops, since this one sits in a panel rather than on top of a card. */
.entity-image--portrait {
  width: min(300px, 100%);
  aspect-ratio: 3 / 4;
  margin: 0;
}

.entity-image--open { cursor: zoom-in; }
.entity-image--open:hover > img,
.entity-image--open:focus-visible > img { filter: none; transform: scale(1.02); }
.entity-image--open:focus-visible { outline: 2px solid var(--steel); outline-offset: -2px; }

/* The full version has no shape of its own — it is the picture, not a slot —
   so the dialog sizes to it and stops at the viewport. */
dialog.modal--image {
  padding: 0;
  width: auto; max-width: min(1200px, calc(100vw - 32px));
  background: var(--bg-0);
}
dialog.modal--image > img {
  display: block;
  max-width: 100%; max-height: calc(100vh - 32px);
  width: auto; height: auto;
}
.modal--image__close { position: absolute; top: var(--s-2); right: var(--s-2); }

.breadcrumb { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s-2); font-size: var(--fs-xs); color: var(--ink-2); }
.breadcrumb a { color: var(--ink-2); text-decoration: none; }
.breadcrumb a:hover { color: var(--ink-0); }
.breadcrumb li + li::before { content: "/"; margin-right: var(--s-2); color: var(--ink-3); }
.breadcrumb li { display: flex; align-items: center; }
.breadcrumb [aria-current] { color: var(--ink-0); }

/* Tabs */
/* Tabs scroll inside their own box; a tab strip must never widen the page. */
.tabs {
  display: flex; gap: var(--s-1);
  border-bottom: var(--hair) solid var(--line-1);
  overflow-x: auto;
  scrollbar-width: none;
}
.tabs::-webkit-scrollbar { display: none; }
.tab { flex: none; }
.tab {
  position: relative;
  padding: var(--s-2) var(--s-3);
  background: none; border: 0;
  color: var(--ink-2);
  font-size: var(--fs-sm); font-weight: 550;
  cursor: pointer;
  transition: color var(--t-fast) var(--ease);
}
.tab:hover { color: var(--ink-1); }
/* A TAB MAY BE A LINK. Where the panels are separate pages — a character sheet
   whose sections each have a URL — the strip is a nav of anchors and the
   current one is aria-current, not aria-selected: role="tab" on something that
   navigates promises a panel switch that is not going to happen. Same two
   lines of paint either way. */
.tab[aria-selected="true"],
.tab[aria-current="page"] { color: var(--ink-0); }
a.tab { text-decoration: none; }
.tab[aria-selected="true"]::after,
.tab[aria-current="page"]::after {
  content: ""; position: absolute; inset: auto 0 -1px; height: 2px; background: var(--ember);
}
.tabpanel { padding-top: var(--s-4); }
.tabpanel[hidden] { display: none; }
/* The filter's three kinds of thing. A card or a panel sets its own display,
   which beats the UA's [hidden] rule, so hiding has to be said outright. */
[data-filter-text][hidden], [data-filter-group][hidden], [data-filter-empty][hidden] { display: none; }

.pager { display: flex; align-items: center; flex-wrap: wrap; gap: var(--s-1); }
.pager__item {
  min-width: 28px; height: 28px; padding-inline: 6px;
  display: grid; place-items: center;
  border: var(--hair) solid transparent; border-radius: var(--r-1);
  background: none; color: var(--ink-2);
  font: 500 var(--fs-sm)/1 var(--font-mono);
  cursor: pointer;
}
.pager__item:hover { background: var(--bg-2); color: var(--ink-0); }
.pager__item[aria-current] { border-color: var(--line-2); background: var(--bg-3); color: var(--ink-0); }

/* ── 12. FEEDBACK ──────────────────────────────────────────────────────── */

.progress { height: 2px; background: var(--line-0); overflow: hidden; }
.progress__bar { height: 100%; background: var(--ink-0); transform-origin: left; transform: scaleX(var(--v, 0)); transition: transform var(--t-slow) var(--ease); }
.progress--indet .progress__bar { animation: sweep 1.2s var(--ease) infinite; transform: none; }

.spinner {
  width: 14px; height: 14px;
  border: 2px solid var(--line-2);
  border-top-color: var(--ember);
  border-radius: 50%;
  animation: spin 700ms linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

.skeleton {
  background: linear-gradient(90deg, var(--bg-2) 0%, var(--bg-3) 50%, var(--bg-2) 100%);
  background-size: 200% 100%;
  animation: shimmer 1.4s linear infinite;
  border-radius: var(--r-1);
  height: 12px;
}
@keyframes shimmer { to { background-position: -200% 0; } }

.callout {
  display: flex; gap: var(--s-3);
  padding: var(--s-3) var(--s-4);
  border: var(--hair) solid var(--line-1);
  border-left: 2px solid var(--ink-2);
  border-radius: var(--r-1);
  background: var(--bg-2);
  font-size: var(--fs-sm);
  color: var(--ink-1);
}
.callout--ok   { border-left-color: var(--ok);  background: var(--ok-a); }
.callout--warn { border-left-color: var(--warn); background: var(--warn-a); }
.callout--crit { border-left-color: var(--crit); background: var(--crit-a); }

.empty {
  display: grid; place-items: center; gap: var(--s-3);
  padding: var(--s-7) var(--s-4);
  text-align: center;
  border: var(--hair) dashed var(--line-1);
  border-radius: var(--r-2);
  color: var(--ink-2);
}

/* Tooltip — CSS only, driven by [data-tip] */
[data-tip] { position: relative; }
[data-tip]::after {
  content: attr(data-tip);
  position: absolute;
  bottom: calc(100% + 8px); left: 50%;
  transform: translateX(-50%) translateY(3px);
  padding: 5px 8px;
  background: var(--bg-4);
  border: var(--hair) solid var(--line-2);
  border-radius: var(--r-1);
  color: var(--ink-0);
  font: 500 var(--fs-xs)/1.3 var(--font-sans);
  white-space: nowrap;
  opacity: 0; pointer-events: none;
  transition: opacity var(--t-fast) var(--ease), transform var(--t-fast) var(--ease);
  z-index: var(--z-popover);
  box-shadow: var(--shadow-2);
}
[data-tip]:hover::after,
[data-tip]:focus-visible::after { opacity: 1; transform: translateX(-50%) translateY(0); }

/* Modal — native <dialog> */
dialog.modal {
  padding: 0;
  border: var(--hair) solid var(--line-2);
  border-radius: var(--r-2);
  background: var(--bg-2);
  color: var(--ink-0);
  width: min(480px, calc(100vw - 32px));
  box-shadow: var(--shadow-3);
}
/* Cap the height and let the body scroll, so a long modal does not run off
   the viewport with its footer unreachable. [open] is required: setting
   display on the bare selector would override the UA's display:none and leave
   a closed dialog visible. */
dialog.modal { max-height: min(80vh, 680px); }
dialog.modal[open] { display: flex; flex-direction: column; }
dialog.modal > .panel__bd { overflow-y: auto; min-height: 0; }
dialog.modal > .panel__hd,
dialog.modal > .panel__ft { flex: none; }

dialog.modal::backdrop { background: rgba(5,6,8,.72); backdrop-filter: blur(3px); }
dialog.modal[open] { animation: modal-in var(--t-base) var(--ease); }
@keyframes modal-in { from { opacity: 0; transform: translateY(-6px) scale(.99); } }

/* Toasts */
.toaster {
  position: fixed; bottom: var(--s-4); right: var(--s-4);
  display: flex; flex-direction: column; gap: var(--s-2);
  z-index: var(--z-toast);
  width: min(340px, calc(100vw - 32px));
}
.toast {
  display: flex; align-items: flex-start; gap: var(--s-3);
  padding: var(--s-3) var(--s-4);
  background: var(--bg-3);
  border: var(--hair) solid var(--line-2);
  border-left: 2px solid var(--ink-2);
  border-radius: var(--r-1);
  box-shadow: var(--shadow-2);
  font-size: var(--fs-sm);
  animation: toast-in var(--t-base) var(--ease);
}
.toast--ok   { border-left-color: var(--ok); }
.toast--crit { border-left-color: var(--crit); }
@keyframes toast-in { from { opacity: 0; transform: translateX(12px); } }

/* Accordion */
/* A panel that folds.

   <details class="panel" open>
     <summary class="panel__hd">…</summary>
     <div class="panel__bd">…</div>
   </details>

   The HEADER becomes the control, so the whole strip is the hit target and
   nothing new appears beside it. No JavaScript: <details> already knows how to
   do this, keyboard and screen reader included, and a kit that reimplements it
   would be reimplementing it worse.

   The marker is INK, not the ember the .accordion below uses, and the
   difference is deliberate. An accordion is a standalone list where nothing
   competes for attention; a panel header sits on screens that spend the accent
   on things that are actually live — the deck screen's thermal alert is the
   whole of its accent budget, and a disclosure triangle is not a warning. */
details.panel > summary.panel__hd {
  cursor: pointer;
  list-style: none;
}
details.panel > summary.panel__hd::-webkit-details-marker { display: none; }
details.panel > summary.panel__hd::after {
  content: "+";
  width: 14px; flex: none; text-align: center;
  font-family: var(--font-mono); color: var(--ink-2);
}
details.panel[open] > summary.panel__hd::after { content: "\2212"; }
/* The start of what a closed panel holds, said in its summary. Gone once the
   panel is open, where the whole of it is right below. */
.panel__teaser { font-size: var(--fs-sm); color: var(--ink-2); line-height: 1.5; overflow-wrap: anywhere; white-space: pre-line; }
details.panel[open] > summary .panel__teaser { display: none; }
details.panel > summary.panel__hd:hover::after { color: var(--ink-0); }
details.panel > summary.panel__hd:focus-visible {
  outline: 2px solid var(--steel); outline-offset: -2px;
}

.accordion { border-top: var(--hair) solid var(--line-1); }
.accordion > details { border-bottom: var(--hair) solid var(--line-1); }
.accordion summary {
  display: flex; align-items: center; gap: var(--s-3);
  padding: var(--s-3) 0;
  cursor: pointer; list-style: none;
  font-size: var(--fs-sm); font-weight: 550;
}
.accordion summary::-webkit-details-marker { display: none; }
.accordion summary::before {
  content: "+"; width: 14px;
  color: var(--ember); font-family: var(--font-mono);
}
.accordion details[open] summary::before { content: "−"; }
.accordion details > *:not(summary) { padding-bottom: var(--s-4); color: var(--ink-2); font-size: var(--fs-sm); }

.avatar {
  width: 32px; height: 32px; flex: none;
  display: grid; place-items: center;
  background: var(--bg-4);
  border: var(--hair) solid var(--line-2);
  color: var(--ink-1);
  font: 600 var(--fs-xs)/1 var(--font-mono);
  clip-path: polygon(6px 0, 100% 0, 100% calc(100% - 6px), calc(100% - 6px) 100%, 0 100%, 0 6px);
}

/* ══════════════════════════════════════════════════════════════════════════
   GAME HUD
   ══════════════════════════════════════════════════════════════════════════ */

/* Meter — the single most reused game component.
   Drive it with two custom properties and nothing else:
     style="--v:.72; --fill:var(--jade)"
   --v is a 0–1 ratio so the fill is a transform, not a layout change. */
.meter {
  --v: 0;
  --fill: var(--ember);
  --h: 6px;
  position: relative;
  height: var(--h);
  background: var(--bg-1);
  border: var(--hair) solid var(--line-1);
  overflow: hidden;
}
.meter__fill {
  height: 100%;
  background: var(--fill);
  transform: scaleX(var(--v));
  transform-origin: left;
  transition: transform var(--t-slow) var(--ease), background var(--t-base) var(--ease);
}
.meter--lg { --h: 10px; }
.meter--sm { --h: 3px; }

/* Segmented: mask the whole bar into --segs cells. Reads as hardware.

   The mask belongs to the BAR and not to the fill. .meter__fill is scaleX'd
   by --v, and a mask on a transformed element scales with it — so masking
   the fill drew --segs cells squeezed into --v of the width: five narrow
   blocks at 40%, rather than two of five lit. Masking the container leaves
   the cells where they are and lets the fill light them. */
.meter--seg {
  --segs: 12;
  border: 0;
  background: none;
  height: var(--h);
  -webkit-mask-image: repeating-linear-gradient(90deg, #000 0 calc(100% / var(--segs) - 3px), transparent calc(100% / var(--segs) - 3px) calc(100% / var(--segs)));
  mask-image: repeating-linear-gradient(90deg, #000 0 calc(100% / var(--segs) - 3px), transparent calc(100% / var(--segs) - 3px) calc(100% / var(--segs)));
}
.meter--seg::before {
  content: ""; position: absolute; inset: 0;
  background: var(--bg-3);
}
.meter--seg .meter__fill { position: relative; }
/* The cell being earned: ember, breathing up from nothing to lit. --at is its
   zero-based index. The bar's mask cuts it to one cell, as it does the fill.
   At rest it is fully lit, so reduced motion stops it lit rather than gone. */
.meter__next {
  position: absolute; top: 0; bottom: 0;
  left: calc(100% * var(--at) / var(--segs));
  width: calc(100% / var(--segs));
  background: var(--ember);
  animation: seg-breathe 1.6s ease-in-out infinite alternate;
}
@keyframes seg-breathe { from { opacity: 0; } }

/* A threshold on a bar: a hairline tick at --x (0–1 of the width), in --mark.
   After the fill in the DOM, so it stands on top of it. */
.meter__mark {
  position: absolute; top: 0; bottom: 0;
  left: calc(var(--x) * 100%);
  width: 2px; margin-left: -1px;
  background: var(--mark, var(--ink-0));
}

/* Overload — the bar is past safe limits and you should feel it */
.meter--hot { --fill: var(--crit); }
/* The colour goes on the fill itself and not only on the variable: a bar whose
   colour was set inline — style="--fill:var(--ok)", which is how a server
   renders one — out-specifies the custom property and would otherwise sit
   there green while it is telling you it is critical. */
.meter--hot .meter__fill { background: var(--crit); animation: throb 1.1s ease-in-out infinite; }
@keyframes throb { 50% { filter: brightness(1.45); } }

/* A labelled meter row: label / bar / value, aligned across a stack */
.vitals { display: grid; gap: var(--s-3); }
.vital  { display: grid; grid-template-columns: minmax(0, 76px) minmax(0, 1fr) auto; align-items: center; gap: var(--s-3); }
/* Narrow label column, for a HUD rail rather than a settings panel. */
.vital--tight { grid-template-columns: minmax(0, 48px) minmax(0, 1fr) auto; }
/* Wide label column, for whole words: PERCEPTION overflows 76px, and the
   meter is positioned, so a label that spills paints UNDER the bar. */
.vital--wide { grid-template-columns: minmax(0, 96px) minmax(0, 1fr) auto; }
.vital__label { font-family: var(--font-label); font-size: var(--fs-xs); font-weight: 600; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-2); }
.vital__value { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--fs-xs); color: var(--ink-1); min-width: 54px; text-align: right; }

/* Big readout — one number that matters, with a delta */
.readout { display: grid; gap: 2px; }
.readout__label { font-family: var(--font-label); font-size: var(--fs-xs); letter-spacing: var(--tracking-caps); text-transform: uppercase; color: var(--ink-2); font-weight: 600; }
.readout__value { font: 650 var(--fs-2xl)/1 var(--font-mono); font-variant-numeric: tabular-nums; letter-spacing: -0.03em; }
.readout__delta { font: 600 var(--fs-xs)/1 var(--font-mono); color: var(--ink-2); }
.readout__delta[data-dir="up"]   { color: var(--ok); }
.readout__delta[data-dir="down"] { color: var(--crit); }

/* Cooldown — conic sweep, --p is 0–100 */
.cooldown {
  --p: 0;
  --ring: var(--ember);
  position: relative;
  width: 44px; height: 44px;
  display: grid; place-items: center;
  border-radius: 50%;
  background: conic-gradient(var(--ring) calc(var(--p) * 1%), var(--bg-3) 0);
  transition: background var(--t-base) linear;
}
.cooldown::before {
  content: ""; position: absolute; inset: 3px;
  background: var(--bg-1); border-radius: 50%;
  border: var(--hair) solid var(--line-1);
}
.cooldown__t { position: relative; font: 600 var(--fs-xs)/1 var(--font-mono); color: var(--ink-1); }
.cooldown[data-ready] { --ring: var(--ok); }

/* Status effect chip — buff/debuff with stack count and a drain bar */
.status {
  --tint: var(--ink-2);
  position: relative;
  display: inline-flex; align-items: center; gap: 6px;
  padding: 4px 8px 6px;
  background: var(--bg-2);
  border: var(--hair) solid var(--line-1);
  border-radius: var(--r-1);
  font: 600 var(--fs-xs)/1 var(--font-sans);
  color: var(--tint);
  letter-spacing: .06em; text-transform: uppercase;
  overflow: hidden;
}
.status--buff   { --tint: var(--ok); }
.status--debuff { --tint: var(--crit); }
.status--neutral{ --tint: var(--warn); }
.status__stack { color: var(--ink-1); font-family: var(--font-mono); }
.status__drain {
  position: absolute; inset: auto 0 0 0; height: 2px;
  background: var(--tint);
  transform: scaleX(var(--v, 1)); transform-origin: left;
  transition: transform 1s linear;
}

/* Critical banner — the loudest thing the kit can do. Use once per screen. */
.hud-alert {
  display: flex; align-items: center; gap: var(--s-3);
  padding: var(--s-3) var(--s-4);
  background: linear-gradient(90deg, var(--crit-a), transparent 70%);
  border: var(--hair) solid var(--ember-2);
  border-left-width: 3px;
  color: var(--ink-0);
  font-size: var(--fs-sm);
  flex-wrap: wrap;
  animation: alert-breathe 2.4s ease-in-out infinite;
}
@keyframes alert-breathe {
  50% { border-color: var(--ember); box-shadow: inset 0 0 30px -12px var(--ember-b); }
}
.hud-alert__code { font: 700 var(--fs-xs)/1 var(--font-mono); color: var(--ember); letter-spacing: .12em; }

/* Framed viewport — minimap, portrait, camera feed */
.frame {
  position: relative;
  background: var(--bg-1);
  border: var(--hair) solid var(--line-1);
  overflow: hidden;
}
.frame__label {
  position: absolute; top: 6px; left: 8px; z-index: 2;
  font: 600 var(--fs-xs)/1 var(--font-mono);
  letter-spacing: .12em; text-transform: uppercase;
  color: var(--ink-2);
}
.frame__hud { position: absolute; bottom: 6px; right: 8px; z-index: 2; }
/* The bracket owns the corner, so the label steps aside for it. The kit's own
   demos show the two separately and never met this. */
.frame.brackets > .frame__label { left: 22px; }

/* Ticker — scrolling one-line feed */
.ticker {
  display: flex; align-items: center;
  height: 28px; overflow: hidden;
  border-block: var(--hair) solid var(--line-1);
  background: var(--bg-1);
  font: 500 var(--fs-xs)/1 var(--font-mono);
  color: var(--ink-2);
  letter-spacing: .06em;
}

/* The tag is a fixed chip the feed runs behind. It needs its own stacking
   context: the tag and the track are both non-positioned flex items, so the
   tag's background paints in the block-background phase while the track's
   text paints later as inline content — the text would cross straight over
   the chip no matter which comes first in the DOM. */
.ticker__tag {
  position: relative;
  z-index: 1;
  flex: none;
  align-self: stretch;
  display: grid; place-items: center;
  padding-inline: 10px;
  background: var(--ember);
  color: var(--ink-on-ember);
  font-weight: 700;
}

/* The strip gets its own clipping box, so the scroll is measured against the
   strip rather than the whole ticker and can never reach under the tag. */
.ticker__viewport {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  /* Overflow clips at the PADDING box, so padding here would not hold a gap —
     text would scroll through it and land hard against the chip. Fade it in
     instead, the same way the terminal scrollback fades at the top. */
  -webkit-mask-image: linear-gradient(90deg, transparent 0, #000 14px);
  mask-image: linear-gradient(90deg, transparent 0, #000 14px);
}

/* Two identical copies, translated by exactly half the track, loops seamlessly.
   The spacing between them has to live INSIDE each copy: a `gap` would make the
   track 2W+G wide while -50% only moves W+G/2, and the feed would jump each
   cycle. Mark the second copy aria-hidden — it is a duplicate, not new text. */
.ticker__track {
  display: flex;
  width: max-content;
  white-space: nowrap;
  animation: ticker 26s linear infinite;
}
.ticker__track > * { padding-inline-end: var(--s-6); }
@keyframes ticker { from { transform: translateX(0); } to { transform: translateX(-50%); } }

/* ══════════════════════════════════════════════════════════════════════════
   TERMINAL / LOG FEED
   ══════════════════════════════════════════════════════════════════════════ */

.term {
  display: flex; flex-direction: column;
  background: var(--bg-1);
  border: var(--hair) solid var(--line-1);
  border-radius: var(--r-1);
  font-family: var(--font-mono);
  font-size: var(--fs-sm);
  overflow: hidden;
}
.term__hd {
  display: flex; align-items: center; gap: var(--s-3);
  padding: 6px var(--s-3);
  border-bottom: var(--hair) solid var(--line-1);
  background: var(--bg-2);
  font-size: var(--fs-xs); letter-spacing: .12em; text-transform: uppercase; color: var(--ink-2);
}
.term__out {
  flex: 1;
  padding: var(--s-3);
  overflow-y: auto;
  max-height: var(--term-h, 260px);
  line-height: 1.65;
  /* Fade the scrollback out at the top instead of a hard cut */
  -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 18px);
  mask-image: linear-gradient(to bottom, transparent 0, #000 18px);
}
.term__line { display: flex; gap: var(--s-3); color: var(--ink-1); white-space: pre-wrap; word-break: break-word; }
.term__ts   { flex: none; color: var(--ink-3); font-variant-numeric: tabular-nums; }
.term__line > *:last-child { min-width: 0; }
.term__lvl  { flex: none; width: 44px; font-weight: 700; letter-spacing: .06em; }
.term__line[data-lvl="ok"]   .term__lvl { color: var(--ok); }
.term__line[data-lvl="warn"] .term__lvl { color: var(--warn); }
.term__line[data-lvl="err"]  .term__lvl { color: var(--crit); }
.term__line[data-lvl="err"]  { color: var(--ink-0); }
.term__line[data-lvl="sys"]  .term__lvl { color: var(--info); }
.term__line em { color: var(--ink-0); font-style: normal; font-weight: 600; }

/* A drawn rule, not 40 literal box characters: it spans whatever width the
   terminal has instead of overflowing narrow ones and clipping wide ones. */
.term__rule {
  height: 1.65em;
  user-select: none;
  background-image: repeating-linear-gradient(90deg, var(--line-2) 0 5px, transparent 5px 8px);
  background-size: 100% 1px;
  background-repeat: no-repeat;
  background-position: 0 50%;
}

.term__prompt {
  display: flex; align-items: center; gap: var(--s-2);
  padding: var(--s-2) var(--s-3);
  border-top: var(--hair) solid var(--line-1);
  background: var(--bg-0);
}
.term__sigil { color: var(--ember); font-weight: 700; }
.term__input {
  flex: 1; background: none; border: 0; outline: none;
  color: var(--ink-0); font: inherit;
}
.term__input::placeholder { color: var(--ink-3); }

/* Blinking block caret for typed/idle states */
.caret::after {
  content: "";
  display: inline-block;
  width: 7px; height: 1em;
  margin-left: 2px;
  background: var(--ember);
  vertical-align: text-bottom;
  animation: blink 1s steps(1) infinite;
}
@keyframes blink { 50% { opacity: 0; } }

/* Typewriter reveal — set --ch to the character count */
.typewriter {
  display: inline-block;
  overflow: hidden; white-space: nowrap;
  animation: type 1.6s steps(var(--ch, 40)) both;
}
@keyframes type { from { width: 0; } to { width: calc(var(--ch, 40) * 1ch); } }

/* ══════════════════════════════════════════════════════════════════════════
   INVENTORY
   ══════════════════════════════════════════════════════════════════════════ */

/* A graded accent ramp, all inside the palette — no rainbow. Each step is a
   custom property so tiles, tooltips and list rows all agree. Item tiers are
   the first two steps; the rest of the ramp is unclaimed and stays here for
   whatever next wants a graded accent. */
.t1 { --tier: var(--ink-2); }
.t2 { --tier: var(--jade); }
.t3 { --tier: var(--steel); }
.t4 { --tier: var(--brass); }
.t5 { --tier: var(--ember); }

/* --cols is an exact column count: right for a stash whose shape the
   server decides. .slots--fluid instead fits as many columns as will hold
   a --slot-min touch target, which is what you want on a phone. */
.slots {
  display: grid;
  grid-template-columns: repeat(var(--cols, 6), minmax(0, 1fr));
  gap: var(--s-2);
}
.slots--fluid {
  --slot-min: 48px;
  grid-template-columns: repeat(auto-fill, minmax(min(var(--slot-min), 100%), 1fr));
}

.slot {
  --tier: var(--line-1);
  position: relative;
  aspect-ratio: 1;
  display: grid; place-items: center;
  /* A slot that does something should be a <button>, so it is reachable by
     keyboard and announces itself. These four lines are the button reset that
     makes one look identical to the <div> the gallery shows. */
  padding: 0; margin: 0;
  font: inherit; color: inherit; text-align: center;
  background: var(--bg-1);
  border: var(--hair) solid var(--line-1);
  border-radius: var(--r-1);
  cursor: pointer;
  transition: border-color var(--t-fast) var(--ease), background var(--t-fast) var(--ease), transform var(--t-fast) var(--ease);
}
.slot:hover { background: var(--bg-3); border-color: var(--line-2); }
.slot:active { transform: scale(.97); }

/* Empty slots get a faint crosshair so the grid still reads when sparse */
.slot--empty::after {
  content: ""; width: 8px; height: 8px;
  border: var(--hair) solid var(--line-1);
  transform: rotate(45deg);
}
.slot--locked { background: repeating-linear-gradient(45deg, var(--bg-1) 0 6px, var(--bg-2) 6px 12px); cursor: not-allowed; opacity: .6; }
.slot--locked::after { content: "◼"; color: var(--ink-3); font-size: 10px; }

/* Filled: the tier colour lights the border and a corner tick */
.slot--filled {
  border-color: color-mix(in srgb, var(--tier) 45%, var(--line-1));
  background: linear-gradient(180deg, color-mix(in srgb, var(--tier) 8%, var(--bg-1)), var(--bg-1));
}
.slot--filled::before {
  content: ""; position: absolute; top: 0; right: 0;
  width: 0; height: 0;
  border-top: 7px solid var(--tier);
  border-left: 7px solid transparent;
}
/* A socket the deck is cooking.

   Slow on purpose: this is a CONDITION and not an event, and a fast pulse
   reads as something happening this second. --crit rather than the raw ember,
   because what it says is that the thing is in danger — one of the three
   meanings the accent is rationed for.

   .slot--doomed is the same state past the point of no return: no longer a
   risk but a certainty, so it stops asking for attention and simply looks
   spent. It keeps the pulse to stay legible beside its neighbours. */
.slot--hot {
  border-color: var(--crit);
  animation: slot-hot 2.4s ease-in-out infinite;
}
@keyframes slot-hot {
  50% { box-shadow: 0 0 0 3px var(--crit-a); }
}
.slot--doomed {
  background: var(--crit-a);
  animation-duration: 1.2s;
}
.slot--doomed .slot__icon { opacity: .55; }

/* The global reduced-motion rule up in section 2 already stops the pulse. What
   it cannot do is leave anything in its place, so this holds the halo still —
   a socket that was pulsing must not become a socket that looks fine. */
@media (prefers-reduced-motion: reduce) {
  .slot--hot { box-shadow: 0 0 0 2px var(--crit-a); }
}

.slot[aria-selected="true"] { border-color: var(--ink-0); box-shadow: 0 0 0 1px var(--ink-0); }
.slot--drop { border-style: dashed; border-color: var(--ember); background: var(--crit-a); }

.slot__icon { font-size: 20px; line-height: 1; filter: grayscale(.25); }
.slot__qty {
  position: absolute; right: 3px; bottom: 2px;
  font: 700 10px/1 var(--font-mono);
  color: var(--ink-1);
  text-shadow: 0 1px 2px #000;
}
.slot__dur {
  position: absolute; inset: auto 3px 3px; height: 2px;
  background: var(--line-1);
}
.slot__dur > i { display: block; height: 100%; background: var(--ok); transform: scaleX(var(--v, 1)); transform-origin: left; }
.slot__dur[data-low] > i { background: var(--crit); }

/* Item row — the same tier language in list form */
.item-row {
  display: grid; grid-template-columns: 32px minmax(0, 1fr) auto; align-items: center; gap: var(--s-3);
  padding: var(--s-2) var(--s-3);
  border-bottom: var(--hair) solid var(--line-0);
  border-left: 2px solid var(--tier, transparent);
}
.item-row:hover { background: var(--bg-2); }
/* A row that submits. The inbox's rows write before they navigate, so they
   are buttons; everything else about them is a row. */
button.item-row { width: 100%; text-align: left; background: none; font: inherit; color: inherit; cursor: pointer; }
.item-row__name { font-size: var(--fs-sm); color: var(--ink-0); }
.item-row__meta { font: 600 var(--fs-xs)/1 var(--font-sans); letter-spacing: .08em; text-transform: uppercase; color: var(--tier, var(--ink-2)); }

/* Item card / hover tooltip */
.item-card {
  /* 240px is the hover width. --item-card-w is what lets the identical card
     fill a modal instead, so the two presentations share one stylesheet rule
     rather than a second class. */
  width: min(var(--item-card-w, 240px), 100%);
  background: var(--bg-2);
  border: var(--hair) solid var(--line-2);
  border-top: 2px solid var(--tier, var(--line-2));
  box-shadow: var(--shadow-2);
  font-size: var(--fs-sm);
}
.item-card__hd { padding: var(--s-3); border-bottom: var(--hair) solid var(--line-1); }
.item-card__name { font-weight: 600; color: var(--ink-0); }
.item-card__tier { font: 600 var(--fs-xs)/1.6 var(--font-sans); letter-spacing: .1em; color: var(--tier); text-transform: uppercase; }
.item-card__stats { padding: var(--s-3); display: grid; gap: 4px; }
.item-card__stat { display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: var(--fs-xs); color: var(--ink-2); }
.item-card__stat b { color: var(--ink-0); font-weight: 600; }
.item-card__flavor { padding: 0 var(--s-3) var(--s-3); color: var(--ink-3); font-size: var(--fs-xs); font-style: italic; line-height: 1.5; }

/* What a container holds, listed on its own card. A hairline divides it from
   the item's own numbers, because inside is a different subject and not a
   deeper elevation. */
.item-card__contents { border-top: var(--hair) solid var(--line-1); }
.item-card__contents > .micro { display: block; padding: var(--s-3) var(--s-3) var(--s-2); color: var(--ink-3); }
.item-card__contents .item-row { grid-template-columns: 20px minmax(0, 1fr) auto; gap: var(--s-2); padding: 4px var(--s-3); }
.item-card__contents .item-row .slot__icon { font-size: 13px; }

/* The sentence that has to be read before one of those buttons is pressed.
   --crit rather than ember-for-decoration: what it says is that the thing
   cannot be undone. */
.item-card__note { padding: var(--s-3) var(--s-3) 0; font-size: var(--fs-xs); line-height: 1.5; color: var(--crit); }

/* What you can do with it. Wraps, because three buttons do not fit across a
   240px card on a 320px phone. */
.item-card__actions {
  display: flex; flex-wrap: wrap; gap: var(--s-2);
  padding: var(--s-3);
  border-top: var(--hair) solid var(--line-1);
}
.item-card__actions > form { display: contents; }

/* Inspector — the floating layer an .item-card is read in.
   One host element for the whole page, positioned by ui-kit.js against
   whatever was pointed at. It never takes the pointer: a hover layer you have
   to reach is a layer that closes on the way, which is why the actions live in
   the modal a click opens instead and are hidden here. */
.inspector {
  position: fixed; top: 0; left: 0;
  z-index: var(--z-popover);
  width: max-content;
  max-width: min(300px, calc(100vw - var(--s-4)));
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--t-fast) var(--ease);
}
.inspector[data-shown] { opacity: 1; }
.inspector [data-act] { display: none; }

/* The same detail, tapped rather than pointed at. The dialog is only a
   backdrop and a position here — the card is the surface, so the shell carries
   no border, fill or padding of its own and would otherwise draw two. */
dialog.modal--inspect {
  background: none; border: 0; box-shadow: none;
  width: min(360px, calc(100vw - 32px));
  --item-card-w: 100%;
}
dialog.modal--inspect > .item-card { overflow-y: auto; min-height: 0; }

/* Loadout — labelled equip sockets */
.loadout { display: grid; gap: var(--s-2); }
.loadout__row { display: grid; grid-template-columns: minmax(0, 88px) 48px minmax(0, 1fr); align-items: center; gap: var(--s-3); }
.loadout__slot { width: 48px; height: 48px; }

/* ── Board: a cyberdeck's sockets, arranged ────────────────────────────────
   Nodes sit at percentage coordinates so the whole board scales with its
   container, and they are SIZED in cqw so they scale with it rather than
   against it. Below --board-min the sockets stop being legible, so the
   wrapper scrolls instead of the board shrinking into a diagram of nothing. */
.board-wrap { overflow-x: auto; }
/* The board with one panel beside it, matched in height.

   The board is the object; the panel next to it is what the object is doing.
   Putting them side by side is what makes that read as one thing rather than
   two sections you scroll between — and the panel is the one that gives way:
   the board sets the row's height and a long list scrolls inside rather than
   stretching it.

   ONE COLUMN BELOW THE BREAKPOINT, and that is not a fallback, it is the right
   answer there. A 320px phone has no room for two columns, and a side panel
   with a fixed height and its own scrollbar inside a page that already scrolls
   is the worst thing you can do to a small screen. Below 900px it is simply a
   panel under the board, which is where it started. */
.board-split { display: grid; gap: var(--s-4); }

@media (min-width: 900px) {
  .board-split {
    grid-template-columns: minmax(0, 1fr) minmax(0, 320px);
    align-items: stretch;
  }
  /* min-height:0 in both places or the flex child refuses to shrink and the
     panel grows the row instead of scrolling inside it. */
  .board-split > details.panel {
    display: flex; flex-direction: column;
    min-height: 0;
  }
  .board-split > details.panel > .panel__bd {
    overflow-y: auto;
    min-height: 0;
  }
}

.board {
  container-type: inline-size;
  position: relative;
  /* A board is an object, not a banner: stretched across a 1100px column it
     becomes a poster with a diagram lost in the middle of it. */
  max-width: min(720px, 100%);
  /* min(), not a bare 360px: min-width beats max-width, so a hard floor here
     pushes the whole page sideways on a 320px phone. The kit's own responsive
     rule, which this line predated. */
  min-width: min(360px, 100%);
  margin-inline: auto;
  aspect-ratio: 4 / 3;
}

/* One SVG behind everything. Boxes carry their own background, so a wire
   drawn centre-to-centre vanishes under the box it arrives at and nothing has
   to work out where an edge is. */
.board__wires { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.board__wire {
  stroke: var(--line-1);
  stroke-width: 1;
  /* The viewBox is stretched to the board's aspect, so without this a wire
     comes out thick one way and thin the other. */
  vector-effect: non-scaling-stroke;
}

.board__node {
  position: absolute;
  z-index: 1;
  /* Positioned by its CENTRE — the point every wire arrives at, and what
     makes one coordinate mean the same thing for a one-socket node and a
     six-socket one. */
  transform: translate(-50%, -50%);
}
/* Out of the node's own box, so the node measures the sockets and nothing
   else: a label under the box would drag the centre down with it. */
.board__label {
  position: absolute; top: calc(100% + 4px); left: 50%;
  transform: translateX(-50%);
  font-family: var(--font-label); font-size: var(--fs-xs); font-weight: 600;
  letter-spacing: var(--tracking-caps); text-transform: uppercase;
  color: var(--ink-2); white-space: nowrap;
  pointer-events: none;
}
.board__count { font-family: var(--font-mono); color: var(--ink-3); }

/* More than one socket of a type gets the box, and the box is what the wire
   attaches to — four sticks of RAM are not four wires saying the same thing. */
.board__box {
  /* An explicit column count rather than wrap: a bank of six is three and
     three, not five and a straggler. --cols comes from BoardNode::columns(). */
  display: grid;
  grid-template-columns: repeat(var(--cols, 1), auto);
  gap: clamp(4px, 1.4cqw, 7px);
  padding: clamp(4px, 1.4cqw, 8px);
  background: var(--bg-1);
  border: var(--hair) solid var(--line-1);
  border-radius: var(--r-1);
}
.board__node--cpu .board__box { background: var(--bg-2); border-color: var(--line-2); }
/* Sockets are the thing on this screen, so they are drawn at a size you can
   read rather than a size that fits. Everything inside one scales with it — a
   fixed 20px glyph in a 60px socket is a stamp in the middle of a box, and the
   empty-socket marker has the same problem in reverse. All three are the same
   container-query ramp, so they stay in proportion at every board width. */
.board .slot { width: clamp(40px, 11cqw, 60px); }
.board .slot__icon { font-size: clamp(20px, 5.5cqw, 30px); }
.board .slot--empty::after { width: clamp(10px, 2.6cqw, 14px); height: clamp(10px, 2.6cqw, 14px); }
/* On a board an empty socket is a HOLE — somewhere a thing is missing from —
   and a filled one is a thing. The kit's own .slot--filled tints by tier, and
   at tier one that tint is grey on grey: at socket size the two states have to
   differ in more than a shade. */
.board .slot--empty { background: transparent; border-style: dashed; }
.board .slot--filled .slot__icon { color: var(--ink-0); filter: none; }

/* Editing. Nodes are focusable here and nowhere else, because dragging is the
   only thing you can do to one and a pointer is not the only way to do it. */
.board[data-board-edit] .board__node { cursor: grab; touch-action: none; }
.board[data-board-edit] .board__node:focus-visible { outline: 2px solid var(--ember); outline-offset: 3px; }
.board__node[data-dragging] { cursor: grabbing; z-index: 2; }

/* ── UTILITIES ─────────────────────────────────────────────────────────── */

/* The corner tick. This is the third place the device appears — .card--select
   marks a chosen option with it and .slot--filled an occupied socket — so it is
   a modifier rather than a fourth copy. It marks a box as one you HAVE, and
   touches nothing else: no border colour, no background, no layout. --tick sets
   the colour and --tick-size the leg. */
.ticked { position: relative; }

/* A real element, not a pseudo: a pseudo-element cannot carry a title, and a
   mark this small is worth explaining on hover. Its borders ARE its box, so
   the whole corner square is the hit area. */
.tick {
  position: absolute; top: 0; right: 0;
  width: 0; height: 0;
  border-top: var(--tick-size, 12px) solid var(--tick, var(--ok));
  border-left: var(--tick-size, 12px) solid transparent;
}

/* Not yours yet. Half-lit until you point at it — the same device .slot--locked
   and a disabled .card--select already use for "not available to you". Pairs
   with .ticked, which marks the ones that are. */
.unlit {
  opacity: .5;
  transition: opacity var(--t-fast) var(--ease);
  /* It lights up under the pointer, so it reads as something you point AT.
     An I-beam would offer to select the text instead. */
  cursor: default;
}
.unlit:hover,
.unlit:focus-within { opacity: 1; }

.stack   { display: flex; flex-direction: column; gap: var(--gap, var(--s-3)); }
.row     { display: flex; align-items: center; gap: var(--gap, var(--s-3)); }
.row--wrap { flex-wrap: wrap; }
.grid    { display: grid; gap: var(--gap, var(--s-4)); }
.grid--pair { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid__wide { grid-column: 1 / -1; }
/* Two cells side by side stop being readable before 640px. */
@media (max-width: 640px) { .grid--pair { grid-template-columns: minmax(0, 1fr); } }
.spacer  { flex: 1; }
.t-right { text-align: right; }
.nowrap  { white-space: nowrap; }
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* ══════════════════════════════════════════════════════════════════════════
   RESPONSIVE
   The components above are overflow-safe at any width — every grid track
   has a zero minimum and every flex row wraps. What follows is only about
   what stays *readable* once a HUD is a hand's width across.
   ══════════════════════════════════════════════════════════════════════════ */

@media (max-width: 560px) {
  /* Vitals: the bar takes the full row and the label/value pair sits above
     it, instead of three columns fighting over 200px. */
  .vital {
    grid-template-columns: minmax(0, 1fr) auto;
    row-gap: 4px;
  }
  .vital__label { grid-area: 1 / 1; }
  .vital__value { grid-area: 1 / 2; text-align: right; }
  .vital > .meter { grid-area: 2 / 1 / 3 / -1; }

  /* Loadout: socket beside the item, slot name above both. */
  .loadout__row { grid-template-columns: 48px minmax(0, 1fr); row-gap: 2px; }
  .loadout__row > .micro { grid-area: 1 / 1 / 2 / -1; }

  /* A 36px readout beside a 13px label reads as a shout at this width. */
  .readout__value { font-size: var(--fs-xl); }

  /* Notches scale with the box; a 16px cut on a 300px panel is a gash. */
  .cut { --cut: 8px; }
  .cut--lg { --cut: 10px; }

  /* The rail is permanent furniture, and every pixel of it is width the page
     does not get. 44px is the smallest it can be and still be a touch target. */
  .shell--docked { --dock-w: 44px; }
  .dock { --dock-w: 44px; }

  /* A panel header's labels are flex:1, so with no basis they shrink to their
     longest word and shred a short sentence into five lines. Give them one and
     the ROW wraps instead, which is what wrapping was for. */
  .panel__hd > .micro { min-width: min(20ch, 100%); }

  /* The mark alone is the logo at this width. The name it stands for is a
     row of the topbar that two resources and a balance need more. */
  .brand__name { display: none; }
}

/* Coarse pointers need a real target. Slots are the only control in the kit
   that can be driven below a tappable size by its own column count. */
@media (pointer: coarse) {
  .slot { min-width: 44px; }
  .slots { overflow-x: auto; }
}

/* ============================================================================
   THE GRID MAP
   A player's memory of cyberspace: one cell per position, centred on them.

   IT SCROLLS, AND THAT IS WHAT MAKES IT WORK ON A PHONE. The cell is a fixed,
   tappable size and the window shows however many of them fit — rather than
   dividing a 320px screen by twenty-one and producing a 14px target. Fewer
   cells at once is a fair price; a control too small to hit is not.

   The eight cells around the player are the step targets, and because the map
   is always centred on them those eight are always in the same eight places.
   The map is a D-pad without one being drawn.
   ========================================================================== */
.gridmap {
  /* Big enough to read a glyph, a grade bar and two corner ticks at once —
     which is what a cell has to carry now. Half this was legible only because
     it was carrying half as much. */
  --cell: 60px;
  --span: 15;
  overflow: auto;
  background: var(--bg-1);
  border: var(--hair) solid var(--line-1);
  padding: var(--s-3);
  /* Tall enough to be a map and short enough to leave the panel below it on
     screen. Capped against the viewport so a short window does not push the
     controls off the bottom. */
  max-height: min(60vh, 560px);
  overscroll-behavior: contain;
}

.gridmap__grid {
  display: grid;
  grid-template-columns: repeat(var(--span), var(--cell));
  grid-auto-rows: var(--cell);
  gap: 1px;
  width: max-content;
  margin: 0 auto;
}

.cell {
  width: var(--cell);
  height: var(--cell);
  display: grid;
  place-items: center;
  font-family: var(--font-mono);
  /* The glyph IS the cell's content, so it is sized like a thing being looked
     at rather than like a label on one. At --fs-xs in a 60px square it read as
     a speck in a box; this fills the square the way an icon does, and leaves
     room under it for the grade bar and either corner tick. */
  font-size: var(--fs-xl);
  line-height: 1;
  color: var(--ink-2);
  background: var(--bg-2);
  border: var(--hair) solid var(--line-0);
  border-radius: var(--r-1);
  padding: 0;
  position: relative;
}

/* OUT OF SIGHT FROM WHERE THEY ARE STANDING.
   The map is memory, and most of what is on it is not being looked at right
   now — so the part that IS gets to be at full strength and everything else
   steps back. The whole cell dims rather than its glyph alone, because the
   fill is half of what a cell says and a bright tile with a faint mark on it
   reads as a mark that has gone wrong.

   A step rather than a fade, and a big one: at .8 the lit square did not read
   as lit — the difference was there in the numbers and not on the screen. What
   the step has to survive is a map where most cells are distant, so the few
   that are not have to win by a margin. Nothing is hidden at .55 — a player
   can still read what they remember out there — and the fog, the glyphs and
   the grade bars all step back together, because the cell dims as one thing.

   The blur is the second half of the same statement, and it says something
   opacity cannot: memory is not merely quieter than sight, it is less exact.
   1px is enough to soften a hairline and a glyph without making either
   unreadable — a player can still tell a silo from a relay out there, which is
   the whole point of drawing it at all. */
.cell--distant { opacity: .55; filter: blur(1px); }

/* Beyond the edge of the world. THE GRID DOES NOT WRAP, so this is a hole in
   the drawing rather than a place — no border, no fill, nothing to read. */
.cell--void { background: transparent; border-color: transparent; }

/* NEVER LOOKED AT — fog, and the difference from empty ground is a LAYER and
   not a shade. It drops below the map's own ground, loses its hairline, and
   carries a faint weave; empty ground sits on the board with its hairline
   intact. One reads as under the table and the other as on it, which is a
   difference you see without comparing two cells side by side.

   The weave is two hairline gradients rather than an image: it has to survive
   a re-skin, and a texture nobody can re-colour is a texture that dates. */
.cell--unswept {
  background:
    repeating-linear-gradient(45deg,
      var(--line-0) 0 1px,
      transparent 1px 6px),
    var(--bg-0);
  border-color: transparent;
}

/* Swept, and nothing was there. Space, not a dead node. */
.cell--empty { background: var(--bg-2); }

/* Something was standing here when they last looked.
   NO HAIRLINE. The fill lifts it off the ground and the glyph says what it is,
   which is enough — and it leaves the border free to mean something. A map
   where every machine is outlined is a map where an outline says nothing; now
   a hairline is only ever the portal, a machine that has just appeared, one
   that is rebooting, or you. */
.cell--system { background: var(--bg-3); border-color: transparent; color: var(--ink-1); }

/* WHAT KIND OF MACHINE, as a colour on the GLYPH and nothing else. A map is a
   screen somebody stares at: four hundred tinted tiles is a quilt, four
   hundred tinted marks is a legend — and an ordinary machine has no hairline
   at all now, so the colour has one job and the border has another.

   `--node` is the one property each kind sets, so everything below it reads the
   colour rather than the type — a fourth kind is a class and a token.        */
.cell--silo    { --node: var(--steel); }
.cell--compute { --node: var(--cyan); }
.cell--relay   { --node: var(--jade); }

.cell--silo, .cell--compute, .cell--relay { color: var(--node); }

/* THE PORTAL. There is exactly one in the world, which is what pays for the
   only animation on the screen: a rationed accent spent on a thing you will
   see once a session is not a rationed accent being wasted.

   It breathes between bone and brass over four seconds — SLOW, because it is a
   condition and not an event, the same reasoning `.slot--hot` follows. The
   pulse is on the glyph and the hairline; the fill stays a --bg step, because
   elevation here is never a glow. */
.cell--portal {
  color: var(--ink-0);
  border-color: color-mix(in srgb, var(--brass) 45%, transparent);
  background: var(--bg-3);
  animation: cell-portal 4s ease-in-out infinite;
}

@keyframes cell-portal {
  0%, 100% { color: var(--ink-0); border-color: color-mix(in srgb, var(--brass) 30%, transparent); }
  50%      { color: var(--brass); border-color: color-mix(in srgb, var(--brass) 70%, transparent); }
}

/* LOCKED DOWN AND COMING BACK UP. A dashed hairline: the machine is not all
   the way there. Static on purpose — the portal keeps the only movement on the
   map, and a screen with two things pulsing has neither of them meaning
   anything. */
.cell--rebooting {
  border-style: dashed;
  /* Its own, because an ordinary machine no longer has one to restyle. */
  border-color: color-mix(in srgb, var(--node, var(--ink-2)) 55%, transparent);
}

/* A BACKDOOR YOU HOLD. The kit's corner tick, which marks a box as one you
   have and marks nothing else — and in ember, because this is the one thing on
   a map that is yours. Bottom-left, leaving the top-right to the bookmark: the
   same device twice, in two corners, both meaning yours.

   Rare by construction. A player with backdoors on half the grid has earned a
   map with ember on half of it. */
.cell--backdoored::before {
  content: "";
  position: absolute;
  left: 0;
  bottom: 0;
  /* A share of the cell rather than a fixed size: the cell doubled once
     already, and a mark that does not follow it becomes a speck. */
  width: 20%;
  height: 20%;
  border-left: var(--hair) solid var(--ember);
  border-bottom: var(--hair) solid var(--ember);
}

/* THE GRADE, ONCE SOMEBODY HAS SCANNED IT OUT OF THE MACHINE — a bar at the
   foot of the cell, `--g` wide, in bone. Nine steps is more than colour can
   carry and a map of four hundred traffic lights is not a map anybody reads;
   a magnitude drawn as a magnitude is read without a legend.

   A background layer rather than a pseudo-element, because both of this cell's
   are spoken for: the bookmark tick and the backdoor tick. */
.cell[style*="--g"] {
  --cell-grade: linear-gradient(to right,
    var(--ink-2) calc(var(--g, 0) * 100%),
    transparent calc(var(--g, 0) * 100%));
}

/* A MACHINE YOU HAVE READ SOMETHING OUT OF. Bottom-right, in --info, which is
   the blue the kit already spends on "known rather than urgent" — and the last
   free corner: the bookmark has the top-right and the backdoor the
   bottom-left.

   It says you LEARNED something, not that you pointed an instrument at it: a
   file with no answers in it yet marks nothing. It also clears itself, because
   what it is keyed on is the ADDRESS — a machine that relocates after a
   lockdown comes back wearing a new one, and the file stops describing it.

   A background layer rather than a pseudo-element, for the reason the grade
   bar is one: both of this cell's pseudo-elements are spoken for. */
.cell--scanned { --cell-read: linear-gradient(to top left, var(--info) 0 50%, transparent 50%); }

.cell--scanned,
.cell[style*="--g"] {
  /* Two layers, and either may be absent — an unscanned grade is not a thing
     that exists, but a read that has not turned up the grade yet is. */
  background-image: var(--cell-read, none), var(--cell-grade, none);
  background-size: 22% 22%, calc(100% - 6px) 2px;
  background-position: 100% 100%, 3px calc(100% - 2px);
  background-repeat: no-repeat;
}

/* Motion is a nicety, and a map that breathes at somebody who asked it not to
   is a map that gets closed. */
@media (prefers-reduced-motion: reduce) {
  .cell--portal { animation: none; color: var(--brass); }
  /* The new cell keeps its brighter hairline; it simply stops travelling. */
  .cell--new { animation: none; border-image: none; }
}

/* You. Bone is the ink of the system and this is the one thing the eye has to
   find first; the accent is deliberately NOT spent here, because a map is a
   screen you stare at and an ember cell would burn a hole in it. */
.cell--you { background: var(--ink-0); border-color: var(--ink-0); color: var(--ink-on-bone); }

/* One of the eight. A brighter hairline and nothing else at rest — the ring
   should be findable without being loud, because it is on screen constantly. */
button.cell--step { cursor: pointer; border-color: var(--line-2); }
button.cell--step:hover { background: var(--bg-4); border-color: var(--ink-2); }
button.cell--step:active { transform: scale(.92); }

/* Anything openable that is not a step. */
button.cell { cursor: pointer; }
button.cell:focus-visible { outline: 2px solid var(--steel); outline-offset: 1px; z-index: 1; }

/* ONLY JUST APPEARED — a light travelling round the hairline, the same idea as
   the sheen crossing a button and for the same reason: a moving reflection
   reads as *something has changed here* where a static mark reads as a
   different kind of thing.

   IT KEEPS THE TYPE COLOUR. The first version of this went bone — ink-0 glyph,
   ink-1 ring — and that was wrong twice over: a new compute node stopped
   looking like a compute node, and a bone hairline is the loudest thing the
   kit has that is not the accent. The light is `--node`, so a new silo sweeps
   steel and a new relay sweeps jade, and what the cell IS survives the mark
   saying it is new.

   Drawn with border-image rather than a background layer or a pseudo-element,
   because on this cell all three of those are spoken for: the fill carries the
   fog weave, the background carries the grade bar, and both pseudo-elements
   are corner ticks. A conic gradient turned by an animated angle is the one
   place left, and it happens to be the right one — the light goes round the
   edge rather than across the face.

   THEY DO NOT MARCH IN STEP. A freshly swept window can hold forty of these at
   once, and forty synchronised sweeps is a disco rather than a map, so the
   phase is staggered by position. Slow, because it is a condition and not an
   event. */
@property --cell-sweep {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

.cell--new {
  /* Underneath the light, and what a browser without @property is left with:
     a hairline in the machine's own colour. Quiet — now that an ordinary
     machine has no border at all, having one is already the whole signal, and
     the travelling light only has to be noticed rather than announced. */
  border-color: color-mix(in srgb, var(--node, var(--ink-1)) 40%, transparent);
  border-image: conic-gradient(
    from var(--cell-sweep),
    transparent 0turn,
    color-mix(in srgb, var(--node, var(--ink-1)) 65%, transparent) 0.06turn,
    transparent 0.14turn,
    transparent 1turn
  ) 1;
  /* Unhurried. It is a condition, and a light going round a 30px square in
     three seconds reads as urgency the fact does not have. */
  animation: cell-sweep 6s linear infinite;
}

@keyframes cell-sweep {
  to { --cell-sweep: 1turn; }
}

/* Four phases is enough to break the lockstep without anybody being able to
   see the pattern in it. */
.cell--new:nth-child(4n+1) { animation-delay: -0.9s; }
.cell--new:nth-child(4n+2) { animation-delay: -2.6s; }
.cell--new:nth-child(4n+3) { animation-delay: -4.2s; }

/* A saved position, marked with the kit's corner tick — the same device that
   means "one you have" everywhere else, which is exactly what a waypoint is. */
.cell--marked::after {
  content: "";
  position: absolute;
  top: 0; right: 0;
  width: 18%; height: 18%;
  background: var(--warn);
  clip-path: polygon(100% 0, 0 0, 100% 100%);
}

/* ── The map and its key, side by side ────────────────────────────────────
   The map scrolls and the key is reference material, so the key gets a column
   of its own rather than a strip of dead space under a short map.

   THE KEY OVERLAYS WHEN IT OPENS, exactly as the rail does and for the same
   reason: the map is a scrolling window that re-centres on the player, and a
   column that pushed it narrower on hover would move the ground under the
   cursor. The frame reserves the collapsed width and the key grows over the
   map's right edge. */
.gridmap-frame {
  /* Declared HERE and not on the key, because the frame is what reserves the
     room: a custom property set on a child cannot be read by its parent, and
     `padding-right: var(--key-w)` with nothing to resolve is a frame that
     reserves nothing and a key that sits on top of the map's right-hand
     column. The key inherits both. */
  --key-w: 46px;
  --key-w-open: min(240px, 70vw);
  position: relative;
  padding-right: var(--key-w);
}

/* A column of swatches that opens to swatches and words. Three ways in, the
   same three the dock has: pointing at it where a pointer exists, the toggle
   everywhere (which is the only way in on a phone), and focus-within, which is
   what a keyboard needs. */
.maplegend {
  position: absolute;
  top: 0;
  right: 0;
  width: var(--key-w);
  max-height: 100%;
  overflow: hidden auto;
  overscroll-behavior: contain;
  background: var(--bg-1);
  border: var(--hair) solid var(--line-1);
  transition: width var(--t-base) var(--ease), box-shadow var(--t-base) var(--ease);
}

.maplegend:is(:focus-within, [data-expanded]) {
  width: var(--key-w-open);
  box-shadow: var(--shadow-2);
}

/* Only where a pointer can hover: a touch device keeps :hover stuck on the
   last thing tapped, which would leave the key jammed open over the map. */
@media (hover: hover) {
  .maplegend:hover { width: var(--key-w-open); box-shadow: var(--shadow-2); }
  .maplegend:hover .maplegend__label { opacity: 1; }
}

.maplegend__toggle {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  width: 100%;
  padding: var(--s-2);
  background: none;
  border: 0;
  border-bottom: var(--hair) solid var(--line-0);
  color: var(--ink-2);
  font: inherit;
  text-align: left;
  cursor: pointer;
}

.maplegend__toggle:hover { color: var(--ink-0); }

.maplegend__icon {
  flex: none;
  width: 26px;
  text-align: center;
  font-family: var(--font-mono);
}

.maplegend__keys {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  padding: var(--s-2);
}

.maplegend__key {
  display: flex;
  align-items: center;
  gap: var(--s-2);
}

/* The words are what the width buys. They fade rather than appear, because the
   column is already sliding and two motions that disagree read as a glitch. */
.maplegend__label {
  white-space: nowrap;
  font-size: var(--fs-sm);
  color: var(--ink-1);
  opacity: 0;
  transition: opacity var(--t-base) var(--ease);
}

.maplegend:is(:focus-within, [data-expanded]) .maplegend__label { opacity: 1; }

/* ON A NARROW SCREEN THERE IS NO ROOM BESIDE THE MAP, so the key goes back
   under it — and stays collapsed, because a legend is something you consult
   rather than something you watch. Closed it costs one line; open it is the
   wrapping row it always was.

   The breakpoint is where a 46px column stops being affordable next to a map
   worth looking at, not a device. */
@media (max-width: 719px) {
  .gridmap-frame { padding-right: 0; }

  .maplegend {
    position: static;
    width: auto;
    max-height: none;
    margin-top: var(--s-3);
  }

  .maplegend__keys { display: none; }

  .maplegend[data-expanded] .maplegend__keys {
    display: flex;
    flex-direction: row;
    flex-wrap: wrap;
    gap: var(--s-2) var(--s-4);
  }

  .maplegend__label { opacity: 1; }
}

.gridmap__legend {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2) var(--s-4);
  align-items: center;
}

.gridmap__key { display: inline-flex; align-items: center; gap: var(--s-2); }

/* The legend reuses .cell so a swatch cannot drift from the thing it explains.
   Size only — the border and fill stay the modifier's business, or the shorthand
   here would repaint the very colours the legend exists to show. */
/* A swatch is a small .cell, and it has to stay a cell: the whole point of the
   legend is that its samples are the real thing wearing the real classes.
   So it overrides the two things that do not survive being shrunk — the size,
   and the glyph inside it, which is sized for a 60px square and would hang out
   of a 14px one. Big enough to show a glyph, because for half these entries
   the glyph IS what is being explained. */
.gridmap__swatch {
  width: 26px;
  height: 26px;
  flex: none;
  font-size: var(--fs-sm);
}

/* A real target for a thumb. The map scrolls, so this costs visible cells
   rather than legibility — the right way round. */
/* The touch size used to be an enlargement. It is a floor now that the cell is
   60px, and kept for the day somebody tunes that number down. */
@media (pointer: coarse) {
  .gridmap { --cell: max(44px, 60px); }
}

/* ============================================================================
   THE BODY MAP
   A wireframe body with the implant regions marked on it — the same device
   as the deck board, applied to a person instead of a machine.

   The body is a picture (images/body.gif, white wire on black). It is
   SCREENED onto the well, so its black drops out and only the wire lands on
   --bg-1; the markers and labels stay SVG and take the kit's tokens.
   ========================================================================== */
.bodymap {
  display: grid;
  grid-template-columns: minmax(min(220px, 100%), 300px) minmax(0, 1fr);
  gap: var(--s-5);
  align-items: start;
}

/* Below the breakpoint the figure sits above the regions rather than beside
   them: two columns at 320px would give each of them nothing. */
@media (max-width: 720px) {
  .bodymap { grid-template-columns: minmax(0, 1fr); }
}

.bodymap__figure {
  width: 100%;
  height: auto;
  display: block;
  background: var(--bg-1);
  border: var(--hair) solid var(--line-1);
  padding: var(--s-3);
}

/* The figure: screened, so the picture's black background is the well's. */
.bodymap__photo { mix-blend-mode: screen; }

/* The overlay is in --info, so it reads as a layer over the wire rather than
   as more of it: the picture is bone-white, and grey on it disappeared. */

/* A leader from a region label to the part of the body it names. */
.bodymap__wire { stroke: var(--info); stroke-width: 1; opacity: .6; }

/* The region markers on the figure. Hollow with room, filled when something
   is in them, bone when full — the same reading as a socket on the deck board. */
.bodymap__node {
  fill: var(--bg-1);
  stroke: var(--info);
  stroke-width: 1.25;
}
.bodymap__node--full { fill: var(--ink-0); stroke: var(--ink-0); }
.bodymap__node--some { fill: var(--info); stroke: var(--info); }

.bodymap__label {
  fill: var(--info);
  font-family: var(--font-label);
  font-size: 9px;
  letter-spacing: var(--tracking-caps);
  text-transform: uppercase;
}

/* The region list beside the figure. */
.region {
  border: var(--hair) solid var(--line-1);
  background: var(--bg-2);
  padding: var(--s-3);
}
.region + .region { margin-top: var(--s-3); }

.region__hd {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
  align-items: baseline;
  justify-content: space-between;
  margin-bottom: var(--s-2);
}

/* One box per slot in the region, filled or empty. Wraps, because four
   abdominal slots and a 238px panel do not otherwise agree. */
.region__slots { display: flex; flex-wrap: wrap; gap: var(--s-2); }
