/* ═══════════════════════════════════════════════════════════════════════
   RICKY'S DEPOT. SHARED STYLESHEET.
   Design system: INSTRUMENT.

   Read this before adding anything:
     · Three colours, three meanings. --accent = interaction. --ice = motion,
       and ONLY motion. --heart = the heart in the support CTA, nothing else.
     · No raw rgba() anywhere. Every alpha is a token, declared in :root.
     · Nothing glides. Machines don't glide. Easing is --mech, never a bounce.
     · Integer line-heights under any hairline. A 1px rule under a fractional
       line box paints across two device rows at half opacity.
     · Animate transform, not width/height. Width re-lays-out every frame.
   ═══════════════════════════════════════════════════════════════════════ */

/* ── FONTS ───────────────────────────────────────────────────────────────
   Self-hosted from shared/fonts/, so no page phones out to a CDN for a
   typeface. Archivo and DM Mono are the site faces (named by --sans / --mono);
   Literata is the EPUB reader's serif reading option, with Georgia under it as
   the system fallback. Variable files carry their axes: Archivo weight+width,
   Literata optical-size+weight, so one file covers every used instance. */
@font-face { font-family:'Archivo'; src:url('fonts/archivo-var.woff2') format('woff2');
  font-weight:100 900; font-stretch:62% 125%; font-style:normal; font-display:swap; }
@font-face { font-family:'DM Mono'; src:url('fonts/dm-mono-400.woff2') format('woff2');
  font-weight:400; font-style:normal; font-display:swap; }
@font-face { font-family:'DM Mono'; src:url('fonts/dm-mono-500.woff2') format('woff2');
  font-weight:500; font-style:normal; font-display:swap; }
@font-face { font-family:'Literata'; src:url('fonts/literata-var.woff2') format('woff2');
  font-weight:200 900; font-style:normal; font-display:swap; }
@font-face { font-family:'Literata'; src:url('fonts/literata-italic-var.woff2') format('woff2');
  font-weight:200 900; font-style:italic; font-display:swap; }

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

/* HIDDEN MEANS HIDDEN. The browser's own rule for this is [hidden]{display:none},
   and it lives at the bottom of the cascade, so ANY class in here that sets a
   display beats it. .tool-acts is display:grid, and every .tool-acts the page
   tried to hide stayed on the screen: Set source point was offered in modes that
   have no source point, and Clear dodge and burn in modes that paint no mask.

   The !important is the point, not a shortcut. There is no reading of the word
   `hidden` in which a display rule somewhere else should win.

   Six apps still have to go through this stylesheet. This is the sort of thing
   that only shows up when a page hides something, and every one of them will. */
[hidden] { display: none !important; }

@property --ang { syntax: '<angle>'; inherits: false; initial-value: 0deg; }

/* ── TOKENS ─────────────────────────────────────────────────────────── */
:root {
  --base:#0C1013; --panel:#141A1E; --raised:#1B2229; --hair:#242E35;
  /* One step above --hair, for a surface sitting on a surface that is already
     raised. The word game board needs it: the squares read too dark against the
     panel, and lifting them to --hair alone would have collided with the active
     row, which already sits there. */
  --raised-2:#2E3941;
  /* The reading ladder. Measured against --panel:
       --ink    14.6:1  the answer. Whatever the person came here for.
       --ink-2   5.7:1  supporting text. Labels, blurbs, captions.
       --ink-3   2.7:1  FAILS AA. Disabled and decorative ONLY. Never a word
                        that has to be read. */
  --ink:#E9EBE3;  --ink-2:#8E958D; --ink-3:#586059;

  --accent:#D8E635;   /* hi-vis. Interaction. The ONLY accent. */
  --ice:#6FE3FF;      /* MOTION ONLY. Never appears at rest. */
  --heart:#E5484D;    /* the heart in the support CTA, and the danger colour.
                         Never decorative, but errors are not decoration. */
  /* CORRECT. Confirmed, right, done. The opposite of --heart, and the fourth
     signal on this site rather than the fourth colour: like the other three it
     exists to be read at a glance on near-black, which is why it is this
     saturated and this bright.

     It is NOT the accent. The accent means you can act on this, and a letter
     that is already right is not something you act on. Word Games is where the
     distinction had to be made: with the accent grading a tile, the cursor and
     the feedback were the same colour on the same board.

     #3CCB6E and not a prettier emerald, deliberately. The accent is ALREADY a
     yellow-green, so the risk here is a green that reads as the accent to
     anyone with a red-green deficiency. This is the candidate furthest from it
     in luminance that still clears 8.3:1 against --panel. Where the two must be
     told apart, the shape carries it too, never the hue alone. */
  --jade:#3CCB6E;

  /* Channel triplets, so alphas compose as tokens instead of literals. */
  --ink-rgb:233 235 227;
  --accent-rgb:216 230 53;
  --ice-rgb:111 227 255;
  --heart-rgb:229 72 77;
  --jade-rgb:60 203 110;

  /* EVERY alpha in the system. If you need a new one, add it here. */
  --halo:rgb(var(--accent-rgb) / .05);
  --vignette:rgb(0 0 0 / .5);

  /* ON-IMAGE CHROME. White line, black keyline. It is what every editor ever
     made does, and the reason is not taste.

     --accent means ONE thing on this site: you can act on this. Every pixel of a
     photograph is already something you can act on, so the moment the accent is
     smeared across the picture it stops saying anything, and it starts competing
     with the photograph for the same job the photograph is there to do.

     White and black are not colours here. They are the two ends of the range the
     photograph is printed in, so they read on any photograph, which is the only
     thing a line drawn on one has to do. */
  --chrome:rgb(255 255 255 / .95);    /* the line */
  --chrome-2:rgb(255 255 255 / .3);   /* the thirds grid inside the crop box */
  --edge:rgb(0 0 0 / .88);            /* the keyline under both of them */
  --gridline:rgb(255 255 255 / .024);
  --spot:rgb(var(--accent-rgb) / .06);
  --sheen:rgb(var(--accent-rgb) / .18);
  --guide:rgb(var(--accent-rgb) / .14);
  --scrim:rgb(12 16 19 / .82);
  --danger-wash:rgb(var(--heart-rgb) / .1);
  --jade-wash:rgb(var(--jade-rgb) / .1);   /* the twin of --danger-wash */
  --pick:rgb(var(--accent-rgb) / .07);   /* a selected page in the workshop grid */

  /* ── THE RATING SCALE ──────────────────────────────────────────────────
     TWO COLOURS THAT ARE NOT SIGNALS, and the only two on this site.

     The four signals answer "what is this": you can act on it, it is moving,
     it is right, it is wrong. A RATING answers something else entirely. It is
     not telling you what a thing is or what to do with it, it is telling you
     how well you did, on a scale, after the fact. That is a different kind of
     statement and it earns a different vocabulary.

     The rule that keeps this honest is that these two are USELESS ANYWHERE
     ELSE. They may only ever appear on a performance grade, they may never
     appear on a control, a state, or a piece of feedback, and the grade must
     always be written out in words beside them. Put --amber on a button and
     the whole argument collapses, because then it is a signal after all and
     the site has six.

     THE ONE EXCEPTION, and the terms it is held to. --violet also marks
     Phrasebox's `far` square: right letter, wrong word. That IS a piece of
     feedback and it does break the rule above, knowingly. It is allowed on
     three conditions, all of which the Word Games section implements and none
     of which are optional:

       1. It is never a control and never a state you can act on. It is a
          verdict on a letter you have already spent, which is the same kind of
          statement a rating is.
       2. It is written out in words beside the colour, exactly as a rating is,
          by the legend under the board. That rule was always the thing making
          a hue-carried meaning safe here, and it is doing the same job there.
       3. It appears on ONE board in ONE app. --violet is still a grade on the
          Practice Range and the two never share a page.

     If a third use ever wants --violet, the answer is no and the correct move
     is a new token, because at that point it is a signal and the site has six
     after all. Read the ladder note in the Word Games section for why this was
     worth paying for.

     THE KNOWN COST, written down so nobody has to rediscover it. Reading a
     scalar off a hue is the thing --jade's own note argues against, and this
     scale does it: violet, jade, ink, amber, heart. It is legible to most
     people at a glance and it is what the arcade idiom expects. It is also
     the ONE place on this site where a colour-deficient reader is relying on
     the word rather than the colour, which is exactly why the word is not
     optional. If a rating ever appears without its label, that is a bug. */
  --amber:#F5A31A;    /* the fourth rung down. Slow, not dangerous.   */
  --violet:#E879F9;   /* the top rung. Better than good.             */
  --amber-rgb:245 163 26;
  --violet-rgb:232 121 249;
  --amber-wash:rgb(var(--amber-rgb) / .1);
  --violet-wash:rgb(var(--violet-rgb) / .1);
  /* The Word Games lattice. --ink-3 reads as a real line against the --raised
     cells (2.13:1) while staying well under the jade and accent that land on
     cells, so the grid is visible without competing with play. */
  --grid:var(--ink-3);
  /* The current word's wash on a board. Fainter than --pick, because a whole
     row lit at .07 was a slab of accent under the letters; this is just enough
     to say "this is the word you are on". */
  --band:rgb(var(--accent-rgb) / .04);
  --flux-1:rgb(var(--ice-rgb) / .12);
  --flux-2:rgb(var(--ice-rgb) / .08);
  --flux-3:rgb(var(--accent-rgb) / .05);
  --flux-held:rgb(var(--accent-rgb) / .03);

  /* THE OPTICAL LIFT. Every label in this system is uppercase mono, and
     uppercase has no descenders, so its ink sits about a pixel ABOVE the centre
     of its own line box. An icon centred on that box is therefore geometrically
     perfect and visibly low. This is the correction, in one place, for every
     icon that stands next to a mono label. Turn this one number if it reads
     wrong. Do not nudge icons individually. */
  --lift:-1px;

  --sans:'Archivo', system-ui, sans-serif;
  --mono:'DM Mono', ui-monospace, monospace;
  --notch:polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 0 100%);
  /* --mech is an ease-OUT. It is for something ARRIVING: it covers 84% of the
     distance almost at once and then settles into place, which is how a machine
     part lands. Use it on transitions and on one-shot animations.

     NEVER use it on a LOOPING animation. A loop has no destination, so an
     arrival curve just pins the value at the far end for most of every cycle.
     A 1.7s pulse eased with --mech is past 0.9 opacity within 0.17s and sits
     there for the other 1.5s: it runs perfectly and it looks like it is doing
     nothing. Loops are linear, or they are steps. */
  --mech:cubic-bezier(.16,.84,.28,1);
  color-scheme: dark;
}

/* ── BASE ───────────────────────────────────────────────────────────── */
body {
  background: var(--base); color: var(--ink);
  font-family: var(--sans); font-size: 15px; line-height: 1.6;
  font-variation-settings: 'wdth' 100, 'wght' 400;
  min-height: 100vh; min-height: 100dvh;   /* 100vh includes the mobile URL bar */
  display: flex; flex-direction: column;
  -webkit-font-smoothing: antialiased;
}

/* grid + halo + vignette */
body::before {
  content: ''; position: fixed; inset: 0; z-index: 0; pointer-events: none;
  background:
    radial-gradient(1100px 480px at 50% -10%, var(--halo), transparent 72%),
    radial-gradient(circle at 50% 42%, transparent 42%, var(--vignette) 130%),
    linear-gradient(var(--gridline) 1px, transparent 1px)        0 0 / 46px 46px,
    linear-gradient(90deg, var(--gridline) 1px, transparent 1px) 0 0 / 46px 46px;
}
/* grain */
body::after {
  content: ''; position: fixed; inset: 0; z-index: 98; pointer-events: none; opacity: .05;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.86' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)'/%3E%3C/svg%3E");
}

a { color: inherit; text-decoration: none; }
button { cursor: pointer; font: inherit; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

.wrap { width: 100%; max-width: 1080px; margin: 0 auto; padding: 0 28px; position: relative; z-index: 1; }
main { flex: 1; }

.mono { font-family: var(--mono); font-size: 10.5px; line-height: 16px; letter-spacing: .05em; text-transform: uppercase; }
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}

/* ── BOOT (index only) ──────────────────────────────────────────────────
   The log gives way to the mark, the mark holds, then it flies apart into the
   plate. Every move is a transform. Nothing animates a box. */
#boot { position: fixed; inset: 0; z-index: 200; pointer-events: none; }
#bootlog {
  position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%);
  font-family: var(--mono); font-size: clamp(9px, 2.7vw, 12px); line-height: 2.05;
  color: var(--ink-2); white-space: pre; text-align: left;
  width: max-content; max-width: calc(100vw - 32px);
  will-change: transform;
}
#bootlog .ok { color: var(--accent); }

/* The mark holds the two letters. The four brackets are NOT in here: they are
   the plate's own, flown out and flown back, because there is only ever one set
   of brackets on this page. inset:0 and position:fixed, so a child's left/top
   IS a viewport coordinate and the boot can park things straight off
   getBoundingClientRect.

   z-index 1, not 200: the mark renders at page level, exactly where the real
   title renders, so the grain sits over both of them and the hand-off at the
   end changes nothing but which element is painting the letter. */
#mark {
  position: fixed; inset: 0; z-index: 1; pointer-events: none;
  opacity: 0;
}

/* R and D, at true title size. They never scale, they only travel: the mark IS
   the title, so the letters are already the size they will end up. */
.mchar {
  position: absolute;
  font-family: var(--sans); font-variation-settings: 'wdth' 118, 'wght' 800;
  letter-spacing: -.035em; line-height: .92; text-transform: uppercase;
  pointer-events: none; transform-origin: left top; will-change: transform;
}
.mchar.solid  { color: var(--ink); }
.mchar.hollow { color: transparent; -webkit-text-stroke: 1.2px var(--ink); }

/* ── THE PLATE (index header) ───────────────────────────────────────── */
header { padding: 64px 0 0; }
.plate { position: relative; padding: 30px 34px; }
.plate::before {
  content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 0;
  opacity: 0; transition: opacity .4s var(--mech);
  background: radial-gradient(220px circle at var(--px,50%) var(--py,50%), var(--spot), transparent 70%);
}
.lit .plate:hover::before { opacity: 1; }
.plate > * { position: relative; z-index: 1; }

/* ── THE BRACKET ──────────────────────────────────────────────────────
   One component. The plate wears four and the boot mark wears four, and they
   are the same object in two places, which is the whole point of the boot.

   Two arms rather than two borders, because the arms grow on hover and a box
   that grows has to animate width. Each arm is scaled along its own length
   only, anchored at the corner, so the 2px stroke is never scaled and never
   lands on a fractional device row. 20px at rest, 24px lit. Both integers. */
.bk {
  position: absolute; width: 20px; height: 20px; z-index: 1; pointer-events: none;
  opacity: 0; transition: opacity .45s var(--mech);
}
.bk::before, .bk::after {
  content: ''; position: absolute; background: var(--accent);
  transition: transform .35s var(--mech);
}
.bk::before { width: 100%; height: 2px; }   /* the arm along the plate's edge */
.bk::after  { width: 2px;  height: 100%; }  /* the arm down the plate's side */

.bk-tl { top: 0; left: 0; }
.bk-tr { top: 0; right: 0; }
.bk-bl { bottom: 0; left: 0; }
.bk-br { bottom: 0; right: 0; }

.bk-tl::before, .bk-tl::after { top: 0; left: 0; }
.bk-tr::before, .bk-tr::after { top: 0; right: 0; }
.bk-bl::before, .bk-bl::after { bottom: 0; left: 0; }
.bk-br::before, .bk-br::after { bottom: 0; right: 0; }

.bk-tl::before, .bk-bl::before { transform-origin: left; }
.bk-tr::before, .bk-br::before { transform-origin: right; }
.bk-tl::after,  .bk-tr::after  { transform-origin: top; }
.bk-bl::after,  .bk-br::after  { transform-origin: bottom; }

.lit .bk { opacity: .34; }
/* Hover only lights the brackets AFTER boot. Before .lit is added the boot
   overlay is up (pointer-events:none), so hovering the title area under it used
   to fire the plate hover and light the brackets mid-boot. */
.lit .plate:hover .bk { opacity: .9; }
.lit .plate:hover .bk::before { transform: scaleX(1.2); }
.lit .plate:hover .bk::after  { transform: scaleY(1.2); }

.head-top { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; flex-wrap: wrap; }

.wordmark {
  font-family: var(--sans); font-variation-settings: 'wdth' 118, 'wght' 800;
  font-size: clamp(1.85rem, 4.6vw, 2.9rem); letter-spacing: -.035em;
  text-transform: uppercase; cursor: default;
  /* The font-size is fluid, so .92 gives a fractional line box at almost every
     viewport, and the whole page below the plate inherits that half pixel. The
     readout's hairlines and the footer rule then paint across two device rows.
     round() snaps the line box to a whole row. The plain .92 above it is the
     fallback for anything that doesn't support round(). */
  line-height: .92;
  line-height: round(.92em, 1px);
  /* The <h1> ships with its real text so the page works without JS. That text
     would otherwise paint for one frame before the script splits it into <ch>
     elements, which is the flash of the title before the boot. Hidden here,
     revealed by the script the moment the split is done. <noscript> puts it
     back. visibility, not display, so the letters still have measurable boxes. */
  visibility: hidden;
}
.wm-line { display: block; }
.wm-l2 {
  color: transparent; -webkit-text-stroke: 1.2px var(--ink);
  transition: color .35s var(--mech), -webkit-text-stroke-color .35s;
}
.wordmark:hover .wm-l2 { color: var(--accent); -webkit-text-stroke-color: var(--accent); }
.wordmark ch { display: inline-block; opacity: 0; }

.roll { opacity: 0; }
.rolled .roll { animation: rise .6s var(--mech) forwards; }

/* THE SECOND TIME. The boot runs once per session, and so does everything that
   comes after it. A refresh, or coming back to the index from an app, is not an
   arrival: it is a return, and a return does not get a curtain.

   Both of these were still playing on every refresh. The rollout, because it is
   an animation on .rolled, which finish() adds whether it booted or skipped. The
   brackets, because .lit only changes their opacity and .bk transitions it. */
.still .roll { animation: none; opacity: 1; }
.still .bk { transition: none; }
@keyframes rise { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
.rolled .r1 { animation-delay: .08s } .rolled .r2 { animation-delay: .21s }
.rolled .r3 { animation-delay: .34s } .rolled .r4 { animation-delay: .47s }
.rolled .r5 { animation-delay: .60s } .rolled .r6 { animation-delay: .73s }

.tagline { margin: 26px 0 0; font-size: 17px; line-height: 26px; color: var(--ink-2); max-width: 52ch; }
.tagline-sub { margin: 7px 0 0; font-size: 14px; line-height: 22px; color: var(--ink-2); }
.tagline-sub b { color: var(--ink); font-variation-settings: 'wght' 500; }

/* ── THE SUPPORT CTA ────────────────────────────────────────────────── */
.support {
  position: relative; overflow: hidden;
  display: inline-flex; align-items: center; gap: 9px;
  background: var(--panel); border: 1px solid var(--hair);
  padding: 11px 20px; cursor: pointer;
  font-size: 12px; line-height: 20px; font-variation-settings: 'wght' 600; color: var(--ink); white-space: nowrap;
  text-transform: uppercase; letter-spacing: .07em;
  transition: border-color .2s, transform .2s var(--mech);
}
.support::before {
  content: ''; position: absolute; inset: 0; transform: translateX(-101%);
  background: linear-gradient(90deg, transparent, var(--sheen), transparent);
  transition: transform .55s var(--mech);
}
.support:hover { border-color: var(--accent); transform: translateY(-1px); }
.support:hover::before { transform: translateX(101%); }
.support .hb {
  color: var(--heart); font-size: 15px; line-height: 1; display: inline-block;
  animation: heartbeat 2.4s ease-in-out infinite;
}
@keyframes heartbeat {
  0%,100% { transform: scale(1) }  12% { transform: scale(1.32) }
  24%     { transform: scale(1) }  36% { transform: scale(1.18) }
  50%     { transform: scale(1) }
}

/* ── THE READOUT ────────────────────────────────────────────────────── */
/* Every number here is literally true. There is no fake telemetry on this
   site and there never will be. If a cell can't be honest, it doesn't exist. */
.readout {
  position: relative; overflow: hidden; display: flex; flex-wrap: wrap;
  border: 1px solid var(--hair); background: var(--panel); margin: 28px 0 0;
}
.flux { position: absolute; inset: 0; pointer-events: none; z-index: 0; display: flex; }
.flux i { flex: 1 1 0; display: block; background: transparent; transition: background .45s linear; }
.cell {
  position: relative; z-index: 1; padding: 12px 18px;
  border-right: 1px solid var(--hair); display: flex; gap: 10px; align-items: baseline;
}
.cell:last-child { border-right: 0; margin-left: auto; }
.cell .k { color: var(--ink-2); }
.cell .v {
  font-family: var(--mono); font-size: 11.5px; line-height: 16px; font-variation-settings: 'wght' 500;
  color: var(--ink); font-variant-numeric: tabular-nums;
}
.cell .v.live { color: var(--accent); }
.cell .v.tick { color: var(--ink-2); }

/* ── CONTROLS ───────────────────────────────────────────────────────── */
.controls {
  display: flex; align-items: flex-end; justify-content: space-between;
  gap: 20px; flex-wrap: wrap; margin: 44px 0 20px;
}
.field { display: flex; flex-direction: column; gap: 8px; }
.field .mono { color: var(--ink-2); }

.search-shell { position: relative; display: block; }
input[type=search] {
  width: 320px; max-width: 100%; background: var(--panel); border: 1px solid var(--hair);
  padding: 10px 13px; color: var(--ink); font-family: var(--sans); font-size: 14px; line-height: 22px;
  outline: none; transition: border-color .18s;
}
input[type=search]::placeholder { color: var(--ink-2); }
input[type=search]:focus { border-color: var(--accent); }
/* scaleX, not width. Width re-lays-out and lands on fractional pixels. */
.search-shell::after {
  content: ''; position: absolute; left: 0; bottom: 0; height: 1px; width: 100%;
  background: var(--accent); transform: scaleX(0); transform-origin: left;
  transition: transform .4s var(--mech);
}
.search-shell:focus-within::after { transform: scaleX(1); }

/* ── THE SEGMENT. One component, two sizes ──────────────────────────── */
/* Replaces both .seg and the old .toggle-group, which were the same control
   defined twice inline on different pages with different padding. */
.seg { display: flex; border: 1px solid var(--hair); }
.seg button {
  background: transparent; border: 0; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  min-width: 92px; padding: 11px 12px; color: var(--ink-2);
  font-family: var(--mono); font-size: 10.5px; line-height: 16px; text-transform: uppercase; letter-spacing: .05em;
  transition: color .14s, background .14s;
}
.seg button + button { border-left: 1px solid var(--hair); }
.seg button[aria-pressed="true"] { background: var(--accent); color: var(--base); }
.seg button:hover:not([aria-pressed="true"]) { color: var(--ink); }
.seg--sm button { min-width: 0; padding: 6px 12px; font-size: 9.5px; line-height: 14px; }
/* Equal columns, whatever the labels say and however many there are. Without
   this, buttons are sized by their own text, so TXT sits next to MARKDOWN at
   half the width and a set of equal choices does not look like one. This is why
   the format labels are acronyms: they are all about as wide as each other, and
   at 280px the panel takes about five before they start to crowd. */
.seg--fill button { flex: 1 1 0; min-width: 0; }
/* A LOUDER segment. The default .seg is recessive on purpose (ink-2 on
   transparent) so a lone segmented choice sits quiet. This variant gives it the
   .btn surface and full ink, so it holds its own when it shares a row with real
   buttons. The pressed look is deliberately left to the base rule (accent fill,
   --base text): the :not([aria-pressed="true"]) here is load-bearing, because
   without it this same-specificity rule would come later in the file and repaint
   the pressed label, giving you unreadable light text on the accent. Add this
   class to any .seg that sits next to buttons. */
.seg--solid { background: var(--panel); }
.seg--solid button:not([aria-pressed="true"]) { color: var(--ink); }
.seg--solid button:not([aria-pressed="true"]):hover { background: var(--raised); }

/* The Word Games picker and action buttons, sized to match each other so the
   bar reads as one row of equal controls rather than three widths. A pressed
   picker button takes the accent fill from .btn[aria-pressed] already. */
.btn--pick { min-width: 108px; }
.btn--act  { min-width: 116px; }
/* REVEAL. It gives up a word, so it wears --heart at rest, not just on hover:
   it is the one destructive control on the board and it should read that way
   before you press it, not after. */
.btn--reveal { border-color: var(--heart); color: var(--heart); }
.btn--reveal:hover:not(:disabled) { border-color: var(--heart); background: var(--danger-wash); transform: translateY(-1px); }
/* The share button swaps its label to Copied or Couldn't copy, so pin its width
   to the widest of the three and centre the text: it must not resize under the
   person as they click it. */
.btn--share { width: 148px; }

/* ── BUTTONS ────────────────────────────────────────────────────────── */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
  padding: 11px 18px; border: 1px solid var(--hair); background: var(--panel);
  color: var(--ink); font-family: var(--mono); font-size: 10.5px; line-height: 16px;
  text-transform: uppercase; letter-spacing: .05em; white-space: nowrap;
  transition: border-color .16s, background .16s, color .16s, transform .16s var(--mech);
}
.btn:hover:not(:disabled) { border-color: var(--accent); transform: translateY(-1px); }
.btn:disabled { color: var(--ink-3); cursor: not-allowed; }
.btn--primary { background: var(--accent); border-color: var(--accent); color: var(--base); }
.btn--primary:hover:not(:disabled) { background: var(--ink); border-color: var(--ink); }
/* A disabled primary button must not keep the live accent fill: --base text on
   accent is right for the enabled button, but once disabled the text drops to
   --ink-3 (see .btn:disabled) and grey-on-accent is unreadable. A disabled
   control is not an interaction, so it also should not wear the interaction
   colour. Fall back to the neutral disabled look the other buttons already use. */
.btn--primary:disabled { background: var(--panel); border-color: var(--hair); color: var(--ink-3); }
.btn--danger:hover:not(:disabled) { border-color: var(--heart); background: var(--danger-wash); }
/* A CHOSEN button. .seg is the right control when the choices sit in one strip,
   but six aspect ratios do not fit in a 280px strip and they go in a grid
   instead. The grid still has to be able to say which one is on, and it says it
   the same way .seg does, so the two read as one idea in two shapes. */
.btn[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--base); }
.btn[aria-pressed="true"]:hover:not(:disabled) { background: var(--ink); border-color: var(--ink); }
.btn svg {
  width: 16px; height: 16px; flex-shrink: 0;
  stroke: currentColor; stroke-width: 1.5; fill: none;
  transform: translateY(var(--lift));       /* see --lift */
}

