/* ==========================================================================
   The Next Coin Club — effects.css
   The visual & interaction layer. Loads AFTER app.css and components.css.

   Philosophy
   ----------
   Nothing here introduces colour. Nothing here is decorative for its own
   sake. Every effect exists to answer one of three questions the interface
   is always being asked:

     "Where am I?"          -> elevation, light direction, atmosphere
     "What can I touch?"    -> magnetism, specular response, press physics
     "Is it working?"       -> skeletons, progress, transitions, pulses

   All of it is built on the tokens already defined in app.css. All of it
   works in both themes. All of it collapses safely under
   `prefers-reduced-motion` and in browsers without modern CSS features.

   CONTENTS
   --------
   §0   Integration notes (how to load this file)
   §1   Registered custom properties (@property)
   §2   Extended tokens — light angle, elevation, specular, atmosphere
   §3   Advanced surface system
   §4   Magnetic + physics interactions
   §5   Scroll-driven animation
   §6   Text craft
   §7   Loading + transition system
   §8   Data visualisation
   §9   Depth + atmosphere
   §10  Micro-details
   §11  Reduced-motion master gate
   §12  JS companion (documented, not shipped — see block at end)
   ========================================================================== */


/* ==========================================================================
   §0  INTEGRATION
   --------------------------------------------------------------------------
   Add ONE line to each page, after components.css:

       <link rel="stylesheet" href="effects.css">

   Nothing else is required. Every class in this file is opt-in EXCEPT the
   refinements in §3.1, §9.1, §10.3 and §10.5, which upgrade existing
   selectors (.surf, .dot, body::after, scrollbars, ::selection) in place
   and are designed to be visually continuous with what is already there —
   a page that loads this file looks like the same page, better lit.
   ========================================================================== */


/* ==========================================================================
   §1  REGISTERED CUSTOM PROPERTIES
   --------------------------------------------------------------------------
   @property gives a custom property a TYPE. A typed custom property can be
   interpolated — which means we can transition and keyframe things CSS has
   never been able to animate: gradient stops, gradient angles, conic sweeps.
   This is what lets §3.3 (specular) and §8.4 (radial progress) work without
   a single line of JavaScript.

   Browsers without @property simply ignore these blocks; the properties
   still work as untyped strings, they just snap instead of tween. That is
   the graceful degradation path and it is acceptable everywhere it is used.
   ========================================================================== */

@property --light-angle {
  syntax: '<angle>'; inherits: true; initial-value: 180deg;
}
@property --spec {
  /* specular intensity, 0–1, multiplied into the highlight alpha */
  syntax: '<number>'; inherits: false; initial-value: 0;
}
@property --sweep {
  /* 0–100, drives conic-gradient progress in §8.4 */
  syntax: '<number>'; inherits: false; initial-value: 0;
}
@property --shimmer {
  syntax: '<number>'; inherits: false; initial-value: 0;
}


/* ==========================================================================
   §2  EXTENDED TOKENS
   ========================================================================== */

:root{
  /* ---- 2.1 Light direction ---------------------------------------------
     A single scene-wide light source. Every edge-light, specular highlight
     and elevation shadow in this file derives its orientation from this one
     angle, which is why surfaces feel like they belong to the same room.
     180deg = light from directly above (CSS gradient angles run clockwise
     from "to top"; 180deg means the gradient runs downward, i.e. the lit
     edge is the top edge). Override per-section for drama, sparingly. */
  --light-angle: 180deg;

  /* ---- 2.2 Physics ------------------------------------------------------
     Two springs. `--spring` (app.css) overshoots and is right for entrances.
     These are tuned for *pressure*: compression should be fast and almost
     linear, release should overshoot slightly and settle. */
  --press-in:   cubic-bezier(.3,.7,.4,1);      /* finger down: quick, no bounce */
  --press-out:  cubic-bezier(.2,1.5,.4,1);     /* finger up: springs back */
  --t-press:    .11s;
  --t-release:  .42s;

  /* ---- 2.3 Magnetism ----------------------------------------------------
     How far a magnetic element travels toward the cursor, as a fraction of
     the cursor's offset from the element's centre. Above ~.35 it stops
     feeling like physics and starts feeling like a bug. */
  --mag-pull:   .26;
  --mag-radius: 26px;   /* invisible proximity pad around the element */
}

/* ---- 2.4 Elevation scale ------------------------------------------------
   e0–e4. Physically consistent in both themes, but NOT the same recipe —
   because light and dark rooms behave differently:

   LIGHT theme: shadow is occlusion. Objects block a bright ambient field,
   so we cast soft grey. Two layers per level — a tight contact shadow that
   grounds the object, and a wide ambient shadow that gives it altitude.

   DARK theme: in a dark room a shadow on a dark surface is invisible. What
   actually reads as "raised" is a *rim of light* on the top edge plus a
   deep black occlusion beneath. So dark-theme elevation adds an inset
   top highlight. This is the difference between a card that floats and a
   card that just has a blurry smudge under it.

   All values are y-offset-and-blur only — no spread, no x-offset — because
   the light is directly overhead (§2.1) and off-axis shadows would break
   the illusion the moment two cards sit side by side.
   ------------------------------------------------------------------------ */
[data-theme="light"]{
  --e0: none;
  --e1: 0 1px 2px rgba(11,11,12,.05), 0 1px 3px rgba(11,11,12,.03);
  --e2: 0 2px 4px rgba(11,11,12,.05), 0 6px 16px rgba(11,11,12,.06);
  --e3: 0 4px 8px rgba(11,11,12,.06), 0 16px 34px rgba(11,11,12,.08);
  --e4: 0 8px 16px rgba(11,11,12,.07), 0 32px 68px rgba(11,11,12,.12);

  /* inner shadow for sunk / inset surfaces — light falls in from the top,
     so the top inner edge is the one in shadow */
  --inset-1: inset 0 1px 2px rgba(11,11,12,.055), inset 0 0 0 1px rgba(11,11,12,.012);
  --inset-2: inset 0 2px 5px rgba(11,11,12,.075), inset 0 1px 1px rgba(11,11,12,.04);

  /* specular — a bright bloom where the pointer is. In light theme the
     surface is already near-white, so the "highlight" is really a soft
     *shadow removal*: we lift a warm-neutral white over the surface. */
  --spec-hi: 255,255,255;
  --spec-lo: 11,11,12;
  --spec-max: .95;      /* alpha ceiling of the specular bloom */
  --spec-ring: .05;     /* alpha of the darkened counter-edge */

  --glass-bg: rgba(255,255,255,.62);
  --glass-bd: rgba(11,11,12,.09);
  --glass-hi: rgba(255,255,255,.9);
  --vig: rgba(11,11,12,.16);
}
[data-theme="dark"]{
  --e0: none;
  --e1: 0 1px 2px rgba(0,0,0,.5),  inset 0 1px 0 rgba(255,255,255,.045);
  --e2: 0 4px 12px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.06);
  --e3: 0 10px 30px rgba(0,0,0,.62), inset 0 1px 0 rgba(255,255,255,.075);
  --e4: 0 24px 64px rgba(0,0,0,.72), inset 0 1px 0 rgba(255,255,255,.09);

  --inset-1: inset 0 1px 2px rgba(0,0,0,.5), inset 0 0 0 1px rgba(255,255,255,.02);
  --inset-2: inset 0 2px 6px rgba(0,0,0,.62), inset 0 1px 1px rgba(0,0,0,.4);

  --spec-hi: 255,255,255;
  --spec-lo: 0,0,0;
  --spec-max: .085;     /* far lower — dark surfaces blow out instantly */
  --spec-ring: .28;

  --glass-bg: rgba(16,16,18,.66);
  --glass-bd: rgba(255,255,255,.11);
  --glass-hi: rgba(255,255,255,.13);
  --vig: rgba(0,0,0,.5);
}


/* ==========================================================================
   §3  ADVANCED SURFACE SYSTEM
   ========================================================================== */

/* ---- 3.1 Edge-light, now steerable -------------------------------------
   app.css defines --edge as a fixed 180deg gradient. We rebuild it from the
   scene light angle so that every .surf in the document re-lights together
   when --light-angle changes — including on a single section:

       <section style="--light-angle:200deg"> ... </section>

   The colour stops are unchanged from app.css, so this is a pure upgrade:
   at the default 180deg it renders byte-identically to before.
   ------------------------------------------------------------------------ */
