/* ============================================================================
   Motion layer — everything that moves outside the illustrations: reveals,
   micro-interactions, hero ambience, answer feedback, result celebration.
   Driven by web/app/motion.js, which sets .has-motion on <html> so that a
   no-JS / failed-boot page still shows all content (reveals hide nothing until
   the runtime is proven alive). Every effect is dropped by prefers-reduced-motion
   at the bottom of this file.
   ========================================================================== */

/* ---------------------------------------------------------- scroll progress */
#scrollprog {
  position: fixed; inset: 0 0 auto 0; height: 3px; z-index: 60;
  background: var(--grad); transform: scaleX(0); transform-origin: 0 50%;
  transition: transform .1s linear; pointer-events: none;
}

/* ------------------------------------------------------------ view entrance */
/* staggered replacement for the plain rise in styles.css */
.view > * { animation: m-rise .3s cubic-bezier(.22,.61,.36,1) both; animation-delay: calc(var(--vi, 0) * 55ms); }
/* A render that arrived inside a view transition (routeWithTransition, the Skilt tabs): the
   transition is its entrance, and this rise-in under it blanked the page and moved the pill's
   target — see the note in main.js. Attribute, not class, so it outlives nothing but the nodes. */
.view > [data-m-norise] { animation: none; }
/* ⚠️ NO `filter` IN THIS KEYFRAME. It used to start at `filter: blur(3px)`, and on
   iOS Safari that left the question card **permanently soft**: a filter promotes the
   element to its own compositing layer, the layer is rasterized once, and WebKit does
   not always re-rasterize it when the filter animates back to none — so the text stayed
   at the blurred raster. It showed worst in the mock exam because this animation re-runs
   on every question, but the selector is `.view > *`, so it was every route. Explicit
   `to` for the same reason: end on a stated, filter-free state rather than an implicit
   one. Opacity and transform alone give the same reveal at no rasterization risk. */
@keyframes m-rise {
  from { opacity: 0; transform: translateY(14px); }
  to { opacity: 1; transform: none; }
}

/* --------------------------------------------------------- reveal on scroll */
.has-motion [data-reveal] {
  opacity: 0; transition: opacity .65s cubic-bezier(.22,.61,.36,1), transform .65s cubic-bezier(.22,.61,.36,1);
  transition-delay: var(--rd, 0ms);
}
.has-motion [data-reveal="up"] { transform: translateY(28px); }
.has-motion [data-reveal="zoom"] { transform: scale(.93); }
.has-motion [data-reveal="left"] { transform: translateX(-30px); }
.has-motion [data-reveal="right"] { transform: translateX(30px); }
.has-motion [data-reveal="pop"] { transform: translateY(18px) scale(.92); }
.has-motion [data-reveal].in { opacity: 1; transform: none; }

/* ------------------------------------------------------------------- topbar */
/* Scroll-edge effect (Apple HIG): signal "scrolled" with shadow + background,
   not by animating layout. Padding still condenses on scroll but isn't
   transitioned — a 4px height snap is imperceptible next to the shadow fade,
   and it keeps the sticky bar off the layout/paint path per frame. */
.topbar { transition: box-shadow .25s ease, background .25s ease; }
body.scrolled .topbar { padding-top: .45rem; padding-bottom: .45rem; box-shadow: 0 6px 24px rgba(16,24,40,.1); }
.brand { transition: transform .18s cubic-bezier(.2,1.5,.4,1); display: inline-block; }
.brand:hover { transform: scale(1.05) rotate(-1.5deg); }
.mainnav a.active { color: var(--text); }
.mainnav a.active::after { right: 0; }
.authnav .btn.small { transition: transform .18s cubic-bezier(.2,1.5,.4,1), box-shadow .2s ease, filter .2s ease; }
.authnav .btn.small:hover { transform: translateY(-2px); }

/* ------------------------------------------------------------------ buttons */
.btn { position: relative; overflow: hidden; isolation: isolate; }
.btn::after {
  content: ""; position: absolute; inset: 0; z-index: -1; pointer-events: none;
  background: linear-gradient(105deg, transparent 32%, rgba(255,255,255,.4) 50%, transparent 68%);
  transform: translateX(-130%); transition: transform .65s cubic-bezier(.3,0,.2,1);
}
.btn:hover::after { transform: translateX(130%); }
.btn.primary:hover { transform: translateY(-2px); }
.btn.big:hover { transform: translateY(-3px) scale(1.015); }
/* keep the press feedback from styles.css, which the hover lift would outrank */
.btn:active, .btn.primary:active, .btn.big:active { transform: translateY(1px) scale(.99); }
.btn svg { transition: transform .22s cubic-bezier(.2,1.4,.4,1); }
.btn:hover svg { transform: translateX(3px); }
.m-ripple {
  position: absolute; border-radius: 50%; pointer-events: none; z-index: -1;
  background: radial-gradient(circle, rgba(255,255,255,.55), rgba(255,255,255,0) 70%);
  transform: translate(-50%, -50%) scale(0); animation: m-ripple .6s ease-out forwards;
}
.btn:not(.primary) .m-ripple { background: radial-gradient(circle, color-mix(in srgb, var(--brand) 35%, transparent), transparent 70%); }
@keyframes m-ripple { to { transform: translate(-50%, -50%) scale(1); opacity: 0; } }

/* attention pulse for the single most important CTA on a view — three beats on
   arrival, then it settles, so a returning visitor isn't nagged by it forever */
.btn.pulse-cta { animation: m-cta 2.2s ease-in-out 3; }
@keyframes m-cta {
  0%, 100% { box-shadow: 0 6px 18px color-mix(in srgb, var(--brand) 35%, transparent); }
  50% { box-shadow: 0 6px 18px color-mix(in srgb, var(--brand) 35%, transparent), 0 0 0 10px color-mix(in srgb, var(--brand) 0%, transparent); }
}

