/* ==========================================================================
   0. Webfonts, self-hosted

   Vendored from Google Fonts rather than linked. The link version cost two
   third-party origins on the critical path: fonts.googleapis.com for this CSS,
   then fonts.gstatic.com for the files. At 150ms RTT on mobile that is DNS,
   TLS and a request, twice, before real text can paint.

   Instrument Sans is one variable file covering 400 to 700, not four static
   weights. Only latin and latin-ext are carried; the browser fetches a subset
   only when a character in its unicode-range appears, so latin-ext is free
   until a name actually needs it.

   index.html and the other pages preload the two latin files. Without that
   they cannot be discovered until this stylesheet has been parsed.
   ========================================================================== */

@font-face {
  font-family: "Bricolage Grotesque";
  font-style: normal;
  font-weight: 400 800;
  font-display: swap;
  src: url("../fonts/bricolage-grotesque-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

@font-face {
  font-family: "Bricolage Grotesque";
  font-style: normal;
  font-weight: 400 800;
  font-display: swap;
  src: url("../fonts/bricolage-grotesque-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: "Instrument Sans";
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("../fonts/instrument-sans-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

@font-face {
  font-family: "Instrument Sans";
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url("../fonts/instrument-sans-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* ==========================================================================
   Maison Acadé-Nova, student portal
   Design system: "Open House"

   Built for a 17 year old on a phone at 9pm, not for a printed worksheet.
   Warm near-white ground, cobalt that commits, marigold that interrupts,
   photography in every section, and motion that acknowledges you.

   The previous system ("Field Notebook") was retired on purpose. It was made
   of hairlines, a print serif and cream paper, which are printed-page
   conventions, and that is exactly why it read as a document.

     1.  Tokens
     2.  Reset + base
     3.  Typography
     4.  Layout + bands
     5.  Header + footer
     6.  Buttons
     7.  Forms
     8.  Cards
     9.  Stickers, chips, badges
     10. Lists (register)
     11. Panels, plates, notices
     12. Empty states
     13. Toasts, skeletons, dialog
     14. Landing components
     15. Motion
     16. Print
   ========================================================================== */

/* --------------------------------------------------------------------------
   1. Tokens
   -------------------------------------------------------------------------- */

:root {
  /* Ground. Warm near-white, never pure, so photographs sit on something
     that has a temperature rather than on a void. */
  --ground:     oklch(0.985 0.007 85);
  --ground-2:   oklch(0.962 0.010 82);
  --surface:    oklch(0.998 0.002 85);

  /* Ink is blue-black rather than brown-black. It reads as a screen colour,
     which is the whole point: this is a product, not a printout. */
  --ink:        oklch(0.23 0.035 265);
  --ink-2:      oklch(0.44 0.030 265);
  /* 0.545 measured 4.46:1 against --ground, which fails WCAG AA by 0.04. This
     token carries every 12px small-caps .label, the .chip text and .price, so
     it is nearly all the small type in the product. Do not lighten it. */
  --ink-3:      oklch(0.525 0.028 265);

  --line:       oklch(0.90 0.010 265);
  --line-firm:  oklch(0.82 0.015 265);

  /* Cobalt. Aspirational and focused. Used as committed flat area, never as
     a gradient wash across a white page. */
  --brand:      oklch(0.50 0.20 264);
  --brand-hi:   oklch(0.43 0.20 264);
  --brand-soft: oklch(0.94 0.035 264);
  --brand-ink:  oklch(0.32 0.14 264);

  /* Marigold. The interruption colour: stickers, marker highlights, the one
     thing on screen that is allowed to be loud. Never used as text on light. */
  --zest:       oklch(0.82 0.155 82);
  /* Marigold for small type sitting ON cobalt. Full --zest measures 3.56:1
     against --brand, which fails AA for the 12px small-caps .label it is used
     for. Same hue, lifted until it passes. Only for text on a brand or ink
     band: on paper it would disappear. */
  --zest-lift:  oklch(0.95 0.075 88);
  --zest-deep:  oklch(0.72 0.155 70);

  /* Mint, reserved for paid and success states so it never reads as the
     brand colour and never triggers the biology-green cliche. */
  --fresh:      oklch(0.88 0.09 165);
  --fresh-ink:  oklch(0.42 0.09 165);

  --warn:       oklch(0.50 0.13 55);
  --warn-soft:  oklch(0.95 0.04 70);

  /* Type */
  --display: "Bricolage Grotesque", "Trebuchet MS", sans-serif;
  --sans:    "Instrument Sans", ui-sans-serif, system-ui, sans-serif;

  --t-xs:   0.78rem;
  --t-sm:   0.9rem;
  --t-base: 1.0625rem;
  --t-lg:   1.25rem;
  --t-xl:   1.6rem;
  --t-2xl:  2.1rem;
  --t-3xl:  2.9rem;
  --t-4xl:  4rem;

  /* Space */
  --s-1: 0.25rem;  --s-2: 0.5rem;   --s-3: 0.75rem;  --s-4: 1rem;
  --s-5: 1.5rem;   --s-6: 2rem;     --s-7: 3rem;     --s-8: 4.5rem;
  --s-9: 6.5rem;

  --measure: 66ch;
  --shell: 71rem;

  /* Radii. Generous, because sharp corners read as institutional. */
  --r-sm: 6px;
  --r-md: 10px;
  --r-lg: 18px;
  --r-xl: 26px;
  --r-pill: 999px;

  /* Elevation, tinted toward the brand hue so shadows stay in the palette
     instead of dropping a grey haze over a warm page. */
  --lift-1: 0 1px 2px oklch(0.23 0.035 265 / 0.06),
            0 3px 8px -3px oklch(0.23 0.035 265 / 0.10);
  --lift-2: 0 2px 4px oklch(0.23 0.035 265 / 0.07),
            0 12px 28px -12px oklch(0.23 0.035 265 / 0.20);
  --lift-3: 0 4px 8px oklch(0.23 0.035 265 / 0.08),
            0 28px 56px -24px oklch(0.23 0.035 265 / 0.30);

  /* Motion. Ease-out-quint: fast start, long settle. Nothing bounces. */
  --ease: cubic-bezier(0.22, 1, 0.36, 1);
  --dur-1: 170ms;
  --dur-2: 280ms;
  --dur-3: 620ms;

  --z-header: 20;
  --z-fab: 30;
  --z-toast: 40;
  --z-dialog: 50;
}

/* --------------------------------------------------------------------------
   2. Reset + base
   -------------------------------------------------------------------------- */

*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; }

/* The UA rule for [hidden] is display:none at the lowest specificity, so any
   author `display` declaration silently beats it. Page state here is toggled
   with the hidden attribute, so it has to win outright. */
[hidden] { display: none !important; }

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  scroll-padding-top: 6rem;
}

body {
  min-height: 100dvh;
  background: var(--ground);
  color: var(--ink);
  font-family: var(--sans);
  font-size: var(--t-base);
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

img, svg, iframe, video { display: block; max-width: 100%; }
svg { fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
a { color: inherit; }

:focus-visible {
  outline: 3px solid var(--brand);
  outline-offset: 2px;
  border-radius: 4px;
}
::selection { background: var(--zest); color: var(--ink); }

.sr-only {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap; border: 0;
}

.skip {
  position: absolute; left: var(--s-4); top: var(--s-4);
  transform: translateY(-200%);
  background: var(--ink); color: var(--ground);
  padding: var(--s-3) var(--s-4); border-radius: var(--r-md);
  font-size: var(--t-sm); font-weight: 600; text-decoration: none;
  z-index: 60; transition: transform var(--dur-1) var(--ease);
}
.skip:focus-visible { transform: translateY(0); }

/* Protected pages stay blank until the session resolves, so nothing gated
   ever flashes before a redirect. */
body[data-auth="pending"] .guarded { visibility: hidden; }
body[data-auth="pending"] .guard-loading { display: flex; }
.guard-loading { display: none; }

/* --------------------------------------------------------------------------
   3. Typography
   -------------------------------------------------------------------------- */

h1, h2, h3, h4 {
  font-family: var(--display);
  font-weight: 700;
  line-height: 1.05;
  letter-spacing: -0.03em;
  text-wrap: balance;
}
h1 { font-size: clamp(var(--t-2xl), 6.2vw, var(--t-4xl)); }
h2 { font-size: clamp(var(--t-xl), 4.4vw, var(--t-3xl)); }
h3 { font-size: var(--t-lg); letter-spacing: -0.02em; }

/* No `text-wrap: pretty` on paragraphs. Dropped 8 August 2026 because it works
   against the client's standing note that text should fill the width: `pretty`
   avoids a short last line by pulling words back off the lines above it.

   Be honest about the size of this, since it is easy to reach for as an
   explanation and it is not one. Measured across the landing page at 412px,
   removing it moved almost nothing: 32 paragraphs short of their box by more than
   15px before, 31 after, same 109px worst case. The remaining gaps are ordinary
   ragged-right wrapping, which no CSS property removes. If he reports a narrow
   text block again, look for a `max-width` or a grid column, not for this.

   `balance` stays on headings. Headings are a few words, balancing them is the
   point, and there is no body copy underneath for them to starve. */


.lede {
  font-size: clamp(1.1rem, 2.2vw, 1.35rem);
  line-height: 1.5;
  color: var(--ink-2);
  max-width: 46ch;
  font-weight: 400;
}
.body { max-width: var(--measure); color: var(--ink-2); }

/* Landing page: text runs the full width of the shell.
   The client asked for this directly, on every section and on mobile too. It
   overrides the 66ch measure and the per-element caps that used to sit inline on
   each heading. The shell still holds the page margins, so "full width" means
   margin to margin, not edge to edge.

   Two deliberate exceptions below: the closing band and the contact band are
   centred, and centred text set the full width of a wide screen is genuinely
   hard to read, so those keep a measure. */
.page-landing .body,
.page-landing .lede,
.page-landing h1,
.page-landing h2,
.page-landing p { max-width: none; }

/* The one exception, and it is a real one. The closing band is centred, and
   centred text run the full width of a wide screen is genuinely hard to read:
   the eye loses the start of the next line. It keeps a measure and stays
   centred. Everything left aligned runs full width as asked. */
.page-landing .ink-band .body,
.page-landing .ink-band h2 { max-width: 52ch; margin-inline: auto; }
.muted { color: var(--ink-2); }
.fine { font-size: var(--t-sm); color: var(--ink-3); }

/* The eyebrow label. Uppercase, tracked, small. */
.label {
  font-size: var(--t-xs);
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-3);
  font-variant-numeric: tabular-nums;
}
.label--brand { color: var(--brand); }
/* Alias kept so pages written against the previous system keep their accent
   colour instead of silently falling back to grey. */
.label--accent { color: var(--brand); }

/* Marker highlight. A painted block behind the word, the way you would
   actually highlight a textbook. Deliberately not gradient text. */
.marker {
  background-image: linear-gradient(
    transparent 12%, var(--zest) 12%, var(--zest) 92%, transparent 92%);
  background-repeat: no-repeat;
  padding-inline: 0.12em;
  box-decoration-break: clone;
  -webkit-box-decoration-break: clone;
}

.eyebrow { display: flex; align-items: center; gap: var(--s-3); margin-bottom: var(--s-4); }

.price {
  font-family: var(--display);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.02em;
}

/* --------------------------------------------------------------------------
   4. Layout + bands
   -------------------------------------------------------------------------- */

.shell {
  width: 100%;
  max-width: var(--shell);
  margin-inline: auto;
  padding-inline: var(--s-4);
}
@media (min-width: 48rem) { .shell { padding-inline: var(--s-6); } }

.band       { padding-block: var(--s-6); }
.band--tall { padding-block: var(--s-7); }
.band--hero { padding-block: var(--s-7) var(--s-6); }
.band--xl   { padding-block: var(--s-7); }
@media (min-width: 48rem) {
  .band--tall { padding-block: var(--s-8); }
  .band--hero { padding-block: var(--s-8) var(--s-7); }
  .band--xl   { padding-block: var(--s-9); }
}

.stack > * + * { margin-top: var(--s-4); }
.stack-sm > * + * { margin-top: var(--s-2); }
.stack-lg > * + * { margin-top: var(--s-5); }

.row { display: flex; align-items: center; gap: var(--s-3); flex-wrap: wrap; }
.row--between { justify-content: space-between; }
.spacer { flex: 1; }
.rule { height: 1px; background: var(--line); border: 0; }

.split { display: grid; gap: var(--s-6); }
@media (min-width: 56rem) {
  .split { grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: var(--s-8); align-items: center; }
  .split--wide { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
  .split--flip > *:first-child { order: 2; }
}

/* Tinted and inverted bands */
.tint { background: var(--ground-2); }
.ink-band {
  background: var(--ink);
  color: oklch(0.95 0.01 265);
}
.ink-band h1, .ink-band h2, .ink-band h3 { color: oklch(0.99 0.005 265); }
.ink-band .label { color: var(--zest-lift); }
.ink-band p, .ink-band .body { color: oklch(0.86 0.015 265); }
/* Without these the secondary button inherits a dark border and dark text on
   a dark band, which renders as an invisible rectangle. */
.ink-band .btn--quiet {
  --btn-bd: oklch(1 0 0 / 0.38);
  --btn-fg: oklch(0.96 0.01 265);
}
.ink-band .btn--quiet:hover:not(:disabled) {
  --btn-bg: oklch(1 0 0 / 0.12);
  --btn-bd: oklch(1 0 0 / 0.6);
}
.ink-band .btn--primary { --btn-bg: var(--zest); --btn-fg: var(--ink); --btn-bd: var(--zest); }
.ink-band .btn--primary:hover:not(:disabled) { --btn-bg: var(--zest-deep); --btn-bd: var(--zest-deep); }

.brand-band {
  background: var(--brand);
  color: oklch(0.97 0.01 264);
}
.brand-band h1, .brand-band h2, .brand-band h3 { color: oklch(0.995 0.004 264); }
.brand-band .label { color: var(--zest-lift); }
.brand-band p, .brand-band .body { color: oklch(0.90 0.03 264); }
/* The standfirst under the band's eyebrow. It says what the numbers are and how
   many learners they cover, so it sits between the eyebrow and the figures and
   runs the full width rather than taking the 66ch measure. Slightly brighter than
   the stat labels, because it qualifies all four of them. */
/* Same size as the stat labels it introduces, which is what he asked for. It was a
   step larger, which made it compete with the figures instead of setting them up. */
.band-note {
  margin-top: var(--s-3);
  margin-bottom: var(--s-6);
  font-size: var(--t-sm);
  line-height: 1.55;
}
.brand-band .band-note { color: oklch(0.94 0.02 264); }

.brand-band .btn--primary {
  --btn-bg: var(--zest); --btn-fg: var(--ink); --btn-bd: var(--zest);
}
.brand-band .btn--primary:hover:not(:disabled) { --btn-bg: var(--zest-deep); --btn-bd: var(--zest-deep); }
.brand-band .btn--quiet { --btn-bd: oklch(1 0 0 / 0.4); --btn-fg: oklch(0.97 0.01 264); }
.brand-band .btn--quiet:hover:not(:disabled) { --btn-bg: oklch(1 0 0 / 0.12); }

/* --------------------------------------------------------------------------
   5. Header + footer
   -------------------------------------------------------------------------- */

.site-head {
  position: sticky; top: 0; z-index: var(--z-header);
  background: color-mix(in oklch, var(--ground) 88%, transparent);
  backdrop-filter: saturate(1.4) blur(12px);
  border-bottom: 1px solid var(--line);
}
.site-head__inner { display: flex; align-items: center; gap: var(--s-4); min-height: 4.5rem; }

.wordmark {
  display: inline-flex; align-items: center; gap: 0.55rem;
  min-width: 0; overflow: hidden; padding-block: 0.5rem;
  font-family: var(--display);
  font-size: 1.08rem; font-weight: 700; letter-spacing: -0.03em;
  color: var(--ink); text-decoration: none; white-space: nowrap;
}
.wordmark__name { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.wordmark__sub {
  display: none; flex: none;
  font-family: var(--sans); font-size: var(--t-xs); font-weight: 600;
  letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-3);
}
@media (min-width: 48rem) { .wordmark__sub { display: inline; } }

/* The mark is painted from tokens, so it follows the palette automatically. */
/* The mark: a house over an open book, which is the client's own logo concept
   rebuilt to survive small sizes. His original had thin outlines, a four pane
   window and a graduation cap floating off the roof, none of which reads below
   about 32px, plus a second cyan blue that is not in this palette.

   Painted from tokens so it follows the theme. A standalone favicon file cannot
   read custom properties, so assets/favicon.svg repeats these colours as hex and
   has to be updated by hand if the palette moves. */
/* His own logo, as an <img>. object-fit: contain so it is never stretched, and
   the border-radius matches what the old plate had. */
/* 3.25rem, not 1.85rem. His logo is the complete lockup, so roughly two thirds of
   the image is the tagline and the name and only the middle third is the crest.
   At the old size the crest was about 12px tall and read as a smudge. The header is
   78px, so this still clears it top and bottom. */
.wordmark__mark { flex: none; width: 3.25rem; height: 3.25rem; object-fit: contain; }
.wordmark__mark .tile { fill: var(--brand); }
/* Marigold, so the roof and the pages separate even in a 16px browser tab. With
   both in cream the two shapes merged into one blob. */
/* The three-line mark. The strokes take the ground colour and the bar takes the
   accent, which is where the brand yellow shows up in the icon. The parent sets
   stroke: none, and that is overridden here because the bar IS a stroke, not a
   fill. */
.wordmark__mark .stroke { fill: var(--ground); }
.wordmark__mark .bar { stroke: var(--zest); fill: none; }

.nav { display: flex; align-items: center; gap: var(--s-1); margin-left: auto; flex: none; }
.nav a, .nav button {
  display: inline-flex; align-items: center; gap: var(--s-2);
  white-space: nowrap; min-height: 44px; padding-inline: var(--s-3);
  font-family: var(--sans); font-size: var(--t-sm); font-weight: 600;
  color: var(--ink-2); text-decoration: none;
  background: none; border: 0; border-radius: var(--r-pill); cursor: pointer;
  transition: color var(--dur-1) var(--ease), background-color var(--dur-1) var(--ease);
}
.nav a:hover, .nav button:hover { color: var(--ink); background: var(--ground-2); }
.nav a[aria-current="page"] { color: var(--brand); background: var(--brand-soft); }
.nav svg { width: 17px; height: 17px; flex: none; }

/* WhatsApp shortcut. Portal only, never on the public nav. Always icon-only,
   including on desktop, because a fifth labelled item overflows the phone
   header and the glyph is recognised without help.
   Green is WhatsApp's darker brand tone rather than the bright #25D366:
   white on the bright one is about 1.9:1, which fails non-text contrast. */
.nav .nav__wa {
  background: #128C7E;
  color: #fff;
  min-width: 44px;
  justify-content: center;
  padding-inline: var(--s-3);
  margin-left: var(--s-1);
}
.nav .nav__wa:hover { background: #0f7568; color: #fff; }
.nav .nav__wa svg { width: 19px; height: 19px; }

/* The tutor's "view as learner" switch. Marked out from the navigation items
   around it, because it changes what the whole portal shows rather than moving
   between pages. Staff only: a learner never sees it. */
.nav .nav__switch { color: var(--brand); background: var(--brand-soft); }
.nav .nav__switch:hover { color: var(--brand-hi); background: var(--brand-soft); }

/* Portal menu.
   On a laptop .nav__items is just a row and the toggle does not exist. On a
   phone the row becomes a drop-down panel, which is what lets every item keep
   its label instead of collapsing to six unlabelled glyphs. */
.nav__items { display: flex; align-items: center; gap: var(--s-1); }
/* Scoped under .nav deliberately. A bare `.nav__toggle` is specificity 0,1,0 and
   loses to `.nav button` at 0,1,1 above, so the button stayed visible on
   desktop. Both rules for it need the extra class. */
.nav .nav__toggle { display: none; }

.nav-short { display: none; }
@media (max-width: 39.999rem) {
  .nav-long { display: none; }
  .nav-short { display: inline; }

  /* The public nav keeps its old behaviour: two items, no menu needed. */
  .nav:not(:has(.nav__items)) .nav-label { display: none; }
  .nav:not(:has(.nav__items)) a,
  .nav:not(:has(.nav__items)) button { padding-inline: var(--s-2); }

  .nav .nav__toggle { display: inline-flex; }

  .nav__items {
    position: absolute; top: calc(100% - var(--s-2)); right: var(--s-4);
    z-index: var(--z-fab);
    display: none;
    flex-direction: column; align-items: stretch;
    min-width: 13rem; padding: var(--s-2);
    background: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--r-lg);
    box-shadow: var(--lift-3);
  }
  .nav--open .nav__items { display: flex; }

  /* Labels come back inside the panel, which is the whole point of having it. */
  .nav__items .nav-label { display: inline; }
  .nav__items a,
  .nav__items button {
    justify-content: flex-start;
    width: 100%; padding-inline: var(--s-3);
    border-radius: var(--r-md);
  }

  /* The header is the positioning context for the panel. */
  .site-head__inner { position: relative; }
}

@media (prefers-reduced-motion: no-preference) {
  .nav--open .nav__items { animation: nav-in var(--dur-2) var(--ease) both; }
  @keyframes nav-in { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
}

.site-foot {
  margin-top: var(--s-8);
  border-top: 1px solid var(--line);
  padding-block: var(--s-5);
  font-size: var(--t-sm); color: var(--ink-3);
}
.site-foot .shell { display: flex; flex-wrap: wrap; gap: var(--s-3) var(--s-5); align-items: center; }
.site-foot a { color: var(--ink-2); text-underline-offset: 3px; }
.site-foot a:hover { color: var(--brand); }

/* --------------------------------------------------------------------------
   6. Buttons
   -------------------------------------------------------------------------- */

.btn {
  --btn-bg: transparent; --btn-fg: var(--ink); --btn-bd: var(--line-firm);
  display: inline-flex; align-items: center; justify-content: center; gap: var(--s-2);
  min-height: 48px; padding: 0.7rem var(--s-5);
  font-family: var(--sans); font-size: var(--t-sm); font-weight: 700;
  letter-spacing: -0.005em; line-height: 1.2;
  color: var(--btn-fg); background: var(--btn-bg);
  border: 1.5px solid var(--btn-bd); border-radius: var(--r-pill);
  cursor: pointer; text-decoration: none;
  transition: background-color var(--dur-1) var(--ease),
              border-color var(--dur-1) var(--ease),
              color var(--dur-1) var(--ease),
              transform var(--dur-1) var(--ease),
              box-shadow var(--dur-1) var(--ease);
}
.btn svg { width: 18px; height: 18px; flex: none; }
.btn:hover:not(:disabled) { transform: translateY(-1px); }
.btn:active:not(:disabled) { transform: translateY(0); }

.btn--primary {
  --btn-bg: var(--brand); --btn-fg: oklch(0.99 0.005 264); --btn-bd: var(--brand);
  box-shadow: var(--lift-1);
}
.btn--primary:hover:not(:disabled) { --btn-bg: var(--brand-hi); --btn-bd: var(--brand-hi); box-shadow: var(--lift-2); }

.btn--zest { --btn-bg: var(--zest); --btn-fg: var(--ink); --btn-bd: var(--zest); box-shadow: var(--lift-1); }
.btn--zest:hover:not(:disabled) { --btn-bg: var(--zest-deep); --btn-bd: var(--zest-deep); }

.btn--ink { --btn-bg: var(--ink); --btn-fg: var(--ground); --btn-bd: var(--ink); }
.btn--quiet { --btn-bd: var(--line-firm); --btn-fg: var(--ink); }
.btn--quiet:hover:not(:disabled) { --btn-bg: var(--surface); --btn-bd: var(--ink-3); box-shadow: var(--lift-1); }
.btn--bare { --btn-bd: transparent; --btn-fg: var(--ink-2); padding-inline: var(--s-3); }
.btn--bare:hover:not(:disabled) { --btn-fg: var(--brand); --btn-bg: var(--brand-soft); }
.btn--danger { --btn-bd: var(--line-firm); --btn-fg: var(--warn); }
.btn--danger:hover:not(:disabled) { --btn-bg: var(--warn); --btn-fg: var(--ground); --btn-bd: var(--warn); }

.btn--wide { width: 100%; }
.btn--sm { min-height: 40px; padding: 0.4rem var(--s-4); font-size: var(--t-xs); }
.btn--lg { min-height: 56px; padding: 0.9rem var(--s-6); font-size: var(--t-base); }

.btn:disabled, .btn[aria-disabled="true"] { opacity: 0.5; cursor: not-allowed; transform: none; }
.btn[data-busy="true"] { color: transparent; position: relative; pointer-events: none; }
.btn[data-busy="true"]::after {
  content: ""; position: absolute; inset: 50% auto auto 50%;
  width: 17px; height: 17px; margin: -8.5px 0 0 -8.5px;
  border: 2px solid currentColor; border-top-color: transparent;
  border-radius: 50%; color: var(--btn-fg);
  animation: spin 600ms linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

/* --------------------------------------------------------------------------
   7. Forms
   -------------------------------------------------------------------------- */

.field { display: block; }
.field + .field { margin-top: var(--s-4); }
.field > label, .field-label {
  display: block; font-size: var(--t-sm); font-weight: 600;
  color: var(--ink); margin-bottom: var(--s-2);
}

.input, .select, .textarea {
  width: 100%; min-height: 50px; padding: 0.7rem var(--s-4);
  font-family: var(--sans); font-size: var(--t-base); line-height: 1.45;
  color: var(--ink); background: var(--surface);
  border: 1.5px solid var(--line-firm); border-radius: var(--r-md);
  transition: border-color var(--dur-1) var(--ease), box-shadow var(--dur-1) var(--ease);
}
.textarea { min-height: 7rem; resize: vertical; }
.input:hover, .select:hover, .textarea:hover { border-color: var(--ink-3); }
.input:focus, .select:focus, .textarea:focus {
  border-color: var(--brand);
  box-shadow: 0 0 0 4px var(--brand-soft);
  outline: none;
}
.input::placeholder, .textarea::placeholder { color: var(--ink-3); }
.input[aria-invalid="true"], .textarea[aria-invalid="true"] { border-color: var(--warn); }

.select {
  appearance: none; padding-right: var(--s-7);
  background-image: linear-gradient(45deg, transparent 50%, var(--ink-2) 50%),
                    linear-gradient(135deg, var(--ink-2) 50%, transparent 50%);
  background-position: right 1.25rem center, right 0.95rem center;
  background-size: 6px 6px, 6px 6px; background-repeat: no-repeat;
}

.field-hint { font-size: var(--t-sm); color: var(--ink-3); margin-top: var(--s-2); }
.field-error {
  display: flex; align-items: flex-start; gap: var(--s-2);
  font-size: var(--t-sm); color: var(--warn); margin-top: var(--s-2); font-weight: 500;
}
.field-error svg { width: 16px; height: 16px; flex: none; margin-top: 0.15em; }
.field-error:empty { display: none; }

.input-prefix { position: relative; }
.input-prefix .input { padding-left: 2.1rem; }
.input-prefix__mark {
  position: absolute; left: var(--s-4); top: 50%; transform: translateY(-50%);
  color: var(--ink-3); pointer-events: none;
}
fieldset { border: 0; padding: 0; }

.segmented {
  display: grid; grid-template-columns: 1fr 1fr; gap: 3px;
  padding: 4px; background: var(--ground-2);
  border: 1px solid var(--line); border-radius: var(--r-pill);
}
.segmented button {
  min-height: 42px; font-family: var(--sans); font-size: var(--t-sm); font-weight: 700;
  color: var(--ink-2); background: transparent; border: 0;
  border-radius: var(--r-pill); cursor: pointer;
  transition: background-color var(--dur-1) var(--ease), color var(--dur-1) var(--ease);
}
.segmented button:hover { color: var(--ink); }
.segmented button[aria-selected="true"] {
  background: var(--surface); color: var(--brand); box-shadow: var(--lift-1);
}

/* --------------------------------------------------------------------------
   8. Cards
   -------------------------------------------------------------------------- */

.cards {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--s-5);
}
@media (min-width: 44rem) { .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (min-width: 64rem) { .cards--3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } }

/* Exactly four cards in a three-across grid leaves one stranded on its own
   row, which reads as a mistake. Pair them instead.

   This is not only tidier, it is how the catalogue is actually shaped: two
   grades, each with a year-long programme and a Boot Camp. Two by two puts
   each grade on its own row, so a learner finds their grade and then chooses
   how much they want.

   A browser without :has() keeps the old three-plus-one. Ugly, not broken. */
@media (min-width: 64rem) {
  .cards--3:has(> :nth-child(4):last-child) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

/* The programme card: image, title, paragraph, action. Blog-post shaped,
   because that is a format teenagers already know how to read. */
.pcard {
  position: relative;
  display: flex; flex-direction: column;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  overflow: hidden;
  box-shadow: var(--lift-1);
  transition: transform var(--dur-2) var(--ease), box-shadow var(--dur-2) var(--ease);
}
.pcard:hover { transform: translateY(-3px); box-shadow: var(--lift-3); }

.pcard__media { position: relative; aspect-ratio: 16 / 10; background: var(--ground-2); overflow: hidden; }
.pcard__media img { width: 100%; height: 100%; object-fit: cover; transition: transform var(--dur-3) var(--ease); }
.pcard:hover .pcard__media img { transform: scale(1.04); }

/* No image yet? A branded pattern, never a grey box. */
.pcard__media--empty {
  background:
    repeating-linear-gradient(135deg,
      var(--brand) 0 22px, var(--brand-hi) 22px 44px);
  display: flex; align-items: center; justify-content: center;
}
.pcard__media--empty svg { width: 42px; height: 42px; color: oklch(1 0 0 / 0.75); }

.pcard__body { padding: var(--s-5); display: flex; flex-direction: column; flex: 1; gap: var(--s-3); }
.pcard__title { font-size: var(--t-lg); line-height: 1.15; }
.pcard__title a { text-decoration: none; }
.pcard__title a::after { content: ""; position: absolute; inset: 0; }
.pcard__text { font-size: var(--t-sm); color: var(--ink-2); line-height: 1.55; }
.pcard__foot { margin-top: auto; padding-top: var(--s-2); position: relative; z-index: 1; }
.pcard__meta { display: flex; flex-wrap: wrap; gap: var(--s-2) var(--s-3); align-items: center; }

/* What you get, on a programme card. Replaces the paragraph that used to sit
   here: a learner comparing two products scans a list and does not read prose,
   which is the whole point of the section this lives in.

   The tick is a CSS shape rather than an icon or a bullet character. It has to
   line up with the first line of text at every wrap, and an inline glyph rides
   the baseline and drifts as soon as an item runs to two lines. */
/* padding-left explicitly zeroed. The UA default indents a ul by about 40px, and
   with list-style removed that indent is invisible but still there, so the list
   sat inset from the card title for no reason. */
.plist {
  list-style: none; margin: 0; padding: 0;
  display: flex; flex-direction: column; gap: var(--s-2);
}
.plist__item {
  display: grid;
  grid-template-columns: 1rem 1fr;
  gap: var(--s-3);
  align-items: start;
  font-size: var(--t-sm);
  line-height: 1.45;
  color: var(--ink);
  font-weight: 500;
}
.plist__mark {
  /* Nudged down so the tick sits on the middle of the first line, not its top. */
  margin-top: 0.28em;
  width: 1rem; height: 1rem;
  border-radius: 50%;
  background: var(--brand-soft);
  position: relative;
}
.plist__mark::after {
  content: "";
  position: absolute;
  left: 0.3rem; top: 0.28rem;
  width: 0.28rem; height: 0.5rem;
  border-right: 2px solid var(--brand);
  border-bottom: 2px solid var(--brand);
  transform: rotate(45deg);
}

/* The last line of each list is what the OTHER programme does not include. It is
   a caveat, not a feature, so it loses the tick, sits above a hairline and is set
   apart. Ticking "Boot Camps Not Included" would read as a benefit. */
.plist__item--out {
  margin-top: var(--s-2);
  padding-top: var(--s-3);
  border-top: 1px solid var(--line);
  color: var(--ink-3);
  font-weight: 500;
  font-style: italic;
}
.plist__item--out .plist__mark { background: none; }
.plist__item--out .plist__mark::after {
  /* A minus, in place of the tick. */
  left: 0.15rem; top: 0.45rem;
  width: 0.7rem; height: 0;
  border-right: 0; border-bottom: 2px solid var(--ink-3);
  transform: none;
}

/* --------------------------------------------------------------------------
   9. Stickers, chips, badges
   -------------------------------------------------------------------------- */

/* The sticker breaks the grid on purpose: rotated, overlapping, loud. It is
   the single most "not a document" element in the system. */
.sticker {
  position: absolute; z-index: 2;
  display: inline-flex; align-items: center; gap: 0.35rem;
  padding: 0.45rem 0.85rem;
  background: var(--zest); color: var(--ink);
  font-family: var(--display); font-size: var(--t-sm); font-weight: 700;
  letter-spacing: -0.01em;
  border-radius: var(--r-pill);
  box-shadow: var(--lift-2);
  transform: rotate(-3deg);
}
/* Straight, not tilted. This one carries a price, and a price set at an angle
   reads as a sticker slapped on rather than as the number you are being asked for.
   It inherits `rotate(-3deg)` from .sticker, so the tilt has to be cancelled here
   rather than just left out. */
.sticker--tr { top: var(--s-4); right: var(--s-4); transform: none; }
.sticker--br { bottom: calc(var(--s-4) * -1); right: var(--s-4); transform: rotate(2deg); }
/* Opt out of the tilt. The rotation is deliberate on the hero, where the sticker
   overlaps a photograph and the angle keeps it from looking pasted on. Against
   the founder's portrait it just read as crooked, which is what he called out. */
.sticker--level { transform: none; }
/* Out to the photograph's right edge, per his note. The generic --br offset sits
   the sticker a step inside the corner, which on the founder's portrait left it
   floating in the middle of the bottom edge rather than anchored to it. */
.tutor-shot .sticker--br { right: 0; }
.sticker--tl { top: var(--s-4); left: var(--s-4); transform: rotate(2deg); }
.sticker--mint { background: var(--fresh); }

.chip {
  display: inline-flex; align-items: center; gap: 0.4rem;
  padding: 0.28rem 0.7rem;
  font-size: var(--t-xs); font-weight: 700; letter-spacing: 0.04em;
  border-radius: var(--r-pill);
  border: 1px solid transparent;
  background: var(--ground-2); color: var(--ink-2);
  white-space: nowrap;
}
.chip--paid    { background: var(--fresh); color: var(--fresh-ink); }
.chip--pending { background: var(--warn-soft); color: var(--warn); }
.chip--failed  { background: var(--warn-soft); color: var(--warn); }
.chip--brand   { background: var(--brand-soft); color: var(--brand-ink); }
.chip--type    { background: var(--ground-2); color: var(--ink-3); }

/* --------------------------------------------------------------------------
   10. Lists (register)
   -------------------------------------------------------------------------- */

.register { display: grid; gap: var(--s-3); }

.reg-item {
  position: relative;
  display: grid;
  grid-template-columns: 2.75rem minmax(0, 1fr);
  gap: 0 var(--s-4);
  padding: var(--s-4);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  box-shadow: var(--lift-1);
  transition: transform var(--dur-1) var(--ease), box-shadow var(--dur-1) var(--ease);
}
.reg-item:hover { transform: translateY(-2px); box-shadow: var(--lift-2); }

.reg-item__num {
  grid-row: 1 / span 2;
  display: flex; align-items: center; justify-content: center;
  width: 2.5rem; height: 2.5rem;
  background: var(--brand-soft); color: var(--brand);
  border-radius: var(--r-md);
  font-family: var(--display); font-weight: 700; font-size: var(--t-sm);
}
.folio { font-variant-numeric: tabular-nums; }

.reg-item__head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-2) var(--s-3); }
.reg-item__title { font-family: var(--display); font-size: var(--t-lg); font-weight: 700; letter-spacing: -0.02em; line-height: 1.2; }
a.reg-item__title { text-decoration: none; }
a.reg-item__title::after { content: ""; position: absolute; inset: 0; }
a.reg-item__title:hover { color: var(--brand); }

.reg-item__meta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-2) var(--s-3); margin-top: var(--s-2); }
.reg-item__body { color: var(--ink-2); font-size: var(--t-sm); margin-top: var(--s-2); max-width: var(--measure); }
.reg-item__actions { display: flex; flex-wrap: wrap; gap: var(--s-2); margin-top: var(--s-3); position: relative; z-index: 1; }

@media (min-width: 40rem) {
  .reg-item { grid-template-columns: 2.75rem minmax(0, 1fr) auto; align-items: center; }
  .reg-item__actions { margin-top: 0; grid-column: 3; grid-row: 1 / span 3; }
}

.reg-item--locked { background: var(--ground-2); box-shadow: none; }
.reg-item--locked .reg-item__num { background: var(--ground); color: var(--ink-3); }
.reg-item--locked:hover { transform: none; box-shadow: var(--lift-1); }

/* --------------------------------------------------------------------------
   10b. Lessons

   A lesson is a block, not a register row, and that is the point. A row is a
   thing in a list you scan; a lesson is a thing you sit down with. It gets its
   own surface, the recording at the top, and the notes nested inside it, so a
   learner never has to work out which PDF belongs to which video.

   Deliberately NOT hover-lifted like .reg-item. The whole block is not a
   single click target, so implying it is would be a lie.
   -------------------------------------------------------------------------- */

.lesson {
  padding: var(--s-5);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  box-shadow: var(--lift-1);
}

.lesson + .lesson { margin-top: var(--s-5); }

.lesson__head {
  display: grid;
  grid-template-columns: 2.75rem minmax(0, 1fr);
  gap: 0 var(--s-4);
  align-items: start;
}
.lesson__head > .folio {
  display: flex; align-items: center; justify-content: center;
  width: 2.5rem; height: 2.5rem;
  background: var(--brand-soft); color: var(--brand);
  border-radius: var(--r-md);
  font-family: var(--display); font-weight: 700; font-size: var(--t-sm);
}

.lesson__title {
  font-family: var(--display); font-size: var(--t-lg); font-weight: 700;
  letter-spacing: -0.02em; line-height: 1.2;
}

/* The objective. Sits directly under the title because it is the reason to
   press play, and it is the one line a learner should read first. */
.lesson__aim {
  color: var(--ink-2); font-size: var(--t-sm);
  margin-top: var(--s-2); max-width: var(--measure);
}
.lesson__aim .label { color: var(--brand); }

.lesson__player { margin-top: var(--s-4); display: grid; gap: var(--s-3); }

/* A lesson with notes up but no recording yet. Stated plainly rather than
   leaving a learner hunting for a player that is not there. */
.lesson__pending {
  display: flex; align-items: center; gap: var(--s-2);
  margin-top: var(--s-4); padding: var(--s-3) var(--s-4);
  background: var(--ground-2);
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  color: var(--ink-2); font-size: var(--t-sm);
}
/* Explicit width and height, like every other icon rule here. An inline SVG with
   no dimensions falls back to 300px wide, and flex: none then holds it there. */
.lesson__pending svg { width: 18px; height: 18px; flex: none; color: var(--ink-3); }

/* Notes belonging to this lesson. Inset and flush so they read as part of the
   lesson rather than as a second list that happens to sit nearby. */
.lesson__notes { margin-top: var(--s-4); }
.lesson__notes .reg-item {
  background: var(--ground-2);
  box-shadow: none;
  padding: var(--s-3) var(--s-4);
}
.lesson__notes .reg-item:hover { transform: none; box-shadow: var(--lift-1); }
.lesson__notes .reg-item__title { font-size: var(--t-base); }

/* --------------------------------------------------------------------------
   11. Panels, plates, notices
   -------------------------------------------------------------------------- */

.panel {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  padding: var(--s-5);
  box-shadow: var(--lift-1);
}
.panel--tight { padding: var(--s-4); }
.panel--flush { background: transparent; box-shadow: none; }

.plate {
  width: 100%; max-width: 27rem; margin-inline: auto;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-xl);
  box-shadow: var(--lift-3);
  padding: var(--s-5);
}
@media (min-width: 40rem) { .plate { padding: var(--s-6); } }
.plate--wide { max-width: 34rem; }
.plate__head { margin-bottom: var(--s-5); }
.plate__head h1 { font-size: var(--t-xl); }
.plate__head p { color: var(--ink-2); font-size: var(--t-sm); margin-top: var(--s-2); }

.notice {
  display: flex; gap: var(--s-3);
  padding: var(--s-4);
  border: 1px solid var(--line); border-radius: var(--r-md);
  background: var(--ground-2);
  font-size: var(--t-sm); color: var(--ink-2);
}
.notice svg { width: 19px; height: 19px; flex: none; margin-top: 0.1em; }
.notice strong { color: var(--ink); font-weight: 700; }
.notice--warn  { background: var(--warn-soft); border-color: color-mix(in oklch, var(--warn) 30%, var(--line)); }
.notice--brand { background: var(--brand-soft); border-color: color-mix(in oklch, var(--brand) 22%, var(--line)); }
.notice--warn svg  { color: var(--warn); }
.notice--brand svg { color: var(--brand); }

.masthead { padding-bottom: var(--s-4); }
.masthead__meta { margin-top: var(--s-4); display: flex; flex-wrap: wrap; gap: var(--s-2) var(--s-4); }

/* Page headers run their standfirst to the right margin, the same call the
   client made for the landing page bands. Applies to every page built on this
   header shape: eyebrow, h1, one paragraph, meta line.

   Two utilities are used for that paragraph and both are covered: `.body` on
   tutor, browse and programme, `.lede` on dashboard and start.

   Scoped to direct children of `.band--hero` on purpose. `.body` and `.lede` are
   site-wide utilities, and widening them outright would stretch every paragraph
   in the portal, including the ones whose measure is deliberate.

   This beats `.greeting__note`'s 42ch on specificity, two classes against one,
   so the dashboard note widens too without needing !important. Headings keep
   their own max-width: he asked about the description, not the title. */
.band--hero > .body,
.band--hero > .lede { max-width: none; }

/* --------------------------------------------------------------------------
   12. Empty states
   -------------------------------------------------------------------------- */

.empty {
  padding: var(--s-7) var(--s-5);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  box-shadow: var(--lift-2);
  text-align: center;
  color: var(--ink-2);
}
.empty__icon {
  display: inline-flex; align-items: center; justify-content: center;
  width: 3.5rem; height: 3.5rem; margin-bottom: var(--s-4);
  background: var(--brand-soft); color: var(--brand);
  border-radius: var(--r-lg);
}
.empty__icon svg { width: 26px; height: 26px; }
.empty h3 { font-size: var(--t-xl); color: var(--ink); }
.empty p { margin-top: var(--s-3); font-size: var(--t-base); line-height: 1.6; max-width: 42ch; margin-inline: auto; }
.empty .btn { margin-top: var(--s-5); }

/* --------------------------------------------------------------------------
   13. Toasts, skeletons, dialog
   -------------------------------------------------------------------------- */

.toasts {
  position: fixed; left: var(--s-4); right: var(--s-4); bottom: var(--s-4);
  z-index: var(--z-toast); display: flex; flex-direction: column; gap: var(--s-2);
  pointer-events: none;
}
@media (min-width: 40rem) { .toasts { left: auto; width: min(25rem, calc(100vw - 3rem)); } }

.toast {
  display: flex; align-items: flex-start; gap: var(--s-3);
  padding: var(--s-4);
  background: var(--ink); color: oklch(0.97 0.01 265);
  border-radius: var(--r-md);
  font-size: var(--t-sm); line-height: 1.45;
  box-shadow: var(--lift-3);
  pointer-events: auto;
  animation: toast-in var(--dur-2) var(--ease) both;
}
.toast svg { width: 18px; height: 18px; flex: none; margin-top: 0.1em; }
.toast--error svg { color: oklch(0.78 0.15 45); }
.toast[data-leaving="true"] { animation: toast-out var(--dur-1) var(--ease) both; }
@keyframes toast-in  { from { opacity: 0; transform: translateY(12px) scale(0.98); } to { opacity: 1; transform: none; } }
@keyframes toast-out { to { opacity: 0; transform: translateY(8px); } }

.skeleton { background: var(--ground-2); border-radius: var(--r-sm); animation: pulse 1.5s var(--ease) infinite; }
.skeleton--line  { height: 1rem; }
.skeleton--title { height: 1.6rem; max-width: 18rem; }
.skeleton--meta  { height: 0.8rem; max-width: 11rem; }
.skeleton--media { aspect-ratio: 16 / 10; border-radius: var(--r-lg); }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }

.guard-loading { min-height: 60dvh; align-items: center; justify-content: center; color: var(--ink-3); }
.spinner {
  width: 22px; height: 22px;
  border: 2.5px solid var(--brand-soft); border-top-color: var(--brand);
  border-radius: 50%; animation: spin 620ms linear infinite;
}

.progress { height: 5px; background: var(--ground-2); border-radius: var(--r-pill); overflow: hidden; }
.progress__bar { height: 100%; width: 0%; background: var(--brand); transition: width var(--dur-2) var(--ease); }
.progress__bar[data-indeterminate="true"] { width: 40%; animation: progress-sweep 1.1s var(--ease) infinite; }
@keyframes progress-sweep { 0% { transform: translateX(-100%); } 100% { transform: translateX(250%); } }

dialog.confirm {
  width: min(26rem, calc(100vw - 2rem));
  padding: var(--s-5);
  border: 1px solid var(--line); border-radius: var(--r-xl);
  background: var(--surface); color: var(--ink);
  box-shadow: var(--lift-3);
}
dialog.confirm::backdrop { background: oklch(0.23 0.035 265 / 0.45); backdrop-filter: blur(3px); }
dialog.confirm h2 { font-size: var(--t-lg); }
dialog.confirm p { color: var(--ink-2); font-size: var(--t-sm); margin-top: var(--s-2); }
dialog.confirm .row { margin-top: var(--s-5); justify-content: flex-end; }
dialog.confirm[open] { animation: dialog-in var(--dur-2) var(--ease) both; }
@keyframes dialog-in { from { opacity: 0; transform: translateY(10px) scale(0.98); } to { opacity: 1; transform: none; } }

/* --------------------------------------------------------------------------
   14. Landing components
   -------------------------------------------------------------------------- */

/* --- Hero -----------------------------------------------------------------

   The learner photograph full bleed, the copy centred over it. This is the
   layout Nkanyiso asked for, so the job here is making centred text on a
   photograph legible rather than arguing with the composition.

   Three things do that work:

   1. A two-stop scrim, not a flat wash. A single dark overlay strong enough for
      the small text kills the photograph. A vertical gradient that is heaviest
      where the type sits and lightest at the edges keeps the room visible while
      still measuring past AA everywhere there is text.
   2. `text-shadow` as the backstop. The photograph is a real room with a lamp in
      it, so brightness varies behind the copy no matter what the scrim does.
   3. A measure on the copy. Centred lines running the full shell width are
      unreadable past about 60 characters, and the client's own mock has the text
      well inside the edges.

   The band is `position: relative` with an absolutely positioned media layer
   rather than a `background-image` on the section, so the scrim can be its own
   element and the whole thing composites in one paint. */

.hero {
  position: relative;
  isolation: isolate;
  display: grid;
  place-items: center;
  text-align: center;
  padding-block: var(--s-8);
  background: var(--ink);
  color: var(--ground);
  overflow: hidden;
}

.hero__media {
  position: absolute;
  inset: 0;
  z-index: -1;
  background-image:
    /* Heaviest through the middle third, where every line of type sits. */
    linear-gradient(
      180deg,
      oklch(0.19 0.03 265 / 0.62) 0%,
      oklch(0.19 0.03 265 / 0.78) 42%,
      oklch(0.19 0.03 265 / 0.72) 72%,
      oklch(0.19 0.03 265 / 0.58) 100%
    ),
    /* WebP, 28KB against the JPEG's 77KB, same 1306x730 frame. This is the LCP
       element on mobile, so its weight is the single number that moves the score.
       The JPEG is kept in the repo as the source, unreferenced.

       Preloaded in index.html: a background-image is invisible to the preload
       scanner, so without that the fetch cannot start until the CSS has been
       downloaded and parsed. That serial chain was most of a 9.3s LCP. */
    url("../img/hero-learner.webp");
  background-size: cover, cover;
  /* The learner sits right of centre in a 1306x730 frame. On a phone the crop
     narrows hard, so bias toward her rather than the empty left of the desk. */
  background-position: center, 62% center;
  background-repeat: no-repeat;
}

.hero__inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s-4);
}

.hero__welcome {
  font-family: var(--sans);
  font-size: var(--t-sm);
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  /* Marigold lifted for small type on a dark ground, the same token the labels
     on the cobalt bands use. */
  color: var(--zest-lift);
  margin: 0;
}

.hero .hero__title {
  max-width: 22ch;
  margin: 0;
  color: var(--ground);
  text-wrap: balance;
  text-shadow: 0 2px 18px oklch(0.19 0.03 265 / 0.55);
}

.hero__copy {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  max-width: 58ch;
  margin: 0;
}
.hero__copy p {
  margin: 0;
  font-size: var(--t-base);
  line-height: 1.6;
  color: oklch(0.95 0.008 85);
  text-shadow: 0 1px 12px oklch(0.19 0.03 265 / 0.7);
}
/* His bold stays bold everywhere. Only one phrase carries the marigold: the UCT
   credential, which is the one claim here that is checkable. Colouring all of
   them turned the two lower paragraphs almost entirely yellow on a phone, which
   is far past the accent budget the palette is built on and stops the emphasis
   meaning anything. */
.hero__copy strong { font-weight: 700; color: var(--ground); }
.hero__copy .hero__hl { color: var(--zest-lift); }

/* His first line is the positioning statement, so it leads. */
.hero__lede { font-size: var(--t-lg) !important; line-height: 1.5 !important; }

.hero__actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--s-3);
  margin-top: var(--s-3);
}

