/* ============================================================================
   Webfonts — self-hosted.

   Served from public/fonts/ rather than a CDN. This app carries a child's
   schoolwork, so it has no business making a third-party request that
   reveals her IP and reading habits on every page load; self-hosting also
   means the type survives offline and costs no extra DNS + TLS handshake in
   front of a render-blocking resource.

   Generated from the Google Fonts CSS, trimmed to the latin and latin-ext
   subsets (cyrillic, greek and vietnamese were dropped) and repointed at
   local files. The unicode-range on each face is what keeps that free: a
   reader only ever downloads the subset their text actually needs, so a
   normal English page pulls the three latin files and nothing else.

   Each family is one variable file per style and subset; the discrete
   font-weight declarations below instance it, which is why four weights of
   Instrument Sans cost one download rather than four.

   All three are SIL Open Font License 1.1. The licences travel with the
   files in public/fonts/OFL-*.txt, as the OFL requires.
   ============================================================================ */

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

/* ============================================================================
   NOVA Design System — Celestial Compass tokens
   ============================================================================

   Extracted from the design exploration in "Claude outputs/" (the v4
   showcase is the newest of the four and the one this file follows). Before
   this file existed the palette lived only inside those prototype HTML
   documents, and public/style.css referenced a "tokens/motion.css" that was
   never in the repo. This is that file.

   Load it BEFORE the stylesheet that consumes it:

     <link rel="stylesheet" href="tokens.css" />
     <link rel="stylesheet" href="style.css" />

   A <link> rather than an @import on purpose: @import inside a stylesheet is
   discovered only after that stylesheet has been fetched and parsed, so it
   costs an extra serial round trip on a render-blocking resource.

   LAYERS
     1. Primitives  -- the raw palette, type, radii and motion. Named after
                       the design system, not after any one screen.
     2. Semantics   -- the names the app's stylesheets actually write. Each
                       one points at a primitive, so overriding a primitive
                       (see the dark theme below) carries through everywhere.

   Only primitives are re-declared per theme. If you add a semantic alias,
   define it once here and let the theme blocks do the work.

   DECLARED BUT NOT YET DRAWN
     Roughly a third of the tokens here have no consumer in style.css or
     parent.css. That is deliberate, not rot: the radius and duration scales,
     the secondary accents (--indigo-accent, --aurora-cyan), the softer gold
     variants, --surface-glass, --card-hover-shadow, --border-focus,
     --ink-faint, and the three base --status-* fills are the design
     system's vocabulary, kept whole so a screen that needs one finds it
     already themed for both light and dark rather than inventing a value.

     Worth knowing because the same shape hid a real bug once: --ease-in-out
     was declared, referenced, and silently resolving to its inline fallback
     because nothing had loaded it, and the type stack sat unused for weeks
     in exactly this list. Unused is fine; unused and *believed to be in use*
     is not. To see the current set:

       node -e 'const f=require("fs"),s=x=>f.readFileSync("public/"+x,"utf8").replace(/\/\*[\s\S]*?\*\//g,"");
       const t=s("tokens.css"),a=t+s("style.css")+s("parent.css");
       const u=new Set([...a.matchAll(/var\((--[a-z0-9-]+)/g)].map(m=>m[1]));
       console.log([...new Set([...t.matchAll(/^\s*(--[a-z0-9-]+)\s*:/gm)].map(m=>m[1]))].filter(x=>!u.has(x)).join("\n"))'

   TEXT vs DECORATION
     Several colours come in a pair: the brand value, and a darker *-text
     value for when the colour has to be read. Every ratio quoted below is
     measured against the surface named, and clears the WCAG AA floor of
     4.5:1 for text and 3:1 for non-text. Reach for the plain value only
     when the colour is decorative and something else already carries the
     meaning. See the note above --star-gold for the full reasoning.
   ============================================================================ */

