/* VeiMester — practice site styles. Light/dark aware, responsive, no framework. */

/* Plus Jakarta Sans, self-hosted from web/fonts/ — NOT from fonts.googleapis.com. Loading it from
   Google made every visitor's IP reach a US server before the page had asked them anything, which
   needs a legal basis a privacy-page mention is not (GDPR art. 6 and ch. V; German courts have
   awarded damages on these facts). Serving it ourselves removes the transfer and drops two DNS+TLS
   round trips. Do not reintroduce the CDN link.
   One variable file per subset (wght 200-800), so the five old static weights are one request: 27 KB
   latin + 21 KB latin-ext. Cyrillic and Vietnamese are deliberately not shipped. Licence: SIL OFL
   1.1, see fonts/OFL.txt, which is why the file has to travel with the font. */
@font-face {
  font-family: "Plus Jakarta Sans";
  font-style: normal;
  font-weight: 200 800;
  font-display: swap;
  src: url("./fonts/plus-jakarta-sans-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;
}
@font-face {
  font-family: "Plus Jakarta Sans";
  font-style: normal;
  font-weight: 200 800;
  font-display: swap;
  src: url("./fonts/plus-jakarta-sans-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;
}

:root {
  --bg: #f4f6fa;
  --surface: #ffffff;
  --surface-2: #eef1f6;
  --text: #12151b;
  --muted: #5b6472;
  --border: #e4e8ef;
  --brand: #c8102e;         /* Norwegian red accent */
  --brand-2: #ff5d52;       /* warm coral, gradient partner */
  --brand-ink: #ffffff;
  /* Ink for a surface that is white in BOTH themes, so its text cannot follow the
     theme either. `.cta-band .btn.primary` is `background: #fff` on purpose — a white
     button on the red band — and taking `var(--brand)` there put dark's #ff5a6e, which
     is tuned for a dark ground, on white at 3.03:1. That is an AA failure on the
     landing page's LAST call to action, and it is invisible in light mode, where
     --brand is already this value. Pinned here and deliberately NOT redefined under
     [data-theme="dark"]; same reasoning as --ok-ink and --gold-ink below. */
  --brand-on-white: #c8102e;
  --blue: #2f6fed;
  --amber: #e08a00;
  --violet: #7c5cff;
  --ok: #1a7f37;
  /* --ok is tuned for white; on the tinted --surface-2 wells it lands at 4.49:1
     for 12.5px text (.stat-delta), i.e. a hairline AA miss. --ok-ink is the same
     green a step darker, for small green text on a tint. Dark theme needs no
     such step (light ink on a dark well already measures ~8.9:1), so there it
     is just --ok — darkening it there would LOWER contrast, not raise it. */
  --ok-ink: #177030;
  --ok-bg: #e7f6ec;
  /* Gold, for an EARNED milestone (.jr-badge.is-earned). --amber could not do
     this job: it measures 2.69:1 on the light surface, which misses not just
     the 4.5:1 text floor but the 3:1 one for icons, so an amber tick on a card
     was invisible as a graphic before it was illegible as text. --gold-ink is
     the same hue walked dark enough to clear AA on BOTH the surface and its own
     tinted well (5.44:1 and 4.67:1, measured). Dark theme inverts the roles —
     there the ink is the light #f2b24a on a dark well (9.34:1 / 6.44:1) — which
     is the same flip --ok/--ok-ink documents above, for the same reason. */
  --gold-ink: #965d00;
  --gold-bg: #faecd6;
  --bad: #c0342a;
  /* --bad is an INK colour (it flips light in dark theme so it reads on a dark
     surface). A red button with white text needs the opposite — a colour that
     stays dark in both themes — or the dark-theme #f87171 puts white ink at
     2.77:1. Hence a separate solid for destructive button backgrounds. */
  --bad-solid: #c0342a;
  --bad-bg: #fdecea;
  /* DECORATIVE gradient. Runs to a bright coral, so it may only ever sit behind
     LARGE text (>=24px, or >=18.66px bold), which needs 3:1 — hero h1 is 4.8:1.
     Never put small white text on this: white needs backdrop luminance <= .1833
     for 4.5:1, and a red channel of 255 alone contributes .2126, so no #ff....
     coral can carry small white ink at AA no matter what green/blue do. */
  --grad: linear-gradient(135deg, #c8102e 0%, #e23048 55%, #ff5d52 100%);
  /* CONTROLS gradient — same brand ramp, stopped before it gets too light, so
     every point under the ink clears 4.5:1 (start 5.9:1, end 4.6:1). Use this,
     not --grad, on anything with normal-size white text: buttons, chips, pills.
     Added 2026-08-01 after measuring: .btn.primary was 3.4-4.2:1 and
     .chip.active 3.6:1 — i.e. every primary CTA in the app failed AA. */
  --grad-ui: linear-gradient(135deg, #c8102e 0%, #e12740 100%);
  /* Solid fallback = the gradient's dark start stop; renders under the gradient
     so the button stays legible if a gradient ever fails to paint.
     CAUTION: this is what masked the failure above. A contrast checker reads
     background-color and scores 5.9:1, but the opaque gradient painted on top is
     what the user actually sees — so --grad-solid made .btn.primary look
     compliant to tooling while rendering at 3.4:1. Measure the gradient. */
  --grad-solid: #c8102e;
  /* ---------------------------------------------------------------- elevation
     A 5-step scale, and every step is THREE layers: a 1px contact shadow, a tight key shadow and a
     wide ambient one. The old two tokens were each a single soft blur, which is why every surface
     read as a sticker on a flat ground — one blur cannot say both "this touches the page here" and
     "this floats". Negative spread on the wide layers keeps the ambient from bleeding out sideways
     past the card, which is what made a bigger shadow look like smudge rather than height.
     Shadow ink is a blue-grey (16,24,40), never black: the page ground is #f4f6fa, and a
     neutral-black shadow over a blue-grey ground reads as dirt. Same reason dark theme's steps are
     near-black AND lower-alpha per layer — on a #0d1015 ground a shadow cannot darken much, so depth
     there comes from --hairline (the lit top edge) more than from the shadow. */
  --e1: 0 1px 2px rgba(16,24,40,.04), 0 1px 3px -1px rgba(16,24,40,.05);
  --e2: 0 1px 2px rgba(16,24,40,.05), 0 4px 10px -3px rgba(16,24,40,.07), 0 10px 26px -12px rgba(16,24,40,.10);
  --e3: 0 1px 3px rgba(16,24,40,.06), 0 10px 22px -8px rgba(16,24,40,.10), 0 24px 52px -20px rgba(16,24,40,.16);
  --e4: 0 2px 6px rgba(16,24,40,.07), 0 18px 38px -12px rgba(16,24,40,.14), 0 40px 80px -28px rgba(16,24,40,.22);
  /* The original two names stay as the aliases everything already uses, so the
     whole sheet moved onto the layered scale in one edit instead of ~100. */
  --shadow: var(--e2);
  --shadow-lg: var(--e3);
  /* The lit top edge. A real raised surface catches light on the edge facing it,
     and this single inset line does more for "premium" than any blur — it is
     what separates a card from a rectangle of white. Paired with --surface-lift
     (a ~1.5% vertical wash) so the surface itself is shaded, not just its edge. */
  --hairline: inset 0 1px 0 rgba(255,255,255,.9);
  --surface-lift: linear-gradient(180deg, rgba(255,255,255,.9), rgba(255,255,255,0) 42%);
  /* Frosted chrome (topbar, sticky bars, overlays). Kept as tokens because the
     blur radius and the surface alpha have to move together — raising one
     without the other is what makes glass read as "washed out" instead. */
  --glass: color-mix(in srgb, var(--surface) 78%, transparent);
  --glass-blur: saturate(180%) blur(14px);
  /* Ambient page wash: three wide, very low-alpha radial pools painted once on
     a fixed body::before. 4-6% is deliberate — it has to be felt, not seen, and
     anything stronger moves the measured contrast of body-level text. */
  --amb-1: rgba(200,16,46,.05);
  --amb-2: rgba(47,111,237,.05);
  --amb-3: rgba(124,92,255,.04);
  /* corner radius scale. There were 21 distinct hard-coded radii in this sheet against 7 uses of the
     two tokens that already existed — so "rounded" meant something slightly different on every
     surface. Seven steps replace them, each within 2px of the values it absorbs, so this unified the
     system without moving any corner enough to notice.
       xs  hairline chrome: bar fills, kbd, small keys        <- 4,5,6,7
       sm  controls: inputs, small buttons, session rows      <- 8,9,10,11
       md  tappable blocks: answer options, explain, search   <- 12,13,14,15
       lg  cards (the house radius)                           <- 16,20
       xl  full-bleed panels: hero, stats band, showcase      <- 22,24,26
       2xl the landing hero                                    <- 30,32
       pill fully-round: chips, badges, segmented controls
     Deliberately NOT on the scale: 50% circles, the 2px underline caps, and the device shells in
     showcase.css (--pr, the laptop lid) — those are drawings of physical objects, not UI surfaces. */
  --r-xs: 6px;
  --r-sm: 10px;
  --r-md: 12px;
  --r-lg: 16px;
  --r-xl: 22px;
  --r-2xl: 32px;
  --r-pill: 999px;
  /* the original two names, kept as aliases: they are load-bearing in 7 rules
     and `--radius` reads better than `--r-lg` for "a card". */
  --radius: var(--r-lg);
  --radius-sm: var(--r-sm);
  /* ONE page rail. Every horizontal container in the app — .view, the sticky
     topbar's contents, both footer rows, .course-wrap — centres on this width, so
     content lines up down the whole page and does not change width between routes.
     It used to be four different caps (920 .view / 1160 landing / 1240 schools /
     1120 footer), which meant signing in visibly shrank the front page: the
     signed-out landing added body.landing (1160) and the signed-in dashboard did
     not (920). Widened to the landing figure because that is the one that was
     designed against real content. */
  --page-w: 1160px;
  --font: "Plus Jakarta Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  /* type scale — every hero/section heading pulls from here so sizes stay
     in lockstep instead of each drifting its own clamp() over time */
  --fs-display: clamp(2.1rem, 5.2vw, 3.4rem); /* landing hero title */
  --fs-h1: clamp(1.8rem, 4.4vw, 2.6rem);      /* page hero h1 (dashboard, cta-band) */
  --fs-h2: clamp(1.6rem, 3.6vw, 2.3rem);      /* section headings */
  --fs-lead: clamp(1.05rem, 1.8vw, 1.2rem);   /* hero/section subheads */
  /* motion tokens — mirror the house curves already used across the sheet so the swipe settle
     (motion.css) and any future transition share one source of truth. --ease-out matches the existing
     cubic-beziers; --ease-spring matches the .brand/.btn spring.
     DURATION BUDGET (raised 2026-08-01, was <300ms): the ceiling is now 2s, but it is a ceiling per
     *moment*, not a licence. Cost scales with repetition: anything that fires constantly — hover,
     focus, per-option feedback, route changes, list re-renders — still belongs at --dur-quick/base,
     because a 2s animation on a control the user hits 45 times an exam is 90s of movement under the
     text they are reading. Spend the long end on things that happen ONCE per session, and never on
     the learning surfaces listed in docs/ARCHITECTURE.md's motion table. */
  --ease-out: cubic-bezier(.22,.61,.36,1);
  --ease-spring: cubic-bezier(.2,1.5,.4,1);
  --dur-quick: .16s;
  --dur-base: .24s;
  --dur-slow: .5s;    /* one-shot reveals: answer feedback, gate/result icons */
  --dur-show: 1.2s;   /* once-per-session showpieces only (result celebration) */
}
:root[data-theme="dark"] {
  --bg: #0d1015; --surface: #161a21; --surface-2: #1f252e;
  --text: #e8eaed; --muted: #99a3b2; --border: #29313c;
  --brand: #ff5a6e; --brand-2: #ff8a7a; --blue: #7aa2ff; --amber: #f2b24a; --violet: #a996ff;
  --ok: #4ade80; --ok-ink: #4ade80; --ok-bg: #10261a; --bad: #f87171; --bad-solid: #b32d24; --bad-bg: #2a1615;
  --gold-ink: #f2b24a; --gold-bg: #3e3528;
  --grad: linear-gradient(135deg, #8f1023 0%, #c8324a 55%, #ff6b62 100%);
  /* dark's own ramp already clears AA up to the 55% stop (9.3:1 / 5.2:1); it is
     only the #ff6b62 end that can't (2.8:1), so controls stop at the mid stop. */
  --grad-ui: linear-gradient(135deg, #8f1023 0%, #c8324a 100%);
  --grad-solid: #8f1023;
  --e1: 0 1px 2px rgba(0,0,0,.35), 0 1px 3px -1px rgba(0,0,0,.3);
  --e2: 0 1px 2px rgba(0,0,0,.4), 0 4px 12px -3px rgba(0,0,0,.42), 0 12px 28px -12px rgba(0,0,0,.5);
  --e3: 0 1px 3px rgba(0,0,0,.45), 0 12px 26px -8px rgba(0,0,0,.5), 0 28px 56px -20px rgba(0,0,0,.6);
  --e4: 0 2px 6px rgba(0,0,0,.5), 0 20px 42px -12px rgba(0,0,0,.55), 0 44px 84px -28px rgba(0,0,0,.65);
  /* Dark's lit edge is a whisper (4.5%, not 90%): on a #161a21 surface the same
     white line that reads as "lit" in light theme reads as a visible white
     stroke — a border someone forgot to remove. The wash flips to a black one
     from the BOTTOM, because in dark theme the depth cue is the surface getting
     darker as it recedes, not lighter as it rises. */
  --hairline: inset 0 1px 0 rgba(255,255,255,.045);
  --surface-lift: linear-gradient(180deg, rgba(255,255,255,.028), rgba(0,0,0,.06));
  --glass: color-mix(in srgb, var(--surface) 76%, transparent);
  --amb-1: rgba(255,90,110,.055);
  --amb-2: rgba(122,162,255,.05);
  --amb-3: rgba(169,150,255,.045);
}
* { box-sizing: border-box; }
/* Kill the grey tap flash on touch — we provide our own :active feedback, and the
   default highlight makes buttons/links feel unpolished on phones. */
a, button, .opt, .chip, .qgrid button, select, summary, [role="button"] {
  -webkit-tap-highlight-color: transparent;
}
/* Shown only on touch devices (e.g. the swipe hint). The coarse-pointer rule in
   the mobile block flips it on; keyboard hints stay for pointer users. */
.touch-only { display: none; }
/* Content artwork — the drawn question scenes, the sign faces, the course figures and photographs —
   is not meant to be lifted out of the page, so the three casual routes to a local copy are closed:
   drag-to-desktop (`-webkit-user-drag`), the iOS long-press "Save Image" sheet
   (`-webkit-touch-callout`), and the selection halo that makes an image look draggable. Right-click
   "Save image as" is handled in app/main.js, since CSS cannot reach the context menu.
   Deterrent, not protection: every file is still a plain URL under web/. The point is that nobody
   does it by accident or in one gesture. */
.scene img, .scene svg, .cs img, .cs svg,
.signimg, .scene-photo, .cs-img, .course-ch-img, .tile-ico, .mk-cintro-ico {
  -webkit-user-drag: none;
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
}
/* Same reasoning for the words: the questions, the explanations and the course text are the product,
   so the page is not a document to select and lift text out of. The selection gesture is off
   everywhere — which also removes "Copy" from the right-click menu and the iOS long-press bar.
   Form fields are the one exception, and a hard one: you cannot fix an email address or edit a search
   term you are not allowed to select. `input, textarea` covers the real cases; the rest is listed so
   a future field type doesn't inherit the block.
   What this does NOT touch: the text is still in the DOM, so screen readers, browser translation,
   in-page find (⌘F) and the crawlable landing pages under web/no|en/ (they load seo.css, not this
   file) all work exactly as before. Same caveat as the artwork above — this stops casual copying. */
body {
  -webkit-user-select: none;
  user-select: none;
}
input, textarea, select, option, [contenteditable="true"] {
  -webkit-user-select: text;
  user-select: text;
}
/* …and the question ID, which exists to be quoted back to us. An ID you cannot copy
   is an ID nobody reports, so the one string on a question card that is worth lifting
   out stays selectable while the stem, the options and the explanation around it do not.
   `display: contents` rather than a plain inline span on purpose: .qmeta becomes a flex
   container when it carries the read-aloud toggle (see .qmeta:has(.tts-toggle) in
   tts.css), where a real span would become its own flex item and pick up the .5rem gap,
   detaching the "·" separator from the ID. With no box generated the run reflows exactly
   as the bare text did, and the text still inherits `user-select` from here. */
.qid {
  display: contents;
  -webkit-user-select: text;
  user-select: text;
}
/* Ground colour is declared on <html>, and <body> is transparent, so the ambient
   wash below has a defined place to sit. Left on <body>, the base colour would
   PROPAGATE to the canvas and body would paint as if it had no background — the
   wash then depends on that propagation rule to be visible at all, which is the
   kind of thing that quietly breaks the day someone gives <html> a background. */
html {
  background: var(--bg);
  /* Stop iOS inflating body text when the phone is turned to landscape. `100%` rather
     than `none`: `none` also disables the user's own pinch-zoom text scaling. */
  -webkit-text-size-adjust: 100%;
}
/* Horizontal overflow is stopped at the viewport, not only at .view — see the hero-route rule on
   .view. `clip`, not `hidden`: clip never makes body a scroll container, so position: sticky inside
   (the topbar, the exam's mobile bar) keeps the viewport as its scrollport. On the root it
   propagates to the viewport as hidden, which is the intent. Set on BOTH html and body because iOS
   Safari ignores it on body alone. */
html, body { overflow-x: clip; }
body {
  margin: 0; background: transparent; color: var(--text);
  /* `100%`, not `16px` (changed 2026-09-05). <html> declares no font-size, so `rem`
     already tracks the reader's browser font-size setting — which meant every padding,
     radius and clamp() in this sheet scaled with it while the prose that INHERITS from
     here stayed pinned at 16px. The layout got airier and the text did not, which is
     worse than ignoring the setting outright. `100%` is 16px on a default profile, so
     nothing moves for the overwhelming majority; it just stops overriding the minority
     who asked for something else. */
  font: 100%/1.55 var(--font);
  display: flex; flex-direction: column; min-height: 100vh;
  -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
}
/* Ambient ground: three wide radial pools, painted once, fixed to the viewport. The page reads as lit
   from somewhere instead of being a flat fill, and because the layer is `fixed` the content scrolls
   THROUGH the light rather than dragging it along — depth for one static paint and zero per-frame
   work. On html::before, not body::before: those two are the edge sign decorations (bottom of this
   file). z-index -1 keeps it behind those and behind the z-index:1 content column, so nothing is ever
   tinted by it — only the gaps. `fixed` here costs nothing; `background-attachment: fixed` on <body>
   would have done the same job while forcing a full repaint per scroll frame on mobile Safari. */
html::before {
  content: ""; position: fixed; inset: 0; z-index: -1; pointer-events: none;
  background:
    radial-gradient(58rem 34rem at 8% -6%, var(--amb-1), transparent 62%),
    radial-gradient(52rem 32rem at 98% 6%, var(--amb-2), transparent 60%),
    radial-gradient(46rem 30rem at 46% 104%, var(--amb-3), transparent 62%);
}
a { color: inherit; }
/* Tracking and leading are size-specific, not one value for all headings: letters
   read too far apart as type grows (so large sizes tighten) and too tight as it
   shrinks, and leading moves the opposite way — an h3 here is body-sized (~1.1rem)
   and wraps to two lines on a card, so it needs room, not the display setting. */
h1, h2, h3 { margin: 0 0 .5rem; }
/* `balance` is the single cheapest typographic upgrade available to a heading:
   the browser evens out the line lengths instead of filling greedily and
   leaving one orphaned word on the last line. Scoped to headings because it is
   capped at ~6 lines and is only worth the layout cost on short text — body
   copy gets `pretty`, which just prevents the orphan. Both degrade to normal
   wrapping where unsupported, so neither needs a fallback. */
h1, h2, h3 { text-wrap: balance; }
h1 { line-height: 1.08; letter-spacing: -.025em; }
h2 { line-height: 1.18; letter-spacing: -.02em; }
h3 { line-height: 1.32; letter-spacing: -.008em; }
p { margin: 0 0 .75rem; text-wrap: pretty; }
:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; border-radius: var(--r-xs); }

/* top bar */
/* The bar is sticky, so its height is a permanent tax on every screen — kept as low
   as the tallest thing inside it allows. That tallest thing is the logo, not the
   padding, which is why the image height is trimmed at each breakpoint too. */
.topbar {
  display: flex; align-items: center; gap: 1rem 1.25rem;
  padding: .28rem 1.25rem; background: var(--glass);
  /* -webkit- prefix is not vestigial here: Safari only dropped the requirement
     in 18, and an unprefixed-only rule degrades to a flat 78%-opaque bar with
     the page legible straight through it on anything older. */
  -webkit-backdrop-filter: var(--glass-blur);
          backdrop-filter: var(--glass-blur);
  border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 20;
}
.brand { display: inline-flex; align-items: center; min-height: 38px; text-decoration: none; }
.brand img { height: 40px; width: auto; display: block; }
.nav-toggle {
  display: none; align-items: center; justify-content: center; flex-direction: column; gap: .22rem;
  width: 2.5rem; height: 2.5rem; border: 1px solid var(--border); border-radius: var(--r-pill);
  background: var(--surface); color: var(--text); cursor: pointer; padding: 0; margin-left: auto;
}
.nav-toggle span {
  display: block; width: 1.05rem; height: 2px; border-radius: 2px; background: currentColor;
}
.mainnav { display: flex; gap: 1.25rem; margin-left: auto; }
.mainnav a { text-decoration: none; color: var(--muted); font-weight: 600; padding: .3rem 0; position: relative; }
.mainnav a::after {
  content: ""; position: absolute; left: 0; right: 100%; bottom: -.15rem; height: 2px;
  background: var(--brand); border-radius: 2px; transition: right .22s ease;
}
.mainnav a:hover { color: var(--text); }
.mainnav a:hover::after { right: 0; }
/* `min-height` matches `.authnav .btn.small` below, and it is a CLS fix, not cosmetics:
   `#authnav` ships EMPTY in index.html and is filled by renderAuthNav() once the module
   loads, so without a reserved box the sticky bar grew 42px -> 46px on boot. Four pixels,
   but they move `#view` — ~94% of a phone viewport — which is a near-1.0 impact fraction,
   and that is most of the 0.96 CLS measured on 2026-08-22. */
.authnav { display: flex; align-items: center; gap: .6rem; margin-left: .45rem; min-height: 40px; }
/* 40px, not 44: these two sit in the sticky bar, whose height they would otherwise
   set. Still far above the 24px WCAG 2.5.8 target-size floor. */
.authnav .btn.small { min-height: 40px; }

/* ---- account menu (header) -------------------------------------------------
   One control per state: signed out it is the red primary "Logg inn" with sign-up
   inside; signed in it is the first name with "Min konto" and "Logg ut" inside. */
.authmenu { position: relative; }
/* Signed-out CTA: a plain link styled as the primary button, no menu affordance. */
.authlink-cta { min-height: 40px; }
.authmenu-btn { display: inline-flex; align-items: center; gap: .4rem; min-height: 40px; }
/* The signed-in trigger is deliberately NOT the brand red: it states who you are, and
   should not outrank the page's own primary action the way the signed-out CTA should. */
.authmenu-btn.is-user { background: var(--surface); color: var(--text); border: 1px solid var(--border); box-shadow: none; }
.authmenu-btn.is-user:hover { background: var(--surface-2); }
.authmenu-avatar {
  display: inline-grid; place-items: center; width: 22px; height: 22px; border-radius: 50%;
  background: var(--grad-solid); color: var(--brand-ink); font-size: .72rem; font-weight: 700;
}
.authmenu-label { max-width: 11ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.authmenu-chev { width: 14px; height: 14px; flex: none; transition: transform .16s ease; }
.authmenu-btn[aria-expanded="true"] .authmenu-chev { transform: rotate(180deg); }
.authmenu-pop {
  position: absolute; top: calc(100% + .4rem); right: 0; z-index: 60;
  min-width: 11rem; display: flex; flex-direction: column; gap: .1rem; padding: .3rem;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md);
  box-shadow: var(--shadow-lg);
}
.authmenu-pop[hidden] { display: none; }
.authmenu-item {
  display: flex; align-items: center; min-height: 40px; padding: .45rem .6rem;
  border: none; border-radius: var(--r-sm); background: transparent;
  color: var(--text); font: inherit; font-weight: 600; text-align: left;
  text-decoration: none; cursor: pointer; white-space: nowrap;
}
.authmenu-item:hover, .authmenu-item:focus-visible { background: var(--surface-2); }
/* Sign-out is the one item that ends the session, so it is separated and toned like
   every other destructive action in the app. */
.authmenu-item.is-signout { color: var(--bad); border-top: 1px solid var(--border); border-radius: 0 0 var(--r-sm) var(--r-sm); margin-top: .2rem; }
@media (prefers-reduced-motion: reduce) { .authmenu-chev { transition: none; } }
.authlink { text-decoration: none; font-weight: 650; color: var(--muted); }
.authlink:hover { color: var(--text); }
.view { flex: 1; width: 100%; max-width: var(--page-w); margin: 0 auto; padding: 1.75rem 1.25rem 3.5rem; overflow-x: clip; }
/* The hero halo (`.lp-hero::after` / `.hero::after` in motion.css) is 170% of the hero and orbits,
   so the clip above cut it at the rail edge — a hard vertical line through a moving glow, plain in
   dark mode once the hero card was gone (owner, 2026-09-14, twice). First attempt was
   `overflow-clip-margin: 30vw` here, and it did NOTHING: the margin is honoured only when overflow
   is `clip` on BOTH axes, and .view is clip on x alone. Verified by screenshot — with the margin
   and with it forced to 0 the frames were identical. (It was signed off from a light-mode shot,
   where the halo is too faint to show its own edge. Check halos in dark.)
   So on the two hero routes the rail does not clip at all, and the page is kept from scrolling
   sideways at the VIEWPORT instead (html/body below) — an edge nobody can see. Checked before
   widening: the only landing content wider than the rail is the sign marquee, which clips itself;
   the signed-in home has no horizontal overflow. */
.view:has(.lp-hero, .hero) { overflow-x: visible; }
/* Checked before widening the clip: the only landing content whose GEOMETRY runs past the rail is
   the sign marquee (its track measures 1,765px over), and `.marquee` clips itself (computed
   overflow-x: hidden), so nothing new paints. The signed-in home has no overflow at all. If a
   future section leans on .view's clip to hide a wide track, it will show here first. */
/* ---- breadcrumbs (app/ui.js crumbs()) --------------------------------------
   Where am I, and how do I get back out. Until now only the course had one, it left
   out Home, and every other section had none — so anyone arriving on a deep route from
   a search result or a shared link had no way up except the topbar.
   The generated landing pages use this same rule (they load styles.css): the two halves of
   the site are one site, and the reader crosses between them.
   `ol` because the order carries the meaning. The separator is a ::before on every item
   but the first, so it is decoration a screen reader never reads out as a word — and it
   is --muted, not --border, because --border in dark mode (#29313c) disappears against
   the dark ground. `min-height: 24px` on the links keeps a comfortable tap target on a
   phone without making the row taller — 24px is the WCAG 2.5.8 floor for an inline text
   link (the 44px target rule is for controls). */
.crumbs { margin: 0 0 .75rem; font-size: .85rem; line-height: 1.5; }
.crumbs ol { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 .45rem; }
.crumbs li { display: flex; align-items: baseline; gap: .45rem; min-width: 0; }
.crumbs li + li::before { content: "/"; color: var(--muted); opacity: .55; }
.crumbs a { color: var(--muted); text-decoration: none; display: inline-flex; align-items: center; min-height: 24px; }
.crumbs a:hover { color: var(--text); text-decoration: underline; }
/* The current page, truncated rather than wrapped: a long lesson title as the last item
   would otherwise wrap to three lines and push the h1 under it down the fold. */
.crumbs [aria-current="page"] { color: var(--muted); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ----------------------------------------------------------------- footer */
/* Same --page-w rail as .view, so the brand block starts on the same line as the
   page content above it. */
.footer { border-top: 1px solid var(--border); background: var(--surface); }
.footinner {
  max-width: var(--page-w); margin: 0 auto; padding: 1.95rem 1.25rem 1.45rem;
  display: grid; grid-template-columns: minmax(220px, 1fr) minmax(0, 2.2fr); gap: 2rem 2.5rem;
}
.footbrand { display: flex; flex-direction: column; gap: .75rem; align-items: flex-start; }
/* The footer's call to action and the wordmark share a line, the button first.
   Wraps rather than shrinks: at narrow widths the button keeps its tap target
   and the wordmark drops under it. */
.footlead { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.footlead .btn.primary { flex: none; }
.footlead [hidden] { display: none; }
.footbrand img { height: 40px; width: auto; }
.footbrand p { margin: 0; color: var(--muted); font-size: .8rem; line-height: 1.55; max-width: 34ch; }
/* Statutory identity line. Takes the same muted colour and .8rem size as the brand
   note above it (owner's call, 2026-08-20) — it is a footer notice, not a callout.
   It only drops the 34ch clamp, so the name/org.nr/address stay on one line instead
   of wrapping mid-number. Do not shrink it below the note: the duty (and Vipps's
   onboarding review) is that the legal name and org number appear *clearly*, and
   fine-print sizing is the failure mode there. Selector carries `.footbrand` so it
   outranks `.footbrand p` — a bare `.footlegal` is one class against a class + a
   type and loses. */
.footbrand .footlegal { max-width: none; }
.footcols { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1.5rem 1.25rem; }
.footcol h2 {
  font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em;
  color: var(--text); margin: 0 0 .55rem;
}
.footcol ul { list-style: none; margin: 0; padding: 0; display: grid; gap: .1rem; }
/* Generous vertical padding rather than tight text rows: these are the smallest
   tap targets on the page, and on a phone the columns collapse to a list where
   mis-taps are otherwise easy.
   30px, down from 32 (2026-08-23, owner asked for a shorter footer): the tallest
   column is eight of these, so 2px a row is 16px off the whole footer and it is still
   6px clear of the 24px WCAG 2.5.8 floor this sheet holds itself to. Do not go under
   28 here — that is the phone value below, and it is already the tight one. */
.footcol a {
  display: inline-block;
  color: var(--muted); text-decoration: none; font-size: .86rem; line-height: 1.4;
  padding: .3rem 0; min-height: 30px;
}
.footcol a:hover { color: var(--text); text-decoration: underline; text-underline-offset: 3px; }
.footcol a:focus-visible { outline: 2px solid var(--blue); outline-offset: 3px; border-radius: var(--r-xs); }
/* inline-block, not a flex item: on a link whose label wraps to two lines a flex
   icon is pushed to the end of the whole box and floats away from the text.
   Inline keeps it glued to the last word, where it reads as part of the label. */
.ext-ico {
  display: inline-block; width: 12px; height: 12px;
  margin-left: .3rem; vertical-align: -0.05em; opacity: .75;
}

.footbar {
  border-top: 1px solid var(--border);
  max-width: var(--page-w); margin: 0 auto; padding: .8rem 1.25rem;
  display: flex; flex-wrap: wrap; gap: .5rem 1.25rem; align-items: center; justify-content: space-between;
  color: var(--muted); font-size: .78rem;
}
.footbar > * { margin: 0; }
.footbar-links { display: flex; flex-wrap: wrap; gap: .25rem 1rem; }
.footbar-links a { color: var(--muted); text-decoration: none; padding: .2rem 0; }
.footbar-links a:hover { color: var(--text); text-decoration: underline; }

@media (max-width: 860px) {
  .footinner { grid-template-columns: 1fr; gap: 1.25rem; }
  .footcols { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.1rem .9rem; }
  /* Denser rows on phones. 28px, not the desktop 32: still comfortably over the 24px
     WCAG 2.5.8 target floor this sheet holds itself to, but four stacked groups of
     them no longer run to a full screen of footer. The list gap stays where it is —
     it was already 1.6px, so the height was in the rows, not between them. */
  .footcol a { min-height: 28px; padding: .2rem 0; line-height: 1.35; }
  .footcol h2 { margin-bottom: .4rem; }
}
@media (max-width: 480px) {
  /* Two columns are kept down to 360px. Collapsing to one here was what made the
     footer ~910px tall on a 390px phone — taller than the screen it sits on — and it
     is not needed: at 350px of content the widest label ("Pass Protection") is ~100px
     against a 165px column. Below 360px there genuinely is not room, so it folds. */
  .footbar { justify-content: flex-start; }
}
/* Below ~340px (iPhone SE 1st gen and narrower) two columns leave ~130px each and
   the longer labels wrap to three lines, so there it folds to one. */
@media (max-width: 339px) {
  .footcols { grid-template-columns: 1fr; gap: .8rem; }
}
.loading { color: var(--muted); }
/* Available to screen readers, invisible on screen. Used by the boot skeleton,
   which is decorative to the eye but must still announce "loading" — #view is an
   aria-live region, so silent grey boxes would tell a screen-reader user nothing. */
.sr-only {
  position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}
/* ------------------------------------------------------------ boot skeleton
   web/bank.json is ~1.7 MB, so on a real connection there is a visible wait before the first view
   paints. This lives in index.html rather than being rendered by JS: it is on screen during the
   module fetch + parse too, which is exactly the window a JS-rendered placeholder cannot cover. Its
   blocks mirror the shape every route opens with (heading, lead, a card, a grid) so the real content
   lands in roughly the same places instead of shifting the page. It carries `.lp` AND `.boot` and
   uses the real `.lp-hero` markup, so the heading lands at the y the router will paint it at instead
   of 58px higher — see the CLS note on `.authnav` above.
   ⚠️ `min-height: 100svh` is the third half of that fix: the skeleton used to be 721px tall in an
   844px viewport, which left the FOOTER on screen, and the first render then shoved it 10 000px down.
   A shift only counts for what is visible, so keeping the fold full of skeleton is what removes it.
   `svh`, not `vh`: on a phone `vh` is the toolbar-less height, which would overshoot. */
.boot { display: grid; gap: clamp(3rem, 7vw, 5.5rem); min-height: 100svh; align-content: start; }
.boot-line { height: 1rem; border-radius: var(--r-xs); }
.boot-line.title { height: 2.1rem; width: min(60%, 20rem); }
.boot-line.lead { width: min(85%, 34rem); }
.boot-card { height: 8.5rem; border-radius: var(--r-lg); }
/* Stands in for `.lp-cta`'s two buttons, so the hero card is roughly the height the real
   one will be. `.btn.big` is 52px; the row wraps to one line at every width here. */
.boot-cta { height: 3.25rem; max-width: 26rem; border-radius: var(--r-md); }
.boot-grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.boot-grid > * { height: 5.5rem; border-radius: var(--r-lg); }

/* soft entrance for freshly-rendered view content */
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
.view > * { animation: rise .3s cubic-bezier(.22,.61,.36,1) both; }
@media (prefers-reduced-motion: reduce) { .view > * { animation: none; } * { scroll-behavior: auto; } }

/* ------------------------------------- the other two accessibility preferences
   Reduced motion has been handled exhaustively for a while (motion.css's kill list,
   plus the JS-side skips). These two were not handled at all until 2026-09-05, and they
   are the two that matter to a sheet built on translucency: every floating surface here
   is a `--glass` fill over a `backdrop-filter`, which is exactly what somebody asking
   for less transparency or more contrast is asking to be rid of.
   Tokens only, on purpose — redefining --glass/--glass-blur at :root reaches the topbar,
   the account popover and anything else that adopts them later, without a second list to
   keep in sync. The per-sheet surfaces that declare their own blur (the read-aloud player
   in tts.css, the map chrome in schools.css) carry their own copy of this block. */
/* The OPEN glass (owner, 2026-09-19): 70 % surface over a strong blur, for the one big card
   a page rests on — the landing-page article, the legal pages, the exam intro — so the edge
   shapes show through as blurred colour and the card stops reading as a white slab. It was
   30 % first, and the owner read that as "fully transparent": the surface is near-white on a
   near-white ground, so at 30 % nothing says "panel" and the shapes just show through. At 70 %
   the shapes dim to a frosted wash and the card is a card again. Never for chrome or small
   controls: --glass (78 %, lighter blur) is that. */
:root { --glass-open: color-mix(in srgb, var(--surface) 70%, transparent); --glass-open-blur: saturate(160%) blur(16px); }
@media (prefers-reduced-transparency: reduce) {
  :root { --glass: var(--surface); --glass-blur: none; --glass-open: var(--surface); --glass-open-blur: none; }
}
@media (prefers-contrast: more) {
  /* Near-solid plus a defined edge: at high contrast the hairline+wash depth cues are
     the first thing to disappear, so the border has to carry the separation alone. */
  :root { --glass: var(--surface); --glass-blur: none; --glass-open: var(--surface); --glass-open-blur: none; --border: var(--muted); }
}

/* cards & buttons */
/* The house surface: base colour + the top-edge wash + the lit hairline, then
   the layered shadow. Written as background-color/-image rather than the
   `background` shorthand ON PURPOSE — a later rule that sets `background: X` on
   a specific card (there are several) then clears the wash along with the
   colour, which is the correct behaviour: a card that picks its own fill has
   opted out of the lighting model rather than getting a stale gloss over it. */
.card {
  background-color: var(--surface); background-image: var(--surface-lift);
  border: 1px solid var(--border); border-radius: var(--radius);
  box-shadow: var(--hairline), var(--shadow); padding: 1.35rem;
}
/* The page's one big card as open glass — see --glass-open. No --surface-lift: its 90 %
   white top wash would repaint the upper half solid. The border softens to match. */
.card.is-glass {
  background: var(--glass-open);
  -webkit-backdrop-filter: var(--glass-open-blur);
          backdrop-filter: var(--glass-open-blur);
  border-color: color-mix(in srgb, var(--border) 70%, transparent);
}
.grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: .5rem;
  font: inherit; font-weight: 650; cursor: pointer; border: 1px solid var(--border);
  background-color: var(--surface); background-image: var(--surface-lift);
  color: var(--text); padding: .65rem 1.15rem;
  border-radius: var(--r-sm); text-decoration: none;
  box-shadow: var(--hairline), var(--e1);
  /* --dur-quick/base, not the 2s ceiling: a button is the single most
     repeatedly-hit surface in the app, so its cost is duration x frequency. */
  transition: transform .06s ease, box-shadow var(--dur-base) var(--ease-out),
              border-color var(--dur-base) var(--ease-out), filter var(--dur-base) var(--ease-out);
}
.btn { white-space: nowrap; }
.btn svg { width: 1.15em; height: 1.15em; flex: none; }
.btn:hover { border-color: var(--muted); box-shadow: var(--hairline), var(--e2); }
.btn:active { transform: translateY(1px); }
/* The specular line is an INSET box-shadow, never an extra background-image
   layer: --grad-ui is the measured, AA-clearing ramp under this button's white
   ink (see the token), and stacking another gradient over it would change the
   thing tools/ui-audit.mjs samples. A 1px inset at the top edge sits above the
   glyph box, so it lights the edge without touching what carries the text. */
.btn.primary {
  background-color: var(--grad-solid); background-image: var(--grad-ui);
  color: var(--brand-ink); border-color: transparent;
  box-shadow: inset 0 1px 0 rgba(255,255,255,.22),
              0 1px 2px rgba(16,24,40,.14),
              0 6px 18px -4px color-mix(in srgb, var(--brand) 42%, transparent);
}
.btn.primary:hover {
  filter: brightness(1.06);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.28),
              0 2px 4px rgba(16,24,40,.16),
              0 10px 26px -6px color-mix(in srgb, var(--brand) 52%, transparent);
}
/* Ghost drops the wash AND the elevation — a transparent button that still
   casts a shadow reads as a raised pane of nothing. */
/* Ghost — a VISIBLE outline (2026-09-14). `transparent` + the 1px --border hairline made the
   hero's "Ta en gratis prøveeksamen" all but vanish on the light page (owner: "not very visible"),
   and once the hero card went, the decorative shapes ran straight behind it. The border is now
   the text colour at 22% — so it tracks both themes instead of a fixed grey — and a faint
   surface wash keeps the label legible over whatever passes behind. Still no elevation: a
   transparent button casting a shadow reads as a raised pane of nothing. */
.btn.ghost { background: color-mix(in srgb, var(--surface) 62%, transparent); box-shadow: none;
  border-color: color-mix(in srgb, var(--text) 22%, transparent); }
.btn.ghost:hover { border-color: color-mix(in srgb, var(--text) 45%, transparent);
  background: color-mix(in srgb, var(--surface) 85%, transparent); box-shadow: var(--e1); }
.btn.block { width: 100%; }
.btn.big { padding: .85rem 1.5rem; font-size: 1.05rem; border-radius: var(--r-md); }
.btn:disabled { opacity: .5; cursor: not-allowed; }

/* hero */
/* Signed-in home hero — UN-BOXED to match .lp-hero (2026-09-14, owner's call: "same treatment on
   both"). It was the brand-gradient card: red, --shadow-lg, white ink, dark-scrim chips, a white
   primary button. Logging in swapped the open landing hero for a big red box, so the two front pages
   spoke different languages. Now they share one: no card, page ink on the page, brand-tinted eyebrow,
   the standard red primary, the standard ghost, and the same orbit glow + art glow motion.css gives
   .lp-hero (see there: `.lp-hero::after, .hero::after`). `.hero` is used by this hero ONLY (checked
   2026-09-14), so these are rewrites, not overrides.
   The old scrim/contrast notes for white-on-red chips no longer apply — there is no red to sit on.
   Side padding 0 for the same reason as .lp-hero: .view carries the gutter and the text should start
   on the rail edge with the brand mark. */
.hero { position: relative; color: var(--text); padding: clamp(2rem, 5vw, 4rem) 0; margin-bottom: 1.5rem; }
.hero > * { position: relative; }
.hero-badge { display: inline-flex; align-items: center; gap: .5rem; font-size: .78rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase;
  padding: .4rem .8rem; border-radius: var(--r-pill); margin-bottom: 1.1rem;
  background: color-mix(in srgb, var(--brand) 12%, transparent); color: var(--brand);
  border: 1px solid color-mix(in srgb, var(--brand) 25%, transparent); }
.hero h1 { font-size: var(--fs-h1); margin-bottom: .5rem; letter-spacing: -.02em; }
.hero .lead { color: var(--muted); font-size: var(--fs-lead); max-width: 46ch; }
.hero-cta { display: flex; gap: .7rem; flex-wrap: wrap; margin-top: 1.3rem; }
.hero-pills { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: 1.3rem; }
/* Page-ink chips, opaque on purpose: the decorative shapes now pass behind the hero, and a
   translucent chip over a shape edge reads as a smudge. */
.pill { display: inline-flex; align-items: baseline; gap: .35rem; background: var(--surface); color: var(--text);
  border: 1px solid color-mix(in srgb, var(--text) 14%, transparent); padding: .3rem .7rem; border-radius: var(--r-pill); font-size: .85rem;
  box-shadow: var(--e1); }
.pill b { font-size: 1rem; color: var(--brand); }

/* stats (legacy inline) */
.stats { display: flex; gap: 1.5rem; flex-wrap: wrap; margin: 1rem 0 0; color: var(--muted); }
.stats b { color: var(--text); font-size: 1.25rem; }

/* section headings
   An <h2> (not a <div>): these are the section headings of every view, and as
   divs they left the card <h3>s below them hanging off the page <h1> — a
   skipped level, which is what a screen-reader user navigates by. The size and
   weight are set here explicitly, so promoting the tag changed no pixels. */
.sec-title { display: flex; align-items: center; gap: .6rem; margin: 1.9rem 0 .9rem; font-size: 1.15rem; font-weight: 750; }
.sec-title .muted { font-weight: 500; font-size: .9rem; margin-left: auto; }
/* the trailing "See all →" link is 18px of text on its own — give it a 24px
   box so it clears the WCAG 2.5.8 minimum target without moving the heading */
.sec-title .muted a { display: inline-flex; align-items: center; min-height: 24px; }

/* dashboard */
.dash { display: grid; grid-template-columns: auto 1fr; gap: 1.4rem; align-items: center; }
.dash-ring { display: grid; place-items: center; text-align: center; }
.dash-ring small { color: var(--muted); font-size: .8rem; display: block; margin-top: .3rem; }
.statgrid { display: grid; grid-template-columns: repeat(3, 1fr); gap: .8rem; }
/* A stat tile is a WELL, not a card: it sits inside a card that is already
   raised, so it gets the lit top edge and no drop shadow at all. Two raised
   things nested is how a UI starts looking like bubble wrap. */
.stat {
  background: var(--surface-2); border: 1px solid var(--border);
  border-radius: var(--radius-sm); padding: .8rem .9rem; box-shadow: var(--hairline);
}
.stat-num { font-size: 1.5rem; font-weight: 800; font-variant-numeric: tabular-nums; line-height: 1.1; letter-spacing: -.02em; }
.stat-label { color: var(--muted); font-size: .78rem; font-weight: 600; }
.weakrow { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: 1rem; align-items: center; }
.weakrow .muted { font-size: .85rem; }

/* ===================================================== state panels (.statecard)
   One shape for every "nothing here yet" / "that broke" panel, generalised from what used to be
   `.onboard`. Before this there were five different treatments for the same job, and worse, a
   *failed* sessions fetch rendered in the same muted grey as the loading message it replaced, so an
   error was indistinguishable from a placeholder.
   Three tones, and the tone is never carried by colour alone: each ships its own icon, and the error
   tone is a live region so a screen reader is actually told. An empty state always offers the next
   action where one exists — a dead end with no way forward is the thing being fixed here. */
.statecard { display: flex; align-items: center; gap: 1.1rem; flex-wrap: wrap; }
.statecard-ico {
  flex: none; width: 3rem; height: 3rem; border-radius: var(--r-md);
  background: var(--surface-2); display: grid; place-items: center; font-size: 1.4rem;
  box-shadow: var(--hairline);
}
.statecard-body { flex: 1; min-width: 200px; }
.statecard-body p { margin: .2rem 0 0; }
.statecard-title { display: block; font-weight: 700; }
.statecard-actions { display: flex; gap: .6rem; flex-wrap: wrap; }
/* error: a left rule + tinted icon well, so it reads as "something went wrong"
   before the sentence is read. Text stays on text tokens (never --bad as body
   copy) so contrast does not depend on the status hue. */
.statecard.is-error { border-left: 3px solid var(--bad); }
.statecard.is-error .statecard-ico { background: var(--bad-bg); }
.statecard.is-info { border-left: 3px solid var(--blue); }
/* Compact variant for a panel nested inside a card that already has a heading
   (the strengths/weaknesses columns) — no icon well, just the sentence. */
.statecard.is-compact { gap: .6rem; }
.statecard.is-compact .statecard-ico { width: 1.9rem; height: 1.9rem; font-size: 1rem; border-radius: var(--r-sm); }
.statecard.is-compact .statecard-body { font-size: .9rem; }
@media (max-width: 560px) {
  /* stack rather than let a 200px body and a full-width button fight for one row */
  .statecard { align-items: flex-start; }
  .statecard-actions, .statecard-actions .btn { width: 100%; justify-content: center; }
}

/* progress ring (SVG) */
.ring { display: block; }
.ring-bg { stroke: var(--surface-2); }
.ring-fg { stroke: var(--brand); transition: stroke-dashoffset .6s cubic-bezier(.22,.61,.36,1); }
.ring-fg.good { stroke: var(--ok); } .ring-fg.mid { stroke: var(--amber); } .ring-fg.low { stroke: var(--bad); }
.ring-txt { fill: var(--text); font-weight: 800; font-size: 1.05rem; font-variant-numeric: tabular-nums; }
.ring-wrap { position: relative; display: inline-grid; place-items: center; }

/* ============================================================ progress panel
   Charts follow one rule set: thin marks, hairline SOLID grid one step off the
   surface, 2px surface-coloured rings/gaps doing the separating, and status
   colour never carrying meaning alone. --amber measures 2.69:1 on the light
   surface (sub-3:1), so every band also ships its name as a word and every
   value is repeated in the history tables below — that is the relief channel,
   not an optional nicety. Text always wears text tokens, never a data colour. */

/* hero: one readiness figure + the KPI row, side by side.
   This panel is the emotional peak of the page — where a learner finds out whether they are ready —
   and it was rendering as the same white card as the raw history table five screens down. It carries
   a wash in the verdict's own colour and a slightly deeper elevation, so the answer registers before
   the sentence is read. The tint tops out at 10%: the panel holds normal-size body copy, and the
   backdrop under that copy is exactly what tools/ui-audit.mjs measures — a heavier wash buys
   atmosphere by spending contrast on the one figure the page exists to deliver. */
/* "What to do today" atop #/progress (todayCard in main.js): one action, stated and offered. Blue
   accents — advice, not a verdict — and nothing here animates: dashboards state true values from
   first paint. */
.today-card { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.today-ic {
  display: grid; place-items: center; width: 44px; height: 44px; border-radius: 50%;
  color: var(--blue); background: color-mix(in srgb, var(--blue) 14%, var(--surface)); flex: none;
}
.today-ic svg { width: 22px; height: 22px; }
.today-txt { flex: 1; min-width: 220px; }
.today-kicker { display: block; font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
.today-txt p { margin: .15rem 0 0; font-weight: 600; }
/* The exam-history action column: right-aligned, never wrapping mid-button. */
.hist-act { text-align: right; white-space: nowrap; }

.prog-hero {
  display: grid; grid-template-columns: auto 1fr; gap: 1.5rem; align-items: center;
  position: relative; box-shadow: var(--hairline), var(--e3);
}
.prog-hero.is-ready { background-image: radial-gradient(120% 150% at 2% 0%, color-mix(in srgb, var(--ok) 12%, transparent), transparent 72%), var(--surface-lift); border-color: color-mix(in srgb, var(--ok) 34%, var(--border)); }
.prog-hero.is-almost { background-image: radial-gradient(120% 150% at 2% 0%, color-mix(in srgb, var(--amber) 12%, transparent), transparent 72%), var(--surface-lift); border-color: color-mix(in srgb, var(--amber) 38%, var(--border)); }
.prog-hero.is-work { background-image: radial-gradient(120% 150% at 2% 0%, color-mix(in srgb, var(--bad) 11%, transparent), transparent 72%), var(--surface-lift); border-color: color-mix(in srgb, var(--bad) 32%, var(--border)); }
/* A soft pool UNDER the ring, matched to the band the ring is already drawn in,
   so the readiness figure sits in its own light instead of on a flat panel.
   This one is allowed to be strong (40%) where the panel wash above is capped
   at 12%: it sits behind a GRAPHIC, and the only text over it is the ring's own
   centred percentage, which the pool's `transparent 62%` hole leaves clear. */
.prog-hero-fig { display: grid; place-items: center; text-align: center; position: relative; }
.prog-hero-fig::before {
  content: ""; position: absolute; width: 92%; aspect-ratio: 1; border-radius: 50%;
  top: 4%; z-index: 0; pointer-events: none;
  background: radial-gradient(circle, transparent 28%, color-mix(in srgb, var(--tone, var(--brand)) 40%, transparent) 52%, transparent 76%);
  filter: blur(12px);
}
.prog-hero.is-ready .prog-hero-fig { --tone: var(--ok); }
.prog-hero.is-almost .prog-hero-fig { --tone: var(--amber); }
.prog-hero.is-work .prog-hero-fig { --tone: var(--bad); }
.prog-hero-fig > * { position: relative; z-index: 1; }
.prog-hero-fig small { color: var(--muted); font-size: .8rem; display: block; margin-top: .35rem; font-weight: 600; }
.prog-verdict { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; margin-bottom: .35rem; }
.prog-verdict h2 { font-size: 1.25rem; margin: 0; }
.prog-hero .lead { margin: 0 0 1rem; font-size: .95rem; }
.prog-hero .statgrid { grid-template-columns: repeat(4, 1fr); }
/* stat-tile values are large standalone numbers -> proportional figures, and a
   signed delta gets its direction colour (up is good here). */
.stat-delta { font-size: .78rem; font-weight: 700; margin-top: .1rem; }
.stat-delta.up { color: var(--ok-ink); } .stat-delta.down { color: var(--bad); }
.stat-delta.flat { color: var(--muted); }
.prog-hero .stat-num, .prog-signsum .stat-num { font-variant-numeric: normal; }
@media (max-width: 720px) {
  .prog-hero { grid-template-columns: 1fr; justify-items: center; text-align: center; }
  .prog-hero .statgrid { grid-template-columns: repeat(2, 1fr); }
  .prog-verdict { justify-content: center; }
}
/* The hamburger breakpoint. Raised 860px -> 1300px on 2026-08-22, and it is a BUG FIX
   rather than a preference: with Norwegian labels the bar already ran past the viewport
   from 1180px down (measured 11px at 1180, 291px at 900) before the safety link was
   added, and `.view { overflow-x: clip }` meant the page did not even scroll to reveal
   it — the right-hand end was simply cut off. Eight nav items plus the account menu do
   not fit a laptop width in Norwegian, so the panel now takes over while there is still
   room for it. If you add a ninth item, re-measure rather than assume. */
@media (max-width: 1300px) {
  /* `wrap` is required, not cosmetic: `.mainnav` is the open hamburger panel with
     `width: 100%` and `order: 6`, so it has to wrap onto its own line. Under `nowrap`
     it stayed in the bar's row and stretched the bar to the panel's full height
     (~450px on a tablet) with the brand and icons floating in the middle of it. This
     was previously only set in the ≤560px block, so phones were fine and every tablet
     width was broken. */
  .topbar { flex-wrap: wrap; padding: .18rem .75rem; gap: .4rem .5rem; }
  /* 24px, not 0: this is a link on every route, and at `min-height: 0` it measured
     74x20 — under the WCAG 2.5.8 floor. It cannot regrow the bar, because the round
     controls below set the height at 2.25rem/36px. */
  .brand { min-height: 24px; }
  /* 29px, up from 26 at the owner's request (2026-08-30). Free of charge: the round
     controls below hold the bar at 2.25rem/36px, so anything under that cannot grow it —
     and a bar that changes height on boot is the CLS bug this file keeps warning about.
     36px is the ceiling; past it the mark starts setting the bar's height itself. */
  .brand img { height: 29px; }
  /* Squeeze the two round controls that would otherwise set the bar's height. */
  .nav-toggle, .topbar .theme-toggle { width: 2.25rem; height: 2.25rem; }
  /* Mobile toolbar order (left → right): brand | language | login | theme | hamburger */
  .brand { order: 1; }
  .langtoggle { order: 2; }
  .authnav { margin-left: 0; order: 3; }
  .theme-toggle { order: 4; }
  .nav-toggle { order: 5; display: inline-flex; margin-left: 0; }
  .mainnav {
    display: none; width: 100%; flex-direction: column; gap: .15rem; margin-left: 0; padding: .4rem; border: 1px solid var(--border);
    border-radius: var(--r-md); background: var(--surface); box-shadow: var(--shadow); order: 6;
  }
  .mainnav.open { display: flex; }
  /* .6rem, so a row is exactly 44px (9.6 + 24.8 line + 9.6) — the touch-target floor
     this app holds itself to, and no taller. The rows carry a hover/active fill, so
     they read as one list at a 2.4px gap; the old 12px padding + 14.4px gap made
     seven links fill a phone screen and look like seven separate cards. */
  .mainnav a { padding: .6rem .8rem; border-radius: var(--r-sm); }
  .mainnav a::after { display: none; }
  .mainnav a.active, .mainnav a:hover { background: var(--surface-2); color: var(--text); }
  /* The account control stays in the bar at every width — it is now a single menu
     button, not a login link plus a sign-up button competing for room. */
  /* Theme toggle moves inside the hamburger menu on mobile; hide the desktop one. */
  .topbar > .theme-toggle { display: none; }
  /* A full-width menu row, so it overrides the round 2.25rem box above. */
  .mainnav .theme-toggle--mobile { display: inline-flex; width: 100%; height: auto; min-height: 44px; justify-content: flex-start; padding: .6rem .8rem; border-radius: var(--r-sm); background: transparent; border: none; color: var(--muted); font-weight: 600; gap: .5rem; }
  .mainnav .theme-toggle--mobile:hover { background: var(--surface-2); color: var(--text); }
  .mainnav .theme-toggle--mobile svg { width: 1.15rem; height: 1.15rem; flex: none; }
  /* Push the icon group (language | login | theme | hamburger) to the right — from the
     BRAND, not from whichever control happens to come first. Hanging the auto margin off
     the leading control breaks the moment that control is hidden at a narrower width,
     which is exactly what happened at ≤560px below. */
  .brand { margin-right: auto; }
  .langtoggle, .theme-toggle { margin-left: 0; }
  .view { padding: 1rem 1rem 3rem; }
  .hero { padding: 1.3rem 0; }   /* sides 0: .view has the gutter; the card is gone */
  .hero-split { grid-template-columns: 1fr; }
  .hero-cta { flex-direction: column; align-items: stretch; }
  .hero-cta .btn.big { width: 100%; justify-content: center; }
  .hero-pills { gap: .4rem; }
  .section-grid, .mastery-grid, .grid { grid-template-columns: 1fr; }
  .card, .sec-card { padding: 1rem; }
  .dash { grid-template-columns: 1fr; }
  .statgrid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .sec-card { min-height: auto; }
}
@media (max-width: 560px) {
  .topbar { padding: .15rem .6rem; gap: .35rem .4rem; }
  .brand { min-height: 24px; }   /* same floor as above — the mark alone is still under it */
  /* 25px, up from 20 at the owner's request (2026-08-30). Same headroom as the tablet
     size above: the 36px round controls still set the bar's height. */
  .brand img { height: 25px; }
  .hero h1 { font-size: calc(var(--fs-h1) - .15rem); }
  /* Wrapping row, not a column: as opaque chips (they were translucent scrims on the old red
     card) three full-width bars read as three more buttons under the two real ones. */
  .hero-pills { justify-content: center; }
  .statgrid { grid-template-columns: 1fr; }
  .btn { min-height: 44px; }
  /* iOS zooms the page when focusing a control whose font is <16px. The text
     inputs are already 16px (body font); the native selects are the only sub-16px
     controls, so bump just them to stop the disorienting auto-zoom on tap. */
  .select { font-size: 16px; }
}

/* trend chart */
.chartwrap { position: relative; }
.chart { display: block; width: 100%; height: auto; overflow: visible; }
.chart-grid { stroke: var(--border); stroke-width: 1; }                  /* hairline, solid */
.chart-axis-txt { fill: var(--muted); font-size: 11px; font-weight: 600; font-variant-numeric: tabular-nums; }
.chart-target { stroke: var(--amber); stroke-width: 1.5; stroke-dasharray: 5 4; }  /* a target, not a gridline */
.chart-target-txt { fill: var(--muted); font-size: 10.5px; font-weight: 700; letter-spacing: .02em; }
.chart-area { fill: var(--blue); opacity: .10; }                          /* a wash, never a block */
.chart-line { fill: none; stroke: var(--blue); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
/* 2px surface ring. pointer-events:none matters — the dot paints *over* its own
   hit circle, so without this it swallows the pointer and hovering the dot
   itself (the obvious target) did nothing while only the ring around it worked. */
.chart-dot { stroke: var(--surface); stroke-width: 2; pointer-events: none; }
.chart-dot.pass { fill: var(--ok); } .chart-dot.fail { fill: var(--bad); }
.chart-endlabel { fill: var(--text); font-size: 12px; font-weight: 800; font-variant-numeric: tabular-nums; }
.chart-hit { fill: transparent; cursor: pointer; outline: none; }         /* ~28px hit target */
.chart-hit:focus-visible + .chart-dot, .chart-hit:hover + .chart-dot { stroke: var(--text); }
.charttip {
  position: absolute; transform: translate(-50%, -125%); pointer-events: none; opacity: 0;
  transition: opacity .12s ease; background: var(--text); color: var(--bg);
  padding: .4rem .6rem; border-radius: var(--r-sm); font-size: .78rem; font-weight: 650;
  white-space: nowrap; z-index: 5; box-shadow: var(--shadow);
}
.charttip.on { opacity: 1; }
.charttip b { font-variant-numeric: tabular-nums; }
/* The SVG scales with its container, and so does text sized in viewBox units —
   at ~430px the 11px axis text renders near 6px. Bump the viewBox font sizes so
   the on-screen size stays put, and drop the interior date labels (which would
   collide at the larger size) in CSS, so a resize needs no re-render. */
@media (max-width: 720px) {
  .chart-axis-txt { font-size: 16px; }
  .chart-target-txt { font-size: 15px; }
  .chart-endlabel { font-size: 17px; }
  .chart-axis-txt.x-mid { display: none; }
}
.chart-legend { display: flex; gap: 1.1rem; flex-wrap: wrap; margin-top: .8rem; color: var(--muted); font-size: .8rem; font-weight: 600; }
.chart-legend span { display: inline-flex; align-items: center; gap: .38rem; }
.lg-key { width: 10px; height: 10px; border-radius: 50%; flex: none; }
.lg-key.pass { background: var(--ok); } .lg-key.fail { background: var(--bad); }
.lg-key.target { width: 16px; height: 0; border-top: 2px dashed var(--amber); border-radius: 0; }

/* strengths / weaknesses, side by side */
/* `align-items: start` so each card is as tall as its own content. Either card
   can legitimately hold fewer topics than the other (they are partitioned at
   the pass mark, not sliced top-3/bottom-3), and as equal-height grid siblings
   the shorter one carried a large panel of dead space under its last row. */
.prog-sw { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: start; }
@media (max-width: 720px) { .prog-sw { grid-template-columns: 1fr; } }
.sw-card { border-top: 3px solid var(--border); }
.sw-card.strong { border-top-color: var(--ok); }
.sw-card.weak { border-top-color: var(--bad); }
.sw-head { display: flex; align-items: center; gap: .5rem; font-weight: 750; font-size: 1.02rem; }
.sw-head .sw-ico { font-size: 1.1rem; line-height: 1; }
.sw-sub { color: var(--muted); font-size: .82rem; margin: .25rem 0 .9rem; }
.sw-list { display: grid; gap: .8rem; }
.sw-item { display: grid; grid-template-columns: 1fr auto; gap: .1rem .8rem; align-items: baseline; }
.sw-name { font-weight: 650; font-size: .92rem; }
.sw-val { font-variant-numeric: tabular-nums; font-weight: 750; font-size: .92rem; }
.sw-sub:first-child { margin-top: 0; }   /* opening line of a card carries no top margin */
.sw-meta { color: var(--muted); font-size: .76rem; grid-column: 1 / -1; }
.sw-item .bar { grid-column: 1 / -1; margin: .15rem 0 0; }
.sw-cta { margin-top: 1rem; }

/* banded topic table: bar + the band as a WORD (colour is never the only cue) */
.tbl.mastery td { padding: .6rem 0; vertical-align: middle; }
/* 24px tall (WCAG 2.5.8 — it was 21px of bare text), and the underline is kept
   but dialled back to 35% ink: seven of these stack in one column, and at full
   strength they read as a ladder of rules rather than a list of topics. Kept
   rather than removed because the link wears the body text colour, so the
   underline is the only thing distinguishing it from plain text (WCAG 1.4.1).
   Hover/focus restores it to full ink. */
.tbl.mastery .m-name {
  font-weight: 650; display: inline-flex; align-items: center; min-height: 24px;
  text-decoration-color: color-mix(in srgb, currentColor 35%, transparent);
  text-underline-offset: 3px;
}
.tbl.mastery .m-name:hover, .tbl.mastery .m-name:focus-visible { text-decoration-color: currentColor; }
.tbl.mastery .bar { margin: .35rem 0 0; max-width: 320px; }
.tbl.mastery td.m-band { text-align: right; white-space: nowrap; }
.tbl.mastery td.m-val { text-align: right; font-variant-numeric: tabular-nums; color: var(--text); font-weight: 700; }
.tbl.mastery .m-sub { color: var(--muted); font-weight: 500; font-size: .78rem; }
/* NB: `.mband`, not `.band` — `.band` is already the landing page's stats strip
   (display:grid), which silently stretched this pill across its whole cell. */
.mband {
  display: inline-flex; align-items: center; gap: .3rem; padding: .16rem .55rem;
  border-radius: var(--r-pill); font-size: .74rem; font-weight: 750; border: 1px solid;
}
.mband.strong { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 35%, transparent); background: var(--ok-bg); }
.mband.weak { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 35%, transparent); background: var(--bad-bg); }
/* light-mode amber is sub-3:1, so "solid" leans on the word + a darker ink step
   than the bar fill it labels. */
.mband.solid { color: #8a5400; border-color: color-mix(in srgb, var(--amber) 40%, transparent); background: color-mix(in srgb, var(--amber) 12%, var(--surface)); }
:root[data-theme="dark"] .mband.solid { color: var(--amber); }
/* bar fills: 4px rounded data-end, square at the baseline it grows from */
.bar > i.strong { background: var(--ok); } .bar > i.solid { background: var(--amber); } .bar > i.weak { background: var(--bad); }
.bar > i { border-radius: 0 var(--r-xs) var(--r-xs) 0; }

/* sign-practice summary */
.prog-signsum { display: grid; grid-template-columns: auto 1fr; gap: 1.2rem; align-items: center; }
@media (max-width: 560px) { .prog-signsum { grid-template-columns: 1fr; } }

/* theory-course summary — same ring-plus-stats shape as the sign summary above, so
   the two read as siblings rather than as two different kinds of panel. */
.prog-course { display: grid; grid-template-columns: auto 1fr; gap: 1.2rem; align-items: center; }
@media (max-width: 560px) { .prog-course { grid-template-columns: 1fr; } }
.prog-course .stat-num small { font-size: .55em; font-weight: 700; color: var(--muted); }
.prog-course .nav-row { margin-top: .9rem; }

/* Pass Protection eligibility checklist (#/pass-protection states the guarantee). */
.prog-pp .pp-summary { display: flex; align-items: center; gap: .45rem; font-weight: 700; margin: .2rem 0 1rem; }
.prog-pp .pp-summary svg { width: 1.1rem; height: 1.1rem; flex: none; }
.prog-pp .pp-summary.is-met { color: var(--ok-ink, var(--ok)); }
.pp-list { list-style: none; padding: 0; margin: 0; display: grid; gap: .75rem; }
.pp-req {
  display: grid; grid-template-columns: auto 1fr auto; gap: .7rem; align-items: center;
  padding: .7rem .8rem; border: 1px solid var(--border); border-radius: var(--r-md);
  background: var(--surface-2);
}
/* Met state is a tick, a word and a tint — never the tint alone, same rule the
   mastery bands follow (see the progress-dashboard notes in ARCHITECTURE.md). */
.pp-req.is-met { border-color: color-mix(in srgb, var(--ok) 32%, var(--border)); background: var(--ok-bg); }
.pp-req-ico { color: var(--muted); display: flex; }
.pp-req-ico svg { width: 1.15rem; height: 1.15rem; }
.pp-req.is-met .pp-req-ico { color: var(--ok-ink, var(--ok)); }
.pp-req-txt { display: grid; gap: .25rem; min-width: 0; }
.pp-req-txt b { font-size: .92rem; font-weight: 700; }
.pp-req .bar { margin: 0; height: 6px; }
.pp-req.is-met .bar > i { background: var(--ok); }
.pp-req-val { text-align: right; font-variant-numeric: tabular-nums; font-size: .86rem; font-weight: 700; white-space: nowrap; }
.pp-req-state { display: block; font-size: .72rem; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: .03em; }
.pp-req.is-met .pp-req-state { color: var(--ok-ink, var(--ok)); }
.pp-note { color: var(--muted); font-size: .84rem; margin: 1rem 0 0; }
@media (max-width: 520px) {
  .pp-req { grid-template-columns: auto 1fr; }
  .pp-req-val { grid-column: 2; text-align: left; }
}

/* ================================================== the journey (streak + milestones)
   Built by journeySection() in main.js over app/journey.js. Sits under the readiness hero: that panel
   answers "am I ready", this one answers "how far have I come", and both come before the diagnosis.

   MOTION: only the milestone bars move, and only because motion.js's fillBars() already grows every
   `.bar > i` from zero — a shape, not a number. There is deliberately no count-up on the streak
   figure and no flame pulse: `#/progress` is a learning surface in the motion table
   (docs/frontend.md), where nothing may loop while it is being read.

   TONE IS NEVER COLOUR ALONE: an earned badge says so in words next to the gold, an unearned one
   states its own shortfall ("3 av 10"), and the day ribbon is one role="img" carrying the count —
   fourteen bare dots announce nothing to a screen reader, and labelling each would announce too much. */
/* `stretch`, so the two cards share a baseline and a bottom edge — at `start` the streak card is
   roughly half the height of the milestone grid beside it and the pair reads as one card plus a
   leftover. The streak card then distributes its own rows to fill (`space-between` below), which is
   also what pushes the day ribbon down to sit against the footnote. */
.journey { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr); gap: 1rem; align-items: stretch; }

/* --- streak card ---
   The warm wash is keyed to the LIVE state: a dead streak gets the plain card surface, so "you have a
   run going" is visible before a word is read — and the number plus `jr.noStreak` still say it
   outright for anyone who can't use the colour.
   Flex column, not a two-column grid: the flame has to stay locked beside the number, and in a grid
   the `span 2` cell that achieved that drifted downward as soon as the card was stretched to match
   the milestone panel, leaving the icon floating under its own figure with a hole above it. A column
   with one `margin-top: auto` on the ribbon pins the top and bottom groups independently. */
.jr-streak { display: flex; flex-direction: column; gap: .35rem; }
.jr-streak-top { display: flex; align-items: center; gap: .9rem; }
.jr-streak.is-live {
  background-image: radial-gradient(120% 140% at 0% 0%, color-mix(in srgb, var(--amber) 13%, transparent), transparent 62%), var(--surface-lift);
  border-color: color-mix(in srgb, var(--amber) 32%, var(--border));
}
.jr-flame {
  flex: none;
  width: 3.1rem; height: 3.1rem; border-radius: var(--r-md);
  display: grid; place-items: center; color: var(--muted);
  background: var(--surface-2); box-shadow: var(--hairline);
}
.jr-flame svg { width: 1.6rem; height: 1.6rem; }
.jr-streak.is-live .jr-flame { color: var(--gold-ink); background: var(--gold-bg); }
.jr-streak-body { display: flex; align-items: baseline; gap: .45rem; flex-wrap: wrap; }
/* tabular figures: the number changes daily in place, and proportional digits
   make it jump sideways as it goes 9 -> 10 */
.jr-streak-num { font-size: 2.1rem; font-weight: 800; line-height: 1; font-variant-numeric: tabular-nums; letter-spacing: -.03em; }
.jr-streak-label { color: var(--muted); font-size: .85rem; font-weight: 650; }
.jr-streak-note { margin: 0; }
.jr-streak-what { margin: .1rem 0 0; color: var(--muted); font-size: .74rem; }
/* --- the 14-day ribbon --- */
/* The strip GROWS to absorb whatever height the milestone panel beside it
   imposes (`flex: 1`, clamped) instead of sitting as a hairline with a hole
   above it. That is also the better drawing: fourteen full-height columns read
   as a fortnight of practice at a glance, where a 6px line read as a decorative
   rule. Clamped at both ends so it is neither a sliver on a short card nor a
   banner on a tall one. */
.jr-days { display: flex; align-items: stretch; gap: .28rem; flex: 1 1 auto; min-height: 2.2rem; max-height: 5.5rem; margin-top: .8rem; }
.jr-day {
  flex: 1; border-radius: var(--r-xs);
  background: var(--surface-2); box-shadow: inset 0 1px 2px rgba(16,24,40,.10);
}
.jr-day.is-on { background-image: var(--grad-ui); box-shadow: inset 0 1px 0 rgba(255,255,255,.3); }
/* Today reads as the open end of the run rather than as one more filled cell.
   An outline, not a pulse — this panel is read for minutes at a time. */
.jr-day.is-today { outline: 2px solid color-mix(in srgb, var(--brand) 55%, transparent); outline-offset: 2px; }

/* --- milestones --- */
.jr-miles-head { font-size: .95rem; margin: 0 0 .7rem; }
.jr-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .6rem; }
.jr-badge {
  display: grid; grid-template-columns: auto 1fr; gap: .1rem .6rem; align-items: center;
  padding: .6rem .7rem; border: 1px solid var(--border); border-radius: var(--r-sm);
  background: var(--surface-2); box-shadow: var(--hairline);
}
.jr-badge-ico {
  grid-row: span 2; width: 2rem; height: 2rem; border-radius: var(--r-sm);
  display: grid; place-items: center; color: var(--muted);
  background: var(--surface); border: 1px solid var(--border);
}
.jr-badge-ico svg { width: 1.1rem; height: 1.1rem; }
.jr-badge-name { font-weight: 650; font-size: .86rem; line-height: 1.25; align-self: end; }
.jr-badge-state { color: var(--muted); font-size: .74rem; font-weight: 600; align-self: start; font-variant-numeric: tabular-nums; }
.jr-badge-bar { grid-column: 2; height: 4px; margin: .25rem 0 0; }
/* Earned: gold ink on a gold well, plus the word "Oppnådd" — the treatment and
   the label always ship together, per the bands rule. */
.jr-badge.is-earned { border-color: color-mix(in srgb, var(--amber) 42%, var(--border)); background: color-mix(in srgb, var(--amber) 9%, var(--surface)); }
.jr-badge.is-earned .jr-badge-ico { color: var(--gold-ink); background: var(--gold-bg); border-color: color-mix(in srgb, var(--amber) 38%, var(--border)); }
.jr-badge.is-earned .jr-badge-state { color: var(--gold-ink); }

@media (max-width: 720px) {
  .journey { grid-template-columns: 1fr; }
}
@media (max-width: 420px) {
  /* Two badge columns at 390px leave ~150px for a name like "Alle temaer over
     kravet", which wraps to four lines and makes the tiles ragged. */
  .jr-list { grid-template-columns: 1fr; }
}

/* section cards (home) */
.tiles { margin-top: 1.5rem; }
.section-grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); }
/* BENTO at desktop (2026-09-14). Six cards on auto-fit made a row of four and a stretched pair
   underneath. Four fixed columns with the first and last cards spanning two gives a 2+1+1 / 1+1+2
   rhythm: Kurs — the product — leads at double width, Trafikkskoler (the longest description) closes
   at double width, and the four learning tools sit between as equals. The two wide cards carry a
   faint wash of their own accent so the emphasis is visible before hover. Below 1000px the spans
   are dropped and auto-fit takes over, which is what the phone/tablet rules already expect. */
@media (min-width: 1000px) {
  .section-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
  .section-grid .sec-card:first-child, .section-grid .sec-card:last-child { grid-column: span 2;
    background-image: linear-gradient(135deg, color-mix(in srgb, var(--accent, var(--brand)) 9%, transparent), transparent 55%), var(--surface-lift); }
  .section-grid .sec-card:first-child .sec-ico, .section-grid .sec-card:last-child .sec-ico { width: 3.4rem; height: 3.4rem; }
  .section-grid .sec-card:first-child .sec-ico svg, .section-grid .sec-card:last-child .sec-ico svg { width: 1.8rem; height: 1.8rem; }
  .section-grid .sec-card:first-child .sec-body h3, .section-grid .sec-card:last-child .sec-body h3 { font-size: 1.2rem; }
  /* The wide cards' copy is short and the row is as tall as the longest neighbour, so top-aligned
     they sat over a slab of empty card. Centred, and the description runs a size up, so the extra
     width reads as emphasis rather than as room nobody filled. */
  .section-grid .sec-card:first-child, .section-grid .sec-card:last-child { align-items: center; }
  .section-grid .sec-card:first-child .sec-body p, .section-grid .sec-card:last-child .sec-body p { font-size: 1rem; max-width: 46ch; }
}
.sec-card {
  display: flex; align-items: flex-start; gap: .9rem; text-decoration: none;
  background-color: var(--surface); background-image: var(--surface-lift);
  border: 1px solid var(--border); border-radius: var(--radius);
  box-shadow: var(--hairline), var(--shadow); padding: 1.15rem;
  transition: transform var(--dur-base) var(--ease-out), box-shadow var(--dur-base) var(--ease-out), border-color var(--dur-base) var(--ease-out);
  position: relative;
}
/* The lift picks up a wash of the card's OWN accent (--accent is set per card),
   so hovering "Signs" throws a violet shadow and "Exam" a red one. The card
   feels picked up rather than merely outlined, and it costs one extra shadow
   layer — no new element and nothing to animate per frame. */
.sec-card:hover {
  transform: translateY(-3px);
  box-shadow: var(--hairline), var(--shadow-lg),
              0 18px 40px -22px color-mix(in srgb, var(--accent, var(--brand)) 60%, transparent);
  border-color: color-mix(in srgb, var(--accent, var(--brand)) 40%, var(--border));
}
.sec-ico {
  flex: none; width: 2.9rem; height: 2.9rem; border-radius: var(--r-md);
  display: grid; place-items: center; color: #fff; background: var(--accent, var(--brand));
  box-shadow: inset 0 1px 0 rgba(255,255,255,.25),
              0 4px 12px -4px color-mix(in srgb, var(--accent, var(--brand)) 65%, transparent);
  transition: transform var(--dur-base) var(--ease-spring), box-shadow var(--dur-base) var(--ease-out);
}
.sec-card:hover .sec-ico {
  transform: scale(1.06);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.3),
              0 8px 20px -5px color-mix(in srgb, var(--accent, var(--brand)) 70%, transparent);
}
.sec-ico svg { width: 1.5rem; height: 1.5rem; }
.sec-body h3 { margin: .1rem 0 .25rem; font-size: 1.08rem; }
.sec-body p { color: var(--muted); margin: 0; font-size: .9rem; }
.sec-arrow { margin-left: auto; color: var(--muted); align-self: center; transition: transform .18s ease; }
.sec-card:hover .sec-arrow { transform: translateX(3px); color: var(--text); }
.sec-badge { position: absolute; top: .7rem; right: .8rem; font-size: .7rem; font-weight: 700; }
/* legacy tile (kept for any other callers) */
.tile { display: flex; align-items: center; gap: .8rem; text-decoration: none; padding: .9rem; }
.tile h3 { color: var(--brand); }
.tile p { color: var(--muted); margin: 0; font-size: .92rem; }
.tile-ico { width: 48px; height: 48px; border-radius: 50%; object-fit: cover; flex: none; }
/* The same 48px disc, but drawn rather than photographed — for a #/dev tile that has
   an icon instead of a chapter image. object-fit does nothing for an inline SVG. */
.tile-ico-svg { display: grid; place-items: center; background: var(--surface-2); color: var(--brand); }
.tile-ico-svg svg { width: 1.5rem; height: 1.5rem; }
.tile-body { min-width: 0; flex: 1; }

/* topic mastery grid */
.mastery-grid { display: grid; gap: .7rem; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); }
.mtopic {
  display: flex; align-items: center; gap: .8rem; text-decoration: none;
  background-color: var(--surface); background-image: var(--surface-lift);
  border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .75rem .9rem;
  box-shadow: var(--hairline), var(--e1);
  transition: transform var(--dur-base) var(--ease-out), border-color var(--dur-base) var(--ease-out), box-shadow var(--dur-base) var(--ease-out);
}
.mtopic:hover { transform: translateY(-2px); border-color: var(--muted); box-shadow: var(--hairline), var(--e2); }
.mtopic .mt-body { min-width: 0; flex: 1; }
.mtopic .mt-name { font-weight: 650; font-size: .92rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mtopic .mt-meta { color: var(--muted); font-size: .78rem; }

/* lock / badge chips */
.lockbadge { font-size: .8em; }
.chip-tag { display: inline-flex; align-items: center; gap: .3rem; font-size: .68rem; font-weight: 700; text-transform: uppercase; letter-spacing: .03em; padding: .15rem .5rem; border-radius: var(--r-pill); white-space: nowrap; }  /* a pill that wraps to two lines is two pills (seen: IKKE BESTÅTT in the mobile history table) */
/* --gold-ink, not --amber: this is the badge that tells a free account a section costs
   money, so it is text and has to be readable. Raw --amber measured 2.36:1 here — the
   same failure --gold-ink was introduced for (see the token comment above). */
.chip-tag.paid { color: var(--gold-ink); background: var(--gold-bg); }
.chip-tag.free { color: var(--muted); background: var(--surface-2); }
.chip-tag.active { color: var(--ok); background: var(--ok-bg); }
/* Pass/fail in the history tables. They were bare coloured text (`.tag-ok`/`.tag-bad` on the
   <td>); as chips they read at a glance down a column and match every other status tag here. */
.chip-tag.ok { color: var(--ok); background: var(--ok-bg); }
.chip-tag.bad { color: var(--bad); background: var(--bad-bg); }

/* exam runner */
.exam-head { display: flex; align-items: center; gap: 1rem; margin-bottom: .6rem; flex-wrap: wrap; }
.timer { font-variant-numeric: tabular-nums; font-weight: 700; font-size: 1.1rem; padding: .3rem .7rem; border-radius: var(--r-sm); background: var(--surface-2); border: 1px solid var(--border); }
/* Under five minutes. A static state change, not a pulse — it has to hold for the
   whole five minutes, so it is built to be unmissable at a glance and then ignorable
   while you read: filled background + the bad border + heavier digits. */
.timer.warn { color: var(--bad); border-color: var(--bad); background: var(--bad-bg); font-weight: 800; }
.progress { color: var(--muted); font-weight: 600; }
.spacer { margin-left: auto; }
/* the demo-exam nudge that sits between the progress bar and the question card */
.exam-demonote { margin: 0 0 1rem; }
/* Track is a groove (inset), fill is a lit ribbon (inset highlight) — so the two
   read as different depths rather than as two stripes. No outer glow on the
   fill: `overflow: hidden` is what keeps the ribbon inside the rounded track,
   and it clips any outward shadow anyway. The `width` transition stays the only
   motion here — see the no-loops-while-reading rule in docs/frontend.md. */
.pbar { height: 7px; border-radius: var(--r-pill); background: var(--surface-2); box-shadow: inset 0 1px 2px rgba(16,24,40,.10); overflow: hidden; margin-bottom: 1.1rem; }
.pbar > i { display: block; height: 100%; background: var(--grad); border-radius: var(--r-pill); box-shadow: inset 0 1px 0 rgba(255,255,255,.28); transition: width .35s var(--ease-out); }

/* pan-y keeps vertical scroll native while our JS owns the horizontal axis for
   swipe-to-navigate — the browser never fights the gesture. */
.qcard { margin-bottom: 1rem; touch-action: pan-y; }
.qmeta { color: var(--muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .04em; margin-bottom: .4rem; }
/* The exam runner's flag toggle, inline in the meta row. Quiet until on; on = the
   gold pair (--amber alone cannot carry ink — see the token's comment). */
.qflag {
  display: inline-flex; align-items: center; gap: .3rem; margin-left: .6rem; vertical-align: middle;
  background: none; border: 1px solid transparent; border-radius: var(--r-pill);
  padding: .12rem .55rem; font: inherit; font-size: .74rem; font-weight: 700;
  text-transform: inherit; letter-spacing: inherit; color: var(--muted); cursor: pointer;
}
.qflag svg { width: .95em; height: .95em; }
.qflag:hover { color: var(--text); border-color: var(--border); }
/* The house press idiom — every pressable answers the finger. */
.qflag { transition: transform .1s ease; }
.qflag:active { transform: scale(.94); }
.qflag.is-on { color: var(--gold-ink); background: var(--gold-bg); border-color: color-mix(in srgb, var(--gold-ink) 30%, transparent); }
/* The question is the one thing on screen that must be read carefully, so it gets
   its own leading rather than the body's 1.55 — tighter than body copy binds the
   two or three lines into a single sentence instead of a stack. */
.qtext { font-size: 1.2rem; font-weight: 650; line-height: 1.42; margin-bottom: 1.1rem; }
/* Gap sits a touch above the options' own inner padding so four answers read as
   four separate targets rather than one striped block. `flex-start` keeps the A/B/C
   badge beside the FIRST line of a wrapping answer instead of drifting to the
   vertical middle of a three-line block, where it stops looking like its label. */
.options { display: grid; gap: .7rem; }
.opt {
  display: flex; gap: .7rem; align-items: flex-start; text-align: left;
  padding: .85rem .9rem; border: 1.5px solid var(--border); border-radius: var(--r-md);
  background: var(--surface); cursor: pointer; font: inherit; color: inherit; width: 100%;
  box-shadow: var(--hairline), var(--e1);
  transition: border-color .14s ease, background .14s ease, box-shadow .14s ease, transform .06s ease;
}
/* Elevation, not a wash: this is a learning surface, so an option gets the
   depth that says "tappable" and nothing that says "look at me". Press
   COLLAPSES the shadow as the option drops the 1px — the two together are what
   make it feel like a physical key rather than a rectangle changing colour.
   All three states stay at .14s/.06s: 45 questions x 4 options is not a place
   to spend the 2s ceiling. */
.opt:hover { border-color: var(--muted); box-shadow: var(--hairline), var(--e2); }
.opt:active { transform: translateY(1px); box-shadow: var(--hairline), var(--e1); }
/* Selected is the one option the eye must find again after scrolling past it,
   so it carries a brand-tinted halo on top of the border colour it already had. */
.opt.selected { box-shadow: var(--hairline), 0 0 0 3px color-mix(in srgb, var(--brand) 14%, transparent), var(--e1); }
.opt .key { flex: none; width: 1.75rem; height: 1.75rem; border-radius: var(--r-sm); background: var(--surface-2); display: grid; place-items: center; font-weight: 700; font-size: .85rem; transition: background .14s ease, color .14s ease; }
.opt.selected { border-color: var(--brand); background: color-mix(in srgb, var(--brand) 8%, var(--surface)); }
.opt.selected .key { background: var(--brand); color: #fff; }
/* The answered states keep the same halo shape the selected state used, in the
   status colour — so revealing the answer re-colours a ring that is already
   there instead of introducing a new object. `m-glow-ok` in motion.css swells
   THIS ring rather than declaring its own box-shadow, which is why its
   keyframes repeat these layers: an animation that set `box-shadow` alone would
   drop the hairline and the elevation for the 0.9s it ran, flattening the
   option at the exact moment it is supposed to feel resolved. */
.opt.correct { border-color: var(--ok); background: var(--ok-bg); box-shadow: var(--hairline), 0 0 0 3px color-mix(in srgb, var(--ok) 18%, transparent), var(--e1); }
.opt.wrong { border-color: var(--bad); background: var(--bad-bg); box-shadow: var(--hairline), 0 0 0 3px color-mix(in srgb, var(--bad) 16%, transparent), var(--e1); }
.opt.correct .key { background: var(--ok); color: #fff; }
.opt.wrong .key { background: var(--bad); color: #fff; }

.nav-row { display: flex; gap: .6rem; margin-top: 1rem; align-items: center; flex-wrap: wrap; }
.qgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(2.2rem, 1fr)); gap: .4rem; margin-top: 1rem; }
.qgrid button { aspect-ratio: 1; border: 1px solid var(--border); background: var(--surface); border-radius: var(--r-sm); font-weight: 600; cursor: pointer; color: var(--muted); transition: transform .1s ease; }
.qgrid button:hover { transform: scale(1.08); }
.qgrid button.answered { background: var(--surface-2); color: var(--text); border-color: var(--muted); }
/* A checked cell reports the OUTCOME. This was `border-color: var(--ok)` for EVERY checked
   cell, right or wrong — the same green the options use for "correct" — so a wrong answer
   showed green in the pager while the card beside it said "Feil". Colour is not the only
   channel, for the same reason as the review grid below: the ✓/✕ sits in the bottom corner,
   opposite the flag dot so the two never collide. */
.qgrid button.checked.ok { border-color: var(--ok); color: var(--ok); }
.qgrid button.checked.bad { border-color: var(--bad); color: var(--bad); }
.qgrid button.checked > i {
  position: absolute; bottom: 0; right: .18rem; font-size: .58rem; font-style: normal;
  font-weight: 800; line-height: 1;
}
/* Mark-for-review: an amber corner dot — "you marked this", readable at a glance over
   45 cells and still visible when answered/checked recolour the cell. Gold-ink, not
   bare --amber, for the same sub-3:1 reason the earned badges use it. Static: this is
   a learning surface, so the mark lights up, it never pulses. */
.qgrid button { position: relative; }
.qgrid button.flagged::after {
  content: ""; position: absolute; top: 3px; right: 3px; width: 7px; height: 7px;
  border-radius: 50%; background: var(--gold-ink);
}
.qgrid button.current { outline: 2px solid var(--brand); outline-offset: 1px; }

/* review pager on the result screen (drawReview() in main.js) --------------------
   The grid is the exam runner's `.qgrid`, re-coloured: this one reports an outcome
   rather than progress, so it carries ✓/✕ as well as green/red — a 2.2rem cell of
   colour with a numeral in it would make colour the only channel, and the numeral is
   the one thing that cannot change. `.current` keeps the runner's brand outline, which
   reads over either fill. */
.qgrid.rev button { position: relative; }
.qgrid.rev button > i {
  position: absolute; top: 0; right: .18rem; font-size: .58rem; font-style: normal;
  font-weight: 800; line-height: 1;
}
.qgrid.rev button.ok { background: var(--ok-bg); color: var(--ok); border-color: color-mix(in srgb, var(--ok) 40%, transparent); }
.qgrid.rev button.bad { background: var(--bad-bg); color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, transparent); }
.revpick .qgrid { margin-top: 0; }
.revnav { justify-content: center; }
.revcount { color: var(--muted); font-size: .88rem; font-variant-numeric: tabular-nums; min-width: 8ch; text-align: center; }
.revhint { justify-content: center; }
/* --dur-quick, not the reveal's .65s: this fires on every arrow press. */
.has-motion .revcard { animation: revcard-in .14s var(--ease-out) both; }
@keyframes revcard-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { .has-motion .revcard { animation: none; } }

.explain { margin-top: .9rem; padding: .85rem .95rem; border-radius: var(--r-md); background: var(--surface-2); border: 1px solid var(--border); font-size: .95rem; }
.explain .src { color: var(--muted); font-size: .82rem; margin-top: .4rem; }
/* "Read the theory" under an explanation — the missed-question half of the
   read↔practice bridge (readUpLink in main.js). Inherits the text colour and takes
   an underline for affordance, same idiom as .linkbtn: --blue only clears the 3:1
   graphics floor, not the 4.5:1 this small text needs. */
.readup {
  display: inline-flex; align-items: center; gap: .35rem; margin-top: .55rem;
  font-size: .85rem; font-weight: 600; color: inherit;
  text-decoration: underline; text-underline-offset: 2px;
}
.readup:hover { color: var(--brand-on-white); }
.readup svg { width: 1em; height: 1em; flex: none; }
.tag-ok { color: var(--ok); font-weight: 700; }
.tag-bad { color: var(--bad); font-weight: 700; }
.lockednote { display: inline-flex; align-items: center; gap: .35rem; color: var(--ok); font-weight: 650; font-size: .88rem; }
.lockednote svg { width: 1.05em; height: 1.05em; }
.kbdhint { color: var(--muted); font-size: .8rem; margin-top: .8rem; display: flex; gap: .8rem; flex-wrap: wrap; }
kbd { font: inherit; font-size: .75rem; font-weight: 700; background: var(--surface-2); border: 1px solid var(--border); border-bottom-width: 2px; border-radius: var(--r-xs); padding: .05rem .4rem; }

/* results */
.result-hero { text-align: center; padding: 2rem 1rem; }
.result-hero.pass { background: linear-gradient(180deg, color-mix(in srgb, var(--ok) 10%, var(--surface)), var(--surface)); }
.result-hero.fail { background: linear-gradient(180deg, color-mix(in srgb, var(--bad) 10%, var(--surface)), var(--surface)); }
.verdict { font-size: 1.9rem; font-weight: 800; margin-top: .8rem; }
.verdict.pass { color: var(--ok); }
.verdict.fail { color: var(--bad); }
/* Next step, on the exam result (nextStepCard() in main.js). Deliberately built from
   the progress dashboard's own parts — `.sw-item`, `.bar` + band fill, `.sw-sub` — so one
   exam's verdict reads in the same language as the lifetime view instead of inventing a
   second vocabulary for "weak". Sits directly under the hero, above the seven-row table
   it summarises. */
.nextstep { display: grid; gap: 1rem; }
.ns-head { display: flex; align-items: flex-start; gap: .7rem; }
.ns-head b { display: block; font-size: 1.05rem; }
.ns-head .sw-sub { margin: .15rem 0 0; }
.ns-ic { flex: none; display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; }
.ns-ic svg { width: 19px; height: 19px; }
/* Status colour, and never only colour: the icon differs too (a tick when passed, a
   target when not) and the heading states the gap in words. */
.ns-ic.pass { background: var(--ok-bg); color: var(--ok); }
.ns-ic.fail { background: var(--bad-bg); color: var(--bad); }
.ns-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: .9rem 1.4rem; }
.ns-lbl { font-size: .74rem; font-weight: 750; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); margin-bottom: .3rem; }
.ns-note { margin: 0; color: var(--muted); font-size: .85rem; line-height: 1.45; }
.nextstep .nav-row { margin: 0; }

/* a heading that opens a new block further down a view (result: per-topic, review) */
.view-h2 { margin-top: 1.75rem; }
.bar { height: 10px; border-radius: var(--r-xs); background: var(--surface-2); overflow: hidden; margin: .3rem 0; }
.bar > i { display: block; height: 100%; background: var(--brand); transition: width .5s ease; }
/* Tables (2026-09-14 pass). Numerals line up (tabular-nums on the whole table, not just the last
   column), rows get side padding so the hover wash does not butt the text against the card edge,
   and the header carries a firmer rule than the rows beneath it. The hover is data rows only —
   `:not(.thead)` — because a header that lights up promises a sort that does not exist. */
.tbl { width: 100%; border-collapse: collapse; margin-top: .5rem; font-variant-numeric: tabular-nums; }
.tbl td { padding: .55rem .6rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
.tbl tr:last-child td { border-bottom: none; }
.tbl td:last-child { text-align: right; color: var(--muted); }
.tbl tr:not(.thead):hover td { background: color-mix(in srgb, var(--surface-2) 75%, transparent); }
.tbl tr:not(.thead):hover td:first-child { border-radius: var(--r-sm) 0 0 var(--r-sm); }
.tbl tr:not(.thead):hover td:last-child { border-radius: 0 var(--r-sm) var(--r-sm) 0; }

/* signs */
.toolbar { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: 1rem; align-items: center; }
.chip { padding: .4rem .85rem; border-radius: var(--r-pill); border: 1px solid var(--border); background: var(--surface); cursor: pointer; font: inherit; font-size: .85rem; color: var(--muted); box-shadow: var(--hairline), var(--e1); transition: background .16s ease, color .16s ease, border-color .16s ease, box-shadow .16s ease, transform .06s ease; }
.chip:hover { border-color: var(--muted); color: var(--text); box-shadow: var(--hairline), var(--e2); }
.chip:active { transform: translateY(1px); box-shadow: var(--hairline), var(--e1); }
.chip.active { background-color: var(--grad-solid); background-image: var(--grad-ui); color: #fff; border-color: transparent; box-shadow: inset 0 1px 0 rgba(255,255,255,.2), 0 4px 12px -3px color-mix(in srgb, var(--brand) 40%, transparent); }
.searchbar { position: relative; margin-bottom: 1rem; }
.searchbar svg { position: absolute; left: .8rem; top: 50%; transform: translateY(-50%); width: 1.1rem; height: 1.1rem; color: var(--muted); pointer-events: none; }
.searchbar input { width: 100%; font: inherit; padding: .7rem .9rem .7rem 2.4rem; border-radius: var(--r-md); border: 1px solid var(--border); background: var(--surface); color: var(--text); }
.searchbar input:focus { outline: 2px solid var(--brand); border-color: transparent; }
/* 320px floor, the same as the drill's review grid below: a catalogue card carries
   the sign's full meaning, and at a 240px floor three columns squeezed that
   paragraph to ~19 characters a line — a ragged column nobody reads. Two roomier
   columns beat three unreadable ones on a surface whose whole job is explaining
   what a sign means. `flex-start` ties the picture to its name at the top of the
   card instead of floating it against the middle of a six-line paragraph. */
.signgrid { display: grid; gap: .8rem; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
.sign { display: flex; gap: .8rem; align-items: flex-start; transition: transform .14s ease, box-shadow .18s ease; }
.sign .signbox { flex: none; }  /* a long meaning must not squash the sign image */
.sign:hover { transform: translateY(-2px); box-shadow: var(--shadow-lg); }
.sign .name { font-weight: 700; }
.sign .mean { color: var(--muted); font-size: .88rem; }
/* A card that is a link (to the sign's landing page): text colour, no underline, no arrow
   glyph (owner, 2026-09-19) — the hover lift every .card.sign already has is the affordance. */
.sign[href] { color: inherit; text-decoration: none; }
.sign > div { flex: 1; min-width: 0; }

/* signs: catalogue / practice mode switch */
.seg { display: inline-flex; gap: .25rem; padding: .25rem; margin-bottom: 1.1rem; border-radius: var(--r-pill); border: 1px solid var(--border); background: var(--surface-2); }
/* `background: transparent` is load-bearing, not tidiness. This class is used on both
   an <a> (the signs tabs) and a <button> (the schools list/map switch), and a <button>
   with no background takes the UA's `buttonface` — #efefef. In light that passed by
   luck (--muted on #efefef is ~5.4:1); in dark it painted a light-grey pill inside the
   dark bar and measured 2.22:1. The .active rule below still sets its own surface. */
.seg-btn { background: transparent; padding: .4rem 1rem; border-radius: var(--r-pill); font-size: .88rem; font-weight: 650; color: var(--muted); text-decoration: none; transition: background .16s ease, color .16s ease; }
.seg-btn:hover { color: var(--text); }
.seg-btn.active { background: var(--surface); color: var(--text); box-shadow: var(--shadow); }
/* The signs tabs paint the active surface on a child pill instead, so the view transition can
   move the box without the label (see signTabs in main.js). Scoped with :has() because the
   schools list/map switch reuses .seg-btn with no pill and keeps the rule above. */
.seg-btn { position: relative; isolation: isolate; }
.seg-btn.active:has(.seg-pill) { background: transparent; box-shadow: none; }
.seg-pill { position: absolute; inset: 0; z-index: -1; border-radius: var(--r-pill); background: var(--surface); box-shadow: var(--shadow); }
.seg-count { display: inline-flex; align-items: center; gap: .4rem; margin-left: auto; font-size: .85rem; color: var(--muted); font-weight: 600; }
.seg-count .select { padding: .3rem .5rem; font-size: .85rem; }

/* signs: the drill card — one big sign, question centred under it */
.signstage { display: grid; place-items: center; padding: .5rem 0 1.1rem; }
.signbox.big .signimg { width: 168px; height: 168px; }
.qtext.center { text-align: center; }
.card.center { text-align: center; }
.card.center .dash-ring { margin-bottom: .6rem; }
/* the drill's "worth another look" list shows exactly what the catalogue shows, so
   it inherits the same 320px floor and top alignment — no override needed */

.select { font: inherit; padding: .5rem .7rem; border-radius: var(--r-sm); border: 1px solid var(--border); background: var(--surface); color: inherit; }

/* Language switcher: two options, so a segmented toggle rather than a dropdown.
   Height is pinned to the same 2.5rem as `.theme-toggle` beside it, because these two
   controls are what set the sticky bar's height — see the CLS note on `.authnav`. */
.langtoggle {
  display: inline-flex; align-items: center; gap: 2px; flex: none;
  height: 2.5rem; padding: 3px; border: 1px solid var(--border);
  border-radius: var(--r-pill); background: var(--surface-2);
}
/* `a` as well as `button`: the app's options swap the SPA's language in place and are
   buttons; the generated landing pages have no SPA to swap, so theirs are links to the
   twin URL (langToggle() in tools/build-seo.mjs). One control, one set of rules — the
   two headers drifted apart the last time that was not true. */
.langtoggle button, .langtoggle a {
  text-decoration: none;
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 2rem; height: 100%; padding: 0 .55rem;
  border: 0; border-radius: var(--r-pill); background: transparent;
  color: var(--muted); font: inherit; font-size: .78rem; font-weight: 700;
  letter-spacing: .02em; cursor: pointer;
  transition: background-color .16s ease, color .16s ease;
}
.langtoggle button:hover, .langtoggle a:hover { color: var(--text); }
.langtoggle button.is-on, .langtoggle a.is-on { background: var(--surface); color: var(--text); box-shadow: var(--shadow-sm, 0 1px 2px rgb(16 24 40 / .08)); }
@media (prefers-reduced-motion: reduce) { .langtoggle button, .langtoggle a { transition: none; } }
/* The panel copy is hidden everywhere except phones — see the paired query below.
   Two non-overlapping queries, never a bare `display: none`, for the same reason the
   theme toggle documents: the in-panel rule would out-specify it. */
@media (min-width: 561px) { .langtoggle--mobile { display: none; } }
@media (max-width: 560px) {
  /* Bar drops the switcher; the panel gains it. Five items no longer fit on one bar line
     at this width — leaving it in wrapped the hamburger onto a second row, under the
     logo on the left. */
  .topbar > .langtoggle:not(.langtoggle--mobile) { display: none; }
  .mainnav .langtoggle--mobile {
    /* Sorts after the links and beside the theme row: both are settings, not
       destinations. The theme toggle picks up `order: 4`, so this sits just before it. */
    order: 3;
    display: flex; width: 100%; height: auto; min-height: 44px;
    justify-content: flex-start; gap: .35rem;
    padding: .45rem .8rem; margin-top: .35rem;
    border: none; border-top: 1px solid var(--border);
    border-radius: var(--r-sm); background: transparent;
  }
  .mainnav .langtoggle--mobile button,
  .mainnav .langtoggle--mobile a {
    flex: 1 1 0; min-height: 36px; height: auto; border-radius: var(--r-sm);
    background: var(--surface-2); font-size: .85rem;
  }
  .mainnav .langtoggle--mobile button.is-on,
  .mainnav .langtoggle--mobile a.is-on { background: var(--grad-solid); color: var(--brand-ink); box-shadow: none; }
  /* Nothing to re-hang: the ≤1300px block pushes the group off `.brand`, so the bar
     still reads brand | (language) | account | theme | ☰ whether or not the language
     control is one of the ones that moved into the panel. */
  .langtoggle, .authnav { margin-left: 0; }
}

/* sign images / placeholders */
.signbox { display: inline-grid; place-items: center; }
.signimg { width: 64px; height: 64px; object-fit: contain; border-radius: var(--r-sm); }
.sign .signimg { width: 72px; height: 72px; }
.qsign { margin: 0 0 1rem; }
.qsign .signimg { width: 104px; height: 104px; }
.code-tag { display: inline-block; font-size: .72rem; font-weight: 700; color: var(--brand); background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-xs); padding: .05rem .35rem; vertical-align: middle; }
.tbl tr.thead td { color: var(--muted); font-size: .74rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; border-bottom: 2px solid var(--border); padding-top: .25rem; }
label.field { display: flex; flex-direction: column; gap: .3rem; font-size: .85rem; color: var(--muted); font-weight: 600; }
.row { display: flex; gap: 1rem; flex-wrap: wrap; align-items: end; }

/* header auth area */
.authnav { display: flex; align-items: center; gap: .6rem; }
/* min-WIDTH matters as much as min-height here: logged in, this link is the
   user's first name (main.js renders `u.first_name`), so a two-letter name gave
   a 16x44 target sitting only .6rem from "Logg ut" — too narrow for WCAG 2.5.8
   and too close for its spacing exemption. Every prior audit ran logged out,
   where the label is "Logg inn" and wide enough to hide the bug. */
.authlink { text-decoration: none; color: var(--muted); font-weight: 700; display: inline-flex; align-items: center; justify-content: center; min-height: 40px; min-width: 44px; padding-inline: .35rem; }
.authlink:hover { color: var(--text); }
.btn.small { padding: .4rem .8rem; font-size: .85rem; border-radius: var(--r-sm); }

/* light/dark toggle — icon swap is pure CSS (attribute selector), so it
   works the instant it's clicked without waiting on app/main.js to load */
.theme-toggle {
  display: inline-flex; align-items: center; justify-content: center;
  width: 2.5rem; height: 2.5rem; border-radius: var(--r-pill); flex: none;
  border: 1px solid var(--border); background: var(--surface-2); color: var(--muted);
  cursor: pointer; transition: border-color .16s ease, color .16s ease, transform .06s ease;
}
/* The theme toggle that lives inside the hamburger menu. Hidden on desktop via a
   min-width query on purpose: as a plain `.theme-toggle--mobile { display: none }` it
   sat *later* in the file than the max-width:860px rule that reveals it, and a media
   query carries no extra specificity — so source order won and the dark-mode row never
   appeared in the mobile menu at all. Two non-overlapping queries cannot fight. */
@media (min-width: 1301px) { .theme-toggle--mobile { display: none; } }
.theme-toggle:hover { color: var(--text); border-color: var(--muted); }
.theme-toggle:active { transform: translateY(1px); }
.theme-toggle svg { width: 1.15rem; height: 1.15rem; }
.theme-toggle .icon-moon { display: block; }
.theme-toggle .icon-sun { display: none; }
:root[data-theme="dark"] .theme-toggle .icon-moon { display: none; }
:root[data-theme="dark"] .theme-toggle .icon-sun { display: block; }

/* The paywall's own plan list is gone: every price is now rendered by the one
   .pricing-grid block further down, shared with the landing page. */

/* auth forms (login / signup / account) */
/* The auth/gate routes (login, signup, password reset, the access gate, the exam intro)
   are a single centred card on an otherwise empty page. #view's 56px bottom padding is
   right for a long scrolling route and leaves a conspicuous well under a short card, so
   it is pulled back here to roughly the card's own rhythm.
   The :last-child rule kills the trailing <p>'s .75rem bottom margin, which was stacking
   on the card's 16px padding and made the inside bottom 29px against a 16px top — the
   generalisation of the .ck-main .authalt override further down. */
.authwrap { display: flex; justify-content: center; margin-bottom: -2rem; }
.authcard > :last-child { margin-bottom: 0; }

/* ------------------------------------------------ the login mascot (app/authcar.js)
   A red car with a face on the login card. Owner-requested (2026-08-29) and the one deliberate
   exception to the auth-forms row of the motion table: every expression answers a user action
   (focus/typing/hover, with keyboard parity), the only loop is a blink every ~6s, and reduced motion
   freezes it into a static friendly car whose expressions still swap. States ride two data attributes
   the JS sets; everything below is transform/opacity only.
   The negative top margin is not spacing, it is compensation: the SVG's viewBox holds 41 of its 150
   units as empty sky above the roof (the car is drawn low so the happy wag and the -2px hop have
   somewhere to go). At width:172px that is ~32px of transparent band, and stacked on .card's 1.35rem
   padding it read as a ~50px hole. Pulling it back up leaves ~23px of real gap. Cropping the viewBox
   instead would be wrong twice: it changes the aspect ratio the result hero sizes against, and
   --ac-px/--ac-py move the pupils in SVG USER UNITS, so the eyes would track further than the face.
   `.rescar .authcar` sets margin:0, so the result hero is unaffected. */
.authcar { width: 172px; margin: -1.9rem auto .3rem; color: var(--text); }
.authcar svg { display: block; width: 100%; height: auto; }
.authcar * { transform-box: fill-box; }
.ac-car { transform-origin: 50% 90%; transition: transform .25s cubic-bezier(.2,.8,.3,1); }
.ac-body { fill: url(#acBody); }
.ac-shade { fill: rgba(30,4,10,.16); }
.ac-crease { stroke: rgba(30,4,10,.14); stroke-width: 2.5; stroke-linecap: round; }
.ac-sheen { fill: rgba(255,255,255,.4); }
.ac-bumper { fill: #a90d26; }
.ac-bumpershine { stroke: rgba(255,255,255,.16); stroke-width: 1.6; stroke-linecap: round; }
.ac-wheel circle { fill: #1c222c; }
.ac-wheel .ac-hubring { fill: #39424f; }
.ac-wheel .ac-hub { fill: #9aa4b2; }
.ac-glassframe { fill: #8f0b22; }
.ac-glass { fill: url(#acGlass); }
.ac-streak { fill: rgba(255,255,255,.07); }
.ac-lightring { fill: #d9a13a; }
.ac-light { fill: #ffe9b8; }
.ac-lightin { fill: #ffd166; }
.ac-lightspec { fill: rgba(255,255,255,.85); }
.ac-glow { opacity: 0; }   /* headlight halos: lit only by the result hero's m-flash (motion.css) */
.ac-sclera { fill: #fff; }
.ac-iris { fill: #33415a; }
.ac-pupil { fill: #171e28; }
.ac-spec { fill: #fff; }
.ac-pupilg { transform: translate(var(--ac-px, 0px), var(--ac-py, 0px)); transition: transform .18s cubic-bezier(.2,.8,.3,1); }
.ac-lid { fill: #232c3a; transform-origin: 50% 0%; transform: scaleY(0); transition: transform .12s ease-out; }
.ac-shut { stroke: #fff; stroke-width: 4.5; stroke-linecap: round; opacity: 0; transition: opacity .12s ease; }
.ac-hapeye { stroke: #fff; stroke-width: 5; stroke-linecap: round; opacity: 0; transition: opacity .15s ease; }
.ac-sclera, .ac-pupilg { transition: opacity .12s ease, transform .18s cubic-bezier(.2,.8,.3,1); }
.ac-brow { stroke: rgba(255,255,255,.85); stroke-width: 4; stroke-linecap: round; transition: transform .18s ease; }
.ac-mouth { transition: opacity .15s ease; }
.ac-mouth-idle { stroke: #5d0716; stroke-width: 4.5; stroke-linecap: round; }
.ac-mouth-happy { stroke: #5d0716; stroke-width: 5; stroke-linecap: round; opacity: 0; }
.ac-mouth-shy { fill: #5d0716; opacity: 0; }
.ac-mouth-sad { stroke: #5d0716; stroke-width: 4.5; stroke-linecap: round; opacity: 0; }
.ac-cheek { fill: #ff8fa0; opacity: 0; transition: opacity .2s ease; }

/* curious — email focused: eyes on the field, brows lift, and the head tilts along
   with the caret (--ac-tilt is set by the same look() that moves the pupils) */
.authcar[data-mood="curious"] .ac-car { transform: rotate(var(--ac-tilt, 1.2deg)); }
.authcar[data-mood="curious"] .ac-brow { transform: translateY(-2.5px); }

/* shy — password focused: eyes shut (classic closed-lid arcs, so the face stays a
   face), head turned away, innocent whistle */
.authcar[data-mood="shy"] .ac-car { transform: rotate(-2.4deg) translateX(-3px); }
.authcar[data-mood="shy"] .ac-sclera,
.authcar[data-mood="shy"] .ac-pupilg { opacity: 0; }
.authcar[data-mood="shy"] .ac-shut { opacity: 1; }
.authcar[data-mood="shy"] .ac-brow-l { transform: translateY(-3px) rotate(-6deg); }
.authcar[data-mood="shy"] .ac-brow-r { transform: translateY(-3px) rotate(6deg); }
.authcar[data-mood="shy"] .ac-mouth-idle { opacity: 0; }
.authcar[data-mood="shy"] .ac-mouth-shy { opacity: 1; }
/* ...unless the password is shown — then the right eye peeks, guiltily downward */
.authcar[data-mood="shy"][data-peek="1"] .ac-eye-r .ac-sclera,
.authcar[data-mood="shy"][data-peek="1"] .ac-eye-r .ac-pupilg { opacity: 1; }
.authcar[data-mood="shy"][data-peek="1"] .ac-eye-r .ac-shut { opacity: 0; }
.authcar[data-mood="shy"][data-peek="1"] .ac-eye-r .ac-pupilg { transform: translate(2.5px, 3.5px); }

/* happy — an action hovered/focused: eyes to arcs, big smile, blush, and one wag
   on arrival (the keyframes end on the lifted resting pose, so the settle is
   seamless; re-entering happy re-runs it, which is the point of a wag) */
.authcar[data-mood="happy"] .ac-car {
  transform: translateY(-2px) scale(1.02);
  animation: ac-wag .5s cubic-bezier(.2,.8,.3,1);
}
.authcar[data-mood="happy"] .ac-sclera,
.authcar[data-mood="happy"] .ac-pupilg { opacity: 0; }
.authcar[data-mood="happy"] .ac-hapeye { opacity: 1; }
.authcar[data-mood="happy"] .ac-mouth-idle { opacity: 0; }
.authcar[data-mood="happy"] .ac-mouth-happy { opacity: 1; }
.authcar[data-mood="happy"] .ac-cheek { opacity: .55; }
@keyframes ac-wag {
  0% { transform: translateY(-2px) scale(1.02) rotate(0deg); }
  30% { transform: translateY(-2px) scale(1.02) rotate(-2.6deg); }
  62% { transform: translateY(-2px) scale(1.02) rotate(2deg); }
  100% { transform: translateY(-2px) scale(1.02) rotate(0deg); }
}

/* sad — a failed sign-in (the form's 'authcar-oops' event): the whole car sags,
   worried inner-up brows, downturned mouth, eyes low. ~2.4s, then back to whatever
   is focused. */
.authcar[data-mood="sad"] .ac-car { transform: translateY(2px); }
.authcar[data-mood="sad"] .ac-pupilg { transform: translate(0, 4.5px) scale(.88); }
.authcar[data-mood="sad"] .ac-brow-l { transform: translateY(-1px) rotate(10deg); }
.authcar[data-mood="sad"] .ac-brow-r { transform: translateY(-1px) rotate(-10deg); }
.authcar[data-mood="sad"] .ac-mouth-idle { opacity: 0; }
.authcar[data-mood="sad"] .ac-mouth-sad { opacity: 1; }

/* the blink — idle and curious only (shut eyes and happy arcs don't blink); the
   second beat makes roughly every other blink read as a quick double-blink */
.authcar[data-mood="idle"] .ac-lid, .authcar[data-mood="curious"] .ac-lid { animation: ac-blink 6.2s ease-in-out infinite; }
@keyframes ac-blink {
  0%, 90%, 100% { transform: scaleY(0); }
  91.5%, 92.5% { transform: scaleY(1); }
  94% { transform: scaleY(0); }
  95.5%, 96.5% { transform: scaleY(1); }
}

@media (prefers-reduced-motion: reduce) {
  .authcar * { transition: none !important; animation: none !important; }
}

/* The mascot parked in the result hero on a pass (renderResult). Smaller than the
   auth card build, and centered; its drive-in and headlight flash live in motion.css. */
.result-hero .rescar { width: 128px; margin: .2rem auto 0; }
.rescar .authcar { width: 100%; margin: 0; }

/* ---------------------------------------------- the traffic-light exam start
   startWithLight() in main.js: the pressed Start button holds its measured box
   (inline width/height) and hosts a small Norwegian signal; JS steps the phase
   classes red → red+amber → green over ~1s and the exam opens on green. */
.tl-btn { gap: .75rem; pointer-events: none; }
/* `.tl-btn .tl`, not `.tl`: `.btn svg { width: 1.15em; height: 1.15em }` above is
   (0,1,1) and beat a bare class, which is what pinned the signal at ~19px — a
   pinhead marooned in a 230px button. Two classes (0,2,0) take it back. */
.tl-btn .tl { height: 2.3rem; width: auto; display: block; flex: none; }
/* Left-aligned in a fixed box so the signal beside it never shifts as the word
   changes (and never as the language does: "Ready …" is wider than "Klar …"). */
.tl-say { min-width: 4.6rem; text-align: left; font-variant-numeric: tabular-nums; }
/* Green means go: the surface itself turns over for the last beat before the deck.
   `color: var(--bg)`, never #fff — --ok inverts role between themes (it is a light
   mint in dark, 1.74:1 under white). Same rule as the Pass Protection panels. */
.tl-btn.is-go {
  background-color: var(--ok); background-image: none; color: var(--bg);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.22), 0 1px 2px rgba(16,24,40,.14),
              0 6px 18px -4px color-mix(in srgb, var(--ok) 46%, transparent);
}
.tl-house { fill: #232a36; stroke: #10151d; stroke-width: 2; }
.tl-lens { fill: #39424f; transition: fill .12s ease, filter .12s ease; }
.tl.p-red .tl-red { fill: #ff4b3e; filter: drop-shadow(0 0 5px rgba(255,75,62,.9)); }
.tl.p-amber .tl-amber { fill: #ffc53d; filter: drop-shadow(0 0 5px rgba(255,197,61,.9)); }
.tl.p-green .tl-red, .tl.p-green .tl-amber { fill: #39424f; filter: none; }
.tl.p-green .tl-green { fill: #35d073; filter: drop-shadow(0 0 6px rgba(53,208,115,.95)); }

/* ------------------------------------------------- the windshield-wiper loader
   wiperLoader() in main.js, inside waitForApp()'s first skeleton card. Fixed
   night-glass darks (not tokens) so it reads in both themes on the skeleton grey;
   the sweep keyframes are motion.css's. */
.wl-host { position: relative; }
.wiperload { position: absolute; inset: 0; display: grid; place-items: center; }
.wiperload svg { width: 148px; height: auto; display: block; }
.wl-glass { fill: #2a3446; }
.wl-cowl { fill: #1d2531; }
.wl-drop { fill: rgba(255,255,255,.4); }
.wl-arm { transform-box: view-box; transform: rotate(24deg); }
.wl-rod { stroke: #9aa4b2; stroke-width: 3; stroke-linecap: round; }
.wl-blade { stroke: #cfd6e0; stroke-width: 5; stroke-linecap: round; }

/* --------------------------------------------------- the result speedometer
   speedo() in main.js — result hero only. The fill arc IS a .ring-fg (same band
   colours, same fillRings() animation); the needle's 1.1s sweep transition is in
   motion.css, this .6s matches .ring-fg's base fallback below it. */
.speedo { display: block; }
.sp-track { stroke: var(--surface-2); }
.sp-red { stroke: var(--bad); opacity: .16; }
.sp-tick { stroke: var(--muted); stroke-width: 2; stroke-linecap: round; opacity: .55; }
.sp-mark { stroke: var(--bad); stroke-width: 3; stroke-linecap: round; }
.sp-needle {
  fill: var(--text); transform-box: view-box; transform-origin: 95px 88px;
  transform: rotate(var(--sp-a, 0deg)); transition: transform .6s cubic-bezier(.22,.61,.36,1);
}
.sp-hub { fill: var(--text); }
.sp-hubdot { fill: var(--surface); }
.sp-txt { font-size: 1.3rem; }

/* --------------------------------------------- the broken-down error vignette
   breakdownArt() in ui.js, on every tone:'error' statecard. Static by design. */
.statecard-art { flex: none; width: 6.6rem; }
.statecard-art svg { display: block; width: 100%; height: auto; }
.statecard.is-compact .statecard-art { width: 4.4rem; }
.bd-ground { stroke: var(--border); stroke-width: 2; stroke-linecap: round; }
.bd-steam { stroke: var(--muted); stroke-width: 2; stroke-linecap: round; opacity: .7; }
.bd-body { fill: #d31732; }
.bd-hood { stroke: #b40e28; stroke-width: 4.5; stroke-linecap: round; }
.bd-slot { stroke: rgba(30,4,10,.45); stroke-width: 2.5; stroke-linecap: round; }
.bd-glass { fill: #33405a; }
.bd-eye { fill: #fff; }
.bd-pupil { fill: #171e28; }
.bd-wheel circle { fill: #1c222c; }
.bd-wheel .bd-hub { fill: #9aa4b2; }
.bd-tri { stroke: var(--bad); stroke-width: 3.5; stroke-linejoin: round; }

.authcard { width: 100%; max-width: 430px; }
.authcard.wide { max-width: 640px; }
.authcard.center { text-align: center; }
/* Access gates and payment outcomes. Was a 2.6rem emoji; now a stroke icon in a
   round well, so it answers to the theme and to its own outcome tone instead of
   being a fixed full-colour glyph that renders differently on every platform.
   Tone is never the only signal — the heading beside it states the outcome in
   words, so this reads correctly in greyscale and to a screen reader (the well is
   aria-hidden). */
.gateicon {
  width: 3.4rem; height: 3.4rem; margin: 0 auto .6rem; border-radius: 50%;
  display: grid; place-items: center;
  background: var(--surface-2); color: var(--muted);
}
.gateicon svg { width: 1.7rem; height: 1.7rem; }
.gateicon.is-ok { background: var(--ok-bg); color: var(--ok); }
.gateicon.is-bad { background: var(--bad-bg); color: var(--bad); }
.gateicon.is-wait { background: color-mix(in srgb, var(--blue) 12%, var(--surface)); color: var(--blue); }
/* Statecard wells use the same stroke language at the smaller card size. */
.statecard-ico svg { width: 1.45rem; height: 1.45rem; color: var(--muted); }
.statecard.is-error .statecard-ico svg { color: var(--bad); }
.statecard.is-info .statecard-ico svg { color: var(--blue); }
.statecard.is-compact .statecard-ico svg { width: 1.05rem; height: 1.05rem; }
.authform { display: grid; gap: .9rem; margin-top: 1rem; }
.authform.grid2 { grid-template-columns: 1fr 1fr; }
/* Section headings inside the signup form (2026-09-14). Eight fields in a flat 2-column grid
   read as a wall; three small labelled groups — about you / address / password — read as three
   short tasks. An <h2>, not a <div>: it is a heading in a form that sits under the card's <h1>. */
.authform .fgroup { grid-column: 1 / -1; margin: .4rem 0 -.3rem; font-size: .74rem; font-weight: 700;
  letter-spacing: .07em; text-transform: uppercase; color: var(--muted); }
.authform .fgroup:first-child { margin-top: 0; }
/* Password strength: four bars + a word. data-level 0-4 lights that many bars, tinted from
   --bad through --amber to --ok. Feedback only — the server's rule is still 8 characters, and
   the meter never blocks a submit. Bars are <i>, so screen readers get only the word
   (aria-live on the wrapper). */
.pwmeter { display: grid; grid-template-columns: repeat(4, 1fr) auto; gap: .3rem; align-items: center; margin-top: .45rem; }
.pwmeter i { height: 4px; border-radius: var(--r-pill); background: var(--border); transition: background var(--dur-base) var(--ease-out); }
.pwmeter b { grid-column: 5; font-size: .74rem; font-weight: 600; color: var(--muted); padding-left: .3rem; white-space: nowrap; }
.pwmeter[data-level="1"] i:nth-child(-n+1) { background: var(--bad); }
.pwmeter[data-level="2"] i:nth-child(-n+2) { background: var(--amber); }
.pwmeter[data-level="3"] i:nth-child(-n+3) { background: color-mix(in srgb, var(--ok) 70%, var(--amber)); }
.pwmeter[data-level="4"] i:nth-child(-n+4) { background: var(--ok); }
.pwmeter[data-level="1"] b { color: var(--bad); }
.pwmeter[data-level="4"] b { color: var(--ok); }
.authform.grid2 .span2 { grid-column: 1 / -1; }
.authfield { gap: .25rem; }
/* An input is a RECESSED surface — the one place in the sheet that gets an inset
   shadow rather than the lit top edge. That inversion is the whole cue: a card
   rises out of the page, a field is cut into it, and using the same treatment
   for both is what makes a form read as a stack of grey boxes. */
.input {
  font: inherit; padding: .65rem .75rem; border-radius: var(--r-sm); border: 1px solid var(--border);
  background: var(--surface); color: var(--text); width: 100%;
  box-shadow: inset 0 1px 2px rgba(16,24,40,.06);
  transition: box-shadow var(--dur-base) var(--ease-out), border-color var(--dur-base) var(--ease-out);
}
/* The halo is additive to the existing 2px outline, not a replacement: the
   outline is the accessible focus indicator and it keeps its contrast and its
   crispness, while the soft ring gives the field the "it woke up" feel. */
.input:focus {
  outline: 2px solid var(--brand); outline-offset: 0; border-color: transparent;
  box-shadow: inset 0 1px 2px rgba(16,24,40,.05), 0 0 0 4px color-mix(in srgb, var(--brand) 14%, transparent);
}
.input.invalid { border-color: var(--bad); background: var(--bad-bg); }

/* ---- the field name, INSIDE the field (2026-09-09) -----------------------------
   Every auth/profile field built by fieldRow()/pwField() in app/main.js. The caption
   rests where the value will be typed — same font, same baseline, so it reads as a
   placeholder — and rises into the box's own top padding once the field is focused or
   holds a value. The padding is asymmetric FOR that caption; it is not decoration.

   Why: a label above the box cost a text row plus its gap (~25px) per field, and
   signup asks for nine. The box itself is the height it always was, so nothing else
   on those screens moved.

   `:has()` rather than a `+` sibling rule, because the caption has to come FIRST inside
   `.ffield` — `.pwtoggle` finds its input with `previousElementSibling` (app/main.js,
   authcar.js), and putting the caption between them broke the eye toggle and the
   mascot's peek. */
.ffield { position: relative; display: block; }
/* The top padding is the caption's room. 1.25rem rather than the 1.05rem this started
   at: the caption is the input's own font scaled down, and at a tighter scale it landed
   at 9.8px — smaller than anything else on the page, including the .78rem error and hint
   lines directly under it. 1.25rem buys enough height for `scale(.82)` (≈11px), which is
   the smallest this text should ever be. */
.ffield .input { padding-top: 1.25rem; padding-bottom: .35rem; }
.flabel {
  position: absolute; left: calc(.75rem + 1px); top: calc(1.25rem + 1px);
  font-size: inherit; font-weight: 600; line-height: 1.55; color: var(--muted);
  /* Clicks fall through to the input, so tapping the caption puts the caret where the
     reader aimed instead of at the start of the value. */
  pointer-events: none;
  max-width: calc(100% - 1.6rem); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  /* Transform only — the caption is absolutely positioned, so scaling it costs no
     layout, and animating `top`/`font-size` would. */
  transform-origin: left top;
  transition: transform var(--dur-base) var(--ease-out), color var(--dur-base) var(--ease-out);
}
.pwfield .flabel { max-width: calc(100% - 3.4rem); }  /* clear of the eye toggle */
.ffield:has(.input:focus) .flabel,
.ffield:has(.input:not(:placeholder-shown)) .flabel,
.ffield.pinned .flabel { transform: translateY(-1.17rem) scale(.82); }
.ffield:has(.input:focus) .flabel { color: var(--brand); }
.ffield:has(.input.invalid) .flabel { color: var(--bad); }
/* A reader who asked for less motion gets the same two positions, arrived at instantly —
   the caption's job is to say what the field is, and that must not depend on an
   animation. The global reduced-motion rule collapses durations, but this states it at
   the property so a future edit to that rule cannot resurrect the slide. */
@media (prefers-reduced-motion: reduce) {
  .flabel { transition: none; }
}

/* password field with a show/hide toggle. Same icon-swap trick as .theme-toggle, but
   scoped to the button's own aria-pressed (not <html data-theme>) since each field
   toggles independently. */
.pwfield { position: relative; }
.pwfield .input { padding-right: 2.5rem; }
.pwtoggle {
  position: absolute; top: 0; right: 0; bottom: 0; width: 2.5rem;
  display: inline-flex; align-items: center; justify-content: center;
  background: none; border: none; padding: 0; margin: 0; color: var(--muted);
  cursor: pointer; border-radius: 0 var(--r-sm) var(--r-sm) 0;  /* tracks .input */
}
.pwtoggle:hover { color: var(--text); }
.pwtoggle:focus-visible { outline: 2px solid var(--brand); outline-offset: -2px; }
.pwtoggle svg { width: 1.15rem; height: 1.15rem; flex: none; }
.pwtoggle .icon-eye-off { display: none; }
.pwtoggle[aria-pressed="true"] .icon-eye { display: none; }
.pwtoggle[aria-pressed="true"] .icon-eye-off { display: block; }
.fielderr { color: var(--bad); font-size: .78rem; font-weight: 600; min-height: 0; }
.fielderr:empty { display: none; }
.hint { color: var(--muted); font-size: .78rem; font-weight: 500; }
.formnote { border-radius: var(--r-sm); padding: .6rem .8rem; font-size: .9rem; font-weight: 600; grid-column: 1 / -1; }
.formnote.err { color: var(--bad); background: var(--bad-bg); border: 1px solid var(--bad); }
.formnote.ok { color: var(--ok); background: var(--ok-bg); border: 1px solid var(--ok); }
.formnote[hidden] { display: none; }
.authalt { margin-top: 1rem; color: var(--muted); font-size: .9rem; }
/* 24px tall (WCAG 2.5.8 — was 18px of bare text), and it keeps an underline
   rather than `text-decoration: none`: it sits mid-sentence next to muted body
   copy, so brand colour plus bold was the only thing marking it as a link
   (WCAG 1.4.1). Dialled to 40% ink so it reads as a link without shouting. */
.authalt a {
  color: var(--brand); font-weight: 700;
  display: inline-flex; align-items: center; min-height: 24px;
  text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
  text-underline-offset: 3px;
}
.authalt a:hover, .authalt a:focus-visible { text-decoration-color: currentColor; }

/* "or" divider + Google sign-in button. The button keeps Google's own white/grey
   styling in both themes rather than inheriting ours: their branding guidelines
   require the unaltered mark on a neutral surface, and a recognisable button is also
   what makes it read instantly as "this is the Google one". */
/* The provider buttons (Vipps, Google). A hair of space between them — enough to read as
   two separate choices rather than one control, without opening a gap that competes with
   the margins around the block. `grid` rather than margins so adding a third provider
   needs no new rule. */
.oauthbtns { display: grid; gap: .4rem; }
.oauthsep { display: flex; align-items: center; gap: .8rem; margin: 1rem 0 .8rem; color: var(--muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .06em; }
.oauthsep::before, .oauthsep::after { content: ''; flex: 1; height: 1px; background: var(--border); }
.btn.google { background: #fff; color: #1f1f1f; border-color: #dadce0; font-weight: 600; }
.btn.google:hover { background: #f7f8f8; border-color: #d2d5d9; }
.btn.google svg { width: 18px; height: 18px; }

/* contact form */
.contactwrap { display: flex; flex-direction: column; align-items: center; gap: 1.5rem; }
.contact-head { text-align: center; max-width: 56ch; }
.contact-head .lead { margin-top: .4rem; }
.contactcard { width: 100%; max-width: 560px; }
.contactcard.center { text-align: center; }
.contact-note { margin: -0.2rem 0 0.6rem; font-size: .82rem; color: var(--ok); display: flex; gap: .4rem; align-items: center; }
.contact-note::before { content: ""; }
/* The textarea + its word counter share one relative box so the counter sits in
   the bottom-right corner of the field itself, like the references describe. */
.msgwrap { position: relative; display: block; }
.msgwrap .input { resize: vertical; min-height: 7rem; padding-bottom: 1.8rem; }
.wordcount { position: absolute; right: .7rem; bottom: .5rem; font-size: .74rem; font-weight: 600; color: var(--muted); pointer-events: none; }
.wordcount.over { color: var(--bad); }
/* Attachments (optional): a paperclip button, then one row per chosen file. The real
   <input type="file"> is hidden and clicked through the button — its native rendering cannot be
   styled to match the rest of the form.
   The form is a grid, and a grid item's automatic minimum size is min-content — so one long
   unbreakable filename stretched the attachments field to 632px inside a 360px phone, where
   `main.view`'s overflow-x: clip hid the size and the remove button instead of scrolling to them.
   `min-width: 0` lets the track shrink so the ellipsis below can do its job. Applied to every field,
   not just this one: a long prefilled email address is the same trap. */
.authform > * { min-width: 0; }
.filelabel { display: flex; align-items: baseline; gap: .35rem; font-weight: 600; font-size: .88rem; }
.filelabel .optional { font-weight: 500; font-size: .8rem; color: var(--muted); }
.btn.filebtn {
  align-self: flex-start; margin-top: .45rem; gap: .4rem;
  font-size: .88rem; padding: .45rem .8rem;
  border-style: dashed;
}
.btn.filebtn svg { width: 17px; height: 17px; }
.btn.filebtn:hover { border-style: solid; border-color: color-mix(in srgb, var(--brand) 40%, var(--border)); }
.filelist { list-style: none; margin: .5rem 0 0; padding: 0; display: grid; gap: .4rem; }
.fileitem {
  /* min-width: 0 again, one level down: the row is itself a grid item of .filelist,
     so it gets the same min-content floor its parent just escaped. Without it the
     grid track resolves to ~660px on a 360px screen. */
  display: flex; align-items: center; gap: .6rem; min-width: 0;
  padding: .45rem .5rem .45rem .7rem; font-size: .85rem;
  background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-sm);
}
/* The name is the flexible part and must be the thing that truncates — a long
   filename should never push the size or the remove button out of the row. */
.filename { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.filebytes { flex: none; font-size: .78rem; font-weight: 600; color: var(--muted); font-variant-numeric: tabular-nums; }
.filedrop {
  /* 32px, not the 26px this started at: it is the one control inside a row, it gets
     used on a phone, and 26 sits only just above the 24px minimum. */
  flex: none; display: grid; place-items: center; width: 32px; height: 32px; padding: 0;
  background: none; border: 0; border-radius: 999px; color: var(--muted); cursor: pointer;
  transition: background .15s ease, color .15s ease;
}
.filedrop svg { width: 15px; height: 15px; }
.filedrop:hover { background: var(--bad-bg); color: var(--bad); }
.filedrop:focus-visible { outline: 2px solid var(--blue); outline-offset: 1px; }
/* A rejected pick leaves the current selection intact, so the error has to point
   at the list rather than replace it. */
.filelist.has-error .fileitem { border-color: color-mix(in srgb, var(--bad) 35%, var(--border)); }
/* Both are <span>s inside the field, so they need to be blocks or the red error
   and the grey hint run together on one line and read as a single sentence. */
.fileitem + .fielderr, .filehint { display: block; }
.filehint { font-size: .78rem; color: var(--muted); line-height: 1.45; margin-top: .35rem; }
.field .fielderr:not(:empty) { display: block; margin-top: .35rem; }
/* `hidden` alone loses to `.btn { display: inline-flex }` and `.filelist
   { display: grid }` — the UA rule it relies on is weaker than any author
   declaration — so the picker stayed visible at the cap and the empty list kept
   its grid gap. Restate it where those two rules can be overridden. */
.btn.filebtn[hidden], .filelist[hidden] { display: none; }
@media (prefers-reduced-motion: reduce) { .filedrop { transition: none; } }

.contact-success .success-icon { display: block; margin: 0 auto .7rem; }
.contact-success .lead { margin: .4rem auto 1.2rem; max-width: 42ch; }
.contact-back { display: inline-flex; align-items: center; gap: .3rem; color: var(--muted); font-weight: 600; font-size: .92rem; text-decoration: none; padding: .35rem 0; }
.contact-back:hover { color: var(--text); text-decoration: underline; }

/* ------------------------------------- footer pages (about/faq/legal/sitemap) */
/* One prose shell for all five pages (app/legal.js). Measure is capped at ~68ch
   inside a wider card: legal text is read linearly, and a full 920px line is the
   difference between skimmable and skipped. */
.legalwrap { display: flex; flex-direction: column; gap: 1.5rem; }
.legal-head { max-width: 60ch; }
.legal-head .lead { margin-top: .4rem; }
.legal-updated { margin-top: .7rem; font-size: .78rem; color: var(--muted); }
.legalcard { width: 100%; }
.legal-prose { max-width: 72ch; }
.legal-prose h2 {
  font-size: 1.06rem; margin: 1.9rem 0 .55rem; padding-top: 1.4rem;
  border-top: 1px solid var(--border);
}
.legal-prose > h2:first-child { margin-top: 0; padding-top: 0; border-top: 0; }
.legal-prose p { margin: 0 0 .8rem; line-height: 1.68; }
/* Inline prose links get the WCAG 2.5.8 24px floor through vertical PADDING, not
   `inline-flex` + min-height like the standalone links elsewhere in this file. An
   inline-flex link is an atomic box: it cannot break across lines, so a multi-word
   one ("terms of service", "course page") would jump to a line of its own on a
   narrow phone. Padding on an inline box grows the hit area — which is what 2.5.8
   measures — without touching the line box, so no prose reflows at all. */
.legal-prose p a, .legal-prose li a, .legal-prose dd a { padding-block: 3px; }
/* .legal-fine is 11.2px, so the same 3px lands at 20px. These two fine-print lines
   carry a single-word link each, so inline-flex is safe here — it cannot need to wrap. */
.legal-prose p.legal-fine a, .ck-fine a { display: inline-flex; align-items: center; min-height: 24px; padding-block: 0; }
/* The link ROW (`.legal-links`) is pills, not prose — it sets its own box. */
.legal-prose p.legal-links a { padding-block: 0; }
.legal-prose p:last-child { margin-bottom: 0; }
.legal-prose code {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .84em;
  background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-xs);
  padding: .08em .35em;
}
.legal-list { margin: 0 0 .9rem; padding-left: 1.15rem; display: grid; gap: .4rem; }
.legal-list li { line-height: 1.6; }
/* Numbered steps (the Pass Protection claim procedure): the marker carries meaning
   here — step 2 after step 1 — so it is given the brand colour and a little weight
   rather than being left as a grey default bullet's numeric twin. */
.legal-steps li::marker { color: var(--brand); font-weight: 800; }
/* Term/definition rows rather than a table: the privacy page is mostly
   "this field → why we hold it", and a table would scroll sideways on a phone. */
.legal-dl { margin: 0 0 .9rem; display: grid; gap: .8rem; }
.legal-dt {
  padding: .7rem .85rem; background: var(--surface-2);
  border: 1px solid var(--border); border-radius: var(--r-sm);
}
.legal-dt dt { font-weight: 700; font-size: .92rem; margin-bottom: .25rem; }
.legal-dt dd { margin: 0; font-size: .9rem; line-height: 1.6; color: var(--muted); }
.legal-note {
  padding: .75rem .9rem; border-left: 3px solid var(--amber);
  background: color-mix(in srgb, var(--amber) 8%, var(--surface));
  border-radius: 0 var(--r-sm) var(--r-sm) 0; font-size: .9rem;
}
/* Fine print: the seller identity at the foot of the terms. No heading over it, 11px
   against 16px body copy, and washed toward the background — it is there to be found by
   someone looking for it, not read by someone who isn't.
   The 90% mix is not a taste call, it is the floor: measured in a real browser, it lands
   at 4.82:1 in light and 5.71:1 in dark, and the next step down (85%) drops light to
   4.34:1, under the 4.5:1 minimum for small text. Fainter than this and the line stops
   being legible, which would mean carrying the cost of publishing the identity without
   the protection of having published it. Re-measure both themes before changing it. */
.legal-fine { color: color-mix(in srgb, var(--muted) 90%, var(--bg)); font-size: .7rem; line-height: 1.5; }
.legal-links { display: flex; flex-wrap: wrap; gap: .5rem .9rem; margin: 0 0 .9rem; }
.legal-link {
  display: inline-block;
  font-weight: 650; font-size: .9rem; color: var(--brand); text-decoration: none;
  padding: .3rem 0; min-height: 32px;
}
.legal-link:hover { text-decoration: underline; text-underline-offset: 3px; }
.legal-empty { text-align: center; padding: 1rem 0 .5rem; }
.legal-empty h2 { border-top: 0; padding-top: 0; margin-top: 0; }
.legal-empty p { max-width: 46ch; margin-inline: auto; color: var(--muted); }
.legal-empty .legal-links { justify-content: center; }

/* FAQ accordion — native <details> so it works with JS off and is find-in-page
   searchable; the chevron, the open transition and the category chrome are what
   we style. Grouped into four categories (app/legal.js), each with an icon tile
   and a count, so the page can be scanned by section instead of read top to
   bottom. */
.faq-groups { display: grid; gap: 1.7rem; }
.faq-group-head { display: flex; align-items: center; gap: .8rem; margin-bottom: .8rem; }
/* Beats .legal-prose h2's divider rules: the icon tile is the separator here, and
   a hairline above it would cut the tile in half. */
.faq-group-head h2 { margin: 0; padding-top: 0; border-top: 0; font-size: 1.02rem; letter-spacing: -.01em; }
.faq-group-txt { min-width: 0; }
.faq-group-txt p { margin: .15rem 0 0; font-size: .84rem; color: var(--muted); line-height: 1.45; }
.faq-ico {
  flex: none; display: grid; place-items: center; width: 38px; height: 38px;
  border-radius: var(--r-sm); background: var(--grad-ui); color: var(--brand-ink);
  box-shadow: 0 4px 12px color-mix(in srgb, var(--brand) 26%, transparent);
}
.faq-ico svg { width: 20px; height: 20px; }
.faq-count {
  flex: none; margin-left: auto; min-width: 26px; padding: .12rem .45rem; text-align: center;
  font-size: .76rem; font-weight: 700; color: var(--muted);
  background: var(--surface-2); border: 1px solid var(--border); border-radius: 999px;
}
.faq-list { display: grid; gap: .55rem; }
.faq-item {
  border: 1px solid var(--border); border-radius: var(--r-md); background: var(--surface);
  transition: border-color .18s ease, box-shadow .18s ease, transform .18s ease;
}
.faq-item:hover { border-color: color-mix(in srgb, var(--brand) 34%, var(--border)); box-shadow: var(--shadow); }
.faq-item[open] {
  border-color: color-mix(in srgb, var(--brand) 40%, var(--border));
  box-shadow: var(--shadow);
}
.faq-item summary {
  display: flex; align-items: center; justify-content: space-between; gap: 1rem;
  padding: .9rem 1rem; cursor: pointer; font-weight: 650; list-style: none;
}
.faq-item summary::-webkit-details-marker { display: none; }
.faq-item summary:focus-visible { outline: 2px solid var(--blue); outline-offset: -2px; border-radius: var(--r-md); }
.faq-q { min-width: 0; line-height: 1.45; }
.faq-item[open] .faq-q { color: var(--brand); }
/* Chevron doubles as the open indicator: a caret that rotates AND a tinted well
   that fills, so the state is not carried by rotation alone. */
.faq-chev {
  flex: none; position: relative; width: 26px; height: 26px; border-radius: 999px;
  background: var(--surface-2); transition: background .18s ease, transform .18s ease;
}
.faq-chev::before {
  content: ''; position: absolute; inset: 0; margin: auto; width: 7px; height: 7px;
  border-right: 2px solid var(--muted); border-bottom: 2px solid var(--muted);
  transform: translateY(-2px) rotate(45deg); transition: transform .22s cubic-bezier(.2,.7,.3,1), border-color .18s ease;
}
.faq-item:hover .faq-chev { background: color-mix(in srgb, var(--brand) 10%, var(--surface-2)); }
.faq-item[open] .faq-chev { background: color-mix(in srgb, var(--brand) 14%, var(--surface-2)); }
.faq-item[open] .faq-chev::before { transform: translateY(1px) rotate(-135deg); border-color: var(--brand); }
.faq-body { padding: 0 1rem .95rem; }
.faq-body p { margin: 0 0 .6rem; font-size: .92rem; line-height: 1.65; color: var(--muted); }
.faq-body p:last-child { margin-bottom: 0; }
/* The answer eases in rather than snapping. Animating the BODY, not the
   <details> height: height/content-visibility animation still needs
   interpolate-size or a JS-driven max-height, and a 6px slide reads as the same
   gesture at a fraction of the risk. */
.faq-item[open] .faq-body { animation: faq-open .24s cubic-bezier(.2,.7,.3,1) both; }
@keyframes faq-open { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }

/* Closing panel: the one place on the page that points at a human. */
.faq-help {
  display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;
  margin-top: .4rem; padding: 1.15rem 1.2rem; border-radius: var(--r-lg);
  border: 1px solid color-mix(in srgb, var(--brand) 22%, var(--border));
  background:
    radial-gradient(120% 140% at 0% 0%, color-mix(in srgb, var(--brand) 9%, transparent), transparent 62%),
    var(--surface-2);
}
.faq-help-ico {
  flex: none; display: grid; place-items: center; width: 40px; height: 40px;
  border-radius: 999px; background: var(--surface); color: var(--brand);
  border: 1px solid color-mix(in srgb, var(--brand) 24%, var(--border));
}
.faq-help-ico svg { width: 21px; height: 21px; }
.faq-help-txt { flex: 1 1 15rem; min-width: 0; }
.faq-help-txt h2 { margin: 0; padding-top: 0; border-top: 0; font-size: 1rem; }
.faq-help-txt p { margin: .2rem 0 0; font-size: .88rem; color: var(--muted); line-height: 1.55; }
.faq-help-cta { flex: none; }
@media (max-width: 520px) {
  .faq-help { flex-direction: column; align-items: flex-start; }
  .faq-help-cta { width: 100%; justify-content: center; }
}
@media (prefers-reduced-motion: reduce) {
  .faq-chev, .faq-chev::before, .faq-item { transition: none; }
  .faq-item[open] .faq-body { animation: none; }
}

/* Sitemap — the one page that spells out the access model in full. */
.sitemap-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1.6rem 2rem; }
.sitemap-group h2 { margin: 0 0 .6rem; padding-top: 0; border-top: 0; font-size: .95rem; }
.sitemap-group ul { list-style: none; margin: 0; padding: 0; display: grid; gap: .1rem; }
/* Not a flex row: as a flex item the access pill stretches to the column width
   whenever the label + pill are too wide to sit on one line. Inline flow lets it
   wrap while keeping its intrinsic pill size. */
.sitemap-group li { display: block; }
.sitemap-group a {
  display: inline-block;
  color: var(--text); text-decoration: none; font-size: .9rem; padding: .32rem 0; min-height: 32px;
}
.sitemap-group a:hover { color: var(--brand); text-decoration: underline; text-underline-offset: 3px; }
.sitemap-badge {
  display: inline-block; white-space: nowrap; vertical-align: middle; margin-left: .45rem;
  font-size: .66rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em;
  padding: .1rem .4rem; border-radius: var(--r-pill); border: 1px solid var(--border); color: var(--muted);
}
.sitemap-badge.sm-free, .sitemap-badge.sm-preview { color: var(--ok-ink); border-color: color-mix(in srgb, var(--ok) 35%, var(--border)); }
.sitemap-badge.sm-paid { color: var(--brand); border-color: color-mix(in srgb, var(--brand) 35%, var(--border)); }
/* .sitemap-grid sits inside .legal-prose, whose 72ch measure is meant for prose,
   not for a multi-column link grid — let it use the whole card. */
.legal-prose:has(.sitemap-grid) { max-width: none; }

/* account page */
.accountwrap { display: grid; gap: 1.25rem; }
.accountwrap .card { margin: 0; }
.acct-head h1 { margin-bottom: .2rem; }
.accountwrap h2 { font-size: 1.05rem; margin-bottom: .8rem; }
.muted { color: var(--muted); }
.small { font-size: .82rem; }
.nav-row.center { justify-content: center; }
.sessions { display: grid; gap: .6rem; margin-bottom: .9rem; }
.sessrow { padding: .6rem .8rem; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface-2); }
.sessrow .badge { color: var(--ok-ink); font-size: .7rem; vertical-align: middle; margin-left: .3rem; }
/* What deleting the account costs, listed above the confirm field. Body text stays
   on --muted, never --bad: the tone is carried by the card's border and heading, and
   status colour as body copy is the thing docs/frontend.md's state-panel rule bans. */
.danger-list { margin: .1rem 0 1rem; padding-left: 1.1rem; color: var(--muted); font-size: .88rem; }
.danger-list li { margin: .2rem 0; }
.card.danger { border-color: color-mix(in srgb, var(--bad) 45%, var(--border)); }
.card.danger h2 { color: var(--bad); }
.btn.danger-btn { background: var(--bad-solid); color: #fff; border-color: transparent; }
.btn.danger-btn:hover { filter: brightness(1.05); }

@media (max-width: 560px) {
  /* `.mainnav { gap: .9rem }` used to live here and was the menu's real spacing:
     it sits later in the file than the <=860px panel rule, both are media queries
     (so equal specificity), and source order won — every row in the hamburger panel
     was pushed 14.4px apart instead of the 5.6px that rule asks for. It dates from
     when .mainnav was still a horizontal wrapping row on phones; as a vertical panel
     it was just dead space. Removed, not overridden. */
  .view { padding: 1.1rem; }
  .authform.grid2 { grid-template-columns: 1fr; }
  .topbar { gap: .6rem 1rem; }
  .dash { grid-template-columns: 1fr; justify-items: center; text-align: center; }
  .statgrid { width: 100%; }
}

/* =====================================================================
   LANDING PAGE (signed-out home) — marketing / conversion design
   ===================================================================== */
/* The width used to live here; it is now --page-w for every route (see :root).
   Only the tighter top padding is landing-specific — the hero carries its own. */
body.landing .view { padding-top: .5rem; }
/* minmax(0,1fr), not the implicit `auto` track: an auto track grows to its
   content's max-content width, so one wide child (the sign marquee, whose track
   is `width: max-content`) would stretch every section past the viewport. */
.lp { display: grid; grid-template-columns: minmax(0, 1fr); gap: clamp(3rem, 7vw, 5.5rem); }
.lp section { scroll-margin-top: 5rem; }
/* The landing FAQ reuses the #/faq accordion (.faq-item in the legal styles); only
   the width is local — a full-bleed accordion on a marketing page reads as a form. */
.lp-faq .faq-list { max-width: 46rem; margin-inline: auto; }

.lp-faq .nav-row { margin-top: 1.4rem; }

/* Landing-page FAQ. The shape is the one zutobi.com/us uses and it is worth
   naming, because it is the opposite of this sheet's usual instinct: the rows
   are NOT cards. No fill, no border, no radius, no shadow — a bold question, a
   plus, and a hairline between each. Boxing them competes with the four device
   replicas above and makes twelve small frames out of a section whose whole job
   is to be read. The category repeats as a heading beside the list, so the tab
   you are in is still named once you have scrolled past the bar.

   CSS only: the radios are the state, so it works with scripts still loading and
   arrow keys move between tabs for free. The inputs sit loose and the labels in
   `.faq-tabbar` so the bar can carry the underline the active tab sits on; that
   costs the adjacent-sibling selector, hence `:has()`, which also means these
   rules never need to know a section's id prefix. */
.faq-tabs { position: relative; }
.faq-tab-in { position: absolute; opacity: 0; pointer-events: none; width: 0; height: 0; }
.faq-tabbar {
  display: flex; flex-wrap: wrap; justify-content: center; gap: 0 .2rem;
  border-bottom: 1px solid var(--border);
}
.faq-tab {
  display: inline-flex; align-items: center; gap: .55rem;
  padding: .85rem 1.3rem; margin-bottom: -1px;
  border-bottom: 2px solid transparent; background: none;
  font-size: .98rem; font-weight: 700; color: var(--muted); cursor: pointer;
  transition: color .16s ease, border-color .16s ease;
}
.faq-tab svg { width: 1.15em; height: 1.15em; flex: none; }
.faq-tab:hover { color: var(--text); }
.faq-tab-in:focus-visible ~ .faq-tabbar .faq-tab { outline: none; }
.faq-tabs:has(> .faq-tab-in:nth-of-type(1):focus-visible) .faq-tabbar > .faq-tab:nth-child(1),
.faq-tabs:has(> .faq-tab-in:nth-of-type(2):focus-visible) .faq-tabbar > .faq-tab:nth-child(2),
.faq-tabs:has(> .faq-tab-in:nth-of-type(3):focus-visible) .faq-tabbar > .faq-tab:nth-child(3),
.faq-tabs:has(> .faq-tab-in:nth-of-type(4):focus-visible) .faq-tabbar > .faq-tab:nth-child(4) {
  outline: 2px solid var(--blue); outline-offset: -2px; border-radius: var(--r-sm);
}
/* The selected tab: brand ink and the rule under it, never colour alone. */
.faq-tabs:has(> .faq-tab-in:nth-of-type(1):checked) .faq-tabbar > .faq-tab:nth-child(1),
.faq-tabs:has(> .faq-tab-in:nth-of-type(2):checked) .faq-tabbar > .faq-tab:nth-child(2),
.faq-tabs:has(> .faq-tab-in:nth-of-type(3):checked) .faq-tabbar > .faq-tab:nth-child(3),
.faq-tabs:has(> .faq-tab-in:nth-of-type(4):checked) .faq-tabbar > .faq-tab:nth-child(4) {
  color: var(--brand-on-white); border-bottom-color: var(--brand);
}
:root[data-theme="dark"] .faq-tabs .faq-tabbar > .faq-tab { color: var(--muted); }
:root[data-theme="dark"] .faq-tabs:has(> .faq-tab-in:nth-of-type(1):checked) .faq-tabbar > .faq-tab:nth-child(1),
:root[data-theme="dark"] .faq-tabs:has(> .faq-tab-in:nth-of-type(2):checked) .faq-tabbar > .faq-tab:nth-child(2),
:root[data-theme="dark"] .faq-tabs:has(> .faq-tab-in:nth-of-type(3):checked) .faq-tabbar > .faq-tab:nth-child(3),
:root[data-theme="dark"] .faq-tabs:has(> .faq-tab-in:nth-of-type(4):checked) .faq-tabbar > .faq-tab:nth-child(4) { color: var(--brand); }

.faq-panel { display: none; }
.faq-tabs:has(> .faq-tab-in:nth-of-type(1):checked) .faq-panel[data-p="0"],
.faq-tabs:has(> .faq-tab-in:nth-of-type(2):checked) .faq-panel[data-p="1"],
.faq-tabs:has(> .faq-tab-in:nth-of-type(3):checked) .faq-panel[data-p="2"],
.faq-tabs:has(> .faq-tab-in:nth-of-type(4):checked) .faq-panel[data-p="3"] { display: grid; }

.lp-faq .faq-panel {
  grid-template-columns: minmax(0, 4fr) minmax(0, 8fr);
  gap: 1rem 3rem; align-items: start;
  max-width: 62rem; margin: 2.6rem auto 0;
}
.faq-panel-h { font-size: clamp(1.4rem, 2.4vw, 1.9rem); font-weight: 800; letter-spacing: -.02em; margin: .5rem 0 0; }
.lp-faq .faq-panel .faq-list { max-width: none; margin: 0; gap: 0; }

/* The rows. Everything the card treatment gives `.faq-item` elsewhere is undone
   here — including the hover shadow and the open border, which are set on the
   shared rule and would otherwise leak a box back in. */
.lp-faq .faq-panel .faq-item,
.lp-faq .faq-panel .faq-item:hover,
.lp-faq .faq-panel .faq-item[open] {
  border: 0; border-bottom: 1px solid var(--border); border-radius: 0;
  background: none; box-shadow: none;
}
.lp-faq .faq-panel .faq-item summary { padding: 1.3rem 0; font-size: 1.1rem; font-weight: 700; gap: 1.5rem; }
.lp-faq .faq-panel .faq-item summary:focus-visible { outline-offset: 2px; border-radius: var(--r-sm); }
.lp-faq .faq-panel .faq-body { padding: 0 2.5rem 1.3rem 0; }
.lp-faq .faq-panel .faq-body p { font-size: .95rem; }

/* Chevron becomes a plus that loses its upright on open — the same "state is not
   rotation alone" rule as the caret it replaces, read as + / -. */
.lp-faq .faq-panel .faq-chev,
.lp-faq .faq-panel .faq-item:hover .faq-chev,
.lp-faq .faq-panel .faq-item[open] .faq-chev {
  width: 18px; height: 18px; background: none; border-radius: 0;
}
.lp-faq .faq-panel .faq-chev::before {
  inset: auto 0; top: 50%; margin: 0; width: 100%; height: 2px; border: 0;
  background: var(--muted); border-radius: 1px; transform: translateY(-50%);
}
.lp-faq .faq-panel .faq-chev::after {
  content: ''; position: absolute; left: 50%; top: 0; bottom: 0; width: 2px;
  background: var(--muted); border-radius: 1px; transform: translateX(-50%);
  transition: transform .22s cubic-bezier(.2,.7,.3,1);
}
.lp-faq .faq-panel .faq-item[open] .faq-chev::before { background: var(--brand); }
.lp-faq .faq-panel .faq-item[open] .faq-chev::after { background: var(--brand); transform: translateX(-50%) scaleY(0); }

@media (max-width: 860px) {
  .lp-faq .faq-panel { grid-template-columns: 1fr; gap: .4rem; margin-top: 1.8rem; }
  .faq-panel-h { margin: 0; }
  .faq-tab { padding: .7rem .9rem; font-size: .88rem; gap: .4rem; }
}
@media (prefers-reduced-motion: reduce) {
  .faq-tab { transition: none; }
  .lp-faq .faq-panel .faq-chev::after { transition: none; }
}
.section-head { text-align: center; max-width: 640px; margin: 0 auto 2.2rem; }
.section-head .eyebrow { color: var(--brand); font-weight: 700; font-size: .82rem; letter-spacing: .08em; text-transform: uppercase; }
.section-head h2 { font-size: var(--fs-h2); margin: .5rem 0 .5rem; }
.section-head p { color: var(--muted); font-size: 1.05rem; }

/* hero — UN-BOXED (2026-09-14). It was a card (radius, border, --shadow-lg, a --surface fill)
   holding a second card (the illustration, with its own --shadow-lg), sitting over a third (the
   red stats band): three stacked boxes before the fold, and the owner read it as exactly that.
   The outer frame is gone. The hero sits straight on the page, and its text starts on the same
   rail edge as the brand mark and every section heading (side padding 0 — .view already carries
   the gutter). `overflow` is no longer hidden, because there is nothing left to clip.

   ⚠️ NO BACKGROUND ON THIS ELEMENT, and that is the rule that keeps it un-boxed. Any paint here
   is confined to the element's rectangle, so a glow that is not fully transparent at every edge
   redraws the box as a faint tinted panel — measured 2026-09-14: two "soft" radial glows put
   here brought the rectangle straight back, worst in dark mode, and bisecting the layers over
   CDP proved they were the only cause. (The trap: in `radial-gradient(55% 70% at …)` those
   percentages are RADII, not diameters, so each glow was ~1230×800px and tinted the whole box.)
   The ambient colour comes from `.lp-hero::after` in motion.css — the orbiting glow that used
   to live inside the card and now floats free — and from `.lp-visual::before` behind the art.
   Neither is a background of this element, so neither can draw its edge.

   ⚠️ The boot skeleton in index.html uses this same class and so follows automatically. The
   VERTICAL padding is unchanged on purpose: that is what fixes the heading's y for the CLS
   note above, and it must stay in step with whatever this rule says. */
.lp-hero { position: relative; display: grid; grid-template-columns: 1.05fr .95fr; gap: clamp(1.5rem, 4vw, 3rem); align-items: center;
  padding: clamp(2rem, 5vw, 4rem) 0; }
.lp-eyebrow { display: inline-flex; align-items: center; gap: .5rem; font-weight: 700; font-size: .82rem;
  padding: .4rem .8rem; border-radius: var(--r-pill); background: color-mix(in srgb, var(--brand) 12%, transparent);
  color: var(--brand); border: 1px solid color-mix(in srgb, var(--brand) 25%, transparent); margin-bottom: 1.1rem; }
.lp-title { font-size: var(--fs-display); line-height: 1.05; letter-spacing: -.03em; margin: 0 0 1rem; }
.lp-title .grad { background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; }
/* Clipped gradient text is painted by a `transparent` colour, so a selection highlight
   lands BEHIND nothing and the words vanish while selected.
   ⚠️ This is INSURANCE, not a live fix — say so plainly rather than letting the next
   reader assume it is load-bearing. `body { user-select: none }` turns selection off
   across the app, and the generated pages under web/no|en/ (which do re-enable it, on
   `.seo-main *` in seo.css) carry no `.lp-title .grad` heading today — checked, zero of
   them. So there is currently no surface where this can be seen. It earns its one line
   because the two things that would expose it are both cheap and plausible: relaxing the
   app-wide selection block, or giving a generated page the real hero heading. Costs
   nothing when no selection is active. */
.lp-title .grad::selection { background: var(--brand); color: #fff; -webkit-text-fill-color: #fff; }
.lp-sub { color: var(--muted); font-size: var(--fs-lead); max-width: 48ch; margin-bottom: 1.6rem; }
.lp-cta { display: flex; gap: .8rem; flex-wrap: wrap; }
.lp-trust { display: flex; gap: 1.2rem; flex-wrap: wrap; margin-top: 1.5rem; color: var(--muted); font-size: .9rem; font-weight: 600; }
.lp-trust span { display: inline-flex; align-items: center; gap: .4rem; }
.lp-trust svg { width: 1.05rem; height: 1.05rem; color: var(--ok); }

/* hero visual: holds the device replicas (app/showcase.js, web/showcase.css).
   The hand-drawn .phone/.pm-* mockup that used to live here was retired on
   2026-08-15 — the showcase frames replaced it, and two hand-drawn handsets in
   two stylesheets sat side by side on the same page. */
.lp-visual { position: relative; display: grid; place-items: center; min-height: 380px; }
/* The glow that replaced the illustration's frame. A plain radial-gradient with a long falloff
   — NO `filter: blur`: a gradient is already soft, and a filtered layer inside the parallaxed
   .lp-visual would be re-composited on every scroll for no gain (see the m-rise filter note in
   docs/frontend.md for the class of trouble filters invite). Brand at the core, blue at the
   rim, so it ties the scene to the page's own two accent hues rather than reading as a shadow. */
.lp-visual::before, .hero-art::before { content: ""; position: absolute; inset: -8% -12%; pointer-events: none; z-index: 0;
  background: radial-gradient(50% 50% at 50% 52%,
    color-mix(in srgb, var(--brand) 20%, transparent),
    color-mix(in srgb, var(--blue) 12%, transparent) 55%, transparent 80%); }
.lp-visual > *, .hero-art > * { position: relative; z-index: 1; }
/* hero illustration. No drop shadow any more — the glow behind it does that job — and a larger
   radius, so it reads as a rounded object in the scene rather than a framed picture. Grown 460
   -> 500px: the card padding it used to sit inside is gone, so there is room. */
.hero-scene { width: 100%; max-width: 500px; aspect-ratio: 500 / 440; height: auto; border-radius: var(--r-2xl); display: block; }

/* ---- landing hero: the frame wall (app/heroframes.js) --------------------------------
   Seven framed stills from the course and the question bank, scattered like prints dropped
   on a table. It replaced the driven car scene here on 2026-09-22 (the dashboard still runs
   that); `.hero-art .hero-scene` above is what still sizes it there.

   The box is the SAME 500x440 the scene used, so nothing around it moved — and the slots in
   heroframes.js are percentages of it, so the cluster scales with the column rather than
   reflowing. Cards are placed from their CENTRE (`translate: -50% -50%`), which is why a
   tilt cannot push one out of the box: rotation happens about that same centre.

   ⚠️ `translate` and `rotate` are separate properties here, NOT a `transform` shorthand.
   motion.css adds the parallax to the `translate` calc per card (deeper cards move less),
   and a `transform` would have to restate the centring and the tilt to do it. */
.hero-frames { position: relative; width: 100%; max-width: 520px; aspect-ratio: 500 / 440;
  margin-inline: auto;
  /* The mount — white in light mode, a lighter board than the page in dark, so a night
     photograph still reads as a framed print rather than a hole in the background. */
  --hf-mat: #fff; --hf-edge: rgba(16, 24, 40, .06);
  --hf-cast: rgba(16, 24, 40, .28); --hf-cast-near: rgba(16, 24, 40, .14); }
[data-theme="dark"] .hero-frames {
  --hf-mat: #262d38; --hf-edge: rgba(255, 255, 255, .08);
  --hf-cast: rgba(0, 0, 0, .55); --hf-cast-near: rgba(0, 0, 0, .35); }
.hf { position: absolute; left: var(--x); top: var(--y); width: var(--w); margin: 0;
  translate: -50% -50%; rotate: var(--r);
  padding: 4px; background: var(--hf-mat); border-radius: 14px;
  box-shadow: 0 0 0 1px var(--hf-edge), 0 12px 28px -10px var(--hf-cast), 0 3px 8px -3px var(--hf-cast-near); }
/* The image keeps its own 3:2 — the files are already cropped to it, so `cover` here is
   insurance against a future frame of another shape, not a crop anything relies on. */
.hf img { display: block; width: 100%; height: auto; aspect-ratio: 3 / 2; object-fit: cover;
  border-radius: 10px; }

/* Phones: the cluster is the hero's whole picture, so it keeps its shape rather than
   becoming seven stamps — the column is already full width by 820px, and .lp-visual's
   min-height (280px under 480px) is what the aspect-ratio plays against. */
@media (max-width: 480px) { .hero-frames { max-width: 380px; } }

/* ---- wide screens: the frames come in from BOTH edges instead (owner, 2026-09-22) ------
   The driving-tests.org shape. The hero copy centres, and the cards leave the rail entirely:
   each one is placed from the hero's CENTRE outward — `right: calc(50% + --fx)` on the left
   flank, `left: calc(50% + --fx)` on the right — so the set spreads as the window widens and
   the outermost simply run off the screen. Nothing new clips them: `.view:has(.lp-hero)` is
   already overflow-x: visible (see there) and `html, body { overflow-x: clip }` stops the
   page scrolling sideways at the viewport, which is the edge we want them cut at.

   1180px, not the 820px the rest of the hero uses: below that the copy and the nearest card
   fight for the same pixels, and the cluster is the better answer. So the cluster is not a
   phone fallback — it is what tablets and small laptops get, unchanged.

   ⚠️ `.lp-hero .lp-visual`, not `.lp-visual`: motion.css loads after this file and its
   `.lp-hero > * { position: relative }` would otherwise win the tie and un-absolute the layer. */
@media (min-width: 1180px) {
  .lp-hero, .hero.hero-split { grid-template-columns: 1fr; justify-items: center; text-align: center;
    min-height: 460px; }
  /* 760px keeps ~40px of air between the longest line and the nearest card's inner edge
     (--fx 540 less half of --fw 224 = 428 from centre, against the copy's 380). */
  .lp-hero > div:first-child, .hero.hero-split > div:first-child { max-width: 760px; }
  .lp-hero .lp-sub, .hero.hero-split .lead { margin-inline: auto; }
  .lp-hero .lp-cta, .lp-hero .lp-trust,
  .hero.hero-split .hero-cta, .hero.hero-split .hero-pills { justify-content: center; }
  .lp-hero .lp-visual, .hero.hero-split .hero-art { position: absolute; inset: 0; min-height: 0;
    display: block; pointer-events: none; }
  /* The little radial glow was sized to sit behind a 500px cluster; stretched over the whole
     hero it is just a wash, and `.lp-hero::after` (motion.css) is already the hero's ambience. */
  .lp-hero .lp-visual::before, .hero.hero-split .hero-art::before { display: none; }
  /* static, so the cards' containing block is .lp-visual — i.e. the hero box, which is what
     --fy is a percentage of. */
  .hero-frames { position: static; max-width: none; aspect-ratio: auto; margin: 0; }
  /* Both flanks stay on `left` and keep the shared `translate: -50%` centring — mirroring
     with `right:` would need the X translate flipped too, in this file and again in the
     parallax calc in motion.css. One sign in one place instead. */
  .hf { top: var(--fy); width: var(--fw); rotate: var(--fr); }
  .hf[data-flank="l"] { left: calc(50% - var(--fx)); }
  .hf[data-flank="r"] { left: calc(50% + var(--fx)); }
}

/* ---- hero illustration palette (app/herodrive.js draws it, this colours it) ----
   The scene's geometry is generated in JS and its colour is entirely here, for one reason: dark mode
   turns the drive into a night drive, and none of that can be expressed by regenerating markup — the
   theme toggle flips an attribute on <html> and the SPA does not re-render. So every fill in the SVG
   is a `.hd-*` class over a token, and the whole scene changes with one block.
   Night is not "the day scene, dimmed". The sun becomes a moon and the stars come out; the fields and
   tarmac go dark while the *markings* get brighter, because at night the only thing lighting them is
   the car's own beams; the sign faces and marker posts light up hardest of all, which is what
   retroreflective sheeting does; tree shadows disappear and are replaced by the pool of light the
   headlights throw; and the car's rear lamps stop being daytime amber reflectors and burn red. */
.hero-scene {
  --hd-sky0: #bfe4ff; --hd-sky1: #eaf6ff;
  --hd-orb: #ffcf4d; --hd-orb-glow-o: .22;
  --hd-star-o: 0;
  --hd-cloud: #fff; --hd-cloud-o: .95;
  --hd-far: #8fd08a; --hd-near: #6cbf6a; --hd-verge: #5cae5a;
  --hd-road: #3b4250;
  --hd-edge: #fff; --hd-edge-o: .55;
  --hd-dash: #f4c542; --hd-dash-o: .95;
  --hd-shade-o: .13; --hd-beam-o: 0;
  --hd-bark: #7a5230; --hd-leaf: #5cae5a; --hd-leaf2: #4f9e4e;
  --hd-pole: #98a2b3; --hd-face: #fff; --hd-ring: #d81f3a; --hd-ink: #1a1f28;
  --hd-refl: #fff;
  --hd-paint: #d81f3a; --hd-paint2: #b81830; --hd-glass: #bfe3ff;
  --hd-lamp: #ffd34d; --hd-glow-o: 0;
  --hd-chrome: #9aa0ab; --hd-plate: #fff;
  --hd-tyre: #2a2f38; --hd-tread: #6b7280; --hd-tread-o: .38;
  --hd-shadow: #1a2230; --hd-shadow-o: .18;
  --hd-car1: #2f6fed; --hd-car2: #e8eaee; --hd-car3: #3aa76d;
  --hd-car1d: #2559c4; --hd-car2d: #c3c8d2; --hd-car3d: #2f8b59;
}
[data-theme="dark"] .hero-scene {
  --hd-sky0: #0a1130; --hd-sky1: #1d2c56;
  --hd-orb: #dfe7fb; --hd-orb-glow-o: .12;
  --hd-star-o: .85;
  --hd-cloud: #46578a; --hd-cloud-o: .4;
  --hd-far: #1d3a2c; --hd-near: #163024; --hd-verge: #1a3529;
  --hd-road: #14181f;
  --hd-edge: #fff; --hd-edge-o: .72;
  --hd-dash: #ffd964; --hd-dash-o: 1;
  --hd-shade-o: 0; --hd-beam-o: 1;
  --hd-bark: #2b2a2f; --hd-leaf: #22402f; --hd-leaf2: #1b3527;
  --hd-pole: #5d6675; --hd-face: #f2f5fb; --hd-ring: #e8324c; --hd-ink: #12151b;
  --hd-refl: #eaf2ff;
  --hd-paint: #a8172c; --hd-paint2: #830f20; --hd-glass: #22314c;
  --hd-lamp: #ff3b30; --hd-glow-o: 1;
  --hd-chrome: #59606c; --hd-plate: #cfd6e2;
  --hd-tyre: #14171d; --hd-tread: #3a4049; --hd-tread-o: .55;
  --hd-shadow: #000; --hd-shadow-o: .34;
  --hd-car1: #1c3a70; --hd-car2: #6b7488; --hd-car3: #1f5a3e;
  --hd-car1d: #142c56; --hd-car2d: #525a6b; --hd-car3d: #17452f;
}
.hero-scene .hd-sky0 { stop-color: var(--hd-sky0); }
.hero-scene .hd-sky1 { stop-color: var(--hd-sky1); }
.hero-scene .hd-orb { fill: var(--hd-orb); }
.hero-scene .hd-orb-glow { fill: var(--hd-orb); opacity: var(--hd-orb-glow-o); }
.hero-scene .hd-stars { fill: #fff; opacity: var(--hd-star-o); }
.hero-scene .hd-clouds { fill: var(--hd-cloud); opacity: var(--hd-cloud-o); }
.hero-scene .hd-far { fill: var(--hd-far); }
.hero-scene .hd-near { fill: var(--hd-near); }
.hero-scene .hd-verge { fill: var(--hd-verge); }
.hero-scene .hd-road { fill: var(--hd-road); }
.hero-scene .hd-edge { fill: var(--hd-edge); opacity: var(--hd-edge-o); }
.hero-scene .hd-dash { fill: var(--hd-dash); opacity: var(--hd-dash-o); }
.hero-scene .hd-shade { fill: #0d1420; opacity: var(--hd-shade-o); }
.hero-scene .hd-beam { opacity: var(--hd-beam-o); }
.hero-scene .hd-bark { fill: var(--hd-bark); }
.hero-scene .hd-leaf { fill: var(--hd-leaf); }
.hero-scene .hd-leaf2 { fill: var(--hd-leaf2); }
.hero-scene .hd-pole { fill: var(--hd-pole); }
.hero-scene .hd-plate { fill: var(--hd-face); stroke: var(--hd-ring); }
.hero-scene .hd-ink { fill: var(--hd-ink); }
.hero-scene .hd-inks { fill: none; stroke: var(--hd-ink); }
.hero-scene .hd-refl { fill: var(--hd-refl); }
.hero-scene .hd-paint { fill: var(--hd-paint); }
.hero-scene .hd-paint2 { fill: var(--hd-paint2); }
.hero-scene .hd-glass { fill: var(--hd-glass); }
.hero-scene .hd-lamp { fill: var(--hd-lamp); }
.hero-scene .hd-lampglow, .hero-scene .hd-headglow { opacity: var(--hd-glow-o); }
.hero-scene .hd-head { fill: #fffbe8; }
.hero-scene .hd-chrome { fill: var(--hd-chrome); }
.hero-scene .hd-plateface { fill: var(--hd-plate); }
.hero-scene .hd-tyre { fill: var(--hd-tyre); }
.hero-scene .hd-treadbar { fill: var(--hd-tread); opacity: var(--hd-tread-o); }
/* `--b` is the only value JS writes into this palette: the suspension modulates the
   car's contact shadow, but how dark that shadow is at rest is a theme decision, so
   the two are multiplied rather than letting a `setAttribute('opacity')` win. */
.hero-scene .hd-cshadow { fill: var(--hd-shadow); opacity: calc(var(--hd-shadow-o) * var(--b, 1)); }
.hero-scene .hd-car1 { fill: var(--hd-car1); }
.hero-scene .hd-car2 { fill: var(--hd-car2); }
.hero-scene .hd-car3 { fill: var(--hd-car3); }
/* the oncoming car's roof is its body colour, a shade darker */
.hero-scene .hd-car1.dk { fill: var(--hd-car1d); }
.hero-scene .hd-car2.dk { fill: var(--hd-car2d); }
.hero-scene .hd-car3.dk { fill: var(--hd-car3d); }

/* dashboard two-column hero (signed-in). This block is the only definition of .hero-split/.hero-art —
   there used to be a second, older one up beside .topbar whose `.hero-art svg { width: min(100%,
   360px) }` was quietly deciding the size: inside `place-items: center` the item is sized from its own
   max-content, so a min() holding a percentage is indefinite there and the SVG fell back to a replaced
   element's default 300x150 (ratio-corrected: 300x264). That is why the signed-in illustration
   measured 300px against the landing page's 460 — the 380 cap below had never been the binding
   constraint. Plain `width: 100%` (from .hero-scene) resolves, which is what .lp-visual has used. */
.hero-split { display: grid; grid-template-columns: 1.08fr .92fr; gap: clamp(1.2rem, 4vw, 2.5rem); align-items: center; }
.hero-split > div:first-child { min-width: 0; }
.hero-art { position: relative; display: grid; place-items: center; }
.hero-art .hero-scene { width: 100%; max-width: 380px; }
@media (max-width: 720px) { .hero-split { grid-template-columns: 1fr; } }
/* Signing in must not shrink the front page. The width was one half of that (see
   --page-w); this is the other: the dashboard hero was a visibly smaller card than
   the .lp-hero it replaces — less padding, a tighter radius and a 380px illustration
   against 460px — so the same screen "lost" ~120px of card the moment you logged in.
   Matched to .lp-hero's figures above.
   Desktop only, deliberately: the phone paddings live in max-width blocks EARLIER in
   this sheet (.hero is 1.35rem/1.1rem under 560px), and an unconditional rule here
   would sit later in the file and silently win on phones. */
@media (min-width: 721px) {
  .hero.hero-split {
    padding: clamp(2rem, 5vw, 4rem) 0;
    gap: clamp(1.5rem, 4vw, 3rem);
  }
  /* .hero-scene was the driven car (app/herodrive.js, archived 2026-09-22); the frame wall
     replaced it and takes the same cap, so signing in still does not shrink the picture. */
  .hero-split .hero-art .hero-frames { max-width: 520px; }
}

/* app preview */
.preview { display: grid; grid-template-columns: .9fr 1.1fr; gap: clamp(1.5rem, 4vw, 3rem); align-items: center; }
.preview-visual { display: grid; place-items: center; }
/* "Et innblikk" carries the ten-second trailer (app/trailer.js) in the slot the
   laptop replica takes in every other device cluster, with showcase.js's real
   handset overlapping it. The visual column grows from .9fr to 1.15fr because the
   trailer swaps to its portrait layout below 420px of its own width, and at .9fr
   on the 1160 rail it landed at 385 — a portrait card where a landscape one
   belongs. The film takes the laptop's 82%, so `.dev-cluster--wide`'s phone rule
   (right: 0, width: 21%) still hangs the handset off its corner unchanged. */
.preview--film { grid-template-columns: 1.15fr .85fr; }
.preview--film .preview-visual { place-items: stretch; }
.preview-cluster .preview-film { width: 86%; }
/* Stacked, the heading goes ABOVE the picture. The rule at the bottom of this
   block pulls `.preview-visual` first on every `.preview`, which was right when the
   visual was a single still handset — the picture WAS the claim. It is now a
   ten-second loop, and a reader who meets it before "Slik ser det ut" is watching
   an unlabelled animation. Both orders are set here, because otherwise the two
   `order: -1` declarations tie and DOM order puts the visual back on top. */
@media (max-width: 900px) {
  .preview--film { grid-template-columns: 1fr; }
  .preview--film .preview-copy { order: -1; }
  .preview--film .preview-visual { order: 0; }
}
.preview-copy .eyebrow { color: var(--brand); font-weight: 700; font-size: .82rem; letter-spacing: .08em; text-transform: uppercase; }
.preview-copy h2 { font-size: var(--fs-h2); margin: .4rem 0 .6rem; }
.preview-copy .price-list { margin: 1rem 0 1.4rem; }
@media (max-width: 720px) { .preview { grid-template-columns: 1fr; } .preview-visual { order: -1; } }

/* stats band. --grad-ui, not --grad: .band-num is large enough for the decorative
   ramp but .band-label is 12.5px, and measured 3.39:1 on the coral end (2026-08-20).
   The label's old `opacity: .92` cost contrast on top of that for a hierarchy the
   size and weight difference already carries, so it is gone rather than dimmed. */
/* KPI ROW, not a card (2026-09-14). This was the third stacked box under the fold — a red
   gradient slab with --shadow-lg — and with the hero un-boxed it was the loudest thing left. Now
   four figures on the page itself, ruled top and bottom, each with a short brand bar above the
   number: the classic stats-row shape, and the bar is the accent instead of a red background.
   Page ink, so the old white-on-coral contrast note is moot; the count-up (data-count) is
   untouched. */
.band { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; padding: 1.6rem 0;
  /* Text-tinted rules, not --border: the hairline token is a card edge and all but vanished on the
     light page (owner: same ghosting as the ghost button). Tracks both themes. */
  border-block: 1px solid color-mix(in srgb, var(--text) 18%, transparent); color: var(--text); }
.band-item { text-align: center; position: relative; padding-top: .9rem; }
.band-item::before { content: ""; position: absolute; top: 0; left: 50%; translate: -50% 0; width: 2.2rem; height: 3px;
  border-radius: var(--r-pill); background: var(--grad); }
.band-num { font-size: clamp(1.9rem, 3.8vw, 2.7rem); font-weight: 800; line-height: 1; letter-spacing: -.02em; }
.band-label { font-size: .85rem; margin-top: .35rem; font-weight: 600; color: var(--muted); }

/* Stand-in for a scene whose library has not arrived yet (see upgradeLandingArt in
   app/main.js). Carries the scenes' own 400x180 aspect, so the real drawing replaces
   it without moving anything below. Lives here, not in scenes.css, because that
   stylesheet is loaded *with* the library it is standing in for. */
.sc-skel { aspect-ratio: 400 / 180; border-radius: var(--r-lg); background: var(--surface-2); border: 1px solid var(--border); }

/* features */
.feature-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1.1rem; }
.feature { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.5rem; box-shadow: var(--shadow);
  transition: transform .18s ease, box-shadow .2s ease; }
.feature:hover { transform: translateY(-4px); box-shadow: var(--shadow-lg); }
.feature .ficon { width: 3.1rem; height: 3.1rem; border-radius: var(--r-md); display: grid; place-items: center; color: #fff;
  background: var(--accent, var(--brand)); margin-bottom: 1rem; }
.feature .ficon svg { width: 1.6rem; height: 1.6rem; }
.feature h3 { font-size: 1.12rem; margin-bottom: .35rem; }
.feature p { color: var(--muted); margin: 0; font-size: .93rem; }

/* how it works */
.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.2rem; counter-reset: step; }
.step { position: relative; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.5rem 1.4rem 1.4rem; box-shadow: var(--shadow); }
/* --grad-ui: the numeral is 14.4px, well under the decorative ramp's >=18.66px-bold
   floor, and measured 3.89:1 on it (2026-08-20). */
.step-num { width: 2.4rem; height: 2.4rem; border-radius: 50%; display: grid; place-items: center; font-weight: 800; color: #fff; background: var(--grad-ui); margin-bottom: .9rem; box-shadow: 0 6px 16px color-mix(in srgb, var(--brand) 35%, transparent); }
.step h3 { font-size: 1.08rem; margin-bottom: .3rem; }
.step p { color: var(--muted); margin: 0; font-size: .93rem; }

/* how it works, as a road (app/roadmap.js).
   The cards keep their original 1-2-3 row; the rail is a second grid row under them,
   so the road runs left to right beneath the steps and a numbered pin sits under each
   card. Nothing is positioned on top of a card and the row heights are untouched, so
   the section contributes no layout shift.
   Road colours are the hero's own values rather than aliases of --hd-*, which are
   scoped to .hero-scene — the two roads must match, and the duplication is four hexes
   against reaching into another component's private tokens. */
.rm { --rm-p: 1; display: grid; gap: 1rem; }
.rm-steps { grid-template-columns: repeat(3, 1fr); }
.rm-rail { position: relative; height: 5.5rem; }
.rm-rail-svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }

/* A softer green than the hero's #5cae5a: there it is a 3.5m-wide field beside the
   carriageway, here it is a 3px outline against the page, and full saturation read as
   a highlighter pen rather than a verge. */
.rm-verge { fill: none; stroke: #a3cea0; stroke-width: 30; stroke-linecap: round; }
.rm-road  { fill: none; stroke: #3b4250; stroke-width: 24; stroke-linecap: round; }
/* stroke-dasharray is set as an attribute by roadmap.js, in px like everything else. */
.rm-dash  { fill: none; stroke: #f4c542; stroke-width: 2; opacity: .95; }
.rm-stopline { stroke: #fff; stroke-width: 3.5; opacity: .8; }
/* The travelled overlay: one dash as long as the whole road, pushed back by the untravelled part.
   --rm-len is written by roadmap.js in px, from the same getTotalLength() the car's position is
   sampled against, so the head of this line and the centre of the car are the same point at every
   --rm-p. When --rm-len is absent (no JS) the dasharray is invalid and the line paints solid, which is
   the right degraded state: a fully travelled road.
   --blue, not --brand: the car is the hero's red (#d81f3a) and a red route line swallowed it whole.
   Blue is already in this scene's palette — it is the colour the hero paints its oncoming cars
   (--hd-car1) — and a blue route over grey tarmac is a convention every reader already knows. */
.rm-lit { fill: none; stroke: var(--blue); stroke-width: 8; stroke-linecap: round;
  stroke-dasharray: var(--rm-len);
  stroke-dashoffset: calc(var(--rm-len, 0px) * (1 - var(--rm-p))); }

/* Car and pins are placed in px by roadmap.js. The car's transform carries its own
   translate(-50%,-50%); the pins get theirs here, since only their left/top move. */
/* z-index above the pins: the car's path runs straight through every waypoint, and
   at p=frac the pin was painting over it. A satnav puck driving over its next
   waypoint is the right read; the pin vanishing under it is not. */
.rm-car { position: absolute; left: 0; top: 0; width: 2.1rem; line-height: 0; z-index: 3; will-change: transform; }
.rm-car svg { display: block; width: 100%; height: auto; overflow: visible;
  filter: drop-shadow(0 2px 3px color-mix(in srgb, #1a2230 34%, transparent)); }
.rm-car-body { fill: #d81f3a; }
.rm-car-glass { fill: #bfe3ff; }
.rm-car-roof { fill: #b81830; }

.rm-pin { position: absolute; transform: translate(-50%, -50%); z-index: 1;
  width: 1.45rem; height: 1.45rem; border-radius: 50%; display: grid; place-items: center;
  font-size: .72rem; font-weight: 800; font-variant-numeric: tabular-nums;
  color: #fff; background: var(--grad-ui); box-shadow: 0 0 0 2px var(--surface);
  transition: filter .4s ease, box-shadow .4s ease; }

/* Reached is the default so a boot failure leaves three finished stops. Only a live
   motion runtime takes them back, and it never touches text opacity.
   The markers desaturate rather than swapping background: --grad-ui is a gradient, so
   background-image cannot transition, and grayscale() both animates and holds
   luminance — the numeral stays at the 3.89:1 it was measured at either way. The card
   itself inherits its transition from `.step` in motion.css. */
.has-motion .rm-stop .step-num { transition: filter .4s ease, box-shadow .4s ease, transform .25s cubic-bezier(.2,1.5,.4,1); }
.has-motion .rm-stop:not(.is-past) .step-num,
.has-motion .rm-pin:not(.is-past) { filter: grayscale(1); box-shadow: 0 0 0 2px var(--surface); }
.has-motion .rm-stop.is-past { border-color: color-mix(in srgb, var(--brand) 38%, var(--border)); }

[data-theme="dark"] .rm-verge { stroke: #22402f; }
[data-theme="dark"] .rm-road { stroke: #14181f; }
[data-theme="dark"] .rm-dash { stroke: #ffd964; opacity: 1; }
[data-theme="dark"] .rm-car-body { fill: #a8172c; }
[data-theme="dark"] .rm-car-glass { fill: #22314c; }
[data-theme="dark"] .rm-car-roof { fill: #830f20; }

/* sign showcase */
/* No box. The two marquee rows are already a bounded shape — they fade out at
   both ends through the mask in motion.css — so the panel around them was a
   second frame drawn around a thing that already had edges. `position: relative`
   stays: motion.css hangs the rows' entrance off `.showcase.in`. */
.showcase { position: relative; }
.showcase-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(72px, 1fr)); gap: .7rem; }
.show-sign { aspect-ratio: 1; background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md); display: grid; place-items: center; padding: .55rem; box-shadow: var(--shadow); transition: transform .16s ease; }
.show-sign:hover { transform: scale(1.08); }
.show-sign img, .show-sign svg { width: 100%; height: 100%; object-fit: contain; }

/* pricing */
/* Flow-field backdrop (tools/build-field.mjs, technique from ThreeUI's spark-badge).
   The section is the highest-intent surface on the site and had a plain background.
   `background-size: cover` over a 1200x420 texture, with the edge falloff already
   baked into the paths (see the generator) so there is no compositing trick to
   degrade. Static: no canvas, no rAF, no layout, nothing to pause off-screen. */
.pricing { position: relative; }
.pricing > * { position: relative; z-index: 1; }
.pricing::before {
  content: ""; position: absolute; inset: -1.5rem -1rem; z-index: 0; pointer-events: none;
  background: url("./field-flow.svg") center / cover no-repeat;
  opacity: .16;
}
/* The asset is dark ink on transparent, so dark mode inverts it rather than shipping a
   second file. See the note in tools/build-field.mjs for why this is not a CSS mask. */
[data-theme="dark"] .pricing::before { filter: invert(1); opacity: .22; }

.pricing-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1.2rem; align-items: stretch; max-width: 900px; margin: 0 auto; }
.price-card { position: relative; display: flex; flex-direction: column; background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 1.3rem 1.2rem; box-shadow: var(--shadow); transition: transform .18s ease, box-shadow .2s ease; }
.price-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-lg); }
.price-card.featured { border-color: color-mix(in srgb, var(--brand) 55%, var(--border)); box-shadow: 0 2px 8px rgba(0,0,0,.06), 0 22px 50px color-mix(in srgb, var(--brand) 22%, transparent); }
/* A pill with 10.9px white ink: exactly the case --grad-ui exists for. It measured
   3.41:1 on --grad (2026-08-20) — the worst reading in the app, on the card the whole
   pricing section is built to sell. */
.price-flag { position: absolute; top: -.75rem; left: 50%; transform: translateX(-50%); background: var(--grad-ui); color: #fff; font-size: .68rem; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; padding: .25rem .8rem; border-radius: var(--r-pill); white-space: nowrap; }
.price-name { font-weight: 700; color: var(--muted); }
.price-amt { font-size: 2.15rem; font-weight: 800; letter-spacing: -.03em; margin: .25rem 0 0; }
.price-amt small { font-size: .95rem; font-weight: 600; color: var(--muted); }
.price-list.pad-t { margin-top: 1rem; }
.price-note { color: var(--muted); font-size: .85rem; margin-bottom: .8rem; }
.price-list { list-style: none; padding: 0; margin: 0 0 1.4rem; display: grid; gap: .55rem; }
.price-list li { display: flex; gap: .5rem; align-items: flex-start; font-size: .92rem; }
/* A price card carries eight feature lines and has to sit beside two others in one
   row, so its copy of the list runs tighter than the same list does on a gate screen
   or under the hero: smaller type, less leading, less air between lines and less
   padding around them. Nothing is dropped — the card is ~19% shorter for it. */
.price-card .price-list { gap: .34rem; margin-bottom: 1rem; }
.price-card .price-list li { font-size: .855rem; line-height: 1.32; gap: .45rem; }
.price-card .price-list svg { width: 1rem; height: 1rem; margin-top: .12rem; }
.price-list svg { width: 1.1rem; height: 1.1rem; flex: none; color: var(--ok); margin-top: .15rem; }
.price-card .btn { margin-top: auto; }

/* Three tiers side by side: free / 1 week / 1 month. Wider than the old two-card
   grid so five to six feature lines per card do not wrap into a wall. */
.pricing-grid { max-width: 1040px; grid-template-columns: repeat(auto-fit, minmax(270px, 1fr)); padding-top: .9rem; }
.price-dur { font-size: .8rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--brand); }
.price-amt { display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap; }
/* No struck-through "before" price and no "save %" pill here on purpose: under
   prisopplysningsforskriften § 9a a reference price has to be one actually charged in the preceding
   30 days, so it cannot be reintroduced as pure styling.
   `.price-camp` is the lawful alternative — a launch price, which advertises the FUTURE price rather
   than a past one. Amber, not brand-red and not `--ok` green: red is already the featured flag right
   above it, and green is Pass Protection's signal directly below. Amber is unused on this card and
   reads as "clock is running". The tint is a color-mix of --amber so one rule covers light and dark;
   the TEXT stays on --text/--muted, because amber on an amber wash fails contrast at this size. */
.price-camp {
  display: flex; align-items: flex-start; gap: .5rem;
  margin: 0 0 .8rem; padding: .5rem .6rem;
  background: color-mix(in srgb, var(--amber) 12%, transparent);
  border: 1px solid color-mix(in srgb, var(--amber) 38%, transparent);
  border-radius: var(--r-sm);
}
.price-camp-ico { flex: 0 0 auto; color: var(--amber); display: flex; }
.price-camp-ico svg { width: 1rem; height: 1rem; margin-top: .1rem; }
.price-camp-txt { display: flex; flex-direction: column; line-height: 1.3; min-width: 0; }
.price-camp-txt b { font-size: .8rem; font-weight: 800; color: var(--text); letter-spacing: .01em; }
.price-camp-txt span { font-size: .76rem; color: var(--muted); }
.price-foot { text-align: center; color: var(--muted); font-size: .85rem; margin: 1.2rem 0 0; }
/* 13.6px text sat in a 17px box. inline-flex here rather than padding because the link
   is a single word at the end of the line, so it can never need to wrap. */
.price-foot a { display: inline-flex; align-items: center; min-height: 24px; }
/* Pass Protection label — only on a plan whose data/plans.json entry carries the
   flag. A link, not a pill: it is a money-back claim, so the conditions have to be
   one tap away. Green because it is the "you get more than you paid for" signal on
   the card. The state is never colour alone — it says what it is in words. */
.price-pp {
  display: flex; gap: .55rem; align-items: center; text-decoration: none;
  color: var(--ok-ink, var(--ok)); background: var(--ok-bg);
  border: 1px solid color-mix(in srgb, var(--ok) 28%, transparent);
  border-radius: var(--r-md); padding: .4rem .6rem; margin: 0 0 .8rem;
  min-height: 40px;
}
.price-pp:hover { border-color: color-mix(in srgb, var(--ok) 55%, transparent); }
.price-pp-ico svg { width: 1.15rem; height: 1.15rem; display: block; }
.price-pp-txt { display: grid; gap: .1rem; text-align: left; line-height: 1.3; }
.price-pp-txt b { font-size: .84rem; font-weight: 800; letter-spacing: .01em; }
.price-pp-txt span { font-size: .78rem; color: var(--muted); }
/* The same claim as a rubber stamp, used on the price card only (the checkout summary keeps
   `.price-pp`). Ink on paper, so there is no fill: a filled pill reads as another badge in a card that
   already carries "Mest populær", a red hang-tag and an amber campaign box.
   Two lines exactly — wordmark over guarantee, divided by a rule. Set as free-standing text it read as
   a rounded button with a caption and the promise broke out through the inner ring; wrapping the
   promise instead cost a third line and made the stamp taller than the campaign box. The line count is
   therefore fixed, and the TYPE is what gives way: the guarantee is `nowrap` and sized so it clears
   the rings on the narrowest card the grid allows. Rotation stays under 2deg — a jaunty angle is the
   difference between a seal and a sticker.
   Constraints, none cosmetic:
   - `.price-card.featured` sets `overflow: hidden`, and the featured card is exactly the plan that
     carries the flag, so the stamp cannot hang off a corner. It stays in flow, `inline-flex` and
     centred, and at under 2deg its corners stay inside the card's 1.2rem side padding even at the
     270px grid minimum.
   - Both lines are solid --ok-ink, never alpha'd: `ui-audit.mjs` does not model foreground alpha (see
     PROGRESS open work #3), so translucent ink would measure as passing while being thinner than it
     reports. The rubber look is carried by the rings and the rule, where contrast does not matter.
   - Uppercase is styling only, so a screen reader still reads the sentence, and the ~62px box keeps it
     well above the 24px WCAG 2.5.8 target floor. */
.price-stamp {
  position: relative; align-self: center; display: inline-flex; flex-direction: column;
  align-items: center; gap: 0; text-align: center; text-decoration: none; max-width: 100%;
  color: var(--ok-ink, var(--ok));
  border: 1.5px solid color-mix(in srgb, var(--ok) 52%, transparent);
  border-radius: 4px; padding: .34rem .82rem .38rem; margin: .15rem 0 .95rem;
  transform: rotate(-1.6deg);
  transition: border-color .18s ease;
}
/* The second ring, with a real gap to the first — one border at any weight is a button,
   two with air between them is a stamp. */
.price-stamp::before {
  content: ""; position: absolute; inset: 3px; border-radius: 2px;
  border: 1px solid color-mix(in srgb, var(--ok) 24%, transparent); pointer-events: none;
}
.price-stamp:hover { border-color: color-mix(in srgb, var(--ok) 76%, transparent); }
.price-stamp-top { display: inline-flex; align-items: center; gap: .32rem; }
.price-stamp-ico svg { width: .85rem; height: .85rem; display: block; }
.price-stamp b { font-size: .715rem; font-weight: 800; letter-spacing: .155em; text-transform: uppercase; line-height: 1; }
/* Two lines total: the wordmark, then the guarantee on ONE line — so this element sets the stamp's
   width, and it is sized against the NARROW case. `.pricing-grid` is `minmax(270px, 1fr)`, so the card
   can be 270px, leaving ~230px of content; at .6rem with .015em tracking the 32-character Norwegian
   line measures ~195px and clears the rings inside that. `nowrap` is deliberate — allowing a wrap here
   is what produced the three-line block, and a stamp that changes height with the viewport reads as a
   layout bug.
   ⚠️ Any longer translation of `pp.badgeSub` has to be measured at a 270px card before it ships: the
   type shrinks to fit the frame, the frame does not grow to fit the type. */
.price-stamp-sub {
  position: relative; margin-top: .38rem; white-space: nowrap;
  font-size: .6rem; font-weight: 700; letter-spacing: .015em; text-transform: uppercase; line-height: 1.2;
}
.price-stamp-sub::before {
  content: ""; position: absolute; left: -.34rem; right: -.34rem; top: -.19rem;
  border-top: 1px solid color-mix(in srgb, var(--ok) 26%, transparent);
}
/* The tier you are already on: reads as a state, not as something to click. */
.btn.is-current { display: inline-flex; align-items: center; justify-content: center; gap: .4rem; color: var(--ok-ink, var(--ok)); background: var(--ok-bg); border-color: transparent; cursor: default; box-shadow: none; }
.btn.is-current svg { width: 1.05rem; height: 1.05rem; }
.gate-list { max-width: 30rem; margin-inline: auto; text-align: left; }
/* Sign-in / paywall screens that carry a pricing table below the headline card.
   `minmax(0, 1fr)` is load-bearing, exactly as on `.lp`: an auto-sized grid column is
   max-content, and the sign marquee's track is ~2 900px wide — without this the whole
   column (pricing table included) stretched to that width and the price cards were laid
   out off-screen to the right. */
.paywrap { display: grid; grid-template-columns: minmax(0, 1fr); gap: clamp(2rem, 5vw, 3.5rem); }
/* The headline card is the only width-capped child (`.authcard.wide`, 640px); the pricing
   table and the upsell sections below it fill the column. A grid item narrower than its
   column sits at the *start* of it, so the card hugged the left edge while its own text
   looked centred — `.authcard.center` centres the text, not the box. `.authwrap` gets
   this for free from `justify-content: center`; a grid has to say it. */
.paywrap > .authcard { margin-inline: auto; }
/* The upsell sections appended to the signed-in dashboard: a rule and a little air
   separate them from the reader's own progress, so the pitch cannot be mistaken for
   part of the dashboard. */
.lp-upsell { margin-top: clamp(2.5rem, 6vw, 4rem); padding-top: clamp(2rem, 5vw, 3rem); border-top: 1px solid var(--border); }

/* checkout (#/purchase/<plan>) — its own page, reached from a price card.
   Two columns: the action on the left, what is being bought on the right. On a phone
   the summary is lifted ABOVE the action (`order: -1`) rather than left underneath it:
   a stacked checkout that opens on "choose how to pay" asks for a decision before it
   has said what the decision is about, and the price is the thing they came to check. */
.ckwrap { max-width: 940px; margin: 0 auto; }
.ck-back { display: inline-flex; align-items: center; min-height: 24px; gap: .4rem; color: var(--muted); font-size: .88rem; font-weight: 600; text-decoration: none; }
/* The arrow glyph points right; this is the only place it means "back". */
.ck-back svg { width: 1rem; height: 1rem; transform: rotate(180deg); }
.ck-back:hover { color: var(--text); }
.ck-title { margin: .6rem 0 0; font-size: clamp(1.5rem, 4vw, 2rem); letter-spacing: -.02em; }
/* "this extends your pass" — stated on the checkout page before paying, because a
   buyer with a pass already running needs to know the days are added to the end and
   not paid for twice. Informational, so it uses the ok/token pair rather than amber. */
.ck-extend { display: flex; gap: .5rem; align-items: flex-start; margin: 0 0 1rem;
  padding: .7rem .85rem; border-radius: var(--r-md); font-size: .9rem;
  background: var(--ok-bg); color: var(--ok-ink); border: 1px solid color-mix(in srgb, var(--ok) 30%, transparent); }
.ck-extend svg { width: 1rem; height: 1rem; flex: none; margin-top: .15rem; }

.ck-steps { list-style: none; display: flex; flex-wrap: wrap; gap: 1.4rem; padding: 0; margin: .9rem 0 1.3rem; }
.ck-steps li { display: flex; align-items: center; gap: .5rem; color: var(--muted); font-size: .9rem; font-weight: 600; }
.ck-steps li.is-now, .ck-steps li.is-done { color: var(--text); }
.ck-step-n { display: grid; place-items: center; width: 1.65rem; height: 1.65rem; border-radius: var(--r-pill); border: 1px solid var(--border); font-size: .8rem; flex: none; }
.ck-steps li.is-now .ck-step-n { background: var(--grad-ui); border-color: transparent; color: #fff; }   /* 12.8px ink; 3.97:1 on --grad */
/* Tint + dark ink, never #fff on --ok: dark theme's --ok is a light mint (#4ade80),
   so white-on-green inverts into unreadable there. --ok-bg/--ok-ink are the pair that
   is legible in both themes. */
.ck-steps li.is-done .ck-step-n { background: var(--ok-bg); border-color: transparent; color: var(--ok-ink); }
.ck-step-n svg { width: .95rem; height: .95rem; }
.ck-grid { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr); gap: 1.2rem; align-items: start; }
.ck-main, .ck-sum { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-lg); box-shadow: var(--shadow); padding: 1.4rem 1.3rem; }
.ck-h { margin: 0; font-size: 1.1rem; }
.ck-lead { color: var(--muted); font-size: .92rem; margin: .35rem 0 1.1rem; }
.ck-main .authform { margin-top: 0; }

/* order summary */
.ck-item { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; }
.ck-item-n { font-weight: 700; }
.ck-item-d { color: var(--muted); font-size: .85rem; }
.ck-item-p { font-weight: 800; font-size: 1.15rem; white-space: nowrap; }
.ck-sum .price-list { margin: 1rem 0; gap: .34rem; }
.ck-sum .price-list li { font-size: .855rem; line-height: 1.32; gap: .45rem; }
.ck-sum .price-list svg { width: 1rem; height: 1rem; margin-top: .12rem; }
.ck-total { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; padding-top: .9rem; border-top: 1px solid var(--border); font-weight: 700; }
.ck-total strong { font-size: 1.35rem; letter-spacing: -.02em; }
/* Block, not flex: as a flex row the trailing "· <terms link>" became a second flex
   item and was pushed onto its own line, leaving the separator dangling at the end of
   the sentence. The icon is aligned optically instead. */
.ck-fine { color: var(--muted); font-size: .8rem; line-height: 1.45; margin: .85rem 0 0; }
.ck-fine svg { width: .95rem; height: .95rem; vertical-align: -.15em; margin-right: .2rem; }

/* payment methods: a radio group that looks like cards. The input stays in the DOM
   (hidden, not removed) so the group keeps real radio semantics, arrow-key movement
   and focus for free — a div with aria-checked would have to reimplement all three. */
.ck-methods { display: grid; gap: .6rem; margin-bottom: 1.1rem; }
.ck-m { position: relative; display: flex; align-items: center; gap: .8rem; padding: .85rem .95rem; border: 1px solid var(--border); border-radius: var(--r-md); cursor: pointer; transition: border-color .15s ease, background .15s ease; }
.ck-m:hover { border-color: color-mix(in srgb, var(--brand) 45%, var(--border)); }
.ck-m input { position: absolute; opacity: 0; width: 0; height: 0; }
.ck-m:has(input:checked) { border-color: var(--brand); background: color-mix(in srgb, var(--brand) 7%, transparent); }
.ck-m:has(input:focus-visible) { outline: 2px solid var(--brand); outline-offset: 2px; }
.ck-m-t { display: grid; gap: .3rem; justify-items: start; min-width: 0; }
.ck-m-t small { color: var(--muted); font-size: .82rem; }

/* Brand marks. Each carries the provider's own colour, so these are the one place in
   the stylesheet with hard-coded hex instead of a token — a theme variable would be
   wrong by definition: Vipps orange is Vipps orange in dark mode too. Both are fixed
   dark grounds with light ink, so neither needs a dark-theme override. */
.ck-mark { display: inline-flex; align-items: center; gap: .1rem; height: 1.65rem; padding: 0 .6rem; border-radius: var(--r-sm); font-weight: 800; letter-spacing: -.01em; line-height: 1; }
/* Vipps orange, from their brand guidelines. The mark is the lowercase wordmark. */
.ck-mark.is-vipps { background: #ff5b24; padding: 0 .55rem; }
/* Apple Pay is specified on black or white only; black reads on both our themes. */
.ck-mark.is-apple { background: #000; color: #fff; font-size: .98rem; padding-left: .45rem; }
.ck-mark.is-apple svg { width: 1.05rem; height: 1.05rem; margin-top: -.12em; }
/* Google Pay: white pill with a dark label, which is the pairing Google's brand
   guidelines specify for a light surface, and the four-colour G is legible on it in both
   themes. Fixed #fff/#3c4043 rather than theme tokens on purpose — the mark travels with
   its own colours, exactly like the Vipps orange and the Apple black above. A 1px border
   keeps the white pill from dissolving into a light background. */
.ck-terms { opacity: .8; }
.ck-terms a { text-decoration: underline; }
/* "Glemt passord?" — a quiet, small link in the conventional spot under the password
   field. A <button> rather than an <a> because it opens a dialog instead of navigating:
   an anchor with no href is not keyboard-focusable, and one with href="#" would push a
   junk history entry. .linkbtn strips the button chrome back to link styling. */
.authforgot { margin: -.35rem 0 .1rem; text-align: right; }
.linkbtn {
  background: none; border: 0; padding: 0; font: inherit; font-size: .85rem;
  color: var(--muted); text-decoration: underline; text-underline-offset: 2px; cursor: pointer;
}
.linkbtn:hover { color: var(--brand-on-white); }
.linkbtn:focus-visible { outline: 2px solid var(--brand); outline-offset: 3px; border-radius: 3px; }

/* The forgot-password dialog. A native <dialog>, so the backdrop, the focus trap, Escape
   and the inert background all come from the platform — this only paints it. */
.pwdlg {
  border: 1px solid var(--border); border-radius: var(--r-lg); padding: 0;
  background: var(--surface); color: var(--text);
  width: min(28rem, calc(100vw - 2rem)); box-shadow: 0 18px 50px rgba(0,0,0,.22);
}
.pwdlg::backdrop { background: rgba(12,16,22,.55); }
.pwdlg form { padding: 1.4rem 1.5rem 1.5rem; margin: 0; }
.pwdlg h2 { margin: 0 0 .3rem; font-size: 1.15rem; }
.pwdlg .lead { margin: 0 0 1rem; font-size: .9rem; color: var(--muted); }
.pwdlg-row { display: flex; gap: .6rem; justify-content: flex-end; margin-top: 1.1rem; }
.pwdlg-row .btn { flex: 0 0 auto; }
@media (max-width: 420px) {
  /* Stack, and put the primary action first under the thumb. */
  .pwdlg-row { flex-direction: column-reverse; }
  .pwdlg-row .btn { width: 100%; }
}

.ck-mark.is-gpay { background: #fff; color: #3c4043; font-size: .98rem; padding-left: .45rem; box-shadow: inset 0 0 0 1px rgba(60, 64, 67, .24); }
.ck-mark.is-gpay svg { width: 1.05rem; height: 1.05rem; margin-top: -.06em; }
.ck-mark.is-card { background: var(--surface-2); color: var(--text); gap: .35rem; font-size: .95rem; }
/* Vipps-branded buttons (login, and the pay CTA while Vipps is the chosen method).
   Solid Vipps orange with a white label and white wordmark — the shape of the official
   vipps_continue_*.svg / vipps_pay_*.svg assets, which their brand guidelines require.
   Hard-coded hex for the same reason as the marks above: Vipps orange is Vipps orange in
   dark mode too, so a theme token would be wrong by definition. */
.btn.vippsbtn {
  gap: .45rem; background: #ff5b24; border-color: transparent; color: #fff;
  /* The pay CTA also carries .btn.primary, whose glow is mixed from --brand (red) and
     reads muddy under orange. Same specificity, so this only wins by sitting later in the
     file — and only for what it declares, which is why the shadow is restated here rather
     than left to inherit. `background` (not background-color) is likewise deliberate: the
     shorthand is what clears .btn.primary's red background-image. */
  box-shadow: inset 0 1px 0 rgba(255,255,255,.22),
              0 1px 2px rgba(16,24,40,.14),
              0 6px 18px -4px rgba(255,91,36,.42);
}
.btn.vippsbtn:hover {
  background: #f04d18; filter: none;
  box-shadow: inset 0 1px 0 rgba(255,255,255,.28),
              0 2px 4px rgba(16,24,40,.16),
              0 10px 26px -6px rgba(255,91,36,.52);
}
/* The official wordmark image. Sized by HEIGHT only — width follows the file's viewBox,
   so the artwork's proportions can never be distorted by a layout change, which is the
   one thing Vipps' guidelines are absolute about. 0.95em tracks the button's own type
   size, and the nudge sits it on the label's baseline rather than its box centre. */
.vipps-wordmark { height: .95em; width: auto; display: block; position: relative; top: .06em; }
/* Inside the method tile the chip sets the orange ground, so the mark just needs size. */
.ck-mark.is-vipps .vipps-wordmark { height: .92em; top: 0; }
.ck-mark.is-card svg { width: 1.15rem; height: 1.15rem; }
.ck-m-tick { margin-left: auto; color: var(--brand); opacity: 0; flex: none; transition: opacity .15s ease; }
.ck-m:has(input:checked) .ck-m-tick { opacity: 1; }
.ck-m-tick svg { width: 1.15rem; height: 1.15rem; }
/* Stripe Elements mount points: Apple Pay's button and the inline card fields.
   `[hidden]` needs saying out loud for all three of these — the attribute's UA rule is
   `display: none`, which LOSES to any class rule that sets display, so `.ck-m` (flex)
   and `.btn` (inline-flex) both stayed visible when hidden was set from script. */
.ck-el { margin: 0 0 1.1rem; }
.ck-el[data-el="apple"] { min-height: 48px; }
.ck-el[hidden], .ck-m[hidden], .ck-main .btn[hidden] { display: none; }
/* A method whose form could not load: still readable, visibly not selectable. */
.ck-m.is-dead { opacity: .55; }
/* Reserves its line height while empty so a payment error does not shove the button. */
.ck-note { color: var(--bad); font-size: .88rem; min-height: 1.25em; margin: .6rem 0 0; }
.ck-main .authalt { margin-bottom: 0; }

@media (max-width: 860px) {
  .ck-grid { grid-template-columns: minmax(0, 1fr); }
  .ck-sum { order: -1; }
}

/* The motion layer only animates a reveal when it is allowed to (see web/motion.css and
   docs/frontend.md); nothing here depends on it, so a reduced-motion reader gets the
   same page with the transitions above collapsed by the global rule. */

/* Featured card: a slow sheen that crosses the card once on entry, then on hover.
   One pass, not a loop — a price you are reading should not keep shimmering.
   `overflow: hidden` is what keeps the sweep inside the rounded corners, so the
   "most popular" flag moves *inside* the padding box: at the usual `top: -.75rem` it
   sat in the overflow and was cut in half. */
.price-card.featured { overflow: hidden; padding-top: 2.1rem; }
.price-card.featured .price-flag { top: .6rem; }
.price-card.featured::after {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  background: linear-gradient(105deg, transparent 35%, color-mix(in srgb, var(--brand) 18%, transparent) 50%, transparent 65%);
  transform: translateX(-120%);
}
.has-motion .price-card.featured.in::after { animation: price-sheen 1.1s ease-out .35s 1 both; }
.price-card.featured:hover::after { animation: price-sheen 1.1s ease-out 1 both; }
@keyframes price-sheen { from { transform: translateX(-120%); } to { transform: translateX(120%); } }
/* Staggered entrance: --i is the card's index, set inline by priceCard(). */
.has-motion .pricing-grid [data-reveal] { transition-delay: calc(var(--i, 0) * 90ms); }
.has-motion .feature-grid [data-reveal] { transition-delay: calc(var(--i, 0) * 55ms); }

/* --- phones: feature + step cards go icon-left, text-right ----
   Stacked (icon over heading over copy) each card is ~230px tall, and with the grid already down to
   ONE column that is four to seven full-height cards to scroll past. Turning each into a row puts the
   icon in a gutter beside the text and roughly halves the height, without touching the desktop
   layout. Scoped to 560px: below the width at which `repeat(auto-fit, minmax(250px, 1fr))` can still
   fit two columns (2x250 + gap needs ~550px), so the row layout only ever applies to a card that is
   already full-bleed. */
/* --- phones: squeeze the landing hero block ----
   Eyebrow → title → sub → two CTAs → three trust lines → stats band ran to ~1480px on a 390px screen,
   roughly four swipes before the first section. The type sizes are left alone (the title is the page's
   one piece of display type) — what is cut is whitespace and the wrapping of the three chip rows,
   which were stacking one item per line at desktop gaps. */
@media (max-width: 560px) {
  .lp-hero { padding: 1.3rem 0; gap: 1.1rem; }   /* sides 0: .view has the gutter; the card is gone */
  .lp-eyebrow { font-size: .76rem; padding: .3rem .7rem; margin-bottom: .7rem; }
  .lp-title { margin-bottom: .5rem; }
  .lp-sub { margin-bottom: 1rem; }
  .lp-cta { gap: .55rem; }
  /* Row gap well under the column gap: these wrap to two or three lines here, and at
     one shared 1.2rem gap the lines drifted apart into a list. */
  .lp-trust { gap: .3rem 1rem; margin-top: .9rem; font-size: .84rem; }
  .band { gap: .6rem; padding: 1rem .8rem; }
  .band-label { font-size: .78rem; margin-top: .15rem; }
}

@media (max-width: 560px) {
  .feature, .step {
    display: grid; grid-template-columns: auto minmax(0, 1fr);
    gap: .1rem .85rem; align-items: start; padding: 1rem 1.05rem;
  }
  /* The icon spans the heading row and the copy row, so it sits against the top of
     the text block rather than floating in the middle of a two-line card. */
  .feature .ficon, .step-num { grid-row: span 2; margin-bottom: 0; align-self: start; }
  .feature .ficon { width: 2.5rem; height: 2.5rem; }
  .feature .ficon svg { width: 1.3rem; height: 1.3rem; }
  .step-num { width: 2rem; height: 2rem; font-size: .9rem; }
  .feature h3, .step h3 { margin: 0 0 .15rem; font-size: 1rem; align-self: center; }
  .feature p, .step p { font-size: .88rem; }
  /* The heading centres itself against the icon only while the copy is there to
     balance it; a card with no <p> would otherwise sit oddly high. */
  .feature h3:last-child, .step h3:last-child { grid-row: span 2; align-self: center; }
}

/* ---------------------------------------------------------- upsell + locks */
.upsell {
  display: flex; align-items: center; gap: .9rem; flex-wrap: wrap;
  padding: .85rem 1.1rem; margin: 1rem 0 1.4rem;
  border: 1px solid color-mix(in srgb, var(--brand) 35%, var(--border));
  border-radius: var(--r-lg);
  background: color-mix(in srgb, var(--brand) 7%, var(--surface));
}
.upsell-ic { display: grid; place-items: center; width: 2rem; height: 2rem; flex: none; border-radius: 50%; color: #fff; background: var(--grad); background-color: var(--brand); }
.upsell-ic svg { width: 1.05rem; height: 1.05rem; }
.upsell-txt { flex: 1 1 16rem; font-size: .92rem; font-weight: 600; }
/* "Did the practice help?" — offered on the result screen after a pass only, so it is
   asked at the one moment the answer is actually known. Same row shape as .upsell, in
   the OK palette rather than the brand one: this is an invitation, not an upsell, and
   it must not read as another thing to buy. */
.review-ask { display: flex; align-items: center; gap: .9rem; flex-wrap: wrap; margin-top: 1rem;
  border-left: 3px solid var(--ok); }
.review-ask-ic { display: flex; color: var(--ok-ink); flex: none; }
.review-ask-ic svg { width: 1.35rem; height: 1.35rem; }
.review-ask-txt { display: flex; flex-direction: column; flex: 1 1 12rem; min-width: 0; }   /* 12rem, not 18: at 390px an 18rem basis pushed the icon onto a line of its own */
.review-ask-txt b { font-size: .98rem; }
.review-ask-txt span { color: var(--muted); font-size: .9rem; }
.review-ask .btn { flex: none; }
/* The expandable variant (`has-form`), which submits the review from the result page
   instead of sending the reader to the contact form. The row above becomes the head,
   so the card itself has to stop being a flex row. The plain variant and the
   thank-you state keep the rules above untouched. */
.review-ask.has-form { display: block; }
.review-ask-head { display: flex; align-items: center; gap: .9rem; flex-wrap: wrap; }
/* Unfolded on a pass, so there is nothing to discover — the toggle survives only as
   the way back to collapsed, which is why it is a quieter control once open. */
.review-ask.has-form.open .review-ask-head .btn { display: none; }
.review-ask-form[hidden] { display: none; }
.review-ask-inner { display: grid; gap: .75rem; margin-top: .95rem; padding-top: .95rem;
  border-top: 1px solid var(--border); }
.review-ask.has-form.open .review-ask-inner { animation: reviewUnfold .28s var(--ease, ease-out) both; }
@keyframes reviewUnfold { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
.stars-field { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
.stars-lbl { font-size: .92rem; font-weight: 600; }
.stars { display: inline-flex; gap: .1rem; }
/* Grey, but not --border grey: these five are the card's primary control, and at
   #e4e8ef on white they read as a disabled decoration rather than something to tap. */
.star { display: flex; padding: .18rem; border-radius: var(--r-sm, 8px); cursor: pointer;
  color: color-mix(in srgb, var(--muted) 42%, transparent);
  transition: color .14s ease, transform .14s ease, filter .14s ease; }
/* The icon set is stroke-only by default; a rating star reads as a star only when it
   is solid, so fill and stroke are both taken from the same currentColor. */
.star svg { width: 1.85rem; height: 1.85rem; fill: currentColor; stroke: currentColor; }
.star-in:focus-visible + .star { outline: 2px solid var(--brand); outline-offset: 2px; }
/* Grey until picked, amber up to the pick — and at five, the whole row goes to the
   site's own red with a glow behind it, so full marks look like full marks. */
.stars[data-rating="1"] .star:nth-of-type(-n + 1),
.stars[data-rating="2"] .star:nth-of-type(-n + 2),
.stars[data-rating="3"] .star:nth-of-type(-n + 3),
.stars[data-rating="4"] .star:nth-of-type(-n + 4) { color: var(--amber); }
.stars[data-rating="5"] .star { color: var(--brand);
  filter: drop-shadow(0 0 5px color-mix(in srgb, var(--brand) 55%, transparent)); }
.stars[data-rating="5"] .star { animation: starPop .34s var(--ease, ease-out) both; }
.stars[data-rating="5"] .star:nth-of-type(2) { animation-delay: .04s; }
.stars[data-rating="5"] .star:nth-of-type(3) { animation-delay: .08s; }
.stars[data-rating="5"] .star:nth-of-type(4) { animation-delay: .12s; }
.stars[data-rating="5"] .star:nth-of-type(5) { animation-delay: .16s; }
@keyframes starPop { 0% { transform: none; } 45% { transform: scale(1.22); } 100% { transform: none; } }
/* A 1.85rem star plus .18rem of padding is a ~35px target — under the 44px a thumb
   needs, and this is the one control on the card. Grown on coarse pointers only, so
   the desktop row keeps its tighter spacing. */
@media (pointer: coarse) { .star { padding: .45rem; } }
.review-ask-send { display: flex; align-items: center; gap: .8rem; flex-wrap: wrap; }
.review-ask-note { font-size: .88rem; color: var(--muted); }
.review-ask-note.bad { color: var(--bad); }
.review-ask.sent { border-left-color: var(--ok); }
.review-ask.sent .review-ask-ic { color: var(--ok); }
/* Motion here is decoration on a form: the unfold and the five-star pop both go, the
   colour change stays (it is the feedback, not the flourish). */
@media (prefers-reduced-motion: reduce) {
  .review-ask.has-form.open .review-ask-inner,
  .stars[data-rating="5"] .star { animation: none; }
  .star { transition: color .14s ease; }
}
/* The permission line in the contact form. Sits between the category and the message,
   and is only in the DOM's flow when a review is what is being sent. */
.contact-consent { margin: -.3rem 0 .2rem; padding: .7rem .85rem; border-radius: var(--r-md);
  background: var(--ok-bg); color: var(--text); font-size: .88rem; line-height: 1.5; }
.contact-consent[hidden] { display: none; }

/* --- dev · reviews (#/dev/reviews) -------------------------------------------
   Local-only screen with one reader, so it borrows the app's existing card/chip
   vocabulary rather than introducing anything new. */
.devrev-list { display: grid; gap: .8rem; }
.devrev-q { margin: 0 0 .7rem; font-size: 1.02rem; line-height: 1.55; border-left: 3px solid var(--brand);
  padding-left: .8rem; }
.devrev-meta { display: flex; align-items: center; gap: .55rem; flex-wrap: wrap; font-size: .88rem; }
.devrev-meta .spacer { flex: 1 1 auto; }
.devrev-mail { margin-top: .5rem; font-size: .8rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.devrev-how ol { margin: .6rem 0 0; padding-left: 1.2rem; display: grid; gap: .45rem; }
.devrev-how p { margin: 0; color: var(--muted); }
.upsell .btn { flex: none; }

/* Locked surfaces. Dimmed and non-interactive-looking, but never blurred content:
   the sign faces and question texts behind the pass are simply not rendered. */
.card.is-locked, .tile.is-locked, .mtopic.is-locked { opacity: .72; }
.tile.is-locked:hover, .mtopic.is-locked:hover { opacity: .9; }
.card.is-locked .select:disabled { cursor: not-allowed; }
.seg-lock { display: inline-flex; margin-left: .35rem; color: var(--muted); }
.seg-lock svg { width: .9rem; height: .9rem; }

@media (prefers-reduced-motion: reduce) {
  .price-card.featured::after { display: none; }
  .has-motion .pricing-grid [data-reveal], .has-motion .feature-grid [data-reveal] { transition-delay: 0s; }
}

/* testimonials */
.testi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 1.1rem; }
.testi { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.5rem; box-shadow: var(--shadow); }
.testi .stars { color: #f5b301; letter-spacing: .1em; font-size: .95rem; }
.testi blockquote { margin: .6rem 0 1.1rem; font-size: 1rem; line-height: 1.5; }
.testi .who { display: flex; align-items: center; gap: .6rem; }
/* Dormant (testimonials were removed 2026-08-15) but pre-fixed to --grad-ui: initials
   are ~16px bold, under the decorative ramp's floor, so the section would have shipped
   the same AA failure the moment real quotes replaced the invented ones. */
.testi .avatar { width: 2.4rem; height: 2.4rem; border-radius: 50%; display: grid; place-items: center; font-weight: 800; color: #fff; background: var(--grad-ui); }
.testi .who b { display: block; font-size: .9rem; }
.testi .who span { color: var(--muted); font-size: .8rem; }

/* final CTA */
/* --grad-ui: the h2 is large enough for the decorative ramp, but the sub-line under
   it is 16.8px and measured 3.77:1 (2026-08-20). Same reasoning as .band above. */
.cta-band { position: relative; overflow: hidden; text-align: center; color: #fff; border-radius: var(--r-xl); padding: clamp(2.2rem, 5vw, 3.5rem); background-color: var(--grad-solid); background-image: var(--grad-ui); box-shadow: var(--shadow-lg); }
.cta-band::before { content: ""; position: absolute; inset: 0; background: radial-gradient(80% 120% at 80% -10%, rgba(255,255,255,.28), transparent 50%); }
.cta-band > * { position: relative; }
.cta-band h2 { font-size: var(--fs-h1); margin-bottom: .6rem; }
.cta-band p { color: #fff; font-size: 1.05rem; margin-bottom: 1.5rem; }   /* was .92 alpha: strictly less contrast, and no checker models foreground alpha */
.cta-band .btn.primary { background: #fff; color: var(--brand-on-white); border: none; box-shadow: 0 8px 24px rgba(0,0,0,.2); }

@media (max-width: 820px) {
  .lp-hero { grid-template-columns: 1fr; text-align: center; }
  .lp-hero .lp-cta, .lp-hero .lp-trust { justify-content: center; }
  .lp-sub { margin-inline: auto; }
  .band { grid-template-columns: repeat(2, 1fr); gap: 1.2rem; }
  .steps { grid-template-columns: 1fr; }
  /* Cards stack, so the rail stops being a connector between neighbours and becomes
     the section's own progress strip — which is why the pins keep their numbers. */
  .rm-steps { grid-template-columns: 1fr; }
  .rm-rail { height: 3.6rem; }
  .rm-verge { stroke-width: 20; } .rm-road { stroke-width: 16; }
  .rm-lit { stroke-width: 6; } .rm-car { width: 1.5rem; }
  .rm-pin { width: 1.2rem; height: 1.2rem; font-size: .62rem; }
}
/* 280, not 340: on a 390px screen the hero drive was taking most of the fold on its
   own. It is a generated scene that samples fewer rows under 700px anyway, so it
   loses no detail worth keeping — it just stops pushing the CTAs below the fold. */
@media (max-width: 480px) { .lp-visual { min-height: 280px; } }

/* =====================================================================
   EDGE DECORATION (CSS fallback) — static, irregular scatter of flat-colour sign-like shapes
   (triangle / circle / diamond / octagon, mixed outline & filled, mixed sizes, some bleeding off the
   outer edge) filling the side margins up to 25% of the viewport on each side. Left and right use two
   different arrangements (not a mirrored copy) so the page doesn't read as symmetric. Faded top and
   bottom so it reads as a quiet border, not wallpaper.
   When JS is available and motion is not reduced, the animated canvas version takes over
   (web/app/edge-animation.js adds .has-edge-anim to <html>, which hides these pseudo-elements).
   ===================================================================== */
body::before, body::after {
    content: "";
    position: fixed; top: 0; bottom: 0; width: clamp(28px, 25vw, 25%);
    background-repeat: repeat-y;
    background-position: center top;
    background-size: 100% auto;
    -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 100px, #000 calc(100% - 100px), transparent 100%);
            mask-image: linear-gradient(to bottom, transparent 0, #000 100px, #000 calc(100% - 100px), transparent 100%);
    pointer-events: none;
    z-index: 0;
  }
  body::before {
    left: 0;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='1500' viewBox='0 0 240 1500'%3E%3Ccircle cx='0' cy='80' r='70' fill='%23e08a00' fill-opacity='0.5'/%3E%3Cpolygon points='150,175 205,276.75 95,276.75' fill='none' stroke='%237c5cff' stroke-opacity='0.55' stroke-width='5' stroke-linejoin='round'/%3E%3Ccircle cx='60' cy='340' r='16' fill='%23c8102e' fill-opacity='0.6'/%3E%3Cpolygon points='172,438 214,480 172,522 130,480' fill='none' stroke='%232f6fed' stroke-opacity='0.55' stroke-width='5' stroke-linejoin='round'/%3E%3Cpolygon points='88.8,636.1 66.1,658.8 33.9,658.8 11.2,636.1 11.2,603.9 33.9,581.2 66.1,581.2 88.8,603.9' fill='none' stroke='%23c8102e' stroke-opacity='0.5' stroke-width='5' stroke-linejoin='round'/%3E%3Cpolygon points='88.8,636.1 66.1,658.8 33.9,658.8 11.2,636.1 11.2,603.9 33.9,581.2 66.1,581.2 88.8,603.9' fill='%23c8102e' fill-opacity='0.08'/%3E%3Cpolygon points='190,708 212,730 190,752 168,730' fill='%231a7f37' fill-opacity='0.5'/%3E%3Ccircle cx='0' cy='860' r='95' fill='none' stroke='%23e08a00' stroke-opacity='0.45' stroke-width='5'/%3E%3Cpolygon points='140,950 170,1005.5 110,1005.5' fill='%232f6fed' fill-opacity='0.4'/%3E%3Ccircle cx='70' cy='1090' r='13' fill='%237c5cff' fill-opacity='0.55'/%3E%3Cpolygon points='65.4,1253 33,1285.4 -13,1285.4 -45.4,1253 -45.4,1207 -13,1174.6 33,1174.6 65.4,1207' fill='%23c8102e' fill-opacity='0.12'/%3E%3Cpolygon points='65.4,1253 33,1285.4 -13,1285.4 -45.4,1253 -45.4,1207 -13,1174.6 33,1174.6 65.4,1207' fill='none' stroke='%23c8102e' stroke-opacity='0.4' stroke-width='5' stroke-linejoin='round'/%3E%3Cpolygon points='178,1312 216,1382.3 140,1382.3' fill='none' stroke='%231a7f37' stroke-opacity='0.5' stroke-width='5' stroke-linejoin='round'/%3E%3Ccircle cx='40' cy='1450' r='12' fill='%23e08a00' fill-opacity='0.55'/%3E%3C/svg%3E");
  }
  body::after {
    right: 0;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='1500' viewBox='0 0 240 1500'%3E%3Ccircle cx='240' cy='120' r='85' fill='%232f6fed' fill-opacity='0.45'/%3E%3Cpolygon points='70,310 120,217.5 20,217.5' fill='none' stroke='%23c8102e' stroke-opacity='0.55' stroke-width='5' stroke-linejoin='round'/%3E%3Cpolygon points='190,350 210,370 190,390 170,370' fill='%23e08a00' fill-opacity='0.55'/%3E%3Cpolygon points='97,515.3 75.3,537 44.7,537 23,515.3 23,484.7 44.7,463 75.3,463 97,484.7' fill='none' stroke='%237c5cff' stroke-opacity='0.5' stroke-width='5' stroke-linejoin='round'/%3E%3Cpolygon points='97,515.3 75.3,537 44.7,537 23,515.3 23,484.7 44.7,463 75.3,463 97,484.7' fill='%237c5cff' fill-opacity='0.1'/%3E%3Cpolygon points='240,565 325,722.25 155,722.25' fill='%231a7f37' fill-opacity='0.32'/%3E%3Ccircle cx='110' cy='760' r='13' fill='%232f6fed' fill-opacity='0.55'/%3E%3Cpolygon points='198,832 246,880 198,928 150,880' fill='none' stroke='%23e08a00' stroke-opacity='0.5' stroke-width='5' stroke-linejoin='round'/%3E%3Cpolygon points='70.3,998.4 58.4,1010.3 41.6,1010.3 29.7,998.4 29.7,981.6 41.6,969.7 58.4,969.7 70.3,981.6' fill='%23c8102e' fill-opacity='0.4'/%3E%3Ccircle cx='240' cy='1120' r='70' fill='none' stroke='%231a7f37' stroke-opacity='0.45' stroke-width='5'/%3E%3Cpolygon points='150,1214 176,1262.1 124,1262.1' fill='%237c5cff' fill-opacity='0.5'/%3E%3Ccircle cx='190' cy='1350' r='12' fill='%23c8102e' fill-opacity='0.5'/%3E%3Cpolygon points='70,1422 88,1440 70,1458 52,1440' fill='%232f6fed' fill-opacity='0.5'/%3E%3C/svg%3E");
  }
:root[data-theme="dark"] body::before, :root[data-theme="dark"] body::after {
  opacity: .8;
  mix-blend-mode: screen;
}
/* Hide the static pseudo-element decorations once the JS edge animation takes over.
   The fallback (no JS / reduced-motion) keeps the CSS version. */
.has-edge-anim body::before, .has-edge-anim body::after { display: none; }
/* NOT .topbar: it is already above these decorations (position: sticky +
   z-index: 20, declared at its own rule). Listing it here once flattened it to
   z-index: 1 — the same layer as .view, which paints later in the DOM — so the
   account menu (z-index 60 INSIDE the bar's stacking context) rendered behind
   the page content. It also silently overrode sticky with relative. */
.view, .footer { position: relative; z-index: 1; }

/* ------------------------------------------------------------ mobile polish */
/* Safe-area insets — on notched/rounded phones (viewport-fit=cover) keep the sticky bar, content and
   footer clear of the notch, home indicator and rounded corners. max() so we never shrink below the
   existing padding on flat screens. Appended last so these win the cascade over the earlier
   responsive paddings.
   The sticky bar is the one thing that does NOT sit on the --page-w rail: it runs edge to edge on
   purpose. Centring its contents on the rail was tried (2026-08-18) and reverted — seven nav links
   plus the auth buttons, the theme toggle and the language select need more than 1160px at desktop
   widths, and squeezing them onto the rail wrapped "Logg inn" onto two lines at 1440. */
.topbar { padding-left: max(1.25rem, env(safe-area-inset-left)); padding-right: max(1.25rem, env(safe-area-inset-right)); }
.view {
  padding-left: max(1.25rem, env(safe-area-inset-left));
  padding-right: max(1.25rem, env(safe-area-inset-right));
  padding-bottom: max(3.5rem, calc(2.5rem + env(safe-area-inset-bottom)));
}
.footinner, .footbar {
  padding-left: max(1.25rem, env(safe-area-inset-left));
  padding-right: max(1.25rem, env(safe-area-inset-right));
}
.footbar { padding-bottom: max(.8rem, env(safe-area-inset-bottom)); }

/* Touch-only affordances (e.g. the swipe hint) appear on coarse pointers. */
@media (pointer: coarse) {
  .touch-only { display: inline; }
}

/* ============================================================ student discount
   The top bar, the verify panel and the #/student page.
   The bar ships VISIBLE in index.html so it is in the first paint; this rule is what the pre-paint
   inline script there switches on, and it is the only way the bar is hidden without a layout shift. A
   bar added by JS after boot would push the header, the hero and everything below it down — the exact
   4 px-header mistake that cost CLS 0.96 once already (docs/frontend.md). Closing it with the X *is*
   allowed to shift, because a shift within 500 ms of a click is outside the metric. */
:root[data-studbar="off"] .studbar { display: none; }

.studbar {
  display: flex; align-items: center; gap: .5rem;
  padding: .5rem max(1rem, env(safe-area-inset-left)) .5rem max(1rem, env(safe-area-inset-right));
  background: var(--grad-ui); color: var(--brand-ink);
  font-size: .88rem; font-weight: 600;
}
.studbar-in {
  display: flex; align-items: center; justify-content: center; gap: .55rem;
  flex: 1; min-width: 0; flex-wrap: wrap;
  color: inherit; text-decoration: none;
}
.studbar-tag {
  flex: none; font-weight: 800; font-size: .78rem; letter-spacing: .02em;
  padding: .15rem .45rem; border-radius: var(--r-xs);
  /* Not #fff on the red: the brand gradient is dark enough that a white chip reads as
     a button and gets clicked expecting something separate to happen.
     A DARK scrim, not the white one it was until 2026-09-05, and for the reason the
     .pill/.hero-badge note above this file already records: a translucent white layer
     LIGHTENS its own backdrop, so it makes white ink worse, not better. Measured by
     `ui-audit.mjs a11y` at 1280 on every route: 4.33:1 against a 4.5 floor (the text is
     12.5px, so it gets no large-text relief). The same alpha inverted measures ~6.3:1 at
     the light end of --grad-ui. The chip still reads as a chip, just as a well rather
     than a highlight — which is also the honest depth cue, since it sits ON the bar. */
  background: rgba(0,0,0,.18);
}
.studbar-cta { text-decoration: underline; text-underline-offset: 3px; white-space: nowrap; }
.studbar-in:hover .studbar-cta { text-decoration-thickness: 2px; }
.studbar-x {
  flex: none; display: grid; place-items: center;
  /* 40px, not the 24px the glyph needs: this is the one control on the bar someone
     taps deliberately, and a close button too small to hit is worse than no bar. */
  width: 40px; height: 40px; margin: -.5rem -.35rem -.5rem 0;
  background: none; border: 0; color: inherit; cursor: pointer; border-radius: 50%;
}
.studbar-x svg { width: 18px; height: 18px; }
.studbar-x:hover { background: rgba(255,255,255,.15); }
.studbar-x:focus-visible { outline: 2px solid var(--brand-ink); outline-offset: -4px; }
@media (max-width: 34rem) {
  .studbar { font-size: .82rem; }
  .studbar-tag { display: none; }
}

/* The discount stated in words under the price grid, not only on the tag. The tag is a
   62 px graphic and the top bar is dismissible, so without this the offer existed on the
   landing page as five characters of red pill — nothing for a crawler that does not run
   JS to read, and nothing for a reader who closed the bar. */
.price-foot-stud { color: var(--text); font-weight: 600; margin-bottom: .2rem; }
.price-foot-stud a { color: var(--brand); text-underline-offset: 3px; }

/* ---- the swing tag on a price card (studentHang() in main.js) ----
   A little tag on a braided rope, hung from a nail at the top right of every PAID price card, so the
   discount is stated where the price is actually read rather than only on a bar the reader can
   dismiss. Two structural rules, both learned the hard way:
   - It hangs INSIDE the card's padding box. `.price-card.featured` is `overflow: hidden` for its
     sheen, and that is what cut the "most popular" flag in half at `top: -.75rem`.
   - The pendulum and the hover sway are on TWO elements (`-swing` / `-sway`), not one. Both would be
     the `animation` property on the same element, so the hover rule would cancel the entrance one —
     and un-hovering would restart the entrance drop, re-dropping the tag every time the pointer left.
   Nothing loops: the tag drops and settles once when the card enters, and sways again only when the
   reader hovers or tabs to it (the same one-shot bargain as the featured card's sheen). */
.stud-hang {
  position: absolute; top: .3rem; right: .7rem; z-index: 2;
  text-decoration: none; line-height: 1;
}
/* The nail it hangs from — the one part that must not move when the rope swings, so it
   sits outside the animated wrapper. */
.stud-hang-nail {
  position: absolute; top: 0; left: 50%; width: 6px; height: 6px; margin-left: -3px;
  border-radius: 50%; background: color-mix(in srgb, var(--text) 42%, transparent);
  box-shadow: inset 0 -1px 0 rgba(0,0,0,.3);
}
/* Both wrappers pivot at the nail, not at their own centre: a tag swinging about its
   middle reads as a spinning badge, not as something hanging. */
.stud-hang-swing, .stud-hang-sway { display: block; transform-origin: 50% 3px; }
/* Twine: two strands winding around each other (ROPE_SVG in main.js), stroked in
   `currentColor` so one asset reads on --surface in both themes. It was a 3 px
   repeating-linear-gradient "braid" first — at that width the stripes vanished and it
   rendered as a solid grey shaft, i.e. a thumbtack. */
.stud-hang-rope { display: block; margin-top: 3px; color: color-mix(in srgb, var(--text) 44%, transparent); }
.stud-hang-rope svg { display: block; margin: 0 auto; overflow: visible; }
.stud-hang-rope path { fill: none; stroke: currentColor; stroke-width: 1.3; stroke-linecap: round; }
.stud-hang-tag {
  position: relative; display: flex; flex-direction: column; align-items: center; gap: .1rem;
  padding: .3rem .45rem .28rem; border-radius: var(--r-xs); white-space: nowrap;
  /* --grad-ui + --brand-ink, the same pairing as .studbar: the discount reads as one
     thing across the site, and it is the gradient that carries white ink at this size. */
  background: var(--grad-ui); color: var(--brand-ink);
  box-shadow: 0 3px 9px color-mix(in srgb, var(--brand) 30%, transparent);
  /* `rotate`, not `transform`: the wrappers own transform, and these compose instead of
     overwriting each other. A tag hanging dead straight looks printed on. */
  rotate: -2.5deg;
}
/* The grommet the cord is threaded through, straddling the tag's top edge. */
.stud-hang-tag::before {
  content: ""; position: absolute; top: -3px; left: 50%; width: 8px; height: 8px;
  margin-left: -4px; border-radius: 50%; box-sizing: border-box;
  border: 2px solid color-mix(in srgb, var(--text) 40%, var(--surface));
  background: var(--surface);
}
.stud-hang-tag b { font-size: .8rem; font-weight: 800; letter-spacing: -.01em; }
.stud-hang-tag small { font-size: .56rem; font-weight: 800; text-transform: uppercase; letter-spacing: .07em; opacity: .9; }
.stud-hang:hover .stud-hang-tag { box-shadow: 0 5px 14px color-mix(in srgb, var(--brand) 40%, transparent); }
.stud-hang:focus-visible { outline: none; }
.stud-hang:focus-visible .stud-hang-tag { outline: 2px solid var(--brand); outline-offset: 3px; }

/* Hung, then let go: it arrives a beat after the card lands, overshoots and damps out.
   The easing is sine-in-out, which is what a pendulum actually does — an ease-out would
   make every swing look like the last one. */
.has-motion .price-card.in .stud-hang-swing { animation: stud-hang-drop 1.5s cubic-bezier(.37,0,.63,1) .45s both; }
.has-motion .price-card:hover .stud-hang-sway,
.has-motion .stud-hang:focus-visible .stud-hang-sway { animation: stud-hang-sway 1.25s cubic-bezier(.37,0,.63,1) both; }
@keyframes stud-hang-drop {
  0%   { opacity: 0; transform: translateY(-9px) rotate(-15deg); }
  20%  { opacity: 1; transform: translateY(0) rotate(11deg); }
  44%  { transform: translateY(0) rotate(-6.5deg); }
  64%  { transform: translateY(0) rotate(3.6deg); }
  84%  { transform: translateY(0) rotate(-1.5deg); }
  100% { opacity: 1; transform: translateY(0) rotate(0); }
}
@keyframes stud-hang-sway {
  0%   { rotate: 0deg; }
  22%  { rotate: 5.5deg; }
  46%  { rotate: -3.6deg; }
  68%  { rotate: 2deg; }
  86%  { rotate: -.9deg; }
  100% { rotate: 0deg; }
}
/* The static state is the finished one — an upright tag, fully visible — so there is
   nothing to restore here beyond stopping the movement. */
@media (prefers-reduced-motion: reduce) {
  .has-motion .price-card.in .stud-hang-swing,
  .has-motion .price-card:hover .stud-hang-sway,
  .has-motion .stud-hang:focus-visible .stud-hang-sway { animation: none; }
}
/* Phones: the cards go full width, so the tag can breathe a little further in from the
   corner without crowding the price. */
@media (max-width: 34rem) {
  .stud-hang { right: 1rem; }
}

/* ---- the verify panel (checkout + #/student) ---- */
.stud {
  border: 1px solid var(--border); border-radius: var(--r-md);
  background: var(--surface-2); padding: .85rem 1rem; margin: 1rem 0;
}
.stud.is-ok { border-color: color-mix(in srgb, var(--ok) 35%, var(--border)); background: var(--ok-bg); }
.stud-compact { margin: .75rem 0 0; }

.stud-tick { display: flex; align-items: flex-start; gap: .65rem; cursor: pointer; }
.stud-tick input { position: absolute; opacity: 0; width: 0; height: 0; }
.stud-tick-box {
  flex: none; width: 22px; height: 22px; margin-top: .05rem; border-radius: var(--r-xs);
  border: 2px solid var(--muted); background: var(--surface);
  display: grid; place-items: center; color: transparent; transition: .15s;
}
.stud-tick-box svg { width: 14px; height: 14px; stroke-width: 3; }
.stud-tick input:checked + .stud-tick-box { background: var(--brand); border-color: var(--brand); color: var(--brand-ink); }
.stud-tick input:focus-visible + .stud-tick-box { outline: 2px solid var(--brand); outline-offset: 2px; }
.stud-tick-txt { display: flex; flex-direction: column; gap: .1rem; line-height: 1.35; }
.stud-tick-txt b { font-size: .95rem; }
.stud-tick-txt small { color: var(--muted); font-size: .82rem; font-weight: 600; }

.stud-body { margin-top: .85rem; padding-top: .85rem; border-top: 1px solid var(--border); }
.stud-form { display: flex; flex-direction: column; gap: .6rem; }
.stud-hint { margin: 0; color: var(--muted); font-size: .8rem; line-height: 1.45; }
.stud-sent { margin: 0; font-size: .85rem; line-height: 1.45; }
.stud-alt { margin: 0; font-size: .82rem; color: var(--muted); text-align: center; }
.stud-code {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 1.35rem; letter-spacing: .3em; text-align: center;
}
.stud .fielderr.is-on { display: block; margin-top: .35rem; }

.stud-okrow { display: flex; align-items: flex-start; gap: .65rem; flex-wrap: wrap; }
.stud-okico { flex: none; display: grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; background: var(--ok); color: #fff; }
.stud-okico svg { width: 16px; height: 16px; stroke-width: 3; }
.stud-okrow > div { flex: 1; min-width: 12rem; display: flex; flex-direction: column; gap: .12rem; }
.stud-okrow b { color: var(--ok-ink); font-size: .95rem; }
.stud-sub { color: var(--muted); font-size: .82rem; line-height: 1.45; }
.stud-save { flex: none; font-weight: 800; color: var(--ok-ink); align-self: center; }

/* ---- #/student ---- */
.studpage-hero { text-align: center; margin: 0 0 1.5rem; }
.studpage-hero h1 { margin: .4rem 0 .5rem; }
.studpage-eyebrow {
  display: inline-block; font-size: .78rem; font-weight: 800; letter-spacing: .08em;
  text-transform: uppercase; color: var(--brand-on-white);
}
.studpage-price { display: flex; align-items: baseline; justify-content: center; gap: .6rem; flex-wrap: wrap; margin: .75rem 0 0; }
.studpage-was { color: var(--muted); text-decoration: line-through; }
.studpage-now { font-size: 1.6rem; font-weight: 800; }
.studpage-steps { display: grid; gap: .85rem; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); margin: 0; padding: 0; list-style: none; }
.studpage-steps li { display: flex; gap: .65rem; align-items: flex-start; }
.studpage-step-n {
  flex: none; display: grid; place-items: center; width: 26px; height: 26px;
  border-radius: 50%; background: var(--brand); color: var(--brand-ink);
  font-size: .82rem; font-weight: 800;
}
.studpage-who { columns: 2 14rem; column-gap: 1.6rem; margin: 0; padding-left: 1.1rem; }
.studpage-who li { break-inside: avoid; margin: 0 0 .3rem; color: var(--muted); font-size: .9rem; }

/* ===================================================== dev business dashboard (#/dev/accounts)
   The operator's only screen, behind DEV_TOOLS + DEV_EMAIL. It is NOT part of the learner
   product, so it is styled entirely out of the existing primitives (.card, .stat, .tbl,
   .bar, .chip-tag) and adds only what a dense operational table genuinely needs. */

/* Six KPIs, not the three .statgrid is built for: these are scanned as a block, and the
   two-row 3x2 shape keeps each number wide enough to stay readable at full precision. */
.dash-kpi { grid-template-columns: repeat(3, minmax(0, 1fr)); }
@media (max-width: 860px) { .dash-kpi { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 480px) { .dash-kpi { grid-template-columns: 1fr; } }
.dash-kpi .stat-num { font-size: 1.35rem; }

.dash-filters { display: flex; flex-wrap: wrap; gap: .8rem 1rem; align-items: end; }
.dash-filters .field { flex: 1 1 10rem; min-width: 0; margin: 0; }
/* The sentence restating the active filters spans the whole row under the controls,
   so a screenshot of this dashboard says what it is a picture of. */
.dash-note { flex: 1 0 100%; margin: .2rem 0 0; }
.dash-count { margin: .9rem 0 .4rem; }
.dash-h3 { margin: 0 0 .6rem; font-size: .95rem; }
.dash-key { font-weight: 700; }
/* Three panels that are read ACROSS — channel, plan, ledger — so the column count is
   stated rather than left to .grid's auto-fit, which collapsed the row to a single
   full-width track here and turned three glanceable panels into three screens of scroll. */
.dash-splits { grid-template-columns: repeat(3, minmax(0, 1fr)); margin-top: 1rem; align-items: start; }
@media (max-width: 900px) { .dash-splits { grid-template-columns: 1fr; } }

/* Stack order is Stripe on the baseline, Vipps above it, EVERYWHERE on this screen. */
.dash-bar.vipps { fill: var(--brand); }
.dash-bar.stripe { fill: var(--blue); }
.lg-key.dash-vipps { background: var(--brand); }
.lg-key.dash-stripe { background: var(--blue); }
/* The split bars reuse .bar, whose default fill is --brand (Vipps); the second series
   needs the same blue it has in the chart above, not the amber .solid normally means. */
.dash-splits .bar > i.solid { background: var(--blue); }

/* Eight columns of real data do not fold into a phone. Rather than hide columns or
   shrink the type to nothing, the table keeps its width and scrolls inside its own card
   — the one place docs/frontend.md allows a horizontal scroller. */
.dash-tablewrap { overflow-x: auto; }
.dash-tbl { min-width: 780px; margin-top: 0; }
.dash-tbl th {
  text-align: left; padding: .3rem .6rem .5rem 0; border-bottom: 1px solid var(--border);
  color: var(--muted); font-size: .72rem; font-weight: 700;
  text-transform: uppercase; letter-spacing: .04em; white-space: nowrap;
}
.dash-tbl td { padding: .55rem .6rem .55rem 0; vertical-align: top; white-space: nowrap; }
/* .tbl greys and right-aligns its last column by default, which is right for a two-column
   readout and wrong for an eight-column table where the last column is data like any other. */
.dash-tbl td:last-child { text-align: right; color: var(--text); }
.dash-tbl th.num, .dash-tbl td.num { text-align: right; font-variant-numeric: tabular-nums; }
.dash-tbl .m-sub { color: var(--muted); font-weight: 500; font-size: .78rem; margin-top: .15rem; }
/* The identity column is the one that may wrap: an e-mail address is long, and truncating
   the thing you search the table BY would defeat the search box above it. */
.dash-tbl .dash-who { white-space: normal; min-width: 15rem; }
.dash-tbl .dash-who a { color: inherit; text-decoration: underline; text-decoration-color: var(--border); }
.dash-tbl .dash-who a:hover { text-decoration-color: currentColor; }
.dash-stars { color: var(--amber); letter-spacing: .05em; }
/* Sandbox money, marked on the row. Amber rather than red: a test purchase is not an
   error, it just must never be mistaken for revenue. */
.dash-tbl .chip-tag.warn-chip {
  color: #8a5400; background: color-mix(in srgb, var(--amber) 16%, var(--surface));
}
:root[data-theme="dark"] .dash-tbl .chip-tag.warn-chip { color: var(--amber); }
/* Pager under the roster. Real <button>s, so `:disabled` actually dims the end stops —
   the anchors used for the dev question-browser steps needed a manual opacity fake. */
.dash-pager { display: flex; align-items: center; justify-content: center; gap: 1rem; margin-top: 1rem; }
.dash-pager .btn:disabled { opacity: .45; pointer-events: none; }
.dash-pageno { color: var(--muted); font-size: .85rem; font-weight: 600; font-variant-numeric: tabular-nums; }

/* ---------- Consent banner (consent.js) ----------
   Bottom-anchored card, above the sticky topbar's z-index 20 because it gates a
   third-party script and must not be paintable-over. It is injected only when no
   choice is stored, so it costs nothing after the first answer. */
.consent {
  position: fixed; left: 1rem; right: 1rem; bottom: 1rem; z-index: 80;
  display: flex; flex-wrap: wrap; align-items: center; gap: .75rem 1rem;
  max-width: 44rem; margin: 0 auto; padding: .9rem 1.1rem;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius); box-shadow: var(--shadow-lg);
  opacity: 0; transform: translateY(.5rem); transition: opacity .2s ease, transform .2s ease;
}
.consent[data-in] { opacity: 1; transform: none; }
.consent-text { flex: 1 1 18rem; margin: 0; font-size: .9rem; color: var(--muted); }
.consent-text a { color: inherit; text-decoration: underline; }
.consent-actions { display: flex; gap: .5rem; margin-left: auto; }
@media (prefers-reduced-motion: reduce) {
  .consent { transition: none; opacity: 1; transform: none; }
}

/* ===================================================== gift cards (#/gift, app/gift.js)
   The card is an OBJECT, and a LIGHT one whatever the theme: it carries the real wordmark,
   whose navy "Mester" would vanish on a dark ground. Bank-card proportions (ISO/IEC 7810
   ID-1, 85.60 × 53.98 mm), a brand stripe, a curve of road with a broken yellow centre line,
   the names and the code. Same markup at three sizes — the designer's live preview, the
   checkout aside, the printable page — so the type scales with the box (`cqi`), not the
   viewport. Colours are fixed hexes on purpose: a physical card does not follow dark mode. */
.gcard-frame { container-type: inline-size; }   /* the size every cqi below reads from */
.gcard {
  /* `clip`, not `hidden`: a scroll container's automatic minimum height is zero, so with
     `hidden` a long greeting plus the two-line fine print was cut off at the bottom edge.
     `clip` still confines the road to the rounded corners and lets the card grow a little
     past its ratio when the content needs it. */
  position: relative; overflow: clip;
  aspect-ratio: 85.6 / 53.98; border-radius: 3.7cqi; color: #1b2340;
  background: linear-gradient(135deg, #ffffff 0%, #f8f9fc 55%, #eef1f7 100%);
  border: 1px solid rgba(27, 35, 64, .1);
  box-shadow: 0 24px 50px -26px rgba(27, 35, 64, .38), inset 0 1px 0 rgba(255, 255, 255, .9);
  display: grid; grid-template-rows: auto 1fr auto; gap: .8cqi;
  /* The text block starts close to the brand stripe (owner call): stripe + a short gap. */
  padding: 3.8cqi 5cqi 3cqi 4.6cqi; font-family: inherit;
}
.gcard::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 1.7cqi; min-width: 4px; background: var(--grad); }
.gcard-road { position: absolute; right: -8%; bottom: -10%; width: 72%; height: auto; pointer-events: none; }
.gcard-top { position: relative; z-index: 1; display: flex; justify-content: space-between; align-items: center; gap: 1rem; }
.gcard-logo { height: 6cqi; width: auto; display: block; }
.gcard-kind { font-size: 1.8cqi; letter-spacing: .18em; text-transform: uppercase; font-weight: 700; color: #4b5570; border: 1px solid rgba(27, 35, 64, .24); padding: .7cqi 1.9cqi; border-radius: 999px; white-space: nowrap; }
.gcard-mid { position: relative; z-index: 1; display: grid; align-content: center; gap: .4cqi; min-width: 0; }
.gcard-access { font-size: 2cqi; text-transform: uppercase; letter-spacing: .13em; font-weight: 700; color: #6b7590; }
.gcard-dur { font-size: 7cqi; font-weight: 800; letter-spacing: -.03em; line-height: 1.02; color: #1b2340; }
.gcard-names { font-size: 2.6cqi; color: #3a4460; margin-top: .3cqi; overflow-wrap: anywhere; }
.gcard-names b { color: #1b2340; font-weight: 700; }
.gcard-dot { color: #9aa2b8; }
.gcard-msg { margin: .6cqi 0 0; font-style: italic; font-size: 2.5cqi; line-height: 1.36; color: #2a3350;
  max-width: 36ch; white-space: pre-line; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
/* The footer row holds only the redeem line, pinned to the bottom-right CORNER (owner call):
   the negative margins pull it past the card's own padding, tighter than the pill above. */
.gcard-bot { position: relative; z-index: 1; display: flex; justify-content: flex-end; align-items: flex-end; margin: .4cqi -1.6cqi -1cqi 0; }
.gcard-code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 3.1cqi; letter-spacing: .09em; font-weight: 700; color: #1b2340;
  background: #fff; border: 1px solid rgba(27, 35, 64, .16); box-shadow: 0 1px 2px rgba(27, 35, 64, .06); padding: 1cqi 1.9cqi; border-radius: 1.8cqi; white-space: nowrap;
  justify-self: start; margin-top: 1cqi; }
.gcard-code.is-pending { color: #8a92a8; letter-spacing: .12em; }
/* The validity under the code, set like the access label above the headline (owner call). */
.gcard-valid { font-size: 2cqi; text-transform: uppercase; letter-spacing: .13em; font-weight: 700; color: #6b7590; margin-top: 1cqi; }
/* One line whenever it can be: nothing shares this row since the code moved up. */
.gcard-fine { font-size: 1.8cqi; color: #6b7590; text-align: right; line-height: 1.35; margin-left: auto; }

/* the buy page */
.giftwrap { display: grid; gap: 1.5rem; }
.gift-hero { text-align: center; max-width: 46rem; margin: 0 auto; }
.gift-eyebrow { display: inline-block; font-size: .78rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; color: var(--brand-on-white); }
.gift-hero h1 { margin: .35rem 0 .5rem; font-size: clamp(2rem, 5.5vw, 3.1rem); letter-spacing: -.03em; }
.gift-hero .lead { margin: 0 auto; max-width: 40rem; }
.gift-steps { list-style: none; display: flex; flex-wrap: wrap; justify-content: center; gap: .6rem 1.6rem; padding: 0; margin: 1.2rem 0 0; }
.gift-steps li { display: flex; align-items: center; gap: .5rem; color: var(--muted); font-size: .9rem; font-weight: 600; }
.gift-step-n { display: grid; place-items: center; width: 1.65rem; height: 1.65rem; border-radius: var(--r-pill); background: var(--grad-ui); color: #fff; font-size: .8rem; font-weight: 800; flex: none; }
.gift-grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); gap: 1.2rem; align-items: start; }
.gift-form { margin: 0; }
.gift-h { display: flex; align-items: center; gap: .6rem; font-size: 1.1rem; margin: 0 0 .9rem; }
.gift-form .gift-h + .gift-h, .gift-form .gift-note + .gift-h, .gift-form .gift-fields + .gift-h { margin-top: 1.6rem; }
.gift-h-n { display: grid; place-items: center; width: 1.6rem; height: 1.6rem; border-radius: 50%; background: var(--surface-2); color: var(--brand-on-white); font-size: .82rem; font-weight: 800; flex: none; }
.gift-h--side { margin-bottom: .7rem; }
.gift-plans { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: .6rem; }
.gift-plan { position: relative; display: grid; grid-template-columns: 1fr auto; align-items: center; gap: .2rem .6rem; padding: .95rem 1rem;
  border: 1px solid var(--border); border-radius: var(--r-md); cursor: pointer; transition: border-color .15s ease, background .15s ease; }
.gift-plan:hover { border-color: color-mix(in srgb, var(--brand) 45%, var(--border)); }
.gift-plan input { position: absolute; opacity: 0; width: 0; height: 0; }
.gift-plan:has(input:checked) { border-color: var(--brand); background: color-mix(in srgb, var(--brand) 7%, transparent); }
.gift-plan:has(input:focus-visible) { outline: 2px solid var(--brand); outline-offset: 2px; }
.gift-plan-t { display: grid; }
.gift-plan-dur { font-weight: 800; font-size: 1.05rem; }
.gift-plan-sub { color: var(--muted); font-size: .8rem; }
.gift-plan-p { font-weight: 800; font-size: 1.15rem; white-space: nowrap; }
.gift-plan .ck-m-tick { position: absolute; top: .5rem; right: .6rem; margin: 0; }
.gift-plan .ck-m-tick svg { width: .95rem; height: .95rem; }
.gift-plan:has(input:checked) .ck-m-tick { opacity: 1; }
.gift-plan-flag { grid-column: 1 / -1; justify-self: start; font-size: .66rem; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; color: var(--brand-on-white); }
.gift-note { display: flex; gap: .45rem; align-items: flex-start; margin: .8rem 0 0; color: var(--muted); font-size: .86rem; line-height: 1.45; }
.gift-note svg { width: 1rem; height: 1rem; flex: none; margin-top: .12rem; }
.gift-fields { margin-top: 0; }
.gift-msg { resize: vertical; min-height: 5.2rem; line-height: 1.45; }
.ffield:has(.gift-msg:focus) .flabel, .ffield:has(.gift-msg:not(:placeholder-shown)) .flabel { transform: translateY(-1.17rem) scale(.82); }
.ffield .gift-msg { padding-top: 1.25rem; }
.gift-count { display: block; text-align: right; margin-top: .3rem; }
.gift-modes { margin-bottom: 0; }
.gift-mode-t { display: inline-flex; align-items: center; gap: .45rem; font-weight: 700; }
.gift-mode-t svg { width: 1.05rem; height: 1.05rem; color: var(--brand-on-white); }
.gift-mail { margin-top: .9rem; padding: .9rem; border-radius: var(--r-md); background: var(--surface-2); }
.gift-mail[hidden] { display: none; }
.gift-when { display: inline-flex; padding: .2rem; border-radius: var(--r-pill); background: var(--surface); border: 1px solid var(--border); }
.gift-seg { position: relative; }
.gift-seg input { position: absolute; opacity: 0; width: 0; height: 0; }
.gift-seg span { display: inline-block; padding: .38rem .85rem; border-radius: var(--r-pill); font-size: .86rem; font-weight: 700; color: var(--muted); cursor: pointer; transition: background .15s ease, color .15s ease; }
.gift-seg:has(input:checked) span { background: var(--grad-ui); color: #fff; }
.gift-seg:has(input:focus-visible) span { outline: 2px solid var(--brand); outline-offset: 2px; }
.gift-mail .field[hidden] { display: none; }
.gift-side { position: sticky; top: 5.2rem; display: grid; gap: .8rem; }
.gift-side .btn { margin-top: .3rem; }
.gift-side .ck-fine { margin: 0; text-align: center; }
.gift-preview .gcard { box-shadow: 0 34px 60px -28px rgba(27, 35, 64, .45), inset 0 1px 0 rgba(255, 255, 255, .9); }
.gift-hero-redeem { margin: 1rem 0 0; }
.gift-hero-redeem .btn { gap: .5rem; }
.gift-hero-redeem svg { color: var(--brand-on-white); }
.gift-how ol { margin: 0; }
.gift-faq details { border-top: 1px solid var(--border); padding: .7rem 0; }
.gift-faq details:last-child { border-bottom: 1px solid var(--border); }
.gift-faq summary { cursor: pointer; font-weight: 700; list-style: none; display: flex; justify-content: space-between; align-items: center; gap: 1rem; }
.gift-faq summary::-webkit-details-marker { display: none; }
.gift-faq summary::after { content: "+"; color: var(--muted); font-weight: 700; font-size: 1.15rem; line-height: 1; flex: none; }
.gift-faq details[open] summary::after { content: "–"; }
.gift-faq p { margin: .5rem 0 0; color: var(--muted); font-size: .93rem; line-height: 1.5; }
.gift-redeemlink { text-align: center; color: var(--muted); font-size: .9rem; margin: 0; }
.gift-redeemlink a { font-weight: 700; }

/* the checkout in gift mode, and the return page */
.ck-sum--gift .gift-ck-card { margin: .3rem 0 1rem; }
.gift-ck-delivery { display: flex; gap: .5rem; align-items: flex-start; margin: .9rem 0 0; padding: .7rem .85rem; border-radius: var(--r-md); font-size: .88rem; line-height: 1.45; background: var(--ok-bg); color: var(--ok-ink); border: 1px solid color-mix(in srgb, var(--ok) 30%, transparent); }
.gift-ck-delivery svg { width: 1rem; height: 1rem; flex: none; margin-top: .15rem; }
.gift-done { display: grid; gap: 1.2rem; justify-items: center; }
.gift-done-head { max-width: 40rem; }
.gift-done-card { width: min(100%, 34rem); }

/* redeem */
.gift-rd .authform { text-align: left; }
.gift-codein { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; letter-spacing: .12em; font-weight: 700; font-size: 1.1rem; text-transform: uppercase; }
/* The fixed 'VM-' in front of the box: same face and size as the typed code, sitting on
   the input's own text line (the input's top padding is the floating caption's room). */
/* The wrapper is NOT positioned: a positioned wrapper would paint its input over the
   floating caption. The prefix is placed against .ffield instead, which is. */
.gift-codewrap { display: block; }
.gift-codewrap .gift-codein { padding-left: 3.1rem; }
.gift-codein-prefix { position: absolute; left: .8rem; bottom: .42rem; z-index: 1; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; letter-spacing: .12em; font-weight: 700; font-size: 1.1rem; line-height: 1.4; color: var(--muted); pointer-events: none; }
.gift-rd .authalt { margin-bottom: .6rem; }
.gift-rd .gift-redeemlink, .gift-rdwrap .gift-redeemlink { margin-top: 1rem; }
.gift-rd-aside .gcard { margin-bottom: .8rem; }
.gift-rd-ok .gift-rd-from { margin: .2rem 0 0; font-weight: 700; }
.gift-rd-ok .gift-rd-msg { margin: .3rem auto 0; font-style: italic; color: var(--muted); max-width: 32ch; white-space: pre-line; }

/* the buyer's card page + account list */
.gift-cardpage { display: grid; gap: 1.2rem; justify-items: center; }
.gift-cp-head { text-align: center; }
.gift-cp-head h1 { margin: 0 0 .3rem; }
.gift-cp-card { width: min(100%, 40rem); }
.gift-cp-note { min-height: 1.3em; margin: 0; color: var(--ok-ink); font-weight: 600; font-size: .9rem; }
.gift-list { list-style: none; margin: 0; padding: 0; display: grid; gap: .6rem; }
.gift-row { position: relative; display: grid; gap: .2rem; padding: .8rem .95rem; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--surface-2); }
.gift-row-main { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; padding-right: 6rem; }
.gift-row-code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-weight: 700; letter-spacing: .06em; }
.gift-row-link { position: absolute; top: .8rem; right: .95rem; display: inline-flex; align-items: center; gap: .3rem; font-size: .85rem; font-weight: 700; text-decoration: none; }
.gift-row-link svg { width: .95rem; height: .95rem; }
.price-foot-gift a { display: inline-flex; align-items: center; gap: .4rem; font-weight: 700; text-decoration: none; }
.price-foot-gift svg { width: 1rem; height: 1rem; color: var(--brand-on-white); }

@media (max-width: 860px) {
  .gift-grid { grid-template-columns: minmax(0, 1fr); }
  .gift-side { position: static; }
  .gift-row-main { padding-right: 0; }
  .gift-row-link { position: static; justify-self: start; margin-top: .2rem; }
}

/* Printing the card: the page becomes the card and nothing else. Colours are forced
   because a printer's "save ink" default would turn the dark card into a white one with
   invisible white type. */
@media print {
  .topbar, .footer, .gift-cardpage .noprint, .authnav, .mobile-nav, [data-consent], .consent,
  .edge-canvas, .consentbar, dialog { display: none !important; }
  body::before, body::after { display: none !important; }
  body, .view { background: #fff !important; padding: 0 !important; margin: 0 !important; }
  .gift-cardpage { display: block; }
  /* Real size: ISO ID-1, the card in a wallet. The hairline is the cutting edge. */
  .gift-cp-card { width: 85.6mm; margin: 15mm auto 0; }
  .gcard { -webkit-print-color-adjust: exact; print-color-adjust: exact; box-shadow: none; break-inside: avoid; border-color: #b8bfcf; }
}

/* ============================================ dev illustration review (#/dev/illustrations)
   Behind DEV_TOOLS + DEV_EMAIL like the rest of /dev, and styled out of the existing
   primitives. The one thing it genuinely needs is a SIDE-BY-SIDE at equal width: the whole
   screen exists to judge a generated image against the drawing it would replace, and any
   difference in size between the two panes reads as a difference in the artwork. */
.illus-grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); margin-top: 1rem; }
.illus-cell { display: block; padding: 0; overflow: hidden; text-decoration: none; }
/* Once a candidate exists the cell shows BOTH, because the whole point of the list is
   scanning a batch — a chip saying "candidate" makes you open 31 pages to see 31 frames.
   A cell that has one is given two columns of the grid so neither picture shrinks. */
.illus-cell.has-cand { grid-column: 1 / -1; }
.illus-pair { display: grid; grid-template-columns: 1fr; }
.has-cand .illus-pair { grid-template-columns: 1fr 1fr; gap: 2px; }
@media (max-width: 720px) { .has-cand .illus-pair { grid-template-columns: 1fr; } }
/* The placeholder holds the 20:9 of the viewBox before its SVG is lazily filled in, so the
   grid does not reflow under the reader as thumbnails arrive on scroll. */
.illus-art { aspect-ratio: 20 / 9; background: var(--surface-2); }
.illus-art .scene { margin: 0; }
.illus-art figcaption { display: none; }
.illus-meta { padding: .7rem .9rem; }
.illus-meta h3 { margin: 0 0 .2rem; font-size: .9rem; display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; }
.illus-meta p { margin: 0; color: var(--muted); font-size: .85rem; }

.illus-compare { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: start; margin: .8rem 0; }
@media (max-width: 760px) { .illus-compare { grid-template-columns: 1fr; } }
.illus-compare figure { margin: 0; }
.illus-compare > figure > figcaption { margin-bottom: .35rem; }
.illus-compare .scene { margin: 0; }
/* Same 20:9 band the scene draws in, so the two panes are the same picture size. A
   candidate of another ratio is letterboxed rather than cropped — a crop would hide
   exactly the edge of frame a wrong-side or stray-sign error hides in. */
.illus-cand, .illus-empty { width: 100%; aspect-ratio: 20 / 9; border-radius: var(--r-md); }
.illus-cand { object-fit: contain; background: var(--surface-2); }
.illus-empty { display: grid; place-items: center; text-align: center; padding: 1rem;
  color: var(--muted); font-size: .85rem; background: var(--surface-2); border: 1px dashed var(--border); }
.illus-prompt { width: 100%; font: .82rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; resize: vertical; }
.illus-opts { margin: .4rem 0; padding-left: 1.2rem; font-size: .9rem; }
.illus-key { font-weight: 700; color: var(--ok); }
