  :root{
    --yellow:#F5D949; --yellow-deep:#E8C328;
    --blue:#8FD8E8; --blue-deep:#5EB9CE;
    --black:#121212; --ink:#1B1B18; --paper:#F7F5EF; --card:#FFFFFF;
    --muted:#77756B; --line:#E7E3D8; --coral:#FF5A36; --radius:18px;
    /* Type scale — every font-size in the app (styles/*.css and inline styles in js/*.js) reads
       from one of these instead of a one-off px value. Before this, the app had ~27 distinct
       font-sizes only 0.5–1px apart (12px/12.5px/13px/13.5px...) with no real hierarchy, and a
       chunk of the smallest ones (8–10.5px) were below a comfortable reading size on a phone.
       This consolidates that down to 10 named steps and raises the floor to 11px. Old values were
       mapped to the nearest step (e.g. 12.5px/13px → --fs-sm, 8–11px → --fs-2xs) — see
       /tmp/fs_migrate.py's mapping table if a future value needs to be re-derived. */
    --fs-2xs:11px; --fs-xs:12px; --fs-sm:13px; --fs-base:14px; --fs-md:16px;
    --fs-lg:18px; --fs-xl:20px; --fs-2xl:24px; --fs-3xl:32px; --fs-4xl:38px;
    /* Status colors — before this, "danger" alone was written 3 different ways (#B23A22 in most
       places, but a plain-typo shorthand #B23 in 3 files — which is actually a *different*,
       more pink-washed red, since 3-digit hex #B23 expands to #BB2233, not #B23A22), and
       "success" was 3 different greens (#2A7A3B / #2E8B57 / #2E9E5B) depending which file wrote
       it. Same story for the pending/warning amber and the live/info teal — same intended meaning,
       slightly different hex per file. One canonical color per status now; every rgba() tint of
       these reuses the *-rgb triple below instead of re-deriving its own numbers. */
    --danger:#B23A22; --danger-rgb:178,58,34;
    --success:#2A7A3B; --success-rgb:42,122,59; --success-bg:#E9F6EC; --success-border:#C7E8CE;
    --warning:#8A6D00; --warning-rgb:232,195,40;
    --info:#12707E; --info-rgb:94,185,206;
    /* Round 9: one shared easing curve for every slide/pop transition in the app (screens,
       drawers, full-page overlays) so moving between them feels like one consistent motion
       language instead of each part animating at its own slightly different curve. This is
       the same curve the course/exam drawers were already using — just named and reused now. */
    --ease-pop: cubic-bezier(.2,.8,.3,1);
    /* Round 10: belt-and-suspenders alongside the <meta name="color-scheme"> tag in index.html —
       keeps native form controls (select/input) light-themed even if that meta tag is ever lost
       (e.g. a page fetched without <head>, or a future rewrite). Without either one, a phone in
       system dark mode renders native <select>/<input> chrome dark while this page's own CSS
       still gives them dark text (it assumes a light background) — invisible dark-on-dark text,
       exactly what was reported for the admin exam-generator's dropdowns. */
    color-scheme: light;
  }
  *{box-sizing:border-box; -webkit-tap-highlight-color:transparent;}
  html,body{margin:0;padding:0;height:100%; overflow:hidden; overscroll-behavior:none; position:fixed; width:100%;}
  body{font-family:'Prompt', sans-serif; background:var(--paper); color:var(--ink);}
  h1,h2,h3{margin:0;} button{font-family:inherit; cursor:pointer;} input,select{font-family:inherit;}
  ::selection{background:var(--yellow);}
  a{color:inherit;}

  #app{position:relative; width:100%; height:100vh; height:100dvh; overflow:hidden;}

  /* ---------- Screen transitions (splash → login/signup → home → ...) ----------
     Round 9 fix: this rule was shipping without its closing brace, which silently turned
     `.hidden` and `.tab-panel` below into rules *nested inside* `.screen` (browsers that
     support CSS nesting read that as ".screen .hidden", ".screen .tab-panel") — so `.hidden`
     (used everywhere in the app, including on elements that are never inside a `.screen`, like
     the exam comment ban notice in the exam drawer) silently stopped applying outside a screen.
     It was also missing a `transition` entirely, so switching screens was an instant cut with
     no animation at all. `.screen.active` used to live disconnected in styles/splash.css (a
     file about the splash animation specifically, not screen transitions in general) — moved
     next to its own base rule here so the whole on/off state for one thing lives in one place,
     per the "one function, one place" cleanup. */
  .screen{
    position:absolute; inset:0; overflow-y:auto; overflow-x:hidden; -webkit-overflow-scrolling:touch;
    overscroll-behavior-y:contain; scroll-behavior:smooth;
    opacity:0; pointer-events:none; transform:translateY(10px);
    transition:opacity 220ms ease, transform 260ms var(--ease-pop);
  }
  .screen.active{opacity:1; pointer-events:auto; transform:translateY(0); z-index:2;}

  /* Round 17 fix: .screen's `transform` above (plus .screen.active's `transform:translateY(0)`)
     establishes a new containing block for any `position:fixed` descendant — which is exactly
     what .bottom-nav is (styles/components.css), and #screen-home is the one screen that has
     it. Instead of staying pinned to the true viewport bottom while #screen-home scrolls
     internally (overflow-y:auto above), the bar was anchored to #screen-home's own box instead
     and scrolled away with the page content — the "bar stuck mid-page instead of at the bottom"
     bug. #screen-home opts out of the transform (keeps the opacity fade) so its one
     position:fixed descendant works correctly; every other .screen (splash/login/signup/...)
     has no fixed descendants, so their slide-in animation is untouched. An ID selector already
     outranks the .screen/.screen.active class rules above, so no !important needed. */
  #screen-home, #screen-home.active{transform:none;}

  .hidden{display:none !important;}
  .tab-panel{}

  /* ---------- Full-page popups (exam player, pricing, profile settings, image lightbox,
     printable solution view) ----------
     One shared toggle instead of each of those five repeating its own
     `display:none` / `.active{display:flex}` pair — that pattern can't animate at all, since
     `display` has no transition. Swapping the hidden state to opacity+visibility+transform is
     what actually makes the open/close animate, and doing it once here means every popup gets
     the same smooth feel for free; a future one only needs to add this one class instead of
     redefining the toggle again. Each popup's own stylesheet still sets its own background/
     layout/z-index override where it needs one (see exam.css, payment.css, profile.css,
     modal.css). */
  .fullscreen-overlay{
    position:fixed; inset:0; z-index:1500;
    opacity:0; visibility:hidden; pointer-events:none;
    transform:translateY(14px) scale(0.98);
    transition:opacity 200ms ease, transform 220ms var(--ease-pop), visibility 0s linear 220ms;
  }
  .fullscreen-overlay.active{
    opacity:1; visibility:visible; pointer-events:auto; transform:none;
    transition:opacity 200ms ease, transform 220ms var(--ease-pop);
  }

  /* ---------- Shared motion primitives ----------
     Round 11: three reusable animations any stylesheet can reference, instead of every new
     section (marketplace, sidebar, auth...) inventing its own fade/pop/shimmer with slightly
     different timing. Same idea as --ease-pop above — one motion vocabulary, used everywhere.
     - motionFadeUp: entrance for cards/list items that appear in a batch (grids, lists).
       Use with nth-child delays for a staggered feel, same pattern exam.css's
       .exam-level-section already uses.
     - motionPop: a quick "acknowledged" bounce for a toggle turning on (bookmark heart,
       wizard step dot completing, a badge appearing) — scales past 1 then settles.
     - motionShimmer: background-position sweep for skeleton loading placeholders.
     Every keyframe below is paired with a `prefers-reduced-motion` guard using the same
     `[data-motion="<name>"]` attribute selector, so a component only has to add the data
     attribute once and reduced-motion support comes for free — no need to hand-write a second
     override rule per component. */
  @keyframes motionFadeUp{ from{ opacity:0; transform:translateY(10px); } to{ opacity:1; transform:none; } }
  @keyframes motionPop{ 0%{ transform:scale(1); } 45%{ transform:scale(1.18); } 100%{ transform:scale(1); } }
  @keyframes motionShimmer{ from{ background-position:-120% 0; } to{ background-position:220% 0; } }

  [data-motion="fade-up"]{ animation:motionFadeUp 380ms cubic-bezier(.2,.8,.3,1) both; }
  [data-motion="fade-up"]:nth-child(1){ animation-delay:0ms; }
  [data-motion="fade-up"]:nth-child(2){ animation-delay:40ms; }
  [data-motion="fade-up"]:nth-child(3){ animation-delay:80ms; }
  [data-motion="fade-up"]:nth-child(4){ animation-delay:120ms; }
  [data-motion="fade-up"]:nth-child(5){ animation-delay:160ms; }
  [data-motion="fade-up"]:nth-child(6){ animation-delay:200ms; }
  [data-motion="fade-up"]:nth-child(n+7){ animation-delay:240ms; }

  [data-motion-pop="true"]{ animation:motionPop 320ms var(--ease-pop); }

  .skeleton-block{
    background:linear-gradient(90deg, rgba(18,18,18,0.06) 25%, rgba(18,18,18,0.11) 37%, rgba(18,18,18,0.06) 63%);
    background-size:400% 100%; animation:motionShimmer 1.4s ease infinite; border-radius:10px;
  }

  @media (prefers-reduced-motion:reduce){
    [data-motion="fade-up"]{ animation:none; }
    [data-motion-pop="true"]{ animation:none; }
    .skeleton-block{ animation:none; background:rgba(18,18,18,0.08); }
  }