/* -------------------------------------------------------------------- cards */
/* cursor spotlight — JS keeps --mx/--my in sync on hover */
.spot::before {
  content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
  background: radial-gradient(240px circle at var(--mx, 50%) var(--my, 0%),
    color-mix(in srgb, var(--accent, var(--brand)) 14%, transparent), transparent 72%);
  opacity: 0; transition: opacity .3s ease;
}
.spot:hover::before { opacity: 1; }
/* NO `transform-style: preserve-3d` here, ever. Nothing inside a tilted card is
   3D-transformed, so it buys no visual — but it makes the card a 3D rendering context,
   and Chrome then hit-tests the card's children against a different projection than it
   paints them with. Over the "Get started" link on a tilted price card that means the
   hit target alternates between the <a> and the <article> as the pointer moves, i.e.
   the cursor vibrates between finger and arrow, and clicks land on whichever won that
   frame. Measured 2026-08-23 at the button's centre with a moving pointer: 13 flips in
   one second with preserve-3d, 0 with it gone. The 1-month card was immune only by
   accident — the `overflow: hidden` it carries for its sheen already forces the used
   value back to `flat`. */
.tilt { transition: transform .35s cubic-bezier(.2,.8,.3,1), box-shadow .3s ease; }
/* The tilt has to follow the pointer, so it owns its own timing — and has to say so
   loudly: `.has-motion [data-reveal]` (this file) puts a .65s transform transition on
   every revealed card and `.has-motion .pricing-grid [data-reveal]` (styles.css) adds
   the stagger delay on top, both of which outrank a bare `.tilt`. That is the lag you
   feel on the 2nd and 3rd price card and not the 1st: the same tilt, arriving 90ms and
   180ms late and then taking 650ms to get there. */
.has-motion [data-reveal].tilt.tilting,
.tilt.tilting { transition: transform .08s linear, box-shadow .3s ease; transition-delay: 0s; }
.sec-card .sec-ico, .feature .ficon { transition: transform .3s cubic-bezier(.2,1.5,.4,1); }
.sec-card:hover .sec-ico, .feature:hover .ficon { transform: translateY(-4px) rotate(-6deg) scale(1.07); }
.mtopic { transition: transform .16s ease, border-color .18s ease, box-shadow .2s ease; }
.mtopic:hover { box-shadow: var(--shadow-lg); }
.stat { transition: transform .18s ease, border-color .18s ease; }
.stat:hover { transform: translateY(-3px); border-color: color-mix(in srgb, var(--brand) 40%, var(--border)); }
.chip { transition: background .16s ease, color .16s ease, border-color .16s ease, transform .12s ease; }
.chip:hover { transform: translateY(-2px); }
.chip:active { transform: translateY(0) scale(.97); }

/* ------------------------------------------------------------- hero ambience
   The gradient itself is static (animating background-position repaints every
   frame); only the orbiting highlight below moves, and that's transform/opacity
   only, so it's the one piece of hero motion that's actually cheap to run. */
.cta-band::after {
  content: ""; position: absolute; inset: -40%; pointer-events: none; z-index: 0;
  background: radial-gradient(closest-side, rgba(255,255,255,.28), transparent 70%);
  animation: m-orbit 16s linear infinite;
}
.cta-band > * { z-index: 1; }
@keyframes m-orbit {
  0% { transform: translate(-12%, -6%) scale(1); } 50% { transform: translate(14%, 8%) scale(1.15); }
  100% { transform: translate(-12%, -6%) scale(1); }
}
/* .hero (the signed-in home) rides these too since it was un-boxed to match (2026-09-14). */
.lp-hero, .hero { isolation: isolate; }
.lp-hero::after, .hero::after {
  /* Lifted from .5 / 45% / 40% (owner, 2026-09-14: "a bit more visible"). Still under the
     headline's contrast floor in light — checked over CDP after the change. */
  content: ""; position: absolute; inset: -35%; z-index: 0; pointer-events: none; opacity: .68;
  background:
    radial-gradient(closest-side, color-mix(in srgb, var(--brand) 58%, transparent), transparent 70%) 20% 30% / 55% 55% no-repeat,
    radial-gradient(closest-side, color-mix(in srgb, var(--blue) 52%, transparent), transparent 70%) 80% 70% / 50% 50% no-repeat;
  filter: blur(40px); animation: m-orbit 24s ease-in-out infinite;
}
.lp-hero > *, .hero > * { position: relative; z-index: 1; }
/* JS parallax on the hero art. The device replicas take the same treatment from
   showcase.css (.dev-cluster), which is also where the phone's idle bob now lives —
   the .phone/.pm-* mockup these rules were written for was retired on 2026-08-15. */
.hero-scene { translate: calc(var(--px, 0) * -8px) calc(var(--py, 0) * -8px); transition: translate .4s ease-out; }
/* The landing hero's frame wall (app/heroframes.js) takes the same pointer parallax, but
   per card and scaled by --z, so the near cards lead the far ones and the cluster gets
   depth instead of sliding as one sheet. The -50% keeps the card centred on its slot —
   styles.css places every card from its centre so a tilt cannot push it out of the box,
   and this calc must carry that centring, not replace it. */
/* …and they ROLL in from the side they belong to, one after another, nearest first — the
   driving-tests.org entrance (its `rollFromLeft`/`rollFromRight`: translateX(±150%) plus a
   ±90° spin, unwinding to rest). Each card comes from its own edge, so the wall assembles
   inward from both sides instead of fading up in place.

   A TRANSITION between two states, not a keyframed animation, because the roll has to REPLAY:
   `.in` is toggled by heroDepth() in motion.js as the hero leaves and re-enters the viewport
   (AOS's `mirror: true` on the reference), and a one-shot @keyframes would have to be torn off
   and re-armed by hand each time. Two states and one transition do it for free — and the
   roll-out runs while the hero is already off screen, so nobody ever sees it leave.

   ⚠️ The roll is on `transform`, while a card's resting state is `translate` + `rotate` —
   three SEPARATE properties. That is the whole trick: the browser applies translate, then
   rotate, then transform, so the roll composes on top of the centring and the tilt without
   restating either, and the pointer parallax keeps writing `translate` underneath while the
   roll is still running. Hence three entries in the transition, each with its own timing.

   --hfd (not --rd, the reveal stagger) is read off --z rather than an --i the markup would
   carry: depth already orders the cards, and the big near ones arriving first is what makes
   the rest read as settling in behind them. */