/* A button that has to hold its own on a photograph. Solid enough to read at a
   glance, and it fills on hover rather than only shifting its border, because a
   1px border change is invisible over a busy background. */
.btn--onphoto {
  background: oklch(1 0 0 / 0.1);
  color: var(--ground);
  border: 1.5px solid oklch(1 0 0 / 0.55);
  backdrop-filter: blur(6px);
}
.btn--onphoto:hover {
  background: var(--ground);
  color: var(--ink);
  border-color: var(--ground);
}

/* Height is set by the copy plus padding, not by a tall min-height. An earlier
   version reserved most of the viewport, which left a band of empty desk under
   the buttons on a laptop and pushed the results below the fold. */
@media (min-width: 56rem) {
  .hero { padding-block: var(--s-8); }
  .hero__inner { gap: var(--s-5); }
}

/* On a phone the photograph is mostly cropped away and the copy is what matters,
   so lean the scrim darker and stop paying for height that only shows more desk. */
@media (max-width: 34rem) {
  .hero__media {
    background-image:
      linear-gradient(
        180deg,
        oklch(0.19 0.03 265 / 0.74) 0%,
        oklch(0.19 0.03 265 / 0.86) 45%,
        oklch(0.19 0.03 265 / 0.8) 100%
      ),
      url("../img/hero-learner.webp");
  }
}

