/* ============================================================================
   IntelX design foundations  —  "Ink & Motion" (August 2026 rebuild)
   ----------------------------------------------------------------------------
   Loaded by every page, LAST in <head>, i.e. after each page's inline <style>.
   That ordering is load-bearing: it lets this one file re-theme ~46 templates
   at equal specificity without editing 46 inline token blocks.

   ---------------------------------------------------------------- The system

   Three rules. Everything below is an application of one of them.

     1. WHITE AND INK.  The canvas is pure #ffffff. Structure is drawn with
        hairlines and type, never with grey fills or boxes-inside-boxes. The
        old palette read "dim" because it was mostly #f4f4f5 panels on white;
        the fix was to delete the panels, not to brighten them.

     2. COLOUR MEANS PERFORMANCE.  Green / amber / red appear on grades,
        progress, memory strength and due-status — and nowhere else. There is
        no decorative accent hue in this product. Every attempt at one (clay,
        iris, sky blue) made the app read as generic SaaS, because a colour
        that can mean anything ends up meaning nothing. Scarcity is what makes
        the green on a mastery bar actually land.

     3. ENERGY COMES FROM MOTION.  The original complaint was "boring and
        dim". Dimness was really stillness: ~540 :hover rules, almost no
        movement, nothing that responded to you. So surfaces lift, controls
        spring, meters fill, numbers count, sections rise as you scroll. All
        of it composite-only (transform/opacity) and all of it surrendered
        under prefers-reduced-motion.

   Sections: 0 tokens · 1 surfaces & shape · 2 buttons · 3 semantic data
   4 motion · 5 press · 6 reduced-motion · 7 transparency & contrast
   ========================================================================== */


/* ═══════════════════════════════════════════════════════ 0. TOKENS ══════ */

:root {
  /* -- Paper ---------------------------------------------------------------
     Pure white, deliberately. --paper-2 is *barely* off-white and exists only
     for full-bleed section bands on marketing pages; it must never be used
     for a card, or the boxed-in look comes straight back. */
  --paper:      #ffffff;
  --paper-2:    #fafbfc;
  --paper-sink: #f8fafc;  /* inset wells: progress troughs, code, callouts */

  /* -- Ink -----------------------------------------------------------------
     Slate-tinted rather than pure black. Pure #0a0a0a against pure white is
     the highest-glare pairing there is, and it was a real part of the "makes
     me tired" complaint. #0f172a keeps 16:1 contrast and reads warmer. */
  --ink:    #0f172a;
  --ink-2:  #334155;   /* body copy, secondary headings */
  --mute:   #64748b;   /* supporting text, labels                 4.8:1 */
  --mute-2: #94a3b8;   /* metadata, eyebrows, placeholders        3.0:1
                          — never body copy, never below 12px     */

  /* -- Rules ---------------------------------------------------------------
     Structure is hairlines. Two weights is all this product needs. */
  --rule:      #e2e8f0;
  --rule-soft: #eef2f7;
  --rule-strong: #cbd5e1;  /* input borders, outlined controls */

  /* -- Radii ---------------------------------------------------------------
     The "blocky" complaint was mostly 4–8px corners on flat-bordered boxes.
     Everything got rounder; anything you can press became a pill. */
  --r-sm:   10px;
  --r-md:   14px;
  --r-lg:   16px;
  --r-xl:   20px;
  --r-pill: 999px;

  /* -- Elevation -----------------------------------------------------------
     Almost nothing. A card at rest is a hairline. Shadow is reserved for
     things that have genuinely left the page: hover-lifted cards, popovers,
     sheets. That restraint is most of what reads as "professional". */
  --lift-1: 0 1px 2px rgba(15, 23, 42, 0.04);
  --lift-2: 0 4px 14px rgba(15, 23, 42, 0.07);
  --lift-3: 0 12px 32px rgba(15, 23, 42, 0.10);

  /* -- Motion --------------------------------------------------------------
     Two curves. --ease-out for travel (decelerating, Apple-ish, never
     bounces on large surfaces); --ease-spring for discrete controls, where a
     slight overshoot is what makes a press feel physical. */
  --ease-out:    cubic-bezier(0.22, 1, 0.36, 1);
  --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
  --dur-press: 120ms;
  --dur-hover: 250ms;
  --dur-move:  380ms;
  --dur-fill:  1100ms;  /* meters and count-ups: slow enough to watch */
}