[data-theme="light"]{
  --edge-hi: rgba(11,11,12,.10);
  --edge: linear-gradient(var(--light-angle), var(--edge-hi), rgba(11,11,12,0) 42%);
}
[data-theme="dark"]{
  --edge-hi: rgba(255,255,255,.22);
  --edge: linear-gradient(var(--light-angle), var(--edge-hi), rgba(255,255,255,0) 42%);
}

/* Transition the angle itself. Because --light-angle is a registered
   <angle> (§1), this actually tweens — the light *rotates* across the card
   rather than jumping. Used by .surf.spec on hover (§3.3). */
.surf{ transition: box-shadow var(--t-slow) var(--ease), --light-angle .6s var(--ease); }

/* `.edge-strong` — for hero and pricing surfaces that need to read as the
   primary object on screen. Doubles the rim without touching the fill. */
.edge-strong{ --edge-hi: color-mix(in srgb, var(--text) 22%, transparent); }
.edge-strong.surf::before{ opacity: .85; }

/* `.edge-none` — kill the rim on nested surfaces so cards inside cards
   don't accumulate a stack of bright lines. */
.edge-none.surf::before{ opacity: 0; }


/* ---- 3.2 Elevation utilities -------------------------------------------
   .e0 … .e4. Apply to any element. `.e-hover-N` raises on hover with the
   same physics: elevation change is accompanied by a small translateY,
   because in the real world nothing casts a bigger shadow without also
   moving closer to you.
   ------------------------------------------------------------------------ */
.e0{ box-shadow: var(--e0); }
.e1{ box-shadow: var(--e1); }
.e2{ box-shadow: var(--e2); }
.e3{ box-shadow: var(--e3); }
.e4{ box-shadow: var(--e4); }

.e-lift{
  transition: box-shadow var(--t-slow) var(--ease),
              transform  var(--t-slow) var(--ease);
  will-change: auto;                     /* promoted only while hovered — see below */
}
.e-lift:hover{ will-change: transform, box-shadow; }
.e-lift.e1:hover{ box-shadow: var(--e2); transform: translate3d(0,-2px,0); }
.e-lift.e2:hover{ box-shadow: var(--e3); transform: translate3d(0,-4px,0); }
.e-lift.e3:hover{ box-shadow: var(--e4); transform: translate3d(0,-6px,0); }


/* ---- 3.3 Specular highlight --------------------------------------------
   A real specular is the light source reflected in the surface. It should
   (a) sit where the pointer is, (b) be tight and bright, not a wash, and
   (c) FADE IN — a highlight that pops on is a hover state, a highlight that
   blooms is a material.

   The trick: we can't animate a gradient's colour stops... unless the alpha
   comes from a registered <number> (§1). `--spec` transitions 0 -> 1 and the
   gradient re-resolves every frame. No JS, no extra DOM.

   .surf::before and .spot::after are both taken by app.css, so the specular
   lives on the element's own background-image layer. .surf sets
   `background:var(--raised)` (shorthand, so background-image is reset to
   none); we re-declare both here so the layer order is explicit and the
   fill colour is preserved.
   ------------------------------------------------------------------------ */
.spec{
  background-image:
    radial-gradient(220px circle at var(--mx,50%) var(--my,50%),
      rgba(var(--spec-hi), calc(var(--spec) * var(--spec-max))),
      rgba(var(--spec-hi), 0) 60%),
    /* counter-edge: the far side of a lit object is always slightly darker.
       This is what stops the highlight looking like a sticker. */
    radial-gradient(520px circle at var(--mx,50%) var(--my,50%),
      rgba(var(--spec-lo), 0),
      rgba(var(--spec-lo), calc(var(--spec) * var(--spec-ring))) 100%);
  background-color: var(--raised);
  transition: --spec .5s var(--ease), --light-angle .6s var(--ease),
              box-shadow var(--t-slow) var(--ease);
}
.spec:hover{
  --spec: 1;
  /* the light also tips slightly toward the pointer side of the card */
  --light-angle: 168deg;
}
/* On a sunk surface the specular must be weaker or the inset reads as a bump */
.sunk-in.spec:hover{ --spec: .55; }

/* DEGRADATION — read this before changing --spec-max.
   Without @property, --spec is an untyped token. It still SUBSTITUTES
   correctly (calc(0 * .95) resolves to 0, calc(1 * .95) to .95), so the
   highlight appears and disappears exactly as intended — it simply snaps
   instead of blooming.

   That snap is deliberately made harmless rather than feature-detected
   away: `@supports at-rule(@property)` is itself newer than @property in
   most engines, so probing for it would disable the fallback in precisely
   the browsers meant to receive it. Instead the ceiling (--spec-max) is set
   low enough — .95 white over an already-white surface in light mode,
   .085 in dark — that an instant transition reads as a normal hover tint
   rather than a flash. Raise --spec-max and you break that guarantee.

   @property support: Chrome 85+, Safari 16.4+, Firefox 128+. Everything
   older gets the snap, which is a fully acceptable hover state. */


/* ---- 3.4 Sunk surfaces --------------------------------------------------
   The inverse of elevation. Inputs, wells, code blocks, empty states,
   anything the eye should read as *below* the page plane. The inner shadow
   is asymmetric (top-weighted) because the light is overhead.
   ------------------------------------------------------------------------ */
.sunk-in{
  background: var(--sunk);
  box-shadow: var(--inset-1);
  border: 1px solid var(--hair);
  border-radius: var(--r-md);
}
.sunk-in.deep{ box-shadow: var(--inset-2); }

/* Kill the edge-light on sunk surfaces — a rim of light on the top edge of
   a recessed object is physically wrong and reads as cheap. */
.sunk-in.surf::before{ opacity: 0; }

/* Upgrade text inputs to the same physical model, without changing the
   look enough to break any existing form. */
input[type=text], input[type=email], input[type=password], input[type=tel]{
  box-shadow: var(--inset-1);
  transition: border-color var(--t-base), background var(--t-base),
              box-shadow var(--t-base) var(--ease);
}
input[type=text]:focus, input[type=email]:focus,
input[type=password]:focus, input[type=tel]:focus{
  /* focused fields rise to the page plane: inset shadow releases */
  box-shadow: var(--e1);
}


/* ==========================================================================
   §4  MAGNETIC + PHYSICS INTERACTIONS
   ========================================================================== */

/* ---- 4.1 Proximity, without JavaScript ---------------------------------
   CSS cannot read cursor distance. But it does not need to in order to
   deliver the *sensation* of magnetism, which is: the element responds
   before you arrive.

   We give the element an invisible pseudo-element pad extending --mag-radius
   beyond its bounds. Hovering the pad triggers :hover on the element. The
   button therefore wakes up roughly 26px early — which is exactly what a
   JS proximity radius feels like, at zero runtime cost and with correct
   pointer-events semantics (the pad does not swallow clicks on neighbours
   because it sits behind, at z-index -1, and is only as large as the gap).
   ------------------------------------------------------------------------ */
.magnetic{
  position: relative;
  --mag-x: 0px;
  --mag-y: 0px;
  --mag-s: 1;
  transform: translate3d(
      calc(var(--mag-x) * var(--mag-pull)),
      calc(var(--mag-y) * var(--mag-pull)), 0)
    scale(var(--mag-s));
  transition: transform var(--t-release) var(--press-out),
              box-shadow var(--t-base) var(--ease);
}
.magnetic::before{
  content: '';
  position: absolute;
  inset: calc(-1 * var(--mag-radius));
  border-radius: inherit;
  z-index: -1;
  pointer-events: auto;        /* this is the proximity field */
}
.magnetic:hover{ --mag-s: 1.025; }

/* Elements that already have a ::before (e.g. .surf) opt out of the pad and
   rely on the JS translate instead. */
.magnetic.surf::before{ inset: 0; }