/* ── THE RACK (index) ───────────────────────────────────────────────── */
.rack-shell { position: relative; }
.cross { position: absolute; inset: 0; pointer-events: none; opacity: 0; transition: opacity .3s; z-index: 3; }
.rack-shell:hover .cross { opacity: 1; }
.cross i { position: absolute; background: var(--guide); }
.cross .cx { top: 0; bottom: 0; width: 1px; transform: translateX(var(--mx,0)); }
.cross .cy { left: 0; right: 0; height: 1px; transform: translateY(var(--my,0)); }
.rack { display: grid; grid-template-columns: repeat(3,1fr); gap: 14px; }

/* .bay is a STATIONARY grid item. .slot is the thing that moves. Hover hangs
   off .bay, never off .slot. Otherwise translateY(-3px) walks the card out
   from under the cursor at the bottom edge and the hover flickers forever. */
.bay { height: 100%; }
.slot {
  height: 100%; clip-path: var(--notch); background: var(--hair);
  padding: 1px;                    /* .slot IS the card's border: 1px of it shows */
  transition: transform .22s var(--mech), background .25s;
}
.bay:hover .slot, .bay:focus-within .slot {
  transform: translateY(-3px);
  background: conic-gradient(from var(--ang),
    var(--hair) 0 60%, #59642A 70%, var(--accent) 81%, #FDFFE8 84%,
    var(--accent) 87%, #59642A 92%, var(--hair) 97%);
  animation: trace 2.6s linear infinite;
}
@keyframes trace { to { --ang: 360deg } }

.card {
  clip-path: var(--notch); background: var(--panel); padding: 22px 22px 20px;
  display: flex; flex-direction: column; gap: 12px; position: relative; height: 100%;
  transition: background .22s;
}
.bay:hover .card, .bay:focus-within .card { background: var(--raised); }

.cb {
  position: absolute; width: 13px; height: 13px; pointer-events: none;
  opacity: 0; transition: opacity .22s var(--mech), transform .3s var(--mech);
}
.cb-tl { top: 9px; left: 9px; border-top: 1px solid var(--accent); border-left: 1px solid var(--accent); transform: translate(5px,5px); }
.cb-br { bottom: 9px; right: 9px; border-bottom: 1px solid var(--accent); border-right: 1px solid var(--accent); transform: translate(-5px,-5px); }
.bay:hover .cb, .bay:focus-within .cb { opacity: .85; transform: translate(0,0); }

/* The stretched link. .app-name must NOT be positioned, so that ::after
   resolves against .card and the whole tile becomes the hit area. The
   underline therefore hangs off an inner <span>. */
.app-name {
  font-size: 17px; font-variation-settings: 'wght' 600; letter-spacing: -.012em;
  line-height: 22px;        /* INTEGER. A 1px rule under a fractional line box
                               paints across two device rows at half opacity */
  width: fit-content;
}
.app-name::after { content: ''; position: absolute; inset: 0; z-index: 2; }
.app-name > span { position: relative; display: block; }
.app-name > span::before {
  content: ''; position: absolute; left: 0; bottom: -3px; height: 1px; width: 100%;
  background: var(--accent); transform: scaleX(0) translateZ(0); transform-origin: left;
  transition: transform .35s var(--mech);
}
.bay:hover .app-name > span::before,
.bay:focus-within .app-name > span::before { transform: scaleX(1) translateZ(0); }

/* 13px blurb on a rack card. NOT the same as .page-desc. That ambiguity is
   what the old shared .app-desc had, and it meant two things at once. */
.card-desc { font-size: 13px; line-height: 20px; color: var(--ink-2); flex: 1; }

.open {
  font-family: var(--mono); font-size: 9.5px; text-transform: uppercase; letter-spacing: .12em;
  color: var(--accent); display: flex; align-items: center; gap: 7px;
  opacity: 0; transform: translateY(6px); margin-top: 2px;
  transition: opacity .25s var(--mech), transform .25s var(--mech);
}
.bay:hover .open, .bay:focus-within .open { opacity: 1; transform: none; }
/* Reserve the LONG bar and scale it down at rest. A transform does not change
   layout, so a bar that only reserves its short width will paint straight over
   the word beside it once it grows.

   isolation + its own layer: during a FLIP reorder the whole .bay gets a
   transient transform, which promotes it to a compositor layer and re-rasterises
   its contents against a new pixel grid. A 1px line landed on a half pixel there
   and came back thicker and brighter on exactly the cards that had moved. Giving
   the bar its own stable layer and keeping its vertical offset off the transform
   (margin, not translateY) means it is snapped once and never re-snapped. */
.open i {
  display: block; width: 28px; height: 1px; background: var(--accent);
  margin-top: var(--lift);                                /* was translateY; keep it off the transform */
  transform-origin: left;
  transform: scaleX(.5);                                  /* 14px at rest */
  transition: transform .3s var(--mech) .1s;
  will-change: transform; backface-visibility: hidden;
}
.bay:hover .open i { transform: scaleX(1); }              /* 28px */

.pin {
  position: absolute; z-index: 4; top: 18px; right: 26px;
  background: transparent; border: 0; cursor: pointer; padding: 3px;
  color: var(--ink-3); opacity: 0; transform: scale(.85);
  transition: color .14s, opacity .2s, transform .2s var(--mech);
}
.pin svg { display: block; width: 16px; height: 16px; stroke: currentColor; stroke-width: 1.5; fill: none; }
.bay:hover .pin, .pin:focus-visible { opacity: 1; transform: none; }
.pin:hover { color: var(--ink); }
.pin[aria-pressed="true"] { color: var(--accent); opacity: 1; transform: none; }
.pin[aria-pressed="true"] svg { fill: var(--accent); }

.nomatch {
  display: none; padding: 64px 0; text-align: center;
  font-family: var(--mono); font-size: 11px; color: var(--ink-2);
  text-transform: uppercase; letter-spacing: .09em;
}
.nomatch b {
  display: block; margin-top: 9px; font-family: var(--sans); font-size: 13px;
  text-transform: none; letter-spacing: 0; color: var(--ink-2);
}

/* ── ICONS ──────────────────────────────────────────────────────────── */
/* THE RULES. They hold for every icon added from here on:
     1. 24 viewBox, rendered at 32px. No plate, no chamfer, no frame.
     2. One stroke weight, 1.5. One accent element.
     3. SWITCH, DON'T MOVE. At 32px a 3px nudge is invisible. A shape becoming
        a DIFFERENT shape is unmissable. Every animation is a substitution.
     4. The accent is present in BOTH states, and the accent is the ONLY thing
        that switches. The ink shapes never move.
     5. Ink is ink. No dimmed strokes, no opacity on .ln. Solid or absent.
     6. RENDER AT 16, 32 OR 48. Nothing else. A 1.5 stroke in a 24 viewBox
        scales with the box: at 32px it is 2px, at 16px it is 1px, and at 12px
        it is 0.75px, which is not a line, it is a grey smear across two device
        rows. The size is not a taste decision, it is 1.5 * (size / 24) landing
        on a whole number, and only 16, 32 and 48 do.
     6. Draw geometry, don't typeset it. Text inside SVG can't be reliably
        centred, because dominant-baseline centres on the font's em box and
        not on the glyph's ink, and it renders in Arial until the webfont
        lands. */
.glyph { width: 32px; height: 32px; flex-shrink: 0; }
.glyph svg { width: 100%; height: 100%; overflow: visible; stroke-linejoin: round; stroke-linecap: round; }
.glyph .ln   { stroke: var(--ink); stroke-width: 1.5; fill: none; }
.glyph .fl   { fill: var(--panel); stroke: var(--ink); stroke-width: 1.5; }
.glyph .inkf { fill: var(--ink); stroke: none; }
.glyph .ac   { stroke: var(--accent); stroke-width: 1.5; fill: none; }
.glyph .acf  { fill: var(--accent); stroke: none; }
.bay:hover .fl { fill: var(--raised); }

/* the switch. A straight crossfade. Nothing moves. */
.fade-a { transition: opacity .22s var(--mech); }
.fade-b { opacity: 0; transition: opacity .22s var(--mech); }
.bay:hover .fade-a { opacity: 0; }
.bay:hover .fade-b { opacity: 1; }
/* the one exception: play/pause earns a scale punch */
.sw-a, .sw-b { transition: opacity .2s var(--mech), transform .24s var(--mech); transform-box: fill-box; transform-origin: center; }
.sw-b { opacity: 0; transform: scale(.8); }
.bay:hover .sw-a { opacity: 0; transform: scale(.8); }
.bay:hover .sw-b { opacity: 1; transform: none; }

/* ── APP PAGES ──────────────────────────────────────────────────────── */
/* These were copy-pasted into all nine pages and declared "always the same".
   They live here now. */
.topnav { padding: 34px 0 0; }
.back-link {
  display: inline-flex; align-items: center; gap: 9px;
  font-family: var(--mono); font-size: 10.5px; line-height: 16px; letter-spacing: .05em; text-transform: uppercase;
  color: var(--ink-2); transition: color .16s;
}
.back-link:hover { color: var(--accent); }
/* A back SIGN. It points, it does not perform. The old growing rule was one
   more thing moving on a page whose whole job is to sit still and work.

   The 12x16 box is the alignment trick: the box is exactly as tall as the line
   box beside it, and preserveAspectRatio centres the 24-square artwork inside
   it. Two flex items of identical height cannot misalign. */
.back-link svg {
  display: block; width: 16px; height: 16px; flex-shrink: 0;   /* 1px stroke */
  stroke: currentColor; stroke-width: 1.5; fill: none;
  stroke-linecap: round; stroke-linejoin: round;
  transform: translateY(var(--lift));       /* see --lift */
}

.page-head { padding: 44px 0 36px; position: relative; }
.page-title {
  font-family: var(--sans); font-variation-settings: 'wdth' 118, 'wght' 800;
  font-size: clamp(1.5rem, 3.4vw, 2.1rem); letter-spacing: -.03em;
  line-height: 1.1;
  line-height: round(1.1em, 1px);   /* fluid size, so snap the line box. See .wordmark. */
  text-transform: uppercase; cursor: default;
}
/* The hollow word fills with accent on hover. Same move as DEPOT in the
   wordmark, and for the same reason: the outline is the word waiting, and the
   fill is the word answering. */
.page-title em {
  font-style: normal; color: transparent; -webkit-text-stroke: 1.2px var(--ink);
  transition: color .35s var(--mech), -webkit-text-stroke-color .35s;
}
.page-title:hover em { color: var(--accent); -webkit-text-stroke-color: var(--accent); }
/* 13px description under a page title. NOT .card-desc. */
.page-desc { margin: 12px 0 0; font-size: 15px; line-height: 24px; color: var(--ink-2); max-width: 60ch; }

.privacy-note {
  display: inline-flex; align-items: center; gap: 8px; margin-top: 18px;
  font-family: var(--mono); font-size: 10px; line-height: 16px; letter-spacing: .05em; text-transform: uppercase;
  color: var(--ink-2); border: 1px solid var(--hair); background: var(--panel); padding: 6px 12px;
}
/* 16x16. Two reasons, and they are the same reason twice.
   The stroke: 1.5 in a 24 viewBox at 16px is exactly 1px. At 12px it was
   0.75px, which is why the shield looked soft and undefined.
   The box: 16px is exactly the line box of the label beside it, so the two flex
   items are the same height and align-items has nothing left to get wrong. */
.privacy-note svg {
  display: block; width: 16px; height: 16px; flex-shrink: 0;
  stroke: var(--accent); stroke-width: 1.5; fill: none;
  transform: translateY(var(--lift));       /* see --lift */
}

/* The generic content panel. This was called .card in the old stylesheet, the
   same name the rack tile uses, so an app page's panel would have
   inherited the notch, the flex column and the hover trace. Renamed. */
.panel {
  background: var(--panel); border: 1px solid var(--hair);
  padding: 24px; margin-bottom: 16px; position: relative;
}

/* ── THE DROP ZONE ──────────────────────────────────────────────────── */
/* Markup: .drop > four .dz + .glyph + .drop-label + optional .drop-sub + .btn
   Add .over on dragover. RD.drop() wires it.

   Not a dashed rectangle. A dashed border is the generic upload-widget cliché
   and it says nothing this system doesn't already say better: the brackets ARE
   the "put something here" mark, and they're the same brackets as the mark, the
   plate and the cards. A drop zone is a bay you put a file into. It gets the
   hairline and the notch, like everything else you put something into. */
.drop {
  position: relative; cursor: pointer; user-select: none;   /* no overflow:hidden.
     It was there to clip the old scan line, and it would clip the lamp's halo. */
  display: flex; flex-direction: column; align-items: center; text-align: center;
  gap: 10px; padding: 52px 24px;
  background: var(--panel); border: 1px solid var(--hair);
  transition: background .2s, border-color .2s;
}
/* .over is not a richer state than :hover. It IS :hover, guaranteed.
   That is the whole job of the class: during a native drag the pointer is
   holding a file, and whether :hover still applies is not something to bet on.
   .over makes the zone light up regardless. Same look, same message, reliably.

   There was a pulsing lamp here, and before that a scanner beam. Both were
   answers to a question nobody asked. The border going accent already says the
   only thing this state has to say, which is "yes, here, let go". */
.drop:hover, .drop.over { background: var(--raised); border-color: var(--accent); }

.dz { position: absolute; width: 14px; height: 14px; pointer-events: none;
  opacity: .3; transition: opacity .22s var(--mech), transform .3s var(--mech); }
.dz-tl { top: 12px; left: 12px;     border-top: 1px    solid var(--accent); border-left: 1px  solid var(--accent); transform: translate(5px,5px); }
.dz-tr { top: 12px; right: 12px;    border-top: 1px    solid var(--accent); border-right: 1px solid var(--accent); transform: translate(-5px,5px); }
.dz-bl { bottom: 12px; left: 12px;  border-bottom: 1px solid var(--accent); border-left: 1px  solid var(--accent); transform: translate(5px,-5px); }
.dz-br { bottom: 12px; right: 12px; border-bottom: 1px solid var(--accent); border-right: 1px solid var(--accent); transform: translate(-5px,-5px); }
.drop:hover .dz, .drop.over .dz { opacity: .9; transform: translate(0,0); }

.drop-label { font-family: var(--mono); font-size: 11px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink); }
.drop-sub { font-size: 13px; color: var(--ink-2); }

/* .glyph .fl is filled with --panel so the outline reads against the surface it
   sits on. The drop zone CHANGES that surface on hover, so the fill has to move
   with it or the document shows up as a darker silhouette of itself. */
.drop .glyph .fl { transition: fill .2s; }
.drop:hover .glyph .fl, .drop.over .glyph .fl { fill: var(--raised); }

/* ── PANEL HEAD ─────────────────────────────────────────────────────── */
/* Title on the left, buttons on the right. Every app has one. */
.panel-head {
  display: flex; align-items: center; justify-content: space-between;
  flex-wrap: wrap; gap: 12px; margin-bottom: 16px;
}
/* A head with no body under it. The 16px was holding a gap open for content
   that is not coming, which left the panel bottom-heavy. */
.panel-head:only-child { margin-bottom: 0; }
/* --ink, not --ink-2. This is a heading, and in these apps it usually carries
   the name of the file you just handed over, which is information. The muted
   tone is for text the heading is speaking FOR, not for the heading. */
.panel-title {
  font-family: var(--mono); font-size: 10.5px; line-height: 16px;
  letter-spacing: .05em; text-transform: uppercase; color: var(--ink);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 44ch;
}
.panel-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }

/* A label sitting BESIDE its control rather than above it. .field is the column
   the index uses. This is the same component, turned. */
.field--row { flex-direction: row; align-items: center; gap: 12px; margin-bottom: 16px; }

/* ── OUTPUT ─────────────────────────────────────────────────────────── */
/* Machine output. Reads as the readout does: mono, tabular, on --base rather
   than --panel, so it sits INSIDE the panel rather than floating on it. */
textarea {
  width: 100%; resize: vertical; min-height: 200px;
  background: var(--base); border: 1px solid var(--hair); padding: 16px;
  font-family: var(--mono); font-size: 12px; line-height: 20px;   /* INTEGER */
  color: var(--ink); white-space: pre-wrap; outline: none;        /* the ANSWER */
  transition: border-color .18s;
}
textarea:focus { border-color: var(--accent); }
/* The browser draws three grey diagonal scratches in the bottom-right corner of
   any resizable textarea. It is the one piece of furniture on this page that
   nobody designed, and it looks like damage. The corner still drags. */
textarea::-webkit-resizer { display: none; }

/* Counts, sizes, page totals. These are numbers a person actually reads, so
   --ink-2. --ink-3 is 2.7:1 and it would have hidden them. */
.meta {
  margin-top: 10px;
  font-family: var(--mono); font-size: 10px; line-height: 16px;
  letter-spacing: .05em; text-transform: uppercase; color: var(--ink-2);
}

/* The transient confirmation next to a button. "Copied", "Saved".

   --jade, not --accent. This was accent because accent was the only signal
   colour that was not danger, and that is not a reason. The word "Copied" is
   not something you can act on, it is something that has happened, and the
   accent has exactly one meaning on this site. Now that there is a colour for
   "done", this is what it is for.

   Changes the look of PDF Workshop and Photo Editor, and nothing else. */
.flash {
  font-family: var(--mono); font-size: 10.5px; line-height: 16px;
  letter-spacing: .05em; text-transform: uppercase; color: var(--jade);
  opacity: 0; transition: opacity .18s;
}
.flash.on { opacity: 1; }

/* A button that has just DONE the thing. It wears the confirmation for a beat
   and goes back. Same idea as .flash, on a control that is its own label,
   which is what a Share button is: there is nowhere beside it to put a word. */
.btn--done, .btn--done:hover:not(:disabled) {
  background: var(--jade-wash); border-color: var(--jade); color: var(--jade);
  transform: none;
}

/* ── PROGRESS ───────────────────────────────────────────────────────── */
/* scaleX, never width. A bar that animates width re-lays-out on every frame
   and lands on fractional pixels, which on a 1px track is the whole bar. */
.prog { margin-top: 22px; }
/* "Reading page 3 of 12". You are watching this line, so it is --ink. */
.prog-label {
  font-family: var(--mono); font-size: 10.5px; line-height: 16px;
  letter-spacing: .05em; text-transform: uppercase; color: var(--ink);
  margin-bottom: 9px;
}
.prog-track { height: 1px; background: var(--hair); }
.prog-bar {
  height: 100%; background: var(--accent);
  transform: scaleX(0); transform-origin: left;
  transition: transform .2s var(--mech);
}

/* ── ALERT ──────────────────────────────────────────────────────────── */
/* Something failed and the person needs to read a sentence about it. */
.alert {
  margin-top: 16px; padding: 12px 16px;
  font-size: 13px; line-height: 20px; color: var(--ink);
  background: var(--danger-wash); border: 1px solid var(--heart);
}

/* ── A TEXT INPUT ───────────────────────────────────────────────────── */
/* The only input the system had was the rack's search box, which is styled off
   its element selector. This is the general one. Mono, because everything you
   type into it in this app is a number. */
.input {
  width: 100%; background: var(--base); border: 1px solid var(--hair);
  padding: 9px 12px; color: var(--ink);
  font-family: var(--mono); font-size: 12px; line-height: 20px;   /* INTEGER */
  outline: none; transition: border-color .18s;
}
.input::placeholder { color: var(--ink-2); }
.input:focus { border-color: var(--accent); }

/* A dropdown. Same box as .input, because it is the same kind of thing: a
   field you fill. The native arrow is stripped and redrawn from two gradients
   rather than an SVG file, so the caret is a palette token and follows the
   theme instead of being a hardcoded colour baked into an image. It goes
   --accent on focus because at that moment it is the control you can act on.

   The OPEN list is drawn by the operating system and cannot be styled, here
   or anywhere else on the web. That is the accepted cost of a real select:
   keyboard support, type-ahead, and a native wheel on a phone, none of which
   a hand-built listbox gets for free. */
.select {
  width: 100%; background-color: var(--base); border: 1px solid var(--hair);
  padding: 9px 30px 9px 12px; color: var(--ink);
  font-family: var(--mono); font-size: 12px; line-height: 20px;   /* INTEGER */
  outline: none; appearance: none; cursor: pointer;
  transition: border-color .18s;
  background-image:
    linear-gradient(45deg,  transparent 50%, var(--ink-2) 50%),
    linear-gradient(135deg, var(--ink-2) 50%, transparent 50%);
  background-position: calc(100% - 16px) calc(50% - 1px), calc(100% - 12px) calc(50% - 1px);
  background-size: 4px 4px, 4px 4px;
  background-repeat: no-repeat;
}
.select:focus {
  border-color: var(--accent);
  background-image:
    linear-gradient(45deg,  transparent 50%, var(--accent) 50%),
    linear-gradient(135deg, var(--accent) 50%, transparent 50%);
}
.select option { background: var(--panel); color: var(--ink); }

/* ── AN ICON BUTTON ─────────────────────────────────────────────────── */
/* A control with no label inside it, so --lift does NOT apply. --lift corrects
   an icon standing BESIDE uppercase mono, whose ink sits above the centre of
   its line box. Here the glyph is centred in its own square and the square is
   what sits in the row, so there is nothing to correct. */
.icon-btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 34px; height: 34px; flex-shrink: 0;
  background: var(--panel); border: 1px solid var(--hair); color: var(--ink);
  transition: border-color .16s, background .16s, color .16s;
}
.icon-btn svg {
  width: 16px; height: 16px;                 /* 1.5 in a 24 box lands on 1px */
  stroke: currentColor; stroke-width: 1.5; fill: none;
  stroke-linecap: round; stroke-linejoin: round;
}
.icon-btn:hover:not(:disabled) { border-color: var(--accent); }
.icon-btn:disabled { color: var(--ink-3); cursor: not-allowed; }
.icon-btn--danger:hover:not(:disabled) { border-color: var(--heart); background: var(--danger-wash); }
/* An icon button that is a MODE, and is currently on. Held down, not pressed
   and released. */
.icon-btn[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); }
/* 24px box, same 16px glyph. For controls that sit on a page tile. */
.icon-btn--sm { width: 24px; height: 24px; }

/* ── A SLIDER ───────────────────────────────────────────────────────── */
/* The track is the same 1px hairline as .prog-track. The thumb is 9px TALL, an
   ODD number, so centring it on a 1px track is -(9 - 1) / 2 = -4px exactly. A
   10px thumb would want -4.5px and would land between two device rows.

   It is 5px WIDE, also odd. It was 9px square, which read as a block sitting on
   the track rather than a mark against it. */
.slider {
  -webkit-appearance: none; appearance: none; width: 100%; height: 16px;
  background: transparent; cursor: pointer;
  /* BLOCK. A range input is inline by default, so the box around it is as tall
     as a LINE, not as tall as the slider, and it carries the line's descender
     gap underneath. Anything positioned at 50% of that box, which is what the
     default's tick mark is, lands about two pixels below the track. */
  display: block;
}
.slider::-webkit-slider-runnable-track { height: 1px; background: var(--hair); }
.slider::-webkit-slider-thumb {
  -webkit-appearance: none; width: 5px; height: 9px; margin-top: -4px;
  background: var(--accent); border: 0;
}
.slider::-moz-range-track { height: 1px; background: var(--hair); }
.slider::-moz-range-thumb { width: 5px; height: 9px; background: var(--accent); border: 0; border-radius: 0; }

/* ── A CONTROL ──────────────────────────────────────────────────────────
   A slider with a name on the left and its value on the right. Photo Editor
   has twenty of these and the shell had none, which is the one thing the
   editor shell was missing. Every app with a number a person turns wants it.

     .ctl > .ctl-top ( .mono + input.ctl-val ) + .ctl-rail > input.slider

   The value is an INPUT, not a label you click to turn into an input. The old
   editor swapped one element for another on click, kept the two in sync by
   hand and needed a class for each. A number input that happens to be styled
   like a readout is the same thing with none of that. */
