/* Reflex Chess: the desktop layout.
 *
 * WHY THIS IS ITS OWN FILE. styles.css is the app's design language and it is
 * written mobile-first on purpose -- a fixed 100dvh viewport, one scrolling
 * pane, a thumb-height tab bar. Everything a pointer and a 1440px window need
 * is a different SHAPE, not a different skin, and mixing the two in one sheet
 * is how the 34rem column ends up being described in fourteen places. So the
 * shape lives here, in one block, behind one breakpoint, loaded last.
 *
 * LOADED LAST IS LOAD-BEARING. Almost every rule below overrides one in
 * styles.css at equal specificity, and at equal specificity the later
 * declaration wins. Keep this <link> after styles.css and after every surface
 * sheet (rush, compete, duel, play) or the rail silently keeps the mobile bar's
 * layout -- which is the exact failure the desktop block at the end of
 * styles.css already carries a comment about.
 *
 * THE BREAKPOINT IS 64rem AND THERE IS ONLY ONE. Below it the only rules that
 * apply are the skip-link styles and the two `order` declarations, and neither
 * paints anything against today's markup. Proven rather than asserted: every
 * view at 390x844, in both themes, renders byte for byte identically with this
 * sheet loaded and without it.
 *
 * ---------------------------------------------------------------------------
 * THE LAYOUT, AND WHY THIS ONE
 *
 * A left rail plus a content area, and it is not chrome copied off chess.com --
 * it is the only shape that fits what this app IS.
 *
 *   1. The tree cannot use width. The road and the nodes live in a fixed 320px
 *      coordinate space that app.js computes (COL/STEP/AMP) so the curve passes
 *      through the icons rather than near them. Stretching the column would
 *      break the alignment that is the whole point of drawing a path at all.
 *      So the extra width on a monitor has to be spent on something OTHER than
 *      the tree, and the only honest something is context: what today's goal
 *      is, how long the streak is, how much is left. That is the right rail.
 *
 *   2. A trainer's navigation is a place, not a mode. Five destinations with
 *      labels, always visible, is what a pointer expects and what lets the
 *      board keep the middle of the screen. A bottom bar on a desktop is
 *      answering a thumb that is not there.
 *
 *   3. The rail STAYS DURING A LESSON. The mobile app hides the tab bar for a
 *      full-screen puzzle because 390px has no room for both; a desktop does,
 *      and every trainer worth sitting next to keeps its navigation up while
 *      you work. It also removes a layout shift: entering a lesson no longer
 *      changes the shape of the window.
 *
 *   4. Everything else -- Tools, Practice, Compete, You -- is a list of cards
 *      that was one column because a phone has one column. Given width they
 *      become a grid, which is the difference between a page and a receipt.
 *
 * THE FIELD IS NOT MINE. styles.css owns the background: one fixed layer on
 * body::before, composed from --field-base / --glow-key / --glow-accent /
 * --field-veil, recoloured per tab. Nothing here paints a gradient. Chrome that
 * has to sit on the field (the rail, the stats bar) uses --field-veil, which is
 * that sheet's token for exactly this, so the two surfaces cannot drift.
 *
 * CONTRAST. Every colour below is a token or a color-mix over one, and the two
 * places where the mobile value did not survive the change of background are
 * called out where they are fixed (see .tab.on in light mode).
 */


/* ---------- skip link ----------
 *
 * Styled here, and inert until index.html carries the anchor (see the patch in
 * the report). Off-screen until focused, then a real target at the top left --
 * the standard shape, because a skip link that is visible all the time is
 * chrome nobody asked for and one that never becomes visible is a lie.
 *
 * WHY IT MATTERS HERE MORE THAN ANYWHERE. The tree renders ~88 skill buttons
 * before the navigation in DOM order, so a keyboard user's route from the top
 * of the page to "Practice" is eighty-eight presses of Tab. */
.skiplink {
  position: fixed;
  top: .5rem; left: .5rem;
  z-index: 100;
  transform: translateY(-200%);
  padding: .7rem 1.1rem;
  border-radius: .8rem;
  font: inherit; font-weight: 700; font-size: .9rem;
  color: var(--ink); background: var(--card);
  text-decoration: none;
  box-shadow: 0 8px 26px rgb(0 0 0 / .45), 0 0 0 1px var(--rule);
}
.skiplink:focus,
.skiplink:focus-visible { transform: translateY(0); }
/* #app is given tabindex="-1" so the skip link can move focus into the view.
   A 3px ring around the entire content area is not useful feedback. */