:root {
  /* Tells the browser both themes are supported, so the parts of the page
     CSS does not paint follow along: scrollbars, native form-control chrome,
     the canvas behind the app before first paint. Without it a dark-mode
     reader gets a light scrollbar and a white flash on load. */
  color-scheme: light dark;

  /* ---- Surfaces ----------------------------------------------------- */
  --canvas: #FBF6F1;
  --surface: #ffffff;
  --surface-raised: #EAE4D6;
  --surface-glass: rgba(255, 255, 255, 0.85);
  --border: rgba(15, 23, 32, 0.10);
  --border-focus: rgba(184, 134, 58, 0.5);
  --card-hover-shadow: 0 6px 20px rgba(15, 23, 32, 0.08);

  /* ---- Ink ----------------------------------------------------------- */
  --ink: #0F1720;        /* 16.8:1 on --canvas */
  --ink-soft: #475569;   /*  7.1:1 on --canvas */
  --ink-faint: #8A94A8;  /*  2.8:1 -- clears neither AA floor, so purely
                              decorative: hairlines and rules. Not for text,
                              and not for a glyph that carries meaning. */

  /* ---- Gold, the brand colour ---------------------------------------- */
  /* --star-gold is the mark. At 2.3:1 as text on --canvas and 2.5:1 behind
     white it is below both AA floors, so it is only ever decorative, and
     only where a label beside it already says the same thing. Anything that
     has to be *perceived* as gold -- text, a fill behind white text, a focus
     or hover border, a state dot carrying meaning on its own -- takes
     --star-gold-text, which is 4.9:1 on --canvas, 5.2:1 on --surface, 5.2:1
     behind white text and 4.6:1 on --star-gold-tint. */
  --star-gold: #C89D3C;
  --star-gold-text: #876822;
  --star-gold-on: #0F1720;
  --star-gold-light: #E6D3A7;
  --star-gold-core: #C89D3C;
  --star-gold-dark: #A87F2E;
  --star-gold-glow: rgba(200, 157, 60, 0.25);
  --star-gold-tint: rgba(200, 157, 60, 0.16);  /* flat backgrounds */

  /* ---- Recording (voice input, §223) ----------------------------------
     Red because it is the one colour everyone already reads as "the mic is
     live", and gold -- the accent every composer button already wears --
     is exactly what made listening invisible before. Used only while audio
     capture is active. 6.1:1 on --canvas, 6.6:1 on --surface (dark: 8.1:1
     and 6.1:1), so it also carries the Stop label as text. */
  --recording: #B4231A;
  --recording-tint: rgba(180, 35, 26, 0.10);

  /* ---- Secondary accents --------------------------------------------- */
  --indigo-accent: #4438C9;  /* 7.4:1 on --canvas */
  --aurora-cyan: #178F92;    /* 3.6:1 -- non-text only at this value */

  /* ---- Status -------------------------------------------------------- */
  /* Same split as the gold. The plain values are the pill fills, dots and
     borders; the -text values are for the label sitting on the -soft tint,
     which is the tightest pairing each one has to survive. */
  /* The -border values are the base at low alpha: a tinted edge around a
     -soft fill, not a full-strength rule. The fill and its label already
     carry the state, so these stay decorative. */
  --status-strong: #2E7D5B;
  --status-strong-text: #256A4B;
  --indigo-text: #4438C9;  /* 7.9:1 on --surface; the textbook Definition label */
  --sp-blue: #1F5FD1;      /* scratchpad inks; the picture sent is always these on white */
  --sp-red: #C62828;
  --sp-grid: rgba(15, 23, 32, 0.12);
  --try-fill: #F8F1E2;     /* Your turn card; --star-gold-text 4.6:1 and --ink 16:1 on it */
  /* The other textbook boxes, fill + label (label on fill, then --ink on fill):
     definition 6.8/15.5, key idea 6.9/15.7, worked example 6.6/15.6, check 5.7/15.9 */
  --tb-definition-fill: #EEECFA;
  --tb-definition-text: #4438C9;
  --tb-key-fill: #FAEBF1;
  --tb-key-text: #93264E;
  --tb-example-fill: #F2EEE7;
  --tb-example-text: #5B5347;
  --tb-check-fill: #E7F3EE;
  --tb-check-text: #256A4B;
  /* Maths house style (2026-10-18): Your working shares Check's green;
     tip 6.2/15.9; a wrong mark 5.7:1 on the working green. */
  --tb-tip-fill: #EAF1FB;
  --tb-tip-text: #2457A6;
  --tb-wrong-text: #B3261E;
  --tb-wrong-tint: rgba(198, 40, 40, 0.10);
  --status-strong-soft: #E7F3EE;

  --status-developing: #B8860B;
  --status-developing-text: #8A6407;
  --status-developing-soft: #FBF1DC;

  --status-error: #D64545;
  --status-error-text: #B3271E;
  --status-error-soft: #F8E3E0;
  --status-error-border: rgba(214, 69, 69, 0.35);

  /* ---- Radii ---------------------------------------------------------- */
  --radius-sm: 8px;
  --radius-md: 12px;
  --radius-lg: 20px;

  /* ---- Type ----------------------------------------------------------- */
  /* All three are loaded by the @font-face block at the top of this file.
     The fallbacks still matter: font-display is swap, so the first paint of
     a cold load uses them, and they are what a reader gets if a font file
     ever fails to fetch.

     Each has a job, following the design exploration rather than taste:
       --sans   body text, and the default on :root for both frontends
       --serif  display only -- page and card titles, the brand mark
       --mono   small uppercase labels and inline code, the "readout" voice

     Mind the weights. Marcellus exists in 400 alone and the loaded cut of
     JetBrains Mono covers 400-500, so a rule adopting either must ask for a
     weight that exists. Anything heavier gets a synthesised bold, which on
     a display serif looks like a printing fault rather than emphasis.
     Instrument Sans is loaded at 400/500/600/700 plus a 400 italic. */
  --serif: "Marcellus", Georgia, serif;
  --sans: "Instrument Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, sans-serif;
  --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;

  /* ---- Motion ---------------------------------------------------------- */
  /* The motion philosophy is fades and small translates -- no bounces, no
     spins -- so the curves here are deliberately few. --ease-out is the
     showcase's dominant entrance curve, used verbatim. --ease-in-out is
     symmetric and exists for looping pulses like the typing star, where an
     ease-out would make the fade limp; the showcase inlined its loops, so
     this one is an addition rather than an extraction.

     Durations are named for intent. Anything built on these must still
     degrade under the prefers-reduced-motion block at the end of this file. */
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
  --dur-fast: 220ms;   /* a mark appearing, a chip popping */
  --dur-base: 300ms;   /* the default for a state change */
  --dur-slow: 400ms;   /* a panel or sheet moving */

  /* ======================================================================
     Semantic aliases — the names the app stylesheets write.
     ====================================================================== */
  --bg: var(--canvas);

  /* A filled gold control uses all three together:
       background: var(--accent); color: var(--on-accent);
       border: 1px solid var(--accent-border);
     The gold is light, so the label on it is dark, not white -- that is what
     lets the fill stay the actual brand colour instead of being darkened
     until white clears 4.5:1, which turned it olive. 7.2:1 in light. The
     fill alone is 2.3:1 against the page, under the 3:1 non-text floor, so
     --accent-border is what makes the control's edge findable.
     Gold *text* on a light background has no fill to sit on and still takes
     --accent-text. An indicator with no label -- a progress or unread dot --
     is carrying meaning alone and needs 3:1, which the fill misses, so those
     take --accent-text too. */
  --accent: var(--star-gold);
  --accent-text: var(--star-gold-text);
  --on-accent: var(--star-gold-on);
  --accent-border: var(--star-gold-dark);
  --accent-soft: var(--star-gold-tint);

  /* Only the shapes the dashboard actually writes. The bare --strong,
     --developing and --struggling aliases were here too, and went when the
     dead .level-* dot rules that were their only callers did. A future
     status dot should reach for the --status-* primitive above rather than
     reviving an alias with one consumer. */
  --strong-text: var(--status-strong-text);
  --developing-text: var(--status-developing-text);
  --struggling-text: var(--status-error-text);
  --struggling-soft: var(--status-error-soft);

  /* Chat bubbles. These lived in style.css as un-themed literals, which is
     how the first dark render came out with the coach's reply as pale text
     on a white bubble -- the bubble was a hardcoded #fff while the text
     inherited a --ink that had flipped to near-white. Each bubble now names
     its own text colour rather than inheriting one, so the pair can never
     drift apart again. Light inverts for emphasis (coach recedes to white,
     the student's own message is the dark one); dark keeps that ordering
     with two raised surfaces instead, 11.8:1 and 9.5:1. */
  --coach-bubble: #ffffff;
  --on-coach-bubble: var(--ink);
  --student-bubble: #232936;
  --on-student-bubble: #ffffff;
  /* Tint behind inline code inside a coach bubble: a wash of the opposite
     end of the scale, so it has to flip with the theme. */
  --code-tint: rgba(0, 0, 0, 0.06);

  /* Predates the --radius-sm/md/lg scale and does not sit on it. Kept so the
     existing rules keep rendering; prefer --radius-lg for new work. */
  --radius: 16px;
}