/* Photo frame. Every image on the site goes through this so a mixed set of
   stock photography still reads as one brand. */
.frame {
  position: relative;
  border-radius: var(--r-xl);
  overflow: hidden;
  background: var(--ground-2);
  box-shadow: var(--lift-3);
}
.frame img { width: 100%; height: 100%; object-fit: cover; }
.frame--tall  { aspect-ratio: 4 / 5; }
.frame--wide  { aspect-ratio: 16 / 10; }
.frame--square{ aspect-ratio: 1; }
/* A whisper of brand over every photo, to tie the set together. */
.frame::after {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  background: linear-gradient(180deg, transparent 55%, oklch(0.23 0.035 265 / 0.22));
  mix-blend-mode: multiply;
}
.frame--plain::after { display: none; }

/* Hero collage: one big frame with a smaller one overlapping it. */
.collage { position: relative; padding-bottom: var(--s-6); padding-right: var(--s-5); }
.collage__main { aspect-ratio: 4 / 3.4; }

/* Phone only: the photograph sits above the copy. This is the ONE hero change
   asked for and kept. Done with `order` rather than by moving the markup, so the
   h1 stays first in the DOM for crawlers and screen readers.

   Everything else about the hero is at its original size, deliberately. Over
   several rounds it was made taller to protect the crop, then shorter to save
   space, then taller again. None of that was wanted. The sizes here and in the
   56rem block above are the originals: do not "improve" them again. */
