/* Vocabelo — "The Ladder" UI (design-claude-v1, screens 1a + 1c).
   Deliberately not sharing anything with learn.css: that theme is light and
   this one is a cool near-black, so a shared stylesheet would fight itself. */

:root {
  --bg:         #141519;
  --ink:        #fbfbfa;
  --you:        oklch(.72 .14 295);
  --you-bright: oklch(.82 .10 295);
  --you-soft:   oklch(.88 .07 295);
  --word:       oklch(.78 .12 85);
  /* Sign-in errors. Kept as light as --you and --word so a failed login reads
     as information rather than alarm, and far enough round the wheel (25 vs 85
     vs 295) to survive the same colour-vision check as the other two. */
  --bad:        oklch(.74 .15 25);
  --line:       rgba(255, 255, 255, .09);
  --dim:        rgba(255, 255, 255, .34);
  --dimmer:     rgba(255, 255, 255, .26);
  --sans: Archivo, system-ui, -apple-system, 'Segoe UI', sans-serif;
  --mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
  --pad: 22px;
}

* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }

html, body {
  margin: 0; height: 100%;
  background: var(--bg); color: var(--ink);
  font-family: var(--sans);
  overscroll-behavior: none;
}
body { overflow: hidden; }

button, a.action { font-family: inherit; }
.hidden { display: none !important; }
/* Not-yet-revealed. Keeps the element's box in the layout so revealing changes
   nothing but opacity — the card is centred in a flex zone, so one pixel of
   height change nudges the clue and the sentence with it. */
.pending { opacity: 0 !important; pointer-events: none; }
.mono { font-family: var(--mono); }
.dim  { color: var(--dim); }
.num-you  { color: var(--you-bright); }
.num-word { color: var(--word); }

.screen {
  position: fixed; inset: 0;
  display: flex; flex-direction: column;
  overflow-y: auto; overscroll-behavior: contain;
  /* The card leaves by translating sideways, which grows this container's
     scrollWidth (390 -> 492 measured). `overflow-y: auto` makes the x axis
     compute to `auto` too, so for ~200ms every grade the whole screen is
     pannable — and when the transform is dropped the browser clamps
     scrollLeft back to 0 and the UI snaps sideways. Clip x, and the exit
     can't create a scroll range at all. */
  /* `hidden` still keeps a scroll range the browser can jump to on focus;
     `clip` removes it outright. Ordered so old engines keep the fallback. */
  overflow-x: hidden;
  overflow-x: clip;
  overscroll-behavior-x: none;
  /* Both unset unless a keyboard is open, so nothing here changes the normal
     case. iOS does not shrink the layout viewport for the keyboard -- it
     scrolls the visual one -- so a fixed inset:0 screen keeps its full height
     and the bottom third, the action button, ends up under the keys. Android
     behaves the same way by default. Pinning top and height to the visual
     viewport is what puts the button back on screen, on both. */
  top: var(--vv-top, 0px);
  height: var(--vv-h, auto);
}
.safe-top    { flex: none; height: calc(28px + env(safe-area-inset-top)); }
.safe-bottom { flex: none; height: calc(16px + env(safe-area-inset-bottom)); }

/* ── The bout (1a) ──────────────────────────────────────────────────────── */

.bout-top {
  flex: none; display: flex; align-items: center; justify-content: space-between;
  padding: 2px var(--pad) 0; min-height: 30px;
}
.chip {
  display: flex; align-items: center; gap: 7px;
  padding: 5px 10px 5px 8px; border: none; border-radius: 999px;
  background: rgba(255, 255, 255, .05); cursor: pointer;
  font: 500 11px var(--mono); color: rgba(255, 255, 255, .62); letter-spacing: .02em;
}
.chip-dot { width: 7px; height: 7px; border-radius: 2px; background: var(--you); }
/* Both chips navigate, so both say so. Without this they are silent hit
   targets — nothing on a touch screen shows a cursor. */
.chip-caret { font-size: 9px; color: rgba(255, 255, 255, .38); margin-left: 1px; }
.chip:active { background: rgba(255, 255, 255, .1); }
.chip-deck { padding: 5px 9px 5px 8px; }
.chip-deck #ladderDeckLabel { font: 500 10.5px var(--mono); letter-spacing: .02em; }
.pill {
  border: 1px solid rgba(255, 255, 255, .16); background: none;
  color: rgba(255, 255, 255, .66); font: 500 10.5px var(--mono);
  padding: 5px 11px; border-radius: 999px; cursor: pointer;
  animation: rise .18s ease both;
}

/* Undo appears and disappears, so the sound toggle cannot be a sibling of it
   under `space-between` — the toggle would slide across the header every time
   a card is graded. Grouping them pins the toggle to the right edge. */
.top-right { display: flex; align-items: center; gap: 8px; }
.icon-btn {
  display: flex; border: none; background: none; padding: 3px;
  cursor: pointer; color: rgba(255, 255, 255, .55);
}
.icon-btn svg { width: 17px; height: 17px; fill: none; stroke: currentColor;
                stroke-width: 1.5; stroke-linejoin: round; stroke-linecap: round; }
.icon-btn .spk { fill: currentColor; stroke-linejoin: round; }
/* Off state: the wave is what "audible" looks like, so it goes rather than
   being dimmed, and the slash makes the state readable at a glance. */
.icon-btn .slash { opacity: 0; transition: opacity .12s ease; }
.icon-btn.off { color: rgba(255, 255, 255, .28); }
.icon-btn.off .wave  { opacity: 0; }
.icon-btn.off .slash { opacity: 1; }
.icon-btn:active { color: rgba(255, 255, 255, .85); }
/* While a clip is playing, the icon is the only indication anything is
   happening — the card itself does not change. */
.icon-btn.playing { color: var(--you-bright); }

.matchup {
  flex: none; display: flex; align-items: flex-end; justify-content: space-between;
  padding: 22px var(--pad) 0;
}
.side { display: flex; flex-direction: column; gap: 2px; }
.side-you  { background: none; border: none; padding: 0; cursor: pointer; align-items: flex-start; }
.side-word { align-items: flex-end; }
.side-label {
  font: 500 9.5px var(--mono); letter-spacing: .14em; color: var(--dim);
}
.side-val { display: flex; align-items: baseline; gap: 8px; }
.side-you .num-you  { font: 700 34px/1 var(--mono); letter-spacing: -.02em; }
.side-word .num-word { font: 700 20px/1.4 var(--mono); letter-spacing: -.01em; }
.delta { font: 700 15px/1 var(--mono); color: var(--you); }
.delta.show { animation: tick 1.5s ease both; }

.track {
  flex: none; position: relative; height: 3px; border-radius: 2px;
  margin: 14px var(--pad) 0; background: rgba(255, 255, 255, .08);
}
.mark {
  /* left is the mark's centre, not its left edge — otherwise a mark at 100%
     hangs off the end of the bar and 0% and 100% are not symmetric. */
  position: absolute; top: -3px; width: 3px; height: 9px; border-radius: 1.5px;
  transform: translateX(-50%);
  transition: left .22s ease;
}
.mark-you  { background: var(--you); }
.mark-word { background: var(--word); }
/* The axis is fixed at 800..2500, so the endpoints are static text in the
   markup. Absolutely positioned and aligned inwards from each end, so they cost
   no layout, cannot shift the card below, and cannot overhang the bar. */