.hf { --hfd: calc((1 - var(--z, 1)) * 420ms);
  translate: calc(-50% + var(--px, 0) * var(--z, 1) * -14px)
             calc(-50% + var(--py, 0) * var(--z, 1) * -14px);
  transition: translate .4s ease-out,
              transform .8s var(--ease-out) var(--hfd),
              opacity .8s var(--ease-out) var(--hfd); }
.has-motion .hero-frames .hf { opacity: 0; transform: translateX(150%) rotate(90deg); }
.has-motion .hero-frames .hf[data-flank="l"] { transform: translateX(-150%) rotate(-90deg); }
.has-motion .hero-frames.in .hf { opacity: 1; transform: none; }

/* Scroll depth on the landing hero: --hero-p (0→1 over the first ~70vh, written by
   heroDepth() in motion.js) pushes the art down 30px and the aurora 54px as the
   page scrolls up past them, so the hero separates into layers instead of leaving
   as one flat card. `translate`, not `transform`: the pointer parallax above and
   the aurora's m-orbit both live on `transform`/`translate` of OTHER elements or
   properties, and the individual translate property composes with a keyframed
   transform instead of fighting it. Transform/opacity only, so none of it counts
   toward CLS. */
.has-motion .lp-visual { translate: 0 calc(var(--hero-p, 0) * 30px); opacity: calc(1 - var(--hero-p, 0) * .22); }
.has-motion .lp-hero::after, .has-motion .hero::after { translate: 0 calc(var(--hero-p, 0) * 54px); }

/* The hero's three ✓ chips land one after another just after the view's own rise
   (.view > * is .3s), a one-shot cascade that walks the eye down the hero:
   title → sub → CTAs → proof. nth-child, not --i: the three are static markup in
   landingHome(), and the boot shell renders no .lp-trust, so nothing replays. */
.has-motion .lp-hero .lp-trust span { animation: m-chip-in .4s var(--ease-out) backwards; animation-delay: .3s; }
.has-motion .lp-hero .lp-trust span:nth-child(2) { animation-delay: .42s; }
.has-motion .lp-hero .lp-trust span:nth-child(3) { animation-delay: .54s; }
@keyframes m-chip-in { from { opacity: 0; transform: translateY(8px); } }

/* hero illustration (app/herodrive.js) — the road, the scenery, the centre line, the
   suspension and the wheels are all driven from JS, because they have to agree with
   one camera model and one distance travelled; CSS keyframes cannot express "bend the
   road and put the trees where the bend now is". What is left here is the ambience
   that is genuinely independent of the drive: the sun's glow, and the two clouds'
   drift. Both sit *inside* `.h-sky`, which carries the JS-set yaw transform — the
   scenes.js rule (never CSS-animate transform on a node that carries a transform
   attribute) is kept by putting the two on different elements.
   `m-dash` and `m-carbob` were retired with the static trapezoid: a stroke-dashoffset
   on a straight line cannot follow a curve, and a 2.8s sine bob is a time-based idle,
   not a ride — the bob is now a function of metres travelled. */
.hero-scene .h-sun { transform-box: fill-box; transform-origin: 50% 50%; animation: m-sunglow 5s ease-in-out infinite; }
.hero-scene .h-cloud.a { animation: m-cloudA 34s linear infinite; }
.hero-scene .h-cloud.b { animation: m-cloudB 46s linear infinite; }
@keyframes m-sunglow { 50% { transform: scale(1.06); opacity: .85; } }
@keyframes m-cloudA { from { transform: translateX(-190px); } to { transform: translateX(520px); } }
@keyframes m-cloudB { from { transform: translateX(-350px); } to { transform: translateX(520px); } }

/* --------------------------------------------------------------- stats band */
.band-item { position: relative; }
.band-num { font-variant-numeric: tabular-nums; }
/* One small pop as a count-up lands on its final value — the number "arriving".
   startCount() adds the class only on the animated path, so dashboards (READ_NOW)
   and reduced-motion, which paint the final value directly, never move. */
.band-num.m-count-done { animation: m-count-pop .32s var(--ease-spring); }
@keyframes m-count-pop { 40% { transform: scale(1.07); } }