.ctl { margin-top: 12px; }
.ctl-top { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.ctl-top .mono { color: var(--ink-2); }

/* The value you have set. It is the answer to "how far", so it is --ink. */
.ctl-val {
  width: 6ch; flex-shrink: 0; padding: 0; text-align: right;
  background: transparent; border: 0; border-bottom: 1px solid transparent;
  color: var(--ink); font-family: var(--mono); font-size: 10.5px; line-height: 16px;
  font-variant-numeric: tabular-nums; outline: none;
  appearance: textfield; -moz-appearance: textfield;
  transition: border-color .16s;
}
/* The browser's spinner is two grey arrows nobody designed, and the slider is
   already the coarse control. This box is for typing an exact number into. */
.ctl-val::-webkit-outer-spin-button,
.ctl-val::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.ctl-val:hover { border-bottom-color: var(--hair); }
.ctl-val:focus { border-bottom-color: var(--accent); }

/* The default's tick. --at is the default as a fraction, 0 to 1, set once in the
   markup, so nothing recomputes it while you drag.

   A 5px thumb's CENTRE travels from 2.5px to (100% - 2.5px), not from edge to
   edge, so a plain percentage only lands right at the midpoint. The mark is 1px
   wide and its left edge is therefore half a pixel back from that centre, which
   is where the 2px comes from. */
.ctl-rail { position: relative; }
.ctl-rail .slider { position: relative; z-index: 1; }
.ctl-rail::before {
  content: ''; position: absolute; z-index: 0;
  top: 50%; margin-top: -3px; width: 1px; height: 6px;
  left: calc(2px + var(--at, .5) * (100% - 5px));
  background: var(--ink-3);        /* DECORATION. A tick mark, not a word. */
}

/* ── THE EDITOR SHELL ───────────────────────────────────────────────────
   Lifted out of Photo Editor and built here, against PDF Workshop, because a
   shell designed around one app and bent to fit the second is a shell shaped
   like the first app.

     .editor-bar    undo, redo, reset, what you are holding, and the way out
     .workspace     .rail | .stage | .tool-panel
     the export     a plain .panel with a .panel-head. It needed no component.

   THE THREE REGIONS OF .workspace HAVE NO BORDERS. The grid gap is 1px and the
   grid's own background is the hairline, so the rule between two regions IS the
   gap. Same trick .slot uses to draw the border on a rack tile. Give a region a
   border of its own and you get two lines where the design has one. */
.editor-bar {
  display: flex; align-items: center; justify-content: space-between;
  gap: 12px; flex-wrap: wrap;
  background: var(--panel); border: 1px solid var(--hair);
  padding: 10px 12px; margin-bottom: 16px;
}
.bar-group { display: flex; align-items: center; gap: 8px; min-width: 0; }
/* The name of the thing you are working on. You look here to answer "what am I
   holding", so it is the answer, so it is --ink. */
.file-tag {
  font-family: var(--mono); font-size: 10.5px; line-height: 16px;
  letter-spacing: .05em; color: var(--ink); margin-left: 6px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 32ch;
}
.file-sub {
  font-family: var(--mono); font-size: 10.5px; line-height: 16px;
  letter-spacing: .05em; text-transform: uppercase; color: var(--ink-2); white-space: nowrap;
}

.workspace {
  display: grid; grid-template-columns: 156px 1fr 280px;
  gap: 1px; background: var(--hair); border: 1px solid var(--hair);
  overflow: hidden;
  /* FIXED, and load-bearing. The right-hand panel is a different height for
     every tool. Without a fixed height here the stage would resize under your
     hands every time you picked a different tool. */
  height: 700px;
}

/* ── THE RAIL ───────────────────────────────────────────────────────── */
.rail { display: flex; flex-direction: column; gap: 2px; padding: 8px; background: var(--panel); overflow: auto; }
.rail-tool {
  position: relative; width: 100%;
  display: flex; flex-direction: column; align-items: center; gap: 8px;
  padding: 13px 6px; background: transparent; border: 0; text-align: center;
  color: var(--ink-2);
  font-family: var(--mono); font-size: 9.5px; line-height: 14px;   /* INTEGER */
  letter-spacing: .05em; text-transform: uppercase;
  transition: color .16s, background .16s;
}
/* The glyph sits ABOVE the label, not beside it, so --lift does not apply. */
.rail-tool:hover { color: var(--ink); background: var(--raised); }
.rail-tool.active { color: var(--ink); background: var(--raised); }
/* The selected rail tool is marked by a bar, and the bar SCALES. A bar that
   animates width would lay the rail out again on every frame. */
.rail-tool::before {
  content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 2px;
  background: var(--accent); transform: scaleX(0); transform-origin: left;
  transition: transform .2s var(--mech);
}
.rail-tool.active::before { transform: scaleX(1); }
/* .glyph .fl is filled with --panel so the outline reads against the surface it
   sits on. A rail tool CHANGES that surface, so the fill moves with it, or the
   document turns into a darker silhouette of itself. Same fault as the one the
   drop zone had. */
.rail-tool .glyph .fl { transition: fill .16s; }
.rail-tool:hover .glyph .fl, .rail-tool.active .glyph .fl { fill: var(--raised); }
/* An ACTION, not a mode. It opens a picker and comes straight back, so it never
   holds a selected state, and it does not get the bar that says one. That is the
   whole of the variant now.

   There was a hairline above it, dividing the actions from the tools. It said
   something true and it said it very loudly, about one button, and a rule that
   exists to explain a distinction you can already see when you press the thing
   is a rule that is doing the button's job for it. */
.rail-tool--act::before { content: none; }
.rail-tool.over { background: var(--raised); color: var(--ink); }

.rail-zoom {
  margin-top: auto; padding: 14px 8px 4px; border-top: 1px solid var(--hair);
  display: flex; flex-direction: column; gap: 6px;
}
.rail-zoom .mono { color: var(--ink-2); text-align: center; }

/* ── THE STAGE ──────────────────────────────────────────────────────── */
.stage { position: relative; background: var(--base); overflow: auto; }
/* A file is being dragged over the document. An outline, not a border, because
   an outline does not take part in layout and the stage must not move. */
.stage.over { outline: 1px solid var(--accent); outline-offset: -1px; background: var(--panel); }

/* A stage holding ONE thing rather than a list of them. It centres it, and it
   does NOT scroll: panning is a transform, and a transform on a scrollable box
   gives you scrollbars that chain up to the page the moment you reach the edge.
   That was the "the whole page scrolls when I drag the photo" fault. */
.stage--fit {
  display: flex; align-items: center; justify-content: center;
  padding: 24px; overflow: hidden; overscroll-behavior: contain;
}

/* Controls that belong to the STAGE rather than to a tool: compare, and pan.
   Squared off, on the surface, like every other button here. They were floating
   glass circles with a blur behind them, which is the one thing on the page
   that came from somewhere else. */
.stage-tools { position: absolute; z-index: 3; top: 12px; right: 12px; display: flex; gap: 8px; }

/* The image and every overlay drawn on it share ONE box, so a tool can place a
   handle in the image's own coordinates and never has to know where the image
   is sitting on the page. This is also the thing that pans, and it pans with a
   transform, which is why the stage above it cannot scroll. */
.frame { position: relative; flex-shrink: 0; }
.frame > canvas { display: block; width: 100%; height: 100%; background: var(--base); }
/* Fullscreen: the editor becomes a full-height column so the workspace can fill
   the screen. It overrides .workspace's fixed 700px (which exists only to stop
   the stage resizing as you switch tools; in fullscreen nothing competes for the
   height). The stage then gets a real height and layout() re-fits the photo. */
#editor:fullscreen { display: flex; flex-direction: column; height: 100vh; height: 100dvh; width: 100vw; padding: 12px; background: var(--base); }
#editor:fullscreen .editor-bar { margin-bottom: 12px; }
#editor:fullscreen .workspace { height: auto; flex: 1 1 auto; min-height: 0; }

/* ── THE CONTEXTUAL PANEL ───────────────────────────────────────────── */
.tool-panel { background: var(--panel); overflow: auto; padding: 18px; }
/* "Select a tool to start." A sentence somebody has to read, so --ink-2, never
   --ink-3. It also does not say "on the left", because below 880px it is not. */
.panel-empty { font-size: 13px; line-height: 20px; color: var(--ink-2); }
.tool-sec + .tool-sec { margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--hair); }
.tool-sec > .mono { display: block; color: var(--ink-2); margin-bottom: 10px; }
/* A grid, so the buttons stretch to the column and line up. Nothing is centred
   by eye. */
.tool-acts { display: grid; gap: 8px; margin-top: 12px; }
/* Six aspect ratios one under another is a column of six. Two up, they read as
   a set of choices, which is what they are. */
.tool-acts--2 { grid-template-columns: 1fr 1fr; }
/* A button whose whole face is an icon. It is a .btn, not an .icon-btn, so it
   stretches to its column like every other button in the panel.

   --lift is turned OFF. It corrects an icon standing BESIDE uppercase mono,
   whose ink sits above the centre of its line box, and there is no mono here
   for it to stand beside. */
.btn--icon { padding: 11px 18px; }
.btn--icon svg { transform: none; }

/* A number you can only read. Laid out exactly as a control's value is, name on
   the left and figure on the right, because that is what it is: a control's
   value with no control under it. */
.ctl-read {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 10px; margin-top: 12px;
}
.ctl-read .mono { color: var(--ink-2); }
.ctl-read span:last-child {
  font-family: var(--mono); font-size: 10.5px; line-height: 16px; font-weight: 400;
  letter-spacing: .05em; color: var(--ink); font-variant-numeric: tabular-nums;
}

/* ── A GRAPH ────────────────────────────────────────────────────────────
   The tone curve. Drawn on --base, like the textarea and the input, so it sits
   INSIDE the panel rather than floating on it. */
.graph {
  display: block; width: 100%; aspect-ratio: 1 / 1;
  background: var(--base); border: 1px solid var(--hair);
  touch-action: none; user-select: none;
}
.graph .grid { stroke: var(--hair); stroke-width: 1; }
.graph .hist { fill: var(--spot); stroke: none; }
/* The straight line the curve starts as, so you can see how far you have bent
   it. DECORATION, and it is dashed, so it is never mistaken for the curve. */
.graph .diag { stroke: var(--ink-3); stroke-width: 1; stroke-dasharray: 3 3; }
.graph .line { fill: none; stroke: var(--accent); stroke-width: 1.5; }
.graph .pt { fill: var(--base); stroke: var(--accent); stroke-width: 1.5; }
.graph .pt.on { fill: var(--accent); }
/* A 4.5px target is a misclick. The circle you aim at is twice the size of the
   circle you see, and it is invisible. */
.graph .hit { fill: transparent; stroke: none; cursor: pointer; }

/* ── SWATCHES ───────────────────────────────────────────────────────────
   A row of colours you PICK from. The colour of a swatch is the subject, not a
   decision: a red band has to be red. This is the one place on the site where a
   colour outside the three means nothing but itself. Squared off, like
   everything else, and chosen with the accent, like everything else. */
.swatches { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; margin-top: 10px; }
.swatch { height: 26px; padding: 0; border: 1px solid var(--hair); cursor: pointer; }
.swatch[aria-pressed="true"] { border-color: var(--accent); outline: 1px solid var(--accent); outline-offset: 2px; }

/* A button that WEARS a colour a person chose off their own photograph. The
   colour is the subject, so it goes on the button, which means the accent
   cannot also go on the button to say the button is selected. It says it with
   an outline instead, exactly as a swatch does, and for the same reason. */
.btn--tint[aria-pressed="true"] {
  background: var(--panel); border-color: var(--accent); color: var(--ink);
  outline: 1px solid var(--accent); outline-offset: 2px;
}
.btn--tint[aria-pressed="true"]:hover:not(:disabled) { background: var(--raised); border-color: var(--accent); }

/* ── A SAVED LIST ───────────────────────────────────────────────────────
   Rows a person has made and can come back to. The 1px gap IS the rule between
   them, the same trick .workspace uses, so no row carries a border of its own. */
.saved { display: grid; gap: 1px; background: var(--hair); border: 1px solid var(--hair); margin-top: 10px; }
.saved-row { display: flex; align-items: center; gap: 6px; background: var(--panel); padding: 4px 4px 4px 10px; }
.saved-name {
  flex: 1; min-width: 0; text-align: left; padding: 4px 0;
  background: transparent; border: 0; color: var(--ink);
  font-family: var(--mono); font-size: 10.5px; line-height: 16px; letter-spacing: .05em;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  transition: color .16s;
}
.saved-name:hover { color: var(--accent); }

/* ── ON-IMAGE CHROME ────────────────────────────────────────────────────
   Everything a tool draws ON TOP of the photograph. Three rules hold it together:

     1. --chrome, and --edge under it. Never the accent. See the token.

     2. Every line is 1px, and it carries its keyline OUTSIDE it and nothing
        inside it. A keyline on BOTH sides is three pixels of line where the
        design has one, and with two focus rings that is six.

     3. Nothing varies by tool. A brush is a brush and a ring is a ring, and a
        person who has picked Burn knows they picked Burn.

   The rings and the brush cursor are ROUND. That is not decoration and it is not
   a departure: they are the footprint of the tool, and the tool is round. */
.overlay { position: absolute; inset: 0; touch-action: none; user-select: none; }
.overlay--aim { cursor: none; }                  /* the cursor IS the tool here */
.overlay--pan { cursor: grab; }
.overlay--pan.grabbing { cursor: grabbing; }

/* What is about to be thrown away. Four of them, around the crop box. */
.shade { position: absolute; background: var(--scrim); pointer-events: none; }

/* The crop box. Its four thirds lines are drawn as backgrounds rather than as
   four more elements, so the box is one node and not five. */
.crop-box {
  position: absolute; border: 1px solid var(--chrome); cursor: move;
  box-shadow: 0 0 0 1px var(--edge);
  background:
    linear-gradient(var(--chrome-2), var(--chrome-2)) 33.333% 0 / 1px 100% no-repeat,
    linear-gradient(var(--chrome-2), var(--chrome-2)) 66.666% 0 / 1px 100% no-repeat,
    linear-gradient(var(--chrome-2), var(--chrome-2)) 0 33.333% / 100% 1px no-repeat,
    linear-gradient(var(--chrome-2), var(--chrome-2)) 0 66.666% / 100% 1px no-repeat;
}

/* One handle, used by the crop box and by the focus ring. 9px is ODD, so its
   centre lands on a whole pixel at -4px, exactly as the slider's thumb is
   centred on its track. */
.handle {
  position: absolute; z-index: 2; width: 9px; height: 9px;
  background: var(--chrome); border: 1px solid var(--edge);
}
.handle[data-h="nw"] { top: -4px; left: -4px;   cursor: nwse-resize; }
.handle[data-h="ne"] { top: -4px; right: -4px;  cursor: nesw-resize; }
.handle[data-h="sw"] { bottom: -4px; left: -4px;  cursor: nesw-resize; }
.handle[data-h="se"] { bottom: -4px; right: -4px; cursor: nwse-resize; }
.handle[data-h="n"] { top: -4px;    left: 50%; margin-left: -4px; cursor: ns-resize; }
.handle[data-h="s"] { bottom: -4px; left: 50%; margin-left: -4px; cursor: ns-resize; }
.handle[data-h="w"] { left: -4px;   top: 50%;  margin-top: -4px;  cursor: ew-resize; }
.handle[data-h="e"] { right: -4px;  top: 50%;  margin-top: -4px;  cursor: ew-resize; }

/* The focus rings. Both the same, because they are two edges of one thing. */
.ring {
  position: absolute; border-radius: 50%; cursor: move;
  border: 1px solid var(--chrome); box-shadow: 0 0 0 1px var(--edge);
}
/* The outer one is a READOUT, not a control. It shows where the effect reaches
   full strength, and it must not swallow a press aimed at the inner ring. */
.ring--out { pointer-events: none; }

/* The brush. The ring is the size of the brush, so it is an honest preview of
   where the paint will land. Every mode gets the same one. */
.brush-ring {
  position: absolute; pointer-events: none; border-radius: 50%;
  border: 1px solid var(--chrome); box-shadow: 0 0 0 1px var(--edge);
  transform: translate(-50%, -50%);
}

/* Where Stamp is copying FROM. The same ring as the brush, at the same size,
   because it is the same circle of pixels: the ones about to be lifted. Nothing
   about it is new. It is one line of CSS.

   And it is the ONE thing on the photograph that is --accent, which does not
   break the rule, it is the rule. Everything else drawn here describes where you
   are pointing. This describes something you PUT somewhere, and can pick up and
   put somewhere else. That is interaction, and interaction is what the accent
   is for. */
.brush-ring--source { border-color: var(--accent); }

/* The eyedropper. A ring on the pixel it will take, and a magnified grid beside
   it, because at a hundred percent a pixel is smaller than the pointer. */
.pick-ring {
  position: absolute; pointer-events: none; width: 13px; height: 13px;
  border-radius: 50%; border: 1px solid var(--chrome);
  box-shadow: 0 0 0 1px var(--edge);
  transform: translate(-50%, -50%);
}
.pick-mag {
  position: absolute; pointer-events: none;
  border: 1px solid var(--chrome); box-shadow: 0 0 0 1px var(--edge);
  image-rendering: pixelated;
}

/* ── PDF WORKSHOP. THE PAGE GRID ────────────────────────────────────────
   The stage of the shell, for a document that is a list of pages. --thumb is
   the zoom, and it is the ONE thing the slider turns.

   THE TRACK IS FIXED, NOT minmax(--thumb, 1fr). With 1fr the tracks STRETCH, so
   --thumb was only ever a minimum, and the tile you saw was whatever was left
   over after the columns had divided the stage between them. Moving the slider
   changed nothing at all until it crossed the width at which one more column
   fitted, and then everything jumped. Most of the ticks did nothing and the rest
   did too much, which is not a slider, it is a set of four buttons in disguise.

   A fixed track means the number on the slider IS the width of the page, every
   tick moves it, and the grid holds as many as fit. It does not fill the stage
   exactly, so it is centred, which is what a contact sheet does anyway. */
.pages {
  position: relative;                    /* the caret is placed against this */
  display: grid; grid-template-columns: repeat(auto-fill, var(--thumb, 130px));
  justify-content: center;
  gap: 12px; padding: 14px; align-items: start;
}
.page-tile {
  position: relative; background: var(--panel); border: 1px solid var(--hair);
  padding: 6px; cursor: grab; user-select: none;
  transition: border-color .16s, background .16s;
}
.page-tile:active { cursor: grabbing; }
.page-tile:hover { border-color: var(--ink-3); }
.page-tile[aria-selected="true"] { border-color: var(--accent); background: var(--pick); }
.page-tile.dragging { opacity: .35; }

/* WHERE IT LANDS, not what it is near. Highlighting the page under the cursor
   answers "which page", when the question you are actually asking is "which
   SIDE of that page".

   ONE element, owned by the grid, NOT a pseudo-element on a tile. Hanging it off
   the tile gave a line whose height was the tile's height, and tiles are not all
   the same height (a landscape page next to a portrait one), so the SAME gap drew
   a different line depending on which of its two neighbours the cursor happened
   to be nearer. It also vanished whenever the cursor sat in the gap itself, which
   belongs to no tile.

   The grid owns it, so the gap is inside the listener, and its height is the
   ROW's height, which both neighbours agree on. Placed with a transform, so
   dragging across the grid never lays it out again. */
.caret {
  position: absolute; top: 0; left: 0; width: 2px;
  background: var(--accent); pointer-events: none;
}

/* The box is sized from the page's own dimensions, turned by whatever rotation
   the page is carrying, so it is already the right shape before the thumbnail
   arrives and the grid does not jump when it does. --raised is the empty page
   waiting to be drawn. */
.page-view { position: relative; display: block; width: 100%; background: var(--raised); }
.page-view canvas { display: block; width: 100%; height: 100%; }

.page-foot { display: flex; align-items: center; justify-content: space-between; gap: 6px; height: 24px; margin-top: 6px; }
.page-num {
  font-family: var(--mono); font-size: 10px; line-height: 16px;   /* INTEGER */
  letter-spacing: .05em; color: var(--ink-2); font-variant-numeric: tabular-nums;
}
.page-tile[aria-selected="true"] .page-num { color: var(--accent); }
/* Rotate and remove, on the page itself. Hidden until you go near it, so that
   five hundred tiles are not five hundred sets of buttons. Shown always where
   there is no hover to go near it with, exactly as .pin is. */
.page-acts { display: flex; gap: 4px; opacity: 0; transition: opacity .16s; }
.page-tile:hover .page-acts, .page-tile:focus-within .page-acts { opacity: 1; }

/* ══ WORD GAMES ═════════════════════════════════════════════════════════
   Three games, one board.

   The board is the whole app, so it is the component, and everything else here
   hangs off it. A crossword is a board that is a GRID with blocks in it. A
   Wordbox guess is a board that is one ROW of one word. A Phrasebox guess is
   the same row with more than one word in it. That is the entire difference,
   which is why .sq is shared and there is no second set of squares.

   NOT .cell. .cell is the index readout, and it has been since the plate was
   built. A crossword square that called itself .cell would inherit a flex row
   with a border down its right hand side.

   THE FOUR STATES OF A GUESSED LETTER:

     exact   the letter is in the right box       a JADE FILL
     near    right letter, right word, wrong box  a JADE RING, jade letter
     far     right letter, but in another word    a VIOLET RING, violet letter
     spent   the letter is not in the answer      the square SINKS to --base

   THIS USED TO BE ONE HUE IN FOUR AMOUNTS, and the change is deliberate, so
   read this before putting it back.

   The old ladder ran jade fill, jade ring, jade letter alone, sunk. The
   argument for it was that quantity carries the order without being learnt and
   survives greyscale and every colour deficiency, which is true and is still
   the better property in the abstract. What it missed is that the bottom rung
   had no MARK on it at all. A lone jade letter in a square shaped exactly like
   an ungraded one does not read as a grade, it reads as a square nobody has
   touched, so in practice the fourth state was invisible rather than quiet.
   Players reported the board as confusing, twice.

   So far now wears the same ring near does, in the other colour, which is the
   distinction players expected to find and did not. The two states differ by
   hue where they used to differ by presence, and that is a REAL cost paid on
   purpose: see the exception recorded against --violet in the tokens.

   WHAT PAYS FOR IT IS THE LEGEND. The rating scale carries a scalar on a hue
   too, and the thing that keeps that honest is the rule that the grade is
   always written out in words beside the colour. The same rule now applies
   here. .wg-legend names all four states on the page, and it is not decoration
   and not a nicety: it is the only reason a colour-deficient player can read
   this board at all. If the legend ever comes off, this ladder is a bug.

   The hue is on the ring AND the letter, never the ring alone. A 1px line is
   the thinnest carrier on the site and hue is already the weakest channel; the
   letter is what gives the state enough area to be seen.

   Wordbox has one word, so its "in this word" and "in the answer" are the same
   thing and it never shows .far. It is untouched by this and stays a pure jade
   ladder over three states. Phrasebox shows all four. The meanings do not
   change between the two, which is the point of having one set.

   NO ACCENT ON A GRADED SQUARE, EVER. The accent is the cursor, in all three
   games, because the square you are typing into is the one thing on the board
   you can act on. Graded squares only exist on submitted rows and the cursor
   only exists on the live one, so they never meet. ═══════════════════════ */

/* The shell. Two panes or one, and the 1px gap between them IS the rule, so
   neither pane carries a border. Same trick as .workspace and .saved. */
.game { display: grid; gap: 1px; background: var(--hair); border: 1px solid var(--hair); }
.game--split { grid-template-columns: auto minmax(0, 1fr); }   /* board | clues */
.pane { background: var(--panel); padding: 20px; min-width: 0; }
.pane--board { display: flex; flex-direction: column; align-items: center; gap: 18px; }

/* ── THE BOARD ────────────────────────────────────────────────────────── */
/* --sq and --cols are set by the app off the DATA, because the size of the
   grid is a fact about the puzzle and not a decision about the design. The app
   reads --cols from the template file, so a 13x13 set and a 10x10 set both
   just work.

   THE LINES ARE BORDERS, NOT GAPS. A grid gap is a track the browser rounds on
   its own, and with the board centred on a fractional pixel each gap rounded
   differently, so some lines came out a pixel wider than others. Instead every
   cell draws its OWN top and left line and the board closes the right and bottom
   edge, so each line is one real 1px border the browser snaps the same way for
   every cell. Uniform at any width and any device pixel ratio. */
.board {
  display: grid; grid-template-columns: repeat(var(--cols), var(--sq));
  gap: 0;
  border-right: 1px solid var(--grid); border-bottom: 1px solid var(--grid);
  user-select: none; touch-action: manipulation;
}

/* A guess. .rows is the six of them, .row is one, .word is one word inside it.
   The gap between .word elements is the space between words in the saying, and
   the gap INSIDE one is the 1px hairline, so the reader can see at a glance
   where one word stops. Wordbox is this with a single .word in each row.

   .row wraps. A twenty four letter saying will not fit across a phone in one
   line at a size anybody can read, so a long guess takes two lines and stays
   one guess, because the words hold together and the gap between guesses is
   bigger than the gap inside one. */
.rows { display: flex; flex-direction: column; align-items: center; gap: 10px; }
.row  { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px 12px; }
/* THE ROW YOU ARE ON. Its cells lift to --hair while played and empty rows stay
   at --raised, so a board of six near-identical rows gains a focal one: the eye
   lands on where you are without a colour or a glow, just a step of contrast.
   Only UNGRADED cells lift: a graded cell (exact/near/far/spent) keeps its own
   background, so a winning row that is still briefly the active row does not get
   dragged back to --hair over its jade. .sq--live is the cursor, which is
   ungraded, so it still lifts. */
.row--live .sq:not(.sq--exact):not(.sq--near):not(.sq--far):not(.sq--spent) { background: var(--raised-2); }
.word { display: flex; gap: 0; border-right: 1px solid var(--grid); border-bottom: 1px solid var(--grid); }

/* ONE SQUARE. Every square on this page, in all three games.

   --hair, a step up from the board beneath it, so the cells LIFT off the grid
   and the lattice is crisp instead of a flat field. The gridlines are the board
   showing through the 1px gaps, and the board is --raised, one step darker, so
   the line is a real edge and not a guess.

   THE BORDER IS THE STATE, AND IT IS ALWAYS 1px. This is the fix for the
   uneven-line problem: a state used to add an inset box-shadow ON TOP of the
   gap-line, so a 'near' cell drew the gap PLUS its own ring and read thicker
   than its neighbour. Now every square owns a 1px inset border of its own, the
   colour of that border is what changes between states, and nothing stacks. The
   only exception is --live, which is deliberately 2px because the cursor is
   meant to sit proud of everything around it. */
/* ONE SQUARE. Every square on this page, in all three games.

   --raised, lifting off the darker page behind the board, with the letters near
   white on top. The GRID LINES are this cell's own top and left border, in
   --grid, so the lattice is uniform (see .board). STATE is a separate inset
   ring layered on top via box-shadow, so a graded or focused cell never changes
   the thickness of the lattice underneath it; it just tints a ring inside the
   cell. --live is the one 2px ring, because the cursor should sit proud of
   everything around it. */
.sq {
  position: relative; width: var(--sq); height: var(--sq);
  box-sizing: border-box;
  border-top: 1px solid var(--grid); border-left: 1px solid var(--grid);
  display: flex; align-items: center; justify-content: center;
  background: var(--hair); color: var(--ink);
  box-shadow: inset 0 0 0 1px transparent;   /* the STATE ring, off by default */
  font-family: var(--mono); font-size: calc(var(--sq) * .46);
  line-height: 1;                            /* INTEGER, and it sits on a hairline */
  text-transform: uppercase;
  transition: background .12s, color .12s, box-shadow .12s;
}
/* The crossword's blocks. The deepest surface on the site, so they read as
   holes in the board rather than as squares of a different colour. */
.sq--block { background: var(--base); }
/* A square you can put a letter in. It is the only thing here you can press. */
.sq--open { cursor: pointer; }

/* THE WORD YOU ARE IN, and THE SQUARE YOU ARE ON. A faint accent wash and then
   an accent ring, which is exactly what a selected page in the workshop grid
   is, because it is exactly the same statement: this is the thing you are
   acting on. The ring, not a fill, so the letter under it stays --ink.

   --live is the ONE 2px border on the board, because the cursor is the one
   thing that should sit above the lattice rather than in it. */
.sq--band { background: var(--band); }
.sq--live { background: var(--pick); box-shadow: inset 0 0 0 2px var(--accent); }

/* A letter you were GIVEN by Reveal rather than one you got. --heart, because a
   revealed square is the crossword's one real error state: you gave up on it,
   and it should read differently from a letter you earned. It also feeds the
   accuracy the same way a wrong letter does. */
.sq--given { color: var(--heart); }

/* The clue number. A number a person reads to find the clue, so --ink-2 and
   never --ink-3. */
.sq-num {
  position: absolute; top: 2px; left: 3px;
  font-size: calc(var(--sq) * .26); line-height: 1;
  color: var(--ink-2); pointer-events: none;
}

/* THE LADDER. See the top of this section for the full argument, including what
   this used to be and why it changed.

   Fill beats ring: exact is the strongest mark on the board because it is the
   strongest statement. near and far are the same ring in two hues, because they
   are the same KIND of answer about two different places, and a player who has
   been told which is which reads them at a glance.

   ALL of them live in the same 1px border box the base square already owns, so
   a graded cell is never a different thickness from an ungraded one. */