/* -- Semantic hues ----------------------------------------------------------

   The ONLY colour in the product. Retuned from the old earthy set (#4a6b3a
   olive / #96601f brown), which was so desaturated it read as another grey
   and gave a correct answer no reward at all.

   Each hue ships three values, because a colour dense enough to fill a 5px
   progress bar is never dark enough to be legible as 12px text on white:

     --good        the fill      — bars, dots, mastery cells
     --good-text   the label     — AA on white, use for any text/number
     --good-bg     the wash      — status pills, graded answer rows

   Fixed thresholds, applied everywhere: >= 70% good · 40–69% warn · < 40% bad.
   Never restate these as literals in a template — read the token, so a future
   retune is one edit. */

:root {
  --good:      #12b76a;
  --good-text: #0d8a54;
  --good-bg:   #ecfdf3;
  --good-soft: #6ed4a2;   /* "improving but not there" — fill only */

  --warn:      #f59e0b;
  --warn-text: #b45309;
  --warn-bg:   #fffaeb;
  --warn-soft: #fbd38d;

  --bad:       #e24b4a;
  --bad-text:  #c0392b;
  --bad-bg:    #fef3f2;

  /* Neutral data — "not started", "no attempts yet", empty states. Absence of
     performance is absence of colour; this must never look like a low score. */
  --none:    #f1f5f9;
  --none-ink: #94a3b8;
}


/* -- Legacy accent neutralisation -------------------------------------------

   ~40 templates declare --iris / --flame / --forest / --dusk / --honey /
   --berry in their own :root and paint decoration with them. Rather than edit
   every one, they are remapped here onto a graphite ramp: the pages keep
   working, and the decorative colour quietly leaves the product.

   They are NOT aliased to the semantic hues on purpose. If --forest still
   resolved to green, every decorative flourish that happened to use it would
   start impersonating a passing grade, and rule 2 would collapse.

   Multi-series charts still need separable series, so the ramp steps in
   lightness rather than collapsing to one value. */

:root {
  --iris:     #0f172a;  /* was the interaction accent — now simply ink */
  --iris-2:   #334155;
  --iris-pressed: #000000;

  --flame:    #334155;
  --flame-2:  #64748b;
  --forest:   #475569;
  --forest-2: #94a3b8;
  --dusk:     #64748b;
  --dusk-2:   #cbd5e1;
  --honey:    #475569;
  --berry:    #334155;

  /* Marketing pages' warm-clay set (index, about, why_intelx). */
  --accent:      #0f172a;
  --accent-soft: #cbd5e1;
  --accent-bg:   #f8fafc;
}


/* ═══════════════════════════════════ 1. SURFACES & SHAPE ════════════════ */

/* The canvas. Pages that painted themselves --paper-2 are pulled back to
   white; the greyness was cumulative, one panel at a time. */
html,
body {
  background: var(--paper);
  color: var(--ink);
}

/* Cards, tiles and panels: hairline + generous radius + white. The shared
   shape language, applied by class-name convention so pages that never
   imported a component still get it. */
.card,
.stat-card,
.chart-card,
.panel,
.tile,
[class*="-card"]:not([class*="-cards"]),
[class*="-panel"],
[class*="-tile"] {
  background: var(--paper);
  border-radius: var(--r-lg);
}

/* Inset wells — the one place a grey fill is correct, because it reads as
   "recessed" rather than "boxed in". */
.progress-bar,
.progress-track,
.meter-track,
[class*="-track"] {
  background: var(--none);
  border-radius: var(--r-pill);
  overflow: hidden;
}

/* Progress fills round with their track. */
.progress-fill,
.meter-fill,
[class*="-fill"] {
  border-radius: var(--r-pill);
}

/* Callouts (memory tricks, tips, notes) lose their tinted box and become a
   quoted block: an ink rule on the left, the palest possible ground. Editorial
   rather than "alert". Square corners — a rounded box with a single-sided
   border always looks like a mistake. */
.callout,
.tip,
.note,
.memory-trick,
[class*="-callout"],
[class*="-trick"] {
  background: var(--paper-sink);
  border-left: 2px solid var(--ink);
  border-radius: 0;
}

/* Eyebrows / section labels: uppercase, tracked, muted. Carries hierarchy
   without needing a colour or a background. */
.section-label,
.page-eyebrow,
.mono.eyebrow,
[class*="-eyebrow"] {
  text-transform: uppercase;
  letter-spacing: 1.5px;
  font-size: 0.72rem;
  color: var(--mute-2);
}