/* ============================================================================
   Dark theme — follows the operating system.

   There is deliberately no in-app toggle and no stored preference. Her phone
   already has the switch, and a second one would be a setting to persist,
   keep in sync across her chat and the parent dashboard, and explain.

   This was opt-in behind a [data-theme] attribute while it was unverified.
   It is a media query now because both surfaces have been rendered against a
   dark canvas and both audit clean -- `npm run audit:contrast` covers this
   query directly, emulating the OS preference rather than forcing a class,
   so what it checks is what a reader actually gets.

   One block, one selector: the attribute version was dropped rather than
   kept alongside, because two copies of a token list drift the moment
   someone edits one of them.

   Only primitives are overridden here; the semantic aliases above inherit.
   `color-scheme` on :root is what carries the theme into the parts of the
   page CSS does not own -- scrollbars, form control chrome, the canvas
   behind the app before the first paint.
   ============================================================================ */
@media (prefers-color-scheme: dark) {
  :root {
    --canvas: #0B1223;
    --surface: #1E2A4D;
    --surface-raised: #2A3866;
    --surface-glass: rgba(30, 42, 77, 0.7);
    --border: rgba(152, 162, 189, 0.18);
    --border-focus: rgba(255, 196, 107, 0.45);
    --card-hover-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);

    --ink: #E8EBF4;
    --ink-soft: #98A2BD;
    --ink-faint: rgba(152, 162, 189, 0.55);

    /* On a dark canvas the legibility problem inverts: the mark is now the
     readable one and the -text value lightens rather than darkens. */
    --star-gold: #FFC46B;
    --star-gold-text: #FFD99F;
    --star-gold-on: #0B1223;
    --star-gold-light: #FFD99F;
    --star-gold-core: #FFC46B;
    --star-gold-dark: #A87F2E;
    --star-gold-glow: rgba(255, 196, 107, 0.35);
    --star-gold-tint: rgba(255, 196, 107, 0.16);
    --recording: #FF8A7A;
    --recording-tint: rgba(255, 138, 122, 0.14);

    --indigo-accent: #5B4CFF;
    --aurora-cyan: #23D1D4;

    /* The showcase only specified --good for the dark theme; the other two
     were lightened to match its weight. All three have now been measured
     against a real dark render of the dashboard, each -text value on its
     own -soft tint composited over --surface, which is the tightest
     pairing any of them has to survive:
       strong      72C9AC on rgb(40,66,90)   5.3:1
       developing  E0B255 on rgb(61,64,78)   5.2:1
       error       F5A5A5 on rgb(64,57,87)   5.6:1
     Error was #F08A8A and came in at 4.48:1 -- passing to the eye, failing
     the 4.5 floor by 0.02, and found by auditing the rendered DOM rather
     than by reading the file. Strong was the showcase's --good, #5FBF9E,
     which passed at 4.7:1 but with less margin than either sibling; it is
     lightened here only as text. The fill keeps --good exactly. */
    --status-strong: #5FBF9E;
    --status-strong-text: #72C9AC;
    --indigo-text: #B3ABFF;  /* 6.8:1 on --surface; --indigo-accent is fill-only here */
    --sp-blue: #7FB0FF;
    --sp-red: #FF8A80;
    --sp-grid: rgba(232, 235, 244, 0.13);
    --try-fill: #453A28;     /* warm, not gold-over-navy (which mixes to grey); label 8.3:1, --ink 10:1 */
    /* definition 6.0/10.4, key idea 7.9/11.3, worked example 7.2/9.9, check 6.0/9.9 */
    --tb-definition-fill: #2C2F63;
    --tb-definition-text: #B3ABFF;
    --tb-key-fill: #47223A;
    --tb-key-text: #FFB0CF;
    --tb-example-fill: #33374A;
    --tb-example-text: #D4C9B6;
    --tb-check-fill: #1C3D38;
    --tb-check-text: #72C9AC;
    --tb-tip-fill: #1D3352;  /* label 7.1:1, --ink 10.7:1 */
    --tb-tip-text: #9CC4FF;
    --tb-wrong-text: #FF9C94; /* 5.9:1 on the working green */
    --tb-wrong-tint: rgba(255, 120, 110, 0.14);
    --status-strong-soft: rgba(95, 191, 158, 0.16);

    --status-developing: #E0B255;
    --status-developing-text: #E0B255;
    --status-developing-soft: rgba(224, 178, 85, 0.16);

    --status-error: #F08A8A;
    --status-error-text: #F5A5A5;
    --status-error-soft: rgba(240, 138, 138, 0.16);
    --status-error-border: rgba(240, 138, 138, 0.35);

    --coach-bubble: var(--surface);
    --student-bubble: var(--surface-raised);
    --on-student-bubble: var(--ink);
    --code-tint: rgba(255, 255, 255, 0.08);
  }
}