@media (max-width: 55.999rem) {
  .hero .collage { order: -1; }
}
.collage__inset {
  position: absolute; right: 0; bottom: 0;
  width: 46%; aspect-ratio: 1;
  border: 5px solid var(--ground);
  border-radius: var(--r-lg);
  overflow: hidden;
  box-shadow: var(--lift-3);
  background: var(--ground-2);
}
.collage__inset img { width: 100%; height: 100%; object-fit: cover; }

/* Meet the tutor: portrait with his quote sitting under it rather than at the
   foot of the prose column. With the quote on the left the text column ran
   far past the photo and set the height of the whole section on its own.
   Splitting them lets the two columns finish at roughly the same point.
   The gap is 2rem and the sticker overhangs its box by 1rem, so it clears. */
.tutor-aside { display: grid; gap: var(--s-7); align-content: start; }
/* No padding-right. It was reserving a gutter for the sticker to sit in, which
   held the portrait short of the page margin: on a phone the photograph had a
   visibly wider gap on its right than on its left, which is what he called out.
   The sticker overlaps the photograph's corner instead, which is what a sticker
   is meant to do.

   padding-bottom stays: the sticker hangs below the frame and needs somewhere to
   go, and that gap is symmetrical so it reads as intended rather than as a
   misalignment. */
