:root {
  color-scheme: dark;

  /* ---------- Surfaces ----------
     A neutral night, one step off black. The chrome carries no hue at all: it
     recedes so that the board — which keeps the accent family — is the one
     large coloured surface of the page, and blue only ever appears where a
     decision or a datum is. Solid values, never stacked opacities. */
  --page: #040405;
  --surface-1: #0d0d0f; /* cards, board panel */
  --surface-2: #131316; /* fields, hover, gauge tracks */
  --surface-3: #1b1b1f; /* menus, popovers, active elements */
  --surface-inset: #08080a; /* inset: table backgrounds, scrolling areas */

  --line: #1d1d22; /* standard rule */
  --line-strong: #33333c; /* emphasised rule: section edge, separator */
  --line-faint: #151519; /* internal rule, between rows of one same list */

  --text-primary: #ededf0;
  --text-secondary: #94949e;
  --text-muted: #82828c; /* 5.1:1 on --surface-1: readable, not decorative */
  --text-on-accent: #f4f6ff;

  /* Compatibility: these names are consumed by the existing views. */
  --grid: var(--line);
  --baseline: var(--line-strong);
  --border: var(--line);

  /* ---------- Persian blue and its steps ----------
     A single decision hue. --accent-deep is the exact Persian blue; the other
     steps are that same blue raised or lowered in value. --accent fills,
     --accent-bright writes: a link in --accent on --page only passes 3.7:1, the
     light step passes 8.3:1. */
  --accent-deep: #1c39bb;
  --accent: #4361ee;
  --accent-hover: #5f79ff;
  --accent-bright: #93a8ff;
  --accent-quiet: #141b3d; /* tinted background: selection, active row */
  --accent-line: #2e3d94;

  /* Cyan is the lightest step of the same family: it serves what is going well.
     Coral is the app's only warm hue, reserved for what is going wrong — a
     mistake rendered in blue does not read. */
  --series-time: #6d8bff;
  --series-accuracy: #45c8f0;
  --status-good: #45c8f0;
  --status-warning: #ff9f6b;
  --status-critical: #ff6b6b;
  --text-on-status: #24091a; /* dark ink: white passes neither on cyan nor on coral */

  /* Changing either square also means changing the dark-squares SVG baked into
     cg-board's background-image below: a data-URI cannot read a variable. */
  --square-light: #c9d3f2;
  --square-dark: #5563bd;
  /* Board layer: highlights placed ON the squares, not on the chrome. The last
     move takes the cyan, lighter than both squares and hence readable on both.
     The selected square is never filled: only its edges are lit, brightly, so
     the piece being moved stays on its own colour. */
  --square-last-move: color-mix(in srgb, var(--series-accuracy) 42%, transparent);
  --square-selected-edge: rgba(255, 255, 255, 0.72);
  --piece-face: #f7f8ff;
  --piece-face-lit: #ffffff;

  /* The two sides. The evaluation bar, the advantage graph and the side badges
     read the same pair: they are domain colours, not decoration. */
  --side-white: #eef1ff;
  --side-black: #2c2c34;
  --side-black-piece: #0c0c10;

  /* Move classification scale (brilliant -> blunder): it goes down from cyan to
     coral by way of blue, without ever leaving the app's two hues. The JS reads
     it via var(), no hex lives in the modules any more. */
  --move-brilliant: #4ae0e8;
  --move-great: #7fb3ff;
  --move-best: var(--series-time);
  --move-good: #9fb0f0;
  --move-book: #8f97c8;
  --move-mistake: var(--status-warning);
  --move-miss: var(--status-critical);

  /* Solidity of a position (known -> never trained). knowledge.js reads these
     four tokens: the category is a domain fact, its colour is not. */
  --knowledge-known: var(--series-accuracy);
  --knowledge-learning: var(--series-time);
  --knowledge-fragile: var(--status-critical);
  --knowledge-untrained: #44444f;

  /* Sequential scale of the activity calendar, derived from Persian blue. */
  --heat-1: #1a245c;
  --heat-2: #26399f;
  --heat-3: #3d59d8;
  --heat-4: #6d8bff;
  --heat-5: #a5b8ff;

  /* ---------- Shapes ----------
     Nothing angular: the smallest step is already a pebble. --r-sm dresses the
     chips and the fields, --r-md the blocks, --r-lg the cards, --r-full
     everything that is pressed. */
  --r-xs: 8px;
  --r-sm: 14px;
  --r-md: 20px;
  --r-lg: 28px;
  --r-xl: 36px;
  --r-full: 999px;

  /* ---------- Depth ----------
     Every shadow carries an offset AND a blur: a glow without an offset is not
     depth, it is decoration. --shadow-press is the reverse: what is pressed in
     hollows out instead of lifting. */
  --shadow-1: 0 2px 4px rgba(3, 5, 18, 0.5);
  --shadow-2: 0 8px 20px -6px rgba(3, 5, 18, 0.62), 0 2px 6px -3px rgba(3, 5, 18, 0.5);
  --shadow-3: 0 26px 64px -16px rgba(3, 5, 18, 0.78), 0 8px 20px -8px rgba(3, 5, 18, 0.55);
  --shadow-press: inset 0 3px 8px rgba(3, 5, 18, 0.55);
  --glow-accent: 0 6px 20px -6px color-mix(in srgb, var(--accent) 65%, transparent);

  /* ---------- Motion ----------
     Slow enough to be seen, short enough not to be paid for: the typical gesture
     is a 140 ms press and a 240 ms release that slightly overshoots.
     --ease-spring only serves transforms — a colour that overshoots its target
     flickers. */
  --t-fast: 140ms;
  --t-base: 240ms;
  --t-slow: 380ms;
  --ease: cubic-bezier(0.22, 0.9, 0.28, 1);
  --ease-spring: cubic-bezier(0.34, 1.45, 0.5, 1);

  /* ---------- Spacing ---------- */
  --s1: 4px;
  --s2: 8px;
  --s3: 14px;
  --s4: 18px;
  --s5: 24px;
  --s6: 30px;
  --s7: 40px;

  /* Board-view presets: the three panel widths of the layout contract.
     Focus reads in 480, Étude needs 500 beside its 300 rail, and the Établi's
     five-column table cannot live under 660. */
  --panel-xs: 400px; /* below the switch point, every preset falls back to this */
  --panel-sm: 480px;
  --panel-md: 500px;
  --panel-lg: 660px;

  /* ---------- Text ----------
     Fredoka carries the titles, the figures and everything that is pressed: its
     round terminals and wide set are the subject of the redesign. Nunito holds
     the body, same roundness but readable at 13 px. Mono is left only for what
     has to align character by character: FEN, fingerprints, PGN. */
  --font-display: "Fredoka", "Segoe UI Rounded", ui-rounded, system-ui, sans-serif;
  --font: "Nunito", "Segoe UI Rounded", ui-rounded, "Segoe UI", system-ui,
    -apple-system, sans-serif;
  --font-mono: ui-monospace, "Cascadia Mono", "SF Mono", Consolas, "Liberation Mono",
    monospace;

  /* Size scale: each step gained 1 to 6 px over the old one, and the weights
     start at 600 — that is where the "big" is played. */
  --fs-2xs: 12px;
  --fs-xs: 13px;
  --fs-sm: 14px;
  --fs-base: 15px;
  --fs-md: 17px;
  --fs-lg: 22px;
  --fs-xl: 27px;
  --fs-2xl: 34px;
  --fs-3xl: 48px;

  --focus-ring: 0 0 0 3px var(--page), 0 0 0 6px var(--accent-bright);
}

* {
  box-sizing: border-box;
}

/* The surfaces we do not draw carry the design all the same. Caret, selection,
   scrollbars and the focus ring arrive by default from the browser and belong to
   no system: we dress them. */
::selection {
  background: var(--accent-line);
  color: var(--text-primary);
}

:focus-visible {
  outline: none;
  box-shadow: var(--focus-ring);
  border-radius: var(--r-sm);
}

* {
  scrollbar-width: thin;
  scrollbar-color: var(--line-strong) transparent;
}
::-webkit-scrollbar {
  width: 10px;
  height: 10px;
}
::-webkit-scrollbar-track {
  background: transparent;
}
::-webkit-scrollbar-thumb {
  border: 3px solid transparent;
  border-radius: var(--r-full);
  background: var(--line-strong) padding-box;
}
::-webkit-scrollbar-thumb:hover {
  background: var(--accent) padding-box;
}
::-webkit-scrollbar-corner {
  background: transparent;
}

/* The scroll gutter is reserved permanently: without it, a view that overflows
   makes the scrollbar appear, the window loses 15px and every centred column
   shifts from one tab to the next. */
html {
  scrollbar-gutter: stable;
}

/* A single light source, at the top of the page, in the app's blue: the cards
   stand out from a background that is not uniformly flat, and the rest of the
   screen darkens toward the bottom. Fixed, so it does not scroll with the
   content — a light that moves is not a light. */
body {
  margin: 0;
  background: radial-gradient(
      120% 70% at 50% -10%,
      color-mix(in srgb, var(--accent-deep) 26%, transparent) 0%,
      transparent 62%
    )
    fixed,
    var(--page);
  color: var(--text-primary);
  font-family: var(--font);
  font-size: var(--fs-md);
  /* Nunito is read at 600 everywhere: its regular weight fades away on a
     background this dark, and the brief asks for bold, not thin. */
  font-weight: 600;
  line-height: 1.55;
  caret-color: var(--accent-bright);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  /* Every figure in this app is a measure compared against another: columns of
     figures must never dance from one row to the next. */
  font-variant-numeric: tabular-nums;
}

/* The titles go to Fredoka: wide set, round terminals, no negative tracking —
   a tight title cancels the roundness of the letterforms. */
h1,
h2,
h3 {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 600;
  letter-spacing: 0;
  text-wrap: balance;
}

/* The app's only h1 is the name at the head of the navigation column: it holds
   the step of the section titles. h3 shares the body's size but neither its weight nor its typeface,
   which is a readable and deliberate distinction. */
h1 {
  font-size: var(--fs-lg);
}
h2 {
  font-size: var(--fs-lg);
  line-height: 1.25;
}
h3 {
  font-size: var(--fs-md);
  line-height: 1.3;
}

/* Fredoka does not stop at the titles: it carries everything that is pressed and
   everything that names. A single list, here, rather than a font-family sown
   through forty rules: that is the condition for the redesign to hold from one
   screen to the next. */
.pause-title,
.stat-label,
.tile-label,
.set-fact dt,
.cycle-done,
.chip,
.tab,
.segment,
.set-switch,
.move-chip {
  font-family: var(--font-display);
}


a {
  color: var(--accent-bright);
  text-decoration: none;
  text-underline-offset: 0.18em;
  text-decoration-thickness: 1px;
  transition: color var(--t-fast) var(--ease);
}
a:hover {
  color: var(--accent-hover);
  text-decoration: underline;
}

code,
kbd,
samp,
pre {
  font-family: var(--font-mono);
  font-size: 0.92em;
}

/* ---------- The navigation column ----------
   The shell lives on the left. Three zones, in the order the board panel already
   uses: a fixed head, a body that scrolls, a fixed foot — the hand learnt that
   grammar once, on the panel.

   Two widths and a single switch, at 1600. That number is not about crowding:
   below it the board is bounded by the WIDTH, so every pixel the column spends
   on words is a pixel off the board. SHELL_BREAKPOINT in board-size.js switches
   at the same number — if one moves, both move.

   Carried by a class and not by `body`: login.html shares this stylesheet, and
   its `.login-body` sets `display: grid` without ever redefining the columns, so
   a bare `body` rule would reach it and wedge the sign-in card into a
   max-content track. */
.app-body {
  display: grid;
  grid-template-columns: max-content minmax(0, 1fr);
  min-height: 100dvh;
}

.app-rail {
  position: sticky;
  top: 0;
  align-self: start;
  z-index: 10;
  display: flex;
  flex-direction: column;
  width: 232px;
  height: 100dvh;
  padding: var(--s4) var(--s3) var(--s3);
  border-right: 1px solid var(--line);
  background: var(--surface-1);
}
.app-rail-head {
  flex: none;
  padding: 0 6px var(--s4);
}
/* Open, the chevron points at the edge the column folds towards; folded, at
   the room it would take back. */
.rail-toggle .icon {
  transform: rotate(90deg);
}
.rail-narrow .rail-toggle .icon {
  transform: rotate(-90deg);
}
.app-rail-body {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  gap: 3px;
  /* The Profile deploys nine sections: a short window does not hold them all.
     Collapsed this becomes `visible` — the flyout below needs it. */
  overflow-y: auto;
}
.app-rail-foot {
  flex: none;
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: var(--s3);
  padding-top: var(--s3);
  border-top: 1px solid var(--line);
}
/* The set gets a line of its own. Sharing one with the mute and the account left
   it 120 px, which turns "Najdorf 200" into "Naj…" — and a truncated set name
   says less than the stack it stands for, which is the whole reason the button
   is there. */
.app-rail-foot .set-picker {
  flex: 1 0 100%;
}
.app-rail-foot .account {
  flex: 1 1 auto;
  min-width: 0;
}

/* The entry of a view, and the box its sections hang from. `relative` is what
   the collapsed flyout anchors on. */
.app-rail-entry {
  position: relative;
  display: flex;
  flex-direction: column;
}
.app-rail .nav-item {
  justify-content: flex-start;
  width: 100%;
  padding: 10px 14px;
}
/* The topbar's sliding pill was a horizontal object: it slid along a row. In a
   column the active entry simply wears its own surface. */
.app-rail .nav-item.active {
  background: var(--surface-3);
  box-shadow: var(--shadow-1);
}
/* Config is not a work destination: a rule separates it from the views. */
.app-rail-sep {
  flex: none;
  height: 1px;
  margin: var(--s3) 8px;
  background: var(--line-strong);
}

/* The sections of the active view. The hairline on the left is what says "branch
   of the entry above" without a second box: a segmented group inside a
   navigation column would read as a second navigation. */
.app-rail-sub {
  display: flex;
  flex-direction: column;
  gap: 1px;
  margin: 3px 0 6px 25px;
  padding-left: 13px;
  border-left: 1px solid var(--line-strong);
}
.app-rail-sub-group + .app-rail-sub-group {
  margin-top: 6px;
}
.app-rail-sub-heading {
  margin: 0;
  padding: 4px 12px 2px;
  color: var(--text-muted);
  font-size: var(--fs-2xs);
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
.app-rail-sub-item {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 7px 12px;
  border: 0;
  border-radius: var(--r-full);
  background: transparent;
  color: var(--text-muted);
  font-family: var(--font-display);
  font-size: var(--fs-sm);
  font-weight: 500;
  text-align: left;
  cursor: pointer;
  transition:
    color var(--t-fast) var(--ease),
    background-color var(--t-fast) var(--ease),
    border-color var(--t-fast) var(--ease);
}
.app-rail-sub-item .icon {
  flex: none;
  opacity: 0.8;
}
.app-rail-sub-item:hover {
  background: var(--surface-2);
  color: var(--text-secondary);
}
/* Two natures in one column, marked apart. A section that MOUNTS a view is an
   onglet and wears a filled pill. A section that SCROLLS to a place lights its
   own edge instead — the same mark the settings rail drew before it moved
   here. */
.app-rail-sub-item.active {
  background: var(--surface-2);
  color: var(--accent-bright);
  font-weight: 600;
}
.app-rail-sub-item.anchor {
  margin-left: -14px;
  padding-left: 14px;
  border-left: 1px solid transparent;
  border-radius: 0 var(--r-sm) var(--r-sm) 0;
}
.app-rail-sub-item.anchor.active {
  border-left-color: var(--accent);
  background: var(--surface-2);
  color: var(--text-primary);
}

/* ---------- the column, collapsed ----------
   Below the switch the column keeps its entries and drops its words. The
   sections cannot follow: thirteen wordless rows do not read, and thirteen more
   icons cannot be drawn honestly — "Animations" has none. They move into a
   flyout, opened by hovering or focusing the active entry, which is the only
   entry with anything to put in one. */
/* Set by app.js: from the window's width below the switch, or from the
   column's own toggle whatever the width. */
  .rail-narrow .app-rail {
    width: 76px;
    padding-left: var(--s2);
    padding-right: var(--s2);
  }
  /* The entries keep the column's full width — centring THEM would shrink each
     one to its icon, and the flyout, anchored on `left: 100%`, would then open
     12 px inside the column instead of clear of it. What gets centred is the
     button inside the entry, and the head and foot, which have no flyout. */
  .rail-narrow .app-rail-head .brand {
    justify-content: center;
  }
  .rail-narrow .app-rail-foot {
    align-items: center;
  }
  /* A flyout is absolutely positioned, and a scrolling ancestor would clip it.
     The body can afford to stop scrolling: collapsed it holds seven icons and
     nothing else, and seven icons always fit. */
  .rail-narrow .app-rail-body {
    overflow: visible;
  }
  .rail-narrow .app-rail-head {
    padding: 0 0 var(--s4);
  }
  .rail-narrow .app-rail .brand h1,
  .rail-narrow .app-rail .account-name,
  .rail-narrow .app-rail .set-switch-caption,
  .rail-narrow .app-rail .set-switch-chevron,
  .rail-narrow .app-rail .set-switch-name {
    display: none;
  }
  .rail-narrow .app-rail .set-switch-icon {
    display: inline-flex;
  }
  .rail-narrow .app-rail .set-picker {
    flex: none;
    width: auto;
  }
  .rail-narrow .app-rail .set-switch {
    width: 44px;
    justify-content: center;
    padding: 0;
  }
  /* Folded, the entry keeps a word under its icon, small: a hammer, a magnifier
     and a target do not say Sets, Review and Plan on their own. Ten pixels of
     Fredoka fit "Repertoire" in the 60 px the column leaves. */
  .rail-narrow .app-rail .nav-item {
    flex-direction: column;
    justify-content: center;
    gap: 3px;
    padding: 8px 2px;
  }
  .rail-narrow .app-rail .nav-label {
    font-size: 10px;
    line-height: 1;
    letter-spacing: 0.01em;
  }
  .rail-narrow .app-rail-foot {
    flex-direction: column;
    flex-wrap: nowrap;
  }
  .rail-narrow .app-rail-foot .set-picker {
    flex: none;
  }

  .rail-narrow .app-rail-sub {
    position: absolute;
    left: 100%;
    top: 0;
    z-index: 20;
    display: none;
    min-width: 200px;
    /* 9 and not 8: the column carries a 1px right border, and 8 would land the
       flyout exactly on it. */
    margin: 0 0 0 9px;
    padding: 6px;
    border: 1px solid var(--line-strong);
    border-radius: var(--r-md);
    background: var(--surface-3);
    box-shadow: var(--shadow-3);
  }
  /* The Profile puts nine sections in here: on a short window they have to
     scroll rather than run off the bottom. */
  .rail-narrow .app-rail-sub {
    max-height: min(70dvh, 520px);
    overflow-y: auto;
  }
  .rail-narrow .app-rail-entry:hover > .app-rail-sub,
  .rail-narrow .app-rail-entry:focus-within > .app-rail-sub {
    display: flex;
  }
  /* The 9px margin plus the column's padding is dead space: a pointer crossing
     it leaves the entry, `:hover` drops, and the flyout closes under the hand.
     A transparent bridge over the gap keeps the pointer inside the entry. On
     the entry, not the flyout: the flyout scrolls, and a scroller clips its
     own pseudo-element. */
  .rail-narrow .app-rail-entry:hover::after {
    content: "";
    position: absolute;
    top: 0;
    bottom: 0;
    left: 100%;
    width: 24px;
  }
  /* Inside the flyout the words are back, so the anchor's edge mark comes back
     with them — but flush, the flyout having its own padding. */
  .rail-narrow .app-rail-sub-item.anchor {
    margin-left: 0;
  }

.app-tabbar {
  display: none;
}
.app-mobile-strip {
  display: none;
}

.brand {
  position: relative;
  justify-self: stretch;
  display: flex;
  align-items: center;
  gap: 12px;
  min-width: 0;
}

.brand-mark {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  flex: none;
  border-radius: var(--r-sm);
  background: var(--surface-2);
  border: 1px solid var(--line);
  font-size: var(--fs-lg);
  line-height: 1;
  color: var(--text-primary);
}

.brand h1 {
  letter-spacing: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.set-picker {
  position: relative;
  /* The flex child of the foot that stretches: the switch fills it, and the menu
     it anchors is measured against it. */
  flex: 1 1 auto;
  min-width: 0;
}

/* The set selector sits at the foot of the navigation column. The set's name is
   truncated there rather than stretching the column. */
.set-switch {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
  width: 100%;
  max-width: none;
  height: 32px;
  padding: 0 10px;
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  background: var(--surface-1);
  color: var(--text-muted);
  font: inherit;
  cursor: pointer;
  transition: background-color var(--t-fast) var(--ease),
    border-color var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.set-switch:hover,
.set-switch[aria-expanded="true"] {
  background: var(--surface-3);
  border-color: var(--line-strong);
  color: var(--text-secondary);
}
.set-switch:disabled {
  cursor: default;
  background: transparent;
}
/* Expanded, the set's name is the button and the icon stays out of the way.
   Collapsed, the name has nowhere to go — and a name truncated to "Naj…" says
   less than the stack it stands for — so the icon takes over and the name moves
   into the title, which renderBadge already writes. */
.set-switch-icon {
  display: none;
  flex: none;
}

.set-switch-chevron {
  display: inline-flex;
}
.set-switch-chevron .icon {
  width: 12px;
  height: 12px;
}

.set-switch-caption {
  font-size: var(--fs-2xs);
  letter-spacing: 0.01em;
  color: var(--text-muted);
}
.set-switch-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: var(--fs-base);
  color: var(--text-secondary);
}
.set-switch:hover .set-switch-name,
.set-switch[aria-expanded="true"] .set-switch-name {
  color: var(--text-primary);
}

/* One book yields one set per chapter: the menu goes from three entries to twenty
   with the very first imported work. So it is bounded in height and scrolls,
   rather than running off the bottom of the window. */
/* Opened upward and to the right: the button that carries it sits at the FOOT of
   the column now, and a menu dropping from there would fall off the screen. Its
   width does not follow the button either — 232 px of column is not a width to
   read a book's chapters in. */
.set-menu {
  position: absolute;
  bottom: calc(100% + 8px);
  left: 0;
  z-index: 20;
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 300px;
  max-width: min(90vw, 340px);
  max-height: min(70dvh, 560px);
  overflow-y: auto;
  padding: var(--s1);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-md);
  background: var(--surface-3);
  box-shadow: var(--shadow-3);
}

/* The provenance announces the entries that follow it: the built sets, the games,
   then one heading per book. The same arrangement as the workshop's list, by the
   same `groupSets`. */
.set-menu-group {
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.set-menu-group + .set-menu-group {
  margin-top: var(--s1);
  border-top: 1px solid var(--line);
}
.set-menu-section {
  padding: var(--s2) var(--s3) var(--s1);
  font-family: var(--font-display);
  font-size: var(--fs-2xs);
  font-weight: 600;
  letter-spacing: 0.01em;
  color: var(--text-muted);
}
.set-menu-item {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: var(--s2) var(--s3);
  border: 0;
  border-radius: var(--r-sm);
  background: transparent;
  color: var(--text-secondary);
  font: inherit;
  text-align: left;
  cursor: pointer;
  transition: background-color var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.set-menu-item:hover {
  background: var(--surface-2);
  color: var(--text-primary);
}
.set-menu-item.active {
  background: var(--accent-quiet);
  color: var(--text-primary);
}
.set-menu-check {
  display: inline-flex;
  width: 16px;
  color: var(--accent);
}
.set-menu-text {
  display: flex;
  flex-direction: column;
  line-height: 1.3;
}
.set-menu-text small {
  font-size: var(--fs-sm);
  color: var(--text-secondary);
}
.set-menu-link {
  margin-top: var(--s1);
  padding: var(--s2) var(--s3);
  border: 0;
  border-top: 1px solid var(--line);
  border-radius: 0;
  background: transparent;
  color: var(--accent);
  font: inherit;
  font-size: var(--fs-base);
  text-align: left;
  cursor: pointer;
}

.nav-item {
  position: relative;
  z-index: 1;
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 7px 15px;
  border: 0;
  border-radius: var(--r-full);
  background: transparent;
  color: var(--text-muted);
  font-family: var(--font-display);
  font-size: var(--fs-base);
  font-weight: 500;
  cursor: pointer;
  transition: color var(--t-fast) var(--ease);
}
.nav-item .icon {
  opacity: 0.85;
  transition: transform var(--t-base) var(--ease-spring);
}
.nav-item:hover {
  color: var(--text-secondary);
}
/* The icon of the hovered tab advances by a hair: it is the only response to
   hovering a tab, the pill being reserved for the active tab. */
@media (hover: hover) {
  .nav-item:hover .icon {
    transform: translateY(-1px) scale(1.08);
  }
}
.nav-item.active {
  color: var(--text-primary);
  font-weight: 600;
}

.icon-btn {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  border: 1px solid var(--line);
  border-radius: var(--r-full);
  background: var(--surface-1);
  color: var(--text-muted);
  cursor: pointer;
  transition: background-color var(--t-fast) var(--ease),
    border-color var(--t-fast) var(--ease), color var(--t-fast) var(--ease),
    transform var(--t-base) var(--ease-spring);
}
.icon-btn:hover {
  background: var(--surface-3);
  border-color: var(--line-strong);
  color: var(--text-primary);
  transform: translateY(-2px);
}

.outlet {
  /* `width: 100%` alongside the centering `margin: auto` : without it, a grid
     item whose inline margins are auto is sized to its own max-content rather
     than stretched, and a page that is only a responsive card grid (`.read-grid`)
     has no wide sibling to force that max-content past the available track --
     it collapses to one column instead of filling the width `max-width` caps.
     Every other view already reads wide enough that this went unnoticed. */
  width: 100%;
  padding: var(--s6) var(--s6) 56px;
  max-width: 1180px;
  margin: 0 auto;
}
/* Board pages: the whole width, but only the board sections spread out. */
/* The three board presets live here. The gutters are the ones they deduct from
   the window — PAGE_PADDING_X and PAGE_PADDING_Y in board-size.js — so the panel
   ends exactly on the fold rather than 14 px past it. Change one, change both. */
.outlet.wide {
  max-width: none;
  padding: 22px 18px;
}
.outlet.wide .card {
  max-width: 1180px;
  margin-left: auto;
  margin-right: auto;
}

/* The card is the basic pebble: frankly round corner, a light rule on the top
   edge to catch the light, a shadow cast below. The two read together as a
   volume, not as a rectangle. */
.card {
  position: relative;
  background: var(--surface-1);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  padding: var(--s5);
  margin-bottom: var(--s4);
  box-shadow: var(--shadow-1);
}
.card::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  border-top: 1px solid color-mix(in srgb, var(--accent-bright) 28%, transparent);
  pointer-events: none;
}

/* A card inside a card is always a mistake: the second frame says nothing the
   first did not already say. */
.card .card {
  background: transparent;
  border: 0;
  border-radius: 0;
  padding: 0;
}

.hint {
  color: var(--text-muted);
  font-size: var(--fs-base);
  line-height: 1.5;
  margin: var(--s1) 0 0;
  max-width: 72ch;
}

.empty {
  color: var(--text-muted);
  padding: var(--s3) 0;
}

.error {
  color: var(--status-critical);
}

/* The button is a pill: Fredoka, comfortable height, full radius. The app's
   signature gesture fits in two lines — it rises 2 px on hover, it squashes to
   0.96 on press. Everything pressable in the app does that. */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  border: 1px solid var(--line-strong);
  background: var(--surface-2);
  color: var(--text-primary);
  font-family: var(--font-display);
  font-size: var(--fs-base);
  font-weight: 500;
  line-height: 1;
  padding: 0 var(--s5);
  height: 40px;
  border-radius: var(--r-full);
  cursor: pointer;
  white-space: nowrap;
  transition: background-color var(--t-fast) var(--ease),
    border-color var(--t-fast) var(--ease), color var(--t-fast) var(--ease),
    box-shadow var(--t-base) var(--ease),
    transform var(--t-base) var(--ease-spring);
}

@media (hover: hover) {
  .btn:hover:not(:disabled) {
    background: var(--surface-3);
    border-color: var(--line-strong);
    transform: translateY(-2px);
    box-shadow: var(--shadow-2);
  }
}

.btn:active:not(:disabled) {
  transform: translateY(0) scale(0.96);
  box-shadow: var(--shadow-press);
  transition-duration: var(--t-fast);
}

.btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

.btn-primary {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--text-on-accent);
  font-weight: 600;
  box-shadow: var(--glow-accent);
}
.btn-primary:hover:not(:disabled) {
  background: var(--accent-hover);
  border-color: var(--accent-hover);
  box-shadow: var(--glow-accent), var(--shadow-2);
}

.btn-ghost {
  background: transparent;
  border-color: var(--line);
  color: var(--text-secondary);
}
.btn-ghost:hover:not(:disabled) {
  background: var(--surface-2);
  color: var(--text-primary);
}

/* A button waiting on the server: it keeps its width, it loses its promise. */
.btn[aria-busy="true"],
.btn.is-loading {
  color: transparent;
  pointer-events: none;
  position: relative;
}
.btn[aria-busy="true"]::after,
.btn.is-loading::after {
  content: "";
  position: absolute;
  width: 13px;
  height: 13px;
  border-radius: 50%;
  border: 2px solid currentColor;
  border-color: var(--text-secondary) transparent var(--text-secondary) transparent;
  animation: btn-spin 700ms linear infinite;
}
@keyframes btn-spin {
  to {
    transform: rotate(1turn);
  }
}

/* ---------- Solve ---------- */

/* Whose move it is is the panel's headline — the one fact worth the largest type
   in the head — and where the attempt stands reads just under it. None of this
   text floats around the board: the board carries only pieces. */
.board-side-head .solve-feedback {
  min-height: 1.3em;
  margin: 5px 0 0;
  white-space: normal;
  overflow-wrap: anywhere;
  line-height: 1.35;
}

/* ---------- Board pages: three presets, one contract ----------

   THE BOARD COLUMN CARRIES THE BOARD AND NOTHING ELSE. Everything that used to
   be stacked above and below it — progress label, hint line, navigation bar,
   session strip — is in the panel now, which had the height to spare. On a
   1920 x 1080 window that is a 979 px board instead of 843.

     --focus   two columns, board + a 480 panel. The training views.
     --etude   three columns, a 300 rail for what is only read and a 500 panel
               for the sheet, plus the advantage graph under the board — the one
               strip a board is ever allowed to carry, because its x-axis is the
               game itself. Analysis.
     --etabli  focus with the panel one width step up (660) and a toolbar strip
               above the layout. The repertoire builder.

   The widths here are the same numbers as BOARD_PRESETS in board-size.js: the
   layout lays them out, board-frame.js deducts them from the window. If one
   moves, both move. */
.board-page.hidden {
  display: none;
}
.board-page {
  --panel: var(--panel-sm);
  --rail: 0px;
  /* What the board carries under it, gutter included: zero everywhere but Étude. */
  --board-under: 0px;
  --column-gutter: 20px;
  /* The evaluation bar and its gutter — EVAL_RESERVE in board-size.js. */
  --eval-reserve: 38px;
}
.board-page--etude {
  --panel: var(--panel-md);
  --rail: 300px;
  --board-under: 56px;
}
.board-page--etabli {
  --panel: var(--panel-lg);
}

/* The board's column is stated, not measured. An `auto` track would have to size
   itself from the frame's width, which is a custom property set from script —
   and a track sized that way is computed once and never revisited when the
   property changes, so the board stayed at its 560 px fallback until the first
   window resize. Stating the track keeps the substitution on the grid itself,
   where a change to --board-size does invalidate it. */
.board-layout {
  display: grid;
  grid-template-columns: calc(var(--board-size, 560px) + var(--eval-reserve)) var(--panel);
  justify-content: center;
  gap: var(--column-gutter);
  align-items: start;
}
.board-page--etude .board-layout {
  grid-template-columns:
    var(--rail)
    calc(var(--board-size, 560px) + var(--eval-reserve))
    var(--panel);
}

/* Établi's toolbar: the tool's own state, above the layout and aligned on it.
   It is the only thing any preset ever pays for above the board. */
.board-toolbar {
  display: flex;
  align-items: center;
  gap: var(--s4);
  min-height: 44px;
  margin-bottom: 14px;
  padding: 0 6px;
}