/* ============================================================================
   THE CANONICAL MOTION LIBRARY (Northstar section 123)
   ----------------------------------------------------------------------------
   Every animation in this app comes from here. Section 123 asks for a finite
   library maintained in one place rather than animation invented screen by
   screen, and the two patterns that existed before this block are what that
   warning looks like in practice: `onboarding-fade-in` was defined twice,
   once in style.css and once in parent.css, and both it and the thinking
   pulse hardcoded their own durations instead of using the tokens sitting a
   few hundred lines above them.
   
   Each pattern is specified in full -- purpose, trigger, modes, duration,
   reduced-motion equivalent, accessibility and academic-integrity notes -- in
   docs/MOTION.md, alongside the section 122 decision test that any new
   pattern has to pass first. Do not add an animation to a component
   stylesheet. Add the pattern here, document it there, or keep the interface
   still.
   ============================================================================ */

/* nova-enter -- content taking its place on screen. The workhorse: an
   onboarding step, a panel's content, anything that arrives where she is
   already looking. Short and small on purpose (4px, --dur-fast): it marks
   that something is new without asking to be watched. */
@keyframes nova-enter {
  from { opacity: 0; transform: translateY(4px); }
  to   { opacity: 1; transform: translateY(0); }
}
.motion-enter {
  animation: nova-enter var(--dur-fast) var(--ease-out);
}

