/* Reflex Chess.
 *
 * Mobile app first, desktop second. The layout is a fixed viewport with one
 * scrolling pane and a pinned tab bar, not a document that happens to be
 * narrow: 100dvh rather than 100vh so the iOS URL bar collapsing does not
 * shear the bottom off, safe-area insets so the tab bar clears the home
 * indicator, and overscroll-behavior:none so a flick at the top of the tree
 * does not bounce the whole page and reveal the browser underneath.
 *
 * THE LOOK. This is a game, not a dashboard. Everything the learner can press
 * is a chunky rounded solid with a 3-4px "shelf" underneath it -- a hard-edged
 * bottom shadow in a darker tint of the button's own colour, never a blurred
 * drop shadow. The shelf is the whole trick: it reads as thickness, so the
 * thing looks pressable before it is pressed, and on :active the button slides
 * down by exactly the shelf height while the shelf collapses to nothing, which
 * is the tactile part. A blurred shadow cannot do that -- it reads as float,
 * and float is a card, and a card is a document.
 *
 * COLOUR. Indigo stays the brand -- Chess.com owns green and Lichess owns grey,
 * and a learning app that looks like either reads as a worse version of it --
 * but a single hue over eight course sections is why the first build read flat.
 * Each section now owns an accent (--sec-basics and friends). A unit picks it
 * up two ways: from data-sec on the section if app.js sets it, and failing that
 * from :nth-of-type, which works with today's markup untouched. Everything
 * inside a unit -- the header, the node faces, the rings -- inherits --sec, so
 * adding a section means adding one variable and one nth-of-type line.
 *
 * TYPE. Fredoka, self-hosted, variable 300-700, OFL. Rounded terminals are the
 * cheapest way to make an interface read as friendly rather than clinical, and
 * one variable file at 29KB replaces the four static weights this design uses.
 * No external font URL anywhere: the app has a strict CSP and has to render on
 * a plane.
 *
 * MOTION. Every reward in the app is a state change on an element that is
 * already on screen, so the animation is what tells the learner something
 * happened. All of it is one-shot and short, none of it loops except the two
 * "alive" states (a lit streak, a low heart count), and the whole lot is
 * switched off under prefers-reduced-motion at the bottom of the file.
 *
 * The board rules at the end are ported from 960-chess-site/public/styles.css
 * and are the contract with board.js and board.css: the checker is drawn in
 * CSS rather than shipped as an image, which is the only reason the colour
 * picker in theme.js can work at all. Do not replace them with a background
 * image without also deleting the picker.
 */

/* ---------- type ----------
   One file, weight axis 300-700, so 400/600/700/800 in this sheet all resolve
   against the same download. font-display:swap because a UI that is invisible
   until a font lands is worse than a UI that reflows once. The 800s below are
   deliberate: the axis tops out at 700 and the browser will synthesise the rest
   or clamp, and clamping to 700 is the intent -- it just means "as heavy as
   this face goes" without hard-coding the ceiling in fifty places. */
@font-face {
  font-family: "Fredoka";
  src: url("/fonts/fredoka-var-latin.woff2") format("woff2");
  font-weight: 300 700;
  font-style: normal;
  font-display: swap;
}

/* ---------- palette ---------- */
:root {
  color-scheme: light dark;

  --brand: #5b6cff;
  --brand-dk: #4453e8;
  --brand-lt: #8b96ff;
  /* The brand indigo darkened 6%, for FILLS THAT CARRY WHITE TEXT ONLY.
     --brand itself is unchanged and stays the brand: it is correct as a border,
     an icon, a highlight and a text colour on a dark field. It is only when
     white sits on top of it that 4.16:1 misses AA, and this is the value that
     clears it at 4.61:1. Same value and same reasoning as seo.css. */
  --btn-fill: #5666f0;
  --good: #29c17e;
  --good-dk: #1a9a61;
  --bad: #ff4d5e;
  --bad-dk: #d8323f;
  --heart: #ff4d5e;
  --streak: #ffab2e;
  --streak-dk: #e08600;
  /* THE ACCENTS AS TEXT, which is a different job from the accents as fills.
     Amber and green are chosen to glow on a near-black field, and on a dark
     background they are correct: --streak measures 9.59:1 there. On white they
     are 1.76:1 and 2.33:1 -- so far under AA that the streak number is close to
     unreadable, and it has shipped that way. The fills must not move: --streak
     is the background of the hint button with near-black text on it, so
     darkening the token itself would break the control it was chosen for.
     Two tokens, overridden only in light. */
  --streak-ink: var(--streak);
  --good-ink: var(--good);
  --brand-ink: var(--brand-lt);
  --crown: #ffc531;
  --crown-dk: #d69c00;

  /* Section accents. Chosen to stay distinguishable at 16px on a phone in
     sunlight, which rules out the two greens and the two blues a full spectrum
     would give you -- the hues below are spaced far enough apart that the unit
     header alone tells you where in the course you are scrolled to. The -dk
     pair of each is the shelf colour, not a hover colour. */
  --sec-basics:       #22c55e;  --sec-basics-dk:    #12a049;
  --sec-tactics-1:    #5b6cff;  --sec-tactics-1-dk: #4453e8;
  --sec-mates:        #ff4d5e;  --sec-mates-dk:     #d8323f;
  --sec-tactics-2:    #ff8f3f;  --sec-tactics-2-dk: #dc6d1c;
  --sec-quiet:        #b07cff;  --sec-quiet-dk:     #8e57e6;
  --sec-endgames:     #16b8c9;  --sec-endgames-dk:  #0d94a3;
  --sec-attack:       #ff5fa2;  --sec-attack-dk:    #dc3a80;
  --sec-openings:     #ffc531;  --sec-openings-dk:  #d69c00;

  /* Dark is the default, matching theme.js's readMode(). The light overrides
     live under [data-theme="light"] below, which is the attribute theme.js
     actually sets -- not data-mode. prefers-color-scheme is deliberately never
     consulted as a signal; the attribute is the only source of truth.

     --bg WAS #0f1117 AND IS NOW FIVE STEPS DARKER. Not a taste tweak: the field
     (see "the field" below) works by having somewhere to glow FROM. At #0f1117
     the base sat close enough to --card that a pool of light behind the content
     had nowhere to go without washing the cards out; dropping the floor lets the
     pools climb back up to roughly the old value while the deep parts of the
     screen go properly black, which is the entire difference between a tinted
     background and a lit one. Every existing pairing gained contrast -- --bg is
     only ever behind light text -- so nothing that passed AA before can fail now.

     index.html's <meta name="theme-color"> still says #0f1117. It is off-limits
     to this change and the difference is imperceptible in a status bar; worth
     updating whenever that file is next touched. */
  --bg: #080a11;
  --surface: #171a29;
  --card: #1c2033;
  --card-2: #232841;
  --ink: #f2f3f8;
  --dim: #99a0bd;
  --rule: #2b3049;
  --shadow: 0 3px 0 rgb(0 0 0 / .45);
  --lift: 0 6px 22px rgb(0 0 0 / .35);
  --glow: 0 0 0 0 rgb(255 255 255 / 0);

  --sq-light: #eef0f7;
  --sq-dark: #7c86c4;
  --mark: 91 108 255;

  --tap: 2.75rem;
  --pad: 1rem;
  --radius: 1.15rem;
  --radius-lg: 1.6rem;
  --shelf: 4px;

  --sans: "Fredoka", ui-rounded, "SF Pro Rounded", "Segoe UI Variable",
          system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  --mono: ui-monospace, SFMono-Regular, Menlo, monospace;

  /* Default section accent, overridden per unit. Anything that wants to be
     "the colour of wherever I am" reads these two rather than --brand, so a
     component dropped into a unit is automatically on theme. */
  --sec: var(--brand);
  --sec-dk: var(--brand-dk);
  --sec-ink: #fff;

  /* ---------- THE FIELD ----------
     The light the whole app sits in. One fixed, full-viewport layer painted by
     body::before (see "the field" further down), composed here as tokens so
     anything else that has to sit in the same light -- the intro page's own
     stylesheet, most of all -- can read the colours rather than re-derive them
     and drift.

     WHY TOKENS AND NOT JUST A RULE. The field is not decoration on one screen;
     it is the room. A section that wants a gradient of its own, or a plate that
     has to look like smoked glass over the field, needs the same three light
     sources and the same base, and copying hex codes into a second stylesheet is
     how two surfaces stop matching three commits later.

     WHAT EACH ONE IS
       --field-base    the deepest colour on screen; the floor everything else
                       is measured up from. Nothing in the app is darker.
       --glow-key      primary light source, as an "R G B" triple so callers can
                       set their own alpha: rgb(var(--glow-key) / .2).
       --glow-accent   the second pool. This is the one that changes per tab
                       (see body[data-tab] below), so a caller that wants "the
                       colour of wherever I am" reads this.
       --glow-floor    the low reflected glow along the bottom edge.
       --field-lit     an OPAQUE solid matching the field at its brightest.
                       For anything that needs a flat fill that disappears into
                       the top of the screen.
       --field-veil    a translucent plate colour. Chrome that has to sit ON the
                       field without blocking it (the stats bar) uses this
                       instead of an opaque --bg, which is what let the old wash
                       be hidden behind the one bar it was painted under.
       --field-image   the whole layer stack, ready to drop into background-image.

     THE ALPHAS ARE MEASURED, NOT PICKED, AND THE CEILING IS A REAL NUMBER.
     Two foreground colours sit directly on the field and set the budget:

       --dim      #99a0bd  relative luminance .3511 -> field must stay under .0391
       --brand-lt #8b96ff  relative luminance .3472 -> field must stay under .0383

     Both need 4.5:1, so .0383 is the hard ceiling for the brightest pixel of
     this stack. Measured in Chrome by screenshotting the bare field and reading
     the actual pixels: the peak is .0318 (top-left corner), which leaves --dim
     at 4.91:1 and --brand-lt at 4.86:1. The deepest point is .0016.

     ANYTHING PAINTED ON THIS FIELD INHERITS THAT BUDGET, including the intro
     page. If a surface needs to go brighter than .0383 it has to bring its own
     opaque plate; there is no colour of small text that survives up there. */
  --field-base: #080a11;
  --glow-key:    91 108 255;
  --glow-accent: 150 110 255;
  --glow-floor:  30 150 210;
  --field-lit:   #262d67;   /* measured: the field's actual brightest pixel */
  --field-veil:  rgb(8 10 17 / .42);

  /* THE STACK IS ORDERED, AND THE ORDER IS THE DESIGN.
     Layers paint first-on-top, so this reads bottom-up: a key light, then a
     falloff that takes most of the screen back down to black, then the two
     smaller pools ON TOP of that falloff so they survive it. That is what makes
     them read as light sources rather than as tints -- a glow needs somewhere
     dark to be a glow in, and the version of this without the falloff layer was
     a flat purple screen with no depth in it at all. */
  --field-image:
    /* Above the falloff: the accent pool, low and to the right, and the floor.
       These are the two that have to stay visible in the dark half. */
    radial-gradient(105% 58% at 106% 44%, rgb(var(--glow-accent) / .22), transparent 100%),
    radial-gradient(150% 46% at 42% 116%, rgb(var(--glow-floor) / .26), transparent 100%),
    /* THE FALLOFF. Centred on the key light and reaching past every corner, so
       the far side of the screen goes properly black at any aspect ratio. A
       rectangular vignette was tried and read as a frame; an ellipse hung off
       the light source reads as distance from it. */
    radial-gradient(120% 80% at 22% 6%, transparent 12%, rgb(0 0 0 / .72) 92%),
    /* THE KEY LIGHT. Centre above the viewport so you see the lower half of a
       pool rather than a spot with a middle in it -- a light source with a
       visible centre reads as a lamp pointed at you. */
    radial-gradient(150% 95% at 14% -10%, rgb(var(--glow-key) / .37), transparent 100%),
    /* And a whisper over the top, so the ceiling is one wash rather than two
       separate pools meeting. */
    linear-gradient(180deg, rgb(var(--glow-key) / .05), transparent 70%);
}

:root[data-theme="light"] {
  --bg: #fbfbff;
  --surface: #f1f2fb;
  --card: #ffffff;
  --card-2: #f5f6fd;
  --ink: #14161f;
  /* WAS #6b7085, WHICH DID NOT PASS. Measured in a browser: #6b7085 on the
     light --bg is 4.80:1 with nothing behind it, and the old #shell wash --
     16% indigo, which light mode was inheriting because the rule was never
     theme-scoped -- dropped the top of the screen to 3.91:1. So light mode has
     been shipping a failure at the top of every screen. #5f6479 is the value
     seo.css already uses for exactly this token on the content pages, so this
     brings the app into line with its own site rather than inventing a colour:
     5.78:1 bare, and it leaves enough headroom for a light field that is
     actually visible. */
  --dim: #5f6479;
  /* The accents, dark enough to be read as text on white. Same hues, taken
     down until they clear AA -- verified in a browser, not computed here. */
  --streak-ink: #a86200;
  --good-ink: #0f7a4d;
  --brand-ink: var(--brand-dk);
  --rule: #e2e4f2;
  --shadow: 0 3px 0 rgb(20 22 31 / .13);
  --lift: 0 6px 22px rgb(60 66 120 / .13);

  --sq-light: #f0f1f8;
  --sq-dark: #8b94cf;

  /* THE SAME ROOM WITH THE LIGHTS ON. Same geometry, same light sources, same
     token names -- so the intro page and everything else keep working -- but
     the pools now tint a near-white base instead of lifting a black one, and
     there is no side falloff, because shading the edges of a white page reads
     as a printing fault rather than as depth.

     The alphas are a third of the dark set and that is not timidity: on white,
     a tint DARKENS, so every point of the light field costs contrast rather
     than buying it. The darkest point of the stack below measures 5.09:1
     against --dim. The old wash measured 3.91:1. */
  --field-base: #fbfbff;
  --glow-key:    91 108 255;
  --glow-accent: 255 120 180;
  --glow-floor:  30 170 210;
  --field-lit:   #eceeff;   /* measured: the light field's most tinted pixel */
  --field-veil:  rgb(251 251 255 / .72);

  --field-image:
    radial-gradient(105% 58% at 106% 44%, rgb(var(--glow-accent) / .06), transparent 100%),
    radial-gradient(150% 46% at 42% 116%, rgb(var(--glow-floor) / .06), transparent 100%),
    radial-gradient(150% 95% at 14% -10%, rgb(var(--glow-key) / .10), transparent 100%);
}