.board-column {
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.board-row {
  display: flex;
  align-items: stretch;
  gap: 10px;
}
/* The board takes its height from its own width, and only the evaluation bar
   stretches to it. Stretching the board instead closes a loop — its height would
   come from the row, its width from the aspect ratio, and the row's height from
   the board — which the browser settles at whatever number it likes. The loop
   only appeared once the column stopped carrying anything but the board. */
.board-row .board-frame {
  align-self: flex-start;
  flex: 0 0 auto;
}
/* The evaluation bar keeps its place when hidden: the board does not slide 28 px
   when it appears. */
.eval-bar.hidden {
  display: block;
  visibility: hidden;
}

/* Under the board, on the one preset that has something to put there. It aligns
   on the board, not on the evaluation bar. 8 px of gutter + 48 of graph = the
   56 of --board-under. */
.board-under {
  height: 48px;
  margin-left: 38px;
}

/* ---------- the panel ----------
   One surface, aligned on the board and exactly as tall as the board column —
   board plus whatever it carries under it, so the two columns always end on the
   same line. Three zones, in the same order in every preset: a fixed head, a
   body that scrolls, a fixed foot. */
.board-side {
  min-width: 0;
  height: calc(var(--board-size, 560px) + var(--board-under));
  display: flex;
  flex-direction: column;
  background: var(--surface-1);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  overflow: hidden;
}

/* The Set panel is the fullest of them all: the brief, the clock, the sheet and
   the actions, all at once, beside a board that does not shrink to make room for
   them (the board takes the window, board-size.js). Rather than choose which line
   loses its size — and end up with a panel whose scale no longer means anything —
   the whole panel is drawn one notch smaller. `zoom` takes type, padding, rules
   and radii together, so the proportions stay the ones it was designed in.

   The height is divided back out: it is stated in the panel's own pixels, which
   `zoom` has just made smaller, and it is the one number that has to keep
   matching the board's. */
.solve .board-side,
.deck-view .board-side {
  --panel-zoom: 0.88;
  zoom: var(--panel-zoom);
  height: calc((var(--board-size, 560px) + var(--board-under)) / var(--panel-zoom));
}

/* HEAD — what is on the board, in one glance: which view, where one is in it,
   whose move, and the brief that used to be a column of its own. */
.board-side-head {
  flex: none;
  padding: 20px 24px 18px;
  border-bottom: 1px solid var(--border);
}
.side-kicker-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  min-height: 18px;
}
.side-kicker,
.etabli .eyebrow,
.progress-label {
  font-family: var(--font-display);
  font-size: var(--fs-2xs);
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  margin: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.side-kicker,
.etabli .eyebrow {
  color: var(--text-muted);
}
.progress-label {
  color: var(--text-secondary);
  flex: none;
}
.side-kicker:empty,
.progress-label:empty {
  display: none;
}
/* Nothing in the head but the kicker row: no title, no hint, no brief. The row
   alone is not a header — it takes no rule and no padding of its own. */
.board-side-head:has(> h2:empty):not(:has(.hint)):not(:has(.hint-line:not(.hidden))):not(:has(.side-brief)) {
  padding-bottom: 10px;
  border-bottom: 0;
}
.board-side-head h2 {
  margin-top: 4px;
  font-size: var(--fs-xl);
  line-height: 1.2;
}
.board-side-head h2:empty {
  display: none;
}
.board-side-head .hint,
.board-side-head .hint-line {
  margin: 7px 0 0;
  line-height: 1.45;
}
.board-side-head .hint-line.hidden {
  display: none;
}

/* The brief: what is known of the exercise before solving it — its difficulty,
   its themes, where it comes from. It used to be a 260 px column beside the
   board; at this width it reads as one row of chips, and the board keeps the
   276 px it cost. */
.side-brief {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-top: 14px;
}
.side-brief:empty {
  display: none;
}
.brief-chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  max-width: 100%;
  padding: 7px 14px;
  border: 1px solid var(--line);
  border-radius: var(--r-full);
  background: var(--surface-2);
  color: var(--text-secondary);
  font-family: var(--font-display);
  font-size: var(--fs-xs);
  font-weight: 500;
  line-height: 1;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.brief-chip:empty {
  display: none;
}
.brief-chip b {
  color: var(--text-primary);
  font-weight: 600;
}
/* The one figure that ranks the exercise takes the accent; the identifier and
   the source link claim no box at all. */
.brief-chip.accent {
  background: var(--accent-quiet);
  border-color: var(--accent-line);
  color: var(--accent-bright);
}
.brief-chip.quiet {
  padding-left: 2px;
  background: transparent;
  border-color: transparent;
  color: var(--text-muted);
}
/* The variation being drilled is what the player reads first. */
.brief-chip.line-name {
  color: var(--text-primary);
  font-size: var(--fs-lg);
  font-weight: 600;
}
/* A chip that is one run of text -- the source of a problem -- ends in dots
   when it overflows: a flex box hands its overflow to an anonymous item, which
   cannot be clipped with an ellipsis, so this one is a plain block. */
.brief-chip.text {
  display: inline-block;
  line-height: 1.2;
}
a.brief-chip:hover {
  border-color: var(--line-strong);
  color: var(--text-primary);
  text-decoration: none;
}
/* The themes arrive as a list the view fills in. They join the row as chips of
   their own rather than as a box inside a box — one container that disappears is
   worth more than one that indents. */
.side-themes {
  display: contents;
}
.side-themes.hidden {
  display: none;
}
.side-brief em {
  font-style: normal;
  padding: 6px 12px;
  border: 1px solid var(--line);
  border-radius: var(--r-full);
  background: var(--surface-2);
  color: var(--text-secondary);
  font-family: var(--font-display);
  font-size: var(--fs-xs);
  font-weight: 500;
  line-height: 1;
}

/* BODY — the measures first, then the sheet, which takes every pixel left over. */
.board-side-body {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  /* One step of air more than the panel used to take. The blocks carry a rule of
     their own, so the gap has not got to draw the separation — only to stop the
     sections touching. The lists below keep their floor; past it, it is the body
     that scrolls, which is what it is for. */
  gap: 14px;
  padding: 18px 24px 16px;
  overflow-y: auto;
}
.board-side-body > * {
  flex: 0 0 auto;
}

/* A single scrollbar per panel: with the tree, it is that one. The body does not
   scroll, otherwise its thumb and the tree's end up 26 px apart — the defect the
   rule below already avoids for the move-list. */
.board-side-body:has(.move-tree) {
  overflow-y: hidden;
}

/* A single scrollbar per panel. When the body already contains a list that
   scrolls, it is that list which absorbs the overflow: two scrollbar thumbs side
   by side 26 px apart means the panel was badly carved, not that the user has to
   choose which one to drag. */
/* The list's block takes the slack, but never less than five moves: pinned between
   the actions and the measurement blocks, it had been squashed to two lines and the
   game's context became unreadable. The floor is on the block, not on the list: on
   the list alone, the box did not reserve the space and the content overflowed on
   top of the following blocks. Below that floor, it is the panel's body that
   scrolls — a genuinely short window, not the normal case. */
.board-side-body:has(.move-list-scroll) > .panel-block:has(.move-list-scroll) {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  min-height: 105px;
  overflow: hidden;
}
/* The tree has the same floor. It no longer has to yield it to the verdict: the
   banner has the foot's room now, not the body's. */
.board-side-body:has(.move-tree) > .panel-block:has(.move-tree) {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  min-height: 72px;
  /* The block carries the list, it does not scroll itself: otherwise its scrollbar
     and the list's end up side by side. */
  overflow: hidden;
}
.board-side .move-list-scroll,
.board-side .move-tree {
  flex: 1 1 auto;
  min-height: 0;
}
/* The analysis's move list fills the panel; Solve's is a block. Its floor is
   five moves: under the engine's lines it had been squashed to nothing, and it
   is the body that scrolls past that floor. */
.board-side-body > .move-list:not(.panel-block) {
  flex: 1 1 auto;
  min-height: 150px;
}
.board-side-body > p {
  margin: 0;
}
.board-side-body > p:has(> .btn) {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: auto;
  padding-top: 12px;
}
.board-side-body > p:has(> .btn) .btn {
  flex: 1 1 auto;
}
/* Inside the panel, the blocks are sections separated by a rule, not nested
   cards. */
.board-side .panel-block {
  background: transparent;
  border: 0;
  border-radius: 0;
  padding: 0 0 14px;
  border-bottom: 1px solid var(--line);
}
.board-side .panel-block:last-child {
  border-bottom: 0;
  padding-bottom: 0;
}

/* FOOT — navigation, session strip, actions. Fixed, at the bottom of the panel,
   identical in the three presets: that is what makes them one system and not
   three layouts. */
.board-side-foot {
  flex: none;
  display: flex;
  flex-direction: column;
  gap: 14px;
  padding: 16px 24px 18px;
  border-top: 1px solid var(--line);
}
.board-side-foot:empty {
  display: none;
}

/* ---------- Étude's rail, to the left of the board ----------
   What is known of the game before reading it: who played it, what it was worth,
   what it opened with. Nothing actionable — the buttons are on the right. */
.board-aside {
  grid-column: 1;
  min-width: 0;
  height: calc(var(--board-size, 560px) + var(--board-under));
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 14px;
  background: var(--surface-1);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  overflow-y: auto;
}
.board-page--etude .board-column {
  grid-column: 2;
}
.board-page--etude .board-side {
  grid-column: 3;
}
.aside-block {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding-bottom: 10px;
  border-bottom: 1px solid var(--line-faint);
}
.aside-block:last-child {
  border-bottom: 0;
  padding-bottom: 0;
}
.aside-label {
  font-family: var(--font-display);
  font-size: var(--fs-xs);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
}
.aside-value {
  font-family: var(--font-display);
  font-size: var(--fs-lg);
  font-variant-numeric: tabular-nums;
  color: var(--text-primary);
  line-height: 1.2;
  overflow-wrap: anywhere;
}
.aside-value.quiet {
  font-family: var(--font);
  font-size: var(--fs-base);
  color: var(--text-secondary);
}
.board-aside .meta-row {
  display: flex;
  justify-content: space-between;
  gap: 8px;
  font-size: var(--fs-base);
  color: var(--text-secondary);
}
.board-aside .meta-row b {
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}
/* A mastery badge reuses the same colour scale as the repertoire: it is the same
   information, it does not change language by changing screen. */
.aside-bucket {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: var(--fs-base);
  color: var(--text-secondary);
}
.aside-bucket i {
  width: 10px;
  height: 10px;
  border-radius: var(--r-full);
  background: var(--knowledge-untrained);
  flex: none;
}

/* ---------- below the switch point ----------
   The panels give up their full widths and Étude's rail folds into a banner above
   its panel. The banner's height is fixed and deducted from the panel's: the
   right-hand column still totals exactly the board column's height. The threshold
   is WIDE_BREAKPOINT in board-size.js — the two must switch together, otherwise
   the width reserved for the board lies by a column. */
@media (max-width: 1279px) {
  .board-page {
    --panel: var(--panel-xs);
  }
  .board-page--etude .board-layout {
    grid-template-columns: calc(var(--board-size, 560px) + var(--eval-reserve)) var(--panel);
    grid-template-rows: auto minmax(0, 1fr);
  }
  .board-page--etude .board-column {
    grid-column: 1;
    grid-row: 1 / span 2;
  }
  .board-page--etude .board-aside {
    grid-column: 2;
    grid-row: 1;
    flex-direction: row;
    align-items: center;
    gap: 14px;
    height: 44px;
    padding: 0 12px;
    overflow-x: auto;
    overflow-y: hidden;
  }
  .board-page--etude .board-side {
    grid-column: 2;
    grid-row: 2;
    margin-top: 8px;
    height: calc(var(--board-size, 560px) + var(--board-under) - 52px);
  }
  .aside-block {
    flex: none;
    flex-direction: row;
    align-items: baseline;
    gap: 6px;
    padding: 0;
    border-bottom: 0;
    white-space: nowrap;
  }
  .aside-value {
    font-size: var(--fs-base);
  }
  /* The banner holds only one line: what reads in one word stays there, the lists
     (themes, source link) wait for the column. */
  .board-aside .aside-block.wide {
    display: none;
  }
  .solve-turn {
    font-size: var(--fs-base);
  }
  .solve-brief .solve-feedback {
    min-height: 0;
    white-space: nowrap;
  }
}

/* ---------- in the panel's foot: navigation and session strip ---------- */
.board-controls {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  min-height: 44px;
}
/* The bar keeps its size; whatever sits opposite it — the session strip, a live
   read-out — gives way. What one can press outranks what one is looking at. */
.board-controls > .board-nav {
  flex: none;
}
.board-nav {
  display: flex;
  align-items: center;
  gap: 2px;
  padding: 2px;
  background: var(--surface-1);
  border: 1px solid var(--border);
  border-radius: var(--r-full);
}
/* The foot is pressed a hundred times a session: its buttons are sized for the
   hand, 44 px outside the pill (38 + 2 × 2 padding + 2 × 1 rule), level with the
   foot's own 44 px buttons beside it. */
.board-nav-btn {
  appearance: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 38px;
  padding: 0;
  border: 0;
  border-radius: var(--r-full);
  background: none;
  color: var(--text-secondary);
  cursor: pointer;
  transition:
    background var(--t-fast) var(--ease),
    color var(--t-fast) var(--ease),
    transform var(--t-fast) var(--ease);
}
.board-nav-btn .icon {
  width: 20px;
  height: 20px;
}
.board-nav-btn:hover:not(:disabled) {
  background: var(--surface-3);
  color: var(--text-primary);
}
/* Everything pressable squashes under the finger. */
.board-nav-btn:active:not(:disabled) {
  transform: translateY(1px);
  box-shadow: var(--shadow-press);
}
.board-nav-btn:disabled {
  color: var(--text-muted);
  opacity: 0.4;
  cursor: default;
}
.board-nav-sep {
  width: 1px;
  height: 22px;
  margin: 0 4px;
  background: var(--line);
  flex: none;
}
/* The strip's sizes are named so the script can count what fits on the row —
   `stripCapacity` in session-strip.js — instead of guessing a cap and clipping.
   Its width comes from the bar, not from its dots: a strip sized by its content
   would resize on every render and observe itself. */
.session-dots {
  --dot: 14px;
  --dot-current: 16px;
  --dot-gap: 8px;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: var(--dot-gap);
  flex: 1 1 0;
  min-width: 0;
  min-height: var(--dot-current);
  flex-wrap: nowrap;
  overflow: hidden;
}
/* Every dot has the same body, whether or not it is a button: a played dot opens
   its puzzle for analysis, the others have nothing to open, and that difference
   must read as a pointer rather than as another shape. */
.session-dots .dot {
  width: var(--dot);
  height: var(--dot);
  padding: 0;
  border: 0;
  border-radius: var(--r-full);
  background: var(--surface-3);
  flex: none;
  transition: background var(--t-base) var(--ease), transform var(--t-base) var(--ease);
}
.session-dots button.dot {
  cursor: pointer;
}
.session-dots button.dot:hover,
.session-dots button.dot:focus-visible {
  transform: scale(1.5);
}
.session-dots .dot.good {
  background: var(--status-good);
}
.session-dots .dot.bad {
  background: var(--status-critical);
}
/* The current dot is the only open circle: it is found without counting. */
.session-dots .dot.current {
  width: var(--dot-current);
  height: var(--dot-current);
  background: none;
  box-shadow: inset 0 0 0 2px var(--accent-bright);
}
/* The dot whose puzzle is on the board: a ring around it, so one knows what one
   is looking at without going back up to the panel. */
.session-dots .dot.analysing {
  transform: scale(1.5);
  box-shadow: 0 0 0 2px var(--surface-1), 0 0 0 4px var(--accent-bright);
}
/* What the last line was worth, written out after its dot: the strip is where
   the eye already goes when a line ends. */
.session-dots .session-tally {
  margin-left: 4px;
  font-size: var(--fs-xs);
  font-variant-numeric: tabular-nums;
  color: var(--text-secondary);
  white-space: nowrap;
}

/* ---------- the move tree ---------- */
.move-tree {
  display: flex;
  flex-direction: column;
  gap: 1px;
  overflow-y: auto;
  padding: 8px 10px;
  font-variant-numeric: tabular-nums;
}
.move-tree-row {
  display: grid;
  grid-template-columns: 2.4em 1fr 1fr;
  align-items: center;
  gap: 4px;
}
.move-tree-row .move-number {
  color: var(--text-muted);
  font-size: var(--fs-sm);
}
.move-tree .move {
  appearance: none;
  display: flex;
  align-items: center;
  gap: 5px;
  background: none;
  border: 0;
  border-radius: var(--r-sm);
  color: var(--text-secondary);
  cursor: pointer;
  font: inherit;
  font-size: var(--fs-base);
  padding: 2px 6px;
  text-align: left;
}
.move-tree .move:hover {
  background: var(--surface-2);
}
.move-tree .move.current {
  background: var(--accent-quiet);
  box-shadow: inset 0 0 0 1px var(--accent-line);
  color: var(--text-primary);
  font-weight: 600;
}
.move-tree .move.empty {
  cursor: default;
  color: var(--text-muted);
}
.move-tree .move .icon {
  width: 13px;
  height: 13px;
  flex: none;
}
.move-tree .move.mark-good .icon {
  color: var(--status-good);
}
.move-tree .move.mark-bad .icon {
  color: var(--status-critical);
}
/* The refused attempts: indented, struck through, and without a button — they
   cannot be navigated to because they were never played on the board. */
.move-tree-tries {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  /* The background goes no further than the moves it carries: spread across the
     whole width for two attempts, it weighed more than the line played. */
  align-self: flex-start;
  max-width: 100%;
  gap: 6px;
  /* The indent aligns the attempts under the column of the move they were aiming
     at: it is what says what they attach to. The tinted background and the strike
     already say they were refused — a coloured rule on top would be the third
     time, and this project accents no edge in that way. */
  margin: 1px 0 2px 2.4em;
  padding: 3px 10px;
  border-radius: var(--r-sm);
  background: color-mix(in srgb, var(--status-critical) 11%, transparent);
}
.move-tree-tries em {
  font-style: normal;
  font-size: var(--fs-sm);
  color: var(--text-muted);
  text-decoration: line-through;
  text-decoration-color: color-mix(in srgb, var(--status-critical) 70%, transparent);
}

/* ---------- the verdict banner, anchored at the bottom of the panel ---------- */
.verdict-banner {
  flex: none;
  display: none;
  flex-direction: column;
  gap: 8px;
  padding: 12px 16px 14px;
  border-top: 1px solid var(--line-strong);
  background: var(--surface-2);
}
.verdict-banner.shown {
  display: flex;
}
/* When the verdict is there, the exercise is finished: "Reveal" no longer reveals
   anything and "Skip" does what the banner's button already does. Two dead buttons
   under the action that counts is space taken from the game sheet. */
.board-side:has(.verdict-banner.shown) .panel-actions {
  display: none;
}
.verdict-banner .verdict-title {
  font-family: var(--font-display);
  font-size: var(--fs-lg);
  line-height: 1.15;
  color: var(--text-primary);
}
.verdict-banner.good .verdict-title {
  color: var(--status-good);
}
.verdict-banner.bad .verdict-title {
  color: var(--status-critical);
}
.verdict-banner.warn .verdict-title {
  color: var(--status-warning);
}
.verdict-banner .verdict-sub {
  font-size: var(--fs-sm);
  color: var(--text-secondary);
  margin: 0;
}
.verdict-banner .btn {
  width: 100%;
}
/* The banner of an attempt the server refused: two ways out, side by side. */
.banner-actions {
  display: flex;
  gap: 8px;
}
.banner-actions .btn {
  flex: 1 1 0;
  min-width: 0;
}
/* Two actions side by side under the verdict: the one that moves on takes the
   room, the one that removes stays narrow and quiet. */
.verdict-banner .verdict-actions {
  display: flex;
  gap: 8px;
}
/* A revealed solution still to be played has no next and nothing to remove yet. */
.verdict-banner .verdict-actions.hidden {
  display: none;
}
.verdict-banner .verdict-actions .btn-primary {
  flex: 1 1 auto;
}
.verdict-banner .verdict-actions .btn-ghost {
  width: auto;
  flex: none;
}

.board-frame {
  position: relative;
  width: var(--board-size, 560px);
  max-width: 100%;
  aspect-ratio: 1;
  /* The board carries the same radius as the cards: it is the largest pebble in
     the app, not a square exception dropped in the middle of round corners. */
  border-radius: var(--r-lg);
  overflow: hidden;
  box-shadow: var(--shadow-3);
  user-select: none;
}
.board-frame.mini {
  width: 100%;
  max-width: 300px;
  border-radius: var(--r-md);
  box-shadow: var(--shadow-2);
}
.board-frame.resizing {
  box-shadow: 0 0 0 2px var(--accent), var(--shadow-3);
}

/* Resize handle, bottom-right corner (chess.com). The box is larger than the mark
   it draws: `clip-path` cuts the grabbable triangle at the full 30px, while the
   gradient stop keeps the painted wedge at its old 22px. The corner is easier to
   catch without becoming more visible. */
.board-resize-handle {
  position: absolute;
  right: 0;
  bottom: 0;
  width: 30px;
  height: 30px;
  z-index: 25;
  cursor: nwse-resize;
  opacity: 0.35;
  background: linear-gradient(135deg, transparent 63%, rgba(238, 241, 255, 0.85) 63%);
  clip-path: polygon(100% 0, 100% 100%, 0 100%);
  transition: opacity var(--t-base) var(--ease);
}
.board-frame:hover .board-resize-handle,
.board-frame.resizing .board-resize-handle {
  opacity: 0.9;
}

/* On a phone the three presets become the same single column: board, then panel,
   then whatever rail there was. The contract still holds — nothing is stacked
   above or below the board except what the preset carries there — it is simply
   that there is no width left for a second column. */
@media (max-width: 900px) {
  .board-page,
  .board-page--etude,
  .board-page--etabli {
    --panel: auto;
  }
  .board-layout,
  .board-page--etude .board-layout {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: none;
  }
  .board-aside,
  .board-column,
  .board-side,
  .board-page--etude .board-aside,
  .board-page--etude .board-column,
  .board-page--etude .board-side {
    grid-column: 1;
    grid-row: auto;
    width: 100%;
  }
  /* On a phone it is the page that scrolls, not the panel: a capped height made
     everything beyond 70dvh unreachable there. */
  .board-side,
  .board-page--etude .board-side {
    margin-top: 0;
    height: auto;
    max-height: none;
  }
  /* The rail becomes the banner it was again, but under the panel: at this width
     the meta is what one consults, not what one starts with. */
  .board-aside,
  .board-page--etude .board-aside {
    order: 2;
    margin-top: 0;
    height: auto;
  }
  .board-toolbar {
    flex-wrap: wrap;
  }
  .board-side-body,
  .board-side-body:has(.move-list-scroll),
  .board-side-body:has(.move-tree) {
    overflow-y: visible;
  }
  .board-side .move-list-scroll,
  .board-side .move-tree {
    max-height: 320px;
    overflow-y: auto;
  }
  /* The board takes what the evaluation bar leaves it; no reserve for it when it
     is hidden. */
  .board-row .board-frame {
    flex: 1 1 auto;
    width: auto;
    min-width: 0;
  }
  .eval-bar.hidden {
    display: none;
  }
  .board-under {
    margin-left: 0;
  }
  .board-resize-handle {
    display: none;
  }
  /* Solve and Deck shrink their panel one notch beside a full-height board
     (`zoom`, further up). Stacked under the board there is no height left to
     trade, and the height that rule computes clips the panel behind
     `overflow: hidden` — here it is the page that scrolls. Same specificity
     (0,2,0), later in the file: this one wins. */
  .solve .board-side,
  .deck-view .board-side {
    zoom: 1;
    height: auto;
  }
}

  /* The blur spreads pixels outside the board: `overflow: hidden` above clips the halo. */
/* The same veil for two states: the pause of the Solve view, and the gate of a rush
   that has not yet received its GO. In both cases the position is in place and must
   not be readable. */
.board-frame.paused .cg-wrap,
.board-frame.veiled .cg-wrap {
  filter: blur(14px) saturate(0.6);
  pointer-events: none;
}

.pause-overlay {
  position: absolute;
  inset: 0;
  z-index: 30;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 6px;
  border-radius: var(--r-sm);
  background: color-mix(in srgb, var(--page) 55%, transparent);
}

.pause-overlay.hidden {
  display: none;
}

.pause-title {
  font-size: var(--fs-xl);
  font-weight: 700;
}

.pause-sub {
  font-size: var(--fs-base);
  color: var(--text-secondary);
  margin-bottom: 10px;
}

.btn-block {
  width: 100%;
  margin-top: 12px;
}

.solve-feedback {
  margin: 0;
  font-size: var(--fs-base);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  color: var(--text-secondary);
}

.solve-feedback.good {
  color: var(--status-good);
}
.solve-feedback.bad {
  color: var(--status-critical);
}
.solve-feedback.warn {
  color: var(--status-warning);
}

/* Board: chessground (Lichess, GPL-3; two patches, see vendor/chessground/PATCHES.md).
   The squares, the highlights, the coordinates and the promotion are ours. */

.board-frame .cg-wrap {
  width: 100%;
  height: 100%;
  display: block;
}

cg-board {
  background-color: var(--square-light);
  /* The dark squares, in --square-dark: keep the two in step by hand. */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 2 2'%3E%3Crect x='1' width='1' height='1' fill='%235563bd'/%3E%3Crect y='1' width='1' height='1' fill='%235563bd'/%3E%3C/svg%3E");
  background-size: 25% 25%;
}

cg-board square.last-move {
  background: var(--square-last-move);
}

cg-board square.selected {
  /* The edges only, no fill. An inset hairline rather than a border: it does not
     add to the square's size (they are content-box at 12.5%). */
  box-shadow: inset 0 0 0 4px var(--square-selected-edge);
}

cg-board square.check {
  background: radial-gradient(
    ellipse at center,
    rgba(255, 107, 107, 0.95) 0%,
    rgba(255, 107, 107, 0.75) 32%,
    rgba(255, 107, 107, 0) 72%
  );
}

cg-board square.move-dest {
  background: radial-gradient(rgba(7, 10, 26, 0.34) 20%, transparent 22%);
}

/* Occupied square = capture: four greyed corners chess.com style, not a dot. */
cg-board square.oc.move-dest {
  /* A disc cut out at the centre: only the four corners are left, whose inner edge
     is concave. The radius (110% of the half-side) tunes their size. */
  background: radial-gradient(
    circle closest-side at center,
    transparent 0 110%,
    rgba(7, 10, 26, 0.32) 111%
  );
}

.cg-wrap.interactive {
  cursor: pointer;
}

.cg-wrap.flash-bad {
  animation: shake 0.28s;
}

.cg-wrap.flash-good cg-board {
  box-shadow: inset 0 0 0 3px var(--status-good);
}

@keyframes shake {
  0%,
  100% {
    transform: translateX(0);
  }
  25% {
    transform: translateX(-5px);
  }
  75% {
    transform: translateX(5px);
  }
}

/* Coordinates inside the board, chess.com style: the figure at the top left of the
   left-hand file, the letter at the bottom right of the bottom rank, in the colour
   of the opposite square. The DOM order (1..8, a..h) does not change with the
   orientation: only the parity of the colours flips. */
.cg-wrap coords {
  font-family: var(--font);
  /* Fluid, scaled to the board's size. The floor is below the type ramp on
     purpose: on a 375 px phone board a 12 px letter sat over the piece in its
     square, a 9 px one stays in the corner the piece leaves free. */
  font-size: clamp(9px, calc(var(--board-size, 560px) * 0.024), var(--fs-xs));
  font-weight: 700;
  opacity: 0.9;
}
/* The frame's corners are round (--r-lg) and clip the board with them: the
   coordinate that sits in a corner -- the top rank's figure, the last file's
   letter -- is pushed in past the arc; the others stay flush. A phone board has
   square corners and skips this. */
@media (min-width: 701px) {
  .cg-wrap coords.ranks:not(.black) coord:last-child,
  .cg-wrap coords.ranks.black coord:first-child {
    padding-top: 8px;
    padding-left: 9px;
  }
  .cg-wrap coords.files:not(.black) coord:last-child,
  .cg-wrap coords.files.black coord:first-child {
    padding-right: 9px;
    padding-bottom: 8px;
  }
}
.cg-wrap coords coord {
  flex: 1 1 0;
}
.cg-wrap coords.ranks,
.cg-wrap coords.ranks.left {
  left: 0;
  top: 0;
  width: auto;
  height: 100%;
  align-items: flex-start;
}
.cg-wrap coords.ranks coord {
  transform: none;
  padding: 2px 0 0 3px;
}
.cg-wrap coords.files {
  left: 0;
  bottom: 0;
  width: 100%;
  height: auto;
  text-transform: lowercase;
}
.cg-wrap coords.files coord {
  text-align: right;
  padding: 0 3px 1px 0;
}
.cg-wrap coords coord:nth-child(odd) {
  color: var(--square-light);
}
.cg-wrap coords coord:nth-child(even) {
  color: var(--square-dark);
}
.cg-wrap.orientation-black coords coord:nth-child(odd) {
  color: var(--square-dark);
}
.cg-wrap.orientation-black coords coord:nth-child(even) {
  color: var(--square-light);
}

/* Promotion: a column of pieces on the arrival file, as on chess.com.
   A click outside the column cancels. */
.promotion-overlay {
  position: absolute;
  inset: 0;
  z-index: 20;
  background: rgba(7, 10, 26, 0.55);
}
.promotion-column {
  position: absolute;
  top: 0;
  left: calc(var(--column) * 12.5%);
  width: 12.5%;
  display: flex;
  flex-direction: column;
}
.promotion-overlay.from-bottom .promotion-column {
  top: auto;
  bottom: 0;
  flex-direction: column-reverse;
}
.promo {
  width: 100%;
  aspect-ratio: 1;
  padding: 6%;
  border: 0;
  border-radius: 50%;
  background: var(--piece-face);
  box-shadow: var(--shadow-2);
  cursor: pointer;
  transition: transform var(--t-fast) var(--ease), background-color var(--t-fast) var(--ease);
}
.promo:hover {
  transform: scale(1.08);
  background: var(--piece-face-lit);
}
.promotion-overlay piece {
  position: static;
  display: block;
  width: 100%;
  height: 100%;
  background-size: contain;
  pointer-events: none;
}

.panel-block {
  background: var(--surface-1);
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  padding: var(--s4);
}

/* The panel while a past puzzle is on the board. It takes the accent so that the
   difference with the training is not a matter of reading a title: at a glance the
   panel says the clock has stopped and this board is not the one being solved. */
.analysis-banner {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  border-color: var(--accent-bright);
}
.analysis-banner h3 {
  margin: 0;
}
.analysis-banner .hint,
.analysis-banner .hint-line {
  margin: 0;
}

.stat-row {
  display: flex;
  justify-content: space-between;
  gap: var(--s3);
}

.stat {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.stat-label {
  font-size: var(--fs-2xs);
  font-weight: 600;
  letter-spacing: 0.01em;
  color: var(--text-muted);
}

.stat b {
  font-size: var(--fs-md);
  font-weight: 600;
  letter-spacing: 0;
}

/* The clock is the product's measure: it is entitled to be the largest figure on
   the screen, and it must never change width while counting. */
.timer {
  font-size: var(--fs-3xl);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  margin: var(--s2) 0 var(--s2);
  letter-spacing: 0;
  line-height: 1;
}

.timer-bar {
  height: 4px;
  background: var(--surface-inset);
  border-radius: var(--r-full);
  overflow: hidden;
  margin-bottom: var(--s3);
}

.timer-bar i {
  display: block;
  height: 100%;
  width: 0;
  background: var(--series-time);
  transition: width 0.1s linear, background-color var(--t-base) var(--ease);
}

.timer-bar i.warn {
  background: var(--status-warning);
}
.timer-bar i.danger {
  background: var(--status-critical);
}

.progress {
  height: 6px;
  background: var(--surface-inset);
  border-radius: var(--r-full);
  overflow: hidden;
}

.progress i {
  display: block;
  height: 100%;
  width: 0;
  background: var(--accent);
  border-radius: inherit;
  transition: width var(--t-slow) var(--ease);
}

.progress i.done {
  background: var(--status-good);
}

/* Two rings side by side: what is left today, what is left in the cycle. Only the
   remaining number is written — the share done reads off the arc, and the colour
   tells the two counts apart without having to label them twice. */
.ring-row {
  display: flex;
  justify-content: center;
  gap: 22px;
}

.ring {
  --ring-progress: 0;
  --ring-color: var(--accent);
  --ring-size: 78px;
  position: relative;
  flex: 0 0 var(--ring-size);
  width: var(--ring-size);
  height: var(--ring-size);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1px;
  border-radius: 50%;
  background: conic-gradient(
    var(--ring-color) calc(var(--ring-progress) * 360deg),
    var(--surface-inset) 0
  );
  transition: background var(--t-slow) var(--ease);
}

/* The inner disc hollows out the ring: no mask, no SVG. */
.ring::before {
  content: "";
  position: absolute;
  inset: 7px;
  border-radius: 50%;
  background: var(--surface-1);
}

.ring b,
.ring span {
  position: relative;
}

.ring b {
  font-size: var(--fs-lg);
  line-height: 1;
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}

.ring span {
  font-size: var(--fs-xs);
  color: var(--text-muted);
  text-align: center;
  line-height: 1.1;
  max-width: calc(var(--ring-size) - 22px);
}

.ring-cycle {
  --ring-color: var(--series-accuracy);
}
/* The right-hand ring's definition opens leftwards: the panel clips whatever
   runs past its edge. */
.ring-cycle .term::after {
  left: auto;
  right: 0;
}

.ring.done b {
  color: var(--ring-color);
}

.meta .meta-row {
  display: flex;
  justify-content: space-between;
  font-size: var(--fs-base);
  padding: 3px 0;
  color: var(--text-secondary);
}

/* The themes also carry .meta-row, whose space-between spread them across the
   whole width of the panel: they are grouped labels, not columns. */
.meta-themes,
.meta .meta-row.meta-themes {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-start;
  gap: 5px;
  padding-top: 8px !important;
}

.meta-themes em,
.themes em {
  font-style: normal;
  font-size: var(--fs-xs);
  padding: 2px 7px;
  border-radius: var(--r-full);
  background: var(--surface-2);
  color: var(--text-secondary);
}


.panel-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

.panel-actions .btn {
  flex: 1;
}

.board-side-foot .btn,
.etabli-foot .btn {
  height: 44px;
}

.solution {
  font-size: var(--fs-md);
  line-height: 1.9;
}

.solution.hidden {
  display: none;
}

.cycle-done {
  background: var(--surface-1);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  padding: 56px var(--s7);
  text-align: center;
}

.cycle-done-time {
  font-size: var(--fs-3xl);
  font-weight: 600;
  letter-spacing: 0;
  color: var(--accent);
  margin: var(--s2) 0 var(--s4);
  font-variant-numeric: tabular-nums;
}

.cycle-done p {
  color: var(--text-secondary);
  max-width: 44ch;
  margin: 0 auto;
}

.cycle-done-actions {
  display: flex;
  gap: 10px;
  justify-content: center;
  margin-top: 24px;
}

/* ---------- Progress of a set ---------- */

/* What defines a set reads in label/value pairs — not in a line of grey metadata.
   The set's name is not restated here: it is the Progress block's selector, just
   above, that carries it. The technical fingerprint (set_id) has no business here
   either: it serves to tell two sets apart in the list, not to open one. */
.set-facts {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: var(--s3) var(--s4);
  margin: 0;
}

.set-fact {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

.set-fact dt {
  font-size: var(--fs-2xs);
  font-weight: 600;
  letter-spacing: 0.01em;
  color: var(--text-muted);
}

.set-fact dd {
  margin: 0;
  font-size: var(--fs-md);
  font-weight: 600;
  line-height: 1.25;
  font-variant-numeric: tabular-nums;
  overflow-wrap: anywhere;
}

/* The end of a set's life, on its card. Two states and two colours: the cyan of
   what is going well for a target reached, the plain grey of the chrome for a set
   already put away -- an archived set is not an alert. */
.set-card-badge {
  align-self: start;
  margin: 0 0 var(--s2);
  font-size: var(--fs-2xs);
  font-weight: 600;
  letter-spacing: 0.01em;
  padding: 3px 10px;
  border-radius: var(--r-full);
}

.set-card-badge.reached {
  color: var(--status-good);
  background: color-mix(in srgb, var(--status-good) 12%, transparent);
  border: 1px solid color-mix(in srgb, var(--status-good) 32%, transparent);
}

.set-card-badge.retired {
  color: var(--text-muted);
  background: var(--surface-2);
  border: 1px solid var(--line);
}

/* A retired card recedes without becoming unreadable: it is still consulted, and a
   card at 50 % opacity is a card one cannot read. */
.set-card.retired {
  border-style: dashed;
}

.set-card.retired .set-card-openings,
.set-card.retired .set-card-meta {
  color: var(--text-muted);
}

.set-card-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 14px;
  font-size: var(--fs-base);
  color: var(--text-secondary);
}

/* The set's fingerprint is the proof of immutability: it reads in mono, like a
   hash, not as running text. */
.set-id {
  display: block;
  margin-top: var(--s2);
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  color: var(--text-muted);
  word-break: break-all;
}

.tiles {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
  gap: var(--s3);
  margin-bottom: var(--s4);
}

.tile {
  background: var(--surface-1);
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  padding: var(--s4) var(--s4) var(--s3);
  display: flex;
  flex-direction: column;
  gap: 3px;
}

.tile-label {
  font-size: var(--fs-2xs);
  font-weight: 600;
  letter-spacing: 0.01em;
  color: var(--text-muted);
}

.tile-value {
  font-size: var(--fs-2xl);
  font-weight: 600;
  letter-spacing: 0;
  line-height: 1.1;
}

.tile-hero .tile-value {
  font-size: var(--fs-3xl);
}

.tile-hint {
  font-size: var(--fs-sm);
  color: var(--text-muted);
  line-height: 1.45;
}

/* ---------- Progress of a Woodpecker set: the cockpit ---------- */

/* The verdict band. It answers "when will this set be done" before anything is
   scrolled, so it sits above the rule rather than inside a card of its own: a frame
   around it would make it one block among six again, which is exactly the problem
   it was built to fix. */
.verdict {
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  padding-bottom: var(--s4);
  margin-bottom: var(--s4);
  border-bottom: 1px solid var(--line);
}

.verdict-kicker {
  font-size: var(--fs-2xs);
  font-weight: 600;
  letter-spacing: 0.01em;
  color: var(--text-muted);
}

.verdict-value {
  font-size: var(--fs-3xl);
  font-weight: 600;
  line-height: 1.05;
}

.verdict-value small {
  font-size: var(--fs-base);
  font-weight: 400;
  color: var(--text-secondary);
}

.verdict-line {
  margin: 0;
  font-size: var(--fs-md);
  line-height: 1.5;
  color: var(--text-secondary);
  max-width: 68ch;
}

.verdict-line b {
  color: var(--accent-bright);
  font-weight: 600;
}

/* The target reached is the good news of this block: it takes the cyan, like
   everything else that is going well. */
.verdict-line b.verdict-done {
  color: var(--status-good);
}

/* What the block does NOT know reads quieter than what it does: a missing
   projection must not have the typographic weight of a date. */
.verdict-quiet {
  color: var(--text-muted);
}

.verdict-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}

.chip {
  font-size: var(--fs-xs);
  color: var(--text-secondary);
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--r-full);
  padding: 3px 10px;
  white-space: nowrap;
}