.track-end {
  position: absolute; top: 8px; font: 400 9.5px/1 var(--mono);
  color: rgba(255, 255, 255, .22); pointer-events: none;
}
.track-end.lo { left: 0; }
.track-end.hi { right: 0; }
/* A hairline tick at each end of the scale. */
.track-end::before {
  content: ''; position: absolute; top: -9px;
  width: 1px; height: 5px; background: rgba(255, 255, 255, .12);
}
.track-end.lo::before { left: 0; }
.track-end.hi::before { right: 0; }
.track-legend {
  flex: none; display: flex; justify-content: space-between;
  padding: 18px var(--pad) 0; font: 400 10.5px var(--sans); color: rgba(255, 255, 255, .36);
}

/* The card owns the slack. The design's mock left ~500px dead between the
   sentence and the buttons; centring inside a flex:1 zone with a capped
   card height keeps it composed at any device size. */
.card-zone {
  flex: 1 1 auto; min-height: 0;
  /* One grid cell, every card in it. During a swipe the departing card and the
     next one are both here, stacked, so neither has to wait for the other and
     no node is ever reset while it is still moving. */
  display: grid; grid-template-areas: 'stack'; align-items: center; justify-items: center;
  padding: 8px 26px; cursor: pointer;
  /* Without this the browser claims the horizontal pan for scrolling and
     fires pointercancel before the card ever moves — swipe grading is
     dead on touch and works only under a mouse. */
  touch-action: none;
  user-select: none; -webkit-user-select: none;
  -webkit-tap-highlight-color: transparent;
  /* Clip the departing card here so it never contributes overflow to the
     screen above. `clip` on one axis with `visible` on the other is not a
     scroll container, so a card taller than the zone still shows in full.
     The margin pushes the clip edge out to the screen edge, past this
     element's 26px padding, so the exit isn't visibly cut short. */
  overflow-x: clip; overflow-clip-margin: 26px;
}
.card {
  grid-area: stack;
  display: flex; flex-direction: column; align-items: center; width: 100%;
  transition: transform .18s ease, opacity .18s ease;
}
/* The incoming card waits for the outgoing one to finish fading. Both are
   transparent — no card background — so a straight cross-fade put two legible
   words on top of each other for ~90ms: measured both at ~.46 opacity, 80px
   apart. The delay MATCHES THE EXIT'S OPACITY DURATION, so one is gone before
   the other starts — the two numbers move together or the overlap comes back.
   Declared above `.leaving-*` so a commit always wins the cascade. */
.card.entering { transition: opacity .15s ease .24s; }
/* The exit throws the card clean off the screen and accelerates on the way out.
   A fixed 120px target was shorter than a real flick — measured at 300px — so
   the card *reversed direction* under the finger, travelling 160px backwards
   over 150ms before it vanished. Even a slow 110px swipe decelerated from
   20px/frame to 0.1px/frame for its last 10px, which reads as the card
   stopping dead. 115vw is past the edge of any phone, so the exit is always a
   continuation of the gesture, never a correction of it. The card-zone's
   overflow clip keeps that distance from creating a scroll range.

   Linear, not eased: an ease-in starts at ~0 speed, so handing a 20px/frame
   flick over to it stalled the card for 30ms before it moved — a hitch in the
   same place as the old reversal. The card is fully transparent well before it
   lands, so nothing is gained by easing a tail nobody sees.

   The DURATION sets the speed, since the distance is fixed. Measure it on the
   distance grade() actually uses — `innerWidth * .9`, i.e. 351px on a 390px
   phone — not on the 115vw below, which is only the fallback for the case
   where the inline transform never lands. At .22s that was ~27px/frame, faster
   than the ~20px/frame a real flick travels, so the card outran the gesture
   that threw it and the button path read as a cut rather than a throw. .4s
   puts it at ~15px/frame (measured 876px/s), which is calm enough to follow.

   Opacity is deliberately much shorter than transform: the card is invisible
   at 239ms (measured) and keeps travelling to 400ms, so the tail costs nothing.
   `.entering`'s delay must stay >= this opacity duration — at .24s vs .24s the
   overlap check reads 0 frames with two legible cards.

   grade() overrides the transform inline to start the throw from wherever the
   finger let go; these values are the fallback for the button path. */
.card.leaving-right,
.card.leaving-left {
  transition: transform .4s linear, opacity .24s ease-out;
  opacity: 0;
}
.card.leaving-right { transform: translateX(115vw) rotate(9deg); }
.card.leaving-left  { transform: translateX(-115vw) rotate(-9deg); }
/* A flagged card is withdrawn, not judged (LIFE-44). Left is "missed" and right
   is "knew it"; sending a report out along either of them says the learner
   answered it, so this one drops straight down instead. Same transition block
   above, so retire()'s transform-end still fires. */
.card.leaving-down {
  transition: transform .4s linear, opacity .24s ease-out;
  opacity: 0;
  transform: translateY(110vh) scale(.92);
}
/* The incoming card fades in exactly where it will sit. `rise` translated it up
   14px, which put a vertical move immediately after a horizontal one — two
   directions inside 200ms, which reads as a jitter rather than one motion.
   The entrance is a plain opacity transition driven from JS (insert at 0, clear
   on the next frame) rather than a keyframe animation: an animation's fill-mode
   pins the property it animates, which is how a stuck `.entering` used to hold
   the card still and kill the drag — and would now also stop `.leaving` from
   fading it back out. A transition can't pin anything. */

.pos-tag {
  font: 500 10px var(--mono); letter-spacing: .16em; color: rgba(255, 255, 255, .3);
  margin-bottom: 18px; text-transform: uppercase; min-height: 13px;
}
.clue {
  font: 400 27px/1.32 var(--sans); color: var(--ink);
  letter-spacing: -.01em; text-align: center; text-wrap: pretty;
}
/* Long clues are common on the pre-Gemma decks (max 204 chars). Step the size
   down rather than letting a card overflow its zone. */
.clue.long   { font-size: 22px; line-height: 1.34; }
.clue.longer { font-size: 18px; line-height: 1.38; }

.sentence {
  display: flex; flex-direction: column; align-items: center; gap: 7px;
  width: 100%; margin-top: 24px; padding-top: 20px;
  border-top: 1px solid var(--line);
}
.sent-nl { font: 400 15px/1.45 var(--sans); color: rgba(255, 255, 255, .62); text-align: center; text-wrap: pretty; }
.sent-en {
  font: 400 13.5px/1.45 var(--sans); color: var(--dim); text-align: center;
  text-wrap: pretty; transition: opacity .16s ease;
}
/* The blank carries the answer from first paint, hidden. Sizing it to the real
   word means filling it in can't re-wrap the sentence.

   The underline lives on `.blank` and the word on `.blank-word`, because the
   two have opposite jobs on reveal: the underline goes away, the word arrives.
   The word arrives by OPACITY — the same property, duration and easing as
   `.answer` — so the card's two Dutch words are driven by one identical
   transition and cannot drift apart (LIC-138).

   The cause was that the blank interpolated `color` from `transparent`, which
   repaints on the main thread, while `.answer` faded on the compositor — the
   standard way two "same .16s" fades drift apart on a busy device.

   Verified at the pixel level, not just in computed style: a screencast of a
   real tap, cropped to each word and scored against its own before/after
   frames, puts the two within 1.4ms of each other at every stage of the fade —
   0.4ms at the half-way point — and that holds at 1x, 6x and 20x CPU
   throttling. One frame is 16.7ms, so they arrive on the same frame.

   An earlier version of this note guessed that any residual stagger would be
   contrast, the 42px answer supposedly reading ~1.44x the 15px sentence word at
   equal alpha. That is wrong, and measuring it is what disproved it: at 2x DPR
   both words keep fully-covered core pixels, so peak contrast is identical to
   three decimals across the whole 0..1 alpha sweep. Do not stagger the two
   durations to "compensate" — there is nothing to compensate for. */
