/* ═══════════════════════════════════════════════════════════════════════════
   KORVA POLISH — Session 7, Agent M.  LOADS LAST, after app.css.

   WHY THIS FILE EXISTS
   --------------------
   The five public surfaces were built by three different sessions and ended up
   on three different design systems:

       /                     `lnd-*`   static/landing.css   (Agent J, newest)
       /pricing /how-it-works `ks-*`   static/surfaces.css  (Session 6)
       /login /register       `k-*`    static/style.css     (older still)

   Measured at a 1440px viewport before any change was made:

     * 42 distinct font sizes across the five pages, 27 of them fractional
       (11.52px, 14.72px, 16.48px, 20.16px, 21.44px ...), because three sheets
       size in rem/em off different roots.
     * 66 distinct computed line-heights, including 23.8656px and 27.4752px.
     * The content rail started at THREE different x positions -- landing 160px,
       pricing/how-it-works 240px, login/register 57.6px -- while the nav brand
       sat at a fourth, 86.4px, on all five.
     * The primary CTA was a different colour, radius, size, weight AND typeface
       on three of the five pages:
           landing   #005F82  999px  16px/700 Manrope
           pricing   #2f5fa8   12px  16px/600 Lexend
           login     #2f5fa8    8px  16.48px/500 Lexend

   That last one is the headline defect, and it is visible WITHOUT leaving the
   home page: the nav's "Start free" pill paints from --accent (#2f5fa8) while
   the brand mark beside it and the hero CTA below it are both Deep Cerulean
   (#005F82).  Two different primary blues, 40px apart, above the fold.

   WHAT IS FIXED WHERE
   -------------------
   Structural drift was fixed at the source: /pricing and /how-it-works were
   moved off `ks-*` onto the `lnd-*` system they should always have shared with
   the landing page, so there is now ONE marketing system rather than two.  This
   file carries only what has to be global -- the accessibility layer, the motion
   layer, the public-nav rail, and the auth-page reconciliation.

   ⚑ GLOBAL CSS HAS A GLOBAL BLAST RADIUS.  Agent K owns the logged-in surfaces
   this session, so every rule below is one of exactly three kinds:

   (a) An accessibility fix that is an improvement everywhere by construction --
       the focus ring (section 2), reduced motion (section 3), and the four
       measured WCAG failures in sections 7 and 8.
   (b) Scoped to a marketing or auth selector that cannot match a logged-in
       page: `.knav--public`, `.knav--auth`, `.k-auth`.
   (c) Section 8 only: ONE shared colour token, `--ink-mute`, re-pointed on
       `body.has-knav-bottom, .ops-page`.  This is called out separately because
       it is the one rule here that does reach Agent K's surfaces by design --
       it was 24 of the 45 measured AA failures, and the fix is to make a
       hardcoded literal read the palette token every line around it reads.

   NO RULE HERE CHANGES A LAYOUT PROPERTY ON A LOGGED-IN SURFACE.  The only
   layout declarations in this file sit under `.knav--public` / `.knav--auth`.
   That is the invariant that makes the 85-check overflow sweep safe to trust.

   ⚑ CONTRAST IS MEASURED.  scratch_agent_m/contrast.py computes the WCAG 2.1
   ratio for every pair this file ships and exits non-zero on a failure.  Numbers
   in the comments came out of that script.  Re-run it if you touch a colour.
   ═══════════════════════════════════════════════════════════════════════════ */


/* ── 1. SCALE TOKENS ───────────────────────────────────────────────────────
   One type scale and one spacing scale, replacing 42 ad-hoc font sizes and 30
   ad-hoc padding values.  Whole and half pixels only: the fractional sizes were
   never chosen, they were rem arithmetic leaking out, and they are what made
   the line-height set explode to 66 values.

   ⚑ EIGHT STEPS COLLAPSED TO SIX (Session 8). This scale was built one session
   before static/type.css and they were two scales for the same job — 12/13/14/
   15/16/18/21/27 against 13/15/17/22/32/48. Two scales is how you get 12px in
   one card and 13px in the one beside it. The eight names are KEPT because ~40
   rules in this sheet and in auth.css read them; each now resolves to the
   nearest step of the one scale, so nothing goes unstyled and nothing forks.
   100 and 200 both land on --font-xs, 300 and 400 on --font-sm, 600 and 700 on
   --font-lg: the pairs were half a step apart and never distinguishable.

   ⚑ MICRO TEXT GOT BIGGER, ON PURPOSE. 12px uppercase eyebrows became 13px.
   This is a product used by ten-year-olds; 12px tracked at 0.14em was the
   smallest text in the app and the least legible.                             */