/* The distance from the first cycle down to the target. The fill goes from the deep
   blue to the cyan: it ENDS on the colour of what is going well, so arriving reads
   as arriving. */
.gauge {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.gauge-track {
  position: relative;
  height: 10px;
  border-radius: var(--r-full);
  background: var(--surface-2);
  overflow: hidden;
}

.gauge-fill {
  position: absolute;
  inset: 0 auto 0 0;
  border-radius: var(--r-full);
  background: linear-gradient(90deg, var(--accent-deep), var(--series-accuracy));
}

.gauge-ends {
  display: flex;
  justify-content: space-between;
  gap: var(--s3);
  font-size: var(--fs-xs);
  color: var(--text-muted);
}

.gauge-ends b {
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}

/* Two columns: the curve of the time alone on the left over both rows, the two
   supporting readings stacked on the right. */
.prog-cockpit {
  display: grid;
  grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr);
  gap: var(--s4);
  margin-bottom: var(--s4);
}

.prog-panel-main {
  grid-row: span 2;
}

/* A panel, not a card: it lives INSIDE the Profile's card, and `.card .card`
   already flattens a second frame to nothing. This one wants its frame -- it is
   what separates two columns -- so it carries its own class. */
.prog-panel {
  background: var(--surface-1);
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  padding: var(--s4);
  min-width: 0;
}

.prog-panel > h4 {
  margin: 0;
  font-size: var(--fs-base);
  font-weight: 600;
  color: var(--text-secondary);
}

.prog-panel > .hint {
  margin: 2px 0 12px;
  font-size: var(--fs-xs);
}

.prog-panel-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s3);
  flex-wrap: wrap;
  margin-bottom: var(--s2);
}

.prog-panel-head h4 {
  margin: 0;
  font-size: var(--fs-base);
  font-weight: 600;
  color: var(--text-secondary);
}

/* The target on the time chart: cyan and dashed, because it is a line one is
   heading FOR, not a value one has measured. */
.chart .target-line {
  stroke: var(--status-good);
  stroke-width: 1.5;
  stroke-dasharray: 6 5;
}

.chart .target-label {
  fill: var(--status-good);
  font-size: var(--fs-2xs);
  font-weight: 600;
}

/* A cycle not yet run is drawn hollow: the eye reads the outline as a promise and
   the filled bars as a fact, without a legend having to say so. */
.chart .bar-group.projected .bar {
  fill: none;
  stroke: var(--series-time);
  stroke-dasharray: 4 4;
  opacity: 0.6;
}

.chart .bar-group.projected .bar-label,
.chart .bar-group.projected .axis-label {
  opacity: 0.6;
}

/* The two tables share one panel and one head. The switch is a radio and a sibling
   selector: no listener, so the rendering module stays a pure string function and
   there is nothing to tear down when the block is replaced. */
.seg {
  display: inline-flex;
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--r-full);
  padding: 2px;
  gap: 2px;
}

.seg input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

.seg label {
  font-size: var(--fs-xs);
  color: var(--text-secondary);
  padding: 4px 12px;
  border-radius: var(--r-full);
  cursor: pointer;
}

.seg input:checked + label {
  background: var(--surface-3);
  color: var(--text-primary);
}

/* The focus ring lands on the label, the input being invisible: without this the
   switch is unusable at the keyboard, which it otherwise is -- arrow keys move
   through a radio group. */
.seg input:focus-visible + label {
  outline: 2px solid var(--accent-bright);
  outline-offset: 2px;
}

/* The radios live inside the head, the panels are its siblings: the state is read
   from the head with :has(), which is the only ancestor both sides share. */
.seg-panel-second,
.prog-panel-head:has(.seg input:last-of-type:checked) ~ .seg-panel-first {
  display: none;
}

.prog-panel-head:has(.seg input:last-of-type:checked) ~ .seg-panel-second {
  display: block;
}

.prog-work .seg-panel .hint {
  margin-top: 0;
  margin-bottom: 10px;
}

/* Under the two-column threshold the cockpit stacks: at that width the right-hand
   column would be too narrow for its axis labels, and a squeezed chart says less
   than a stacked one. */
@media (max-width: 900px) {
  .prog-cockpit {
    grid-template-columns: minmax(0, 1fr);
  }

  .prog-panel-main {
    grid-row: auto;
  }
}

/* Distribution of a deck's solidity: a single bar, because what reads is the share
   of green that grows and the share of red that recedes. */
.knowledge-bar {
  display: flex;
  overflow: hidden;
  height: 14px;
  margin-top: 4px;
  border-radius: var(--r-full);
  background: var(--surface-2);
}
.knowledge-part {
  height: 100%;
}
.knowledge-part + .knowledge-part {
  border-left: 1px solid var(--surface-1);
}
.knowledge-legend {
  display: flex;
  flex-wrap: wrap;
  gap: 8px 18px;
  margin-top: 12px;
  font-size: var(--fs-base);
  color: var(--text-secondary);
}

/* Empty state of a graph: the axes as a watermark, the message placed over them. */
.chart-empty {
  position: relative;
  display: grid;
  place-items: center;
}
.chart-empty .chart-skeleton {
  width: 100%;
  height: 150px;
  opacity: 0.45;
}
.chart-empty .empty {
  position: absolute;
  margin: 0;
  padding: var(--s2) var(--s4);
  border-radius: var(--r-sm);
  background: color-mix(in srgb, var(--surface-1) 88%, transparent);
  text-align: center;
  max-width: 52ch;
}

.chart-head {
  margin-bottom: 14px;
}

.chart-wrap {
  position: relative;
}

.chart {
  width: 100%;
  height: auto;
  display: block;
  overflow: visible;
}

.chart .grid {
  stroke: var(--grid);
  stroke-width: 1;
}

.chart .baseline {
  stroke: var(--baseline);
  stroke-width: 1;
}

.chart .axis-label {
  fill: var(--text-muted);
  font-size: var(--fs-xs);
  font-family: var(--font);
  font-variant-numeric: tabular-nums;
}

.chart .axis-title {
  fill: var(--text-muted);
  font-size: var(--fs-xs);
  font-family: var(--font);
}

.chart .bar {
  fill: var(--series-time);
}

.chart .bar-hit {
  fill: transparent;
}

.bar-group.partial .bar {
  fill: none;
  stroke: var(--series-time);
  stroke-width: 2;
  stroke-dasharray: 5 4;
}

.chart .bar-label {
  fill: var(--text-secondary);
  font-size: var(--fs-sm);
  font-family: var(--font);
  font-variant-numeric: tabular-nums;
}

@media (hover: hover) {
  .bar-group:hover .bar {
    fill: color-mix(in srgb, var(--series-time) 75%, var(--text-primary));
  }
}

.chart-accuracy .line {
  fill: none;
  stroke: var(--series-accuracy);
  stroke-width: 2;
  stroke-linejoin: round;
  stroke-linecap: round;
}

.chart-accuracy .dot {
  fill: var(--series-accuracy);
  stroke: var(--surface-1);
  stroke-width: 2;
}

.dot-group.partial .dot {
  fill: var(--surface-1);
  stroke: var(--series-accuracy);
}

.chart-accuracy .dot-hit {
  fill: transparent;
}

@media (hover: hover) {
  .dot-group:hover .dot {
    r: 7;
  }
}

.tooltip {
  position: absolute;
  pointer-events: none;
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  padding: 7px 11px;
  font-size: var(--fs-sm);
  color: var(--text-primary);
  white-space: nowrap;
  box-shadow: var(--shadow-2);
  z-index: 20;
}

.tooltip.hidden {
  display: none;
}

.calendar-wrap {
  display: flex;
  gap: 8px;
  align-items: flex-start;
  overflow-x: auto;
}

.cal-daynames {
  display: grid;
  grid-template-rows: repeat(7, 16px);
  font-size: var(--fs-2xs);
  color: var(--text-muted);
  padding-top: 2px;
}

.chart.calendar {
  width: auto;
  max-width: 100%;
  height: 112px;
  flex: none;
}

.cal-cell {
  fill: var(--surface-2);
}

.cal-cell.level-1 {
  fill: var(--heat-1);
}
.cal-cell.level-2 {
  fill: var(--heat-2);
}
.cal-cell.level-3 {
  fill: var(--heat-3);
}
.cal-cell.level-4 {
  fill: var(--heat-4);
}
.cal-cell.level-5 {
  fill: var(--heat-5);
}

.cal-cell.today {
  stroke: var(--text-secondary);
  stroke-width: 1.5;
}

.cal-legend {
  display: flex;
  align-items: center;
  gap: 5px;
  font-size: var(--fs-xs);
  color: var(--text-muted);
  margin-top: 10px;
}

.cal-swatch {
  width: 12px;
  height: 12px;
  border-radius: var(--r-xs);
  background: var(--surface-2);
  display: inline-block;
}

.cal-swatch.level-1 {
  background: var(--heat-1);
}
.cal-swatch.level-2 {
  background: var(--heat-2);
}
.cal-swatch.level-3 {
  background: var(--heat-3);
}
.cal-swatch.level-4 {
  background: var(--heat-4);
}
.cal-swatch.level-5 {
  background: var(--heat-5);
}

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

table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--fs-base);
}

/* A table header stays readable when the page scrolls under it: the heuristics
   reports run to hundreds of rows. */
th {
  position: sticky;
  top: 0;
  z-index: 1;
  text-align: left;
  font-weight: 600;
  font-size: var(--fs-2xs);
  letter-spacing: 0.01em;
  color: var(--text-muted);
  padding: var(--s2) var(--s3);
  background: var(--surface-1);
  border-bottom: 1px solid var(--line-strong);
  white-space: nowrap;
}

td {
  padding: 9px var(--s3);
  border-bottom: 1px solid var(--line-faint);
  color: var(--text-secondary);
}

tbody tr:last-child td {
  border-bottom: 0;
}

tbody tr {
  transition: background-color var(--t-fast) var(--ease);
}

td.num,
td.mono {
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  color: var(--text-primary);
}

td.mono {
  font-family: var(--font-mono);
  font-size: var(--fs-sm);
}

td.rank {
  color: var(--text-muted);
  width: 24px;
}

td.themes {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  border-bottom: 0;
}

.settings-form {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  gap: 14px;
  align-items: end;
}

.settings-status {
  font-size: var(--fs-base);
  color: var(--text-muted);
}

.settings-status.good {
  color: var(--status-good);
}
.settings-status.bad {
  color: var(--status-critical);
}

/* ---------- Sets ---------- */

/* Spaced cards, not the rows of a table. A set carries three lines of metadata and
   two buttons: stuck to its neighbour by a 2px rule, one no longer knew where one
   ended. The gap separates them, the edging holds them. */
.set-list {
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  margin-top: var(--s4);
}

.set-card {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: var(--s4);
  padding: var(--s4);
  border: 0;
  border-radius: var(--r-sm);
  background: transparent;
  box-shadow: inset 0 0 0 1px var(--line-faint);
  transition:
    background-color var(--t-fast) var(--ease),
    box-shadow var(--t-fast) var(--ease);
}

@media (hover: hover) {
  .set-card:hover {
    background: var(--surface-2);
  }
}

.set-card.active {
  background: var(--accent-quiet);
  box-shadow: inset 0 0 0 1px var(--accent-line);
}

/* A mistake deck is not a frozen set: the tinted background says so before reading.
   A 3px edging would do the same thing while unbalancing the card. */
.deck-card {
  background: color-mix(in srgb, var(--series-time) 7%, transparent);
}
/* The deck's only option sits alone in its block: the switch fills the row. */
.deck-options {
  display: flex;
}
.deck-options .switch {
  margin-left: 0;
  width: 100%;
  justify-content: space-between;
}

.deck-card h3::before {
  content: "";
  display: inline-block;
  width: 7px;
  height: 7px;
  margin-right: var(--s2);
  border-radius: var(--r-xs);
  background: var(--series-time);
  vertical-align: 0.08em;
}

.set-card h3 {
  margin-bottom: 4px;
}

.set-card-actions {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-shrink: 0;
}

/* A square button next to a text button: same height, no label. */
.btn-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 9px 11px;
}

/* The confirmation replaces the card: the armed set offers nothing else. */
.set-card-arming {
  box-shadow: inset 0 0 0 1px var(--status-critical);
  background: color-mix(in srgb, var(--status-critical) 9%, transparent);
}

.set-card-warning {
  font-size: var(--fs-base);
  color: var(--text-secondary);
  margin-top: 2px;
}

.set-card-arming .error {
  font-size: var(--fs-base);
  margin-top: 6px;
}

@media (max-width: 700px), (max-height: 500px) and (max-width: 900px) {
  .set-card {
    flex-direction: column;
    align-items: stretch;
  }
  /* Four buttons on one row overflowed a phone by 20 px and clipped the bin. */
  .set-card-actions {
    flex-wrap: wrap;
  }
}

.set-card-openings {
  font-size: var(--fs-sm);
  color: var(--text-muted);
  margin-top: 4px;
}

/* ---------- provenance: a group's heading, a book's card ----------

   Three provenances follow one another in the same list: the sets built by hand,
   the games, the books. The heading only appears if there is more than one group --
   a lone label above a single group names a structure that does not exist. */

.set-group-label {
  margin-top: var(--s2);
  font-family: var(--font-display);
  font-size: var(--fs-2xs);
  font-weight: 600;
  letter-spacing: 0.01em;
  color: var(--text-muted);
}
.set-group-label:first-child {
  margin-top: 0;
}

/* A book is a folder: its card caps the sets of its chapters and is pressed to open
   them. The whole card is the handle -- the chevron stays a real button, for the
   keyboard and to state the state. */
.set-group-book {
  display: grid;
  grid-template-rows: auto 0fr;
  transition: grid-template-rows var(--t-slow) var(--ease);
}
.set-group-book.open {
  grid-template-rows: auto 1fr;
}

/* A solid background detaches it from the set cards, which are transparent: a
   folder is an object, not one more row. The hover stays that of `.set-card` — it
   is the same card, with the same response. */
.book-folder {
  cursor: pointer;
  background: var(--surface-1);
}
/* The active set may be folded inside it: the folder says so from the outside,
   otherwise the page no longer shows anywhere what is being worked on. */
.book-folder.has-active {
  box-shadow: inset 0 0 0 1px var(--accent-line);
}

.book-folder h3 {
  display: flex;
  align-items: center;
  gap: var(--s2);
}
.book-folder-mark {
  display: inline-flex;
  color: var(--text-muted);
}
.set-group-book.open .book-folder-mark {
  color: var(--accent-bright);
}

/* The chevron points down when closed, up when open: the rotation belongs to the
   common gesture, not to one more piece of staging. */
.book-folder-toggle .icon {
  transition: transform var(--t-base) var(--ease);
}
.set-group-book.open .book-folder-toggle .icon {
  transform: rotate(180deg);
}

/* `overflow: hidden` goes on the drawer and nowhere else: it is the grid element
   whose row closes, and without that its content overflowed on top of the cards
   below at zero height.

   So the padding lives inside: the top one lets the cards rise 2 px on hover, the
   bottom one lets their shadow through. */
.book-drawer {
  position: relative;
  overflow: hidden;
}
.book-drawer-inner {
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  padding: var(--s3) 2px var(--s2) var(--s5);
}

/* The chapters arrive in a cascade, but only on a requested unfold (`is-opening`,
   set by the JS and removed afterwards). A list render lays them down with the book
   already open: choosing a set does not replay the opening.
   The motion is that of the view entrance, offset by 40 ms per rank. */
.set-group-book.is-opening .book-drawer-inner > * {
  animation: view-enter var(--t-base) var(--ease) both;
  animation-delay: calc(var(--rank, 0) * 40ms);
}

/* The drawer is indented from its book, and a solid rule ties it to it: without
   that stroke, fifteen offset cards no longer said what they belonged to. It runs
   from one edge of the cards to the other, not of the drawer -- its padding is the
   shadow's margin, not the content's. */
.book-drawer::before {
  content: "";
  position: absolute;
  top: var(--s3);
  bottom: var(--s2);
  left: var(--s4);
  width: 1px;
  background: var(--line);
}

/* ---------- renaming ----------

   The field takes the title's place, inside the card: renaming destroys nothing,
   and the name must be edited where it is read. The full-card confirmation is
   reserved for deletion. */

.set-rename {
  display: flex;
  align-items: center;
  gap: var(--s2);
  flex-wrap: wrap;
  margin-bottom: 4px;
}
/* Same typeface and same step as the h3 it replaces: the title must not change size
   on becoming editable, otherwise the card moves under the finger. */
.set-rename-input {
  flex: 1 1 200px;
  min-width: 0;
  font-family: var(--font-display);
  font-size: var(--fs-md);
}
.set-rename .error {
  flex: 1 1 100%;
  font-size: var(--fs-base);
}

/* The Sets page: the list above, the workshop below. Two cards, no rail — unlike
   Config, there is nothing to go and find further down. */
.sets-page {
  display: flex;
  flex-direction: column;
  gap: var(--s5);
}

/* Two sources for one same set, hence a segment and not two buttons that look
   alike: the chosen source is solid, the other is a background. */
.source-picker {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 2px;
  margin-top: var(--s4);
  padding: 3px;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: var(--surface-1);
  max-width: 760px;
}
.source-tab {
  display: flex;
  flex-direction: column;
  gap: var(--s1);
  align-items: flex-start;
  border: 0;
  border-radius: calc(var(--r-md) - 3px);
  background: transparent;
  color: var(--text-muted);
  cursor: pointer;
  font: inherit;
  padding: var(--s3) var(--s4);
  text-align: left;
  transition: background-color var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.source-tab:hover {
  background: var(--surface-2);
  color: var(--text-secondary);
}
.source-tab b {
  font-size: var(--fs-base);
  font-weight: 600;
}
.source-tab small {
  font-size: var(--fs-xs);
  color: var(--text-muted);
}
.source-tab.active {
  background: var(--surface-3);
  color: var(--text-primary);
  box-shadow: inset 0 0 0 1px var(--line-strong);
}
.source-tab.active small {
  color: var(--text-secondary);
}

.source-panel {
  margin-top: var(--s4);
}

/* ---------- Book panel ---------- */

.books-panel {
  display: flex;
  flex-direction: column;
  gap: var(--s4);
  max-width: 760px;
}
.book-form {
  display: flex;
  align-items: center;
  gap: var(--s3);
  flex-wrap: wrap;
}
/* The upload area is a field, not a drag-and-drop target: the file is chosen, and
   the button stays disabled as long as none is chosen. */
.book-drop {
  flex: 1 1 320px;
  display: flex;
  align-items: center;
  gap: var(--s2);
  border: 1px dashed var(--line-strong);
  border-radius: var(--r-sm);
  background: var(--surface-1);
  color: var(--text-muted);
  cursor: pointer;
  font-size: var(--fs-xs);
  padding: var(--s3) var(--s4);
  transition: border-color var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.book-drop:hover {
  border-color: var(--accent);
  color: var(--text-secondary);
}
.book-drop input {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}

.book-list {
  display: flex;
  flex-direction: column;
  gap: var(--s3);
}

.book-details {
  margin-top: var(--s2);
}
.book-details summary {
  color: var(--text-muted);
  cursor: pointer;
  font-size: var(--fs-2xs);
}
.book-details summary:hover {
  color: var(--text-secondary);
}

/* The report is the only trace of what the reading discarded: the PDF no longer
   exists. One line per chapter, its losses spelled out under its count. */
.book-report {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  margin-top: var(--s3);
}
.book-report-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto auto;
  gap: var(--s1) var(--s3);
  align-items: baseline;
  font-size: var(--fs-2xs);
}
.book-report-row b {
  color: var(--text-primary);
  font-weight: 600;
}
.book-report-row span {
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
}
.book-report-row em {
  color: var(--accent);
  font-style: normal;
  font-variant-numeric: tabular-nums;
}
.book-report-row small {
  grid-column: 1 / -1;
  color: var(--text-muted);
}

.build-form {
  display: flex;
  flex-direction: column;
  gap: 14px;
  margin-top: 16px;
  max-width: 760px;
}

.field-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
  gap: 12px;
}

label {
  display: flex;
  flex-direction: column;
  gap: 5px;
  font-size: var(--fs-xs);
  font-weight: 500;
  color: var(--text-muted);
}

input,
select,
textarea {
  font: inherit;
  font-size: var(--fs-base);
  padding: 7px 10px;
  border-radius: var(--r-sm);
  border: 1px solid var(--line-strong);
  background: var(--surface-inset);
  color: var(--text-primary);
  transition: border-color var(--t-fast) var(--ease),
    background-color var(--t-fast) var(--ease);
}

input:hover:not(:disabled),
select:hover:not(:disabled),
textarea:hover:not(:disabled) {
  border-color: var(--line-strong);
}

input:focus,
select:focus,
textarea:focus {
  outline: none;
  border-color: var(--accent);
  background: var(--surface-2);
}

input:disabled,
select:disabled,
textarea:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

input::placeholder,
textarea::placeholder {
  color: var(--text-muted);
}

/* A field that accepts only figures reads in tabular figures. */
input[type="number"],
input[type="text"] {
  font-variant-numeric: tabular-nums;
}

textarea {
  resize: vertical;
  font-family: var(--font-mono);
  font-size: var(--fs-sm);
  line-height: 1.55;
}

.chip {
  font: inherit;
  font-size: var(--fs-sm);
  padding: 4px 10px;
  border-radius: var(--r-full);
  border: 1px solid var(--line-strong);
  background: var(--surface-1);
  color: var(--text-secondary);
  cursor: pointer;
  transition: border-color var(--t-fast) var(--ease),
    background-color var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}

.chip:hover {
  border-color: var(--accent-line);
  background: var(--accent-quiet);
  color: var(--text-primary);
}

.chip em {
  font-style: normal;
  color: var(--text-muted);
  font-size: var(--fs-xs);
  margin-left: 4px;
}

.build-status {
  font-size: var(--fs-base);
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}

.build-status.hidden {
  display: none;
}

/* An import's log: what is settled in grey, what is running in light. The list is
   not framed -- it already lives in the source's panel. */
.book-log {
  list-style: none;
  margin: 0 0 var(--s2);
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.book-log li {
  color: var(--text-muted);
  padding-left: var(--s3);
  position: relative;
}
.book-log li::before {
  content: "·";
  position: absolute;
  left: 4px;
}
.book-log li:last-child {
  color: var(--text-secondary);
}

/* The step in progress is not settled: the beating dot says so without text. */
.book-progress-now {
  color: var(--text-primary);
  padding-left: var(--s3);
  position: relative;
}
.book-progress-now::before {
  content: "";
  position: absolute;
  left: 3px;
  top: 0.55em;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--accent);
  animation: book-progress-pulse 1.4s var(--ease) infinite;
}
@keyframes book-progress-pulse {
  50% {
    opacity: 0.25;
  }
}
@media (prefers-reduced-motion: reduce) {
  .book-progress-now::before {
    animation: none;
  }
}

.book-progress-done {
  color: var(--text-primary);
  margin-top: var(--s2);
}

.hidden {
  display: none;
}

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

.move-list {
  padding: 0;
  overflow: hidden;
}

.move-list-scroll {
  display: grid;
  grid-template-columns: 2.6em 1fr 1fr;
  align-items: center;
  gap: 1px 4px;
  max-height: 220px;
  overflow-y: auto;
  padding: 10px 12px;
  font-variant-numeric: tabular-nums;
}

.move-number {
  color: var(--text-muted);
  font-size: var(--fs-sm);
}

.move-list-scroll .move {
  appearance: none;
  background: none;
  border: 0;
  border-radius: var(--r-sm);
  color: var(--text-secondary);
  cursor: pointer;
  font: inherit;
  font-size: var(--fs-base);
  padding: 2px 6px;
  text-align: left;
}

.move-list-scroll .move:hover {
  background: var(--surface-2);
}

.move-list-scroll .move.current {
  background: var(--surface-2);
  color: var(--text-primary);
  font-weight: 600;
}

.move-list-scroll .move.empty {
  cursor: default;
}

.move-list-note {
  border-top: 1px solid var(--border);
  color: var(--text-muted);
  font-size: var(--fs-sm);
  padding: 8px 12px;
}

.board-frame.reviewing {
  outline: 2px solid var(--status-warning);
  outline-offset: 4px;
  border-radius: var(--r-md);
}

/* ---------- Repertoire (heuristic) ---------- */

.repertoire-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 16px;
  margin-top: 16px;
}
@media (max-width: 700px), (max-height: 500px) and (max-width: 900px) {
  .repertoire-grid { grid-template-columns: 1fr; }
}
.repertoire-status { font-size: var(--fs-base); color: var(--text-secondary); margin: 10px 0 14px; }
.repertoire-status code { font-size: var(--fs-sm); color: var(--text-primary); }
.empty-inline { color: var(--text-muted); font-style: italic; }
.repertoire-actions { display: flex; gap: 10px; margin-top: 12px; }
.btn-danger { color: var(--status-critical); border-color: var(--status-critical); }
.drop {
  border: 1.5px dashed var(--grid);
  border-radius: var(--r-md);
  padding: 18px;
  text-align: center;
  color: var(--text-secondary);
  cursor: pointer;
  font-size: var(--fs-base);
}
.drop.armed, .drop:hover { border-color: var(--accent); background: var(--accent-quiet); color: var(--text-primary); }
.drop .file-name {
  font-family: var(--font-mono);
  font-size: var(--fs-sm);
  color: var(--series-accuracy);
  font-weight: 600;
}
.ok { color: var(--status-good); font-size: var(--fs-base); margin-top: 10px; }

/* ---------- Form fields & choice pickers (shared) ---------- */

.field { margin-bottom: 18px; }
.field-label { font-weight: 600; font-size: var(--fs-base); margin-bottom: 6px; display: block; }
.choices,
.animation-profiles { display: flex; flex-wrap: wrap; gap: 8px; }
.choice { position: relative; }
.choice input {
  position: absolute; inset: 0; width: 100%; height: 100%; margin: 0;
  opacity: 0; pointer-events: none;
}
.choice span {
  display: inline-flex;
  align-items: center;
  min-height: 32px;
  padding: 6px 12px;
  border: 1px solid var(--grid);
  border-radius: var(--r-full);
  font-size: var(--fs-base);
  cursor: pointer;
  user-select: none;
  color: var(--text-secondary);
}
.choice input:checked + span {
  background: var(--accent);
  color: var(--text-on-accent);
  border-color: transparent;
  font-weight: 600;
}
.choice input:disabled + span { opacity: 0.4; cursor: not-allowed; }
.choice input:focus-visible + span { outline: none; box-shadow: var(--focus-ring); }
.token-input {
  font-family: var(--font-mono);
  font-size: var(--fs-base);
  width: 100%;
  padding: 8px 10px;
  border: 1px solid var(--line-strong);
  border-radius: var(--r-sm);
  background: var(--surface-inset);
  color: var(--text-primary);
}
.hint-line { color: var(--text-muted); font-size: var(--fs-sm); margin: 6px 0 0; }
.hint-line a { color: var(--accent); }
.san { font-family: var(--font-mono); font-size: var(--fs-sm); }

/* ---------- Tab strip (shared) ---------- */

/* Base of every tab strip: Puzzles and Repertoire build their mode-switcher
   bars on it (`.puzzle-tabs` further down scopes the pill look), each button
   carrying `tab` or `tab active`. */
.tabs { display: flex; flex-wrap: wrap; }
.tab {
  border: none;
  background: none;
  color: var(--text-muted);
  font-size: var(--fs-base);
  white-space: nowrap;
  cursor: pointer;
}
.tab:hover { color: var(--text-secondary); background: var(--surface-2); }

/* ---------- Review (repertoire) ---------- */

.review-view .row { display: flex; gap: 14px; align-items: flex-end; flex-wrap: wrap; }
.review-view .row label { display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-base); color: var(--text-secondary); }
.review-view input[type="text"], .review-view input[type="number"] {
  font: inherit;
  background: var(--page);
  color: var(--text-primary);
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  padding: 7px 10px;
}
.hint-link {
  background: none;
  border: none;
  padding: 0;
  font: inherit;
  color: var(--series-accuracy);
  cursor: pointer;
  text-decoration: underline;
}
.verdict-badge { display: inline-block; font-size: var(--fs-xs); padding: 2px 8px; border-radius: var(--r-full); border: 1px solid var(--border); color: var(--text-muted); }
.verdict-badge.clean { color: var(--series-accuracy); border-color: var(--series-accuracy); }
.verdict-badge.deviation { color: var(--status-critical); border-color: var(--status-critical); }
.quiz-side h3 { font-size: var(--fs-md); margin: 0; }
.quiz-side .context { color: var(--text-secondary); font-size: var(--fs-base); }
.quiz-side .line { font-family: var(--font-mono); font-size: var(--fs-sm); color: var(--text-secondary); margin: 10px 0; }
/* `.quiz-verdict`, not `.verdict`: that name belongs to the Profile cockpit's
   band (a flex column), and under it every move of this sentence sat on its
   own line. */
.quiz-verdict { font-size: var(--fs-md); margin: 12px 0; line-height: 1.5; }
.quiz-verdict.good { color: var(--series-accuracy); }
.quiz-verdict.bad { color: var(--status-critical); }
.quiz-verdict .san { display: inline; }
/* The reflection question and its answers: one click, then the explanation. */
.reflection-question { margin: 12px 0 8px; font-size: var(--fs-md); }
.reflection-reasons { display: flex; flex-wrap: wrap; gap: 8px; }
.reflection-reasons .btn[aria-pressed="true"] { border-color: currentColor; }
/* The facts of the position, read top to bottom. */
.explanation { margin: 12px 0; }
.explanation-line { margin: 0 0 8px; line-height: 1.5; font-size: var(--fs-md); }
.explanation-line .san { display: inline; }
.explanation-line.threat { color: var(--status-critical); }
.explanation-line.chance { color: var(--series-accuracy); }
/* Why the move just found, in the reader's own words from Build: a quiet
   quoted line under the context, read while the next move is being thought
   about. */
.drill-why {
  margin: 0 0 10px;
  padding-left: 10px;
  border-left: 2px solid var(--accent-line);
  font-size: var(--fs-sm);
  font-style: italic;
  line-height: 1.4;
  color: var(--text-secondary);
}
.drill-why b {
  display: block;
  font-style: normal;
  font-weight: 600;
  font-size: var(--fs-xs);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-muted);
}
.drill-why.hidden {
  display: none;
}

.review-score { font-size: var(--fs-xl); font-weight: 600; }

/* --- Training view: wide grid, notes panel, ribbon tree --- */

.training-controls { display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap; margin: 14px 0 6px; }
.training-controls label { display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-base); color: var(--text-secondary); }

.notes-panel { background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-md); padding: 14px; display: flex; flex-direction: column; gap: 10px; }
.notes-panel h3 { margin: 0; font-size: var(--fs-md); }
.notes-panel .context { margin: 0; font-size: var(--fs-base); color: var(--text-secondary); }
.notes-panel textarea { resize: vertical; min-height: 110px; }
.notes-panel.hidden { display: none; }
.board-side-body > .notes-panel { order: 1; }
/* Opening name above the line, in the right-hand column of the drills. */
.opening-name { margin: 0 0 6px; font-weight: 600; color: var(--text-primary); }

/* ---- Training: a single workbench ----
   Header with the actions, a ledger of figures + a mastery bar (which also serves
   as the tree's legend), then the explorer: the ribbon tree, panned by hand, with
   the position on a board floating over it. */
/* The home is a canvas: the outlet gives it the whole viewport (as it does for
   the reader), the tree fills it, and the card floats over the tree as a strip. */