.tutor-shot { position: relative; padding-bottom: var(--s-6); }
.tutor-say { margin: 0; }
/* One column, so the two-column pairing is gone and reading order is all that
   is left. The quote has to come after the introduction, or a stranger is
   quoted before the page has said who he is. `display: contents` dissolves the
   wrapper so the photo, the story and the quote can be ordered independently.
   The reveal attributes sit on the children for exactly this reason: a
   `display: contents` element cannot be transformed or faded. */
/* The right column: the story, then the button under it. */
.tutor-main { display: grid; gap: var(--s-6); align-content: start; }

/* One column, so the two-column pairing is gone and reading order is all that is
   left. Both wrappers dissolve so their children become siblings in the one grid
   and can be ordered independently.

   The order is his: photograph, the story, then the quote and the byline, and the
   button last. The quote has to come after the introduction or a stranger is
   quoted before the page has said who he is, and the button has to come after the
   quote or it interrupts the two of them.

   The reveal attributes sit on the children for exactly this reason: an element
   with display: contents cannot be transformed or faded. */
@media (max-width: 55.999rem) {
  .tutor-aside,
  .tutor-main { display: contents; }
  .tutor-shot { order: 1; }
  .tutor-bio  { order: 2; }
  .tutor-say  { order: 3; }
  .tutor-cta  { order: 4; }
}
.tutor-say .quote {
  max-width: none;
  font-size: clamp(1.1rem, 2.1vw, 1.4rem);
}
.tutor-say figcaption { margin-top: var(--s-3); }