:root {
  --m-fs-100: var(--font-xs);    /* 13 — micro caps, eyebrows (was 12)         */
  --m-fs-200: var(--font-xs);    /* 13 — citations, fine print                 */
  --m-fs-300: var(--font-sm);    /* 15 — meta, secondary UI (was 14)           */
  --m-fs-400: var(--font-sm);    /* 15 — card body                             */
  --m-fs-500: var(--font-body);  /* 17 — body copy, buttons (was 16)           */
  --m-fs-600: var(--font-lg);    /* 22 — lead-in, disclosure summary (was 18)  */
  --m-fs-700: var(--font-lg);    /* 22 — large sub-head (was 21)               */
  --m-fs-800: var(--font-xl);    /* 32 — section head floor (was 27)           */

  /* The rhythm names follow type.css too, so a rule reading --m-lh-body and a
     rule reading --lh-body cannot disagree. --m-lh-snug has no equivalent in
     the six-token spec and stays as its own value. */
  --m-lh-tight: var(--lh-head);
  --m-lh-snug:  1.45;
  --m-lh-body:  var(--lh-body);

  --m-s-1:  4px;
  --m-s-2:  8px;
  --m-s-3: 12px;
  --m-s-4: 16px;
  --m-s-5: 20px;
  --m-s-6: 24px;
  --m-s-7: 32px;
  --m-s-8: 40px;
  --m-s-9: 56px;
  --m-s-10: 72px;
  --m-s-11: 88px;

  /* Motion.  Two durations only.  Anything slower than 220ms on a hover reads
     as lag rather than as polish.                                             */
  --m-t-fast: 120ms;
  --m-t-base: 180ms;
  --m-ease: cubic-bezier(.2, .6, .3, 1);

  /* Depth.  Restrained and monochrome -- tinted from the ink, never from a hue,
     so a shadow never reads as a fifth colour.                                 */
  /* ⚑ Agent P3, Session 8 — a THIRD private depth scale, alongside style.css's
     six names and landing.css's two. Three names, three depths, so this one maps
     cleanly onto the site scale rather than being deleted; call sites unchanged. */
  --m-shadow-1: var(--shadow-d1);
  --m-shadow-2: var(--shadow-d2);
  --m-shadow-3: var(--shadow-d3);
}


/* ── 2. ACCESSIBILITY — FOCUS ─────────────────────────────────────────────
   MEASURED DEFECT.  app.css:400 sets the keyboard focus ring on every
   logged-in surface to `outline: 3px solid var(--k-blue)`.  --k-blue is
   #4f91ee, which theme.css itself documents as "3.17 on white -- SURFACE/CTA
   FILL ONLY, never text".  theme.css:340 had already set a --k-purple ring
   (#7e3df4, 5.44:1); app.css loads later and silently downgraded it.  Confirmed
   by really focusing each control in a browser and reading outlineColor:

       /                 rgb(126, 61, 244)   #7e3df4   5.44
       /pricing          rgb(126, 61, 244)   #7e3df4   5.44
       /practice/home    rgb(79, 145, 238)   #4f91ee   3.17   <-- logged-in
       /dashboard        rgb(79, 145, 238)   #4f91ee   3.17   <-- logged-in

   3.17 clears WCAG 2.2 SC 1.4.11's 3:1 floor for a non-text indicator by 0.17,
   on white.  It has no margin left for the tinted card surfaces it actually
   lands on.  One ring, the stronger one, everywhere.

   ⚑ THE CLASS-LEVEL SELECTORS BELOW ARE NOT PADDING.  An element-only rule at
   (0,1,1) loses to any class rule at (0,2,0) no matter how late it loads, and a
   CSSOM sweep of every loaded sheet found four focus rules that out-specify it:

       style.css  .k-btn:focus-visible      (0,2,0)  2px solid var(--accent)
       style.css  .k-input:focus            (0,2,0)  outline: none + box-shadow
       style.css  .input/.textarea/.select:focus     outline: none + box-shadow
       style.css  .pin-cell:focus           (0,2,0)  outline: none + box-shadow

   The first meant the login button kept a 2px #2f5fa8 ring while everything
   around it had moved to the 3px purple. The rest are handled in the
   forced-colors block below, for the reason stated there.

   ⚑ `.option:focus-visible` (style.css:2398) is deliberately LEFT ALONE. It
   rings a practice answer option in that section's own accent, which is a real
   design decision on Agent K's surfaces and is visible; uniformity is not worth
   overriding an intentional, working indicator.                               */