.outlet.wide:has(.training-home:not(.hidden)) {
  height: 100dvh;
  max-width: none;
  padding: 0;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
/* Every wrapper between the outlet and the home (the repertoire sub-tabs, the
   view root) passes the height down, or the home sizes to nothing. */
.outlet.wide:has(.training-home:not(.hidden)) :is(.repertoire, .subtab-outlet, .training-view) {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
}
.training-home { position: relative; flex: 1; min-height: 0; }
.training-home.hidden { display: none; }

.tree-strip {
  position: absolute;
  top: 12px;
  left: 12px;
  right: 12px;
  z-index: 4;
  padding: 10px 14px;
  max-width: none;
}
.strip-row { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
.strip-row h2 { margin: 0; font-size: var(--fs-md); white-space: nowrap; }
.tree-strip .ledger { display: flex; gap: 14px; margin: 0; padding: 0; border: 0; }
.tree-strip .ledger .stat { display: flex; align-items: baseline; gap: 5px; border: 0; padding: 0; white-space: nowrap; }
.tree-strip .ledger .stat-value { font-size: var(--fs-lg); }
.tree-strip .mastery { flex: 1 1 200px; min-width: 160px; margin: 0; }
.tree-strip .mastery-bar { height: 6px; }
.tree-strip .legend { display: flex; flex-wrap: wrap; gap: 2px 12px; margin: 4px 0 0; font-size: var(--fs-xs); line-height: 1.4; color: var(--text-secondary); }
.tree-strip .card-head-actions { margin-left: auto; }
.tree-strip .card-foot { margin-top: 6px; }
.tree-strip .card-foot .hint { font-size: var(--fs-sm); margin: 0; }

.drill-list { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; max-height: 132px; overflow-y: auto; }
.drill-list .hint { margin: 0; font-size: var(--fs-sm); }
.drill-list-title { margin: 0; font-size: var(--fs-sm); color: var(--text-secondary); font-weight: 600; }
.drill-row { display: flex; align-items: center; gap: 10px; font-size: var(--fs-sm); min-width: 0; }
.drill-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.drill-bar { flex: 0 0 120px; height: 6px; border-radius: var(--r-full); overflow: hidden; background: var(--surface-2); }
.drill-bar i { display: block; height: 100%; }
.drill-percent { flex: 0 0 auto; min-width: 5ch; text-align: right; color: var(--text-secondary); }
.drill-when { flex: 0 0 auto; color: var(--text-secondary); }
@media (max-width: 700px), (max-height: 500px) and (max-width: 900px) {
  /* The name is the one thing the user must read: it takes the row's full
     width, the rest (colour, bar, %, actions) wraps onto a second line. */
  .drill-row { flex-wrap: wrap; row-gap: 4px; }
  .drill-name { flex-basis: 100%; }
  .drill-bar { flex: 1 1 60px; }
}


.ledger {
  display: grid;
  /* Four stat tiles (due, new, done, trained) + the mastery tile: five columns. */
  grid-template-columns: repeat(5, minmax(0, 1fr));
  gap: 0 24px;
  margin-top: 18px;
  padding: 16px 0 0;
  border-top: 1px solid var(--border);
}
.ledger .stat {
  padding: 0;
  margin: 0;
  background: none;
  border-left: 1px solid var(--border);
  padding-left: 16px;
}
.ledger .stat:first-child { border-left: 0; padding-left: 0; }
.ledger .stat-value {
  font-size: var(--fs-2xl);
  font-weight: 700;
  letter-spacing: 0;
  line-height: 1.15;
  font-variant-numeric: tabular-nums;
}
.ledger .stat-mastery .stat-value { color: var(--status-good); }
.ledger .mastery {
  grid-column: 1 / -1;
  margin-top: 14px;
}
.mastery-bar {
  display: flex;
  gap: 2px;
  height: 8px;
  border-radius: var(--r-full);
  overflow: hidden;
  background: var(--surface-2);
}
.mastery-bar i { display: block; height: 100%; transition: width var(--t-slow) var(--ease); }
.mastery-bar:empty { background: var(--surface-2); }
.ledger .legend {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 16px;
  margin: 8px 0 0;
  font-size: var(--fs-sm);
  line-height: 1.6;
  color: var(--text-secondary);
}
.legend-item { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.legend-item i { width: 9px; height: 9px; border-radius: var(--r-xs); flex: none; }
@media (max-width: 700px), (max-height: 500px) and (max-width: 900px) {
  .ledger { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 20px; }
  .ledger .stat:nth-child(odd) { border-left: 0; padding-left: 0; }
}

.explorer.tree-viewport {
  position: absolute;
  inset: 0;
  overflow: hidden;
  background: var(--surface-inset);
}
.tree-svg {
  display: block;
  width: 100%;
  height: 100%;
  cursor: grab;
  touch-action: none;
  user-select: none;
}
.tree-svg:active { cursor: grabbing; }
.tree-svg g.animated { transition: transform 300ms var(--ease); }
.tree-root { fill: var(--surface-2); stroke: var(--border); stroke-width: 1.5; cursor: pointer; }
/* The transparent twin under each ribbon: it catches the pointer, and wears the
   selection outline so the ribbon keeps its state colour. */
.tree-hit { fill: none; stroke: transparent; cursor: pointer; }
.tree-hit.selected { stroke: var(--text-primary); }
.tree-ribbon {
  fill: none;
  stroke-linecap: butt;
  pointer-events: none;
  transition: opacity var(--t-base) var(--ease);
}
.tree-ribbon.opponent { opacity: 0.75; }
/* Another move order joining a line drawn elsewhere: dashed, so the bend reads
   as a junction and not as a branch of its own. */
.tree-ribbon.transposition { stroke-dasharray: 6 4; }
.tree-svg.hovering .tree-ribbon { opacity: 0.22; }
.tree-svg.hovering .tree-ribbon.lit { opacity: 1; }
/* No font-size here: the script counter-scales it so a label reads 11 px at
   any zoom. */
.tree-label { font-family: var(--font-mono); pointer-events: none; }
/* Every ribbon carries its label; the zoom decides which ones are legible. */
.tree-label.hidden { display: none; }
.tree-label.inside { fill: #fff; }
.tree-label.above { fill: var(--text-secondary); }
/* Both sit over the svg: they must let the hand through, and the empty hint has
   to beat the earlier `.hidden` at equal specificity or it would cover the whole
   tree. */
.tree-caption { position: absolute; left: 12px; bottom: 8px; margin: 0; max-width: 60%; pointer-events: none; }
.tree-empty { position: absolute; inset: 0; display: grid; place-items: center; margin: 0; pointer-events: none; }
.tree-empty.hidden { display: none; }

/* The floating board: the position must read, not merely be guessed. */
.tree-float {
  position: absolute;
  right: 12px;
  bottom: 12px;
  width: 320px;
  padding: 10px;
  background: var(--surface-1);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  box-shadow: var(--shadow-2);
  display: flex;
  flex-direction: column;
  gap: 8px;
  z-index: 3;
}
.tree-float.hidden { display: none; }
.tree-float-body { display: flex; gap: 8px; align-items: stretch; }
.tree-float-body .board-frame.mini { flex: 1; max-width: none; }
.tree-float-toggle { position: absolute; top: 4px; right: 4px; z-index: 1; }
.tree-float .line { margin: 0; font-family: var(--font-mono); font-size: var(--fs-sm); line-height: 1.5; color: var(--text-primary); min-height: 1.5em; }
.tree-float .line .off-repertoire { color: var(--text-secondary); }
.tree-float-actions { margin: 0; }
.drill-limit { display: inline-flex; flex-direction: row; align-items: center; margin: 0; gap: 4px; font-size: var(--fs-sm); color: var(--text-secondary); }
.drill-limit input { width: 4.5em; }
.tree-float-open { position: absolute; right: 12px; bottom: 12px; z-index: 3; }
.tree-float-open.hidden { display: none; }
@media (max-width: 900px) {
  .tree-float { width: 200px; }
}
@media (max-width: 700px), (max-height: 500px) and (max-width: 900px) {
  /* The mobile strip (57 px) sits above the outlet: without this the canvas
     runs 57 px past the fold and the "Board" pill lands under the tab bar. */
  .outlet.wide:has(.training-home:not(.hidden)) { padding: 0; height: calc(100dvh - 57px - env(safe-area-inset-top)); }
  .training-home .tree-viewport { bottom: calc(57px + env(safe-area-inset-bottom)); }
  .tree-strip { top: 8px; left: 8px; right: 8px; padding: 8px 10px; }
  .strip-row { gap: 10px; }
  .tree-strip .ledger { gap: 10px; flex-wrap: wrap; }
}
@media (prefers-reduced-motion: reduce) {
  .tree-svg g.animated { transition: none; }
}

.tree-toggle.active { border-color: var(--text-primary); color: var(--text-primary); font-weight: 600; }

/* ---- Analysis view (Review) ---- */
.move-list { display: flex; flex-direction: column; gap: 2px; overflow-y: auto; padding-right: 6px; }
/* Later than .hidden at equal specificity, like .report.hidden: without it the
   Moves tab stayed rendered under the report, and scrolling its current move
   into view dragged the whole panel (the page, on a phone) down to it. */
.move-list.hidden { display: none; }
.move-line { display: grid; grid-template-columns: 34px 1fr 1fr; gap: 4px; align-items: center; }
.move-number { color: var(--text-muted); font-size: var(--fs-sm); font-variant-numeric: tabular-nums; }
.move-chip {
  background: none; border: 1px solid transparent; border-radius: var(--r-sm); cursor: pointer;
  color: var(--text-secondary); font-family: inherit; font-size: var(--fs-base); padding: 3px 6px; text-align: left;
}
.move-chip:hover { background: var(--surface-2); }
.move-chip.current { border-color: var(--border); background: var(--surface-2); color: var(--text-primary); }
.move-chip.tone-good { color: var(--series-accuracy); }
.move-chip.tone-great { color: var(--series-time); font-weight: 600; }
.move-chip.tone-brilliant { color: var(--series-accuracy); font-weight: 700; }
.move-chip.tone-warn { color: var(--status-warning); }
.move-chip.tone-bad { color: var(--status-critical); font-weight: 600; }
.move-chip.tone-neutral { color: var(--text-secondary); }
.move-glyph { margin-left: 3px; font-weight: 700; }
/* The classification marks that are pictograms are drawn icons: they scale to the
   size of the text around them. */
.move-glyph .icon,
.report-glyph .icon {
  width: 13px;
  height: 13px;
  vertical-align: -0.16em;
}
.move-glyph:has(.icon) {
  display: inline-flex;
  align-items: center;
}
/* A single gutter for both registers: without a fixed width, the labels started at
   three different abscissas depending on whether the row carried a notation or an
   icon. */
.report-glyph {
  min-width: 18px;
}
.report-glyph:has(.icon) {
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
.move-detail { margin: 10px 0 0; font-size: var(--fs-base); color: var(--text-secondary); }

/* ---- The engine's lines (Analysis), laid out as chess.com does ----
   The depth, the verdict on the move shown and the best one, three live lines,
   the opening's name, then the moves. */
.engine-head {
  margin: 0;
  font-size: var(--fs-xs);
  color: var(--text-muted);
  text-align: right;
  font-variant-numeric: tabular-nums;
}
.move-detail:has(.engine-verdict-row) { margin: 0; display: flex; flex-direction: column; gap: 4px; }
.engine-verdict-row,
.engine-line {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
}
.engine-verdict-text {
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: 4px;
  color: var(--tone, var(--text-primary));
  font-weight: 600;
}
.engine-verdict-text .move-glyph { margin-left: 0; }
.eval-chip {
  flex: none;
  min-width: 52px;
  padding: 2px 6px;
  border-radius: 4px;
  font-size: var(--fs-sm);
  font-weight: 700;
  text-align: center;
  font-variant-numeric: tabular-nums;
}
/* The chip's colour is the side that leads: a board's white and black, not
   the theme's. */
.eval-chip.is-white { background: #f2f2f2; color: #1a1a1a; }
.eval-chip.is-black { background: #3a3a3f; color: #f2f2f2; }
.eval-chip.is-pending { background: var(--surface-2); color: var(--text-muted); }
.engine-lines {
  display: flex;
  flex-direction: column;
  border-top: 1px solid var(--border);
  border-bottom: 1px solid var(--border);
}
.engine-lines.hidden,
.engine-head.hidden,
.analysis-opening.hidden { display: none; }
.engine-line { padding: 4px 0; }
.engine-line + .engine-line { border-top: 1px solid var(--border); }
.engine-pv {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  font-size: var(--fs-sm);
  color: var(--text-secondary);
}
.engine-line.is-open { align-items: flex-start; }
.engine-line.is-open .engine-pv { white-space: normal; }
.engine-pv-number { margin: 0 2px 0 4px; color: var(--text-muted); }
.engine-pv-number:first-child { margin-left: 0; }
.engine-pv-move {
  padding: 1px 2px;
  border: 0;
  border-radius: 3px;
  background: none;
  color: inherit;
  font: inherit;
  cursor: pointer;
}
.engine-pv-move:hover { background: var(--surface-2); color: var(--text-primary); }
.engine-line-toggle {
  flex: none;
  padding: 0 4px;
  border: 0;
  background: none;
  color: var(--text-muted);
  font: inherit;
  cursor: pointer;
}
.engine-line-toggle:hover { color: var(--text-primary); }
.engine-idle { margin: 0; padding: 6px 0; font-size: var(--fs-sm); color: var(--text-muted); }
.analysis-opening {
  margin: 0;
  font-size: var(--fs-sm);
  color: var(--text-secondary);
}
/* The time each side spent, White's bar over Black's, when the PGN had clocks. */
.move-list.has-times .move-line { grid-template-columns: 34px 1fr 1fr 72px; }
.move-times { display: flex; flex-direction: column; gap: 2px; }
.move-time {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 4px;
  font-size: 11px;
  line-height: 1;
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
}
.move-time em { font-style: normal; min-width: 30px; text-align: right; }
.move-time i { display: block; height: 7px; border-radius: 2px; }
.move-time.is-white i { background: #d8d8dc; }
.move-time.is-black i { background: #5a5a62; }
/* Where the engine is in its reading: a footnote at the panel's end, in the
   smallest size, not a headline over the report. */
.deepen-line {
  margin: auto 0 0;
  padding-top: 8px;
  font-size: var(--fs-xs);
  color: var(--text-muted);
}
.deepen-line.hidden { display: none; }

/* ---- Game report (Analysis) ----
   What one reads on closing a game: how both sides played, before going to see
   which move. The move list becomes the second tab. */
/* The row at the top of the analysis panel: the tabs, and in the corner the one
   switch that decides whether the engine speaks at all. */
.report-head {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 12px;
}
.report-tabs {
  display: flex;
  flex: 1 1 auto;
  gap: 4px;
  padding: 3px;
  border-radius: var(--r-sm);
  background: var(--surface-2);
}
.report-tabs.hidden { display: none; }

/* A switch, not a button: its two states are read at a glance, and the one that
   is on is the one that costs nothing to leave. Pushed to the corner, because it
   governs the whole panel rather than anything in particular in it. */
.switch {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  margin-left: auto;
  padding: 5px 6px 5px 12px;
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  background: var(--surface-2);
  color: var(--text-muted);
  cursor: pointer;
  font: inherit;
  font-size: var(--fs-base);
  transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
}
.switch:hover { color: var(--text-secondary); }
.switch.is-on { color: var(--text-primary); }
.switch-name { font-weight: 600; letter-spacing: 0.01em; }
.switch-track {
  position: relative;
  width: 48px;
  height: 26px;
  flex: none;
  border-radius: 13px;
  background: var(--surface-inset);
  border: 1px solid var(--line-strong);
  transition: background-color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
}
.switch-track i {
  position: absolute;
  top: 3px;
  left: 3px;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--text-muted);
  transition: transform var(--t-fast) var(--ease), background-color var(--t-fast) var(--ease);
}
.switch.is-on .switch-track {
  background: var(--accent-quiet);
  border-color: var(--accent-line);
}
.switch.is-on .switch-track i {
  background: var(--accent-bright);
  transform: translateX(22px);
}
.report-tab {
  flex: 1 1 0;
  padding: 6px 0;
  border: 0;
  border-radius: calc(var(--r-sm) - 2px);
  background: transparent;
  color: var(--text-muted);
  font: inherit;
  font-size: var(--fs-base);
  cursor: pointer;
}
.report-tab:hover { color: var(--text-secondary); }
.report-tab.is-active { background: var(--surface-1); color: var(--text-primary); font-weight: 600; }

.report { display: flex; flex-direction: column; gap: 14px; }
.report.hidden { display: none; }
/* The sentence before the figures: what happened, with the costly moves as
   links onto the board. */
.report-summary {
  margin: 0;
  font-size: var(--fs-md);
  line-height: 1.5;
  color: var(--text-secondary);
}
.report-summary b { color: var(--text-primary); font-variant-numeric: tabular-nums; }
.report-summary .hint-link { color: var(--accent-bright); text-decoration-style: dotted; }
.report-sides { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.report-side {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  padding: 12px 8px;
  border-radius: var(--r-md);
  background: var(--surface-2);
  --tone: var(--text-muted);
}
.report-side.tone-high { --tone: var(--series-accuracy); }
.report-side.tone-mid { --tone: var(--status-warning); }
.report-side.tone-low { --tone: var(--status-critical); }
.report-side-name { font-size: var(--fs-sm); color: var(--text-muted); }
.report-accuracy { font-size: var(--fs-xl); font-weight: 700; color: var(--tone); font-variant-numeric: tabular-nums; }
.report-meter {
  width: 100%;
  height: 4px;
  margin: 4px 0 2px;
  border-radius: var(--r-full);
  background: var(--line-strong);
  overflow: hidden;
}
.report-meter i { display: block; height: 100%; background: var(--tone); border-radius: var(--r-full); }
.report-rating {
  margin-top: 8px;
  font-size: var(--fs-lg);
  font-weight: 700;
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}
.report-range {
  display: block;
  font-size: var(--fs-xs);
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
}
.report-caption { font-size: var(--fs-xs); color: var(--text-muted); text-align: center; }

/* One row per label encountered: White then Black, as on a game sheet. */
.report-counts { display: flex; flex-direction: column; gap: 1px; }
.report-count {
  display: grid;
  grid-template-columns: 26px 1fr 34px 34px;
  align-items: center;
  gap: 6px;
  padding: 4px 6px;
  border-radius: var(--r-sm);
  font-size: var(--fs-base);
  color: var(--text-secondary);
}
.report-count:hover { background: var(--surface-2); }
.report-glyph { font-weight: 700; text-align: center; }
.report-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.report-count b { text-align: center; font-weight: 600; color: var(--text-primary); font-variant-numeric: tabular-nums; }
.report-count.tone-bad b { color: var(--status-critical); }
.report-count.tone-warn b { color: var(--status-warning); }

/* ---- Advantage graph ----
   Below the board, in the same colours as the evaluation bar: the light share is
   White's. The footer goes to a column when it carries it. */
.advantage {
  height: 48px;
  border-radius: var(--r-sm);
  overflow: hidden;
  cursor: pointer;
  touch-action: none;
  box-shadow: inset 0 0 0 1px var(--line);
}
/* The live analysis: the same graph, but alone on screen and therefore much
   taller. It is the subject of the page, not a frieze under a board. */
.live-chart { height: min(38vh, 240px); cursor: default; }
.live-chart .advantage-chart { height: 100%; }
.live .context { margin-bottom: 12px; }
.game-accuracy-empty { color: var(--text-muted); }

.advantage-chart { display: block; width: 100%; height: 48px; }
.advantage-ground { fill: var(--side-black); }
.advantage-area { fill: var(--side-white); }
.advantage-even { stroke: var(--line-strong); stroke-width: 1; stroke-dasharray: 6 6; }
.advantage-mark { stroke-width: 3; opacity: 0.75; }
.advantage-cursor { stroke: var(--series-time); stroke-width: 3; }
.move-detail b { color: var(--tone, var(--text-primary)); }

/* Evaluation bar: White's winning chances, to the left of the board. */
.eval-bar {
  position: relative;
  flex: 0 0 28px;
  width: 28px;
  border-radius: var(--r-sm);
  overflow: hidden;
  background: var(--side-black);
  box-shadow: inset 0 0 0 1px var(--line);
}
.eval-bar i {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 50%;
  background: var(--side-white);
  transition: height var(--t-base) var(--ease);
}
.eval-bar.flipped i {
  bottom: auto;
  top: 0;
}
.eval-bar span {
  position: absolute;
  left: 0;
  right: 0;
  font-size: var(--fs-xs);
  font-weight: 700;
  letter-spacing: 0;
  font-variant-numeric: tabular-nums;
  text-align: center;
}
/* The label sits in the majority half: white side if >= 50, black side otherwise. */
.eval-bar span.on-white { bottom: 4px; color: var(--side-black); }
.eval-bar span:not(.on-white) { top: 4px; color: var(--side-white); }
.eval-bar.flipped span.on-white { bottom: auto; top: 4px; }
.eval-bar.flipped span:not(.on-white) { top: auto; bottom: 4px; }
/* ---- Game list (Review) ----
   A single card: the header, a summary of the games loaded, then one row per game —
   the final position, both sides, the score, the accuracy as a gauge, and what the
   analysis says about it. The list breaks out of the card's padding to touch both
   its edges: a hovered row becomes a full band instead of a floating rectangle, and
   the summary's rule aligns on the frame. Moves and date are columns when wide and
   move back into the row's footer when the window tightens. */

.games {
  margin: var(--s6) calc(var(--s5) * -1) calc(var(--s5) * -1);
  border-top: 1px solid var(--line);
}
.games.hidden { display: none; }

/* The username, on the page it serves: a reminder under the title once set,
   a form in the card's body while it is not. */
.games-account { margin: 4px 0 0; }
.games-account b { color: var(--text-primary); }
.account-form { margin-top: var(--s4); }
.account-form .context { margin: 0 0 10px; color: var(--text-secondary); }
.account-row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.account-row input[type="text"] {
  flex: 1 1 220px;
  max-width: 320px;
  font: inherit;
  padding: 9px 12px;
  background: var(--surface-inset);
  color: var(--text-primary);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-sm);
}
.inline-field-cap { color: var(--text-muted); font-size: var(--fs-xs); }
/* The label stays on one line; it is the actions row that wraps on a phone. */
.games-card .inline-field { white-space: nowrap; }

/* Filters and order: one row of selects above the tally. Nothing is fetched
   on a change, the rows on screen are the ones being sifted. */
.games-filters {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  padding: var(--s3) var(--s5);
  border-bottom: 1px solid var(--line);
}

/* Column names for the figures that read as bare numbers ("—", "17"). Same
   grid as the rows, so the names sit over what they name. */
.game-head {
  display: grid;
  grid-template-columns: 68px 104px minmax(200px, 1fr) 68px 112px 52px 116px auto;
  column-gap: var(--s5);
  padding: 8px var(--s5) 6px;
  border-bottom: 1px solid var(--line-faint);
  font-family: var(--font-display);
  font-size: var(--fs-2xs);
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
}
.game-head span:nth-child(4) { text-align: center; }
.game-head span:nth-child(6), .game-head span:nth-child(7) { text-align: right; }

.games-more { padding: var(--s4) var(--s5); text-align: center; }
.games-more .btn.hidden { display: none; }
.games-empty { margin: 0; padding: var(--s5); color: var(--text-muted); text-align: center; }

.games-tally {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 12px 28px;
  padding: var(--s4) var(--s5);
  border-bottom: 1px solid var(--line);
  font-size: var(--fs-base);
  color: var(--text-secondary);
}
.games-tally:empty { display: none; }
.tally-record { display: inline-flex; align-items: baseline; gap: 6px; }
.tally-record b { font-size: var(--fs-xl); font-weight: 700; letter-spacing: 0; color: var(--text-primary); }
.tally-marks { display: inline-flex; gap: 8px; }
.tally-mark {
  display: inline-flex; align-items: baseline; gap: 5px;
  padding: 3px 11px; border-radius: var(--r-full);
  background: var(--surface-2); font-size: var(--fs-base); font-variant-numeric: tabular-nums;
}
.tally-mark b { font-weight: 700; color: var(--text-primary); }
.tally-mark i { font-style: normal; font-size: var(--fs-xs); letter-spacing: 0.01em; color: var(--text-muted); }
.tally-mark.tone-win b { color: var(--status-good); }
.tally-mark.tone-loss b { color: var(--status-critical); }
.tally-cadences { display: inline-flex; flex-wrap: wrap; gap: 8px; }
.tally-accuracy { margin-left: auto; }
.tally-accuracy b { color: var(--text-primary); font-weight: 700; font-variant-numeric: tabular-nums; }
.tally-sub { color: var(--text-muted); }
.tally-busy { color: var(--accent-bright); font-weight: 600; }

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

/* The row is the unit of reading: air above and below, tight groups inside each
   cell, and everything on the same optical line. */
.game-row {
  display: grid;
  grid-template-columns: 68px 104px minmax(200px, 1fr) 68px 112px 52px 116px auto;
  column-gap: var(--s5);
  row-gap: 6px;
  align-items: center;
  padding: var(--s4) var(--s5);
  border-bottom: 1px solid var(--line-faint);
  transition: background-color var(--t-fast) var(--ease);
}
.game-row:last-child {
  border-bottom: 0;
  border-radius: 0 0 calc(var(--r-lg) - 1px) calc(var(--r-lg) - 1px);
}
@media (hover: hover) {
  .game-row:hover { background: var(--surface-2); }
}

.game-preview { grid-row: 1 / span 2; width: 68px; }
.mini-preview {
  width: 100%; aspect-ratio: 1; border-radius: var(--r-xs); overflow: hidden;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
}

.game-cadence {
  display: flex; align-items: center; gap: 8px;
  color: var(--text-secondary); font-size: var(--fs-base); white-space: nowrap;
}
.game-cadence-icon { display: inline-flex; color: var(--text-muted); }
.game-cadence-icon svg { width: 17px; height: 17px; }

.game-players { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.game-player {
  display: flex; align-items: center; gap: 8px; min-width: 0;
  font-size: var(--fs-md); line-height: 1.4; color: var(--text-secondary);
}
.game-player.won .game-name { color: var(--text-primary); font-weight: 600; }
.game-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.game-elo { font-size: var(--fs-sm); color: var(--text-muted); font-variant-numeric: tabular-nums; }
.game-side { width: 10px; height: 10px; border-radius: var(--r-xs); flex: 0 0 auto; }
/* Side badge: the same reading everywhere in the app, instead of a piece glyph
   borrowed from the system font. */
.side-dot {
  display: inline-block;
  width: 9px;
  height: 9px;
  margin-right: var(--s2);
  border-radius: var(--r-xs);
  vertical-align: 0.06em;
}
.side-dot.side-white {
  background: var(--side-white);
}
.side-dot.side-black {
  background: var(--side-black-piece);
  box-shadow: inset 0 0 0 1px var(--text-muted);
}

.game-side.white { background: var(--side-white); }
.game-side.black { background: var(--side-black-piece); box-shadow: inset 0 0 0 1px var(--text-muted); }
.game-delta { font-size: var(--fs-sm); font-weight: 600; font-variant-numeric: tabular-nums; }
.game-delta.up { color: var(--series-accuracy); }
.game-delta.down { color: var(--status-critical); }
.game-delta.flat { color: var(--text-muted); }

.game-score { display: flex; align-items: center; justify-content: center; gap: 10px; }
.game-points {
  display: flex; flex-direction: column; gap: 3px;
  font-size: var(--fs-md); line-height: 1.35; font-weight: 600;
  color: var(--text-secondary); font-variant-numeric: tabular-nums;
}
.game-outcome {
  display: inline-flex; align-items: center; justify-content: center;
  width: 19px; height: 19px; border-radius: var(--r-xs);
  font-size: var(--fs-base); font-weight: 700; line-height: 1; color: var(--text-on-status);
}
.game-outcome.tone-win { background: var(--status-good); }
.game-outcome.tone-loss { background: var(--status-critical); }
.game-outcome.tone-draw { background: var(--text-muted); }
.game-outcome.tone-unknown { background: var(--surface-2); color: var(--text-muted); }

/* Accuracy: the figure and a gauge, tinted according to the band. */
.game-accuracy { display: flex; flex-direction: column; gap: 6px; font-size: var(--fs-md); --tone: var(--text-muted); }
.game-accuracy.tone-high { --tone: var(--series-accuracy); }
.game-accuracy.tone-mid { --tone: var(--status-warning); }
.game-accuracy.tone-low { --tone: var(--status-critical); }
.game-accuracy-value { color: var(--tone); font-weight: 600; font-variant-numeric: tabular-nums; }
.game-accuracy-meter {
  display: block; width: 92px; height: 5px; border-radius: var(--r-full);
  background: var(--surface-2); overflow: hidden;
}
.game-accuracy-meter i { display: block; height: 100%; background: var(--tone); border-radius: var(--r-full); }
.game-accuracy-busy { color: var(--accent-bright); font-size: var(--fs-sm); font-style: italic; }

.game-moves, .game-date {
  font-size: var(--fs-base); color: var(--text-muted);
  font-variant-numeric: tabular-nums; white-space: nowrap;
}
.game-moves { text-align: right; }
.game-date { text-align: right; }

.game-actions { display: flex; gap: 8px; justify-content: flex-end; justify-self: end; }
.game-actions .btn { padding: 0 14px; height: 32px; font-size: var(--fs-base); border-radius: var(--r-sm); }

/* The footer keeps its height even when empty: without it, a game with no known
   opening would make a shorter row than its neighbours. */
.game-footer {
  grid-column: 2 / -1;
  display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
  min-height: 18px;
  font-size: var(--fs-sm); color: var(--text-muted);
}
.game-footer > span:first-child { color: var(--text-secondary); }
.game-dot { color: var(--baseline); }
/* Duplicates of the moves and date columns: they only appear if those go away. */
.game-narrow { display: none; }

@media (max-width: 1060px) {
  .game-row, .game-head { grid-template-columns: 68px 104px minmax(160px, 1fr) 68px 112px auto; }
  .game-moves, .game-date, .game-head span:nth-child(6), .game-head span:nth-child(7) { display: none; }
  .game-narrow { display: inline; }
}

/* A phone: the card gets compact — a smaller preview, tighter rows, the
   footer on one line — twenty games used to be 5 700 px of scroll. A phone on
   its side too (the landscape clause of MOBILE_QUERY, board-size.js): the
   six-column row of the 1060 rule is 830 px on an 812 px screen, and every
   "Review" button was cut off at the right edge. */
@media (max-width: 760px), (max-height: 500px) and (max-width: 900px) {
  .game-head { display: none; }
  /* The field and its two buttons do not fit one row of a phone: the row takes
     the card's whole width (it would not shrink otherwise) and wraps inside. */
  .games-card .card-head-actions { flex: 1 1 100%; flex-wrap: wrap; }
  .game-row {
    grid-template-columns: 48px minmax(0, 1fr) auto;
    column-gap: var(--s3);
    row-gap: 6px;
    padding: 10px var(--s4);
  }
  .game-preview { grid-row: 1 / span 3; width: 48px; align-self: start; }
  .game-player { font-size: var(--fs-base); line-height: 1.3; gap: 6px; }
  .game-footer {
    display: block;
    min-height: 0;
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
    font-size: var(--fs-xs);
  }
  .game-footer > span { display: inline; }
  .game-footer .game-narrow { display: inline; }
  /* The flex gap is gone with the flex: the dots carry the air themselves. */
  .game-footer .game-dot { margin: 0 5px; }
  .games-filters { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; }
  .games-filters .select-inline { flex: none; min-height: 44px; }
  .game-cadence { grid-column: 2; grid-row: 1; }
  .game-score { grid-column: 3; grid-row: 1; justify-content: flex-end; }
  .game-players { grid-column: 2 / -1; grid-row: 2; }
  .game-accuracy { grid-column: 2; grid-row: 3; }
  .game-actions { grid-column: 3; grid-row: 3; }
  .game-footer { grid-column: 1 / -1; }
  .games-tally { padding: var(--s4); }
  .tally-accuracy { margin-left: 0; }
}
/* .review-view .row (0,2,0) would beat .hidden (0,1,0): we neutralise it here. */
.review-view .row.hidden { display: none; }

/* ---------- Cross-cutting fluidity ---------- */

@keyframes view-enter {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}
.view-enter {
  animation: view-enter var(--t-base) var(--ease);
}
/* ---- card headers, stat banners and segmented controls ----
   Title on the left, action on the right on the same line: the page no longer sends
   the button down below a paragraph of explanation. */
.card-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 20px;
  flex-wrap: wrap;
}
.card-head-text { min-width: 0; }
.card-head-text .hint { max-width: 62ch; }
/* On My games, the header carries the page on its own: without a paragraph under
   the title, it gains the next size step up. */
.games-card .card-head { align-items: center; }
.games-card .card-head h2 {
  font-size: var(--fs-2xl);
  line-height: 1.15;
  letter-spacing: -0.01em;
}
.card-head-actions {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-shrink: 0;
}
/* Same specificity as .hidden and later in the file: without this the row
   could not be hidden (My games, while there is no username to load for). */
.card-head-actions.hidden { display: none; }
.card-foot:empty { display: none; }
.card-foot .hint:empty, .card-foot .error:empty { display: none; }
.card-foot > * + *:not(.hidden) { margin-top: 4px; }

/* Import of a PGN. The field is monospaced: a PGN is aligned text, and one reads
   it to check the paste landed whole, not to enjoy it. */
.pgn-input {
  width: 100%;
  margin-top: 16px;
  padding: 12px 14px;
  background: var(--surface-inset);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-sm);
  color: var(--text-primary);
  font-family: var(--font-mono);
  font-size: var(--fs-sm);
  line-height: 1.5;
  resize: vertical;
}
.import-color { margin-top: 16px; }
/* The actions sit on one line rather than stacking: this footer holds buttons,
   not the messages the generic rule is spacing out. */
.import-actions {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 10px;
  margin-top: 20px;
}
.import-actions > * + *:not(.hidden) { margin-top: 0; }
.import-actions label.btn { cursor: pointer; }

.select-inline {
  background: var(--surface-inset);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-sm);
  color: var(--text-primary);
  font: inherit;
  font-size: var(--fs-base);
  padding: 7px 10px;
}

/* A two-state toggle: a single block, not two buttons floating apart. */
.segmented {
  display: inline-flex;
  padding: 2px;
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  background: var(--surface-inset);
}
.segment {
  border: 0;
  border-radius: calc(var(--r-sm) - 2px);
  background: transparent;
  color: var(--text-muted);
  cursor: pointer;
  font: inherit;
  font-size: var(--fs-base);
  padding: 5px 13px;
  transition: background-color var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
}
.segment:hover:not(:disabled) { color: var(--text-secondary); }
.segment.active {
  background: var(--surface-3);
  color: var(--text-primary);
  font-weight: 600;
  box-shadow: var(--shadow-1);
}
.segment:disabled { opacity: 0.4; cursor: not-allowed; }


/* ---------- Config page ----------
   The sections stacked, one under the other: the page is long by nature, but the
   navigation column carries its anchors, so one never has to walk it to find a
   setting. */
/* One column: the rail of anchors moved into the navigation column, where every
   section of every other view now lives too. */
.config-view {
  display: flex;
  flex-direction: column;
}
.config-sections {
  display: flex;
  flex-direction: column;
  gap: 40px;
}
/* A section is not a card: it contains cards. */
.config-section {
  /* The 88 here cleared a sticky topbar. There is none any more: what a section
     has to clear is the page's own gutter. */
  scroll-margin-top: var(--s5);
  border-radius: var(--r-md);
}
.config-section-head { margin-bottom: 14px; }
.config-section-head .hint { max-width: 68ch; }
.config-section .card { margin-bottom: 0; }
.config-section .repertoire-grid { margin: 0; }

/* A link to a section makes it flash once: otherwise one cannot see where the
   scrolling stopped in the middle of a uniform page. Config and Profile both use
   it — hence the generic name. */
@keyframes section-flash {
  from { box-shadow: 0 0 0 2px var(--accent); }
  to { box-shadow: 0 0 0 2px transparent; }
}
.config-section.flash { animation: section-flash 1.1s ease-out; }

.settings-block { display: flex; flex-direction: column; gap: 14px; }
.settings-block .field { margin-bottom: 0; }
.settings-block .hint { max-width: 68ch; }
.field-full { grid-column: 1 / -1; }
/* A short field placed next to a button, in a card's header. `row`, because the
   generic label above stacks its text over its input: this one reads as a
   sentence, "Games (max 100) [20]". */
.inline-field {
  display: inline-flex;
  flex-direction: row;
  align-items: center;
  gap: 8px;
  font-size: var(--fs-base);
  color: var(--text-muted);
}
.inline-field input { width: 72px; }

/* ---------- The measures ----------
   Clock, accuracy, scores, tile values: Nunito at 800, its heaviest weight. Fredoka
   has no fixed-pitch figures and ignores `tnum` — its "1" is a third narrower than
   its "0", so a counting clock starts to dance in it and a column of scores stops
   being comparable. Nunito measures all ten of its figures at the same width. When
   the typeface and the tabular-figure rule conflict, it is the rule that wins. This
   block lives here, after the views, so as to beat the weights each one declared for
   itself. */
.timer,
.stat b,
.run-hero-figure,
.run-hero-aside,
.run-score,
.run-record,
.run-chain-hidden,
.tile-value,
.cycle-done-time,
.meter .meter-value,
.review-score,
.report-accuracy,
.tally-record b,
.ledger .stat-value {
  font-family: var(--font);
  font-weight: 800;
  font-variant-numeric: tabular-nums;
}

/* ---------- The gesture ----------
   A single motion, applied everywhere: what is pressed rises 2 px under the cursor
   and squashes to 0.96 under the finger. The release slightly overshoots its target
   (--ease-spring), which gives the theme its plastic material. A single rule carries
   the gesture for the whole app: that is what makes it coherent rather than a
   collection of screens each animating in its own way. */
.btn,
.chip,
.tab,
.move-chip,
.tree-toggle,
.segment,
.set-switch,
.set-menu-item {
  transition: background-color var(--t-fast) var(--ease),
    border-color var(--t-fast) var(--ease), color var(--t-fast) var(--ease),
    box-shadow var(--t-base) var(--ease),
    transform var(--t-base) var(--ease-spring);
}
.chip:hover:not(:disabled),
.tab:hover:not(:disabled),
.move-chip:hover:not(:disabled),
.tree-toggle:hover:not(:disabled),
.segment:hover:not(:disabled),
.set-switch:hover:not(:disabled) {
  transform: translateY(-2px);
}
.btn:active:not(:disabled),
.chip:active,
.icon-btn:active,
.tab:active:not(:disabled),
.move-chip:active,
.tree-toggle:active,
.set-switch:active,
.segment:active:not(:disabled) {
  transform: translateY(0) scale(0.96);
  box-shadow: var(--shadow-press);
  transition-duration: var(--t-fast);
}

/* Every view arrives rising 10 px. It is the app's only entrance animation: the
   content settles, it does not appear. */
@keyframes view-enter {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
}
.outlet > * {
  animation: view-enter var(--t-slow) var(--ease) both;
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 1ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 1ms !important;
    scroll-behavior: auto !important;
  }
  /* Motion that is suppressed must really be: without this the hover and press
     states keep their offset, played over 1 ms — hence a jump. */
  .btn,
  .chip,
  .tab,
  .move-chip,
  .tree-toggle,
  .segment,
  .set-switch,
  .icon-btn,
  .nav-item .icon {
    transform: none !important;
  }
  /* The GO grows on hover in a gate that is about to be clicked: with motion
     suppressed, it must stay still, not jump in 1 ms. */
  .run-gate:hover .run-go,
  .run-gate:active .run-go,
  .run-countdown-digit {
    transform: none !important;
  }
  /* The rings, the settling pips, the beating clock: everything a run adds to say
     "well done" or "hurry up" stops saying it in movement. */
  .run-go-ring {
    display: none;
  }
  .chain-pip,
  .run-score,
  .run-hero-figure,
  .run-lives {
    animation: none !important;
    transform: none !important;
  }
}

/* ---------- Profile ----------
   Verdict, evidence, action: the banner situates, the diagnosis decides, the rest
   verifies. Nothing here invents a colour — the bands reuse the state tokens, the
   curve reuses --series-time. */

.profile-view {
  display: flex;
  flex-direction: column;
  gap: 18px;
}

.profile-card {
  padding: 18px 20px;
}

.profile-card-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
  margin-bottom: 14px;
}

.profile-note {
  font-size: var(--fs-sm);
  color: var(--text-muted);
}

.profile-subhead {
  margin: 20px 0 10px;
  font-size: var(--fs-base);
}

/* ---- identity strip ----
   One line: who, the streak, the window, the refresh. The old header's prose
   lives on the avatar's title attribute — a fact to consult, not to reread. */

.profile-id {
  display: flex;
  align-items: center;
  gap: 12px;
  flex-wrap: wrap;
  padding: 2px 2px 0;
}

.profile-id-name {
  font-size: var(--fs-lg);
  min-width: 0;
}

.profile-id-gap {
  flex: 1;
}

.profile-avatar {
  width: 40px;
  height: 40px;
  flex: none;
  border-radius: var(--r-md);
  background: var(--surface-2);
  border: 1px solid var(--border);
  display: grid;
  place-items: center;
  font-size: var(--fs-lg);
  color: var(--text-secondary);
  object-fit: cover;
  cursor: help;
}

.profile-streak {
  font-size: var(--fs-sm);
  font-weight: 600;
  padding: 3px 9px;
  border-radius: var(--r-full);
  background: color-mix(in srgb, var(--status-warning) 16%, transparent);
  color: var(--status-warning);
}

.profile-error {
  margin: 0;
  font-size: var(--fs-sm);
  color: var(--status-critical);
}

.profile-seg {
  display: flex;
  gap: 2px;
  padding: 3px;
  border-radius: var(--r-sm);
  background: var(--surface-2);
}

.profile-seg button {
  border: none;
  background: none;
  font: inherit;
  font-size: var(--fs-sm);
  padding: 4px 11px;
  border-radius: calc(var(--r-sm) - 2px);
  color: var(--text-muted);
  cursor: pointer;
}

.profile-seg button:hover {
  color: var(--text-secondary);
}

.profile-seg button.on {
  background: var(--grid);
  color: var(--text-primary);
}

.profile-refresh {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 1px;
  padding: 6px 12px;
  border-radius: var(--r-sm);
  border: 1px solid var(--border);
  background: none;
  color: var(--text-secondary);
  font: inherit;
  font-size: var(--fs-sm);
  line-height: 1.2;
  cursor: pointer;
}

.profile-refresh:hover:not(:disabled) {
  border-color: var(--text-muted);
  color: var(--text-primary);
}

.profile-refresh:disabled {
  cursor: progress;
  opacity: 0.7;
}

.profile-refresh small {
  font-size: var(--fs-2xs);
  color: var(--text-muted);
}

/* ---- cockpit ----
   The hero and its two side tiles: the level held, the method's promise, the
   work's constancy. Everything below the fold is detail, opened on demand. */

.profile-cockpit {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 300px;
  gap: 18px;
  align-items: stretch;
}

@media (max-width: 1100px) {
  .profile-cockpit {
    grid-template-columns: minmax(0, 1fr);
  }
}

.profile-side {
  display: grid;
  grid-template-rows: 1fr 1fr;
  gap: 18px;
  min-width: 0;
}

.profile-tilecard {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 6px;
  padding: 16px 18px;
  border: 1px solid var(--border);
  background: var(--surface-1);
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
}

.profile-tilecard:hover {
  border-color: var(--text-muted);
}

.profile-tile-label {
  font-size: var(--fs-2xs);
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-muted);
  display: flex;
  gap: 8px;
  align-items: baseline;
  max-width: 100%;
}

.profile-tile-label em {
  font-style: normal;
  text-transform: none;
  letter-spacing: 0;
  font-weight: 600;
  color: var(--text-secondary);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.profile-tile-value {
  font-size: var(--fs-xl);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  line-height: 1.1;
}

.profile-tile-value small {
  font-size: var(--fs-xs);
  font-weight: 500;
  color: var(--text-muted);
}

.profile-minical {
  width: 100%;
  max-width: 240px;
  margin-top: auto;
}

.profile-delta {
  font-size: var(--fs-sm);
  font-weight: 600;
  white-space: nowrap;
}

.profile-delta.up {
  color: var(--status-good);
}

.profile-delta.down {
  color: var(--status-critical);
}

/* ---- diagnosis ---- */

.profile-lens {
  display: grid;
  grid-template-columns: 300px minmax(0, 1fr);
  gap: 20px;
  align-items: start;
}

.profile-radar {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
}

.profile-radar svg {
  width: 100%;
  max-width: 280px;
  height: auto;
  overflow: visible;
}

.profile-radar-label {
  fill: var(--text-muted);
  font-size: var(--fs-2xs);
}

.profile-radar-legend {
  display: flex;
  gap: 16px;
  margin: 0;
  font-size: var(--fs-xs);
  color: var(--text-muted);
}

.profile-radar-legend i {
  display: inline-block;
  width: 14px;
  height: 3px;
  border-radius: var(--r-full);
  vertical-align: middle;
  margin-right: 6px;
}

.profile-radar-legend i.dashed {
  height: 0;
  border-top: 2px dashed var(--text-muted);
}

.profile-dims {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 10px;
}

.profile-dim {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 12px 13px;
  border-radius: var(--r-md);
  border: 1px solid var(--border);
  background: var(--surface-2);
  font: inherit;
  color: inherit;
  text-align: left;
}

.profile-dim:hover {
  border-color: var(--text-muted);
}

.profile-dim.open {
  border-color: var(--series-time);
}

/* A single axis carries the label: seven alerts cannot be ranked. */
.profile-dim.flag {
  border-color: color-mix(in srgb, var(--status-critical) 55%, transparent);
  background: color-mix(in srgb, var(--status-critical) 9%, transparent);
}

.profile-dim-tag {
  position: absolute;
  top: -8px;
  right: 8px;
  padding: 2px 7px;
  border-radius: var(--r-full);
  background: var(--status-critical);
  color: var(--text-on-status);
  font-size: var(--fs-2xs);
  font-weight: 700;
  letter-spacing: 0.01em;
}

.profile-dim-name {
  font-size: var(--fs-sm);
  font-weight: 600;
  color: var(--text-secondary);
}

.profile-dim-row {
  display: flex;
  align-items: baseline;
  gap: 10px;
}

.profile-dim-score {
  display: block;
  font-size: var(--fs-xl);
  font-weight: 700;
  letter-spacing: 0;
  line-height: 1.1;
  font-variant-numeric: tabular-nums;
}

/* The tile's picture is the axis over time; a hole in the data is a hole in
   the line, and repertoire — which has no honest past — says so in words. */
.profile-dim-trend,
.profile-dim-none {
  display: flex;
  align-items: center;
  min-height: 26px;
}

.profile-dim-spark {
  width: 100%;
  max-width: 120px;
  height: 26px;
}

.profile-dim-none {
  font-size: var(--fs-2xs);
  color: var(--text-muted);
}

/* ---- detail panels ---- */

.profile-detail-head {
  display: flex;
  align-items: baseline;
  gap: 10px;
  margin-bottom: 14px;
}

.profile-crumb {
  font-size: var(--fs-2xs);
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.profile-kpis {
  display: flex;
  flex-wrap: wrap;
  gap: 10px 28px;
  margin-bottom: 14px;
}

.profile-kpi b {
  display: block;
  font-size: var(--fs-xl);
  font-weight: 700;
  line-height: 1.1;
  font-variant-numeric: tabular-nums;
}

.profile-kpi b em {
  font-style: normal;
  font-size: var(--fs-sm);
  color: var(--text-secondary);
}

.profile-kpi small {
  font-size: var(--fs-2xs);
  letter-spacing: 0.01em;
  color: var(--text-muted);
}

/* Labelled horizontal bars: what the four tables became. */
.profile-bars {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-top: 10px;
}

.profile-bars-narrow {
  max-width: 460px;
}

.profile-bar {
  display: grid;
  grid-template-columns: minmax(150px, 240px) minmax(0, 1fr) minmax(120px, auto);
  gap: 12px;
  align-items: center;
  font-size: var(--fs-sm);
}

.profile-bar .track {
  height: 9px;
}

.profile-bar .track i.warn {
  background: var(--status-warning);
}

.profile-bar .track i.crit {
  background: var(--status-critical);
}

.profile-bar-label {
  color: var(--text-secondary);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.profile-bar-label .profile-note {
  font-size: var(--fs-2xs);
}

.profile-bar-value {
  text-align: right;
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.profile-bar-value b {
  color: var(--text-primary);
}

.profile-bars-legend {
  display: flex;
  gap: 14px;
  margin: 0 0 2px;
}

.profile-bars-legend i {
  display: inline-block;
  width: 10px;
  height: 10px;
  border-radius: 3px;
  vertical-align: middle;
  margin-right: 5px;
}

.profile-panel-curve {
  position: relative;
  max-width: 620px;
  margin-top: 6px;
}

.profile-panel-curve .chart {
  height: 170px;
}

.profile-curve-note {
  fill: var(--text-muted);
  font-size: var(--fs-2xs);
}

.profile-panel-act {
  margin: 18px 0 0;
}

.profile-stack {
  display: flex;
  height: 10px;
  border-radius: var(--r-xs);
  overflow: hidden;
  background: var(--surface-2);
  margin-top: 14px;
}

.profile-stack i {
  height: 100%;
}

.profile-paces,
.profile-quality,
.profile-recent {
  list-style: none;
  margin: 14px 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
  font-size: var(--fs-base);
}

.profile-paces li {
  display: grid;
  grid-template-columns: 90px minmax(0, 1fr) 48px;
  gap: 12px;
  align-items: center;
}

.profile-paces .profile-stack {
  margin-top: 0;
}

.profile-paces b,
.profile-quality b {
  text-align: right;
  font-variant-numeric: tabular-nums;
}

.profile-quality li {
  display: grid;
  grid-template-columns: minmax(0, 140px) minmax(0, 1fr) 44px;
  gap: 12px;
  align-items: center;
  color: var(--text-secondary);
}

.profile-view .track {
  height: 6px;
  border-radius: var(--r-full);
  background: var(--grid);
  overflow: hidden;
  min-width: 60px;
}

.profile-view .track > i {
  display: block;
  height: 100%;
  border-radius: var(--r-full);
  background: var(--series-time);
}

.profile-quality .quality-blunder .track > i {
  background: var(--status-critical);
}

.profile-quality .quality-mistake .track > i {
  background: var(--status-warning);
}

.profile-quality .quality-brilliant .track > i,
.profile-quality .quality-best .track > i {
  background: var(--series-accuracy);
}



.profile-axis {
  position: absolute;
  left: 0;
  font-size: var(--fs-2xs);
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
}

.profile-axis.top {
  top: 4px;
}

.profile-axis.bottom {
  bottom: 4px;
}

/* ---- tables ---- */

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

.profile-table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--fs-base);
}

.profile-table th {
  text-align: left;
  font-weight: 500;
  font-size: var(--fs-2xs);
  letter-spacing: 0.01em;
  color: var(--text-muted);
  padding: 0 10px 8px 0;
  border-bottom: 1px solid var(--border);
  white-space: nowrap;
}

.profile-table td {
  padding: 8px 10px 8px 0;
  border-bottom: 1px solid var(--line-faint);
  font-variant-numeric: tabular-nums;
  color: var(--text-secondary);
}

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

.profile-table td.name {
  color: var(--text-primary);
}

.profile-table td.muted {
  color: var(--text-muted);
}

.profile-table .num {
  text-align: right;
}


.profile-pill {
  display: inline-block;
  padding: 2px 8px;
  border-radius: var(--r-full);
  font-size: var(--fs-xs);
  font-variant-numeric: tabular-nums;
}

.profile-pill.g {
  color: var(--status-good);
  background: color-mix(in srgb, var(--status-good) 18%, transparent);
}

.profile-pill.w {
  color: var(--status-warning);
  background: color-mix(in srgb, var(--status-warning) 16%, transparent);
}

.profile-pill.c {
  color: var(--status-critical);
  background: color-mix(in srgb, var(--status-critical) 16%, transparent);
}

.profile-dot {
  display: inline-block;
  width: 9px;
  height: 9px;
  border-radius: 50%;
  margin-right: 7px;
  vertical-align: middle;
  border: 1px solid var(--border);
}

.profile-dot.white {
  background: var(--square-light);
}

.profile-dot.black {
  background: var(--square-dark);
}

/* ---- positions to review ---- */

.profile-weak {
  list-style: none;
  margin: 0 0 14px;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
  gap: 14px;
}

.profile-weak li {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.profile-mini {
  width: 100%;
  aspect-ratio: 1;
}

.profile-mini-line {
  margin: 0;
  font-size: var(--fs-sm);
  color: var(--text-secondary);
  word-break: break-word;
}

.profile-weak .profile-note {
  display: block;
}

/* ---- progress of a set ---- */

/* Progress used to have its own tab; it has become a section of the Profile. Its
   content is that of the former page — tiles, graphs, tables — and its blocks keep
   their `card` class: `.card .card` already flattens them, so they lie one under the
   other inside the Profile's card without adding a second frame to it. Nothing to
   restyle, only to place in the right spot. */
.profile-progress-title .hint {
  max-width: 56ch;
}

/* The moved blocks had h3 titles, like the Profile's sections: "Total time per
   cycle" read as high as the "Progress" that contains it. One step below gives the
   hierarchy back to the section's title. */
.profile-progress-body h3 {
  font-size: var(--fs-base);
  color: var(--text-secondary);
}

/* The selector does not get crushed when a set name is long, and does not push the
   card beyond its width when it is too long. */
.profile-progress .select-inline {
  max-width: min(320px, 100%);
}

.profile-progress-body > :last-child {
  margin-bottom: 0;
}

/* ---- activity ---- */

.profile-recent li {
  display: grid;
  grid-template-columns: 110px minmax(0, 1fr) 44px;
  gap: 12px;
  align-items: center;
  color: var(--text-secondary);
}

.profile-recent b {
  justify-self: end;
}

@media (max-width: 900px) {
  .profile-lens {
    grid-template-columns: 1fr;
  }
  .profile-rule {
    display: none;
  }
  .profile-head-controls {
    margin-left: 0;
  }
}

/* ---- explanation on hover ----
   What the score means, where it comes from, how it is graded. On hover only:
   displayed permanently, it would drown the grid. */

.profile-help {
  position: absolute;
  top: calc(100% + 8px);
  left: 50%;
  transform: translateX(-50%) translateY(-4px);
  z-index: 30;
  width: max-content;
  max-width: 300px;
  padding: 12px 14px;
  border-radius: var(--r-md);
  border: 1px solid var(--border);
  background: var(--surface-2);
  box-shadow: var(--shadow-3);
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity var(--t-base) var(--ease), transform var(--t-base) var(--ease);
  text-align: left;
}

.profile-dim:hover .profile-help,
.profile-dim:focus-visible .profile-help,
.profile-hero:hover .profile-help,
.profile-hero:focus-visible .profile-help {
  opacity: 1;
  visibility: visible;
  transform: translateX(-50%) translateY(0);
}

.profile-help b {
  display: block;
  font-size: var(--fs-base);
  color: var(--text-primary);
}

.profile-help p {
  margin: 4px 0 0;
  font-size: var(--fs-sm);
  line-height: 1.45;
  color: var(--text-secondary);
}

.profile-help dl {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: 3px 12px;
  margin: 10px 0 0;
  font-size: var(--fs-xs);
}

.profile-help dt {
  color: var(--text-muted);
  white-space: nowrap;
}

.profile-help dd {
  margin: 0;
  color: var(--text-secondary);
}

.profile-dim {
  cursor: help;
}

/* A card in the top row would push its explanation out of the frame at the bottom
   of the page; one in the last row places it above. */
.profile-dims > .profile-dim:nth-child(n + 5) .profile-help {
  top: auto;
  bottom: calc(100% + 8px);
}

/* ---- progression hero ---- */

.profile-hero {
  position: relative;
  cursor: pointer;
  display: flex;
  flex-direction: column;
}

.profile-hero:hover {
  border-color: var(--text-muted);
}

.profile-hero.open {
  border-color: var(--series-time);
}

.profile-hero-main {
  display: flex;
  align-items: baseline;
  gap: 12px;
  flex-wrap: wrap;
}

.profile-hero-value {
  font-size: var(--fs-3xl);
  font-weight: 700;
  letter-spacing: 0;
  line-height: 1;
  color: var(--series-time);
  font-variant-numeric: tabular-nums;
}

.profile-hero-paces {
  margin: 12px 0 0;
  align-self: flex-start;
}

.profile-hero-paces b {
  font-weight: 700;
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}

.profile-hero-foot {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  margin: 6px 0 0;
}

/* ---- hover on the rating curve ---- */

.profile-point {
  fill: transparent;
  cursor: crosshair;
}

.profile-point-dot {
  fill: var(--text-primary);
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--t-fast) var(--ease);
}

.profile-point:hover + .profile-point-dot {
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  .profile-help,
  .profile-point-dot {
    transition: none;
  }
}

/* ---- hero curve ---- */

.profile-hero-chart {
  position: relative;
  margin-top: 14px;
}

.profile-hero-chart .chart {
  height: 230px;
}

/* The thin line of the raw rating passes behind the smoothing: it is the raw
   material, not the point. */
.profile-raw-line {
  opacity: 0.45;
}

.profile-true-legend {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 18px;
  margin: 8px 0 0;
  font-size: var(--fs-xs);
  color: var(--text-muted);
}

.profile-true-legend i {
  display: inline-block;
  width: 14px;
  vertical-align: middle;
  margin-right: 6px;
}

.profile-true-legend i.solid {
  height: 3px;
  border-radius: var(--r-full);
  background: var(--series-time);
}

.profile-true-legend i.thin {
  height: 0;
  border-top: 1px solid var(--text-muted);
}

/* ---- action plan ----
   Facts with buttons: a stripe of at most three, the first carrying the axis
   to work on. No sentence lives here. */

.profile-plan {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: 14px;
}

/* ---- insights row ---- */

/* chess.com's "advanced stats" strip: one chip per reading of the games, the
   same tile as the cockpit's side column. The open one carries the accent, as
   the hero does when its panel is shown. */
.profile-insights {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 14px;
}

.profile-insight.open {
  border-color: var(--series-time);
}

.profile-mini-line .quality-brilliant {
  color: var(--series-time);
}

/* Wraps: the fact keeps a reading width and the buttons drop under it when
   the row is too narrow for both -- on a phone two buttons left the sentence
   one word per line. */
.profile-action {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 14px;
  padding: 13px 16px;
  border-radius: var(--r-md);
  border: 1px solid var(--border);
  border-left: 4px solid var(--accent);
  background: var(--surface-1);
}

.profile-action.crit {
  border-left-color: var(--status-critical);
}

.profile-action-fact {
  flex: 1 1 220px;
  min-width: 0;
}

.profile-action-fact b {
  display: block;
  font-size: var(--fs-base);
}

.profile-action-fact small {
  display: block;
  font-size: var(--fs-2xs);
  color: var(--text-muted);
}

.profile-action .btn {
  white-space: nowrap;
}

.profile-action-buttons {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  justify-content: flex-end;
  margin-left: auto;
}

/* The figure the score was made of, under it: "84" alone said nothing. */
.profile-dim-measure {
  font-size: var(--fs-2xs);
  color: var(--text-muted);
}

.profile-lens-note {
  margin: 14px 0 0;
}

/* The curves' X axis: first and last bucket, on the baseline. */
.profile-axis.x {
  top: auto;
  bottom: 4px;
  left: 20px;
}
.profile-axis.x.right {
  left: auto;
  right: 20px;
}

/* ---- detail zone ---- */

.profile-detail {
  display: flex;
  flex-direction: column;
  gap: 18px;
  scroll-margin-top: 12px;
}

/* ---------- Account: the navigation column and the sign-in screen ----------
   The account's name is contextual information, not a command: it reads in
   --text-muted, next to the only button that acts on it. */

.account {
  display: flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
}

.account-name {
  max-width: 160px;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  font-size: var(--fs-base);
  color: var(--text-muted);
}

/* The sign-in page has no navigation column at all: a single card, centred, and
   the choice of provider for all its content. That is why the shell's grid is
   carried by `.app-body` and not by `body`. */
.login-body {
  display: grid;
  place-items: center;
  min-height: 100dvh;
  padding: var(--s5);
}

.login-card {
  width: 100%;
  max-width: 380px;
  padding: var(--s6);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  background: var(--surface-1);
  box-shadow: var(--shadow-3);
}

.login-brand {
  display: flex;
  align-items: center;
  gap: 12px;
}

.login-brand h1 {
  margin: 0;
  font-size: var(--fs-lg);
  letter-spacing: 0;
}

.login-lead {
  margin: var(--s4) 0 0;
  font-size: var(--fs-base);
  color: var(--text-secondary);
}

.login-error {
  margin: var(--s4) 0 0;
  padding: 10px 12px;
  border: 1px solid var(--status-critical);
  border-radius: var(--r-sm);
  font-size: var(--fs-base);
  color: var(--status-critical);
}

.login-providers {
  margin-top: var(--s5);
}

.login-note {
  margin: 0;
  font-size: var(--fs-base);
  color: var(--text-muted);
}

/* ---------- Waiting ----------
   A knight, large, hopping in place. Two things make the bound: the squash on
   take-off then the stretch in the air — nothing moves in a straight line at
   constant speed — and the shadow that fades as it rises, the only proof of the
   height. */

.loader {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s5);
  padding: var(--s7) var(--s4);
  text-align: center;
}

/* The box reserves the height of the bound: the knight rises 38 px, and nothing
   above it must move when it takes off. */
.loader-knight {
  position: relative;
  width: 88px;
  height: 134px;
}

.loader-piece {
  position: absolute;
  bottom: 6px;
  left: 0;
  width: 88px;
  color: var(--piece-face);
  /* The squash starts from the feet, never from the centre: otherwise the knight
     sinks into the ground instead of bending its legs. */
  transform-origin: 50% 100%;
  animation: loader-hop 760ms ease-in-out infinite;
}
.loader-piece svg {
  display: block;
  width: 88px;
  height: 88px;
  margin: 0 auto;
  filter: drop-shadow(0 3px 6px color-mix(in srgb, var(--page) 85%, transparent));
}

.loader-shadow {
  position: absolute;
  bottom: 0;
  left: 50%;
  width: 46px;
  height: 10px;
  margin-left: -23px;
  border-radius: 50%;
  background: color-mix(in srgb, var(--accent-deep) 48%, transparent);
  animation: loader-shadow 760ms ease-in-out infinite;
}

/* The jump: crouch, spring, glide, come down, and a small landing bounce before
   setting off again. */
@keyframes loader-hop {
  0% {
    transform: translateY(0) scale(1.14, 0.86);
  }
  14% {
    transform: translateY(-6px) scale(0.9, 1.14);
  }
  50% {
    transform: translateY(-38px) scale(1, 1);
  }
  66% {
    transform: translateY(-6px) scale(1.04, 0.96);
  }
  74% {
    transform: translateY(0) scale(1.22, 0.8);
  }
  84% {
    transform: translateY(-6px) scale(0.96, 1.06);
  }
  94%,
  100% {
    transform: translateY(0) scale(1, 1);
  }
}

/* The shadow betrays the height: it shrinks and pales at the top of the arc. */
@keyframes loader-shadow {
  0%,
  74% {
    transform: scale(1.08, 1);
    opacity: 0.7;
  }
  50% {
    transform: scale(0.5, 1);
    opacity: 0.2;
  }
  94%,
  100% {
    transform: scale(1, 1);
    opacity: 0.55;
  }
}

.loader-label {
  margin: 0;
  color: var(--text-secondary);
  font-size: 16px;
}

/* The quote is the eye's real occupation: it takes the space of a text, not of a
   caption. */
.loader-quote {
  margin: 0;
  max-width: 42ch;
}
.loader-quote blockquote {
  margin: 0;
  font-size: 17px;
  line-height: 1.5;
  color: var(--text-primary);
  font-style: italic;
}
.loader-quote figcaption {
  margin-top: var(--s2);
  font-size: 14px;
  color: var(--text-muted);
}

/* The knight settles and falls silent: the quote and the announcement remain. */
@media (prefers-reduced-motion: reduce) {
  .loader-piece,
  .loader-shadow {
    animation: none;
  }
}

/* ---------- Rain of pieces (sign-in) ----------
   The background of the sign-in screen: the board's pieces fall gently behind the
   card. Everything is played in `transform` and in opacity, on nodes out of flow:
   the card does not move by a pixel, and nothing is laid out again on every frame. */

.piece-rain {
  position: fixed;
  inset: 0;
  overflow: hidden;
  z-index: 0;
  pointer-events: none;
}

/* Above the rain, obviously: the rain is the background. */
.login-card {
  position: relative;
  z-index: 1;
}

.rain-drop {
  position: absolute;
  top: 0;
  display: block;
  width: var(--rain-size);
  height: var(--rain-size);
  opacity: var(--rain-opacity);
  animation: rain-fall var(--rain-duration) linear var(--rain-delay) infinite;
}

/* The cburnett SVG occupies the whole drop: without this it would keep the 12.5% of
   the square that chessground's stylesheet gives it. */
.piece-rain piece {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  background-size: contain;
  background-repeat: no-repeat;
  background-position: center;
  /* The cburnett SVGs are white outlined in black: at this opacity the outline eats
     the piece and all that is left is a dirty smudge. The filter flattens them into
     solid silhouettes — the same reading as the brand's knight — without touching
     the piece set itself. */
  filter: brightness(0) invert(1);
}

/* The fall starts above the edge and ends below it: a piece never appears nor
   disappears in mid-page. The drift and the rotation happen along the same path — a
   piece that falls straight down and perfectly flat does not fall, it descends. */
@keyframes rain-fall {
  from {
    transform: translate3d(0, -22vh, 0) rotate(0deg);
  }
  to {
    transform: translate3d(var(--rain-drift), 118vh, 0) rotate(var(--rain-spin));
  }
}

  /* Decor has no right to insist: without motion it has nothing left to say, and a
     sky of frozen pieces is worth no more than a plain background. */
@media (prefers-reduced-motion: reduce) {
  .piece-rain {
    display: none;
  }
}

/* ---------- Rush and Streak runs ----------

   The one place in the app where a green is allowed, and it does not leave it: a
   run is the only mode that pays out coup by coup, and cyan — which everywhere
   else means "this is going well" — reads as information, not as reward. Forest
   green is the reward. The tokens are declared on `.run` and nowhere else, so
   `--status-good` keeps its meaning in Set, in the profile and in the graphs.

   The forest itself is a fill, never a text: at #1e8a4d on the page it clears
   4.5:1 and no more, which holds under a 16 px pip and fails at 13 px. The light
   step is what writes and what flashes. */

.run {
  --run-win-deep: #0f3d24;
  --run-win: #1e8a4d;
  --run-win-bright: #5fe39b;
}

/* THE REWARD IS A SQUARE, NOT THE BOARD.

   A good move lights the edges of the square the piece lands on. The whole board
   going green said "something happened"; the square says WHERE, which is the only
   part the player did not already know — and at four moves a minute a full-board
   flash stops being read after the first minute.

   The mark is placed by `board.flashSquare`, which owns the geometry; here it is
   only sized and lit. 12.5% is one square of eight, the same arithmetic the
   promotion overlay above already runs on. */
.square-flash {
  position: absolute;
  z-index: 15;
  left: calc(var(--column) * 12.5%);
  top: calc(var(--row) * 12.5%);
  width: 12.5%;
  height: 12.5%;
  pointer-events: none;
  border-radius: 2px;
  /* Everything here is a fraction of the board, never a fixed pixel: a 3 px edge
     is a frame on a 432 px board and a hairline on a 900 px one, and the reward
     has to look the same in every window. The clamp only keeps it drawable at the
     two extremes of the size slider. */
  --flash-edge: clamp(3px, calc(var(--board-size, 560px) * 0.007), 7px);
  --flash-inner: calc(var(--board-size, 560px) * 0.022);
  --flash-outer: calc(var(--board-size, 560px) * 0.03);
  animation: square-flash 0.62s var(--ease) forwards;
}

/* Outside a run the square answers in the app own cyan: the green belongs to the
   modes that pay out, and to them alone.

   The edge carries the signal and the two glows only seat it. The inner one stays
   deliberately shy: the piece that has just landed is on this square, and a wash
   strong enough to be admired is strong enough to hide it. */
.square-flash.good {
  box-shadow:
    inset 0 0 0 var(--flash-edge) var(--status-good),
    inset 0 0 var(--flash-inner) calc(var(--flash-inner) * -0.35) var(--status-good),
    0 0 var(--flash-outer) calc(var(--flash-outer) * -0.2) var(--status-good);
}

.run .square-flash.good {
  box-shadow:
    inset 0 0 0 var(--flash-edge) var(--run-win-bright),
    inset 0 0 var(--flash-inner) calc(var(--flash-inner) * -0.35) var(--run-win-bright),
    0 0 var(--flash-outer) calc(var(--flash-outer) * -0.2) var(--run-win);
}

/* It arrives slightly too large and settles: the edges are what one sees, and an
   edge that only fades in is not seen at all next to a piece that has just moved.
   It leaves slowly — the reward is the part worth lingering on. */
@keyframes square-flash {
  0% {
    opacity: 0;
    transform: scale(1.3);
  }
  18% {
    opacity: 1;
    transform: scale(1);
  }
  45% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}

/* ---------- the panel of a run ----------
   Three measures, three blocks, separated by a rule and never boxed: what this
   puzzle is worth (or how long is left), where the run stands, what has been
   faced. The order changes with the mode, the shape never does. */

/* ONE LAYOUT, AT EVERY SIZE.

   The panel is not the same height on two machines: 250 px on a 1366 x 768 laptop,
   562 on the reference 1920 x 1080, 914 on a 1440 screen. An earlier pass spread
   the blocks over that height with `space-between` and tightened them under a
   media query below it, which is not one layout but two — and the second was
   never the one it was designed against.

   What varies here is the AIR and nothing else. The order of the measures, their
   sizes, their labels and their rules are identical everywhere; the gaps shrink
   with the window, and the chain is the one block that takes what is left.

   `height`, not `min-height`: that is what lets the chain give ground instead of
   pushing the clock out of the panel. A run whose measures scroll away while the
   clock runs is worse than a ledger one has to scroll. */
.run-panel {
  display: flex;
  flex-direction: column;
  /* The cap is the spacing scale itself: from a 960 px viewport up, 2.5vh has
     reached --s5 and the panel is drawn on the scale, not near it. Below that the
     air gives way before anything else does. */
  gap: clamp(12px, 2.5vh, var(--s5));
  height: 100%;
}

.run-block {
  display: flex;
  flex-direction: column;
  gap: clamp(8px, 1.5vh, var(--s3));
  padding-bottom: clamp(12px, 2.5vh, var(--s5));
  border-bottom: 1px solid var(--line);
}

.run-block:last-child {
  padding-bottom: 0;
  border-bottom: 0;
}

.run-label {
  font-family: var(--font-display);
  font-size: var(--fs-2xs);
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
}

/* THE HERO — the one figure the eye lands on between two moves. The rating of the
   puzzle on a streak, the time left on a rush. Centred and alone: a measure that
   has to be picked out of a row is not a hero. */
.run-hero {
  align-items: center;
  text-align: center;
  gap: var(--s2);
  /* Pinned to the top of the panel. On the reference screen nothing scrolls and
     this never shows; below it, where four measures and a chain do not fit in
     250 px whatever the spacing, the clock is the one thing that must not leave
     the screen. Sticky rather than a second layout: same blocks, same order,
     same sizes — one of them simply stays put. */
  position: sticky;
  top: 0;
  z-index: 1;
  background: var(--surface-1);
}

/* The hero is entitled to be the largest figure on the screen, and it must never
   change width while counting. */
.run-hero-figure {
  display: inline-block;
  font-size: var(--fs-3xl);
  line-height: 1;
}

/* The elapsed time of a streak: it is read afterwards, not played against. */
.run-hero-aside {
  font-size: var(--fs-sm);
  color: var(--text-muted);
}

/* The rating changes with each puzzle, and it changes in place: without this the
   substitution is invisible and one goes on playing the previous number. */
.run-hero-figure.shift {
  animation: run-shift var(--t-slow) var(--ease-spring);
}

@keyframes run-shift {
  from {
    transform: translateY(7px) scale(0.94);
    opacity: 0.3;
  }
  to {
    transform: none;
    opacity: 1;
  }
}

.run-timer-bar {
  width: 100%;
  height: 8px;
  background: var(--surface-inset);
  border-radius: var(--r-full);
  overflow: hidden;
}

.run-timer-bar i {
  display: block;
  height: 100%;
  width: 100%;
  border-radius: var(--r-full);
  background: var(--series-time);
  transition: width 0.1s linear, background-color var(--t-base) var(--ease);
}

.run-timer-bar i.warn {
  background: var(--status-warning);
}

.run-timer-bar i.danger {
  background: var(--status-critical);
}

/* The last ten seconds: the figure stops being read and starts being felt. */
.run-hero.urgent .run-hero-figure {
  color: var(--status-critical);
  animation: run-urgent 1s var(--ease) infinite;
}

@keyframes run-urgent {
  0%,
  100% {
    transform: scale(1);
  }
  14% {
    transform: scale(1.07);
  }
}

/* THE SCORE — and the record beside it, at half its size. The record is the mark
   to pass, not the measure of the run; once it is passed there is nothing left to
   compare oneself to, and the figure gives way to the fact. */
.run-score-row {
  display: flex;
  align-items: baseline;
  gap: var(--s3);
}

.run-score {
  display: inline-block;
  font-size: var(--fs-2xl);
  line-height: 1;
}

.run-record {
  font-size: var(--fs-sm);
  color: var(--text-muted);
}

/* No weight here: the measures block already sets these figures at 800, and a 700
   declared later would make the record BEATEN lighter than the record to beat. */
.run-record.beaten {
  color: var(--run-win-bright);
  letter-spacing: 0.02em;
}

/* The figure jumps when it climbs, and goes green on the way up before landing
   back in the panel own ink. */
.run-score.won {
  animation: run-score-won 0.42s var(--ease-spring);
}

@keyframes run-score-won {
  0% {
    transform: scale(1);
    color: var(--run-win-bright);
  }
  40% {
    transform: scale(1.24);
    color: var(--run-win-bright);
  }
  100% {
    transform: scale(1);
    color: var(--text-primary);
  }
}

/* THE CHAIN — one pip per puzzle faced, in the order they were faced. Blue for
   solved, coral for missed, an open ring for the one on the board. Twice the size
   of the session strip dots: this is the ledger of the run, not a position
   marker. */
.run-chain-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s2);
}

/* A chain that silently loses its beginning lies about its length. */
.run-chain-hidden {
  font-size: var(--fs-2xs);
  color: var(--text-muted);
}

.run-chain-hidden:empty {
  display: none;
}

/* The pips stay at the top of their block: what looks like empty panel is the room
   the chain is going to fill. A ledger stopped at a fixed height would say the
   opposite.

   And it is the only block allowed to give ground. Below two rows it scrolls
   rather than shrink further — a one-row slit with a scrollbar in it is not a
   chain, it is a defect. */
.run-chain-block {
  flex: 1 1 auto;
  min-height: 0;
}

.run-chain {
  display: flex;
  flex-wrap: wrap;
  align-content: flex-start;
  /* Elastic down to the pips themselves, and not only down to their block: a
     scrolling area sized to its content scrolls even when the panel had room to
     spare above it. */
  flex: 1 1 auto;
  gap: var(--s2);
  min-height: 40px;
  overflow-y: auto;
  scrollbar-width: thin;
}

.chain-pip {
  width: 16px;
  height: 16px;
  border-radius: var(--r-full);
  background: var(--surface-3);
  flex: none;
}

.chain-pip.good {
  background: var(--accent-bright);
}

.chain-pip.bad {
  background: var(--status-critical);
  box-shadow: 0 0 14px -3px var(--status-critical);
}

/* The open pip breathes: it is the only one standing for something not yet
   decided, and it is found without counting. */
.chain-pip.current {
  background: none;
  box-shadow: inset 0 0 0 2px var(--accent-line);
  animation: chain-waiting 1.9s var(--ease) infinite;
}

@keyframes chain-waiting {
  0%,
  100% {
    box-shadow: inset 0 0 0 2px var(--accent-line);
  }
  50% {
    box-shadow: inset 0 0 0 2px var(--accent-bright);
  }
}

/* The pip that has just settled, and it alone — recreating the row would replay
   the entrance on all of them at once, which reads as a flicker, not as a reward.
   A solved one lands green then cools to the blue of the chain: the reward is the
   moment, the ledger is the colour. */
.chain-pip.fresh {
  animation: chain-settle 0.44s var(--ease-spring);
}

.chain-pip.fresh.good {
  animation: chain-settle-win 0.52s var(--ease-spring);
}

@keyframes chain-settle {
  0% {
    transform: scale(0.2);
    opacity: 0;
  }
  55% {
    transform: scale(1.32);
    opacity: 1;
  }
  100% {
    transform: scale(1);
    opacity: 1;
  }
}

@keyframes chain-settle-win {
  0% {
    transform: scale(0.2);
    opacity: 0;
    background: var(--run-win-bright);
  }
  55% {
    transform: scale(1.32);
    opacity: 1;
    background: var(--run-win-bright);
  }
  100% {
    transform: scale(1);
    opacity: 1;
    background: var(--accent-bright);
  }
}

/* THE LIVES — the three of a rush, and a block of their own. Sitting straight on
   top of the chain they read as its first row: same shape, same size, one gap
   apart. What is left to lose and what has been played are two different facts,
   and they are not counted the same way.

   A streak has one life, which is a rule and not a count: the block is dropped
   there, and the first coral pip of the chain says it. */
.run-lives-block.hidden {
  display: none;
}

.run-lives {
  display: flex;
  gap: var(--s2);
}

.run-lives .life {
  width: 14px;
  height: 14px;
  border-radius: var(--r-full);
  background: var(--status-critical);
  box-shadow: 0 0 14px -3px var(--status-critical);
  transition: background-color var(--t-fast) var(--ease), box-shadow var(--t-fast) var(--ease);
}

/* A life lost goes dark, it does not disappear: the player must see at a glance how
   many they had, not only how many are left. */
.run-lives .life.spent {
  background: var(--surface-3);
  box-shadow: none;
}

.run-lives.lost {
  animation: run-lives-lost 0.36s var(--ease);
}

@keyframes run-lives-lost {
  0%,
  100% {
    transform: translateX(0);
  }
  25% {
    transform: translateX(-5px);
  }
  62% {
    transform: translateX(4px);
  }
}

.run-done {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s3);
  padding: var(--s4) 0;
  text-align: center;
}

.run-done h2 {
  margin: 0;
  font-size: var(--fs-xl);
}

.run-done-reason,
.run-done-peak {
  margin: 0;
  color: var(--text-muted);
  font-size: var(--fs-base);
}

.run-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
  gap: var(--s2);
  width: 100%;
  margin-top: var(--s2);
}