/* Stat figures for the proof band */
.stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s-5) var(--s-4); }
@media (min-width: 56rem) { .stats { grid-template-columns: repeat(4, minmax(0, 1fr)); } }

/* Subgrid, so the eyebrow, the figure and the label each sit on a shared row
   across all four stats. Two of the four have no eyebrow now, and without this
   their figures rode up level with the other two's eyebrows and the row of
   numbers stopped lining up. `span 3` is the three parts of a stat; a stat with
   no eyebrow simply leaves its first row empty. */
.stat {
  display: grid;
  grid-template-rows: subgrid;
  grid-row: span 3;
  align-content: start;
}
/* Each part is pinned to its subgrid row. Without this, a stat with no eyebrow
   auto-places its figure into row 1, level with the other stats' eyebrows, and the
   row of numbers stops lining up. Pinned, a missing eyebrow just leaves row 1
   empty. */
.stat__was    { grid-row: 1; }
.stat__figure { grid-row: 2; }
.stat__label  { grid-row: 3; }

.stat__was {
  display: block; font-size: var(--t-xs); font-weight: 700;
  letter-spacing: 0.12em; text-transform: uppercase;
  opacity: 0.75; margin-bottom: 0.35rem; font-variant-numeric: tabular-nums;
}
.stat__figure {
  display: block; font-family: var(--display); font-weight: 800;
  font-size: clamp(2.2rem, 5.5vw, 3.1rem); line-height: 1;
  letter-spacing: -0.04em; font-variant-numeric: tabular-nums;
}
.stat__label { margin-top: var(--s-3); font-size: var(--t-sm); line-height: 1.45; max-width: 24ch; opacity: 0.9; }

.ticks { list-style: none; padding: 0; display: grid; gap: var(--s-3); }
.ticks li {
  display: flex; gap: var(--s-3); align-items: flex-start;
  padding: var(--s-4);
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--r-md);
}
.ticks svg { width: 20px; height: 20px; flex: none; color: var(--brand); margin-top: 0.15em; }

.quote {
  font-family: var(--display); font-weight: 600;
  font-size: clamp(1.35rem, 3vw, 1.85rem);
  line-height: 1.25; letter-spacing: -0.025em;
  color: var(--ink); max-width: 26ch;
}
/* Under the founder's portrait the quote is not loose text, it belongs to the
   picture above it, so it runs to the same width. At 26ch it stopped well short
   of the photograph's right edge and read as misaligned. */
.tutor-say .quote { max-width: none; }
.quote::before { content: "\201C"; color: var(--brand); }
.quote::after  { content: "\201D"; color: var(--brand); }

/* --------------------------------------------------------------------------
   Teaching track record

   Mark-forward, unlike the review cards below it. What persuades a parent here
   is the number, so the number leads and the quote supports it. The photo slot
   falls back to initials, because the learners had not sent photographs yet
   and a card that only works with one is a card that cannot ship.
   -------------------------------------------------------------------------- */

/* No card. The reviews below are surfaced boxes with stars and an avatar, which
   is the shape of an opinion. A mark is a fact, so these are set like a results
   sheet: a heavy rule, then a number big enough to read across a room. Nothing
   is enclosed, nothing has a shadow, and there is no rounding anywhere. That is
   what makes it read as a different KIND of proof rather than more reviews. */

.tracks { display: grid; gap: var(--s-6); }

/* Stacked on a phone, so each figure carries its own rule. */
.track { margin: 0; padding-top: var(--s-4); border-top: 1px solid var(--line); }

/* Side by side, the rule belongs to the ROW, not to each column. Three separate
   rules with gaps between them read as three unrelated things; one line reads as
   one set of results, which is what they are. */
@media (min-width: 48rem) {
  .tracks {
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: var(--s-7);
    border-top: 1px solid var(--line);
  }
  .track { border-top: 0; padding-top: var(--s-5); }
}

.track__mark {
  font-family: var(--display); font-weight: 800;
  font-size: clamp(3.6rem, 9vw, 5.4rem); line-height: 0.85;
  letter-spacing: -0.055em; color: var(--brand);
  font-variant-numeric: tabular-nums;
}
.track__pc {
  font-size: 0.38em; letter-spacing: -0.02em;
  margin-left: 0.06em; color: var(--ink-3);
}

/* Stacked text, not a row. The initials avatar that used to sit beside it was
   removed on the client's mock: three coloured circles reading SK, LG and KM were
   a second thing to look at in a row whose job is to show three numbers. */
.track__by { margin-top: var(--s-4); }
.track__by strong { display: block; font-size: var(--t-sm); }
.track__by .fine { display: block; }

/* `.track__note`, the one-line fact under each mark, was removed 2026-08-07.
   The section had already lost its quotes for the same reason: the mark and the
   name are the claim, and everything else added to them was padding. Do not
   reintroduce it without a reason better than "there is room". */

/* --------------------------------------------------------------------------
   The named learner under the marks.

   Sits below the results sheet, not above it, and is not a card. The claim being
   made is about three results; she is the part of it a reader can verify. Making
   her the headline would shift the claim onto her achievements, which are hers.
   -------------------------------------------------------------------------- */

.feat {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: var(--s-4) var(--s-5);
  /* stretch, not start: the photograph matches the height of the text beside it,
     so the text always begins and ends within the picture. A fixed box let the
     source link hang below the frame. */
  align-items: stretch;
  margin-top: var(--s-6);
  padding-top: var(--s-6);
  border-top: 1px solid var(--line);
}
/* Nothing in this block carries a width cap. It used to stop at 62ch, and the
   quote kept a 54ch measure of its own after that, which is what left the right
   third of the shell empty beside it. That was defensible while the quote was 24px
   display type, where a line running 1900px really is unreadable. Now that the
   quote is body size, matching the paragraph below it, the cap was doing nothing
   but reproducing the narrow text block the client called out on page 6. Every
   line in here runs to the margin. */
@media (max-width: 34rem) { .feat { grid-template-columns: 1fr; } }

.avatar--lg { width: 4rem; height: 4rem; font-size: var(--t-lg); }

/* A portrait frame, not a round avatar. She is holding her top learner
   certificate, and a 64px circle crops it away entirely. The 3:4 matches the
   source photograph so nothing is cropped at all. */
.feat__face {
  display: block; width: 11rem; flex: none;
  /* No aspect-ratio on the wide layout. The frame takes its height from the grid
     row, and object-fit keeps the photograph undistorted inside it. */
  border-radius: var(--r-md); overflow: hidden; background: var(--ground-2);
  box-shadow: var(--lift-2);
}
.feat__face img { width: 100%; height: 100%; object-fit: cover; display: block; }
@media (max-width: 34rem) {
  .feat__face { width: 11rem; aspect-ratio: 3 / 4; }
}

.feat__lead {
  font-family: var(--display);
  font-size: var(--t-lg); font-weight: 600;
  letter-spacing: -0.02em; line-height: 1.3; color: var(--ink);
}

/* Her own words. Set in the display face like the founder's quote, so the two
   testimonials on this page read as the same kind of thing. */
.feat__name {
  font-family: var(--display); font-size: var(--t-lg); font-weight: 800;
  letter-spacing: -0.02em; line-height: 1.2; color: var(--ink);
}
/* Same heading as Meet Simesihle, per his note. They are the two headings in this
   block and there is no hierarchy between them worth expressing. */
.feat__sub {
  margin-top: var(--s-5);
  font-family: var(--display); font-size: var(--t-lg); font-weight: 800;
  letter-spacing: -0.02em; line-height: 1.2; color: var(--ink);
}

/* Her words are set exactly like the Beyond the Classroom paragraph: same face,
   size, weight, colour and leading. It was display type at --t-lg, which made the
   quote shout over the two headings around it. The quotation marks stay, because
   they are what says this is a quote once the type no longer does. */
.feat__quote {
  margin-top: var(--s-2);
  font-size: var(--t-sm); line-height: 1.6; color: var(--ink-2);
}
.feat__quote::before { content: "\201C"; color: var(--brand); }
.feat__quote::after  { content: "\201D"; color: var(--brand); }

.feat__body {
  margin-top: var(--s-3);
  font-size: var(--t-sm); line-height: 1.6; color: var(--ink-2);
}

/* The link is the point of the block, so it is styled as an action rather than
   as a footnote. */
.feat__src {
  display: inline-flex; align-items: center; gap: var(--s-2);
  margin-top: var(--s-3);
  /* Padded to a 44px touch target. It sits on its own line rather than inline in
     a sentence, so the WCAG exemption for inline links does not apply to it. */
  min-height: 44px; padding-block: var(--s-2);
  font-size: var(--t-sm); font-weight: 600;
  color: var(--brand); text-decoration: underline; text-underline-offset: 3px;
}
.feat__src:hover { color: var(--brand-hi); }
.feat__src svg { width: 15px; height: 15px; flex: none; }

/* Reviews */
/* --------------------------------------------------------------------------
   Programme comparison

   A real <table>, because it is two things compared on the same rows, and a
   screen reader announcing "How often, Annual Programme, 4 classes per month"
   is exactly what a sighted reader gets from the columns. A grid of divs would
   throw that away for no gain.
   -------------------------------------------------------------------------- */