/* nova-notice -- something arrived that she did not ask for. Reserved for
   the notices the app raises on its own mid-conversation (a topic shift, a
   routed experience), which are inserted above the composer while she is
   reading somewhere else and are genuinely easy to miss. Slower and slightly
   further than nova-enter because being noticed is the entire point. */
@keyframes nova-notice {
  from { opacity: 0; transform: translateY(-6px); }
  to   { opacity: 1; transform: translateY(0); }
}
.motion-notice {
  animation: nova-notice var(--dur-base) var(--ease-out);
}

/* nova-reveal -- a visual opening into inspection view (section 105, SHOW).
   Grows the last few percent rather than appearing: the point is to show
   that the thing she tapped is the thing now filling the screen, which a
   hard cut leaves her to infer. One-shot, and short enough not to delay
   looking at it. */
@keyframes nova-reveal {
  from { opacity: 0; transform: scale(0.94); }
  to   { opacity: 1; transform: scale(1); }
}
.motion-reveal {
  animation: nova-reveal var(--dur-base) var(--ease-out);
}

/* nova-inspect -- one pass across work that is being looked at (section 107,
   REVIEW). Deliberately a single sweep, not a loop: it says "I am reading
   this now", and a repeating shimmer over her own handwriting would be
   decoration sitting on top of the thing she wants to see. Nothing about it
   marks a location, so it cannot hint at where an error is (section 122,
   question 5). */
@keyframes nova-inspect {
  from { transform: translateX(-120%); }
  to   { transform: translateX(120%); }
}

/* nova-thinking -- the coach is working. Fade only: no translation, no
   bounce, no spin. It runs for as long as a reply takes, so anything with
   movement in it would sit in her peripheral vision pulling at her attention
   during the one moment she is most likely to be thinking. Components using
   this must declare a resting opacity under reduced motion; stopping a loop
   mid-fade would otherwise leave the element near-invisible. */
@keyframes nova-thinking {
  0%, 100% { opacity: 0.45; }
  50%      { opacity: 1; }
}
.motion-thinking {
  animation: nova-thinking 1800ms var(--ease-in-out) infinite;
}

