/*
 * One stylesheet for the whole site.
 *
 * Two constraints drive every decision here:
 *
 *   1. Parents, not students. Anything that needs explaining is a mistake.
 *      One accent, one button style, few borders, lots of air.
 *   2. Mid-range Android on mobile data. No framework, no icon set, no JS
 *      for layout, no third-party connection of any kind. Four self-hosted
 *      latin subsets, 114KB in total, and nothing else is downloaded to
 *      draw a page.
 */

/*
/*
 * Plus Jakarta Sans is gone. The house look now follows McMaster-Carr:
 * grayscale with one green and one yellow, square corners, small dense type,
 * and navigation at the top rather than the bottom.
 *
 * Their faces (HelveticaNeue eText Pro, Futura LT Pro BoldCond) are licensed
 * commercially and cannot be copied. The free stand-ins that replaced them,
 * Inter and Archivo Narrow, are gone too: the site is now set in the reader's
 * own system UI face — Segoe UI where there is one — so there is nothing left
 * to download for either. That is 67KB off every page load, and the two
 * `.woff2` files stay in `static/fonts` only because deleting a font is
 * easier than finding it again.
 *
 * What is still self-hosted is the two faces that have a job no system font
 * can do: the reading face below, and the player's hand.
 */
/* The reading face, and the one the rest of the platform was already set in.
 *
 * Every Manim loop's caption is Plus Jakarta Sans (`manim/scenes/_style.py`),
 * every guide PDF is Plus Jakarta Sans (`docs/pdfstyle.py`), and so is the
 * report a parent gets after the free check. The web subset has been sitting
 * in `static/fonts` unused since the house look moved to Inter, so a lesson
 * page was the one surface in the product not speaking in the platform's own
 * voice — a caption in one face and the paragraph explaining it in another,
 * a centimetre apart on the same slide.
 *
 * 27KB, latin subset, variable 200-800. No italic in the family, so `<em>`
 * is synthesised; that is what the PDF builder works around by reaching for
 * a heavier weight instead, and it is a fair trade for one face across
 * video, print and page. */
@font-face {
  font-family: "Plus Jakarta Sans";
  src: url("../fonts/plus-jakarta-sans-latin.7660bd9909fb.woff2") format("woff2");
  font-weight: 200 800;
  font-style: normal;
  font-display: swap;
}
/* The face the player is set in.
 *
 * Futura Handwritten, by Billy Snyder — see the licence note beside the file,
 * which matters here because the aggregator sites carrying it disagree about
 * its terms and the designer's own wording is the permissive one we rely on.
 *
 * Two things about it shape how it is used below. It has **one weight** and
 * no italic, so bold and emphasis are synthesised by the browser rather than
 * drawn; and the source has **no dashes at all** — no en dash, no em dash, no
 * minus sign — which on a voice that uses em dashes as often as this one does
 * would have meant a different typeface appearing mid-sentence, several times
 * a slide. `tools/build_futura_handwritten.py` draws those three from the
 * hyphen, so the subset served here is closed under the punctuation a lesson
 * actually types. */
@font-face {
  font-family: "Futura Handwritten";
  src: url("../fonts/futura-handwritten-latin.b05dc9faeaa5.woff2") format("woff2");
  font-weight: 400 700;
  font-style: normal;
  font-display: swap;
}

:root {
  /* The site's own voice, everywhere outside a lesson.
   *
   * Segoe UI cannot be shipped — it comes with Windows and Microsoft licenses
   * it that way, so there is no webfont to self-host and no CDN that may
   * serve it. That is why this is a stack and not a `@font-face`: Windows
   * gets Segoe, and every other device gets the face its own designers chose
   * for exactly this job. Most of our learners are on Android phones, so for
   * most of them this reads as Roboto. That is the intended outcome rather
   * than a fallback we tolerate — a UI face the device already renders well
   * beats a downloaded approximation of somebody else's. */
  --font: "Segoe UI", system-ui, -apple-system, "Helvetica Neue",
          roboto, arial, sans-serif;

  /* The ground every Manim Loop renders on. Must equal LOOP_BG in
     manim/scenes/_style.py — the video box is painted with it so a loop never
     flashes white before its first frame lands. */
  --loop-bg: #243019;

  /* Buttons and small UI labels. Once Archivo Narrow, which gave them a
     condensed Futura slot; now the same face as the body, because a button
     is the site talking in its own voice and there is no reason for it to
     change accent halfway down the page. Weight and letter-spacing carry the
     emphasis the condensing used to. */
  --font-ui: "Segoe UI", system-ui, -apple-system, "Helvetica Neue",
             roboto, arial, sans-serif;

  /* For reading, as opposed to for using.
   *
   * Inter is a UI face. It is drawn to be unambiguous at 13px in a toolbar,
   * which is the opposite problem from a paragraph somebody reads for a
   * minute: even colour, no personality, and at the size a lesson wants it
   * looks like a settings screen.
   *
   * Plus Jakarta Sans is the answer the platform had already given — it is
   * what the loops are captioned in and what every guide is set in, so a
   * lesson set in it is the page and the video speaking with one voice
   * rather than two. Modern rather than bookish, which is the register a
   * fourteen-year-old on a phone is actually reading in. */
  --font-read: "Plus Jakarta Sans", "Segoe UI", system-ui, -apple-system,
               "Helvetica Neue", arial, sans-serif;

  /* The player, and nowhere else. A lesson is somebody talking you through
     something, and this is what that sounds like on the page. Deliberately
     not on the ordinary lesson page, which is what a parent skims, and not on
     anything a learner has to fill in. */
  --font-hand: "Futura Handwritten", "Plus Jakarta Sans", "Segoe UI",
               system-ui, sans-serif;

  /* The whole palette, exactly as given. Nothing outside this list. */
  --green-700: #336633;   /* headings, the masthead strip */
  --green-900: #244A24;   /* pressed and hover states of the green */
  --green-800: #2B5A2B;
  --green-600: #3D7A3D;
  --green-300: #9CBE9C;
  --green-100: #EEEEEE;   /* soft bands are grey here, not tinted green */

  --yellow: #FED700;      /* calls to action, and nothing else */
  --yellow-dark: #E5C200;

  --blue: #006699;        /* links on hover, and anything flagged for attention */

  /* Pink, added for the blog and used nowhere else.
   *
   * The rule above this list is that the palette is closed, so an addition
   * needs a job rather than a mood. This one has a narrow one: the blog is
   * the part of the site written by a person rather than by the business, and
   * it is the page a stranger is sent to. A signature colour of its own is
   * what makes it read as somebody's writing and not as another product page.
   *
   * Two, because one is not enough to be both an accent and legible: the
   * bright one sits on the blue panel and on ink, the deep one is for small
   * text on white, where the bright one is under 4.5:1 and would fail. */
  --pink: #E85D8A;
  --pink-deep: #B12F5C;

  --ink: #333333;
  --ink-strong: #000000;
  --muted: #666666;
  --muted-2: #888888;
  /* The highlight behind a link: the green wash, kept faint enough to read
     black text through when it fills. */
  --link-wash: #D9E8D4;
  /* The rail's two states. Faint enough that a filled row is a hint, not a
     block of colour down the side of every page. */
  --rail-hover: #F1F6EC;
  --rail-on: #E4EFDE;
  --line: #CCCCCC;
  --line-soft: #EEEEEE;
  --bg: #FFFFFF;
  --bg-soft: #F2F2F2;

  /* There is no red in the palette, so "watch out" is carried by the blue
     and by weight, not by colour alone. */
  --warn: #006699;

  --radius: 0;            /* square. Nothing on their site is rounded. */
  --wrap: 72rem;
  --narrow: 34rem;
  /* The catalogue panel. Wide enough for a course title on one line, which
     is what stops it reading as a cramped list of truncations. */
  --sidebar-width: 17.5rem;
  /* One number for the green band's height, shared by the sections strip and
     the panel's share of it. Two numbers would drift and the band would step
     where they meet, which is the whole thing we are fixing. */
  --nav-height: 2.6rem;
  /* And one for the panel's inside edge, so the wordmark above it can be told
     to line up with it rather than being nudged until it looks right. */
  --panel-pad: 1.25rem;
  /* The masthead's height, and the offset everything sticking below it needs.
     Enforced with `min-height` rather than measured, because the stylesheet's
     first rule is no JavaScript for layout — and a number that is asserted
     rather than observed has to be made true.

     The row's natural height is about 4.06rem — the search input's line box
     plus its padding and border, plus the row's own padding — so this sits
     above it with room to spare. That matters: a floor the content already
     exceeds is not a floor, it is a decoration, and the band below would
     overlap by however far the two had drifted. */
  --masthead-height: 4.5rem;
  /* Masthead plus band. Everything below the top block is told to start here,
     from one number, so nothing can be told a different one. */
  --topbar-height: calc(var(--masthead-height) + var(--nav-height));
  --rail: 13rem;              /* the collapsed bar's label width on a phone */
  --gutter: 1rem;
}

* { box-sizing: border-box; }

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;

  /* Always reserve the scrollbar's width, even on a page short enough not to
     need one.

     Without this, a centred layout moves sideways by half a scrollbar every
     time you navigate between a page that scrolls and one that does not — so
     the rail, the masthead and the content all shift as you go from
     Worksheets to Classes to Blog. It reads as the page being unstable, and
     it is the reason a persistent nav never felt persistent.

     Two properties, because one of them is not old enough. `overflow-y:
     scroll` keeps the track present in every browser ever shipped, including
     the ones that predate `scrollbar-gutter` — Safari before 16, Chrome
     before 94. `scrollbar-gutter` is the better-behaved modern version and
     wins where it is understood. Neither shows anything extra where
     scrollbars overlay the content, which is every phone and every Mac on
     its default setting. */
  overflow-y: scroll;
  scrollbar-gutter: stable;
}

body {
  margin: 0;
  /* Set separately rather than via the `font` shorthand: the shorthand with a
     custom property is easy to break, and the font is the first thing anyone
     judges. */
  font-family: var(--font);
  font-size: 15px;
  line-height: 1.5;
  color: var(--ink);
  background: var(--bg);

  /* Guard against a single wide child (a canvas, a long URL) making the whole
     page scroll sideways — which is what makes gutters look like they have
     vanished. `clip` rather than `hidden` so sticky positioning still works. */
  overflow-x: clip;

  /* Sticky footer. Without this, a page shorter than the screen leaves the
     footer sitting in the middle of it with white underneath — which reads as
     a broken page, and is worst on a phone where short pages are common.
     dvh as well as vh because mobile browsers change the viewport height as
     their toolbars slide away. */
  min-height: 100vh;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
}

img { max-width: 100%; height: auto; display: block; }
/* Links are highlighted, not underlined.
 *
 * A tinted band sits under the word at rest and grows to cover it on hover —
 * a highlighter pen rather than a rule. The band is what marks a link, so it
 * stays visible without hovering: colour alone would leave a link in a
 * paragraph indistinguishable from the text around it for anyone who cannot
 * separate the two.
 *
 * It is drawn as a background rather than a border so it costs no layout,
 * wraps with the text across lines, and animates by size alone.
 *
 * Green, not yellow. Yellow belongs to the buttons. */
a {
  color: var(--ink-strong);
  text-decoration: none;
  background-image: linear-gradient(var(--link-wash), var(--link-wash));
  background-repeat: no-repeat;
  background-size: 100% .18em;
  background-position: 0 96%;
  transition: background-size .12s ease, color .12s ease;
}
a:hover, a:focus-visible {
  color: var(--green-900);
  background-size: 100% 100%;
}
@media (prefers-reduced-motion: reduce) { a { transition: none; } }

/* Everything that is a control rather than a word in a sentence: navigation,
 * buttons, cards, tiles, tabs. These carry their own state — a fill, a rule,
 * a weight — and a second one underneath reads as a mistake. */
.masthead a, .sections a, .rail a, .site-footer a, .topbar__browse a,
.btn, .plain, .card a, .golink, .game__back,
.tc-scope a, .clstabs a, .cwmenu__list a, .cwitem__name, .rolecard,
/* The paper studio: every link in it is a row, a tab or a button. */
.st a,
/* And the worksheet rows: two links a line, and a band under each would make
   a list of thirty sheets look like a page of hyperlinks. */
.sheets a, .chips a,
.signup-google__btn, .flash a,
/* A link that is a grid or flex item is blockified, so its band would run to
   the end of the cell rather than sit under the words. These rows carry
   their own hover. */
.tt-top__go a, .tt-row__name, .tt-row__do a, .tt-tile {
  background-image: none;
}

/* Small and quiet, the way a catalogue sets headings. Their h1 is green and
   normal weight; ours keeps the green and a little more size, because a
   lesson has to hold long prose and needs a visible outline to skim. */
h1, h2, h3 { line-height: 1.25; margin: 0 0 .6rem; letter-spacing: 0; font-weight: 600; }
h1 { font-size: 1.6rem; color: var(--green-700); }
h2 { font-size: 1.15rem; margin-top: 2rem; }
h3 { font-size: 1rem; margin-top: 1.4rem; }
p { margin: 0 0 1.1rem; }

.wrap { max-width: var(--wrap); margin: 0 auto; padding: 0 var(--gutter); }
.narrow { max-width: var(--narrow); }
.lede { font-size: 1.12rem; color: var(--muted); }
.meta { color: var(--muted); font-size: .92rem; }
.section { padding-block: 3rem; }
.center { text-align: center; }

/* Spacing utilities.
   A small fixed set, so templates never need a one-off `style=` attribute for
   a margin. Inline styles are how a design system quietly stops being one:
   they are invisible to a global change and drift apart page by page. */
.m-0 { margin: 0; }
.mt-0 { margin-top: 0; }
.mt-1 { margin-top: .5rem; }
.mt-2 { margin-top: 1rem; }
.mt-3 { margin-top: 1.5rem; }
.mt-4 { margin-top: 2rem; }
.pt-3 { padding-top: 2rem; }
.pt-4 { padding-top: 3rem; }
.mb-3 { margin-bottom: 1.5rem; }
.ml-1 { margin-left: .5rem; }
.tight { margin: 0 0 .5rem; }
.tight-2 { margin: 0 0 1rem; }

/* A price line above a call to action. */
.price { margin: 0 0 .3rem; font-size: 1.15rem; }
/* "(optional)" beside a field label, which is itself bold. */
.label-optional { font-weight: 400; }

/* Third-party embed (the Cal.com calendar). Fixed height because the iframe
   cannot report its own, and it must not collapse to nothing. */
.embed-frame {
  border: 1px solid var(--line); border-radius: var(--radius);
  overflow: hidden; margin-top: 1rem;
}
.embed-frame iframe { width: 100%; height: 44rem; border: 0; display: block; }

/* A row of buttons or links. */
.actions { display: flex; gap: .75rem; flex-wrap: wrap; margin-top: 1.25rem; }
.chips { display: flex; gap: .5rem; flex-wrap: wrap; margin: 0 0 1.5rem; }

/* Label on the left, value on the right — timetables, score rows. */
.split { display: flex; justify-content: space-between; gap: 1rem; }

/* A link that should not look like one. */
.plain { text-decoration: none; color: inherit; }

/* Tutor byline on a class. A parent is choosing a person, not a timetable. */
.tutor { display: flex; align-items: center; gap: .75rem; margin: 1rem 0; }
.tutor__photo { width: 3rem; height: 3rem; border-radius: 50%; object-fit: cover; flex: none; }
.tutor__photo--blank {
  display: grid; place-items: center;
  background: var(--green-700); color: #fff;
  font-weight: 700; font-size: 1.15rem; text-transform: uppercase;
}
.tutor__text { min-width: 0; }
.tutor__text strong { display: block; line-height: 1.3; }
.tutor__text .meta { display: block; font-size: .88rem; }

/* ------------------------------------------------------- masthead + shell */

.skip {
  position: absolute; left: -9999px;
  background: var(--yellow); color: #000; padding: .5rem .9rem; z-index: 100;
}
.skip:focus { left: .5rem; top: .5rem; }

/* Row one: wordmark, a search box that dominates, a short account nav.
   Search gets that much room on purpose — on a catalogue it is the way in. */