/* Available to assistive tech, invisible on screen. Used for the table caption
   and the empty corner header, both of which a screen reader needs and a sighted
   reader does not. */
.visually-hidden {
  position: absolute; width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  clip-path: inset(50%); overflow: hidden; white-space: nowrap;
}


/* --------------------------------------------------------------------------
   Programme picture picker, on the tutor's programme settings form
   -------------------------------------------------------------------------- */

.imgpick {
  display: grid;
  grid-template-columns: minmax(0, 12rem) minmax(0, 1fr);
  gap: var(--s-4);
  align-items: start;
}
@media (max-width: 34rem) { .imgpick { grid-template-columns: 1fr; } }

/* Same 16:9 as the card it will appear on, so what he sees here is the crop
   the learner gets rather than a surprise later. */
.imgpick__preview,
.imgpick__art {
  width: 100%;
  aspect-ratio: 16 / 9;
  border-radius: var(--r-md);
  border: 1px solid var(--line);
  object-fit: cover;
  background: var(--ground-2);
}
.imgpick__art {
  display: flex; align-items: center; justify-content: center;
  color: var(--ink-3);
  font-size: var(--t-sm);
  text-align: center;
}
.imgpick__actions { display: grid; gap: var(--s-3); }

/* `.stars` was removed with the placeholder reviews. The real ones carry no
   rating, so there is nothing honest to render. Do not bring it back. */

.review__quote {
  font-size: var(--t-base);
  line-height: 1.55;
  color: var(--ink);
  /* The learners wrote in paragraphs. Keeping their breaks costs one line of CSS
     and is part of why a real review reads as a real person. */
  white-space: pre-line;
}
.review__quote::before { content: "\201C"; }
.review__quote::after  { content: "\201D"; }

/* Clamped by default in the markup, so a card is a readable height before any
   script runs. line-clamp is applied on the class rather than the element, which
   is what lets the button simply remove it to expand. */
.review__quote--clamped {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  /* 8 lines, measured rather than picked: at 7 all four reviews were clipped,
     and two of them only lost a handful of words, so the button revealed almost
     nothing. At 8 the two ordinary reviews read in full on a desktop and only
     Precious's and Kgatontle's, which are genuinely long, offer to expand. */
  -webkit-line-clamp: 8;
  line-clamp: 8;
  overflow: hidden;
}

/* A button, not a link: it reveals text in place and goes nowhere. Styled as
   text so it does not compete with the real calls to action on the page. */
.review__more {
  align-self: start;   /* .pcard__body is a flex column and already sets the gap */
  padding: 0;
  background: none;
  border: 0;
  color: var(--brand);
  font: inherit;
  font-size: var(--t-sm);
  font-weight: 600;
  text-decoration: underline;
  text-underline-offset: 3px;
  cursor: pointer;
}
.review__more:hover { color: var(--brand-hi); }
.review__more:focus-visible { outline: 2px solid var(--brand); outline-offset: 3px; border-radius: 2px; }

.review__by {
  display: flex; align-items: center; gap: var(--s-3);
  margin-top: auto; padding-top: var(--s-4);
  border-top: 1px solid var(--line);
}
.review__by strong { display: block; font-size: var(--t-sm); }
.review__by .fine { display: block; }

/* Initials rather than a stock portrait: attaching a real stranger's face to
   a quote they never said is a step past placeholder into deception. */
.avatar {
  display: inline-flex; align-items: center; justify-content: center;
  width: 2.6rem; height: 2.6rem; flex: none;
  background: var(--brand-soft); color: var(--brand);
  border-radius: 50%;
  font-family: var(--display); font-weight: 700; font-size: var(--t-sm);
  letter-spacing: 0;
}

.faq { display: grid; gap: var(--s-3); }
.faq details {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--r-md); overflow: hidden;
  transition: box-shadow var(--dur-1) var(--ease);
}
.faq details[open] { box-shadow: var(--lift-2); }
.faq summary {
  display: flex; align-items: center; gap: var(--s-3);
  padding: var(--s-4) var(--s-5); min-height: 56px;
  font-family: var(--display); font-size: var(--t-base); font-weight: 700;
  letter-spacing: -0.02em; color: var(--ink);
  cursor: pointer; list-style: none;
  transition: color var(--dur-1) var(--ease);
}
.faq summary::-webkit-details-marker { display: none; }
.faq summary:hover { color: var(--brand); }
.faq summary::after {
  content: ""; margin-left: auto; flex: none;
  width: 9px; height: 9px;
  border-right: 2.5px solid var(--brand); border-bottom: 2.5px solid var(--brand);
  transform: rotate(45deg) translateY(-2px);
  transition: transform var(--dur-2) var(--ease);
}
.faq details[open] summary::after { transform: rotate(225deg) translateY(-2px); }
/* An open answer is a flow of blocks, not one paragraph. His answers run to
   three or four paragraphs and one of them carries a list.

   The panel's bottom padding sits on the last child rather than on every
   paragraph. With it on each <p>, consecutive paragraphs were separated by a full
   panel's worth of space and read as separate answers. */
.faq details > p,
.faq details > ul {
  padding-inline: var(--s-5);
  max-width: var(--measure);
  color: var(--ink-2);
  font-size: var(--t-sm);
  line-height: 1.6;
}
.faq details > p + p,
.faq details > p + ul,
.faq details > ul + p { margin-top: var(--s-3); }
.faq details > :last-child { padding-bottom: var(--s-5); }

/* Real bullets. The marker is indented inside the panel padding rather than
   hanging off the left edge of the card. */
.faq details > ul {
  list-style: disc;
  padding-left: calc(var(--s-5) + 1.15rem);
  display: flex; flex-direction: column; gap: var(--s-1);
}
.faq details > ul li::marker { color: var(--brand); }

/* Contact channels. Real details, tappable, no form. */
.channels { display: grid; gap: var(--s-3); align-content: start; }

.channel {
  display: flex;
  align-items: center;
  gap: var(--s-4);
  padding: var(--s-4) var(--s-5);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  box-shadow: var(--lift-1);
  text-decoration: none;
  color: var(--ink);
  transition: transform var(--dur-1) var(--ease),
              box-shadow var(--dur-1) var(--ease),
              border-color var(--dur-1) var(--ease);
}
.channel:hover { transform: translateY(-2px); box-shadow: var(--lift-2); border-color: var(--brand); }

.channel__icon {
  flex: none;
  display: inline-flex; align-items: center; justify-content: center;
  width: 3rem; height: 3rem;
  background: var(--ground-2); color: var(--ink-2);
  border-radius: var(--r-md);
}
.channel__icon svg { width: 22px; height: 22px; }

.channel__body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.channel__kind {
  font-size: var(--t-xs); font-weight: 700;
  letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-3);
}
.channel__detail {
  font-family: var(--display); font-weight: 700; font-size: var(--t-base);
  letter-spacing: -0.02em;
  overflow-wrap: anywhere;
}
.channel__go {
  margin-left: auto; flex: none; color: var(--ink-3);
  transition: color var(--dur-1) var(--ease), transform var(--dur-1) var(--ease);
}
.channel__go svg { width: 20px; height: 20px; }
.channel:hover .channel__go { color: var(--brand); transform: translateX(3px); }

/* The phone number leads, so it carries the brand colour rather than sitting
   in the same neutral well as the others. */
.channel--primary { border-color: color-mix(in oklch, var(--brand) 35%, var(--line)); }
.channel--primary .channel__icon { background: var(--brand); color: oklch(0.99 0.005 264); }
.channel--primary:hover { border-color: var(--brand); }

/* Brand marks are solid, not stroked. */
svg[data-filled] { fill: currentColor; stroke: none; }

.credit {
  display: inline-flex; align-items: center; gap: var(--s-3);
  padding: var(--s-3) var(--s-4);
  background: oklch(1 0 0 / 0.12);
  border: 1px solid oklch(1 0 0 / 0.22);
  border-radius: var(--r-pill);
  font-size: var(--t-sm);
}
.credit svg { width: 17px; height: 17px; flex: none; }

/* On a phone the results band's call to action and its UCT credit both run the
   full width of the band, per the client's mock. They were sitting at their own
   content width, which on a narrow screen left an awkward gap of blue to the right
   of each one and made the band's bottom edge look unfinished.

   Scoped to .brand-band: .row and .credit are generic and are used elsewhere,
   where hugging the content is correct. */
/* Side by side, the call to action and the credit read as one pair, so the credit
   takes its height from the button rather than sitting shorter than it. stretch
   rather than a hardcoded 56px to match .btn--lg: if the button's size ever
   changes the credit follows, and the credit's own align-items keeps its tick and
   text centred inside the taller box. */
.brand-band .row { align-items: stretch; }

/* 44rem, not 34rem. The two do not fit on one line below about 700px, and between
   544 and 700 they were wrapping to separate lines at their own natural widths,
   which looked like neither one thing nor the other. Above this they sit side by
   side at matching heights; below it they stack, both full width. */
@media (max-width: 44rem) {
  .brand-band .row {
    flex-direction: column;
    align-items: stretch;
  }
  .brand-band .row > .btn { width: 100%; }
  .brand-band .credit { justify-content: center; text-align: left; }
}
.credit a { text-underline-offset: 3px; font-weight: 600; }

/* --------------------------------------------------------------------------
   Greeting and first-run welcome
   -------------------------------------------------------------------------- */

.greeting { font-size: clamp(2rem, 5.5vw, 3.2rem); }
.greeting__note { color: var(--ink-2); max-width: 42ch; }

/* Both fade up on arrival. Small, but it is the difference between a page
   that appeared and a page that greeted you. */
.js .greeting, .js .greeting__note {
  animation: rise var(--dur-3) var(--ease) both;
}
.js .greeting__note { animation-delay: 90ms; }

/* --------------------------------------------------------------------------
   Audience pickers

   A list of checkboxes with quick-pick chips above it. The chips exist because
   the catalogue splits by grade, so "everyone in Grade 11" is one tap rather
   than hunting for the two rows that happen to say Grade 11.
   -------------------------------------------------------------------------- */

.pickers { border: 0; padding: 0; margin: 0 0 var(--s-4); }
.pickers legend {
  padding: 0; margin-bottom: var(--s-2);
  font-size: var(--t-sm); font-weight: 600; color: var(--ink);
}
.pickers__quick { gap: var(--s-2); margin-bottom: var(--s-3); flex-wrap: wrap; }
.pickers__list {
  display: grid; gap: 2px;
  border: 1px solid var(--line); border-radius: var(--r-md);
  background: var(--surface); overflow: hidden;
}
.pickers__list .check {
  margin: 0; padding: var(--s-3) var(--s-4);
  border-bottom: 1px solid var(--line);
}
.pickers__list .check:last-child { border-bottom: 0; }
.pickers__list .check:hover { background: var(--ground-2); }
/* The whole row reads as selected, not just the box, so a glance down the list
   shows the audience without reading every label. */