/* ------------------------------------------------------------ sign marquee */
.marquee { overflow: hidden; padding: .25rem 0; -webkit-mask-image: linear-gradient(90deg, transparent, #000 7%, #000 93%, transparent); mask-image: linear-gradient(90deg, transparent, #000 7%, #000 93%, transparent); }
.marquee + .marquee { margin-top: .7rem; }
.marquee-track { display: flex; gap: .7rem; width: max-content; animation: m-marquee 46s linear infinite; }
.marquee.rev .marquee-track { animation-direction: reverse; }
.marquee:hover .marquee-track { animation-play-state: paused; }
/* 100, not 74: at the old size a sign was recognised rather than read, and the
   section's whole claim is that you can learn them here. Less inner padding too,
   so the growth goes to the artwork instead of the tile around it. */
.marquee .show-sign { width: 100px; flex: none; padding: .5rem; }
@keyframes m-marquee { to { transform: translateX(-50%); } }
/* Each row enters from the direction it scrolls — the top row drifts leftward, so
   it arrives from the left; the reversed row from the right — one shot, when the
   section's reveal lands. On the .marquee element, never .marquee-track: the track
   is mid-loop on `transform`, and a second transform there would restart it, while
   the parent's `translate` composes. The masked edges hide the off-screen start,
   and .view's overflow-x clip keeps the +48px side out of the scroll width. */
.has-motion .showcase.in .marquee { animation: m-mq-in .8s var(--ease-out) backwards; }
.has-motion .showcase.in .marquee.rev { animation-name: m-mq-in-rev; }
@keyframes m-mq-in { from { opacity: 0; translate: -48px 0; } }
@keyframes m-mq-in-rev { from { opacity: 0; translate: 48px 0; } }

/* ------------------------------------------------------- exam / practice UI
   The reading surface stays still. Answering is the one moment that gets motion,
   because right/wrong is the feedback the whole app exists to deliver; hovering
   and navigating do not, because they happen constantly (options are re-rendered
   on every ←/→ and the jump grid is swept dozens of times per exam) and any
   motion there is motion under the sentence you are mid-way through reading.
   Options deliberately have NO entrance of their own: `.view > *` already brings
   the whole question card in, so a second staggered animation per option was
   double motion — and because `.opt.correct` re-declared that same animation,
   picking an answer restarted it, sliding the correct option up from below at the
   exact moment it should simply light up. */
.qcard { transition: box-shadow .3s ease; }
.opt { position: relative; overflow: hidden; }
.opt .key { transition: transform .2s cubic-bezier(.2,1.6,.4,1), background .14s ease, color .14s ease; }
.opt.selected .key { animation: m-pop .3s cubic-bezier(.2,1.6,.4,1); }
.opt.correct::after, .opt.wrong::after {
  position: absolute; right: .85rem; top: 50%; font-weight: 800; font-size: 1.05rem;
  animation: m-badge .45s cubic-bezier(.2,1.7,.4,1) both;
}
/* The ✓ is DRAWN, not just popped (2026-08-01, under the raised 2s ceiling).
   A left-to-right clip wipe tracks the way a check is actually written — short
   arm first, then the long up-stroke — so the mark reads as being made rather
   than appearing. It layers over m-badge (different properties: clip-path vs
   transform/opacity), which is why both can run without one restarting the other.
   Correct only: ✕ keeps the plain pop, because dwelling on a wrong answer is not
   something to make satisfying — the eye should move to the explanation.
   Note the wipe lives ONLY in the keyframes: `.opt.correct::after` is already in
   the reduced-motion kill list below, and with the animation off there is no
   clip-path anywhere, so the tick simply renders whole. */
.opt.correct::after {
  content: "✓"; color: var(--ok);
  animation: m-badge .45s cubic-bezier(.2,1.7,.4,1) both,
             m-check-draw var(--dur-slow, .5s) cubic-bezier(.22,.61,.36,1) both;
}
.opt.wrong::after { content: "✕"; color: var(--bad); }
@keyframes m-check-draw { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0 -12% 0 0); } }
.opt.correct { animation: m-glow-ok .9s ease .1s; }
.opt.wrong .key { animation: m-shake .45s ease; }
@keyframes m-badge { from { opacity: 0; transform: translate(10px, -50%) scale(.4); } to { opacity: 1; transform: translate(0, -50%) scale(1); } }
@keyframes m-pop { 50% { transform: scale(1.16); } }
/* Swells the resting halo `.opt.correct` already carries (styles.css) instead of
   declaring a shadow of its own — box-shadow is one property, so a keyframe that
   named only the glow would blank the hairline and the elevation for the 0.9s it
   ran and the option would visibly flatten, then pop back, on every right answer.
   Both stops therefore restate the resting layers around the ring that grows. */
@keyframes m-glow-ok {
  0%, 100% { box-shadow: var(--hairline), 0 0 0 3px color-mix(in srgb, var(--ok) 18%, transparent), var(--e1); }
  40% { box-shadow: var(--hairline), 0 0 0 8px color-mix(in srgb, var(--ok) 26%, transparent), var(--e1); }
}
@keyframes m-shake { 10%, 90% { transform: translateX(-2px); } 30%, 70% { transform: translateX(3px); } 50% { transform: translateX(-3px); } }
.explain { animation: m-slide .45s cubic-bezier(.22,.61,.36,1) both; overflow: hidden; }
@keyframes m-slide { from { opacity: 0; transform: translateY(-8px); max-height: 0; padding-top: 0; padding-bottom: 0; } }
/* The progress bar does NOT shimmer. It used to carry an infinite `m-sweep` — a
   highlight crossing it every 1.9s — and `.pbar` is on all three learning
   surfaces (exam runner, practice runner, sign drill), so during a 90-minute
   exam that was ~2,800 sweeps animating a few centimetres under the question
   being read. That is precisely the "nothing loops while the user is reading"
   rule this file already applies to the question grid and the timer; the bar had
   simply been missed. Its width transition below is the motion that carries
   meaning: it moves when, and only when, progress actually changes.
   `m-sweep` itself stays — the loading skeleton and `.skel` still use it, where a
   perpetual shimmer is the whole point because it says "still working". */
.pbar > i { position: relative; overflow: hidden; }
@keyframes m-sweep { from { transform: translateX(-100%); } to { transform: translateX(200%); } }
/* The timer's warning state is a colour/weight change, not a heartbeat: it holds
   for the last five minutes, and a five-minute pulse in the corner of the eye
   competes with the question for attention rather than informing. The digits are
   already counting — that is the motion. See .timer.warn in styles.css. */
.timer { transition: color .2s ease, border-color .2s ease, background .2s ease; }
.qgrid button { transition: transform .14s cubic-bezier(.2,1.6,.4,1), background .18s ease, border-color .18s ease; }
.qgrid button:hover { transform: scale(1.07); }
/* no pulse on the current cell — styles.css gives it a solid outline, which says
   "you are here" without animating for the whole 90 minutes */
.kbdhint kbd { transition: transform .15s ease; }
.kbdhint:hover kbd { transform: translateY(-1px); }

/* ------------------------------------------------------------------ results */
.result-hero { position: relative; overflow: hidden; }
.result-hero.pass::before {
  content: ""; position: absolute; inset: -50%; pointer-events: none;
  background: radial-gradient(closest-side, color-mix(in srgb, var(--ok) 22%, transparent), transparent 70%);
  animation: m-orbit 14s linear infinite;
}
.verdict { animation: m-verdict .8s cubic-bezier(.2,1.5,.4,1) both .3s; }
@keyframes m-verdict { from { opacity: 0; transform: translateY(14px) scale(.85); } }
/* The sign-drill / practice summary's score line lands the same way the exam verdict
   does — one spring, beginning as the ring's fill gets going. Completing a deck is
   rare-tier, which is where this budget lives; the JS pairs it with confetti only on
   a perfect run. */
.sp-verdict { animation: m-verdict .8s cubic-bezier(.2,1.5,.4,1) both .3s; }
.ring-fg { transition: stroke-dashoffset 1.1s cubic-bezier(.22,.61,.36,1); }
/* the speedometer needle sweeps with its own fill arc — same duration, same ease
   (renderResult starts it at 0 and hands over the final angle, fillRings-style) */
.sp-needle { transition: transform 1.1s cubic-bezier(.22,.61,.36,1); }
.bar > i { transition: width .9s cubic-bezier(.22,.61,.36,1); }
#m-confetti { position: fixed; inset: 0; z-index: 70; pointer-events: none; }

