/* ===========================================================================
   Fonts, served from qualify.my rather than Google Fonts.

   The pages previously fetched a stylesheet from fonts.googleapis.com, which
   then pointed at font files on fonts.gstatic.com: two extra origins, each
   costing a DNS lookup and a TLS handshake before a single word could render
   in the right typeface.

   Self-hosted because of who reads this site. Part of the audience arrives
   from government and large-corporate networks, and where those block an
   external origin the whole page falls back to a system font: every line
   length shifts and the typography stops being the typography. That is a far
   more visible failure than the animation libraries going missing, and it
   would land on the highest-value visitors.

   It also means no visitor's IP address is handed to a third party before the
   page has finished rendering, which keeps the privacy notice simple.

   Both families are SIL Open Font License, so hosting them is permitted.
   Files come from the Fontsource npm packages:
     @fontsource-variable/inter-tight   (one variable file covers 400/500/600)
     @fontsource/ibm-plex-mono          (400 and 500)

   unicode-range is what keeps this cheap: latin-ext is only fetched if a
   character in that block actually appears, so the usual cost is the 45 KB
   Inter Tight file plus 15 KB of Plex Mono. Chinese and Malay both fall back
   to the reader's system fonts and download nothing, which is why there is no
   CJK file here.

   font-display: swap — text paints immediately in the fallback and swaps when
   the file lands, rather than holding the page blank.
   =========================================================================== */