.run-cell {
  display: flex;
  flex-direction: column;
  gap: var(--s1);
  padding: var(--s1);
  border: 2px solid var(--line);
  border-radius: var(--r-sm);
  background: var(--surface-1);
  cursor: pointer;
  transition: border-color var(--t-fast) var(--ease);
}

.run-cell.missed {
  border-color: var(--status-critical);
}

.run-cell:hover {
  border-color: var(--accent);
}

/* The cell whose puzzle is on the board: the grid says which one one is reading. */
.run-cell.analysing {
  border-color: var(--accent-bright);
  box-shadow: 0 0 0 2px var(--accent-bright);
}

.run-cell-rating {
  color: var(--text-muted);
  font-size: var(--fs-sm);
  font-variant-numeric: tabular-nums;
}

.run-done-actions {
  display: flex;
  gap: var(--s2);
  margin-top: var(--s3);
}

/* ---------- Starting a run ----------
   Rush opens on a gate: the first puzzle is already in place behind a blur, a GO
   uncovers it. The countdown that follows no longer hides anything — it counts over
   a readable position, and that is its whole point: three seconds to read the puzzle,
   and the move goes out the instant the clock starts. */

.run-gate {
  position: absolute;
  inset: 0;
  z-index: 30;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--s6);
  background: color-mix(in srgb, var(--page) 45%, transparent);
  cursor: pointer;
}