/* THE ACCENT POOL FOLLOWS THE TAB. app.js publishes the active tab as
   body[data-tab], which already exists for the site footer, so this costs no
   markup and no JavaScript: the second light source takes the colour of the
   place you are in, and the room changes when you change rooms.

   ONLY THE ACCENT MOVES. The key light stays indigo on every screen, because
   the brand has to be the constant and a background that recolours completely
   between taps is a novelty rather than a house. And it is one custom property,
   so switching tabs repaints one fixed layer once -- there is no transition on
   it, deliberately: a background cross-fading under a view that is itself
   animating in is two things moving for no reason.

   A full-screen view (lesson, rush, results) leaves data-tab on whatever it was
   entered from, which is the right stale value: a lesson opened from Learn
   should look like Learn. */
body[data-tab="practice"] { --glow-accent: 255 143 63; }
body[data-tab="compete"]  { --glow-accent: 255 95 162; }
body[data-tab="tools"]    { --glow-accent: 22 184 201; }
body[data-tab="you"]      { --glow-accent: 255 197 49; }

* { box-sizing: border-box; }

/* The hidden attribute has to beat a display declaration, and it does not: the
   UA sheet's display:none loses to any author rule. app.js and lesson.js both
   toggle .hidden on elements this sheet gives display:flex (#tabbar during a
   lesson, .feedback between puzzles), so without this the feedback bar sits
   open under the board with an empty button in it for the whole puzzle. */
[hidden] { display: none !important; }

/* ---------- links ----------
   THE APP NEVER STYLED A BARE <a> AND IT NEEDED TO. Every anchor with its own
   class does set a colour -- .brand, .tab, .guide-link, .sitefoot a -- so the
   gap was invisible until the About block on the You tab, whose licence credits
   are plain anchors. Those fell through to the USER AGENT default, and the UA
   picks that off `color-scheme`, not off this app's own theme: with the OS in
   dark mode and the app in light mode it served #9e9eff on white, which
   measures 2.21:1. The app's theme has to be the only thing that decides.

   Split by theme for the usual reason -- full indigo is a mid-tone that reads
   on white and disappears on black, and the light field needs the darker end
   for the same 4.5:1 (measured 4.97:1 light, 7.7:1 dark). */
   `a:not([class])` -- UNCLASSED ANCHORS ONLY, and that qualifier is the whole
   rule. Written bare, this scores (0,2,1) against `.tab`'s (0,1,0), and the tab
   bar is built from `<a class="tab">`: every tab took the link colour, so all
   five read as the active one and the pill was the only thing distinguishing
   them. It is a component-versus-content problem, and `[class]` is the honest
   line between the two -- anything with a class has somebody styling it on
   purpose, and this rule exists only for the anchors nobody styled at all. */
a:not([class]) { color: var(--brand-dk); }
:root:not([data-theme="light"]) a:not([class]) { color: var(--brand-lt); }

html, body {
  height: 100%;
  margin: 0;
  overscroll-behavior: none;
}

/* The base colour goes on the ROOT, not on body. html's background is what the
   browser propagates to the canvas, so putting it here leaves body free to
   carry the field pseudo-element without the two fighting over which one paints
   the page -- and it means an overscroll past the end of the document still
   shows the field's own floor rather than white. */
html { background-color: var(--field-base); }

/* ---------- the field ----------
 *
 * ONE FIXED LAYER, PAINTED ONCE, NEVER AGAIN.
 *
 * This is the whole trick and it is worth stating plainly, because the obvious
 * implementations of "glowing background" are all wrong here:
 *
 *   - A gradient on #shell (what this was) is clipped to a 34rem column, sits
 *     UNDER an opaque stats bar which hides the brightest part of it, and
 *     scrolls nowhere because the shell does not scroll. It was invisible.
 *   - A blurred element, or a backdrop-filter over the page, costs a full-frame
 *     GPU pass on every scrolled frame of a very long tree.
 *   - A gradient on the scrolling pane repaints as it scrolls.
 *
 * Fixed and outside the scroller costs nothing at all. The scrolling happens
 * inside .scroll, which is a separate overflow container; this element is not
 * in it, does not move with it, and is not repainted by it. The measured cost
 * of adding it to a 4000px tree scroll is zero frames (see the report).
 *
 * z-index -1 puts it behind every box in the document while staying in front of
 * the canvas. body is not a stacking context, so this lands in the root one,
 * which is exactly where a page-wide backdrop belongs.
 *
 * NOTHING HERE MOVES. A drifting or breathing background was tried and cut: it
 * is the one effect on this screen that would repaint a full-viewport layer on
 * every frame, it would be sharing that budget with the intro page's
 * scroll-driven reveals, and a background that pulses at someone using the app
 * in bed is an accessibility problem before it is a feature. There is therefore
 * nothing for prefers-reduced-motion to switch off, which is the strongest
 * version of honouring it. */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -1;
  background-color: var(--field-base);
  background-image: var(--field-image);
  pointer-events: none;
}

body {
  font-family: var(--sans);
  font-weight: 500;
  background: transparent;
  color: var(--ink);
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
  /* An app does not let you select its chrome by long-pressing it. */
  user-select: none;
  -webkit-user-select: none;
}

/* ---------- shell ---------- */

#shell {
  display: flex;
  flex-direction: column;
  height: 100dvh;
  max-width: 34rem;
  margin: 0 auto;
  position: relative;
  /* TRANSPARENT. The shell used to carry the colour wash itself, which is why
     nobody could see it: the wash was clipped to a 34rem column, the top of it
     was covered by an opaque stats bar, and in light mode it was inherited
     unscoped and failed contrast. The light now comes from the field on
     body::before, which spans the viewport and sits behind everything, so the
     shell only has to hold the layout. */
  background: transparent;
}


#app { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }

.scroll {
  flex: 1 1 auto;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  padding: 0 var(--pad);
  /* Feeds .unit-head::after, which prints the section number. */
  counter-reset: unit;
}
.tail { height: 2.5rem; }

h1 { font-size: 1.85rem; font-weight: 700; letter-spacing: -.025em; margin: 1rem 0 .25rem; }
h2 { font-size: 1.1rem;  font-weight: 700; letter-spacing: -.015em; margin: 1.7rem 0 .6rem; }
p  { margin: 0 0 .8rem; line-height: 1.5; }
.sub { color: var(--dim); font-size: .92rem; }
.dim { color: var(--dim); font-weight: 600; }

/* ---------- stats bar ----------
   Three pills rather than three runs of text. Each one is a self-contained
   coloured token so the eye can find "how many hearts" without reading. */

.statsbar {
  display: flex; align-items: center; gap: .5rem;
  padding: max(.55rem, env(safe-area-inset-top)) var(--pad) .55rem;
  border-bottom: 1px solid var(--rule);
  /* A VEIL, NOT A LID. This bar sits exactly where the field is brightest, and
     an opaque fill here is what hid the old wash completely -- the top 60px of
     the screen, the part a phone user looks at first, was the one place the
     colour was never allowed to show. The veil is dark enough to keep the stat
     labels off the pool (measured: --dim reads 6.6:1 through it against 5.6:1
     bare) and translucent enough that the light still climbs behind the brand.
     Nothing scrolls under this bar -- .scroll is a sibling, not a parent -- so
     translucency costs no legibility anywhere else. */
  /* A GRADIENT VEIL, NOT A FLAT ONE. Flat, it drew a hard horizontal seam right
     across the brightest part of the field -- a black strip pasted over the
     glow, which is the exact thing the old opaque bar was doing. Fading the
     veil out at its own bottom edge dissolves the seam while keeping the top of
     it dark, which is where a phone's status bar overlaps and where the
     protection is actually wanted. */
  background: linear-gradient(180deg, var(--field-veil), transparent);
  position: relative; z-index: 6;
}

/* The name lives here rather than in a page title, because the app has no
   other permanent surface: every view replaces the whole scroller, so this bar
   is the only thing on screen that is always the app itself. */
.brand {
  display: inline-flex; align-items: center; gap: .35rem;
  font-weight: 800; font-size: .95rem; letter-spacing: -.01em;
  color: var(--ink); text-decoration: none;
  margin-right: auto;
  white-space: nowrap;
}
.brand-mark {
  width: 1.5rem; height: 1.5rem; border-radius: 50%;
  display: grid; place-items: center; font-size: .95rem;
  background: var(--brand); color: #fff;
  flex: none;
}
.brand-mark svg { width: 68%; height: 68%; }
/* The full name is the first thing to go when three stats and a sign-in button
   are competing for a 390px bar. The mark alone still identifies the app. */
@media (max-width: 23rem) {
  .brand { font-size: 0; gap: 0; }
  .brand-mark { font-size: .95rem; }
}

.statgroup { display: flex; align-items: center; gap: .25rem; }

.stat {
  display: inline-flex; align-items: center; gap: .28rem;
  font: inherit; font-weight: 800; font-size: .9rem;
  color: var(--dim); background: none; border: 0;
  padding: .3rem .4rem; border-radius: .6rem;
  cursor: pointer;
  font-variant-numeric: tabular-nums;
}
.stat:active { background: var(--surface); }
.stat-icon { font-size: 1rem; line-height: 1; display: inline-flex; }
.stat-icon svg { width: 1.05rem; height: 1.05rem; }
.stat { white-space: nowrap; }
.stat.streak.on { color: var(--streak-ink); }
.stat.hearts { color: var(--heart); }
.stat.hearts.low { animation: pulse 1.4s ease-in-out infinite; }
.stat.goal { color: var(--brand); }

.ring {
  width: 1rem; height: 1rem; border-radius: 50%;
  background: conic-gradient(var(--brand) calc(var(--pct) * 1%), var(--rule) 0);
  display: inline-block;
}

/* The helper. Anchored under the bar rather than to the stat itself: three
   stats sit within 120px of the right edge, so a popover pinned to any of them
   would need flipping logic to stay on screen. One full-width card cannot go
   off the edge and has room for a real sentence. */
.statpop {
  position: absolute; top: 100%; left: var(--pad); right: var(--pad);
  z-index: 40;
  margin-top: .4rem;
  background: var(--card);
  border-radius: .9rem;
  padding: .8rem .95rem;
  box-shadow: 0 10px 30px rgb(0 0 0 / .5), 0 0 0 1px var(--rule);
  display: flex; flex-direction: column; gap: .25rem;
  animation: sheetUp .2s ease both;
}
.statpop b { font-size: .92rem; }
.statpop span { font-size: .82rem; line-height: 1.45; color: var(--dim); }

.stat.signin {
  font-size: .82rem; color: #fff; background: var(--brand);
  border-radius: 999px; padding: .35rem .7rem;
  box-shadow: 0 3px 0 var(--brand-dk);
}
.stat.signin:active { transform: translateY(3px); box-shadow: none; }

/* ---------- sign-in overlay ----------
   The view underneath stays rendered and is blurred, so a new arrival sees the
   shape of what they are being offered -- eight sections of a real course, or
   their own stats -- rather than a sign-up form with nothing behind it. A modal
   over an empty screen asks for the commitment before showing what it buys.

   #app is the positioning context and already clips, so the panel is absolute
   inside it and the tab bar (a sibling) stays reachable. Never trap someone. */
#app { position: relative; }

#app.blurred > .statsbar,
#app.blurred > .scroll {
  /* Light enough to read the shape of the tree through. At 7px the course
     behind was a smear, which defeats the reason for blurring rather than
     covering: a new arrival is supposed to see the path they are being
     offered. */
  filter: blur(3.5px) saturate(.85);
  pointer-events: none;
  user-select: none;
}

.welcome {
  position: absolute; inset: 0; z-index: 60;
  display: flex; align-items: center; justify-content: center;
  /* Generous side padding: at 1rem the card ran to within a few pixels of both
     edges on a 390px phone and read as a full-screen takeover rather than as
     something floating over the course. The gap is what says "there is a page
     behind this". */
  padding: var(--pad) 1.6rem;
  background: color-mix(in srgb, var(--bg) 55%, transparent);
  animation: sheetUp .32s cubic-bezier(.21,.9,.3,1.05) both;
}

.welcome-card {
  width: 100%; max-width: 20.5rem; text-align: center;
  background: var(--card);
  border-radius: var(--radius-lg);
  /* Bottom padding is deliberately lighter than the top: the last child is a
     link with its own tap padding, so matching the numbers would leave the card
     visibly bottom-heavy. These are the values that LOOK even, not the ones
     that measure even. */
  padding: 1.75rem 1.35rem 1.15rem;
  box-shadow: 0 10px 40px rgb(0 0 0 / .45), 0 0 0 1px var(--rule);
}

.welcome-mark {
  width: 3.25rem; height: 3.25rem; margin: 0 auto;
  border-radius: 50%; display: grid; place-items: center;
  font-size: 1.7rem; line-height: 1;
  background: var(--brand); color: #fff;
  box-shadow: 0 var(--shelf) 0 var(--brand-dk);
}

/* Balanced and tightened, because the headline is now a sentence rather than a
   single word. It carries the phrase the .sub rule below already had to fix for
   the same reason -- "a few / minutes a day" orphans its last line otherwise,
   and an orphan in the biggest text on the first screen is very visible. */