.blank {
  display: inline-block; min-width: 44px; border-bottom: 1.5px solid var(--word);
  vertical-align: baseline; font-weight: 600; color: var(--word);
  transition: border-bottom-color .16s ease;
}
.blank-word { display: inline-block; transition: opacity .16s ease; }

/* ── Revealed vs not ─────────────────────────────────────────────────────────
   The state lives on the card, so a freshly built card is unrevealed by rule
   rather than by a class the JS has to remember to re-apply. That distinction
   is the whole bug this replaced: re-applying it meant the answer transitioned
   1 -> 0 on the new word, i.e. the answer was legible for ~150ms before you
   had recalled anything, cross-fading against the hint coming the other way. */
.card:not(.revealed) .answer     { opacity: 0; }
.card:not(.revealed) .blank-word { opacity: 0; }
.card:not(.revealed) .sent-en    { opacity: 0; }
.card.revealed .hint             { opacity: 0; }
.card.revealed .blank            { border-bottom-color: transparent; }

/* One cell, two children: the slot is always as tall as the answer. */
.reveal-slot {
  display: grid; width: 100%; padding-top: 26px; min-height: 70px;
}
.reveal-slot > * { grid-area: 1 / 1; align-self: center; justify-self: center; }
.answer {
  font: 600 42px/1.05 var(--sans); color: var(--word);
  letter-spacing: -.03em; text-align: center; text-wrap: balance;
  transition: opacity .16s ease;
}
.hint {
  font: 400 13px/1.42 var(--sans); color: rgba(255, 255, 255, .28);
  text-align: center; transition: opacity .16s ease;
}

.flag {
  flex: none; border: none; background: none; color: var(--dimmer);
  font: 400 11px var(--sans); padding: 8px 12px; cursor: pointer;
  transition: opacity .16s ease;
}

.grade {
  flex: none; display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
  padding: 10px 18px 8px;
}
.grade-btn {
  height: 100px; border-radius: 26px; cursor: pointer;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px;
  /* A transition, not an animation: the buttons sit in the row from first paint
     as `.pending`, so revealing is an opacity change on a box that never moved.
     (An animation with fill-mode would pin opacity and kill the fade.) */
  transition: opacity .16s ease;
}
/* The grade row belongs to the card being answered, so on a commit it leaves
   with it — instantly, not over 160ms. Fading it out meant the answered
   question's buttons were still lit while its card was already off-screen. */