/* nova-moment -- the NOVA mark reacting to something worth noticing in her
   thinking (section 108, and section 110's vocabulary).
   ONE pattern for all four moments rather than the distinct motions
   section 108 sketches per state. That section is permissive ("may act as
   a subtle non-verbal character"), and three near-identical blooms would
   be exactly the decoration section 122's decision test rejects: the
   difference between a spark and an insight is carried by the signal glyph
   beside the reply, which says it in a word, not by two animations a
   13-year-old would never learn to tell apart.
   One-shot and short. It fires when a reply lands, never while she is
   working, so it cannot pressure her (section 109). */
@keyframes nova-moment {
  0%   { transform: scale(1); }
  40%  { transform: scale(1.18); }
  100% { transform: scale(1); }
}
.motion-moment {
  animation: nova-moment var(--dur-base) var(--ease-out);
}

/* nova-recording -- the microphone is live (section 223: "the student must
   always know when audio capture is active").
   The second looping pattern in the library, and a deliberate exception to
   section 109's stillness beside the composer, approved for voice as a
   dynamic-first feature: while she is speaking she is not in the silent
   thinking that 109 protects, and a mic that looks idle while it records
   is the failure 223 names. It runs only while capture is actually active
   and stops the moment it ends. Gentle on purpose -- a slow fade with a
   slight shrink, no travel -- and paired with a live level meter drawn from
   her real voice (app.js), never a decorative wave. */
@keyframes nova-recording {
  0%, 100% { opacity: 1;    transform: scale(1); }
  50%      { opacity: 0.45; transform: scale(0.8); }
}
.motion-recording {
  animation: nova-recording 1600ms var(--ease-in-out) infinite;
}

/* nova-fade -- the movement-free form of an entrance, used when she has asked
   for Subtle movement (section 118). Not a pattern in its own right: the
   same moments, with the travel taken out. */
@keyframes nova-fade {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* ============================================================================
   HER MOVEMENT PREFERENCE (Northstar section 118)
   ----------------------------------------------------------------------------
   Subtle keeps every moment but removes the travel: entrances, notices and
   the lightbox reveal become plain fades, and the inspection sweep -- which
   is nothing but travel -- stops entirely. The thinking pulse is already
   fade-only, so it survives Subtle untouched; it is the one signal that
   tells her the coach is working.
   
   Minimal stops everything. Note that it sets animation: none on the two
   looping patterns rather than relying on a collapsed duration: a loop with
   a 0.01ms duration is not stopped, it is frozen at an arbitrary keyframe.
   That exact mistake shipped once on the NOVA mark, and audit-motion.js
   checks this setting for it now as well as the OS one.
   ============================================================================ */
[data-animations="subtle"] .motion-enter,
[data-animations="subtle"] .motion-notice,
[data-animations="subtle"] .motion-reveal {
  animation-name: nova-fade;
}
[data-animations="subtle"] .msg-image-wrap.inspecting::after {
  animation: none;
  display: none;
}

[data-animations="minimal"] *,
[data-animations="minimal"] *::before,
[data-animations="minimal"] *::after {
  animation-duration: 0.01ms !important;
  animation-iteration-count: 1 !important;
  transition-duration: 0.01ms !important;
}
[data-animations="minimal"] .typing-star,
[data-animations="minimal"] .avatar.thinking .nova-star,
[data-animations="minimal"] .motion-recording,
[data-animations="minimal"] .voice-status.busy .voice-busy i {
  animation: none;
}
[data-animations="minimal"] .voice-status.busy .voice-busy i { opacity: 0.8; }
[data-animations="minimal"] .msg-image-wrap.inspecting::after {
  animation: none;
  display: none;
}

/* ============================================================================
   A system-wide floor for reduced motion. Individual components should still
   define their own resting state where "no animation" is not enough on its
   own -- the typing star, for example, holds at a fixed opacity so it does
   not disappear mid-fade. This block only guarantees nothing keeps moving.
   ============================================================================ */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
  /* A loop has to be stopped, not collapsed (see the note above her
     movement preference). The recording dot then rests solid, which still
     says "live" -- the dot, the red and the ticking timer carry the state
     without any motion at all. */
  .motion-recording { animation: none; }
  /* The transcribing dots rest visibly rather than freezing mid-fade;
     the words beside them say it is working. :root lifts this above the
     style.css rule that starts the loop, which loads after this file and
     would otherwise win at equal specificity -- the NOVA mark bug again. */
  :root .voice-status.busy .voice-busy i { animation: none; opacity: 0.8; }
}