/* Inputs: pill-shaped, hairline, ink focus ring. */
input[type="text"],
input[type="email"],
input[type="password"],
input[type="search"],
input[type="number"],
select,
.form-control,
.field input {
  border-radius: var(--r-pill);
  border: 1px solid var(--rule-strong);
  background: var(--paper);
  transition: border-color var(--dur-hover) ease, box-shadow var(--dur-hover) ease;
}

textarea,
textarea.form-control {
  border-radius: var(--r-md);
  border: 1px solid var(--rule-strong);
  background: var(--paper);
  transition: border-color var(--dur-hover) ease, box-shadow var(--dur-hover) ease;
}

input:hover,
select:hover,
textarea:hover {
  border-color: var(--mute-2);
}

input:focus,
select:focus,
textarea:focus {
  border-color: var(--ink);
  box-shadow: 0 0 0 3px rgba(15, 23, 42, 0.07);
  outline: none;
}

/* Keyboard focus is ink, matching the controls. */
:focus-visible {
  outline: 2px solid var(--ink) !important;
  outline-offset: 2px;
}

/* Inline prose links — running text only. `li a` is navigation in this
   codebase (every nav item is `<li><a>`), so matching it would paint the
   entire top nav. `p > a` requires a direct paragraph child, which no nav
   produces. */
p > a:not([class]),
.prose a:not([class]),
article a:not([class]) {
  color: var(--ink);
  text-decoration-color: var(--rule-strong);
  text-underline-offset: 3px;
  transition: text-decoration-color var(--dur-hover) ease;
}

p > a:not([class]):hover,
.prose a:not([class]):hover {
  text-decoration-color: var(--ink);
}


/* ═════════════════════════════════════════════ 2. BUTTONS ═══════════════ */

/* One button system: ink pill (primary), outlined pill (secondary), bare
   (ghost). No colour — the primary action is already the highest-contrast
   thing on a white page, so colour would add emphasis it doesn't need, and
   spend the one hue the semantic system depends on. */

/* `button[type="submit"]` and `input[type="submit"]` are matched by element,
   not by class, because the auth forms (login, register, reset_password) use
   a bare <button type="submit"> with no class at all — so a `.btn`-only rule
   left the most important control on the sign-up funnel as a square default
   button. Anything that submits a form is a primary action; it gets the pill. */
.btn,
.btn-primary,
.btn-secondary,
.btn-ghost,
button.btn,
a.btn,
button[type="submit"],
input[type="submit"] {
  border-radius: var(--r-pill);
  font-weight: 500;
  transition: transform var(--dur-hover) var(--ease-spring),
              background-color var(--dur-hover) ease,
              border-color var(--dur-hover) ease,
              box-shadow var(--dur-hover) ease;
  touch-action: manipulation;
}

.btn-primary,
button[type="submit"],
input[type="submit"],
.btn.btn-glow {
  background: var(--ink);
  border: 1px solid var(--ink);
  color: var(--paper);
  box-shadow: none;  /* kills the old marketing "glow" halo */
}

.btn-primary:hover,
button[type="submit"]:hover,
input[type="submit"]:hover,
.btn.btn-glow:hover {
  background: #000000;
  border-color: #000000;
  box-shadow: var(--lift-2);
}

/* A submit button that opts into the quieter styles must not be overridden
   back to a primary fill by the element-level rule above. */
button[type="submit"].btn-secondary,
button[type="submit"].btn-ghost {
  background: var(--paper);
  border-color: var(--rule-strong);
  color: var(--ink);
}

.btn-secondary,
.btn-ghost {
  background: var(--paper);
  border: 1px solid var(--rule-strong);
  color: var(--ink);
}

.btn-secondary:hover,
.btn-ghost:hover {
  border-color: var(--ink);
  background: var(--paper);
}

/* The lift. Every discrete control rises slightly toward the cursor — this
   single rule is the biggest contributor to the app feeling alive. Suppressed
   on touch (section 5), where there is no hover to speak of. */
@media (hover: hover) {
  .btn:hover,
  .btn-primary:hover,
  .btn-secondary:hover,
  .btn-ghost:hover,
  a[class*="-btn"]:hover,
  button[class*="-btn"]:hover {
    transform: translateY(-2px) scale(1.02);
  }
}