.welcome-card h1 {
  font-size: 1.45rem;
  line-height: 1.22;
  margin: .8rem 0 .45rem;
  text-wrap: balance;
}
.welcome-card .sub {
  margin: 0 0 1.4rem;
  line-height: 1.45;
  /* Stops the last line falling to a single orphaned word, which is what
     "a few / minutes a day" was doing. */
  text-wrap: balance;
}

/* The action stack. Gap rather than per-child margins so the last button never
   contributes a stray gap above the link. */
.welcome-card .btn { width: 100%; margin: 0 0 .6rem; }
.welcome-card .btn:last-of-type { margin-bottom: 0; }

/* The quiet third option. A real button for accessibility, styled as a link so
   it never competes with the two that matter. Full width so its tap target is
   the whole row rather than just the words. */
.linky {
  display: block; width: 100%;
  background: none; border: 0; font: inherit; font-size: .85rem;
  color: var(--dim); text-decoration: underline; cursor: pointer;
  padding: .75rem .5rem .25rem;
  margin: 0;
}
.linky:hover { color: var(--ink); }
:root[data-theme="dark"] .stat.goal { color: var(--brand-lt); }

.ring {
  width: 1.1rem; height: 1.1rem; border-radius: 50%;
  background: conic-gradient(var(--brand) calc(var(--pct) * 1%), var(--rule) 0);
  display: inline-block;
  /* The hole makes it read as a ring rather than a pie, which matters at 17px. */
  -webkit-mask: radial-gradient(circle, transparent 42%, #000 44%);
          mask: radial-gradient(circle, transparent 42%, #000 44%);
}

/* ---------- the tree ---------- */

/* Unit accents. data-sec wins if app.js sets it; the :nth-of-type twin is the
   fallback that works against the markup as it stands today. Order below is
   course order and must stay in sync with data/curriculum.json. */
.unit {
  margin-top: 1.6rem;
  counter-increment: unit;
  --sec: var(--brand); --sec-dk: var(--brand-dk); --sec-ink: #fff;
}
.unit:nth-of-type(1), .unit[data-sec="basics"]    { --sec: var(--sec-basics);    --sec-dk: var(--sec-basics-dk); }
.unit:nth-of-type(2), .unit[data-sec="tactics-1"] { --sec: var(--sec-tactics-1); --sec-dk: var(--sec-tactics-1-dk); }
.unit:nth-of-type(3), .unit[data-sec="mates"]     { --sec: var(--sec-mates);     --sec-dk: var(--sec-mates-dk); }
.unit:nth-of-type(4), .unit[data-sec="tactics-2"] { --sec: var(--sec-tactics-2); --sec-dk: var(--sec-tactics-2-dk); }
.unit:nth-of-type(5), .unit[data-sec="quiet"]     { --sec: var(--sec-quiet);     --sec-dk: var(--sec-quiet-dk); }
.unit:nth-of-type(6), .unit[data-sec="endgames"]  { --sec: var(--sec-endgames);  --sec-dk: var(--sec-endgames-dk); }
.unit:nth-of-type(7), .unit[data-sec="attack"]    { --sec: var(--sec-attack);    --sec-dk: var(--sec-attack-dk); }
/* Gold is the one accent white text cannot sit on, so this section flips the
   foreground instead of dulling the gold. */
.unit:nth-of-type(8), .unit[data-sec="openings"]  { --sec: var(--sec-openings);  --sec-dk: var(--sec-openings-dk); --sec-ink: #4a3400; }

.unit-head {
  /* Above the road, and opaque, so the lead-in disappears cleanly behind it. */
  position: relative; z-index: 2;
  display: flex; align-items: flex-start; gap: 1rem;
  /* Lit from the top left rather than flat. A hard-edged diagonal gloss was
     tried first and read as a rendering seam at phone width; a soft two-stop
     ramp gives the block volume without ever showing an edge. */
  background: linear-gradient(150deg,
    color-mix(in srgb, var(--sec) 82%, #fff) 0%, var(--sec) 55%,
    color-mix(in srgb, var(--sec) 88%, #000) 100%);
  color: var(--sec-ink);
  border-radius: var(--radius-lg); padding: 1rem 1.1rem;
  box-shadow: 0 var(--shelf) 0 var(--sec-dk);
  position: relative; overflow: hidden;
}
/* The section's number, oversized and half off the bottom edge. Pure CSS
   counter, so it needs no markup app.js does not already emit, and it is the
   one element on the header that makes eight identical bars feel numbered. */
.unit-head::after {
  content: counter(unit);
  position: absolute; right: .85rem; bottom: -1.15rem;
  font-size: 4rem; font-weight: 700; line-height: 1; letter-spacing: -.06em;
  color: rgb(255 255 255 / .24); pointer-events: none;
}
.unit-head > * { position: relative; z-index: 1; }
.unit-head h2 { margin: 0 0 .2rem; font-size: 1.18rem; letter-spacing: -.02em; }
.unit-head p { margin: 0; font-size: .82rem; opacity: .9; line-height: 1.35; font-weight: 500; }
.unit-crowns {
  margin-left: auto; font-weight: 700; font-size: 1.15rem; white-space: nowrap;
  background: rgb(0 0 0 / .16); border-radius: 999px; padding: .25rem .6rem;
}
.unit-crowns .dim { color: inherit; opacity: .6; }

/* Alternating offsets give the column the wandering path a tree wants without
   any of the absolute positioning a real winding path would need. */
/* ---------- the path ----------
   The route is an SVG computed in app.js from the same coordinates the nodes
   are placed at, so it passes THROUGH each icon rather than near it. Both live
   in a fixed pixel space (see COL/STEP/AMP there); this block only has to stop
   the browser from second-guessing that.

   The column is scaled rather than reflowed on narrow screens: the curve and
   the node positions have to stay in agreement, and a media query that moved
   one without the other would break the alignment that is the whole point. */
.nodes {
  position: relative;
  width: 320px;
  /* Negative top margin pulls the road's lead-in up behind the section header,
     which is opaque and stacked above it. That is what makes sixteen separate
     per-unit paths read as one continuous road. */
  margin: -1.6rem auto 0;
  z-index: 0;
}
@media (max-width: 22.5rem) {
  .nodes { transform: scale(.92); transform-origin: top center; }
}

.route {
  /* IN FLOW, deliberately. It is what gives .nodes its height: app.js sizes the
     svg from the node count, and taking it out of the flow collapsed every unit
     to zero and stacked all sixteen of them on top of each other. The nodes are
     the things that float over it, not the other way round. */
  display: block;
  width: 320px;
  overflow: visible;
  pointer-events: none;
}
.route-bed,
.route-lit {
  fill: none;
  stroke-linecap: round;
  stroke-width: 9;
}
/* The unreached route. Visible enough to read as a road ahead, faint enough
   that it never competes with the part already walked. */
/* ONE COLOUR FOR THE WHOLE ROAD, not the section accent.
   Per-section colouring meant the road changed hue every unit, which reads as
   eight unrelated paths rather than one route -- and it made the lit/unlit
   distinction, the only thing the road is actually saying, compete with a
   decorative one. Green is walked, blue is ahead, everywhere. */
.route-bed {
  stroke: color-mix(in srgb, var(--brand) 30%, transparent);
  /* The road ahead fades in under the nodes as they cascade, so the route is
     never briefly a bare line with no stops on it. */
  animation: fadeIn .5s ease both;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
/* The reached route, plus its glow. A drop-shadow filter rather than a
   box-shadow, because a stroke has no box. */
.route-lit {
  stroke: var(--good);
  filter: drop-shadow(0 0 7px color-mix(in srgb, var(--good) 75%, transparent));
  /* Long and eased-out: this is the road being walked, and it should feel like
     travel rather than a progress bar filling. Runs once on load and again
     whenever a node opens. */
  transition: stroke-dasharray 1.1s cubic-bezier(.22,.61,.36,1);
}

.node {
  position: absolute;
  /* Centred by MARGINS, never by transform. nodeIn animates transform, and an
     animation with fill-mode both holds its final transform -- which silently
     overwrote translate(-50%,-50%) and left every node half its own width and
     height away from the point the road was drawn through. That is why the
     path did not pass through the icons.

     The offsets centre the RING on the point, not the whole node: the name and
     crown count hang below it and must not drag the icon off the road. */
  margin-left: -3.25rem;                 /* half of width */
  margin-top: -2.075rem;                 /* half of .node-ring height */
  display: flex; flex-direction: column; align-items: center; gap: .35rem;
  background: none; border: 0; padding: 0; cursor: pointer;
  font-family: inherit; color: var(--ink);
  width: 6.5rem;
}
/* NOT opacity on the node.
   Fading the whole node made its face translucent, so the road showed straight
   through the middle of every locked icon and stopped reading as passing
   behind them. The face stays fully opaque and the DIMMING moves to the
   contents: a quieter icon and a quieter label. */
.node[disabled] { cursor: default; }
.node.locked .node-face { color: color-mix(in srgb, var(--dim) 70%, transparent); }
.node.locked .node-name { color: var(--dim); }
.node.locked .node-ring { background: var(--rule); }

.node-ring {
  width: 4.15rem; height: 4.15rem; border-radius: 50%;
  display: grid; place-items: center;
  background: conic-gradient(var(--crown) calc(var(--pct) * 1%), var(--rule) 0);
  padding: 4px;
  transition: transform .12s ease;
}
.node:not([disabled]):active .node-ring { transform: scale(.93); }

.node-face {
  width: 100%; height: 100%; border-radius: 50%;
  display: grid; place-items: center;
  background: var(--surface); color: var(--dim);
  box-shadow: var(--shadow);
}
/* Opaque, or the route shows through the middle of a node and it stops reading
   as a stop on the road. */
.node.locked .node-face { background: var(--card); }
.node.open .node-face { background: var(--sec, var(--brand)); color: #fff; box-shadow: 0 3px 0 var(--sec-dk, var(--brand-dk)); }
.node.done .node-face { background: var(--crown); color: #4a3500; box-shadow: 0 3px 0 var(--crown-dk); }
.node.gated .node-face { background: var(--card); color: var(--brand); box-shadow: 0 var(--shelf) 0 var(--rule), inset 0 0 0 2px var(--brand); }

/* The icons are drawn SVGs now, so they size off the box rather than off a
   font. 44% of the face is the optical match for the old 1.5rem glyph. */
.node-face svg { width: 44%; height: 44%; }

.node-name {
  font-size: .76rem; font-weight: 700; text-align: center; line-height: 1.15;
}
.node-crowns { font-size: .66rem; color: var(--dim); font-weight: 600; }
.node:not([disabled]):active .node-face { transform: translateY(var(--shelf)); box-shadow: 0 0 0 var(--rule); }

/* A halo on whatever is unlocked and not yet crowned. There are only ever one
   or two of these on a whole tree, so an infinite animation here is one or two
   compositor layers, not forty-nine -- and it is the thing that answers the
   only question this screen gets asked, which is "where do I go next". */
.node.open .node-face::after {
  content: ""; position: absolute; inset: -5px;
  border-radius: 50%; border: 3px solid var(--sec);
  animation: halo 2.2s ease-out infinite;
  pointer-events: none;
}

.node-name { font-size: .8rem; font-weight: 600; text-align: center; line-height: 1.2; }
.node-crowns { font-size: .68rem; color: var(--dim); font-weight: 600; }

/* ---------- the account gate ----------
   A gated node is NOT a locked node. Locked means "not earned yet" and is
   correctly discouraging; gated means "one tap away" and has to look inviting,
   or the learner reads the whole rest of the course as unreachable and stops.
   So it keeps full opacity, takes the brand colour rather than its section
   tint, and carries a pen instead of a padlock. */
.node.gated { opacity: 1; cursor: pointer; }
.node.gated .node-face {
  background: var(--card);
  color: var(--brand);
  box-shadow: 0 var(--shelf) 0 var(--rule), inset 0 0 0 2px var(--brand);
}
.node.gated .node-ring { background: var(--rule); }
.node.gated .node-name { color: var(--brand); }
/* Full indigo is a mid-tone and this label sits on the FIELD, not on a card --
   measured at 3.36:1 where the tree runs through the lit part of the screen,
   which fails AA and failed it on the old flat background too (4.48:1, under
   the line). The lighter brand is the same fix the sheet already applies to
   .tab.on, .btn.ghost and .level.now for exactly this reason. */
:root[data-theme="dark"] .node.gated .node-name { color: var(--brand-lt); }

.gate { text-align: center; }
.gate-mark {
  font-size: 2.6rem; line-height: 1;
  margin: 1.6rem auto .4rem;
  width: 4.5rem; height: 4.5rem; border-radius: 50%;
  display: grid; place-items: center;
  background: var(--brand); color: #fff;
  box-shadow: 0 var(--shelf) 0 var(--brand-dk);
}
.gate h1 { margin-top: .4rem; }
.gate .btn { width: 100%; margin-bottom: .6rem; }

.gate-list {
  list-style: none; margin: 1.2rem 0; padding: 0;
  display: grid; gap: .5rem; text-align: left;
}
.gate-list li {
  background: var(--card); border: 1px solid var(--rule);
  border-radius: .8rem; padding: .7rem .9rem;
  font-size: .92rem;
}
.gate-list b { color: var(--brand); }

/* Entrance. Units cascade, and inside each unit the nodes cascade again, so
   opening the tree reads as the course dealing itself out rather than a page
   appearing. Delays are capped at eight so the sixteen-skill openings section
   does not end on a two-second wait. */
.unit { animation: unitIn .42s cubic-bezier(.21,.9,.3,1.1) both; }
.unit:nth-of-type(2) { animation-delay: .05s }
.unit:nth-of-type(3) { animation-delay: .10s }
.unit:nth-of-type(4) { animation-delay: .15s }
.unit:nth-of-type(5) { animation-delay: .20s }
.unit:nth-of-type(6) { animation-delay: .25s }
.unit:nth-of-type(7) { animation-delay: .30s }
.unit:nth-of-type(8) { animation-delay: .35s }

.node { animation: nodeIn .38s cubic-bezier(.34,1.4,.64,1) both; animation-delay: .30s; }
/* The road is part of the same arrival, not a separate event. It waits for the
   nodes to land and then runs between them, which is the order the eye wants:
   the stops appear, then the route joins them up. */
.route-lit { transition-delay: .34s; }
.route-bed { animation-delay: .30s; }
.node:nth-child(1) { animation-delay: .04s }
.node:nth-child(2) { animation-delay: .08s }
.node:nth-child(3) { animation-delay: .12s }
.node:nth-child(4) { animation-delay: .16s }
.node:nth-child(5) { animation-delay: .20s }
.node:nth-child(6) { animation-delay: .24s }
.node:nth-child(7) { animation-delay: .28s }

/* A crowned skill re-announces itself every time the tree paints. Duolingo does
   the same thing and it is the reason the tree feels like a trophy case. */
.node.done .node-face { animation: crownPop .5s cubic-bezier(.34,1.6,.64,1) both; }

/* ---------- skill sheet ---------- */

.sheet .back {
  background: none; border: 0; color: var(--dim); font: inherit; font-weight: 600;
  padding: 1rem 0 .2rem; cursor: pointer;
  /* 39px without this, from padding alone. The only way back off the skill
     sheet should not be the smallest target on it. */
  min-height: var(--tap);
  display: inline-flex; align-items: center;
}
.sheet h1 { margin-top: .4rem; }
.levels { margin: 1.1rem 0 1.4rem; display: grid; gap: .55rem; }
.level {
  display: flex; align-items: center; gap: .7rem;
  background: var(--card); border: 2px solid var(--rule);
  border-radius: var(--radius); padding: .8rem .95rem;
  box-shadow: 0 3px 0 var(--rule);
  animation: rowIn .35s cubic-bezier(.21,.9,.3,1.1) both;
}
.level:nth-child(2) { animation-delay: .05s }
.level:nth-child(3) { animation-delay: .10s }
.level:nth-child(4) { animation-delay: .15s }
.level:nth-child(5) { animation-delay: .20s }
/* The next level up is the only thing on this screen the learner has to find,
   so it is the only thing wearing the brand colour. */
.level.now { border-color: var(--brand); box-shadow: 0 3px 0 var(--brand-dk); }
.level.now .level-n { color: var(--brand); }
:root[data-theme="dark"] .level.now .level-n { color: var(--brand-lt); }
.level.done { opacity: .65; }
.level-n { font-weight: 700; font-size: .95rem; }
.level-tag { margin-left: auto; color: var(--dim); font-size: .8rem; font-weight: 600; }
.pips { display: flex; gap: .28rem; }
.pip { width: .55rem; height: .55rem; border-radius: 50%; background: var(--rule); }
.pip.on { background: var(--crown); box-shadow: 0 0 0 2px rgb(255 197 49 / .22); }

/* ---------- buttons ---------- */

/* THE FILL IS --btn-fill, NOT --brand, and the two differ by 6%.
   White on the brand indigo measures 4.16:1, which misses AA for normal text --
   and this is the most-pressed control in the app. Neither ink passes on
   --brand: near-black on it is 4.32:1, so the fill is what has to move.
   #5666f0 clears 4.61:1 with white and is not a colour anyone will notice has
   changed. seo.css made exactly this fix for the content pages and explains it
   at length beside its own token; this is the app catching up with its own
   site rather than a second opinion about the brand. */
.btn {
  font: inherit; font-weight: 700; letter-spacing: .005em;
  background: var(--btn-fill, #5666f0); color: #fff;
  border: 0; border-radius: var(--radius);
  padding: .85rem 1.3rem; min-height: var(--tap);
  cursor: pointer; box-shadow: 0 var(--shelf) 0 var(--brand-dk);
  transition: transform .1s ease, box-shadow .1s ease, filter .1s ease;
}
.btn:active { transform: translateY(var(--shelf)); box-shadow: 0 0 0 var(--brand-dk); }
.btn[disabled] { background: var(--rule); color: var(--dim); box-shadow: none; cursor: default; transform: none; }
.btn.big { width: 100%; font-size: 1.05rem; padding: 1.05rem; }
.btn.ghost {
  background: transparent; color: var(--brand);
  box-shadow: inset 0 0 0 2px var(--rule), 0 3px 0 var(--rule);
}
:root[data-theme="dark"] .btn.ghost { color: var(--brand-lt); }
.btn.ghost:active { transform: translateY(3px); box-shadow: inset 0 0 0 2px var(--rule); }
/* A hint is help, not progress, so it is amber rather than brand -- the learner
   should be able to tell at a glance which button costs them nothing. */
.btn.hint {
  background: var(--streak); color: #5a3200;
  box-shadow: 0 var(--shelf) 0 var(--streak-dk);
  /* --tap, not 0. This opted out of the 44px floor every other button in the
     app keeps and landed at 38px -- on the one control a learner reaches for
     while stuck mid-puzzle, with the board directly above it to mis-hit. The
     button is meant to read quietly, and it still does: quiet is the type size
     and the colour, not the size of the target. */
  font-size: .9rem; padding: .65rem 1rem; min-height: var(--tap);
}
.btn.hint:active { transform: translateY(var(--shelf)); box-shadow: 0 0 0 var(--streak-dk); }

.icon-btn {
  background: none; border: 0; color: var(--dim);
  font-size: 1.7rem; line-height: 1; cursor: pointer;
  width: var(--tap); height: var(--tap); flex: none;
  border-radius: 50%;
  transition: transform .12s ease, background .12s ease;
}
.icon-btn:active { transform: scale(.88); background: var(--card); }

.chip {
  font: inherit; font-weight: 600; font-size: .88rem;
  background: var(--card); color: var(--ink);
  border: 2px solid var(--rule); border-radius: 999px;
  padding: .5rem 1rem; cursor: pointer;
  box-shadow: 0 3px 0 var(--rule);
  transition: transform .1s ease, box-shadow .1s ease;
}
.chip:active { transform: translateY(3px); box-shadow: none; }
.chip.on {
  background: var(--brand); color: #fff; border-color: var(--brand-dk);
  box-shadow: 0 3px 0 var(--brand-dk);
  animation: pop .3s cubic-bezier(.34,1.6,.64,1);
}
.goals { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .6rem; }

/* Board colour swatches, if theme.js's picker is ever mounted in this app. */
.swatches { display: flex; gap: .45rem; flex-wrap: wrap; }
.swatch {
  width: 2.1rem; height: 2.1rem; border: 0; border-radius: .7rem; cursor: pointer;
  background: linear-gradient(135deg, var(--a) 0 50%, var(--b) 50% 100%);
  box-shadow: 0 3px 0 rgb(0 0 0 / .25);
  transition: transform .12s ease;
}
.swatch[aria-pressed="true"] { outline: 3px solid var(--brand); outline-offset: 2px; }
.swatch:active { transform: translateY(3px); box-shadow: none; }
.panel-label { font-size: .7rem; font-weight: 700; color: var(--dim); text-transform: uppercase; letter-spacing: .06em; }

/* ---------- lesson ---------- */

.lesson { display: flex; flex-direction: column; height: 100%; }
.lesson-top {
  display: flex; align-items: center; gap: .6rem;
  padding: max(.6rem, env(safe-area-inset-top)) var(--pad) .7rem;
}
.bar {
  flex: 1 1 auto; height: 1rem; background: var(--rule);
  border-radius: 999px; overflow: hidden;
  box-shadow: inset 0 2px 0 rgb(0 0 0 / .12);
}
.bar-fill {
  height: 100%; width: 0; border-radius: 999px;
  background: linear-gradient(180deg,
    color-mix(in srgb, var(--good) 78%, #fff) 0 45%, var(--good) 45%);
  /* Overshoot on purpose. The bar is the only feedback for "that one counted",
     and a linear slide reads as a loading indicator while a spring reads as a
     reward. The overshoot is clipped by the track's overflow, so nothing can
     visibly exceed 100%. */
  transition: width .45s cubic-bezier(.34,1.56,.64,1);
}
.hearts {
  display: inline-flex; align-items: center; gap: .28rem; color: var(--heart);
  font-weight: 700; font-size: 1.05rem;
  padding: .2rem .55rem .2rem .45rem; border-radius: 999px;
  background: color-mix(in srgb, var(--heart) 14%, transparent);
}
.hearts .heart-icon { animation: beat 2.4s ease-in-out infinite; }
.hearts.low { color: #fff; background: var(--heart); }
.hearts.low .heart-icon { animation: beat .85s ease-in-out infinite; }
.heart-icon { font-size: 1.15rem; line-height: 1; }

.lesson-body {
  flex: 1 1 auto; min-height: 0;
  display: flex; flex-direction: column; align-items: center;
  /* Centred, not top-aligned. The board plus its prompt is shorter than the
     viewport on a tall phone, and stacking from the top left a third of the
     screen empty below the help button while the board sat under the chrome.
     Centring puts the board where the eye already is and splits the slack
     evenly above and below it.

     justify-content is safe here because the feedback bar is a sibling with
     margin-top:auto -- it still pins to the bottom when it appears. */
  justify-content: center;
  gap: .2rem;
  /* Positioning context for the floating feedback card, which is centred on
     the board rather than docked to the screen. */
  position: relative;
  padding: 0 var(--pad) 1rem;
}
/* The prompt and the combo counter share a row and are centred as a pair, so
   the combo appearing does not shove the prompt sideways. */
.prompt-row {
  display: flex; align-items: center; justify-content: center;
  /* No gap: a collapsed combo is zero-width but a gap beside it is not, and
     that gap shifts the prompt off centre for the whole puzzle. The spacing
     lives on .combo.show instead, where it only exists when the pill does. */
  gap: 0; flex-wrap: wrap; width: 100%;
  margin: .5rem 0 .85rem;
  /* TWO LINES RESERVED, ALWAYS. This is the height of the pill at two lines,
     and it is fixed rather than minimum because the row's content changes
     length on every answer: a motif prompt ("Win the pinned piece!") wraps to
     two lines on a narrow phone, and the praise that replaces it ("Correct")
     fits on one. .lesson-body is a centred column, so the row shrinking by a
     line re-centres the whole column and the board visibly jumps at the exact
     moment the learner is looking at it.

     Reserving the taller case costs one line of empty space on short prompts
     and makes the board immovable, which is the better trade in a trainer
     where the board is the thing being read. */
  height: 4rem;
  align-content: center;
}
.prompt {
  font-weight: 700; font-size: 1.15rem; letter-spacing: -.01em;
  margin: 0;
  padding: .35rem 1rem; border-radius: 999px;
  background: var(--card); box-shadow: 0 2px 0 var(--rule);
}
.prompt.dim { color: var(--dim); }
.prompt.good {
  color: #fff; background: var(--good); box-shadow: 0 3px 0 var(--good-dk);
  animation: pop .42s cubic-bezier(.34,1.7,.64,1);
}
.prompt.bad {
  color: #fff; background: var(--bad); box-shadow: 0 3px 0 var(--bad-dk);
  animation: shake .42s cubic-bezier(.36,.07,.19,.97);
}

.board-wrap {
  /* 100% - 2rem rather than 92vw: the vw unit ignores the shell's own padding,
     so on a 390px phone the board came within 16px of the screen edge and the
     file letters rendered flush against it. This keeps a real gutter at every
     width and still caps the board on desktop. */
  width: 100%;
  max-width: min(30rem, 100% - 1rem);
  margin: 0 auto;
  border-radius: .9rem;
  box-shadow: 0 8px 24px rgb(0 0 0 / .22), 0 0 0 4px var(--card);
}
.board-wrap.wobble { animation: wobble .5s ease-in-out; }

/* The help affordance sits under the board and is deliberately quiet until it
   is wanted: a learner who is about to solve it should not be reading a button
   that offers to solve it for them.

   THE RULES FOR .help-row AND .hint-text LIVE TOGETHER FURTHER DOWN, under
   "the help row". There were two competing blocks for each of them -- this one
   and that one -- and neither author could see the other's. The cascade then
   picked the layout properties from the later block and the skin (background,
   padding, box-shadow) from this one, so the box that was supposed to reserve
   exactly one line's height was silently 22px of padding taller than the rule
   that sized it thought it was. Keeping one block per selector is what makes
   the reserved height something you can reason about at all. */

/* ---------- the feedback bar ----------
 *
 * FLOATS OVER THE BOARD, centred, rather than docking at the bottom of the
 * screen. The learner's thumb is ON the board at the instant they solve --
 * that is where they just dropped a piece -- so putting Continue anywhere else
 * costs a deliberate hand movement on every single puzzle. Over six puzzles a
 * lesson and forty a rush, that movement is most of the difference between the
 * app feeling fast and feeling like a form.
 *
 * It is compact and sits just below centre rather than covering the middle:
 * the position they just solved is the thing they want to look at for the half
 * second before they move on, and a full-width bar over the board hides the
 * move that made it work. */
.feedback {
  position: absolute;
  /* Centred with auto margins, NOT with translate(-50%).
     The pop animation animates `transform`, and with fill-mode `both` it holds
     its final transform after finishing -- which silently overwrote a
     translate-based centring and left the card hanging off to the right. Any
     element that is both centred and animated has to be centred by a property
     the animation does not touch. */
  left: 0; right: 0;
  margin-inline: auto;
  width: fit-content;
  /* Sits ON the board's centre, not the column's. .lesson-body is taller than
     the board -- it also holds the prompt row and the help row -- so a
     percentage of the COLUMN lands well below the middle of the board, which
     is where the hand actually is. Sitting in the board's upper third rather
     than on its centre line: the thumb travels up to it from wherever the
     piece was dropped, and the lower half of the board -- where the mating
     net usually is -- stays uncovered. */
  top: 34%;
  z-index: 20;
  max-width: min(20rem, calc(100% - 2rem));
  padding: .8rem 1rem;
  border-radius: var(--radius-lg);
  display: flex; flex-direction: column; align-items: stretch; gap: .5rem;
  background: var(--card);
  /* Opaque card plus a wide soft shadow, so it reads as sitting ON the board
     rather than being part of it, without a scrim that would grey the whole
     position out. */
  box-shadow: 0 12px 34px rgb(0 0 0 / .55), 0 0 0 1px var(--rule);
  animation: cardPop .28s cubic-bezier(.34,1.7,.64,1) both;
}
.feedback.hit {
  background: color-mix(in srgb, var(--good) 22%, var(--card));
  box-shadow: 0 12px 34px rgb(0 0 0 / .5), 0 0 0 2px var(--good);
}
.feedback.hit .btn { background: var(--good); box-shadow: 0 var(--shelf) 0 var(--good-dk); }
.feedback.hit .btn:active { box-shadow: 0 0 0 var(--good-dk); }
.feedback.miss {
  background: color-mix(in srgb, var(--bad) 22%, var(--card));
  box-shadow: 0 12px 34px rgb(0 0 0 / .5), 0 0 0 2px var(--bad);
}
.fb-text {
  font-weight: 800; font-size: 1rem;
  text-align: center;
}
.feedback.hit .fb-text { color: var(--good); }
.feedback.miss .fb-text { color: var(--bad); }
.feedback .btn { width: 100%; padding: .7rem 1.6rem; }

/* THE RANDOM BLUE THING IN THE BOTTOM-RIGHT CORNER.
 *
 * styles.css recolours the feedback bar's button for the hit state
 * (.feedback.hit .btn -> green) and says nothing about the miss state, so a
 * missed-then-solved puzzle fell through to the bare .btn rule -- brand indigo,
 * the colour the app otherwise reserves for the primary action on the results
 * and skill screens. The bar itself is bad-tinted and nearly empty at that
 * moment, so the button read as an unattached blue slab in the corner of the
 * screen rather than as part of a bar. Matching the button to its bar is the
 * pattern .feedback.hit already establishes; the miss state was simply missed.
 *
 * The paired :active rule is not optional. .btn:active drops the shelf to
 * `0 0 0 var(--brand-dk)`, so without this the button would flash indigo under
 * the learner's thumb on every press -- which is exactly the frame they are
 * looking at when they press it. */
.feedback.miss .btn {
  background: var(--bad);
  box-shadow: 0 var(--shelf) 0 var(--bad-dk);
}
.feedback.miss .btn:active { box-shadow: 0 0 0 var(--bad-dk); }

/* The bar has to keep its height when the copy beside the button is short.
 * .fb-text is what gives the bar its content height; lesson.js no longer sends
 * it an empty string, but a one-word line still collapses the bar noticeably
 * shorter than the six-word one next to it, and a control bar that changes
 * height between puzzles reads as the layout breaking rather than as feedback.
 * min-height is the button's own height so the bar is the same slab either
 * way. */
/* Empty text must not leave a gap above the button now the bar is stacked.
   :empty is the honest test -- lesson.js sends "" on some paths. */
.feedback .fb-text:empty { display: none; }


/* Combo counter. It collapses to zero width when empty rather than being
   display:none, so the prompt beside it never jumps when a run starts. */
.combo {
  opacity: 0; transform: scale(.5);
  max-width: 0; padding: 0; overflow: hidden; white-space: nowrap;
  pointer-events: none;
  font-weight: 700; font-size: .95rem; letter-spacing: -.01em;
  color: #5a3200; background: var(--streak);
  border-radius: 999px;
  box-shadow: 0 3px 0 var(--streak-dk);
  transition: opacity .18s ease, transform .3s cubic-bezier(.34,1.7,.64,1);
}
.combo.show {
  opacity: 1; transform: scale(1);
  max-width: 12rem; padding: .35rem .85rem; margin-left: .5rem;
  animation: comboPop .45s cubic-bezier(.34,1.7,.64,1);
}

/* ---------- worked example ----------
   A full-screen takeover, not a panel: lesson.js hides .lesson and mounts this
   in its place, so it has to own the whole column the way .lesson does. */
.example-host { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
.example {
  display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0;
  padding: 0 var(--pad) calc(.6rem + env(safe-area-inset-bottom));
  animation: sheetUp .28s cubic-bezier(.21,.9,.3,1.05) both;
}
.example-top {
  display: flex; align-items: center; gap: .5rem;
  padding: max(.5rem, env(safe-area-inset-top)) 0 .5rem;
}
.example-head h2 { margin: 0; font-size: 1.05rem; letter-spacing: -.015em; }
.example-motif { margin: 0; font-size: .8rem; color: var(--dim); line-height: 1.3; }

/* The commentary block is the whole point of the screen, so it gets the accent
   rail and a fixed minimum height -- text that changes length between plies
   must not walk the buttons up and down under the learner's thumb. */
.example-step {
  margin: .8rem 0 .7rem; min-height: 5.5rem;
  background: var(--card); border-radius: var(--radius);
  border-left: 5px solid var(--brand);
  padding: .8rem 1rem; box-shadow: 0 3px 0 var(--rule);
}
.example-move {
  display: flex; align-items: baseline; gap: .5rem;
  margin: 0 0 .35rem; font-weight: 700; font-size: 1.05rem;
}
.example-ply { font-size: .7rem; font-weight: 700; color: var(--dim); text-transform: uppercase; letter-spacing: .05em; }
.example-san {
  font-family: var(--mono); font-weight: 600; font-size: 1rem;
  background: var(--card-2); color: var(--ink);
  padding: .18rem .5rem; border-radius: .5rem;
}
.example-why { margin: 0; font-size: .92rem; font-weight: 500; line-height: 1.45; color: var(--dim); }

.example-nav { display: flex; align-items: center; justify-content: center; gap: .5rem; margin-bottom: .5rem; }
.example-nav .btn { flex: 1 1 0; }
.example-nav .icon-btn { flex: none; font-size: 1.4rem; }
.example-done { width: 100%; }

/* ---------- results ---------- */

.results {
  flex: 1 1 auto; display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: 1.2rem;
  padding: var(--pad) var(--pad) calc(var(--pad) + env(safe-area-inset-bottom));
  text-align: center;
}
/* Two auto margins, one above the headline and one above the buttons, split the
   free space evenly: the score block sits optically centred while the buttons
   stay pinned to the thumb. Centring the whole column instead floats the
   primary action into the middle of the screen, which is where nobody's thumb
   is. */
.results h1 {
  font-size: 2.3rem; margin: auto 0 0; letter-spacing: -.03em;
  animation: bigPop .55s cubic-bezier(.34,1.65,.64,1) both;
}
.score { display: flex; gap: .6rem; width: 100%; }
.score-cell {
  flex: 1; background: var(--card); border: 2px solid var(--rule);
  border-radius: var(--radius); padding: .9rem .4rem;
  display: flex; flex-direction: column; gap: .1rem;
  box-shadow: 0 var(--shelf) 0 var(--rule);
  animation: cellIn .45s cubic-bezier(.34,1.5,.64,1) both;
}
/* Staggered so the three numbers land one at a time. Three cells arriving
   together is a layout; three arriving in sequence is a scoreboard. */
.score-cell:nth-child(1) { animation-delay: .10s }
.score-cell:nth-child(2) { animation-delay: .20s }
.score-cell:nth-child(3) { animation-delay: .30s }
.score-cell:nth-child(1) { border-color: var(--brand); box-shadow: 0 var(--shelf) 0 var(--brand-dk); }
.score-cell:nth-child(2) { border-color: var(--good);  box-shadow: 0 var(--shelf) 0 var(--good-dk); }
.score-cell:nth-child(3) { border-color: var(--crown); box-shadow: 0 var(--shelf) 0 var(--crown-dk); }
.score-cell b { font-size: 1.5rem; font-weight: 700; letter-spacing: -.02em; }
.score-cell:nth-child(1) b { color: var(--brand); }
:root[data-theme="dark"] .score-cell:nth-child(1) b { color: var(--brand-lt); }
.score-cell:nth-child(2) b { color: var(--good); }
.score-cell:nth-child(3) b { color: var(--crown); }
.score-cell span { font-size: .68rem; color: var(--dim); font-weight: 600; text-transform: uppercase; letter-spacing: .05em; }

.badges { display: flex; flex-wrap: wrap; gap: .5rem; justify-content: center; }
.badge {
  display: inline-flex; align-items: center; gap: .45rem;
  background: var(--surface); border-radius: 999px;
  padding: .5rem 1rem; font-weight: 600; font-size: .88rem;
  box-shadow: 0 3px 0 var(--rule);
  animation: badgeIn .5s cubic-bezier(.34,1.6,.64,1) both;
}
.badge:nth-child(1) { animation-delay: .40s }
.badge:nth-child(2) { animation-delay: .52s }
.badge:nth-child(3) { animation-delay: .64s }
.badge:nth-child(4) { animation-delay: .76s }
/* The crown is the rarest thing on this screen and gets the only spin. */
.badge.crown {
  background: var(--crown); color: #4a3400; box-shadow: 0 3px 0 var(--crown-dk);
}
.badge.crown span { animation: crownSpin .8s cubic-bezier(.34,1.5,.64,1) .45s both; display: inline-block; }
.badge.quest-done { background: color-mix(in srgb, var(--good) 22%, var(--surface)); color: var(--good); }

/* A rank promotion happens a handful of times in the whole course, so it does
   not get a pill in the badge line -- it gets its own row, at pill scale times
   two, arriving after the score has landed. */
.rankup {
  display: flex; align-items: center; gap: .8rem; width: 100%;
  padding: .8rem 1rem; border-radius: var(--radius-lg); text-align: left;
  background: linear-gradient(135deg,
    color-mix(in srgb, var(--crown) 30%, var(--card)), var(--card) 75%);
  border: 2px solid color-mix(in srgb, var(--crown) 55%, var(--rule));
  box-shadow: 0 var(--shelf) 0 var(--crown-dk);
  animation: badgeIn .55s cubic-bezier(.34,1.6,.64,1) .35s both;
}
.rankup .rank-icon { animation: crownSpin .9s cubic-bezier(.34,1.5,.64,1) .5s both; }
.rankup div { display: flex; flex-direction: column; }
.rankup b { font-size: 1.15rem; font-weight: 700; letter-spacing: -.02em; }
.rankup span { font-size: .7rem; color: var(--dim); font-weight: 600; text-transform: uppercase; letter-spacing: .05em; }

/* Newly earned badges on the results screen. Same tile as the You grid, but
   sized to however many landed rather than locked to three columns. */
.achv-grid.earned-row {
  width: 100%;
  grid-template-columns: repeat(auto-fit, minmax(5.5rem, 1fr));
}
.achv.earned {
  border-color: color-mix(in srgb, var(--crown) 55%, var(--rule));
  box-shadow: 0 3px 0 var(--crown-dk);
  animation: badgeIn .55s cubic-bezier(.34,1.6,.64,1) .5s both;
}

.results-cta {
  width: 100%; display: grid; gap: .6rem; margin-top: auto;
  padding-bottom: env(safe-area-inset-bottom);
}
.results-cta .btn { width: 100%; }

/* ---------- you ---------- */

/* Stat cards, one accent each. Six identical grey tiles was the single most
   austere thing on the old You screen; the numbers are the content, so the
   numbers get the colour. */
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: .55rem; }
.card {
  /* The wash is kept at 6%: warm tints (the amber streak card) go muddy brown
     against a navy card long before the cool ones do, and the tile has to read
     as one family. The border and the number carry the colour instead. */
  background: color-mix(in srgb, var(--tint, var(--brand)) 6%, var(--card));
  border: 2px solid color-mix(in srgb, var(--tint, var(--brand)) 34%, var(--rule));
  border-radius: var(--radius);
  padding: .85rem .5rem; display: flex; flex-direction: column; gap: .15rem; text-align: center;
  box-shadow: 0 3px 0 color-mix(in srgb, var(--tint, var(--brand)) 22%, var(--rule));
  animation: cellIn .4s cubic-bezier(.34,1.5,.64,1) both;
}
.card:nth-child(1) { animation-delay: .04s; --tint: var(--sec-tactics-1); }
.card:nth-child(2) { animation-delay: .08s; --tint: var(--streak); }
.card:nth-child(3) { animation-delay: .12s; --tint: var(--sec-attack); }
.card:nth-child(4) { animation-delay: .16s; --tint: var(--sec-quiet); }
.card:nth-child(5) { animation-delay: .20s; --tint: var(--sec-endgames); }
.card:nth-child(6) { animation-delay: .24s; --tint: var(--sec-basics); }
.card b { font-size: 1.3rem; font-weight: 700; letter-spacing: -.02em; color: var(--tint, var(--ink)); }
.card b .dim { color: var(--dim); font-size: .85rem; }
.card span { font-size: .68rem; color: var(--dim); font-weight: 600; }
.acct { margin-bottom: 1rem; }
.empty { display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; gap: .5rem; }

/* ---------- rank ----------
   One wide card: the tier badge on the left, and a stacked name / bar / caption
   beside it. .rank-body is the stack; it has to be allowed to shrink (min-width
   0) or a long rank name pushes the bar past the card's right edge. */
.rank {
  display: flex; align-items: center; gap: .85rem;
  background: linear-gradient(135deg,
    color-mix(in srgb, var(--brand) 24%, var(--card)), var(--card) 70%);
  border: 2px solid color-mix(in srgb, var(--brand) 40%, var(--rule));
  border-radius: var(--radius-lg); padding: 1rem;
  box-shadow: 0 var(--shelf) 0 var(--rule);
  animation: cellIn .45s cubic-bezier(.34,1.5,.64,1) both;
}
.rank-icon {
  flex: none; width: 3.2rem; height: 3.2rem; border-radius: 50%;
  display: grid; place-items: center; font-size: 1.7rem; line-height: 1;
  background: var(--crown); color: #4a3400;
  box-shadow: 0 3px 0 var(--crown-dk);
  animation: crownPop .55s cubic-bezier(.34,1.6,.64,1) .1s both;
}
.rank-body { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: .35rem; }
.rank-name { font-size: 1.2rem; font-weight: 700; letter-spacing: -.02em; }
.rank-body .sub { font-size: .8rem; }
.rank-bar {
  width: 100%;
  height: .8rem; background: var(--rule); border-radius: 999px; overflow: hidden;
  box-shadow: inset 0 2px 0 rgb(0 0 0 / .12);
}
.rank-fill {
  height: 100%; width: 0; border-radius: 999px;
  background: linear-gradient(90deg, var(--brand), var(--sec-attack));
  transition: width .6s cubic-bezier(.34,1.5,.64,1);
  animation: fillIn .7s cubic-bezier(.34,1.4,.64,1) .15s both;
}

/* ---------- quests ---------- */

.quests { display: grid; gap: .55rem; }
.quest {
  display: flex; align-items: center; gap: .65rem; flex-wrap: wrap;
  background: var(--card); border: 2px solid var(--rule); border-radius: var(--radius);
  padding: .8rem .95rem; box-shadow: 0 3px 0 var(--rule);
  font-weight: 600; font-size: .95rem;
  animation: rowIn .38s cubic-bezier(.21,.9,.3,1.1) both;
}
.quest:nth-child(2) { animation-delay: .06s }
.quest:nth-child(3) { animation-delay: .12s }
.quest:nth-child(4) { animation-delay: .18s }
.quest-text { flex: 1 1 auto; min-width: 0; }
.quest-n { color: var(--dim); font-size: .85rem; margin-left: auto; }
/* The bar is forced onto its own row underneath the label and the count.
   order:2 with a 100% basis does that without caring how many siblings the
   markup ends up with, which it has already changed once. */
.quest-bar {
  flex: 1 0 100%; order: 2;
  height: .7rem; background: var(--rule); border-radius: 999px; overflow: hidden;
  box-shadow: inset 0 2px 0 rgb(0 0 0 / .12);
}
.quest-fill {
  height: 100%; width: 0; border-radius: 999px; background: var(--streak);
  transition: width .5s cubic-bezier(.34,1.56,.64,1);
  animation: fillIn .6s cubic-bezier(.34,1.4,.64,1) .1s both;
}
.quest.done {
  border-color: var(--good); box-shadow: 0 3px 0 var(--good-dk);
  background: color-mix(in srgb, var(--good) 14%, var(--card));
  animation: rowIn .38s cubic-bezier(.21,.9,.3,1.1) both, pop .45s cubic-bezier(.34,1.6,.64,1);
}
.quest.done .quest-fill { background: var(--good); }
.quest.done .quest-n { color: var(--good); }
.quest.done::after {
  content: "\2713"; order: 1; flex: none; margin-left: .45rem;
  font-weight: 700; color: #fff; background: var(--good);
  width: 1.5rem; height: 1.5rem; border-radius: 50%;
  display: grid; place-items: center; font-size: .85rem;
}

/* ---------- achievements ---------- */

.achv-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: .55rem; }
.achv {
  display: flex; flex-direction: column; align-items: center; gap: .3rem;
  text-align: center; padding: .8rem .45rem .7rem;
  background: var(--card); border: 2px solid var(--rule); border-radius: var(--radius);
  box-shadow: 0 3px 0 var(--rule);
  animation: cellIn .4s cubic-bezier(.34,1.5,.64,1) both;
}
.achv:nth-child(2) { animation-delay: .05s }
.achv:nth-child(3) { animation-delay: .10s }
.achv:nth-child(4) { animation-delay: .15s }
.achv:nth-child(5) { animation-delay: .20s }
.achv:nth-child(6) { animation-delay: .25s }
.achv:nth-child(7) { animation-delay: .30s }
.achv:nth-child(8) { animation-delay: .35s }
.achv:nth-child(9) { animation-delay: .40s }
.achv-icon {
  width: 2.7rem; height: 2.7rem; border-radius: 50%;
  display: grid; place-items: center; font-size: 1.4rem; line-height: 1;
  background: var(--crown); color: #4a3400;
  box-shadow: 0 3px 0 var(--crown-dk);
}
.achv-name { font-size: .74rem; font-weight: 700; line-height: 1.2; }
.achv-blurb { font-size: .63rem; color: var(--dim); font-weight: 500; line-height: 1.25; }
/* Locked is drained rather than hidden: the point of a locked achievement is
   that you can see what you have not got yet. */
.achv.locked { background: var(--surface); box-shadow: none; opacity: .6; }
.achv.locked .achv-icon { background: var(--rule); color: var(--dim); box-shadow: none; filter: grayscale(1); }
.achv.locked .achv-name { color: var(--dim); }

/* ---------- activity graph ----------
   Structure from app.js: .activity > .act-day (one per weekday) > .act-col
   (the empty-to-full track) > .act-bar (the fill, height set inline as a
   percentage) plus a bare <span> for the letter.

   .act-day is a GRID, not a flex column, and that is load-bearing. The bar's
   height is a percentage, and a percentage height only resolves against a
   parent with a definite height. A grid track gives .act-col exactly that; as a
   flex child its height is auto and every bar collapses to nothing, which is
   what the first pass shipped. */
.activity {
  display: flex; align-items: stretch; gap: .3rem;
  height: 6rem; padding: .8rem .9rem .65rem;
  background: var(--card); border: 2px solid var(--rule); border-radius: var(--radius);
  box-shadow: 0 3px 0 var(--rule);
}
.act-day {
  flex: 1 1 0; min-width: 0;
  display: grid; grid-template-rows: 1fr auto; gap: .35rem; justify-items: center;
}
.act-day > span {
  font-size: .62rem; font-weight: 700; color: var(--dim);
  text-transform: uppercase; letter-spacing: .04em;
}
.act-day.today > span { color: var(--streak); }
.act-col {
  width: 100%; max-width: 1.9rem; height: 100%; margin: 0 auto;
  display: flex; flex-direction: column; justify-content: flex-end;
  background: color-mix(in srgb, var(--rule) 55%, transparent);
  border-radius: .45rem; overflow: hidden;
}
.act-bar {
  flex: none; width: 100%; min-height: .3rem;
  border-radius: .45rem;
  background: linear-gradient(180deg, var(--brand-lt), var(--brand));
  transform-origin: 50% 100%;
  animation: barGrow .45s cubic-bezier(.34,1.5,.64,1) both;
}
.act-day:nth-child(2) .act-bar { animation-delay: .05s }
.act-day:nth-child(3) .act-bar { animation-delay: .10s }
.act-day:nth-child(4) .act-bar { animation-delay: .15s }
.act-day:nth-child(5) .act-bar { animation-delay: .20s }
.act-day:nth-child(6) .act-bar { animation-delay: .25s }
.act-day:nth-child(7) .act-bar { animation-delay: .30s }
.act-bar.today { background: linear-gradient(180deg, var(--streak), var(--streak-dk)); }

/* ---------- records ---------- */

.records { display: grid; grid-template-columns: repeat(3, 1fr); gap: .55rem; }
.record {
  background: var(--card); border: 2px solid var(--rule); border-radius: var(--radius);
  padding: .85rem .5rem; text-align: center;
  display: flex; flex-direction: column; gap: .12rem;
  box-shadow: 0 3px 0 var(--rule);
  animation: cellIn .4s cubic-bezier(.34,1.5,.64,1) both;
}
.record:nth-child(1) { --tint: var(--sec-attack); }
.record:nth-child(2) { --tint: var(--streak); animation-delay: .06s }
.record:nth-child(3) { --tint: var(--sec-endgames); animation-delay: .12s }
.record:nth-child(4) { --tint: var(--sec-quiet); animation-delay: .18s }
.record:nth-child(5) { --tint: var(--good); animation-delay: .24s }
.record:nth-child(6) { --tint: var(--crown); animation-delay: .30s }
.record-val { font-size: 1.35rem; font-weight: 700; letter-spacing: -.02em; color: var(--tint, var(--sec-attack)); }
.record-label { font-size: .66rem; color: var(--dim); font-weight: 600; line-height: 1.2; }

/* ---------- tab bar ---------- */

#tabbar {
  display: flex;
  /* THE BACKDROP-FILTER IS GONE, AND ITS REMOVAL IS A PERFORMANCE FIX, NOT A
     STYLE ONE. It used to blur whatever was behind the bar. Nothing is: the tab
     bar is a flex SIBLING of #app inside the shell, so no content ever scrolls
     under it, and the only thing behind it now is the field -- a smooth
     gradient, which blurring cannot change. It was a full-width GPU pass every
     composited frame for a visual difference of exactly zero pixels.

     The veil is what the stats bar uses at the other end of the screen, so the
     two pieces of chrome are the same material, and the floor glow now reads
     through the bar instead of being covered by it. */
  background: var(--field-veil);
  border-top: 1px solid var(--rule);
  padding-bottom: env(safe-area-inset-bottom);
  flex: none;
}
.tab {
  position: relative;
  flex: 1; display: flex; flex-direction: column; align-items: center; gap: .15rem;
  padding: .6rem 0 .55rem; text-decoration: none;
  color: var(--dim); font-size: .66rem; font-weight: 700;
  letter-spacing: .04em; text-transform: uppercase;
}
/* A filled pill behind the active tab, drawn from the middle out. Colour alone
   was not enough to find at a glance on a dark background. */
.tab::before {
  content: ""; position: absolute; inset: .3rem .9rem;
  border-radius: 999px; background: var(--brand);
  opacity: 0; transform: scale(.6);
  transition: opacity .2s ease, transform .28s cubic-bezier(.34,1.6,.64,1);
}
.tab.on::before { opacity: .16; transform: scale(1); }
.tab.on { color: var(--brand); }
:root[data-theme="dark"] .tab.on { color: var(--brand-lt); }
/* And the other end of the same problem. Full indigo on the light background
   measures 4.08:1 -- it was failing before the field existed and the field made
   it 3.90:1. The darker brand clears 4.97:1 and is still unmistakably the
   active tab. */
:root[data-theme="light"] .tab.on { color: var(--brand-dk); }
.tab-icon { position: relative; font-size: 1.35rem; line-height: 1; transition: transform .12s ease; }
.tab:active .tab-icon { transform: scale(.85); }
.tab.on .tab-icon { animation: tabPop .4s cubic-bezier(.34,1.7,.64,1); }
.tab span:not(.tab-icon) { position: relative; }

/* ---------- confetti ----------
   celebrate.js creates one canvas and reuses it, so this has to be a full
   viewport overlay that can never intercept the tap it is drawn on top of. It
   sits above the tab bar (z 5) and the combo pill (z 40) because it is the last
   thing painted and covering the whole screen is the point. */
.confetti-layer {
  position: fixed; inset: 0;
  width: 100%; height: 100%;
  pointer-events: none;
  z-index: 90;
}

/* ---------- keyframes ----------
   Everything here is one-shot except flame and beat. Transforms and opacity
   only -- nothing in this block triggers layout. */

@keyframes pulse { 50% { opacity: .55; } }

/* Alive, not animated: a two-percent scale and a brightness nudge. Big enough
   to catch peripheral vision, small enough that it never begs. */
@keyframes flame {
  0%, 100% { transform: scale(1) rotate(0deg); filter: brightness(1); }
  35%      { transform: scale(1.14) rotate(-4deg); filter: brightness(1.25); }
  65%      { transform: scale(1.04) rotate(3deg); filter: brightness(1.1); }
}
@keyframes beat {
  0%, 62%, 100% { transform: scale(1); }
  12%           { transform: scale(1.22); }
  26%           { transform: scale(1); }
  38%           { transform: scale(1.14); }
}

/* Expands and fades, so it reads as a ripple leaving the node rather than a
   ring that grows and snaps back. */
@keyframes halo {
  0%        { transform: scale(1);    opacity: .65; }
  70%, 100% { transform: scale(1.42); opacity: 0; }
}

@keyframes pop {
  0%   { transform: scale(.86); }
  55%  { transform: scale(1.07); }
  100% { transform: scale(1); }
}
@keyframes bigPop {
  0%   { opacity: 0; transform: scale(.6); }
  60%  { opacity: 1; transform: scale(1.08); }
  100% { opacity: 1; transform: scale(1); }
}
@keyframes comboPop {
  0%   { transform: translate(-50%, 0) scale(.6); }
  55%  { transform: translate(-50%, -6px) scale(1.18); }
  100% { transform: translate(-50%, 0) scale(1); }
}
@keyframes shake {
  0%, 100%      { transform: translateX(0); }
  15%, 45%, 75% { transform: translateX(-7px); }
  30%, 60%, 90% { transform: translateX(7px); }
}
@keyframes wobble {
  0%, 100% { transform: translateX(0) rotate(0deg); }
  20%      { transform: translateX(-6px) rotate(-.8deg); }
  40%      { transform: translateX(5px) rotate(.7deg); }
  60%      { transform: translateX(-3px) rotate(-.4deg); }
  80%      { transform: translateX(2px) rotate(.2deg); }
}

@keyframes unitIn { from { opacity: 0; transform: translateY(18px) scale(.98); } }
@keyframes nodeIn { from { opacity: 0; transform: translateY(14px) scale(.82); } }
@keyframes rowIn  { from { opacity: 0; transform: translateY(10px); } }
@keyframes cellIn { from { opacity: 0; transform: translateY(14px) scale(.9); } }
@keyframes sheetUp { from { transform: translateY(100%); } }
@keyframes badgeIn {
  0%   { opacity: 0; transform: scale(.4) rotate(-8deg); }
  60%  { opacity: 1; transform: scale(1.12) rotate(2deg); }
  100% { opacity: 1; transform: scale(1) rotate(0); }
}
@keyframes crownPop {
  0%   { transform: scale(.5); }
  55%  { transform: scale(1.14); }
  78%  { transform: scale(.96); }
  100% { transform: scale(1); }
}
@keyframes crownSpin {
  0%   { transform: scale(.3) rotate(-160deg); }
  60%  { transform: scale(1.3) rotate(12deg); }
  100% { transform: scale(1) rotate(0); }
}
@keyframes tabPop {
  0%   { transform: scale(.7) translateY(3px); }
  55%  { transform: scale(1.22) translateY(-2px); }
  100% { transform: scale(1) translateY(0); }
}
/* scaleY from the bottom, so a bar grows out of the axis instead of sliding
   down onto it. */
@keyframes barGrow { from { transform: scaleY(0); opacity: .4; } }
@keyframes fillIn  { from { transform: scaleX(0); transform-origin: 0 50%; } }

/* ---------- board ----------
   Ported from 960-chess-site. See the header note: the checker must stay a CSS
   gradient for the colour picker to work. */

.cg-wrap { width: 100%; aspect-ratio: 1 / 1; height: auto; }

.cg-wrap cg-board {
  border-radius: .7rem;
  background-color: var(--sq-light);
  background-image: conic-gradient(
    var(--sq-dark) 0 25%, var(--sq-light) 0 50%,
    var(--sq-dark) 0 75%, var(--sq-light) 0);
  background-size: 25% 25%;
  background-position: 0 0;
  background-repeat: repeat;
}

.cg-wrap coords {
  font-family: var(--mono);
  /* Scaled off the board, not the viewport: cqw would need a container query,
     and vw sized them off the phone rather than off the board they sit on, so
     they were illegibly small on desktop and crowded on mobile. */
  font-size: clamp(.5rem, 1.9vw, .68rem);
  letter-spacing: .04em; font-weight: 600;
  /* They sit on top of the squares, so they need enough contrast to read over
     a light square and a dark one both. A shadow does that without a plate. */
  opacity: .9;
  text-shadow: 0 1px 2px rgb(0 0 0 / .45);
  color: #fff;
}

.cg-wrap cg-board square.move-dest {
  background: radial-gradient(rgb(var(--mark) / .70) 22%, rgb(var(--mark) / .90) 0,
                              rgba(0,0,0,.3) 0, rgba(0,0,0,0) 0);
}
.cg-wrap cg-board square.oc.move-dest {
  background: radial-gradient(transparent 0%, transparent 80%, rgb(var(--mark) / .45) 80%);
}
.cg-wrap cg-board square.last-move { background-color: rgba(155,199,0,.41); }
.cg-wrap cg-board square.selected   { background: rgb(var(--mark) / .50); }
.cg-wrap cg-board square.check {
  background: radial-gradient(ellipse at center, rgba(255,0,0,1) 0%, rgba(231,0,0,1) 25%,
    rgba(169,0,0,0) 89%, rgba(158,0,0,0) 100%);
}

/* ---------- reduced motion ----------
   The blanket kill is deliberate. Every entrance in this sheet uses fill:both
   with an opacity:0 start, and switching animations off means the fill never
   applies, so elements render in their final state -- there is nothing left
   invisible. celebrate.js opts itself out separately. */
@media (prefers-reduced-motion: reduce) {
  * { animation: none !important; transition: none !important; }
}


/* ---------- practice screen ----------
   Its own spacing scale because it is the only screen that is a menu rather
   than a feed: three or four large tap targets with nothing between them, where
   the default h2 rhythm (tuned for the dense You tab) leaves the blocks running
   into each other and the page reading as one undifferentiated list.

   The rule is that a heading belongs to what FOLLOWS it, so the space above a
   heading is always larger than the space below. That single relationship is
   what makes the groups legible without needing rules or boxes. */
.practice .practice-head { padding: .4rem 0 .2rem; }
.practice .practice-head h1 { margin-bottom: .35rem; }
.practice .practice-head .sub { margin-bottom: 0; max-width: 30rem; }

.practice-block { margin-top: 2.1rem; }
.practice-block > h2 { margin: 0 0 .3rem; }
.practice-block > .sub { margin-bottom: .95rem; max-width: 30rem; }
.practice-block > .btn { width: 100%; }

/* The cards carry their own bottom margin, so the wrapper only has to stop the
   last one adding a gap the section margin already provides. */
.rush-cards > :last-child { margin-bottom: 0; }

.practice .tail { height: 3rem; }


/* Recent runs. A log of scores is just a log; the delta column is what turns it
   into a target, so it gets the accent and a fixed width that keeps the numbers
   in a straight line down the page. */
.runlog { display: grid; gap: .4rem; }
.run {
  display: grid; grid-template-columns: 3rem 1fr auto;
  align-items: center; gap: .8rem;
  background: var(--card); border: 1px solid var(--rule);
  border-radius: .8rem; padding: .6rem .85rem;
}
.run-score {
  font-size: 1.15rem; font-weight: 800;
  font-variant-numeric: tabular-nums; text-align: right;
}
.run-mode { display: flex; flex-direction: column; font-size: .9rem; font-weight: 700; min-width: 0; }
.run-mode .dim { font-size: .72rem; font-weight: 600; }
.run-tag {
  font-size: .72rem; font-weight: 800;
  padding: .2rem .5rem; border-radius: 999px;
  background: var(--surface); color: var(--dim);
  font-variant-numeric: tabular-nums;
}
.run-tag.best { background: color-mix(in srgb, var(--crown) 28%, var(--card)); color: var(--crown); }


/* ---------- skill sheet ----------
   The start button is pinned rather than sitting under five level rows. On a
   phone those rows plus the explanation push it below the fold, so the one
   action the screen exists for was the one thing you had to scroll to find.
   Pinned, the rows scroll under it and the decision is always one tap away. */
.sheet-cta {
  flex: none;
  padding: .75rem var(--pad) calc(.75rem + env(safe-area-inset-bottom));
  background: linear-gradient(to top,
    var(--bg) 62%, color-mix(in srgb, var(--bg) 55%, transparent));
  border-top: 1px solid var(--rule);
}
.sheet-cta .btn { width: 100%; }
/* The pinned bar covers the end of the list, so the list has to end above it. */
.sheet .tail { height: 1.2rem; }

/* What the motif actually is, before the levels that drill it. Set as body
   copy rather than as another muted .sub, because it is the only teaching text
   on the screen and the surrounding greys would bury it. */
.teach {
  font-size: .95rem; line-height: 1.55;
  color: var(--ink);
  background: var(--card);
  border-left: 4px solid var(--sec, var(--brand));
  border-radius: .3rem .8rem .8rem .3rem;
  padding: .8rem .95rem;
  margin: .9rem 0 1.2rem;
}

.guide-link {
  /* inline-flex with the tap floor rather than inline-block: this sits on its
     own line as a navigation link out to the written guide, so it is a target
     rather than a word in a sentence, and 17px of it was not one. */
  display: inline-flex; align-items: center; min-height: var(--tap);
  margin-top: .2rem;
  font-size: .88rem; font-weight: 700;
  color: var(--brand); text-decoration: none;
}
/* Same case as .node.gated .node-name: a mid-tone indigo sitting on the field
   rather than on a card. */
:root[data-theme="dark"] .guide-link { color: var(--brand-lt); }
.guide-link:hover { text-decoration: underline; }


/* Small print: the licence credit and the free-forever line. Sized down with a
   looser leading so a legal sentence does not read as body copy. */
.sub.tiny { font-size: .78rem; line-height: 1.5; }
.gate .gate-mark { margin-top: 1.6rem; }
.gate h2 { margin-top: 2.2rem; }
.gate .btn { margin-bottom: .7rem; }


/* ---------- name field ----------
   THE PROBLEM THIS FIXES: styled like the buttons around it -- same radius,
   same height, same centred bold text -- the input read as a third button, and
   the card became three identical rounded rectangles with no way to tell which
   one you type into and which one you press.

   So it is deliberately NOT button-shaped. It is inset rather than raised
   (recessed background, no shelf shadow), left-aligned rather than centred,
   squarer, and shorter. Every one of those differences is doing the same job:
   saying "this is a hole you put something in", not "this is a thing you
   press".

   16px font size is not a style choice. iOS zooms the whole viewport when a
   focused input is smaller than that, and it never zooms back out. */
.namefield {
  display: block; width: 100%;
  font: inherit; font-size: 1rem; font-weight: 600;
  text-align: left;
  color: var(--ink);
  background: var(--bg);
  border: 2px solid var(--rule);
  border-radius: .6rem;
  padding: .7rem .85rem;
  margin: 0 0 1rem;
  box-shadow: inset 0 2px 4px rgb(0 0 0 / .28);
}
.namefield::placeholder { color: var(--dim); font-weight: 500; }
.namefield:focus {
  outline: none;
  border-color: var(--brand);
  box-shadow: inset 0 2px 4px rgb(0 0 0 / .28), 0 0 0 3px rgb(91 108 255 / .25);
}

/* A long question wraps to two lines in a 20.5rem card and then crowds the
   field under it. Slightly smaller and tighter here than the welcome heading,
   which is one short brand word. */
.welcome-card h1.ask { font-size: 1.25rem; line-height: 1.25; }

/* ---------- greeting ----------
   Above the first unit rather than inside the stats bar: the bar is a live
   readout that has to stay the same height on every screen, and a name is not
   the same kind of thing as a heart count. */
.hello { margin: 1.1rem 0 .4rem; }
.hello-hi {
  font-size: 1.35rem; letter-spacing: -.02em;
  margin: 0 0 .7rem;
}
.hello-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: .5rem; }
.hello-stat {
  background: var(--card); border: 1px solid var(--rule);
  border-radius: .8rem; padding: .6rem .4rem;
  display: flex; flex-direction: column; align-items: center; gap: .1rem;
}
.hello-stat b { font-size: 1.2rem; font-variant-numeric: tabular-nums; }
.hello-stat span {
  font-size: .65rem; font-weight: 700; color: var(--dim);
  text-transform: uppercase; letter-spacing: .04em; text-align: center;
}


/* Scale only, so it can be used on an element positioned by auto margins.
   `pop` also moves the element, which is fine for a caption and wrong for
   anything that has to stay centred. */
@keyframes cardPop {
  0%   { transform: scale(.86); opacity: 0; }
  100% { transform: scale(1);   opacity: 1; }
}


/* ---------- the help row ----------
   Hidden by VISIBILITY, never by display, and with its height reserved.
   .lesson-body is a centred column, so removing this row from the flow the
   moment a puzzle is solved changed the column's height and re-centred
   everything -- the whole board jumped up as the answer landed. Reserving the
   space costs one row of empty pixels and keeps the board absolutely still
   from the first move to the last.

   Same reason .hint-text reserves its line: revealing a hint must not shove
   the position the learner is reading. */
.help-row {
  min-height: var(--tap);
  display: flex; align-items: center; justify-content: center;
  margin-top: .5rem;
}
.help-row.spent { visibility: hidden; }

/* RESERVING ONE LINE WAS RESERVING THE WRONG NUMBER.
 *
 * The veiled box was empty, so it stood at its min-height of 1.4rem -- 22px.
 * Every hint in the course is taller than that: measured across all 522 of
 * them at 390px, 127 render at 42px, 383 at 62px and 12 at 81px. Not one fits.
 * So the reveal grew the centred column by 20 to 59px and the board slid up
 * under the learner's eyes on every single hint, in all 49 skills -- the exact
 * thing the comment above says this rule prevents.
 *
 * Two halves to the fix. lesson.js now writes the hint into the box when the
 * puzzle LOADS and only unveils it later, so the box is already exactly as
 * tall as the sentence it is going to show and revealing changes nothing.
 * min-height then only has to cover the gap BETWEEN puzzles, where a one-line
 * hint following a three-line one would otherwise re-centre the column: three
 * lines plus the padding is the tallest hint the course contains, so the slot
 * is the same height for every puzzle and the board never moves at all.
 *
 * Expressed in em (the box's own font-size) plus the padding in rem, so it
 * tracks the type rather than being a pixel constant that goes stale. */
.hint-text {
  min-height: calc(4.2em + 1.4rem);
  margin: .35rem 0 0;
  font-size: .88rem; line-height: 1.4;
  color: var(--ink); text-align: center;
  max-width: 26rem;
  display: flex; align-items: center; justify-content: center;
  background: color-mix(in srgb, var(--streak) 16%, var(--card));
  border-radius: var(--radius); padding: .7rem .95rem;
  box-shadow: 0 3px 0 color-mix(in srgb, var(--streak) 30%, var(--rule));
}
/* Veiled, not removed: display:none would give the slot no height and hand the
   shift straight back. */
.hint-text[hidden] { display: flex !important; visibility: hidden; }
/* The arrival is animated on the class rather than on .hint-text itself. The
   element is in the DOM from the moment the puzzle loads, so an animation
   declared on the element would have played to an empty screen long before the
   learner ever asked for help. */
.hint-text.revealed { animation: rowIn .28s cubic-bezier(.21,.9,.3,1.1) both; }


/* ---------- course tracks ----------
   The course is a spine plus branches now, and 16 sections read as one endless
   scroll without a marker where its character changes. Set as a quiet rule
   rather than another coloured band: the unit headers are already loud, and a
   second loud thing above them competes rather than organises. */
.track {
  margin: 2.4rem 0 .2rem;
  font-size: .78rem; font-weight: 800;
  letter-spacing: .12em; text-transform: uppercase;
  color: var(--dim);
  display: flex; align-items: center; gap: .7rem;
}
.track::after {
  content: ""; flex: 1 1 auto; height: 1px; background: var(--rule);
}

/* A branch waiting on a crown says so. A column of padlocks with no reason
   reads as "you are not good enough yet" rather than "one more crown". */
.unit.waiting { opacity: .92; }
.unit-gate {
  margin: .5rem 0 0; padding: .5rem .8rem;
  background: var(--card); border: 1px dashed var(--rule);
  border-radius: .7rem;
  font-size: .8rem; color: var(--dim);
}
.unit-gate b { color: var(--ink); }

/* ---------- rush groups ---------- */
.rush-group { margin-top: 1.5rem; }
.rush-group:first-child { margin-top: 0; }
.rush-group > h3 {
  margin: 0 0 .15rem; font-size: .95rem; letter-spacing: -.01em;
}
.rush-group > .sub { margin-bottom: .7rem; font-size: .82rem; }

/* The opening modes label themselves with a move rather than a glyph: "e5"
   identifies a repertoire faster than any icon could. */
.rush-mono {
  font-family: var(--mono); font-style: normal;
  font-size: .95rem; font-weight: 700;
}


/* ---------- greeting chips ----------
   Small and colourful rather than three bordered cards. These numbers are a
   nice-to-see; the previous version made the least important thing on the
   screen the heaviest block on it, sitting directly above the first node. */
.hello { margin: 1rem 0 .2rem; }
.hello-hi { font-size: 1.3rem; letter-spacing: -.02em; margin: 0 0 .55rem; }
.hello-chips { display: flex; flex-wrap: wrap; gap: .4rem; }
.chipstat {
  display: inline-flex; align-items: center; gap: .3rem;
  font-size: .8rem; font-weight: 700;
  padding: .32rem .65rem; border-radius: 999px;
  background: var(--card); color: var(--dim);
  border: 1px solid var(--rule);
}
/* A raw <svg> has no intrinsic size the way a glyph does, so it takes the
   width of its box and crushed the label into three lines. Every drawn icon in
   the app needs an explicit box; these are the three places they appear. */
.chipstat { white-space: nowrap; }
.chipstat svg { width: 1rem; height: 1rem; flex: none; }
.chipstat.streak { color: var(--streak-ink); border-color: color-mix(in srgb, var(--streak) 35%, var(--rule)); }
/* --brand-ink, for the same reason as --streak-ink above: --brand-lt is the
   pale indigo that reads on a near-black field and measures 2.66:1 on white. */
.chipstat.solved { color: var(--brand-ink); border-color: color-mix(in srgb, var(--brand) 35%, var(--rule)); }
.chipstat.goal   { color: var(--good-ink); border-color: color-mix(in srgb, var(--good) 35%, var(--rule)); }
.chipstat.goal.done { background: color-mix(in srgb, var(--good) 18%, var(--card)); }


/* Drawn icons inside the badge pills and the gate mark. Same rule as the
   chips: give every svg a box or it takes the whole one. */
.badge svg { width: 1rem; height: 1rem; flex: none; }
.gate-mark svg { width: 45%; height: 45%; }
.welcome-mark svg { width: 52%; height: 52%; }

/* ---------- desktop ----------
 *
 * LAST IN THE FILE ON PURPOSE. These rules override .tab and .statsbar, which
 * are declared several hundred lines above; at equal specificity the later
 * declaration wins, so sitting earlier meant the rail silently kept the mobile
 * bar's column layout and its active-pill.
 *
 * A 34rem column centred in a 1440px window is a phone screenshot pasted onto
 * a monitor: two thirds of the viewport is empty, and a tab bar pinned to the
 * bottom edge is answering a thumb that is not there. So above 64rem the
 * chrome changes shape rather than just getting wider -- the tab bar becomes a
 * left rail, which is where a pointer expects navigation and where it can
 * afford to show labels next to icons rather than under them.
 *
 * Done entirely in CSS. The markup is identical at every width, so there is no
 * second layout to keep in step and no JS breakpoint that can disagree with
 * the stylesheet about which one is showing.
 */
@media (min-width: 64rem) {
  /* The field already spans the viewport, so the desktop page needs no
     background of its own -- and it must not have one, or it would paint over
     the layer it is meant to be sitting in. What changes at this width is the
     COMPOSITION, not the mechanism: a 1440px window is more than twice as wide
     as a phone, so the two ceiling pools are widened and pulled apart to land
     either side of the content column rather than stacking on top of it, and
     the side falloff is deepened because there is now a lot of screen outside
     the shell that should read as unlit room. */
  body { background: transparent; }
  body::before {
    background-image:
      radial-gradient(60% 62% at 100% 44%, rgb(var(--glow-accent) / .22), transparent 100%),
      radial-gradient(85% 48% at 44% 116%, rgb(var(--glow-floor) / .24), transparent 100%),
      radial-gradient(75% 85% at 30% 4%, transparent 10%, rgb(0 0 0 / .74) 92%),
      radial-gradient(90% 100% at 26% -12%, rgb(var(--glow-key) / .38), transparent 100%),
      linear-gradient(180deg, rgb(var(--glow-key) / .045), transparent 70%);
  }
  :root[data-theme="light"] body::before {
    background-image:
      radial-gradient(60% 62% at 100% 44%, rgb(var(--glow-accent) / .06), transparent 100%),
      radial-gradient(85% 48% at 44% 116%, rgb(var(--glow-floor) / .06), transparent 100%),
      radial-gradient(90% 100% at 26% -12%, rgb(var(--glow-key) / .10), transparent 100%);
  }

  #shell {
    max-width: 68rem;
    height: 100dvh;
    display: grid;
    grid-template-columns: 15.5rem 1fr;
    /* A PANE OF GLASS, NOT A CARD. Filling the shell with an opaque --bg turned
       the one place the design puts its content into the one place the light
       could not reach: a flat slab with a lit room around it. Left transparent,
       the field runs straight through the column and the shell is described by
       its rim and its rail divider alone, which is all a container on a desktop
       ever needed to say. */
    background: transparent;
    /* A rim and a top edge catching the light. Two shadows rather than a border
       so the shell keeps its exact grid geometry -- a border would add 2px to a
       fixed 68rem column and shift the rail. */
    box-shadow: 0 0 0 1px var(--rule), inset 0 1px 0 rgb(255 255 255 / .06);
  }

  /* The rail. Ordered first visually but still the second element in the DOM,
     which is correct for a screen reader: navigation after content is the
     order a keyboard user wants. */
  #tabbar {
    grid-column: 1; grid-row: 1;
    flex-direction: column;
    align-items: stretch;
    justify-content: flex-start;
    gap: .25rem;
    padding: 1.4rem .75rem;
    border-top: 0;
    border-right: 1px solid var(--rule);
  }

  .tab {
    flex: 0 0 auto;
    flex-direction: row;
    align-items: center;
    justify-content: flex-start;
    gap: .8rem;
    padding: .8rem 1rem;
    border-radius: .9rem;
    font-size: .95rem;
    text-transform: none;
    letter-spacing: -.01em;
  }
  .tab-icon { font-size: 1.35rem; }
  /* The mobile bar draws the active pill BEHIND THE ICON via a pseudo element
     sized to the icon. At rail width that pill inflates to the width of the
     row and renders as a large disc behind the label, so it is switched off
     and the highlight moves to the row itself, which is now the whole target. */
  .tab.on { background: color-mix(in srgb, var(--brand) 15%, transparent); }
  .tab.on::before, .tab.on::after { display: none; content: none; }
  .tab.on .tab-icon { animation: none; transform: none; }
  .tab span:not(.tab-icon) { position: static; }

  #app { grid-column: 2; grid-row: 1; min-height: 0; }

  /* Content gets a comfortable measure rather than the full 52rem, which would
     put a 100-character line length on the guides and a board wider than any
     board needs to be. */
  .scroll { padding: 0 2rem; }
  .scroll > .unit,
  .scroll > .practice-block,
  .scroll > .hello,
  .scroll > h1,
  .scroll > h2,
  .scroll > .sub,
  .scroll > .grid,
  .scroll > .records,
  .scroll > .achv-grid,
  .scroll > .quests,
  .scroll > .activity,
  .scroll > .rank,
  .scroll > .track { max-width: 34rem; margin-left: auto; margin-right: auto; }

  .statsbar { padding-left: 2rem; padding-right: 2rem; }
  /* The brand is in the rail's own header at this size, so repeating it in the
     bar is noise. */
  .statsbar .brand { visibility: hidden; }
}

/* The rail needs a title where the bottom bar needed none. */
@media (min-width: 64rem) {
  #tabbar::before {
    content: "Reflex Chess";
    display: block;
    font-weight: 800; font-size: 1.05rem; letter-spacing: -.02em;
    color: var(--ink);
    padding: .2rem 1rem 1.1rem;
  }
}


/* ---------- site footer ----------
   Sits below the app shell, so it is only reachable by scrolling past a
   100dvh viewport -- which on a phone means it is effectively invisible while
   still being a real, crawlable, clickable link. That is the honest version of
   what it is for: it exists to pass authority to the content sets and to give
   a reader who wants prose somewhere to go, without spending any of the app's
   own chrome on it. */
.sitefoot {
  border-top: 1px solid var(--rule);
  background: var(--surface);
  padding: 1.6rem var(--pad) calc(1.6rem + env(safe-area-inset-bottom));
}
.sitefoot nav {
  max-width: 34rem; margin: 0 auto;
  display: flex; flex-wrap: wrap; gap: .5rem 1.4rem;
  justify-content: center;
}
.sitefoot a {
  color: var(--dim); text-decoration: none;
  font-size: .85rem; font-weight: 600;
}
.sitefoot a:hover { color: var(--brand); text-decoration: underline; }

/* SHOWN ON THE TOOLS TAB, AND NOWHERE ELSE.
 *
 * It used to sit below every route. The app shell is 100dvh, so the footer was
 * the only thing making the document taller than the viewport -- and therefore
 * the only reason the app scrolled at all. That is wrong twice over. In a
 * lesson or a rush it turned any stray vertical swipe into 116px of scroll and
 * slid the board up under the learner mid-puzzle (measured: 247px from the top
 * down to 131px, with a strip of links where the position had been). And even
 * on the tree, where nothing moved, it gave a screen that should not scroll a
 * little bounce at the bottom -- which on an installed PWA reads as a web page
 * pretending to be an app, which is the one thing this shell is trying not to
 * be.
 *
 * Restricting it to Tools fixes both, and it puts the links where somebody
 * looking for them would actually go: that tab is already a list of everything
 * on the site that is not a puzzle. Every one of these destinations is either a
 * card on that tab or reachable from Compete.
 *
 * THE FOOTER STAYS IN THE MARKUP, which is the point of doing it this way
 * rather than deleting it. index.html is the strongest page on the domain, and
 * these are its only crawlable links into the ~200 content pages. Same HTML is
 * served to everyone, and the links are genuinely visible on a real route -- so
 * this is a layout decision, not a cloak. */
.sitefoot { display: none; }
body[data-tab="tools"] .sitefoot { display: block; }

/* And never under a full-screen view, whatever the last tab was. A lesson, a
   rush and the results screen do not repaint the tab bar, so `data-tab` still
   reads whatever the learner was on when they left it -- and if that was Tools,
   the footer would come back underneath a board. Keyed on the tab bar being
   hidden, which is already precisely what app.js means by full-screen. The #id
   in :has() makes this (1,1,1) against the rule above, so it wins on
   specificity rather than on source order. */
body:has(#tabbar[hidden]) .sitefoot { display: none; }


/* ---------- tool cards ----------
   Their own component rather than a borrowed .rush-card. A rush card is a
   three column grid (icon | body | best score); a tool has neither an icon nor
   a score, so its chevron was being placed in the middle column and drifting
   left or right with the length of the blurb. Two columns, and the arrow is
   pinned to the end of the row where it belongs.

   text-decoration is killed explicitly. These are anchors, and .btn never had
   to think about that because nothing else in the app styles a link as a
   block; the underline was running under the title and the blurb both. */
.toolcard {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: center;
  gap: 1rem;
  width: 100%;
  margin-bottom: .6rem;
  padding: .95rem 1.1rem;
  background: var(--card);
  border: 1px solid var(--rule);
  border-radius: var(--radius);
  box-shadow: 0 3px 0 var(--rule);
  color: var(--ink);
  text-decoration: none;
  transition: transform .1s ease, box-shadow .1s ease;
}
.toolcard:hover { border-color: var(--brand); }
.toolcard:active { transform: translateY(3px); box-shadow: none; }
.toolcard:last-child { margin-bottom: 0; }

.toolcard-body { display: flex; flex-direction: column; gap: .15rem; min-width: 0; }
.toolcard-body b { font-size: 1rem; font-weight: 800; letter-spacing: -.01em; }
.toolcard-body span {
  font-size: .82rem; font-weight: 500; color: var(--dim); line-height: 1.35;
}

/* Fixed box so the chevron sits on the same vertical line down the whole
   column regardless of how many lines the blurb wraps to. */
.toolcard-arrow { width: 1.15rem; height: 1.15rem; color: var(--dim); flex: none; }
.toolcard:hover .toolcard-arrow { color: var(--brand); }


/* The update prompt. Fixed above the tab bar rather than a modal: a new build
   being available is not urgent enough to interrupt a lesson, and a learner who
   ignores it loses nothing. */
.swtoast {
  position: fixed; left: 50%; bottom: calc(4.5rem + env(safe-area-inset-bottom));
  transform: translateX(-50%);
  z-index: 80;
  font: inherit; font-size: .85rem; font-weight: 700;
  background: var(--card); color: var(--ink);
  border: 0; border-radius: 999px;
  padding: .6rem 1.1rem;
  box-shadow: 0 6px 24px rgb(0 0 0 / .5), 0 0 0 1px var(--rule);
  cursor: pointer;
  animation: sheetUp .3s ease both;
}
.swtoast:active { transform: translateX(-50%) translateY(2px); }


/* `.welcome-aside` lived here for the rating-test offer under the old welcome
   modal's buttons. That modal is gone -- the first visit is now the intro page
   (public/app/intro.js), which makes the same offer as one of its launch
   points -- so the rule matched nothing and is deleted rather than left to rot.
   `overlay()` itself is still alive and still styled below: maybeAskName() uses
   it to ask a learner what to call them. */