.run-gate.hidden {
  display: none;
}

/* The largest target in the app: a third of the board. The whole board starts the
   run — the button does not have to be aimed at, it has to be seen.

   It used to be a flat disc, in the literal sense: one fill and one hairline. It is
   a pebble now, like everything else that is pressed here — lit from above,
   hollowed at the foot, sitting in its own shadow. */
.run-go {
  position: relative;
  display: grid;
  place-items: center;
  width: 34%;
  min-width: 132px;
  aspect-ratio: 1;
  border: 0;
  border-radius: var(--r-full);
  background: radial-gradient(
    circle at 50% 30%,
    var(--accent-hover),
    var(--accent) 48%,
    var(--accent-deep)
  );
  box-shadow:
    inset 0 2px 0 color-mix(in srgb, var(--accent-bright) 70%, transparent),
    inset 0 -14px 28px -14px rgba(3, 5, 18, 0.75),
    var(--shadow-3),
    0 0 70px -14px color-mix(in srgb, var(--accent) 85%, transparent);
  color: var(--text-on-accent);
  cursor: pointer;
  transition: transform var(--t-base) var(--ease-spring),
    box-shadow var(--t-base) var(--ease);
}

.run-go-face {
  grid-area: 1 / 1;
  font-family: var(--font-display);
  /* Scaled to the board, like the countdown digit that follows it: a fixed size
     reads as a caption on a 900 px board and as a shout on a 500 px one. */
  font-size: calc(var(--board-size, 560px) * 0.078);
  font-weight: 600;
  letter-spacing: 0.04em;
  line-height: 1;
  text-shadow: 0 3px 10px rgba(3, 5, 18, 0.45);
}

/* Two rings leaving the pebble on a slow beat, half a cycle apart. It is what tells
   the eye there is something to press without a single thing blinking — the gate
   has no other moving part, and a still disc over a blurred board reads as a
   screenshot. */
.run-go-ring {
  position: absolute;
  inset: 0;
  border: 2px solid var(--accent-bright);
  border-radius: var(--r-full);
  opacity: 0;
  pointer-events: none;
  animation: run-go-breathe 2.6s var(--ease) infinite;
}

.run-go-ring:nth-child(2) {
  animation-delay: 1.3s;
}

@keyframes run-go-breathe {
  from {
    transform: scale(1);
    opacity: 0.5;
  }
  to {
    transform: scale(1.5);
    opacity: 0;
  }
}

/* What one is about to enter, said before entering it. The gate used to be a button
   over a blurred board and nothing else: the format was written in a title one had
   already stopped reading. */
.run-gate-contract {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--fs-md);
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-secondary);
  text-shadow: 0 2px 14px rgba(3, 5, 18, 0.85);
}

/* The hover applies to the whole gate, not to the button: it is the board that is
   clicked, the button is only its mark. */
.run-gate:hover .run-go {
  transform: scale(1.04);
}

.run-gate:active .run-go {
  transform: scale(0.96);
  box-shadow: var(--shadow-press);
}

.run-go:focus-visible {
  outline: none;
  box-shadow: var(--focus-ring);
}

/* The figure scales to the board and stays transparent: the pieces must be readable
   through it, otherwise the three seconds serve no purpose.
   `pointer-events: none` -- there is nothing to click here, and the board stays
   locked by the code until the count is over. */
.run-countdown {
  position: absolute;
  inset: 0;
  z-index: 30;
  display: grid;
  place-items: center;
  pointer-events: none;
}

.run-countdown.hidden {
  display: none;
}

.run-countdown-digit {
  color: var(--text-primary);
  font-family: var(--font-display);
  font-size: calc(var(--board-size, 560px) * 0.42);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  line-height: 1;
  opacity: 0.62;
  text-shadow: 0 10px 40px rgba(3, 5, 18, 0.7);
  animation: countdown-beat var(--t-base) var(--ease-spring);
}

/* Each figure enters by growing: the count is visible even when the eye is already
   on the position, which is precisely where it should be. */
@keyframes countdown-beat {
  from {
    opacity: 0.2;
    transform: scale(1.35);
  }
  to {
    opacity: 0.62;
    transform: scale(1);
  }
}


/* ---------- the Creator — the Établi preset ---------- */

/* The board column carries the board and nothing else. One toolbar above the
   stage holds the tool's own state — colour, coverage, the way out to PGN —
   and the panel takes the three-zone contract: a head that says what is on the
   board, a body that scrolls the candidate table, a foot that never moves.
   Coral appears in exactly one place — the mass of a hole — because it is the
   system's only warm hue and a hole is the only thing here that is going
   wrong. */

.etabli-work {
  width: fit-content;
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  gap: 14px;
}

/* ---------- the toolbar ---------- */

.etabli-toolbar {
  display: flex;
  align-items: center;
  gap: var(--s4);
  min-height: 44px;
  padding: 0 6px;
}

.etabli-toolbar .btn {
  height: 36px;
  padding: 0 18px;
  font-size: var(--fs-sm);
}

.etabli-coverage {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: 12px;
  white-space: nowrap;
}

.etabli-coverage-bar {
  flex: 0 0 260px;
  height: 6px;
  border-radius: var(--r-full);
  background: var(--surface-inset);
  overflow: hidden;
}

.etabli-coverage-bar i {
  display: block;
  height: 100%;
  width: 0;
  background: var(--accent);
  transition: width var(--t-base) var(--ease);
}

.etabli-coverage-num {
  font-family: var(--font-display);
  font-size: var(--fs-md);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

.etabli-coverage-line {
  overflow: hidden;
  text-overflow: ellipsis;
  font-size: var(--fs-base);
  color: var(--text-muted);
}

/* The coverage target reads as part of the sentence and stays a control: a
   decision is changed where it is stated, and it goes quiet green the moment
   it is met — reaching it is the only news this label ever has. */
.etabli-target {
  appearance: none;
  border: 0;
  padding: 0 2px;
  background: transparent;
  font: inherit;
  font-size: var(--fs-base);
  color: var(--text-muted);
  cursor: pointer;
}

.etabli-target:hover {
  color: var(--text-secondary);
}

.etabli-target.is-reached {
  color: var(--status-good);
}

/* ---------- the stage: board column + panel ---------- */

.etabli-stage {
  display: flex;
  align-items: flex-start;
  justify-content: center;
  gap: 20px; /* STAGE_GAP in creator.js */
}

.etabli-board-col {
  display: flex;
  align-items: stretch;
  gap: 10px; /* part of EVAL_RESERVE in creator.js */
}

.etabli-frame {
  flex: none;
  width: var(--board-size, 560px);
  /* Both sizes are stated, not derived. The column stretches its items — that
     is what puts the evaluation bar at board height — and chessground sizes
     its container in absolute pixels: a frame left to stretch would take its
     height from that stale measure and hand it back to the width through
     aspect-ratio, so no resize could ever land. The zero floors keep the flex
     minimums from re-introducing the same lock through min-content. */
  height: var(--board-size, 560px);
  min-width: 0;
  min-height: 0;
}

/* ---------- the panel: head / body / foot ---------- */

.etabli-panel {
  flex: 0 0 var(--panel-lg);
  width: var(--panel-lg);
  height: var(--board-size, 560px);
  display: flex;
  flex-direction: column;
  background: var(--surface-1);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  overflow: hidden;
}

.etabli-head {
  flex: none;
  padding: 16px 20px 14px;
  border-bottom: 1px solid var(--border);
}

.etabli-head-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
}