.sq--exact { background: var(--jade); color: var(--base); }
.sq--near  { box-shadow: inset 0 0 0 1px var(--jade);   color: var(--jade); }
.sq--far   { box-shadow: inset 0 0 0 1px var(--violet); color: var(--violet); }
/* SUNK. Not a grey fill: the surfaces on this site are close in luminance and a
   grey square next to a dark one is not a state, it is a rounding error. The
   square drops to the deepest surface there is and the letter drops with it,
   and BOTH move, which is what makes it read. Still legible, because you want to
   see what you have already spent. */
.sq--spent { background: var(--base); color: var(--ink-2); }

/* ── THE KEYBOARD ─────────────────────────────────────────────────────── */
/* Two jobs and they are the same job. On a phone it is how you type. On a
   desktop it is the record of what you have spent, which is the thing you
   actually came to look at. So it carries the SAME four states as the squares,
   each key showing the best it has managed, and there is nothing new to learn.

   QWERTY, not alphabetical. It is a keyboard. */
.keys { display: flex; flex-direction: column; align-items: center; gap: 6px; width: 100%; }
.krow { display: flex; justify-content: center; gap: 5px; width: 100%; }
.key {
  flex: 0 1 34px; min-width: 0; height: 46px; padding: 0;
  display: flex; align-items: center; justify-content: center;
  background: var(--hair); border: 1px solid var(--ink-3); color: var(--ink);
  font-family: var(--mono); font-size: 11px; line-height: 16px;
  letter-spacing: .05em; text-transform: uppercase;
  transition: background .12s, color .12s, border-color .12s, box-shadow .12s;
}
.key:hover:not(:disabled) { border-color: var(--accent); background: var(--raised); }
/* Enter and Backspace. Twice the word, so twice the key. Same colour as every
   other key; they are the two wide ones, and the app puts DEL on the left and
   ENTER on the right. */
.key--wide { flex: 0 1 74px; }
/* The same four states, on the same ladder. .far moved with the board and wears
   the same violet. If these two ever disagree the keyboard stops being a record
   of the board and becomes a second thing to learn. */
.key--exact { background: var(--jade); border-color: var(--jade); color: var(--base); }
.key--near  { border-color: var(--jade);   color: var(--jade); }
.key--far   { border-color: var(--violet); color: var(--violet); }
.key--spent { background: var(--base); color: var(--ink-3); border-color: var(--base); }

/* ── THE LEGEND ───────────────────────────────────────────────────────── */
/* The guess games graded and said so nowhere. The marks had to be inferred from
   a board that never explained itself, which is the likeliest reason Phrasebox
   was reported as confusing: not that two states looked alike, but that no
   state was ever named. It is also the condition --violet is allowed here on
   at all; see the tokens.

   The swatches are the real marks, not approximations of them. Each one is a
   .sq stripped to its box and wearing one grade, so the legend cannot drift out
   of step with the board by being drawn a second way. Prefixed, because the
   board's own class names predate the prefix rule and .legend is exactly the
   kind of bare name that collides silently later.

   ONLY THE STATES THAT NEED TEACHING. The sunk square is not in here: a letter
   that has gone dark has obviously been spent, and a legend that lists the
   obvious teaches the reader to skip it. Wordbox lists two, Phrasebox three.

   No swatch stands alone. The word beside it is the point of the component. */
.wg-legend { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px 20px; }
.wg-leg { display: flex; align-items: center; gap: 8px; }
.wg-key {
  width: 16px; height: 16px; flex-shrink: 0; box-sizing: border-box;
  border: 1px solid var(--grid); background: var(--hair);
}
.wg-key--exact { background: var(--jade); }
.wg-key--near  { box-shadow: inset 0 0 0 1px var(--jade); }
.wg-key--far   { box-shadow: inset 0 0 0 1px var(--violet); }
.wg-leg-t {
  font-family: var(--mono); font-size: 9.5px; line-height: 14px;
  letter-spacing: .05em; text-transform: uppercase; color: var(--ink-2);
}

/* An input that exists ONLY to raise a phone's keyboard over a board that is
   made of divs. The crossword needs one. Wordbox and Phrasebox have a keyboard
   of their own on the page, which is part of the game rather than a workaround.

   NOT .sr-only. A 1px clipped element is reliably reachable for a screen reader
   and unreliably focusable for iOS, which may decline to raise the keyboard for
   something it has decided is off screen, and may scroll the page to it if it
   does. Fixed, in the corner, at zero opacity, which is what already works.

   16px, because iOS zooms the whole page in when it focuses an input smaller
   than that, and the board goes off the side of the screen. */
.capture {
  position: fixed; top: 0; left: 0; width: 1px; height: 1px;
  opacity: 0; pointer-events: none; border: 0; outline: none; padding: 0;
  font-size: 16px;
}

/* ── THE CLUES ────────────────────────────────────────────────────────── */
.clues { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; height: 100%; min-height: 0; }
.clue-col { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
.clue-head {
  font-family: var(--mono); font-size: 10.5px; line-height: 16px;
  letter-spacing: .05em; text-transform: uppercase; color: var(--ink-2);
  padding-bottom: 8px; margin-bottom: 8px; border-bottom: 1px solid var(--hair);
}
.clue-list { overflow: auto; flex: 1; }
.clue {
  display: flex; gap: 10px; width: 100%; text-align: left;
  padding: 6px 8px; background: transparent; border: 0; cursor: pointer;
  transition: background .14s;
}
.clue:hover { background: var(--raised); }
/* The clue you are answering. The accent wash the current square uses, plus an
   accent left edge so the clue reads as firmly the current one rather than a
   faintly tinted row in a wall of grey. It is the list's echo of the cursor. */
.clue--on { background: var(--pick); box-shadow: inset 2px 0 0 0 var(--accent); }
.clue-num {
  flex-shrink: 0; min-width: 22px;
  font-family: var(--mono); font-size: 10.5px; line-height: 20px;   /* INTEGER */
  color: var(--ink-2); font-variant-numeric: tabular-nums;
}
.clue--on .clue-num { color: var(--accent); }
/* The clue itself is the ANSWER to "what am I looking for", so --ink-2 is the
   floor here and --ink is the one you are on. */
.clue-text { font-size: 13px; line-height: 20px; color: var(--ink-2); }
.clue--on .clue-text { color: var(--ink); }
/* Done. Struck through, and struck through IS the state, so the colour is only
   there to get out of the way of the ones that are left. */
.clue--done .clue-text { color: var(--ink-3); text-decoration: line-through; }

/* ── THE RESULT ───────────────────────────────────────────────────────── */
/* What you did today, in place, under the board. It was a fixed overlay with a
   sweeping gradient, a pulsing glow and a breathing title, all three of them
   looping forever, which an app on this site does not do. It is now a thing
   that appears where you are already looking. */
.result { border: 1px solid var(--jade); background: var(--jade-wash); padding: 20px 22px; margin-top: 16px; }
.result-title {
  font-family: var(--mono); font-size: 11px; line-height: 16px;
  letter-spacing: .12em; text-transform: uppercase; color: var(--jade);
}
.result--lost { border-color: var(--hair); background: var(--panel); }
.result--lost .result-title { color: var(--ink-2); }
.result-sub  { margin-top: 8px; font-size: 14px; line-height: 22px; color: var(--ink); }
.result-line { margin-top: 4px; font-size: 13px; line-height: 20px; color: var(--ink-2); }
.result-acts { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 16px; }

/* Your record. A number you come back for, so it is --ink, and it is tabular
   because it changes under itself. */
.tallies { display: flex; flex-wrap: wrap; gap: 0 24px; margin-top: 16px; }
.tally { display: flex; align-items: baseline; gap: 8px; }
.tally .k { font-family: var(--mono); font-size: 10.5px; line-height: 16px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-2); }
.tally .v { font-family: var(--mono); font-size: 11.5px; line-height: 16px; color: var(--ink); font-variant-numeric: tabular-nums; }

/* The crossword's solved tick. Sits under the board in the same pane, drawn like
   every other icon on the site: 1.5 stroke, no fill, jade for "done". Not a
   modal, not animated; it appears where you already are. */
.done-badge {
  display: flex; flex-direction: column; align-items: center; gap: 8px;
  margin-top: 4px; color: var(--jade);
}
.done-badge svg {
  width: 40px; height: 40px; fill: none; stroke: var(--jade);
  stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round;
}
.done-badge span {
  font-family: var(--mono); font-size: 10.5px; line-height: 16px;
  letter-spacing: .12em; text-transform: uppercase;
}

/* The dictionary credit. A small (i) in the action bar of the two guess games,
   with the attribution on hover or focus. Same idiom as everything else: mono,
   a dark panel, a hairline. */
.info-dot {
  position: relative; display: inline-flex; align-items: center; justify-content: center;
  width: 34px; height: 34px; cursor: help; color: var(--ink-3);
  transition: color .14s;
}
.info-dot:hover, .info-dot:focus-visible { color: var(--ink-2); outline: none; }
.info-dot svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.5; }
.info-dot svg circle:last-of-type { fill: currentColor; stroke: none; }
.info-pop {
  position: absolute; top: calc(100% + 8px); right: 0; z-index: 20;
  width: 260px; padding: 10px 12px;
  background: var(--panel); border: 1px solid var(--hair); color: var(--ink-2);
  font-family: var(--mono); font-size: 10.5px; line-height: 16px; letter-spacing: .02em;
  text-transform: none;
  opacity: 0; transform: translateY(-4px); pointer-events: none;
  transition: opacity .16s var(--mech), transform .16s var(--mech);
}
.info-dot:hover .info-pop, .info-dot:focus-visible .info-pop { opacity: 1; transform: none; }

/* ── THE VAULT (index) ──────────────────────────────────────────────── */
/* The readout at the top of the page is the privacy claim, performed: nothing
   of yours leaves this machine. The vault is the consequence of that claim.
   Nobody is holding a copy for you, so here is the handle. Same voice, real
   numbers, no fake telemetry. */
.vault {
  margin: 56px 0 0; border: 1px solid var(--hair); background: var(--panel);
}
.vault-top {
  display: flex; align-items: center; justify-content: space-between;
  gap: 20px; flex-wrap: wrap; padding: 16px 20px;
}
.vault-stats { display: flex; align-items: baseline; gap: 18px; flex-wrap: wrap; }
.vault-stats .k { font-family: var(--mono); font-size: 10.5px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-2); }
.vault-stats .v { font-family: var(--mono); font-size: 11.5px; color: var(--ink); font-variant-numeric: tabular-nums; }
.vault-stats .v.live { color: var(--accent); }
.vault-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.vault-actions .btn { min-width: 124px; }
.vault-note {
  border-top: 1px solid var(--hair); padding: 13px 20px;
  font-size: 13px; color: var(--ink-2);
}
.vault-note b { color: var(--ink); font-variation-settings: 'wght' 500; }

/* ── DIALOG. The restore manifest ───────────────────────────────────── */
dialog {
  border: 1px solid var(--hair); background: var(--panel); color: var(--ink);
  padding: 0; max-width: 480px; width: calc(100% - 40px); clip-path: var(--notch);
  /* A modal is centred by the browser with `margin: auto` against `inset: 0`.
     The reset on line 15 sets `margin: 0` on EVERYTHING, which quietly took that
     away, and every dialogue on the site has been pinned to the top of the
     screen ever since. Putting it back here fixes the vault's restore dialogue
     on the index as well. */
  margin: auto;
}
dialog::backdrop { background: var(--scrim); }
/* The notch is an INDEX mark. An app page is squared off, so a dialogue that
   appears on one loses it. */
dialog.plain { clip-path: none; }
.dlg { padding: 26px 26px 22px; }
.dlg h2 { font-size: 17px; font-variation-settings: 'wght' 600; letter-spacing: -.012em; margin-bottom: 6px; }
.dlg p { font-size: 14px; color: var(--ink-2); margin-bottom: 18px; }
.dlg-manifest { border: 1px solid var(--hair); background: var(--base); margin-bottom: 20px; }
.dlg-manifest div {
  display: flex; justify-content: space-between; gap: 16px; padding: 9px 14px;
  font-family: var(--mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em;
}
.dlg-manifest div + div { border-top: 1px solid var(--hair); }
.dlg-manifest .m-app { color: var(--ink); }
.dlg-manifest .m-n { color: var(--accent); }
.dlg-actions { display: flex; gap: 10px; justify-content: flex-end; }
.dlg-err { color: var(--heart); }
/* THE EDIT DIALOG. Reuses .dlg-manifest rows: the app name on the left, and on
   the right either its item count plus a remove control, or an inline confirm.
   A remove is permanent, so it asks once before it does anything. */
.m-row-right { display: inline-flex; align-items: center; gap: 12px; }
.m-confirm { display: inline-flex; align-items: center; gap: 8px; }
.m-confirm .mini {
  font-family: var(--mono); font-size: 9px; line-height: 1; letter-spacing: .05em; text-transform: uppercase;
  padding: 6px 10px; border: 1px solid var(--hair); background: var(--panel); color: var(--ink); cursor: pointer;
  transition: border-color .14s, background .14s, color .14s;
}
.m-confirm .mini:hover { border-color: var(--accent); }
.m-confirm .mini--danger { border-color: var(--heart); color: var(--heart); }
.m-confirm .mini--danger:hover { border-color: var(--heart); background: var(--danger-wash); }

/* ── FOOTER ─────────────────────────────────────────────────────────── */
footer { margin-top: 88px; }
/* .foot is a CHILD of .wrap, never the same element. Sharing one element meant
   .foot's `padding: 22px 0 50px` overrode .wrap's `padding: 0 28px` and the
   footer lost its side gutters.

   The hairline is on .foot, not on <footer>. On <footer> it ran the full width
   of the window while every other line on the page stops at the 1080px column,
   so it was the one rule on the site that did not line up with anything. */
.foot {
  display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 14px;
  border-top: 1px solid var(--hair);
  padding: 22px 0 50px; font-family: var(--mono); font-size: 10.5px; line-height: 16px;
  text-transform: uppercase; letter-spacing: .05em; color: var(--ink-2);
}
.foot nav { display: flex; gap: 24px; }
.foot a { transition: color .16s; }
.foot a:hover { color: var(--accent); }

/* ── THE EPUB READER ─────────────────────────────────────────────────────
   The overlay reader for apps/epub-reader.html, in named components so the
   page carries no <style> of its own.

   The chrome (bars, panels, buttons) is site-dark in BOTH reading themes; only
   the reading surface flips. That is deliberate: the accent keeps its contrast
   on a dark bar, and a dark frame around a light page is the familiar e-reader
   shape. Dark reading reuses the site's own --base/--ink; light reading flips
   them, an off-white page with near-black text, which is the one light surface
   on the site and needs no new colour to make.

   Nothing here loops. Panels open on one-shot --mech transitions, which the
   reduced-motion block at the foot of this file neutralises. The settings
   toggles are the site's .seg; only the numeric stepper is bespoke, since the
   system has no +/- control. */
/* In the page flow like every other app, not a takeover of the browser window.
   A bounded reading panel the fullscreen button expands to the whole screen;
   position:relative anchors the toc and settings panels to THIS box. */
.reader-panel {
  display: none; position: relative; overflow: hidden;
  flex-direction: column; height: min(78vh, 880px); min-height: 460px;
  border: 1px solid var(--hair);
  background: var(--read-bg); color: var(--read-text);
}
.reader-panel.active { display: flex; }
.reader-panel.theme-dark  { --read-bg: var(--base); --read-text: var(--ink); }
.reader-panel.theme-light { --read-bg: var(--ink);  --read-text: var(--base); }
/* Style = On renders each chapter on the book's own white page, so the surface
   behind the page is white too, not the off-white light ground; otherwise a
   pure-white page floats on a paler frame and reads as uneven. */
.reader-panel.style-on { --read-bg: #ffffff; --read-text: #171310; }
/* Fullscreen: fill the screen and drop the frame. */
.reader-panel:fullscreen { width: 100vw; height: 100vh; max-height: none; border: 0; }

.reader-topbar, .reader-bottombar {
  display: flex; align-items: center; gap: 14px;
  padding: 12px 18px; flex-shrink: 0; position: relative; z-index: 3;
  background: var(--panel);
}
.reader-topbar { border-bottom: 1px solid var(--hair); }
.reader-bottombar { border-top: 1px solid var(--hair); }

/* Native OS button chrome off, once, for every button in the reader. */
.reader-panel button { appearance: none; -webkit-appearance: none; font: inherit; }

.reader-icon-btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 34px; height: 34px; flex-shrink: 0; cursor: pointer;
  background: transparent; border: 1px solid transparent; color: var(--ink-2);
  transition: background .15s, color .15s, border-color .15s;
}
.reader-icon-btn:hover { color: var(--ink); background: var(--raised); border-color: var(--hair); }
.reader-icon-btn:disabled { opacity: .3; cursor: not-allowed; }
.reader-icon-btn:disabled:hover { color: var(--ink-2); background: transparent; border-color: transparent; }
.reader-icon-btn svg { width: 17px; height: 17px; stroke: currentColor; stroke-width: 1.7; fill: none; }
/* The panel this button opens is open: a pressed control, so the accent, the
   same on-state .seg and the toggles use. */
.reader-icon-btn.is-active { color: var(--base); background: var(--accent); border-color: var(--accent); }

.reader-title {
  flex: 1; text-align: center; font-family: var(--mono); font-size: 10.5px;
  letter-spacing: .08em; text-transform: uppercase; color: var(--ink-2);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 8px;
}
.reader-topbar-actions { display: flex; gap: 6px; flex-shrink: 0; }

.reader-viewport { flex: 1; position: relative; overflow: hidden; cursor: default; }
/* Host, sized to fill the viewport. Reading margins live on the shadow body
   itself (BASE_SHADOW_CSS), since padding on a positioned ancestor does not
   constrain an absolutely-positioned child. */
.reader-content { position: absolute; inset: 0; opacity: 1; }
/* Faint gutter down the middle of a two-page spread. currentColor is the
   reading ink, so it flips with the theme and reads on either page. */
.reader-viewport.spread::after {
  content: ''; position: absolute; top: 6%; bottom: 6%; left: 50%; width: 1px;
  background: currentColor; opacity: .12; pointer-events: none; z-index: 1;
}

/* Bottom-bar reading progress. --ink-2, not the accent: it is a readout of
   where you are, not something to act on. */
.reader-progress-wrap { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; min-width: 0; }
.reader-progress-track { width: 100%; max-width: 480px; height: 2px; background: var(--hair); overflow: hidden; }
.reader-progress-fill { height: 100%; width: 0%; background: var(--ink-2); transition: width .2s var(--mech); }
.reader-progress-label {
  font-family: var(--mono); font-size: 10px; letter-spacing: .06em;
  text-transform: uppercase; color: var(--ink-2); white-space: nowrap;
}

/* ToC panel, docks left. */
.reader-toc-panel {
  position: absolute; top: 0; bottom: 0; left: 0; width: min(320px, 82vw);
  background: var(--panel); border-right: 1px solid var(--hair); z-index: 12;
  display: none; flex-direction: column;
}
.reader-toc-panel.is-open { display: flex; }
.reader-toc-header {
  display: flex; align-items: center; justify-content: space-between;
  padding: 16px 18px; border-bottom: 1px solid var(--hair); flex-shrink: 0;
  font-family: var(--mono); font-size: 11px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-2);
}
.reader-toc-list { overflow-y: auto; padding: 8px 0; }
.toc-entry {
  display: block; width: 100%; text-align: left; background: transparent; border: none;
  padding: 9px 18px; font-family: var(--sans); font-size: 13px; color: var(--ink); cursor: pointer;
  transition: background .15s, color .15s; line-height: 1.4;
}
.toc-entry:hover { background: var(--raised); color: var(--ink); }
.toc-entry.active { color: var(--accent); }
.toc-empty { padding: 18px; font-size: 12px; color: var(--ink-2); }

/* Settings panel, docks right. Kept in flow so open/close is a one-shot fade. */
/* Settings panel, docks right. Plain show/hide: a fade that leaves the panel in
   the layout was invisible-but-clickable off, and the transform raced the open. */
.reader-settings-panel {
  position: absolute; top: 56px; right: 14px; width: 248px;
  background: var(--panel); border: 1px solid var(--hair); z-index: 12; padding: 16px;
  display: none; flex-direction: column; gap: 14px;
}
.reader-settings-panel.is-open { display: flex; }
/* Every row: label left, a fixed-width control right, so the controls line up in
   one column no matter how wide the label or the toggle text is. */
.set-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 30px; }
.set-label { font-family: var(--mono); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; color: var(--ink-2); white-space: nowrap; }
.set-ctl { width: 134px; height: 30px; flex-shrink: 0; }
.set-note { font-family: var(--sans); font-size: 11px; line-height: 1.5; color: var(--ink-2); margin: 2px 0 0; }
/* Two-button toggle sized to fill the fixed control cell, so Serif/Sans,
   Dark/Light and Off/On all present as one aligned column. Its own component
   rather than the page-level .seg, which is built to size to its label; here it
   must fill an exact 134px cell. Selected state is the accent, as .seg's is. */
.set-toggle { display: flex; width: 100%; height: 100%; border: 1px solid var(--hair); }
.set-toggle button {
  flex: 1 1 0; min-width: 0; padding: 0; border: 0; cursor: pointer;
  background: var(--panel); color: var(--ink-2);
  font-family: var(--mono); font-size: 10px; letter-spacing: .04em; text-transform: uppercase;
  display: flex; align-items: center; justify-content: center;
}
.set-toggle button + button { border-left: 1px solid var(--hair); }
.set-toggle button:hover:not([aria-pressed="true"]) { color: var(--ink); }
.set-toggle button[aria-pressed="true"] { background: var(--accent); color: var(--base); }

/* Font-size stepper. No site component for a numeric +/- control, so it is a
   named one; the buttons match .btn's hairline-to-accent hover. */
.reader-stepper { display: flex; align-items: stretch; gap: 6px; width: 100%; height: 100%; }
.reader-stepper button {
  width: 30px; height: 100%; border: 1px solid var(--hair); cursor: pointer;
  background: var(--panel); color: var(--ink); font-size: 15px; line-height: 1;
  display: flex; align-items: center; justify-content: center; flex-shrink: 0; padding: 0;
  transition: border-color .16s, color .16s;
}
.reader-stepper button:hover { border-color: var(--accent); color: var(--accent); }
.reader-stepper span { flex: 1; font-family: var(--mono); font-size: 13px; color: var(--ink); display: flex; align-items: center; justify-content: center; }

/* Loading. A plain label, no spinner: nothing here loops. */
.reader-loading {
  position: absolute; inset: 0; z-index: 8; display: none;
  align-items: center; justify-content: center;
  background: var(--read-bg); color: var(--ink-2);
  font-family: var(--mono); font-size: 11px; letter-spacing: .08em; text-transform: uppercase;
}
.reader-loading.active { display: flex; }

@media (max-width: 640px) {
  .reader-title { font-size: 10px; }
  .reader-settings-panel { right: 8px; left: 8px; width: auto; }
}

/* Landing: drop bay + the recently-read shelf. Hidden while a book is open. */
#landing[hidden] { display: none; }

/* The shelf: the last books this browser has opened, most recent first, with
   the place you stopped. The whole list is the memory we keep; the most recent
   is the one whose bytes are still cached, so only it resumes in one tap (the
   RESUME chip). Choosing any other re-picks the file and lands on the saved
   page. This is the durable rd:epub-reader:progress map made visible. */
.shelf { margin-top: 20px; }
.shelf[hidden] { display: none; }
.shelf-head { font-family: var(--mono); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; color: var(--ink-2); margin-bottom: 10px; }
.shelf-list { display: flex; flex-direction: column; gap: 1px; background: var(--hair); border: 1px solid var(--hair); }
.shelf-item {
  display: flex; align-items: center; gap: 12px; width: 100%; text-align: left; cursor: pointer;
  background: var(--panel); border: 0; padding: 13px 16px; color: var(--ink); transition: background .14s;
}
.shelf-item:hover { background: var(--raised); }
.shelf-item .title { flex: 1; min-width: 0; font-family: var(--sans); font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.shelf-item .where { font-family: var(--mono); font-size: 10px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-2); white-space: nowrap; flex-shrink: 0; }
.shelf-item .tag { font-family: var(--mono); font-size: 9px; letter-spacing: .06em; text-transform: uppercase; color: var(--base); background: var(--accent); padding: 2px 7px; flex-shrink: 0; }

/* The book glyph in the drop zone. Strokes inherit from the svg; the one filled
   shape (.fl) is the page body, which the shared .drop rules fade to --raised on
   hover so it does not read as a dark silhouette. */
.drop .glyph { width: 44px; height: 40px; stroke: var(--ink); stroke-width: 1.5; fill: none; stroke-linecap: round; stroke-linejoin: round; }
.drop .glyph .fl { fill: var(--panel); }

/* Parse error under the drop zone. Errors are not decoration, so --heart. */
.drop-error { display: none; margin-top: 14px; border: 1px solid var(--heart); background: var(--danger-wash); color: var(--heart); padding: 12px 16px; font-size: 13px; line-height: 20px; }

/* ── THE YOUTUBE FEED ─────────────────────────────────────────────────────
   Components for apps/youtube-feed.html, in named classes so the page carries
   no <style> of its own.

   This app adds NO new colour. The old page was a YouTube-red skin: a red top
   bar that swept across every card on hover, a red glow, a red play button, and
   a seven-colour palette that tinted each channel a different hue. All of that
   was decoration, and on this site the four signals each mean one thing and are
   never decorative. So the whole app is built from the ink ladder, and the
   signals appear only where they are true: --heart on a channel that failed to
   resolve, and the accent only on things you act on (a pressed filter, a hover
   on a card that is itself a link to the video).

   Nothing loops. The old "resolving" badge pulsed and the refresh button span,
   both forbidden here. Resolving is a plain mono label; refreshing disables the
   button and shows a counted text progress line, not a spinner. ────────────── */

/* THE MANAGER. A .panel whose head is a button that shows or hides the body.
   The show/hide is an instant `hidden` toggle, not an animated accordion: the
   site has no ambient motion, and an app you have already set up wants its
   manager out of the way without theatre. The caret is the one moving part, a
   one-shot rotate that reduced-motion drops to an instant flip. */
.yt-toggle {
  display: flex; align-items: center; gap: 12px; width: 100%; text-align: left;
  background: transparent; border: 0; padding: 0; margin-bottom: 16px;
  color: var(--ink);
}
/* Collapsed, the head is the panel's only visible content, so the 16px that
   spaces it from the body would just hang under it. aria-expanded carries the
   state (the body uses [hidden], so :only-child never matches). */
.yt-toggle[aria-expanded="false"] { margin-bottom: 0; }
/* Prefixed name on purpose: the bare .caret is a live global (the PDF page's
   drag-insertion bar, absolutely placed with an accent fill), and inheriting it
   turned this into a yellow square pinned to the corner that ate its own clicks.
   A disclosure chevron: points right when closed, down when open. */