/* Chips / filters / tags: outlined pill, inverting to ink when active or
   hovered. The inversion is the whole interaction — no accent required. */
.chip,
.filter-chip,
.tab-btn,
.view-tab,
.demo-chip,
[class*="-chip"] {
  border-radius: var(--r-pill);
  border: 1px solid var(--rule);
  background: var(--paper);
  color: var(--ink-2);
  transition: transform var(--dur-hover) var(--ease-spring),
              background-color 180ms ease,
              border-color 180ms ease,
              color 180ms ease;
}

@media (hover: hover) {
  .chip:hover,
  .filter-chip:hover,
  .tab-btn:hover,
  .view-tab:hover,
  .demo-chip:hover,
  [class*="-chip"]:hover {
    transform: translateY(-2px);
    background: var(--ink);
    border-color: var(--ink);
    color: var(--paper);
  }
}

.chip.active,
.filter-chip.active,
.tab-btn.active,
.view-tab.active,
[class*="-chip"].active {
  background: var(--ink) !important;
  border-color: var(--ink) !important;
  color: var(--paper) !important;
}

/* Selection state — an option you have picked but not yet submitted. Must
   stay ink: grading (section 3) outranks it, and a green "selected" would
   pre-announce the answer. */
.option-item.selected,
.quiz-option.selected,
.opt-btn.selected,
.option-row.selected {
  border-color: var(--ink) !important;
  box-shadow: inset 2px 0 0 var(--ink);
}


/* ═══════════════════════════════ 3. SEMANTIC DATA COLOUR ════════════════ */

/* The only place colour lives. These classes are the contract — a template
   that needs to express performance adds `.is-good` / `.is-warn` / `.is-bad`
   and gets the fill, text and wash treatment appropriate to the element.
   See templates/UI.md for the thresholds. */

.is-good  { color: var(--good-text); }
.is-warn  { color: var(--warn-text); }
.is-bad   { color: var(--bad-text);  }
.is-none  { color: var(--none-ink);  }

.progress-fill.is-good, .meter-fill.is-good, .bar-fill.is-good { background: var(--good); }
.progress-fill.is-warn, .meter-fill.is-warn, .bar-fill.is-warn { background: var(--warn); }
.progress-fill.is-bad,  .meter-fill.is-bad,  .bar-fill.is-bad  { background: var(--bad);  }
.progress-fill.is-none, .meter-fill.is-none, .bar-fill.is-none { background: var(--none-ink); }

/* Status pills — graded, overdue, in progress. */
.pill-good, .status-good, .badge.is-good {
  background: var(--good-bg); color: var(--good-text);
  border-radius: var(--r-pill); border: none;
}
.pill-warn, .status-warn, .badge.is-warn {
  background: var(--warn-bg); color: var(--warn-text);
  border-radius: var(--r-pill); border: none;
}
.pill-bad, .status-bad, .badge.is-bad {
  background: var(--bad-bg); color: var(--bad-text);
  border-radius: var(--r-pill); border: none;
}

/* Graded answers. This is the product's most important colour moment: the
   instant after you commit to an answer. It gets the full treatment —
   fill, border and text — because it must be unmistakable at a glance. */
.option-item.correct,
.quiz-option.correct,
.opt-btn.correct,
.answer.correct,
[class*="-option"].correct {
  background: var(--good-bg) !important;
  border-color: var(--good) !important;
  color: var(--good-text) !important;
}

.option-item.incorrect,
.quiz-option.incorrect,
.opt-btn.incorrect,
.answer.incorrect,
[class*="-option"].incorrect,
[class*="-option"].wrong {
  background: var(--bad-bg) !important;
  border-color: var(--bad) !important;
  color: var(--bad-text) !important;
}

/* Mastery-map cells. */
.mastery-cell { border-radius: 6px; transition: transform 200ms var(--ease-spring); }
.mastery-cell.is-good { background: var(--good); }
.mastery-cell.is-fading { background: var(--good-soft); }
.mastery-cell.is-warn { background: var(--warn); }
.mastery-cell.is-bad  { background: var(--bad); }
.mastery-cell.is-none { background: var(--none); border: 1px solid var(--rule); }

@media (hover: hover) {
  .mastery-cell:hover { transform: scale(1.18); }
}


/* ═══════════════════════════════════════════ 4. MOTION SYSTEM ═══════════ */