.etabli-line {
  color: var(--text-secondary);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.etabli-head h2 {
  margin-top: 4px;
  font-size: var(--fs-xl);
  line-height: 1.2;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.etabli-intro {
  margin: 6px 0 0;
  min-height: calc(2 * 1.45em);
  font-size: var(--fs-base);
  color: var(--text-secondary);
  line-height: 1.45;
}

/* The price of the selected candidate, one quiet line under the identity:
   what it covers, what it costs to remember. */
.etabli-note {
  margin: 4px 0 0;
  min-height: 1.4em;
  font-family: var(--font-display);
  font-size: var(--fs-sm);
  font-variant-numeric: tabular-nums;
  color: var(--accent-bright);
}

/* The head-row's right half: the walked line, and the nib that opens the
   position's note. Grouped so the row keeps its two ends. */
.etabli-head-tools {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
}

.etabli-note-btn {
  appearance: none;
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 26px;
  height: 22px;
  padding: 0;
  border: 0;
  border-radius: var(--r-full);
  background: none;
  color: var(--text-muted);
  cursor: pointer;
  transition:
    color var(--t-fast) var(--ease),
    background var(--t-fast) var(--ease);
}

.etabli-note-btn:hover {
  color: var(--text-primary);
  background: var(--surface-2);
}

.etabli-note-btn.hidden {
  display: none;
}

.etabli-note-btn svg {
  width: 14px;
  height: 14px;
}

/* The reader's own words about the position: a quiet quoted line in the head,
   clamped to two lines — the full text is one click from being edited. */
.etabli-posnote {
  margin-top: 6px;
}

.etabli-posnote:empty {
  display: none;
}

.etabli-posnote-text {
  appearance: none;
  width: 100%;
  border: 0;
  padding: 0 0 0 10px;
  border-left: 2px solid var(--accent-line);
  background: none;
  font: inherit;
  font-size: var(--fs-sm);
  font-style: italic;
  color: var(--text-secondary);
  text-align: left;
  line-height: 1.4;
  cursor: pointer;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.etabli-posnote-text:hover {
  color: var(--text-primary);
}

.etabli-note-edit {
  width: 100%;
  min-height: 3.4em;
  resize: vertical;
  font-size: var(--fs-sm);
}

/* Why the move just made: the reason sits under the position's note, the
   move named in the label so the two notes are never mistaken for each other.
   Empty, the line is an invitation in the muted tone, not a blank. */
.etabli-movenote {
  margin-top: 6px;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.etabli-movenote:empty {
  display: none;
}

.etabli-movenote-label {
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.etabli-movenote-text {
  appearance: none;
  width: 100%;
  border: 0;
  padding: 0 0 0 10px;
  border-left: 2px solid var(--accent);
  background: none;
  font: inherit;
  font-size: var(--fs-sm);
  color: var(--text-secondary);
  text-align: left;
  line-height: 1.4;
  cursor: pointer;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.etabli-movenote-text.is-empty {
  border-left-color: var(--line-strong);
  color: var(--text-muted);
  font-style: italic;
}

.etabli-movenote-text:hover {
  color: var(--text-primary);
}

.etabli-body {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  padding: 14px 20px 12px;
}

.etabli-scroll {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.etabli-body .error {
  margin: 8px 0 0;
}

/* The engine's three lines, over the table: never squeezed by the scroll. */
.etabli-lines {
  flex: none;
}

/* The pending choice between two answers, pinned above the scrolling table. */
.etabli-conflict {
  flex: none;
}

.etabli-conflict:not(:empty) {
  margin-bottom: 10px;
}

/* ---------- the candidate table ---------- */

.etabli-table {
  display: flex;
  flex-direction: column;
}

.etabli-thead,
.etabli-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 84px 84px 84px 84px;
  align-items: center;
  gap: 12px;
}

.etabli-thead {
  padding: 0 10px 8px;
  border-bottom: 1px solid var(--border);
}

.etabli .th {
  font-family: var(--font-display);
  font-size: var(--fs-2xs);
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
  text-align: left;
}

.etabli .th.numeric {
  text-align: right;
}

.etabli .th.sortable {
  appearance: none;
  border: 0;
  padding: 0;
  background: none;
  cursor: pointer;
}

.etabli .th.sortable:hover {
  color: var(--text-primary);
}

.etabli-row {
  appearance: none;
  width: 100%;
  border: 0;
  padding: 12px 10px;
  background: none;
  border-bottom: 1px solid var(--line-faint);
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
  transition: background var(--t-fast) var(--ease);
}

.etabli-row:last-child {
  border-bottom: 0;
}

.etabli-row:hover {
  background: var(--surface-2);
  border-radius: var(--r-sm);
}

.etabli-row.is-selected {
  background: var(--accent-quiet);
  border-radius: var(--r-sm);
  border-bottom-color: transparent;
  box-shadow: inset 0 0 0 1px var(--accent-line);
}

.etabli-row:focus-visible {
  outline: 2px solid var(--accent-bright);
  outline-offset: -2px;
  border-radius: var(--r-sm);
}

.etabli-reply {
  min-width: 0;
  display: flex;
  flex-direction: column;
}

.etabli-san {
  font-family: var(--font-display);
  font-size: var(--fs-lg);
  line-height: 1.1;
  color: var(--text-primary);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.etabli-san.quiet {
  color: var(--text-muted);
}

.etabli-sub {
  font-size: var(--fs-sm);
  color: var(--text-muted);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* A stored move's own reason takes the sub-line over from the opening name. */
.etabli-sub.is-note {
  color: var(--text-secondary);
  font-style: italic;
}

.etabli-num {
  text-align: right;
  font-size: var(--fs-md);
  font-variant-numeric: tabular-nums;
  color: var(--text-primary);
}

.etabli-num.is-good {
  color: var(--status-good);
}

.etabli-num.quiet {
  color: var(--text-muted);
}

/* A move already yours is a fact, not a choice: its row goes quiet. */
.etabli-row.is-treated .etabli-san {
  color: var(--text-secondary);
}

.etabli-book {
  display: flex;
  justify-content: flex-end;
}

.etabli-show {
  font-size: var(--fs-sm);
  color: var(--accent-bright);
}

.etabli-credit {
  margin: 0.5rem 0 0;
  font-size: 0.75rem;
  color: var(--text-muted);
}

/* ---------- the queue strip ---------- */

.etabli-queue {
  appearance: none;
  flex: none;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  width: 100%;
  margin-top: 12px;
  padding: 14px 0 2px;
  border: 0;
  border-top: 1px solid var(--border);
  background: none;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}

.etabli-queue.hidden {
  display: none;
}

.etabli-queue:hover .etabli-queue-line {
  color: var(--text-primary);
}

.etabli-queue-text {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

.etabli-queue-line {
  font-size: var(--fs-base);
  color: var(--text-secondary);
}

.etabli-dots {
  display: flex;
  align-items: center;
  gap: 6px;
  flex: none;
}

.etabli-dot {
  width: 8px;
  height: 8px;
  border-radius: var(--r-full);
  background: var(--surface-3);
}

.etabli-dot.is-done {
  background: var(--status-good);
}

.etabli-dot.is-current {
  background: var(--series-time);
}

/* ---------- the foot ---------- */

.etabli-foot {
  flex: none;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 14px 20px 16px;
  border-top: 1px solid var(--border);
}

.etabli-foot.hidden {
  display: none;
}

.etabli-foot .board-nav {
  flex: none;
  background: var(--surface-2);
}

.etabli-keep {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* On a phone the bar alone fills the width: it takes its own row, and the
   decision gets the next one whole instead of a 50 px sliver. */
@media (max-width: 700px), (max-height: 500px) and (max-width: 900px) {
  .etabli-foot {
    flex-wrap: wrap;
    gap: 10px;
    padding: 12px 16px 14px;
  }
  .etabli-foot .board-nav {
    flex: 1 0 100%;
    justify-content: space-between;
  }

  /* The toolbar's coverage sentence is prose, not a label: at phone width it
     wraps onto its own lines instead of forcing the whole page wide. The
     target select is the worst offender — `white-space: pre` sizes it to its
     longest option text in one unbroken line — so it is capped to the row's
     own width instead of dictating it. */
  .etabli-toolbar {
    flex-wrap: wrap;
    height: auto;
    min-height: 0;
  }
  .etabli-coverage {
    flex: 1 1 100%;
    flex-wrap: wrap;
    white-space: normal;
  }
  .etabli-coverage-line {
    flex: 1 1 100%;
    min-width: 0;
    max-width: 100%;
    white-space: normal;
    overflow: visible;
    text-overflow: clip;
  }
  .etabli-target {
    min-width: 0;
    max-width: 100%;
    white-space: normal;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  /* The four numeric tracks give up their desktop 84px so the move name
     (first column) keeps a readable width: at 375px, 313px of grid content
     remains once the panel border and the body/row padding are taken out,
     and 4 x 42px tracks + 4 x 8px gaps (200px) leave 113px for the move. */
  .etabli-thead,
  .etabli-row {
    grid-template-columns: minmax(0, 1fr) repeat(4, 42px);
    gap: 8px;
  }
  .etabli-num {
    font-size: var(--fs-xs);
  }
}

/* ---------- where to start, and the seed within reach ---------- */

.etabli-block {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding-bottom: 12px;
  border-bottom: 1px solid var(--line-faint);
}

.etabli-block:last-child {
  border-bottom: 0;
  padding-bottom: 0;
}

.etabli-seed-card {
  padding: 12px;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: var(--surface-2);
}

.etabli-seed-card .seed-panel {
  padding: 0;
  background: none;
}

/* Below the width the two columns need, the panel steps under the board. */
@media (max-width: 1100px) {
  .etabli-work {
    width: 100%;
  }
  .etabli-stage {
    flex-direction: column;
    align-items: center;
  }
  .etabli-panel {
    width: 100%;
    max-width: var(--panel-lg);
    height: auto;
    max-height: 70dvh;
  }
  .etabli-frame {
    width: min(var(--board-size, 560px), calc(100vw - 100px));
    height: min(var(--board-size, 560px), calc(100vw - 100px));
  }
}

/* ---------- the queue ---------- */

.queue-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--s1);
}

.queue-button {
  display: grid;
  grid-template-columns: 4.5rem 1fr;
  grid-template-areas: "mass line" "mass detail";
  align-items: baseline;
  gap: 0 var(--s3);
  width: 100%;
  padding: var(--s3);
  border: 0;
  border-radius: var(--r-md);
  background: var(--surface-1);
  text-align: left;
  cursor: pointer;
  transition:
    background var(--t-fast) var(--ease),
    transform var(--t-fast) var(--ease);
}

.queue-button:hover {
  background: var(--surface-2);
}

.queue-mass {
  grid-area: mass;
  font-family: var(--font-display);
  font-size: var(--fs-md);
  font-variant-numeric: tabular-nums;
  color: var(--status-critical);
}

.queue-line {
  grid-area: line;
  font-family: var(--font-display);
  font-size: var(--fs-sm);
  color: var(--text-primary);
}

.queue-detail {
  grid-area: detail;
  font-size: var(--fs-xs);
  color: var(--text-muted);
}

.queue-detail.is-leak {
  color: var(--text-secondary);
}

/* ---------- the walk ---------- */

.mark-kept {
  color: var(--text-muted);
}

/* A tag is a fact, not a recommendation, so it is a quiet chip and not a badge:
   sharp is the one warm thing on the page after the mass of a hole, because a
   move the field neglects and scores with is the only thing here that is an
   opportunity rather than a measurement. */
.move-tag {
  display: inline-block;
  margin-left: var(--s1);
  padding: 0 6px;
  border-radius: var(--r-sm);
  font-family: var(--font);
  font-size: var(--fs-xs);
  letter-spacing: 0.02em;
  vertical-align: middle;
  cursor: help;
}

.move-tag.is-sharp {
  background: var(--accent-quiet);
  color: var(--accent-bright);
  box-shadow: inset 0 0 0 1px var(--accent-line);
}

.move-tag.is-consistent {
  background: var(--surface-2);
  color: var(--text-secondary);
}

/* The end of a line is the one moment the engine speaks, so it is framed like the
   rule offer — worth reading, never in the way. The two ways on live in the
   foot, where every other decision already is. */
.line-end {
  padding: var(--s3);
  border-radius: var(--r-md);
  background: var(--surface-2);
  box-shadow: inset 0 0 0 1px var(--line-strong);
}

.line-end-head {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--fs-base);
  color: var(--text-primary);
}

.line-end-detail {
  margin: var(--s1) 0 0;
  font-size: var(--fs-xs);
  font-variant-numeric: tabular-nums;
  color: var(--text-secondary);
}

/* ---------- where to start ---------- */

/* The page opens on a question, in the panel and not in a dialog: a repertoire
   is not a modal decision. Continuing is the first choice because it is the
   common one; starting from nothing is the one that makes the page usable to
   someone with nothing. */
.start-choice {
  display: flex;
  flex-direction: column;
  gap: 2px;
  width: 100%;
  padding: var(--s2) var(--s3);
  border: 0;
  border-radius: var(--r-md);
  background: var(--surface-2);
  text-align: left;
  cursor: pointer;
  transition: background var(--t-fast) var(--ease);
}

.start-choice:hover:not(:disabled) {
  background: var(--surface-3);
}

.start-choice.active {
  background: var(--accent-quiet);
  box-shadow: inset 0 0 0 1px var(--accent-line);
}

/* A colour with nothing in it is shown and not hidden: knowing that the other
   side is empty is part of the answer to "where do I start". */
.start-choice:disabled {
  cursor: default;
  opacity: 0.55;
}

.start-choice-head {
  font-family: var(--font-display);
  font-size: var(--fs-base);
  color: var(--text-primary);
}

.start-choice-detail {
  font-size: var(--fs-xs);
  font-variant-numeric: tabular-nums;
  color: var(--text-muted);
}

.start-note {
  margin: 0;
  font-size: var(--fs-xs);
  color: var(--text-secondary);
}

.seed-panel {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  padding: var(--s3);
  border-radius: var(--r-md);
  background: var(--surface-1);
}

/* The whole control is one line wide: a dropdown of families, and the button
   that lays the chosen trunk down. Nothing here is typed. */
.seed-pick {
  width: 100%;
  min-width: 0;
}

.seed-note {
  margin: 0;
  font-size: var(--fs-xs);
  color: var(--accent-bright);
}

/* A conflict is a choice, not a failure: two access lines meeting on one
   position is the normal way it happens. Coral would say "you broke something". */
.conflict-card {
  padding: var(--s3);
  border-radius: var(--r-md);
  background: var(--surface-2);
  box-shadow: inset 0 0 0 1px var(--line-strong);
}

.conflict-head {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--fs-base);
  color: var(--text-primary);
}

.conflict-detail {
  margin: var(--s1) 0 var(--s3);
  font-size: var(--fs-xs);
  color: var(--text-secondary);
}

.conflict-actions,
.drawer-line {
  display: flex;
  gap: var(--s2);
  align-items: center;
}

.drawer-line {
  margin: 0;
  font-size: var(--fs-xs);
  color: var(--text-muted);
}

/* ---------- Plan ---------- */

/* The routine on top, the coach under it; the outlet's gap is owned here so a
   card's own margin is not counted twice. */
.plan-view {
  display: flex;
  flex-direction: column;
  gap: var(--s4);
}
.plan-view > .card {
  margin-bottom: 0;
}

/* Before the plan exists there is a heading and two lines of hint: those states
   keep the narrow measure they were written for, centred in the outlet. Once
   the plan is on record there are three blocks of it, and they take the outlet's
   full 1180 px — see .plan-loaded. */
.plan-coach > section:not(.plan-loaded) {
  max-width: 640px;
  margin-inline: auto;
}

.plan-engine-hint {
  color: var(--status-warning);
}

.plan-stage {
  display: flex;
  align-items: center;
  gap: var(--s2);
  color: var(--text-secondary);
  font-size: var(--fs-base);
}
/* Without this, `.plan-stage`'s own `display: flex` outranks the base `.hidden`
   rule on source order (same specificity, declared later): the stage line would
   stay on screen right through the failed sub-state it is supposed to leave. */
.plan-stage.hidden {
  display: none;
}

/* A live line, not a static one: the dot breathes while the job runs, echoing
   the run-hero figure that never sits dead between two puzzles. */
.plan-stage::before {
  content: "";
  width: 8px;
  height: 8px;
  flex: none;
  border-radius: var(--r-full);
  background: var(--accent-bright);
  animation: plan-pulse 1.4s var(--ease) infinite;
}

@keyframes plan-pulse {
  0%,
  100% {
    opacity: 0.35;
    transform: scale(0.85);
  }
  50% {
    opacity: 1;
    transform: scale(1);
  }
}

/* State 3 is a grid, and the grid owns the spacing: each block is a `.card`
   whose own `margin-bottom` is cancelled here so the gap is not counted twice.
   One column on a narrow window, in the order the page is read; two once there
   is room, the constat beside the sets it argues for, and the evidence folded
   under both. */
.plan-loaded {
  overflow-wrap: break-word;
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--s4);
}

/* Same trap as `.plan-stage` above: `display: grid` is declared later than the
   base `.hidden` rule at equal specificity, so without this the loaded state
   would stay on screen underneath the three it is supposed to replace. */
.plan-loaded.hidden {
  display: none;
}

.plan-loaded > .card {
  margin-bottom: 0;
}

@media (min-width: 1100px) {
  .plan-loaded {
    grid-template-columns: minmax(0, 1.2fr) minmax(300px, 1fr);
    grid-template-areas:
      "constat  drills"
      "evidence evidence";
    align-items: start;
  }
  .plan-constat {
    grid-area: constat;
  }
  .plan-drills {
    grid-area: drills;
  }
  .plan-evidence {
    grid-area: evidence;
  }
}

/* ---------- Routine of the day ---------- */

.plan-routine-head {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: var(--s3) var(--s5);
  flex-wrap: wrap;
  margin-bottom: var(--s4);
}

/* The one figure of the page: how much of today is done. It reads at the
   metric size, and turns to the good status once nothing is left. */
.plan-routine-state {
  margin: var(--s1) 0 0;
  font-family: var(--font-display);
  font-size: var(--fs-2xl);
  font-weight: 600;
  line-height: 1;
  font-variant-numeric: tabular-nums;
}
.plan-routine-state.done {
  color: var(--status-good);
}

.plan-routine-week {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: var(--s2);
}
.plan-routine-week .hint {
  margin: 0;
}

/* ---------- The flame ----------

   The streak, worn rather than stated. Four tiers, set by the script as
   `data-tier`: an ember at zero (grey, no light), a flame from the first day,
   a halo that flickers from seven, a blue flame from thirty -- the hottest,
   and the one colour the page otherwise keeps for its accent. Four states as
   `data-state`: `never` and `lost` sit under the ember; `safe` is the earned
   day; `pending` is the hourglass -- alive, but not yet today -- and the only
   one that moves on its own: the flame fades and breathes, the day's dot
   breathes with it, the line turns to the warning. Motion is the hourglass's
   whole argument, so it goes as one piece under reduced motion. */

.plan-flame {
  display: flex;
  align-items: center;
  gap: var(--s2);
  --flame-colour: var(--text-muted);
  --flame-core: var(--surface-3);
  --flame-glow: transparent;
}
.plan-flame-icon {
  width: 34px;
  height: 34px;
  fill: var(--flame-colour);
  filter: drop-shadow(0 0 6px var(--flame-glow));
  transform-origin: 50% 90%;
  transition:
    fill 400ms ease,
    filter 400ms ease,
    opacity 400ms ease;
}
.plan-flame-core {
  fill: var(--flame-core);
  transition: fill 400ms ease;
}
.plan-flame-count {
  font-family: var(--font-display);
  font-size: var(--fs-2xl);
  font-weight: 600;
  line-height: 1;
  font-variant-numeric: tabular-nums;
  color: var(--flame-colour);
  transition: color 400ms ease;
}

.plan-routine-week[data-tier="flame"] .plan-flame {
  --flame-colour: var(--status-warning);
  --flame-core: #ffd48a;
}
.plan-routine-week[data-tier="halo"] .plan-flame {
  --flame-colour: var(--status-warning);
  --flame-core: #ffe4a8;
  --flame-glow: color-mix(in srgb, var(--status-warning) 70%, transparent);
}
.plan-routine-week[data-tier="halo"] .plan-flame-icon {
  animation: flame-flicker 1.6s ease-in-out infinite alternate;
}
.plan-routine-week[data-tier="blue"] .plan-flame {
  --flame-colour: var(--accent-bright);
  --flame-core: #e6ecff;
  --flame-glow: color-mix(in srgb, var(--accent) 80%, transparent);
}
.plan-routine-week[data-tier="blue"] .plan-flame-icon {
  animation: flame-flicker 1.2s ease-in-out infinite alternate;
}

/* The hourglass: the flame is still there, but thinner, and it breathes. */
.plan-routine-week[data-state="pending"] .plan-flame-icon {
  opacity: 0.55;
  animation: flame-breathe 2.4s ease-in-out infinite;
}
.plan-routine-week[data-state="pending"] .plan-week-day.today {
  animation: flame-breathe 2.4s ease-in-out infinite;
}
.plan-routine-week[data-state="pending"] .plan-flame-line {
  color: var(--status-warning);
}
.plan-routine-week[data-state="lost"] .plan-flame-line {
  color: var(--status-critical);
}
.plan-routine-week[data-state="safe"] .plan-flame-line {
  color: var(--text-secondary);
}

/* The pop: once, when the streak passes the last one this browser saw. */
.plan-routine-week.pop .plan-flame-icon {
  animation: flame-pop 600ms cubic-bezier(0.2, 1.4, 0.4, 1) 1;
}
.plan-routine-week.pop .plan-flame-count {
  animation: flame-pop 600ms cubic-bezier(0.2, 1.4, 0.4, 1) 1;
}

@keyframes flame-flicker {
  from {
    transform: scaleY(1) skewX(0deg);
  }
  50% {
    transform: scaleY(1.06) skewX(-2deg);
  }
  to {
    transform: scaleY(0.97) skewX(2deg);
  }
}
@keyframes flame-breathe {
  0%,
  100% {
    opacity: 0.45;
  }
  50% {
    opacity: 1;
  }
}
@keyframes flame-pop {
  0% {
    transform: scale(1);
  }
  40% {
    transform: scale(1.45);
  }
  100% {
    transform: scale(1);
  }
}

@media (prefers-reduced-motion: reduce) {
  .plan-flame-icon,
  .plan-flame-count,
  .plan-routine-week[data-state="pending"] .plan-week-day.today {
    animation: none !important;
  }
}

/* Seven days, one dot each: filled when trained, ringed for today. The
   letter sits inside the dot so the row needs no second line of labels. */
.plan-week {
  display: flex;
  gap: var(--s2);
  list-style: none;
  margin: 0;
  padding: 0;
}
.plan-week-day {
  width: 28px;
  height: 28px;
  border-radius: var(--r-full);
  display: grid;
  place-items: center;
  font-family: var(--font-display);
  font-size: var(--fs-2xs);
  font-weight: 600;
  color: var(--text-muted);
  background: var(--surface-inset);
  border: 2px solid transparent;
}
.plan-week-day.active {
  background: var(--accent);
  color: var(--text-on-accent);
}
.plan-week-day.today {
  border-color: var(--accent-bright);
}
/* Today still unearned while a streak is alive: the ring breathes, the way the
   figure of a running job does above. */
.plan-week-day.at-risk {
  animation: plan-pulse 1.4s var(--ease) infinite;
}

@media (prefers-reduced-motion: reduce) {
  .plan-week-day.at-risk {
    animation: none;
  }
}

.plan-day-turn {
  margin: var(--s2) 0 0;
}

/* Back after a week: one line of what piled up, one button to resume on.
   `:not(.hidden)`: this rule comes after `.hidden`'s and would otherwise win
   over it -- an empty banner with a lone Resume showed on every visit. */
.plan-back:not(.hidden) {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s3);
  flex-wrap: wrap;
  margin: 0 0 var(--s3);
  padding: var(--s3);
  border-left: 4px solid var(--accent);
  border-radius: var(--r-sm);
  background: var(--surface-2);
}
.plan-back-line {
  margin: 0;
  flex: 1 1 320px;
}

.plan-target-count {
  grid-area: count;
  font-family: var(--font-display);
  font-size: var(--fs-md);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.plan-target .progress {
  grid-area: bar;
}
/* One Go, or one per deck colour, side by side in the same cell. */
.plan-target-go {
  grid-area: go;
  display: flex;
  gap: var(--s2);
}

.plan-set-notes {
  list-style: none;
  margin: var(--s4) 0 0;
  padding: var(--s3) 0 0;
  border-top: 1px solid var(--line);
  display: flex;
  flex-direction: column;
  gap: var(--s2);
}
.plan-set-note {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: var(--s3);
  color: var(--text-secondary);
  font-size: var(--fs-sm);
}

/* ---------- Constat ---------- */

.plan-constat-head {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: var(--s3) var(--s5);
  flex-wrap: wrap;
}
/* The eyebrow (`.tile-label`, reused as-is) sits right above it: removing the
   heading's own top margin keeps the two reading as one unit. */
.plan-constat-head h2 {
  margin: var(--s1) 0 0;
}
/* The button and the plan's age read as one right-hand block: the age is the
   reason to press the button, not a stray line under the heading. */
.plan-constat-aside {
  flex: none;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: var(--s1);
  text-align: right;
}
.plan-constat-aside .hint {
  margin: 0;
}

.plan-action-games {
  margin: 0 0 var(--s3);
  font-size: var(--fs-sm);
  color: var(--text-secondary);
}

.plan-evidence > summary {
  cursor: pointer;
  font-family: var(--font-display);
  font-size: var(--fs-md);
  font-weight: 600;
}

/* ---------- Recommended actions ---------- */

.plan-action-list {
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  list-style: none;
  margin: var(--s3) 0 0;
  padding: 0;
}

.plan-action {
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  padding: var(--s3);
}

.plan-action-title {
  margin: 0 0 var(--s1);
  font-size: var(--fs-base);
}

.plan-action-detail {
  margin: 0 0 var(--s2);
  color: var(--text-muted);
}

.plan-action-evidence {
  margin: 0 0 var(--s2);
  padding-left: var(--s3);
  color: var(--text-muted);
  font-size: var(--fs-sm);
}

.plan-action-stage {
  margin: var(--s2) 0 0;
  color: var(--text-muted);
  font-size: var(--fs-sm);
}

/* ---------- Today ---------- */

.plan-target-list {
  list-style: none;
  margin: var(--s3) 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--s2);
}

/* A target is one row: check, label, count, bar, button -- the bar under the
   label and the count, the button spanning both lines. */
.plan-target {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto auto;
  grid-template-areas:
    "check text  count go"
    "check bar   bar   go";
  column-gap: var(--s3);
  row-gap: var(--s2);
  align-items: center;
  padding: var(--s3);
  background: var(--surface-2);
  border-radius: var(--r-sm);
}
.plan-target-check {
  grid-area: check;
}
.plan-target-text {
  grid-area: text;
}

/* A plain ring that fills solid once the target is met — the same status hue
   the rest of the app already reserves for "going well" (--status-good), never
   redrawn as an image for something this small. */
.plan-target-check {
  flex: none;
  width: 18px;
  height: 18px;
  border-radius: var(--r-full);
  border: 2px solid var(--line-strong);
}

.plan-target.done .plan-target-check {
  border-color: var(--status-good);
  background: var(--status-good);
}

.plan-target-text {
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 1px;
}

.plan-target-label {
  font-weight: 600;
}

.plan-target.done .plan-target-label {
  color: var(--text-secondary);
}

.plan-target-progress {
  font-size: var(--fs-sm);
  color: var(--text-muted);
}

/* ---------- Coach note ---------- */

/* The note is machine-written markdown (see plan-note.js): headings, paragraphs
   and lists all land inside this one wrapper, so their spacing is scoped here
   rather than styling bare <h3>/<p>/<ul> globally. */
/* The note is the only running prose on the page: it keeps a reading measure
   even when the column around it is wider. */
.plan-note {
  max-width: 68ch;
  margin-top: var(--s3);
}

.plan-note h3 {
  font-size: var(--fs-md);
  margin: var(--s4) 0 4px;
}
.plan-note h3:first-child {
  margin-top: 0;
}
.plan-note p {
  margin: 0 0 var(--s2);
  line-height: 1.5;
}
.plan-note ul {
  margin: 0 0 var(--s2);
  padding-left: 1.2em;
}
.plan-note li {
  line-height: 1.5;
}

/* ---------- Evidence ----------
   Three columns, one per block of the diagnosis (games, tactics, openings):
   the figures are not one undifferentiated run of tiles, they answer three
   different questions, and grouping them stops the last tile of a flat grid
   from sitting alone in a hole. */
.plan-evidence-groups {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: var(--s4);
  margin-top: var(--s3);
}

.plan-evidence-tiles {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
}

/* The card underneath is already `--surface-1`: a tile in the same tone would
   be an invisible frame, so it steps up one surface. */
.plan-evidence .tile {
  background: var(--surface-2);
  border-color: var(--line-faint);
  padding: var(--s3) var(--s4);
}

.plan-evidence-subhead {
  font-size: var(--fs-xs);
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin: 0 0 var(--s2);
}

/* Below the three columns, not inside them: the moments belong to the whole
   diagnosis and want the card's full width. */
.plan-evidence [data-role="worst-moments-section"] {
  margin-top: var(--s5);
  padding-top: var(--s4);
  border-top: 1px solid var(--line);
}

/* Capped rather than stretched to the card: four short fields pulled to the
   two far edges of 1180 px would stop reading as a row. */
.plan-worst-moments {
  list-style: none;
  margin: 0;
  padding: 0;
  max-width: 680px;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

/* One row per lost position, the whole row pressable — five stacked underlined
   links were a wall of blue text with no shape to scan. */
.plan-moment {
  width: 100%;
  display: grid;
  grid-template-columns: 4.5rem 7rem minmax(0, 1fr) auto;
  align-items: baseline;
  gap: var(--s3);
  padding: var(--s2) var(--s3);
  background: none;
  border: 0;
  border-radius: var(--r-sm);
  font: inherit;
  text-align: left;
  color: var(--text-secondary);
  cursor: pointer;
}

.plan-moment:hover {
  background: var(--surface-2);
  color: var(--text-primary);
}

/* The points thrown away are why the move is in this list, so they lead — in
   the one warm hue the app reserves for what is going wrong, in the figures
   the rest of the measures use. */
.plan-moment-loss {
  font-variant-numeric: tabular-nums;
  font-weight: 600;
  color: var(--status-warning);
  text-align: right;
}

.plan-moment-move {
  font-family: var(--font-mono);
  font-size: var(--fs-sm);
  color: var(--text-primary);
}

.plan-moment-context {
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  font-size: var(--fs-sm);
}

.plan-moment-cadence {
  font-size: var(--fs-xs);
  color: var(--text-muted);
}

/* Two lines on a phone rather than four columns squeezed: the move and its
   cadence on top, the opponent under them. */
@media (max-width: 700px), (max-height: 500px) and (max-width: 900px) {
  .plan-moment {
    grid-template-columns: 3.5rem minmax(0, 1fr) auto;
    gap: 2px var(--s2);
  }
  .plan-moment-cadence {
    grid-column: 3;
    grid-row: 1;
  }
  .plan-moment-context {
    grid-column: 2 / -1;
    grid-row: 2;
  }
}

/* ---------- the More page ----------
   The mobile home of the satellites: a pile of tappable cards. Reachable on
   desktop by URL alone, where it lays the same cards in a centred column. */
.more-page {
  display: flex;
  flex-direction: column;
  gap: 10px;
  max-width: 560px;
  margin: 0 auto;
}
.more-card {
  display: flex;
  align-items: center;
  gap: 14px;
  min-height: 64px;
  padding: 12px 18px;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: var(--surface-1);
  box-shadow: var(--shadow-1);
  color: var(--text-primary);
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.more-card .icon {
  flex: none;
  width: 22px;
  height: 22px;
  color: var(--accent-bright);
}
.more-card-text {
  display: flex;
  flex-direction: column;
  min-width: 0;
}
.more-card-text b {
  font-family: var(--font-display);
  font-size: var(--fs-md);
  font-weight: 500;
}
.more-card-text small {
  color: var(--text-muted);
  font-size: var(--fs-sm);
}
.more-account {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-top: 12px;
  padding-top: 14px;
  border-top: 1px solid var(--line);
}
.more-account-name {
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  color: var(--text-muted);
}

/* ---------- the build screen ----------
   Five settings, one verdict, three thumbnails. */
.build-field { display: flex; flex-direction: column; gap: 0.25rem; }
/* Two boxes that bound each other, no longer two sliders that could cross and
   silently swap which end each one drove. */
.band-row { display: flex; align-items: center; gap: 0.75rem; }
.band-field { display: flex; align-items: stretch; gap: 0.25rem; }
/* Four digits plus the box's own padding and border: at 6ch, "1400" read "140(". */
.band-field input[type="number"] {
  width: calc(4ch + 22px); text-align: center; font-variant-numeric: tabular-nums;
}
/* The native spinners are a few pixels tall and their size is not ours to set --
   it differs by browser. Replaced by two real buttons, sized like every other
   thing on this screen one is meant to press. */
.band-field input[type="number"]::-webkit-outer-spin-button,
.band-field input[type="number"]::-webkit-inner-spin-button {
  appearance: none; margin: 0;
}
.band-field input[type="number"] { appearance: textfield; -moz-appearance: textfield; }
.band-step {
  width: 2.5rem; flex: 0 0 auto;
  background: var(--surface-2); color: var(--text-primary);
  border: 1px solid var(--border); border-radius: var(--r-sm);
  font-size: var(--fs-lg); line-height: 1; cursor: pointer;
}
.band-step:hover:not(:disabled) { background: var(--accent-quiet); border-color: var(--accent-line); }
.band-step:disabled { opacity: 0.4; cursor: default; }
.band-sep { color: var(--text-muted); }

/* The picker: a box to search in, the rows it matches, the chips already kept.
   The list is positioned over what follows rather than pushing it down -- a row
   of results that shoved the Build button about on every keystroke would undo
   the fluidity the whole screen is for. */
.build-picker { position: relative; }
.picker-results {
  position: absolute; z-index: 5; left: 0; right: 0; top: 100%;
  max-height: 17rem; overflow-y: auto;
  background: var(--surface-2); border: 1px solid var(--border);
  border-radius: var(--r-md); box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
}
.picker-row {
  display: flex; justify-content: space-between; align-items: baseline; gap: 1rem;
  width: 100%; padding: 0.4rem 0.6rem; text-align: left;
  background: none; border: 0; color: var(--text-primary); cursor: pointer;
}
.picker-row:hover, .picker-row:focus-visible { background: var(--accent-quiet); }
.picker-row-aside {
  color: var(--text-muted); font-size: var(--fs-sm); white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
.picker-empty { margin: 0; padding: 0.4rem 0.6rem; color: var(--text-muted); }
.chip-row { display: flex; flex-wrap: wrap; gap: 0.35rem; margin: 0.25rem 0; }
.chip-active { font-weight: 600; }
/* The counted ways out. They must not read as one more filter sitting next to
   the opening chips two rows above: they are the click that turns a dead end
   into a set, and the only chip on this screen that carries a number. So they
   take the accent at rest -- the neutral chips only ever borrow it on hover. */
.chip-suggestion {
  border-color: var(--accent);
  background: var(--accent-quiet);
  color: var(--accent-bright);
  font-weight: 600;
}
/* `.chip:hover` is one class more specific and lands earlier in the file: without
   its own hover rule the suggestion would fade back to the neutral chip's. */
.chip-suggestion:hover {
  border-color: var(--accent-hover);
  background: var(--accent-quiet);
  color: var(--text-primary);
}
/* A field naming no family the catalogue knows. The verdict says what is wrong;
   this only says where. */
.build-field input[aria-invalid="true"] { border-color: var(--status-warning); }
.build-verdict { margin: 0.5rem 0 0; font-variant-numeric: tabular-nums; }
/* Three tones and not two: a set that is only just complete is not a good set.
   The tokens are the theme's -- the cyan carries what goes well, the coral what
   goes badly, and it is the application's only warm hue. */
.build-verdict.tone-enough { color: var(--status-good); }
.build-verdict.tone-thin { color: var(--status-warning); }
.build-verdict.tone-short { color: var(--status-critical); }
.build-sample { display: flex; gap: 0.5rem; margin: 0.5rem 0; }
.build-sample .mini-preview { width: 6.5rem; }
.build-advanced summary { cursor: pointer; }

/* ---------- the first visit ----------
   Three ways in, side by side; the fourth, at the player's level, takes the
   accent because it is the one that makes a set out of this page. */
.welcome-cards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: var(--s3);
  margin-top: var(--s4);
}
.welcome-card {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  padding: var(--s4);
  border-radius: var(--r-sm);
  box-shadow: inset 0 0 0 1px var(--line-faint);
}
.welcome-card h3 { margin: 0; }
.welcome-card p { margin: 0; color: var(--text-secondary); font-size: var(--fs-sm); }
.welcome-card p:not(.error) { flex: 1; }
.welcome-card .btn { align-self: flex-start; }
.welcome-card-level {
  background: var(--accent-quiet);
  box-shadow: inset 0 0 0 1px var(--accent-line);
}
.welcome-account { display: flex; flex-wrap: wrap; gap: var(--s2); }
.welcome-account input { flex: 1 1 12ch; min-width: 0; }

/* ---------- Read library ----------
   The grid of covers that opens the Read mode. `.read-upload` reuses
   `.book-drop`, styled above for the same gesture in the Sets tab. */
.read {
  display: flex;
  flex-direction: column;
  gap: var(--s4);
}
.read-upload {
  display: flex;
  align-items: center;
  gap: var(--s3);
  flex-wrap: wrap;
}
.read-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
  gap: var(--s4);
}
.read-card {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  padding: var(--s3);
  background: var(--surface-1);
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  transition: border-color var(--t-fast) var(--ease), transform var(--t-fast) var(--ease);
}
.read-card:hover {
  border-color: var(--accent);
  transform: translateY(-2px);
}
/* The clickable area that opens the reader. A plain button reset: the
   sibling seal control (below) needed a control of its own, and a <button>
   cannot nest another one -- hence the card split into two. */
.read-card-open {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  padding: 0;
  margin: 0;
  background: none;
  border: none;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
}
.read-card-seal,
.read-card-delete {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
}
.read-card-seal-actions {
  display: flex;
  gap: var(--s2);
}
/* The deposit's chapter split, followed in the library the way an import is
   followed in the Sets tab. */
.read-split {
  padding: var(--s3);
  border-radius: var(--r-sm);
  background: var(--accent-quiet);
  border: 1px solid var(--accent-line);
  color: var(--text-primary);
  font-size: var(--fs-sm);
}
/* A failed split keeps the same box but the critical colour: `.error` alone
   would lose to `.read-split`'s own `color`, both being single-class rules. */
.read-split.error {
  background: color-mix(in srgb, var(--status-critical) 14%, var(--surface-1));
  border-color: color-mix(in srgb, var(--status-critical) 45%, transparent);
  color: var(--status-critical);
}
.read-card-warning {
  color: var(--text-muted);
  font-size: var(--fs-sm);
}
.read-card-sealed {
  color: var(--status-good);
  font-size: var(--fs-sm);
}
.read-card-link {
  background: none;
  border: none;
  padding: 0;
  color: var(--accent);
  cursor: pointer;
  text-decoration: underline;
  font: inherit;
}
/* The card's one destructive gesture, kept quiet: muted until pointed at, so
   that a library of one book does not open on a red button. */
.read-card-link--quiet {
  align-self: flex-start;
  color: var(--text-muted);
  font-size: var(--fs-sm);
  text-decoration: none;
}
.read-card-link--quiet:hover,
.read-card-link--quiet:focus-visible {
  color: var(--status-critical);
  text-decoration: underline;
}
/* `#read:<unknown>`: the sentence instead of a viewer that has nothing to show. */
.read-missing {
  margin: 0;
  color: var(--text-muted);
}
.read-cover {
  width: 100%;
  aspect-ratio: 3 / 4;
  object-fit: cover;
  border-radius: var(--r-xs);
  background: var(--surface-2);
}
.read-title {
  color: var(--text-primary);
  font-size: var(--fs-sm);
}
/* Track and fill of the reading progress: never claims completion before
   `progressPercent` (read-library.js) actually reaches 100. */
.read-bar {
  display: block;
  height: 6px;
  border-radius: var(--r-full);
  background: var(--surface-2);
  overflow: hidden;
}
.read-bar i {
  display: block;
  height: 100%;
  background: var(--accent);
  border-radius: var(--r-full);
}
.read-progress,
.read-problems {
  color: var(--text-muted);
}
.read-card--sealed .read-problems {
  color: var(--status-good);
}

/* ---------- the reader ----------
   The book on the left, the board panel on the right -- the panel is wired up
   by a later task; for now it only names the picked diagram. Out of scope for
   the phone breakpoint below: a scanned page and a board do not fit side by
   side under 700px, and the reader is not part of this pass. */
/* L'outlet devient une colonne pleine hauteur quand il porte le lecteur, et lui
   seul : un livre se lit a la hauteur de la fenetre. La valeur figee d'avant
   (`100dvh - 220px`) laissait ~146 px vides sous le livre sur tous les ecrans.
   `:has` est deja employe plus haut dans cette feuille. */
.outlet:has(.read-reader-shell) {
  height: 100dvh;
  display: flex;
  flex-direction: column;
}
/* `.read` (read.js's root) sits between the outlet and the shell: it has to
   pass the height down, or the shell sizes to its content. */
.outlet:has(.read-reader-shell) > .read {
  flex: 1;
  min-height: 0;
}
.read-reader-shell {
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  flex: 1;
  min-height: 0;
}
.read-back {
  align-self: flex-start;
  background: none;
  border: none;
  color: var(--accent-bright);
  font-size: var(--fs-sm);
  cursor: pointer;
  padding: 0;
}
.read-back:hover {
  color: var(--accent-hover);
}
.read-reader-mount {
  min-height: 0;
  flex: 1;
}
.read-reader {
  display: grid;
  /* La largeur du panneau est une variable posee par le lecteur et retenue
     dans localStorage : un lecteur qui veut un grand echiquier le garde. */
  grid-template-columns: minmax(0, 1fr) 6px var(--read-board-width, 320px);
  gap: var(--s3);
  align-items: start;
  height: 100%;
}
/* Pendant un glisser de la poignee, l'iframe ne doit pas avaler le pointeur qui
   passe au-dessus du livre : la poignee tient la capture, mais un iframe est un
   autre document et Chrome lui cede les evenements des qu'on le survole. */
.read-reader--dragging .read-frame {
  pointer-events: none;
}
/* La poignee : discrete au repos, franche des qu'on la vise. */
.read-handle {
  position: relative;
  height: 100%;
  border-radius: var(--r-full);
  background: var(--border);
  cursor: col-resize;
  touch-action: none;
}
/* La zone de prise deborde le trait de huit pixels de chaque cote, dans les
   gouttieres de la grille qui en font quatorze : six pixels se visent mal a la
   souris, et c'est le geste qu'on refait le plus souvent dans le lecteur. Le
   trait visible, lui, reste fin. */
.read-handle::before {
  content: "";
  position: absolute;
  inset-block: 0;
  inset-inline: calc(-1 * var(--s2));
}
.read-handle:hover,
.read-handle:focus-visible {
  background: var(--accent-bright);
}
.read-book {
  min-width: 0;
  height: 100%;
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  /* La carte de solution flotte au-dessus du livre, et de lui seul :
     l'echiquier a cote rejoue la ligne pendant qu'elle la raconte. */
  position: relative;
}
/* Le livre lui-meme : le viewer de pdf.js, dans un iframe de meme origine
   (read-viewer.js). Zoom, defilement, numero de page, clavier, recherche : a
   lui. Le cadre prend toute la hauteur que la colonne lui laisse. */
.read-frame {
  flex: 1;
  min-height: 0;
  width: 100%;
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  background: var(--surface-inset);
}
/* Le repli des controles : discret, pousse au bout de la barre d'outils. C'est
   le seul chemin de retour quand la rangee est pliee, donc il ne se plie jamais
   lui-meme. */
.read-toolbar-chrome {
  margin-left: auto;
  font-size: var(--fs-sm);
  color: var(--text-muted);
}
/* The toolbar's buttons carry an icon and a word; a phone keeps the icon
   alone (the phone block below). The one-column zoom is the phone's: at
   desktop widths the viewer's own zoom is the right one and the button hides. */
.read-tool .icon {
  flex: none;
}
.read-zoom {
  display: none;
}
/* Plie la rangee au-dessus du livre. Chaque pixel qu'elle occupait revient au
   livre. */
.read-book--bare .read-toolbar > :not(.read-toolbar-chrome),
.read-book--bare .read-banner,
.read-book--bare .read-banner-action {
  display: none;
}
/* Le sommaire : la decoupe en chapitres, comme liste de saut. A gauche de la
   barre, les commandes a droite. */
.read-toc {
  margin-right: auto;
  max-width: 24ch;
  padding: var(--s1) var(--s2);
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  color: var(--text-primary);
  font: inherit;
  font-size: var(--fs-sm);
}
.read-banner {
  padding: var(--s3);
  border-radius: var(--r-sm);
  background: var(--accent-quiet);
  border: 1px solid var(--accent-line);
  color: var(--text-primary);
  font-size: var(--fs-sm);
}
/* Le bouton du bandeau `deferred` : lire la section telle quelle. Aligne a
   gauche pour ne pas s'etaler sur toute la colonne. */
.read-banner-action {
  align-self: flex-start;
}
.read-banner--failed {
  background: color-mix(in srgb, var(--status-critical) 14%, var(--surface-1));
  border-color: color-mix(in srgb, var(--status-critical) 45%, transparent);
  color: var(--status-critical);
}
/* `min-height: 0` + `overflow: auto`: the panel's height is the viewport's,
   never its content's. Without them a grid item cannot be shorter than what it
   holds, so an editor whose palette plus board overflow makes the panel grow
   with them -- and `fitBoard` (read-problem.js), which sizes the board from the
   panel's free height, then measures a panel inflated by the very board it is
   sizing. Measured: the board shrank for half a second after the handle was
   released, one ResizeObserver tick at a time, the pieces trailing behind. */
.read-board {
  height: 100%;
  min-height: 0;
  min-width: 0;
  overflow: auto;
  background: var(--surface-1);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  padding: var(--s4);
}
.read-problem {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  height: 100%;
  min-height: 0;
}
.read-problem-heading {
  font-weight: 600;
  margin: 0;
}
.read-problem-provenance {
  color: var(--accent-bright);
  font-size: var(--fs-sm);
  margin: 0;
}
/* Carre : la largeur est posee par `fitBoard` (read-problem.js), la hauteur suit
   par `aspect-ratio`. `flex: 1` + `aspect-ratio` ne tenaient pas ensemble : des
   que flex fixe la hauteur, le rapport cede et l'echiquier sortait a 240 x 538. */
.read-problem-board {
  flex: none;
  align-self: center;
  width: 100%;
  aspect-ratio: 1;
}
.read-problem-status {
  color: var(--text-muted);
  font-size: var(--fs-sm);
  margin: 0;
}
/* Le pied sous l'echiquier, comme celui des autres panneaux de plateau : une
   rangee d'outils (la pilule d'icones, Analyse en face), puis la decision. */
.read-problem-foot {
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  margin-top: var(--s1);
  padding-top: var(--s3);
  border-top: 1px solid var(--line);
}
.read-problem-tools {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s2);
}
.read-problem-tools > .board-nav {
  flex: none;
}
.read-problem-tools > .board-nav:not(:has(.board-nav-btn:not(.hidden))) {
  display: none;
}
.read-problem-analyse {
  padding: 0 var(--s4);
}
/* Les boutons y sont presses a chaque coup : 44 px, a la taille de la main.
   Ceux d'une meme rangee se partagent sa largeur a parts egales, et passent
   l'un sous l'autre quand le libelle ne tient plus (echiquier au plus etroit). */
.read-problem-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s2);
}
.read-problem-foot .btn {
  height: 44px;
}
.read-problem-actions .btn {
  flex: 1 1 0;
  min-width: max-content;
  padding: 0 var(--s3);
}
.read-problem-actions:not(:has(.btn:not(.hidden))) {
  display: none;
}
.read-problem-status--error {
  color: var(--status-critical);
}

/* ---------- placing a board by hand ----------
   The net for whatever the vision pass did not see: a toolbar toggle, the
   rectangle a drag draws over the page, and the free-form board it opens. */
.read-toolbar {
  display: flex;
  justify-content: flex-end;
  align-items: center;
  gap: var(--s3);
  /* Une colonne de livre etroite (panneau tire a 70 %) ne doit pas pousser les
     boutons a gauche sous le rail : la rangee se replie sur deux lignes. */
  flex-wrap: wrap;
}
/* The sealed notice sits to the left of the toolbar's controls: `margin-right:
   auto` rather than `justify-content: space-between` on the row, which would
   push the lone toggle to the start when there is no notice to balance it. */
.read-sealed {
  color: var(--text-muted);
  font-size: var(--fs-sm);
}
.read-place-toggle--active {
  color: var(--accent-bright);
  border-color: var(--accent-line);
  background: var(--accent-quiet);
}
/* La coupe manuelle : le bouton et sa ligne de confirmation, dans la barre
   d'outils du livre. Meme deux-temps que la suppression et le scellement. */
.read-cut {
  display: flex;
  align-items: center;
  gap: var(--s2);
}
.read-cut-confirm {
  display: flex;
  align-items: center;
  gap: var(--s2);
  flex-wrap: wrap;
}
.read-cut-title {
  min-width: 12ch;
  padding: var(--s1) var(--s2);
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  color: var(--text-primary);
  font-size: var(--fs-sm);
}
.read-cut-error {
  color: var(--status-critical);
  font-size: var(--fs-sm);
  margin: 0;
}
.read-placement-editor {
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  height: 100%;
  min-height: 0;
}
.read-placement-board {
  flex: none;
  align-self: center;
  width: 100%;
  aspect-ratio: 1;
}
/* Porte aussi `cg-wrap`, pose par le script : c'est sous cette classe que
   chessground accroche l'image de chaque piece. `display: grid` doit donc
   reprendre la main sur le `display: block` que `.cg-wrap` impose -- meme poids
   de selecteur, et cette feuille est liee apres celle du vendeur. */
/* Des cases de taille fixe : en `1fr`, la palette grandissait avec le panneau
   et l'echiquier, qui prend la hauteur restante, retrecissait d'autant. */
.read-placement-palette {
  display: grid;
  grid-template-columns: repeat(6, 40px);
  justify-content: center;
  gap: var(--s1);
}
.read-placement-piece {
  aspect-ratio: 1;
  padding: 4%;
  border: 1px solid var(--line);
  border-radius: var(--r-xs);
  background: var(--surface-2);
  cursor: grab;
}
.read-placement-piece:hover {
  border-color: var(--accent);
}
/* `.cg-wrap piece` is `position: absolute; top: 0; left: 0`, sized for a
   board: left as is, each piece fills the whole palette and the twelve stack
   into one black heap in its middle. Static, it fills its own button. */
.read-placement-piece piece {
  position: static;
  display: block;
  width: 100%;
  height: 100%;
  background-size: contain;
  background-repeat: no-repeat;
  background-position: center;
  pointer-events: none;
}
.read-placement-side {
  display: flex;
  gap: var(--s3);
  color: var(--text-muted);
  font-size: var(--fs-sm);
}
.read-placement-side-option {
  display: flex;
  align-items: center;
  gap: var(--s1);
  cursor: pointer;
}
/* Both set their own `display`, and both come after `.hidden` in this sheet:
   the palette and the side row fold away in the play mode all the same. */
.read-placement-palette.hidden,
.read-placement-side.hidden {
  display: none;
}

/* Le focus y est pose par le script, pour sortir les fleches de l'iframe : pas
   par le clavier, rien a montrer. */
.read-line:focus {
  box-shadow: none;
}

/* ---------- l'echiquier complet (analysis-board.js) ---------- */
.analysis-toolbar {
  display: flex;
  justify-content: flex-end;
  gap: var(--s2);
}
.analysis-toolbar .btn[aria-pressed="true"] {
  color: var(--accent-bright);
  border-color: var(--accent-line);
  background: var(--accent-quiet);
}
/* `fitBoard` pose la largeur sur l'echiquier lui-meme (`.analysis-board`), pas
   sur la rangee : la rangee se contente d'epouser sa largeur (barre d'eval +
   echiquier), sinon `width: 100%` la forcerait a la largeur du panneau alors
   que sa hauteur reelle -- celle de l'echiquier -- ne la remplit pas toujours,
   et un panneau en `height: auto` (le lecteur sur telephone) reboucle alors
   sur sa propre mesure a chaque frame. */
.analysis-row {
  flex: none;
  align-self: center;
  display: flex;
  align-items: stretch;
  gap: 10px;
}
/* `flex: none` : la largeur vient de `fitBoard`, jamais du partage flex avec
   la barre d'eval -- sinon la hauteur de l'echiquier (`aspect-ratio: 1`)
   suivrait une largeur amputee de la barre, et ne vaudrait plus le cote que
   `fitBoard` vient de poser. `align-self: flex-start` : meme raison que sur
   `.board-row .board-frame`, un peu plus haut dans ce fichier -- etirer
   l'echiquier boucle la hauteur sur la largeur, la largeur sur la hauteur. */
.analysis-board {
  flex: none;
  align-self: flex-start;
  aspect-ratio: 1;
}
/* Hauteur fixe : `fitBoard` ne remesure qu'au redimensionnement du panneau, une
   liste qui grandirait pousserait l'echiquier hors du panneau. */
.analysis-moves {
  flex: none;
  height: 7em;
  overflow-y: auto;
  display: flex;
  flex-wrap: wrap;
  align-content: flex-start;
  gap: 2px 4px;
  font-size: var(--fs-sm);
}
/* Une variante du premier niveau passe a la ligne sous le coup qu'elle
   remplace, en retrait ; les suivantes restent dans le fil, entre parentheses. */
.analysis-variation {
  flex-basis: 100%;
  display: flex;
  flex-wrap: wrap;
  gap: 2px 4px;
  padding-left: var(--s3);
  border-left: 1px solid var(--line);
  color: var(--text-muted);
}
.analysis-subvariation {
  display: contents;
}
.analysis-number {
  color: var(--text-muted);
  padding-left: var(--s1);
}
.analysis-move {
  border: 0;
  border-radius: var(--r-xs);
  padding: 0 4px;
  background: none;
  color: inherit;
  font: inherit;
  cursor: pointer;
}
.analysis-move:hover {
  background: var(--surface-3);
}
.analysis-move--current {
  background: var(--accent-quiet);
  color: var(--accent-bright);
}

/* ---------- la carte de solution ----------
   Au-dessus du livre, et pas dans le panneau de l'echiquier : celui-ci est
   occupe a jouer la ligne pendant que la carte la raconte. */
.read-solution-backdrop {
  /* Absolu dans `.read-book`, et non fixe au viewport : le voile ne doit pas
     couvrir l'echiquier qui rejoue la ligne que la carte explique. */
  position: absolute;
  inset: 0;
  z-index: 60;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--s4);
  background: color-mix(in srgb, var(--surface-inset) 55%, transparent);
}
.read-solution {
  max-width: 46ch;
  /* Bornee aussi par la colonne qui l'accueille : le fond n'est plus le
     viewport entier, la carte ne doit pas deborder du livre. */
  max-height: min(70dvh, 100%);
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  padding: var(--s4);
  background: var(--surface-1);
  border: 1px solid var(--accent-line);
  border-radius: var(--r-md);
  box-shadow: 0 18px 48px rgb(0 0 0 / 45%);
}
.read-solution-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s3);
}
.read-solution-title {
  margin: 0;
  font-size: var(--fs-md);
}
.read-solution-close {
  background: none;
  border: none;
  color: var(--text-muted);
  cursor: pointer;
  font-size: var(--fs-md);
  padding: 0;
}
.read-solution-close:hover,
.read-solution-close:focus-visible {
  color: var(--text-primary);
}
.read-solution-text {
  margin: 0;
  /* Le texte imprime arrive tel qu'il a ete lu, ses retours a la ligne compris. */
  white-space: pre-wrap;
  line-height: 1.6;
}
.read-solution-provenance {
  margin: 0;
  color: var(--accent-bright);
  font-size: var(--fs-sm);
}
/* La carte qui montre la page elle-meme : plus large que la carte de texte,
   bornee par le livre qu'elle couvre. Les canvas sont rendus a la densite de
   l'ecran et affiches a la largeur de la carte. */