.yt-toggle .yt-caret {
  width: 12px; height: 12px; flex-shrink: 0; color: var(--ink-2);
  stroke: currentColor; stroke-width: 1.5; fill: none; stroke-linecap: round; stroke-linejoin: round;
  transform: rotate(0deg); transition: transform .2s var(--mech);
}
.yt-toggle[aria-expanded="true"] .yt-caret { transform: rotate(90deg); }
.yt-toggle-label {
  flex: 1; font-family: var(--mono); font-size: 10.5px; line-height: 16px;
  letter-spacing: .05em; text-transform: uppercase;
}
/* The count is information, so an ink tone, not a signal. */
.yt-toggle-count { font-family: var(--mono); font-size: 10.5px; line-height: 16px; color: var(--ink-2); }
.yt-toggle:hover .yt-toggle-label { color: var(--ink); }
.yt-body[hidden] { display: none; }

/* THE API-KEY BAY. A framed sub-panel on --base so it sits inside the manager
   panel rather than floating on it, the same relationship .meta output has to a
   panel elsewhere. */
.yt-key { border: 1px solid var(--hair); background: var(--base); padding: 14px 16px; margin-bottom: 16px; }
.yt-key-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 12px; }
.yt-key-title { font-family: var(--mono); font-size: 10px; line-height: 16px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-2); }
/* A link out to Google, which is an action, so it may carry the accent. */
.yt-key-link { font-family: var(--mono); font-size: 10px; line-height: 16px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-2); transition: color .14s; }
.yt-key-link:hover { color: var(--accent); }
.yt-key-row { display: flex; gap: 8px; margin-bottom: 14px; }
.yt-key-row .input { flex: 1; min-width: 0; }
/* The numbered setup steps. Numbers in --ink-3 (decorative ordinals), the
   words in --ink-2 (they are read). */
.yt-steps { display: flex; flex-direction: column; gap: 6px; }
.yt-step { display: flex; gap: 10px; font-size: 13px; line-height: 20px; color: var(--ink-2); }
.yt-step-n { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); flex-shrink: 0; padding-top: 1px; }
.yt-step a { color: var(--accent); }
.yt-step b { color: var(--ink); font-variation-settings: 'wght' 500; }
/* The active-key row: a masked key, a "done" tick in --jade (it is confirmed,
   not something you act on), and a Change button. */
.yt-key-active { display: flex; align-items: center; gap: 10px; }
.yt-key-mask { flex: 1; min-width: 0; font-family: var(--mono); font-size: 11px; color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.yt-key-ok { font-family: var(--mono); font-size: 10px; line-height: 16px; letter-spacing: .05em; text-transform: uppercase; color: var(--jade); flex-shrink: 0; }

/* ADD A CHANNEL. Input plus button; the input goes disabled until a key is set. */
.yt-add { display: flex; gap: 8px; margin-bottom: 14px; }
.yt-add .input { flex: 1; min-width: 0; font-family: var(--sans); font-size: 14px; }
.yt-add .input:disabled { opacity: .4; cursor: not-allowed; }
.yt-add .input.is-error { border-color: var(--heart); }

/* THE CHANNEL LIST. Rows share the hairline-grid look of .shelf. */
.yt-list { display: flex; flex-direction: column; gap: 1px; background: var(--hair); border: 1px solid var(--hair); }
.yt-list:empty { display: none; }
.yt-empty { font-family: var(--mono); font-size: 10px; line-height: 16px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-2); text-align: center; padding: 16px 0; }
.yt-chan { display: flex; align-items: flex-start; gap: 12px; background: var(--panel); padding: 11px 14px; }
/* A failed channel is an error, so --heart, at rest, on the whole row edge. */
.yt-chan.is-error { box-shadow: inset 2px 0 0 0 var(--heart); }
/* The avatar. One ink-tone treatment, not seven hues. The initial shows until
   the real thumbnail loads over it. */
.yt-avatar {
  position: relative; width: 28px; height: 28px; flex-shrink: 0; overflow: hidden; margin-top: 1px;
  display: flex; align-items: center; justify-content: center;
  background: var(--raised); border: 1px solid var(--hair);
  font-family: var(--mono); font-size: 12px; text-transform: uppercase; color: var(--ink-2);
}
.yt-avatar img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.yt-chan-info { flex: 1; min-width: 0; }
.yt-chan-name { font-size: 14px; line-height: 20px; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.yt-chan-sub { font-family: var(--mono); font-size: 10px; line-height: 16px; color: var(--ink-2); margin-top: 1px; }
.yt-chan-err { font-size: 12px; line-height: 18px; color: var(--heart); margin-top: 3px; }
/* Centred against the row, not pinned to its first line: the row is two lines of
   text tall, and the tag and the remove button read as a pair sitting beside it.
   align-self centres the pair vertically in the row; align-items keeps the two of
   them centred against each other. */
.yt-chan-acts { display: flex; align-self: center; align-items: center; gap: 6px; flex-shrink: 0; }
/* A tag on a channel: mono, ink tones, a bordered chip. Not the accent (nothing
   here is being acted on) and not cyan (that is motion only). "Resolving" and
   "Playlist" are both just labels, so both look the same. Height matched to the
   small icon button beside it so the two chips line up. */
.yt-tag { display: inline-flex; align-items: center; height: 24px; box-sizing: border-box; font-family: var(--mono); font-size: 9px; line-height: 1; letter-spacing: .06em; text-transform: uppercase; color: var(--ink-2); border: 1px solid var(--hair); padding: 0 8px; white-space: nowrap; }

/* FEED CONTROLS. The filter is a .seg carrying .seg--solid (so it reads at the
   same weight as the buttons next to it); the shorts toggle is a .btn that holds
   its pressed state (a legitimate accent, it is a chosen option); refresh is a
   plain .btn. The refreshed-at label is information, so an ink tone. */
.yt-controls { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
.yt-controls-group { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.yt-refreshed { font-family: var(--mono); font-size: 10px; line-height: 16px; color: var(--ink-2); white-space: nowrap; }

/* A short honest note about what this app contacts. Quiet background reading, so
   the dimmest ink and no width cap: it fills the line and sits back rather than
   competing with the controls above it. Set off by a hairline on the left like
   an aside. */
.yt-note { font-size: 13px; line-height: 20px; color: var(--ink-3); border-left: 1px solid var(--hair); padding: 2px 0 2px 14px; margin-bottom: 20px; }

/* THE FEED STATE. One centred block that carries "nothing loaded", "loading",
   and "no videos". The counted progress line under it is a plain ink readout,
   never a bar. */
.yt-count { font-family: var(--mono); font-size: 10px; line-height: 16px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-2); margin-bottom: 16px; }
.yt-status { text-align: center; padding: 56px 20px; }
.yt-status-label { font-family: var(--mono); font-size: 11px; line-height: 16px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink); margin-bottom: 8px; }
.yt-status-sub { font-size: 13px; line-height: 20px; color: var(--ink-2); }
.yt-progress { font-family: var(--mono); font-size: 11px; line-height: 16px; letter-spacing: .05em; color: var(--ink-2); margin-top: 14px; }

/* THE VIDEO GRID. Intrinsically responsive: tiles are a min width and the grid
   fits as many columns as the width allows, so no per-width breakpoints. A card
   IS a link to the video, so its hover carries a restrained lift, not a red
   glow. The accent underline on the title is the same "you can act on this"
   mark the index cards use. */
.yt-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; }
.yt-card { display: flex; flex-direction: column; background: var(--panel); border: 1px solid var(--hair); transition: border-color .18s, background .18s; }
/* A card IS a link to the video, so hovering it is an interaction: the border
   takes the accent, the same "you can act on this" mark the rest of the site
   uses. */
.yt-card:hover { border-color: var(--accent); background: var(--raised); }
.yt-thumb { position: relative; aspect-ratio: 16 / 9; background: var(--base); overflow: hidden; flex-shrink: 0; }
.yt-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* The play mark. An accent-filled circle with a thin dark (--base) border so it
   stays visible on a pale thumbnail, and the triangle takes the same --base so it
   reads against the accent. Hidden until you hover the card, so the accent stays
   tied to the interaction and nothing sits on every tile at rest. The triangle is
   centred on its own centroid (which sits just left of the box centre), which is
   what reads as optically centred for a play glyph. Reduced-motion drops the fade
   to an instant show. */
.yt-play {
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
  width: 46px; height: 46px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  background: var(--accent); border: 1px solid var(--base);
  opacity: 0; transition: opacity .18s;
}
.yt-play svg { width: 20px; height: 20px; fill: var(--base); }
.yt-card:hover .yt-play { opacity: 1; }
.yt-thumb-badge { position: absolute; top: 7px; left: 7px; z-index: 1; font-family: var(--mono); font-size: 9px; line-height: 14px; letter-spacing: .06em; text-transform: uppercase; color: var(--ink); background: var(--scrim); border: 1px solid var(--hair); padding: 2px 7px; }
.yt-info { display: flex; flex-direction: column; gap: 5px; padding: 11px 13px 14px; flex: 1; }
.yt-vmeta { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
/* Channel name and age are both information: ink tones, not the old per-channel
   colour and not a signal. */
.yt-vchan { font-family: var(--mono); font-size: 10px; line-height: 16px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.yt-vage { font-family: var(--mono); font-size: 10px; line-height: 16px; color: var(--ink-2); flex-shrink: 0; }
.yt-vtitle { font-size: 13px; line-height: 19px; color: var(--ink-2); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color .18s; }
.yt-card:hover .yt-vtitle { color: var(--ink); }

/* ── THE PST / OST READER ─────────────────────────────────────────────────
   Components for apps/pst-reader.html, in named classes so the page carries
   no <style> of its own.

   The old page was a pre-system app. It ran on token names that no longer
   exist (--cyan, --surface, --border, --amber), wrapped itself in a starfield
   and a glitch title, and leaned on cyan for every hover and every selected
   row. Here cyan is --ice and means motion only, so none of that carries over.
   Selection is the one thing you have acted on in this view, so the active
   folder and the open message take the accent - as a single inset edge, never
   a fill. Hover only lifts the surface to --raised; it is not selection and
   never takes the accent. An error takes --heart. Everything else - counts,
   dates, labels, the diagnostics - is the ink ladder, because it is read.

   THE EMAIL PAPER IS A DELIBERATE EXCEPTION. HTML mail is authored for a white
   page; dropped onto this dark UI, the email's own dark text vanishes. So the
   message body alone sits on a light "paper" surface with dark ink, walled off
   to .pst-body-surface and its children. Those are literal light values on
   purpose - they are the email's world, not the site's, and they sit outside
   the four-signal palette by design. ──────────────────────────────────────── */

/* LOADING. Text and a counted detail line. The old build spun a 0.7s infinite
   ring here, which is exactly the ambient loop this site does not run. */
.pst-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; min-height: 420px; padding: 40px 0; }
.pst-loading-lbl { font-family: var(--mono); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--ink-2); }
.pst-loading-sub { font-family: var(--mono); font-size: 11px; color: var(--ink-3); }

/* THE THREE-PANE READER. Folder tree | message list | one open message. Once a
   file is open the body gets .pst-reading and the wrap widens for room. */
.pst-app { display: flex; flex-direction: row; height: 70vh; min-height: 480px; overflow: hidden; background: var(--panel); border: 1px solid var(--hair); }
body.pst-reading .wrap { max-width: 1480px; }
body.pst-reading .pst-app { height: 78vh; }
/* Full screen expands the reader itself (Fullscreen API), matching the book
   reader; drop the frame and fill the screen. */
.pst-app:fullscreen { width: 100vw; height: 100vh; max-height: none; border: 0; background: var(--base); }
body.pst-reading .pst-app:fullscreen { height: 100vh; }

.pst-pane { display: flex; flex-direction: column; overflow: hidden; width: 240px; flex-shrink: 0; border-right: 1px solid var(--hair); }
/* the message list is the second pane, and wants a little more room than the tree */
.pst-app > .pst-pane + .pst-pane { width: 300px; }
.pst-pane-hdr { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-shrink: 0; padding: 11px 12px 9px; border-bottom: 1px solid var(--hair); font-family: var(--mono); font-size: 9px; letter-spacing: .16em; text-transform: uppercase; color: var(--ink-3); }
.pst-pane-hdr > span:first-child { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pst-pane-name { flex-shrink: 1; overflow: hidden; text-overflow: ellipsis; font-family: var(--mono); font-size: 9px; letter-spacing: 0; text-transform: none; color: var(--ink-3); }
.pst-tree { flex: 1; overflow-y: auto; overscroll-behavior: contain; padding: 4px 0; }
.pst-pane-foot { flex-shrink: 0; padding: 6px 8px; border-top: 1px solid var(--hair); }
/* A right-side cluster in a pane header, and a square icon button for it. The
   button is a pressed control when its panel/mode is on, so it takes the accent
   at rest then - the same on-state the .seg and the reader's icon buttons use. */
.pst-pane-tools { display: flex; align-items: center; gap: 6px; }
.pst-icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; flex-shrink: 0; color: var(--ink-2); background: transparent; border: 1px solid var(--hair); cursor: pointer; transition: color .16s, border-color .16s, background .16s; }
.pst-icon-btn:hover { color: var(--ink); border-color: var(--accent); }
.pst-icon-btn[aria-pressed="true"] { color: var(--base); background: var(--accent); border-color: var(--accent); }
.pst-icon-btn svg { width: 15px; height: 15px; stroke: currentColor; stroke-width: 1.7; fill: none; stroke-linecap: round; stroke-linejoin: round; }

/* FOLDER ROWS. The selected row takes the accent as an inset left edge and
   lifts to --raised; hover only lifts. */
.pst-folder-row { display: flex; align-items: center; gap: 5px; margin: 1px 4px; padding: 5px 8px; cursor: pointer; white-space: nowrap; overflow: hidden; user-select: none; transition: background .12s; }
.pst-folder-row:hover { background: var(--raised); }
.pst-folder-row.pst-active { background: var(--raised); box-shadow: inset 2px 0 0 0 var(--accent); }
.pst-f-toggle { display: flex; align-items: center; justify-content: center; width: 14px; height: 14px; flex-shrink: 0; font-size: 9px; color: var(--ink-3); cursor: pointer; }
.pst-f-icon { display: flex; align-items: center; width: 15px; height: 15px; flex-shrink: 0; color: var(--ink-3); }
.pst-f-icon svg { width: 15px; height: 15px; stroke: currentColor; stroke-width: 1.5; fill: none; stroke-linejoin: round; }
.pst-f-name { flex: 1; overflow: hidden; text-overflow: ellipsis; font-size: 12px; color: var(--ink-2); }
.pst-folder-row.pst-active .pst-f-name { color: var(--ink); }
.pst-f-count { flex-shrink: 0; font-family: var(--mono); font-size: 10px; color: var(--ink-3); }

/* MESSAGE LIST. Same selection language as the folder tree. */
.pst-msg-list { flex: 1; overflow-y: auto; overscroll-behavior: contain; }
.pst-msg-row { padding: 10px 12px; cursor: pointer; overflow: hidden; border-bottom: 1px solid var(--hair); transition: background .12s; }
.pst-msg-row:hover { background: var(--raised); }
.pst-msg-row.pst-active { background: var(--raised); box-shadow: inset 2px 0 0 0 var(--accent); }
.pst-m-subject { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--ink); }
.pst-m-from { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 3px; font-size: 11px; color: var(--ink-2); }
.pst-m-date { margin-top: 2px; font-family: var(--mono); font-size: 10px; color: var(--ink-3); }

/* EMPTY / PLACEHOLDER STATES. All ink, all quiet. */
.pst-view { display: flex; flex-direction: column; flex: 1; min-width: 0; overflow: hidden; background: var(--base); }
.pst-empty { display: flex; align-items: center; justify-content: center; flex: 1; min-height: 200px; padding: 32px; text-align: center; font-family: var(--mono); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; color: var(--ink-3); }
.pst-pane-empty { padding: 20px 12px; text-align: center; font-family: var(--mono); font-size: 9px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-3); }
.pst-loading-row { padding: 10px 12px; font-family: var(--mono); font-size: 10px; letter-spacing: .06em; color: var(--ink-3); }

/* THE OPEN MESSAGE. */
.pst-msg-inner { display: flex; flex-direction: column; flex: 1; min-height: 0; padding: 28px 32px; }
/* Everything above the body stays pinned; only the body surface scrolls, so the
   reader shows one scrollbar however the page is served (blob iframes on file://
   are a different origin and cannot be measured, which is what produced the old
   double scrollbar). */
.pst-msg-head, .pst-msg-meta, .pst-att-bar, .pst-remote-bar, .pst-body-sep { flex-shrink: 0; }
.pst-msg-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 16px; }
.pst-msg-subj { flex: 1; font-size: 18px; line-height: 1.35; color: var(--ink); word-break: break-word; }
.pst-msg-meta { padding: 12px 16px; margin-bottom: 20px; background: var(--panel); border: 1px solid var(--hair); }
.pst-meta-row { display: flex; gap: 10px; margin-bottom: 5px; font-size: 12px; }
.pst-meta-row:last-child { margin-bottom: 0; }
.pst-meta-lbl { flex-shrink: 0; width: 46px; padding-top: 1px; font-family: var(--mono); font-size: 9px; letter-spacing: .12em; text-transform: uppercase; color: var(--ink-3); }
.pst-meta-val { color: var(--ink-2); overflow-wrap: anywhere; }
.pst-body-sep { border: 0; border-top: 1px solid var(--hair); margin: 0 0 20px; }

/* EMAIL PAPER (see the exception note at the top). Literal light values on
   purpose: this is the email's surface, not the site's. */