/* ---- 4.2 Press physics --------------------------------------------------
   A real button compresses fast and rebounds slowly. Using one transition
   for both directions is the single most common reason buttons feel cheap.
   We swap the timing function between rest and :active so compression is
   near-instant (110ms, no overshoot) and release springs (420ms, overshoot).

   We compress by scale AND drop the elevation, because a pressed object is
   closer to the surface and therefore casts a tighter shadow.
   ------------------------------------------------------------------------ */
.press{
  transition: transform var(--t-release) var(--press-out),
              box-shadow  var(--t-release) var(--ease);
}
.press:active{
  transform: scale(.965);
  box-shadow: var(--e0);
  transition: transform var(--t-press) var(--press-in),
              box-shadow  var(--t-press) var(--press-in);
}

/* Magnetic + press composed. Keeps the magnet translate while compressing,
   so the button doesn't snap back to centre the moment you click it. */
.magnetic:active{
  --mag-s: .965;
  transition: transform var(--t-press) var(--press-in);
}

/* ---- 4.3 Applied to the existing button, non-destructively -------------
   .btn already has transform transitions in app.css. We only take over when
   the author explicitly opts in with .magnetic / .press. */
.btn.magnetic{ transform-origin: center; }
.btn.solid.magnetic:hover{ box-shadow: var(--e2); }
.btn.solid.magnetic:active{ box-shadow: var(--e1); }

/* ---- 4.4 Rubber-band drag affordance -----------------------------------
   For horizontally scrollable strips (carousels, tile rows). Overscroll
   behaves like iOS rather than hitting a wall. */
.rubber{
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scroll-snap-type: x proximity;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
}
.rubber::-webkit-scrollbar{ display: none; }
.rubber > *{ scroll-snap-align: start; }


/* ==========================================================================
   §5  SCROLL-DRIVEN ANIMATION
   --------------------------------------------------------------------------
   `animation-timeline` runs animations off the scroll position instead of
   off time. The browser drives them on the compositor: no scroll listener,
   no IntersectionObserver, no layout reads, no jank. Where it is not
   supported, everything below must render as a normal, fully-visible,
   fully-styled page.

   The pattern used throughout: define the FINAL state as the base style,
   and only introduce the animated start state *inside* the @supports block.
   This means an unsupporting browser never sees an element at opacity 0
   waiting for an animation that will never arrive — the single most common
   way scroll animation breaks a site.
   ========================================================================== */

@supports (animation-timeline: view()) {

  /* ---- 5.1 Enter: fade + scale ----------------------------------------
     `view()` creates a timeline covering the element's pass through the
     viewport. `animation-range: entry 0% cover 34%` runs the animation from
     the moment the element's leading edge appears until it is ~a third of
     the way up. Finishing early matters: an animation still resolving at
     the point the user starts reading is a distraction, not a reveal. */
  .v-enter{
    animation: vEnter linear both;
    animation-timeline: view();
    animation-range: entry 0% cover 34%;
  }
  @keyframes vEnter{
    from{ opacity: 0; transform: translate3d(0,26px,0) scale(.985); }
    to  { opacity: 1; transform: none; }
  }

  /* Softer variant for dense lists, where full travel would look busy. */
  .v-enter.subtle{ animation-name: vEnterSubtle; animation-range: entry 0% cover 26%; }
  @keyframes vEnterSubtle{
    from{ opacity: 0; transform: translate3d(0,12px,0); }
    to  { opacity: 1; transform: none; }
  }

  /* ---- 5.2 Parallax depth ---------------------------------------------
     `--depth` is the layer's distance from the camera: 0 = glued to the
     page, 1 = far background. Travel is expressed in cqh-free px so it is
     predictable across breakpoints. Transform-only — never top/margin. */
  .v-parallax{
    animation: vPara linear both;
    animation-timeline: view();
    animation-range: cover 0% cover 100%;
    will-change: transform;
  }
  @keyframes vPara{
    from{ transform: translate3d(0, calc(var(--depth,.35) * 60px), 0); }
    to  { transform: translate3d(0, calc(var(--depth,.35) * -60px), 0); }
  }

  /* ---- 5.3 Sticky headers that shrink ----------------------------------
     Timeline is the *scroller*, not the element, and the range is tied to
     the header's own sticky travel. Uses only transform + opacity, so the
     content below never reflows as the header condenses. */
  .v-shrink{
    position: sticky;
    top: 0;
    animation: vShrink linear both;
    animation-timeline: view();
    animation-range: exit 0% exit 100%;
    transform-origin: left center;
  }
  @keyframes vShrink{
    from{ transform: scale(1);   opacity: 1; }
    to  { transform: scale(.86); opacity: .62; }
  }

  /* ---- 5.4 Reading progress -------------------------------------------
     `scroll()` is a document-level timeline. This replaces the JS in
     app.js that reads scrollHeight on every scroll event (a forced layout
     per frame). Apply to any 2px bar; it fills as the page is read.
     The existing #prog element keeps working via JS — this is the
     zero-cost alternative for new pages. */
  .v-progress{
    transform-origin: 0 50%;
    animation: vProgress linear both;
    animation-timeline: scroll(root block);
  }
  @keyframes vProgress{ from{ transform: scaleX(0); } to{ transform: scaleX(1); } }

  /* Scoped variant: progress through one article, not the whole document. */
  .v-progress.scoped{ animation-timeline: view(); animation-range: cover 0% cover 100%; }

  /* ---- 5.5 Text reveal start state (see §6.1) -------------------------- */
  .reveal-text{
    animation: rtSweep linear both;
    animation-timeline: view();
    animation-range: entry 10% cover 30%;
  }
  .reveal-text > span{
    animation: rtWord linear both;
    animation-timeline: view();
    animation-range: entry 10% cover 34%;
  }
}

/* Fallback path for the enter animation: if `view()` is unavailable we hand
   the job to the IntersectionObserver already running in app.js, which adds
   `.in` to `.reveal` containers. Placing `.v-enter` inside a `.reveal` makes
   it inherit that behaviour with zero extra JS. */
@supports not (animation-timeline: view()) {
  .reveal > .v-enter{
    opacity: 0; transform: translate3d(0,22px,0);
    transition: opacity .85s var(--ease), transform .85s var(--spring);
  }
  .reveal.in > .v-enter{ opacity: 1; transform: none; }
}


/* ==========================================================================
   §6  TEXT CRAFT
   ========================================================================== */

/* ---- 6.1 .reveal-text ---------------------------------------------------
   A clip-path wipe rather than a fade. Type that fades in reads as *loading*;
   type that is uncovered reads as *arriving*. The clip travels bottom-to-top
   with a slight vertical offset on the glyphs themselves, which is what
   makes it look like a printing press rather than a mask.

   Two modes:
     <h2 class="reveal-text">Whole block</h2>          — one wipe
     <h2 class="reveal-text">                          — per word/line stagger
       <span style="--i:0">Learn</span>
       <span style="--i:1">crypto.</span>
     </h2>

   Base state is FULLY VISIBLE. The hidden start state is introduced only
   inside the @supports block in §5.5, so no-JS / no-support browsers show
   finished type immediately.
   ------------------------------------------------------------------------ */
.reveal-text{ display: block; }
.reveal-text > span{
  display: inline-block;
  animation-delay: calc(var(--i,0) * 55ms);
}
@keyframes rtSweep{
  from{ clip-path: inset(0 0 105% 0); transform: translate3d(0,.18em,0); }
  to  { clip-path: inset(0 0 -20% 0); transform: none; }
}
@keyframes rtWord{
  from{ clip-path: inset(0 0 110% 0); transform: translate3d(0,.24em,0); opacity: 0; }
  40% { opacity: 1; }
  to  { clip-path: inset(0 0 -25% 0); transform: none; opacity: 1; }
}
/* Descenders (g, y, p) get clipped by inset(0 0 -20% 0) at rest on tight
   line-heights, so we always over-shoot the final inset into negative. */

/* ---- 6.2 .balance / .pretty --------------------------------------------
   `text-wrap: balance` equalises line lengths — correct for headlines up to
   ~6 lines. `pretty` only prevents orphans and is far cheaper — correct for
   body copy. app.css already balances h1–h4; these expose it as a utility
   and give body copy the cheaper treatment it should have. */
.balance{ text-wrap: balance; }
.pretty { text-wrap: pretty; }
.nowrap { text-wrap: nowrap; white-space: nowrap; }