.read-solution--page {
  max-width: min(740px, 100%);
}
.read-solution-clips {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  color: var(--text-muted);
  font-size: var(--fs-sm);
}
.read-solution-clips canvas {
  display: block;
  width: 100%;
  height: auto;
  border: 1px solid var(--line);
  border-radius: var(--r-sm);
  background: #fff;
}
/* D'ou vient le TEXTE de la carte, ce qui n'est pas d'ou vient la ligne. */
.read-solution-textsource {
  margin: 0;
  color: var(--text-muted);
  font-size: var(--fs-sm);
}
/* Les mots du livre quand ils n'ont pas pu mener (figurines perdues au scan) :
   en second, en retrait, tels quels. */
.read-solution-printed {
  margin: 0;
  padding-left: var(--s3);
  border-left: 2px solid var(--line);
  color: var(--text-muted);
  white-space: pre-wrap;
  line-height: 1.5;
  font-size: var(--fs-sm);
}

/* ---------- the shell on a phone ----------
   Under MOBILE_QUERY (board-size.js — every phone block below spells it the
   same way, a test holds them together: a narrow viewport, or a short one up
   to 900 px wide, which is a phone on its side) the rail disappears entirely.
   The views move into a bottom tab bar, the active view's sections and the
   set picker into a strip pinned at the top, and the set menu opens as a
   sheet from the bottom edge.
   AT THE END OF THE FILE on purpose: several rules in here override base
   rules of the same specificity (.set-menu, .outlet, .icon-btn, th) and
   must come later in the file than every one of them. */
@media (max-width: 700px), (max-height: 500px) and (max-width: 900px) {
  .app-body {
    display: block;
    min-height: 100dvh;
  }
  .app-rail {
    display: none;
  }
  /* iOS repaints a fixed background attachment on every scrolled frame. The
     light stays; it simply scrolls with the page here. */
  body {
    background-attachment: scroll;
  }
  /* Sticky against the page scroll, a header glued mid-screen serves nothing. */
  th {
    position: static;
  }

  .outlet,
  .outlet.wide {
    max-width: none;
    padding: 16px 16px calc(76px + env(safe-area-inset-bottom));
  }

  .app-tabbar {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 40;
    display: grid;
    grid-auto-flow: column;
    grid-auto-columns: 1fr;
    gap: 2px;
    padding: 4px 6px calc(4px + env(safe-area-inset-bottom));
    background: var(--surface-1);
    border-top: 1px solid var(--line);
  }
  .tab-item {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 2px;
    min-height: 48px;
    padding: 4px 0;
    border: 0;
    border-radius: var(--r-sm);
    background: none;
    color: var(--text-muted);
    font-family: var(--font-display);
    font-size: 11px;
    font-weight: 500;
    cursor: pointer;
  }
  .tab-item .icon {
    width: 22px;
    height: 22px;
  }
  .tab-item.active {
    background: var(--surface-3);
    color: var(--accent-bright);
  }

  /* The strip sits OUTSIDE the outlet (a sibling above it): full width by
     itself, no gutter compensation needed. */
  .app-mobile-strip {
    position: sticky;
    top: 0;
    /* Above the tab bar (40) and the move bar (30): the sticky strip is a
       stacking context, and the set sheet + scrim inside it inherit its
       level — lower, the fixed bars would paint over the open sheet. */
    z-index: 45;
    display: flex;
    align-items: center;
    gap: 8px;
    margin: 0;
    padding: 6px 12px;
    padding-top: calc(6px + env(safe-area-inset-top));
    background: var(--page);
    border-bottom: 1px solid var(--line);
  }
  .app-mobile-strip.hidden {
    display: none;
  }
  .strip-sections {
    display: flex;
    flex: 1 1 auto;
    min-width: 0;
    gap: 4px;
    overflow-x: auto;
    scrollbar-width: none;
    /* "Repertoires / Repertoire training / Woodpecker…" ran off the right edge
       with nothing to say so. A shadow at each edge that still hides a tab, and
       none once everything is in view: the covers are attached to the content
       and slide over the shadows, which are attached to the box. */
    --strip-shadow: color-mix(in srgb, var(--text-primary) 28%, transparent);
    background:
      linear-gradient(to right, var(--page) 40%, transparent) left / 32px 100% no-repeat local,
      linear-gradient(to left, var(--page) 40%, transparent) right / 32px 100% no-repeat local,
      linear-gradient(to right, var(--strip-shadow), transparent) left / 14px 100% no-repeat scroll,
      linear-gradient(to left, var(--strip-shadow), transparent) right / 14px 100% no-repeat scroll;
  }
  .strip-sections:empty {
    display: none;
  }
  .strip-tab {
    flex: none;
    min-height: 44px;
    padding: 8px 14px;
    border: 0;
    border-radius: var(--r-full);
    background: transparent;
    color: var(--text-muted);
    font-family: var(--font-display);
    font-size: var(--fs-sm);
    font-weight: 500;
    white-space: nowrap;
    cursor: pointer;
  }
  .strip-tab.active {
    background: var(--surface-3);
    color: var(--accent-bright);
  }
  /* Anchor targets land under the sticky strip without this. */
  .config-section {
    scroll-margin-top: calc(64px + env(safe-area-inset-top));
  }
  .app-mobile-strip .set-picker {
    flex: none;
    margin-left: auto;
  }
  .app-mobile-strip .set-switch {
    height: 44px;
    max-width: 46vw;
  }

  /* The set menu becomes a sheet: full width, up from the bottom edge, a scrim
     behind. The markup does not move — the same node the rail anchors above
     its foot on desktop. */
  .set-menu {
    position: fixed;
    left: 0;
    right: 0;
    top: auto;
    bottom: 0;
    z-index: 60;
    min-width: 0;
    max-width: none;
    max-height: min(70dvh, 560px);
    border-radius: var(--r-lg) var(--r-lg) 0 0;
    padding-bottom: calc(12px + env(safe-area-inset-bottom));
  }
  .set-picker:has(.set-menu:not(.hidden))::before {
    content: "";
    position: fixed;
    inset: 0;
    z-index: 50;
    background: rgba(4, 4, 5, 0.6);
  }

  /* Finger sizes for the app-level chrome. */
  .icon-btn {
    width: 44px;
    height: 44px;
  }
  .btn {
    min-height: 44px;
  }
  .choice span {
    min-height: 44px;
  }
}

/* ---------- board pages on a phone ----------
   One vertical template, chess.com's: context above, board edge to edge, the
   panel flattened into the page's own flow, move navigation pinned above the
   tab bar. The panel stops being a surface — `display: contents` dissolves the
   box and its three zones become slices of the page: the head rises above the
   board (the coach-bubble slot), the body follows it, the foot's move bar goes
   to the thumb. The contract still holds: nothing sits above or below the
   board but what the preset carries there.
   Below the shell block on purpose: its .outlet.wide must out-cascade the
   shell's own .outlet padding at equal specificity. */
@media (max-width: 700px), (max-height: 500px) and (max-width: 900px) {
  .outlet.wide {
    padding: 0 0 calc(132px + env(safe-area-inset-bottom));
  }
  .board-layout,
  .board-page--etude .board-layout {
    gap: 10px;
  }

  .board-row {
    position: relative;
  }
  .board-row .board-frame {
    flex: 0 0 auto;
    width: var(--board-size, 100vw);
    margin-inline: auto;
    /* Edge to edge: a radius has no meaning when the edges touch the screen. */
    border-radius: 0;
    box-shadow: none;
  }
  /* The evaluation bar stops costing width: a slim overlay on the board's left
     edge, the way chess.com draws it. Hidden it already costs nothing. */
  .eval-bar {
    position: absolute;
    left: 0;
    top: 0;
    height: 100%;
    width: 14px;
    z-index: 5;
    border-radius: 0;
  }

  /* The panel dissolves into the page. Its children become items of the
     board-layout grid; `order` puts the head (and Étude's read-only rail)
     above the board. */
  .board-side {
    display: contents;
  }
  .board-side-head {
    order: -1;
    padding: 4px 16px 6px;
    border-bottom: 0;
  }
  .board-page--etude .board-aside {
    order: -1;
    margin: 0 16px;
  }
  .board-side-body {
    padding: 0 16px;
  }
  /* The page scrolls; nothing inside it does. */
  .board-side .move-list-scroll,
  .board-side .move-tree {
    max-height: none;
    overflow-y: visible;
  }
  .board-side-foot {
    display: contents;
  }
  .panel-actions {
    padding: 0 16px;
  }
  .board-controls {
    position: fixed;
    left: 0;
    right: 0;
    bottom: calc(56px + env(safe-area-inset-bottom));
    z-index: 30;
    min-height: 52px;
    padding: 6px 12px;
    background: var(--surface-2);
    border-top: 1px solid var(--line);
  }
  /* The fixed bar leaves the strip about 96 px next to the nav buttons: the
     dots alone fill it. The line's tally is read on a desktop and, on a phone,
     in the end-of-session summary. */
  .session-dots .session-tally {
    display: none;
  }
  .board-nav-btn {
    width: 44px;
    height: 44px;
  }
  /* The one-notch shrink of the Set panel has no meaning once the panel IS the
     page (and `zoom` on dissolved boxes only skews measures). */
  .solve .board-side,
  .deck-view .board-side {
    zoom: 1;
    height: auto;
  }
  /* Tight in the corner: on a 47 px square every pixel of inset is a pixel over
     the piece. */
  .cg-wrap coords.ranks coord {
    padding: 1px 0 0 2px;
  }
  .cg-wrap coords.files coord {
    padding: 0 2px 1px 0;
  }
  .board-under {
    margin-left: 0;
    padding: 0 16px;
  }
  /* The entrance animation keeps a retained transform on the mounted view
     (`both` fill), and any transform turns the view into the containing
     block of every `position: fixed` inside it — the move bar would pin to
     the page instead of the screen. On a phone the entrance loses its lift
     and keeps the fade alone. */
  .outlet > * {
    animation-name: view-enter-flat;
  }
  /* Le lecteur : une colonne. Le livre prend la hauteur de l'ecran moins la barre
     d'onglets, l'echiquier vient dessous, la poignee n'a plus rien a separer. La
     grille a trois colonnes ecrasait le livre a zero pixel sur 390. */
  .outlet:has(.read-reader-shell) {
    height: auto;
    display: block;
  }
  /* Read is a `wide` view, and the phone strips a wide outlet of its gutters
     for the board's sake: the library then ran its sentence and its cards
     into the screen's edges. The gutters come back for the library alone --
     the book keeps the full width. */
  .read {
    padding: 0 var(--s4);
  }
  .read:has(.read-reader-shell) {
    padding: 0;
  }
  /* The card's text links (Delete this book, Open the set) grow a finger's
     height without growing their words. */
  .read-card-link {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
  }
  .read-reader {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: auto auto;
    height: auto;
  }
  .read-handle {
    display: none;
  }
  /* The screen less the tab bar, the outlet's top gutter and the Library
     link above the book: the book's bottom edge used to sit under the tab
     bar by the height of that link. */
  .read-book {
    height: calc(100dvh - 76px - 16px - 44px - var(--s3) - env(safe-area-inset-bottom));
  }
  .read-board {
    height: auto;
    min-height: 360px;
  }
  /* A finger's target for the way back: a 61 x 16 text link was not one. */
  .read-back {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
    padding: 0 var(--s2);
  }
  /* One row, not five stacked buttons: the chapter list takes the width, the
     tools keep their icons and lose their words (`aria-label` names them). The
     row only wraps for what cannot fit on one -- the cut's confirmation, the
     sealed notice. */
  .read-toolbar {
    flex-wrap: nowrap;
    justify-content: flex-start;
    gap: var(--s2);
  }
  .read-toolbar:has(.read-cut-confirm, .read-sealed) {
    flex-wrap: wrap;
  }
  .read-sealed {
    flex: 1 1 100%;
  }
  .read-toc {
    flex: 1 1 auto;
    min-width: 0;
    max-width: none;
    min-height: 44px;
    margin-right: 0;
  }
  .read-tool {
    flex: none;
    width: 44px;
    min-height: 44px;
    padding: 0;
  }
  .read-tool-label {
    display: none;
  }
  .read-zoom {
    display: inline-flex;
  }
  .read-toolbar-chrome {
    margin-left: 0;
  }
}

/* ---------- Puzzles on a phone ----------
   What the mobile persona met on Set and Rush: "Reveal" and "Skip" at y=1016
   on an 812 screen, a 200 px head over the board, the score and the lives of
   a rush under the tab bar. The head folds to its lines, the panel's whole
   foot -- move bar AND actions -- goes to the thumb, and a run's measures
   share one row. Scoped to `.puzzles`: the other board pages keep the move
   bar alone down there. */
@media (max-width: 700px), (max-height: 500px) and (max-width: 900px) {
  .puzzles .board-side-head {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 2px 10px;
    padding: 6px 16px 4px;
  }
  /* The strip above already says Set, Rush or Streak. */
  .puzzles .side-kicker {
    display: none;
  }
  .puzzles .side-kicker-row {
    flex: 1 1 100%;
    min-height: 0;
  }
  .puzzles .board-side-head h2 {
    margin: 0;
    font-size: var(--fs-md);
  }
  .puzzles .board-side-head .solve-feedback {
    flex: 1 1 100%;
    order: 3;
    min-height: 0;
    margin: 0;
    font-size: var(--fs-sm);
  }
  .puzzles .board-side-head .solve-feedback:empty {
    display: none;
  }
  .puzzles .board-side-head .hint-line {
    flex: 1 1 100%;
    order: 4;
    margin: 0;
  }
  /* One line of chips, scrolled sideways past the edge rather than wrapped. */
  .puzzles .side-brief {
    flex: 1 1 auto;
    flex-wrap: nowrap;
    min-width: 0;
    margin: 0;
    overflow-x: auto;
    scrollbar-width: none;
  }
  .puzzles .side-brief > * {
    flex: none;
  }

  /* The foot: the move bar, then the actions, in one bar over the tab bar. */
  .puzzles .board-side-foot {
    position: fixed;
    left: 0;
    right: 0;
    bottom: calc(56px + env(safe-area-inset-bottom));
    z-index: 30;
    display: flex;
    flex-direction: column;
    gap: 6px;
    padding: 6px 12px;
    background: var(--surface-2);
    border-top: 1px solid var(--line);
  }
  .puzzles .board-controls {
    position: static;
    min-height: 0;
    padding: 0;
    background: none;
    border-top: 0;
  }
  .puzzles .panel-actions {
    padding: 0;
  }
  .puzzles .panel-actions .btn {
    min-height: 44px;
  }
  /* The verdict and the save banner land where the thumb already is. */
  .puzzles .verdict-banner.shown {
    position: fixed;
    left: 0;
    right: 0;
    bottom: calc(56px + env(safe-area-inset-bottom));
    z-index: 31;
  }
  .outlet.wide:has(.puzzles) {
    padding-bottom: calc(196px + env(safe-area-inset-bottom));
  }

  /* A run's three measures on one row, the chain under them: the clock, the
     score and the lives read at a glance, without a scroll mid-run. */
  .puzzles .run-panel {
    flex-direction: row;
    flex-wrap: wrap;
    align-items: flex-start;
    gap: 10px 14px;
    height: auto;
  }
  .puzzles .run-hero,
  .puzzles .run-score-block,
  .puzzles .run-lives-block {
    flex: 1 1 0;
    min-width: 0;
    padding-bottom: 0;
    border-bottom: 0;
  }
  .puzzles .run-hero {
    position: static;
    align-items: flex-start;
    text-align: left;
    background: none;
  }
  .puzzles .run-hero-figure {
    font-size: var(--fs-2xl);
  }
  .puzzles .run-chain-block {
    flex: 1 1 100%;
  }
}

@keyframes view-enter-flat {
  from {
    opacity: 0;
  }

}

/* ---------- Said out loud: toasts, the ? card, the house words ----------
   Three pieces of chrome that belong to no page. */
.toasts {
  position: fixed;
  left: 50%;
  bottom: calc(24px + env(safe-area-inset-bottom));
  z-index: 60;
  display: flex;
  flex-direction: column;
  gap: 8px;
  width: min(560px, calc(100vw - 32px));
  transform: translateX(-50%);
  pointer-events: none;
}
.toast {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 12px 10px 16px;
  border: 1px solid var(--line-strong);
  border-left: 4px solid var(--status-critical);
  border-radius: var(--r-md);
  background: var(--surface-3);
  box-shadow: var(--shadow-2);
  color: var(--text-primary);
  font-size: var(--fs-sm);
  pointer-events: auto;
  animation: toast-in var(--t-base) var(--ease);
}
.toast.info {
  border-left-color: var(--accent);
}
.toast-text {
  flex: 1 1 auto;
  min-width: 0;
}
.toast-close {
  flex: none;
  width: 32px;
  height: 32px;
  border: 0;
  border-radius: var(--r-full);
  background: transparent;
  color: var(--text-muted);
  font-size: 20px;
  line-height: 1;
  cursor: pointer;
}
.toast-close:hover {
  background: var(--surface-2);
  color: var(--text-primary);
}
@keyframes toast-in {
  from {
    opacity: 0;
    transform: translateY(8px);
  }
}
/* On a phone the tab bar owns the bottom: the toasts sit above it. */
@media (max-width: 700px), (max-height: 500px) and (max-width: 900px) {
  .toasts {
    bottom: calc(76px + env(safe-area-inset-bottom));
  }

  /* ---------- Plan on a phone ---------- */

  /* The Go buttons take a row of their own under the bar: beside the count
     they left the text column eighty pixels wide, "white 23 due" on seven
     lines and a set's name on four. */
  .plan-target {
    grid-template-columns: auto minmax(0, 1fr) auto;
    grid-template-areas:
      "check text  count"
      "check bar   bar"
      "check go    go";
  }
  .plan-target-go {
    justify-content: flex-end;
  }

  /* ---------- Profile on a phone ---------- */

  /* The two side tiles stop sharing a height: stretched to the calendar's,
     the Woodpecker tile showed three lines and three hundred pixels of nothing. */
  .profile-side {
    grid-template-rows: auto auto;
  }
  /* One axis per line: the name, the score, the figure it was made of. Seven
     cards of 120 px for a word and a number made the page four screens long. */
  .profile-dims {
    grid-template-columns: 1fr;
    gap: 6px;
  }
  .profile-dim {
    flex-direction: row;
    align-items: center;
    gap: 10px;
    padding: 8px 12px;
    min-height: 44px;
  }
  .profile-dim-name {
    flex: 1 1 auto;
  }
  .profile-dim-score {
    font-size: var(--fs-lg);
  }
  .profile-dim-measure {
    order: 1;
    flex: 0 1 auto;
    text-align: right;
  }
  .profile-dim-trend,
  .profile-dim-none {
    order: 2;
    min-height: 0;
  }
  .profile-dim-spark {
    width: 56px;
    height: 18px;
  }
  .profile-dim-tag {
    top: -7px;
    right: 10px;
  }
  /* The strip of sections scrolls sideways: its right edge fades so the
     reader sees there is more. The padding keeps the last tab clear of the
     fade once scrolled to the end. */
  .strip-sections {
    padding-inline-end: 28px;
    mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
  }
}

.shortcuts {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: grid;
  place-items: center;
  padding: 24px;
  background: rgba(3, 5, 18, 0.62);
}
/* `.hidden` is declared far above and loses to the `display: grid` here at
   equal specificity: without this line the card veiled every page on load. */
.shortcuts.hidden {
  display: none;
}
.shortcuts-card {
  width: min(440px, 100%);
  max-height: calc(100dvh - 48px);
  overflow: auto;
  padding: 20px 22px;
  border: 1px solid var(--line-strong);
  border-radius: var(--r-lg);
  background: var(--surface-1);
  box-shadow: var(--shadow-2);
}
.shortcuts-card h2 {
  margin: 0 0 12px;
  font-size: var(--fs-lg);
}
.shortcuts-list {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 8px 14px;
  align-items: center;
  margin: 0;
}
.shortcuts-list dt,
.shortcuts-list dd {
  margin: 0;
}
.shortcuts-list dd {
  color: var(--text-secondary);
  font-size: var(--fs-sm);
}
.shortcuts-empty {
  grid-column: 1 / -1;
  color: var(--text-muted);
}
.shortcuts-card .hint {
  margin: 14px 0 0;
}
kbd {
  display: inline-block;
  min-width: 26px;
  padding: 2px 7px;
  border: 1px solid var(--line-strong);
  border-bottom-width: 2px;
  border-radius: 6px;
  background: var(--surface-inset);
  color: var(--text-primary);
  font-size: var(--fs-xs);
  text-align: center;
}

/* A house word: a dotted underline, its sentence on hover, focus or tap. */
.term {
  position: relative;
  text-decoration: underline dotted;
  text-underline-offset: 3px;
  text-decoration-color: var(--text-muted);
  cursor: help;
  outline-offset: 2px;
}
.term::after {
  content: attr(data-def);
  position: absolute;
  left: 0;
  bottom: calc(100% + 6px);
  z-index: 40;
  display: none;
  width: max-content;
  max-width: min(320px, 80vw);
  padding: 8px 10px;
  border: 1px solid var(--line-strong);
  border-radius: var(--r-sm);
  background: var(--surface-3);
  box-shadow: var(--shadow-2);
  color: var(--text-primary);
  font-family: var(--font);
  font-size: var(--fs-xs);
  font-weight: 500;
  line-height: 1.4;
  text-align: left;
  text-decoration: none;
  text-transform: none;
  letter-spacing: normal;
  white-space: normal;
}
.term:hover::after,
.term:focus::after {
  display: block;
}

/* ---------- Review on a phone ----------
   After the shell block: the etude rail rule below must out-cascade the
   900 px one (`width: 100%`) at equal specificity. */
@media (max-width: 700px), (max-height: 500px) and (max-width: 900px) {
  /* `width: 100%` from the 900 px rule plus 16 px of margin on each side
     overflowed the phone by 32 px and floated the whole page sideways: a grid
     item stretches on its own. And the banner wraps rather than scrolls: a
     strip 520 px wide on a 375 px screen kept "Your accuracy" off to the
     right, where nobody looks for it. */
  .board-page--etude .board-aside {
    width: auto;
    height: auto;
    flex-wrap: wrap;
    row-gap: 4px;
    padding: 8px 12px;
    overflow: visible;
  }
}
/* A finger: no arrows, no wheel, and the keys that name them are not worth
   the line they take. */
@media (pointer: coarse) {
  .keyboard-only {
    display: none;
  }
  .move-chip {
    min-height: 44px;
  }
}
/* The move list as a ribbon under the board, the way Lichess does it on a
   phone: one row that scrolls sideways, each move a finger wide. `contents`
   on the line dissolves the number/white/black grid into the ribbon. */
@media (pointer: coarse) and (max-width: 700px), (pointer: coarse) and (max-height: 500px) and (max-width: 900px) {
  .move-list {
    flex-direction: row;
    flex-wrap: nowrap;
    align-items: center;
    gap: 6px;
    padding: 4px 0 8px;
    overflow-x: auto;
    overflow-y: hidden;
    scrollbar-width: thin;
  }
  .move-line {
    display: contents;
  }
  /* A ribbon has no room for the clocks. */
  .move-times {
    display: none;
  }
  .move-number {
    flex: none;
  }
  .move-chip {
    flex: none;
    padding: 0 10px;
    white-space: nowrap;
  }
}

/* ---------- a phone on its side ----------
   The phone template (MOBILE_QUERY, board-size.js) holds up to 900 px wide
   while the viewport is short; this is the part of it that only makes sense
   sideways. The board takes the height -- less the strip and the tab bar,
   MOBILE_LANDSCAPE_RESERVE in board-size.js -- and the panel comes back as a
   column beside it: the desktop's shape with the phone's chrome. The move bar
   and the actions return to the panel's foot, since under the board is now
   under the screen. After every phone block on purpose: it undoes their
   portrait rules at equal specificity. */
@media (max-height: 500px) and (max-width: 900px) {
  .outlet.wide,
  .outlet.wide:has(.puzzles) {
    padding: 8px 12px calc(64px + env(safe-area-inset-bottom));
  }
  .board-layout,
  .board-page--etude .board-layout {
    grid-template-columns: var(--board-size, 240px) minmax(0, 1fr);
    grid-template-rows: auto;
    justify-content: start;
    gap: 12px;
  }
  .board-column,
  .board-page--etude .board-column {
    grid-column: 1;
    grid-row: 1;
  }
  .board-side,
  .board-page--etude .board-side,
  .solve .board-side,
  .deck-view .board-side {
    grid-column: 2;
    grid-row: 1;
    display: flex;
    height: var(--board-size, 240px);
    max-height: none;
    overflow: hidden;
    background: var(--surface-1);
    border: 1px solid var(--border);
    border-radius: var(--r-md);
  }
  /* Étude's rail goes under both columns: the meta is consulted, not started
     with, and there is no third column to give it. */
  .board-aside,
  .board-page--etude .board-aside {
    grid-column: 1 / -1;
    grid-row: 2;
    order: 0;
    margin: 0;
  }
  .board-row .board-frame {
    border-radius: var(--r-md);
  }
  .board-side-head {
    padding: 6px 12px 4px;
    border-bottom: 1px solid var(--border);
  }
  .board-side-body,
  .board-side-body:has(.move-list-scroll),
  .board-side-body:has(.move-tree) {
    padding: 6px 12px;
    overflow-y: auto;
  }
  .board-side-foot,
  .puzzles .board-side-foot {
    position: static;
    display: flex;
    flex-direction: column;
    gap: 6px;
    padding: 6px 12px 8px;
    background: none;
    border-top: 1px solid var(--line);
  }
  .board-controls {
    position: static;
    min-height: 0;
    padding: 0;
    background: none;
    border-top: 0;
  }
  .panel-actions,
  .puzzles .panel-actions {
    padding: 0;
  }
  /* The reader: the outlet's gutters are the landscape ones above (8 px over,
     64 px under), and the book takes what they leave. The one-column zoom is
     portrait's (read-reader.js): sideways, the page whole is two columns as
     wide as the portrait screen. */
  .read-book {
    height: calc(100dvh - 64px - 8px - 44px - var(--s3) - env(safe-area-inset-bottom));
  }
  .read-zoom {
    display: none;
  }
}

/* The second half of an admin's sign-in: one field, the code, in the place of
   the provider buttons it replaces. */
.login-code {
  display: grid;
  gap: var(--s3);
}
.login-code input {
  text-align: center;
  font-size: var(--fs-xl);
  letter-spacing: 0.2em;
}

/* The door to the admin area, at the foot of the rail, for admins only. The
   folded rail has no room for a word: the More page carries it on a phone. */
.account-admin {
  font-family: var(--font-display);
  font-size: var(--fs-sm);
  color: var(--accent-bright);
  text-decoration: none;
  padding: 0 var(--s2);
}
.rail-narrow .app-rail .account-admin {
  display: none;
}

/* ---- Studies: the library section and the editor ---------------------- */
.study-library {
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  padding-top: var(--s4);
  border-top: 1px solid var(--line-strong);
}
.study-library-title {
  margin: 0;
  font-size: var(--fs-lg);
}
.study-card .read-title {
  min-height: 2.6em;
}
.study-mount {
  display: flex;
  flex-direction: column;
}
/* Three columns: the chapters, the board, the moves. The board column takes
   what the viewport height leaves; below 1100px the columns stack. */
/* The board is a `.board-frame` in the "etude" preset (board-frame.js):
   `--board-size` comes from the window and the corner handle, like every
   other board page. The middle column is that size plus the eval bar. */
.study {
  display: grid;
  grid-template-columns: 260px max-content auto;
  gap: var(--s4);
  align-items: start;
  flex: 1;
  min-height: 0;
}
.study-side {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  max-height: calc(100dvh - 120px);
  overflow-y: auto;
  padding-right: var(--s1);
}
.study-side-head {
  display: flex;
  flex-direction: column;
  gap: var(--s1);
}
.study-name {
  margin: 0;
  font-size: var(--fs-lg);
  line-height: 1.25;
}
.study-chapter {
  border-top: 1px solid var(--line-faint);
}
.study-chapter-name {
  cursor: pointer;
  padding: var(--s2) 0;
  font-weight: 600;
  font-size: var(--fs-sm);
  color: var(--text-secondary);
  list-style-position: inside;
}
.study-chapter[open] > .study-chapter-name {
  color: var(--text-primary);
}
.study-entries {
  list-style: none;
  margin: 0 0 var(--s2);
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.study-entry {
  width: 100%;
  text-align: left;
  border: 0;
  border-radius: var(--r-xs);
  padding: var(--s1) var(--s2);
  background: none;
  color: var(--text-secondary);
  font: inherit;
  font-size: var(--fs-sm);
  cursor: pointer;
}
.study-entry:hover {
  background: var(--surface-2);
  color: var(--text-primary);
}
.study-entry--current {
  background: var(--accent-quiet);
  color: var(--accent-bright);
}
.study-entry--game::before {
  content: "♟ ";
  color: var(--text-muted);
}
.study-main {
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  /* The column is exactly the board plus the eval bar: the actions wrap
     under it instead of stretching the column. */
  width: calc(var(--board-size, 560px) + 38px);
}
.study-title {
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
  font-size: var(--fs-lg);
  line-height: 1.25;
}
.study-title small {
  font-size: var(--fs-sm);
  font-weight: 400;
  color: var(--text-muted);
}
/* The board's side: the column's width, capped by the viewport's height so the
   moves and the actions stay on screen. The eval bar (28px + 10px gap) is
   taken out of the width, so turning the engine on never resizes the board. */
.study-main .analysis-row {
  align-self: stretch;
}
.study-status {
  margin: 0;
  font-size: var(--fs-sm);
  color: var(--text-muted);
  min-height: 1.4em;
}
.study-status--error {
  color: var(--status-critical);
}
.study-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s2);
  align-items: center;
}
.study-confirm {
  display: inline-flex;
  align-items: center;
  gap: var(--s2);
  font-size: var(--fs-sm);
}
.study-notes {
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  /* A reading column, not the rest of the screen: 380px by default, dragged
     from its bottom-right corner (native resize) between 280px and what the
     nav, the chapters and the board leave. */
  width: 380px;
  min-width: 280px;
  max-width: calc(100vw - 230px - 260px - var(--study-board) - 38px - 3 * var(--s4));
  height: calc(100dvh - 120px);
  overflow: auto;
  resize: horizontal;
}
/* The moves list grows with the line here: nothing under it is measured. */
.study-moves.analysis-moves {
  height: auto;
  max-height: 40dvh;
  padding: var(--s2);
  background: var(--surface-inset);
  border-radius: var(--r-xs);
}
.study-moves .analysis-move--current {
  background: var(--accent-quiet);
  color: var(--accent-bright);
}
/* A commented move carries a small mark: the comment is read on the right,
   under the moves, once the move is the current one. */
.analysis-move--commented::after {
  content: "";
  display: inline-block;
  width: 4px;
  height: 4px;
  margin-left: 3px;
  vertical-align: super;
  border-radius: 50%;
  background: var(--accent-bright);
}
.study-comment-label {
  margin: 0;
  font-weight: 600;
  font-size: var(--fs-sm);
  color: var(--text-secondary);
}
/* Reading text: a measure, a line-height, room between paragraphs. */
.study-comment {
  font-size: 15px;
  line-height: 1.55;
  max-width: 62ch;
  color: var(--text-primary);
}
.study-comment p {
  margin: 0 0 0.8em;
}
.study-comment p:last-child {
  margin-bottom: 0;
}
/* The note of the move shown, typed in place: it grows with its text. */
.study-comment-input {
  field-sizing: content;
  min-height: 7em;
  max-height: 50dvh;
  width: 100%;
  padding: var(--s2);
  font: inherit;
  font-size: 15px;
  line-height: 1.55;
  color: var(--text-primary);
  background: var(--surface-inset);
  border: 1px solid var(--line);
  border-radius: var(--r-xs);
  resize: vertical;
}
.study-add {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s1);
  padding: var(--s1) 0 var(--s2);
}
.study-add .btn {
  font-size: var(--fs-sm);
}
.study-paste {
  display: flex;
  flex-direction: column;
  gap: var(--s1);
  width: 100%;
}
.study-paste textarea,
.study-paste input {
  width: 100%;
  font: inherit;
  font-size: var(--fs-sm);
}
.study-details summary {
  cursor: pointer;
  font-size: var(--fs-sm);
  color: var(--text-secondary);
}
.study-details-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  gap: var(--s2);
  padding-top: var(--s2);
}
.study-field {
  display: flex;
  flex-direction: column;
  gap: 2px;
  font-size: var(--fs-sm);
  color: var(--text-muted);
}
.study-field input,
.study-field select {
  font: inherit;
  color: var(--text-primary);
}
@media (max-width: 1100px) {
  .study {
    grid-template-columns: minmax(0, 1fr);
  }
  .study-main {
    width: 100%;
  }
  .study-side,
  .study-notes {
    max-height: none;
    overflow: visible;
  }
  .study-notes {
    width: auto;
    max-width: none;
    height: auto;
    resize: none;
  }
}