#app:focus, #app:focus-visible { outline: none; }

/* ---------- tab order ----------
 *
 * PAIRED WITH THE index.html PATCH THAT MOVES <nav id="tabbar"> ABOVE <main
 * id="app">, and a no-op until that lands.
 *
 * The nav is second in the DOM today, with a comment arguing that content
 * before navigation is what a keyboard user wants. On a tree of 88 focusable
 * skill buttons it is the opposite: reaching "Practice" from the top of the
 * page costs 88 presses of Tab, and on a desktop the navigation is also the
 * first thing on screen, so the DOM order and the visual order disagree.
 *
 * Moving it in the markup and putting it back with `order` fixes the focus
 * order at every width and changes not one rendered pixel: below the
 * breakpoint #shell is a flex column, so order alone decides which end the bar
 * sits at, and above it both children are placed explicitly by grid-column.
 * Outside the media query because the reason applies to a phone too. */
#shell > #app    { order: 1; }
#shell > #tabbar { order: 2; }

/* ===========================================================================
   DESKTOP
   =========================================================================== */
@media (min-width: 64rem) {

  /* ---------- focus ----------
   *
   * INSIDE THE MEDIA QUERY, AND THAT WAS NOT THE FIRST CHOICE. A keyboard is
   * not a screen width, so this block started out global -- and the mobile
   * comparison caught it: rush.js focuses the Start button as the intro screen
   * mounts, and a script-driven focus with no prior pointer event DOES match
   * :focus-visible in Chrome. A global rule therefore repainted a ring around
   * one button on the phone, which is a regression however small. The phone
   * keeps the UA ring it has today; the desktop chrome, which is the thing a
   * keyboard user is actually going to tab through, gets this one.
   *
   * The app is built out of custom controls -- .node is a <button> with no
   * border, .tab is an <a> styled as a row -- so a ring in the accent, offset
   * off the shape, is what makes tabbing through the tree legible at all. */
  :where(
    a, button, input, select, textarea, [tabindex]
  ):focus-visible {
    outline: 3px solid var(--brand-lt, #8b96ff);
    outline-offset: 2px;
    border-radius: max(.35rem, .25em);
  }
  /* Round targets get a round ring. The outline follows the border-radius, so
     these only have to restate the shape, not the ring. */
  .node:focus-visible { outline-offset: 4px; border-radius: 1rem; }
  .stat:focus-visible,
  .chip:focus-visible,
  .swatch:focus-visible { border-radius: 999px; }
  .btn:focus-visible,
  .toolcard:focus-visible,
  .rush-card:focus-visible,
  .mode-card:focus-visible { border-radius: var(--radius, 1.15rem); }
  .tab:focus-visible { outline-offset: -3px; }

  /* ---------- the frame ----------
   *
   * The shell fills the window instead of being a 68rem card floating in it.
   * A fixed-width slab on a 1920px monitor is the same mistake as a 34rem
   * column on a 1440px one, one size later: the rail should be at the edge of
   * the screen, where a pointer throws itself without aiming, and the CONTENT
   * is what gets a measure -- not the container.
   *
   * `auto minmax(0, 1fr)` rather than a fixed first track. The rail sets its
   * own width, so if anything ever hides it (a future full-screen mode, or a
   * deployment that ships without navigation) the first track collapses to
   * zero and the content takes the whole window. The fixed 15.5rem track it
   * replaces did not: hiding the tab bar for a lesson left a 15.5rem empty
   * column on screen and shoved the board off centre by half of it, which is
   * what a desktop lesson actually looked like before this file.
   *
   * min-width: 0 on the content track is what stops a wide board or a long
   * unbroken word from inflating the grid past the viewport. */
  #shell {
    max-width: none;
    width: 100%;
    grid-template-columns: auto minmax(0, 1fr);
    box-shadow: none;
  }

  /* ---------- the rail ---------- */

  #tabbar {
    width: var(--dt-rail);
    flex: none;
    padding: 0;
    gap: 0;
    /* The same veil the stats bar uses, so the two pieces of chrome are one
       material and the field still shows through both. */
    background: var(--field-veil);
    border-top: 0;
    border-right: 1px solid var(--rule);
    /* The mobile bar blurs because it sits over scrolling content. Nothing
       scrolls under the rail, so the blur is a full-height GPU pass buying
       nothing. */
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
    /* The bar is hidden by app.js for a lesson, a rush and the results screen.
       On a desktop it stays: see (3) in the header note. The [hidden] attribute
       is still what those views set, and the site footer still keys off it, so
       nothing else in the app has to know. */
  }
  #tabbar[hidden] { display: flex !important; }
  /* Except when there is nothing in it. A deep link straight to a lesson --
     a reload, a shared URL -- renders the board without ever calling
     paintTabbar, so the element exists with no tabs inside it. Shown, that is a
     rail containing a wordmark and a footer and nothing else. :empty is the
     honest test and it ignores generated content, so the two pseudo elements
     above do not defeat it. The `auto` track then collapses to zero and the
     board takes the whole window, which is the right answer for that case. */
  #tabbar:empty { display: none !important; }

  /* The wordmark. The rail is the only permanent surface at this width, so it
     is the one place the app can afford to say its own name in full.
     EXACTLY AS TALL AS THE STATS BAR, and both are set from --dt-head rather
     than from two padding sums that happen to agree: the rail's hairline and
     the header's hairline meet at the same y, so the chrome reads as one bar
     across the window with a corner in it rather than as two panels that
     nearly line up. box-sizing has to be restated because a pseudo element
     does not inherit it from the `*` rule. */
  #tabbar::before {
    content: "Reflex Chess";
    box-sizing: border-box;
    display: flex;
    align-items: center;
    height: var(--dt-head);
    padding: 0 1.15rem;
    margin-bottom: 1rem;
    border-bottom: 1px solid var(--rule);
    font-weight: 800; font-size: 1.1rem; letter-spacing: -.025em;
    color: var(--ink);
  }

  /* The rail's foot. Two facts that are true, unmatchable by anything running
     ads, and repeated nowhere else in the chrome. Pushed to the bottom by the
     auto margin, which is what gives the rail a top and a bottom rather than a
     list that stops. */
  #tabbar::after {
    content: "Free forever \00b7  No ads";
    display: block;
    margin: auto .7rem 0;
    padding: 1rem .45rem 1.15rem;
    border-top: 1px solid var(--rule);
    font-size: .72rem; font-weight: 700;
    letter-spacing: .06em; text-transform: uppercase;
    color: var(--dim);
  }

  .tab {
    flex: 0 0 auto;
    flex-direction: row;
    align-items: center;
    justify-content: flex-start;
    gap: .8rem;
    margin: .1rem .7rem;
    padding: .68rem .85rem;
    border-radius: .85rem;
    font-size: .95rem; font-weight: 700;
    text-transform: none;
    letter-spacing: -.01em;
    color: var(--dim);
    transition: background .14s ease, color .14s ease;
  }
  /* A fixed box for the glyph, so five different chess characters with five
     different side bearings still line their labels up. */
  .tab-icon {
    font-size: 1.45rem;
    width: 1.6rem;
    text-align: center;
    flex: none;
  }
  /* THE #tabbar PREFIX IS NOT DECORATION. styles.css now colours every bare
     anchor -- `:root:not([data-theme="light"]) a { color: var(--brand-lt) }`,
     added for the licence links on the You tab -- and that selector scores
     (0,2,1) against `.tab`'s (0,1,0), so it wins. The result is that all five
     nav links, active or not, render in the same brand indigo and the only
     thing left saying where you are is the pill. An id in the selector is what
     puts the rail back in charge of its own colours. */
  #tabbar .tab { color: var(--dim); }
  #tabbar .tab:hover { background: color-mix(in srgb, var(--ink) 8%, transparent); color: var(--ink); }
  .tab:active .tab-icon { transform: none; }

  #tabbar .tab.on {
    background: color-mix(in srgb, var(--brand) 18%, transparent);
    color: var(--brand-lt);
  }
  /* MEASURED, NOT PICKED. Light mode inherits `.tab.on { color: var(--brand) }`
     from styles.css, and #5b6cff on the 15%-indigo pill it also sets computes
     to 3.37:1 -- a fail, shipping today, on the one word in the rail that says
     where you are. --brand-dk on the same pill is 4.67:1 and is a colour the
     palette already owns. Dark mode was never in danger: --brand-lt reads
     6.03:1 on its pill. */
  :root[data-theme="light"] #tabbar .tab.on {
    color: var(--brand-dk);
    background: color-mix(in srgb, var(--brand) 14%, transparent);
  }
  #tabbar .tab.on:hover { background: color-mix(in srgb, var(--brand) 24%, transparent); }
  .tab.on .tab-icon { animation: none; transform: none; }
  .tab span:not(.tab-icon) { position: static; }

  /* The marker. styles.css switches both pseudo elements off at rail width
     because the mobile pill inflates into a disc behind the label; this brings
     ::after back as the thing that pill was for -- one small mark, at the edge
     of the row, that finds the active tab from the corner of the eye. */
  .tab.on::after {
    content: "";
    display: block;
    position: absolute;
    left: 0; top: 25%; bottom: 25%;
    width: 3px;
    border-radius: 0 3px 3px 0;
    background: currentColor;
  }

  /* ---------- the content column ----------
   *
   * ONE MEASURE PER SURFACE, SET AS A VARIABLE ON body, READ BY BOTH THE
   * HEADER AND THE SCROLLER. The stats bar and the page under it have to share
   * a right edge or the numbers float off into the corner while the content
   * sits centred -- which is what the first desktop pass did, and it reads as
   * two pages stacked.
   *
   * Centred by padding rather than by capping every child. styles.css caps a
   * hand-written list of thirteen selectors at 34rem, so any element it does
   * not name (the Tools header, for one) runs full bleed while its own cards
   * sit centred. Padding on the scroller cannot miss a child. */
  body {
    --dt-head: 3.4rem;      /* the height of the stats bar and of the rail's own head */
    --dt-rail: 15.5rem;     /* the navigation */
    --dt-gut: 2rem;         /* the smallest gap between the content and anything */
    --dt-tree: 25rem;       /* the course column: the 320px road plus its shoulders */
    --dt-side: 17rem;       /* the day's numbers, beside the tree */
    --dt-col: 34rem;
  }
  /* Learn's measure is the two columns plus the gap between them, stated once
     so the stats bar's right edge lands on the rail's right edge. */
  body[data-tab="learn"]    { --dt-col: calc(var(--dt-tree) + var(--dt-gut) + var(--dt-side)); }
  body[data-tab="practice"] { --dt-col: 54rem; }
  body[data-tab="compete"]  { --dt-col: 52rem; }
  body[data-tab="tools"]    { --dt-col: 64rem; }
  body[data-tab="you"]      { --dt-col: 52rem; }

  #app .scroll {
    padding-inline: max(var(--dt-gut), calc((100% - var(--dt-col)) / 2));
  }
  /* Undo the per-child cap. It has to be an #app rule to beat `.scroll > .unit`
     and friends at (0,2,0). The auto margins those rules also set are left
     alone deliberately: with no cap they do nothing, and zeroing them would
     also zero the auto margins elements like .gate-mark rely on to centre
     themselves. */
  #app .scroll > * { max-width: none; }

  .statsbar {
    padding-inline: max(var(--dt-gut), calc((100% - var(--dt-col)) / 2));
    padding-block: 0;
    min-height: var(--dt-head);
  }
  .stat { padding: .35rem .55rem; border-radius: .7rem; transition: background .14s ease; }
  .stat:hover { background: color-mix(in srgb, var(--ink) 8%, transparent); }
  .stat.signin:hover { background: var(--brand-dk); }

  /* ---------- Learn: the tree, and the day beside it ----------
   *
   * Two columns: the road at its fixed width, and a rail that answers the
   * question the tree cannot -- what today looks like. Both are things the
   * mobile app already renders; the phone stacks them because it has to, and
   * stacking them means the streak scrolls away the moment you start reading
   * the course.
   *
   * The greeting only exists once the learner has a name, so the second column
   * is only asked for when there is something to put in it -- :has() on the
   * scroller, rather than a permanently reserved gutter that is empty for every
   * signed-out visitor. */
  /* minmax rather than a flat 25rem: at 1024 the window cannot afford the full
     measure, and a rigid track would push the rail off the right edge and give
     the scroller a horizontal scrollbar. The floor is 20rem because the road
     itself is a fixed 320px and anything narrower would clip it. */
  body[data-tab="learn"] #app .scroll:not(.sheet) {
    display: grid;
    grid-template-columns: minmax(20rem, var(--dt-tree)) var(--dt-side);
    column-gap: var(--dt-gut);
    align-content: start;
    justify-content: center;
  }
  /* margin-inline: 0 is not tidying. styles.css centres these blocks with auto
     margins, and an auto INLINE margin on a grid item stops it stretching to
     its track and shrink-wraps it to its content instead -- which turned the
     track heading into a centred label with its rule collapsed to nothing. */
  body[data-tab="learn"] #app .scroll:not(.sheet) > * { grid-column: 1; margin-inline: 0; }
  /* Spanning the rows is what lets it stick: a sticky box can only travel
     inside its own grid area, and an area one row tall has nowhere to go. */
  body[data-tab="learn"] #app .scroll:not(.sheet) > .hello {
    grid-column: 2;
    grid-row: 1 / span 200;
    align-self: start;
    position: sticky;
    top: 1.4rem;
    margin: 1.4rem 0 0;
  }
  /* No greeting, no rail. A signed-out visitor has no name and no numbers, and
     a reserved 17rem gutter with nothing in it is worse than no gutter. The
     course column widens a little to fill the space it is given, because a
     400px ribbon centred in a 1440px window is the complaint this file exists
     to answer. */
  body[data-tab="learn"] #app .scroll:not(.sheet):not(:has(> .hello)) {
    grid-template-columns: minmax(20rem, 30rem);
    padding-inline: max(var(--dt-gut), calc((100% - 30rem) / 2));
  }
  /* Grid items do not collapse margins with their neighbours the way blocks
     do, so the track heading's bottom margin stopped being absorbed by the
     unit's top margin the moment the scroller became a grid. */
  body[data-tab="learn"] #app .scroll:not(.sheet) > .track { margin-bottom: -1.4rem; }

  /* The rail itself. Same content the phone shows as a row of chips above the
     first node; here they are a stack, because a narrow column reads down and
     because the numbers are worth more than a row of pills is worth. */
  .scroll > .hello {
    background: color-mix(in srgb, var(--card) 72%, transparent);
    border: 1px solid var(--rule);
    border-radius: var(--radius-lg);
    padding: 1.05rem 1.1rem 1.15rem;
  }
  .scroll > .hello::before {
    content: "Today";
    display: block;
    font-size: .68rem; font-weight: 800;
    letter-spacing: .12em; text-transform: uppercase;
    color: var(--dim);
    margin-bottom: .5rem;
  }
  .scroll > .hello .hello-hi {
    font-size: 1.02rem;
    line-height: 1.25;
    margin: 0 0 .8rem;
  }
  .scroll > .hello .hello-chips {
    flex-direction: column;
    align-items: stretch;
    gap: .4rem;
  }
  .scroll > .hello .chipstat {
    justify-content: flex-start;
    gap: .5rem;
    padding: .5rem .7rem;
    border-radius: .75rem;
    font-size: .88rem;
  }
  .scroll > .hello .chipstat svg { width: 1.05rem; height: 1.05rem; }

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

  /* Hover is the whole reason a pointer feels different from a thumb, and the
     app had none: every affordance in styles.css is an :active state. Scaled on
     the RING rather than on the node, because the node's box is what the road
     was drawn through -- moving it would pull the icon off the path. */
  .node:not([disabled]) .node-ring { transition: transform .14s ease; }
  .node:not([disabled]):hover .node-ring { transform: scale(1.07); }
  .node:not([disabled]):hover .node-name { color: var(--ink); }

  .unit-head { transition: transform .14s ease; }

  /* ---------- Practice, Tools, Compete: cards become a grid ----------
   *
   * .rush-cards is the shared container for the rush modes and the tool list.
   * At phone width its children carry their own bottom margin; in a grid that
   * margin becomes a second gap, so it is zeroed here and the gap owns the
   * spacing. */
  .rush-cards {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
    gap: .7rem;
    align-items: stretch;
  }
  .rush-cards > .rush-card,
  .rush-cards > .toolcard { margin-bottom: 0; height: 100%; }
  /* The groups wrapper holds its own headed sections, so it must not become a
     grid itself -- only the card lists inside it do. */
  .rush-cards:has(> .rush-group) {
    display: block;
  }

  .toolcard,
  .rush-card,
  .mode-card,
  .opp-card { transition: transform .12s ease, box-shadow .12s ease, border-color .12s ease; }
  .toolcard:hover,
  .rush-card:hover,
  .mode-card:hover { transform: translateY(-2px); }
  .rush-card:hover { border-color: var(--brand); }

  /* The one-button blocks (Start a set, Start a duel) should not become a
     1000px slab because the column got wider. A primary action is a target,
     and past about 24rem a wider target is not an easier one. */
  .practice-block > .btn,
  .sheet-cta .btn,
  .results-cta .btn { max-width: 24rem; }
  .practice-block > .btn { width: 100%; }

  /* Compete's hub: the two ladders side by side. They are the same kind of
     offer and reading one under the other makes the second look like an
     afterthought. Selected by what they contain rather than by a class,
     because compete.js gives them none. */
  body[data-tab="compete"] #app .scroll.compete {
    display: grid;
    grid-template-columns: 1fr 1fr;
    column-gap: 1.4rem;
    align-content: start;
  }
  /* margin-inline: 0 for the same reason as the tree: compete.css centres these
     blocks with auto margins, and an auto inline margin on a grid item
     shrink-wraps it instead of stretching it -- which left the page title and
     the leaderboard card floating in the middle while the two ladders below
     them squared up to the left and right edges. */
  #app .scroll.compete > * { grid-column: 1 / -1; max-width: none; margin-inline: 0; }
  #app .scroll.compete > .practice-block:has(.mode-card) {
    grid-column: span 1;
    align-self: start;
  }
  #app .scroll.compete > .practice-block:has(.mode-card) .btn { max-width: none; }
  .rate-pair { gap: 1rem; }

  /* ---------- You: the same page, half as tall ----------
   *
   * The profile is eleven blocks stacked in a 34rem ribbon, so on a 900px
   * screen the badges are three scrolls below the rank.
   *
   * NOT CSS COLUMNS, WHICH IS THE OBVIOUS ANSWER AND IS WRONG HERE. `columns:2`
   * was tried first and it fails on this shell specifically: .scroll is a flex
   * child with a definite height, so a multicol container fills that height and
   * then makes its NEXT column to the right -- the page laid itself out
   * sideways and the badge grid ran off the edge of the window into a
   * horizontal scrollbar. Multicol needs a container free to grow downwards and
   * this one is not.
   *
   * So the width is spent inside the blocks rather than on stacking them. Every
   * one of these is already a grid of small tiles at three across; giving them
   * the columns they can use costs no reordering, cannot depend on how many
   * sections the markup happens to contain today, and cuts the page from about
   * 2600px to about 1400px. */
  /* The six lifetime totals have no heading of their own in the markup --
     they follow the quests directly -- so at this width they need the gap a
     heading would otherwise have given them. */
  body[data-tab="you"] .grid    { grid-template-columns: repeat(6, 1fr); margin-top: 1.5rem; }
  body[data-tab="you"] .records { grid-template-columns: repeat(5, 1fr); }
  body[data-tab="you"] .achv-grid { grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr)); }
  /* Quests are two short rows and a bar each; side by side they read as a
     checklist rather than as three more slabs. */
  body[data-tab="you"] .quests  { grid-template-columns: 1fr 1fr; }
  /* The week chart is the one block that gains from being taller as well as
     wider: at 6rem in a 52rem column the bars are wide stubs. */
  body[data-tab="you"] .activity { height: 8rem; }

  .card:hover,
  .achv:not(.locked):hover,
  .record:hover { transform: translateY(-2px); transition: transform .12s ease; }
  .chip { transition: transform .1s ease, box-shadow .1s ease, background .14s ease; }
  .chip:not(.on):hover { border-color: var(--brand); }

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

  #app .scroll.sheet { padding-inline: max(var(--dt-gut), calc((100% - 34rem) / 2)); }
  .sheet-cta {
    padding-inline: max(var(--dt-gut), calc((100% - 34rem) / 2));
    background: none;
    border-top: 1px solid var(--rule);
  }
  .sheet-cta .btn { display: block; margin: 0 auto; }
  .level { transition: transform .12s ease, border-color .12s ease; }
  .level:hover { border-color: color-mix(in srgb, var(--brand) 55%, var(--rule)); }

  /* ---------- the board ----------
   *
   * THE ONE RULE: the whole position has to be on screen without scrolling,
   * and it has to be big enough to read a knight from across a desk.
   *
   * The mobile cap is 30rem, which was a phone's width and is now just a
   * number. The real constraint on a desktop is HEIGHT: the board is square, so
   * the moment it is wider than the space left over after the prompt row, the
   * help row and the top bar, the bottom rank falls off the viewport -- and a
   * puzzle you have to scroll to see is not a puzzle you can solve at a glance.
   *
   * 19rem is that chrome, measured on a real lesson: the top bar (~3.1rem), the
   * prompt row (a fixed 4rem, reserved for two lines whatever the prompt says),
   * the help row and the hint slot under it (~6.6rem), and the padding. So the
   * board is 36rem on a 900px window and 31rem on an 800px one -- whichever
   * limit is smaller wins, and a short window gets a smaller board rather than a
   * clipped one. Measured after the fact: at 1440x900 the last pixel of the hint
   * slot lands at y=883 of 900. */
  .board-wrap {
    max-width: min(36rem, calc(100dvh - 19rem), calc(100% - 2rem));
  }
  /* A rush has a taller header -- clock, drain bar and a score row -- but no
     help row and no hint slot under the board, so it can afford more of the
     window than a lesson can. */
  .rush .board-wrap { max-width: min(36rem, calc(100dvh - 15rem), calc(100% - 2rem)); }

  /* The bars above the board are sized to the board, not to the column. A
     progress bar running the full width of a 1000px content area over a 500px
     board reads as two different screens. */
  .lesson-top,
  .rush-top,
  .example-top,
  .example-step,
  .example-nav {
    width: 100%;
    max-width: 34rem;
    margin-inline: auto;
  }
  .lesson-top { padding-top: 1rem; }

  /* The results screen is a column of full-width blocks; without a cap the
     three score cells stretch to a metre wide. */
  #app > .results { max-width: 30rem; margin-inline: auto; }

  /* The rush intro and the run summary are built for a thumb: the content
     stacks from the top and the button is pushed to the bottom edge with an
     auto margin, so the two are a hand's width apart on a phone and half a
     metre apart on a monitor. A pointer has no such reach, so the column is
     centred and the button rejoins the thing it belongs to. */
  .rush-intro-body,
  .rush-over { justify-content: center; }
  .rush-intro-body .btn { margin-top: 1.4rem; }
  .over-cta { margin-top: 1.4rem; }

  /* ---------- everything below the app ---------- */

  .sitefoot { padding-inline: var(--dt-gut); }
  .sitefoot nav { max-width: 64rem; justify-content: flex-start; gap: .6rem 1.6rem; }

  /* The update toast is anchored to a tab bar that is no longer at the bottom
     of the screen. */
  .swtoast { left: auto; right: 1.5rem; bottom: 1.5rem; transform: none; }
  .swtoast:active { transform: translateY(2px); }

  /* The overlay panels (welcome, name prompt) float over #app, which is now
     the content area rather than the whole window. That is the right place for
     them -- they belong to the view they are blurring -- but the card was sized
     for a phone. */
  .welcome-card { max-width: 24rem; }
}

/* Past 1280 there is room to stop economising: the rail takes its labels
   without crowding them, the gutters open up, and the course column and the
   day's rail each gain a rem. Nothing changes shape -- these are the same three
   tracks, breathing. */
@media (min-width: 80rem) {
  body {
    --dt-rail: 16.5rem;
    --dt-gut: 2.5rem;
    --dt-tree: 26rem;
    --dt-side: 19rem;
  }
}
/* And past 1600, a wider window buys margin rather than more columns. A fourth
   column of tool cards on a 27-inch monitor puts the last one further out than
   the eye tracks in one sweep, so only the two card surfaces grow, and only
   once. */
@media (min-width: 100rem) {
  body[data-tab="tools"] { --dt-col: 70rem; }
  body[data-tab="you"]   { --dt-col: 56rem; }
  body { --dt-gut: 3rem; }
}