/* The pass mascot: the car from the login card drives into the result hero, and on
   the confetti beat (950ms — as the needle lands) flashes its headlights twice, the
   Norwegian driver's thank-you. One-shot per result render; renderResult adds
   m-flash only under a live, unreduced motion runtime. */
.has-motion .rescar { animation: m-rescar-in .5s var(--ease-out) backwards .15s; }
@keyframes m-rescar-in { from { opacity: 0; transform: translateX(-42px); } }
.rescar.m-flash .ac-glow { animation: ac-flash 1.2s ease-in-out both; }
.rescar.m-flash .ac-lightin { animation: ac-flash-in 1.2s ease-in-out both; }
@keyframes ac-flash {
  0%, 100% { opacity: 0; }
  15%, 30% { opacity: .9; }
  45% { opacity: 0; }
  60%, 75% { opacity: .9; }
  90% { opacity: 0; }
}
@keyframes ac-flash-in {
  15%, 30% { fill: #fff6d9; }
  60%, 75% { fill: #fff6d9; }
}

/* The wiper loader (waitForApp's skeleton): the one licensed loop tier — a loading
   state, same as the .skel shimmer it sits on. Parallel arms, a beat over the
   shimmer's 1.4s so the two never read as synchronised. */
.wl-arm { animation: wl-sweep 1.05s ease-in-out infinite alternate; }
@keyframes wl-sweep { from { transform: rotate(24deg); } to { transform: rotate(-24deg); } }

/* -------------------------------------------------------------------- signs
   A sign is NEVER rotated. Orientation is part of what the learner has to
   recognise: 25 signs in the catalogue differ from a sibling only by which way
   they face (106.1 "fra begge sider" vs 106.3 "fra venstre", 146.1-.5, 527.1-.4),
   and the mirror pairs (330.1/.2, 510.1/.2, 526.1/.2 …) are the same sign flipped.
   Tilting the artwork on hover teaches that the angle is decorative. It is not.
   Scale only, so the sign gets bigger — which is the thing you actually want when
   you are squinting at a detail. */
.sign .signimg, .show-sign img, .show-sign svg { transition: transform .3s cubic-bezier(.2,1.4,.4,1); }
.sign:hover .signimg { transform: scale(1.12); }
.show-sign { transition: transform .2s cubic-bezier(.2,1.5,.4,1), box-shadow .25s ease; }
.show-sign:hover { transform: translateY(-5px) scale(1.1); box-shadow: var(--shadow-lg); }
.searchbar input { transition: box-shadow .2s ease, border-color .2s ease; }
.searchbar input:focus { box-shadow: 0 0 0 4px color-mix(in srgb, var(--brand) 14%, transparent); }
/* Catalogue cards have no entrance animation: the grid re-renders on every
   keystroke in the search box, so a staggered fade-up replayed across the whole
   result set on each letter typed — search-as-you-type is the one interaction that
   must never animate. The section still arrives via `.view > *`. */

/* --------------------------------------------------------------- forms/misc */
.input, .select, .langsel { transition: box-shadow .2s ease, border-color .2s ease, transform .15s ease; }
.input:focus { box-shadow: 0 0 0 4px color-mix(in srgb, var(--brand) 14%, transparent); }
.formnote { animation: m-slide .35s ease both; }
.fielderr:not(:empty) { animation: m-shake .4s ease; }
/* One-shot arrival pop, not an infinite bob: the gate/verify/result icon
   announces itself once, then rests so the eye moves to the CTA below.
   280ms, well inside the 2s ceiling; neutralised under reduced-motion (below). */
/* No `display` here: this sheet loads after styles.css, and an `inline-block`
   left over from the emoji version was overriding the icon well's grid centring. */
.gateicon { animation: m-gate-in .28s var(--ease-spring) both; }
@keyframes m-gate-in { from { opacity: 0; transform: scale(.82); } to { opacity: 1; transform: scale(1); } }
/* The forgot-password <dialog> used to pop fully-formed via showModal(); this is its
   arrival. ENTRY ONLY on purpose: close() also remove()s the node, and an exit the
   user asked for should be instant, not something they wait out (slow where the user
   decides, fast where the system responds). */
.pwdlg[open] { animation: m-dlg-in .24s var(--ease-out); }
.pwdlg[open]::backdrop { animation: m-fade-in .24s ease; }
@keyframes m-dlg-in { from { opacity: 0; transform: translateY(10px) scale(.96); } }
@keyframes m-fade-in { from { opacity: 0; } }
/* Marking a course lesson read: the button's ✓ draws itself — the same written-stroke
   vocabulary as a correct answer (m-check-draw above) — and the button pops once,
   filling the ~550ms beat courseLesson()'s done handler holds before advancing to the
   next lesson. Feedback first, then the page may change. */
.btn.m-lesson-done { animation: m-count-pop .32s var(--ease-spring); }
.btn.m-lesson-done svg { animation: m-check-draw .45s cubic-bezier(.22,.61,.36,1) both; }

/* The progress trend line draws itself, left to right, once per render (2026-09-14). Same
   vocabulary as the ring/bar fills-from-zero that docs/frontend.md already permits on progress
   panels: one-shot, tied to a number appearing, never looping. `pathLength="1"` on the path
   normalises its length so a 1 -> 0 dashoffset works whatever the line's real geometry is.
   The area wash and the dots follow the line in rather than pre-empting it. */
.chart-line[pathLength] { stroke-dasharray: 1; animation: m-chart-draw 1.1s cubic-bezier(.22,.61,.36,1) both; }
.chart-area { animation: m-fade-in .6s ease .5s both; }
.chart-dot { animation: m-count-pop .32s var(--ease-spring) .9s both; transform-box: fill-box; transform-origin: center; }
@keyframes m-chart-draw { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }

/* Skilt tabs — the active pill slides between Skiltkatalog and Skiltøving (View Transitions,
   driven by routeWithTransition() in main.js; see the note there for why it is scoped to these
   two routes only). The pill carries the name, so the browser morphs its box from the old tab to
   the new one; its own old/new snapshots crossfade inside that box, which is what carries the
   label change. Scoped to the signs tablist — the schools page reuses .seg for a list/map switch
   that does not route, and a second element with this name on one page would abort the transition.
   ⚠️ The ROOT pair is silenced, and the OLD root snapshot is hidden rather than merely un-animated:
   the two snapshots blend with plus-lighter, so two fully opaque roots on top of each other flash
   the whole page brighter for a frame. Hiding the old one leaves an instant page swap under a
   moving pill, which is the whole intent. --dur-base is the route-change tier (docs/frontend.md). */
.seg[role="tablist"] .seg-pill { view-transition-name: seg-pill; }
/* The two LABELS are lifted as well, and this is about layering, not motion. Everything with a
   view-transition-name is painted on a layer above the page snapshot, so a moving pill that was
   the only lifted thing covered the destination label until the transition ended and the live
   DOM's stacking (pill behind text) came back — "the toggle goes white until it arrives" (owner,
   2026-09-14). Lifting the tabs gives them their own layer to order above the pill. Their boxes
   do not move between the two routes, so their groups stand still and only cross-fade the colour
   change (muted <-> text). The tab snapshot excludes the pill because the pill is named itself. */
.seg[role="tablist"] .seg-btn:nth-child(1) { view-transition-name: seg-tab-1; }
.seg[role="tablist"] .seg-btn:nth-child(2) { view-transition-name: seg-tab-2; }
::view-transition-group(seg-pill) { z-index: 1; }
::view-transition-group(seg-tab-1), ::view-transition-group(seg-tab-2) { z-index: 2; }
::view-transition-old(seg-tab-1), ::view-transition-new(seg-tab-1),
::view-transition-old(seg-tab-2), ::view-transition-new(seg-tab-2) { animation-duration: var(--dur-base); }
::view-transition-old(root) { display: none; }
::view-transition-new(root) { animation: none; }
::view-transition-group(seg-pill) { animation-duration: var(--dur-base); animation-timing-function: var(--ease-out); }
/* One image, filling the moving box. Two defaults were the wobble the owner saw:
   (1) the UA sizes snapshot images `height: auto`, so as the group narrowed from the wider
       label to the narrower one the image kept its aspect ratio and SHRANK vertically,
       top-anchored — the pill dipped mid-slide;
   (2) old and new cross-fade, and they were snapshots of two different labels.
   The pill is now a plain surface, so the old image adds nothing — hidden — and the new one is
   pinned to 100% height with no fade. What is left is a single rounded rect sliding and gently
   resizing, which is the whole effect. */
::view-transition-old(seg-pill) { display: none; }
::view-transition-new(seg-pill) { animation: none; height: 100%; }

/* Bookmarking a lesson, same beat and the same reason. cToggleMark() re-renders the whole
   reader, so the glyph simply blinked outline -> filled with nothing to say a state had
   changed — the one control here that changed state silently. The ribbon unfurls DOWNWARD
   (clip from the top), which is the motion the object itself makes, so it reads as the
   bookmark dropping into place rather than as a generic fade.

   ⚠️ Marking only. Un-bookmarking gets no animation: there is no ribbon to drop, and
   replaying the same flourish for the undo would make the two states indistinguishable by
   feel. The JS adds this class on the marking direction only.

   The `-12%` end inset matches m-check-draw's: a clip that stops exactly at 100% shaves the
   stroke's outer edge on fractional-pixel layouts. */
/* ⚠️ `.course-bmk`, NOT `.btn` — the reader's bookmark is its own control and carries
   neither class. Written as `.btn` first and it silently did nothing: the class landed on
   the element and no animation ever started (caught over CDP, clip-path stayed `none`). */
.m-bookmarked { animation: m-count-pop .32s var(--ease-spring); }
.m-bookmarked svg { animation: m-ribbon-drop .4s cubic-bezier(.22,.61,.36,1) both; }
@keyframes m-ribbon-drop { from { clip-path: inset(0 0 100% 0); } to { clip-path: inset(0 0 -12% 0); } }

/* The mobile nav panel used to pop straight to display:flex — the same class of gap
   the forgot-password dialog had. Entry only, and dismissal stays instant: an exit
   the user asked for should never be waited out. */
.mainnav.open { animation: m-mnav-in .18s var(--ease-out); }
@keyframes m-mnav-in { from { opacity: 0; translate: 0 -6px; } }
/* The account menu grows out of its trigger (top right), not out of thin air —
   popovers scale from where they came from; only modals stay centered. Display
   toggling ([hidden]) restarts the animation on every open, which is the point. */
.authmenu-pop { transform-origin: top right; }
.authmenu-pop:not([hidden]) { animation: m-menu-in .16s var(--ease-out); }
@keyframes m-menu-in { from { opacity: 0; transform: scale(.96); } }

/* The paper handed in (finishExam): a deliberate submit lifts the card away before
   renderResult's ring→verdict→confetti takes over. Ease-in INTO the exit — the auth
   flip-out's grammar — ~240ms, once per exam; a timeout skips it in JS (the clock
   ending the exam is not a hand-in). pointer-events: none because the swipe handler
   is still bound underneath, and a drag mid-beat would re-render the runner. */
.qcard.m-handin { animation: m-handin .24s cubic-bezier(.55,.06,.68,.19) both; pointer-events: none; }
@keyframes m-handin { to { opacity: 0; transform: translateY(-18px) scale(.98); } }

/* One-shot welcome after signup (dashboardHome's .hero-hello, armed by signupView):
   the hero pills cascade in exactly the way the landing trust chips do — the same
   keyframe, the same beat — spent on the only true first-run in the product. The
   class is never re-rendered (the flag is consumed), so nothing replays. */
.has-motion .hero-hello .hero-pills .pill { animation: m-chip-in .4s var(--ease-out) backwards; animation-delay: .3s; }
.has-motion .hero-hello .hero-pills .pill:nth-child(2) { animation-delay: .42s; }
.has-motion .hero-hello .hero-pills .pill:nth-child(3) { animation-delay: .54s; }

/* ------------------------------------------------ login ↔ signup card flip
   The cross-link between the two auth routes turns the card half-way out; the
   incoming route turns it back in. Two half-turns across one route change read as
   one card with two faces — "same place, other task" — because both views render
   the same .authcard on the same spot. Wired by flipAuthLink()/flipAuthIn() in
   main.js, which skip it (plain navigation) under reduced motion or a dead motion
   runtime. The out-half accelerates INTO the edge and the in-half decelerates out
   of it: one continuous turn, the one entrance where an ease-in curve is right.
   0→-90 then +90→0 is the same angular direction — the face swaps edge-on.
   m-flip-stage kills the generic `.view > *` rise for this navigation only; a card
   that rises while it turns is two animations fighting. */
.authwrap { perspective: 1100px; }
.authwrap.m-flip-stage { animation: none; }
.authcard.m-flip-out, .authcard.m-flip-out-rev {
  animation: m-flip-out .2s cubic-bezier(.55,.06,.68,.19) both; pointer-events: none;
}
.authcard.m-flip-out-rev { animation-name: m-flip-out-rev; }
.authcard.m-flip-in { animation: m-flip-in .32s var(--ease-out) both; }
.authcard.m-flip-in-rev { animation: m-flip-in-rev .32s var(--ease-out) both; }
/* The two faces are different boxes (430px login vs 640px signup, and far apart in
   height), so flipAuthIn() morphs the incoming card from the outgoing card's measured
   size to its own while it turns — width/height, one-shot, on a page with nothing
   else to lay out. Without it the silhouette snaps at the halfway point. */
.authcard.m-flip-size { transition: width .32s var(--ease-out), height .32s var(--ease-out); }
@keyframes m-flip-out { to { transform: rotateY(-90deg); opacity: .9; } }
@keyframes m-flip-out-rev { to { transform: rotateY(90deg); opacity: .9; } }
@keyframes m-flip-in { from { transform: rotateY(90deg); opacity: .9; } }
@keyframes m-flip-in-rev { from { transform: rotateY(-90deg); opacity: .9; } }
.plan, .price-card, .feature, .step, .testi { transition: transform .2s cubic-bezier(.2,.8,.3,1), box-shadow .25s ease, border-color .25s ease; }
.step-num { transition: transform .25s cubic-bezier(.2,1.5,.4,1); }

/* ------------------------------------------------- border tracer (app/tracer.js)
   A comet running the host's outline. pathLength="1" on each rect renormalises the
   dash pattern to fractions of the perimeter, so these numbers are size-independent
   and there is no JS: three nested dashes share one dashoffset animation, giving a
   head at 0.05 of a lap with a tail out to 0.26 at falling opacity.
   The host must be positioned; .tr-host below does that without touching .btn's or
   .price-card's own rules. */
.tr-host { position: relative; }
.tr { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none;
  overflow: visible; z-index: 2; }
/* The `d` is written by app/tracer.js — pathLength is only honoured on <path>, so the
   rounded rect cannot be a <rect> here. See the note at the top of that file. */
.tr path {
  fill: none; stroke: var(--brand); stroke-width: 2.5; stroke-linecap: round;
  animation: tr-run 3.4s linear infinite;
}
/* stroke-dasharray is written per path by app/tracer.js, in px. */
.tr-t1 { opacity: .95; }
.tr-t2 { opacity: .4; }
.tr-t3 { opacity: .16; }
@keyframes tr-run { from { stroke-dashoffset: var(--tr-len, 0px); } to { stroke-dashoffset: 0; } }
/* On the primary CTA the comet is the button's own ink, not the brand red it already
   is; white reads as a highlight sweeping the edge.
   `.btn .tr` also has to out-specify `.btn svg { width: 1.15em; height: 1.15em }` in
   styles.css (two classes beats one class + one type), or the overlay is sized like an
   icon — it measured 19x19 inside a 110x48 button before this rule existed. */
.btn .tr { width: 100%; height: 100%; }
/* Slower than the price card's 3.4s: a button's outline is roughly a third of the
   perimeter, so an equal duration would read as three times the speed. */
.btn .tr path { animation-duration: 5.5s; }
.btn.primary .tr path { stroke: #fff; stroke-width: 2; }

.step:hover .step-num { transform: scale(1.12) rotate(-8deg); }
.testi .stars { animation: m-shine 4s ease-in-out infinite; }
@keyframes m-shine { 50% { filter: brightness(1.25); } }
.avatar { transition: transform .25s cubic-bezier(.2,1.5,.4,1); }
.testi:hover .avatar { transform: scale(1.12) rotate(-6deg); }
.sec-title { position: relative; }
.sec-title::after {
  content: ""; position: absolute; left: 0; bottom: -.35rem; height: 2px; width: 0;
  background: var(--grad); border-radius: 2px; transition: width .8s cubic-bezier(.22,.61,.36,1) .1s;
}
.sec-title.in::after { width: 2.6rem; }

/* loading skeleton */
.skel { position: relative; overflow: hidden; background: var(--surface-2); border-radius: var(--radius-sm); }
.skel::after {
  content: ""; position: absolute; inset: 0;
  background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--text) 7%, transparent), transparent);
  animation: m-sweep 1.4s linear infinite;
}
.loading { display: inline-flex; align-items: center; gap: .6rem; }
.loading::before {
  content: ""; width: 1rem; height: 1rem; border-radius: 50%; flex: none;
  border: 2px solid var(--border); border-top-color: var(--brand); animation: m-spin .7s linear infinite;
}
@keyframes m-spin { to { transform: rotate(360deg); } }