/* Driven by /static/js/motion.js. Every utility here is opt-in via a class or
   data attribute, so no existing layout starts moving unless a template asks
   for it. Only transform and opacity are animated — both composite off the
   main thread, so none of this can contend with layout or scrolling. */

/* -- 4a. Scroll reveal ------------------------------------------------------
   Content rises into place as it enters the viewport. motion.js adds
   `.is-in` via IntersectionObserver; the resting state is defined first so
   content is never stranded invisible if the script fails to load. */

/* CRITICAL: [data-reveal] on its own hides NOTHING.

   The first version set opacity:0 here and relied on motion.js to put it
   back. That inverts the rule this whole system is built on — the resting
   state must be the finished state — and it failed exactly as you would
   expect: in an environment where IntersectionObserver delivered no
   callbacks, nine sections of the landing page stayed permanently invisible
   with no way to recover.

   So the hiding is applied by JS (`.is-armed`), and only AFTER it has
   confirmed it can observe. If the script is blocked, throws, or the browser's
   observer is broken, the page renders complete and static — worst case is
   "the animation didn't play", never "the content is gone". */
[data-reveal] {
  transition: opacity 700ms var(--ease-out), transform 700ms var(--ease-out);
}

[data-reveal].is-armed {
  opacity: 0;
  transform: translateY(18px);
  will-change: opacity, transform;
}

[data-reveal].is-in {
  opacity: 1;
  transform: none;
}

/* Stagger. Children of a [data-reveal-group] cascade rather than arriving as
   one slab — the difference between "the page loaded" and "the page is
   telling you something". Capped at 8; beyond that the tail feels laggy. */
[data-reveal-group] > [data-reveal]:nth-child(1) { transition-delay: 0ms; }
[data-reveal-group] > [data-reveal]:nth-child(2) { transition-delay: 70ms; }
[data-reveal-group] > [data-reveal]:nth-child(3) { transition-delay: 140ms; }
[data-reveal-group] > [data-reveal]:nth-child(4) { transition-delay: 210ms; }
[data-reveal-group] > [data-reveal]:nth-child(5) { transition-delay: 280ms; }
[data-reveal-group] > [data-reveal]:nth-child(6) { transition-delay: 350ms; }
[data-reveal-group] > [data-reveal]:nth-child(7) { transition-delay: 420ms; }
[data-reveal-group] > [data-reveal]:nth-child(n+8) { transition-delay: 490ms; }

/* -- 4b. Hover lift on surfaces --------------------------------------------
   Cards that navigate rise and sharpen their border. Hover-capable pointers
   only: on touch this would latch after a tap and look stuck. */

@media (hover: hover) {
  a.card,
  a[class*="-card"],
  .card-link,
  .tile,
  [data-lift] {
    transition: transform var(--dur-move) var(--ease-out),
                border-color var(--dur-hover) ease,
                box-shadow var(--dur-hover) ease;
  }

  a.card:hover,
  a[class*="-card"]:hover,
  .card-link:hover,
  .tile:hover,
  [data-lift]:hover {
    transform: translateY(-4px);
    border-color: var(--mute-2);
    box-shadow: var(--lift-2);
  }
}

/* Rows nudge instead of lifting — a full-bleed row that rises detaches from
   its list. */
@media (hover: hover) {
  .list-row,
  .assignment-row,
  [data-nudge] {
    transition: transform 200ms var(--ease-out), background-color 160ms ease;
  }

  .list-row:hover,
  .assignment-row:hover,
  [data-nudge]:hover {
    transform: translateX(3px);
    background: var(--paper-sink);
  }
}

/* -- 4c. Meters -------------------------------------------------------------
   A bar that is simply *there* at 72% says nothing. A bar that fills to 72%
   in front of you is the app reporting a result. motion.js reads
   data-fill="72%" and sets the width when the bar scrolls into view. */

[data-fill] {
  width: 0;
  transition: width var(--dur-fill) var(--ease-out);
}

/* -- 4d. Count-ups ----------------------------------------------------------
   Marked with data-count="385"; motion.js animates from 0 on entry.
   Tabular figures stop the number jittering as digits change width. */

[data-count] {
  font-variant-numeric: tabular-nums;
}

/* -- 4e. Ambient float ------------------------------------------------------
   Reserved for ONE element per page — the hero's product card. A page with
   two floating things looks unstable. */

@keyframes ix-float {
  0%, 100% { transform: translateY(0) rotate(0.35deg); }
  50%      { transform: translateY(-9px) rotate(-0.35deg); }
}