a:focus-visible,
button:focus-visible,
summary:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
[tabindex]:focus-visible,
.k-btn:focus-visible,
.ks-btn:focus-visible,
.lnd-btn:focus-visible,
body.has-knav-bottom a:focus-visible,
body.has-knav-bottom button:focus-visible,
body.has-knav-bottom summary:focus-visible,
body.has-knav-bottom [tabindex]:focus-visible,
.ops-page a:focus-visible,
.ops-page button:focus-visible {
  outline: 3px solid var(--k-purple, #7e3df4);
  outline-offset: 2px;
}

/* Inputs draw their focus state as an inset box-shadow (style.css:424, :2990)
   with `outline: none`.  A box-shadow is painted, not an outline, so Windows
   High Contrast / forced-colors throws it away and the field becomes the only
   control on the page with no focus indicator at all.  Restore a real outline
   in that mode, for every control.

   ⚑ The class-level `:focus` selectors are REQUIRED here, not decorative. The
   rules that remove the outline are `.k-input:focus`, `.input/.textarea/
   .select:focus`, `.pin-cell:focus` and `body.theme-kid .textarea/.input:focus`
   -- all at (0,2,0) or higher. A bare `input:focus-visible` at (0,1,1) loses to
   every one of them, so the first draft of this block was a no-op on exactly
   the controls it existed to rescue. Matching their shape is what makes it work.  */
@media (forced-colors: active) {
  a:focus-visible,
  button:focus-visible,
  summary:focus-visible,
  input:focus-visible,
  select:focus-visible,
  textarea:focus-visible,
  [tabindex]:focus-visible,
  .k-btn:focus-visible,
  .k-input:focus,
  .input:focus,
  .textarea:focus,
  .select:focus,
  .pin-cell:focus,
  body.theme-kid .input:focus,
  body.theme-kid .textarea:focus {
    outline: 3px solid CanvasText;
    outline-offset: 2px;
  }
}

/* `<summary>` is keyboard-operable and is the only control on /pricing's FAQ.
   style.css gives it no focus treatment of its own, so it relied on the UA ring
   that `outline: none` elsewhere had trained the eye not to expect.            */
summary { cursor: pointer; }

/* ⚑ Q6: AND IT HAD NO HOVER STATE AT ALL — a pointer cursor and nothing else.
   Measured by scratch_agent_q6/hover_focus.py, which hovers a real pointer and
   diffs the computed style: `.knav-who` (the kid nav's account disclosure) and
   the bare `<summary>` on every section hub returned ZERO changed properties,
   while every <a> and <button> in the product returned a colour or background
   change.  A `<summary>` is a control — it opens and closes a panel — so it gets
   the same treatment, on Q3's recipe: named properties, 180ms, and non-motion
   so the reduced-motion block above has nothing to undo.

   ⚑ DELIBERATELY LOW SPECIFICITY, (0,0,1).  `.lnd-faq summary:hover` (0,2,1) in
   landing.css and any component's own rule both still win; this is the floor for
   the summaries nobody wrote a rule for, not an override of the ones somebody
   did.  `cursor: pointer` above is the precedent for that scope.               */
summary { transition: color var(--m-t-base, 180ms) var(--m-ease, ease),
                      background-color var(--m-t-base, 180ms) var(--m-ease, ease),
                      border-color var(--m-t-base, 180ms) var(--m-ease, ease); }
summary:hover { color: var(--ink, #1F1A14); }

/* `.knav-who` already marks its OPEN state with the accent border (style.css:4883).
   Hovering previews that same state rather than inventing a second language. */
.knav-who:hover { border-color: var(--accent); color: var(--ink); }

/* ⚑ THE `.diff-tile` HOVER RING THAT STOOD HERE IS REMOVED (U6a). Its target
   was the kid-home difficulty picker, and that picker is gone — difficulty is
   set on /section/<id> now, where `.sec-diff__opt` carries its own states. The
   original finding still generalises and is worth keeping in mind: an inline
   `style=` attribute beats any stylesheet declaration for the SAME property, so
   a hover rule touching `background` or `border` on an inline-styled control
   loses silently; `box-shadow` was chosen because the attribute did not set it. */
/* ⚑ Q6: THE ONLY HORIZONTAL OVERFLOW LEFT IN THE PRODUCT, and it was one missing
   word.  Measured by scratch_agent_q6/audit.py across 280 renders (56 pages x 5
   widths): /practice/section/writing overflowed by 51px at 375px and nothing else
   overflowed anywhere.

   `.q-toolbar` (style.css:3832) correctly carries `flex-wrap: wrap`.  Its FIRST
   CHILD, though, is an unclassed `<div style="display: flex; gap: 10px;
   align-items: center">` written inline in practice_writing.html and
   practice_abstract.html, and that inner row does NOT wrap — so on a phone the
   four chips ("Written expression / Statement / <skill> / <tier>") ran off the
   right edge and took the document's scrollWidth with them.  Writing is the
   template that shows the most chips, which is why it was the one that failed
   while the other three practice sections passed with the identical bug latent.

   Fixed here rather than in the two inline attributes so a third template
   inheriting the pattern is covered too.  `flex-wrap` is not among the properties
   those `style=` attributes set, so a stylesheet rule wins cleanly; the inline
   `gap: 10px` is shorthand for both axes, so the wrapped rows get 10px of row gap
   for free.                                                                    */
.q-toolbar > div { flex-wrap: wrap; }


/* ── 3. ACCESSIBILITY — MOTION ─────────────────────────────────────────────
   MEASURED DEFECT.  style.css:3139 already ships a reduced-motion net, but it
   only collapses DURATION:

       * { animation-duration: .01ms !important; transition-duration: .01ms !important; }

   A `transform: translateY(-2px)` with a 0.01ms transition still moves; it just
   moves instantly, which is a jump rather than a glide and is precisely what a
   vestibular trigger is.  There are 20 hover-lift transforms across style.css,
   surfaces.css and landing.css.  Neutralise the lift itself.

   ⚑ Enumerated, not blanket.  A bare `*:hover { transform: none }` would also
   destroy the four STATIC transforms in the codebase -- `.toast`
   (translateX(-50%)), `.k-bleed`, `.fo .mouth`, and `.sec-toggle__knob`
   (translateX(19px) when checked) -- each of which would visibly jump the
   moment a pointer entered it.  All four were checked and none is an <a> or a
   <button>, so the element selectors below cannot reach them.                 */
@media (prefers-reduced-motion: reduce) {
  a:hover, a:focus-visible, a:active,
  button:hover, button:focus-visible, button:active,
  summary:hover, summary:focus-visible,
  .card-lift:hover, .section-tile:hover, .option:hover,
  .k-card--interactive:hover, .fab:hover,
  .ks-hero:hover, .ks-sec:hover, .ks-sec:focus-visible,
  .ks-tile:hover, .ks-tile:focus-visible, .ks-resume:hover,
  .sec-action:hover, .sec-test-card:hover, .sec-focus:hover, .kback:hover {
    transform: none !important;
  }
  /* Smooth in-page scrolling is motion too. */
  html { scroll-behavior: auto !important; }
}


/* ── 4. GLOBAL POLISH ──────────────────────────────────────────────────────
   Depth and transition only; no new colour.  Hierarchy on these pages is built
   from weight, size and spacing, which is why nothing below introduces a hue. */

/* Anchor links land under the sticky nav without them. */
html { scroll-behavior: smooth; }
:target { scroll-margin-top: 84px; }

/* Tap targets.  WCAG 2.2 SC 2.5.8 asks for 24x24 minimum; 44px is the comfort
   figure and is what a parent on a phone actually hits.  The public nav links
   measured 30px tall at 375px.                                                */
@media (max-width: 760px) {
  .knav--public .knav-link {
    min-height: 44px;
    display: inline-flex;
    align-items: center;
  }
}


/* ── 5. PUBLIC NAV — RAIL AND BRAND ────────────────────────────────────────
   MEASURED DEFECT 1: the nav had no max-width.  `.nav-inner` is padded with
   --gutter-lg (86.4px at 1440, and clamp(96px,7vw,160px) = 134.4px at 1920),
   so the brand tracked the VIEWPORT while the content rail was capped at
   1120px.  The gap between the brand and the copy beneath it therefore grew
   with the window: 73.6px at 1440, 265.6px at 1920.

   Matching the marketing rail exactly: 1120px of content inside a 22px gutter,
   so the "K" sits directly above the first character of the headline.
   Measured after: brand.left == .lnd-inner.left at 1024 / 1440 / 1920.

   Specificity note -- style.css:3123 sets `.nav-inner { padding-inline: ... }`
   inside a min-width media query.  Media queries add no specificity, so the
   two-class selector below wins on its own merit; it does not need !important
   and does not depend on this file's load order.                              */
.knav--public .knav-inner {
  max-width: calc(1120px + 44px);
  margin-inline: auto;
  padding-inline: 22px;
}
@media (max-width: 760px) {
  .knav--public .knav-inner { padding-inline: 18px; }   /* == .lnd-band mobile */
}

/* MEASURED DEFECT 2: the nav CTA painted from --accent (#2f5fa8) while the
   brand mark next to it painted from --k-cerulean (#005F82) and the hero CTA
   under it did too.  Deep Cerulean is the brand -- theme.css:47 says so, and
   it is the one blue in the palette that is safe in BOTH directions (7.10 on
   white, and white on it is also 7.10).  #2f5fa8 was the pre-cerulean accent
   and simply never got updated here.

   Scoped to .knav--public: the logged-in nav's CTA is set by app.css from
   --k-blue-text and belongs to Agent K's surfaces this session.               */
.knav--public .knav-link--cta,
.knav--auth .knav-link--cta {
  background: var(--k-cerulean, #005F82);
  border-color: var(--k-cerulean, #005F82);
  color: #fff;                                   /* 7.10 on cerulean          */
  transition: background var(--m-t-base) var(--m-ease),
              border-color var(--m-t-base) var(--m-ease);
}
.knav--public .knav-link--cta:hover,
.knav--public .knav-link--cta:focus-visible,
.knav--auth .knav-link--cta:hover,
.knav--auth .knav-link--cta:focus-visible {
  background: #004A66;                           /* 9.67 -- landing's deep     */
  border-color: #004A66;
  color: #fff;
}
.knav--public .knav-link.is-active {
  border-color: var(--k-cerulean, #005F82);
  color: var(--k-cerulean, #005F82);             /* 7.10 on white              */
}


/* ── 6. AUTH PAGES — /login and /register ──────────────────────────────────
   These keep their full-viewport split, which is the right shape for a form and
   is the one thing about them that was not broken.  Reconciled with the rest of
   the product below.

   MEASURED: the editorial panel was #1A1128, the ink colour, used as a surface.
   It was the only near-black plane in the product and it is what made these two
   pages read as belonging to a different site.  It becomes Deep Cerulean, the
   same treatment landing.css gives `.lnd-band--deep`, with landing's measured
   on-deep text pair.

   ⚑ EVERY ACCENT ON THIS PANEL HAD TO MOVE WITH IT.  style.css:3407-3440 tunes
   five colours specifically for the near-black -- #C9BCFA, #ABA4C0, #7e3df4 and
   two rgba(126,61,244,...) washes -- and comments say so.  Re-pointing only the
   background would have left #ABA4C0 at 2.71:1 on cerulean, a fail.  They are
   all restated below against landing.css's measured on-deep pair.             */
.k-auth__aside {
  background: var(--k-cerulean, #005F82);
  color: #CFE4EC;                                          /* 5.39             */
}
.k-auth__aside .k-eyebrow { color: #CFE4EC; }              /* 5.39             */
.k-auth__aside .auth-quote { color: #fff; }                /* 7.10             */
.k-auth__aside .auth-quote em { color: #CFE4EC; }          /* 5.39             */
.k-auth__aside .auth-points li,
.k-auth__aside .auth-steps p,
.k-auth__aside p { color: #CFE4EC; }                       /* 5.39             */
.k-auth__aside .auth-steps strong { color: #fff; }         /* 7.10             */
.k-auth__aside .auth-points li::before { background: #CFE4EC; }
.k-auth__aside .auth-steps__n {
  border-color: rgba(255, 255, 255, .45);
  background: rgba(255, 255, 255, .14);
  color: #fff;                                             /* 7.10 on the wash */
}
.k-auth__aside .k-rule { background: rgba(255, 255, 255, .22) !important; }

/* The primary CTA, matched to the landing page's: cerulean, pill, 16px/700.
   Was #2f5fa8, 8px radius, 16.48px/500 -- a different button entirely.        */
.k-auth .k-btn--primary {
  background: var(--k-cerulean, #005F82);
  border-color: var(--k-cerulean, #005F82);
  color: #fff;                                             /* 7.10             */
  /* Manrope, not .k-btn's Lexend: with everything else matched, the typeface was
     the last thing still telling a visitor that /login is a different product
     from /pricing. Measured after -- the primary CTA is now byte-identical on
     /, /pricing and /login: 16px / 700 / 15px 28px / 999px / rgb(0,95,130). */
  font-family: var(--font-geist);
  font-size: var(--m-fs-500);
  font-weight: 700;
  border-radius: 999px;
  padding: 15px 28px;
  box-shadow: var(--m-shadow-2);
  transition: background var(--m-t-base) var(--m-ease),
              box-shadow var(--m-t-base) var(--m-ease),
              transform var(--m-t-base) var(--m-ease);
}
.k-auth .k-btn--primary:hover {
  background: #004A66;                                     /* 9.67             */
  border-color: #004A66;
  transform: translateY(-1px);
  box-shadow: var(--m-shadow-3);
}
.k-auth .k-btn--primary:active { transform: translateY(0); }

/* Type scale.  These were the worst fractional offenders on the site:
   11.52 / 13.92 / 14.72 / 15.04 / 15.36 / 16.48 / 16.96 / 20.8px.             */
.k-auth .k-eyebrow { font-size: var(--m-fs-100); letter-spacing: .1em; font-weight: 700; }
.k-auth .k-lead    { font-size: var(--m-fs-500); line-height: var(--m-lh-body); }
.k-auth .k-label   { font-size: var(--m-fs-300); font-weight: 600; }
.k-auth .k-input   { font-size: var(--m-fs-500); }
.k-auth .k-hint    { font-size: var(--m-fs-300); }
.k-auth .auth-points li,
.k-auth .auth-steps p { font-size: var(--m-fs-400); line-height: var(--m-lh-body); }

/* The in-form link inherited `color: var(--accent)` from an inline style in the
   template; both templates now carry .lnd-link-ish semantics via this class so
   the colour lives in CSS and matches the marketing pages.                     */
.k-auth a.k-auth__link {
  color: var(--k-cerulean, #005F82);                       /* 7.10             */
  font-weight: 600;
  text-underline-offset: 2px;
}
.k-auth a.k-auth__link:hover { color: #004A66; }           /* 9.67             */

/* MEASURED DEFECT: the form column's inner rail sat at 57.6px while the brand
   above it sat at 86.4px -- a 28.8px disagreement on the one page where the eye
   has nothing else to line up against.  The cause is two different gutter
   tokens: `.k-auth__form-col` pads with --gutter (clamp(20px,4vw,64px) = 57.6
   at 1440) and `.nav-inner` pads with --gutter-lg (clamp(24px,6vw,96px) = 86.4).

   ⚑ The auth nav deliberately does NOT take the .knav--public 1120px rail from
   section 5.  That rail centres the brand at x=160 on a 1440 viewport, which
   would put it 102px to the RIGHT of a form column that starts at 57.6 -- worse
   than the defect being fixed.  A left-anchored split needs a left-anchored
   nav, so `.knav--auth` stays full-bleed and simply borrows --gutter, the
   form column's own token.  Both then track together at every width by
   construction rather than by a matched pair of magic numbers.                */
.knav--auth .knav-inner {
  max-width: none;
  margin-inline: 0;
  padding-inline: var(--gutter);
}


/* ── 7. GLOBAL FOOTER — a real WCAG AA failure, on every page ──────────────
   FOUND BY MEASUREMENT, not by eye.  The measured contrast sweep walks every
   visible text node on all five public pages and composites its real backdrop;
   it returned ten failures and all ten were this footer, which base.html renders
   on EVERY page, logged-in surfaces included:

       .korva-footer-brand   #005F82 on #211E1B   2.34:1   needs 4.5   FAIL
       .korva-footer-meta    #8B8378 on #211E1B   4.43:1   needs 4.5   FAIL

   The cause is a half-finished palette migration.  `.korva-footer` is the last
   surviving surface of the retired warm cream palette -- background #211E1B with
   #8B8378 / #C9C0B4 / #55504A browns on it (style.css:2784-2811).  theme.css:289
   then re-pointed `.korva-footer-brand` to Deep Cerulean, which is 7.10:1 on
   WHITE and was never re-checked against a near-black backdrop, where it is
   2.34.  The brand word in the footer has been effectively illegible since that
   commit.  `.korva-footer-links a:hover` has the same bug: --accent (#2f5fa8)
   is 2.71:1 there.

   It is also, visually, the single most out-of-place element in the product --
   a warm brown plane under a page that is otherwise entirely cool.

   Re-pointed to the cool ink token with measured on-dark values.  Ink rather
   than cerulean deliberately: /pricing and /how-it-works end in a cerulean
   `.lnd-band--deep` CTA band, and a cerulean footer directly beneath it would
   merge into one undifferentiated block.                                      */
/* ⚑ Q3, Session 9: the GROUND MOVED UNDER THESE FIVE.  --k-ink went from the
   cool #1A1128 to the warm #1F1A14 when the surface system was warmed, and every
   value below was a purple-grey chosen against the old plane — a lilac-tinted
   footer under a warm-sand page is the one thing on the screen still arguing with
   the palette.  Re-measured on the NEW ground by _q3_contrast.py, and every pair
   came out BETTER than the one it replaces rather than merely acceptable:

     footer body / meta   #ABA4C0 → #B4ACA0    7.24 → 7.69 on #1F1A14
     footer links         #D6D2E0 → #DCD6CB   11.64 → 11.94
     separator glyph      #6B6580 → #6E6659    3.12 → 3.05  decorative only
     brand word           #FFFFFF unchanged   17.27

   The separator is a "·" between two labels and is the only sub-4.5 value here;
   it was 3.29 before and is documented as decorative in the original note. */
.korva-footer {
  background: var(--k-ink, #1F1A14);
  color: #B4ACA0;                                          /* 7.69             */
}
.korva-footer-brand { color: #FFFFFF; }                    /* 17.27            */
.korva-footer-meta  { color: #B4ACA0; }                    /* 7.69             */
.korva-footer-sep   { color: #6E6659; }                    /* 3.05, decorative */
.korva-footer-links a {
  color: #DCD6CB;                                          /* 11.94            */
  transition: color var(--m-t-base) var(--m-ease);
}
.korva-footer-links a:hover,
.korva-footer-links a:focus-visible { color: #FFFFFF; }    /* 17.27            */


/* ── 8. --ink-mute: one literal, 24 AA failures ───────────────────────────
   FOUND BY MEASUREMENT.  After the footer was fixed, the measured sweep still
   returned 45 failures across 13 pages.  24 of them were one colour:
   rgb(139,133,152) = #8B8598, carrying real text at 3.34-3.56:1 on white, on
   --k-bg and on --k-tint, across /practice/home, /dashboard, /settings,
   /challenge and /mocks -- "Parent dashboard", "0 questions attempted",
   "Year 6 ACER ASET preparation", "Daily XP goal:", "Back to dashboard".

   The source is a single line, app.css:135:

       body.has-knav-bottom,
       .ops-page {
         --ink-soft:    var(--k-muted);
         --ink-mute:    #8B8598;        <-- the only literal in the block
         ...

   Every other line in that block is a `var(--k-*)`, and the block's own comment
   says why: "Each override is expressed AS a --k-* token rather than as a
   literal, so if H1 retunes the palette these follow instead of fighting it."
   This one line does not follow that rule, and #8B8598 is not a palette colour
   at all -- the palette's muted is --k-muted #6B6580, measured 5.53:1 on white.
   So this is the file breaking its own stated contract, and the cost is 24
   AA failures on the surfaces a paying parent actually uses.

   ⚑ Fixed HERE rather than in app.css because app.css is out of scope this
   session. A custom property re-declared on the identical selector from a
   later sheet wins on load order at equal specificity, so this is exactly the
   one-line change app.css should carry, applied from the layer that may.
   It changes a colour only -- no layout property is touched.                  */
/* ⚑ Q3, Session 9: app.css now carries this itself — the literal is gone from
   that file and `--ink-mute: var(--k-muted)` is written where the block's own
   comment always said it belonged. This restatement is KEPT rather than deleted
   because it is the same declaration and costs nothing, and because deleting it
   would make the guarantee depend on app.css never regressing. The fallback
   literal is updated to --k-muted's current value; the token itself was warmed
   from #6B6580 to #6E6659 and measures 5.66 on white, 5.03 on the tier-1 page
   ground (both up from 5.53 / 4.91). */
body.has-knav-bottom,
.ops-page {
  --ink-mute: var(--k-muted, #6E6659);                     /* 5.66 on white    */
}

/* The persona caption in the shared nav ("Practising as <name>") is real
   information, not decoration, and was painting from --ink-faint (#9A94AB,
   2.92:1 on white).  It takes the muted token like every other caption.
   ⚑ --ink-faint itself is deliberately NOT re-pointed: it is also the
   placeholder colour, and darkening it globally would flatten a hierarchy that
   is intentional. Only the instances carrying real text are moved.            */
.knav-more__cap { color: var(--ink-mute); }

/* The parent dashboard's "Test-ready score" card (.dash-trs, style.css:3613) is
   another intact block of the retired cream palette -- #211E1B with #8B8378 on
   it, exactly like the footer was.  Three of its labels measure 4.43:1 against
   a 4.5 requirement: "Test-ready score", "Needs 10 more questions", and the
   per-part percentage.

   ⚑ Deliberately a MINIMAL fix, not a restyle.  The card belongs to a surface
   Agent K owns this session, so the background, the layout and the card's warm
   identity are all left exactly as they are; only the three failing labels move,
   and they move to #A79E92 -- a colour ALREADY used inside this same block for
   .dash-trs__part-label, so nothing new is introduced. 4.43 -> 6.28.           */
.dash-trs .k-eyebrow,
.dash-trs .k-hint,
.dash-trs__part-value { color: #A79E92; }                  /* 6.28 on #211E1B  */

/* ⚑ Q3, Session 9: TWO MORE LABELS ON THE SAME CARD, FOUND THE SAME WAY.
   The rendered sweep (_q3_sweep.py) walks every text node on the page and
   composites its real backdrop, and it returned two nodes inside .dash-trs that
   Agent M's three-selector fix above did not cover:

     .dash-trs__score span         #8B8378 on #211E1B   4.43:1  needs 4.5  "/100"
     .dash-trs__score--pending     #55504A on #211E1B   2.08:1  needs 3.0  "—"

   Both are the same defect as the three above — a cream-palette brown left on a
   near-black card — and both take the SAME #A79E92 rather than a new value, so
   the card still has exactly two text colours on it. The em-dash is the score
   PLACEHOLDER at 64px, which is what a parent sees before their child has
   answered ten questions: the first number they ever look for, at 2.08:1.
   Unchanged by the surface work; simply never measured before. */
.dash-trs__score span      { color: #A79E92; }             /* 6.28 on #211E1B  */
.dash-trs__score--pending  { color: #A79E92; }             /* 6.28 on #211E1B  */