/* ------------------------------------------------------ swipe-to-navigate */
/* The question card can be dragged left/right to move between questions. While
   the finger is down we translate 1:1 (no transition); on release it springs to
   its resting/committed position. TRAFFIC SAFETY: this only ever translateX-es
   the whole card — it never rotates, reflects or re-scales, so a sign's meaning
   (which is encoded in its orientation) is never altered. The illustration layer
   itself is untouched. */
.qcard.swiping { transition: none; will-change: transform; }
.qcard.settling { transition: transform var(--dur-base) var(--ease-out); }

/* ------------------------------------------------------- touch hover reset */
/* Touch devices fire a synthetic :hover on tap that sticks until the next tap
   elsewhere, so every hover lift/scale above would otherwise freeze mid-effect
   after a tap. Where there's no real hovering pointer, drop the hover transforms;
   the instant :active press feedback still applies. The button sheen is parked
   off-screen (not reset to 0) so it never appears stranded mid-button. */
@media (hover: none) {
  /* The elements themselves: no transform at all. This list and the sheen below used to
     be ONE selector list sharing ONE declaration, so every element here inherited the
     sheen's `translateX(-130%)` — and -130% of its own width is a long way. Tapping the
     account button threw it 174px left across the logo; tapping "Neste" in a question
     threw the button off the side of the card, and it stayed there until you tapped
     somewhere else, because the synthetic hover sticks. Two rules, not one. */
  .brand:hover, .authnav .btn.small:hover, .btn.primary:hover, .btn.big:hover,
  .btn:hover svg, .chip:hover, .stat:hover, .mtopic:hover, .qgrid button:hover,
  /* .sign and .sec-card were listed only by their CHILD (.signimg / .sec-ico) until
     2026-09-05, so the cards' own lift — translateY(-2px) and (-3px) — was never reset
     and stayed stuck after a tap. A list keyed by hand drifts from the rules it mirrors;
     these two are the drift. */
  .show-sign:hover, .sign:hover, .sign:hover .signimg,
  .sec-card:hover, .sec-card:hover .sec-ico,
  .feature:hover .ficon, .step:hover .step-num, .testi:hover .avatar { transform: none; }

  /* …and the half this block never covered: a hover state is rarely only a transform.
     The stuck LIFT was the visible bug, but the elevation and border tone stick with it,
     so a tapped card stayed one step off the page in shadow terms too. Values are the
     resting ones restated from styles.css — the tokens, not literals, so they follow the
     elevation scale.
     Deliberately NOT here: .opt and .chip. Both look like the same omission and are not
     — a tap on either immediately produces a state class (.opt.selected/.correct/.wrong,
     .chip.active) whose own box-shadow and border-color out-specify the hover skin, so
     there is nothing left stranded to reset. Adding them would be worse than nothing:
     at equal specificity from this later sheet, the reset would win over those state
     classes and strip the selected halo off the answer the user just picked. */
  .sec-card:hover { box-shadow: var(--hairline), var(--shadow); border-color: var(--border); }
  .sign:hover { box-shadow: var(--hairline), var(--shadow); }
  .mtopic:hover { box-shadow: var(--hairline), var(--e1); border-color: var(--border); }
  .btn:not(.primary):not(.ghost):hover { box-shadow: var(--hairline), var(--e1); border-color: var(--border); }
  /* The one that is not cosmetic. `.btn.primary:hover` carries `filter: brightness(1.06)`,
     and a stuck filter is the iOS soft-text trap this sheet already documents for
     entrance keyframes: any filter — even a no-op one — holds the element on its own
     compositing layer, which WebKit does not reliably re-rasterise. On touch the
     synthetic hover never lifts on its own, so that layer would persist for the life of
     the page on the app's most-tapped control. */
  .btn.primary:hover { filter: none; }

  /* The sheen alone is parked off-screen rather than reset to 0, so it is never left
     stranded mid-button. This is the one selector the -130% was ever meant for. */
  .btn:hover::after { transform: translateX(-130%); }
}