.grade-btn.instant, .flag.instant { transition: none; }
.grade-main { font: 600 19px var(--sans); }
.grade-sub  { font: 500 10px var(--mono); letter-spacing: .06em; }
.missed {
  border: 1px solid rgba(255, 255, 255, .16); background: rgba(255, 255, 255, .035);
  color: rgba(255, 255, 255, .82);
}
.missed .grade-sub { color: rgba(255, 255, 255, .4); }
.knew { border: 1px solid var(--you); background: var(--you); color: #15121f; }
.knew .grade-sub { color: rgba(21, 18, 31, .62); }
/* ── The Ladder (1c) ────────────────────────────────────────────────────── */

.ladder-top {
  /* `baseline` here would hang the deck button off the title's baseline and
     push it below the header box; it is a control now, not a label. */
  flex: none; display: flex; align-items: center; justify-content: space-between;
  padding: 0 var(--pad);
}
.ladder-title { font: 600 15px var(--sans); color: #f4f4f3; }

.rating-row { flex: none; display: flex; align-items: flex-end; gap: 12px; padding: 18px var(--pad) 0; }
.rating { font: 700 60px/1 var(--mono); color: var(--you-bright); letter-spacing: -.03em; }
.rating-delta { font: 500 12px var(--mono); color: rgba(255, 255, 255, .42); padding-bottom: 9px; }

.spark-wrap { flex: none; padding: 14px var(--pad) 0; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.spark-wrap:focus { outline: none; }
.spark-wrap:focus-visible { outline: 1px solid var(--you); outline-offset: 4px; border-radius: 6px; }
#spark { width: 100%; height: 54px; display: block; overflow: visible; }
.spark-empty {
  height: 54px; display: flex; align-items: center; justify-content: center;
  font: 400 12px var(--sans);
}
/* Peer ratings are context, not competition: recessive steps, deliberately
   below --you in both lightness and weight. The three hues are validated for
   colour-vision separation against #141519 and against each other, so they are
   fixed values rather than tints of the theme. */
.spark-legend {
  display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 9px;
  font: 400 10px var(--sans); color: rgba(255, 255, 255, .42);
}
.spark-key { display: inline-flex; align-items: center; gap: 5px; }
.spark-swatch { width: 9px; height: 2px; border-radius: 1px; flex: none; }
/* A line swatch for a series, a bar swatch for the effort bars -- the shape has
   to say which of the two encodings the key belongs to, since the bars are
   deliberately colourless and would otherwise read as a greyed-out peer. */
.spark-swatch.bar { width: 4px; height: 8px; }
.spark-key .k-elo { font: 500 10px var(--mono); color: rgba(255, 255, 255, .62); }
/* Window pill and span caption travel together on the right, so the reading
   ("which window") sits next to its own dates rather than across the row. */
.spark-tail { margin-left: auto; display: inline-flex; align-items: center; gap: 7px; }
.spark-range { font: 400 9.5px var(--mono); color: var(--dimmer); }
/* Outlined, unlike every other label on this row: it is the one element here
   that does something, and a tap target has to look unlike a caption. */
.spark-zoom {
  font: 500 9.5px var(--mono); color: rgba(255, 255, 255, .62);
  border: 1px solid rgba(255, 255, 255, .18); border-radius: 999px;
  padding: 1px 6px; line-height: 1.5;
}

/* A field the generator has not filled yet. Legible enough to read as an
   answer ("nothing here"), quiet enough not to compete with real content. */
.missing { color: rgba(255, 255, 255, .22); font-variant-numeric: normal; }

.field-block { flex: none; padding: 26px var(--pad) 0; }
.field-head { display: flex; justify-content: space-between; margin-bottom: 9px; }
.label { font: 500 9.5px var(--mono); letter-spacing: .14em; color: var(--dim); }
#metCount { letter-spacing: 0; }
.field {
  position: relative; background: rgba(255, 255, 255, .025);
  border-radius: 10px; padding: 11px 10px;
}
.dots { display: flex; flex-direction: column; flex-wrap: wrap; align-content: flex-start; height: 77px; gap: 3px; }
.dot { width: 5px; height: 5px; border-radius: 1.5px; flex: none; }
.level-line { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--you); opacity: .85; }
/* Positioned, not spaced-between: #legYou is placed under the level line by
   placeLevelLabel(). Height is pinned because all three children are out of
   flow and the row would otherwise collapse. */
.field-legend {
  position: relative; height: 13px; margin-top: 8px;
  font: 400 10px/13px var(--sans); color: rgba(255, 255, 255, .32);
}
.field-legend > span { position: absolute; top: 0; white-space: nowrap; }
#legLow  { left: 0; }
#legHigh { right: 0; }
#legYou  { left: 50%; transform: translateX(-50%); }
.you-text { color: oklch(.78 .12 295); }

/* Six columns rather than three, so a row of three and a row of two can both
   run the full width: the three play tiles span 2 each, the two scheduler
   tiles span 3. With `repeat(3, 1fr)` the second row's two tiles would sit in
   columns 1 and 2 and leave a hole under the third, which reads as a tile that
   failed to load rather than as a deliberate pair. */
.tiles { flex: none; display: grid; grid-template-columns: repeat(6, 1fr); gap: 10px; padding: 24px var(--pad) 0; }
.tile {
  background: rgba(255, 255, 255, .04); border-radius: 14px;
  padding: 13px 12px; display: flex; flex-direction: column; gap: 4px;
}
/* Scoped to `.tiles >`, not bare `.tile`: the word bank has its own unrelated
   `.tile` rule further down this file, and a bare grid-column here would land
   on every bank tile too. Inert there today (`.bank` is flex) and invisible if
   it ever stopped being. */
.tiles > .tile { grid-column: span 2; }
.tiles > .tile-wide { grid-column: span 3; }
.tile-num { font: 700 22px var(--mono); color: #f2f2f1; }
.tile-lab { font: 400 10px var(--sans); color: rgba(255, 255, 255, .42); }

.ladder-actions { flex: none; display: flex; flex-direction: column; gap: 9px; padding: 22px var(--pad) 0; }
.action {
  display: flex; align-items: center; justify-content: space-between;
  padding: 15px 18px; border-radius: 18px; border: 1px solid rgba(255, 255, 255, .1);
  background: none; cursor: pointer; text-decoration: none;
}
.action.primary { padding: 17px 18px; border-color: var(--you); background: rgba(140, 110, 255, .1); }
.action-text { display: flex; flex-direction: column; gap: 3px; align-items: flex-start; }
.action-main { font: 600 15px var(--sans); color: var(--you-soft); }
.action-main.sm { font: 500 14px var(--sans); color: rgba(255, 255, 255, .8); }
.action-sub { font: 400 11px var(--sans); color: rgba(210, 198, 255, .55); }
.action.primary .mono { color: rgba(210, 198, 255, .55); }

/* Now an <a> rather than a <button> (it leaves for the classic UI), so the two
   things a button gave for free have to be said: no underline, and centred text
   inside the box the flex parent stretches it to. */
.back {
  flex: none; margin: 20px auto 0; border: none; background: none;
  color: var(--dimmer); font: 400 12px var(--sans); padding: 10px 16px; cursor: pointer;
  text-decoration: none; text-align: center;
}

/* ── The grammar map (proof of concept) ─────────────────────────────────── */

.gmap-lede {
  flex: none; margin: 14px 0 0; padding: 0 var(--pad);
  font: 400 12.5px/1.5 var(--sans); color: var(--dim); max-width: 42ch;
}

.gmap-list {
  flex: none; display: flex; flex-direction: column;
  padding: 16px var(--pad) 8px;
}

/* ── Branches ─────────────────────────────────────────────────────────────
   The list is forty-one rules, and forty-one rules in one column is an
   inventory. The seven headings are the shape of the language itself
   (docs/grammar_map.py): word order, verb forms, tense, nouns, adjectives,
   pronouns, particles — so a rule is read as part of something rather than as
   the next line down.

   The heading is deliberately not a control: nothing collapses and nothing
   filters. Its whole job is to say where you are, and a chevron on each of the
   seven would turn a reading into a file tree. */

.gmap-branch { display: flex; flex-direction: column; }
.gmap-branch + .gmap-branch { margin-top: 22px; }

.gmap-branch-head {
  display: grid; grid-template-columns: 34px 1fr auto; align-items: baseline;
  gap: 0 12px; padding: 0 10px 2px 0;
}
/* The number is the map's own branch number, so the app and the drawing can be
   read side by side. Sitting in the same 34px column as the nodes puts it on
   the spine: the branch begins where its rules do. */
.gmap-branch-n {
  justify-self: center;
  font: 500 10.5px var(--mono); color: var(--dimmer);
}
.gmap-branch-name {
  font: 600 11px var(--sans); letter-spacing: .08em; text-transform: uppercase;
  color: rgba(255, 255, 255, .62);
}
.gmap-branch-count { font: 500 10.5px var(--mono); color: var(--dimmer); }
.gmap-branch-count em { font: 400 10.5px var(--sans); font-style: normal; }
/* A whole branch finished is worth more than a rule finished, and the count is
   the only thing that says so. */
.gmap-branch.done .gmap-branch-name { color: var(--you-soft); }
.gmap-branch.done .gmap-branch-count { color: var(--you-soft); }
/* The map's legend, per branch: rules you derive · lists you memorise · habits
   you absorb. One line, under the title, in the column the labels use. */
.gmap-branch-kind {
  grid-column: 2 / span 2; margin-top: 2px;
  font: 400 10.5px var(--sans); color: var(--dimmer);
}

.gmap-rows { display: flex; flex-direction: column; }

/* The spine is a segment per row rather than one line down the list, because
   only the row knows where its own node is: the node is centred in it, so the
   first and last segments stop at 50 % and the line begins and ends exactly on
   a node instead of on a guessed offset that a longer label would move. No gap
   between the rows, or the segments read as a dashed line.

   `first-child`/`last-child` are per `.gmap-rows`, which is why the rows have a
   container of their own: one spine per branch, starting and ending on a node,
   rather than a single line down the page that would run through the headings
   and read as one undivided list again. */
.gmap-item::before {
  content: ''; position: absolute; left: 17px; top: 0; bottom: 0;
  width: 1px; background: var(--line);
}
.gmap-item:first-child::before { top: 50%; }
.gmap-item:last-child::before  { bottom: 50%; }

.gmap-item {
  position: relative; display: grid;
  grid-template-columns: 34px 1fr auto; align-items: center; gap: 0 12px;
  padding: 11px 10px 11px 0; border: none; border-radius: 14px;
  background: none; color: inherit; text-align: left; cursor: pointer;
  font-family: inherit;
}
/* Held, not tapped (LIFE-35). The row dims and settles under the finger for the
   480 ms before the sheet opens, so a hold that is about to do something says so
   while there is still time to slide off it. `touch-callout` and `user-select`
   off: without them iOS raises its own copy/lookup bubble over the press and
   Android leaves the row highlighted blue afterwards. */
.gmap-item {
  -webkit-touch-callout: none;
  -webkit-user-select: none; user-select: none;
}
.gmap-item:active { background: rgba(255, 255, 255, .04); }
.gmap-item.pressing {
  background: rgba(255, 255, 255, .06);
  transform: scale(.985);
  transition: transform .28s ease, background .28s ease;
}

/* The node carries the state, so the row does not have to say it in words:
   hollow is untouched, half-filled is started, solid is spent. Ringed in the
   page background so the spine passes behind it rather than through it. */
.gmap-node {
  grid-row: 1 / span 2; justify-self: center;
  width: 13px; height: 13px; border-radius: 5px;
  border: 1px solid rgba(255, 255, 255, .22); background: var(--bg);
  box-shadow: 0 0 0 4px var(--bg);
}
.gmap-item.started .gmap-node { border-color: var(--you); background: rgba(140, 110, 255, .35); }
.gmap-item.done    .gmap-node { border-color: var(--you); background: var(--you); }

.gmap-label { font: 500 14px var(--sans); color: rgba(255, 255, 255, .86); }
.gmap-item.done .gmap-label { color: rgba(255, 255, 255, .55); }
.gmap-count { font: 500 10.5px var(--mono); color: var(--dim); justify-self: end; }
.gmap-item.done .gmap-count { color: var(--you-soft); }
.gmap-meta {
  grid-column: 2 / span 2; display: flex; align-items: center; gap: 10px;
  margin-top: 6px;
}
.gmap-sub { font: 400 10.5px var(--sans); color: var(--dimmer); white-space: nowrap; }

/* Kept thin and quiet: ten of these stacked is a lot of bar, and the number to
   its left already says the same thing exactly. */
.gmap-bar {
  flex: 1; height: 3px; border-radius: 2px;
  background: rgba(255, 255, 255, .08); overflow: hidden;
}
.gmap-fill { display: block; height: 100%; border-radius: 2px; background: var(--you); }

/* ── Deck sheet ─────────────────────────────────────────────────────────── */

.scrim { position: fixed; inset: 0; background: rgba(0, 0, 0, .5); z-index: 20; animation: fade .16s ease both; }
.sheet {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 21;
  max-height: 72vh; overflow-y: auto;
  background: #1b1c21; border-radius: 22px 22px 0 0;
  padding: 10px 18px calc(24px + env(safe-area-inset-bottom));
  animation: slideUp .2s cubic-bezier(.22, 1, .36, 1) both;
}
.sheet-grip { width: 36px; height: 4px; border-radius: 2px; background: rgba(255, 255, 255, .18); margin: 0 auto 14px; }
.sheet-title { font: 600 13px var(--sans); color: rgba(255, 255, 255, .55); }
.deck-list { display: flex; flex-direction: column; gap: 2px; margin-top: 10px; }
.deck-item {
  display: flex; align-items: center; justify-content: space-between;
  padding: 13px 12px; border: none; border-radius: 12px;
  background: none; color: rgba(255, 255, 255, .82);
  font: 500 14px var(--sans); cursor: pointer; text-align: left;
}
.deck-item.active { background: rgba(140, 110, 255, .12); color: var(--you-soft); }
.deck-item .mono { font-size: 11px; color: var(--dim); }

/* ── Queue sheet (LIFE-20) ──────────────────────────────────────────────── */

/* Two lines rather than the deck row's one: a deck is a name you recognise, a
   queue is a promise about the next twenty minutes and has to say what it is. */
.mode-item {
  display: flex; flex-direction: column; align-items: flex-start; gap: 3px;
  padding: 13px 12px; border: none; border-radius: 12px;
  background: none; color: rgba(255, 255, 255, .82);
  font: 500 14px var(--sans); cursor: pointer; text-align: left; width: 100%;
}
.mode-item.active { background: rgba(140, 110, 255, .12); }
.mode-main { font: 600 15px var(--sans); }
.mode-item.active .mode-main { color: var(--you-soft); }
.mode-sub  { font: 400 11px var(--sans); color: var(--dim); }
.mode-item.active .mode-sub { color: rgba(210, 198, 255, .55); }

/* ── Rule sheet (LIFE-35) ───────────────────────────────────────────────── */

/* Red only once armed. An unarmed row that is already the colour of the thing
   it will do reads as "this is dangerous, do not touch"; the point is that it
   is safe to touch and the second tap is not. */
.mode-item.danger .mode-main { color: rgba(255, 160, 150, .92); }
.mode-item.danger.armed {
  background: rgba(255, 90, 80, .14);
}
.mode-item.danger.armed .mode-main { color: rgb(255, 130, 120); }
.mode-item.danger.armed .mode-sub  { color: rgba(255, 190, 185, .72); }

.sheet-note {
  margin: 6px 12px 2px; max-width: 34ch;
  font: 400 11px/1.5 var(--sans); color: var(--dim);
}

/* ── Flag sheet (LIFE-44) ───────────────────────────────────────────────── */

/* Which card is being reported, so the note is written about a word the
   learner can still see rather than one they are remembering. */
.flag-word {
  margin: 6px 12px 0;
  font: 600 15px/1.35 var(--sans); color: rgba(255, 255, 255, .88);
  /* A word is one line. A grammar card is named by its rule and the English
     sentence it asks about (LIFE-47), which on the longest items runs to three.
     Clamped rather than left to grow: the field sits under this, and with the
     keyboard up the sheet is already capped at the visual viewport -- an
     unclamped title pushes the note field off the bottom of its own sheet. */
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3;
  overflow: hidden;
}
.flag-reason {
  display: block; width: 100%; margin: 12px 0 2px;
  padding: 12px 12px; border: 1px solid rgba(255, 255, 255, .12);
  border-radius: 12px; background: rgba(255, 255, 255, .05);
  color: rgba(255, 255, 255, .92); font: 400 15px var(--sans);
}
.flag-reason::placeholder { color: var(--dim); }
.flag-reason:focus { outline: none; border-color: rgba(140, 110, 255, .5); }
#flagSubmit .mode-main { color: var(--you-soft); }

/* The only sheet with a field in it, so the only one the keyboard can cover.
   Every sheet is fixed to the *layout* viewport, which does not shrink when
   the keys come up (see the viewport meta note in learn2.html); the screen
   itself is already pinned to the visual viewport with these two vars, so the
   sheet is lifted by the same amount rather than by a guess at keyboard height.
   Scoped to this sheet: the others cannot be open with a keyboard up, and
   moving them would be a change with no case behind it. */
body.kb-up .flag-sheet {
  bottom: calc(100% - var(--vv-top, 0px) - var(--vv-h, 100%));
  max-height: calc(var(--vv-h, 100%) - 12px);
}

.toast {
  position: fixed; left: 50%; bottom: calc(28px + env(safe-area-inset-bottom));
  transform: translateX(-50%); z-index: 30;
  background: rgba(255, 255, 255, .1); backdrop-filter: blur(8px);
  color: rgba(255, 255, 255, .88); font: 500 12px var(--sans);
  padding: 9px 16px; border-radius: 999px; animation: rise .16s ease both;
}

/* ── Sign in ────────────────────────────────────────────────────────────── */
/* The login page links this same stylesheet rather than carrying its own copy
   of the palette. It is the one screen a signed-out visitor sees, so it has to
   look like the app it is the door to -- and a second copy of --you would drift
   the first time the purple is retuned. */

/* Centring with `align-items: center` alone clips the top of a child taller
   than the viewport (an unreachable negative scroll offset). `margin: auto` on
   the child centres identically and degrades to top-aligned + scrollable, which
   matters here: a phone with the keyboard up is a ~260px viewport. */
.auth-screen {
  position: fixed; inset: 0; display: flex;
  overflow-y: auto; overscroll-behavior: contain;
  padding: calc(24px + env(safe-area-inset-top)) var(--pad)
           calc(24px + env(safe-area-inset-bottom));
}
/* No max-width of its own: the column is what gets capped (below, with the same
   460px the app uses), so the form fills the gutter here exactly as the grade
   buttons fill it there. A 360px card floating in the middle of a 460px column
   would be inset twice. */
.auth-card { margin: auto; width: 100%; }

.auth-brand { display: flex; align-items: center; gap: 10px; }
/* The same rounded square that marks the deck on the chip, at wordmark size. */
.auth-dot { width: 13px; height: 13px; border-radius: 4px; background: var(--you); flex: none; }
.auth-name { font: 600 27px var(--sans); color: var(--ink); letter-spacing: -.01em; }
.auth-tag {
  display: block; margin-top: 9px;
  font: 500 9.5px var(--mono); letter-spacing: .14em; color: var(--dim);
}

.auth-form { display: flex; flex-direction: column; gap: 14px; margin-top: 34px; }
.auth-field { display: flex; flex-direction: column; gap: 7px; }
.auth-field label { font: 500 9.5px var(--mono); letter-spacing: .14em; color: var(--dim); }
.auth-field input {
  width: 100%; background: rgba(255, 255, 255, .04);
  border: 1px solid rgba(255, 255, 255, .09); border-radius: 12px;
  padding: 13px 14px; color: var(--ink);
  font: 400 15px var(--sans); outline: none;
  transition: border-color .14s ease, background .14s ease;
}
.auth-field input::placeholder { color: rgba(255, 255, 255, .22); }
.auth-field input:focus {
  border-color: var(--you); background: rgba(140, 110, 255, .07);
}
/* Chrome paints autofilled inputs on a hard-coded near-white and ignores
   `background`. The inset shadow is the only way to repaint the box, and the
   text needs -webkit-text-fill-color because `color` is overridden too. */
.auth-field input:-webkit-autofill,
.auth-field input:-webkit-autofill:focus {
  -webkit-text-fill-color: var(--ink);
  -webkit-box-shadow: inset 0 0 0 100px #1b1c21;
  caret-color: var(--ink);
}

/* Solid purple, like the "Knew it" commit -- this is the affirmative action on
   the screen, not one of two choices, so it takes the filled treatment rather
   than the outlined `.action.primary`. */
.auth-submit {
  /* Set apart from the two fields: at the 14px form gap it read as a third row
     of the form rather than the thing that submits it. */
  margin-top: 12px; width: 100%; padding: 15px;
  border: 1px solid var(--you); border-radius: 18px; background: var(--you);
  color: #15121f; font: 600 15px var(--sans); cursor: pointer;
  transition: filter .14s ease;
}
.auth-submit:hover  { filter: brightness(.96); }
.auth-submit:active { filter: brightness(.9); }
/* The button is the same purple as the theme's focus ring, so a ring drawn in
   --you would vanish against it; offset white is the only readable option. */
.auth-submit:focus-visible {
  outline: 2px solid rgba(255, 255, 255, .75); outline-offset: 3px;
}

/* Announced, not just coloured: the message is inserted after a failed POST, so
   a screen reader lands on a page whose only change is this line. */
.auth-error {
  display: flex; align-items: flex-start; gap: 8px; margin-top: 16px;
  font: 400 12.5px/1.45 var(--sans); color: var(--bad);
  animation: rise .16s ease both;
}
.auth-error::before {
  content: ""; width: 5px; height: 5px; border-radius: 1.5px;
  background: var(--bad); flex: none; margin-top: 6px;
}

@keyframes rise    { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
@keyframes fade    { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { transform: translateY(100%); } to { transform: none; } }
@keyframes tick {
  0%   { opacity: 0; transform: translateY(9px); }
  30%  { opacity: 1; transform: none; }
  75%  { opacity: 1; transform: none; }
  100% { opacity: 0; transform: translateY(-5px); }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: .001ms !important; transition-duration: .001ms !important; }
}

/* Tablet / desktop: the loop is designed for one thumb, so cap the column
   rather than letting a 27px clue stretch across 1400px. */
@media (min-width: 620px) {
  .screen { max-width: 460px; margin: 0 auto; border-left: 1px solid var(--line); border-right: 1px solid var(--line); }
  .sheet  { max-width: 460px; margin: 0 auto; }
  /* Sign-in takes the same column and the same side rules, so the door is
     visibly the same width as the room. `inset: 0` plus a max-width leaves the
     auto margins to centre it, exactly as for .screen. */
  .auth-screen {
    max-width: 460px; margin: 0 auto;
    border-left: 1px solid var(--line); border-right: 1px solid var(--line);
  }
}

/* ── Grammar challenges (LIFE-12) ───────────────────────────────────────────
   A challenge is the same card box as a word: same width, same centring, same
   entrance and exit. Only the contents differ, so switching type mid-session
   never moves the furniture. */
.chal { justify-content: flex-start; }
.rule-tag { color: var(--you-soft); opacity: .75; }

/* The English is the prompt here, not a translation shown after the fact, so
   it carries the card's largest type -- the inverse of a word card. */
.chal-en {
  font: 400 21px/1.36 var(--sans); color: rgba(255, 255, 255, .82);
  text-align: center; text-wrap: pretty; letter-spacing: -.01em;
}
/* The words you have to produce. Amber is the word's colour everywhere else in
   the app, and this is the word's side of the bout. `background` rather than a
   colour swap alone: at 13px a hue change is easy to miss, a lit span is not. */
.chal-en .hl {
  /* Lighter fill and a brighter ink than the plain --word: a one-letter target
     ("I", "a") is a chip barely wider than its own glyph, and at 22% fill with
     --word ink that glyph disappears into its own highlight. */
  background: color-mix(in oklch, var(--word) 15%, transparent);
  color: oklch(.9 .09 85); border-radius: 4px; padding: 1px 4px; margin: 0 -2px;
}

.chal-nl-wrap {
  width: 100%; margin-top: 22px; padding-top: 18px;
  border-top: 1px solid var(--line); text-align: center;
}
.chal-nl { font: 400 16px/1.6 var(--sans); color: rgba(255, 255, 255, .66); }
/* The gap holds its width from empty, so filling it cannot re-wrap the
   sentence -- the same rule the word card's `.blank` follows. */
.chal-nl .slot {
  display: inline-block; min-width: 74px; padding: 0 6px;
  border-bottom: 1.5px solid var(--you); color: var(--you-bright);
  font-weight: 500; text-align: center;
}
.chal-nl .slot.filled { border-bottom-color: var(--you-soft); }

.chal-input-wrap { width: 100%; margin-top: 20px; }
.chal-input {
  width: 100%; height: 46px; border-radius: 14px; text-align: center;
  background: rgba(255, 255, 255, .05); color: var(--ink);
  border: 1px solid var(--line); font: 500 18px var(--sans);
  outline: none; caret-color: var(--you);
}
.chal-input:focus { border-color: var(--you); background: rgba(255, 255, 255, .07); }
.chal-input::placeholder { color: var(--dimmer); font-weight: 400; }
/* Once graded, what you typed is history: the slot now carries the right
   answer, so the input must stop competing with it for attention. */
.chal-input.spent { color: var(--dim); background: transparent; border-color: var(--line); }

/* Tiles wrap and centre; a bank of four short articles and a bank of three long
   verb forms both read as one block rather than a ragged column. */
.bank { display: flex; flex-wrap: wrap; justify-content: center; gap: 9px; }
.tile {
  padding: 10px 16px; border-radius: 13px; cursor: pointer;
  border: 1px solid rgba(255, 255, 255, .16); background: rgba(255, 255, 255, .045);
  color: rgba(255, 255, 255, .88); font: 500 16px var(--sans);
  transition: opacity .14s ease, border-color .14s ease;
}
.tile.used { opacity: .28; pointer-events: none; }

/* ── The ordering card (LIFE-45) ────────────────────────────────────────────
   Two rows: the sentence being built, and the tiles left to place. The build
   row sits where the Dutch sentence sits on a word bank and is separated the
   same way, so the eye lands in the same place whichever kind of challenge
   the card turned out to be. */
.order .build {
  width: 100%; margin-top: 22px; padding-top: 18px;
  border-top: 1px solid var(--line);
  display: flex; flex-wrap: wrap; justify-content: center;
  gap: 8px; min-height: 46px; align-content: flex-start;
  border-bottom: 1.5px solid transparent;
}
/* Empty, it still has to be visibly the place the sentence goes -- otherwise
   the card reads as a bank of tiles with nowhere to put them. A dashed rule
   says "here", and it is the same width as the row so nothing moves when the
   first chunk lands. */
.order .build.empty::after {
  content: 'tap the words in order';
  display: block; width: 100%; text-align: center; padding-top: 12px;
  font: 400 13px var(--sans); color: var(--dimmer);
}
.order .chunk {
  padding: 9px 13px; border-radius: 11px; cursor: pointer;
  border: 1px solid var(--you-soft); background: color-mix(in oklch, var(--you) 13%, transparent);
  color: var(--you-bright); font: 500 16px var(--sans);
  transition: border-color .14s ease, background .14s ease;
}
/* Once the card is graded the row is history and stops offering itself: the
   chunks keep the order the learner chose, coloured by the verdict, next to
   the sentence it should have been. */
.order .build.right .chunk, .order .build.wrong .chunk { pointer-events: none; }
.order .build.right .chunk {
  border-color: var(--you); background: color-mix(in oklch, var(--you) 20%, transparent);
}
.order .build.wrong .chunk {
  border-color: color-mix(in oklch, var(--bad) 55%, transparent);
  background: color-mix(in oklch, var(--bad) 12%, transparent);
  color: color-mix(in oklch, var(--bad) 70%, var(--ink));
}
/* The bank needs air under the build row it is feeding, and the whole card can
   outgrow the screen on a seven-tile subclause -- so the tiles scroll rather
   than pushing the verdict and the action button off the bottom. */
.order .bank { margin-top: 20px; }
/* A whole sentence is a longer verdict than one word: it has to be allowed to
   wrap rather than being clipped to the card's width, and the sentence itself
   is the answer rather than the verdict -- so it takes the word's colour and
   the lead that introduces it recedes. Without that the right sentence is
   drawn in the same red as the wrong order sitting directly above it. */
.order .chal-verdict { text-wrap: pretty; line-height: 1.5; }
.order .chal-verdict .lead { color: var(--dim); font-weight: 400; }
.order .chal-verdict .ans { color: var(--word); font-weight: 500; }
/* A used tile on an ordering card is the whole chunk over again, two rows under
   the chunk itself -- at a word bank's .28 the bank reads as a second copy of
   the sentence. It still has to hold its place (a bank that reflows re-aims the
   next tap), so it fades further instead of leaving. */
.order .tile.used { opacity: .14; }

.chal-verdict {
  margin-top: 16px; min-height: 19px;
  font: 500 14px var(--sans); color: var(--dim); text-align: center;
}
.chal-verdict.right { color: var(--you-bright); }
.chal-verdict.wrong { color: var(--bad); }
/* Neither right nor wrong yet: the answer is on screen and the decision is the
   learner's, so the line must not read as a verdict in either colour. */
.chal-verdict.near { color: var(--word); }

/* One action, full width, always in the same place -- it is never covered by a
   keyboard because it is the row the two grade buttons already occupied. */
/* The two grade buttons must leave the grid entirely, not just go transparent:
   left in place they still own their two cells, so the full-width check button
   wrapped onto a second row and doubled the row's height. */
.grade.challenge { grid-template-columns: 1fr; }
.grade.challenge .grade-btn:not(.check) { display: none; }
.check { border: 1px solid var(--you); background: var(--you); color: #15121f; }
.check.idle { border-color: rgba(255, 255, 255, .16); background: rgba(255, 255, 255, .035); color: rgba(255, 255, 255, .5); }

/* ── Typed word card (LIFE-9, design 4a/5a) ─────────────────────────────────
   The flip card produced instead of recognised. Everything above the input is
   the word card unchanged, so switching between the two mid-session moves
   nothing: same clue, same sentence block, same box. */
.typed { justify-content: flex-start; }
/* The sentence gap behaves exactly like a challenge's -- it holds its width
   from empty, so typing into it cannot re-wrap the line. One rule for both
   rather than a copy, or they drift. */
.chal-nl .slot, .sent-nl .slot {
  display: inline-block; min-width: 74px; padding: 0 6px;
  border-bottom: 1.5px solid var(--you); color: var(--you-bright);
  font-weight: 500; text-align: center;
}
.chal-nl .slot.filled, .sent-nl .slot.filled { border-bottom-color: var(--you-soft); }
/* The underline must not move when a tile fills the gap (LIFE-27). An empty
   inline-block has no line box, so its baseline is its bottom edge; give it
   text and the baseline becomes the text's, which drops the box -- and the
   border with it -- by the descender depth. Measured at 8.59px on a word bank,
   which reads as the sentence twitching every time you pick a word.

   A zero-width space is the smallest thing that gives the empty gap a real
   line box, so the box is the same height before and after. Not on `.slot-live`
   (the typed gap): its <input> is already a baseline of its own, and a second
   one alongside it would be the bug rather than the fix. */
.chal-nl .slot:not(.slot-live)::before,
.sent-nl .slot:not(.slot-live)::before { content: "\200B"; }
/* A typed card shows its English from the start: the clue above it already gave
   the meaning away, and what is hidden here is the Dutch. Written at the same
   specificity as the rule it overrides -- `.typed .sent-en` alone loses to
   `.card:not(.revealed) .sent-en`, which is one class and one pseudo-class, and
   the line stayed at opacity 0 with its space reserved. */
.card.typed:not(.revealed) .sent-en { opacity: 1; }

/* Near miss only. A ghost button: it is an option, not the way forward -- the
   way forward is the full-width action below it. */
.count-it {
  margin-top: 10px; padding: 7px 14px; border-radius: 999px; cursor: pointer;
  background: transparent; border: 1px solid var(--line);
  color: var(--you-soft); font: 500 12px var(--sans);
}
.count-it.hidden { display: none; }

/* ── The gap you type into (LIFE-18) ────────────────────────────────────────
   The blank in the sentence IS the field. It keeps the blank's underline, its
   minimum width and its colour, so the card looks the same whether it is
   waiting for a word or holding one -- what changed is that the caret is now
   in the sentence rather than in a box below it. */
.slot-live { position: relative; padding: 0 4px; }
/* focus-within, not :focus, because the focused node is the input INSIDE the
   gap and the underline lives on the gap. */
.slot-live:focus-within {
  border-bottom-color: var(--you);
  background: rgba(255, 255, 255, .05);
  border-radius: 5px 5px 0 0;
}
/* Everything the boxed field is, undone: no box, no height of its own, and the
   sentence's own type rather than a size of its own. Listed after `.chal-input`
   so it wins on order at equal-or-higher specificity. */
.slot-live .chal-input {
  width: auto; min-width: 60px; height: auto; padding: 0;
  border: 0; border-radius: 0; background: transparent;
  font: inherit; font-weight: 500; line-height: inherit;
  color: var(--you-bright); text-align: center;
  vertical-align: baseline;
}
.slot-live .chal-input:focus { border: 0; background: transparent; }
/* The contenteditable variant (LIFE-18). A span has no width of its own, so
   it needs the box the <input> got for free: inline-block to take the
   min-width, `pre` so a trailing space still holds its place, and `break-spaces`
   so a long answer wraps inside the gap instead of pushing the sentence out. */
.slot-live .chal-input.ce {
  display: inline-block; min-width: 60px; outline: none;
  white-space: pre-wrap; word-break: break-word;
  -webkit-user-select: text; user-select: text;
}
/* Frozen: right, and already on its way out (see freezeSlot). The field stays
   and keeps the focus -- that is what holds the keyboard open for the next
   card -- so only its live-ness is taken away. */
.slot-live.frozen, .slot-live.frozen:focus-within {
  background: transparent; border-bottom-color: var(--you-soft);
}
.slot-live.frozen .chal-input { caret-color: transparent; }
/* Measures, never paints. `white-space: pre` so a trailing space still counts
   toward the width -- otherwise the caret jumps back a space and forward again
   as you type the next word. Absolute, so it adds nothing to the gap's box. */
.slot-sizer {
  position: absolute; left: 0; top: 0; visibility: hidden;
  white-space: pre; font: inherit; font-weight: 500; padding: 0;
  pointer-events: none;
}

/* ── With the keyboard up ───────────────────────────────────────────────────
   A phone with the keyboard open is a ~260px viewport, and the bout is built
   for the whole screen: header, matchup, track, odds line, card, flag and two
   100px thumb zones. All of it still fits, compacted -- the ratings and the
   track are half the reason to answer, so they stay on screen and shrink. Only
   a card too tall for what is left pushes them out (`.kb-tight`).

   Driven by a class the visualViewport listener sets, not by a media query: no
   query can tell "short screen" from "screen with a keyboard on it", and the
   two want opposite layouts. */
body.kb-up .safe-top    { height: max(6px, env(safe-area-inset-top)); }
body.kb-up .safe-bottom { height: max(4px, env(safe-area-inset-bottom)); }
/* Nothing is removed: the chrome shrinks instead. Most of the height is in the
   matchup, where each side stacks a label over a value -- turned sideways they
   are two single lines, and a rating is still legible at 20px. That plus the
   padding either side of the track buys ~80px, which is the whole difference
   between "compact" and "gone". */
body.kb-up .bout-top { min-height: 26px; }
body.kb-up .chip { padding: 3px 9px 3px 7px; }
body.kb-up .pill { padding: 3px 9px; }
body.kb-up .icon-btn svg { width: 15px; height: 15px; }
body.kb-up .matchup { padding-top: 8px; align-items: baseline; }
body.kb-up .side { flex-direction: row; align-items: baseline; gap: 7px; }
body.kb-up .side-you .num-you   { font-size: 20px; }
body.kb-up .side-word .num-word { font-size: 16px; line-height: 1; }
body.kb-up .delta { font-size: 12px; }
body.kb-up .track { margin-top: 9px; }
/* The 800/2500 ticks hang 18px under the bar and the odds line starts at those
   same two edges, so this padding is the only thing keeping them off each
   other. Below 17px they print one on top of the other. */
body.kb-up .track-legend { padding-top: 17px; font-size: 10px; }
body.kb-up .flag { padding: 4px 12px; font-size: 10px; }

/* One tier further down. A long challenge on a phone that only leaves ~300px of
   viewport is not a choice between compact chrome and full chrome -- it is the
   chrome or the sentence being answered. Which cards need this is a fact about
   the card, not about the device, so it is set from a measurement of the
   mounted card (`fitCard` in learn2.js), never from a height threshold. The
   header stays: undo and the sound toggle are worth 26px at any size. */
body.kb-up.kb-tight .matchup,
body.kb-up.kb-tight .track,
body.kb-up.kb-tight .track-legend,
body.kb-up.kb-tight .flag { display: none; }
body.kb-up.kb-tight .bout-top { min-height: 24px; }
/* `safe center` is the whole point: plain centring in a grid clips BOTH ends of
   anything taller than the cell -- and it is scrolled to the middle, so the top
   of a long clue is unreachable, not just hidden. `safe` falls back to `start`
   the moment the card overflows, which is exactly when centring stops being
   worth anything. */
body.kb-up .card-zone { align-items: safe center; padding: 6px 20px; overflow-y: auto; }
body.kb-up .grade { padding: 6px 14px 4px; }
body.kb-up .grade-btn { height: 54px; }
body.kb-up .grade-main { font-size: 17px; }
body.kb-up .grade-sub { display: none; }
/* The card's own furniture tightens with it. */
body.kb-up .pos-tag { margin-bottom: 10px; }
body.kb-up .clue { font-size: 21px; }
body.kb-up .clue.long { font-size: 18px; }
body.kb-up .clue.longer { font-size: 16px; }
body.kb-up .chal-en { font-size: 18px; }
body.kb-up .chal-nl-wrap { margin-top: 12px; padding-top: 12px; }
body.kb-up .sentence { margin-top: 12px; padding-top: 12px; }
body.kb-up .chal-input-wrap { margin-top: 12px; }
body.kb-up .chal-verdict { margin-top: 10px; }

/* ── Grammar brief (LIFE-9, design 3a) ──────────────────────────────────────
   A rule is just another opponent: same box, same two thumb zones, same exit.
   Nothing is hidden, so it is the one card type with no reveal step. */
.brief { justify-content: flex-start; text-align: left; align-items: stretch; }
.brief .rule-tag { text-align: center; }
.brief-title {
  font: 600 21px/1.3 var(--sans); color: var(--ink);
  letter-spacing: -.01em; text-wrap: pretty;
}
.brief-body {
  margin-top: 10px; font: 400 14px/1.55 var(--sans);
  color: rgba(255, 255, 255, .6); text-wrap: pretty;
}
.brief-examples {
  margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--line);
  display: flex; flex-direction: column; gap: 13px;
}
.brief-ex { display: flex; flex-direction: column; gap: 3px; padding-left: 11px; border-left: 2px solid var(--line); }
.ex-nl { font: 400 15px/1.42 var(--sans); color: rgba(255, 255, 255, .82); text-wrap: pretty; }
/* The same amber highlight the challenges use for the words in play, so "this
   is the bit the rule is about" means one thing across every card type. */
.ex-nl .hl {
  background: color-mix(in oklch, var(--word) 15%, transparent);
  color: oklch(.9 .09 85); border-radius: 4px; padding: 1px 4px; margin: 0 -2px;
}
.ex-en { font: 400 12.5px/1.4 var(--sans); color: var(--dim); text-wrap: pretty; }
/* Sits with its own example, and is the only line on the card a model wrote --
   held at the smallest weight in the type scale so it reads as a margin note
   rather than as part of the rule. */
.ex-note {
  margin-top: 2px; font: 400 10.5px/1.35 var(--mono); color: var(--dimmer);
  letter-spacing: .01em;
}

/* Dutch quoted inside an English sentence (LIFE-30). Amber, because amber is
   already "the word" everywhere else in the app -- the challenge's English
   target, the example highlight, the flip card's answer -- so a learner reads
   one colour meaning one thing rather than learning a second convention.

   Colour only, no background chip. The chips work on `.hl` because there is one
   per card; a brief body cites eight pronouns in a row, and eight lit
   rectangles in one paragraph is a highlighter, not a distinction. */
.nl { color: var(--word); }
/* The note is deliberately the quietest line on the card, and it stays that
   way: full --word here outshouts the example sentence above it, which is the
   actual Dutch being taught. */
.ex-note .nl { color: color-mix(in oklch, var(--word) 72%, var(--dimmer)); }