.masthead { background: #fff; border-bottom: 1px solid var(--line); }
.masthead__inner {
  max-width: var(--wrap); margin: 0 auto; padding: .7rem var(--gutter);
  display: flex; align-items: center; gap: 1.25rem;
}
.masthead__brand {
  font-family: var(--font-ui);
  font-size: 1.35rem; font-weight: 700; letter-spacing: .01em;
  color: var(--green-700); text-decoration: none; white-space: nowrap;
}
.masthead__brand em { font-style: normal; color: var(--yellow-dark); }
/* Which of the four sites you are on, said once beside the wordmark. Quiet
   on purpose: a teacher knows who they are, and this is only there so a
   changed masthead is explained rather than surprising. */
/* Who you are, and which of the four sites this is.
 *
 * This replaced a chip beside the wordmark that said the same thing and that
 * nobody read — it was small, grey, and glued to a logo, which is three
 * reasons to skip over something. Here it sits where an account name is
 * looked for, reads as a sentence, and is a link back to where this menu
 * starts. */
.whoami {
  display: inline-flex; align-items: baseline; gap: .4rem;
  max-width: 16rem; min-width: 0;
}
.whoami__name {
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.whoami__role {
  flex: none;
  padding: .1rem .4rem; border-radius: 999px;
  background: var(--green-700); color: #fff;
  font-family: var(--font-ui); font-size: .66rem; font-weight: 700;
  letter-spacing: .08em; text-transform: uppercase;
}
.masthead__account a.whoami:hover .whoami__role { background: var(--ink-strong); }

/* The menu, named on itself. In the band, in the band's own white, set apart
   from the links by weight and letter-spacing rather than by a rule — a rule
   here reads as the first item being separated from the rest, which is the
   opposite of what it is. */
.sections__mode {
  display: none; align-items: center; flex: none;
  padding-right: .9rem; margin-right: .3rem;
  font-family: var(--font-ui); font-size: .7rem; font-weight: 700;
  letter-spacing: .12em; text-transform: uppercase;
  color: #fff; opacity: .72; white-space: nowrap;
}

/* Grows to fill whatever the brand and the account nav leave, which on a
   wide screen is most of the bar. Search is the way into a catalogue. */
.masthead__search { flex: 1 1 12rem; display: flex; min-width: 0; }
/* A teacher's masthead has no catalogue search, and keeps its grid cell so
   the wordmark still sits over the panel. Nothing in it, nothing drawn. */
.masthead__search--none { display: block; }
.masthead__search input {
  flex: 1 1 auto; min-width: 0;
  font: inherit; font-size: .95rem;
  padding: .5rem .7rem;
  border: 2px solid var(--green-700); border-right: 0;
  background: #fff; color: var(--ink);
}
.masthead__search input::placeholder { color: var(--muted-2); }
/* `[type=submit]` on purpose: the global submit-button rule further down is
   more specific than a bare `button`, and would otherwise make this one a
   full-width yellow block and squeeze the input to nothing. */
.masthead__search button[type=submit] {
  width: auto; min-height: 0;
  font-family: var(--font-ui); font-size: .85rem; font-weight: 700;
  letter-spacing: .04em; text-transform: uppercase;
  padding: .5rem 1.1rem; cursor: pointer;
  border: 2px solid var(--green-700); background: var(--green-700); color: #fff;
}
.masthead__search button[type=submit]:hover { background: var(--green-900); border-color: var(--green-900); }

.masthead__tools { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 1.25rem; }
.masthead__account { display: flex; align-items: center; gap: .9rem; white-space: nowrap; }
/* Desktop only — on a phone the bar above the panel already says where you are. */
.topbar__browse { display: none; }
/* The last link in a signed-in strip: the way across to the public site.
   Set apart so it reads as a door out rather than as one more section. */
.sections__aside { margin-left: auto; opacity: .8; font-style: italic; }
/* Something is waiting. A dot, not a number: on a strip of six words a
   counter reads as a badge on a phone app, and the honest content of the
   signal is "look at your messages" — the count is on the inbox, where it can
   be acted on. */
.sections__new {
  display: inline-block; width: .4rem; height: .4rem; margin-left: .3rem;
  border-radius: 50%; background: var(--yellow, #FED700);
  vertical-align: .35em;
}
.masthead__account a { color: var(--ink-strong); text-decoration: none; font-size: .88rem; }
.masthead__account a:hover { color: var(--blue); text-decoration: underline; }

/* ------------------------------------------------------- the worksheet catalogue
 *
 * A row of chips and one list. The old page was three levels of headings and
 * a paragraph per sheet — right when there were six sheets and a course to
 * explain, verbose the moment somebody arrives knowing what they want.
 */
.cat__head { margin-bottom: 1.25rem; }
.cat__head h1 { margin-bottom: .3rem; }
.cat__count { margin: .3rem 0 0; font-size: .85rem; color: var(--muted); }
.cat__first { margin: .5rem 0 0; font-size: .88rem; }

.chips { display: grid; gap: .4rem; margin: 0 0 1.75rem;
  padding-bottom: 1.1rem; border-bottom: 1px solid var(--line); }
.chips__row { display: flex; flex-wrap: wrap; gap: .35rem; align-items: baseline; }
.chips__what {
  flex: 0 0 4.5rem; font-family: var(--font-ui); font-size: .68rem;
  font-weight: 700; letter-spacing: .08em; text-transform: uppercase;
  color: var(--muted);
}
/* A chip is a link that has been chosen or not. No shadows, no pills with
   borders on all four sides — the state is the fill. */
.chips a.chip {
  padding: .2rem .6rem; font-size: .82rem; line-height: 1.5;
  color: var(--ink); text-decoration: none; background: var(--bg-soft);
  border: 1px solid transparent; border-radius: 999px;
}
.chips a.chip:hover { border-color: var(--line); }
.chips a.chip--on { background: var(--green-700); color: #fff; }

.cat__unit { margin: 0 0 1.75rem; }
.cat__unitname { margin: 0; font-size: 1.05rem; }
.cat__from { margin: .1rem 0 .5rem; font-size: .8rem; color: var(--muted); }
.cat__none { color: var(--muted); }

.sheets { list-style: none; margin: 0 0 1.5rem; padding: 0;
  border-top: 1px solid var(--line-soft, #e8ece6); }
/* Columns, not a flex row that ends where its own words end. Left to itself
   the paper link sat at a different distance on every line, because it was
   pushed by the length of the lesson title beside it — thirty rows of that
   reads as a list nobody aligned. */
.sheets__row {
  display: grid; grid-template-columns: minmax(0, 1fr) 8rem minmax(0, 14rem);
  gap: .2rem 1.5rem; align-items: baseline;
  padding: .55rem .2rem; border-bottom: 1px solid var(--line-soft, #e8ece6);
}
.sheets__title { font-family: var(--font-ui); font-weight: 600; font-size: .95rem;
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sheets__do { display: contents; }
@media (max-width: 40rem) {
  /* One column a line on a phone, in the order they are written. */
  .sheets__row { grid-template-columns: minmax(0, 1fr); }
  .sheets__do { display: flex; gap: 1.25rem; }
}
.sheets__pdf { white-space: nowrap; font-weight: 600; font-size: .85rem; }
.sheets__lesson { color: var(--muted); font-size: .85rem;
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sheets__lesson:hover { color: var(--blue); }

/* Site-wide messages. */
.flash { list-style: none; margin: 0 0 1.25rem; padding: 0; display: grid; gap: .5rem; }
.flash__item {
  padding: .7rem .9rem; border-left: 4px solid var(--green-700);
  background: var(--bg-soft); font-size: .92rem;
}
.flash__item--warning { border-left-color: var(--warn); }
.flash__item--error { border-left-color: var(--warn); font-weight: 600; }

/* Sign out.
 *
 * A form so the POST Django requires is possible, drawn as the link beside
 * it. `form button[type=submit]` is (0,1,2) and paints a full-width yellow
 * slab, so this has to match that weight or lose: `.masthead__account form
 * button.masthead__signout-btn` is (0,2,2) and wins on its own merits. */
.masthead__signout { display: inline; margin: 0; }
.masthead__account form button.masthead__signout-btn {
  width: auto; min-height: 0; margin: 0; padding: 0;
  background: none; border: 0; box-shadow: none;
  font-family: inherit; font-size: .88rem; font-weight: 400; letter-spacing: 0;
  text-transform: none; color: var(--ink-strong); cursor: pointer;
}
.masthead__account form button.masthead__signout-btn:hover {
  color: var(--blue); text-decoration: underline; background: none;
}

/* Sign-up: which kind of account, and the Google button on each form. */
.rolelist { list-style: none; margin: 1.25rem 0 0; padding: 0;
  display: grid; gap: .75rem; }
.rolecard {
  display: grid; grid-template-columns: 2.25rem 1fr; gap: .25rem .9rem;
  padding: 1rem 1.1rem; border: 1px solid var(--line);
  text-decoration: none; color: inherit; background: #fff;
}
.rolecard:hover { border-color: var(--green-700); background: var(--bg-soft); }
.rolecard__icon { grid-row: 1 / span 2; color: var(--green-700); }
.rolecard__icon svg { width: 1.6rem; height: 1.6rem; }
.rolecard__name {
  font-family: var(--font-ui); font-weight: 700; font-size: 1.02rem;
  color: var(--green-900);
}
.rolecard__what { font-size: .9rem; color: var(--muted); }

.signup-google { margin: 1.25rem 0 0; }
.signup-google form, .signup-google { display: block; }
.signup-google button.signup-google__btn {
  width: 100%; margin: 0; padding: .7rem 1rem;
  display: flex; align-items: center; justify-content: center; gap: .6rem;
  background: #fff; color: var(--ink-strong);
  border: 1px solid var(--line); box-shadow: none;
  font-family: var(--font-ui); font-size: .95rem; font-weight: 600;
  letter-spacing: 0; text-transform: none; cursor: pointer;
}
.signup-google button.signup-google__btn:hover {
  background: var(--bg-soft); border-color: var(--ink-strong);
}
.signup-google__mark { width: 1.15rem; height: 1.15rem; flex: none; }
.signup-or {
  margin: .9rem 0 0; text-align: center;
  font-size: .82rem; color: var(--muted-2);
}

/* Row two: the green strip of sections. */
.sections { background: var(--green-700); }
.sections__inner {
  max-width: var(--wrap); margin: 0 auto; padding: 0 var(--gutter);
  display: flex; gap: .25rem; overflow-x: auto;
  min-height: var(--nav-height);
}
/* On a narrow phone the account row is one line and the name is the part
   that can go: the badge alone still answers "which of the four am I in",
   which is the question this is for. */
@media (max-width: 30rem) { .whoami__name { display: none; } }
.sections a {
  display: flex; align-items: center;
  font-family: var(--font-ui); font-size: .92rem; font-weight: 600;
  letter-spacing: .02em;
  color: #fff; text-decoration: none;
  padding: .55rem .85rem; white-space: nowrap;
}
.sections a:hover { background: var(--green-900); }
.sections a[aria-current="page"] { background: var(--green-900); box-shadow: inset 0 -3px 0 var(--yellow); }

/* THE STRIP RUNS TIGHTER ON THE SCREEN IT IS ACTUALLY READ ON
 *
 * Between where the desktop layout starts and where the window is genuinely
 * wide, the panel takes 15rem and the strip gets what is left — about 700px
 * on the 950px viewport a 1080p laptop reports at 165% scaling. Seven links
 * at full padding do not fit in that, and the way they do not fit is the
 * problem: `overflow-x: auto` keeps the row looking finished and puts the
 * last two behind a sideways scroll nobody is told about.
 *
 * The number of links is the real budget and it is held by a test. This is
 * the margin either side of it: about 90px back across a full row, which is
 * the difference between fitting and fitting comfortably. Above 78rem the
 * padding goes back to normal, because there the room is real.
 */
@media (min-width: 56.0625rem) and (max-width: 78rem) {
  .sections a { padding-inline: .6rem; font-size: .88rem; }
  .sections__inner { gap: 0; }
  .sections__mode { padding-right: .7rem; margin-right: .15rem; }
}

/* The layout, small screens first.
 *
 * Stacked, in DOM order: masthead, rail, content, footer. No grid, no
 * ordering, nothing to get out of step — a phone gets the document as it is
 * written. The desktop arrangement is added at 64rem and takes nothing away.
 */
.layout {
  flex: 1 0 auto;               /* pushes the footer down on a short page */
  display: flex; flex-direction: column;
  min-width: 0;
}
.shell {
  flex: 1 0 auto;
  max-width: var(--wrap); margin: 0 auto; width: 100%;
  padding: 0 var(--gutter);
}
.shell__content { min-width: 0; padding-block: 1.5rem 3rem; }
/* Pages that are their own thing — the homepage, a sitting — drop the rail. */
.shell--full .rail,
.shell--full .rail-col { display: none; }


/* ---------------------------------------------------------- hiding the panel
 *
 * The control sits over the panel's own column in the green band, and the
 * checkbox that drives it is the first thing in the body — so one sibling
 * selector reaches the band, the layout and the masthead, all of which are
 * the same two-column grid.
 *
 * On a phone none of this applies: the panel is already a drawer of its own
 * and there is no column to reclaim.
 */
.railhide {
  display: inline-flex; align-items: center; gap: .4rem;
  cursor: pointer; user-select: none;
  font: inherit; color: inherit;
}
.railhide__icon {
  width: 1rem; height: .7rem; position: relative; flex: none;
  border-top: 2px solid currentColor; border-bottom: 2px solid currentColor;
}
.railhide__icon::after {
  content: ""; position: absolute; left: 0; right: 0; top: calc(50% - 1px);
  border-top: 2px solid currentColor;
}
.railhide:hover { opacity: .75; }
.rail-hide:focus-visible + .layout .railhide,
.railhide:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }



/* ============================================================== desktop
 *
 * The catalogue arrangement: a permanent left panel, and everything else —
 * masthead, sections strip, content, footer — in the column beside it.
 *
 *   ┌────────────┬──────────────────────────────┐
 *   │            │  masthead                    │
 *   │  rail      ├──────────────────────────────┤
 *   │  (sticky,  │  sections                    │
 *   │   own      ├──────────────────────────────┤
 *   │   scroll)  │  content                     │
 *   │            ├──────────────────────────────┤
 *   │            │  footer                      │
 *   └────────────┴──────────────────────────────┘
 *
 * Placed by grid area, not by `margin-left: var(--sidebar-width)` on four
 * separate things. Margins have to be kept in step with the panel's width by
 * hand, and the first one anybody forgets puts content underneath the panel —
 * which is the failure this whole arrangement is prone to.
 *
 * Sticky rather than fixed, for the same reason. A fixed panel is out of the
 * flow, so every sibling needs a margin to compensate and the two can drift
 * apart. A sticky grid item keeps its column: nothing can slide under it,
 * because there is nothing to slide under.
 *
 * `:not(.shell--full)` so the homepage and a sitting are untouched. They are
 * deliberately full-bleed and one of them must have no navigation at all.
 */
/* WHERE THE DESKTOP ARRANGEMENT STARTS, AND WHY IT MOVED
 *
 * It used to start at 961px, which sounds generous and is not. A 1080p laptop
 * at Windows' 165% scaling — the machine this site is built and taught on —
 * reports a viewport of about 950 CSS pixels. Every teacher on one of those
 * was being given the phone layout: the panel collapsed to a "Contents" bar,
 * the section strip folded behind a hamburger, and none of the three-column
 * work they were looking at ever appeared for them.
 *
 * 897px instead. Below that the stack is genuinely better; above it there is
 * room for a 15rem panel and a column of content that still measures well.
 * The collapse ends where this begins — `@media (max-width: 56rem)` — and a
 * test holds the two numbers together, because a gap between them is a width
 * with no layout at all.
 */
@media (min-width: 56.0625rem) and (max-width: 78rem) {
  :root { --sidebar-width: 15rem; }
}

@media (min-width: 56.0625rem) {
  body:not(.shell--full) .layout {
    display: grid;
    grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
    /* Content, then footer. The content row takes the slack, so a short page
       still puts the footer at the bottom. The masthead and the band are not
       here — they are in the top block, outside the grid. */
    grid-template-rows: 1fr auto;
    align-items: start;
  }

  /* The footer runs the whole width: stopping short of the left edge would
     leave a notch of white under the panel with nothing in it. */
  body:not(.shell--full) .site-footer { grid-column: 1 / -1; grid-row: 2; }

  /* The column fills the content row, so its rule runs from the band down to
     the footer and stops there. */
  body:not(.shell--full) .rail-col {
    grid-column: 1; grid-row: 1;
    /* `align-items: start` on the grid would otherwise size this to its
       content. It has to fill the two rows: it is both the rule running down
       to the footer and the box the panel sticks inside, and a short one
       would let the panel stop scrolling long before the page does. */
    align-self: stretch;
    border-right: 1px solid var(--line);
    background: #fff;
  }

  /* The panel sticks inside that column. Containment is the column's box,
     which is unambiguous — a sticky *grid item* is contained by its grid
     area, which is correct per spec and subtle enough not to bet a visible
     bug on. Sliding over the footer is what that bet loses. */
  body:not(.shell--full) .rail {
    position: sticky; top: var(--topbar-height); z-index: 10;
    /* `max-height`, not `height`: the panel starts below the masthead and
       there is no reliable number for how tall that is. A fixed height would
       be right at the top of the page and wrong once scrolled. */
    /* The viewport, less what the top block is holding above it. */
    max-height: calc(100vh - var(--topbar-height));
    max-height: calc(100dvh - var(--topbar-height));
    /* Its own scrollbar once the list outgrows the screen, so forty chapters
       do not make the page taller than its content does. */
    overflow-y: auto; overscroll-behavior: contain;
    /* No padding of its own: the green block at its top runs edge to edge,
       so the inset belongs to the list below it. */
    padding: 0;
    margin: 0; border-right: 0; border-bottom: 0;
    background: transparent;
  }
  body:not(.shell--full) .rail__panel { padding: 1.5rem var(--panel-pad) 2rem; }


  body:not(.shell--full) .shell     { grid-column: 2; grid-row: 1; }

  /* The whole top block holds position as one thing.
   *
   * One sticky element, not three. The earlier version made the masthead and
   * the strip sticky where they stood, which did nothing at all: each was a
   * grid item in an `auto` row exactly its own height, and a sticky grid item
   * cannot move outside its grid area. It fails silently — the declaration is
   * accepted and simply has no effect.
   *
   * Out of the grid, its containing block is the page, so it holds while the
   * page scrolls under it. And with the masthead and the band inside one
   * element there is no offset to keep in step between them.
   */
  body:not(.shell--full) .topbar {
    position: sticky; top: 0; z-index: 30;
    background: #fff;   /* content must not show through as it passes under */
  }

  /* The band carries the same two columns as the layout below it, which is
     what puts "Browse" over the panel and the strip over the content. One
     grid row rather than two matched heights: they cannot fall out of line
     because they are the same line. */
  body:not(.shell--full) .topbar__band {
    display: grid; grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
  }
  body:not(.shell--full) .topbar__browse {
    display: flex; align-items: center;
    min-height: var(--nav-height); margin: 0;
    padding: 0 var(--panel-pad);
    background: var(--green-700); color: #fff;
    font-family: var(--font-ui); font-size: .8rem; font-weight: 700;
    letter-spacing: .08em; text-transform: uppercase;
  }

  /* Inside the column each band runs its full width and starts a comfortable
     distance in from the panel, rather than being centred in a container of
     its own — which would leave the nav floating in the middle of its own
     strip with white either side. Long prose is still measured: that is
     `.shell__content .prose > *`, further down, and it stays capped. */
  body:not(.shell--full) .sections__inner,
  body:not(.shell--full) .shell {
    max-width: none; margin: 0; padding-inline: 1.75rem;
  }

  /* The menu's own name, shown once the strip is a strip. On a phone it is a
     drawer behind a hamburger and the badge in the account row is already on
     screen saying the same thing. */
  .sections__mode { display: inline-flex; }

  /* The masthead carries the page's own two columns: the wordmark over the
     panel, search and account over the content. Nothing draws a line through
     it — the alignment is what tells you the columns continue upward, and
     alignment that is eight pixels out reads as a mistake rather than as a
     decision, which is what it was. */
  body:not(.shell--full) .masthead__inner {
    max-width: none; margin: 0; padding-inline: 0;
    display: grid; grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
    align-items: center; gap: 0;
    /* Makes `--masthead-height` true rather than approximately true. The band
       below is told to start there, and a floor the content already exceeds
       would let the two overlap by the difference. */
    min-height: var(--masthead-height);
  }
  body:not(.shell--full) .masthead__brand { padding-left: var(--panel-pad); }
  body:not(.shell--full) .masthead__tools { padding-inline: 1.75rem; }

  /* The first nav item's *text* lines up with the heading below it. The strip
     pads its container and then every link pads itself again, so left to
     itself the first item can never sit above the content it introduces. */
  body:not(.shell--full) .sections__inner { padding-left: calc(1.75rem - .85rem); }

  body:not(.shell--full) .shell { padding-block: 0; }

  /* The current section, marked on the panel the way it is on the strip. */
  .rail a[aria-current] { border-left-color: var(--green-700); }
}

/* The rail.
 *
 * Rows rather than a list of words: each one is a whole strip you can hit,
 * led by the icon for where it goes, filling on hover and staying filled for
 * the page you are on. The full-width fill is what does the work — it is why
 * the current page is obvious from the corner of an eye, and why the rail
 * stops reading as a column of loose links. */
.rail {
  min-width: 0; padding-block: 1.25rem;
  border-right: 1px solid var(--line-soft);
  padding-right: .5rem;
}
.rail-toggle, .rail-toggle-label { display: none; }
.rail__title {
  font-family: var(--font-ui); font-size: .68rem; font-weight: 700;
  letter-spacing: .09em; text-transform: uppercase;
  color: var(--muted-2); margin: 0 0 .35rem; padding-left: .7rem;
}
.rail__title + .rail__list { margin-bottom: 1.4rem; }
.rail__list, .rail__sub { list-style: none; margin: 0; padding: 0; }
.rail__list > li { margin-bottom: 1px; }
.rail a {
  display: flex; align-items: center; gap: .65rem;
  padding: .45rem .7rem;
  font-family: var(--font-ui); font-size: .87rem; line-height: 1.3;
  color: var(--ink-strong); text-decoration: none;
  border-left: 3px solid transparent;
}
.rail a svg { width: 1.05rem; height: 1.05rem; flex: none; color: var(--muted); }
.rail a span { min-width: 0; }
.rail a:hover { background: var(--rail-hover); color: var(--green-900); }
.rail a:hover svg { color: var(--green-700); }
.rail a[aria-current] {
  font-weight: 600; color: var(--green-900);
  background: var(--rail-on); border-left-color: var(--green-700);
}
.rail a[aria-current] svg { color: var(--green-700); }
/* The same dot as the strip's, in the panel. */
.rail__new { width: .4rem; height: .4rem; border-radius: 50%;
  background: var(--yellow, #FED700); flex: none; margin-left: auto; }


/* ------------------------------------------------------- putting it away
 *
 * A block of its own, deliberately after the desktop arrangement rather than
 * inside it. Every region up there is styled by exactly one rule — there is a
 * test that counts them — because a second rule for the same region makes the
 * layout depend on which order they happen to be written in. These are the
 * override, so they say so by being separate and by being last.
 *
 * The width is one variable and the three grids already read it, which is why
 * hiding the panel is one declaration rather than four. The panel itself is
 * then taken out of the flow: a zero-width column would otherwise still draw
 * its right-hand rule and clip whatever is in it.
 */
@media (min-width: 56.0625rem) {
  /* THE HEADER IS NOT PART OF THIS
   *
   * Hiding the panel used to set the width variable to zero, and the masthead
   * reads that variable too — so its first column collapsed and the wordmark
   * was clipped to a sliver behind the search box. The header has nothing to
   * do with a panel below it; only the content and the band's own first cell
   * are its business.
   *
   * So the variable is left alone and the two grids that should change are
   * told their own columns. The masthead keeps the arrangement it always has,
   * which also means nothing in it moves when the panel comes and goes. */
  /* TWO COLUMNS, THE FIRST OF THEM NOTHING
   *
   * Not one column. The content is placed at `grid-column: 2` and the footer
   * spans `1 / -1`, so a single-column grid put the content in an implicit
   * second column sized to its own content — pushed right, with the width of
   * the panel it replaced standing empty beside it — and made `-1` mean the
   * end of the *explicit* grid, so the footer covered the left half and
   * stopped. Both were plainly visible and I talked myself out of them.
   *
   * Keeping the second track and collapsing the first changes exactly one
   * thing: the width of a column nothing is in. */
  .rail-hide:checked ~ .layout {
    grid-template-columns: 0 minmax(0, 1fr);
  }
  .rail-hide:checked ~ .layout .rail-col { display: none; }

  /* The band keeps a cell, and the panel does not.
   *
   * Setting the width variable to zero collapsed every one of the three
   * grids that read it — including the band, whose first cell holds the
   * control that brings the panel back. The button was squeezed to nothing
   * and its icon spilled over the first word of the menu beside it: the one
   * control you need was a smudge on top of a label.
   *
   * So the band is told its own two columns while the panel is away. The
   * layout and the masthead still go to zero, which is the point — the width
   * goes to the page. */
  /* `.topbar` is the sibling; the band lives inside it. Written as a sibling
     of the band itself, none of this matched anything at all — which is why
     the button stayed squashed on top of the first word of the menu. */
  .rail-hide:checked ~ .topbar .topbar__band {
    grid-template-columns: 3.25rem minmax(0, 1fr);
  }
  .rail-hide:checked ~ .topbar .topbar__browse {
    padding-inline: 0; justify-content: center;
  }
  /* Just the icon in that width. The word is what does not fit. */
  .rail-hide:checked ~ .topbar .railhide__word { display: none; }
}
/* The lesson list, now its own block at the foot of the rail rather than
   nested inside a course. Keeps its indented look so it still reads as
   subordinate to the course above it. */
.rail__sub { margin: .1rem 0 .75rem 0; }
.rail__sub a { font-size: .84rem; color: var(--muted); padding-left: 1.1rem; }
.rail__sub a svg { width: .95rem; height: .95rem; }
.rail__sub a[aria-current="page"] { color: var(--green-900); }
.rail__ref { color: var(--muted-2); margin-right: .3rem; }

/* Hamburger: a checkbox, so the menu opens with JavaScript switched off.
   Hidden on a desktop, where the sections sit in a strip of their own. */
.nav-toggle { display: none; }
.nav-toggle-label { display: none; }
.nav-overlay { display: none; }

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

.btn {
  display: inline-flex; align-items: center; justify-content: center;
  min-height: 2.4rem; padding: .45rem 1.1rem;
  font-family: var(--font-ui); font-size: .9rem; font-weight: 700;
  letter-spacing: .04em; text-transform: uppercase; line-height: 1.2;
  background: var(--yellow); color: var(--green-900);
  border: 0; border-radius: var(--radius);
  text-decoration: none; cursor: pointer; text-align: center;
}
.btn:hover { background: var(--yellow-dark); color: var(--green-900); }
.site-header .btn { color: var(--green-900); }
.btn--quiet {
  background: transparent; color: var(--green-700);
  box-shadow: inset 0 0 0 1.5px var(--line);
}
.btn--quiet:hover { background: var(--green-100); color: var(--green-900); }
.site-header .btn--quiet { color: #fff; box-shadow: inset 0 0 0 1.5px rgba(255,255,255,.35); }
.btn--block { width: 100%; }
/* Submit buttons get the primary treatment without needing a class on every
   form in the codebase. */
form button[type=submit] {
  display: inline-flex; align-items: center; justify-content: center;
  width: 100%; min-height: 2.9rem; padding: .55rem 1.35rem;
  font: inherit; font-weight: 650;
  background: var(--yellow); color: var(--green-900);
  border: 0; border-radius: var(--radius); cursor: pointer;
}
form button[type=submit]:hover { background: var(--yellow-dark); }

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

.hero { padding-block: 3.5rem 2.5rem; }
.hero h1 { font-size: 2.5rem; max-width: 16ch; }
.hero .lede { max-width: 44ch; }
.hero__actions { display: flex; gap: .75rem; flex-wrap: wrap; margin-top: 1.75rem; }

.band { background: var(--green-100); padding-block: 3rem; margin: 3.5rem 0 0; }
.band--dark { background: var(--green-700); color: #fff; }
.band--dark h2 { color: #fff; }
.band--dark .lede { color: var(--green-300); }

/* ---------------------------------------------------------------- lists */

/* `min(16rem, 100%)` rather than a bare 16rem: on a narrow phone a fixed
   minimum forces the track wider than its container and the page scrolls
   sideways. */
.grid { display: grid; gap: 1.25rem; grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr)); }

/* Minimal cards: a hairline and space, not a box with shadows. */
.card { border-top: 2px solid var(--green-700); padding: 1rem 0 0; }
.card h3 { margin-top: 0; }
.card a { text-decoration: none; color: var(--ink); }
.card a:hover { color: var(--green-700); }
.card--boxed {
  border: 1px solid var(--line); border-top: 2px solid var(--green-700);
  border-radius: var(--radius); padding: 1.15rem;
}
.card--soft { background: var(--green-100); border: 0; border-radius: var(--radius); padding: 1.15rem; }

.steps { list-style: none; padding: 0; counter-reset: step; margin: 1.5rem 0 0; }
.steps li { position: relative; padding-left: 2.75rem; margin-bottom: 1.5rem; }
.steps li::before {
  counter-increment: step; content: counter(step);
  position: absolute; left: 0; top: 0;
  width: 1.9rem; height: 1.9rem; border-radius: 50%;
  display: grid; place-items: center;
  background: var(--yellow); color: var(--green-900);
  font-size: .9rem; font-weight: 700;
}

/* A module carries a title and a sentence, so the gap between items has to
   read as larger than the gap inside one. Without this the summary looks like
   it belongs to the unit below it. */
.steps--modules li { margin-bottom: 1.1rem; }
.steps--modules li > a { font-weight: 600; }
.steps--modules li > .meta { margin-top: .15rem; }

/* The worksheets index. Not `.sheet` — that belongs to the printable pack
   view and carries print rules that strip its padding and margins, so a
   listing built on the same name would come apart the moment somebody
   printed this page. */
.sheetlist { list-style: none; padding: 0; margin: .75rem 0 0; }
.sheetlist__item {
  padding: .9rem 0; border-top: 1px solid var(--line);
}
.sheetlist__item:first-child { border-top: 0; padding-top: .3rem; }
.sheetlist__title { font-weight: 600; margin-bottom: .15rem; }
/* The lesson link and the PDF link on one line, wrapping rather than
   truncating: a lesson title is part of the sentence here, not a label. */
.sheetlist__links { margin: .35rem 0 0; font-size: .95rem; }

.pill {
  display: inline-block; padding: .12rem .55rem; margin-right: .3rem;
  border-radius: 999px; background: var(--green-100); color: var(--green-800);
  font-size: .8rem; font-weight: 600;
}
.pill--yellow { background: var(--yellow); color: var(--green-900); }

.rows { list-style: none; margin: 1.5rem 0 0; padding: 0; }
.rows li { padding: 1rem 0; border-bottom: 1px solid var(--line); }
.rows li:first-child { border-top: 1px solid var(--line); }

/* ---------------------------------------------------------------- prose */

/* Text keeps a readable measure. Everything you are meant to *look* at —
   an explorable, a diagram, a table, a graph — breaks out wider on a big
   screen, because a 500px sketch on a 27" monitor is the page apologising
   for itself. */
/* Text keeps a readable measure; the things you are meant to *look* at get
   more room. Done by letting the article fill its column and constraining the
   children, rather than by pulling wide blocks outwards with negative
   margins — those were 8rem wider than the column and landed on top of the
   rail. Nothing here can leave the column it is in. */
/* The reading face and a reading size, for the same reason as the player's
   prose slides: the rest of the site is a UI at 15px and a lesson is not a
   UI. The measure widens with the type so a line still holds about
   sixty-five characters. */
.prose {
  max-width: 46rem; margin-inline: auto;
  font-family: var(--font-read);
  font-size: 1.15rem; line-height: 1.6;
}
/* Anything inside a lesson that is a control or a label stays on the UI
   face — a question set, a download, the caption under a picture. Reading is
   what changed, not the furniture. */
.prose .practice, .prose .worksheet, .prose .download,
.prose figcaption, .prose .meta, .prose .audio__title {
  font-family: var(--font);
}

.shell__content .prose { max-width: none; }
/* Left-aligned, not centred: media blocks run the width of the column and
   text stops at a readable measure, so centring the text would leave the
   two with different left edges and read as a misalignment. */
.shell__content .prose > * { max-width: 46rem; margin-inline: 0; }
.shell__content .prose > .w-block-sketch,
.shell__content .prose > .w-block-desmos,
.shell__content .prose > .w-block-image,
.shell__content .prose > .w-block-table,
.shell__content .prose > .w-block-video,
.shell__content .prose > .w-block-embed { max-width: 100%; }
.prose h2 { margin-top: 2.25rem; }
.prose ul, .prose ol { margin: 0 0 1.1rem; padding-left: 1.25rem; }
.prose li { margin-bottom: .4rem; }
.prose figure { margin: 2rem 0; }
.prose figcaption { font-size: .9rem; color: var(--muted); margin-top: .6rem; }
.prose iframe { max-width: 100%; border: 0; aspect-ratio: 16/9; height: auto; }

/* An aside, set as prose rather than as a panel.
 *
 * This was a tinted card with a yellow bar down its left edge, and on a
 * lesson page it read as an advertisement inside the lesson — a second,
 * louder column of text competing with the maths for the same square inch.
 * On a phone it also cost the page most of its width to say something the
 * words could say by starting in bold.
 *
 * So the indicator is the type. The heading runs into the first line as a
 * bold lead, which is what a printed textbook has always done, and the four
 * styles differ in what the heading *says* rather than in what colour they
 * are painted. */
.callout { margin: 1.5rem 0; }
.callout__heading {
  display: inline; margin: 0;
  font-size: inherit; font-weight: 700; color: var(--ink-strong);
}
.callout__heading::after { content: " "; }
/* The body's first paragraph continues the heading's line rather than
   starting under it — one sentence, not a title with a caption. */
.callout__body { display: inline; }
.callout__body > p:first-child { display: inline; }
.callout__body > p:last-child { margin-bottom: 0; }

/* Maths. Before KaTeX arrives the LaTeX source is on screen, which is ugly
   but present — better than a gap that never fills on a stalled connection.
   A long equation scrolls in its own box for the same reason a table does. */
.math { margin: 1.75rem 0; text-align: center; }
.math__render { overflow-x: auto; overflow-y: hidden; padding: .2rem 0; }
.math__source {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: .95rem; color: var(--muted);
}
.math__render[data-rendered] .math__source { display: none; }
.math figcaption { font-size: .9rem; color: var(--muted); margin-top: .5rem; }

/* A table is the one block that can be wider than the page. It scrolls inside
   its own box so the body never scrolls sideways, and the box is focusable so
   a keyboard can reach the overflow. */
.datatable { margin: 1.75rem 0; }
.datatable__caption {
  font-weight: 650; font-size: .95rem; margin-bottom: .5rem; color: var(--ink);
}
.datatable__scroll { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius); }
.datatable__scroll:focus-visible { outline: 2px solid var(--green-700); outline-offset: 2px; }
.datatable table { border-collapse: collapse; width: 100%; font-size: .95rem; }
.datatable th, .datatable td {
  padding: .55rem .8rem; text-align: left; border-bottom: 1px solid var(--line);
  white-space: nowrap;
}
.datatable thead th { background: var(--green-100); font-weight: 650; }
.datatable tbody tr:last-child th, .datatable tbody tr:last-child td { border-bottom: 0; }
.datatable tbody th { font-weight: 650; }

.download {
  display: block; text-decoration: none; color: var(--ink);
  border: 1px solid var(--line); border-radius: var(--radius);
  padding: .9rem 1rem; margin: 1rem 0;
}
.download:hover { border-color: var(--green-700); }
.download__title { display: block; font-weight: 650; }
.download__desc, .download__meta { display: block; font-size: .9rem; color: var(--muted); }

/* --- the people in a problem -------------------------------------------- */

/* A row that wraps, each figure with its name under it. No box, no tint: they
   are a drawing in the prose, and the moment they sit in a panel they become
   an illustration *about* the problem rather than the people in it. */
.cast {
  display: flex; flex-wrap: wrap; gap: 1.5rem 2rem;
  align-items: flex-start;
  margin: 1.5rem 0;
}
.cast__person { margin: 0; max-width: 11rem; }
.cast__name {
  font-weight: 700; color: var(--ink-strong);
  margin-top: .2rem;
}
.cast__says {
  margin: .1rem 0 0; font-size: .92rem; color: var(--muted);
  line-height: 1.35;
}

/* Beside a line of prose rather than under it, for the one-off case: a peep
   dropped into a sentence sits on the baseline like a very large capital. */
.peep--inline { vertical-align: -.55em; margin-right: .15em; }

/* ---------------------------------------------------------------- video */

.video { margin: 2rem 0; }

/* A spoken guide.
 *
 * The player is a control and looks like one; the words around it are words.
 * This used to sit in a grey band with its title in green, which made a
 * one-line caption and a script of plain speech into a second column of
 * furniture — the same panel habit as the old callout, in a different place.
 * No tint, no radius, no padding: a line saying what it is, and the player. */
.audio { margin: 1.5rem 0; }
.audio__title { font-size: .95rem; color: var(--muted); margin-bottom: .5rem; }
.audio__player { width: 100%; display: block; }

/* A printable sheet shown as pages. The page image carries its own white
   background and margins, so it wants a border rather than padding. */
.worksheet { margin: 1.75rem 0; }
.worksheet__head { margin-bottom: .6rem; }
.worksheet__title { font-weight: 600; display: block; }
.worksheet__desc { color: var(--muted); font-size: .92rem; }
.worksheet__page {
  display: block; width: 100%; height: auto; margin: 0 auto .75rem;
  border: 1px solid var(--line, #ddd); border-radius: var(--radius);
  background: #fff;
}
.worksheet__actions { display: flex; flex-wrap: wrap; gap: .5rem; margin: .75rem 0 0; }

/* Superseded by .worksheet for generated sheets; still used by `::: download`
   for anything uploaded by hand. */ Tall enough to be a
   page and not a letterbox; it scrolls inside itself on a phone. */
.download__preview { margin: .5rem 0 1.5rem; }
.download__preview summary {
  cursor: pointer; font-size: .92rem; color: var(--muted); padding: .3rem 0;
}
.download__pdf {
  display: block; width: 100%; height: min(80vh, 40rem);
  border: 1px solid var(--line, #ddd); border-radius: var(--radius);
  background: #fff;
}

/* A silent loop, inline in the prose. Sized like a figure rather than like a
   video, because that is what it is — a picture that moves. */
.animation { margin: 1.75rem 0; }
/* The ground behind a loop is the loop's own ground.
 *
 * A video box is painted before the first frame arrives and wherever the
 * frame does not exactly fill it — a rounding of a pixel at the rounded
 * corners, a moment on a slow connection. White behind a green film flashes
 * and edges it; the same colour behind it does neither. `--loop-bg` is the
 * background the Loop scenes render on, kept here as one value so the two
 * cannot drift apart. */
.animation__video {
  display: block; width: 100%; max-width: 34rem; height: auto;
  margin: 0 auto; border-radius: var(--radius);
  background: var(--loop-bg);
}
.animation figcaption {
  margin-top: .5rem; font-size: .92rem; color: var(--muted);
  text-align: center;
}
/* Someone who has asked their system for less movement has asked for it here
   too. The controls stay, so the loop is still watchable on purpose. */
@media (prefers-reduced-motion: reduce) {
  .animation__video { animation: none; }
}
.video__frame {
  position: relative; aspect-ratio: 16/9; overflow: hidden;
  background: var(--green-900); border-radius: var(--radius);
}
.video__frame iframe { width: 100%; height: 100%; border: 0; display: block; }
.video__play {
  width: 100%; height: 100%; padding: 0; border: 0; cursor: pointer;
  background: var(--green-800); display: grid; place-items: center; position: relative;
}
.video__play img { width: 100%; height: 100%; object-fit: cover; }
.video__play-icon {
  position: absolute; display: grid; place-items: center;
  width: 4rem; height: 4rem; border-radius: 50%;
  background: var(--yellow); color: var(--green-900);
  font-size: 1.4rem; padding-left: .2rem;
}
.video__duration {
  position: absolute; right: .6rem; bottom: .6rem;
  background: rgba(0,0,0,.7); color: #fff;
  font-size: .8rem; padding: .1rem .45rem; border-radius: 4px;
}

/* Expandable explanations and transcripts share one look.
 *
 * A disclosure is a control rather than a decoration — it does something
 * when you press it — so unlike the callout it keeps a mark. The mark is the
 * smallest one that still reads as "there is more here": a bold summary and
 * a caret that turns. No panel, no coloured bar, no tint. */
.nutshell, .transcript { margin: 1.25rem 0; }
.nutshell > summary, .transcript > summary {
  cursor: pointer; font-weight: 650; color: var(--ink-strong);
  list-style: none; padding: .2rem 0;
}
.nutshell > summary:hover, .transcript > summary:hover { color: var(--green-700); }
.nutshell > summary::-webkit-details-marker,
.transcript > summary::-webkit-details-marker { display: none; }
/* Drawn, not typed - the rule the rest of the site follows for arrows, and
 * here it was broken twice over. This was a caret that had been saved through
 * the wrong encoding at some point and was sitting in the file as U+0083
 * followed by a capital A, so every disclosure on the site opened with a tofu
 * box and a stray letter against its label. Two borders on an empty box are
 * the same shape with no font in the way at all. */
.nutshell > summary::before, .transcript > summary::before {
  content: ""; display: inline-block;
  width: .36em; height: .36em;
  margin-right: .6em;
  border-right: 2px solid var(--muted-2);
  border-bottom: 2px solid var(--muted-2);
  transform: translateY(-.15em) rotate(-45deg);
  transition: transform .15s ease;
}
.nutshell[open] > summary::before, .transcript[open] > summary::before {
  transform: translateY(-.05em) rotate(45deg);
}
@media (prefers-reduced-motion: reduce) {
  .nutshell > summary::before, .transcript > summary::before { transition: none; }
}
.nutshell__body, .transcript__body { padding-top: .4rem; }
.nutshell__body > *:last-child, .transcript__body > *:last-child { margin-bottom: 0; }
/* A transcript is the guidance itself whenever the recording does not exist
   yet, so it is set as text to read rather than as small grey supporting
   matter. Bounded and scrolled, because a script can be long. */
.transcript__body { max-height: 22rem; overflow-y: auto; }

/* ---------------------------------------------------------------- sketches */

.sketch { margin: 2rem 0; }
.sketch__prompt { font-weight: 650; }
.sketch__stage {
  display: grid; place-items: stretch; overflow: hidden;
  background: var(--green-100); border-radius: var(--radius);
}
.sketch__start {
  width: 100%; min-height: inherit; background: none; border: 0; padding: 2rem 0;
  cursor: pointer; position: relative; display: grid; place-items: center;
}
.sketch__start-label {
  display: inline-block; padding: .6rem 1.2rem;
  font-family: var(--font-ui); font-size: .9rem; font-weight: 700;
  letter-spacing: .04em; text-transform: uppercase;
  background: var(--yellow); color: #000;
}
/* With a poster behind it the label has to sit *on* the picture, centred.
   An absolutely positioned grid child takes its static position from the
   padding edge, not the centre, so it needs saying explicitly. */
.sketch__start:has(img) { padding: 0; }
.sketch__start img { display: block; width: 100%; height: auto; }
.sketch__start img + .sketch__start-label,
.desmos__start img + .sketch__start-label {
  /* Along the bottom rather than dead centre: the middle of a poster is
     usually the part worth looking at. */
  position: absolute; left: 50%; bottom: .9rem;
  transform: translateX(-50%);
  box-shadow: 0 2px 10px rgba(0, 0, 0, .25);
}
.desmos__start:has(img) { padding: 0; }
.desmos__start img { display: block; width: 100%; height: auto; }
.sketch__credit { display: block; margin-top: .3rem; }
.sketch canvas { display: block; max-width: 100%; }
.sketch__error, .sketch__loading, .sketch__missing { padding: 2rem 1rem; color: var(--muted); }

.sketch-ui { width: 100%; padding: .9rem 1rem 1rem; background: #fff; border-top: 1px solid var(--line); }
.sketch-ui__row { display: flex; align-items: center; gap: .8rem; margin-bottom: .7rem; }
.sketch-ui__row:last-child { margin-bottom: 0; }
.sketch-ui__label { flex: none; min-width: 6.5rem; font-size: .92rem; font-weight: 650; }
.sketch-ui input[type=range] { flex: 1; min-width: 0; height: 2rem; accent-color: var(--green-700); }
.sketch-ui button {
  min-height: 2.6rem; padding: .4rem 1.1rem; width: auto; font: inherit; font-weight: 650;
  background: var(--green-700); color: #fff; border: 0; border-radius: var(--radius); cursor: pointer;
}
.sketch-ui button:hover { background: var(--green-800); }

/* ---------------------------------------------------------------- forms */

label { display: block; font-weight: 650; margin: 1.1rem 0 .35rem; font-size: .95rem; }
input[type=text], input[type=email], input[type=tel], input[type=number],
input[type=password], select, textarea {
  width: 100%; padding: .75rem .8rem; font: inherit;
  border: 1px solid var(--line); border-radius: var(--radius);
  background: #fff; color: var(--ink);
}
input:focus-visible, select:focus-visible, textarea:focus-visible,
a:focus-visible, button:focus-visible, summary:focus-visible {
  outline: 3px solid var(--yellow); outline-offset: 2px;
}
.errors { color: var(--warn); font-size: .9rem; margin: .35rem 0 0; }
.checkline { display: flex; gap: .6rem; align-items: flex-start; margin: 1rem 0 0; }
.checkline input { width: 1.15rem; height: 1.15rem; margin-top: .25rem; flex: none; accent-color: var(--green-700); }
.checkline label { margin: 0; font-weight: 400; font-size: .95rem; }

.gate { background: var(--green-100); border-radius: var(--radius); padding: 1.5rem; margin: 2rem 0; }
.gate h2 { margin-top: 0; }

/* ---------------------------------------------------------------- quiz */

/* The sitting.
 *
 * One screenful, on a phone and on a laptop. A learner scrolling to find the
 * answer box has to hold the question in their head while they do it, and on
 * a maths test that working memory is the thing being measured.
 *
 * Two things get us there. The page is exactly the height of the viewport
 * with the actions pinned to the bottom, and above about 900px it splits into
 * two columns — the question on the left, everything they touch on the right.
 * That halves the vertical stack and spends the width a wide screen has,
 * instead of leaving a narrow column in a field of white.
 */
/* `.quiz` is the wrapper on every diagnostic page — the start page and the
   report as well as a sitting — so what it carries by default has to suit a
   page you read and scroll. A narrow measured column, as before.

   Everything the *sitting* needs is scoped to `.quiz-page`, which only
   sit.html sets. That is the rule this file learned the hard way: page layout
   hangs off a page's own class, never off a shared modifier. `.shell--full`
   is worn by the homepage, the start page and the report too, and a sitting
   rule hung there reaches all three. */
.quiz { max-width: var(--narrow); margin: 0 auto; padding: 1.75rem var(--gutter) 4rem; }

/* --- the sitting, and only the sitting -------------------------------- */

.quiz-page .shell__content { display: flex; padding-block: 0; }
/* The sitting owns its gutters. The shell's would stack on top of the quiz's,
   which on a narrow phone is 2rem of white each side before any maths. */
.quiz-page .shell { padding-inline: 0; }
.quiz-page .quiz {
  flex: 1; width: 100%; max-width: 64rem; margin: 0 auto;
  padding: 1rem var(--gutter) 1.25rem;
  display: flex; flex-direction: column; min-height: 0;
}

.quiz-page .quiz__head {
  display: flex; align-items: baseline; gap: .75rem;
  margin-bottom: .6rem;
}
.quiz-page .quiz__brand {
  font-family: var(--font-ui);
  font-size: 1.15rem; font-weight: 700; letter-spacing: .01em;
  color: var(--green-700); white-space: nowrap;
}
.quiz-page .quiz__strand { flex: 1 1 auto; }

/* The clock on a paper that has one, and the note on a teacher's dry run.
 * Both scoped to `.quiz-page` like everything else the sitting owns: that
 * class is set by one template, and the modifier it sits beside is shared
 * with three pages that must not grow either of these. */
.quiz-page .quiz__clock {
  margin: 0; flex: none;
  font-family: var(--font-ui); font-variant-numeric: tabular-nums;
  font-size: 1.05rem; font-weight: 700; letter-spacing: .02em;
  color: var(--green-700);
}
.quiz-page .quiz__clock--soon { color: var(--warn); }
.quiz-page .quiz__dry {
  margin: 0 0 .6rem; padding: .35rem .7rem;
  font-family: var(--font-ui); font-size: .78rem; font-weight: 600;
  letter-spacing: .04em; text-transform: uppercase;
  color: var(--ink-strong); background: var(--yellow-pale, #FFF8D9);
  border: 1px dashed var(--line);
}

.quiz-page .quiz__body {
  flex: 1; min-height: 0;
  display: grid; grid-template-columns: minmax(0, 1fr);
  gap: 1.25rem;
  /* One column, so the leftover height goes *between* the question and the
     buttons rather than piling up underneath them. The buttons then sit
     where a thumb already is, and there is no dead band at the bottom. */
  align-content: space-between;
}
/* The question can scroll inside its own column if an image is very tall.
   The actions never do — a Next button below the fold is a Next button
   nobody finds. */
.quiz-page .quiz__ask { min-height: 0; overflow-y: auto; }
.quiz-page .quiz__act { align-self: end; }
/* "3 to return to at the end", beside the strand. Small, but where the eye
   already goes rather than under the buttons where it was being missed. */
.quiz-page .quiz__tally {
  display: inline-block; margin-left: .5rem; padding: .1rem .4rem;
  background: var(--bg-soft); color: var(--muted);
  font-size: .8rem; font-weight: 600; white-space: nowrap;
}

/* Phones. The gutter comes in, the vertical padding comes down, and the
   keypad is allowed the full width it now has. */
@media (max-width: 560px) {
  .quiz-page .quiz { padding: .75rem .65rem .5rem; }
  .quiz-page .quiz__head { margin-bottom: .4rem; }
  .quiz-page .progress { margin-bottom: .9rem; }
  .quiz-page .quiz__body { gap: .9rem; }
  .q-answer { margin-top: .9rem; }
}

@media (min-width: 900px) {
  .quiz-page .quiz { padding-block: 1.5rem; }
  /* Read on the left, touch on the right. The right column now carries the
     answer box and the keypad as well as the buttons, which is what it was
     always described as doing — while it held a button on its own, a
     question with a diagram ran down the left in one tall strip and left
     half the screen white. */
  .quiz-page .quiz__body {
    grid-template-columns: minmax(0, 1fr) minmax(0, 24rem);
    gap: 2.5rem; align-content: stretch;
  }
  .quiz-page .quiz__ask { align-self: center; }
  /* Top, not bottom: the answer follows the question across, and a box
     pinned to the floor of a tall column is a box beside nothing. */
  .quiz-page .quiz__act { align-self: start; display: flex;
                          flex-direction: column; gap: .9rem; }
  /* The box sits at the top of its column; the keypad keeps its own gap
     under it, and Next keeps clear of the keypad. Flush against sixteen keys
     it reads as a seventeenth. */
  .quiz-page .quiz__act .q-answer { margin-top: 0; }
  .quiz-page .quiz__act .btn--block { margin-top: .35rem; }
}

/* A tall image is the one thing that can still push the page over. Capped
   rather than allowed to set the height of the sitting. */
/* The question's own image — the matrix grid. Sized against the viewport so
   a short phone gets a smaller one rather than a scrollbar. Option images are
   excluded: they are laid out by their own grid. */
.quiz-page .quiz__ask > img,
.quiz-page .quiz__ask .q-stem + img { max-height: clamp(7rem, 34vh, 26rem); width: auto; margin-inline: auto; }
@media (min-width: 900px) {
  .quiz-page .quiz__ask > img,
  .quiz-page .quiz__ask .q-stem + img { max-height: clamp(10rem, 52vh, 34rem); }
}

/* Short screens — a laptop in a browser with three toolbars, or a phone
   landscape. Everything tightens rather than the page growing. */
@media (max-height: 720px) {
  .q-answer input[type=text] { font-size: 1.25rem; padding: .55rem .8rem; }
  .q-advice { display: none; }
  /* The grid only. Option panels are sized by their own grid, and shrinking
     them here would leave four specks floating in a wide row. */
  .quiz-page .quiz__ask > img,
  .quiz-page .quiz__ask .q-stem + img { max-height: 30vh; }
}

/* ...except once there are two columns.
   
   The 30vh above is for the one-column case, where the diagram, the answer
   box, the keypad and the button are all stacked and every pixel the picture
   takes is a pixel the Next button moves down. With the answer moved into
   its own column that competition is gone: the diagram has the left side to
   itself, and shrinking it there only makes a "find the missing side"
   question harder to read for no gain. Placed after the short-screen block
   so it wins on order rather than on weight. */
@media (min-width: 900px) {
  .quiz-page .quiz__ask > img,
  .quiz-page .quiz__ask .q-stem + img { max-height: min(52vh, 30rem); }
}

.progress { height: 5px; background: var(--line); border-radius: 999px; overflow: hidden; margin-bottom: 1.5rem; }
.progress > span { display: block; height: 100%; background: var(--green-700); transition: width .2s ease; }
/* A question stem sits a little larger in a standalone sitting than inside a
   lesson, where it has a numbered row and prose around it. Everything below
   the stem — the options themselves — is identical in both. */
.quiz-page .q-stem { font-size: 1.25rem; margin: .4rem 0 .75rem; }

/* Screen-reader-only. Used where a visible label would repeat what the
   question already says. */
.visually-hidden {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
}

/* The number beside a practice question, so the stem itself needs no markup
   of its own and can be the shared partial. */
.practice__number {
  float: left; margin: .1rem .6rem 0 0; font-weight: 700; color: var(--muted);
}

ul.q-options { list-style: none; margin: 1.25rem 0 0; padding: 0; }
ul.q-options li { margin: 0 0 .6rem; }
/* A block, not a flex row.
 *
 * It was `display: flex`, which is right for the graded variant — a radio
 * beside a label, in a row, with a gap — and quietly wrong for the self-check
 * one, whose whole content is a sentence.
 *
 * A flex container makes every child a flex item, **including each run of
 * bare text**. While an option was plain text that was one item and looked
 * like a paragraph. The moment KaTeX rendered the notation inside it, the
 * option became four items — text, expression, text, expression — laid out as
 * four columns, each squeezed to a fifth of the width. "8 + 2 = 10, then
 * 10 + 3 = 13" came out as two stacked towers with the word "then" floating
 * between them, in every multi-part option on the site.
 *
 * So the row is asked for only where there is something to put in a row. */
.q-option {
  display: block; margin: 0;
  width: 100%; text-align: left;
  padding: .9rem 1rem; font: inherit; font-weight: 500; min-height: 3rem;
  background: #fff; color: var(--ink);
  border: 1.5px solid var(--line); border-radius: var(--radius); cursor: pointer;
}
.q-option:has(input[type=radio]) {
  display: flex; align-items: center; gap: .75rem;
}
.q-option input[type=radio] { width: 1.2rem; height: 1.2rem; flex: none; accent-color: var(--green-700); }
.q-option:has(input:checked) { border-color: var(--green-700); background: var(--green-100); }
.q-option:hover { border-color: var(--green-600); }

/* Self-check states, set by practice.js. Same element, same shape — only the
   border says anything, so a wrong answer is not a splash of colour. */
.q-option.is-chosen { font-weight: 650; }
.q-option.is-right { border-color: var(--green-700); box-shadow: inset 0 0 0 1px var(--green-700); }
.q-option.is-wrong { border-color: var(--warn); box-shadow: inset 0 0 0 1px var(--warn); }
/* Staff answer key on a printed worksheet. Never on a learner's copy. */
.q-option.is-answer { box-shadow: inset 0 0 0 2px var(--green-700); font-weight: 700; }
.q-option.is-answer::after { content: " ✓"; color: var(--green-700); }

.q-stem { font-size: 1.15rem; font-weight: 650; margin: 0 0 .75rem; }
.q-numeric { display: flex; gap: .5rem; align-items: center; margin: 0; }
.q-numeric input { flex: 1; min-width: 0; }
.q-numeric input.is-right { border-color: var(--green-700); }
.q-numeric input.is-wrong { border-color: var(--warn); }
.q-numeric .btn { width: auto; flex: none; }

/* The standing rules of the paper, top right — where the rubric sits on a
   real exam paper, out of the reading line. It is a condition of the whole
   sitting, not a note about this question. Pushed there by `.quiz__strand`
   taking the slack in `.quiz__head`. */
.q-rule {
  flex: none; margin: 0; text-align: right;
  font-size: .8rem; font-weight: 700; line-height: 1.3;
  text-transform: uppercase; letter-spacing: .04em; color: var(--muted);
}
.q-rule span { display: block; font-weight: 500; text-transform: none; letter-spacing: 0; }

/* Said once, on the first question. Working it out on paper before choosing
   is the habit worth building, and repeating it above all twenty-five would
   turn it into wallpaper. */
.q-advice {
  margin: .75rem 0 0; padding: .7rem .9rem;
  background: var(--bg-soft); border-left: 3px solid var(--green-700);
  font-size: .95rem;
}

/* The way out of a question. A small grey outline, never the yellow — that
   is reserved for the thing we want them to do, which is answer, and a way
   out painted the same colour competes with it. Centred, well below Next,
   and hidden for the first thirty seconds by sitting.js. */
.q-aside { margin: 1.5rem 0 0; text-align: center; }
/* Beaten by `form button[type=submit]`, which is (0,1,2) and paints a
   full-width yellow slab — the same rule the sign-out button had to climb
   over, for the same reason. `form button.q-aside__link` is (0,2,1) and
   wins on its own merits.
   
   It matters more than it looks. This is the way out of a question, and
   drawn as a second Next it invites a child to take it before they have
   tried — which is a missing answer dressed up as a decision. It went
   unnoticed until skipping was switched on. */
form button.q-aside__link,
.q-aside__link {
  display: inline-block; width: auto; padding: .4rem .9rem;
  font: inherit; font-size: .85rem; font-weight: 500;
  color: var(--muted); background: transparent;
  border: 1px solid var(--line); border-radius: var(--radius);
  cursor: pointer;
}
form button.q-aside__link:hover,
.q-aside__link:hover { color: var(--ink); border-color: var(--muted-2);
  background: transparent; }
.q-back { font-weight: 600; margin: .25rem 0 0; }

/* The maths keypad.
 *
 * A calculator layout, because that is the shape everyone already knows and a
 * learner should not have to work ours out on the way to answering a maths
 * question. Digits in the first three columns; the things a phone keyboard
 * makes hard — divide, minus, space, delete — in the fourth, tinted so the
 * hand goes there without reading.
 *
 * Sits under the field, full width, not squeezed beside it.
 */
.q-answer { margin: 1.25rem 0 0; }
.q-answer input[type=text] {
  width: 100%; padding: .8rem .9rem;
  font-size: 1.5rem; font-weight: 600; font-variant-numeric: tabular-nums;
  border: 1.5px solid var(--line); border-radius: var(--radius);
  background: #fff; color: var(--ink);
}
.q-answer input[type=text]:focus {
  outline: none; border-color: var(--green-700);
  box-shadow: inset 0 0 0 1px var(--green-700);
}

.keypad {
  display: grid; grid-template-columns: repeat(4, 1fr);
  gap: 1px; margin-top: .75rem; max-width: 22rem;
  /* The gap is the grid showing through, so the keys read as one slab
     rather than sixteen loose boxes. */
  background: var(--line); border: 1px solid var(--line);
}
.keypad__key {
  /* Against the viewport, not a fixed height: a long word-problem stem plus a
     fixed keypad is what pushes a phone over into scrolling. */
  min-height: clamp(2.5rem, 7.5vh, 3.4rem); border: 0; border-radius: 0;
  font: inherit; font-size: 1.35rem; font-weight: 600;
  font-variant-numeric: tabular-nums;
  background: #fff; color: var(--ink); cursor: pointer;
  touch-action: manipulation; user-select: none;
  transition: background .08s ease;
}
.keypad__key:hover { background: var(--bg-soft); }
.keypad__key:active { background: var(--green-100); color: var(--green-900); }
.keypad__key:focus-visible {
  outline: 2px solid var(--green-700); outline-offset: -2px; position: relative;
}
/* The fourth column. Quieter than the digits, and set apart, because they are
   the operations rather than the number. */
.keypad__key--op,
.keypad__key--delete,
.keypad__key--clear {
  background: var(--bg-soft); color: var(--muted); font-size: 1.2rem;
}
.keypad__key--op:hover,
.keypad__key--delete:hover,
.keypad__key--clear:hover { background: var(--line-soft); color: var(--ink); }
.keypad__key--clear { font-size: 1.05rem; letter-spacing: .04em; }

@media (max-width: 560px) {
  .keypad { max-width: none; }
  .keypad__key { font-size: 1.3rem; }
  .q-answer input[type=text] { font-size: 1.45rem; }
  /* A word problem is two or three lines of prose. It does not need the
     display size a bare "6 x 7" earns. */
  .quiz-page .q-stem { font-size: 1.1rem; margin-bottom: .5rem; }
}

/* Picture answers. The radio is still there and still does the work; it is
   just not what the learner aims at. Two columns on a phone so each panel
   stays big enough to read the shapes inside it. */
ul.q-options--images { display: grid; grid-template-columns: repeat(4, 1fr); gap: .6rem; }
ul.q-options--images li { margin: 0; }
.q-option--image {
  display: grid; place-items: center; margin: 0; padding: .5rem;
  min-height: 0; border: 1.5px solid var(--line); border-radius: var(--radius);
  cursor: pointer; background: #fff;
}
.q-option--image img { display: block; width: 100%; height: auto; }
/* Hidden from sight, not from the browser: keyboard focus, screen-reader
   announcement and form submission all still run through the real input. */
.q-option--image input[type=radio] {
  position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none;
}
.q-option--image:has(input:checked) {
  border-color: var(--green-700); box-shadow: inset 0 0 0 2px var(--green-700);
}
.q-option--image:has(input:focus-visible) { outline: 2px solid var(--green-700); outline-offset: 2px; }
.q-option--image span { font-weight: 600; padding: 1rem .5rem; }
@media (max-width: 560px) {
  /* One row, not two. The 3x3 grid above is what has to stay readable, and
     two rows of options were taking the height it needed — which is what made
     a matrix the only question a learner had to scroll. A single option panel
     holds one to four shapes, so it survives being small far better than the
     grid does. */
  ul.q-options--images { grid-template-columns: repeat(4, 1fr); gap: .35rem; }
  .q-option--image { padding: .25rem; }
}

/* Ordering items. One row per thing to place, with a target big enough to hit
   on a phone — this is the item type where a mis-tap is indistinguishable
   from a misconception, so the target has to be honest. */
/* Said once, above the rows. Quiet on purpose: it is a fact about the
   question, not a warning, and a learner who has understood the task should
   be able to slide past it. It exists because a column of selects invites
   filling downwards, and nearly a third of the first answers we collected
   were exactly that — the rows left as shown. */
.q-ordering__hint {
  margin: 1rem 0 0; font-family: var(--font-ui);
  font-size: .9rem; font-weight: 600; color: var(--muted);
}
ol.q-ordering { list-style: none; margin: .5rem 0 0; padding: 0; }
ol.q-ordering li {
  display: flex; align-items: center; gap: .75rem; margin: 0 0 .6rem;
  padding: .65rem .75rem .65rem 1rem;
  border: 1.5px solid var(--line); border-radius: var(--radius);
}
ol.q-ordering label { margin: 0; flex: 1 1 auto; font-weight: 600; font-size: 1.15rem; }
ol.q-ordering select {
  flex: none; width: 4.5rem; min-height: 3rem; padding: .5rem;
  font-size: 1rem; font-weight: 600;
  border: 1.5px solid var(--line); border-radius: var(--radius);
  background: var(--surface, #fff); color: inherit;
}
ol.q-ordering .errors { flex: 1 0 100%; margin: .35rem 0 0; }

/* Once ordering.js is in, the selects stop being the interface: the row is
   dragged, and the arrow keys move a focused row. The selects stay in the DOM
   carrying the answer, so a failed script leaves a working question rather
   than a dead one. */
ol.q-ordering--draggable select { display: none; }
ol.q-ordering--draggable .q-ordering__row { cursor: grab; touch-action: none; user-select: none; }
ol.q-ordering--draggable .q-ordering__row.is-dragging {
  cursor: grabbing; position: relative; z-index: 2;
  border-color: var(--green-700); background: var(--green-100);
  box-shadow: 0 6px 18px rgba(0,0,0,.16);
  /* The dragged row is pinned to the finger, so it must never ease — a
     transition here is a lag between the pointer and the thing it is
     holding, which reads as the row being slippery. */
  transition: none;
}
/* The rows making room do ease, and only these. `will-change` keeps them on
   their own layer so opening a gap does not repaint the list. */
ol.q-ordering--draggable.is-reordering .q-ordering__row:not(.is-dragging) {
  transition: transform .16s cubic-bezier(.2, 0, 0, 1);
  will-change: transform;
}
/* Some people find motion unpleasant, and a placement test is a bad place to
   make someone feel unwell. The rows still move, just without the slide. */
@media (prefers-reduced-motion: reduce) {
  ol.q-ordering--draggable.is-reordering .q-ordering__row:not(.is-dragging) {
    transition: none;
  }
}
.q-ordering__place {
  flex: none; width: 1.9rem; height: 1.9rem; display: grid; place-items: center;
  font-weight: 700; font-size: .9rem;
  background: var(--green-100); color: var(--green-700); border-radius: var(--radius);
}
.q-ordering__grip {
  flex: none; width: 1rem; height: 1.4rem; cursor: grab;
  background-image: radial-gradient(currentColor 1px, transparent 1px);
  background-size: 5px 5px; color: var(--line);
}
/* The whole row is the drag target, so the row is what shows it can move.
   The grip stays as a hint and is never the only thing you can grab — that
   mistake made the rows undraggable on a phone, where it is hidden. */
ol.q-ordering--draggable .q-ordering__grip { margin-left: auto; }
ol.q-ordering--draggable .q-ordering__row:hover { border-color: var(--green-700); }
ol.q-ordering--draggable .q-ordering__row:focus-visible {
  outline: 2px solid var(--green-700); outline-offset: 2px;
}

@media (max-width: 560px) {
  ol.q-ordering label { font-size: 1.05rem; }
  ol.q-ordering .q-ordering__row { padding: .8rem .75rem; }
}

.bar { margin: 0 0 1rem; }
.bar__label { display: flex; justify-content: space-between; font-size: .95rem; margin-bottom: .3rem; font-weight: 600; }
.bar__track { height: 9px; background: var(--line); border-radius: 999px; overflow: hidden; }
.bar__fill { display: block; height: 100%; background: var(--green-700); }
.bar--low .bar__fill { background: var(--yellow); }
.blur { filter: blur(5px); user-select: none; pointer-events: none; opacity: .55; }

/* ---------------------------------------------------------------- footer */

/* Thin and grey. A catalogue puts its navigation at the top; the footer only
   carries what nobody browses for. */
.site-footer {
  flex-shrink: 0;
  margin-top: 4rem;
  background: var(--bg-soft); border-top: 3px solid var(--green-700);
  font-size: .86rem; color: var(--muted);
}
.site-footer__inner { max-width: var(--wrap); margin: 0 auto; padding: 2.4rem var(--gutter) 1.4rem; }

/* Brand column wide, link columns even. Grouped by what a visitor is trying
   to do, so they can skip to their group instead of reading every link. */
.site-footer__grid {
  display: grid; gap: 2rem;
  grid-template-columns: 1.6fr 1fr 1fr 1.2fr;
}
.site-footer__mark {
  font-family: var(--font-ui); font-size: 1.35rem; font-weight: 700;
  color: var(--green-700); text-decoration: none; display: inline-block;
}
.site-footer__mark em { color: var(--yellow); font-style: normal; }
.site-footer__blurb { margin: .6rem 0 0; max-width: 26rem; line-height: 1.55; }

.site-footer__col { display: flex; flex-direction: column; align-items: flex-start; gap: .45rem; }
.site-footer__head {
  font-family: var(--font-ui); font-size: .78rem; font-weight: 700;
  letter-spacing: .08em; text-transform: uppercase;
  color: var(--ink); margin: 0 0 .3rem;
}
.site-footer__note { margin: 0 0 .7rem; line-height: 1.5; }
.site-footer a { color: var(--ink-strong); text-decoration: none; }
.site-footer a:hover { color: var(--blue); text-decoration: underline; }
.site-footer .btn:hover { text-decoration: none; }

.site-footer__base {
  margin-top: 2.2rem; padding-top: 1rem; border-top: 1px solid var(--line);
  display: flex; justify-content: space-between; gap: 1.5rem; flex-wrap: wrap;
  font-size: .8rem;
}
/* Not boilerplate. It is us being precise about a claim we deliberately do
   not make, and a parent who checks will find it. */
.site-footer__legal { margin: 0; max-width: 46rem; }
.site-footer__copy { margin: 0; white-space: nowrap; }

@media (max-width: 900px) {
  .site-footer__grid { grid-template-columns: 1fr 1fr; }
  .site-footer__brand { grid-column: 1 / -1; }
}
/* On a phone the two link groups stay side by side. Collapsing them into one
   column turns the footer into a long run of links you have to scroll past,
   which is the thing a grouped footer exists to avoid. */
@media (max-width: 560px) {
  .site-footer__inner { padding: 1.8rem var(--gutter) 1.2rem; }
  .site-footer__grid { gap: 1.5rem 1.25rem; }
  .site-footer__brand { grid-column: 1 / -1; }
  .site-footer__blurb { font-size: .88rem; }
  .site-footer__col:last-child { grid-column: 1 / -1; }
  .site-footer__base { flex-direction: column; gap: .7rem; margin-top: 1.6rem; }
  .site-footer__copy { white-space: normal; }
}


/* --------------------------------------------------- shell on small screens */

@media (max-width: 56rem) {
  /* The rail becomes a strip above the content rather than disappearing —
     it is the only place a lesson list exists. */
  /* Collapsed to a single bar. Open, the list sat between the masthead and
     the lesson, so the page opened on a table of contents — and every course
     added pushed the actual content further down. */
  /* No negative margin any more. It used to be escaping `.shell`'s padding
     from inside it; the rail is a region of the layout now and is already
     full width, so the same rule would have pulled it off the left edge. */
  .rail {
    border-right: 0; border-bottom: 1px solid var(--line);
    padding: 0; margin-inline: 0;
  }
  .rail-toggle-label {
    display: flex; align-items: center; gap: .5rem;
    padding: .7rem var(--gutter); cursor: pointer;
    background: var(--bg-soft);
    font-size: .9rem; font-weight: 600; color: var(--ink);
  }
  .rail-toggle-label__text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .rail-toggle-label__hint {
    margin-left: auto; flex: none;
    font-family: var(--font-ui); font-size: .75rem; font-weight: 700;
    letter-spacing: .05em; text-transform: uppercase; color: var(--green-700);
  }
  /* A caret that turns, so the bar says whether it is open. */
  .rail-toggle-label__hint::after {
    content: " ▾"; display: inline-block; transition: transform .15s ease;
  }
  .rail-toggle:checked ~ .rail-toggle-label .rail-toggle-label__hint::after {
    transform: rotate(180deg);
  }

  .rail__panel { display: none; padding: .75rem var(--gutter) 1rem; }
  .rail-toggle:checked ~ .rail__panel {
    display: block;
    /* However many courses there are, the list scrolls inside itself rather
       than growing the page. */
    max-height: 60vh; overflow-y: auto;
  }
  .rail__title + .rail__list { margin-bottom: 1.25rem; }

  /* Stacked on a phone: wordmark, then search, then the account links.
     Three things across a 390px screen leaves the search box too narrow to
     read your own query back. */
  .masthead__inner { display: block; padding: .6rem var(--gutter); }
  .masthead__brand { display: block; font-size: 1.15rem; }
  /* The wrapper is a desktop grid cell. On a phone it has no job, so it stops
     being a row and lets the search box and the account line stack as they
     did before it existed. */
  .masthead__tools { display: block; }
  /* One line: the links a signed-in parent uses on the left, the menu button
     hard right. Stacking them put the button on a row of its own with nothing
     beside it, which is a band of empty space above every page. */
  .masthead__account {
    display: flex; flex-wrap: nowrap; justify-content: flex-start;
    align-items: center; gap: .5rem .7rem; margin-top: .4rem;
    white-space: normal;
  }
  .masthead__account .nav-toggle-label { margin-left: auto; }
  .masthead__account a { font-size: .82rem; }
  .masthead__search { display: flex; width: 100%; margin-top: .5rem; }

  /* --- the sections, as a drawer --------------------------------------- */

  .nav-toggle-label {
    display: block; padding: .45rem .35rem;
    cursor: pointer; line-height: 0; position: relative; z-index: 70;
  }
  .nav-toggle-label span,
  .nav-toggle-label span::before,
  .nav-toggle-label span::after {
    display: block; width: 22px; height: 2px;
    background: var(--green-700); content: ""; position: relative;
  }
  .nav-toggle-label span::before { position: absolute; top: -7px; }
  .nav-toggle-label span::after { position: absolute; top: 7px; }

  .nav-overlay {
    display: block; position: fixed; inset: 0; z-index: 60;
    background: rgba(0, 0, 0, .45);
    opacity: 0; pointer-events: none; transition: opacity .2s ease;
    cursor: pointer;
  }
  .nav-toggle:checked ~ .nav-overlay { opacity: 1; pointer-events: auto; }

  /* Off-screen rather than display:none, so it slides in rather than
     appearing, and so the links stay focusable in order. */
  .sections {
    position: fixed; top: 0; right: 0; bottom: 0; z-index: 65;
    width: min(78vw, 17rem);
    padding-top: 3.5rem;
    overflow-y: auto;
    transform: translateX(100%);
    transition: transform .22s ease;
    box-shadow: -12px 0 30px rgba(0, 0, 0, .28);
  }
  .nav-toggle:checked ~ .topbar__band .sections { transform: translateX(0); }

  .sections__inner { display: block; padding: 0; overflow: visible; }
  .sections a {
    display: block; padding: .85rem 1.1rem;
    border-bottom: 1px solid rgba(255, 255, 255, .14);
    font-size: 1rem;
  }
  .sections a[aria-current="page"] { box-shadow: inset 3px 0 0 var(--yellow); }

  /* Stop the page behind the drawer scrolling under the finger. */
  body:has(.nav-toggle:checked) { overflow: hidden; }

  @media (prefers-reduced-motion: reduce) {
    .sections, .nav-overlay { transition: none; }
  }

  .btn--small { padding: .25rem .6rem; font-size: .75rem; }
}


/* ---------------------------------------------------------------- small screens */

@media (max-width: 46rem) {
  :root { --gutter: 1.5rem; }

  h1 { font-size: 1.8rem; }
  .hero { padding-block: 2.25rem 1.5rem; }
  .hero h1 { font-size: 2rem; max-width: none; }
  .section, .band { padding-block: 2.25rem; }
}

/* --- Desmos ------------------------------------------------------------- */
.desmos { margin: 2rem 0; }
.desmos__frame {
  background: var(--green-100); border-radius: var(--radius);
  overflow: hidden; display: grid; place-items: center;
}
.desmos__frame iframe { display: block; }
.desmos__start {
  width: 100%; min-height: inherit; padding: 2.5rem 0;
  background: none; border: 0; cursor: pointer;
  display: grid; place-items: center; position: relative;
}
/* A hint of graph paper, so the placeholder says "graph" before it loads. */
.desmos__grid {
  position: absolute; inset: 0;
  background-image:
    linear-gradient(to right, rgba(53,70,39,.10) 1px, transparent 1px),
    linear-gradient(to bottom, rgba(53,70,39,.10) 1px, transparent 1px);
  background-size: 28px 28px;
}

/* --- Practice questions -------------------------------------------------- */
/* No panel. Questions sit on the page.
 *
 * This was a tinted, rounded, padded card, and it earned none of it: it
 * boxed the practice off from the lesson it belongs to, ate horizontal
 * room on a phone, and said "form" where the page means "keep going".
 * The same treatment came off the audio block for the same reason. */
.practice { margin: 2rem 0; }
.practice__heading { margin-top: 0; }
.practice__q { padding: 1rem 0; }
.practice__q:first-of-type { padding-top: .5rem; }
.practice__stem { margin-bottom: .75rem; }
.practice__feedback { margin: .75rem 0 0; font-size: .95rem; font-weight: 600; }
.practice__feedback.is-right { color: var(--green-700); }
.practice__feedback.is-wrong { color: var(--warn); }

/* --- countable things ------------------------------------------------------
 *
 * A row you can count, and a gap you can see. The gap is the whole teaching —
 * seven stones with a space after the fourth is a number bond a learner checks
 * by counting rather than by believing a diagram — so it is wider than the
 * spacing between the things themselves by a clear margin, not by a hair.
 *
 * Wrapping is allowed and the gap survives it, because fifteen counters do not
 * fit a phone in one line and a row that scrolls sideways is a row nobody
 * counts to the end of.
 */
.things {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: .45rem;
  justify-content: center;
}
.things__gap {
  /* Its own flex item rather than a margin, so it cannot collapse and cannot
     end a line invisibly. */
  flex: 0 0 auto;
  width: 1.8rem;
}
.things .prop { display: block; }

.things-figure { margin: 1.5rem 0; }
.things-figure figcaption {
  margin-top: .6rem;
  text-align: center;
  color: var(--muted);
  font-size: .95rem;
}

/* --- inline asides ---------------------------------------------------------
 *
 * A word in the prose that opens a short silent loop under the line.
 *
 * The trigger is underlined rather than boxed: it is a word in a sentence and
 * has to keep reading as one. A button that looks like a button in the middle
 * of a paragraph stops the sentence dead, which is the opposite of the point —
 * the reader is meant to keep their place, not be handed a control.
 */
.aside__trigger {
  font: inherit;
  color: var(--focus-blue, #006699);
  background: none;
  border: 0;
  padding: 0;
  border-bottom: 2px dotted currentColor;
  cursor: pointer;
}
.aside__trigger::after { content: " ▸"; font-size: .8em; }
.aside__trigger[aria-expanded="true"]::after { content: " ▾"; }
.aside__trigger:focus-visible {
  outline: 2px solid var(--focus-blue, #006699);
  outline-offset: 3px;
}

/* Closed still costs nothing on the page: no height, no margin, nothing to
   read past. But it opens rather than appears.
 *
 * `display: none` to `display: block` cannot be animated, so the panel used
 * to blink into place — the reader's eye is thrown to it instead of carried
 * to it, and the line of prose they were in the middle of jumps down the
 * screen in one frame. A nutshell in the same lesson grows its height over
 * 0.3s. Two disclosures that behave differently read as two different kinds
 * of thing, and they are not: both are the same promise, that pressing a word
 * costs you nothing and returns you to your place.
 *
 * So: height, opacity and margin all move together over the same 0.3s
 * nutshell uses. The height itself is set by the loader, because a stylesheet
 * cannot measure. */
.aside__panel {
  display: block;
  overflow: hidden;
  height: 0;
  opacity: 0;
  margin: 0;
  max-width: 22rem;
  transition: height .3s ease-in-out, opacity .3s ease-in-out,
              margin .3s ease-in-out;
}
.aside__panel[data-open] {
  opacity: 1;
  margin: .8rem 0;
}
.aside__video {
  display: block;

  /* Big enough to read a ten-frame off, and still not the whole column.
     An aside plays under the sentence the reader is in the middle of, so it
     stays an illustration rather than a screening — but the first version of
     this was so cautious the counters were too small to follow, which defeats
     the point of showing them move at all.

     Two ceilings, and the lower one always wins: 36rem of width, and on a
     lesson slide whatever height that slide can actually spare. The slide
     ceiling is the one that must never be relaxed — nothing scrolls there, so
     a video past the edge is not scrolled to, it is gone.

     `width` and `height` are both auto so the intrinsic 1280x720 shrinks to
     whichever of the two limits binds first, keeping its proportions. Set a
     definite `width: 100%` instead and `max-height` would squash the picture
     rather than scale it.

     `--aside-room` is set by the loader on a lesson slide, where nothing
     scrolls and anything past the bottom edge is not scrolled to but cut off.
     Off a slide it never appears and the fallback stands. */
  width: auto;
  height: auto;
  /* 80%, not 100%.
   *
   * `min(36rem, 100%)` reads as a limit and behaves as none: in a lesson
   * column 332px wide the 36rem never binds, 100% does, and the clip fills
   * the column edge to edge. An aside that spans the measure stops reading as
   * an aside — it is the thing on the page, and the sentence it belongs to
   * has been pushed aside by its own footnote. Inset, it stays subordinate to
   * the prose while still being big enough to follow.
   *
   * On an article page the column is wide enough that 36rem binds first, so
   * that one is unchanged. */
  max-width: min(36rem, 80%);
  max-height: min(var(--aside-room, 70vh), 22rem);
  aspect-ratio: 16 / 9;

  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--loop-bg);

  /* The slide.
     The panel's own height opens the space; the video travels up into it
     from under the top edge, which `overflow: hidden` on the panel keeps
     hidden until it arrives. Height alone reads as a reveal, and the reader
     said it read as a blink. */
  transform: translateY(-10px);
  transition: transform .3s cubic-bezier(.2, .7, .3, 1);
}
.aside__panel[data-open] .aside__video { transform: none; }
/* There is deliberately no `prefers-reduced-motion` override here.
 *
 * That setting is on for this project's own users — Windows turns it on with
 * "Show animations", Android with battery saver — and honouring it by cutting
 * the duration to nothing produces exactly the blink this rule exists to
 * remove. We have made that mistake once already: the scratch transition was
 * hidden outright under reduced motion, which is why it was invisible on
 * every device we tested and had to be put back.
 *
 * A panel growing to its own height over 0.3s is a disclosure, not motion in
 * the sense the setting means: nothing travels across the screen, nothing
 * parallaxes, nothing flashes. Nutshell animates its bubbles unconditionally
 * for the same reason, and matching nutshell is the whole point of this rule.
 */

/* --- nutshells ------------------------------------------------------------
 *
 * The definition itself never shows. Nutshell deletes `:x` headings and their
 * sections on start, but that happens after first paint and the script is
 * fetched lazily — so without this the reader sees a heading reading
 * ":x number bond" flash up, and sees it permanently if the script never
 * arrives. Hidden here, the worst case is a lesson whose prose reads normally
 * and whose expandables do not open, which is the right thing to degrade to.
 *
 * `display: none` rather than clipping, and it is safe: Nutshell finds these
 * with `querySelectorAll` and reads `innerText`, which for an element that is
 * not rendered returns its text content rather than the empty string.
 */
.nutshell-def, .nutshell-def__body { display: none; }

/* The end of a definition, and the reason it exists.
 *
 * Nutshell walks forward from a `:x` heading deleting siblings until it meets
 * a heading, an `<hr>`, or the end of the container. A lesson does not put a
 * heading after every definition, so without this it deleted the definition
 * and then carried on — taking out the paragraph holding the link that opens
 * it, and whatever prose followed. Five of eight nutshells could not be
 * clicked because there was nothing left to click.
 *
 * Structure rather than a rule anybody should see, so it is hidden — but it
 * must stay in the DOM, because being there is its whole job. */
.nutshell-def__end { display: none; }

/* The expandable itself: a word you can open, and it has to look like one
   without shouting. Underline rather than a button, in the player's blue. */
.nutshell-expandable {
  color: var(--focus-blue, #006699);
  text-decoration: underline;
  text-decoration-style: dotted;
  text-underline-offset: .18em;
  cursor: pointer;
}
.nutshell-expandable[mode="open"] { text-decoration-style: solid; }

/* The bubble names the page it came from, which is the right thing when a
   nutshell is pulled off somebody else's site and pure noise when it came
   from the paragraph above it. Ours are always same-page. */
.nutshell-bubble-from { display: none; }

/* --- Cal.com inline embed ------------------------------------------------ */
/* The embed sets its own height. We only give it somewhere to sit and a
   minimum so the page does not jump while it loads. */
.cal-embed {
  width: 100%;
  min-height: 32rem;
  overflow: hidden;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  margin-top: 1rem;
  background: var(--green-100);
}

/* The embed reports its own height once loaded; the reserved minimum stops
   the page jumping while it does. */
.cal-embed__loading {
  display: grid; place-items: center; min-height: 30rem;
  margin: 0; color: var(--muted); font-size: .95rem;
}
.cal-embed[hidden] { display: none; }

/* Once the calendar is in, the container takes its height from the iframe —
   the reserved minimum would otherwise leave a gap underneath. */
.cal-embed.is-ready { min-height: 0; background: transparent; }
.cal-embed.is-ready .cal-embed__loading { display: none; }

/* --- The plan ------------------------------------------------------------ */
.plan { border-left: 3px solid var(--green-700); padding-left: 1rem; margin: 1.25rem 0 2rem; }
.plan__fix {
  background: var(--yellow); color: var(--green-900);
  padding: .85rem 1rem; border-radius: var(--radius); margin: 0 0 1rem;
}
.plan__fix .meta { color: var(--green-800); }
.plan__steps { list-style: none; margin: 0 0 1rem; padding: 0; }
.plan__step { display: flex; gap: .7rem; padding: .6rem 0; border-bottom: 1px solid var(--line); }
.plan__step:last-child { border-bottom: 0; }
.plan__step .meta { display: block; font-size: .9rem; }
.plan__mark {
  flex: none; width: 1.5rem; height: 1.5rem; border-radius: 50%;
  display: grid; place-items: center; font-size: .85rem; font-weight: 700;
}
.plan__step.is-ready .plan__mark { background: var(--green-700); color: #fff; }
.plan__step.is-blocked .plan__mark { background: var(--line); color: var(--muted); }
.plan__step.is-blocked strong { color: var(--muted); }

/* --- Worksheets on screen ------------------------------------------------ */
.sheet__bar { display: flex; gap: 1.5rem; align-items: flex-start; flex-wrap: wrap; justify-content: space-between; }
.sheet__item { padding-top: 1.5rem; margin-top: 2rem; border-top: 2px solid var(--green-700); }
.sheet__item:first-of-type { border-top: 0; margin-top: 1rem; }
.sheet__head h2 { margin-top: 0; }
.sheet__gap { background: var(--green-100); border-left: 3px solid var(--warn); padding: 1rem 1.15rem; border-radius: 0 var(--radius) var(--radius) 0; margin: 1.5rem 0; }
.sheet__footer { margin-top: 3rem; }

/* --- On paper ------------------------------------------------------------
   A worksheet has to survive a cheap printer: black on white, no filled
   backgrounds eating toner, and nothing split across a page break that has
   to be read as one thing. */
@media print {
  @page { margin: 16mm 14mm; }

  .site-header, .site-footer, .sheet__bar, .nav, .nav-overlay,
  .band, .sketch-ui, .video__play, .cal-embed,
  form button, .btn { display: none !important; }

  html, body {
    background: #fff !important;
    color: #000 !important;
    font-size: 11pt;
  }
  .wrap, .section, .sheet { max-width: none; padding: 0; margin: 0; }

  a { color: #000 !important; text-decoration: none; }

  /* Keep a question, a callout or an explanation whole on one page. */
  .sheet__item, .callout, .practice__q, .nutshell, figure, .plan__step {
    page-break-inside: avoid;
  }

  /* Maths prints. KaTeX has already run by the time the print dialog opens,
     so what is on paper is the rendered equation — but the box must not clip
     it, and a half-printed equation is worse than a wrapped one. */
  .math__render { overflow: visible !important; }
  .math { page-break-inside: avoid; }

  /* On paper there is no scrolling, so the box has to give way rather than
     clip. Wrapping beats a table with its right-hand columns cut off. */
  .datatable__scroll { overflow: visible !important; border: 0 !important; }
  .datatable th, .datatable td {
    white-space: normal;
    border: 1pt solid #666 !important;
  }
  .datatable thead th { background: #fff !important; }
  .sheet__item { border-top: 1pt solid #000; page-break-before: always; }
  .sheet__item:first-of-type { page-break-before: avoid; }

  .callout, .practice, .card--soft, .gate, .sheet__gap {
    background: #fff !important;
    border: 1pt solid #666 !important;
    box-shadow: none !important;
  }

  /* An expandable explanation is a click on screen; on paper it must simply
     be there, or the reader never sees it. */
  .nutshell, .transcript { border-left: 0; padding-left: 0; }
  .nutshell[open], .nutshell { display: block; }
  .nutshell > summary::before, .transcript > summary::before { border: 0; }
  .nutshell__body { display: block !important; }

  /* Room to actually write. */
      .practice__feedback { display: none !important; }
  
  /* Interactives cannot print. The prompt stays, and says where to go. */
  .sketch__stage, .desmos__frame, .video__frame {
    border: 1pt dashed #666 !important;
    background: #fff !important;
    min-height: 0 !important;
    padding: 10pt;
  }
  .sketch__stage::after, .desmos__frame::after, .video__frame::after {
    content: "Interactive — open this worksheet online to use it.";
    font-size: 9pt; font-style: italic; color: #444;
  }
  .sketch__prompt { font-weight: 700; }
  .sheet__footer { border-top: 1pt solid #999; padding-top: 6pt; font-size: 9pt; }
}

/* A smaller button for the masthead. */
.btn--small { min-height: 2rem; padding: .3rem .8rem; font-size: .8rem; }

/* Prose sits inside the shell, so it no longer needs its own page gutter. */
.shell__content > .wrap { padding-inline: 0; max-width: none; }
.shell__content .prose { line-height: 1.6; }

/* ==========================================================================
   The thread: where a lesson sits, and where a learner is
   ========================================================================== */
/* Quiet on purpose. This is context around the teaching, not the teaching,
   and a box that shouts competes with the lesson above it. */
.thread {
  margin-top: 3rem; padding-top: 1.5rem;
  border-top: 1px solid var(--line); font-size: .95rem;
}
.thread__part { margin-bottom: 1.75rem; }
.thread__heading { font-size: 1rem; margin: 0 0 .4rem; font-family: var(--font-ui); }
.thread__list { margin: 0; padding-left: 1.1rem; }
.thread__list li { margin-bottom: .3rem; }

.thread__part--syllabus { background: var(--green-100); padding: 1rem 1.15rem; }
.thread__objectives { margin: .6rem 0 0; }
.thread__objectives dt { font-weight: 700; margin-top: .75rem; }
.thread__objectives dd { margin: .25rem 0 0; color: var(--muted); }
/* Small, grey, last. A syllabus code is a filing reference for a school, not
   an explanation for the person reading the page. */
.thread__code {
  font-size: .75em; font-weight: 400; color: var(--muted-2);
  margin-left: .4rem; white-space: nowrap;
}
.thread__done { margin: 2rem 0; }
.thread__ticked { font-weight: 700; margin-bottom: .4rem; }
.thread__nav {
  display: flex; justify-content: space-between; gap: 1rem;
  flex-wrap: wrap; margin-top: 1.5rem;
}
.thread__path { margin-top: 1rem; }

.path__course { margin-bottom: 2.5rem; }
.path__next { font-size: 1.05rem; }
.path__blocked {
  background: var(--green-100); border-left: 3px solid var(--green-700);
  padding: .85rem 1rem; margin: 1rem 0;
}
.path__lessons li.is-blocked { color: var(--muted-2); }
.path__weak { padding-left: 1.1rem; }
.path__weak li { margin-bottom: .8rem; }
.path__children { list-style: none; padding: 0; display: flex; gap: .75rem; flex-wrap: wrap; }

/* ==========================================================================
   The arcade
   ========================================================================== */
.arcade {
  list-style: none; padding: 0; margin: 2rem 0 0; display: grid; gap: 1.25rem;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
.arcade__card {
  display: block; text-decoration: none; color: var(--ink);
  border: 1px solid var(--line); background: var(--bg);
  transition: border-color .12s ease, transform .12s ease;
}
.arcade__card:hover, .arcade__card:focus-visible {
  border-color: var(--green-700); transform: translateY(-2px); color: var(--ink);
}
.arcade__shot { display: block; background: var(--green-100); }
.arcade__shot img { display: block; width: 100%; height: auto; }
.arcade__body { display: block; padding: .9rem 1rem 1.1rem; }
.arcade__title { display: block; font-size: 1.05rem; font-family: var(--font-ui); }
.arcade__hook { display: block; margin-top: .2rem; font-size: .9rem; color: var(--muted); }

/* --- A game on its own page --------------------------------------------- */
/* The masthead, rail and footer are removed by the template. Everything left
   around a game is an invitation to leave it. */
body.is-game { background: var(--bg-soft); }
body.is-game .shell { display: block; max-width: none; padding: 0; margin: 0; }
body.is-game .shell__content { max-width: none; padding: 0; }
.game {
  min-height: 100vh; min-height: 100dvh;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  padding: 3rem 1rem 2rem;
}
.game__bar {
  position: fixed; top: 0; left: 0; right: 0; display: flex;
  align-items: center; gap: .75rem; padding: .6rem 1rem;
  font-family: var(--font-ui); font-size: .85rem;
}
.game__back { text-decoration: none; color: var(--muted); }
.game__back:hover { color: var(--green-700); }
.game__name { color: var(--muted-2); }
/* Pushed to the far end of the bar, away from the way out. Hosting is a
   deliberate act by a teacher; it should not sit under the thumb of a child
   reaching for Back. */
.game__host {
  margin-left: auto; text-decoration: none; font-weight: 700;
  color: var(--green-700); letter-spacing: .03em;
}
.game__host:hover { color: var(--green-900); text-decoration: underline; }
/* The two doors into a live room, above the arcade itself. */
.arcade__live { display: flex; flex-wrap: wrap; gap: .6rem; margin: 0 0 1.5rem; }
.sketch--game { margin: 0; width: 100%; max-width: 640px; }
.sketch--game .sketch__stage {
  background: var(--bg); border: 1px solid var(--line);
  display: grid; place-items: center;
}
/* ---------------------------------------------------------------- lesson player
 *
 * The lesson as slides. Its own environment, deliberately: the site is square
 * and green, and the player is rounded and carries a blue as well, because it
 * is a place a learner goes into rather than a page they read. The assessment
 * does the same thing for the same reason.
 *
 * Everything is scoped to `.focus`. That rule is not taste — it is the one
 * that stopped the assessment layout breaking the homepage, and
 * `content/test_lesson_focus.py` fails on an unscoped selector in this block.
 */
/* The player takes the width it is given and never asks for more.
 *
 * A grid or flex item will not shrink below the intrinsic width of its
 * contents unless it is told it may, and a lesson slide is full of things
 * with a wide intrinsic width: a rendered equation, a game canvas, a long
 * unbroken product like `10 + 5 = 15`. One of those pushes the item wider
 * than the screen, every ancestor grows with it, and `overflow-x: clip` on
 * the body chops the surplus off — so the page is not scrollable sideways,
 * it is simply missing its right-hand edge. Which is exactly how it looked
 * on a phone: correct left margin, sentences cut mid-word, the fullscreen
 * button half gone.
 *
 * `min-width: 0` at every level from the shell down is the whole fix. */
.lesson-focus .shell__content { padding: 0; min-width: 0; }
.lesson-focus .shell,
.lesson-focus .layout { min-width: 0; }
/* The ancestors only. `.focus__step` sizes itself with `flex: 0 0 100%` and
   is checked for exactly that; adding a competing `max-width` here broke
   three tests that were right to complain. */
.lesson-focus .focus { min-width: 0; max-width: 100%; }
/* The page itself never scrolls. Only a slide scrolls, and only when its
   contents genuinely do not fit. */
body.lesson-focus { overflow: hidden; }

.focus {
  /* Its own tokens, so the theme is one edit rather than forty. */
  --focus-blue: #006699;
  --focus-blue-deep: #004E75;
  --focus-blue-pale: #E8F1F6;
  --focus-green: #2B5A2B;
  --focus-green-pale: #EAF2EA;
  --focus-line: #DCE4EA;
  --focus-radius: .75rem;

  display: grid;
  grid-template-rows: auto 1fr auto;
  /* THE COLUMN, WITHOUT WHICH THE RIGHT-HAND SIDE OF EVERY PHONE IS MISSING.
   *
   * There is one column and it was never declared, so it was `auto` — and an
   * `auto` grid track is `minmax(auto, auto)`, whose floor is the min-content
   * width of what it holds. A track is allowed to be wider than the grid it
   * is in. So on a 375px phone this column measured 429px, the bar and the
   * slide track filled all 429 of it, and `overflow-x: clip` on the body cut
   * the surplus off — a correct left margin, sentences ending mid-word, half
   * a fullscreen button.
   *
   * `minmax(0, 1fr)` gives the track a floor of zero, so it takes the width
   * it is given and the content wraps inside it. `min-width: 0` on the
   * ancestors was necessary and was not sufficient: it let `.focus` itself
   * shrink to 327px, and the column inside it went on being 429. */
  grid-template-columns: minmax(0, 1fr);
  /* dvh, not vh: on a phone vh includes the browser chrome that slides away,
     so the indicator strip would sit a toolbar's worth below the fold. */
  height: 100dvh;
  background: #fff;
}

/* --- the top bar ------------------------------------------------------- */

/* Nothing above the lesson but the controls themselves.
 *
 * This had a pale blue gradient and a rule under it, which together drew a
 * strip across the top of every slide and made the player look like a document
 * viewer with a toolbar. The lesson is meant to be the only thing on the page;
 * a band of furniture above it is the one piece of chrome that cannot be
 * ignored, because it is there on every beat. The buttons carry their own
 * edges and do not need a shelf to sit on. */
.focus__bar {
  display: flex; align-items: center; gap: .75rem;
  padding: .55rem .8rem;
  background: transparent;
}
/* A pill, not a bare glyph. The way out has to be unmistakable and quiet at
   the same time, and a lone X is neither — it reads as "close this box". */
.focus__exit {
  display: inline-flex; align-items: center; gap: .35rem;
  padding: .35rem .8rem .35rem .6rem;
  border: 1px solid var(--focus-line); border-radius: 999px;
  background: #fff; color: var(--focus-blue-deep);
  font-size: .85rem; line-height: 1.3; text-decoration: none;
  white-space: nowrap; flex: none;
}
.focus__exit:hover {
  background: var(--focus-blue-pale); border-color: var(--focus-blue);
  color: var(--focus-blue-deep);
}
.focus__title {
  margin: 0; font-size: .9rem; font-weight: 600; color: var(--focus-green);
  flex: 1; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  text-align: right;
}

/* --- the slides -------------------------------------------------------- */

.focus__track {
  display: flex;
  overflow-x: auto;
  overflow-y: hidden;
  scroll-snap-type: x mandatory;
  /* One slide per swipe. Without this a firm flick crosses three of them and
     lands wherever momentum ran out, which is most of what "glitchy" was. */
  scroll-snap-stop: always;
  /* No `scroll-behavior: smooth` here. It applies to *every* scroll of this
     box, including the browser's own snap correction after a flick — so a
     swipe was animated by the snap and then animated again by this, and the
     two eased on different curves. The script asks for smooth when it wants
     it, which is when a pill is tapped. */
  overscroll-behavior-x: contain;
  scrollbar-width: none;
}
.focus__track::-webkit-scrollbar { display: none; }

/* Once the script is running, the browser stops moving this track.
 *
 * Native snap is a free scroll tidied up afterwards — a flick carries its
 * momentum across however many slides it bought and is then pulled back to
 * the nearest edge. Landing between two slides, overshooting the one you
 * wanted, and the correction visible as a jerk are all that one fact. A
 * swipe should mean the next page or the previous one, so the gesture is
 * read in JavaScript and the track is moved by `scrollLeft`, which still
 * works on a box that refuses to be scrolled by hand.
 *
 * `pan-y` leaves the vertical alone. That is what lets the question set keep
 * its own scroll without swallowing the swipe off the slide — the thing that
 * had a learner tapping pills because swiping did nothing. */
.focus__track--driven {
  overflow-x: hidden;
  scroll-snap-type: none;
  touch-action: pan-y;
}
/* The escape hatch, set by the script only if it finds that `scrollLeft` did
   not move a hidden box — which no browser does, and which would otherwise
   be a lesson stuck on its first slide with no symptom to go on. `auto`
   restores the native scroll; `pan-y` still keeps the fling away. */
.focus__track--handmade { overflow-x: auto; }

/* While a finger is down the slide follows it, so no easing may sit between
   the two; the animation belongs to the release. */
.focus__track--driven.is-dragging { scroll-behavior: auto; }
.focus__track--driven.is-dragging * { cursor: grabbing; }
.focus__track:focus-visible { outline: 2px solid var(--focus-blue); outline-offset: -2px; }
@media (prefers-reduced-motion: reduce) {
  .focus__track { scroll-behavior: auto; }
}

.focus__step {
  /* `0 0 100%` resolves against the flex container, and a flex item's default
     `min-width: auto` lets wide content push it past that basis — which is
     how a slide comes to rest half a picture off centre. Both are pinned. */
  flex: 0 0 100%;
  width: 100%;
  min-width: 0;
  scroll-snap-align: start;

  /* Nothing on a slide scrolls. This is the load-bearing line.

     A vertically scrollable slide inside a horizontally snapping track is two
     nested scrollers, and a thumb never swipes in a straight line: the inner
     scroller claims part of the gesture, the track gets only some of the
     horizontal movement, and the snap never completes. That is what "does not
     land square" was. Everything below exists so that this can stay hidden. */
  overflow: hidden;
  display: grid;

  /* Do not render a slide nobody is looking at.
   *
   * This is the whole of the performance answer, and it is one line. A
   * lesson is eleven slides of KaTeX, images, an audio player and a game,
   * and every one of them was being styled, laid out and painted on load —
   * so the first swipe on a mid-range Android happened while the main
   * thread was still busy with slide nine. `content-visibility: auto` skips
   * all of that for anything off screen and does it again as slides come
   * and go, which is what keeps this honest as the course grows to a
   * hundred lessons rather than one.
   *
   * `contain-intrinsic-size` is deliberately absent: the box is `flex: 0 0
   * 100%` and stretched to the track, so its size never depended on its
   * contents and there is nothing for the browser to guess. That is also
   * why this is safe next to scroll-snap — a guessed size would move the
   * snap positions under the swipe. */
  content-visibility: auto;
}
/* A column, not a three-row grid.
 *
 * It was `grid-template-rows: auto 1fr auto`, which is exactly right for a
 * slide with three children and silently wrong for one with four: the last
 * slide of a lesson also carries the onward links, those land in an implicit
 * fourth row, and the total comes to more than the height available. With
 * `overflow: hidden` above, "more than the height available" is not a
 * scrollbar, it is content that is simply not there — which is why the foot
 * of a worksheet page was cut off and nothing on screen said so.
 *
 * A flex column takes any number of children: one part grows, the rest are
 * their own height, and nothing lands in a row that was never declared. */
.focus__inner {
  width: 100%; max-width: 46rem;
  height: 100%;
  margin: 0 auto;
  padding: 1rem 1.1rem 1.25rem;
  display: flex;
  flex-direction: column;
  gap: .7rem;
  overflow: hidden;
}
/* The part that gives. Everything else is its own size. */
.focus__body, .focus__sheet { flex: 1 1 auto; min-height: 0; }
.focus__heading, .focus__sheet-foot, .focus__onward { flex: 0 0 auto; }
.focus__heading {
  margin: 0;
  /* Shrinks with the screen rather than at two breakpoints, so a heading on a
     short phone in landscape does not eat the slide. */
  font-size: clamp(1.05rem, 3.2dvh, 1.6rem);
  line-height: 1.25;
  color: var(--focus-green);
}

/* The middle row: the part that has to give. */
.focus__body {
  min-height: 0;
  display: grid;
  gap: .8rem;
  align-content: safe center;
  grid-template-rows: minmax(0, auto);
}
.focus__step--split .focus__body {
  /* Two columns when there is both a picture and words. Stacked, that beat is
     half a screen of picture and half a screen of nothing on a wide display. */
  align-content: stretch;
}
@media (min-width: 52rem) {
  .focus__step--split .focus__body {
    grid-template-columns: 1fr 1fr;
    grid-template-rows: minmax(0, 1fr);
    align-items: center;
    gap: 1.5rem;
  }
}
/* A short, wide window — a laptop, or a phone turned sideways — is where a
   stacked slide runs out of height first, so split there too. */
@media (max-height: 34rem) and (min-width: 40rem) {
  .focus__step--split .focus__body {
    grid-template-columns: 1fr 1fr;
    grid-template-rows: minmax(0, 1fr);
    align-items: center;
    gap: 1.25rem;
  }
}

.focus__visual, .focus__words {
  min-height: 0; min-width: 0;
  display: grid; gap: .7rem; align-content: safe center;

  /* The line that stops content leaving the slide.
   *
   * `min-height: 0` lets a grid item shrink; it does not stop one *growing*.
   * A column of prose and figures sized itself to its content and simply
   * overflowed its row — measured at 489px inside a 305px row — and since a
   * slide is `overflow: hidden` and never scrolls, everything past the edge
   * was not scrolled to but cut off and gone. Silently: nothing about the
   * markup, the server or the article view showed it.
   *
   * `max-height: 100%` is the ceiling `minmax(0, auto)` never provided. It
   * resolves against the row, so the column can be no taller than the space
   * the slide actually has. */
  max-height: 100%;
}
.focus__words {
  overflow: hidden;
  /* A nutshell that opens upward hangs from the line it belongs to, and needs
     something to hang from. */
  position: relative;
}

/* A bubble opened upward. Out of flow, so it can use room the prose is
   already occupying — the only way to get real height on a full slide — and
   so that opening one does not shove the rest of the beat off the edge.
   The arrow is hidden rather than flipped: it points up from the top of the
   bubble, and above the line that is a pointer to nothing. */
.nutshell-bubble--up { margin-top: 0 !important; z-index: 3; }
.nutshell-bubble--up .nutshell-bubble-arrow { display: none; }

/* Prose gives a little before anything is lost. The player lowers `--shrink`
   a step at a time when a beat is slightly too full for the screen it is on;
   it never goes far enough to matter to a reader, and a beat that needs more
   than that is too full to be fixed by typography. */
.focus__words { --shrink: 1; }

/* Media fills the room it is given and never more. `min-height: 0` on every
   ancestor is what lets these actually shrink — without it a grid item
   refuses to go below its content's height and pushes the slide open. */
.focus__inner .animation, .focus__inner .video, .focus__inner .audio,
.focus__inner .worksheet, .focus__inner .sketch, .focus__inner .practice,
.focus__inner figure { margin: 0; min-height: 0; }
.focus__inner .animation__video,
.focus__inner img {
  max-height: 100%; max-width: 100%; width: auto; height: auto;
  margin-inline: auto; object-fit: contain;
}
.focus__inner .animation__video { border-radius: var(--focus-radius); }
.focus__inner .callout, .focus__inner .card, .focus__inner .nutshell {
  border-radius: var(--focus-radius);
}
/* Prose shrinks a little on short screens rather than being cut off. */
.focus__words p, .focus__words li {
  font-size: calc(clamp(.88rem, 2.1dvh, 1rem) * var(--shrink, 1));
}

/* A cast stands in a row on a slide, not in a column.
 *
 * `.cast__person` is 11rem wide with a 2rem gap, which needs 384px for two
 * people — more than the 334px a split slide gives a column, so they wrapped
 * onto separate rows and the cast alone took 277px of the 305px the whole
 * beat had. That is what was cutting "Stop at ten on the way" off at Musa's
 * blue cap: the second peep was the thing hanging over the edge.
 *
 * Sharing the row costs nothing — a peep is a small drawing and its line of
 * speech is short — and it took that beat from 489px of content to 297px,
 * inside the 297px available, with no shrinking of anything at all.
 *
 * `flex-basis: 7rem` rather than `nowrap`: two or three people share a row,
 * and a crowd still wraps rather than being squeezed to slivers. */
.focus__inner .cast {
  margin: 0;
  gap: .6rem .9rem;
}
.focus__inner .cast__person { max-width: none; flex: 1 1 7rem; min-width: 0; }
.focus__inner .cast__says {
  font-size: calc(.82rem * var(--shrink, 1));
}

/* A slide that is only words is a page to read, and is set like one.
 *
 * The rest of the player is sized by its content: a picture fills the room
 * it is given, a game claims a height, a sheet takes the window. Words are
 * the one thing that does not push back, so at 16px in a UI face they sat in
 * a band across the middle of a laptop with white above and below — the
 * empty space was not a layout fault, it was type set two sizes too small
 * for the only job on the screen.
 *
 * So: the reading face, and a size that grows with the window instead of
 * stopping at a UI ceiling. The measure stays at the 46rem the player
 * already uses, which at this size is around sixty-five characters a line —
 * long enough to fill the width, short enough to find the next line.
 *
 * Scoped to `--prose`, so a caption beside a picture is untouched. A caption
 * is a label on something else and should stay quiet and small. */
/* The voice, on every slide of the player.
 *
 * Not only on the pages of pure reading: a title above a game in one face
 * and a paragraph on the next slide in another reads as two lessons rather
 * than one. So the whole player speaks in it — headings, prose, captions —
 * and the exceptions below are the two places it would cost something. */
.focus__heading,
.focus__words { font-family: var(--font-hand); }

/* Anything the learner has to answer stays on the interface face.
 *
 * A question is read under pressure, at small size, by someone who may be
 * finding the maths hard already, and the notation inside it is set by KaTeX
 * in a third face regardless. A handwritten stem beside a rendered equation
 * is two voices in one sentence and neither of them is plain. Same for the
 * fine print under a sheet, and for anything typed into a box. */
.focus__inner .practice,
.focus__inner .q-stem,
.focus__inner .q-options,
.focus__inner .meta,
.focus__inner input,
.focus__inner button { font-family: var(--font); }

/* And the size, only where the slide is nothing but sentences.
 *
 * The rest of the player is sized by its content: a picture fills the room
 * it is given, a game claims a height, a sheet takes the window. Words are
 * the one thing that does not push back, so at 16px they sat in a band
 * across the middle of a laptop with white above and below — the empty space
 * was not a layout fault, it was type set two sizes too small for the only
 * job on the screen.
 *
 * `--prose` deliberately excludes a slide carrying a display equation. KaTeX
 * typesets at its own size and cannot grow with the words around it, so a
 * beat that mixes the two overflows a screen that does not scroll — which is
 * not a scrollbar, it is a caption printed on top of the next paragraph. */
.focus__step--prose .focus__words p,
.focus__step--prose .focus__words li {
  font-size: clamp(1.05rem, 2.9dvh, 1.45rem);
  line-height: 1.6;
}
.focus__step--prose .focus__body { gap: 1.1rem; }
.focus__step--prose .focus__words { gap: 1rem; }
.focus__step--prose .focus__heading { font-size: clamp(1.3rem, 4dvh, 2.1rem); }

/* Bold, in a face that has one weight.
 *
 * The lesson's whole emphasis system is a bold opening sentence, and this font
 * ships no bold to give it. Letting the browser synthesise one smears the
 * outline sideways, which on a marker-pen face is a blot at reading size.
 *
 * So the letter is drawn a second time, one pixel to the right, in its own
 * colour. That is a heavier stroke rather than a wider letter — which is what
 * bold *is* in a handwritten face, where you would simply press harder — and
 * it costs one property. The trick is Nicky Case's, from The Evolution of
 * Trust, which is set in this same font and had the same problem.
 *
 * Colour as well as weight: black against the body's grey. The two together
 * read as emphasis at every size we set this face at. */
.focus__words strong,
.focus__words b {
  color: var(--ink-strong);
  font-weight: 400;
  text-shadow: 1px 0 0 currentColor;
}
.focus__inner .practice strong,
.focus__inner .practice b { font-weight: 700; text-shadow: none; }

/* A sketch sets its stage height inline from the block's `height` field —
   580px for Bond Rush, which is most of a phone. Inline styles beat every
   selector, so this is the one place `!important` is the honest tool. */
.focus__inner .sketch__stage,
.focus__inner .desmos__frame {
  min-height: 0 !important;
  /* `flex: 1 1 0`, not `auto`: the basis is zero so the stage is whatever
     height is left over, rather than whatever its contents want. */
  flex: 1 1 0;
  height: auto;
  position: relative;
  /* No fill behind a letterboxed board. The stage is wider than a portrait
     game needs, and a pale green panel either side of it reads as two grey
     bars rather than as room. */
  background: none;
}
/* A p5 canvas is the size its sketch asked for — 332 by 580 for Bond Rush,
   which is taller than the room a slide has. It only resizes its *width* on
   a window change; the height is fixed by the game, so the bottom of the
   board was simply below the slide.
 *
 * Taken out of flow and pinned to the stage. `max-height: 100%` on a canvas
 * that is still in flow resolves against a height the canvas is itself
 * setting — a percentage against an indefinite height, which the browser
 * ignores, which is why every earlier attempt at this changed nothing.
 * Positioned, the containing block is the stage's own box and the
 * percentages have something real to resolve against.
 *
 * Scaling a canvas with CSS is safe for taps here: p5 divides pointer
 * coordinates by the ratio of the drawing buffer to the rendered box, so a
 * letterboxed board is still hit where it looks. */
/* An iframe has no aspect ratio of its own to preserve, so it fills the box
   rather than being letterboxed. Pinned for the same reason the canvas is:
   the frame carries an inline `min-height` from the block's height field —
   440px by default — and a percentage against a height the frame is itself
   setting resolves against nothing. */
.focus__inner .desmos__frame iframe {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  border: 0;
}
.focus__inner .sketch canvas {
  /* Centred by transform rather than by `inset: 0` and auto margins. Auto
     margins on an absolutely positioned replaced element resolve the box
     against the offsets and stretch it, so the board came out 332 by 240
     where it wants 332 by 580 — fitted, and squashed. A max-width and a
     max-height alone leave a replaced element to shrink on its own ratio. */
  position: absolute; top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  max-width: 100%; max-height: 100%;
  /* p5 writes the canvas size as an inline style and rewrites it on every
     resize, and an inline style beats every selector. Without `!important`
     the width stays at what the sketch asked for while the height is
     clamped, and the board is fitted by being squashed. The canvas's own
     width and height attributes give it the ratio to shrink on. */
  width: auto !important; height: auto !important;
}
/* Same reason as the slide itself: a figure has a prompt, a stage, a caption
   and now a button, and declared rows are a promise about how many children
   there will be that a template will eventually break. */
.focus__inner .sketch,
.focus__inner .desmos {
  display: flex; flex-direction: column; gap: .4rem; min-height: 0;
}
.focus__inner .sketch__prompt,
.focus__inner .sketch figcaption,
.focus__inner .desmos figcaption { flex: 0 0 auto; margin: 0; }
/* The preview before the game loads. It fills the stage rather than sitting
   in the middle of it at its own size — fitted, a portrait poster in a
   landscape box is a stamp with a "Try it" label bigger than the picture,
   and on a phone it was a strip you could miss entirely. Cropped from the
   top, because the top of a game's poster is its title and its mode pills,
   which is the part that says what this is. */
.focus__inner .sketch__start,
.focus__inner .desmos__start {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  padding: 0;
  display: grid; place-items: center;
  border-radius: var(--focus-radius);
  overflow: hidden;
  /* The stage behind a running game is transparent, so a letterboxed board
     does not sit between two grey bars. Before it runs there is no board,
     and a "Try it" label floating on white is not a thing that looks
     pressable — so the preview itself carries the fill. */
  background: var(--green-100);
}
.focus__inner .sketch__start img,
.focus__inner .desmos__start img {
  width: 100%; height: 100%;
  object-fit: cover; object-position: top center;
}
.focus__inner .sketch figcaption, .focus__inner .sketch__prompt {
  font-size: clamp(.8rem, 1.9dvh, .95rem);
}

/* --- a question set ----------------------------------------------------- */

/* Six questions in two columns of three, rather than one column a learner
   has to scroll. Arithmetic questions are short — "$15 - 8$" and a box — so
   a column half the width of a phone is still comfortable, and three of them
   fit where six never did.

   The 46rem measure is right for prose and wrong for this: two columns
   inside 46rem on a 1400px display is two narrow columns with a hand's width
   of nothing on either side, and the questions scroll anyway. Questions are
   not prose and do not want a reading measure. */
.focus__step--practice .focus__inner { max-width: 76rem; }

/* The block's own heading, on a slide whose heading already says it. Two
   headings one above the other is nineteen pixels spent saying the same
   thing twice, on the slide with the least room to spare. */
.focus__step--practice .focus__inner .practice__heading { display: none; }
.focus__step--practice .focus__inner { padding: .8rem 1rem 1rem; }

/* Four answers stacked is four rows of forty pixels, and three of those in a
   column is more than any screen has. Two by two is the same four answers in
   half the height, and a multiple choice with four short options is a shape
   a learner reads at a glance rather than reads down. */
.focus__inner .q-options {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: .35rem;
  margin-top: .35rem;
}
.focus__inner .q-options > li { margin: 0; }
.focus__inner .q-option { height: 100%; padding: .45rem .7rem; }

/* The rest of the same hundred and thirty pixels. Measured on a maximised
   window on a 1366 by 768 laptop — the machine most of these lessons will
   actually be opened on — where six questions with one four-option choice
   among them ran 130px past the bottom. Every number below is a place that
   spacing was set for a page that scrolls, on a slide that does not. */
.focus__inner .q-stem { margin-bottom: .4rem; }

.focus__inner .practice {
  min-height: 0;
  overflow-y: auto;
  /* Kept as a safety valve for a set that still will not fit — a long word
     problem, or ten questions.
   *
   * `touch-action: auto`, and the reason matters. It was `pan-y`, on the
   * theory that naming the vertical axis would let a horizontal swipe pass
   * through to the track. It does the opposite: `touch-action` says which
   * gestures the browser may handle for a touch that *starts here*, and
   * `pan-y` means it may not pan horizontally at all. So a swipe begun
   * anywhere on the questions did nothing whatever, and the only way off
   * the slide was to tap a pill — which is exactly what a learner reported.
   *
   * Left to itself the browser chains per axis: the questions take the
   * vertical, and the horizontal goes to the only ancestor that can scroll
   * it, which is the track. That is the behaviour we wanted the whole time,
   * and it was the declaration preventing it. */
  touch-action: auto;
  overscroll-behavior: contain;
}

/* The number and the question, in two columns rather than a float.
 *
 * `float: left` puts the number in the first line box of whatever follows.
 * When that is a stem with KaTeX in it — a taller line than plain text —
 * the number sits against the top of the line and the maths sits on its own
 * baseline, so the two are a few pixels out; and when the stem wraps, the
 * second line runs back under the number. A column does not have opinions
 * about line boxes. */
.focus__inner .practice__q {
  display: grid;
  grid-template-columns: 1.3rem minmax(0, 1fr);
  column-gap: .45rem;
  align-items: start;
  padding: .1rem 0 .3rem;
  margin: 0;
}
.focus__inner .practice__q > * { grid-column: 2; min-width: 0; }
.focus__inner .practice__number {
  grid-column: 1; grid-row: 1;
  float: none; margin: 0;
  text-align: right;
  /* Same first baseline as the stem beside it. */
  line-height: inherit;
}
.focus__inner [data-practice-questions] {
  display: grid;
  gap: .5rem 1.25rem;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  align-content: start;
}
.focus__inner .practice__heading { font-size: clamp(.95rem, 2.4dvh, 1.1rem); }
.focus__inner .practice .meta { font-size: clamp(.78rem, 1.8dvh, .9rem); }
/* Very wide and not very tall — a laptop — is where two columns of three
   still runs past the bottom. Three of two is the same six questions in two
   thirds of the height. Must come after the rule it overrides: both are one
   class and one attribute, so the later one wins and there is no !important
   worth spending here. */
@media (min-width: 68rem) and (max-height: 46rem) {
  .focus__inner [data-practice-questions] {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }
}
/* One column on a genuinely narrow phone, where half a screen cannot hold a
   stem and an answer box side by side. Four options go back to a single
   column there for the same reason. */
@media (max-width: 26rem) {
  .focus__inner [data-practice-questions] { grid-template-columns: 1fr; }
  .focus__inner .q-options { grid-template-columns: 1fr; }
}

/* --- a game, which is where the learner spends the slide ---------------- */

/* Stacked on a phone, the words above a game eat the height it needs. On a
   stage slide they are compact and the game takes the rest. */
/* A chain, and it only works whole: the game can only take the height that
   is left over if every box between it and the slide is stretched to the
   slide rather than sized to its own contents. One `align-content: safe
   centre` anywhere along it and the game is back to asking for 580px.
   `--stage` and `--split` can both be on one slide, so this has to beat the
   `align-items: center` the two-column rule sets as well. */
.focus__step--stage .focus__body {
  align-content: stretch;
  align-items: stretch;
}
.focus__step--stage .focus__visual {
  min-height: 0;
  display: flex; flex-direction: column;
}
.focus__step--stage .focus__visual .sketch,
.focus__step--stage .focus__visual .desmos { flex: 1 1 0; }
.focus__step--stage .focus__words {
  align-content: start;
  font-size: clamp(.82rem, 1.9dvh, .95rem);
}
@media (max-width: 51.99rem) {
  .focus__step--stage .focus__body {
    grid-template-rows: auto minmax(0, 1fr);
  }
  /* Words first, then the game filling what is left. */
  .focus__step--stage .focus__words { order: 1; }
  .focus__step--stage .focus__visual { order: 2; }

  /* On a phone the game is the slide. Everything around it was competing
     for the same three hundred pixels — a heading, an audio player, a
     paragraph, the sketch's own prompt and its caption — and what lost was
     the game, down to a strip too small to read the word "Play" in. The
     words are still there; they are simply not each given a line of their
     own above the thing they are describing. */
  .focus__step--stage .focus__words {
    font-size: clamp(.78rem, 1.8dvh, .9rem);
    gap: .35rem;
  }
  .focus__step--stage .focus__inner .sketch__prompt,
  .focus__step--stage .focus__inner .sketch figcaption {
    font-size: .78rem; line-height: 1.35;
  }
  /* The caption is about the game and can be read after it. On a phone it
     is two lines between the board and the bottom of the screen, and the
     board needs them more. */
  .focus__step--stage .focus__inner .sketch figcaption,
  .focus__step--stage .focus__inner .desmos figcaption { display: none; }
  .focus__step--stage .focus__heading { font-size: 1rem; }
}

/* Play it big. A toggle rather than the Fullscreen API: iOS Safari refuses
   `requestFullscreen` on anything but a video, and a game a third of our
   learners cannot expand is worse than one nobody can. A fixed overlay works
   on every browser there is.

   In its own row at the foot of the slide rather than floating over the
   corner of the game. It floated, and what it floated over was the sketch's
   own prompt — "Sixty seconds, three lives" with a white pill sitting on the
   words. A control that obscures the sentence explaining the control is not
   a small bug, and there is no corner of a game that is reliably empty. Two
   centimetres of honest space costs less.

   It lives inside the figure rather than in a row of the slide, because when
   the figure goes fixed and covers the window the row would be underneath
   it — a full-screen game with its own close button sealed behind it. */
.sketch__expand {
  align-self: flex-end;
  display: inline-flex; align-items: center; gap: .35rem;
  padding: .35rem .8rem;
  border: 1px solid var(--focus-line, #dce4ea); border-radius: 999px;
  background: #fff; color: var(--focus-blue-deep, #004E75);
  font: inherit; font-size: .8rem; line-height: 1.3; cursor: pointer;
  white-space: nowrap;
}
.sketch__expand:hover {
  background: var(--focus-blue-pale, #E8F1F6);
  border-color: var(--focus-blue, #006699);
}

/* Expanded: the game and nothing else. The rows are declared rather than
   left implicit for the same reason the slide's are — a figure has a prompt,
   a stage, a caption and a script in it, and an undeclared row is a row that
   gets cut off. */
.sketch.is-expanded,
.desmos.is-expanded {
  position: fixed; inset: 0; z-index: 60;
  margin: 0; padding: max(.75rem, env(safe-area-inset-top)) .75rem .75rem;
  background: #fff;
  display: flex; flex-direction: column; gap: .5rem;
}
.sketch.is-expanded .sketch__stage,
.desmos.is-expanded .desmos__frame {
  flex: 1 1 auto; min-height: 0 !important; height: auto !important;
  display: grid; place-items: center;
}
/* A p5 canvas has the size its sketch asked for. Letterboxed inside the
   stage it stays whole; stretched to fill it, the edges go off screen —
   which is what "cut off in full mode" was. */
.sketch.is-expanded .sketch__stage canvas {
  max-width: 100%; max-height: 100%;
  width: auto; height: auto;
  object-fit: contain;
}
/* `content-visibility: auto` on a slide brings `contain: paint` with it, and
   a paint-contained box is a containing block for its `position: fixed`
   descendants — so an expanded game would be fixed to its slide and clipped
   to it rather than filling the window. The slide it is on is the one that
   must not be contained. */
.focus__step:has(.sketch.is-expanded),
.focus__step:has(.desmos.is-expanded) { content-visibility: visible; }
.sketch.is-expanded .sketch__prompt,
.desmos.is-expanded .sketch__prompt { display: none; }
.sketch.is-expanded figcaption,
.desmos.is-expanded figcaption { display: none; }
body.is-playing { overflow: hidden; }

/* --- a page of a printed sheet ------------------------------------------ */

/* A worksheet page is the one slide with no words of the player's own on it,
   so it takes the window: no reading measure, no heading above it, and only
   the thin foot below.
 *
 * An A4 page is 1:1.41. Fitted to a phone it is limited by width, so it ends
 * up around 360 by 510 — a whole page of arithmetic at a third of print
 * size, which is what "shows fully but is not very visible" is. Fitting it
 * better is not possible; the page is that shape. So it fits by default and
 * zooms to the full width on a tap, and then it pans. That is the part of a
 * PDF viewer we would actually use, without the megabyte of viewer. */
.focus__step--sheet .focus__inner {
  max-width: none;
  padding: .5rem .5rem .6rem;
  gap: .4rem;
}
.focus__sheet {
  min-height: 0; margin: 0;
  /* `1 1 0`, not `1 1 auto`: the basis is zero, so the figure is the height
     left over rather than the height of a full-size A4 image. */
  flex: 1 1 0;
  position: relative;
  overflow: hidden;
}
/* Pinned to the figure, for the same reason as the game's canvas and with
   the same history: `max-height: 100%` on an image still in flow resolves
   against a height the image is itself setting, so the browser ignores it
   and the page renders at its full 993 by 1404. With `overflow: hidden`
   above that is not a scrollbar — it is the bottom two thirds of the
   worksheet simply not being there, which is what "cut off at the bottom"
   was. Centred by transform rather than by auto margins, which stretch a
   replaced element to the offsets instead of letting it keep its ratio. */
.focus__sheet-page {
  position: absolute; top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  max-height: 100%; max-width: 100%; width: auto; height: auto;
  border: 1px solid var(--focus-line); border-radius: var(--focus-radius);
  background: #fff;
  cursor: zoom-in;
}

/* --- the reader --------------------------------------------------------
 *
 * The slide shows an image of the page, because that is 25KB and paints at
 * once. This is the PDF itself, opened over everything, with what a reader
 * has: every page in one scroll, where you are in them, zoom, fit, print
 * and a way out. An image cannot do those, and enlarging one enlarges the
 * blur with it; a page re-rendered at the size it is shown is sharp at any
 * magnification. */
.reader {
  position: fixed; inset: 0; z-index: 90;
  display: grid; grid-template-rows: auto 1fr;
  background: #F2F4F6;
}
.reader[hidden] { display: none; }
body.is-reading { overflow: hidden; }

.reader__bar {
  display: flex; align-items: center; gap: .5rem;
  padding: max(.5rem, env(safe-area-inset-top)) .7rem .5rem;
  background: #22303A; color: #fff;
}
.reader__where {
  flex: 1; min-width: 0;
  font-size: .85rem; text-align: center;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.reader__zoom { display: inline-flex; gap: .3rem; }
.reader__btn {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 2.1rem; height: 2.1rem; padding: 0 .7rem;
  border: 1px solid rgba(255, 255, 255, .25); border-radius: 999px;
  background: rgba(255, 255, 255, .08); color: #fff;
  font: inherit; font-size: .82rem; line-height: 1;
  text-decoration: none; cursor: pointer; white-space: nowrap;
}
.reader__btn:hover { background: rgba(255, 255, 255, .18); }

/* `pan-y pinch-zoom` rather than `auto`: the vertical is the reader's and
   pinching is the reader's, but the horizontal is nobody's. This sits over
   the lesson player, and a page dragged sideways must not turn out to have
   changed the slide underneath it. */
.reader__pages {
  overflow: auto;
  touch-action: pan-y pinch-zoom;
  overscroll-behavior: contain;
  padding: .75rem;
  display: flex; flex-direction: column; align-items: center; gap: .75rem;
  -webkit-overflow-scrolling: touch;
}
.reader__page {
  display: block; flex: none;
  background: #fff;
  box-shadow: 0 1px 6px rgba(0, 0, 0, .22);
}

.focus__sheet-foot {
  display: flex; align-items: center; justify-content: space-between;
  gap: .75rem; margin: 0;
  font-size: .8rem;
}
.focus__zoom {
  display: inline-flex; align-items: center; gap: .35rem;
  padding: .3rem .75rem;
  border: 1px solid var(--focus-line); border-radius: 999px;
  background: #fff; color: var(--focus-blue-deep);
  font: inherit; font-size: .8rem; cursor: pointer; white-space: nowrap;
}
.focus__zoom:hover {
  background: var(--focus-blue-pale); border-color: var(--focus-blue);
}

/* A film or a guide that has not been recorded yet is one line saying so,
   and then the words it would have said. The line is the aside — smaller and
   grey — and the script below it is ordinary text. It was a dashed, tinted,
   centred box, which announced the absence of a recording more loudly than
   any lesson announces its actual content. */
.focus__inner .video--unfilmed,
.focus__inner .audio--unrecorded { text-align: left; }

/* An unrecorded guide's transcript is a page of speech, and opening it on a
   slide that cannot grow pushed the rest of the slide off both ends at once
   — the first line of the transcript cut off at the top, the game cut off at
   the bottom, and no scrollbar to say either had happened.
 *
 * Bounded and scrolled instead. `pan-y` for the same reason as the zoomed
   sheet: vertical here, sideways passes through to the track. */
.focus__inner .transcript__body {
  max-height: min(38dvh, 18rem);
  overflow-y: auto;
  touch-action: pan-y;
  overscroll-behavior: contain;
}
.focus__inner .transcript__body p { font-size: clamp(.82rem, 1.9dvh, .95rem); }

.focus__onward {
  display: flex; flex-wrap: wrap; gap: .5rem;
  margin: .75rem 0 0; padding-top: 1.1rem;
  border-top: 1px solid var(--focus-line);
}

/* "I've finished this", above the way onward and below the rule.
   Above, because it is about the lesson just read and "Next" is about the
   one after it — and because a learner who taps Next first has left without
   ever seeing this, which is how the completion table stayed empty. */
.focus__finish {
  display: flex; flex: 0 0 auto;
  margin: .75rem 0 0; padding-top: 1.1rem;
  border-top: 1px solid var(--focus-line);
}
.focus__finish + .focus__onward { border-top: 0; padding-top: .5rem; }

/* Marked, and it stays marked. The tick is drawn in CSS rather than shipped
   as an icon so it can appear the instant the button is tapped — the post
   has not come back yet, and on these connections it may be a while. */
.focus__done.is-done {
  background: var(--green-100); color: var(--green-900);
  box-shadow: inset 0 0 0 1.5px var(--green-900);
}
.focus__done.is-done::before { content: "✓ "; }


/* --- the drawn line ------------------------------------------------------
 *
 * The player has a handwritten face and machine-perfect boxes, which is two
 * voices in one interface: the words look like somebody wrote them and the
 * controls look like a form. This is the other half of that face.
 *
 * SVG rather than the PNG the technique comes from. The Evolution of Trust
 * draws its buttons as three 12KB rasters at a fixed 250px, which is the
 * right call for a game on a fixed 960x540 stage and the wrong one for a
 * lesson that has to hold from a 320px phone to a laptop: a stretched raster
 * goes soft, and three of them is 40KB. This is one path, about 400 bytes,
 * inlined so it costs no request at all, and `non-scaling-stroke` keeps the
 * line one weight however far it is stretched.
 *
 * Three things make a line look drawn rather than computed, and the path has
 * all three: no edge is straight, the four corners have different radii, and
 * the stroke overshoots where it closes instead of meeting itself exactly.
 */
.focus__inner .hand,
.focus .hand {
  position: relative;
  border: 0 !important;
  border-radius: 0;
  background-color: transparent;
  background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20200%2060%22%20preserveAspectRatio%3D%22none%22%3E%3Cpath%20d%3D%22M13%2C7%20C62%2C3.5%20139%2C4.5%20187%2C8%20C194.5%2C9%20196%2C21%20195%2C31%20C194%2C42%20196.5%2C51%20187.5%2C53.5%20C138%2C57%2061%2C56%2013.5%2C52.5%20C5%2C51.5%204%2C40%205%2C30%20C6%2C19%203.5%2C10%2013%2C7%20C15%2C6.6%2017%2C6.4%2019%2C6.3%22%20fill%3D%22none%22%20stroke%3D%22%231B2413%22%20stroke-width%3D%222.5%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%2F%3E%3C%2Fsvg%3E");
  background-repeat: no-repeat;
  background-size: 100% 100%;
  padding: .5rem 1.1rem;
  color: var(--focus-ink, var(--ink-strong));
  transition: transform .1s ease-out;
}

/* Pressed, and lifted a little on the way to it. Scaling the whole control
   rather than shading it: a drawn button has no fill to darken. */
.focus .hand:hover { transform: scale(1.02); }
.focus .hand:active { transform: scale(.96); }
@media (prefers-reduced-motion: reduce) {
  .focus .hand { transition: none; }
  .focus .hand:hover, .focus .hand:active { transform: none; }
}

/* The primary way onward is drawn in the green rather than the ink, which is
   the whole of the difference. A drawn interface has one lever for emphasis
   and it is the colour of the line. */
.focus .hand--go { background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20200%2060%22%20preserveAspectRatio%3D%22none%22%3E%3Cpath%20d%3D%22M13%2C7%20C62%2C3.5%20139%2C4.5%20187%2C8%20C194.5%2C9%20196%2C21%20195%2C31%20C194%2C42%20196.5%2C51%20187.5%2C53.5%20C138%2C57%2061%2C56%2013.5%2C52.5%20C5%2C51.5%204%2C40%205%2C30%20C6%2C19%203.5%2C10%2013%2C7%20C15%2C6.6%2017%2C6.4%2019%2C6.3%22%20fill%3D%22none%22%20stroke%3D%22%23354627%22%20stroke-width%3D%223%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%2F%3E%3C%2Fsvg%3E"); color: var(--focus-green); }
.focus .hand[disabled],
.focus .hand[aria-disabled=true] {
  background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20200%2060%22%20preserveAspectRatio%3D%22none%22%3E%3Cpath%20d%3D%22M13%2C7%20C62%2C3.5%20139%2C4.5%20187%2C8%20C194.5%2C9%20196%2C21%20195%2C31%20C194%2C42%20196.5%2C51%20187.5%2C53.5%20C138%2C57%2061%2C56%2013.5%2C52.5%20C5%2C51.5%204%2C40%205%2C30%20C6%2C19%203.5%2C10%2013%2C7%20C15%2C6.6%2017%2C6.4%2019%2C6.3%22%20fill%3D%22none%22%20stroke%3D%22%23C9CFC0%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%2F%3E%3C%2Fsvg%3E");
  color: var(--muted-2);
  transform: none;
}


/* --- the drawn line, on the things a learner answers with ---------------- */

/* Everything below is one path stretched: the same wobble as the buttons, so
   the whole player is drawn by one hand rather than being a drawn button in a
   set of computed boxes.
 *
 * Only inside the player. On the ordinary lesson page — which is what a
 * parent skims, and what prints — a question keeps its ruled box, because a
 * drawn outline photocopies badly and reads as informal in a place where the
 * job is to look like schoolwork. */

/* An option to choose. The chosen one is drawn in the green rather than
   filled with it: a drawn interface has one lever for emphasis and it is the
   colour of the line, and a tinted panel behind a hand-drawn outline is the
   panel habit coming back in through the side door. */
.focus__inner .q-option {
  border: 0 !important;
  border-radius: 0;
  background-color: transparent;
  background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20200%2060%22%20preserveAspectRatio%3D%22none%22%3E%3Cpath%20d%3D%22M13%2C7%20C62%2C3.5%20139%2C4.5%20187%2C8%20C194.5%2C9%20196%2C21%20195%2C31%20C194%2C42%20196.5%2C51%20187.5%2C53.5%20C138%2C57%2061%2C56%2013.5%2C52.5%20C5%2C51.5%204%2C40%205%2C30%20C6%2C19%203.5%2C10%2013%2C7%20C15%2C6.6%2017%2C6.4%2019%2C6.3%22%20fill%3D%22none%22%20stroke%3D%22%23C9CFC0%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%2F%3E%3C%2Fsvg%3E");
  background-repeat: no-repeat;
  background-size: 100% 100%;
  box-shadow: none !important;
}
.focus__inner .q-option:hover { background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20200%2060%22%20preserveAspectRatio%3D%22none%22%3E%3Cpath%20d%3D%22M13%2C7%20C62%2C3.5%20139%2C4.5%20187%2C8%20C194.5%2C9%20196%2C21%20195%2C31%20C194%2C42%20196.5%2C51%20187.5%2C53.5%20C138%2C57%2061%2C56%2013.5%2C52.5%20C5%2C51.5%204%2C40%205%2C30%20C6%2C19%203.5%2C10%2013%2C7%20C15%2C6.6%2017%2C6.4%2019%2C6.3%22%20fill%3D%22none%22%20stroke%3D%22%23354627%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%2F%3E%3C%2Fsvg%3E"); }
.focus__inner .q-option:has(input:checked),
.focus__inner .q-option.is-chosen { background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20200%2060%22%20preserveAspectRatio%3D%22none%22%3E%3Cpath%20d%3D%22M13%2C7%20C62%2C3.5%20139%2C4.5%20187%2C8%20C194.5%2C9%20196%2C21%20195%2C31%20C194%2C42%20196.5%2C51%20187.5%2C53.5%20C138%2C57%2061%2C56%2013.5%2C52.5%20C5%2C51.5%204%2C40%205%2C30%20C6%2C19%203.5%2C10%2013%2C7%20C15%2C6.6%2017%2C6.4%2019%2C6.3%22%20fill%3D%22none%22%20stroke%3D%22%23354627%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%2F%3E%3C%2Fsvg%3E"); }
.focus__inner .q-option.is-right { background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20200%2060%22%20preserveAspectRatio%3D%22none%22%3E%3Cpath%20d%3D%22M13%2C7%20C62%2C3.5%20139%2C4.5%20187%2C8%20C194.5%2C9%20196%2C21%20195%2C31%20C194%2C42%20196.5%2C51%20187.5%2C53.5%20C138%2C57%2061%2C56%2013.5%2C52.5%20C5%2C51.5%204%2C40%205%2C30%20C6%2C19%203.5%2C10%2013%2C7%20C15%2C6.6%2017%2C6.4%2019%2C6.3%22%20fill%3D%22none%22%20stroke%3D%22%23354627%22%20stroke-width%3D%223%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%2F%3E%3C%2Fsvg%3E"); }
.focus__inner .q-option.is-wrong { background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20200%2060%22%20preserveAspectRatio%3D%22none%22%3E%3Cpath%20d%3D%22M13%2C7%20C62%2C3.5%20139%2C4.5%20187%2C8%20C194.5%2C9%20196%2C21%20195%2C31%20C194%2C42%20196.5%2C51%20187.5%2C53.5%20C138%2C57%2061%2C56%2013.5%2C52.5%20C5%2C51.5%204%2C40%205%2C30%20C6%2C19%203.5%2C10%2013%2C7%20C15%2C6.6%2017%2C6.4%2019%2C6.3%22%20fill%3D%22none%22%20stroke%3D%22%23A8481B%22%20stroke-width%3D%222.5%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%2F%3E%3C%2Fsvg%3E"); }

/* A box to type into. Same line, and the caret sits inside it rather than in
   a field that looks borrowed from a form. */
.focus__inner .q-numeric input,
.focus__inner input[type=text],
.focus__inner input[type=number] {
  border: 0 !important;
  border-radius: 0;
  background-color: transparent;
  background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20200%2060%22%20preserveAspectRatio%3D%22none%22%3E%3Cpath%20d%3D%22M13%2C7%20C62%2C3.5%20139%2C4.5%20187%2C8%20C194.5%2C9%20196%2C21%20195%2C31%20C194%2C42%20196.5%2C51%20187.5%2C53.5%20C138%2C57%2061%2C56%2013.5%2C52.5%20C5%2C51.5%204%2C40%205%2C30%20C6%2C19%203.5%2C10%2013%2C7%20C15%2C6.6%2017%2C6.4%2019%2C6.3%22%20fill%3D%22none%22%20stroke%3D%22%23C9CFC0%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%2F%3E%3C%2Fsvg%3E");
  background-repeat: no-repeat;
  background-size: 100% 100%;
  padding: .55rem .9rem;
}
.focus__inner .q-numeric input:focus,
.focus__inner input[type=text]:focus,
.focus__inner input[type=number]:focus {
  outline: none;
  background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20200%2060%22%20preserveAspectRatio%3D%22none%22%3E%3Cpath%20d%3D%22M13%2C7%20C62%2C3.5%20139%2C4.5%20187%2C8%20C194.5%2C9%20196%2C21%20195%2C31%20C194%2C42%20196.5%2C51%20187.5%2C53.5%20C138%2C57%2061%2C56%2013.5%2C52.5%20C5%2C51.5%204%2C40%205%2C30%20C6%2C19%203.5%2C10%2013%2C7%20C15%2C6.6%2017%2C6.4%2019%2C6.3%22%20fill%3D%22none%22%20stroke%3D%22%23354627%22%20stroke-width%3D%223%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%2F%3E%3C%2Fsvg%3E");
}
.focus__inner .q-numeric input.is-right { background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20200%2060%22%20preserveAspectRatio%3D%22none%22%3E%3Cpath%20d%3D%22M13%2C7%20C62%2C3.5%20139%2C4.5%20187%2C8%20C194.5%2C9%20196%2C21%20195%2C31%20C194%2C42%20196.5%2C51%20187.5%2C53.5%20C138%2C57%2061%2C56%2013.5%2C52.5%20C5%2C51.5%204%2C40%205%2C30%20C6%2C19%203.5%2C10%2013%2C7%20C15%2C6.6%2017%2C6.4%2019%2C6.3%22%20fill%3D%22none%22%20stroke%3D%22%23354627%22%20stroke-width%3D%223%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%2F%3E%3C%2Fsvg%3E"); }
.focus__inner .q-numeric input.is-wrong { background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20200%2060%22%20preserveAspectRatio%3D%22none%22%3E%3Cpath%20d%3D%22M13%2C7%20C62%2C3.5%20139%2C4.5%20187%2C8%20C194.5%2C9%20196%2C21%20195%2C31%20C194%2C42%20196.5%2C51%20187.5%2C53.5%20C138%2C57%2061%2C56%2013.5%2C52.5%20C5%2C51.5%204%2C40%205%2C30%20C6%2C19%203.5%2C10%2013%2C7%20C15%2C6.6%2017%2C6.4%2019%2C6.3%22%20fill%3D%22none%22%20stroke%3D%22%23A8481B%22%20stroke-width%3D%222.5%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%2F%3E%3C%2Fsvg%3E"); }

/* The board a game sits in, and the sheet a worksheet page sits on. A
   rectangle this large wants a quieter line than a button does — at 600px
   across, a 2.5px stroke is a frame around the game rather than the edge of
   it. */
.focus__inner .sketch__stage,
.focus__inner .desmos__frame {
  border: 0 !important;
  border-radius: 0;
  background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20200%2060%22%20preserveAspectRatio%3D%22none%22%3E%3Cpath%20d%3D%22M13%2C7%20C62%2C3.5%20139%2C4.5%20187%2C8%20C194.5%2C9%20196%2C21%20195%2C31%20C194%2C42%20196.5%2C51%20187.5%2C53.5%20C138%2C57%2061%2C56%2013.5%2C52.5%20C5%2C51.5%204%2C40%205%2C30%20C6%2C19%203.5%2C10%2013%2C7%20C15%2C6.6%2017%2C6.4%2019%2C6.3%22%20fill%3D%22none%22%20stroke%3D%22%23E4E8DB%22%20stroke-width%3D%221.5%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20vector-effect%3D%22non-scaling-stroke%22%2F%3E%3C%2Fsvg%3E");
  background-repeat: no-repeat;
  background-size: 100% 100%;
}

/* The grey band behind a question set goes with them. It was there to group
   the questions; the drawn outlines do that now, and two groupings on one
   set of questions is one too many. */
/* `.practice` carries no background anywhere now, so the player has
   nothing left to override here. */


/* --- the rub-off ---------------------------------------------------------
 *
 * Content scratched off, the next slide scratched on. The frames are Nicky
 * Case's, from The Evolution of Trust (CC0) — see `SCRATCH.LICENCE.md` beside
 * the file for what was changed and why it is 49KB rather than 687.
 *
 * **An overlay, not a clip on the slide.** The first attempt at this animated
 * a `clip-path` on the slide itself and was invisible in practice, for a
 * reason that is obvious afterwards: the slides sit in a horizontal
 * scroll-snapping track, so the slide being wiped is already sliding off the
 * screen and a wipe drawn on it goes with it. What has to be covered is the
 * window, which is what an overlay is.
 *
 * The image is a two-column, twenty-row sprite sheet of alpha masks. Column
 * one covers the screen and column two uncovers it, and stepping the
 * background position through the rows is the whole animation — no
 * compositing, no canvas, one element.
 */
.focus__scratch {
  position: fixed;
  inset: 0;
  z-index: 90;
  display: none;
  pointer-events: none;

  /* A **mask** over a colour, not a white picture.
   *
   * The sheet is white strokes on transparent, which is right in a game whose
   * page is white and whose content is black text — the strokes cover the
   * text and you watch it vanish. Here it was invisible, and the reason is
   * that a lesson is mostly white already: white marks appearing on white
   * paper are only visible where they happen to cross a letter, which for
   * the first ten frames is almost nowhere.
   *
   * Masking a tinted panel instead makes every stroke visible from the first
   * one, on any slide, whatever is underneath it. The tint is the site's own
   * soft green — chalk dust rather than a flash of colour. */
  -webkit-mask-size: 200% 2000%;
  mask-size: 200% 2000%;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  /* WHITE, AND THE PAGE IS WHITE TOO. That is not the bug it looks like.
   *
   * I got this backwards twice. Reading "white strokes, white page" I decided
   * they must be invisible and went hunting for a colour that would contrast
   * — #E7ECDF, then a dark green — and both were wrong, because the strokes
   * are not supposed to be seen *as strokes on paper*. The reference page is
   * `background: #fff` and its marks are white: what you watch is the black
   * text being covered over and taken away. The mark is legible only where it
   * crosses something, and that is the entire effect. On a slide that is
   * mostly blank there is little to see, and that is true of the original too.
   *
   * So: white, and it is the content disappearing that reads, not the paint. */
  background: #FFFFFF;
}
.focus__scratch.is-scratching { display: block; }

/* Nothing at all for a learner who has asked for less movement. The slide
   still changes; it simply changes without being scratched over. */
/* No reduced-motion rule here, deliberately.
 *
 * There was one — `display: none !important` — and it did what it said:
 * the sweep painted its 39 frames correctly and every one of them was
 * invisible, on any machine with the setting on. Which is most of them;
 * Windows offers "Show animations" as a toggle and Android battery saver
 * sets it for you.
 *
 * Nor is there a softened version any more. The first replacement was a
 * fade, and a fade is not a scratch: the text swapped and the screen
 * blinked green, which is a jump cut with a colour over it.
 *
 * This overlay is not decoration on top of a change. The slide swap
 * happens *inside* it, hidden, while the screen is covered — so removing
 * it does not calm the transition down, it removes the transition and
 * leaves the cut. The reference this is taken from checks the preference
 * nowhere, and is right not to. */

/* --- the indicators ---------------------------------------------------- */

.focus__foot {
  display: flex; align-items: center; justify-content: center;
  gap: .25rem;
  padding: .7rem .8rem;
  padding-bottom: max(.7rem, env(safe-area-inset-bottom));
  border-top: 1px solid var(--focus-line);
  background: #fff;
}
/* One line, always. Eleven pills wrapping onto a second row turns the
   indicator into a paragraph and pushes the slide up — so they share the
   width instead of each claiming a fixed one. */
.focus__dots {
  display: flex; align-items: center; justify-content: center;
  gap: clamp(.25rem, 1.4vw, .55rem);
  list-style: none; padding: 0;
  flex-wrap: nowrap;
  max-width: 30rem;

  /* `flex: 1 1 auto` and no auto margins.
   *
   * This was `margin: 0 auto` with the default `flex: 0 1 auto`, and the row
   * took 92px of the 257 available — so the marks were squeezed to a few
   * pixels no matter what size they were asked to be. Centring is already the
   * job of `justify-content` here; the auto margins were doing nothing but
   * competing with the flexing for the same space.
   *
   * Measured across widths after the change: 13px marks at 345, 14 at 360,
   * 22 at 465, and the full 30 from about 685 up, with no overflow anywhere. */
  flex: 1 1 auto;
  margin: 0;
}
/* Each mark wants its own tap target and gives ground rather than overflow.
 *
 * When these were pills they were `flex: 1 1 0` and shared whatever width
 * there was. Turning them into circles I set a fixed 2rem and `flex: 0 0
 * auto`, which on a phone is eleven 32px targets plus gaps plus two arrows —
 * about 470px of row inside a 360px screen. It pushed the whole page sideways
 * and there is no wrapping to save it, because the row is `nowrap` on
 * purpose.
 *
 * `0 1 2rem` asks for the same 2rem and lets it shrink when the screen says
 * no. The mark inside is sized separately and stays a circle either way. */
/* 2.4rem so a 1.875rem circle has air around it, and still `0 1` so the row
   gives ground on a phone rather than overflowing — which is the failure this
   `min-width: 0` and that shrink factor were added to fix. */
.focus__dots > li { flex: 0 1 2.4rem; min-width: 0; }

/* Back and on, built to belong to the pills rather than to sit beside them.
 *
 * Same height as a pill's tap target, the same rounded shape, the same blue,
 * and no label — an icon on its own. A "Next" with a word on it is a button;
 * a round blue mark at the end of a row of blue marks is the end of the row.
 * The distinction matters because this strip is the only chrome left at the
 * bottom of the player, and the moment it reads as a toolbar it stops being
 * a progress indicator.
 *
 * Faded rather than removed at the ends: a control that vanishes moves the
 * row under the thumb that was reaching for it. */
.focus__step-btn {
  display: inline-flex; align-items: center; justify-content: center;
  flex: none;
  width: 2.2rem; height: 2.2rem;
  border: 0; border-radius: 999px;
  background: none; color: var(--focus-blue);
  font: inherit; cursor: pointer;
  transition: background .18s ease, opacity .18s ease;
}
.focus__step-btn:hover:not(:disabled) { background: var(--focus-blue-pale); }
.focus__step-btn:disabled { opacity: .25; cursor: default; }
.focus__step-btn:focus-visible {
  outline: 2px solid var(--focus-blue); outline-offset: -2px;
}
.focus__step-btn[hidden] { display: none; }
@media (prefers-reduced-motion: reduce) {
  .focus__step-btn { transition: none; }
}
/* Circles: an outline for what is ahead, filled in for what is behind.
 *
 * They were pills, on the argument that a pill has a length and so reads as a
 * track being worked through. That argument was about the wrong thing. A row
 * of eleven bars is a *progress bar chopped up* — it says how far along, which
 * a learner can already feel — where a row of circles says **how many**, and
 * how many is the question a child actually has ("how much is left?"). The
 * form is The Evolution of Trust's, which marks its ten slides exactly so.
 *
 * The mark is inside the button, so the button stays a thumb-sized tap target
 * while the circle stays small enough that eleven of them fit a phone.
 */
.focus__dot {
  display: grid; place-items: center;
  width: 100%; height: 2.6rem;   /* clears the 1.875rem mark with room to tap */
  min-width: 0;
}
.focus__dot-mark {
  display: block;
  /* 30px at the top, which is the reference's own `#select .dot`.
   *
   * These were capped at .95rem — barely half that — because eleven of them
   * plus arrows once overflowed a 360px phone, and I fixed that by making the
   * circle small rather than by making it *shrink*. The result was right on a
   * phone and far too timid on a laptop, where there is room for the thing to
   * be a real target.
   *
   * So: a floor of .75rem, 5vw in between, and 1.875rem at the ceiling. The
   * viewport term does the accommodating — 5vw is under 19px on a 375px
   * screen, so eleven still fit — and the cap is reached around 600px, which
   * is the point at which the row has room to spare. `min(…, 100%)` on top,
   * because the mark must never be wider than the button holding it however
   * hard the row is squeezed; that is what keeps the overflow fixed. */
  width: min(clamp(.75rem, 5vw, 1.875rem), 100%);
  aspect-ratio: 1;
  border-radius: 50%;
  border: 2px solid var(--focus-line);
  background: transparent;
  transition: background .18s ease, border-color .18s ease, transform .18s ease;
}
/* Behind you: filled, in the green the rest of the player uses for done. */
.focus__dot.is-done .focus__dot-mark {
  background: var(--focus-green); border-color: var(--focus-green);
}
/* Here: filled and a shade larger. Larger rather than wider, because the row
   is centred and a circle that grows sideways would shuffle its neighbours
   under a thumb already reaching for one. */
.focus__dot.is-at .focus__dot-mark {
  background: var(--focus-blue); border-color: var(--focus-blue);
  transform: scale(1.35);
}
.focus__dot:hover .focus__dot-mark { border-color: var(--focus-blue-deep); }
.focus__dot:focus-visible { outline: 2px solid var(--focus-blue); outline-offset: -3px; border-radius: 999px; }
@media (prefers-reduced-motion: reduce) {
  .focus__dot-mark { transition: none; }
}

/* --- full screen -------------------------------------------------------- */

/* The bar and the pills are about four centimetres of a phone between them.
   On the slides where height is the whole problem — a worksheet page, a game
   — that is the difference between readable and not, so they go away.
 *
   The rows collapse rather than the bars being hidden: `display: none` on a
   grid child leaves the track sized as if it were there on some browsers,
   and `visibility: hidden` leaves the space outright. Zero rows plus
   `overflow: hidden` gives the slide the pixels, which is the point. */
.focus.is-wide { grid-template-rows: 0 1fr 0; }
.focus.is-wide .focus__bar,
.focus.is-wide .focus__foot {
  overflow: hidden;
  padding-top: 0; padding-bottom: 0;
  border: 0;
  opacity: 0;
  pointer-events: none;
}
/* The only way back, so it is fixed rather than in the bar that has gone.
   Deliberately small and pale: it is a way out, not a thing to look at. */
.focus__wide, .focus__restore, .focus__sound {
  display: inline-flex; align-items: center; justify-content: center;
  width: 2rem; height: 2rem; flex: none;
  border: 1px solid var(--focus-line); border-radius: 999px;
  background: #fff; color: var(--focus-blue-deep);
  font: inherit; cursor: pointer;
}
.focus__wide:hover, .focus__restore:hover, .focus__sound:hover {
  background: var(--focus-blue-pale); border-color: var(--focus-blue);
}
/* On, and saying so by being filled rather than by growing a second icon.
   A speaker with a slash through it and a speaker without one are two
   drawings a child has to tell apart; a filled circle and an empty one are
   the same drawing in two states. */

.focus__sound { margin-right: .35rem; }
.focus__sound.is-on {
  background: var(--focus-green); border-color: var(--focus-green); color: #fff;
}
.focus__sound[hidden] { display: none; }
.focus__restore {
  position: fixed; z-index: 70;
  top: max(.5rem, env(safe-area-inset-top)); right: .5rem;
  background: rgba(255, 255, 255, .88);
  backdrop-filter: blur(3px);
}
.focus__restore[hidden] { display: none; }

@media (min-width: 48rem) {
  .focus__bar { padding: .65rem 1.1rem; }
  .focus__inner { padding: 1.5rem 1.5rem 1.75rem; gap: 1rem; }
  .focus__title { font-size: .95rem; }
  .focus__dots { gap: .35rem; }
}

/* ------------------------------------------------------------------- icons

   An icon is the colour and size of the text it sits in, so it needs nothing
   at the call site. `vertical-align: -0.125em` puts an optical-centre glyph on
   the text baseline; without it every icon rides a fraction high and the whole
   page looks slightly loose without anyone being able to say why. */
.icon { display: inline-block; vertical-align: -0.125em; flex: none; }

/* A back link, as a pill rather than an arrow character floating in a
   paragraph. Same shape everywhere it appears, which is what makes it read as
   a control rather than as prose that happens to be a link. */
.backlink {
  display: inline-flex; align-items: center; gap: .4rem;
  padding: .3rem .75rem .3rem .6rem;
  border: 1px solid var(--line, #e0e0e0); border-radius: 999px;
  background: #fff; color: var(--green-800, #354627);
  font-size: .88rem; text-decoration: none; line-height: 1.4;
}
.backlink:hover {
  background: var(--green-100); border-color: var(--green-300, #a8b694);
}

/* A forward link that ends a card: text, then the icon. */
.golink { display: inline-flex; align-items: center; gap: .35rem; }
.golink .icon { transition: transform .15s ease; }
.golink:hover .icon { transform: translateX(2px); }
@media (prefers-reduced-motion: reduce) {
  .golink:hover .icon { transform: none; }
}