.pst-body-surface { display: flex; flex: 1 1 auto; min-height: 0; overflow: hidden; background: #eef1f6; border: 1px solid #d7deea; }
.pst-frame { flex: 1; min-height: 0; width: 100%; border: 0; background: transparent; color-scheme: light; overscroll-behavior: contain; }
.pst-body-text { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 22px 24px; max-width: 80ch; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 13px; line-height: 1.7; color: #1a2536; }
.pst-body-text a { color: #0e6f87; }
.pst-nobody { padding: 22px 24px; font-size: 12px; font-style: italic; color: #5a6b85; }

/* ATTACHMENTS. Each chip is a download button, so it takes the accent on hover
   the way every other button on the site does. */
.pst-att-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 14px 0; padding: 10px 12px; background: var(--panel); border: 1px solid var(--hair); }
.pst-att-hdr { margin-right: 4px; font-family: var(--mono); font-size: 9px; letter-spacing: .12em; text-transform: uppercase; color: var(--ink-3); }
.pst-att-chip { display: inline-flex; align-items: center; gap: 8px; max-width: 300px; padding: 5px 12px; font-size: 12px; color: var(--ink-2); background: transparent; border: 1px solid var(--hair); cursor: pointer; transition: color .16s, border-color .16s; }
.pst-att-chip:hover { color: var(--ink); border-color: var(--accent); }
.pst-att-size { font-family: var(--mono); font-size: 10px; color: var(--ink-3); font-variant-numeric: tabular-nums; }

/* REMOTE-IMAGE BAR. Not an error and not itself the action, so it is ink, not a
   signal; the Load-images button beside it is the action. Loading remote images
   is the one thing in this app that reaches the network - it happens inside the
   sandboxed message iframe under that iframe's own policy, while the page around
   it stays connect-src 'none'. */
.pst-remote-bar { display: flex; align-items: center; gap: 12px; margin: 12px 0; padding: 9px 14px; font-size: 12px; line-height: 1.5; color: var(--ink-2); background: var(--raised); border: 1px solid var(--hair); }
.pst-remote-bar span { flex: 1; }

/* ERROR NOTICE. --heart, because it is one. */
.pst-notice { margin: 10px; padding: 12px 14px; font-size: 12px; line-height: 1.6; }
.pst-notice--err { color: var(--heart); background: var(--danger-wash); border: 1px solid var(--heart); }

/* SMALL TEXT BUTTONS (Export, Diagnostics, Save .eml, Load images, the diag
   actions). Quiet mono chips; the accent arrives on hover, since each is a
   thing you click. */
.pst-lite-btn { padding: 5px 10px; font-family: var(--mono); font-size: 9px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-2); background: transparent; border: 1px solid var(--hair); cursor: pointer; transition: color .16s, border-color .16s; }
.pst-lite-btn:hover { color: var(--ink); border-color: var(--accent); }
.pst-lite-btn--wide { width: 100%; }

/* DIAGNOSTICS. A developer panel pinned to the bottom; muted throughout, its
   title an ink label rather than a signal. */
.pst-diag { position: fixed; left: 0; right: 0; bottom: 0; z-index: 100; max-height: 45vh; overflow: auto; padding: 10px 14px; background: var(--base); border-top: 1px solid var(--hair); font-family: var(--mono); font-size: 11px; color: var(--ink-2); }
.pst-diag-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 6px; margin-bottom: 8px; }
.pst-diag-title { font-family: var(--mono); font-size: 9px; font-weight: 400; letter-spacing: .16em; text-transform: uppercase; color: var(--ink-3); }
.pst-diag-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.pst-diag-close { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; color: var(--ink-3); background: transparent; border: 1px solid var(--hair); cursor: pointer; transition: color .16s, border-color .16s; }
.pst-diag-close:hover { color: var(--ink); border-color: var(--accent); }
.pst-diag-close svg { width: 12px; height: 12px; stroke: currentColor; stroke-width: 2; fill: none; stroke-linecap: round; }
.pst-diag-out { margin: 0; white-space: pre-wrap; word-break: break-all; font-size: 11px; color: var(--ink-2); }

/* NARROW: the three panes stack, each capped so the open message still shows. */
@media (max-width: 900px) {
  .pst-app { flex-direction: column; height: auto; min-height: 0; }
  body.pst-reading .pst-app { height: auto; }
  .pst-app > .pst-pane { width: 100%; max-height: 220px; border-right: 0; border-bottom: 1px solid var(--hair); }
  .pst-app > .pst-pane + .pst-pane { width: 100%; }
  .pst-view { min-height: 360px; }
}

/* ══ PRACTICE RANGE ══════════════════════════════════════════════════════
   Three drills, two shells. Typing gets a block of words; Reaction and
   Accuracy share one arena, because they are the same box with different
   rules in it. Same reasoning as Word Games, where one board serves a
   crossword and two guessing games.

   WHERE THE COLOUR GOES, AND WHY IT IS NOT ALL THE SAME KIND.

   The four signals do their usual job here and nothing more:

     --accent   you can act on this. The target, the pressed control, and the
                flash that means CLICK NOW.
     --ice      motion. UNUSED here, deliberately: see the caret's note.
     --jade     right. A hit.
     --heart    wrong. A mistyped character, a missed target, a false start,
                and the last five seconds of the clock.

   THE RATING SCALE is separate, and deliberately so. See its note in the
   tokens. A reaction time is not a signal, it is a grade, and it uses the
   five-step scale rather than the four signals:

     Elite     under 190ms    --violet
     Sharp     under 220ms    --jade
     Average   under 250ms    --ink
     Slow      under 300ms    --amber
     Sluggish  over           --heart

   The thresholds are the legacy ones to the millisecond. The grade is always
   written out beside the colour, never carried by the colour alone.

   THE FIVE RUNGS, ON ALL THREE DRILLS. Reaction is graded on milliseconds,
   typing on words per minute, accuracy on the hit rate. The names do not
   change between them, so a grade means the same thing wherever you meet it.

     Elite     under 190ms    over 120wpm    over 95%
     Sharp     under 220ms    over  90wpm    over 85%
     Average   under 250ms    over  60wpm    over 75%
     Slow      under 300ms    over  30wpm    over 65%
     Sluggish  over           under 30wpm    under 65%

   EASY MODE IS CAPPED AT AVERAGE. The targets are half again as large and sit
   for two and a half times as long, so a hit rate on easy is not the same
   measurement as a hit rate on hard, and a scale that handed out Elite for
   both would be quietly lying about the harder one. On easy the ladder starts
   at Average and runs down. If you want the top two rungs, they are on hard,
   which is the point of having two.

   A CORRECT CHARACTER IS NOT GRADED, so it is not jade and it is not on the
   scale. It is simply the text, in --ink, and the only mark in the whole
   block is the one on a character you got wrong. This is what the legacy
   typing test did and it was right: in a wall of two hundred words, marking
   the errors is information and marking the successes is noise.

   NOTHING HERE LOOPS, and this is the one place the port refuses the legacy
   behaviour outright. The old page ran a shimmer across the stats bar, a
   blinking caret, a blinking clock, a pulsing wait-ring and three radar rings
   behind an elite score, every one of them `infinite`. The rings are back and
   the glow is back, but they arrive once, in response to something you did,
   and then they are gone. The site's global prefers-reduced-motion rule
   removes all of it without leaving a half-drawn state behind. ═══════════ */

/* ── THE READOUT ──────────────────────────────────────────────────────── */
/* .tally, borrowed off the record block under the board, because a live WPM
   and a stored best are the same thing twice: a name and a number you read.

   ONE READOUT, IN ONE PLACE, ON ALL THREE DRILLS: pinned to the top inside the
   play box, three metrics pushed to the corners, with the round's progress bar
   as a hairline directly above it. Typing used to carry its own version of
   this OUTSIDE and above the box, which made the typing test a different shape
   from the other two for no reason anybody could have named. */
.rng-hud {
  position: absolute; top: 0; left: 0; right: 0; z-index: 5;
  margin: 0; padding: 16px 18px; gap: 0 12px;
  justify-content: space-between; pointer-events: none;
}
.rng-hud .v { font-size: 15px; }
/* The last five seconds. --heart, and STILL. The old one blinked at 0.5s
   forever, which is an idle loop wearing a deadline as an excuse. */
.rng-low { color: var(--heart); }

/* ── THE WORDS ────────────────────────────────────────────────────────── */
/* Five lines, at a size you can read without leaning in, in a column that
   stops well short of the window. Full-bleed text on a wide monitor is a
   forty-word line, and a typing test is the one place where the eye has to
   find the NEXT word instantly. The block travels under a fixed window rather
   than the window scrolling, so the line you are on stays where it was for
   the whole run.

   THE MASK FADES THE BOTTOM EDGE ONLY. It used to fade both, which meant the
   first line of a fresh run was half-faded before you had typed a character,
   and the thing you were about to read looked like the thing you had
   finished. Below the current line there is genuinely more text running off
   the edge, so that edge is soft. Above it there is not. */
/* THE SAME BOX AS THE ARENA. Same width, same height, same --base surface,
   same hairline, the same readout pinned inside the top of it. The three
   drills are one app, and the only thing that should differ between them is
   what is happening in the middle of the box.

   .rng-words is the box; .rng-view is the window the text travels under. They
   have to be two elements: the readout is pinned to the box and must not
   scroll, and the text has to be clipped by something that starts BELOW the
   readout rather than behind it. */
.rng-words {
  position: relative; width: 100%; height: 460px; overflow: hidden;
  background: var(--base); border: 1px solid var(--hair);
}
.rng-view {
  position: absolute; left: 0; right: 0; top: 62px; bottom: 0;
  overflow: hidden; padding: 6px 26px 0;
}
/* The bottom fade is a gradient overlay rather than a mask on the box, because
   a mask fades the BORDER out along with the text and left the box looking
   unfinished along its bottom edge. */
.rng-view::after {
  content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 110px;
  background: linear-gradient(to bottom, rgb(12 16 19 / 0), var(--base));
  pointer-events: none; z-index: 2;
}
/* MONO, and not the sans. Every glyph is the same width, so the caret sits on
   a fixed grid and a mistyped letter does not shove the rest of the line
   sideways as you correct it. */
.rng-line {
  font-family: var(--mono); font-size: 26px; line-height: 68px;  /* INTEGER: the scroll steps by whole lines */
  color: var(--ink-2); padding: 0 4px; user-select: none;
  transition: transform .3s var(--mech);
}
.rng-word { display: inline; }
.rng-char { display: inline; position: relative; }
/* Typed and right: the text, plainly. Typed and wrong: the one mark. */
.rng-ok  { color: var(--ink); }
.rng-bad {
  color: var(--heart); text-decoration: line-through;
  text-decoration-thickness: 2px; text-decoration-color: var(--heart);
}
/* THE CARET. --accent. It was --ice on the argument that the caret is the one
   thing on the page that moves, which is true and was the wrong reading: the
   caret is where your next keystroke LANDS, and "this is the thing you are
   acting on" is what the accent has always meant, on every page here. It is
   also the same colour the cursor wears on a Word Games board, which is the
   same statement about the same kind of object.

   --ice therefore appears nowhere in this app. That is the correct outcome
   for a token reserved for motion in a page that no longer has any idle
   motion in it, and better than spending it to avoid an empty slot.

   A box-shadow rather than a border, so it cannot shift the layout of the
   line it sits in. It does not blink. It does not need to: it moves every
   time you press a key, which is more than any blink ever told you. */
.rng-at { box-shadow: -3px 0 0 0 var(--accent); }

.rng-hint {
  font-family: var(--mono); font-size: 10.5px; line-height: 18px;
  letter-spacing: .12em; text-transform: uppercase; color: var(--ink-2);
  text-align: center; min-height: 18px; margin: 0;
}

/* ── THE ARENA ────────────────────────────────────────────────────────── */
/* --base, not --panel. It is a hole in the page you shoot into, so it is the
   deepest surface here, the same way the crossword's blocks are. */
.rng-arena {
  position: relative; width: 100%; height: 460px; overflow: hidden;
  background: var(--base); border: 1px solid var(--hair);
  touch-action: none; user-select: none;
  transition: background .5s var(--mech), border-color .5s var(--mech), box-shadow .5s var(--mech);
}
/* The phase says whether the box is a control. Every one of these is either
   "you may click this" or "you may not", and the cursor is where that is said. */
.rng-idle      { cursor: pointer; }
.rng-countdown { cursor: default; }
.rng-waiting   { cursor: pointer; }
/* THE SIGNAL. A whole-arena accent flood, because the accent means you can act
   on this and there has never been a truer instance of it on this site: the
   entire box is, for as long as it is lit, one button. No transition on the
   way IN: the millisecond you are measuring starts here, so the light has to
   be instant or the number is a lie about the screen rather than about you. */
.rng-fired {
  cursor: pointer; background: var(--accent); border-color: var(--accent);
  transition: none;
}
.rng-early     { cursor: default; border-color: var(--heart); background: var(--danger-wash); }
.rng-result    { cursor: default; }
.rng-active    { cursor: crosshair; }
.rng-over      { cursor: default; }

/* ── THE GRADE ────────────────────────────────────────────────────────────
   FIVE CLASSES THAT SET TWO VARIABLES AND NOTHING ELSE. Everything that wears
   a grade - the box, the number, the tier chip, the Go again button, a score
   in the history strip - reads --grade and --grade-rgb rather than naming a
   colour. That is what makes a grade a scale rather than five hard-coded
   looks: the whole result changes colour together because there is only one
   place the colour is written down, and a sixth rung would be one more line.

   It is also what lets the count-up TRAVEL. The app swaps rng-gN as the
   number climbs, and because every part reads the same variable, the box, the
   digits, the chip and the button all move down the scale together. */
.rng-g1 { --grade: var(--violet); --grade-rgb: var(--violet-rgb); }
.rng-g2 { --grade: var(--jade);   --grade-rgb: var(--jade-rgb); }
.rng-g3 { --grade: var(--ink);    --grade-rgb: var(--ink-rgb); }
.rng-g4 { --grade: var(--amber);  --grade-rgb: var(--amber-rgb); }
.rng-g5 { --grade: var(--heart);  --grade-rgb: var(--heart-rgb); }

/* Applied alongside rng-gN to the thing being graded. A wash and a glow, not
   a fill, so the number sitting on top stays readable at every rung. */
.rng-graded {
  background: rgb(var(--grade-rgb) / .1);
  border-color: var(--grade);
  box-shadow: 0 0 90px rgb(var(--grade-rgb) / .26), inset 0 0 120px rgb(var(--grade-rgb) / .09);
}
/* Average is the middle of the scale and it is --ink, so the glow would be a
   white halo announcing a result that is, by definition, unremarkable. */
.rng-graded.rng-g3 { background: rgb(var(--ink-rgb) / .03); box-shadow: none; border-color: var(--ink-3); }

.rng-graded .rng-big  { color: var(--grade); }
.rng-graded .rng-tier { color: var(--grade); }
/* THE BUTTON WEARS THE GRADE, and it is an outline. It was --panel, which
   against a washed and glowing box read as a black hole punched in the
   result. An outline in the grade colour belongs to the result instead of
   interrupting it, and it is still obviously a button. */
.rng-graded .rng-again,
.rng-graded .rng-again:hover:not(:disabled) {
  background: transparent; border-color: var(--grade); color: var(--grade);
}

/* The round clock, on the top edge. .prog-track and .prog-bar off the shared
   system; the transition comes off because this one is driven a frame at a
   time and a tween would lag behind its own value. */
.rng-clock { position: absolute; top: 0; left: 0; right: 0; z-index: 6; }
.rng-clock .prog-bar { transition: none; }

/* What the arena has to say, in the middle of it. Nothing in here takes a
   click except the button, so the block itself is transparent to the pointer:
   the arena underneath is the control. */
.rng-mid {
  position: absolute; inset: 0; z-index: 4;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 10px; padding: 24px; text-align: center; pointer-events: none;
}
.rng-label {
  font-family: var(--mono); font-size: 10.5px; line-height: 16px;
  letter-spacing: .12em; text-transform: uppercase; color: var(--ink-2);
}
/* The answer, so --ink, and tabular because it changes under itself. */
.rng-big {
  font-family: var(--mono); font-size: 46px; line-height: 56px;
  color: var(--ink); font-variant-numeric: tabular-nums;
}
/* The one that is a TITLE rather than a number. Uppercase, because it is an
   instruction and every instruction on this site is set this way. */
.rng-big--cap { font-size: 28px; line-height: 36px; letter-spacing: .1em; text-transform: uppercase; }
/* The unit is not part of the number and should not compete with it, so it
   does not take the grade. The transition is what makes the count-up TRAVEL
   rather than jump between the five rungs. */
.rng-big { transition: color .45s var(--mech); }
.rng-unit { font-size: .5em; color: var(--ink-2); }
.rng-again { pointer-events: auto; margin-top: 10px; }

/* THE CHARGE. Three cells that fill in turn. A countdown is a determinate
   progress and not a loop, so it is drawn as three states of a thing rather
   than as an animation: the app adds the class, the cell is lit, and if you
   stop the round mid-charge nothing is left running.

   WHITE, and glowing. --ink is the brightest thing this palette has, which is
   what a charging light should be, and it keeps the countdown off the rating
   scale entirely: the charge is not telling you how you did, it is telling
   you to get ready. */
.rng-charge { display: flex; gap: 7px; }
.rng-cell {
  width: 38px; height: 8px; background: var(--hair);
  border: 1px solid var(--hair); transition: background .12s, box-shadow .12s, border-color .12s;
}
.rng-lit {
  background: var(--ink); border-color: var(--ink);
  box-shadow: 0 0 12px rgb(233 235 227 / .75), 0 0 26px rgb(233 235 227 / .35);
}

/* ── THE LADDER ───────────────────────────────────────────────────────── */
.rng-tier {
  margin-top: 2px; padding: 2px 0;
  font-family: var(--mono); font-size: 11px; line-height: 16px;
  letter-spacing: .14em; text-transform: uppercase; color: var(--ink-2);
}
/* WIDTH IS RESERVED BEFORE THE ANSWER IS. The word says "Calculating..." for
   the two seconds the number is climbing and then swaps itself for the grade.
   Without it the grade and the button appeared at the end and shoved the score
   you were reading upward at the exact moment you were reading it.

   NO BOX. It was outlined, which made it look like the button beneath it and
   invited a click on a word that does nothing. It is a label. */
.rng-tier { min-width: 148px; text-align: center; transition: color .45s var(--mech); }

/* THE RADAR RINGS. Three of them, staggered, behind an Elite score only. The
   legacy version ran `infinite` and was still running while you read your
   result and while you played the next round. These expand ONCE, from the
   click that earned them, and remove themselves. */
.rng-ring {
  position: absolute; top: 50%; left: 50%; z-index: 2;
  width: 8px; height: 8px; border-radius: 50%; pointer-events: none;
  border: 1px solid rgb(var(--violet-rgb) / .8);
  box-shadow: 0 0 8px rgb(var(--violet-rgb) / .5), inset 0 0 8px rgb(var(--violet-rgb) / .2);
  animation: rng-radar 1.8s ease-out forwards;
}
/* Staggered in CSS rather than by a JS-written delay: the three offsets are
   fixed, so they are style and not data. */
.rng-ring--b { animation-delay: .6s; }
.rng-ring--c { animation-delay: 1.2s; }
@keyframes rng-radar {
  0%   { transform: translate(-50%, -50%) scale(1);  opacity: .9; }
  60%  { opacity: .45; }
  100% { transform: translate(-50%, -50%) scale(98); opacity: 0; }
}

/* ── THE TARGET ───────────────────────────────────────────────────────── */
/* A thing you click, so it is the accent, and it is the only accent in the
   arena while a round is running. Round, because a circle has no corner to
   aim at that is nearer than the middle. The ring, the inner ring, the
   crosshair and the centre dot are all back: at 38px on hard, the crosshair
   is what tells your eye where the middle is before you have found the edge.

   HARD IS AMBER. It is the one use of a rating colour that is not a rating,
   and it is allowed for the same reason the rating scale exists: it is not a
   signal either. It says which of two rule-sets you chose, which is the same
   thing the difficulty control above it says, and it says it on an object
   that has no other job. */
.rng-target {
  position: absolute; z-index: 20; transform: translate(-50%, -50%);
  border-radius: 50%; cursor: crosshair; animation: rng-in .12s var(--mech);
}
.rng-target * { pointer-events: none; }
.rng-target i, .rng-target b, .rng-target u, .rng-target s {
  position: absolute; display: block;
}
.rng-target i {
  inset: 0; border-radius: 50%; border: 2px solid var(--accent);
  box-shadow: 0 0 14px rgb(var(--accent-rgb) / .6), 0 0 30px rgb(var(--accent-rgb) / .18),
              inset 0 0 10px rgb(var(--accent-rgb) / .08);
}
.rng-target u { inset: 28%; border-radius: 50%; border: 1px solid rgb(var(--accent-rgb) / .32); }
.rng-target s { top: 50%; left: 0; right: 0; height: 1px; background: rgb(var(--accent-rgb) / .42); transform: translateY(-50%); }
.rng-target s + s { top: 0; bottom: 0; left: 50%; right: auto; width: 1px; height: auto; transform: translateX(-50%); }
.rng-target b {
  top: 50%; left: 50%; width: 7px; height: 7px; border-radius: 50%;
  background: var(--accent); transform: translate(-50%, -50%);
  box-shadow: 0 0 10px var(--accent);
}
.rng-target--hard i {
  border-color: var(--amber);
  box-shadow: 0 0 14px rgb(var(--amber-rgb) / .6), 0 0 30px rgb(var(--amber-rgb) / .18),
              inset 0 0 10px rgb(var(--amber-rgb) / .08);
}
.rng-target--hard u { border-color: rgb(var(--amber-rgb) / .32); }
.rng-target--hard s { background: rgb(var(--amber-rgb) / .42); }
.rng-target--hard b { background: var(--amber); box-shadow: 0 0 10px var(--amber); }
@keyframes rng-in { from { transform: translate(-50%, -50%) scale(.7); opacity: 0; } }

/* Hit and miss. One shot each, removed when they are done. Jade opens outward
   because something landed; heart just fades because nothing did. */
.rng-ripple, .rng-flash {
  position: absolute; z-index: 15; border-radius: 50%;
  transform: translate(-50%, -50%); pointer-events: none;
}
.rng-ripple { border: 2px solid var(--jade); box-shadow: 0 0 14px rgb(var(--jade-rgb) / .5); animation: rng-hit .6s var(--mech) forwards; }
.rng-flash  { border: 2px solid rgb(var(--heart-rgb) / .55); background: rgb(var(--heart-rgb) / .07); animation: rng-miss .45s var(--mech) forwards; }
@keyframes rng-hit  { to { transform: translate(-50%, -50%) scale(4);  opacity: 0; } }
@keyframes rng-miss { to { transform: translate(-50%, -50%) scale(.8); opacity: 0; } }

/* The +1 over a hit. It is the only place the score is confirmed AT the point
   you were looking, rather than in the HUD you were not. */
.rng-pop {
  position: absolute; z-index: 25; transform: translate(-50%, -50%);
  font-family: var(--mono); font-size: 18px; color: var(--jade);
  text-shadow: 0 0 12px rgb(var(--jade-rgb) / .55); pointer-events: none;
  animation: rng-pop .72s ease-out forwards;
}
@keyframes rng-pop { to { transform: translate(-50%, -160%); opacity: 0; } }

/* ── THE HISTORY STRIP ────────────────────────────────────────────────────
   The last ten runs, newest first, each wearing the grade it earned. This is
   the thing the legacy app got right and that a single averaged line threw
   away: ten graded scores in a row is a shape, and you can see a bad streak
   or a good night in it without reading a number. The chip falls back to
   --ink-2 when no grade is set, so it can never render invisible. */
.rng-strip { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; }
.rng-chip {
  padding: 5px 11px; border: 1px solid rgb(var(--grade-rgb, var(--ink-rgb)) / .4);
  font-family: var(--mono); font-size: 11px; line-height: 16px;
  letter-spacing: .04em; color: var(--grade, var(--ink-2));
  font-variant-numeric: tabular-nums; white-space: nowrap;
}
/* EVERY CHIP LOOKS THE SAME. One of them used to be filled to mark the record,
   which read as a selected or active chip rather than a historical one, and
   when the record was older than ten runs nothing was filled at all, so the
   same strip meant two different things on two different days. The record has
   its own line above, where it is named. */
.rng-chip .rng-unit { font-size: .82em; }

/* ── THE RESULT, OVER THE WORDS ───────────────────────────────────────── */
/* The arena states its own result in the middle of itself, so only the typing
   test needs this: the same idea, over the block you were just reading. It
   covers the words rather than sitting under them, because at the moment a
   run ends the words are finished and the score is the only thing on the page
   worth any space. */
/* THE TYPING RESULT IS NOT A PANEL, IT IS THE BOX. It used to be an opaque
   sheet with its own border laid over the words, and it was wrong twice.

   The border and the glow never appeared: .rng-words clips its children, so
   the graded edge and the outer shadow were cut off, and the typing result
   was the only one of the three that did not take the colour of its score.

   And the wash fought the sheet. .rng-graded sets a translucent background,
   which overrode the sheet's opaque one, so for the two seconds of the count
   the words were visible THROUGH the result and flickering as the grade
   walked down the scale. That was the flashing.

   So the box takes the grade, exactly as the arena does, the words are taken
   away underneath rather than covered over, and this is a transparent layer
   holding the text in the middle of it. */
.rng-sheet {
  position: absolute; inset: 0; z-index: 30;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 10px; padding: 24px; text-align: center;
}
/* A run that beat the record says so. --violet because a record is the top of
   the scale by definition, whatever grade the run itself earned. */
.rng-pb { font-family: var(--mono); font-size: 10.5px; letter-spacing: .14em; text-transform: uppercase; color: var(--violet); }

@media (max-width: 880px) {
  .rng-arena { height: 400px; }
  .rng-line  { font-size: 21px; line-height: 54px; }
  .rng-words { height: 400px; }
  .rng-view  { top: 58px; padding: 6px 20px 0; }
  .rng-big   { font-size: 38px; line-height: 46px; }
}
@media (max-width: 560px) {
  .rng-arena { height: 330px; }
  .rng-hud   { gap: 0 20px; }
  .rng-line  { font-size: 17px; line-height: 42px; }
  .rng-words { height: 330px; }
  .rng-view  { top: 54px; padding: 4px 16px 0; }
  .rng-tier  { min-width: 0; }
  .rng-big   { font-size: 30px; line-height: 38px; }
  .rng-big--cap { font-size: 20px; line-height: 28px; }
}

/* ── THE TERMS PAGE ───────────────────────────────────────────────────────
   A reading page, so it is ink from top to bottom. The old build put the
   section headings in cyan, which is --ice here and means motion; a heading
   is not a signal and does not move, so the headings are the ink ladder and
   the only colour on the page is the accent word in the title, which every
   page gets. ─────────────────────────────────────────────────────────────── */
.terms-meta { margin-top: 8px; font-family: var(--mono); font-size: 10.5px; line-height: 16px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-3); }
/* Full page width, by preference. The usual argument for a narrow column is
   line length - past roughly 80 characters the eye starts losing its place on
   the return sweep - so the text steps up to 15px to claw some of that back:
   bigger glyphs mean fewer characters in the same width. */
.terms-doc { padding-bottom: 24px; }
.terms-section { margin-bottom: 52px; }
.terms-section:last-child { margin-bottom: 0; }
.terms-section-title { margin-bottom: 24px; padding-bottom: 10px; border-bottom: 1px solid var(--hair); font-family: var(--mono); font-size: 10.5px; line-height: 16px; font-weight: 400; letter-spacing: .16em; text-transform: uppercase; color: var(--ink-3); }
.terms-section > p { margin-bottom: 18px; font-size: 15px; line-height: 1.8; color: var(--ink-2); }
.terms-section > p:last-child { margin-bottom: 0; }
.terms-block { margin-bottom: 28px; }
.terms-block:last-child { margin-bottom: 0; }
.terms-block-title { margin-bottom: 8px; font-family: var(--mono); font-size: 10.5px; line-height: 16px; font-weight: 400; letter-spacing: .12em; text-transform: uppercase; color: var(--ink); }
.terms-block p { margin-bottom: 12px; font-size: 15px; line-height: 1.8; color: var(--ink-2); }
.terms-block p:last-child { margin-bottom: 0; }
/* The attribution run. One paragraph, names separated by interpuncts, set a
   step down because it is a reference list rather than something you read
   straight through. */
.terms-credits { font-size: 14px; line-height: 1.9; color: var(--ink-3); }

/* ── THE SUPPORT PAGE ─────────────────────────────────────────────────────
   Two ways to give, side by side, each in a plain bay.

   The old page skinned each card in its payment brand: a cyan card with a cyan
   glow and cyan corner brackets, an orange card with an orange glow. That is
   the same thing the YouTube feed was doing with red, and it goes for the same
   reason. Brand colour carries no meaning inside this system and competes with
   the four signals. Both cards are now the same neutral bay, and the accent
   appears on the one thing you actually press. The payment marks themselves are
   drawn in ink for the same reason - a logo in its own colour beside a logo in
   another colour is two more palettes on the page.

   THE QR PLATE IS A DELIBERATE EXCEPTION, like the reader's email paper. A QR
   code is read by a camera that expects dark modules on a light field, and
   inverting it costs scans. So that one plate stays literally white, and it is
   walled off to .give-qr-plate. ─────────────────────────────────────────── */
.give-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 48px; }
.give-card { display: flex; flex-direction: column; gap: 20px; padding: 28px; background: var(--panel); border: 1px solid var(--hair); }
.give-head { display: flex; align-items: center; gap: 14px; padding-bottom: 20px; border-bottom: 1px solid var(--hair); }
.give-icon { display: flex; align-items: center; justify-content: center; width: 46px; height: 46px; flex-shrink: 0; color: var(--ink-2); border: 1px solid var(--hair); }
.give-icon svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; }
/* TELLING THE TWO CARDS APART, WITHOUT SPENDING A COLOUR.

   There were brand tints here - a Bitcoin orange edge, a Buy Me a Coffee yellow
   edge, a blue icon before that. All gone. Each code carries its own logo in the
   middle now, and a logo says whose it is better than a coloured line ever did,
   so the line was two more colours earning nothing. The page is back to the four
   signals plus ink, and the only lit things on it are the two controls.

   Worth writing down since it came up twice: --amber was never an option for the
   Bitcoin side. The rating scale note is explicit that amber and violet may only
   appear on a performance grade and never on a control, and a way to pay is a
   control. Borrowing it would have quietly made the site six signals. The brand
   orange that did the job for a while was a literal, not a token - and it is
   almost exactly --amber to look at, which is the joke. */
/* A filled glyph inside an outlined icon. The icon rule strokes everything by
   default, which is right for line art and wrong for a solid mark. */
.give-icon .give-fill { fill: currentColor; stroke: none; }

.give-label { display: block; margin-bottom: 3px; font-family: var(--mono); font-size: 9.5px; line-height: 14px; letter-spacing: .14em; text-transform: uppercase; color: var(--ink-3); }
.give-title { display: block; font-family: var(--sans); font-size: 15px; line-height: 20px; color: var(--ink); }
.give-body { display: flex; flex-direction: column; flex: 1; gap: 16px; }
.give-desc { font-size: 14px; line-height: 1.65; color: var(--ink-2); }
/* The button and the address row are the matching element in each card, so they
   are the same height and the two cards read as a pair. */
.give-btn { width: 100%; height: 40px; }

.give-note { font-family: var(--mono); font-size: 9.5px; line-height: 14px; letter-spacing: .07em; text-transform: uppercase; color: var(--ink-3); text-align: center; }

/* Two codes on one page, so each one says what it is. Unlabelled, they are two
   identical white squares and the only way to tell them apart is to scan one. */
.give-qr { display: flex; flex-direction: column; align-items: center; gap: 8px; }
/* The white padding IS the quiet zone - the clear margin a scanner needs to find
   the code at all. Buy Me a Coffee's own file ships with almost none, so the
   plate supplies it for both codes equally, which is also what makes the two
   read as the same object. */