[data-float] {
  animation: ix-float 5.5s ease-in-out 1.2s infinite;
}

/* Pointer parallax: motion.js writes --tilt-x / --tilt-y from cursor position
   over the hero. Wrapped on its own element so it composes with the float
   above rather than fighting it for the transform property. */
[data-tilt] {
  transform: perspective(1200px)
             rotateX(var(--tilt-y, 0deg))
             rotateY(var(--tilt-x, 0deg));
  transition: transform 400ms var(--ease-out);
}

/* -- 4f. Typewriter ---------------------------------------------------------
   The hero lesson types itself out, which is the clearest possible statement
   of what this product does. */

@keyframes ix-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }

.ix-caret {
  display: inline-block;
  animation: ix-blink 1s step-end infinite;
  font-weight: 300;
}

/* -- 4g. Page entrance ------------------------------------------------------
   Applied to the first screenful, which is above the fold and therefore never
   triggers a scroll observer. */

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

[data-enter] {
  animation: ix-rise 650ms var(--ease-out) both;
}

/* Guard: data-enter and data-float both set the `animation` shorthand, so an
   element carrying both gets only one of them — and if the entrance is the one
   that loses, the element is stranded at that keyframe's opacity:0 with no
   animation left to move it. This shipped once on the hero card and blanked
   it. Float wins (it is the ambient, continuous behaviour) and opacity is
   pinned visible so the failure can never be invisible content again.
   The correct authoring is still one hook per element. */
[data-float][data-enter] {
  animation: ix-float 5.5s ease-in-out 1.2s infinite;
  opacity: 1;
}

[data-enter="1"] { animation-delay:  40ms; }
[data-enter="2"] { animation-delay: 130ms; }
[data-enter="3"] { animation-delay: 220ms; }
[data-enter="4"] { animation-delay: 310ms; }
[data-enter="5"] { animation-delay: 400ms; }
[data-enter="6"] { animation-delay: 490ms; }


/* ═══════════════════════════════════════════════ 5. PRESS ══════════════ */

/* The grey flash iOS paints on tap is a *replacement* for real feedback and
   lands ~100ms late. Suppress it; the :active rules do the job on
   pointer-down instead. */
a, button, [role="button"],
input[type="submit"], input[type="button"],
label, summary {
  -webkit-tap-highlight-color: transparent;
}

button, .btn, .btn-primary, .btn-ghost, .btn-secondary,
[role="button"], input[type="submit"], input[type="button"] {
  touch-action: manipulation;  /* drops the legacy ~300ms double-tap delay */
}

button:active, .btn:active, .btn-primary:active, .btn-ghost:active,
.btn-secondary:active, [role="button"]:active,
input[type="submit"]:active, input[type="button"]:active {
  transform: scale(0.97);
}

button:disabled:active, .btn:disabled:active,
[role="button"][aria-disabled="true"]:active, input:disabled:active {
  transform: none;
}

/* Full-width rows must not scale — shrinking a full-bleed row reads as a
   rendering glitch. They tint instead. */
.nav-links a:active, .mbn-item:active, .list-row:active,
.option-row:active, .menu-row:active {
  transform: none;
  background: rgba(15, 23, 42, 0.06);
}

a.card:active, a.course-card:active, .card-link:active, .tile:active {
  transform: scale(0.995);
}

p > a:active { transform: none; opacity: 0.6; }

/* ---- The long tail -------------------------------------------------------

   An audit found 215 distinct selectors across the product pages defining a
   :hover state and no :active state. On a phone those give no press feedback
   at all. Writing 215 bespoke rules would rot immediately, so these match the
   project's own naming conventions (-btn, -button, -card, -option, -row,
   -item), gated to genuinely interactive elements — an anchor, a button, an
   ARIA button, a label, or something carrying a click handler. A bare
   <div class="stat-item"> is not matched and keeps its static behaviour. */

a[class*="-btn"], a[class*="-button"], button[class],
[role="button"][class], label[class*="-option"], [onclick][class] {
  transition: transform var(--dur-press) ease-out, opacity var(--dur-press) ease-out;
  touch-action: manipulation;
}

a[class*="-btn"]:active, a[class*="-button"]:active,
button[class*="-btn"]:active, button[class*="-button"]:active,
button[class*="btn-"]:active, [role="button"][class*="-btn"]:active,
[role="button"][class*="-button"]:active, [onclick][class*="-btn"]:active,
[onclick][class*="-button"]:active {
  transform: scale(0.97);
}