.pickers__list .check:has(input:checked) { background: var(--brand-soft); }
.pickers__list .check:has(input:checked) span { color: var(--brand-ink); font-weight: 600; }

/* A checkbox and its label as one target. The whole row is clickable, which
   on a phone is the difference between a 16px box and a comfortable hit. */
.check {
  display: flex; align-items: center; gap: var(--s-3);
  min-height: 44px; cursor: pointer;
  font-size: var(--t-sm); color: var(--ink-2);
}
.check input[type="checkbox"] {
  width: 20px; height: 20px; flex: none;
  accent-color: var(--brand); cursor: pointer;
}

/* --------------------------------------------------------------------------
   Announcements

   Cards rather than register rows. A register is a list of things you own and
   scan down; an announcement is one thing you read. Two across at most, so a
   quiet week never spreads three items thinly across the top of the page.
   -------------------------------------------------------------------------- */

.notices { display: grid; gap: var(--s-4); }
@media (min-width: 52rem) { .notices { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
/* The "nothing new" state is one item in a two-column grid. Left alone it
   would sit in the left half looking like a card that failed to load. */
.notices > .empty { grid-column: 1 / -1; }

.notice-card {
  display: flex; flex-direction: column; align-items: flex-start; gap: var(--s-3);
  padding: var(--s-5);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  box-shadow: var(--lift-1);
}
/* Marigold rather than a heavier border: pinned should read as "start here",
   not as an error. */
.notice-card--pinned { border-color: var(--zest); box-shadow: var(--lift-2); }

.notice-card__pin {
  display: inline-flex; align-items: center; gap: 0.35rem;
  font-size: var(--t-xs); font-weight: 700; letter-spacing: 0.06em;
  text-transform: uppercase; color: var(--ink-3);
}
.notice-card__pin svg { width: 14px; height: 14px; }

.notice-card__meta { display: flex; flex-wrap: wrap; gap: var(--s-2); }
.notice-card__meta .chip svg { width: 13px; height: 13px; }

.notice-card__title {
  font-size: var(--t-lg); line-height: 1.25; letter-spacing: -0.02em;
  max-width: 30ch;
}
.notice-card__body {
  font-size: var(--t-sm); line-height: 1.55; color: var(--ink-2);
  white-space: pre-line;   /* a tutor typing a list gets their line breaks back */
}
.notice-card .btn svg { width: 15px; height: 15px; }
.notice-card .fine { margin-top: auto; padding-top: var(--s-2); }

.welcome {
  position: relative;
  margin-top: calc(var(--s-6) * -1);
  margin-bottom: var(--s-2);
  padding: var(--s-6);
  background: var(--brand);
  color: oklch(0.97 0.01 264);
  border-radius: var(--r-xl);
  box-shadow: var(--lift-3);
  animation: welcome-in var(--dur-3) var(--ease) both 120ms;
}
@media (min-width: 48rem) { .welcome { padding: var(--s-7); } }

.welcome .label { color: var(--zest-lift); }
.welcome__title {
  margin-top: var(--s-2);
  font-size: clamp(1.5rem, 3.4vw, 2.1rem);
  color: oklch(0.99 0.005 264);
  max-width: 20ch;
}

.welcome__steps {
  list-style: none;
  padding: 0;
  margin-top: var(--s-5);
  display: grid;
  gap: var(--s-4);
}
@media (min-width: 56rem) { .welcome__steps { grid-template-columns: repeat(3, 1fr); gap: var(--s-5); } }

.welcome__steps li {
  display: flex;
  gap: var(--s-3);
  align-items: flex-start;
  font-size: var(--t-sm);
  line-height: 1.5;
  color: oklch(0.93 0.02 264);
}
.welcome__steps strong { color: oklch(0.99 0.005 264); font-weight: 700; }

.welcome__n {
  flex: none;
  display: inline-flex; align-items: center; justify-content: center;
  width: 1.9rem; height: 1.9rem;
  background: var(--zest); color: var(--ink);
  border-radius: 50%;
  font-family: var(--display); font-weight: 700; font-size: var(--t-sm);
}

.welcome__close {
  position: absolute; top: var(--s-4); right: var(--s-4);
  display: inline-flex; align-items: center; justify-content: center;
  width: 40px; height: 40px;
  background: oklch(1 0 0 / 0.14); color: oklch(0.97 0.01 264);
  border: 0; border-radius: 50%; cursor: pointer;
  transition: background-color var(--dur-1) var(--ease);
}
.welcome__close:hover { background: oklch(1 0 0 / 0.26); }
.welcome__close svg { width: 18px; height: 18px; }

.welcome .btn--bare { --btn-fg: oklch(0.92 0.02 264); }
.welcome .btn--bare:hover:not(:disabled) { --btn-fg: oklch(0.99 0.005 264); --btn-bg: oklch(1 0 0 / 0.12); }
.welcome .btn--primary { --btn-bg: var(--zest); --btn-fg: var(--ink); --btn-bd: var(--zest); }
.welcome .btn--primary:hover:not(:disabled) { --btn-bg: var(--zest-deep); --btn-bd: var(--zest-deep); }

@keyframes welcome-in {
  from { opacity: 0; transform: translateY(16px) scale(0.985); }
  to   { opacity: 1; transform: none; }
}

/* Floating help button. Support that is present, not buried in settings. */
.fab {
  position: fixed; right: var(--s-4); bottom: var(--s-4); z-index: var(--z-fab);
  display: inline-flex; align-items: center; gap: var(--s-2);
  min-height: 52px; padding: 0 var(--s-5);
  background: var(--ink); color: var(--ground);
  border: 0; border-radius: var(--r-pill);
  font-family: var(--sans); font-size: var(--t-sm); font-weight: 700;
  text-decoration: none; cursor: pointer;
  box-shadow: var(--lift-3);
  transition: transform var(--dur-1) var(--ease), background-color var(--dur-1) var(--ease);
  animation: fab-in var(--dur-3) var(--ease) both 900ms;
}
.fab:hover { transform: translateY(-2px); background: var(--brand); }
.fab svg { width: 20px; height: 20px; }

/* WhatsApp variant, on the landing page. Its own green rather than the ink fill,
   because the glyph is only recognised at a glance in WhatsApp's colour.
   #128C7E is the darker brand tone: white on the bright #25D366 measures about
   1.9:1, which fails non-text contrast. */
.fab--wa { background: #128C7E; color: #fff; }
.fab--wa:hover { background: #0f7568; color: #fff; }
@keyframes fab-in { from { opacity: 0; transform: translateY(20px) scale(0.9); } to { opacity: 1; transform: none; } }
@media (max-width: 30rem) { .fab .fab__label { display: none; } .fab { padding-inline: var(--s-4); } }

/* --------------------------------------------------------------------------
   15. Motion
   -------------------------------------------------------------------------- */

/* Load reveal, for content that is present at first paint. */
[data-reveal] > * { animation: rise var(--dur-3) var(--ease) both; animation-delay: calc(var(--i, 0) * 60ms); }

/* Scroll reveal, driven by IntersectionObserver in motion.js. Elements start
   hidden only once JS confirms it is running, so a failed script never
   leaves a blank page. */
.js [data-inview] { opacity: 0; transform: translateY(18px); }
.js [data-inview].is-in {
  opacity: 1; transform: none;
  transition: opacity var(--dur-3) var(--ease), transform var(--dur-3) var(--ease);
  transition-delay: calc(var(--i, 0) * 70ms);
}

@keyframes rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
  [data-reveal] > *, .js [data-inview] { animation: none; opacity: 1; transform: none; }
  .pcard:hover, .reg-item:hover, .btn:hover:not(:disabled), .fab:hover { transform: none; }
}

/* --------------------------------------------------------------------------
   16. Video embeds + print
   -------------------------------------------------------------------------- */

.embed {
  position: relative; aspect-ratio: 16 / 9;
  background: var(--ground-2);
  border: 1px solid var(--line); border-radius: var(--r-lg); overflow: hidden;
}
.embed iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }

/* The viewer's own email over the player. Top right, clear of every provider's
   control bar, which sits along the bottom.

   pointer-events: none is load-bearing: anything over an iframe that catches
   clicks breaks play, seek and fullscreen. mix-blend-mode keeps it legible on
   both a dark frame and a bright one without ever being loud enough to spoil
   the lesson.

   Faint on purpose. It has to survive a screen recording, not win attention. */
.embed__mark {
  position: absolute; z-index: 2;
  top: 0.6rem; right: 0.75rem;
  max-width: 55%; overflow: hidden;
  text-overflow: ellipsis; white-space: nowrap;
  pointer-events: none; user-select: none;
  font-size: 11px; font-weight: 600; letter-spacing: 0.02em;
  color: oklch(1 0 0 / 0.42);
  mix-blend-mode: difference;
  text-shadow: 0 1px 2px oklch(0 0 0 / 0.35);
}
@media (min-width: 48rem) { .embed__mark { font-size: 12px; } }

/* A watermark that vanishes when someone prints or saves the page is not doing
   its job. */
@media print { .embed__mark { color: oklch(0 0 0 / 0.5); mix-blend-mode: normal; } }

@media print {
  body { background: #fff; }
  .site-head, .site-foot, .btn, .toasts, .nav, .fab { display: none !important; }
  .pcard, .reg-item { break-inside: avoid; box-shadow: none; }
}

/* --------------------------------------------------------------------------
   Legal pages: privacy, terms, payments, refunds
   -------------------------------------------------------------------------- */

/* These carry a reading measure, unlike the landing page sections. The client
   asked for marketing copy to run the full width, and it does. Policy text is a
   different job: at the full shell width a 16px line runs to about 130 characters,
   which nobody reads. 78ch is roughly 700px at desktop. */
.legal { max-width: 78ch; }
.legal h2 {
  font-size: var(--t-xl);
  margin-top: var(--s-7);
  margin-bottom: var(--s-4);
}
.legal h2:first-child { margin-top: 0; }
.legal p,
.legal ul { color: var(--ink-2); font-size: var(--t-base); line-height: 1.65; }
.legal p + p,
.legal ul + p,
.legal p + ul { margin-top: var(--s-4); }
.legal strong { color: var(--ink); font-weight: 700; }
.legal ul {
  list-style: disc;
  padding-left: 1.35rem;
  display: flex; flex-direction: column; gap: var(--s-3);
}
.legal ul li::marker { color: var(--brand); }
.legal a { font-weight: 600; }

/* The Google Drive folder on a lesson. A link out, not an in-app file, so it is
   set apart from the notes register above it rather than pretending to be a row
   in it. */
.lesson__drive { margin-top: var(--s-4); font-size: var(--t-sm); }
.lesson__drive a { display: inline-flex; align-items: center; gap: var(--s-2); font-weight: 600; }
.lesson__drive svg { width: 16px; height: 16px; flex: none; }