/* ---- 6.3 Optical sizing for Newsreader ---------------------------------
   Newsreader is a variable font with an `opsz` axis (6–72). Optical size is
   not a scale factor — it changes stroke contrast, aperture and spacing so
   the face is *drawn* for its size. Browsers apply `auto` optical sizing,
   but `auto` follows the computed font-size, which is wrong for display
   type set in a dark theme (which needs a lower opsz to hold weight) and
   wrong for small serif captions (which need a higher opsz to stay open).

   These utilities let us set it deliberately. Paired with a matching
   letter-spacing, because optical size and tracking are the same decision.
   ------------------------------------------------------------------------ */
.opt-display{
  font-family: var(--serif);
  font-variation-settings: 'opsz' 60;
  letter-spacing: -.024em;
}
.opt-title{
  font-family: var(--serif);
  font-variation-settings: 'opsz' 32;
  letter-spacing: -.014em;
}
.opt-text{
  font-family: var(--serif);
  font-variation-settings: 'opsz' 14;
  letter-spacing: 0;
}
.opt-caption{
  font-family: var(--serif);
  font-variation-settings: 'opsz' 8;
  letter-spacing: .006em;
}
/* Dark type on a light ground appears heavier than light type on dark.
   Drop the optical size a step in dark mode so display type keeps its
   colour (in the typographic sense) across themes. */
[data-theme="dark"] .opt-display{ font-variation-settings: 'opsz' 52; }
[data-theme="dark"] .opt-title  { font-variation-settings: 'opsz' 28; }

/* ---- 6.4 .numeric -------------------------------------------------------
   Tabular figures, slashed zero, and a tighter tracking so long numbers
   don't sprawl. `.numeric.align` additionally right-aligns and locks the
   width so a column of figures forms a clean rule down its left edge. */
.numeric{
  font-variant-numeric: tabular-nums slashed-zero;
  font-feature-settings: 'tnum' 1, 'zero' 1, 'ss01' 1;
  letter-spacing: -.012em;
  font-variant-ligatures: none;
}
.numeric.align{ text-align: right; font-variant-numeric: tabular-nums slashed-zero; }
/* Deltas and percentages read better with lining figures at slightly
   reduced size relative to the label they sit beside. */
.numeric.delta{ font-size: .92em; letter-spacing: 0; }

/* ---- 6.5 Gradient-mask truncation --------------------------------------
   `text-overflow: ellipsis` announces truncation. A gradient mask *implies*
   continuation — the text doesn't stop, it recedes. Correct for previews,
   collapsed cards, and any place the user can expand to see more.

   Uses mask-image so it works over any background, in either theme, with no
   knowledge of what is behind it — unlike the usual gradient-overlay hack,
   which requires a solid known background colour and breaks the instant the
   card is placed on a textured or translucent surface.
   ------------------------------------------------------------------------ */