.give-qr-plate { display: block; padding: 16px; background: #fff; line-height: 0; }
.give-qr-plate canvas, .give-qr-plate img, .give-qr-plate table { display: block; }
/* The plate stacks the code and its mark. Both codes are generated at the same
   size, so the two plates match to the pixel. */
.give-qr-plate { position: relative; }
.give-qr-code { display: block; width: 132px; height: 132px; }
.give-qr-code canvas, .give-qr-code img, .give-qr-code table { display: block; width: 132px; height: 132px; }
/* THE MARK IN THE MIDDLE. It works because the code is generated at error level
   H, which carries enough redundancy to rebuild the modules hidden underneath.
   The white pad is not decoration: a scanner needs the covered area to read as
   a clean block rather than as broken modules. Kept to roughly a tenth of the
   code's area, which measured reliable with margin to spare - bigger starts
   eating the redundancy that makes it work at all. */
.give-qr-logo { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; pointer-events: none; }
/* The pad is a disc, not a rounded square. The corners of a square pad cover
   modules without covering any logo, which is pure waste - a disc steals about
   a sixth less area at the same width, so the mark itself can be bigger while
   costing the code no more than the small square did. */
/* overflow:visible matters here. An SVG clips to its viewBox by default, so a
   mark drawn right up to the edge - which the Bitcoin disc is, by construction -
   gets its extremes shaved flat by the clip. Letting it overflow means each mark
   can fill its box completely and still render whole. */
.give-qr-logo svg { width: 34px; height: 34px; padding: 6px; background: #fff; border-radius: 50%; overflow: visible; }
.give-qr-cap { font-family: var(--mono); font-size: 9.5px; line-height: 14px; letter-spacing: .07em; text-transform: uppercase; color: var(--ink-3); }

/* Accent border, matching the accent button opposite: this row is something you
   act on, and the two cards should present their action with the same weight. */
.give-addr { display: flex; align-items: stretch; height: 40px; background: var(--base); border: 1px solid var(--accent); overflow: hidden; }
.give-addr-field { flex: 1; min-width: 0; padding: 10px 12px; background: transparent; border: 0; outline: none; cursor: text; user-select: all; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: var(--mono); font-size: 11px; line-height: 16px; color: var(--ink-2); }
/* Copying the address is this card's action, the way the button is the other
   card's, so it wears the accent and each card has exactly one thing lit. */
.give-copy { flex-shrink: 0; padding: 0 16px; background: var(--accent); color: var(--base); border: 0; cursor: pointer; white-space: nowrap; font-family: var(--mono); font-size: 10.5px; line-height: 16px; letter-spacing: .05em; text-transform: uppercase; transition: background .16s; }
.give-copy:hover { background: var(--ink); }
/* Done, so jade. It says the address is on your clipboard, nothing more. */
.give-copy.give-copied { background: var(--jade); color: var(--base); }

@media (max-width: 640px) {
  .give-grid { grid-template-columns: 1fr; }
}

/* ── WORD GAMES. ONE HEIGHT ────────────────────────────────────────────────
   The three games were three different heights, so switching between them
   moved the page under you. Width was never the problem: all three sections
   sit in the same .wrap and .game is a grid with no width of its own, so they
   have always been exactly as wide as the bar above them.

   576px is Wordbox measured rather than estimated, and the arithmetic matters
   because being six pixels out is still visible:

     rows   6 * (52px square + 1px .word border-bottom) + 5 * 10px gap  = 368
     keys   3 * 46px key + 2 * 6px gap                                  = 150
     gap    .pane--board                                                =  18
     pad    .pane, both sides                                           =  40
                                                                          576

   Wordbox is the tallest, so it sets the floor and nothing is ever cropped to
   meet it. Phrasebox now shares Wordbox's 52px cap, so a short saying comes
   out at exactly 368px of rows too and the two are the same box by
   construction rather than by padding. The crossword's board is 535px and is
   padded up.

   The board CENTRES in the leftover space, except on the crossword. There the
   board sits beside a column of clues and its top edge lines up with the
   Across and Down headings, which is an alignment worth more than centring
   twenty pixels of slack.                                                   */
.wg-level { min-height: 576px; }
.wg-level:not(.game--split) > .pane--board { justify-content: center; }

/* THE CROSSWORD SQUARE IS DERIVED, NOT FIXED. It used to be a flat 38px cap,
   which meant a 13x13 set left space at the bottom of the box while a 15x15
   set silently overflowed it, and a 10x10 set sat tiny in the middle of it.

   The grid is square, so its width and its height are the same decision. The
   box is 576px and the pane eats 40px of that, so the board can be 535px tall
   and no more; the square is whatever divides into that. Width only gets a
   say when it is the tighter of the two, which is what --wg-gutter is for:
   everything that is NOT board on that row. Above 880px the clues sit beside
   the board and need a column of their own, and below it they stack
   underneath and need none.

     56  .wrap padding    40  .pane padding    1  the grid line    340  clues

   Capped at 52px, which is Wordbox's square, so no set is ever drawn larger
   than the largest thing elsewhere on the page.                             */
.wg-level .board { --wg-gutter: 438px; }
@media (max-width: 880px) { .wg-level .board { --wg-gutter: 98px; } }

/* ── THE HEALTH HUB ────────────────────────────────────────────────────────
   Three tools, one page: Status, Diet, Workouts. Status owns the body figures
   and every target in Diet is derived from them, which is why they are one app
   and one storage id.

   THE FIGURE IS NOT DECORATION AND IT IS NOT --ice. A wireframe body is not
   motion, not success and not danger, so it is drawn in the ink ladder like
   any other thing on this site that is simply there. What IS interactive on
   it - the measurement nodes you tap - is --accent, because the accent means
   you can act on this, and that is the entire job the figure does: it is the
   picker. A node only exists once you track that measurement, so the figure
   fills in as you use it.

   No glow, no pulse and nothing rotating. The reference for this was a
   science-fiction hologram and the parts of it that could not come across are
   exactly the parts rule 4 forbids.                                         */

/* Two columns where there is room. The left is WHO YOU ARE and holds still:
   the figure, and every fixed fact about you under it. The right is WHAT YOU
   DID and changes: the chart, and the week you are editing. Keeping those two
   jobs apart is the whole layout.

   380px, not 300px, because the left column is now a character sheet carrying
   a dozen label-and-value rows rather than a figure and a switch. One column
   below 880px, where a figure and a form side by side leave neither enough. */
.hh-cols { display: grid; grid-template-columns: 380px minmax(0, 1fr); gap: 1px; background: var(--hair); border: 1px solid var(--hair); }
.hh-col  { background: var(--panel); padding: 20px; min-width: 0; }

/* THE PORTRAIT. A small square above the data, the size of a profile picture
   and not a feature of the page. It is the one piece of pure decoration here
   and it breaks the "every element is doing a job" rule deliberately: it is
   what makes this column read as a character card rather than a settings
   form. At 96px it earns that at a glance and then gets out of the way. */
.hh-portrait { position: relative; width: 96px; height: 96px; margin: 0 auto 20px; border: 1px solid var(--hair); background: var(--base); }
.hh-portrait-img { width: 100%; height: 100%; display: block; object-fit: cover; }
/* The mark on an empty slot when the panel is LOCKED. A glyph, not a button:
   it says a picture goes here and it cannot be pressed, so it is ink-3 and
   has no hover. It is a span rather than a disabled button so the keyboard
   never lands on it and a screen reader never announces a control. */
.hh-portrait-mark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--ink-3); }
.hh-portrait-mark svg { width: 18px; height: 18px; stroke: currentColor; stroke-width: 1.5; fill: none; stroke-linecap: round; stroke-linejoin: round; }
/* In edit mode the same corner becomes real controls, sat over the picture. */
.hh-portrait-acts { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 6px; background: var(--scrim); }
.hh-porterr { font-size: 13px; line-height: 20px; color: var(--heart); text-align: center; margin: 0 0 14px; }

/* THE CROPPER. The canvas is 192 backing shown at 192, so the thing you drag
   IS the output at its real size and no preview can drift from the result.
   192 is stored for a 96 slot because every phone draws at 2x. */
.hh-crop { display: flex; flex-direction: column; align-items: center; gap: 12px; margin-bottom: 20px; }
.hh-crop-canvas { width: 192px; height: 192px; display: block; border: 1px solid var(--hair); background: var(--base); cursor: move; touch-action: none; }
.hh-crop .slider { width: 192px; }
.hh-crop-acts { display: flex; gap: 8px; }

/* A form. Label above input, because a label beside a 40px input on a phone
   costs half the width of the input to save one line of height. */
.hh-form  { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 14px; }
.hh-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.hh-label { font-family: var(--mono); font-size: 10.5px; line-height: 16px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-2); }
.hh-hint  { font-size: 13px; line-height: 20px; color: var(--ink-3); }
.hh-field .seg { width: 100%; }

/* THE CHARACTER SHEET. One column of label-and-value rows: name and unit on
   the left, the thing itself on the right. Some values are fields you fill and
   some are readouts the app worked out, and they deliberately look different -
   a readout is never in a box that looks like an input, so you can tell at a
   glance which numbers you own and which ones the page owes you.

   A measurement row only exists once you track that measurement. Nobody is
   shown an empty box for a calf they have never measured. */
.hh-sheet { display: flex; flex-direction: column; gap: 1px; background: var(--hair); border: 1px solid var(--hair); }
.hh-srow  { display: grid; grid-template-columns: 104px minmax(0, 1fr); gap: 12px; align-items: center; background: var(--panel); padding: 8px 12px; min-height: 40px; }
.hh-skey  { font-family: var(--mono); font-size: 10.5px; line-height: 16px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-2); }
.hh-sval  { font-family: var(--mono); font-size: 12px; line-height: 20px; color: var(--ink); }
.hh-sval small { color: var(--ink-2); }
/* Nothing recorded is not zero. An em space of ink-3 says the row exists and
   the number does not, without pretending the number is a number. */
.hh-sval--none { color: var(--ink-3); }
/* Date of birth is three fields on one row, set once. */
/* Date of birth is three dropdowns. On its own full-width row, because three
   selects sharing a 1fr cell beside a 104px label gutter truncated the month
   to "Marcl" and clipped the year. It only exists while editing; once locked
   you are shown your age and never the date it came from. */
.hh-srow--wide { grid-template-columns: minmax(0, 1fr); gap: 6px; }
.hh-dob { display: grid; grid-template-columns: 1fr 1.6fr 1fr; gap: 6px; }

/* THE CHECKLIST. A tick is show, not record, so the tick is --accent (you can
   act on it) and clearing data is --heart (destructive) and only appears when
   there is data to lose. The reading count sits between them so the button is
   never pressed without the number beside it. */
.hh-check { display: flex; flex-direction: column; gap: 1px; background: var(--hair); border: 1px solid var(--hair); }
.hh-check-row { display: flex; align-items: center; gap: 10px; background: var(--panel); padding: 7px 12px; min-height: 40px; }
.hh-check-lab { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; cursor: pointer; }
.hh-check-name { font-size: 14px; line-height: 20px; color: var(--ink); }
.hh-check-n { font-family: var(--mono); font-size: 9.5px; line-height: 16px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-3); white-space: nowrap; }
.hh-cbx { appearance: none; width: 16px; height: 16px; flex: 0 0 16px; border: 1px solid var(--hair); background: var(--base); cursor: pointer; transition: background .14s var(--mech), border-color .14s var(--mech); }
.hh-cbx:checked { background: var(--accent); border-color: var(--accent); }
.hh-cbx:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Stacked blocks down the right column. The section heads were collapsing
   their margins against the panel above and putting a title hard against a
   chart, so the space is owned by the block rather than by the heading. */
.hh-block { margin-top: 22px; }

/* BUTTON SIZING. Two buttons sitting side by side are a PAIR, and a pair whose
   halves are different widths reads as an accident. .btn--pick already carries
   a min-width for exactly this reason on the tool picker; these are the same
   problem in four more places. Edit/Save, Use/Clear and Save/Clear are each
   one control in two states or two halves of one decision, so they match.
   92px, not the picker's 108px, because these sit inside panels. */
.hh-pair .btn { min-width: 92px; }
/* The strip arrows hold one character and should be square-ish, not as wide
   as the word they sit beside. */
.hh-nav .btn { min-width: 44px; padding-left: 0; padding-right: 0; }
/* 1M, 6M, 1Y, 5Y and ALL are five choices of one thing, so they are five
   equal cells. Left to shrink-wrap, ALL came out wider than the rest. */
.hh-chart-head .seg button { min-width: 46px; justify-content: center; }
/* A section heading INSIDE the bordered sheet has to line up with the rows it
   introduces, not with the sheet's edge, or it reads as sitting outside the
   box it is actually in. 12px matches .hh-srow and .hh-check-row. */
.hh-sheet > .hh-sechead { padding: 0 12px; }

/* A derived number. It is a readout and never an input, so it is never in a
   box that looks like one. --ink for the figure, --ink-2 for where it came
   from, because the provenance is the thing that makes a number that moves on
   its own explicable rather than mysterious. */
.hh-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 1px; background: var(--hair); border: 1px solid var(--hair); }
.hh-stat  { background: var(--panel); padding: 14px 16px; }
.hh-stat-k { font-family: var(--mono); font-size: 10.5px; line-height: 16px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-2); }
.hh-stat-v { font-family: var(--mono); font-size: 22px; line-height: 30px; color: var(--ink); margin-top: 2px; }
.hh-stat-v small { font-size: 12px; color: var(--ink-2); }
.hh-stat-s { font-size: 11px; line-height: 16px; color: var(--ink-3); margin-top: 2px; }
/* A target you have overridden is no longer the suggestion, and saying so is
   the difference between a number you trust and a number you wonder about. */
.hh-stat--set .hh-stat-v { color: var(--accent); }
.hh-stat .input { margin-top: 10px; width: 100%; }

/* A list of blocks you made: ingredients, meals, exercises. One row shape for
   all of them, because they are the same thing at different sizes. */
.hh-list  { display: flex; flex-direction: column; gap: 1px; background: var(--hair); border: 1px solid var(--hair); }
.hh-list:empty { display: none; }
.hh-item  { display: flex; align-items: center; gap: 12px; background: var(--panel); padding: 11px 14px; }
.hh-item-main { flex: 1; min-width: 0; }
.hh-item-name { font-size: 14px; line-height: 20px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hh-item-sub  { font-family: var(--mono); font-size: 10.5px; line-height: 16px; color: var(--ink-2); margin-top: 1px; }
.hh-item-acts { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.hh-empty { font-family: var(--mono); font-size: 10.5px; line-height: 16px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-2); text-align: center; padding: 16px 0; }


/* THE PLAN. A day is a column of meals. The grid wraps rather than scrolling
   sideways, because a four week plan on a phone is a scroll either way and a
   vertical one is the one the thumb is already doing. */

/* One item, two units, because an ingredient is locked to a dimension and not
   to a unit. They sit on one row under one name so a person resolves it at a
   glance instead of us guessing a density we do not have. */
.hh-sechead { font-family: var(--mono); font-size: 10.5px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-2); margin: 18px 0 8px; }

/* ── THE ARITHMETIC ────────────────────────────────────────────────────────
   Every suggested target is the output of a formula, and until now those
   formulas lived only in the source. This block replaces the override boxes
   that became redundant when the panel got an edit mode, and it shows only
   while editing: it is reference material for the moment you are changing
   something, not decoration for the moment you are reading a number.       */
.hh-maths { border: 1px solid var(--hair); background: var(--base); padding: 16px 18px; }
.hh-maths > .hh-sechead:first-child { margin-top: 0; }
.hh-mrow { display: grid; grid-template-columns: 88px minmax(0, 1fr); gap: 12px; padding: 4px 0; }
.hh-mkey { font-family: var(--mono); font-size: 10.5px; line-height: 18px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-2); }
.hh-mval { font-family: var(--mono); font-size: 12px; line-height: 18px; color: var(--ink); }
.hh-mnote { font-size: 13px; line-height: 20px; color: var(--ink-3); margin: 2px 0 12px 100px; }

/* ── THE AGENDA ────────────────────────────────────────────────────────────
   One day per ROW and one week per COLUMN, so Monday sits beside Monday and a
   day has room to carry its own totals instead of just a stack of names.

   It also settles a layout problem that had no good answer: seven columns
   divides into a phone exactly never, and the choice was between a squeezed
   grid and a sideways scroll. Seven rows divides into anything.

   The column count is a custom property rather than a written-out
   grid-template-columns, so the narrow-screen rule below can collapse to one
   week without having to out-shout an inline style with !important.        */
.hh-agenda { display: grid; grid-template-columns: 72px repeat(var(--weeks, 1), minmax(0, 1fr)); gap: 1px; background: var(--hair); border: 1px solid var(--hair); }
.hh-ag-corner { background: var(--panel); }
.hh-ag-wk { background: var(--panel); padding: 8px 12px; font-family: var(--mono); font-size: 10.5px; line-height: 16px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-2); }
.hh-ag-day { background: var(--panel); padding: 12px; font-family: var(--mono); font-size: 10.5px; line-height: 16px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-2); }
.hh-ag-cell { background: var(--panel); padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; min-height: 68px; min-width: 0; }
/* A day's totals, and where they came from. A total that silently leaves out
   the two meals you never filled in is worse than no total at all, so the
   count travels with it rather than being implied. */
/* Totals sit RIGHT, the way every ledger and account sheet sets numbers. It
   also separates them from the meal names on the left, so a day reads as a
   list with a sum under it rather than as one undifferentiated column. */
.hh-ag-tot { font-family: var(--mono); font-size: 10.5px; line-height: 16px; color: var(--ink-2); margin-top: auto; text-align: right; }
.hh-ag-part { font-family: var(--mono); font-size: 9.5px; line-height: 14px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-3); text-align: right; }
.hh-agenda-nav { display: none; margin-bottom: 12px; }

/* A meal sitting in a day. */
.hh-chip { display: flex; align-items: center; gap: 8px; border: 1px solid var(--hair); padding: 5px 8px; min-width: 0; }
/* The meal name is a button, because it opens that meal below. It is styled
   as text rather than as a control: the whole cell is already busy, and an
   accent button per meal would turn a plan into a wall of yellow. */
.hh-chip-n { flex: 1; min-width: 0; text-align: left; background: none; border: 0; padding: 0; cursor: pointer; font-family: var(--sans); font-size: 13px; line-height: 18px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hh-chip-n:hover:not(:disabled) { color: var(--accent); }
.hh-chip-n:disabled { cursor: default; color: var(--ink-3); }
/* Removing from a day is a small job in a small space, so the button shrinks
   rather than the row growing to fit a full-size one. */
.btn--tiny { min-height: 0; height: 24px; padding: 0 8px; font-size: 9.5px; }

/* ── FOOD ──────────────────────────────────────────────────────────────────
   Ingredients and meals are one section with two tabs, because they are one
   job: the library you build things out of.

   The add line is ONE line. The name field had the full width and needed a
   fraction of it, while the two choices that actually decide how an
   ingredient behaves were tucked underneath in grey and read as captions. */
.hh-addrow { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 14px 0; }
.hh-addrow .input { flex: 1 1 180px; min-width: 0; }
.hh-addrow .hh-qty { flex: 0 0 110px; }
.hh-unitwrap { display: flex; align-items: center; }
/* A select is width:100% by default, which is right in a form row and wrong
   in a flex row where it should take the width of its own content. */
/* A select fills its box like every other field. The old flex rule shrank it
   to its content, which is why the closed control looked half the width of the
   inputs beside it. The OPEN list is still the browser's own and unstyleable,
   but the box you see when it is shut now matches its neighbours. */
.hh-unit { width: 100%; }
.hh-addrow .select { flex: 1 1 140px; }
/* The setup and milestone dropdowns are a fixed, comfortable width that sits
   as a matched pair with the number boxes beside them - not shrink-wrapped to
   the text (which left a control half the height's worth of width) and not
   stretched across the whole row. The OPEN native list is still the browser's
   and cannot be sized from here; this is the closed control, which is what you
   see and interact with. */
.hh-setup-sel { flex: 0 0 116px; width: 116px; }
.hh-unitfix { font-family: var(--mono); font-size: 12px; line-height: 20px; color: var(--ink-2); padding: 0 4px; }
/* A combo you can type into or pick from. The list is a datalist, so the
   dropdown is the browser's own - the same accepted cost as a select. */
.hh-combo { min-width: 0; flex: 1 1 auto; }
/* Inside one meal. The back link and the name make it read as a place you
   have gone to, rather than a drawer that opened under a list. */
/* A closed meal is a hairline row; an opened one is a hairline BOX holding
   everything about itself. Without it the editor read as a region of the panel
   and there was no edge saying where the meal stopped and the library began. */
.hh-mealbox { border: 1px solid var(--hair); background: var(--base); padding: 16px 18px; margin: 14px 0; }
.hh-mealbox > .hh-sechead:first-of-type { margin-top: 16px; }
/* The name and the way out share a line, so leaving is always in one place
   whether it says Close or Save. */
.hh-mealhead { display: flex; align-items: center; gap: 12px; }
.hh-mealhead .btn { flex: 0 0 auto; min-width: 92px; }
.hh-mealname { flex: 1; min-width: 0; font-size: 15px; }
.hh-mealtitle { flex: 1; min-width: 0; margin: 0; font-family: var(--sans); font-size: 16px; line-height: 24px; font-variation-settings: 'wght' 500; color: var(--ink); }
/* Changing an amount is a name, a number, a unit and a button. That is one
   line. It was wrapping to three because .input is width:100% by default,
   which is right in a form column and wrong in a row. */
.hh-item--edit { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.hh-item--edit .hh-item-name { flex: 1 1 120px; min-width: 0; }
.hh-item--edit .input { width: auto; flex: 0 0 96px; }
.hh-item--edit .select { width: auto; flex: 0 0 auto; min-width: 88px; }
.hh-item--edit .hh-unitfix { flex: 0 0 auto; }
.hh-item--edit .btn { flex: 0 0 auto; }
.hh-notetext { font-size: 14px; line-height: 22px; color: var(--ink-2); white-space: pre-wrap; margin: 0; }
.hh-notes { width: 100%; resize: vertical; min-height: 88px; font-family: var(--sans); font-size: 14px; line-height: 22px; }

/* ── WORKOUTS ──────────────────────────────────────────────────────────────
   The three panels reuse the diet idioms wholesale - .hh-mealbox for an opened
   item, .hh-agenda for the schedule, .hh-addrow for the add lines - so only the
   genuinely new pieces are here: the milestone track, the muscle-group grid,
   the exercise-in-a-session row, and the weekly volume readout.             */

/* THE MILESTONE TRACK. A row of rungs you light as you reach them. Lit is
   --accent because it is a thing you achieved and can act on next; unlit is the
   ink ladder, a rung not yet earned. It wraps rather than scrolling, because a
   rung hidden off the edge is a goal you cannot see. The mini variant on the
   list is the same track with no labels, just the shape of your progress. */
.hh-track { display: flex; flex-wrap: wrap; gap: 4px; margin: 4px 0 2px; }
.hh-rung { font-family: var(--mono); font-size: 11px; line-height: 16px; padding: 5px 10px; border: 1px solid var(--hair); background: var(--base); color: var(--ink-3); cursor: pointer; transition: background .14s var(--mech), color .14s var(--mech), border-color .14s var(--mech); }
.hh-rung.is-lit { background: var(--accent); border-color: var(--accent); color: var(--base); font-variation-settings: 'wght' 500; }
/* A rung whose kind is not currently in play - a weight rung while the
   exercise is set to bodyweight - is kept but shown dim, since turning weight
   back on brings it back into force. */
.hh-rung.is-off { opacity: .4; }
.hh-rung:hover:not(.is-lit) { border-color: var(--ink-3); color: var(--ink-2); }
.hh-track--mini { gap: 2px; margin: 0; pointer-events: none; }
.hh-track--mini .hh-rung { width: 14px; height: 6px; padding: 0; font-size: 0; }
/* Collapsed row: main and, for a dropset exercise, drop mini-tracks stacked
   with a one-letter tag so progress on both shows without opening. */
.hh-minis { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; }
.hh-mini-wrap { display: flex; align-items: center; gap: 6px; }
.hh-mini-tag { font-family: var(--mono); font-size: 8.5px; letter-spacing: .05em; color: var(--ink-3); width: 8px; }

/* The milestone editor: a unit, a list of typed rungs each removable, and the
   fill helper that generates an evenly spaced run. */
.hh-ms-edit { margin-top: 4px; }
.hh-ms-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
/* In a milestone row the kind select and the value share the width evenly and
   stretch to fill, so neither is a narrow control stranded in empty space. The
   remove X keeps its fixed size at the end. */
.hh-ms-kind { flex: 1 1 120px; width: auto; }
.hh-ms-row .hh-qty { flex: 1 1 120px; }
.hh-ms-row .hh-ms-time { flex: 1 1 120px; display: flex; }
.hh-ms-row .hh-ms-time .hh-tiny { flex: 1 1 auto; width: auto; }

/* The muscle-group checklist: the same checkbox as the Status tracking list,
   laid out as a wrapping grid because ten groups in a column is a long thin
   thing and they read fine two or three abreast. */
.hh-groups { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 4px 14px; margin: 2px 0; }

/* AN EXERCISE INSIDE A SESSION. Name that jumps to the exercise, then sets,
   reps, load and scheme. In edit mode the between-sets rest hangs underneath on
   its own line so the main row does not become a dozen tiny fields. */
.hh-super-tag { font-family: var(--mono); font-size: 9.5px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-2); margin-bottom: 8px; }
.hh-witem { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.hh-witem-name { flex: 1 1 140px; min-width: 0; text-align: left; background: none; border: 0; padding: 0; cursor: pointer; font-family: var(--sans); font-size: 14px; line-height: 20px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hh-witem-name:hover:not(:disabled) { color: var(--accent); }
.hh-witem-name:disabled { cursor: default; color: var(--ink-3); }
.hh-witem-sub { font-family: var(--mono); font-size: 11px; line-height: 18px; color: var(--ink-2); }
.hh-witem-x { font-family: var(--mono); color: var(--ink-3); }
.hh-tiny { width: 56px; flex: 0 0 56px; text-align: center; padding-left: 4px; padding-right: 4px; }
.hh-super-add { margin-top: 8px; margin-bottom: 0; }

/* A row that opens something when clicked, styled like a plain item so it does
   not read as a button but shows it is live on hover. */
.hh-item-open { text-align: left; background: none; border: 0; cursor: pointer; width: 100%; }
.hh-item-open:hover .hh-item-name { color: var(--accent); }



/* ── WORKOUT SETUP LINES ────────────────────────────────────────────────────
   The exercise editor builds its shape from labelled rows: a small label, then
   the control it introduces, wrapping on narrow screens. */
.hh-setup-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.hh-inline-lab { flex: 0 0 auto; }
.hh-setup-read { display: flex; flex-direction: column; gap: 1px; background: var(--hair); border: 1px solid var(--hair); }
.hh-setup-read .hh-srow { background: var(--panel); }
/* A yes/no switch: the site segment, sized to sit inline in a setup row. */
.hh-toggle { flex: 0 0 auto; width: auto; }
.hh-toggle button { min-width: 44px; }

/* ── MILESTONE LADDERS ───────────────────────────────────────────────────────
   One row, or two when dropsets are on. Each is a titled block: a wrapping
   track of rungs you light, and below it in edit mode the typed values. */
.hh-ladder { margin-bottom: 16px; }
.hh-ladder-t { font-family: var(--mono); font-size: 9.5px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-3); margin-bottom: 6px; }
.hh-ms-time { display: inline-flex; align-items: center; gap: 4px; }

/* ── SESSION BLOCKS ──────────────────────────────────────────────────────────
   Each block is a bounded box so it is obvious where one exercise stops and
   the next starts. A superset draws one border around the pair, with the two
   exercises stacked and a quiet "with" between them and no divider inside. */
.hh-block-box { border: 1px solid var(--hair); background: var(--base); padding: 12px 14px; margin-bottom: 10px; }
.hh-block-box--super { border-color: var(--ink-3); }
.hh-super-tag { font-family: var(--mono); font-size: 9.5px; letter-spacing: .05em; text-transform: uppercase; color: var(--accent); margin-bottom: 8px; }
.hh-super-amp { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); margin: 2px 0 2px 4px; }
.hh-block-foot { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--hair); }
.hh-super-add { margin-top: 8px; margin-bottom: 0; }

/* ── TWO-CLICK REMOVE ────────────────────────────────────────────────────────
   A bare X that never deletes on the first press. Clicking it once expands it
   into a full red Remove; clicking anything else disarms it. This is the only
   destructive control in the tab, so it is deliberately a two-step. */
/* The two-click remove: idle, its X is plain ink like any icon button; the
   danger only shows when you mean it. Hovering warns in red - this is a
   delete. Armed, it is a solid red button with light text so the word reads
   clearly - the earlier dark-on-dark was invisible. */
.hh-rm { flex: 0 0 auto; }
.hh-rm:hover:not(:disabled) { border-color: var(--heart); color: var(--heart); }
.hh-rm--armed { min-width: 0; height: 24px; padding: 0 10px; font-size: 9.5px;
                background: var(--heart); border-color: var(--heart); color: var(--base); }
.hh-rm--armed:hover:not(:disabled) { background: var(--heart); border-color: var(--heart); color: var(--base); }


/* ── MUSCLE GROUPS THIS PLAN ─────────────────────────────────────────────────
   Not a set-count judgement - that misleads the moment someone does one savage
   drop-set day - just which groups the plan trains and how often. Greyed until
   hit, then lit with a tick and a per-week count. One week reads "2 / week";
   several read "2 · 3 · 1" left to right. Compact: a wrapping grid, not a
   full-width row each. */
/* Ten groups split cleanly: five across, and since ten is also divisible by
   two it folds to two columns when the row gets tight rather than leaving an
   odd hanging cell. */
.hh-mg { display: grid; grid-template-columns: repeat(5, 1fr); gap: 1px; background: var(--hair); border: 1px solid var(--hair); }
@media (max-width: 720px) { .hh-mg { grid-template-columns: repeat(2, 1fr); } }
.hh-mg-cell { display: flex; align-items: center; gap: 8px; background: var(--panel); padding: 8px 12px; }
.hh-mg-tick { width: 14px; height: 14px; flex: 0 0 14px; display: flex; align-items: center; justify-content: center; }
.hh-mg-tick svg { width: 14px; height: 14px; stroke: var(--jade); stroke-width: 2.4; fill: none; stroke-linecap: round; stroke-linejoin: round; }
.hh-mg-name { flex: 1; min-width: 0; font-size: 13px; color: var(--ink-3); }
.hh-mg-cell.is-hit .hh-mg-name { color: var(--ink); }
.hh-mg-count { font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }

/* ── HISTORY ───────────────────────────────────────────────────────────────
   A chart of WEEKLY AVERAGES, never daily readings. A daily line for someone
   who weighs themselves every morning is two kilos of water noise around a
   trend of a few hundred grams, and a chart that jumps like that is one nobody
   can read. The week is the smallest honest unit, so it is the only one.

   The line is INK, not a signal. It is not interactive, not moving, not
   correct and not wrong, so by the four-signal rule it is simply there. The
   one thing that IS accent is the dot for the week you have selected, because
   clicking a dot is what loads that week into the editor below - the dot is a
   control and the rest of the chart is a readout.

   A week with no reading gets no dot. The line runs between the readings that
   exist, spaced by real elapsed time, so a month you did not measure shows up
   as a long flat stretch between two dots rather than as four invented points.
   The dots are where the data is; the line is only how to read between them.
   Nothing is ever plotted at zero for a week that was simply skipped.       */
.hh-chart { width: 100%; height: auto; display: block; touch-action: manipulation; }
.hh-chartwrap { border: 1px solid var(--hair); background: var(--base); padding: 14px 12px; }
/* The timeframe on the left, and the change ACROSS that timeframe opposite it.
   The change is ink, not a signal: losing three kilos is not --jade and
   gaining them is not --heart, because whether a number moving down is good
   depends on what you are trying to do, and the app does not get an opinion
   about your body. */
.hh-chart-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
.hh-delta { font-family: var(--mono); font-size: 14px; line-height: 20px; color: var(--ink-2); }
.hh-axis { font-family: var(--mono); font-size: 9.5px; fill: var(--ink-3); letter-spacing: .05em; }
.hh-line { fill: none; stroke: var(--ink-2); stroke-width: 1.5; stroke-linejoin: round; stroke-linecap: round; }
.hh-grid { stroke: var(--hair); stroke-width: 1; }
.hh-dot  { fill: var(--base); stroke: var(--ink-2); stroke-width: 1.5; cursor: pointer; transition: fill .14s var(--mech); }
.hh-dot:hover { fill: var(--ink-2); }
.hh-dot.is-on { fill: var(--accent); stroke: var(--accent); }
.hh-dot-hit { fill: transparent; stroke: none; cursor: pointer; }

/* ── THE CALENDAR ──────────────────────────────────────────────────────────
   The only place a reading is written or corrected. It shows a WEEK for weight
   and body fat and a MONTH for the circumferences, matching each metric's
   cadence, so a tape measure reading lands on the day you actually took it
   rather than being filed under the 1st because that was easier to build.

   Always exactly seven cells. For weight that is Monday to Sunday; for a
   circumference it is the last seven MONTHS, because a circumference is a
   monthly measurement and a full grid of thirty day cells was showing thirty
   blanks to hold one number. There is no growing list of every reading ever
   taken, because the list was never the point: you either want the trend,
   which is the chart, or you want to fix a reading, which is this.

   A cell with a reading carries it. A cell without one is empty, not zero.  */
.hh-cal { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 1px; background: var(--hair); border: 1px solid var(--hair); }
.hh-cal-h { background: var(--panel); padding: 6px 2px; text-align: center; font-family: var(--mono); font-size: 9.5px; line-height: 14px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-3); }
.hh-cell { background: var(--panel); padding: 7px 2px; display: flex; flex-direction: column; align-items: center; gap: 2px; cursor: pointer; border: 0; min-height: 46px; transition: background .14s var(--mech); }
.hh-cell:hover:not(:disabled) { background: var(--raised); }
.hh-cell:disabled { cursor: default; }
.hh-cell.is-on { background: var(--pick); box-shadow: inset 0 0 0 1px var(--accent); }
.hh-cell.is-today .hh-cell-n { color: var(--ink); }
.hh-cell-n { font-family: var(--mono); font-size: 10.5px; line-height: 16px; color: var(--ink-3); }
.hh-cell-v { font-family: var(--mono); font-size: 11px; line-height: 16px; color: var(--ink); }
.hh-cell-v--none { color: var(--hair); }

/* The editor for whichever day is selected. ONE input, always in the same
   place, rather than thirty-one inputs that are 44px wide on a phone. */
.hh-edit { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 14px; }
.hh-edit .hh-skey { flex: 0 0 auto; }
.hh-edit .input { flex: 0 0 110px; }
/* Day of the month. Narrower than the value beside it because it holds at
   most two digits, and a 110px box for "13" invites you to type a weight. */
.hh-edit .hh-dom { flex: 0 0 72px; }
.hh-nav { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.hh-nav-label { font-family: var(--mono); font-size: 10.5px; line-height: 16px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-2); flex: 1; text-align: center; }

/* ── THE SERIES TRACKER ────────────────────────────────────────────────────
   apps/series-tracker.html. Follows TV shows through the TVmaze API. Prefix st-.

   Decisions a future reader would otherwise undo:
     · Show status (Running / Ended / To Be Determined / In Development) is INK,
       never a signal. It is information, not something you act on, so it never
       wears the accent. The ONE coloured thing in this app is "done": a ticked
       episode and a fully-watched progress bar take --jade, the site's done
       signal. Nothing else here is coloured.
     · The episode tick goes --accent on hover (you can act on it) and --jade
       when checked (it is done). Two signals, each doing its one job.
     · Progress width is set from JS because it is a genuinely dynamic value; it
       is not transitioned, so nothing glides.
     · Posters are null on TVmaze more often than you would think. Every poster
       sits in a fixed 2:3 box, and .st-poster--none is a real fallback plate
       (initials), so a missing image never reflows a row or shows a broken icon.
     · Tabs are a plain .seg. Exactly one .st-tab is ever without [hidden]. */

.st-refreshed { font-family: var(--mono); font-size: 10px; line-height: 16px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-2); }

.st-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.st-row .input { flex: 1 1 220px; width: auto; }

/* The provenance line. Same recessive treatment as the YouTube feed's note:
   ink-3 behind a hairline rule, so it reads as fine print, not a banner. */
.st-note { margin: 4px 0 20px; font-size: 13px; line-height: 20px; color: var(--ink-3); border-left: 1px solid var(--hair); padding: 2px 0 2px 14px; }
.st-note a { color: var(--ink-2); }
.st-note a:hover { color: var(--accent); }

.st-tabs { margin-bottom: 16px; }
.st-tab { display: block; }

/* Search results */
.st-results { display: flex; flex-direction: column; gap: 1px; margin-top: 16px; background: var(--hair); border: 1px solid var(--hair); }
.st-result { display: flex; gap: 12px; align-items: flex-start; padding: 12px; background: var(--panel); }
.st-result-main { flex: 1 1 auto; min-width: 0; }
.st-result-title { font-size: 14px; line-height: 20px; color: var(--ink); }
.st-dim { color: var(--ink-2); }
.st-summary { margin-top: 4px; font-size: 12px; line-height: 18px; color: var(--ink-2);
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }

/* A poster in a fixed 2:3 box, so a missing one never reflows the row. */
.st-poster { display: block; flex-shrink: 0; width: 72px; aspect-ratio: 2 / 3; background: var(--raised); border: 1px solid var(--hair); overflow: hidden; }
.st-poster img { display: block; width: 100%; height: 100%; object-fit: cover; }
.st-poster--sm { width: 62px; }
.st-poster--none { display: flex; align-items: center; justify-content: center;
  font-family: var(--mono); font-size: 12px; letter-spacing: .04em; color: var(--ink-2); }

/* Show list and card */
.st-shows { display: flex; flex-direction: column; gap: 1px; background: var(--hair); border: 1px solid var(--hair); }
.st-show { background: var(--panel); }
.st-show-head { display: flex; gap: 12px; align-items: flex-start; padding: 14px; }
.st-show-main { flex: 1 1 auto; min-width: 0; }
.st-show-title { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.st-title { font-size: 15px; line-height: 22px; color: var(--ink); }
a.st-title { transition: color .16s; }
a.st-title:hover { color: var(--accent); }
.st-ext { font-family: var(--mono); font-size: 9.5px; line-height: 16px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-2); transition: color .16s; }
.st-ext:hover { color: var(--accent); }

.st-meta { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 6px; }
.st-status { font-size: 12px; line-height: 18px; color: var(--ink-2); }
.st-next { margin-top: 8px; font-size: 13px; line-height: 20px; color: var(--ink-2); }
.st-next b { color: var(--ink); font-weight: 500; }

.st-show-acts { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }

/* Two-click remove. This deliberately mirrors the Health Hub's .hh-rm: idle it
   is a plain X that warns red on hover; the one press only arms it, and the
   armed state is a solid red button that spells out Remove so the destructive
   step reads clearly and never fires on a single click. If these two ever want
   to be one class, promote them; they are the same control. */
.st-rm:hover:not(:disabled) { border-color: var(--heart); color: var(--heart); }
.st-rm--armed, .st-rm--armed:hover:not(:disabled) { background: var(--heart); border-color: var(--heart); color: var(--base); }

/* Progress. Fill is neutral ink until complete, then the done signal. */
.st-prog { display: inline-flex; align-items: center; gap: 10px; }
.st-prog-track { width: 120px; height: 4px; background: var(--hair); overflow: hidden; }
.st-prog-fill { display: block; height: 100%; width: 0; background: var(--ink-2); }
.st-prog--done .st-prog-fill { background: var(--jade); }
.st-prog-n { font-family: var(--mono); font-size: 10.5px; line-height: 16px; color: var(--ink-2); }
.st-prog--done .st-prog-n { color: var(--jade); }

/* Expanded episodes */
.st-seasons { border-top: 1px solid var(--hair); padding: 6px 14px 14px; }
.st-link-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 12px 0; }
.st-link-label { font-family: var(--mono); font-size: 10px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-2); }
.st-link-input { flex: 1 1 220px; width: auto; }
/* A saved link, shown locked until you press Edit. It reads as a link, not a
   field, so the row makes clear the value is set rather than waiting on you. */
.st-link-saved { flex: 1 1 220px; min-width: 0; font-family: var(--mono); font-size: 12px; line-height: 20px;
  color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; transition: color .16s; }
.st-link-saved:hover { color: var(--accent); }

.st-season { margin-top: 12px; }
.st-season-head { display: flex; align-items: center; gap: 12px; padding-bottom: 6px; border-bottom: 1px solid var(--hair); }
.st-season-name { font-family: var(--mono); font-size: 10.5px; line-height: 16px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink); }
.st-season-n { font-family: var(--mono); font-size: 10.5px; line-height: 16px; color: var(--ink-2); margin-right: auto; }

.st-eps { display: flex; flex-direction: column; }
.st-ep { display: grid; grid-template-columns: 20px 68px 1fr auto; align-items: center; gap: 12px; padding: 7px 0; }
.st-ep-code { font-family: var(--mono); font-size: 11px; line-height: 16px; color: var(--ink-2); }
.st-ep-name { font-size: 13px; line-height: 18px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.st-ep-date { font-family: var(--mono); font-size: 10px; line-height: 16px; color: var(--ink-3); white-space: nowrap; }
.st-ep--future .st-ep-code, .st-ep--future .st-ep-name { color: var(--ink-3); }

/* The tick. Actionable on hover (accent), done when checked (jade). */
.st-tick { width: 18px; height: 18px; flex-shrink: 0; padding: 0; cursor: pointer;
  background: var(--base); border: 1px solid var(--hair); color: transparent;
  display: inline-flex; align-items: center; justify-content: center;
  transition: border-color .14s, background .14s, color .14s; }
.st-tick svg { width: 12px; height: 12px; stroke: currentColor; stroke-width: 2; fill: none; stroke-linecap: round; stroke-linejoin: round; }
.st-tick:hover { border-color: var(--accent); }
.st-tick[aria-checked="true"] { border-color: var(--jade); background: var(--jade-wash); color: var(--jade); }
.st-tick--locked { width: 18px; height: 18px; flex-shrink: 0; border: 1px dashed var(--ink-3); background: transparent; }

/* Schedule tab */
.st-releases { display: flex; flex-direction: column; }
.st-release { display: grid; grid-template-columns: 168px 1fr auto; align-items: baseline; gap: 14px; padding: 9px 0; border-top: 1px solid var(--hair); }
.st-release:first-child { border-top: 0; }
.st-release-date { font-family: var(--mono); font-size: 11px; line-height: 18px; color: var(--ink-2); }
.st-release-show { font-size: 14px; line-height: 20px; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.st-release-ep { font-family: var(--mono); font-size: 11px; line-height: 18px; color: var(--ink-2); }

/* Completed tab stats */
.st-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; margin-bottom: 16px; background: var(--hair); border: 1px solid var(--hair); }
.st-stat { display: flex; flex-direction: column; gap: 6px; padding: 18px 16px; background: var(--panel); }
.st-stat-n { font-family: var(--mono); font-size: 22px; line-height: 26px; color: var(--ink); }
.st-stat-l { font-family: var(--mono); font-size: 9.5px; line-height: 14px; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-2); }

@media (max-width: 720px) {
  .st-stats { grid-template-columns: repeat(2, 1fr); }
  .st-release { grid-template-columns: 1fr auto; }
  .st-release-show { grid-column: 1 / -1; order: 3; white-space: normal; }
  .st-ep { grid-template-columns: 20px 60px 1fr; }
  .st-ep-date { display: none; }
}


/* ── SCROLLBARS ─────────────────────────────────────────────────────── */
* { scrollbar-width: thin; scrollbar-color: var(--hair) var(--base); }
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-track,
*::-webkit-scrollbar-corner { background: var(--base); }
*::-webkit-scrollbar-thumb { background: var(--hair); border: 2px solid var(--base); }
*::-webkit-scrollbar-thumb:hover { background: var(--ink-3); }

/* ── RESPONSIVE ─────────────────────────────────────────────────────── */
@media (max-width: 880px) {
  .rack { grid-template-columns: repeat(2,1fr); }
  .cross { display: none; }

  /* THE EDITOR SHELL, STACKED. Rail on top, stage, panel underneath. The 1px
     grid gap goes on drawing the rules, so there is still nothing to border.

     The rail WRAPS. It does not scroll. A rail that scrolls sideways puts a
     tool off the edge of the screen and leaves a scrollbar as the only clue
     that it is there, which is not a clue. */
  .workspace { grid-template-columns: 1fr; height: auto; }
  .rail { flex-direction: row; flex-wrap: wrap; overflow: visible; }
  /* FOUR to a row, and no more, whatever the width. A basis of 84px let the row
     hold as many as happened to fit, so eight tools came out five and three: two
     rows that did not agree with each other. A quarter of the rail, less the
     three 2px gaps between them, is four and four. */
  .rail-tool { flex: 1 1 calc(25% - 6px); min-width: 0; padding: 10px 6px; }
  /* The bar moves to the bottom edge and scales the other way. */
  .rail-tool::before {
    top: auto; bottom: 0; left: 0; right: 0; width: auto; height: 2px;
    transform: scaleY(0); transform-origin: bottom;
  }
  .rail-tool.active::before { transform: scaleY(1); }
  .rail-zoom {
    margin-top: 0; flex: 1 1 100%; flex-direction: row; align-items: center;
    gap: 12px; border-top: 0; padding: 4px 6px 2px;
  }
  .rail-zoom .slider { flex: 1; }
  .stage { min-height: 420px; }

  /* THE FIT STAGE NEEDS A CEILING, NOT A FLOOR, and this is the one rule that
     made zoom stop being zoom on a phone.

     .stage--fit is a WINDOW onto one object: the frame is sized in pixels by
     the zoom and panned by a transform, so the stage has to be the thing that
     stays still while the frame grows behind it. Above, .workspace is fixed at
     700px and the stage inherits a real height from it. Here the stack sets the
     workspace to auto, so the stage had only a 420px FLOOR and took its height
     from its own contents. Zoom then grew the frame, the stage grew with it,
     nothing ever overflowed, and the picture simply got bigger down a longer
     page.

     It also fed back into the arithmetic. The fit scale is computed from
     stage.clientHeight, so a stage measuring itself against the frame it is
     supposed to be containing was reading a height it had got from the last
     frame. A ceiling breaks that loop as well.

     min-height: 0 is not tidying. The floor above still applies to .stage--fit
     and would win on any phone shorter than about 750px, which is most of them,
     putting the whole fault straight back. */
  .stage--fit { height: 56vh; height: 56dvh; max-height: 460px; min-height: 0; }

  /* FULLSCREEN, STACKED. #editor is a flex column and the workspace is allowed
     to shrink inside it, but the workspace is a GRID whose rows size to their
     contents, so the 420px floor above pushed the column past the viewport and
     .workspace's overflow: hidden took the bottom off the photograph rather
     than scrolling to it. Name the rows, give the middle one all the room that
     is left, and let the stage fill it. Scoped to the stacked layout: on a wide
     screen the workspace is three COLUMNS and naming rows would break it. */
  #editor:fullscreen .workspace { grid-template-rows: auto minmax(0, 1fr) auto; }
  #editor:fullscreen .stage--fit { height: auto; max-height: none; }

  /* WORD GAMES, STACKED. The board on top, the clues underneath. The 1px grid
     gap goes on drawing the rule, so there is still nothing to border. The
     clue columns stay side by side, because Across and Down are two lists and
     stacking them buries Down under a hundred clues. */
  .game--split { grid-template-columns: 1fr; }
  .clues { height: auto; }
  .clue-list { max-height: 320px; }

  /* AND THE 576px FLOOR COMES OFF. It exists so the three games are one box on
     a wide screen and switching between them does not shift the page. That is
     worth a little slack there and nothing at all here: a phone shows one game,
     you cannot see the other two to compare, and six rows of an 18px board left
     a quarter of a screen of empty panel above the board while the keyboard sat
     off the bottom of it. Vertical room is the scarcest thing on this layout
     and this was spending it on an alignment nobody can see. */
  .wg-level { min-height: 0; }

  /* THE HEALTH HUB, STACKED. The app section claimed this collapse in a
     comment and never wrote the rule, so a fixed 380px column sat on a 360px
     phone and pushed the whole page sideways. Who you are on top, what you
     did underneath: the same reading order, one column at a time. */
  .hh-cols { grid-template-columns: minmax(0, 1fr); }

  /* THE AGENDA, ONE WEEK AT A TIME. Four columns of meals on a phone is four
     unreadable columns. The rows stay, the weeks become a choice, and the
     navigator that was hidden on a wide screen appears to make it. */
  .hh-agenda { grid-template-columns: 56px minmax(0, 1fr); }
  .hh-agenda-nav { display: block; }
  .hh-agenda[data-week="0"] [data-w]:not([data-w="0"]),
  .hh-agenda[data-week="1"] [data-w]:not([data-w="1"]),
  .hh-agenda[data-week="2"] [data-w]:not([data-w="2"]),
  .hh-agenda[data-week="3"] [data-w]:not([data-w="3"]) { display: none; }
}
@media (max-width: 640px) {
  header { padding: 44px 0 0; }
  .plate { padding: 22px 16px; }
  .page-head { padding: 32px 0 28px; }
}
@media (max-width: 560px) {
  .wrap { padding: 0 20px; }

  /* THE BAR WRAPS, AND ITS GROUPS WRAP TOO. .editor-bar already wrapped; the
     .bar-groups INSIDE it did not, so on a phone the three game buttons and the
     date could not get onto separate lines. They stayed on one, .btn's
     white-space: nowrap refused to fold the labels, and the date printed itself
     straight over the top of CROSSWORD.

     min-width: 0 on .btn--pick for the same reason: a 108px floor times three
     plus the gaps is wider than a phone, and a button that cannot shrink and
     cannot wrap has nowhere left to go but over its neighbour. */
  .editor-bar .bar-group { flex-wrap: wrap; }
  .btn--pick { min-width: 0; }
  /* Seven cells share about 280px here, so roughly 40px each. The numbers stay
     legible and everything around them gives up its padding first. A strip
     that scrolls sideways would hide a day behind the edge, which is worse. */
  .hh-col { padding: 16px; }
  .hh-cal-h { padding: 5px 1px; font-size: 9px; letter-spacing: 0; }
  .hh-cell { padding: 6px 1px; min-height: 42px; }
  .hh-cell-v { font-size: 10.5px; }
  .hh-srow { grid-template-columns: 88px minmax(0, 1fr); gap: 8px; padding: 8px 10px; }
  .hh-sheet > .hh-sechead { padding: 0 10px; }
  .hh-check-row { padding: 7px 10px; }
  /* The editor stacks rather than squeezing a 110px field to nothing. */
  .hh-edit .input, .hh-edit .hh-dom { flex: 1 1 100%; }
  .hh-pair .btn, .hh-edit .btn { flex: 1 1 0; min-width: 0; }
  /* aspect-ratio, not just width. The canvas is 192 square and the rule below
     used to set only the width, so on any column narrower than 192px it was
     drawn square and displayed as a rectangle: the crop preview lied about the
     crop. height: auto hands the sizing to the ratio. */
  .hh-crop-canvas, .hh-crop .slider { width: 100%; max-width: 192px; }
  .hh-crop-canvas { aspect-ratio: 1 / 1; height: auto; }
  .hh-mrow { grid-template-columns: minmax(0, 1fr); gap: 0; }
  .hh-mnote { margin-left: 0; }
  .hh-addrow .input, .hh-addrow .hh-qty, .hh-addrow .seg { flex: 1 1 100%; }
  .rack { grid-template-columns: 1fr; }
  input[type=search] { width: 100%; }
  .field:first-child, .field:last-child { flex: 1 1 100%; }
  .seg { width: 100%; } .seg button { flex: 1; min-width: 0; }
  .controls { margin: 32px 0 18px; }
  /* the readout wrapped as flex: ragged right edge, stray border-rights
     ending rows. As a 2-up grid it stays a machined block. */
  .readout { display: grid; grid-template-columns: 1fr 1fr; }
  .cell {
    margin-left: 0; padding: 11px 14px; flex-wrap: wrap; gap: 4px 10px;
    border-right: 1px solid var(--hair); border-bottom: 1px solid var(--hair);
  }
  .cell:nth-of-type(2n) { border-right: 0; }
  .cell:last-child { grid-column: 1 / -1; border-right: 0; border-bottom: 0; }
  .vault-top { flex-direction: column; align-items: stretch; }
  .vault-actions .btn { flex: 1; }

  .pane { padding: 14px; }
  /* One list, and the two heads keep it apart. Two columns of clues in 320px is
     four words a line. */
  .clues { grid-template-columns: 1fr; }
  /* The keys shrink before the row wraps. A wrapped QWERTY is not a QWERTY. */
  .key { flex: 1 1 0; height: 42px; font-size: 10px; }
  .key--wide { flex: 1.6 1 0; }
  .krow { gap: 4px; }
}

/* ── TOUCH ──────────────────────────────────────────────────────────── */
/* The pin only ever appeared on .bay:hover. On a touch device there is no
   hover, so pinning was unreachable. It was the one piece of real functionality
   gated behind a hover affordance. .cb and .open stay hover-only: they are
   affordances, not features. */
@media (hover: none) {
  .pin { opacity: 1; transform: none; padding: 10px; top: 11px; right: 19px; }
  /* Rotate and remove are functionality, not affordance. There is no hover here
     to reveal them with, so they are always out. */
  .page-acts { opacity: 1; }

  /* SIXTEEN PIXELS, AND IT IS NOT A TYPE DECISION. Mobile Safari zooms the
     whole page the moment it focuses a field whose text is smaller than 16px,
     and it does not reliably zoom back out afterwards. Every field on this
     site was 12px or 14px, so every field on this site threw the layout
     sideways when you tapped it. The box stays 40px: the padding gives back
     exactly what the larger line box takes, so nothing around it moves. */
  .input, .select { font-size: 16px; line-height: 22px; padding: 8px 12px; }
  .select { padding-right: 30px; }
  input[type=search] { font-size: 16px; line-height: 22px; padding: 8px 13px; }

  /* THE TARGET GROWS, THE BUTTON DOES NOT. An icon button is 34px, a thumb
     wants about 44, and simply making it 44 would coarsen a control that is
     the right size for the design and is drawn beside things sized to match
     it. So the hit area is a transparent overlay, centred, larger than the
     box it belongs to and invisible in every other respect.

     NOT on --sm. That one is 24px and sits on the page tiles in the workshop,
     inches from its neighbours, and a 44px target there would start catching
     presses meant for the tile next door. Under-sized is an irritation;
     overlapping targets do the wrong thing, which is worse. */
  .icon-btn:not(.icon-btn--sm) { position: relative; }
  .icon-btn:not(.icon-btn--sm)::after {
    content: ''; position: absolute; top: 50%; left: 50%;
    width: 44px; height: 44px; transform: translate(-50%, -50%);
  }

  /* THE TWO THAT NEEDED ROOM RATHER THAN A BIGGER TARGET. An episode tick is
     18px in a row 32px tall, and a page tile's buttons are 24px inches from
     the next tile. Growing either target alone would make it overlap its
     neighbour, so the ROW grows first and the target grows into the space
     that opens up. Both stay the size they are drawn; only the gaps change. */
  .st-ep { padding: 12px 0; }
  .st-tick, .st-tick--locked { width: 22px; height: 22px; }
  .st-tick { position: relative; }
  .st-tick::after {
    content: ''; position: absolute; top: 50%; left: 50%;
    width: 40px; height: 40px; transform: translate(-50%, -50%);
  }
  .page-acts { gap: 8px; }
  .icon-btn--sm { position: relative; }
  .icon-btn--sm::after {
    content: ''; position: absolute; top: 50%; left: 50%;
    width: 34px; height: 34px; transform: translate(-50%, -50%);
  }

  /* HOVER IS NOT AN INPUT HERE, so anything that only appears on hover is
     simply gone. These two were hiding real things behind it.

     :focus-visible is the wrong hook on a touch screen: it is a heuristic
     about keyboards and it usually declines to match a tap, so the popover
     could not be opened at all. Plain :focus can. */
  .info-dot:focus .info-pop { opacity: 1; transform: none; }
  .yt-play { opacity: 1; }
}

/* ── REDUCED MOTION ─────────────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation: none !important; transition: none !important; }
  .roll, .wordmark ch { opacity: 1; }
  .bk { opacity: .34; }
  .bay:hover .slot { transform: none; background: var(--accent); }
  .cb, .open { opacity: 1; transform: none; }
  /* The bar carries its own transform, which reduced motion leaves in place
     while killing the tween, so hovered cards snapped to 28px and the rest sat
     at 14px, a different width on every card. Pin it to the full bar and hold
     it there. */
  .open i, .bay:hover .open i { transform: scaleX(1); }
  .search-shell::after { transform: scaleX(1); opacity: 0; }
  .search-shell:focus-within::after { opacity: 1; }
  #boot, #mark, .flux { display: none; }
}