a[class*="-card"]:active, button[class*="-card"]:active,
[role="button"][class*="-card"]:active, [onclick][class*="-card"]:active {
  transform: scale(0.995);
}

a[class*="-option"]:active, a[class*="-row"]:active, a[class*="-item"]:active,
button[class*="-option"]:active, button[class*="-row"]:active,
button[class*="-item"]:active, label[class*="-option"]:active,
[role="button"][class*="-option"]:active, [role="button"][class*="-row"]:active,
[role="button"][class*="-item"]:active, [onclick][class*="-option"]:active,
[onclick][class*="-row"]:active, [onclick][class*="-item"]:active {
  transform: none;
  background-color: rgba(15, 23, 42, 0.06);
}

/* Sticky-hover guard. On touch, :hover latches after a tap and the element
   stays lit until you tap elsewhere — the most common "this feels broken"
   symptom on a phone. Where the pointer cannot hover, hover styling is
   suppressed and :active does all the work.

   `:not(:active)` matters: touch browsers fire :hover *and* :active during
   the press, so without the guard this block would sit later in the cascade
   at equal specificity and cancel the press feedback above. */
@media (hover: none) {
  a[class*="-card"]:hover:not(:active),
  a[class*="-item"]:hover:not(:active),
  a[class*="-row"]:hover:not(:active),
  a[class*="-option"]:hover:not(:active),
  button[class]:hover:not(:active),
  [role="button"][class]:hover:not(:active) {
    transform: none;
  }
}


/* ══════════════════════════════════ 6. REDUCED MOTION ═══════════════════ */

/* "Reduced" does not mean "none": opacity and colour changes aid
   comprehension and are not vestibular triggers, so they survive. What goes
   is travel, scale, parallax, and anything that loops.

   This whole section is why the motion system above is safe to be as
   expressive as it is. */

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    animation-delay: 0ms !important;
    transition-duration: 0.01ms !important;
    transition-delay: 0ms !important;
    scroll-behavior: auto !important;
  }

  button:active, .btn:active, [role="button"]:active,
  a.card:active, .tile:active {
    transform: none !important;
    opacity: 0.7;
  }

  /* Entrance animations start at opacity:0 / translateY(). With durations
     crushed to 0.01ms an unfinished animation can strand content invisible,
     so force the resting state. */
  .content-animate-in, .fade-in, .fade-in-up, .scroll-reveal, .reveal,
  [data-animate], [data-reveal], [data-enter] {
    opacity: 1 !important;
    transform: none !important;
  }

  /* Ambient loops stop entirely — a permanently drifting card is the single
     worst offender for motion sensitivity. */
  [data-float], [data-tilt] {
    animation: none !important;
    transform: none !important;
  }

  /* Meters jump to their value rather than sweeping. motion.js still sets the
     width; only the sweep is removed. */
  [data-fill] { transition: none !important; }
}


/* ═════════════════════════ 7. TRANSPARENCY & CONTRAST ═══════════════════ */

/* 67 surfaces use backdrop-filter. When the user has asked for less
   transparency, a blurred translucent bar is exactly what they opted out of:
   raise the background to opaque and drop the filter entirely (a
   backdrop-filter with no visible translucency still costs a GPU pass). */

@media (prefers-reduced-transparency: reduce) {
  #mainNav, #mobileBottomNav, .nav, .navbar, .toolbar,
  .sheet, .glass, .modal-panel, [style*="backdrop-filter"] {
    backdrop-filter: none !important;
    -webkit-backdrop-filter: none !important;
    background: var(--paper) !important;
  }
}

@media (prefers-contrast: more) {
  #mainNav, #mobileBottomNav, .nav, .navbar, .toolbar, .sheet, .modal-panel {
    backdrop-filter: none !important;
    -webkit-backdrop-filter: none !important;
    background: var(--paper) !important;
    border-color: var(--ink) !important;
  }

  /* Muted body copy is the first thing to fail a contrast check. */
  .mute, .muted, .page-sub, .nav-links a { color: var(--ink) !important; }

  /* Hairlines disappear at high contrast; the shape language depends on
     them, so they darken rather than vanish. */
  .card, .stat-card, .chart-card, .panel, .tile,
  [class*="-card"], [class*="-panel"] {
    border-color: var(--ink-2) !important;
  }
}