.fade-end{                       /* single line, fades at the right edge */
  overflow: hidden;
  white-space: nowrap;
  -webkit-mask-image: linear-gradient(to right, #000 78%, transparent 100%);
          mask-image: linear-gradient(to right, #000 78%, transparent 100%);
}
.fade-block{                     /* multi-line, fades at the bottom */
  overflow: hidden;
  max-height: var(--lines, 4.8em);
  -webkit-mask-image: linear-gradient(to bottom, #000 58%, transparent 100%);
          mask-image: linear-gradient(to bottom, #000 58%, transparent 100%);
}
/* Expanded state — remove the mask entirely rather than animating it
   (masks are expensive to interpolate). Height animates, mask snaps at the
   end of the transition, which is imperceptible. */
.fade-block.open{
  max-height: var(--open-h, 200em);
  -webkit-mask-image: none; mask-image: none;
  transition: max-height .5s var(--ease);
}
/* Clamp is still the right tool when the count of lines is the contract. */
.clamp{ display: -webkit-box; -webkit-box-orient: vertical;
        -webkit-line-clamp: var(--lines,3); overflow: hidden; }


/* ==========================================================================
   §7  LOADING + TRANSITION SYSTEM
   ========================================================================== */

/* ---- 7.1 Shimmer skeletons ---------------------------------------------
   A skeleton is only worth having if it is the same shape as the thing it
   replaces. A generic grey box tells the user "something is broken"; a
   skeleton with the card's exact radius, padding and internal rhythm tells
   them "your content is 200ms away" and eliminates the layout shift when it
   lands.

   The shimmer is a translated pseudo-element, not an animated
   background-position. background-position repaints the whole element every
   frame; a translated overlay is composited. On a dashboard with 12
   skeletons this is the difference between 60fps and 30.
   ------------------------------------------------------------------------ */
.sk{
  position: relative;
  overflow: hidden;
  background: var(--sunk);
  border-radius: var(--r-sm);
  isolation: isolate;
}
.sk::after{
  content: '';
  position: absolute;
  inset: 0;
  transform: translateX(-100%);
  background: linear-gradient(90deg,
    transparent 0%,
    color-mix(in srgb, var(--text) 6%, transparent) 50%,
    transparent 100%);
  animation: skShimmer 1.5s var(--ease) infinite;
}
@keyframes skShimmer{ to{ transform: translateX(100%); } }

/* Primitives — compose these to match any component. */
.sk-line{ height: 1em; border-radius: 4px; }
.sk-line.sm{ height: .8em; }
.sk-line.w40{ width: 40%; } .sk-line.w60{ width: 60%; }
.sk-line.w75{ width: 75%; } .sk-line.w90{ width: 90%; }
.sk-avatar{ width: 30px; height: 30px; border-radius: 50%; flex-shrink: 0; }
.sk-avatar.lg{ width: 52px; height: 52px; }
.sk-chip{ width: 40px; height: 40px; border-radius: 12px; flex-shrink: 0; }

/* Shape-matched to .surf + .fcard (components.css) */
.sk-card{
  border: 1px solid var(--hair);
  border-radius: var(--r-lg);
  padding: 26px;
  background: var(--raised);
  overflow: hidden;
}
.sk-card::after{ display: none; }        /* shimmer lives on the children */
.sk-card > * + *{ margin-top: 10px; }
.sk-card .sk-chip{ margin-bottom: 16px; }

/* Shape-matched to .row / .srow (app.css) */
.sk-row{
  display: flex; gap: 18px; align-items: center;
  padding: 17px 24px;
  border-bottom: 1px solid var(--hair);
}
.sk-row:last-child{ border-bottom: 0; }
.sk-row .sk-line:first-of-type{ width: 82px; flex-shrink: 0; }
.sk-row .sk-line:nth-of-type(2){ flex: 1; }

/* Shape-matched to .chart (components.css) — a plausible area silhouette so
   the skeleton occupies the same visual mass as the real chart. */
.sk-chart{
  width: 100%;
  aspect-ratio: 520 / 150;
  border-radius: var(--r-sm);
  -webkit-mask-image: linear-gradient(to top, #000 0 62%, transparent 100%);
          mask-image: linear-gradient(to top, #000 0 62%, transparent 100%);
}

/* Stagger so a list of skeletons breathes instead of strobing in unison. */
.sk-stagger .sk::after{ animation-delay: calc(var(--i,0) * 90ms); }


/* ---- 7.2 Page transitions ----------------------------------------------
   Cross-document View Transitions. `@view-transition { navigation: auto }`
   is a *declarative* opt-in — no JavaScript at all — that makes same-origin
   navigations animate. Unsupported browsers ignore the at-rule entirely and
   navigate normally. This is the rare case where the progressive
   enhancement costs literally nothing.

   The transition is deliberately restrained: a 260ms crossfade with a 6px
   rise on the incoming page. Anything longer or larger makes a fast site
   feel slow, which is the opposite of what a transition is for.
   ------------------------------------------------------------------------ */
@view-transition{ navigation: auto; }

::view-transition-old(root){
  animation: vtOut .18s var(--ease) both;
}
::view-transition-new(root){
  animation: vtIn .26s var(--ease) both;
}
@keyframes vtOut{ to{ opacity: 0; } }
@keyframes vtIn { from{ opacity: 0; transform: translate3d(0,6px,0); } }

/* Persistent chrome must NOT cross-fade — the nav should feel like it never
   left. Naming an element makes it its own transition group; giving that
   group no animation freezes it in place across the navigation.
   Requires `view-transition-name` on the element (see §12 JS/HTML note). */
::view-transition-group(nav),
::view-transition-group(tabs){ animation: none; }
::view-transition-old(nav),  ::view-transition-new(nav),
::view-transition-old(tabs), ::view-transition-new(tabs){
  animation: none; mix-blend-mode: normal;
}

/* Same-document transitions (tab switches, day1/established toggle on the
   dashboard). Driven by document.startViewTransition — see §12. */
.vt-swap{ view-transition-name: swap; }
::view-transition-old(swap){ animation: vtOut .16s var(--ease) both; }
::view-transition-new(swap){ animation: vtIn  .3s  var(--ease) both; }


/* ---- 7.3 Indeterminate top progress bar --------------------------------
   For work of unknown duration (auth, checkout, first paint of member data).
   Two overlapping runners at different speeds — a single sliding block
   reads as a repeating loop and makes time feel slower; two out-of-phase
   runners read as continuous activity.

   Sits above nav (z-index 61 vs nav's 60) and is 2px tall to match the
   existing .prog bar exactly, so the two are visually the same object.
   ------------------------------------------------------------------------ */
.topbar{
  position: fixed;
  top: 0; left: 0; right: 0;
  height: 2px;
  z-index: 61;
  overflow: hidden;
  pointer-events: none;
  opacity: 0;
  transition: opacity .2s var(--ease);
}
.topbar.on{ opacity: 1; }
.topbar::before, .topbar::after{
  content: '';
  position: absolute;
  inset-block: 0;
  left: 0;
  background: var(--text);
  will-change: transform;
}
.topbar::before{ width: 100%; animation: tb1 2.1s cubic-bezier(.65,.815,.735,.395) infinite; }
.topbar::after { width: 100%; animation: tb2 2.1s cubic-bezier(.165,.84,.44,1) 1.15s infinite; }
@keyframes tb1{
  0%  { transform: translateX(-100%) scaleX(.08); }
  60% { transform: translateX(20%)   scaleX(.62); }
  100%{ transform: translateX(120%)  scaleX(.12); }
}
@keyframes tb2{
  0%  { transform: translateX(-100%) scaleX(.04); }
  100%{ transform: translateX(120%)  scaleX(.5); }
}
.topbar::before, .topbar::after{ transform-origin: 0 50%; }

/* Determinate mode — set --p (0–100) and it becomes a fill. */
.topbar.determinate::after{ display: none; }
.topbar.determinate::before{
  animation: none;
  transform: scaleX(calc(var(--p,0) / 100));
  transition: transform .3s var(--ease);
}


/* ==========================================================================
   §8  DATA VISUALISATION
   ========================================================================== */

/* ---- 8.1 Animated gradient area fill -----------------------------------
   The dashboard chart fills its area with `url(#ag)`, a linearGradient whose
   stops use `stop-color: currentColor`. `stop-opacity` is a CSS-animatable
   property on SVG <stop> elements — which means we can breathe the fill's
   density without touching the markup or adding a single element.

   The effect: the fill rises into place after the line has drawn (matching
   the existing 0.7s delay), then settles into an extremely slow, almost
   subliminal density drift. It reads as a live surface rather than a static
   image, which is exactly what a chart of an ongoing thing should read as.
   ------------------------------------------------------------------------ */
.chart .area{
  /* keep the existing fade-in from components.css, then hand over */
  animation: fadein .9s var(--ease) .7s forwards;
}
.chart linearGradient stop:first-child{
  animation: areaRise 1.1s var(--ease) .7s backwards,
             areaDrift 9s ease-in-out 1.8s infinite;
}
@keyframes areaRise { from{ stop-opacity: 0; } to{ stop-opacity: .16; } }
@keyframes areaDrift{ 0%,100%{ stop-opacity: .16; } 50%{ stop-opacity: .225; } }

/* Dark theme needs a denser fill to read at all against #0A0A0B. */
[data-theme="dark"] .chart linearGradient stop:first-child{
  animation: areaRiseD 1.1s var(--ease) .7s backwards,
             areaDriftD 9s ease-in-out 1.8s infinite;
}
@keyframes areaRiseD { from{ stop-opacity: 0; } to{ stop-opacity: .22; } }
@keyframes areaDriftD{ 0%,100%{ stop-opacity: .22; } 50%{ stop-opacity: .30; } }

/* The terminal point should pulse — it is the "now" marker. */
.chart .pt{
  transform-box: fill-box;
  transform-origin: center;
  animation: ptIn .5s var(--spring) 1.9s backwards;
}
@keyframes ptIn{ from{ transform: scale(0); opacity: 0; } }


/* ---- 8.2 Hover crosshair + value tooltip -------------------------------
   Pure CSS positioning driven by --cx (0–100, percent across the plot) and
   --cy. app.js already writes --mx/--my on .spot elements; the tiny
   snapping helper in §12 writes --cx/--cy/--cval here. Everything else —
   the rule, the dot, the tooltip, the reveal — is CSS.

   The crosshair is a 1px rule at --hair2 opacity, not a full-height dark
   line: at this scale a heavy crosshair obscures more data than it locates.
   ------------------------------------------------------------------------ */
.chart.cross{ cursor: crosshair; }
.chart.cross .xh{
  position: absolute;
  top: 0; bottom: 0;
  left: calc(var(--cx, 50) * 1%);
  width: 1px;
  background: var(--hair2);
  opacity: 0;
  pointer-events: none;
  transform: translateX(-.5px);
  transition: opacity var(--t-base) var(--ease), left .12s var(--ease);
}
.chart.cross .xh::after{           /* the point on the line */
  content: '';
  position: absolute;
  left: 50%;
  top: calc(var(--cy, 50) * 1%);
  width: 7px; height: 7px;
  margin: -3.5px 0 0 -3.5px;
  border-radius: 50%;
  background: var(--raised);
  border: 2px solid var(--text);
  box-shadow: var(--e1);
}
.chart.cross:hover .xh{ opacity: 1; }

.chart.cross .xtip{
  position: absolute;
  left: calc(var(--cx, 50) * 1%);
  top: calc(var(--cy, 50) * 1%);
  transform: translate(-50%, calc(-100% - 14px));
  background: var(--btn-bg);
  color: var(--btn-fg);
  font-size: 11.5px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  padding: 5px 9px;
  border-radius: 7px;
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  box-shadow: var(--e2);
  transition: opacity var(--t-base) var(--ease),
              left .12s var(--ease), top .12s var(--ease);
}
.chart.cross:hover .xtip{ opacity: 1; }
/* Flip the tooltip inward at the plot edges so it never clips. */
.chart.cross .xtip[data-edge="start"]{ transform: translate(0, calc(-100% - 14px)); }
.chart.cross .xtip[data-edge="end"]  { transform: translate(-100%, calc(-100% - 14px)); }


/* ---- 8.3 Sparkline ------------------------------------------------------
   An inline chart with no axes, no labels and no interaction — its only job
   is to give a number a direction. Sized in `ch` and `em` so it always
   matches the type it sits beside, at any font-size, without hardcoding.
   ------------------------------------------------------------------------ */
.spark{
  display: inline-block;
  width: var(--w, 7ch);
  height: 1.05em;
  vertical-align: -.12em;
  overflow: visible;
}
.spark path{
  fill: none;
  stroke: currentColor;
  stroke-width: var(--sw, 1.5);
  stroke-linecap: round;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
}
.spark .fill{ fill: currentColor; opacity: .1; stroke: none; }
.spark .cap { fill: currentColor; stroke: none; }
.spark.up  { color: var(--ok); }
.spark.flat{ color: var(--mute); }
/* Draw-on for sparklines that appear after data loads. */
.spark.draw path{
  stroke-dasharray: 100; stroke-dashoffset: 100;
  animation: draw 1.1s var(--ease) forwards;
}


/* ---- 8.4 Radial progress with animated conic sweep ---------------------
   A conic-gradient ring. The sweep animates because --sweep is a registered
   <number> (§1) — without @property a conic-gradient's angle stop cannot be
   transitioned at all, which is why almost every implementation of this
   uses an SVG circle and stroke-dashoffset. The conic version is one
   element, no SVG, and stays perfectly crisp at any size.

   Usage:  <div class="radial" style="--p:68"><b>68%</b></div>
   ------------------------------------------------------------------------ */
.radial{
  --size: 64px;
  --track: 5px;
  position: relative;
  width: var(--size);
  height: var(--size);
  border-radius: 50%;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  background:
    conic-gradient(var(--text) calc(var(--sweep) * 1%), transparent 0),
    conic-gradient(var(--hair) 0 100%);
  /* punch the centre out — mask, not an overlaid circle, so the component
     is transparent in the middle and works on any surface */
  -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - var(--track)), #000 calc(100% - var(--track)));
          mask: radial-gradient(farthest-side, transparent calc(100% - var(--track)), #000 calc(100% - var(--track)));
  animation: radialSweep 1.2s var(--ease) both;
}
@keyframes radialSweep{ from{ --sweep: 0; } to{ --sweep: var(--p, 0); } }

/* The label must sit OUTSIDE the mask, so it is a sibling layer. */
.radial-wrap{ position: relative; display: inline-flex; align-items: center; justify-content: center; }
.radial-wrap > .val{
  position: absolute;
  font-size: 13px; font-weight: 600; letter-spacing: -.02em;
  font-variant-numeric: tabular-nums;
}
.radial.lg{ --size: 92px; --track: 7px; }
.radial.sm{ --size: 40px; --track: 3.5px; }
/* Rounded cap on the leading edge — a small detail that separates this from
   every other conic ring on the internet. Only drawn once there is progress
   worth capping. */
.radial-wrap > .cap{
  position: absolute;
  width: var(--track, 5px); height: var(--track, 5px);
  border-radius: 50%; background: var(--text);
  top: 0; left: 50%;
  transform-origin: 50% calc(var(--size, 64px) / 2);
  transform: translateX(-50%) rotate(calc(var(--p,0) * 3.6deg));
  transition: transform 1.2s var(--ease);
}

/* Fallback where @property is missing: no tween, but the ring still renders
   at its final value, which is the important part. */
@supports not (background: conic-gradient(red calc(var(--x) * 1%), transparent 0)) {
  .radial{ background: conic-gradient(var(--text) calc(var(--p,0) * 1%), var(--hair) 0); animation: none; }
}


/* ---- 8.5 Segmented bar --------------------------------------------------
   Multi-part progress: 3 lessons done, 2 in progress, 5 not started. A
   single bar with three fills is unreadable at 6px tall; discrete segments
   with 2px gaps are legible at a glance and let you show state per unit.

   Extends .bar from app.css rather than replacing it, so the sizing,
   radius and border are inherited and stay in sync.
   ------------------------------------------------------------------------ */
.segbar{
  display: flex;
  gap: 3px;
  height: 6px;
  align-items: stretch;
}
.segbar i{
  flex: var(--f, 1);
  background: var(--sunk);
  border: 1px solid var(--hair);
  border-radius: var(--r-pill);
  transition: background var(--t-slow) var(--ease),
              border-color var(--t-slow) var(--ease),
              flex-grow var(--t-slow) var(--ease);
}
.segbar i.done{ background: var(--text); border-color: var(--text); }
.segbar i.now {
  background: var(--text);
  border-color: var(--text);
  /* the active segment breathes so the eye finds it without colour */
  animation: segNow 2.6s ease-in-out infinite;
}
@keyframes segNow{ 0%,100%{ opacity: 1; } 50%{ opacity: .45; } }
.segbar.lg{ height: 9px; gap: 4px; }
.segbar.stacked i{ border-radius: 0; }
.segbar.stacked i:first-child{ border-radius: var(--r-pill) 0 0 var(--r-pill); }
.segbar.stacked i:last-child { border-radius: 0 var(--r-pill) var(--r-pill) 0; }


/* ==========================================================================
   §9  DEPTH + ATMOSPHERE
   ========================================================================== */

/* ---- 9.1 Refined grain -------------------------------------------------
   app.css paints a 140px noise tile at its natural size. On a 27" display
   that tile repeats ~50 times across and the grain reads as a fine, even
   dither; on a phone it repeats 3 times and the same grain reads as coarse
   blotches. Scaling the tile with the viewport keeps the *apparent* grain
   density constant across every device, which is what film actually does.

   Second correction: on 2x/3x displays each noise pixel is drawn at half or
   a third the physical size, so the grain gets finer AND more numerous —
   the net effect is that it reads as muddy haze rather than texture. We pull
   the opacity down on high-DPI screens to compensate.
   ------------------------------------------------------------------------ */
body::after{
  background-size: clamp(110px, 13vw, 210px);
}
@media (min-resolution: 2dppx){
  [data-theme="light"]{ --grain: .022; }
  [data-theme="dark"] { --grain: .034; }
}
/* Kill grain over media — noise over a photograph is just noise. */
.media, .media img{ isolation: isolate; }
.no-grain{ position: relative; z-index: 3; }

/* ---- 9.2 Vignette -------------------------------------------------------
   Off by default. Add `class="vignette"` to <body>. Extremely subtle: the
   corners lose ~4% luminance in light mode, ~12% in dark. Its job is to stop
   the eye wandering off the edge of a wide viewport, not to be seen.
   Uses body::before (app.css only occupies body::after).
   ------------------------------------------------------------------------ */
body.vignette::before{
  content: '';
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 1;
  background: radial-gradient(ellipse 120% 100% at 50% 42%, transparent 52%, var(--vig) 100%);
}
/* Scoped variant for a single hero or media block. */
.vignette-in{ position: relative; }
.vignette-in::after{
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  border-radius: inherit;
  background: radial-gradient(ellipse 110% 100% at 50% 40%, transparent 55%, var(--vig) 100%);
}

/* ---- 9.3 Light source utility ------------------------------------------
   The hero on index.html hand-rolls a radial glow. This generalises it so
   every section can place light from the SAME implied source with one
   class, keeping the whole page consistently lit instead of each section
   inventing its own sun.

   --lx / --ly position it (default: above and behind the content),
   --lsize scales it, --lint scales its intensity.
   ------------------------------------------------------------------------ */
.lightsource{ position: relative; isolation: isolate; }
.lightsource::before{
  content: '';
  position: absolute;
  left: var(--lx, 50%);
  top: var(--ly, -18%);
  width: var(--lsize, min(940px, 128vw));
  aspect-ratio: 1.55 / 1;
  transform: translateX(-50%);
  pointer-events: none;
  z-index: -1;
  background: radial-gradient(ellipse at center,
    color-mix(in srgb, var(--glow) calc(var(--lint, 1) * 100%), transparent),
    transparent 64%);
}
.lightsource.below::before{ top: auto; bottom: var(--ly, -18%); }
.lightsource.left::before { --lx: 12%; }
.lightsource.right::before{ --lx: 88%; }
.lightsource.soft::before { --lint: .55; }

/* ---- 9.4 Glass -----------------------------------------------------------
   Frosted overlay surface. The two things that make glass convincing and
   are almost always missing:
     1. SATURATION boost, not just blur. Real frosted glass concentrates
        colour; blur alone produces flat grey mush.
     2. A 1px light rim on the TOP edge only. Glass catches the light source
        on its leading edge — this single inset highlight does more for the
        illusion than the blur radius does.

   Works in both themes because the tint comes from --glass-bg, which is
   theme-scoped, rather than from a hardcoded white/black.
   ------------------------------------------------------------------------ */
.glass{
  background: var(--glass-bg);
  -webkit-backdrop-filter: blur(22px) saturate(180%);
          backdrop-filter: blur(22px) saturate(180%);
  border: 1px solid var(--glass-bd);
  box-shadow: var(--e3), inset 0 1px 0 var(--glass-hi);
  border-radius: var(--r-lg);
}
.glass.thin{ -webkit-backdrop-filter: blur(12px) saturate(150%); backdrop-filter: blur(12px) saturate(150%); }
.glass.heavy{ -webkit-backdrop-filter: blur(38px) saturate(200%); backdrop-filter: blur(38px) saturate(200%); }

/* Fallback: without backdrop-filter, transparency is unreadable. Go opaque. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))){
  .glass{ background: var(--raised); }
}


/* ==========================================================================
   §10  MICRO-DETAILS
   ========================================================================== */

/* ---- 10.1 Animated focus ring ------------------------------------------
   app.css sets a 2px solid outline at 3px offset — correct and accessible.
   We keep it exactly, and add a second, animated ring that expands outward
   once and dissipates. The permanent ring is what satisfies WCAG; the
   expanding ring is what tells the user *which direction focus came from*
   in a long keyboard traversal. Never replaces the outline, only joins it.

   Scoped to :focus-visible so pointer users never see it.
   ------------------------------------------------------------------------ */
:focus-visible{
  outline: 2px solid var(--text);
  outline-offset: 3px;
  transition: outline-offset .18s var(--press-out);
}
.ring-pulse:focus-visible{ position: relative; }
.ring-pulse:focus-visible::after{
  content: '';
  position: absolute;
  inset: -3px;
  border-radius: inherit;
  border: 2px solid var(--text);
  pointer-events: none;
  animation: focusRing .55s var(--ease) forwards;
}
@keyframes focusRing{
  from{ transform: scale(1);    opacity: .55; }
  to  { transform: scale(1.14); opacity: 0; }
}
/* Buttons that are already elevated get a contrasting inner ring instead,
   so the focus indicator never disappears into a dark solid fill. */
.btn.solid:focus-visible{ outline-color: var(--text); outline-offset: 3px;
  box-shadow: var(--e2), inset 0 0 0 1.5px color-mix(in srgb, var(--btn-fg) 55%, transparent); }

/* ---- 10.2 Selection per surface ----------------------------------------
   ::selection with one global colour is wrong the moment you have inverted
   surfaces. On a solid dark button or a media caption, the page-level
   selection tint is invisible. These rules invert selection inside inverted
   contexts so highlighted text is always legible.
   ------------------------------------------------------------------------ */
::selection{ background: var(--sel); color: var(--text); }
.btn.solid ::selection, .tile.on ::selection, .chip.solid ::selection{
  background: color-mix(in srgb, var(--btn-fg) 28%, transparent);
  color: var(--btn-fg);
}
.media .cap ::selection{ background: rgba(255,255,255,.3); color: #fff; }
/* Numeric data selects as a block — selecting a figure should give you the
   figure, not a ragged partial. */
.numeric{ -webkit-user-select: all; user-select: all; }
.numeric.free{ -webkit-user-select: text; user-select: text; }

/* ---- 10.3 Scrollbar refinement -----------------------------------------
   The existing scrollbar uses a 3px border to fake padding, which means the
   thumb's visible width changes with the border colour and it looks chunky
   on dark. This keeps the same geometry but makes the thumb recede until
   the container is hovered — present when you need it, invisible when you
   don't. Firefox gets the standards path.
   ------------------------------------------------------------------------ */
*{ scrollbar-color: var(--hair2) transparent; scrollbar-width: thin; }
::-webkit-scrollbar-thumb{
  background: var(--hair2);
  border-radius: 99px;
  border: 3px solid transparent;
  background-clip: content-box;
  transition: background-color var(--t-base);
}
::-webkit-scrollbar-thumb:hover{ background: var(--mute); background-clip: content-box; }
/* Overflow regions inside cards get a quieter treatment still. */
.cmdlist::-webkit-scrollbar{ width: 8px; }
.cmdlist::-webkit-scrollbar-thumb{ border-width: 2px; }

/* ---- 10.4 Animated underline system ------------------------------------
   Four behaviours, one mechanism (a scaled-X pseudo rule). The important
   detail is TRANSFORM-ORIGIN, which encodes meaning:
     .u        origin left  — "continues in reading direction" (nav, inline)
     .u-center origin centre — "this is a discrete object" (buttons, tags)
     .u-out    origin right on exit — the rule retracts the way you came,
               which is what makes rapid hover across a nav feel liquid
               instead of stuttery
     .u-thick  a 2px rule for links inside body copy, where 1px disappears

   Uses text-underline-offset-aware positioning and `currentColor`, so it
   inherits correctly on both themes and inside inverted surfaces.
   ------------------------------------------------------------------------ */
.u{ position: relative; }
.u::after{
  content: '';
  position: absolute;
  left: 0; right: 0; bottom: -3px;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: right center;               /* exit */
  transition: transform var(--t-slow) var(--ease);
}
.u:hover::after, .u:focus-visible::after{
  transform: scaleX(1);
  transform-origin: left center;                /* enter */
}
.u-center::after{ transform-origin: center; }
.u-center:hover::after{ transform-origin: center; }
.u-thick::after{ height: 2px; bottom: -4px; border-radius: 1px; }
/* Persistent underline that THINS on hover — for links that must always be
   visibly links (legal, disclosures) but should still respond. */
.u-always::after{ transform: scaleX(1); opacity: .35; }
.u-always:hover::after{ opacity: 1; }

/* ---- 10.5 Badge shimmer on state change --------------------------------
   When a badge changes state (Pending -> Confirmed, Draft -> Live) a single
   sweep of light crosses it once. One sweep, 900ms, never repeats. A badge
   that shimmers continuously is an advertisement; a badge that shimmers
   once is a receipt.

   Add `.flash` (by JS, on change) — the animation removes nothing and the
   class can be left on, because the animation does not loop.
   ------------------------------------------------------------------------ */
.badge{ position: relative; overflow: hidden; isolation: isolate; }
.badge.flash::after{
  content: '';
  position: absolute;
  inset: 0;
  transform: translateX(-110%) skewX(-18deg);
  background: linear-gradient(90deg, transparent,
    color-mix(in srgb, var(--text) 22%, transparent), transparent);
  animation: badgeFlash .9s var(--ease) 1;
  pointer-events: none;
}
.badge.ok.flash::after{
  background: linear-gradient(90deg, transparent,
    color-mix(in srgb, var(--ok) 34%, transparent), transparent);
}
@keyframes badgeFlash{ to{ transform: translateX(110%) skewX(-18deg); } }

/* ---- 10.6 Live pulse refinement ----------------------------------------
   The existing .dot pulses a single expanding ring. Two problems: at 2.4s
   it is slow enough to read as decorative rather than live, and a single
   ring at constant opacity looks like a ripple in water rather than a
   broadcast.

   Fix: TWO rings, out of phase, with the second at lower opacity and a
   different easing — and the core dot itself gains a very slight brightness
   breath. The result reads unmistakably as "transmitting". Same markup,
   same size, no layout change.
   ------------------------------------------------------------------------ */
.dot{ animation: dotCore 2.4s ease-in-out infinite; }
.dot::after{ animation: pulse 2.4s cubic-bezier(.2,.6,.35,1) infinite; }
.dot::before{
  content: '';
  position: absolute;
  inset: -3px;
  border-radius: 50%;
  background: var(--ok);
  opacity: .3;
  animation: pulse 2.4s cubic-bezier(.2,.6,.35,1) 1.2s infinite;
}
@keyframes dotCore{ 0%,100%{ opacity: 1; } 50%{ opacity: .78; } }

/* Neutral variant — a live indicator that is NOT a success state
   (e.g. "session in progress"). Monochrome, same physics. */
.dot.neutral{ background: var(--text); }
.dot.neutral::after, .dot.neutral::before{ background: var(--text); }

/* ---- 10.7 Hairline rules that fade at the ends -------------------------
   A border that runs edge to edge boxes content in. A rule that fades out
   at both ends separates without enclosing. Used between list sections. */
.rule{
  height: 1px; border: 0;
  background: linear-gradient(90deg, transparent, var(--hair2) 18%, var(--hair2) 82%, transparent);
}
.rule.v{ width: 1px; height: auto; align-self: stretch;
  background: linear-gradient(180deg, transparent, var(--hair2) 18%, var(--hair2) 82%, transparent); }


/* ==========================================================================
   §11  REDUCED-MOTION MASTER GATE
   --------------------------------------------------------------------------
   `prefers-reduced-motion` is not "no animation" — it is "no motion that
   could trigger vestibular discomfort". The distinction matters:

     REMOVED : anything that translates, scales, parallaxes, sweeps, or
               loops indefinitely. All of it.
     KEPT    : opacity crossfades under ~250ms, colour transitions, and
               the SHAPE of every component — because a user with reduced
               motion enabled should get the same interface, not a
               degraded one.

   Skeletons keep a slow opacity breath instead of a travelling shimmer, so
   loading state is still communicated. Progress bars go determinate-static
   rather than disappearing, so the user is not left without feedback.
   ========================================================================== */
@media (prefers-reduced-motion: reduce){

  /* Nothing loops, nothing travels. */
  *, *::before, *::after{
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
    scroll-behavior: auto !important;
  }

  /* Scroll-driven animations must resolve to their FINAL state, not their
     start state — otherwise elements stay invisible forever. */
  .v-enter, .v-parallax, .v-shrink, .reveal-text, .reveal-text > span{
    animation: none !important;
    opacity: 1 !important;
    transform: none !important;
    clip-path: none !important;
  }
  .v-progress{ transform: none !important; }

  /* Magnetism and press become instant, non-travelling state changes. */
  .magnetic, .press{ transform: none !important; }
  .magnetic:hover, .magnetic:active, .press:active{ transform: none !important; }
  .magnetic:hover{ box-shadow: var(--e2); }

  /* Specular becomes a static, permanent, gentle highlight rather than a
     pointer-tracking bloom. */
  .spec{ --spec: .4; background-position: 50% 50%; }
  .spec:hover{ --spec: .55; --light-angle: 180deg; }

  /* Skeletons: breathe instead of sweep. This is explicitly re-enabled
     above the blanket rule because loading feedback is functional, and a
     600ms opacity fade is well within reduced-motion guidance. */
  .sk::after{
    animation: skBreath 1.6s ease-in-out infinite !important;
    animation-duration: 1.6s !important;
    animation-iteration-count: infinite !important;
    transform: none !important;
    background: color-mix(in srgb, var(--text) 5%, transparent) !important;
  }
  @keyframes skBreath{ 0%,100%{ opacity: .25; } 50%{ opacity: .7; } }

  /* Indeterminate progress: a static 100%-width bar at reduced opacity.
     Still communicates "busy", never moves. */
  .topbar::before{
    animation: none !important;
    transform: scaleX(1) !important;
    opacity: .35;
  }
  .topbar::after{ display: none; }

  /* Live pulse: the ring stops, the dot keeps a slow opacity breath, which
     is the accessible way to say "live". */
  .dot::after, .dot::before{ animation: none !important; opacity: 0 !important; }
  .dot{
    animation: dotCore 2.4s ease-in-out infinite !important;
    animation-duration: 2.4s !important;
    animation-iteration-count: infinite !important;
  }
  .segbar i.now{ animation: none !important; opacity: 1; }

  /* Chart: final state immediately, no drift. */
  .chart .line{ stroke-dashoffset: 0 !important; }
  .chart .area, .chart .pt{ opacity: 1 !important; transform: none !important; }
  .chart linearGradient stop:first-child{ animation: none !important; }

  /* View transitions off entirely — a cross-fade between full pages is
     exactly the kind of thing this preference exists to prevent. */
  ::view-transition-old(root), ::view-transition-new(root),
  ::view-transition-old(swap), ::view-transition-new(swap){ animation: none !important; }

  /* Focus ring stays, its expansion does not. */
  .ring-pulse:focus-visible::after{ animation: none !important; opacity: 0 !important; }
  .badge.flash::after{ animation: none !important; opacity: 0 !important; }
}

/* Forced-colors / high-contrast: strip every decorative layer so the OS
   palette is not fighting our gradients. */
@media (forced-colors: active){
  .spec, .sk::after, .glass, .lightsource::before,
  body.vignette::before, .vignette-in::after{ background: none !important; }
  .surf::before, .spot::after{ display: none !important; }
  .radial{ forced-color-adjust: none; }
}


/* ==========================================================================
   §12  JS COMPANION
   --------------------------------------------------------------------------
   Everything above is CSS-only EXCEPT the four behaviours below, which
   genuinely cannot be expressed in CSS today. app.js is owned by another
   agent, so these are documented here for them to paste in. Each is
   self-contained, feature-detected, passive, and safe to omit — the CSS
   degrades to the fallbacks described in each section.

   Total: ~55 lines. No dependencies.

   ---------------------------------------------------------------------------
   (A) MAGNETIC POINTER TRACKING  — enables §4.1's directional pull.
       Without it, .magnetic still works via the proximity pad (scale only).

       (function(){
         if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
         if (!matchMedia('(pointer: fine)').matches) return;   // no magnets on touch
         document.querySelectorAll('.magnetic').forEach(function(el){
           var r = null;
           el.addEventListener('pointerenter', function(){ r = el.getBoundingClientRect(); });
           el.addEventListener('pointermove', function(e){
             if (!r) r = el.getBoundingClientRect();
             el.style.setProperty('--mag-x', (e.clientX - (r.left + r.width  / 2)) + 'px');
             el.style.setProperty('--mag-y', (e.clientY - (r.top  + r.height / 2)) + 'px');
           }, { passive: true });
           el.addEventListener('pointerleave', function(){
             r = null;
             el.style.setProperty('--mag-x', '0px');
             el.style.setProperty('--mag-y', '0px');
           });
         });
       })();

       NOTE: getBoundingClientRect is read ONCE per pointerenter, not per
       pointermove — that is what keeps this off the layout-thrash path.

   ---------------------------------------------------------------------------
   (B) SPECULAR / SPOTLIGHT COORDINATES  — §3.3 reuses the --mx/--my that
       app.js already writes for .spot. Only change needed: widen the
       selector so .spec elements are included.

           document.querySelectorAll('.spot, .spec')   // was: '.spot'

       Without this, .spec still blooms — just always from the centre.

   ---------------------------------------------------------------------------
   (C) CHART CROSSHAIR SNAPPING  — enables §8.2.
       CSS can position the crosshair from --cx/--cy but cannot know the
       data. This snaps to the nearest datapoint and writes the value.

       Markup needed inside .chart.cross (sibling of the <svg>):
           <i class="xh"></i><b class="xtip"></b>
       and a data attribute on the chart:
           data-points="0,3 1,5 2,8 3,9 4,12 5,14 6,18 7,21"
       (x index, y value — the CSS handles all positioning.)

       (function(){
         document.querySelectorAll('.chart.cross[data-points]').forEach(function(c){
           var pts = c.dataset.points.trim().split(/\s+/).map(function(p){
                 var a = p.split(','); return { x:+a[0], y:+a[1] };
               }),
               n = pts.length - 1,
               max = Math.max.apply(null, pts.map(function(p){ return p.y; })),
               tip = c.querySelector('.xtip');
           c.addEventListener('pointermove', function(e){
             var r = c.getBoundingClientRect(),
                 t = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)),
                 i = Math.round(t * n),
                 p = pts[i];
             c.style.setProperty('--cx', (i / n) * 100);
             c.style.setProperty('--cy', (1 - p.y / max) * 100);
             if (tip){
               tip.textContent = p.y;
               tip.dataset.edge = i === 0 ? 'start' : (i === n ? 'end' : '');
             }
           }, { passive: true });
         });
       })();

   ---------------------------------------------------------------------------
   (D) VIEW TRANSITION NAMES + SAME-DOCUMENT SWAPS  — enables §7.2's
       persistent nav. Cross-document transitions work with zero JS; these
       two lines just stop the nav from cross-fading with the page.

           var n = document.querySelector('nav');  if (n) n.style.viewTransitionName = 'nav';
           var t = document.querySelector('.tabs'); if (t) t.style.viewTransitionName = 'tabs';

       For same-document swaps (the dashboard's day1 <-> established toggle),
       wrap the DOM mutation:

           function swap(fn){
             if (!document.startViewTransition ||
                 matchMedia('(prefers-reduced-motion: reduce)').matches) return fn();
             document.startViewTransition(fn);
           }

   ---------------------------------------------------------------------------
   (E) TOP PROGRESS BAR  — §7.3. Markup: <div class="topbar" id="tb"></div>
       as the first child of <body>. Then:

           var tb = document.getElementById('tb');
           window.nccBusy = function(on){ tb && tb.classList.toggle('on', !!on); };

       Call nccBusy(true) before an async operation, nccBusy(false) after.
   ========================================================================== */