@font-face {
  font-family: "Inter Tight";
  font-style: normal;
  font-display: swap;
  font-weight: 100 900;
  src: url(../fonts/inter-tight-latin-wght-normal.woff2) format("woff2-variations");
  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: "Inter Tight";
  font-style: normal;
  font-display: swap;
  font-weight: 100 900;
  src: url(../fonts/inter-tight-latin-ext-wght-normal.woff2) format("woff2-variations");
  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: "IBM Plex Mono";
  font-style: normal;
  font-display: swap;
  font-weight: 400;
  src: url(../fonts/ibm-plex-mono-latin-400-normal.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: "IBM Plex Mono";
  font-style: normal;
  font-display: swap;
  font-weight: 400;
  src: url(../fonts/ibm-plex-mono-latin-ext-400-normal.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: "IBM Plex Mono";
  font-style: normal;
  font-display: swap;
  font-weight: 500;
  src: url(../fonts/ibm-plex-mono-latin-500-normal.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: "IBM Plex Mono";
  font-style: normal;
  font-display: swap;
  font-weight: 500;
  src: url(../fonts/ibm-plex-mono-latin-ext-500-normal.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;
}

/* Chinese, added 28/08/2026. Until now the stack NAMED "Noto Sans SC" but never
   loaded it, so every Chinese glyph fell through to whatever the operating
   system happened to supply: PingFang on a Mac, Microsoft YaHei on Windows,
   WenQuanYi on some Linux builds. Combined with `font-synthesis-weight: none`
   on the body, which is right for the Latin variable font, that meant
   `font-weight: 600` on the Chinese headline did nothing at all: the fallback
   had one weight and the browser was forbidden from faking a heavier one. Qi
   Jie reported it as "the Chinese hero is not bold like the English".

   These two files are Noto Sans SC subset to the 791 characters the Chinese
   pack actually uses, 106KB and 108KB. The unicode-range covers CJK blocks
   ONLY, so an English or Malay visitor never downloads a byte: Latin
   punctuation, including curly quotes and the ellipsis, resolves from Inter
   Tight, which is earlier in the stack and covers U+2000-206F.

   If the Chinese copy gains a character outside the subset it will fall back to
   the system font for that one glyph. Rebuild the subset after any Chinese copy
   change: see tools/README for the pyftsubset command. */
@font-face {
  font-family: "Noto Sans SC";
  font-style: normal;
  font-display: swap;
  font-weight: 400;
  src: url(../fonts/noto-sans-sc-400.woff2) format("woff2");
  unicode-range: U+2E80-2EFF,U+3000-303F,U+3400-4DBF,U+4E00-9FFF,U+F900-FAFF,U+FF00-FFEF;
}
@font-face {
  font-family: "Noto Sans SC";
  font-style: normal;
  font-display: swap;
  font-weight: 600;
  src: url(../fonts/noto-sans-sc-600.woff2) format("woff2");
  unicode-range: U+2E80-2EFF,U+3000-303F,U+3400-4DBF,U+4E00-9FFF,U+F900-FAFF,U+FF00-FFEF;
}

/* ===========================================================================
   Qualify.my — v3
   A standalone design system. This file REPLACES styles.css; it does not
   layer on top of it. index.html links this stylesheet only.

   Direction: the art direction of the Tresmares Capital teardown, rebuilt
   around the RPEL Malaysia reach map rather than around the old brand sheet.

     Colour      Effectively monochrome. One accent: emerald #059669.
                 No sand, no mint, no dark-tinted sections.
     Type        Inter Tight structural, IBM Plex Mono for every label,
                 eyebrow, figure and piece of metadata. Two families,
                 two jobs, no exceptions.
     Sizing      clamp() throughout with a hard 16px floor on body copy.
                 This is the deliberate fix for the vw-sizing fragility
                 flagged in the teardown.
     Easing      A named token set. Unrelated animations across the page
                 share a vocabulary, which is why they feel related.
   =========================================================================== */

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

:root {
  /* Surfaces. One neutral family, cool-leaning, no warm greys mixed in. */
  --bg:        #fbfbfa;
  --bg-sunk:   #f4f5f3;
  --bg-raised: #ffffff;

  /* Ink. Three steps is enough; a fourth always ends up misused.
     Every step clears 4.5:1 against all three surfaces above, so any of
     them is safe on small text without a per-use check:
       ink   17.17 : 1    ink-2  7.96 : 1    ink-3  5.13 : 1   (on --bg) */
  --ink:   #16181c;
  --ink-2: #4a4f57;
  --ink-3: #666c72;

  /* The single accent, in two weights.
     --accent (3.64:1 on --bg) is cleared for large display text, rules,
     borders and fills only. Anything set below about 24px uses
     --accent-deep (5.30:1), which is the only emerald allowed on body,
     label and link text. */
  --accent:       #059669;
  --accent-deep:  #047857;
  --accent-pale:  #d6ece2;
  --accent-wash:  #f0f8f4;

  /* Stipple ink, shared with the reach map so the textures match. */
  --dot:  #13201b;
  --line: #e4e7e4;
  --line-strong: #cfd4cf;

  /* WhatsApp is a third-party brand mark and is the one licensed exception
     to the single-accent rule. It appears on action surfaces only. */
  --wa: #25d366;
  --wa-ink: #128c3e;

  /* Motion vocabulary. */
  --ease:      cubic-bezier(.16, 1, .3, 1);
  --ease-in:   cubic-bezier(.7, 0, .84, 0);
  --ease-soft: cubic-bezier(.35, .8, .25, 1);
  --dur-fast: 180ms;
  --dur:      260ms;
  --dur-slow: 480ms;

  /* Type. Body copy never computes below 16px at any viewport width. */
  /* The two mono sizes below the body scale. They were repeated as raw rem
     values in eight places, which is how a type scale quietly stops being one.
     --mono-label is the tracked uppercase size (eyebrows, region names, the
     scroll cue, stat captions). --mono-fig is the numeral size (beat labels,
     the quiz counter, step numbers, marker values) and is fixed rather than
     fluid, because tabular figures that resize between breakpoints stop
     lining up in a column. */
  --mono-label: clamp(0.6875rem, 0.66rem + 0.1vw, 0.75rem);
  --mono-fig:   0.75rem;

  --step--1: clamp(0.8125rem, 0.79rem + 0.12vw, 0.875rem);
  --step-0:  clamp(1rem,     0.97rem + 0.16vw, 1.0625rem);
  --step-1:  clamp(1.125rem, 1.06rem + 0.32vw, 1.3125rem);
  --step-2:  clamp(1.375rem, 1.22rem + 0.78vw, 1.9375rem);
  --step-3:  clamp(1.875rem, 1.52rem + 1.78vw, 3.125rem);
  --step-4:  clamp(2.5rem,   1.72rem + 3.9vw,  5.25rem);
  /* Unchanged, and deliberately so. --step-5 now governs only the STACKED
     hero, which is phones and tablets; from 1024px up the split sets the
     display size from its own column instead (see section 7). Narrowing this
     scale to solve a desktop problem was tried on 27/08/2026 and reverted: it
     made phones smaller for no reason, and at 320px it pushed the hero's own
     button 78px below the fold in Bahasa Malaysia. Measured, not assumed. */
  --step-5:  clamp(3rem,     1.7rem + 6.5vw,   7rem);

  --font:  "Inter Tight", "Noto Sans SC", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
  /* CJK fallbacks last: IBM Plex Mono has no CJK, and the monospace fallbacks
     after it (Menlo, Consolas) have only partial coverage. In Chinese that
     rendered 年 and 月 correctly and 日 and 队 as tofu boxes in the same line.
     Per-character fallback means Latin still sets in Plex Mono. */
  --mono:  "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas,
           "Noto Sans SC", "Microsoft YaHei", "PingFang SC", monospace;

  --maxw: 1240px;
  --gutter: clamp(1.25rem, 4vw, 3rem);
  --radius: 10px;
  --header-h: 68px;

  --z-header: 60;
  --z-scrim:  55;
  --z-fab:    45;
}

/* ---------- 2. Reset and base -------------------------------------------- */

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

html {
  scroll-behavior: smooth;
  scroll-padding-top: calc(var(--header-h) + 1.5rem);
  -webkit-text-size-adjust: 100%;
}
/* Lenis owns scrolling once it initialises; native smooth scroll would fight it. */
html.lenis { scroll-behavior: auto; }
html.lenis body { min-height: 100%; }
.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font: 400 var(--step-0)/1.65 var(--font);
  font-synthesis-weight: none;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  /* clip, NOT hidden. `overflow-x: hidden` computes overflow-y to auto, which
     turns body into a scroll container: that breaks position:sticky on the
     hero stage and detaches scroll(root block) from the document scroller.
     `clip` suppresses the same horizontal overflow without creating one. */
  overflow-x: clip;
}

h1, h2, h3, h4 {
  margin: 0 0 .55em;
  font-weight: 600;
  line-height: 1.08;
  letter-spacing: -.028em;
  text-wrap: balance;
}
h1 { font-size: var(--step-4); }
h2 { font-size: var(--step-3); }
h3 { font-size: var(--step-1); letter-spacing: -.018em; line-height: 1.25; }
h4 { font-size: var(--step-0); letter-spacing: -.01em; }

p { margin: 0 0 1.1rem; color: var(--ink-2); text-wrap: pretty; }
p:last-child { margin-bottom: 0; }

a { color: var(--accent-deep); text-underline-offset: .22em; text-decoration-thickness: 1px; }
a:hover { color: #036c4e; }

img, svg { max-width: 100%; display: block; }
strong, b { font-weight: 600; color: var(--ink); }

::selection { background: var(--accent-pale); color: var(--ink); }

:where(a, button, input, textarea, summary, [tabindex]):focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: 3px;
}

/* Numbers are read as data on this site, so they get tabular figures
   everywhere rather than only in the statistics block. */
.mono, .fig { font-variant-numeric: tabular-nums; }

/* ---------- 3. Layout primitives ----------------------------------------- */

.wrap {
  width: 100%;
  max-width: var(--maxw);
  margin-inline: auto;
  padding-inline: var(--gutter);
}
/* Third instance of the same fault as .prose and .measure: a narrower measure
   put on the CONTAINER rather than on its contents. Because .wrap centres, the
   narrower box centres too, so these two sections started 190px right of the
   header and of the three other sections on their own page. A measure should
   change how wide the text runs, never where the column begins. */
.wrap--narrow { max-width: var(--maxw); }
.wrap--narrow > * { max-width: 860px; }

.band { padding-block: clamp(4rem, 9vw, 8.5rem); position: relative; }
.band--tight { padding-block: clamp(2.5rem, 5vw, 4rem); }
/* One sunk surface only. Used to mark a change of chapter, never decoratively. */
.band--sunk { background: var(--bg-sunk); }
.band--sunk + .band--sunk { padding-top: 0; }

.measure { max-width: 62ch; }

/* When .measure lands on the same element as .wrap it stops being a measure
   and becomes the column: .measure is declared after .wrap and both are single
   classes, so 62ch wins over --maxw. The container then centres at 62ch and no
   longer lines up with the site header or with neighbouring sections, which is
   the same fault .prose had on the guide articles.

   Restore the container and move the cap onto the children. The centred
   variant is excluded: on the 404 the whole column is meant to be centred. */
.wrap.measure:not(.measure--center) { max-width: var(--maxw); }
.wrap.measure:not(.measure--center) > * { max-width: 62ch; }
.measure-tight { max-width: 46ch; }

.rule { height: 1px; background: var(--line); border: 0; margin: 0; }

/* ---------- 4. Eyebrows, labels and small type --------------------------- */

.eyebrow {
  margin: 0 0 1.15rem;
  font: 500 var(--mono-label)/1 var(--mono);
  letter-spacing: .18em;
  text-transform: uppercase;
  color: var(--ink-3);
}
.eyebrow--accent { color: var(--accent-deep); }
.eyebrow .tick { color: var(--accent-deep); }

.label {
  font: 400 var(--mono-label)/1.4 var(--mono);
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--ink-3);
}

.lede { font-size: var(--step-1); color: var(--ink-2); line-height: 1.55; }

.note {
  font: 400 var(--step--1)/1.55 var(--mono);
  letter-spacing: .02em;
  color: var(--ink-3);
}

/* ---------- 5. Buttons ---------------------------------------------------- */

.btn {
  display: inline-flex;
  align-items: center;
  gap: .6rem;
  padding: .95rem 1.5rem;
  border: 1px solid transparent;
  border-radius: 6px;
  font: 500 var(--step-0)/1 var(--font);
  letter-spacing: -.008em;
  text-decoration: none;
  cursor: pointer;
  transition: background var(--dur) var(--ease),
              border-color var(--dur) var(--ease),
              color var(--dur) var(--ease),
              transform var(--dur-fast) var(--ease);
}
.btn:active { transform: translateY(1px); }

.btn--primary { background: var(--ink); color: var(--bg); }
.btn--primary:hover { background: var(--accent-deep); color: #fff; }

.btn--accent { background: var(--accent-deep); color: #fff; }
.btn--accent:hover { background: #036c4e; color: #fff; }

.btn--quiet { background: transparent; color: var(--ink); border-color: var(--line-strong); }
.btn--quiet:hover { border-color: var(--ink); color: var(--ink); }

/* White on WhatsApp green, matching the sticky button and WhatsApp's own
   lockup. Qi Jie's call, made against the alternative of a white mark with a
   dark label.

   Worth knowing what it costs: white on #25d366 is roughly 2:1 contrast,
   under the 4.5:1 WCAG asks of body text, and 15px/500 does not qualify as
   large text. It is legible for most people and it is the pattern everyone
   recognises, but it will be the weakest text on the site outdoors on a
   phone. The hover green is darker, so contrast improves on hover rather
   than degrading. If this ever needs fixing without losing the look, darken
   the button background rather than darkening the label: the label matching
   the mark is the whole point of the choice. */
/* DECIDED 24/08/2026, by Qi Jie, and not to be quietly reverted.

   White on #25d366 measures 1.98:1, which fails WCAG AA for text (needs
   4.5:1). It is kept anyway. The reason is that the white-mark-on-green
   lockup is what makes this button read as WhatsApp before anyone parses a
   word of it, and on this site the WhatsApp conversation IS the measured
   conversion. Recognition was judged worth more than the ratio.

   The alternatives were built and rejected on the record:
     - #042c13 label on the same green (7.70:1). Legible, keeps the fill
       exact, but dark-on-green is not the lockup people recognise.
     - white on a darkened green #0b7a3b (5.44:1). Sits only 1.47:1 from the
       brand's own Deep Pine #0B5C43, so it stops reading as WhatsApp and
       starts reading as a second brand-green button.
     - label outside the green (17.17:1). Best ratio, but drops the button
       from primary to tertiary weight beside the ink button next to it.

   If you are here to raise the contrast, read the above first. */
.btn--wa { background: var(--wa); color: #fff; }
.btn--wa:hover { background: #1fbe5b; color: #fff; }
.btn--wa svg { width: 19px; height: 19px; }

/* The tertiary style. Its existence is what stops every section ending in
   the same filled-button-plus-ghost-button pair. */
.link-arrow {
  display: inline-flex;
  align-items: center;
  gap: .5rem;
  font: 500 var(--step-0)/1 var(--font);
  color: var(--ink);
  text-decoration: none;
  padding-block: .4rem;
  border-bottom: 1px solid var(--line-strong);
  transition: border-color var(--dur) var(--ease), color var(--dur) var(--ease), gap var(--dur) var(--ease);
}
/* Touch target, 24/08/2026: 30px tall to 44px.

   Two constraints shaped this. ::after is already the arrow glyph, so the
   overlay has to be ::before. And .link-arrow is an inline-flex, where a
   ::before would normally become a flex item and open a `gap`-sized hole
   before the label. Positioning it absolutely takes it out of flex layout
   entirely, so it adds hit area and nothing else. Padding was tried first and
   rejected: it changed the height of the block around it. */
.link-arrow { position: relative; }
.link-arrow::before { content: ""; position: absolute; inset: -7px 0; }
.link-arrow::after { content: "\2192"; transition: transform var(--dur) var(--ease); }
.link-arrow:hover { color: var(--accent-deep); border-color: var(--accent-deep); gap: .75rem; }

.actions { display: flex; flex-wrap: wrap; gap: 1rem 1.5rem; align-items: center; }

/* ---------- 6. Header ----------------------------------------------------- */

.skip {
  position: absolute; left: -9999px; top: 0; z-index: 200;
  background: var(--ink); color: var(--bg);
  padding: .8rem 1.2rem; font: 500 var(--step--1)/1 var(--mono);
  letter-spacing: .08em; text-transform: uppercase; text-decoration: none;
}
.skip:focus { left: 0; }

.hdr {
  position: fixed;
  inset-block-start: 0;
  inset-inline: 0;
  z-index: var(--z-header);
  height: var(--header-h);
  background: rgba(251, 251, 250, .82);
  backdrop-filter: saturate(1.6) blur(14px);
  -webkit-backdrop-filter: saturate(1.6) blur(14px);
  border-bottom: 1px solid transparent;
  transform: translateY(0);
  transition: transform var(--dur) var(--ease), border-color var(--dur) var(--ease);
  will-change: transform;
}
.hdr.is-hidden { transform: translateY(-100%); }
.hdr.is-stuck  { border-bottom-color: var(--line); }

.hdr__in {
  height: 100%;
  display: flex;
  align-items: center;
  gap: clamp(1rem, 3vw, 2.5rem);
}
.hdr__brand { display: flex; align-items: center; margin-inline-end: auto; }
.hdr__brand img { width: 138px; height: auto; }

.nav { display: flex; align-items: center; gap: clamp(1rem, 2.2vw, 2rem); }
.nav a:not(.btn) {
  font: 400 var(--step--1)/1 var(--font);
  font-size: .9375rem;
  color: var(--ink-2);
  text-decoration: none;
  padding-block: .5rem;
  border-bottom: 1px solid transparent;
  transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.nav a:not(.btn):hover { color: var(--ink); }
.nav a[aria-current="page"] { color: var(--ink); border-bottom-color: var(--accent); }
.nav .btn { padding: .65rem 1.15rem; font-size: .9375rem; }

.lang {
  display: flex;
  align-items: center;
  border: 1px solid var(--line);
  border-radius: 5px;
  overflow: hidden;
}
/* Touch target, 24/08/2026. This was 34x25, the smallest interactive thing on
   the site, and on a trilingual site it is the primary affordance for the BM
   and Chinese readers.

   Vertically it is extended for free by the ::after below: nothing sits above
   or below the pill, so the hit region grows without the drawing moving.

   Horizontally it cannot be. The three buttons are adjacent inside one pill,
   so extending each sideways would make neighbouring hit regions OVERLAP, and
   a tap landing in an overlap goes to whichever button is later in the
   stacking order. That turns a small target into a wrong one. Widening the
   padding widens the pill instead, which is the one visible change here:
   about 24px across the three, and deliberate. */
.lang button {
  position: relative;
  appearance: none;
  background: none;
  border: 0;
  padding: .45rem .85rem;
  font: 400 var(--mono-label)/1 var(--mono);
  letter-spacing: .08em;
  color: var(--ink-3);
  cursor: pointer;
  transition: color var(--dur) var(--ease), background var(--dur) var(--ease);
}
/* Vertical only. `inset: -10px 0` takes the button to 45px tall and stops
   exactly at its own edges, so no two hit regions ever overlap. */
.lang button::after { content: ""; position: absolute; inset: -10px 0; }
.lang button + button { border-inline-start: 1px solid var(--line); }
.lang button:hover { color: var(--ink); }
.lang button[aria-pressed="true"] { background: var(--ink); color: var(--bg); }

.nav-toggle { display: none; }

/* ---------- 7. Hero: stipple topography ---------------------------------- */

/* Three stipple lattices at three depths, drifting and scaling at different
   rates so the field thickens into a ridge as the visitor descends. The two
   nearer layers are cut by a repeating diagonal so the dots read as contour
   lines rather than as a flat screen.

   The motion is native CSS scroll-driven animation. It runs on the compositor,
   costs no JavaScript, and continues to work if the GSAP or Lenis CDN is
   blocked. Requirements it imposes on the rest of the page, all of which are
   met and none of which are obvious:

     1. The section must be the first element in the document flow.
     2. The document must be the scroller. Lenis is left in its default
        window-scrolling mode for this reason; giving it a wrapper element
        would move the scroll port and silently freeze every layer here.
     3. No ancestor may create a scroll container. `overflow-x: hidden` on
        body computes overflow-y to auto, which would break the sticky stage.
        The base rule uses `overflow-x: clip` instead, which does not.

   Browsers without scroll-driven animation support (Safari below 26, Firefox
   without the flag) get the composed end state from the @supports block at the
   foot of this section. Nothing is gated behind the animation. */

/* --qm-runway is the only number worth touching here. Every content range
   below is expressed as a fraction of it rather than in absolute vh, so
   shortening the scrub keeps the choreography proportional instead of leaving
   the headline still settling after the stage has unpinned. Halve it, double
   it, or set it to 60vh: the sequence holds its shape. */
.hero {
  --qm-lattice: 11px;             /* dot pitch, matches the reach map */
  /* 70vh, not the original 100vh (24/08/2026). The choreography is unchanged:
     every range below is a fraction of this, so the sequence holds its shape
     and only the travel between its stages shortens. The 100vh version spent
     its last ~200px of scrub below a quarter opacity, where the headline is
     no longer legible against the near-white field, and pushed the eligibility
     check to 2.1 screens, against PRODUCT.md's one-section rule. Phones had
     already been running 70vh; this brings the desktop into line with them. */
  --qm-runway: 70vh;              /* scroll distance the whole scrub occupies */
  --qm-cue-out:    .19;           /* scroll cue has faded by here */
  /* .62, not .34 (27/08/2026). Qi Jie asked to keep the dissolve, and it is
     kept. What changed is when it starts. At .34 the content dropped below
     4.5:1 by 55% of the runway while it was still the only thing on screen,
     and the reader was still reading it. Now it holds full contrast for the
     first two thirds and clears in the last third, so nothing dissolves under
     someone who has not finished with it. */
  --qm-settle-in:  .62;           /* headline holds full opacity until here */
  --qm-settle-out: .97;           /* and has cleared before the stage unpins */
  position: relative;
  height: calc(100dvh + var(--qm-runway));
  background: var(--bg);
}

.hero__stage {
  position: sticky;
  top: 0;
  height: 100dvh;
  overflow: clip;
  display: grid;
  align-items: center;
}

/* ── the three lattices ── */

.hero__topo {
  position: absolute;
  inset: -14%;
  z-index: 0;
  pointer-events: none;
  will-change: transform;
  background-size: var(--qm-lattice) var(--qm-lattice);
  animation-timeline: scroll(root block);
  animation-range: 0 var(--qm-runway);
  animation-fill-mode: both;
  animation-timing-function: linear;
  animation-duration: 1s;         /* ignored on a scroll timeline */
}

/* far field: even, pale, drifts slowest */
.hero__topo--far {
  opacity: .5;
  background-image: radial-gradient(circle, #9aa09a 1.05px, transparent 1.15px);
  -webkit-mask-image: radial-gradient(115% 95% at 88% 58%, #000 8%, rgba(0,0,0,.35) 52%, transparent 82%);
          mask-image: radial-gradient(115% 95% at 88% 58%, #000 8%, rgba(0,0,0,.35) 52%, transparent 82%);
  animation-name: qmTopoSlow;
}

/* mid contours */
.hero__topo--mid {
  background-image: radial-gradient(circle, var(--ink-2) 1.5px, transparent 1.6px);
  -webkit-mask-image: repeating-linear-gradient(72deg, #000 0 9px, transparent 9px 46px),
                      radial-gradient(100% 82% at 80% 66%, #000 12%, transparent 74%);
          mask-image: repeating-linear-gradient(72deg, #000 0 9px, transparent 9px 46px),
                      radial-gradient(100% 82% at 80% 66%, #000 12%, transparent 74%);
  -webkit-mask-composite: source-in;
          mask-composite: intersect;
  animation-name: qmTopoMid;
}

/* near ridge: darkest, fades in, drifts fastest */
.hero__topo--near {
  background-image: radial-gradient(circle, var(--dot) 1.85px, transparent 1.95px);
  -webkit-mask-image: repeating-linear-gradient(72deg, #000 0 7px, transparent 7px 46px),
                      radial-gradient(64% 52% at 84% 74%, #000 6%, transparent 70%);
          mask-image: repeating-linear-gradient(72deg, #000 0 7px, transparent 7px 46px),
                      radial-gradient(64% 52% at 84% 74%, #000 6%, transparent 70%);
  -webkit-mask-composite: source-in;
          mask-composite: intersect;
  animation-name: qmTopoFast;
}

/* ── grading: hold the left of the frame near-flat so type stays legible ── */

.hero__wash,
.hero__wash2 { position: absolute; inset: 0; z-index: 1; pointer-events: none; }

.hero__wash {
  background: linear-gradient(100deg,
    var(--bg) 0%,
    rgba(251, 251, 250, .94) 28%,
    rgba(251, 251, 250, .42) 52%,
    rgba(251, 251, 250, 0) 74%);
}
/* Deepens as the lattices thicken, so the ridge never eats the headline. */
.hero__wash2 {
  background: linear-gradient(180deg,
    rgba(251, 251, 250, .6) 0%,
    rgba(251, 251, 250, 0) 34%,
    rgba(251, 251, 250, .9) 100%);
  animation: qmWashIn 1s linear both;
  animation-timeline: scroll(root block);
  animation-range: 0 var(--qm-runway);
}

/* Grain. The one element here that animates on a timer rather than on scroll,
   so motion.js parks it whenever the hero leaves the viewport: a perpetual
   full-viewport repaint under mix-blend-mode is exactly the kind of thing that
   drains a mid-range Android battery two screens after anyone can see it. */
.hero__grain {
  position: absolute;
  inset: -20%;
  z-index: 2;
  opacity: .52;
  mix-blend-mode: multiply;
  pointer-events: none;
  background-size: 200px 200px;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.92' numOctaves='3' stitchTiles='stitch'/><feColorMatrix type='saturate' values='0'/></filter><rect width='220' height='220' filter='url(%23n)'/></svg>");
  animation: qmGrain 1.1s steps(4, end) infinite;
}
.hero__grain.is-parked { animation-play-state: paused; }

/* ── content ── */

/* Settles upward and dissolves across the back half of the runway. The range
   starts late on purpose: the primary action holds full opacity for the first
   third of the scroll, so nobody watches the button they were about to press
   fade out from under the cursor. */
.hero__in {
  position: relative;
  z-index: 3;
  width: 100%;
  /* min-width: 0 is load-bearing, and its absence was a real bug (27/08/2026).
     .hero__in is a grid item, so its automatic minimum size is its MIN-CONTENT
     width, which is at least the longest unbreakable word in the headline. In
     Bahasa Malaysia that word is "membuktikannya.", 420px at the phone display
     size, so on a 390px phone this box was laid out 427px wide. Everything
     inside it inherited that width, which is why the LEDE was being cut off at
     the screen edge as well: the paragraph wrapped correctly, to a container
     that was itself wider than the screen. English and Chinese never showed it.
     Measured before and after: 427px -> 390px, and the lede's longest line goes
     from ending at 399px to 364px, inside the 370px content edge. */
  min-width: 0;
  padding-block: var(--header-h) 0;
  animation: qmHeadSettle 1s linear both;
  animation-timeline: scroll(root block);
  animation-range: calc(var(--qm-runway) * var(--qm-settle-in))
                   calc(var(--qm-runway) * var(--qm-settle-out));
}

.hero h1 {
  font-size: calc(var(--step-5) * var(--hero-fit));
  font-weight: 600;
  letter-spacing: -.042em;
  /* A safety net, not a style. It fires only on a word that cannot fit its
     line at all. Nothing in the current three headlines reaches it, now that
     the Malay line has been reworded and each language sizes to its own fit
     factor. It stays because a translation edit can reintroduce a long word,
     and a broken word beats a clipped one. */
  overflow-wrap: break-word;
  /* See the leading note above. This was .80 until 28/08/2026, taken from the
     reference site's .70, and it was simply too tight for a typeface with a
     .22em descender: every language overlapped. Tight leading buys height, but
     not below the point where the letters touch. */
  line-height: 1;
  max-width: 15ch;
  margin: 0;
}
.hero h1 em { font-style: normal; color: var(--accent); }

/* Per-script display fit for the STACKED hero only, below 1024px. From 1024
   up the headline spans the full width and all three languages share one size;
   see the hero block further down.

   On a narrow screen they cannot share a size: the Malay sentence is built
   from long words ("kerjanya.", "kelayakan", "Dapatkan") and Chinese sets
   full-width glyphs, so at the English size both overflow their line and gain
   an extra one, which on a 360x640 phone under reduced motion puts the button
   below the fold. Measured 28/08/2026 as the largest factor that holds each
   language to its line count at every viewport in the sweep. */
:root              { --hero-fit: 1; }
:root:lang(ms)     { --hero-fit: .80; }
:root:lang(zh-Hans){ --hero-fit: .90; }

/* Leading is a measured minimum, not a taste. Below these values glyphs
   actually collide: a descender lands on the cap or ascender of the line
   underneath. Worst pair in each language, as a fraction of the font size,
   from the font's own ink metrics (Inter Tight 600, Noto Sans SC 600):
     English  y over G      .96
     Malay    g over j/i    .98
     Chinese  做 over 在     1.04
   The values below clear the worst case with a small margin. The old .80
   overlapped by 11px in English and 17px in Chinese at 1440, which is the
   defect Qi Jie reported on 28/08/2026. If the hero copy is ever reworded,
   re-measure: a new letter can raise the requirement. */
:root:lang(zh-Hans) .hero h1 { line-height: 1.10; }

/* Only 400 and 600 are bundled. CSS font matching resolves a missing 500 by
   looking DOWN first, so Chinese buttons and links would land on 400 and read
   lighter than their Latin counterparts. Name 600 rather than ship a third
   file for one weight. */
:root:lang(zh-Hans) .btn,
:root:lang(zh-Hans) .link-arrow { font-weight: 600; }

/* The lede stays at --step-1. Measured against the reference at a matched
   viewport, its body copy is 21.07px and ours is 21.00px: the same size. What
   made theirs read smaller and calmer was leading of 1.20 against our 1.65,
   and a ~30ch measure against our 46ch. So the fix is leading and measure, not
   size. Shrinking type that carries the accreditation argument, for an
   audience of 30 to 50 year olds on laptops, would have been the wrong trade. */
.hero__lede {
  /* This margin replaces the hairline rule that used to sit between the
     headline and this paragraph, removed 28/08/2026 at Qi Jie's request. The
     rule held the gap open with its own margins, so deleting the element alone
     would have pulled the lede 47px up into the headline on phones and tablets.
     The value is the sum of the two margins it carried, 1.75rem + 1.1rem at the
     floor and 2.4rem + 1.4rem at the ceiling, so the stacked spacing is
     unchanged to the pixel. The split resets it: there the grid does the work. */
  margin: clamp(2.85rem, 5.5vw, 3.8rem) 0 0;
  max-width: 40ch;
  font-size: var(--step-1);
  line-height: 1.45;
  letter-spacing: -.012em;
  color: var(--ink-2);
}

.hero__actions { margin-top: clamp(1.75rem, 3.5vw, 2.5rem); }

/* The hero from 1024px up. Rebuilt 28/08/2026 after three rounds of trying to
   make three languages behave inside one narrow side column.

   Shape: the headline spans the full content width on row 1; the lede sits at
   the foot of the left column on row 2 and the buttons at the foot of the
   right one. Below 1024px nothing changes: phones and tablets keep the stack.

   WHY IT IS NOT A SIDE-COLUMN SPLIT ANY MORE. In the previous version the
   headline took the left column and everything else was squeezed into 382px on
   the right. That column could not hold the Malay call to action: "Semak
   kelayakan anda" plus "Lihat cara ia berfungsi" needs 432px, so it wrapped to
   two rows and made the Malay right column as tall as its headline, which
   English never was. English wrapped there too, below 1280px, and Chinese
   below 1180: two rows was the narrow-column behaviour of the component, not a
   Malay defect. Every fix inside that geometry cost something visible: a
   smaller Malay button, a smaller headline, or a wider column paid for out of
   the headline.

   Giving the headline the full width removes the competition. The buttons get
   a 440px column of their own and hold one row in every language at the full
   17px, the lede gets a 52-character measure instead of 38, and all three
   languages run at the SAME display size. Only the line count differs: three
   lines in English and Malay, two in Chinese. Measured at 1440x700: the call
   to action lands at 625 of 700, with every language above the fold.

   Reference points, measured live rather than assumed: Linear sets a 64px
   headline across 94% of the width with a 15px paragraph beneath it; Tres
   Mares, the original reference for this page, sets 147px in a side split. A
   104px headline across the full width sits between the two. */
@media (min-width: 1024px) {
  .hero__in {
    display: grid;
    grid-template-columns: minmax(0, 1.15fr) minmax(0, .85fr);
    grid-template-rows: auto auto;
    column-gap: clamp(2.5rem, 5vw, 5rem);
    row-gap: clamp(2rem, 4vw, 3rem);
    align-content: center;
  }
  /* One size for all three languages, and no --hero-fit here. The factor still
     governs the STACKED hero below 1024px, where the screen width is the
     constraint. Across the full content width each language simply finds its
     own line count, which is what makes the three heroes read as one layout.
     104px at 1440. Raising the ceiling costs a fourth line in English and
     Malay before it costs anything else; re-measure the fold if you do. */
  .hero h1       { grid-column: 1 / -1; grid-row: 1; margin: 0; max-width: none;
                   font-size: clamp(3.5rem, -3.89rem + 11.54vw, 6.5rem); }
  .hero__lede    { grid-column: 1; grid-row: 2; max-width: 52ch; margin: 0; align-self: end; }
  .hero__actions { grid-column: 2; grid-row: 2; align-self: end; justify-self: end; margin-top: 0; }
}

/* Between 1024 and 1279 the row below the headline splits evenly instead of
   1.15 to .85. The buttons need 432px in Malay, which is more than .85 of a
   1024px container can give, and an even split reaches 438px there. Above
   1280 the wider left share reads better and the buttons have room either
   way. */
@media (min-width: 1024px) and (max-width: 1279px) {
  .hero__in { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
}

/* Short desktop windows, added 28/08/2026 with the full-width headline. A
   1366x640 or 1280x600 window is a laptop with a half-height browser. The new
   hero is taller than the old side split there, because the headline takes
   three lines above the row instead of sitting beside it, and under reduced
   motion, which adds its own padding, that pushed the button 10 to 16px below
   the fold. Closing the row gap buys 24px, which covers both. Verified by
   fresh load, not by resize. */
@media (min-width: 1024px) and (max-height: 700px) {
  .hero__in { row-gap: 1.5rem; }
}

/* Sits outside .hero__in so it fades on its own, early, the moment the
   visitor has demonstrated they know how to scroll. */
.hero__cue {
  position: absolute;
  z-index: 3;
  inset-block-end: clamp(1.5rem, 4vh, 2.5rem);
  inset-inline-start: var(--gutter);
  margin: 0;
  font: 400 var(--mono-label)/1 var(--mono);
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--ink-3);
  animation: qmCueOut 1s linear both;
  animation-timeline: scroll(root block);
  animation-range: 0 calc(var(--qm-runway) * var(--qm-cue-out));
}

/* ── keyframes ── */

@keyframes qmTopoSlow {
  from { transform: scale(1.04) translate3d(0, 0, 0); }
  to   { transform: scale(1.3)  translate3d(-2%, -7%, 0); }
}
@keyframes qmTopoMid {
  from { transform: scale(1)    translate3d(0, 3%, 0);    opacity: .55; }
  to   { transform: scale(1.2)  translate3d(-4%, -9%, 0); opacity: 1; }
}
@keyframes qmTopoFast {
  from { transform: scale(.98)  translate3d(0, 8%, 0);     opacity: 0; }
  to   { transform: scale(1.16) translate3d(-6%, -12%, 0); opacity: 1; }
}
@keyframes qmWashIn  { from { opacity: .28; } to { opacity: .86; } }
@keyframes qmCueOut  { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(6px); } }
@keyframes qmHeadSettle {
  from { opacity: 1; transform: translate3d(0, 0, 0); }
  to   { opacity: 0; transform: translate3d(0, -56px, 0); }
}
@keyframes qmGrain {
  0%   { transform: translate3d(0, 0, 0); }
  25%  { transform: translate3d(-2%, 1.5%, 0); }
  50%  { transform: translate3d(1.5%, -2%, 0); }
  75%  { transform: translate3d(-1%, -1%, 0); }
  100% { transform: translate3d(0, 0, 0); }
}

/* ── no scroll-driven animation support: compose the end state ── */

@supports not (animation-timeline: scroll(root block)) {
  .hero { height: auto; }
  .hero__stage {
    /* relative, NOT static (25/08/2026). Static reads as the honest value here,
       since the stage is no longer pinned, but it silently breaks the clip:
       the three .hero__topo lattices are absolutely positioned and scaled past
       the frame on purpose, and overflow clipping does not apply to an
       absolutely positioned descendant whose containing block sits outside the
       clipping element. Unposition the stage and the lattices escape
       `overflow: clip` above and push the document 275px wider than the
       viewport at 1280, giving the homepage a horizontal scrollbar. Relative
       lays out identically to static and restores the containing block. */
    position: relative;
    height: auto;
    min-height: 100dvh;
    padding-block: calc(var(--header-h) + clamp(3rem, 9vh, 6rem)) clamp(3rem, 8vw, 5.5rem);
  }
  .hero__topo, .hero__wash2, .hero__in, .hero__cue { animation: none; }
  .hero__topo--far  { transform: scale(1.14) translate3d(-1%, -3%, 0); opacity: .5; }
  .hero__topo--mid  { transform: scale(1.1)  translate3d(-2%, -4%, 0); opacity: .85; }
  .hero__topo--near { transform: scale(1.06) translate3d(-3%, -5%, 0); opacity: .7; }
  .hero__wash2 { opacity: .55; }
  .hero__in { opacity: 1; transform: none; }
  .hero__cue { display: none; }
}

/* ---------- 8. Accreditation line ----------------------------------------- */

/* The revolving logo band was removed at the client's request. This is the
   claim on its own, set quietly between the hero and the eligibility check. */
.accred {
  padding-block: clamp(1.5rem, 3vw, 2.25rem);
  border-top: 1px solid var(--line);
}

/* ---------- 9. Word-by-word scroll reveal --------------------------------- */

/* Colour is animated, never opacity, and the un-revealed value is measured
   rather than eyeballed: #666c72 sits at 5.13:1 on --bg and 4.86:1 on
   --bg-sunk, so the sentence passes AA at every scroll position rather than
   only once the animation has finished. The reveal travels 5.13 -> 17.17,
   which is a clearly legible move without ever hiding the words. */
.reveal-words { color: var(--ink); }
/* The accent word, before JavaScript touches it (and if it never does).
   splitWords replaces this <em> with .w spans when the reveal runs, so this
   rule only paints during the gap between paint and boot, or permanently on a
   page where the module never loads. Either way the reader sees the emphasis
   the sentence was written to carry, not a stray brace. */
.reveal-words em { font-style: normal; color: var(--accent); }
.reveal-words .w {
  color: var(--ink-3);
  transition: color 420ms var(--ease);
}
.reveal-words .w.on { color: inherit; }
/* Accent words only ever appear at display sizes, where 3.64:1 clears AA. */
.reveal-words .w--accent.on { color: var(--accent); }

/* ---------- 10. Narrative statements -------------------------------------- */

.story { display: grid; gap: clamp(3.5rem, 8vw, 7rem); }

.stmt {
  display: grid;
  grid-template-columns: minmax(0, 5rem) minmax(0, 1fr);
  gap: clamp(1.25rem, 3vw, 2.5rem);
  align-items: start;
}
/* Named beats rather than 01 / 02 / 03. The sequence is a narrative, not a
   procedure, and the label carries meaning that a numeral does not. Numerals
   are reserved for the four assessment steps, where order is the point. */
.stmt__n {
  font: 400 var(--mono-fig)/1.4 var(--mono);
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--ink-3);
  padding-top: .7em;
  border-top: 1px solid var(--line);
  margin: 0;
}
/* No max-width here.
   This element used to carry `max-width: 24ch`, which the 900px rule below
   resets to `none`. That meant the cap only ever applied BELOW 900px, which is
   the one place it must not: on a 390px phone it squeezed the whole beat into
   about 224px of a 350px column, roughly 64%. The 31px display heading lives
   inside it, so headings were wrapping inside 224px instead of the ~340px
   their own 20ch measure allows, and every beat ran two or three lines longer
   than it needed to. A cap that is overridden everywhere it makes sense and
   active everywhere it does not is not a cap, it is a leftover. */
.stmt h3 {
  font-size: var(--step-3);
  font-weight: 500;
  letter-spacing: -.03em;
  line-height: 1.06;
  margin: 0;
  max-width: 20ch;
  /* Even line lengths matter more here than anywhere else on the page: these
     headings are the largest type in the body of the site, so a one-word
     final line is very visible. */
  text-wrap: balance;
}
.stmt p {
  margin: 1.1rem 0 0;
  max-width: 48ch;
  color: var(--ink-2);
}
.stmt--turn .stmt__n { color: var(--accent-deep); border-top-color: var(--accent); }

/* The turn in the story gets the only surface change in the sequence. */
.stmt--turn h3 { color: var(--ink); }

@media (min-width: 900px) {
  .stmt__body { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: clamp(2rem, 5vw, 4rem); align-items: start; }
  .stmt p { margin-top: .4rem; }
}

/* ---------- 10b. Statistics ----------------------------------------------- */

/* Teardown 04. The copy column sticks while the evidence scrolls past it, so
   one paragraph of context stays on screen for the whole argument. The two
   card columns are offset by a margin, not by an animation, which is what
   stops the grid ever resolving into a tidy row. */

.stats { display: grid; gap: clamp(2.5rem, 6vw, 4.5rem); }

@media (min-width: 960px) {
  .stats { grid-template-columns: minmax(0, .82fr) minmax(0, 1fr); gap: clamp(3rem, 7vw, 6rem); }
  .stats__copy {
    position: sticky;
    top: calc(var(--header-h) + clamp(2rem, 6vh, 4.5rem));
    align-self: start;
  }
}
.stats__copy h2 { max-width: 14ch; }
.stats__copy .lede { max-width: 42ch; }

.stats__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
  gap: clamp(2rem, 4vw, 3.25rem) clamp(1.5rem, 3vw, 2.5rem);
}
/* Two per row on phones as well (26/08/2026). The `auto-fit` rule above was
   never a mobile design, it was whatever happened to fit: a 430px phone
   already got two columns and a 390px one got a single file of six, so the
   layout changed between two phones in the same pocket. Six figures stacked
   one per line ran 769px at 390px wide, against 393px two-up, and left the
   right half of every row empty, which is what prompted this.
   It costs nothing in wrapping: `.stat > span` carries a 20ch measure, so the
   labels were already wrapping to their own line length rather than to the
   column, and the line counts are identical in both layouts at 320, 360, 390
   and 430. The figures fit with room to spare at 320, the narrowest phone. */
@media (max-width: 619px) {
  .stats__grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: clamp(1.75rem, 5vw, 2.25rem) clamp(1rem, 4vw, 1.5rem);
  }
}
/* The offset. Second column drops by roughly half a card so the eye reads
   down a stagger rather than across a row.
   Deliberately NOT applied to the phone layout above: at a 167px column the
   stagger adds 44px back and breaks the row alignment that is doing the work
   of making a narrow grid readable. It earns its place where there is room to
   read down a column, which is not a phone. */
@media (min-width: 620px) {
  .stats__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .stats__grid > .stat:nth-child(even) { margin-top: clamp(1.75rem, 4vw, 3.25rem); }
}

.stat {
  /* Declared HERE, on the element motion.js writes to, and nowhere below it.
     It previously carried a `--resolve: 0` fallback on `.stat b` as well, and
     because custom properties inherit, that inner declaration shadowed the
     value being set on the card: the numeral read 0 forever and stayed grey
     no matter what the scroll driver wrote. A default for an inherited
     property belongs at the top of the subtree, never repeated inside it. */
  --resolve: 0;
  display: grid;
  gap: .75rem;
  /* Pack the two rows to the top.
     .stat is itself a grid item, so it stretches to match the tallest card in
     its row — and with the default `align-content: stretch` that surplus was
     shared out between the figure row and the label row. Half of it landed
     INSIDE .fig, below the numeral, so the space between a figure and its
     label depended on how many lines its neighbour's label happened to wrap
     to. Three of the six cards were affected and three were not, which is why
     it read as random. Measured: .fig boxes of 85px, 67px and 76px against a
     50px line-height, on identical markup.

     Translating the labels changed which cards wrapped, which is why it
     surfaced then, but the fault predates it. */
  align-content: start;
  padding-top: 1.1rem;
  border-top: 1px solid var(--line-strong);
}
.stat b {
  font: 600 var(--step-3)/1 var(--font);
  letter-spacing: -.035em;
  font-variant-numeric: tabular-nums;
  /* Resolves neutral to emerald rather than pale-emerald to emerald. Pale
     emerald measured 1.13:1 against this surface, which would have left the
     headline figure effectively invisible until it animated. This version
     moves hue instead of lightness: 5.13:1 on entry, 3.45:1 resolved, both
     of which clear AA at this size, and the shift still reads clearly.

     Colour resolve rather than a count-up: the value is correct on first
     paint, so a fast scroller never reads a number that is still climbing.

     --resolve is written by motion.js as the card travels from 88% to 45% of
     the viewport, so the figure interpolates with scroll position rather than
     snapping at a threshold. color-mix does the interpolation in oklab, which
     keeps the midpoints off the grey axis: a straight sRGB mix between these
     two passes through a dead olive. Both endpoints clear AA at this size, so
     every frame in between does too. */
  color: var(--ink);
}

/* Only the figure itself resolves to emerald. The suffix stays ink, so the
   colour marks the quantity rather than the whole lockup: "1,350" reads as
   the number and "+" as a qualifier on it. */
.stat__n {
  color: color-mix(in oklab, var(--accent) calc(var(--resolve) * 100%), var(--ink-3));
  /* Inherited from <b> rather than left to chance. Stated explicitly because
     this element sits inside a block that also styles spans, and a numeral
     silently picking up a label's size and tracking is exactly the failure
     this rule exists to prevent. */
  font: inherit;
  letter-spacing: inherit;
  text-transform: none;
  /* The count changes the string width every frame; tabular figures and a
     reserved inline size stop the suffix jittering sideways as it climbs. */
  font-variant-numeric: tabular-nums;
  display: inline-block;
  min-width: var(--fig-w, auto);
}
.stat b i,
.stat b sup { color: var(--ink); }

/* Optical spacing between the figure and its suffix.
   The boxes butt together at zero gap for every card, but they do not LOOK
   equally spaced: "+" and "%" carry generous left side bearing inside the
   glyph, while a unit word (yrs / thn) or a CJK character (年) starts flush
   against its box edge and reads as glued to the digits. Equal margins would
   produce unequal apparent gaps, so the word gets the margin and the symbols
   keep their built-in bearing. */
.fig__unit { margin-inline-start: .1em; }

/* No color-mix support: the figure is ink until it resolves, never a
   mid-animation colour that was never checked for contrast. */
@supports not (color: color-mix(in oklab, red, blue)) {
  .stat__n { color: var(--ink-3); transition: color 620ms var(--ease); }
  .stat.on .stat__n { color: var(--accent); }
}
.stat b i { font-style: normal; }
.stat b sup { font-size: .42em; vertical-align: super; line-height: 0; margin-inline-start: .1em; }
/* Touch target, 24/08/2026. The asterisk is drawn at 6x16, which is the
   smallest target on the site and effectively untappable with a thumb. The
   superscript itself is untouched; only the hit region grows, to 44x44
   centred on the glyph. Nothing about this is visible. */
.stat b sup a { color: inherit; text-decoration: none; position: relative; }
.stat b sup a::after {
  content: "";
  position: absolute;
  left: 50%; top: 50%;
  width: 44px; height: 44px;
  transform: translate(-50%, -50%);
}
.stat b sup a:hover { text-decoration: underline; }
/* Direct child, not any descendant. `.stat span` also matched the numeral
   span nested inside <b>, and at 0,1,1 it outranked `.stat__n` at 0,1,0 —
   which is why the figures rendered at the 12px mono label size, in ink-3,
   uppercased and letter-spaced, instead of at --step-3 in emerald. The
   caption is the only span that is a direct child of .stat. */
.stat > span {
  font: 400 var(--mono-label)/1.5 var(--mono);
  letter-spacing: .13em;
  text-transform: uppercase;
  color: var(--ink-3);
  max-width: 20ch;
}

.stats__notes {
  grid-column: 1 / -1;
  display: grid;
  gap: .9rem;
  margin-top: clamp(1rem, 3vw, 2rem);
  padding-top: 1.5rem;
  border-top: 1px solid var(--line);
  scroll-margin-top: calc(var(--header-h) + 2rem);
}
.stats__notes .note { max-width: 62ch; }

/* ---------- 11. Section headings ------------------------------------------ */

.sec-head { margin-bottom: clamp(2.5rem, 6vw, 4.5rem); }
.sec-head h2 { max-width: 20ch; margin-bottom: 0; }
.sec-head .lede { margin-top: 1.25rem; max-width: 54ch; }

.sec-head--split {
  display: grid;
  gap: clamp(1.5rem, 4vw, 3rem);
}
@media (min-width: 900px) {
  .sec-head--split { grid-template-columns: minmax(0, 1fr) minmax(0, .85fr); align-items: end; }
  .sec-head--split .lede { margin-top: 0; }
}

/* ---------- 11b. Eligibility check ---------------------------------------- */

/* The conversion instrument. It sits on a raised surface with a hairline
   border rather than a shadow: elevation here communicates "this is the one
   interactive object on the page", which is the only thing a card should
   ever be doing. Nothing in this block animates on entry. */

.check {
  display: grid;
  gap: clamp(2rem, 5vw, 4rem);
  align-items: start;
}
/* The split engages at 1080px, not 900px. Between 900 and 1080 the old
   breakpoint handed the card a 408px column while the result state still
   needed 687px of height: the tallest and narrowest the card ever got, on
   exactly the small-laptop and landscape-tablet sizes it was meant to help.
   Below 1080 the card takes the full width instead, which is wider than it
   ever was in two columns. */
@media (min-width: 1080px) {
  .check { grid-template-columns: minmax(0, .92fr) minmax(0, 1fr); }
}

/* This was the one section heading set a step below the others, which made
   the most important section on the page read as a sub-heading of the one
   above it. Every band heading is now --step-3; --step-2 is reserved for
   headings INSIDE a section (step titles, the quiz result, the lead quote). */
.check__copy h2 { max-width: 18ch; }
.check__steps {
  margin: 1.75rem 0 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: .85rem;
}
/* Marker and grid inherited from .rule-list, which this pattern was
   generalised into; only the type size is particular to this block. */
.check__steps li { font-size: var(--step--1); line-height: 1.5; }
/* The escape hatch to the measured conversion. Deliberately a sentence with a
   link in it rather than a second button: a visitor scanning for the action
   should find one obvious control, and this should only be noticed by someone
   who has already decided the questions are not for them. */
.check__skip {
  margin-top: clamp(1.5rem, 3vw, 2rem);
  padding-top: clamp(1.1rem, 2.4vw, 1.5rem);
  border-top: 1px solid var(--line);
  font-size: var(--step--1);
  color: var(--ink-3);
}
.check__skip a {
  color: var(--ink);
  text-decoration: none;
  border-bottom: 1px solid var(--line-strong);
  padding-bottom: .1rem;
  transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.check__skip a:hover { color: var(--accent-deep); border-bottom-color: var(--accent-deep); }

/* A floor, not a fixed height. Holding one exact height across all four
   questions would mean either dead air on the short ones or 118px-tall option
   buttons, both worse than what they fix. The floor is set just under the
   tallest question so the card never shrinks mid-sequence and never jumps when
   JavaScript replaces the 254px fallback on load, which was the largest and
   most visible of the three shifts. */
.quiz {
  display: flex;
  flex-direction: column;
  min-height: clamp(19rem, 40vh, 23rem);
  background: var(--bg-raised);
  border: 1px solid var(--line);
  border-radius: 14px;
  padding: clamp(1.5rem, 3.5vw, 2.5rem);
}

/* Each rendered state fades up as it replaces the last, so advancing a
   question reads as one object changing rather than as a flicker. 140ms:
   long enough to register, short enough that the next option is already
   clickable by the time the eye lands on it.

   No fill mode, and the keyframes never reach zero opacity. If the animation
   fails to run at all, in a headless renderer or a browser that has dropped
   the rule, the content is simply visible: nothing here gates whether the one
   interactive object on the page can be seen. */
@keyframes quizStateIn {
  from { opacity: .35; transform: translateY(4px); }
  to   { opacity: 1; transform: none; }
}
.quiz > * { animation: quizStateIn 140ms var(--ease); }
@media (prefers-reduced-motion: reduce) {
  .quiz > * { animation: none; }
}

.quiz__progress { height: 2px; background: var(--line); overflow: hidden; }
/* scaleX rather than width: the fill is a compositor-only transform, so
   advancing a question never triggers layout. quiz.js writes the transform. */
.quiz__bar {
  height: 100%;
  background: var(--accent);
  transform-origin: 0 50%;
  transition: transform var(--dur-slow) var(--ease-soft);
}

/* Position in the sequence, stated rather than implied. A 2px bar shows that
   progress exists; it does not show that there are four questions and this is
   the second, which is the thing that stops someone abandoning halfway. */
.quiz__count {
  margin: .85rem 0 1.1rem;
  font: 400 var(--mono-fig)/1 var(--mono);
  letter-spacing: .14em;
  color: var(--ink-3);
  font-variant-numeric: tabular-nums;
}

.quiz__q {
  font: 500 var(--step-1)/1.35 var(--font);
  letter-spacing: -.018em;
  color: var(--ink);
  margin: 0 0 1.35rem;
}

.quiz__opts { display: grid; gap: .6rem; align-content: start; }
/* Two columns when quiz.js has measured every label as short enough to hold
   one. Four two-word answers do not need four full-width rows. */
.quiz__opts--pair { grid-template-columns: 1fr 1fr; }

.quiz__opt {
  appearance: none;
  text-align: start;
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: .9rem 1.1rem;
  font: 400 var(--step-0)/1.4 var(--font);
  color: var(--ink);
  cursor: pointer;
  transition: border-color var(--dur) var(--ease), background var(--dur) var(--ease), transform var(--dur-fast) var(--ease);
}
.quiz__opt:hover { border-color: var(--accent); background: var(--accent-wash); }
.quiz__opt:active { transform: translateY(1px); }

.quiz__result h3 { font-size: var(--step-2); margin: 1.5rem 0 .6rem; }
.quiz__result > .btn--wa { margin-top: 1.25rem; width: 100%; justify-content: center; }

/* The lead form, behind a disclosure. This is the secondary path: someone who
   will not use WhatsApp. Styled as a quiet link rather than a second button so
   it never competes with the primary action for attention. */
.quiz__alt { margin-top: 1.35rem; }
.quiz__alt > summary {
  display: inline-flex;
  align-items: center;
  gap: .45rem;
  cursor: pointer;
  list-style: none;
  font: 400 var(--step--1)/1.5 var(--font);
  color: var(--ink-2);
  border-bottom: 1px solid var(--line-strong);
  padding-bottom: .15rem;
  transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.quiz__alt > summary::-webkit-details-marker { display: none; }
.quiz__alt > summary::after {
  content: "\203A";
  font-size: 1.1em;
  line-height: 1;
  transition: transform var(--dur) var(--ease);
}
.quiz__alt[open] > summary::after { transform: rotate(90deg); }
.quiz__alt > summary:hover { color: var(--ink); border-bottom-color: var(--accent); }
.quiz__alt > form { margin-top: 1rem; }

.quiz__fine { margin-top: 1.35rem; font: 400 var(--step--1)/1.55 var(--font); color: var(--ink-3); }
.quiz__field {
  width: 100%;
  margin-top: .6rem;
  padding: .85rem 1rem;
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 8px;
  font: 400 var(--step-0)/1.4 var(--font);
  color: var(--ink);
  transition: border-color var(--dur) var(--ease);
}
.quiz__field::placeholder { color: var(--ink-3); }
.quiz__field:focus { border-color: var(--accent); }
.quiz__field:user-invalid { border-color: #b4302b; }
.quiz form .btn { margin-top: .85rem; width: 100%; justify-content: center; }

.quiz__fallback h3 { font-size: var(--step-2); }
.quiz__fallback .btn { margin-top: 1.25rem; width: 100%; justify-content: center; }

/* Narrow cards cannot hold two columns of answers whatever the label length,
   so the pair grid collapses below the width where it stops being readable.
   Container query rather than a viewport query: the card is 350px wide on a
   phone and 562px in the desktop split, and it is the card that decides. */
.quiz { container-type: inline-size; }
@container (max-width: 340px) {
  .quiz__opts--pair { grid-template-columns: 1fr; }
}

/* The .rpel__saves list and .story__note block were removed with the copy they
   carried. The four saves each duplicated a claim that had a better home (the
   hero, the process, the FAQ), and the note restated the turn beat two
   sentences after it. Their styles are gone rather than left orphaned. */

/* ---------- 11e2. Evidence (statistics, map, voices) ---------------------- */

/* Three former sections in one band. The internal blocks are separated by
   space rather than by a surface change, so the eye reads them as one
   argument in three movements instead of three separate claims. */
.evidence__map,
.evidence__voices { margin-top: clamp(4rem, 9vw, 7rem); }
.evidence__map .sec-head,
.evidence__voices .sec-head { margin-bottom: clamp(2rem, 4vw, 3rem); }

/* ---------- 11f. The four steps ------------------------------------------- */

/* Teardown 05 with the pin removed. The index is sticky and every entry is a
   real anchor, so deep links, the back button and keyboard navigation all
   keep working. A hard pin would have broken every one of them for the sake
   of a transition. */

.steps { display: grid; gap: clamp(2rem, 5vw, 3.5rem); }

/* The base rule has to come BEFORE the sticky media query, not after it.
   Both selectors are `.steps__index` with identical specificity, so source
   order alone decides the winner: a later `position: relative` silently
   cancelled `position: sticky` and the index scrolled away with the page
   while the rail kept filling. Nothing errored; it just stopped pinning. */
.steps__index { position: relative; padding-inline-start: 1.6rem; }

@media (min-width: 900px) {
  .steps { grid-template-columns: minmax(0, .5fr) minmax(0, 1fr); gap: clamp(3rem, 7vw, 6rem); }
  .steps__index { position: sticky; top: calc(var(--header-h) + clamp(2rem, 6vh, 4rem)); align-self: start; }
}
/* The rail is decorative navigation on phones, where the panels are already
   in reading order directly below it. */
@media (max-width: 899px) { .steps__index { display: none; } }

.steps__index ol { margin: 0; padding: 0; list-style: none; display: grid; }
.steps__index li + li { border-top: 1px solid var(--line-strong); }
.steps__index a {
  display: flex;
  align-items: baseline;
  gap: .85rem;
  padding-block: .9rem;
  font-size: var(--step-0);
  color: var(--ink-3);
  text-decoration: none;
  transition: color var(--dur) var(--ease), translate var(--dur) var(--ease);
}
.steps__index a:hover { color: var(--ink); }
.steps__index a:hover .steps__n { color: var(--accent-deep); }

/* One continuous rail, not a mark that hops between items. The track runs the
   full height of the index and the emerald fill is scrubbed to the reader's
   progress through the four panels, so the line descends as they read and the
   active entry is whatever the line has reached.

   scaleY on a bar rather than height on a div: the fill updates on every
   scroll frame and has to stay on the compositor. */
.steps__index::before,
.steps__index::after {
  content: "";
  position: absolute;
  inset-block: 0;
  inset-inline-start: 0;
  width: 2px;
}
.steps__index::before { background: var(--line-strong); }
.steps__index::after {
  background: var(--accent);
  transform-origin: 50% 0;
  transform: scaleY(var(--steps-progress, 0));
  will-change: transform;
}

.steps__index li { position: relative; }
/* translate, not padding: the active item changes on every scroll-spy update,
   and animating padding would run layout on each one. */
.steps__index a.is-active { color: var(--ink); translate: .4rem 0; }
.steps__index a.is-active .steps__n { color: var(--accent-deep); }
.steps__index a .steps__n { color: var(--ink-3); }

@media (prefers-reduced-motion: reduce) {
  .steps__index::after { transform: scaleY(1); }
  .steps__index a.is-active { translate: none; }
}

.steps__n {
  font: 400 var(--mono-fig)/1 var(--mono);
  letter-spacing: .1em;
  color: var(--ink-3);
  font-variant-numeric: tabular-nums;
  transition: color var(--dur) var(--ease);
}

.steps__panels { display: grid; gap: clamp(2.5rem, 5vw, 4rem); }

.step {
  display: grid;
  grid-template-columns: minmax(0, 3rem) minmax(0, 1fr);
  gap: clamp(1rem, 2.5vw, 1.75rem);
  align-items: start;
  padding-top: clamp(1.25rem, 2.5vw, 1.75rem);
  border-top: 1px solid var(--line-strong);
  scroll-margin-top: calc(var(--header-h) + 2rem);
}
.step h3 { font-size: var(--step-2); font-weight: 500; margin-bottom: .75rem; }
.step p { max-width: 54ch; }
.step > p.steps__n { padding-top: .35em; margin: 0; }

/* The concrete detail line. Mono, so it reads as specification rather than as
   more prose, and it is where the reassurance actually lives. */
.step__meta {
  margin-top: 1rem;
  font: 400 var(--step--1)/1.6 var(--mono);
  letter-spacing: .02em;
  color: var(--ink-3);
}

@media (max-width: 620px) {
  .step { grid-template-columns: minmax(0, 1fr); gap: .75rem; }
}

/* The progression teaser, folded in as the tail of the process rather than
   given a band of its own. It answers "and then what", which is a question a
   reader only has once they have understood the process above it. */
.steps__tail {
  margin-top: clamp(3rem, 7vw, 5.5rem);
  padding-top: clamp(1.5rem, 3vw, 2.25rem);
  border-top: 1px solid var(--line-strong);
}
.steps__tail h3 { font-size: var(--step-2); font-weight: 500; max-width: 20ch; }
.steps__tail p { max-width: 62ch; }
.steps__tail .actions { margin-top: 1.5rem; }

@media (min-width: 900px) {
  .steps__tail {
    display: grid;
    grid-template-columns: minmax(0, .5fr) minmax(0, 1fr);
    gap: clamp(3rem, 7vw, 6rem);
  }
  .steps__tail h3 { margin: 0; }
  .steps__tail .actions { grid-column: 2; }
}

/* ---------- 11g. Proof ----------------------------------------------------- */

/* One lead quote at a larger measure, two supporting quotes beneath. Not three
   equal cards: the eye is given an order to read them in, and the strongest
   claim is the one that gets the size. */

.proof { display: grid; gap: clamp(2.5rem, 5vw, 4rem); }

.quote { margin: 0; display: grid; gap: 1.5rem; }
.quote blockquote { margin: 0; }
.quote blockquote p {
  margin: 0;
  color: var(--ink);
  text-wrap: pretty;
}
.quote figcaption { display: flex; align-items: center; gap: .9rem; }
.quote figcaption span { display: grid; gap: .2rem; }
.quote figcaption b { font-weight: 500; font-size: var(--step-0); }
.quote figcaption span span,
.quote figcaption b + * { font: 400 var(--step--1)/1.4 var(--mono); color: var(--ink-3); }

/* Blur-up loading (teardown 11). The blur is on the image itself and clears
   on decode, so the layout never shifts and no empty grey box is ever shown.
   This is a Core Web Vitals fix more than a visual one. */
.quote__face {
  border-radius: 50%;
  object-fit: cover;
  filter: blur(8px);
  transform: scale(1.04);
  transition: filter var(--dur-slow) var(--ease), transform var(--dur-slow) var(--ease);
}
.quote__face.is-loaded { filter: none; transform: none; }

.quote--lead blockquote p {
  font: 500 var(--step-2)/1.35 var(--font);
  letter-spacing: -.022em;
  max-width: 26ch;
}
.quote--lead .quote__face { width: 52px; height: 52px; }

.proof__pair { display: grid; gap: clamp(2rem, 4vw, 3rem); }
.proof__pair .quote blockquote p { font-size: var(--step-0); max-width: 42ch; color: var(--ink-2); }
.proof__pair .quote__face { width: 44px; height: 44px; }

@media (min-width: 900px) {
  .proof { grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); gap: clamp(3rem, 7vw, 5.5rem); align-items: start; }
  .proof__pair { padding-top: .5rem; }
}

/* ---------- 11h. Reach map ------------------------------------------------- */

.reach { display: grid; gap: clamp(1.5rem, 3vw, 2.25rem); }
.reach__stage { position: relative; }
.reach__svg { display: block; width: 100%; height: auto; overflow: visible; }

/* Markers are positioned as a percentage of the stage by reach.js, so they
   stay registered to the geometry at every width without a resize listener. */
.reach__marker {
  position: absolute;
  transform: translate(-50%, -50%);
  width: 0; height: 0;
  opacity: 0;
  transition: opacity var(--dur-slow) var(--ease);
}
.reach__marker.is-in { opacity: 1; }
/* Markers scale with the map (26/08/2026). reach.js sets --r as the radius in
   viewBox units; the sizes below turn that into a length. The px branch is the
   old fixed behaviour and is what browsers without container query units get:
   0.73333 is the scale the radii were tuned at (1144/1560), so those browsers
   see exactly
   what shipped before. Everywhere else the @supports block below overrides it
   with the container-relative version, and the dot keeps the same relationship
   to the landmass at 320px as it does at 1920px. */
.reach__marker i {
  position: absolute;
  display: block;
  border-radius: 50%;
  background: var(--accent);
  width:  calc(var(--r) * 1.46667px);
  height: calc(var(--r) * 1.46667px);
  left:   calc(var(--r) * -0.73333px);
  top:    calc(var(--r) * -0.73333px);
  transform: scale(.2);
  transition: transform 600ms var(--ease-soft);
}
@supports (width: 1cqw) {
  .reach__stage { container-type: inline-size; }
  /* --u is declared on the marker, not on the dot: the label is a sibling of
     the dot, not a child of it, and custom properties only inherit downwards.
     It is not declared on .reach__stage either, because container query units
     resolve against the nearest ancestor container and the stage is the
     container itself, which would make 100cqw fall back to the viewport. */
  .reach__marker { --u: calc(100cqw / 1560); }   /* 1560 is the SVG viewBox width */
  .reach__marker i {
    width:  calc(var(--r) * 2 * var(--u));
    height: calc(var(--r) * 2 * var(--u));
    left:   calc(var(--r) * -1 * var(--u));
    top:    calc(var(--r) * -1 * var(--u));
  }
}
.reach__marker.is-in i { transform: scale(1); }
.reach__marker i::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 1px solid var(--accent);
  opacity: 0;
}
.reach__marker.is-in i::after { animation: reachHalo 3.4s cubic-bezier(.2,.6,.3,1) infinite; }
@keyframes reachHalo {
  0%   { transform: scale(1);   opacity: .5; }
  70%  { transform: scale(2.9); opacity: 0; }
  100% { transform: scale(2.9); opacity: 0; }
}
/* States with no cohort yet: outlined, neutral, no halo. Absence should not
   pulse, and it should not be emerald either. The original pale green ring
   measured 1.94:1 against this surface, failing the 3:1 that WCAG 1.4.11
   requires of a graphic conveying information, and it read as a quiet success
   rather than as a gap. Ink-3 clears at 4.86:1 and keeps emerald meaning
   exactly one thing on this map: a cohort that has actually run. */
.reach__marker--pending i { background: none; border: 1.5px dashed var(--ink-3); }
.reach__marker--pending i::after { display: none; }

.reach__marker b {
  position: absolute;
  white-space: nowrap;
  font: 500 var(--step--1)/1.15 var(--font);
  color: var(--ink);
}
.reach__marker em {
  font: 500 var(--mono-fig)/1 var(--mono);
  font-style: normal;
  letter-spacing: .06em;
  color: var(--accent-deep);
  margin-inline-start: .5rem;
}
.reach__marker--pending em { color: var(--ink-3); }
/* The offset is the scaled dot radius plus a fixed 10px of air: the marker
   shrinks with the map, the gap between dot and label does not, because that
   gap is typographic rather than cartographic. */
.reach__marker--r b { inset-inline-start: calc(var(--r) * 0.73333px + 10px); inset-block-start: calc(-.55em + var(--dy, 0px)); }
.reach__marker--l b { inset-inline-end:   calc(var(--r) * 0.73333px + 10px); inset-block-start: calc(-.55em + var(--dy, 0px)); text-align: end; }
@supports (width: 1cqw) {
  .reach__marker--r b { inset-inline-start: calc(var(--r) * var(--u) + 10px); }
  .reach__marker--l b { inset-inline-end:   calc(var(--r) * var(--u) + 10px); }
}

.reach__region {
  position: absolute;
  transform: translate(-50%, -50%);
  font: 400 var(--mono-label)/1 var(--mono);
  letter-spacing: .24em;
  text-transform: uppercase;
  color: var(--ink-3);
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  transition: opacity 800ms var(--ease);
}
.reach__region.is-in { opacity: 1; }

.reach__legend {
  margin: 0;
  padding-top: clamp(1.25rem, 2.5vw, 1.75rem);
  border-top: 1px solid var(--line);
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: .75rem clamp(1.5rem, 4vw, 2.75rem);
}
.reach__legend li { display: flex; align-items: center; gap: .6rem; font-size: var(--step--1); color: var(--ink-2); }
.reach__key { flex: none; border-radius: 50%; }
.reach__key--dot     { width: 14px; height: 14px; background: var(--accent); }
.reach__key--pending { width: 14px; height: 14px; border: 1.5px dashed var(--ink-3); }
.reach__key--land    { width: 5px;  height: 5px;  background: var(--dot); opacity: .55; }

.reach__note { max-width: 64ch; }

/* Below this width the labels collide with the geometry whatever we do, so
   the map becomes a texture and the figures are carried by the legend and
   the statistics block above. */
@media (max-width: 680px) {
  .reach__marker b { display: none; }
  .reach__legend { gap: .6rem 1.25rem; }
}

@media (prefers-reduced-motion: reduce) {
  .reach__marker, .reach__region { opacity: 1; }
  .reach__marker i { transform: scale(1); }
  .reach__marker i::after { animation: none; }
}

/* ---------- 11i. Progression ----------------------------------------------- */

.onward { display: grid; gap: clamp(1.5rem, 4vw, 3rem); }
.onward h2 { max-width: 18ch; margin: 0; }
.onward .lede { max-width: 52ch; }
@media (min-width: 900px) {
  .onward { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; }
}

/* The .fund block was removed when employer funding was folded into the FAQ.
   Its styles are deleted rather than left behind: an orphaned .fund__lead h2
   at --step-2 was one of only two rules undercutting the heading scale, and
   dead CSS is where the next inconsistency comes from. */

/* ---------- 11k. Questions -------------------------------------------------- */

/* Collapsed, per the v1 pattern. Native <details> with a shared `name`, so the
   group behaves as an exclusive accordion without a line of JavaScript, and
   the answers stay in the DOM for search and for find-in-page.

   No plus/minus glyph and no chevron: the marker is a single rule that rotates
   from a plus into a minus, which is the same hairline vocabulary the rest of
   the page uses for its rules and dividers. */
.qa { display: grid; }

.qa__item { border-top: 1px solid var(--line); }
.qa__item:last-child { border-bottom: 1px solid var(--line); }

.qa__item > summary {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: clamp(1.5rem, 4vw, 3rem);
  padding-block: clamp(1.25rem, 2.6vw, 1.75rem);
  cursor: pointer;
  list-style: none;
  font: 500 var(--step-1)/1.3 var(--font);
  letter-spacing: -.018em;
  color: var(--ink);
  transition: color var(--dur) var(--ease);
}
.qa__item > summary::-webkit-details-marker { display: none; }
.qa__item > summary:hover { color: var(--accent-deep); }
.qa__item > summary > span:first-child { max-width: 34ch; }

/* The marker. A horizontal rule plus a vertical one that collapses on open. */
.qa__mark {
  position: relative;
  flex: none;
  width: 13px;
  height: 13px;
  margin-top: .35em;
}
.qa__mark::before,
.qa__mark::after {
  content: "";
  position: absolute;
  inset-block-start: 50%;
  inset-inline-start: 0;
  width: 100%;
  height: 1px;
  background: var(--ink-3);
  transition: transform var(--dur) var(--ease), background var(--dur) var(--ease);
}
.qa__mark::after { transform: rotate(90deg); }
.qa__item[open] .qa__mark::after { transform: rotate(0deg); }
.qa__item[open] .qa__mark::before,
.qa__item[open] .qa__mark::after { background: var(--accent-deep); }
.qa__item > summary:hover .qa__mark::before,
.qa__item > summary:hover .qa__mark::after { background: var(--accent-deep); }

/* Grid-rows rather than height: this animates a single value the compositor
   can interpolate, and it needs no measurement in JavaScript. Browsers that
   do not support the animation simply show the answer immediately. */
.qa__body {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows var(--dur-slow) var(--ease);
}
.qa__item[open] .qa__body { grid-template-rows: 1fr; }
.qa__body > p {
  overflow: hidden;
  margin: 0;
  max-width: 62ch;
  color: var(--ink-2);
}
/* The padding lives on the inner element so it collapses with the row. */
.qa__item[open] .qa__body > p { padding-bottom: clamp(1.25rem, 2.6vw, 1.75rem); }

@media (prefers-reduced-motion: reduce) {
  .qa__body { transition: none; }
}

/* The employer is addressed once, in one sentence, at the point where cost has
   just been discussed. The old treatment gave this audience a full band of its
   own, which put a second offer in front of a reader who had not yet acted on
   the first. */
.qa__aside {
  margin-top: clamp(1.75rem, 3.5vw, 2.5rem);
  max-width: 62ch;
  font-size: var(--step--1);
  color: var(--ink-3);
}
.qa__aside a { color: var(--accent-deep); text-decoration: underline; cursor: pointer; }
.qa__aside a:hover { color: #036c4e; }

/* ---------- 11l. Final call ------------------------------------------------- */

.final { text-align: center; }
.final__in { display: grid; justify-items: center; gap: 1.25rem; }
.final h2 { max-width: 18ch; margin: 0; }
.final .lede { max-width: 46ch; }
.final__actions { justify-content: center; margin-top: 1rem; }
.final__trust { margin-top: .5rem; }

/* ---------- 11c. Reading progress ----------------------------------------- */

.progress {
  position: fixed;
  inset-block-start: 0;
  inset-inline: 0;
  height: 2px;
  z-index: calc(var(--z-header) + 1);
  background: var(--accent);
  transform: scaleX(0);
  transform-origin: 0 50%;
  pointer-events: none;
}

/* ---------- 11d. Footer ---------------------------------------------------- */

/* Deliberately not a four-column link farm. One row of the paths that
   actually get used, the company line, and the legal statement. */

.ftr {
  border-top: 1px solid var(--line);
  padding-block: clamp(3rem, 6vw, 5rem) clamp(2.5rem, 5vw, 4rem);
}
.ftr__in { display: grid; gap: clamp(2rem, 4vw, 3rem); }
.ftr__brand { display: grid; gap: .9rem; justify-items: start; }
.ftr__brand img { width: 150px; height: auto; }

/* Social profiles, under the company line. Deliberately quiet: these are the
   only links on the site that send someone away from the funnel, so they read
   as information about who we are rather than as a call to action. Same ink as
   the footer navigation, accent on hover like everything else.

   44px hit area via padding rather than size, so the touch target meets the
   accessibility minimum without the icons looking heavy. */
/* Author `display` beats the user-agent rule for [hidden], so without this the
   list stays laid out and a visitor with JavaScript off sees three icons
   linking nowhere: precisely the failure the hidden attribute was there to
   prevent. Caught in the browser, not by reading the CSS. */
.ftr__social[hidden] { display: none; }

.ftr__social {
  margin: 0;
  padding: 0;
  list-style: none;
  display: flex;
  gap: .35rem;
  margin-inline-start: -.55rem;   /* optical: pull the padded row back to the text edge */
}
.ftr__social a {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  color: var(--ink-3);
  transition: color var(--dur) var(--ease);
}
.ftr__social a:hover { color: var(--accent); }
.ftr__social a:focus-visible { outline: 2px solid var(--accent); outline-offset: -6px; border-radius: 4px; }

.ftr__nav { display: flex; flex-wrap: wrap; gap: .75rem clamp(1.25rem, 3vw, 2.5rem); }
/* Touch target, 24/08/2026: 34px tall to 44px, via the overlay below rather
   than padding. Padding plus a cancelling negative margin was tried first and
   rejected: flex `gap` is measured between margin boxes, so the negative
   margin widened the visual row spacing instead of cancelling out, and the
   footer shifted. An out-of-flow overlay cannot do that. */
.ftr__nav a {
  position: relative;
  font-size: var(--step--1);
  color: var(--ink-2);
  text-decoration: none;
  padding-block: .35rem;
  border-bottom: 1px solid transparent;
  transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
/* Vertical only. The rows sit .75rem (12px) apart, so 5px each way clears
   44px without two rows' hit regions ever meeting. */
.ftr__nav a::before { content: ""; position: absolute; inset: -5px 0; }
.ftr__nav a:hover { color: var(--ink); border-bottom-color: var(--accent); }

/* Accreditation lockup. Greyscale at rest so five third-party brand colours
   never compete with the single accent; colour returns on hover for anyone
   inspecting them, which is exactly when the colour is useful. */
.ftr__accred { padding-top: clamp(1.5rem, 3vw, 2.25rem); border-top: 1px solid var(--line); }
.ftr__accred .label { margin: 0 0 1.25rem; }

.ftr__marks {
  margin: 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: clamp(1.5rem, 4vw, 3rem);
}
/* Rest state has to carry the meaning on its own. The hover restore below is
   desktop-only, and most of this audience is on Android, so anything that is
   only legible on hover is not legible. contrast(.9) is also removed for the
   same reason it was removed from the client wall above: it flattens the thin
   strokes in the TQUK and HRD Corp artwork into the background. */
.ftr__marks img {
  height: 30px;
  width: auto;
  filter: grayscale(1);
  opacity: .88;
  transition: opacity var(--dur) var(--ease), filter var(--dur) var(--ease);
}
/* The two HRD Corp marks are round seals, not wordmarks. Matching all five on
   height gives a seal a thirtieth of the drawing area a wordmark gets, and its
   inner ring of type disappears. Match them on optical area instead. */
.ftr__marks img[src$="hrdc-registered-tp.png"],
.ftr__marks img[src$="hrdc-claimable.png"] { height: 46px; }
/* Same optical-area reasoning applied to the two awarding-body wordmarks
   (25/08/2026). At a shared 30px the row measured Highfield 57.5, NCFE 56.1
   and TQUK 41.2 on sqrt(width x height), because NCFE is a squat wide lockup
   and TQUK is a tall stacked one. Height parity was producing size disparity.
   NCFE to 25px and TQUK to 34px brings both to 46.8 and 46.7, matching the two
   seals at 46.0. Highfield is deliberately left at 30px and so stays the
   largest mark in the row at 57.5: it is the primary awarding partner and the
   hierarchy is intended. Do not "tidy" these to a single height.
   Note the residual difference is ink density, not size: NCFE is 77.5% ink
   coverage against TQUK's 13.7%, so NCFE still carries more visual weight at
   equal optical area. That is an artwork problem, not a CSS one. */
.ftr__marks img[src$="ncfe.png"] { height: 25px; }
.ftr__marks img[src$="tquk.png"] { height: 34px; }
.ftr__marks li:hover img { opacity: 1; filter: none; }

.ftr__legal { max-width: 62ch; padding-top: clamp(1.5rem, 3vw, 2.25rem); }

@media (min-width: 900px) {
  .ftr__in { grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); align-items: start; }
  .ftr__nav { justify-content: flex-end; }
  .ftr__accred, .ftr__legal { grid-column: 1 / -1; }

  /* Both of these are consequences of the social icons arriving on 11/09/2026,
     and both are desktop-only because that is where the two-column grid is and
     where the problem was measured. The stacked mobile footer was checked and
     left alone.

     The icons made the brand column 111px taller than the nav column, and with
     `align-items: start` the nav stayed pinned level with the logo, leaving a
     hole in the bottom right of the band.

     The nav now sits on the same optical row as the company line. 34px is the
     rendered logo height (150px wide at its 150:34 intrinsic ratio) and the
     extra .5rem lands the nav's text centre within a pixel of the note's:
     measured 228 against 229 in the browser at 1264px.

     Tried and rejected on 11/09/2026: `align-self: end`, which put the nav on
     the icon baseline. It closed the hole and was geometrically tidy, and Qi
     Jie's reaction was that it read as a detached foot. He was right. The
     company line and the nav are both secondary information about who we are,
     so they belong on a row together; the icons are a third thing and can sit
     under them. The hole is smaller than before and now reads as deliberate
     rather than as something that slipped.

     The pull-up is optical. The icons carry a 44px touch target around a 20px
     glyph, so 12px of it is dead space at the top, and the gap under the
     company line read as ~32px against the 14px rhythm of everything else in
     that stack. Measured, not guessed: note bottom 239, icon box top 271. */
  .ftr__nav { align-self: start; margin-block-start: calc(34px + .5rem); }
  .ftr__social { margin-block-start: -.7rem; }
}

/* ---------- 12. Floating WhatsApp action ---------------------------------- */

.wa-fab {
  position: fixed;
  inset-block-end: clamp(1rem, 3vw, 1.75rem);
  inset-inline-end: clamp(1rem, 3vw, 1.75rem);
  z-index: var(--z-fab);
  width: 54px; height: 54px;
  display: grid; place-items: center;
  border-radius: 50%;
  background: var(--wa);
  /* White on WhatsApp green is the official lockup, and the one v1 shipped.
     It is a brand mark rather than an icon we are free to restyle: people
     identify it by silhouette and colour before they read anything, so
     matching the app exactly is worth more than the contrast a darker glyph
     would buy. aria-label carries the meaning for anyone who cannot see it. */
  color: #fff;
  box-shadow: 0 6px 20px rgba(19, 32, 27, .18);
  transition: transform var(--dur) var(--ease);
}
.wa-fab:hover { transform: translateY(-2px) scale(1.03); }

/* ---------- 13. Reduced motion -------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
  }
  .reveal-words .w { color: inherit !important; }

  /* The hero collapses to a single static screen. The blanket rule above kills
     the scroll timelines, which would otherwise leave the stage pinned through
     180vh of empty scrolling with nothing moving: a worse experience than the
     motion it was avoiding. The grain stops entirely. */
  .hero { height: auto !important; }
  .hero__stage {
    /* relative, not static. Same reason as the @supports fallback above: static
       drops the containing block for the absolutely positioned .hero__topo
       lattices, they escape `overflow: clip`, and the homepage gains a
       horizontal scrollbar for exactly the visitors who asked for less motion.
       Verified 25/08/2026: relative takes horizontal scroll to zero at 360,
       390, 768, 1280 and 1920 with the hero pixel-identical. */
    position: relative !important;
    height: auto !important;
    min-height: 100dvh;
    padding-block: calc(var(--header-h) + clamp(3rem, 9vh, 6rem)) clamp(3rem, 8vw, 5.5rem);
  }
  .hero__topo, .hero__wash2, .hero__in, .hero__grain { animation: none !important; }
  .hero__topo--far  { transform: scale(1.14) translate3d(-1%, -3%, 0); opacity: .5; }
  .hero__topo--mid  { transform: scale(1.1)  translate3d(-2%, -4%, 0); opacity: .85; }
  .hero__topo--near { transform: scale(1.06) translate3d(-3%, -5%, 0); opacity: .7; }
  .hero__wash2 { opacity: .55; }
  .hero__in { opacity: 1 !important; transform: none !important; }
  .hero__cue { display: none; }
}

/* ---------- 14. Narrow viewports ------------------------------------------ */

/* Below 360px the hero display has to come down hard, and Bahasa Malaysia is
   why. "membuktikannya." measures 388px at the 48px this scale would otherwise
   give, against 280px of content width, so it is either clipped by the screen
   or broken across two lines; broken, it takes the headline to eight lines and
   pushes the button 53px below the fold. At 2.125rem the longest word fits its
   line whole, the headline is five lines and everything stays above the fold.
   320px is the narrowest phone still in real use; 360 is the width the rest of
   the site is designed against. */
@media (max-width: 359px) {
  :root { --step-5: 2.125rem; }
}

/* Short landscape windows, added 28/08/2026. A 900x600 or 1024x600 window is
   a laptop with a half-height browser, or a phone held sideways. The stacked
   hero sizes its display from the width alone, so at 900px wide it asks for
   86px of type in 600px of height and the button falls below the fold. It did
   so before the leading was corrected as well; the correction made it worse,
   which is what surfaced it. Portrait phones are deliberately excluded: 360x640
   is short too, but there the width is already doing the limiting. */
@media (orientation: landscape) and (max-height: 640px) {
  :root { --step-5: clamp(2.5rem, 1.2rem + 4vw, 3.5rem); }
}

@media (max-width: 860px) {
  .nav-toggle {
    display: inline-grid;
    place-items: center;
    /* 44px, not 42: two pixels short of the usual minimum for no reason. */
    width: 44px; height: 44px;
    margin-inline-start: auto;
    background: none;
    border: 1px solid var(--line);
    border-radius: 5px;
    color: var(--ink);
    cursor: pointer;
  }
  .hdr__brand { margin-inline-end: 0; }
  .nav {
    position: fixed;
    inset: var(--header-h) 0 auto;
    flex-direction: column;
    align-items: stretch;
    gap: 0;
    padding: 1rem var(--gutter) 1.75rem;
    background: var(--bg);
    border-bottom: 1px solid var(--line);
    transform: translateY(-8px);
    opacity: 0;
    visibility: hidden;
    transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease), visibility var(--dur);
  }
  .nav.is-open { opacity: 1; visibility: visible; transform: translateY(0); }
  .nav a:not(.btn) { padding-block: .9rem; border-bottom: 1px solid var(--line); font-size: 1.0625rem; }
  .nav .btn { margin-top: 1rem; justify-content: center; padding-block: .9rem; }
  .lang { margin-top: 1.25rem; align-self: flex-start; }

  /* A tighter lattice on phones: the finer pitch keeps the contour reading at
     the smaller frame. The runway override that used to live here is gone;
     the base is now 70vh too, and duplicating the value here would mean a
     later change to the base silently failed to reach phones. */
  .hero { --qm-lattice: 9px; }
  .hero h1 { max-width: none; }
  .hero__lede { max-width: 42ch; }
  /* The wash was graded for a wide frame; on a portrait screen the type sits
     over the middle of the field rather than to its left, so the flat zone has
     to come from the top instead of the side. */
  .hero__wash {
    background: linear-gradient(168deg,
      var(--bg) 0%,
      rgba(251, 251, 250, .92) 34%,
      rgba(251, 251, 250, .45) 62%,
      rgba(251, 251, 250, 0) 88%);
  }

  .stmt { grid-template-columns: minmax(0, 1fr); gap: 1rem; }
  .stmt__n { border-top: 0; padding-top: 0; }
}

/* ===========================================================================
   15. INTERIOR PAGES
   Everything below is used only by the pages behind the homepage: the
   explainer, progression, about, the five guides, and the two legal
   documents. They previously ran on the v1 stylesheet, so a visitor
   following any link out of the homepage landed on a different-looking site.

   This is a chrome-and-tokens pass, not a redesign. Structure and copy are
   left alone; what changes is that everything now speaks the v3 vocabulary:
   hairline rules instead of elevation, mono for labels and metadata, one
   accent, and the same type scale as the homepage.
   =========================================================================== */

/* ---------- 15a. Shared helpers ------------------------------------------- */

.center { text-align: center; }
.measure--center { margin-inline: auto; }
.actions--center { justify-content: center; }

/* v1's ghost button is v3's quiet button; kept as an alias so the guides do
   not need per-file edits if one is missed. */
.btn--ghost { background: transparent; color: var(--ink); border-color: var(--line-strong); }
.btn--ghost:hover { border-color: var(--ink); color: var(--ink); }

/* ---------- 15b. Long-form prose ------------------------------------------ */

/* The guides are the organic search surface, so they are read rather than
   scanned. Measure is capped at 68 characters and the vertical rhythm is
   looser than the homepage's. */
/* The 68ch cap is a READING MEASURE, and it belongs on the flow content, not
   on the container. It used to sit here, on the same element as .wrap, which
   collapsed the entire article to 668px: breadcrumb, h1, meta and all. Two
   consequences, both visible on any desktop screen. The article floated 286px
   to the right of the site header's left edge, because .wrap centres at 1240px
   and .prose centred at 668px. And the h1 wrapped over five lines inside a
   572px column, which is why the page read as a phone layout on a monitor.

   Now the container is the site's normal column, so the article lines up with
   the masthead, and only the prose inside it is measured. Tables opt out: they
   need whatever width they can get. */
/* NO max-width on .prose itself. Setting one here — even `none` — overrides
   .wrap's own max-width, because both are single-class selectors and .prose
   comes later in the file. `68ch` collapsed the article to 668px; `none` blew
   it out to the full viewport. The container is .wrap's job; .prose only
   measures what is inside it. */
.prose > * { max-width: 68ch; }
.prose > .breadcrumb,
.prose > .article-meta,
.prose > .cmp-wrap,
.prose > .faq { max-width: none; }
/* A display-size headline wants a tighter measure than body prose, the same
   way .stmt h3 does, or it runs to a very long single line on a wide screen. */
.prose > h1 { max-width: 18ch; }
.prose > * + * { margin-top: 1.15rem; }
.prose h2 {
  font-size: var(--step-2);
  margin: clamp(2.5rem, 5vw, 3.5rem) 0 0;
  padding-top: clamp(1.25rem, 2.5vw, 1.75rem);
  border-top: 1px solid var(--line);
  max-width: 24ch;
}
.prose h2:first-child { margin-top: 0; border-top: 0; padding-top: 0; }
.prose h3 { font-size: var(--step-1); margin-top: clamp(1.75rem, 3.5vw, 2.5rem); }
.prose p, .prose li { color: var(--ink-2); }
.prose ul, .prose ol { padding-inline-start: 1.25rem; display: grid; gap: .6rem; }
.prose li::marker { color: var(--accent-deep); }
.prose strong { color: var(--ink); }
.prose a { text-decoration: underline; }

/* ---------- 15c. Breadcrumb ------------------------------------------------ */

.breadcrumb {
  margin: 0 0 clamp(1.5rem, 3vw, 2.25rem);
  font: 400 var(--mono-label)/1.4 var(--mono);
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--ink-3);
}
.breadcrumb a { color: var(--ink-3); text-decoration: none; border-bottom: 1px solid transparent; }
.breadcrumb a:hover { color: var(--ink); border-bottom-color: var(--accent); }
.breadcrumb__sep { margin-inline: .5rem; opacity: .5; }

/* ---------- 15d. Article metadata and closing call ------------------------- */

.article-meta {
  margin: 0 0 clamp(2rem, 4vw, 3rem);
  padding-bottom: clamp(1.25rem, 2.5vw, 1.75rem);
  border-bottom: 1px solid var(--line);
  font: 400 var(--step--1)/1.6 var(--mono);
  letter-spacing: .02em;
  color: var(--ink-3);
}

.article-cta {
  margin-top: clamp(3rem, 6vw, 4.5rem);
  padding-top: clamp(1.75rem, 3.5vw, 2.5rem);
  border-top: 1px solid var(--line-strong);
  display: grid;
  gap: 1.25rem;
  justify-items: start;
}
.article-cta p { max-width: 52ch; margin: 0; }

/* ---------- 15e. Guides index --------------------------------------------- */

/* A ruled list rather than a grid of equal cards. The pillar guide is given
   the first row on its own so the set reads as one lead plus four
   supporting pieces, not five interchangeable tiles. */
.guides-grid { display: grid; gap: 0; }

/* Each guide is a single anchor wrapping its whole block, which makes the
   entire row a target rather than just the headline. That means the element
   inherits link colour, so it has to be reset explicitly or every word of
   every card renders emerald. */
.guide {
  display: grid;
  gap: .6rem;
  padding-block: clamp(1.5rem, 3vw, 2.25rem);
  border-top: 1px solid var(--line);
  color: inherit;
  text-decoration: none;
  transition: border-color var(--dur) var(--ease);
}
.guide:last-child { border-bottom: 1px solid var(--line); }
.guide h2, .guide h3 {
  font-size: var(--step-1);
  margin: 0;
  max-width: 28ch;
  color: var(--ink);
  transition: color var(--dur) var(--ease);
}
.guide p { margin: 0; max-width: 60ch; color: var(--ink-2); }
.guide:hover { border-top-color: var(--accent); }
.guide:hover h2, .guide:hover h3 { color: var(--accent-deep); }

.guide__tag {
  font: 400 var(--mono-label)/1 var(--mono);
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--ink-3);
}
.guide--pillar .guide__tag { color: var(--accent-deep); }
.guide--pillar h2 { font-size: var(--step-2); max-width: 22ch; }

/* A span, not a nested link: the card is already the anchor, and a link
   inside a link is invalid. Styled as an affordance only. */
.guide__more {
  justify-self: start;
  font-size: var(--step--1);
  color: var(--ink);
  border-bottom: 1px solid var(--line-strong);
  padding-bottom: .1rem;
  transition: color var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.guide:hover .guide__more { color: var(--accent-deep); border-bottom-color: var(--accent-deep); }

@media (min-width: 860px) {
  .guides-grid { grid-template-columns: 1fr 1fr; column-gap: clamp(2rem, 5vw, 4rem); }
  .guide--pillar { grid-column: 1 / -1; }
}

/* ---------- 15f. Four-step track (explainer page) ------------------------- */

.steptrack { position: relative; }
.strack { margin: 0; padding: 0; list-style: none; display: grid; gap: clamp(1.75rem, 3.5vw, 2.5rem); }
.strack__item {
  display: grid;
  grid-template-columns: 2.5rem minmax(0, 1fr);
  gap: clamp(1rem, 2.5vw, 1.5rem);
  align-items: start;
  padding-top: 1.1rem;
  border-top: 1px solid var(--line-strong);
}
.strack__item h3 { margin: 0 0 .5rem; font-size: var(--step-1); }
.strack__item p { margin: 0; max-width: 54ch; }
.strack__dot {
  font: 400 var(--mono-fig)/1 var(--mono);
  letter-spacing: .1em;
  color: var(--accent-deep);
  font-variant-numeric: tabular-nums;
  padding-top: .35em;
}
/* The v1 connector line was drawn behind numbered dots that no longer exist
   in this treatment; the rules above each step carry the sequence instead. */
.strack-line { display: none; }

/* ---------- 15g. Progression ladder --------------------------------------- */

.ladder { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
.rung {
  display: grid;
  gap: .5rem;
  padding-block: clamp(1.25rem, 2.6vw, 1.75rem);
  border-top: 1px solid var(--line);
}
.rung:last-child { border-bottom: 1px solid var(--line); }
.rung h3 { margin: 0; font-size: var(--step-1); }
.rung p { margin: 0; max-width: 56ch; }
/* The qualification the reader already holds, or is about to. */
.rung--top { border-top-color: var(--accent); }
.rung--top h3 { color: var(--accent-deep); }

/* ---------- 15g-bis. Client wall ------------------------------------------

   Built to the Claude Design sample. Values taken from it directly:
     grid    repeat(6, 1fr), gap 16px
     tile    #fff, 1px #e4e7e4, radius 6px, height 116px
     hover   translateY(-4px), 0 10px 26px rgba(19,32,27,.09), border #d3ded8
     logo    object-fit: contain, grayscale(1)
     rule    1px #e4e7e4 running out to a mono label

   Two deliberate departures, both noted for the record:

   1. NO contrast() on the logo filter. An earlier version here used
      grayscale(1) contrast(.95), which pulls #ffffff to rgb(249,249,249). On a
      white tile that reads as a grey panel behind every logo which then
      "fades" on hover, because hover sets filter:none. The sample has no
      contrast() and was right not to.

   2. The label is --accent-deep (#047857), not the sample's #059669, matching
      the rest of the site. #059669 measures 3.45:1 at 11px on the sunk
      background, under the 4.5:1 floor for small text; this stylesheet already
      restricts that green to display sizes. #047857 measures 5.01:1.

   The sample also carried twelve hand-tuned paddings, 16px to 38px, to even
   out logo sizes. Those are gone: tools/normalise-logos.py bakes the optical
   balance into the artwork, so one rule covers all twelve at any tile size. */

.clients {
  margin: clamp(2rem, 4vw, 3rem) 0 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 16px;
  /* The sample is a fixed six columns, which is a desktop-only design. Twelve
     logos want to divide cleanly at every width: 6x2, 4x3, 3x4, 2x6. */
  grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 560px)  { .clients { grid-template-columns: repeat(3, 1fr); } }
@media (min-width: 860px)  { .clients { grid-template-columns: repeat(4, 1fr); } }
@media (min-width: 1140px) { .clients { grid-template-columns: repeat(6, 1fr); } }

.clients li {
  height: 116px;
  background: var(--bg-raised);
  border: 1px solid var(--line);
  border-radius: 6px;
  transition: transform .35s var(--ease), box-shadow .35s var(--ease), border-color .35s var(--ease);
}

.clients img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
  box-sizing: border-box;
  padding: 14px 20px;
  filter: grayscale(1);
  opacity: .72;
  transition: filter .35s var(--ease), opacity .35s var(--ease);
}

.clients li:hover {
  transform: translateY(-4px);
  box-shadow: 0 10px 26px rgba(19, 32, 27, .09);
  border-color: #d3ded8;
}
.clients li:hover img { filter: none; opacity: 1; }

/* "There are more of them than this." A hairline running out of the grid to a
   mono label, exactly as the sample draws it. Two real spans rather than a
   pseudo-element: it is what the sample uses, and it survives any stylesheet
   that fails to load with the words still on the page. */
.clients-more {
  display: flex;
  align-items: center;
  gap: 20px;
  margin-top: 28px;
}
.clients-more__rule {
  flex: 1;
  height: 1px;
  background: var(--line);
}
.clients-more__label {
  flex: none;
  margin: 0;
  font: 500 var(--mono-label)/1 var(--mono);
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--accent-deep);
}

@media (prefers-reduced-motion: reduce) {
  .clients li, .clients img { transition: none; }
  .clients li:hover { transform: none; }
}

/* ---------- CJK measure correction ----------------------------------------

   Every measure in this stylesheet is set in `ch`, which is the advance width
   of the "0" glyph in the Latin font. A Chinese glyph is roughly twice that
   wide, so a heading capped at 20ch fits about twenty English words' worth of
   characters and only about ten Chinese ones. Headings were breaking after
   nine or ten characters, mid-phrase and mid-quotation:
   "这里说的「受认 / 证」到底是什么意思".

   `em` is the right unit for CJK, because one CJK glyph is one em by
   definition. The figures below are chosen to give a comparable number of
   GLYPHS per line to what the ch values give in English: display headings
   around fourteen, section headings around twenty, prose headings a little
   more. Scoped to the language attribute, which i18n.js sets on <html>, so
   English and BM are untouched.                                            */

html[lang="zh-Hans"] {
  --measure-display: 14em;   /* hero and page headlines */
  --measure-head:    20em;   /* section headings */
  --measure-prose:   30em;   /* body copy and article headings */
}

html[lang="zh-Hans"] .pagehead h1,
html[lang="zh-Hans"] .notfound h1,
html[lang="zh-Hans"] .stats__copy h2 { max-width: var(--measure-display); }

html[lang="zh-Hans"] .pagehead--long h1,
html[lang="zh-Hans"] .sec-head h2,
html[lang="zh-Hans"] .check__copy h2,
html[lang="zh-Hans"] .onward h2,
html[lang="zh-Hans"] .final h2,
html[lang="zh-Hans"] .steps__tail h3,
html[lang="zh-Hans"] .guide--pillar h2 { max-width: var(--measure-head); }

html[lang="zh-Hans"] .prose > h1,
html[lang="zh-Hans"] .prose > h2,
html[lang="zh-Hans"] .prose > h3 { max-width: var(--measure-prose); }

/* Body copy: 68ch is about 68 Latin characters but only ~34 Chinese ones,
   which is close enough to a comfortable CJK line that it needs no change.
   The ledes are tighter, so they do. */
html[lang="zh-Hans"] .pagehead .lede,
html[lang="zh-Hans"] .sec-head .lede { max-width: var(--measure-prose); }

/* ---------- 15h. Comparison table ----------------------------------------- */

.cmp-wrap { overflow-x: auto; margin-top: clamp(1.5rem, 3vw, 2rem); }
.cmp-table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--step--1);
  min-width: 34rem;
}
.cmp-table th, .cmp-table td {
  text-align: start;
  padding: .9rem 1rem .9rem 0;
  border-bottom: 1px solid var(--line);
  vertical-align: top;
}
.cmp-table thead th {
  font: 400 var(--mono-label)/1.4 var(--mono);
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--ink-3);
  border-bottom-color: var(--line-strong);
}
.cmp-table tbody th { font-weight: 500; color: var(--ink); }
.cmp-table td { color: var(--ink-2); }

/* ---------- 15i. Callout, trust strip, cards ------------------------------ */

/* Not a tinted box. A rule and a wider measure, so an aside reads as the
   author stepping forward rather than as a coloured panel. */
/* Was a left-hand accent stripe. A coloured border on one side of a block is
   a decoration the rest of this site never uses — every other aside, note and
   divider here is a full-width hairline rule. Replaced with the same rule plus
   a mono label, which is the vocabulary already established by the statistics
   footnote and the step metadata. */
.callout {
  margin-block: clamp(2rem, 4vw, 3rem);
  padding-top: clamp(1.1rem, 2.4vw, 1.5rem);
  border-top: 1px solid var(--accent);
  max-width: 60ch;
}
.callout::before {
  content: attr(data-label);
  display: block;
  margin-bottom: .6rem;
  font: 500 var(--mono-label)/1 var(--mono);
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--accent-deep);
}
.callout p { margin: 0; color: var(--ink); }

/* ---------- 15c2. Hairline-dash list ---------------------------------------
   Generalised from .check__steps, which had this treatment scoped to a single
   section. A bare <ul> appears nowhere on the homepage; where a list of short
   parallel items is needed, the marker is a hairline dash in the accent. Any
   page can now reach for it instead of falling back to browser bullets. */
.rule-list {
  margin: 1.25rem 0 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: .7rem;
}
/* The marker is drawn with absolute positioning, NOT as a grid column.
   This used to be `display: grid; grid-template-columns: 1.15rem 1fr`, with
   ::before filling column one. That works only while the <li> contains a
   single run of text. Give it `<strong>HRD Corp</strong> runs the scheme…` and
   the <strong> and the text after it become TWO grid items: the strong takes
   column two, and the text wraps onto the next row into the 1.15rem marker
   column, where it renders one word per line. Thirteen list items across two
   guides were doing exactly that.

   The text run is an anonymous grid item, so no selector can reach it and no
   `grid-column` rule can rescue it. The layout model itself had to change.
   Padding plus an absolute marker is immune to whatever the content contains. */
.rule-list li {
  position: relative;
  padding-inline-start: 1.9rem;      /* 1.15rem rule + .75rem gap, as before */
  color: var(--ink-2);
  line-height: 1.55;
}
.rule-list li::before {
  content: "";
  position: absolute;
  inset-inline-start: 0;
  top: .78em;
  width: 1.15rem;
  height: 1px;
  background: var(--accent);
}
/* Two columns once the items are short enough to pair without wrapping. */
@media (min-width: 720px) {
  .rule-list--pair { grid-template-columns: 1fr 1fr; column-gap: clamp(2rem, 4vw, 3rem); }
}
.callout p { margin: 0; color: var(--ink); }

.trust, .centre-logos {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: clamp(1.5rem, 4vw, 3rem);
  margin-top: clamp(1.5rem, 3vw, 2.25rem);
}
.trust img, .centre-logos img {
  height: 30px;
  width: auto;
  filter: grayscale(1) contrast(.9);
  opacity: .62;
  transition: opacity var(--dur) var(--ease), filter var(--dur) var(--ease);
}
.trust img:hover, .centre-logos img:hover { opacity: 1; filter: none; }

/* Three eligibility points on the explainer. Ruled columns, not cards: the
   page already has one elevated object and it is the call to action. */
.grid-3 { display: grid; gap: clamp(1.5rem, 3vw, 2.25rem); margin-top: clamp(1.5rem, 3vw, 2rem); }
@media (min-width: 720px) { .grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
.card {
  display: grid;
  gap: .5rem;
  padding-top: 1.1rem;
  border-top: 1px solid var(--line-strong);
}
.card h3 { margin: 0; font-size: var(--step-0); }
.card p { margin: 0; font-size: var(--step--1); }

/* ---------- 15j. Legal documents ------------------------------------------ */

/* Fourth and last instance of the container-versus-measure fault. .legal sat
   on the same element as .wrap, so Terms and Privacy centred at 70ch and began
   276px right of the masthead on a 1440px screen. Measure the contents. */
/* No max-width on .legal itself, not even `none`: any value here overrides
   .wrap's own max-width, because both are single-class selectors and .legal is
   declared later. 70ch collapsed the column; `none` blew it full-bleed. */
.legal > * { max-width: 70ch; }
.legal h2 {
  font-size: var(--step-1);
  margin: clamp(2rem, 4vw, 2.75rem) 0 .75rem;
  padding-top: clamp(1rem, 2vw, 1.4rem);
  border-top: 1px solid var(--line);
}
.legal h2:first-of-type { margin-top: 0; border-top: 0; padding-top: 0; }

/* Body copy at --step-0, not --step--1.
   These two pages are eleven sections of continuous prose that someone reads
   when they are looking for one specific clause: the retention period, what
   gets shared with the awarding organisations, how to withdraw consent. That
   is the worst possible place for the smallest type on the site. --step--1
   bottoms out at 13px, which is fine for a caption under a card and not fine
   for four hundred words of policy. The colour was never the problem here
   (--ink-2 on --bg measures 7.96:1); the size was. */
.legal p, .legal li { color: var(--ink-2); font-size: var(--step-0); line-height: 1.75; }
.legal ul, .legal ol { padding-inline-start: 1.25rem; display: grid; gap: .6rem; }
.legal p + p, .legal p + ul, .legal p + ol { margin-top: .9em; }

.legal-meta {
  font: 400 var(--step--1)/1.6 var(--mono);
  letter-spacing: .02em;
  color: var(--ink-3);
  margin-bottom: clamp(2rem, 4vw, 3rem);
  padding-bottom: 1.25rem;
  border-bottom: 1px solid var(--line);
}

/* ---------- 15k. FAQ on interior pages ------------------------------------ */

/* The v1 pages use <details> inside .faq. Given the same treatment as the
   homepage accordion so the two never look like different components. */
.faq { display: grid; }
.faq details { border-top: 1px solid var(--line); }
.faq details:last-of-type { border-bottom: 1px solid var(--line); }
.faq summary {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 1.5rem;
  padding-block: clamp(1.1rem, 2.4vw, 1.5rem);
  cursor: pointer;
  list-style: none;
  font: 500 var(--step-1)/1.3 var(--font);
  letter-spacing: -.018em;
  color: var(--ink);
  transition: color var(--dur) var(--ease);
}
.faq summary::-webkit-details-marker { display: none; }
.faq summary::after {
  content: "+";
  flex: none;
  font: 400 1.1em/1 var(--mono);
  color: var(--ink-3);
  transition: transform var(--dur) var(--ease), color var(--dur) var(--ease);
}
.faq details[open] summary::after { content: "\2013"; color: var(--accent-deep); }
.faq summary:hover { color: var(--accent-deep); }
.faq details > p {
  margin: 0;
  padding-bottom: clamp(1.1rem, 2.4vw, 1.5rem);
  max-width: 62ch;
  color: var(--ink-2);
}

/* ---------- 15l. 404 ------------------------------------------------------- */

.notfound { display: grid; gap: 1.25rem; justify-items: start; min-height: 46vh; align-content: center; }
.notfound h1 { font-size: var(--step-3); max-width: 18ch; }

/* ---------- 15m. Interior page header ------------------------------------- */

/* Why this exists: with the chrome ported and the tokens shared, interior
   pages still read as a different site. The homepage's visual signature is not
   its colour or its type — it is the stipple topography behind the hero. Every
   interior page opened on flat white, so the family resemblance stopped at the
   header bar.

   This is the hero field at a quarter of its intensity: same lattice, same
   pitch, same mask geometry, no scroll-driven scaling and no grain. Enough to
   say "same site", not so much that a 400-word explainer looks like a landing
   page. */

.pagehead {
  position: relative;
  overflow: clip;
  padding-block: calc(var(--header-h) + clamp(2.5rem, 7vh, 5rem)) clamp(2.5rem, 6vw, 4.5rem);
  border-bottom: 1px solid var(--line);
}

.pagehead__field {
  position: absolute;
  inset: -20% -10% -30%;
  z-index: 0;
  pointer-events: none;
  background-image: radial-gradient(circle, rgba(19, 32, 27, .42) 1.2px, transparent 1.35px);
  background-size: 13px 11.26px;
  -webkit-mask-image: radial-gradient(ellipse 70% 78% at 84% 34%, #000 0%, rgba(0,0,0,.3) 44%, transparent 72%);
          mask-image: radial-gradient(ellipse 70% 78% at 84% 34%, #000 0%, rgba(0,0,0,.3) 44%, transparent 72%);
  opacity: .32;
}

/* One emerald wash, matching the hero's, so the accent is present above the
   fold on every page rather than only where a link happens to fall. */
.pagehead__glow {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background: radial-gradient(ellipse 40% 60% at 86% 28%, rgba(5, 150, 105, .10), transparent 66%);
}

.pagehead__in { position: relative; z-index: 1; }
.pagehead h1 { font-size: var(--step-4); letter-spacing: -.035em; line-height: 1.02; max-width: 18ch; margin: 0; }

/* For a headline that has to carry a phrase rather than make a statement.
   --step-4 at 18ch is tuned for the four-to-six-word headings the interior
   pages use ("And then what?", "How RPEL works"); at that size anything past
   about forty characters stacks into five or six lines and reads as a wall
   rather than a headline. The guides index has to lead with the full
   "Recognition of Prior Experiential Learning" for search and for the
   expand-before-first-use rule, so it cannot simply be shortened. One step
   down with a wider measure keeps it to three lines and keeps the rhythm. */
.pagehead--long h1 { font-size: var(--step-3); max-width: 26ch; }

.pagehead .lede { margin-top: clamp(1.1rem, 2.4vw, 1.6rem); max-width: 56ch; }
.pagehead .breadcrumb { margin-bottom: clamp(1.25rem, 2.6vw, 1.9rem); }
/* The paid landing page opens on an eyebrow where the interior pages open on a
   breadcrumb, so it needs the same optical gap before the headline. */
.pagehead .eyebrow { margin-bottom: clamp(1.25rem, 2.6vw, 1.9rem); }
.pagehead .actions { margin-top: clamp(1.75rem, 3.5vw, 2.5rem); }

/* The rule under the headline, as on the hero. Static here: the hero's version
   is scrubbed to scroll, but that belongs to a full-height stage, not to a
   page header the reader passes in a second. */
.pagehead__rule {
  height: 1px;
  background: var(--line-strong);
  max-width: 34rem;
  margin: clamp(1.5rem, 3vw, 2.2rem) 0 0;
}

@media (prefers-reduced-motion: reduce) {
  .pagehead__field { opacity: .24; }
}