/* ------------------------------------------------- off-screen loops are parked
   Set by pauseOffscreenLoops() in motion.js on decorative sections only. Paused,
   not cancelled: the animation holds its current frame and resumes exactly where
   it left off, so scrolling back never shows a jump. */
.m-paused, .m-paused *, .m-paused::before, .m-paused::after { animation-play-state: paused; }

/* ------------------------------------------------------------ reduced motion */
@media (prefers-reduced-motion: reduce) {
  #scrollprog { transition: none; }
  .has-motion [data-reveal] { opacity: 1 !important; transform: none !important; transition: none; }
  .view > *, .explain, .verdict, .formnote { animation: none; }
  .tr { display: none; }   /* the tracer is pure decoration; there is no static state worth keeping */
  /* The hero drive stops itself under reduced motion (herodrive.js paints exactly
     one frame and never starts its rAF), so only the CSS ambience is listed here. */
  .hero-scene .h-sun, .hero-scene .h-cloud,
  .gateicon, .testi .stars,
  .marquee-track, .showcase.in .marquee, .lp-hero .lp-trust span,
  .band-num.m-count-done, .btn.pulse-cta, .opt.correct, .opt.wrong .key,
  .opt.selected .key, .opt.correct::after, .opt.wrong::after,
  /* the JS side already skips these four under reduced motion; CSS backstop in case
     the preference flips mid-session */
  .sp-verdict, .pwdlg[open], .pwdlg[open]::backdrop,
  .btn.m-lesson-done, .btn.m-lesson-done svg,
  .m-bookmarked, .m-bookmarked svg,
  .chart-line[pathLength], .chart-area, .chart-dot,
  ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*),
  .mainnav.open, .authmenu-pop:not([hidden]), .qcard.m-handin,
  .hero-hello .hero-pills .pill,
  /* JS skips these too (renderResult/startWithLight check the preference); the
     wiper arms freeze at their resting angle, still readable as a windshield */
  .rescar, .rescar .ac-glow, .rescar .ac-lightin, .wl-arm, .tl-lens,
  .authcard.m-flip-out, .authcard.m-flip-out-rev,
  .authcard.m-flip-in, .authcard.m-flip-in-rev { animation: none !important; }
  .authcard.m-flip-size { transition: none; }
  .sp-needle { transition: none; }
  /* `.pbar > i::after` used to be listed here; the element no longer exists (see
     the progress-bar note above), so listing it would only mislead the next reader. */
  .hero::after, .cta-band::after, .lp-hero::after, .result-hero.pass::before,
  .skel::after, .loading::before { animation: none; opacity: .25; }
  .btn::after { display: none; }
  .hero-scene, .dev-cluster, .lp-visual, .lp-hero::after, .hero::after { translate: none; }
  /* heroDepth() in motion.js also stops writing --hero-p under reduced motion;
     this pins the resting state even if the preference flips mid-session. */
  .lp-visual { opacity: 1; }
  /* The frame wall is two states joined by a transition, so it is pinned at the resting one
     rather than listed above — mountHeroFrames() also adds `.in` outright under the
     preference, this is the backstop if it flips mid-session. */
  .has-motion .hero-frames .hf { opacity: 1 !important; transform: none !important; transition: none; }
  .sec-title.in::after { transition: none; width: 2.6rem; }
  .qcard.settling { transition: none; }
}
