/* Lifeline — app shell styles.
 *
 * Two layers of custom properties, on purpose:
 *
 *   1. BRAND tokens (--brand-*) are the church's fixed palette. They are
 *      declared once on :root and never redeclared anywhere.
 *   2. SURFACE tokens (--surface, --ink, --line, --gloss, --elev-*) are
 *      *relative to whatever you are sitting on*. Every component styles itself
 *      with these and only these, so a container can re-declare the surface —
 *      `.paper` turns a dark navy region into the warm reading page — and
 *      everything inside it, including components written months later, adapts
 *      without a single component-level override.
 *
 * DESIGN.md §5: dark navy shell, warm light reading surface, ONE red accent.
 * Red is load-bearing (the lifeline pulse). It is never used for decoration.
 *
 * ---------------------------------------------------------------------------
 * DEPTH BUDGET — read before adding an effect
 * ---------------------------------------------------------------------------
 * The congregation is on cheap Android phones over church wifi. Depth here is
 * bought with things the compositor already does for free:
 *
 *   layered gradients · static box-shadows · transform · opacity
 *
 * Nothing animates width, height, top/left, box-shadow or background-position.
 * `filter`/`backdrop-filter` appear exactly twice (top bar, tab bar) on small
 * fixed strips, never on a scrolling list. Every animation is escapable through
 * prefers-reduced-motion at the bottom of this file.
 */

/* ===================================================================
   Tokens
   =================================================================== */
:root {
  /* Brand — the logo's own inks, unchanged. The mark still prints navy script
     on a white plate; nothing here recolours it. */
  --brand-ink:   #13275C;
  --brand-pulse: #B03020;
  --brand-paper: #F6F2EA;

  /* Shell — red, per Chance 2026-07-28. Not a flat primary: an oxblood floor
     the colour of dark velvet, climbing to the brand pulse and breaking into a
     warmer flare, so the page has somewhere to fall off to and somewhere to
     catch light. Red and gold is not a decorative pick for this congregation —
     it is the palette of the Guadalupe roses and the festival gilt they already
     live in, and it reads as celebration in both languages. */
  --brand-shell: #3E0B0A;   /* oxblood floor */
  --brand-navy:  #1E3A82;   /* THE PANEL SURFACE — blue, see below */
  --brand-blue:  #A32A1D;   /* ⚠ LIES: this is the structural RED, not a blue.
                               The name survives from the pre-red palette. Now
                               that real blue is back in the app, read this
                               token as "structural red" everywhere it appears
                               and reach for --panel* when you want blue. */
  --brand-sky:   #D9A441;   /* GOLD: every accent that used to be sky */
  --brand-void:  #240605;   /* deepest fall-off */
  --brand-lift:  #7E1B15;   /* lit crest OF THE RED SHELL — not of a panel */

  /* Panels — BLUE, per Chance 2026-07-29: "background red, the cards on top
     blue". The split is field vs object. The FIELD stays oxblood — page,
     ambient, splash, top bar, tab bar, the alert band — and every OBJECT
     standing on it is navy. This is the logo's own pairing (navy script, red
     pulse), so the two families were never strangers; it just puts the blue
     where the mark can't be hurt by it.
     ⚠ --brand-lift is the shell's crest and is RED. A panel gradient must
     reach for --panel-lift / --panel-deep or it ends up red-to-blue.

     The panel is #1E3A82 rather than a deeper navy because Chance asked for
     blue *"so it stands out from the background"* — that is a separation
     requirement, not just a hue preference. #1B3474 on the oxblood field
     carried only 1.34:1 and leaned entirely on hue to do the work, which is
     the first thing to go on a cheap phone in Sunday sunlight. #1E3A82 is
     1.49:1 and is the lightest the panel can go before --ink-faint drops
     under 4.5:1 on it (4.67 as built). Cards also keep their gold hairline
     and elevation, so the boundary never depends on the fill alone. */
  --panel-lift:  #3A5EAA;   /* lit crest of a panel */
  --panel-deep:  #14285C;   /* a panel's fall-off */

  /* Gold, stated in its own right — hairlines, rules, and the pulse. */
  --gold:        #D9A441;
  --gold-lit:    #F0D08A;
  --gold-deep:   #9A6B1F;

  /* Surface — the dark chrome is the default surface. */
  --surface:     var(--brand-shell);
  --surface-2:   var(--brand-navy);
  --surface-3:   #2E51A0;
  --ink:         #F7EFEA;
  --ink-dim:     #D9C0B4;
  /* Lifted from #A98A80. The faint ink is spent on BOTH grounds — tab labels on
     the oxblood field, .tile-meta and .post-meta on the blue panels — and the
     blue panel is the lighter of the two, so the old value fell to 3.70:1 there
     the moment the cards changed colour. 5.17:1 on the panel, 6.94:1 on the
     shell. Small text is exactly where a token move like this goes unnoticed. */
  --ink-faint:   #BFA79E;
  --line:        rgba(217, 164, 65, 0.19);
  --accent:      var(--brand-sky);
  --accent-ink:  var(--brand-shell);
  --focus:       var(--brand-sky);

  /* Dimensional kit. --gloss is the "light from above" that makes a flat panel
     read as a physical surface; --hairline is the lit top edge of that surface;
     the --elev ramp is a close contact shadow plus a wide ambient one, which is
     what separates a real object from a drop-shadowed rectangle. */
  --gloss:       linear-gradient(180deg, rgba(255,255,255,.075), rgba(255,255,255,0) 46%);
  --hairline:    rgba(255, 255, 255, .10);
  --elev-1:      0 1px 2px rgba(0,0,0,.30), 0 4px 12px -6px rgba(0,0,0,.50);
  --elev-2:      0 2px 4px rgba(0,0,0,.28), 0 16px 32px -16px rgba(0,0,0,.62);
  --elev-3:      0 6px 14px rgba(0,0,0,.30), 0 34px 64px -26px rgba(0,0,0,.72);
  --shadow:      var(--elev-2);      /* legacy alias */

  --radius:      16px;
  --radius-sm:   11px;
  --radius-xs:   8px;
  --tap:         44px;              /* minimum touch target */
  --pad:         clamp(14px, 4vw, 22px);
  --maxw:        720px;

  /* Rhythm. One scale, used everywhere, so vertical spacing is a decision
     instead of whatever the last person typed. */
  --s1: 4px;  --s2: 8px;  --s3: 12px; --s4: 16px;
  --s5: 22px; --s6: 30px; --s7: 44px;

  /* Type scale. Ratio ~1.18 at the body end, opening up at the display end so a
     heading has somewhere to be big without a jump. */
  --t-xs:  .74rem;
  --t-sm:  .86rem;
  --t-md:  1rem;
  --t-lg:  1.09rem;
  --t-xl:  1.3rem;
  --t-2xl: clamp(1.45rem, 1.16rem + 1.5vw, 1.85rem);
  --t-3xl: clamp(1.72rem, 1.3rem + 2.2vw, 2.3rem);

  --font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
          "Noto Sans", sans-serif;
  --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;

  --ease:     cubic-bezier(.2, .8, .3, 1);
  --ease-in:  cubic-bezier(.4, 0, .8, .3);

  --safe-top:    env(safe-area-inset-top, 0px);
  --safe-bottom: env(safe-area-inset-bottom, 0px);

  /* The pulse, as artwork. One SVG, three tints — the app's signature mark shows
     up as a section rule, a card watermark and the splash trace, so the same
     line is doing the same job everywhere instead of three lookalike shapes.
     Inline data URIs: no extra request, works offline, no build step.

     ⚠ THE PATH IS A REAL CARDIAC COMPLEX (2026-07-30), not a decorative zigzag.
     Read left to right at baseline y=8 in a 104×16 box:

       H18            TP rest — the long flat stretch between beats
       Q24 3.6 30 8   P wave, ROUNDED (atrial depolarisation, small)
       H38            PR segment, flat
       L40.5 9.8      Q — a small dip BELOW baseline
       L44 1.4        R — the tall narrow spike
       L47.5 13.6     S — undershoot below baseline
       L50 8          back to baseline
       H60            ST segment, flat
       Q70 2 80 8     T wave, ROUNDED and taller than P (repolarisation)
       H104           back to rest

     The contrast between the ROUNDED P/T curves and the STRAIGHT-segment QRS is
     what makes it read as cardiac rather than as a sawtooth. Keep the quadratics
     quadratic. A Bézier's midpoint sits at (P0 + 2C + P2)/4, so the control-point
     y values above put the P peak at 5.8 and the T peak at 5.0 — T is deliberately
     the taller of the two, as it is on a real strip.

     ⚠ Stroke thinned 2.2 → 1.9: a narrow QRS at 2.2 blobs into a triangle at
     watermark sizes. Keep the viewBox at 104×16 — four surfaces derive their
     tile width from the 6.5:1 ratio. */
  --ekg-red: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 104 16'%3E%3Cpath d='M0 8 H18 Q24 3.6 30 8 H38 L40.5 9.8 L44 1.4 L47.5 13.6 L50 8 H60 Q70 2 80 8 H104' fill='none' stroke='%23B03020' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  --ekg: var(--ekg-gold);   /* set per surface: bright gold on the shell, deep gold on paper */
  /* Brightened from #D9A441 to #F5C542 (2026-07-30) — the trace reads as a live
     monitor now, so it has to be legible in motion, not just present. */
  --ekg-gold: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 104 16'%3E%3Cpath d='M0 8 H18 Q24 3.6 30 8 H38 L40.5 9.8 L44 1.4 L47.5 13.6 L50 8 H60 Q70 2 80 8 H104' fill='none' stroke='%23F5C542' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  /* The same gold taken dark enough to hold on warm paper. Bright gold on
     #F5EFE2 disappears; this keeps the line gold on EVERY surface without
     giving up legibility on the reading pages. */
  --ekg-gold-deep: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 104 16'%3E%3Cpath d='M0 8 H18 Q24 3.6 30 8 H38 L40.5 9.8 L44 1.4 L47.5 13.6 L50 8 H60 Q70 2 80 8 H104' fill='none' stroke='%23B07E12' stroke-width='2.1' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");

  /* One tile of the mark is 104×16, so its width is 6.5× its rendered height.
     Each surface sets --ekg-tile from its own height; the value is still used
     to size the repeat, it just no longer drives a translation. */
  --ekg-run-speed: 3.4s;

  /* ---- monitor sweep ----
     One pass of the head across the element. A real monitor runs 25mm/s, which
     works out near 8s across a phone-width screen and reads as sluggish; 4.5s
     keeps the beat legible without feeling like a fast-forward. Taste dial. */
  --ekg-sweep-speed: 4.5s;
  /* The blank stretch the head leaves ahead of itself before the previous
     sweep resumes. This gap is the single clearest "it is erasing and
     redrawing in place" cue — without it the line just looks redrawn. */
  --ekg-gap: 6%;
  /* How far the freshly-drawn trace stays hot before settling to its resting
     brightness. Fixed distance, not a fraction of progress: phosphor decays
     over a distance, so a proportional trail would make the whole line dim
     early in the sweep and bright late. */
  --ekg-trail: 20%;
  /* ⚠ ONE resting brightness for BOTH sweeps, and that is deliberate. Giving
     the previous sweep its own dimmer value looks better in a still frame and
     is wrong in motion: at the wrap, every pixel that was "drawn" becomes
     "previous" in one frame, so the whole line changes brightness and hue at
     once, every cycle. Measured at 98.5% vs 1.5% of the sweep it was a plainly
     visible full-width flash — the sort of thing that is fine in a demo and
     maddening on a page someone is reading.
     A real monitor holds the old trace at full brightness until the head
     erases it; what distinguishes old from new is the GAP and the CURSOR, not
     opacity. The only brightness variation left is the hot trail just behind
     the head, which is local and travels with it. */
  --ekg-rest: 0.42;
  --ekg-head-ink: #FFF3D0;  /* the head itself — hotter than the trace */
  --ekg-glow: rgba(245, 197, 66, .75);
  --ekg-grid-ink: rgba(245, 197, 66, .055);
  --ekg-grid-step: 9px;
  /* Composed once so the surfaces and the reduced-motion override cannot drift
     apart — the override has to restate background-image in full, and two
     hand-copied gradient stacks are two things to forget to change. */
  /* ⚠ The head is a HAIRLINE CURSOR, not a wipe. A percentage width looks
     reasonable in the stylesheet and renders as a ~14px bar on a phone, which
     reads as a loading swipe passing over the card rather than a monitor
     drawing a line. Fixed 2px, and the ends fade out vertically so it does not
     terminate in two hard corners against the card edge. The bloom around it
     comes from the ::after drop-shadow, which is anchored to the trace itself. */
  --ekg-head-img: linear-gradient(180deg, transparent, var(--ekg-head-ink) 16% 84%, transparent);
  --ekg-grid-img:
    repeating-linear-gradient(90deg, var(--ekg-grid-ink) 0 1px, transparent 1px var(--ekg-grid-step)),
    repeating-linear-gradient(0deg,  var(--ekg-grid-ink) 0 1px, transparent 1px var(--ekg-grid-step));
}

/* The reading surface. Anything inside `.paper` becomes light-on-warm without
   knowing it moved — including its light, its edge and its shadow. */
.paper {
  --surface:    var(--brand-paper);
  --surface-2:  #FFFFFF;
  --surface-3:  #ECE5D8;
  --ink:        #14203C;
  --ink-dim:    #4E5A73;
  --ink-faint:  #7C879B;
  --line:       rgba(11, 26, 63, 0.14);
  /* Bronze, not the structural red. --btn-danger is --brand-pulse #B03020; a
     red primary here measured 7.23 against danger's 6.38 on white text — the
     same button to anyone glancing, so "Save" and "Remove" stopped being
     distinguishable on the reading surface. Bronze is the shell's gold taken
     dark enough to carry white text (5.58:1), which keeps the primary action
     gold-family on BOTH surfaces while danger stays the only red button. */
  --accent:     #8A6014;
  --accent-ink: #FFFFFF;
  --focus:      var(--brand-blue);
  --gloss:      linear-gradient(180deg, rgba(255,255,255,.9), rgba(255,255,255,0) 54%);
  --hairline:   rgba(255, 255, 255, .95);
  --elev-1:     0 1px 2px rgba(11,26,63,.10), 0 6px 14px -8px rgba(11,26,63,.22);
  --elev-2:     0 2px 5px rgba(11,26,63,.10), 0 18px 34px -18px rgba(11,26,63,.34);
  --elev-3:     0 8px 18px rgba(11,26,63,.14), 0 36px 60px -28px rgba(11,26,63,.42);
  --shadow:     var(--elev-2);
  /* Chance asked for gold on every page (2026-07-30). Bright gold is invisible
     on warm paper, so the reading surface takes the deep gold instead of the
     red. --ekg-red is kept defined: red is still the load-bearing pulse colour
     and this is a one-line revert if the paper pages want it back. */
  --ekg:        var(--ekg-gold-deep);
  /* The sweep re-scores every one of these against a LIGHT ground. A head of
     #FFF3D0 and a 5.5%-alpha gold grid are both invisible on warm paper, and a
     glow made of light does nothing on a surface that is already light. Same
     reasoning as the ink tokens above: a surface change re-scores everything
     spent on it. */
  --ekg-head-ink: #6B4A08;
  --ekg-glow:     rgba(107, 74, 8, .38);
  --ekg-grid-ink: rgba(20, 32, 60, .055);
  --ekg-rest:     0.55;
}

/* ===================================================================
   Base
   =================================================================== */
*, *::before, *::after { box-sizing: border-box; }
[hidden] { display: none !important; }

html, body {
  margin: 0;
  padding: 0;
  min-height: 100%;
}

body {
  background: var(--brand-shell);
  color: var(--ink);
  font-family: var(--font);
  font-size: 16px;
  line-height: 1.55;
  -webkit-text-size-adjust: 100%;
  -webkit-tap-highlight-color: transparent;
  overscroll-behavior-y: none;
}

h1, h2, h3 {
  margin: 0 0 .4em;
  line-height: 1.16;
  font-weight: 700;
  letter-spacing: -.014em;
  text-wrap: balance;          /* Spanish runs long; break it where it reads */
}
h1 { font-size: var(--t-2xl); letter-spacing: -.02em; }
h2 { font-size: var(--t-xl); }
h3 { font-size: var(--t-lg); }
p  { margin: 0 0 .85em; }

/* No underlines, per Chance 2026-07-29. Every anchor this app renders is either
   painted as an OBJECT — a tile, a button, a badge, a book, a tab — or is an
   accent-coloured text control (.linkish). None of them are prose links, so the
   UA underline was landing under whole tiles and badge chips and reading as a
   defect rather than an affordance. Colour + weight + a 44px target carry the
   "this is tappable" job here. If real inline prose links ever appear inside
   body copy, give THEM the underline back explicitly — don't reopen this. */
a { color: var(--accent); text-decoration: none; }

/* The one structural device that repeats: a small caps-tracked label naming the
   context a block belongs to. It is never decorative — if it does not name
   something true, it does not get one. */
.eyebrow {
  margin: 0 0 var(--s2);
  font-size: var(--t-xs);
  font-weight: 700;
  /* .12em, not .15em: "IGLESIA BAUTISTA LIFELINE" is 25 characters and at the
     wider tracking it reached the exact edge of a 360px screen. The label that
     names the church is the last thing that should be flirting with a clip. */
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

:focus-visible {
  outline: 3px solid var(--focus);
  outline-offset: 2px;
  border-radius: 4px;
}

.noscript {
  position: relative;
  z-index: 2;
  max-width: 34rem;
  margin: 3rem auto;
  padding: var(--pad);
  background: var(--brand-paper);
  color: #14203C;
  border-radius: var(--radius);
}

/* ===================================================================
   Ambient background — the depth field the whole app sits in
   ===================================================================
   Three stacked layers, all fixed, all behind everything, none of them
   interactive:

     .ambient        static layered radial gradients (the "room")
     .ambient-drift  two soft lights that drift on transform only
     #ambient-canvas an optional low-density constellation (app.js decides)
     .ambient-veil   a navy scrim that guarantees text contrast no matter what
                     the canvas happens to draw underneath a paragraph

   The veil is not optional. Without it, a bright particle passing under body
   copy costs contrast at exactly the moment somebody is reading a prayer
   request on a phone in sunlight.
*/
.ambient {
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  overflow: hidden;
  background-color: var(--brand-shell);
  background-image:
    radial-gradient(90% 55% at 50% -8%,  rgba(126, 27, 21, .85) 0%, rgba(126, 27, 21, 0) 68%),
    radial-gradient(70% 45% at 8% 22%,   rgba(163, 42, 29, 0.42) 0%, rgba(163, 42, 29, 0) 70%),
    radial-gradient(85% 50% at 98% 76%,  rgba(94, 21, 18, .85) 0%,  rgba(94, 21, 18, 0) 72%),
    radial-gradient(120% 90% at 50% 112%, rgba(36, 6, 5, .95) 0%,  rgba(36, 6, 5, 0) 60%);
}

.ambient-drift {
  position: absolute;
  top: -30%;
  left: -25%;
  width: 150%;
  height: 150%;
  opacity: .55;
  background-image:
    radial-gradient(28% 22% at 30% 34%, rgba(217, 164, 65, 0.19) 0%, rgba(217, 164, 65, 0) 68%),
    radial-gradient(24% 20% at 72% 62%, rgba(163, 42, 29, 0.31) 0%, rgba(163, 42, 29, 0) 70%);
  will-change: transform;
  animation: drift 68s var(--ease) infinite alternate;
}

/* A second, slower light going the other way. Two crossing drifts read as a
   living room; one reads as a slider. */
.ambient-drift-b {
  opacity: .4;
  background-image:
    radial-gradient(30% 24% at 66% 24%, rgba(176, 48, 32, .10) 0%, rgba(176, 48, 32, 0) 66%),
    radial-gradient(26% 22% at 22% 78%, rgba(217, 164, 65, 0.14) 0%, rgba(217, 164, 65, 0) 70%);
  animation: drift-b 94s var(--ease) infinite alternate;
}

#ambient-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  opacity: .9;
}

.ambient-veil {
  position: absolute;
  inset: 0;
  background:
    linear-gradient(180deg, rgba(62, 11, 10, .55) 0%, rgba(62, 11, 10, .18) 30%,
                            rgba(62, 11, 10, .22) 70%, rgba(62, 11, 10, .62) 100%);
}

@keyframes drift {
  from { transform: translate3d(-3%, -2%, 0) scale(1); }
  to   { transform: translate3d(4%, 3%, 0) scale(1.12); }
}
@keyframes drift-b {
  from { transform: translate3d(3%, 2%, 0) scale(1.08); }
  to   { transform: translate3d(-4%, -3%, 0) scale(1); }
}

/* ===================================================================
   Splash — the EKG reveal
   ===================================================================
   The identity moment, and the only place the app is allowed to be theatrical.
   Depth comes from four parallax planes that move at different rates:

     rings   → slowest, furthest back
     trace   → the pulse drawing itself under the plate
     plate   → the logo on its white plate, the subject
     bloom   → the glow the pulse throws as it passes, closest to the viewer

   Everything is clip-path / transform / opacity. No width, no left, no
   box-shadow keyframes: those relayout or repaint every frame and stutter on
   the cheap Android phones a lot of this congregation carries.
*/
.splash {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: grid;
  place-items: center;
  overflow: hidden;
  background-color: var(--brand-shell);
  /* Identical gradient stack to .ambient, so when the splash fades the field
     behind it is already the same room — a dissolve, not a cut. */
  background-image:
    radial-gradient(90% 55% at 50% -8%,  rgba(126, 27, 21, .85) 0%, rgba(126, 27, 21, 0) 68%),
    radial-gradient(70% 45% at 8% 22%,   rgba(163, 42, 29, 0.42) 0%, rgba(163, 42, 29, 0) 70%),
    radial-gradient(85% 50% at 98% 76%,  rgba(94, 21, 18, .85) 0%,  rgba(94, 21, 18, 0) 72%),
    radial-gradient(120% 90% at 50% 112%, rgba(36, 6, 5, .95) 0%,  rgba(36, 6, 5, 0) 60%);
  opacity: 1;
  transform: scale(1);
  transition: opacity 420ms ease, transform 420ms var(--ease-in);   /* FADE_MS */
}
/* The hand-off: the splash does not blink out, it opens up and lets the app
   through — it grows very slightly as it fades, so the eye reads "moved
   closer", not "disappeared". */
.splash.done {
  opacity: 0;
  transform: scale(1.06);
  pointer-events: none;
}

.splash-field {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  pointer-events: none;
}
.splash-ring {
  position: absolute;
  width: min(88vw, 460px);
  aspect-ratio: 1;
  border: 1px solid rgba(217, 164, 65, 0.16);
  border-radius: 50%;
  opacity: 0;
  animation: ring-out 2600ms var(--ease) both;
}
.splash-ring-2 { animation-delay: 320ms; border-color: rgba(217, 164, 65, 0.12); }
.splash-ring-3 { animation-delay: 640ms; border-color: rgba(176, 48, 32, .16); }

.splash-stage {
  position: relative;
  display: grid;
  justify-items: center;
  gap: clamp(14px, 4vw, 22px);
  animation: stage-in 520ms var(--ease) both;
}

.splash-plate {
  position: relative;
  z-index: 2;
  overflow: hidden;
  width: min(78vw, 420px);
  padding: clamp(16px, 5vw, 26px);
  background: #FFFFFF;              /* the white plate. Never recolour the logo. */
  background-image: linear-gradient(170deg, #FFFFFF 0%, #FFFFFF 58%, #F1EDE6 100%);
  border-radius: 20px;
  box-shadow:
    0 1px 0 rgba(255,255,255,.9) inset,
    0 2px 6px rgba(0,0,0,.30),
    0 26px 60px -18px rgba(0,0,0,.62),
    0 0 0 1px rgba(11,26,63,.06);
  animation: plate-in 620ms var(--ease) both;
}

.splash-logo {
  display: block;
  width: 100%;
  height: auto;
  /* Hidden at the start, revealed left-to-right as the pulse passes over it,
     as if the logo's own EKG line were drawing it. */
  clip-path: inset(0 100% 0 0);
  animation: ekg-reveal 1100ms cubic-bezier(.32, .06, .28, 1) 180ms both;
}

/* Full-width element carrying a thin red line at its LEFT edge, so a single
   translateX(100%) walks that line across the whole plate. Translating a 3px
   box by a percentage of 3px would go nowhere — this is the trick that keeps
   the sweep on the compositor. */
.splash-sweep {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 100%;
  pointer-events: none;
  background: linear-gradient(
    90deg,
    rgba(176, 48, 32, 0)   0,
    rgba(176, 48, 32, .18) 0,
    rgba(176, 48, 32, .18) 3px,
    var(--brand-pulse)     3px,
    var(--brand-pulse)     6px,
    rgba(176, 48, 32, 0)   7px
  );
  transform: translateX(-8px);
  opacity: 0;
  animation: ekg-sweep 1100ms cubic-bezier(.32, .06, .28, 1) 180ms both;
}

/* The bloom the pulse throws. A *static* radial gradient being translated on
   the compositor — never an animated box-shadow. It sits behind the plate and
   overhangs it top and bottom, so as the sweep crosses the logo the glow spills
   out around the plate's edges onto the navy. Inside the plate it would be
   invisible; the plate is white. */
.splash-bloom {
  position: absolute;
  z-index: 1;
  top: -55%;
  bottom: -30%;
  left: -30%;
  width: 60%;
  pointer-events: none;
  background: radial-gradient(closest-side,
    rgba(255, 128, 102, .55) 0%,
    rgba(196, 62, 44, .34) 38%,
    rgba(176, 48, 32, 0) 76%);
  transform: translateX(0);
  opacity: 0;
  animation: ekg-bloom 1100ms cubic-bezier(.32, .06, .28, 1) 180ms both;
}

/* The pulse continues past the plate: a hairline EKG under the mark that draws
   itself in step with the sweep. This is the moment that says "lifeline". */
.splash-trace {
  width: min(78vw, 420px);
  height: 18px;
  background: var(--ekg) center / auto 18px repeat-x;
  opacity: .5;
  clip-path: inset(0 100% 0 0);
  animation: ekg-reveal 1400ms cubic-bezier(.32, .06, .28, 1) 260ms both;
}

@keyframes plate-in {
  from { opacity: 0; transform: scale(.9) translateY(10px); }
  to   { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes stage-in {
  from { transform: scale(.98); }
  to   { transform: scale(1); }
}
@keyframes ring-out {
  0%   { opacity: 0; transform: scale(.55); }
  22%  { opacity: 1; }
  100% { opacity: 0; transform: scale(1.5); }
}
@keyframes ekg-reveal {
  from { clip-path: inset(0 100% 0 0); }
  to   { clip-path: inset(0 0 0 0); }
}
@keyframes ekg-sweep {
  0%   { transform: translateX(-8px);  opacity: 0; }
  6%   { opacity: 1; }
  94%  { opacity: 1; }
  100% { transform: translateX(100%);  opacity: 0; }
}
@keyframes ekg-bloom {
  0%   { transform: translateX(0);    opacity: 0; }
  12%  { opacity: 1; }
  86%  { opacity: .85; }
  100% { transform: translateX(215%); opacity: 0; }
}

/* Vestibular safety: show the finished state instantly. The splash still
   dismisses on the same timer, so nothing about the flow changes. */
@media (prefers-reduced-motion: reduce) {
  .splash, .splash-plate, .splash-logo, .splash-sweep,
  .splash-bloom, .splash-trace, .splash-stage, .splash-ring { animation: none !important; }
  .splash { transition: opacity 1ms linear; transform: none; }
  .splash.done { transform: none; }
  .splash-logo, .splash-trace { clip-path: none; }
  .splash-sweep, .splash-bloom, .splash-field { display: none; }
}

/* ===================================================================
   The living trace — every EKG line runs like a monitor
   ===================================================================
   Two behaviours, because the marks do two different jobs:

   .ekg-live  — a repeating trace that SCROLLS, for the watermark strips.
                The mark is painted on a ::before and moved with translate3d
                so it stays on the compositor; animating background-position
                would repaint the strip every frame instead.
                It translates by exactly one tile (6.5 × its height), which is
                the only distance that loops without a visible seam.

   .ekg-sweep — a single mark that WALKS the full width, for the section rule,
                where the composition is "one beat, then a hairline" and a
                repeating line would lose that. Same trick .splash-sweep uses:
                a full-width layer carrying the mark at its left edge, so one
                translateX(100%) crosses the whole element.

   Both keep their own opacity, set by the element that opts in.

   The existing trace elements are listed here rather than given new classes in
   the markup: index.html is being edited in another session right now, and a
   CSS-only wiring cannot collide with it. */
/* ⚠ THE OLD VERSION WAS A STRIP CHART, NOT A MONITOR. It translated a repeating
   tile, so the LINE SLID SIDEWAYS past a fixed frame — which is what a paper
   chart recorder does: the paper moves under a stationary pen. A bedside
   monitor is the exact inverse. The trace STAYS PUT and a bright head travels
   left→right drawing it, blanking the previous sweep through a short gap just
   ahead of itself, then wraps to the left edge. Nothing ever moves sideways.
   If you find yourself reaching for translateX here, you are rebuilding the
   strip chart.

   FOUR layers are needed, and they have three DIFFERENT erase behaviours, so
   the assignment across one element plus two pseudo-elements is forced:

     element    grid           persists — never erased, never clipped, and it
                               also carries the head glow, which shows through
                               because both layers above it are transparent
                               exactly where the head is
     ::before   previous sweep blanked from the left edge through head+gap
     ::after    new trace      revealed 0→head by clip-path, with a fixed-length
                               hot trail decaying back to --ekg-rest

   Everything is driven off ONE animated value, --ekg-head, so the clip, the
   blanking gap and the head glow cannot drift apart. Animating three separate
   properties in three keyframe blocks would look identical on the first run and
   skew on a slow frame. */
@property --ekg-head {
  syntax: '<percentage>';
  inherits: true;
  initial-value: 100%;
}

.ekg-live,  .hero-trace, .stub-trace, .auth-trace,
.ekg-sweep, .pulse-rule {
  position: relative;
  overflow: hidden;
  animation: ekg-sweep-head var(--ekg-sweep-speed) linear infinite;
}

/* The grid and the head glow ride the element's own background, which is the
   only surface here that is neither clipped nor masked. Layer order matters:
   the head is listed first so it paints ON TOP of the grid.
   ⚠ .pulse-rule prepends its hairline to this list — see its own rule. */
.ekg-live,  .hero-trace, .stub-trace, .auth-trace, .ekg-sweep {
  background-image: var(--ekg-head-img), var(--ekg-grid-img);
  background-size: 2px 100%, auto, auto;
  background-position: var(--ekg-head) center, left center, left center;
  background-repeat: no-repeat, repeat, repeat;
}

.ekg-live::before,  .hero-trace::before, .stub-trace::before, .auth-trace::before,
.ekg-sweep::before, .pulse-rule::before,
.ekg-live::after,   .hero-trace::after,  .stub-trace::after,  .auth-trace::after,
.ekg-sweep::after,  .pulse-rule::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: var(--ekg-trace, var(--ekg)) left center / auto 100% repeat-x;
}

/* The previous sweep. Blanked from the left edge through head+gap, because
   everything behind the head is the new trace's job and the gap is the blank
   the head leaves in front of itself. */
.ekg-live::before,  .hero-trace::before, .stub-trace::before, .auth-trace::before,
.ekg-sweep::before, .pulse-rule::before {
  opacity: var(--ekg-rest);
  /* Same bloom as ::after for the same reason the opacities match: a glow on
     only the drawn half vanishes across the full width at the wrap. */
  filter: drop-shadow(0 0 3px var(--ekg-glow));
  -webkit-mask-image: linear-gradient(90deg,
    transparent 0 calc(var(--ekg-head) + var(--ekg-gap)),
    #000        calc(var(--ekg-head) + var(--ekg-gap)) 100%);
          mask-image: linear-gradient(90deg,
    transparent 0 calc(var(--ekg-head) + var(--ekg-gap)),
    #000        calc(var(--ekg-head) + var(--ekg-gap)) 100%);
}

/* The trace being drawn right now. clip-path stops it dead at the head; the
   mask is the phosphor decay behind it. --ekg-trail is a fixed distance, so the
   hot stretch is the same length at every point in the sweep. */
.ekg-live::after,  .hero-trace::after, .stub-trace::after, .auth-trace::after,
.ekg-sweep::after, .pulse-rule::after {
  clip-path: inset(0 calc(100% - var(--ekg-head)) 0 0);
  -webkit-mask-image: linear-gradient(90deg,
    rgba(0,0,0,var(--ekg-rest)) 0 calc(var(--ekg-head) - var(--ekg-trail)),
    #000 var(--ekg-head));
          mask-image: linear-gradient(90deg,
    rgba(0,0,0,var(--ekg-rest)) 0 calc(var(--ekg-head) - var(--ekg-trail)),
    #000 var(--ekg-head));
  filter: drop-shadow(0 0 3px var(--ekg-glow));
}

@keyframes ekg-sweep-head {
  from { --ekg-head: 0%; }
  to   { --ekg-head: 100%; }
}

/* A monitor that never rests is a distraction, and for anyone motion-sensitive
   a permanently moving line is worse than a splash they can wait out. Hold the
   trace still, fully drawn — it still reads as the mark, it just stops running.
   --ekg-head keeps its 100% initial value, so the clip is fully open and the
   blanking gap has run off the right edge; only the head has to be hidden.
   ⚠ This is also the fallback where @property is unsupported (pre-16.4 Safari,
   pre-128 Firefox): the custom property never interpolates, so the trace simply
   renders drawn and still rather than breaking. */
@media (prefers-reduced-motion: reduce) {
  .ekg-live,  .hero-trace, .stub-trace, .auth-trace,
  .ekg-sweep, .pulse-rule { animation: none; }
  /* The GRID stays — it is texture, not motion. Only the head, which is a
     moving object with nowhere meaningful to park, is dropped.
     ⚠ background-size/position/repeat MUST be restated with it. Those are
     positional lists matched to the layer stack by index, so dropping the head
     image without dropping its `2px 100%` slot silently hands that width to
     whatever layer moved up into first place — which rendered the pulse-rule's
     hairline as a 2px bar parked at the right edge, and squeezed the grid's
     vertical lines to 2px. Caught in the reduced-motion screenshot; it does not
     show up anywhere else. */
  .ekg-live,  .hero-trace, .stub-trace, .auth-trace,
  .ekg-sweep {
    background-image: var(--ekg-grid-img);
    background-size: auto, auto;
    background-position: left center, left center;
    background-repeat: repeat, repeat;
  }
  /* ⚠ .pulse-rule's background reset is NOT here — it lives beside .pulse-rule's
     own rule further down. That rule declares its own background-image and is
     ~250 lines later in the file, so an override here loses on source order at
     equal specificity. Same-specificity overrides have to follow what they
     override. */
  .ekg-live::after,  .hero-trace::after, .stub-trace::after, .auth-trace::after,
  .ekg-sweep::after, .pulse-rule::after { filter: none; }
}

/* ===================================================================
   Shell layout
   =================================================================== */
.shell {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  min-height: 100vh;
  min-height: 100dvh;
}
/* The app arrives rather than appears — it takes over the motion the splash was
   already carrying instead of starting from a standstill. Runs once, when boot
   unhides the shell.
   ⚠ THE TRANSFORM MUST NEVER SIT ON .shell ITSELF. A transform on an ancestor
   makes it the containing block for position:fixed descendants, so while (or
   wherever) the shell carries one, the "fixed" tab bar is pinned to the END OF
   THE PAGE instead of the screen — members had to scroll to the bottom to
   change tabs. So the rise animates on the topbar and the view (siblings of
   the bar), and the bar itself only fades. */
.shell:not([hidden]) > .topbar,
.shell:not([hidden]) > .view { animation: shell-in 520ms var(--ease) both; }
.shell:not([hidden]) > .tabbar { animation: shell-fade 520ms var(--ease) both; }
@keyframes shell-in {
  from { opacity: 0; transform: translateY(10px) scale(.99); }
  to   { opacity: 1; transform: none; }
}
@keyframes shell-fade {
  from { opacity: 0; }
  to   { opacity: 1; }
}

.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 50;
  padding: 10px 14px;
  background: var(--brand-sky);
  color: var(--brand-shell);
  font-weight: 700;
  border-radius: 0 0 var(--radius-sm) 0;
}
.skip-link:focus {
  left: 0;
  top: var(--safe-top);
}

.topbar {
  position: sticky;
  top: 0;
  z-index: 20;
  display: flex;
  align-items: center;
  gap: var(--s3);
  padding: calc(var(--safe-top) + 10px) var(--pad) 10px;
  background: linear-gradient(180deg, rgba(30, 5, 4, .92) 0%, rgba(62, 11, 10, .74) 100%);
  -webkit-backdrop-filter: blur(14px) saturate(1.2);
  backdrop-filter: blur(14px) saturate(1.2);
  box-shadow: 0 1px 0 var(--hairline) inset;
}
/* The bar's bottom edge is the pulse rule in miniature: a short red segment,
   then a hairline. Same device as `.pulse-rule`, same meaning. */
.topbar::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 1px;
  background: linear-gradient(90deg,
    var(--brand-pulse) 0, var(--brand-pulse) 46px,
    var(--line) 46px, var(--line) 100%);
}

/* The logo's navy script is invisible on navy — it always rides a white plate. */
.brand-plate {
  position: relative;
  display: flex;
  align-items: center;
  padding: 6px 10px;
  background: #FFFFFF;
  background-image: linear-gradient(170deg, #FFFFFF 0%, #FFFFFF 60%, #F1EDE6 100%);
  border-radius: var(--radius-xs);
  box-shadow:
    0 1px 0 rgba(255,255,255,.85) inset,
    0 1px 3px rgba(0,0,0,.34),
    0 8px 18px -10px rgba(0,0,0,.6);
}
.brand-plate img { display: block; width: auto; height: 26px; }

/* Signed out, the auth screen shows its own bigger plate — the bar keeps only
   the language toggle, which must stay reachable BEFORE anyone signs in. */
.shell.is-auth .topbar { background: transparent; -webkit-backdrop-filter: none; backdrop-filter: none; box-shadow: none; }
.shell.is-auth .topbar::after { display: none; }
.shell.is-auth .topbar > .brand-plate { display: none; }

.topbar-actions { margin-left: auto; display: flex; gap: var(--s2); }

.lang-toggle {
  min-width: var(--tap);
  min-height: 36px;
  padding: 0 14px;
  font: 700 var(--t-sm)/1 var(--font);
  letter-spacing: .06em;
  color: var(--brand-sky);
  background: linear-gradient(180deg, rgba(217, 164, 65, 0.17), rgba(217, 164, 65, 0.05));
  border: 1px solid var(--line);
  border-radius: 999px;
  box-shadow: 0 1px 0 var(--hairline) inset, var(--elev-1);
  cursor: pointer;
  transition: transform 140ms var(--ease), background-color 140ms linear;
}
.lang-toggle:active { transform: scale(.95); background-color: rgba(217, 164, 65, 0.22); }

.status {
  min-height: 0;
  padding: 0 var(--pad);
  color: var(--ink-dim);
  font-size: var(--t-sm);
}
.status:not(:empty) {
  padding-top: var(--s2);
  padding-bottom: var(--s2);
  animation: status-in 260ms var(--ease) both;
}
.status.warn { color: #FFD9A8; }
@keyframes status-in {
  from { opacity: 0; transform: translateY(-4px); }
  to   { opacity: 1; transform: none; }
}

/* ===================================================================
   Security notice (#notice) — "somebody else changed your passcode"
   ===================================================================
   This is the one place the app raises its voice, so it gets the accent that
   means "attention" everywhere else in the palette: the pulse red, as a bar
   down the leading edge. It sits between the chrome and the view at every
   route, so it is styled as a band rather than a card — a card here would read
   as content and be scrolled past.
   Deliberately tolerant of markup: heading, paragraphs and a dismiss button
   are all optional, in any combination. */
.notice {
  position: relative;
  z-index: 2;
  width: 100%;
  max-width: var(--maxw);
  margin: var(--s3) auto 0;
  padding: var(--s4) var(--pad) var(--s4) calc(var(--pad) + 6px);
  border: 1px solid rgba(176, 48, 32, .48);
  border-radius: var(--radius);
  background-color: rgba(58, 20, 22, .55);
  background-image: linear-gradient(180deg, rgba(176, 48, 32, .26), rgba(176, 48, 32, .08));
  box-shadow: 0 1px 0 rgba(255,255,255,.10) inset, var(--elev-2);
  color: #FFE2DC;
  font-size: var(--t-sm);
  line-height: 1.5;
  animation: notice-in 300ms var(--ease) both;
}
.notice::before {
  content: "";
  position: absolute;
  left: 0;
  top: 10px;
  bottom: 10px;
  width: 4px;
  border-radius: 0 4px 4px 0;
  background: var(--brand-pulse);
  box-shadow: 0 0 12px rgba(176, 48, 32, .8);
}
.notice > :last-child { margin-bottom: 0; }
/* The urgent variant (the leader's claim ticket is still live) burns brighter
   and cannot be dismissed — app.js omits the button. */
.notice-urgent {
  border-color: rgba(224, 92, 70, .7);
  background-image: linear-gradient(180deg, rgba(196, 62, 44, .38), rgba(176, 48, 32, .14));
}
.notice-body { display: flow-root; }
.notice-body > :last-child { margin-bottom: 0; }
.notice h1, .notice h2, .notice h3, .notice .notice-title {
  margin: 0 0 var(--s2);
  font-size: var(--t-md);
  font-weight: 700;
  letter-spacing: .01em;
  color: #FFFFFF;
}
.notice p { margin: 0 0 var(--s3); }
.notice-text { overflow-wrap: anywhere; }
/* The line that asks for an action, not just the one that reports a fact. */
.notice-act { font-weight: 600; color: #FFFFFF; }
.notice-dismiss { margin-top: var(--s1); }
.notice button {
  min-height: 36px;
  padding: 0 16px;
  font: 700 var(--t-sm) var(--font);
  color: #FFE2DC;
  background: rgba(255, 255, 255, .10);
  border: 1px solid rgba(255, 226, 220, .34);
  border-radius: 999px;
  cursor: pointer;
  transition: transform 140ms var(--ease), background-color 140ms linear;
}
.notice button:active { transform: scale(.96); background-color: rgba(255,255,255,.18); }

/* The exact shape app.js emits: a text block, then an optional dismiss button.
   Side by side where there is room, stacked on a narrow phone — a 44px pill
   crushed against a three-line sentence on a 320px screen is how a banner ends
   up being tapped away by accident. */
.notice-body { flex: 1 1 260px; min-width: 0; }
.notice:has(.notice-dismiss) {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: var(--s3);
}
.notice-dismiss { flex: 0 0 auto; align-self: center; }

/* While `claimOpen` is true the code the leader generated still opens the
   account. That is not a notification, it is a live credential in somebody
   else's hands, so it is louder and it carries a call to action. */
.notice-urgent {
  border-color: rgba(176, 48, 32, .78);
  background-image: linear-gradient(180deg, rgba(176, 48, 32, .40), rgba(176, 48, 32, .14));
}
.notice-act { font-weight: 700; color: #FFFFFF; }

.view {
  position: relative;
  z-index: 1;
  flex: 1 1 auto;
  width: 100%;
  max-width: var(--maxw);
  margin: 0 auto;
  /* Decoration may bleed, the PAGE may not. `clip` rather than `hidden`: it
     clips without creating a scroll container, so nothing inside starts
     scrolling on its own and position:sticky keeps working. A 360px phone must
     never scroll sideways — that is the single most broken-feeling thing a
     layout can do, and a glow or a tilted tile is not worth it. */
  overflow-x: clip;
  padding: var(--pad);
  padding-bottom: calc(var(--pad) + 84px + var(--safe-bottom));
  outline: none;
}
.view:focus { outline: none; }

/* ===================================================================
   Tab bar
   =================================================================== */
.tabbar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 30;
  display: flex;
  justify-content: center;
  gap: 2px;
  padding: 7px 6px calc(7px + var(--safe-bottom));
  background: linear-gradient(180deg, rgba(62, 11, 10, .82) 0%, rgba(20, 3, 3, .96) 100%);
  -webkit-backdrop-filter: blur(16px) saturate(1.25);
  backdrop-filter: blur(16px) saturate(1.25);
  box-shadow:
    0 1px 0 var(--hairline) inset,
    0 -12px 30px -18px rgba(0,0,0,.9);
  border-top: 1px solid var(--line);
}

.tab {
  position: relative;
  flex: 1 1 0;
  max-width: 120px;
  min-height: var(--tap);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 3px;
  padding: 6px 2px;
  color: var(--ink-faint);
  background: transparent;
  border: 0;
  border-radius: var(--radius-sm);
  font: 600 var(--t-xs)/1.1 var(--font);
  text-decoration: none;
  cursor: pointer;
  transition: color 160ms linear, transform 160ms var(--ease);
}
.tab svg {
  width: 22px;
  height: 22px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.9;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition: transform 200ms var(--ease);
}
.tab-label {
  text-align: center;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 100%;
}
.tab:active { transform: scale(.94); }

/* The selected tab is a lit panel, not just tinted text. */
.tab[aria-current="page"] {
  color: var(--brand-sky);
  background: linear-gradient(180deg, rgba(217, 164, 65, 0.19), rgba(217, 164, 65, 0.04));
  box-shadow: 0 1px 0 rgba(255,255,255,.10) inset;
}
.tab[aria-current="page"] svg { transform: translateY(-1px) scale(1.06); }
.tab[aria-current="page"] .tab-dot { opacity: 1; transform: scaleX(1); }
.tab-dot {
  width: 16px;
  height: 2px;
  border-radius: 2px;
  background: var(--brand-pulse);
  box-shadow: 0 0 8px rgba(176, 48, 32, .9);
  opacity: 0;
  transform: scaleX(.3);
  transition: opacity 160ms linear, transform 200ms var(--ease);
}

/* ===================================================================
   Dimensional surfaces
   ===================================================================
   A card is three things at once: a body colour, a light falling on it from
   above (--gloss on ::before), and a lit top edge (--hairline, inset). Take any
   one away and it flattens.
*/
.card {
  position: relative;
  isolation: isolate;
  background-color: var(--surface-2);
  background-image: linear-gradient(180deg, rgba(255,255,255,.045), rgba(0,0,0,.06));
  color: var(--ink);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: var(--pad);
  margin: 0 0 var(--s4);
  box-shadow: 0 1px 0 var(--hairline) inset, var(--elev-2);
}
.card::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  border-radius: inherit;
  background: var(--gloss);
  pointer-events: none;
}
.card > :last-child { margin-bottom: 0; }
.paper.card { background-image: linear-gradient(180deg, rgba(255,255,255,.6), rgba(11,26,63,.03)); }

.muted { color: var(--ink-dim); }
.faint { color: var(--ink-faint); font-size: var(--t-sm); }

.section-title {
  margin: 0 0 var(--s3);
}
.section-title .eyebrow { margin-bottom: var(--s1); }
.section-title h1 { margin: 0; }

.section-head {
  display: flex;
  align-items: baseline;
  gap: var(--s3);
  margin: var(--s6) 0 var(--s3);
}
.section-head h2 { margin: 0; font-size: var(--t-lg); }
/* The shelf's book count. `nowrap` because "3 books" breaking across two lines
   reads as a second heading, and `flex: 0 0 auto` so a long shelf name shrinks
   the rule rather than squeezing the count. Tabular figures so a column of
   shelves has its numbers in line. */
.section-head .shelf-count {
  flex: 0 0 auto;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
.section-head .rule {
  flex: 1 1 auto;
  height: 1px;
  background: var(--line);
}

/* ---------------------------------------------------------------------------
   Collapsible shelves

   The shelf head IS the section head — a <summary> carrying the same flex row,
   so the name, count, badge and rule keep the layout they already had and this
   block only has to add what disclosure needs.
   --------------------------------------------------------------------------- */

summary.section-head {
  cursor: pointer;
  /* BOTH are required, and for different engines: `list-style` covers modern
     Chrome and Firefox, the webkit pseudo-element covers Safari — which is what
     most of the congregation is holding. Drop either and the browser draws its
     own triangle beside ours. */
  list-style: none;
  -webkit-tap-highlight-color: transparent;
}
summary.section-head::-webkit-details-marker { display: none; }

/* It is now the tab stop that opens a shelf. Inset so the ring hugs the row
   rather than colliding with the tile grid below it. */
summary.section-head:focus-visible {
  outline: var(--focus);
  outline-offset: 2px;
  border-radius: var(--radius-sm);
}

.shelf-chevron {
  flex: 0 0 auto;
  width: 1em;
  height: 1em;
  /* `align-items: baseline` on the row would hang an icon below the text, so
     this one opts out and centres against the heading instead. */
  align-self: center;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
  color: var(--ink-faint);
  transition: transform 140ms var(--ease);
}
/* One glyph, two states: the mark points right when shut and down when open,
   so the icon and the shelf can never disagree about what is showing. */
.shelf[open] > summary .shelf-chevron { transform: rotate(90deg); }

/* The body sits under the head with the same gap the flat layout had, so
   collapsing changes what is on screen and not how it is spaced. */
.shelf > .books,
.shelf > .faint { margin-top: var(--s3); }

@media (prefers-reduced-motion: reduce) {
  .shelf-chevron { transition: none; }
}

/* The signature rule: the church's own EKG, then a hairline. It marks a
   section the way the logo marks the app. */
/* The pulse, then a HAIRLINE — the second layer is sized to 1px tall and
   offset past the mark. A 90deg colour-stop gradient here painted the full
   16px height instead, which reads as a grey slab bolted to the end of the
   heartbeat. */
.pulse-rule {
  height: 16px;
  border: 0;
  margin: 0 0 var(--s5);
  /* The hairline stays put and spans the rule; the beat sweeps across it like a
     monitor. The head rides on top of the hairline, so the blank gap travels
     THROUGH the rule itself — which is exactly what a monitor baseline does.
     No grid here: at 16px tall it would render as about two rows of noise, and
     the rule appears ~6 times on a screen. */
  background-image: var(--ekg-head-img), linear-gradient(var(--line), var(--line));
  background-position: var(--ekg-head) center, left center;
  background-size: 2px 100%, 100% 1px;
  background-repeat: no-repeat, no-repeat;
}

/* Held still, the head has nowhere meaningful to park, so it goes — leaving the
   hairline alone. Its whole layer list must be restated: these are positional
   lists matched by index, and dropping the head image while leaving its
   `2px 100%` slot in place hands that width to the hairline, which then renders
   as a 2px bar stuck at the right edge. */
@media (prefers-reduced-motion: reduce) {
  .pulse-rule {
    background-image: linear-gradient(var(--line), var(--line));
    background-size: 100% 1px;
    background-position: left center;
    background-repeat: no-repeat;
  }
}

.badge {
  display: inline-block;
  padding: 3px 10px;
  border-radius: 999px;
  font-size: var(--t-xs);
  font-weight: 700;
  letter-spacing: .04em;
  background: var(--surface-3);
  color: var(--ink-dim);
  box-shadow: 0 1px 0 var(--hairline) inset;
}
.badge-role {
  background: linear-gradient(180deg, #C4432F, var(--brand-pulse));
  color: #fff;
  box-shadow: 0 1px 0 rgba(255,255,255,.25) inset, 0 2px 8px -3px rgba(176,48,32,.9);
}

/* ===================================================================
   Tilt — the "physical" press
   ===================================================================
   app.js writes --rx/--ry from where the finger landed; the element leans away
   from the touch by a few degrees and settles back. Transform only, so it costs
   the compositor one matrix and nothing else. The tilt is deliberately small:
   the goal is "this moved under my thumb", not a carnival.
*/
[data-tilt] {
  transform: perspective(760px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(var(--ts, 1));
  transition: transform 340ms var(--ease), box-shadow 340ms var(--ease);
  will-change: transform;
}
[data-tilt].is-pressed {
  transition-duration: 90ms;
  box-shadow: 0 1px 0 var(--hairline) inset, var(--elev-1);
}

/* ===================================================================
   Home — hero and tiles
   =================================================================== */
.hero {
  position: relative;
  overflow: hidden;
  padding: var(--s6) var(--pad) calc(var(--s6) + 14px);
  margin: 0 0 var(--s5);
  border-radius: var(--radius);
  border: 1px solid var(--line);
  background-color: var(--brand-navy);
  background-image:
    radial-gradient(120% 90% at 12% 0%, rgba(58, 94, 170, 0.77) 0%, rgba(58, 94, 170, 0) 62%),
    radial-gradient(90% 80% at 100% 100%, rgba(20, 40, 92,.9) 0%, rgba(20, 40, 92,0) 70%),
    linear-gradient(180deg, rgba(255,255,255,.06), rgba(0,0,0,.10));
  box-shadow: 0 1px 0 var(--hairline) inset, var(--elev-3);
}
.hero h1 { font-size: var(--t-3xl); margin-bottom: var(--s3); }
.hero p { margin-bottom: 0; max-width: 46ch; }
/* The mark, running out of the card's bottom edge. Watermark weight — it is
   texture, not a second logo. */
.hero-trace {
  position: absolute;
  left: 0;
  right: 0;
  bottom: -2px;
  height: 26px;
  --ekg-tile: 169px;              /* 26 x 6.5 -- one whole tile, so it loops seamlessly */
  --ekg-trace: var(--ekg-gold);
  opacity: .30;                   /* was .16: a running line has to be seen to read as running */
  pointer-events: none;
}

.tiles {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: var(--s3);
  margin: 0 0 var(--s4);
}
/* Three tiles in a two-column grid leaves a hole. The odd one out takes the
   whole row instead, so the block ends on a deliberate edge rather than a gap.
   Scoped to the two-column range — at three columns there is no leftover. */
@media (max-width: 779px) {
  .tiles > :last-child:nth-child(odd) { grid-column: 1 / -1; min-height: 0; }
  .tiles > :last-child:nth-child(odd) .tile-note { max-width: 34ch; }
}

.tile {
  position: relative;
  isolation: isolate;
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  padding: var(--s4);
  min-height: 132px;
  border-radius: var(--radius);
  border: 1px solid var(--line);
  background-color: var(--surface-2);
  background-image: linear-gradient(165deg, rgba(255,255,255,.07), rgba(0,0,0,.10) 70%);
  box-shadow: 0 1px 0 var(--hairline) inset, var(--elev-2);
  color: var(--ink);
}
.tile::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  border-radius: inherit;
  background: var(--gloss);
  pointer-events: none;
}
.tile-icon {
  width: 30px;
  height: 30px;
  fill: none;
  stroke: var(--brand-sky);
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
  filter: drop-shadow(0 2px 6px rgba(217, 164, 65, 0.42));
}
.tile-name {
  font-size: var(--t-lg);
  font-weight: 700;
  letter-spacing: -.012em;
}
.tile-note {
  margin: 0;
  font-size: var(--t-sm);
  line-height: 1.4;
  color: var(--ink-dim);
}
.tile-flag {
  margin-top: auto;
  align-self: flex-start;
  padding: 2px 9px;
  border-radius: 999px;
  font-size: var(--t-xs);
  font-weight: 700;
  color: var(--brand-sky);
  background: rgba(217, 164, 65, 0.14);
  border: 1px solid var(--line);
}

/* ===================================================================
   Forms / auth
   ===================================================================
   Signed out is the plainest screen in most apps and the first one anybody
   sees. Here it gets a place to stand: a halo behind the plate, the pulse
   under it, and the church's own name and town closing the page.
*/
.auth {
  position: relative;
  max-width: 440px;
  margin: 0 auto;
  padding-top: var(--s3);
}

.auth-head {
  position: relative;
  text-align: center;
  margin-bottom: var(--s5);
  padding-bottom: var(--s4);
}
/* The halo. A single static radial gradient, scaled and faded in — no filter,
   no repaint. It is what stops the white plate looking pasted onto navy.
   Sized to the container, never wider: a 130%-wide decoration here made the
   whole 360px page 395px wide and scroll sideways. `closest-side` means it has
   already faded to nothing well before the edges, so it reads as a halo
   without ever asking the page for room it should not have. */
.auth-head::before {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: -16%;
  aspect-ratio: 1.35;
  background: radial-gradient(closest-side,
    rgba(217, 164, 65, 0.31) 0%,
    rgba(163, 42, 29, 0.21) 45%,
    rgba(163, 42, 29, 0) 78%);
  pointer-events: none;
  z-index: 0;
  animation: halo-in 900ms var(--ease) both;
}
.auth-head > * { position: relative; z-index: 1; }
@keyframes halo-in {
  from { opacity: 0; transform: scale(.85); }
  to   { opacity: 1; transform: scale(1); }
}

.auth-head .brand-plate {
  display: inline-flex;
  margin-bottom: var(--s4);
  padding: 14px 20px;
  border-radius: 14px;
  box-shadow:
    0 1px 0 rgba(255,255,255,.9) inset,
    0 2px 6px rgba(0,0,0,.3),
    0 22px 46px -18px rgba(0,0,0,.66);
}
.auth-head .brand-plate img { height: 38px; }
.auth-head h1 { font-size: var(--t-2xl); margin-bottom: var(--s2); }
.auth-head p { margin: 0 auto; max-width: 34ch; }

/* The pulse closes the header block — the same mark as the splash trace, at
   rest. */
.auth-trace {
  height: 14px;
  margin: var(--s4) auto 0;
  width: 70%;
  --ekg-tile: 91px;               /* 14 x 6.5 */
  opacity: .55;                   /* was .42 */
}

.auth-foot {
  margin: var(--s6) 0 0;
  text-align: center;
  font-size: var(--t-xs);
  letter-spacing: .06em;
  color: var(--ink-faint);
}
.auth-foot span { display: block; }
.auth-foot span + span { margin-top: 3px; opacity: .75; }

.switcher {
  display: flex;
  gap: 4px;
  padding: 4px;
  margin: 0 0 var(--s4);
  background: rgba(0, 0, 0, .18);
  border: 1px solid var(--line);
  border-radius: 999px;
  box-shadow: inset 0 2px 6px rgba(0,0,0,.30);
}
.paper .switcher { background: rgba(11,26,63,.05); box-shadow: inset 0 2px 5px rgba(11,26,63,.10); }
/* `a` as well as `button`: the auth tabs and the language chooser are buttons,
   but the admin tabs are real links (they carry a route, and a leader should be
   able to open one in a new tab). They were left unstyled here, so they
   rendered as bare inline text with no pill and no gap — "Members Moderation
   Library" read as one run-on line, which is why the admin area never looked
   like it had tabs at all. */
.switcher button,
.switcher a {
  flex: 1;
  min-height: 38px;
  padding: 0 10px;
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: var(--ink-dim);
  font: 600 var(--t-sm) var(--font);
  cursor: pointer;
  transition: color 160ms linear, transform 160ms var(--ease);
  /* An `a` is not a button: it needs to be told to fill and centre itself. */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  text-decoration: none;
}
.switcher button:active,
.switcher a:active { transform: scale(.97); }
/* Auth tabs use aria-selected (they are tabs); the language chooser uses
   aria-pressed (it is a pair of toggle buttons, not a tablist). Both are
   "the chosen one" to a reader, so both get the same paint. */
.switcher button[aria-selected="true"],
.switcher a[aria-selected="true"],
.switcher button[aria-pressed="true"] {
  background-color: var(--accent);
  background-image: linear-gradient(180deg, rgba(255,255,255,.28), rgba(0,0,0,.14));
  color: var(--accent-ink);
  box-shadow: 0 1px 0 rgba(255,255,255,.38) inset, 0 4px 12px -5px rgba(0,0,0,.5);
}

.field { margin: 0 0 var(--s4); }
.field label {
  display: block;
  margin-bottom: var(--s2);
  font-size: var(--t-sm);
  font-weight: 600;
  letter-spacing: .01em;
  color: var(--ink-dim);
}
.field input {
  width: 100%;
  min-height: var(--tap);
  padding: 11px 13px;
  font: 500 var(--t-md) var(--font);
  color: var(--ink);
  background-color: rgba(8, 17, 43, .45);
  background-image: linear-gradient(180deg, rgba(0,0,0,.16), rgba(255,255,255,.02));
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  box-shadow: inset 0 2px 5px rgba(0,0,0,.32);
  transition: border-color 160ms linear, box-shadow 160ms linear;
}
.paper .field input {
  background-color: #fff;
  background-image: none;
  box-shadow: inset 0 2px 4px rgba(11,26,63,.08);
}
.field input:focus {
  border-color: rgba(217, 164, 65, 0.66);
  box-shadow: inset 0 2px 5px rgba(0,0,0,.32), 0 0 0 3px rgba(217, 164, 65, 0.22);
}
.field input::placeholder { color: var(--ink-faint); }
.hint { margin: var(--s2) 0 0; font-size: var(--t-xs); line-height: 1.45; color: var(--ink-faint); }
.field-code input {
  font-family: var(--mono);
  letter-spacing: .28em;
  text-transform: uppercase;
}
/* The wide tracking is for a six-character code, not for the hint text. At
   .28em, "CÓDIGO DE LA IGLESIA" runs off the end of its own field. */
.field-code input::placeholder {
  letter-spacing: .02em;
  text-transform: none;
}

/* Show/hide is an ICON, not a word.
   It used to be a text button, and "Show passcode" / "Mostrar la clave" simply
   does not fit beside a passcode field on a 360px phone — it rendered as
   "Show pas…" in English and "Mostrar l…" in Spanish, which is an ellipsis
   where a control's name should be. The sentence still exists and is still
   translated: it is the button's aria-label and its tooltip, so a screen
   reader and a long-press both read the real words in the member's own
   language. A fixed square also means neither language can ever change the
   input's usable width. */
.field-secret { position: relative; }
.field-secret input { padding-right: 52px; }
.field-secret .reveal {
  position: absolute;
  right: 6px;
  bottom: 6px;
  display: grid;
  place-items: center;
  width: 38px;
  height: 32px;
  padding: 0;
  color: var(--accent);
  background: rgba(217, 164, 65, 0.12);
  border: 1px solid var(--line);
  border-radius: var(--radius-xs);
  cursor: pointer;
}
.field-secret .reveal svg {
  width: 19px;
  height: 19px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.field-secret .reveal .eye-slash { opacity: 0; transition: opacity 120ms linear; }
.field-secret .reveal[aria-pressed="true"] .eye-slash { opacity: 1; }
.paper .field-secret .reveal { background: rgba(163, 42, 29, 0.08); }

.btn {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--s2);
  width: 100%;
  min-height: var(--tap);
  padding: 13px 18px;
  font: 700 var(--t-md) var(--font);
  letter-spacing: .005em;
  /* The primary action follows --accent, so it is gold on the dark shell and red
     on .paper without knowing it moved. Two reasons it is not simply red: a red
     button on an oxblood page has almost nothing to sit against, and --btn-danger
     IS red — a primary that matched it would make "Save" and "Delete" the same
     object. The sheen is white-and-black over the accent rather than two fixed
     hues, so it survives the next palette too. */
  color: var(--accent-ink);
  background-color: var(--accent);
  background-image: linear-gradient(180deg, rgba(255,255,255,.30) 0%,
                                    rgba(255,255,255,0) 56%, rgba(0,0,0,.20) 100%);
  border: 1px solid rgba(255, 255, 255, .18);
  border-radius: var(--radius-sm);
  box-shadow:
    0 1px 0 rgba(255,255,255,.38) inset,
    0 2px 4px rgba(0,0,0,.22),
    0 10px 22px -14px rgba(0,0,0,.55);
  cursor: pointer;
  transition: transform 150ms var(--ease), box-shadow 150ms var(--ease), filter 150ms linear;
}
.btn:active {
  transform: translateY(1px) scale(.99);
  box-shadow: 0 1px 0 rgba(255,255,255,.18) inset, 0 1px 3px rgba(0,0,0,.3);
  filter: brightness(.96);
}
.btn:disabled { opacity: .55; cursor: default; transform: none; }
.btn-ghost {
  color: var(--ink-dim);
  background-color: transparent;
  background-image: linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,0));
  border-color: var(--line);
  box-shadow: 0 1px 0 var(--hairline) inset, var(--elev-1);
}
.btn-danger {
  background-color: var(--brand-pulse);
  background-image: linear-gradient(180deg, #C74936 0%, var(--brand-pulse) 60%, #8E2417 100%);
  box-shadow:
    0 1px 0 rgba(255,255,255,.24) inset,
    0 2px 4px rgba(0,0,0,.22),
    0 12px 26px -14px rgba(176, 48, 32, 1);
}
.btn-row { display: flex; flex-wrap: wrap; gap: var(--s3); margin-top: var(--s2); }
.btn-row .btn { width: auto; flex: 1 1 150px; }

.form-error, .form-ok {
  position: relative;
  margin: 0 0 var(--s4);
  padding: 11px 13px 11px 15px;
  border-radius: var(--radius-sm);
  font-size: var(--t-sm);
  line-height: 1.45;
  animation: notice-in 240ms var(--ease) both;
}
.form-error::before, .form-ok::before {
  content: "";
  position: absolute;
  left: 0;
  top: 8px;
  bottom: 8px;
  width: 3px;
  border-radius: 3px;
}
.form-error {
  background: linear-gradient(180deg, rgba(176,48,32,.22), rgba(176,48,32,.12));
  border: 1px solid rgba(176, 48, 32, .5);
  color: #FFD7D0;
}
.form-error::before { background: var(--brand-pulse); }
.paper .form-error { color: #7A1D12; }
.form-ok {
  /* Gold, not the structural red it used to carry. On the old red panels a red
     wash still read as "fine"; on a blue panel it reads as .form-error with a
     different border, which is the opposite of what this element means. The
     bar, the border and the fill now all say gold. */
  background: linear-gradient(180deg, rgba(217, 164, 65, 0.24), rgba(217, 164, 65, 0.12));
  border: 1px solid rgba(217, 164, 65, 0.5);
}
.form-ok::before { background: var(--brand-sky); }
/* Advisory, not a failure: the off-16:9 upload still goes through, so this must
   not read as .form-error. Bronze rather than gold keeps it distinct from
   .form-ok on the same panel — "look at this" without "something broke". */
.form-warn {
  position: relative;
  margin: 0 0 var(--s3);
  padding: 11px 13px 11px 15px;
  border-radius: var(--radius-sm);
  font-size: var(--t-sm);
  line-height: 1.45;
  background: linear-gradient(180deg, rgba(217, 164, 65, .16), rgba(176, 90, 18, .10));
  border: 1px solid rgba(217, 164, 65, .38);
  color: #F3DFB4;
  animation: notice-in 240ms var(--ease) both;
}
.form-warn::before {
  content: "";
  position: absolute;
  left: 0;
  top: 8px;
  bottom: 8px;
  width: 3px;
  border-radius: 3px;
  background: #C98A1E;
}
.paper .form-warn { color: #6B4A08; }
/* The crop preview reuses .msg-art, so what a leader is shown here is the same
   frame members get — not an approximation of it. Capped so it stays a preview
   inside the form rather than a second hero. */
#msgArtWarn .msg-art, #msgArtWarnEs .msg-art {
  max-width: 320px;
  border-radius: var(--radius-sm);
  margin-bottom: var(--s3);
}
@keyframes notice-in {
  from { opacity: 0; transform: translateY(-5px); }
  to   { opacity: 1; transform: none; }
}

.alt-line { margin-top: var(--s4); text-align: center; color: var(--ink-dim); font-size: var(--t-sm); }
.linkish {
  background: none;
  border: 0;
  padding: 4px;
  color: var(--accent);
  font: 600 var(--t-sm) var(--font);
  text-decoration: none;
  cursor: pointer;
}

/* One-time secret (a generated passcode shown once and never again). */
.secret-slip {
  margin: var(--s3) 0;
  padding: var(--s4);
  text-align: center;
  background: #fff;
  background-image: linear-gradient(170deg, #fff 0%, #fff 60%, #F1EDE6 100%);
  color: #14203C;
  border-radius: var(--radius-sm);
  box-shadow: 0 1px 0 rgba(255,255,255,.9) inset, var(--elev-3);
  font: 700 1.6rem/1.2 var(--mono);
  letter-spacing: .18em;
  word-break: break-all;
}

/* ===================================================================
   Profile
   =================================================================== */
.identity {
  display: flex;
  align-items: center;
  gap: var(--s4);
  margin-bottom: var(--s4);
}
/* The member's initial on a lit disc. Nobody has uploaded a photo (there is no
   upload yet), so an empty circle would be a hole — this is a real object made
   from data we already have. */
.identity-mark {
  flex: 0 0 auto;
  display: grid;
  place-items: center;
  width: 58px;
  height: 58px;
  border-radius: 50%;
  background-image:
    radial-gradient(90% 90% at 30% 22%, rgba(255, 244, 222, .52) 0%, rgba(255, 244, 222, 0) 60%),
    linear-gradient(180deg, var(--accent), rgba(0,0,0,.34));
  color: var(--accent-ink);
  font: 700 1.5rem/1 var(--font);
  box-shadow:
    0 1px 0 rgba(255,255,255,.45) inset,
    0 10px 22px -10px rgba(0,0,0,.6),
    0 0 0 1px rgba(217, 164, 65, 0.30);
}
.identity-body { min-width: 0; }
.identity-body h2 {
  margin: 0 0 var(--s2);
  font-size: var(--t-xl);
  overflow-wrap: anywhere;
}
.chip-row { display: flex; flex-wrap: wrap; gap: var(--s2); }

/* ===================================================================
   Stubs and empty states (Phase 2+ tabs)
   ===================================================================
   An empty screen is an invitation, not an apology. It gets the same
   dimensional treatment as a full one so a member never lands on something
   that looks broken.
*/
.stub { text-align: left; overflow: hidden; }
.stub-medallion {
  position: relative;
  display: grid;
  place-items: center;
  width: 62px;
  height: 62px;
  margin-bottom: var(--s4);
  border-radius: 18px;
  background-image:
    radial-gradient(85% 85% at 28% 20%, rgba(217, 164, 65, 0.36) 0%, rgba(217, 164, 65, 0) 62%),
    linear-gradient(180deg, rgba(58, 94, 170, .95), rgba(20, 40, 92, .95));
  box-shadow:
    0 1px 0 rgba(255,255,255,.16) inset,
    0 14px 28px -14px rgba(0,0,0,.85),
    0 0 0 1px var(--line);
}
.paper .stub-medallion {
  background-image:
    radial-gradient(85% 85% at 28% 20%, rgba(255,255,255,.9) 0%, rgba(255,255,255,0) 62%),
    linear-gradient(180deg, #FFFFFF, #ECE5D8);
}
.stub-icon {
  width: 30px;
  height: 30px;
  color: var(--brand-sky);
  stroke: currentColor;
  fill: none;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.paper .stub-icon { color: var(--brand-blue); }
.stub-flag {
  display: inline-block;
  margin-bottom: var(--s2);
  padding: 3px 10px;
  border-radius: 999px;
  font-size: var(--t-xs);
  font-weight: 700;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--brand-sky);
  background: rgba(217, 164, 65, 0.14);
  border: 1px solid var(--line);
}
.stub-trace {
  height: 20px;
  margin-top: var(--s4);
  --ekg-tile: 130px;              /* 20 x 6.5 */
  --ekg-trace: var(--ekg-gold);
  opacity: .28;                   /* was .14 */
}
.stub-list { margin: var(--s3) 0 0; padding-left: 18px; color: var(--ink-dim); }
.stub-list li { margin: var(--s1) 0; }

.spinner {
  width: 18px;
  height: 18px;
  border: 2px solid rgba(255, 255, 255, .35);
  border-top-color: #fff;
  border-radius: 50%;
  animation: spin 700ms linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

/* ===================================================================
   Element defaults — the ones the browser would otherwise decide
   ===================================================================
   `.field input` was the only styled input in the file, so every bare
   <textarea>, <input type=number|search> and <select> fell through to the UA
   sheet — which on Chrome means monospace at 13.3px. That is why the board's
   compose box rendered in a different typeface than the app around it.

   These are surface-relative, so the same textarea is dark inside the shell
   and warm inside `.paper` without anybody writing a variant. */
textarea,
select,
input[type="text"], input[type="number"], input[type="search"],
input[type="password"], input[type="email"], input[type="tel"] {
  font: 500 var(--t-md) var(--font);
  color: var(--ink);
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  box-shadow: 0 1px 0 var(--hairline) inset;
  padding: 12px 14px;
  width: 100%;
  max-width: 100%;
  -webkit-appearance: none;
  appearance: none;
}
textarea {
  display: block;
  resize: vertical;
  min-height: 84px;
  line-height: 1.5;
}
textarea:focus,
select:focus,
input[type="text"]:focus, input[type="number"]:focus, input[type="search"]:focus,
input[type="password"]:focus, input[type="email"]:focus, input[type="tel"]:focus {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
  border-color: transparent;
}
textarea::placeholder, input::placeholder { color: var(--ink-faint); }

/* A number input's spinner is a 20px-wide tap hazard next to a Save button. */
input[type="number"] { -moz-appearance: textfield; }
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }

button { font-family: inherit; }

/* Screen-reader-only label. Present for every input that has no visible one. */
.sr-label {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* ===================================================================
   Small shared parts
   =================================================================== */
.lead { font-size: var(--t-lg); line-height: 1.55; color: var(--ink-dim); max-width: 52ch; }

.loading {
  display: flex;
  align-items: center;
  gap: var(--s3);
  padding: var(--s5) var(--s2);
  color: var(--ink-dim);
  font-size: var(--t-sm);
}

.btn-small {
  width: auto;
  min-height: 36px;
  padding: 7px 14px;
  font-size: var(--t-sm);
  border-radius: var(--radius-xs);
}
.btn-row .btn-small { flex: 0 0 auto; }

.badge-alert {
  background: linear-gradient(180deg, #C4432F, var(--brand-pulse));
  color: #fff;
  box-shadow: 0 1px 0 rgba(255,255,255,.25) inset;
}
.badge-pin {
  background: rgba(217, 164, 65, 0.22);
  color: var(--brand-sky);
  box-shadow: 0 1px 0 var(--hairline) inset;
}
.paper .badge-pin { background: rgba(163, 42, 29, 0.12); color: var(--brand-blue); }
.badge-link { text-decoration: none; cursor: pointer; }
.badge-link:active { transform: scale(.96); }

.chip-note {
  display: inline-flex;
  align-items: center;
  min-height: 30px;
  padding: 0 12px;
  border-radius: 999px;
  font-size: var(--t-xs);
  color: var(--ink-faint);
  background: var(--surface-3);
}

/* A checkbox and its words are ONE tap target, not two. */
.check {
  display: flex;
  align-items: flex-start;
  gap: var(--s3);
  min-height: var(--tap);
  padding: var(--s2) 0;
  font-size: var(--t-sm);
  line-height: 1.45;
  color: var(--ink-dim);
  cursor: pointer;
}
.check input[type="checkbox"] {
  flex: 0 0 auto;
  width: 20px; height: 20px;
  margin-top: 1px;
  /* --accent, not --brand-blue: that token holds the structural RED, so the
     checkbox was painting itself red on what is now a blue card. */
  accent-color: var(--accent);
}

/* The one place a caution is not an error: "this will be visible to the youth
   app too". Amber, never red — red is the pulse and stays load-bearing. */
.warn-box {
  position: relative;
  margin: 0 0 var(--s4);
  padding: var(--s4) var(--s4) var(--s4) calc(var(--s4) + 4px);
  border-radius: var(--radius-sm);
  border: 1px solid rgba(255, 190, 110, .45);
  background: rgba(255, 176, 74, .17);
  color: var(--ink);
  font-size: var(--t-sm);
  line-height: 1.5;
}
/* This is the only thing between a leader and an upload that surfaces in the
   youth app's library. It has to survive being glanced at. */
.warn-box::before {
  content: "";
  position: absolute;
  inset: 0 auto 0 0;
  width: 4px;
  border-radius: var(--radius-sm) 0 0 var(--radius-sm);
  background: #E2A33C;
}
.warn-box b, .warn-box strong { color: #FFD9A8; }
.paper .warn-box b, .paper .warn-box strong { color: #7A4A0A; }
.warn-box > :last-child { margin-bottom: 0; }

.form-error-card {
  border-color: rgba(176, 48, 32, .45);
  background-image: linear-gradient(180deg, rgba(176,48,32,.16), rgba(176,48,32,.04));
}
.reasons { margin: var(--s3) 0 0; padding-left: 18px; color: var(--ink-dim); font-size: var(--t-sm); }
.reasons li { margin: var(--s1) 0; }

/* Home tiles — the meta line under a tile name. */
.tile-meta { display: block; margin-top: var(--s1); font-size: var(--t-xs); color: var(--ink-faint); }
.tile-flags { display: flex; flex-wrap: wrap; gap: var(--s1); margin-top: var(--s2); }

/* Splash ring 1 is the leader of the three — no delay. Its siblings carry
   their own offsets; without this rule the base class was doing that job by
   accident and a future edit to `.splash-ring` would have moved it. */
.splash-ring-1 { animation-delay: 0ms; }

/* ===================================================================
   Trivia
   ===================================================================
   The whole screen was unstyled markup, which is why "Level 1" and "1 of 10"
   read as "Level 11 of 10" and the choice keys read as "AActs": adjacent
   inline spans with no layout between them.

   Difficulty is the organising idea. `.tier-N` is on the chip INSIDE the card,
   so `:has()` hoists it to the card as --tier / --tier-soft and the top edge,
   the chip and the key badges all inherit one colour. Without :has() support
   the card simply stays sky — it degrades, it does not break.
   =================================================================== */
.trivia-bar {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s3);
  margin: 0 0 var(--s2);
}
.trivia-count { font-size: var(--t-sm); font-weight: 700; color: var(--ink-dim); letter-spacing: .02em; }
.trivia-points { font-size: var(--t-sm); color: var(--ink-faint); text-align: right; }

.progress {
  height: 6px;
  margin: 0 0 var(--s5);
  border-radius: 999px;
  background: rgba(62, 11, 10, .55);
  box-shadow: 0 1px 0 var(--hairline) inset;
  overflow: hidden;
}
/* The inline width is set by the renderer. Deliberately NOT transitioned —
   the depth budget forbids animating width. */
.progress > span {
  display: block;
  height: 100%;
  border-radius: inherit;
  background: linear-gradient(90deg, var(--brand-blue), var(--brand-sky));
}

.trivia-card {
  --tier: var(--brand-sky);
  --tier-soft: rgba(217, 164, 65, 0.19);
  position: relative;
  overflow: hidden;
}
/* The lit top edge, in the difficulty colour. */
.trivia-card::after {
  content: "";
  position: absolute;
  inset: 0 0 auto;
  height: 3px;
  background: var(--tier);
  opacity: .9;
}
/* Difficulty ramp, bronze climbing to deep crimson.
   ⚠ --tier is spent as TEXT ON WHITE, not on the shell: app.js builds the card
   as `card paper trivia-card`, so the chip and the answer keys sit on the paper
   surface. A ramp picked to glow on oxblood measured 1.45–3.44:1 here — worse
   than the blue it replaced. These are chosen against paper and run 4.53–9.15:1,
   every step past 4.5. Contrast RISES with difficulty, so the ramp darkens and
   deepens as the questions get harder. The last two steps are palette tokens
   already (--brand-blue, --brand-lift). The tier number is printed on the chip,
   so colour is never the only thing carrying difficulty. */
.trivia-card:has(.tier-1), .trivia-card:has(.tier-2) {
  --tier: #8A6A16; --tier-soft: rgba(138, 106, 22, .13);    /* bronze        4.53:1 */
}
.trivia-card:has(.tier-3), .trivia-card:has(.tier-4) {
  --tier: #9C5A12; --tier-soft: rgba(156, 90, 18, .13);     /* amber brown   4.84:1 */
}
.trivia-card:has(.tier-5), .trivia-card:has(.tier-6) {
  --tier: #A8431A; --tier-soft: rgba(168, 67, 26, .13);     /* burnt orange  5.40:1 */
}
.trivia-card:has(.tier-7), .trivia-card:has(.tier-8) {
  --tier: #A32A1D; --tier-soft: rgba(163, 42, 29, .13);     /* structural red 6.47:1 */
}
.trivia-card:has(.tier-9), .trivia-card:has(.tier-10) {
  /* The hardest tier is the deepest ink on the card, not the brightest. */
  --tier: #7E1B15; --tier-soft: rgba(126, 27, 21, .13);      /* deep crimson  9.15:1 */
}

.trivia-meta {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s3);
  margin: 0 0 var(--s3);
}
.tier {
  display: inline-flex;
  align-items: center;
  padding: 4px 12px;
  border-radius: 999px;
  font-size: var(--t-xs);
  font-weight: 800;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--tier, var(--brand-blue));
  background: var(--tier-soft, rgba(163, 42, 29, 0.17));
  box-shadow: 0 0 0 1px var(--tier-soft, transparent) inset;
}
.trivia-meta .faint { flex: 0 0 auto; font-variant-numeric: tabular-nums; }

.trivia-q {
  margin: 0 0 var(--s5);
  font-size: var(--t-xl);
  line-height: 1.35;
  letter-spacing: -.01em;
  text-wrap: balance;
}

.choices { display: grid; gap: var(--s3); }

.choice {
  display: flex;
  align-items: center;
  gap: var(--s3);
  width: 100%;
  min-height: var(--tap);
  padding: 12px 14px;
  text-align: left;
  font: 600 var(--t-md) var(--font);
  color: var(--ink);
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  box-shadow: 0 1px 0 var(--hairline) inset, var(--elev-1);
  cursor: pointer;
  transition: transform 140ms var(--ease), border-color 140ms linear, opacity 140ms linear;
}
.choice:active:not(:disabled) { transform: translateY(1px) scale(.995); }
.choice:disabled { cursor: default; }

.choice-key {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 30px; height: 30px;
  border-radius: 9px;
  font-size: var(--t-sm);
  font-weight: 800;
  color: var(--tier, var(--brand-blue));
  background: var(--tier-soft, rgba(163, 42, 29, 0.17));
}
.choice-text { flex: 1 1 auto; min-width: 0; line-height: 1.4; }

/* Answered states. Colour AND a mark, never colour alone. */
.choice.is-right {
  border-color: rgba(37, 140, 80, .5);
  background: rgba(52, 168, 96, .14);
  color: #14602F;
}
.choice.is-right .choice-key { background: rgba(37, 140, 80, .2); color: #14602F; }
.choice.is-right::after { content: "✓"; margin-left: auto; font-weight: 800; color: #1B7A41; }
.choice.is-wrong {
  border-color: rgba(176, 48, 32, .5);
  background: rgba(176, 48, 32, .12);
  color: #7A1D12;
}
.choice.is-wrong .choice-key { background: rgba(176, 48, 32, .18); color: #7A1D12; }
.choice.is-wrong::after { content: "✕"; margin-left: auto; font-weight: 800; color: #A32A19; }
.choice.is-dim { opacity: .5; box-shadow: none; }
/* The optimistic state: pressed, answer not back from the server yet. */
.choice.is-picked { border-color: var(--tier); transform: scale(.995); }

.trivia-result {
  margin-top: var(--s5);
  padding: var(--s4);
  border-radius: var(--radius-sm);
  background: var(--surface-3);
  box-shadow: 0 1px 0 var(--hairline) inset;
}
.trivia-result.is-right { background: rgba(52, 168, 96, .12); }
.trivia-result.is-wrong { background: rgba(176, 48, 32, .10); }
.trivia-verdict {
  display: flex;
  align-items: center;
  gap: var(--s2);
  margin: 0 0 var(--s2);
  font-size: var(--t-lg);
  font-weight: 800;
}
.trivia-award {
  display: inline-flex;
  align-items: center;
  padding: 2px 10px;
  border-radius: 999px;
  font-size: var(--t-sm);
  font-weight: 800;
  color: #fff;
  background: linear-gradient(180deg, #C4432F, var(--brand-pulse));
}
.trivia-key { margin: 0 0 var(--s2); font-weight: 700; }
.trivia-fact { margin: 0 0 var(--s2); color: var(--ink-dim); line-height: 1.55; }
.trivia-result .btn-row { margin-top: var(--s4); }

.trivia-done { text-align: center; }
.trivia-done h2 { margin: 0 0 var(--s3); font-size: var(--t-2xl); }
.trivia-done p { margin: 0 0 var(--s2); }

.review { margin: 0; padding: 0; list-style: none; display: grid; gap: var(--s3); }
.review-row {
  position: relative;
  padding: var(--s4) var(--s4) var(--s4) calc(var(--s4) + 4px);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  box-shadow: 0 1px 0 var(--hairline) inset, var(--elev-1);
}
.review-row::before {
  content: "";
  position: absolute;
  inset: 0 auto 0 0;
  width: 4px;
  border-radius: var(--radius-sm) 0 0 var(--radius-sm);
}
.review-row.is-right::before { background: #34A860; }
.review-row.is-wrong::before { background: var(--brand-pulse); }
.review-q { margin: 0 0 var(--s1); font-weight: 700; line-height: 1.4; }
.review-fact { margin: var(--s2) 0 0; font-size: var(--t-sm); color: var(--ink-dim); line-height: 1.5; }

/* ===================================================================
   Board
   =================================================================== */
.board-bar {
  display: flex;
  align-items: center;
  gap: var(--s3);
  margin: 0 0 var(--s4);
}
.board-bar p { flex: 1 1 auto; margin: 0; font-size: var(--t-sm); }

.compose .post-input { margin-bottom: var(--s2); }

.post-input {
  font: 500 var(--t-md) var(--font);
  line-height: 1.55;
}

.posts { display: grid; gap: var(--s4); }

.post {
  position: relative;
  padding: var(--pad);
  border-radius: var(--radius);
  background-color: var(--surface-2);
  background-image: var(--gloss);
  box-shadow: 0 1px 0 var(--hairline) inset, var(--elev-1);
}
.post-reply {
  margin-left: var(--s5);
  border-left: 2px solid var(--line);
  border-radius: var(--radius-sm);
}

.post-head { display: flex; align-items: center; gap: var(--s3); margin: 0 0 var(--s3); }
.post-mark, .member-mark {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 38px; height: 38px;
  border-radius: 50%;
  font-size: var(--t-md);
  font-weight: 800;
  color: var(--brand-sky);
  background: linear-gradient(180deg, var(--panel-lift), var(--panel-deep));
  box-shadow: 0 1px 0 var(--hairline) inset, var(--elev-1);
}
.paper .post-mark, .paper .member-mark {
  color: #fff;
  /* Was `#2A8DCC → var(--brand-blue)`, which the red retheme quietly turned
     into a blue-to-RED gradient on the one surface nobody re-screenshotted. */
  background: linear-gradient(180deg, #2A8DCC, #13275C);
}
.post-who, .member-who { flex: 1 1 auto; min-width: 0; }
.post-author, .member-name {
  margin: 0;
  font-weight: 700;
  line-height: 1.25;
  overflow-wrap: anywhere;
}
.post-meta { margin: 2px 0 0; font-size: var(--t-xs); color: var(--ink-faint); }
.post-chips { flex: 0 0 auto; display: flex; flex-wrap: wrap; gap: var(--s1); justify-content: flex-end; }

.post-body {
  margin: 0;
  line-height: 1.62;
  overflow-wrap: anywhere;
  white-space: pre-wrap;
}
/* Showing the author's own words instead of the translation. Marked, because
   the member asked for it and should be able to see that they got it. */
.post-body.is-original {
  padding-left: var(--s3);
  border-left: 2px solid var(--accent);
  color: var(--ink-dim);
  font-style: italic;
}

.post-note {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--s2);
  margin: 0 0 var(--s3);
  font-size: var(--t-xs);
  color: var(--ink-faint);
}
.post-note-text { flex: 1 1 200px; min-width: 0; line-height: 1.45; }
/* A machine translation says so, every time, in the reader's language. */
.post-note-machine {
  padding: 6px 10px;
  border-radius: var(--radius-xs);
  background: var(--surface-3);
}

.post-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s2);
  margin-top: var(--s4);
  padding-top: var(--s3);
  border-top: 1px solid var(--line);
}
.post-form:not([hidden]) { display: block; margin-top: var(--s3); }
.post-form .btn-row { margin-top: var(--s2); }

.post-replies { margin-top: var(--s4); display: grid; gap: var(--s3); }
.post-replies-head {
  margin: 0;
  font-size: var(--t-xs);
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

/* Removed by a leader. The slot stays — a post that vanishes silently reads
   like a bug to the person who wrote it. */
.post-gone {
  background-image: none;
  border: 1px dashed var(--line);
  box-shadow: none;
  color: var(--ink-faint);
  font-style: italic;
}

/* Waiting on a leader's approval: the author's own copy, nobody else's. */
.post-waiting {
  margin-top: var(--s3);
  padding: var(--s3) var(--s4);
  border-radius: var(--radius-sm);
  background: rgba(255, 176, 74, .10);
  border: 1px solid rgba(255, 190, 110, .30);
  font-size: var(--t-sm);
  color: var(--ink-dim);
}
.post-waiting-title { margin: 0 0 var(--s1); font-weight: 700; color: var(--ink); }
.post-waiting > :last-child { margin-bottom: 0; }

/* ===================================================================
   Library
   =================================================================== */
.books {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  gap: var(--s4);
}
.book {
  display: flex;
  flex-direction: column;
  gap: var(--s1);
  text-decoration: none;
  color: inherit;
}
.book-cover {
  display: block;
  width: 100%;
  aspect-ratio: 2 / 3;
  object-fit: cover;
  margin-bottom: var(--s2);
  border-radius: var(--radius-sm);
  background: var(--surface-3);
  box-shadow: 0 1px 0 var(--hairline) inset, var(--elev-2);
}
/* No cover art: the spine treatment, not a grey box. */
.book-cover-blank {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--s4);
  text-align: center;
  font-size: var(--t-lg);
  font-weight: 800;
  line-height: 1.25;
  color: var(--brand-sky);
  background:
    linear-gradient(90deg, rgba(0,0,0,.28) 0 6px, rgba(255,255,255,.06) 6px 9px, transparent 9px),
    linear-gradient(180deg, var(--panel-lift), var(--panel-deep));
  overflow: hidden;
}
.book-title { font-weight: 700; line-height: 1.3; overflow-wrap: anywhere; }
.book-author { font-size: var(--t-sm); color: var(--ink-dim); }
.book-meta { font-size: var(--t-xs); color: var(--ink-faint); }

.book-detail-top { display: flex; gap: var(--s4); margin-bottom: var(--s4); }
.book-detail-top .book-cover { flex: 0 0 108px; width: 108px; margin-bottom: 0; }
.book-facts { flex: 1 1 auto; min-width: 0; display: grid; gap: var(--s1); align-content: start; }

.page-row { display: flex; align-items: center; gap: var(--s3); margin-top: var(--s3); }
.page-input { flex: 0 0 96px; width: 96px; text-align: center; font-variant-numeric: tabular-nums; }

/* ===================================================================
   Points
   =================================================================== */
.big-number {
  margin: 0 0 var(--s1);
  font-size: clamp(2.6rem, 1.9rem + 3.4vw, 3.6rem);
  font-weight: 800;
  line-height: 1;
  letter-spacing: -.03em;
  font-variant-numeric: tabular-nums;
  color: var(--ink);
}
/* The security record on Profile: "somebody else changed your passcode, and
   you already dismissed the alert". A quiet left edge in the pulse, so it
   still reads as a security line weeks later — not a centred banner. */
.standing {
  border-left: 3px solid rgba(176, 48, 32, .55);
}
.standing .eyebrow { color: var(--brand-pulse); }

.ranks { margin: 0; padding: 0; list-style: none; display: grid; gap: var(--s2); }
.rank-row {
  display: flex;
  align-items: center;
  gap: var(--s3);
  min-height: var(--tap);
  padding: var(--s2) var(--s4);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  box-shadow: 0 1px 0 var(--hairline) inset, var(--elev-1);
}
.rank-no {
  flex: 0 0 auto;
  min-width: 2.6ch;
  font-size: var(--t-sm);
  font-weight: 800;
  color: var(--ink-faint);
  font-variant-numeric: tabular-nums;
}
.rank-name { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
.rank-pts {
  flex: 0 0 auto;
  font-weight: 800;
  font-variant-numeric: tabular-nums;
  color: var(--ink);
}
/* The member's own row, wherever it lands. */
.rank-row.is-me {
  background-image: linear-gradient(90deg, var(--tier-soft, rgba(217, 164, 65, 0.19)), transparent 70%);
  box-shadow: 0 0 0 1px var(--accent) inset, var(--elev-1);
}
.rank-row.is-me .rank-name { font-weight: 700; }
/* "···" — the gap between the top of the board and where you actually are. */
.rank-gap {
  padding: var(--s1) var(--s4);
  text-align: center;
  letter-spacing: .3em;
  color: var(--ink-faint);
}
.ranks-me { margin-top: var(--s2); }

/* ===================================================================
   Admin — members, moderation, library
   ===================================================================
   The three-tier role model is visible here or it is not real: a leader sees
   set/not-set, never a passcode. The dot is the whole affordance, so it gets a
   shape as well as a colour. */
.admin-tabs { flex-wrap: wrap; }

.members { margin: 0; padding: 0; list-style: none; display: grid; gap: var(--s3); }
.member {
  padding: var(--s4);
  border-radius: var(--radius-sm);
  background-color: var(--surface-2);
  background-image: var(--gloss);
  box-shadow: 0 1px 0 var(--hairline) inset, var(--elev-1);
}
.member-head { display: flex; align-items: center; gap: var(--s3); }
.member-chips { flex: 0 0 auto; display: flex; flex-wrap: wrap; gap: var(--s1); justify-content: flex-end; }
.member-acts { display: flex; flex-wrap: wrap; gap: var(--s2); margin-top: var(--s3); }
.member-slip:not([hidden]) { display: block; margin-top: var(--s3); }

.pc-dot {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: var(--t-xs);
  font-weight: 700;
  color: var(--ink-dim);
}
.pc-dot::before {
  content: "";
  width: 9px; height: 9px;
  border-radius: 50%;
  background: currentColor;
}
.pc-set { color: #34A860; }
.pc-unset { color: #D9762F; }

.mod-post { border-left: 3px solid var(--brand-pulse); }

/* ===================================================================
   Per-route accents
   ===================================================================
   `#view` carries data-route (set in renderRoute). One hook, so a screen can
   have its own character without every component learning where it lives.
   Kept deliberately thin: an accent shift, not a second theme. */
#view[data-route="trivia"] { --accent: #E2513A; }
#view[data-route="points"] { --accent: #E2A33C; }
#view[data-route="board"]  { --accent: var(--brand-sky); }

@media (min-width: 780px) {
  .books { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
}

@media (max-width: 380px) {
  .book-detail-top { flex-direction: column; }
  .book-detail-top .book-cover { flex: 0 0 auto; width: 132px; }
  .trivia-bar { flex-direction: column; align-items: flex-start; gap: var(--s1); }
  .trivia-points { text-align: left; }
}

/* ===================================================================
   Motion preferences — one place, applies to everything above
   =================================================================== */
@media (prefers-reduced-motion: reduce) {
  .ambient-drift { animation: none !important; }
  #ambient-canvas { display: none !important; }
  .shell:not([hidden]) > .topbar,
  .shell:not([hidden]) > .view,
  .shell:not([hidden]) > .tabbar { animation: none !important; }
  .auth-head::before { animation: none !important; }
  .status:not(:empty), .form-error, .form-ok, .notice { animation: none !important; }
  .notice button:active { transform: none; }
  [data-tilt] { transform: none !important; transition: none !important; }
  .tab, .tab svg, .tab-dot, .btn, .lang-toggle, .switcher button, .switcher a {
    transition-duration: 1ms !important;
  }
  .btn:active, .tab:active, .lang-toggle:active, .switcher button:active,
  .switcher a:active { transform: none; }
  .choice { transition-duration: 1ms !important; }
  .choice:active, .choice.is-picked { transform: none !important; }
  .badge-link:active { transform: none; }
  .spinner { animation-duration: 2.4s; }
}

/* Hover states belong to devices that can hover. On a phone :hover sticks after
   a tap and leaves a tile looking selected. */
@media (hover: hover) and (pointer: fine) {
  .tile:hover, .card.is-interactive:hover {
    box-shadow: 0 1px 0 var(--hairline) inset, var(--elev-3);
  }
  .tab:hover { color: var(--ink-dim); }
  .tab[aria-current="page"]:hover { color: var(--brand-sky); }
  .btn:hover { filter: brightness(1.06); }
  .lang-toggle:hover { background-color: rgba(217, 164, 65, 0.17); }
  .switcher button:hover, .switcher a:hover { color: var(--ink); }
  .choice:hover:not(:disabled) { border-color: var(--tier); }
  .book:hover .book-cover { box-shadow: 0 1px 0 var(--hairline) inset, var(--elev-3); }
  .rank-row:hover { box-shadow: 0 1px 0 var(--hairline) inset, var(--elev-2); }
}

/* Wider screens: the app is a phone app first, but a desk browser should not
   look broken on Sunday afternoon. */
@media (min-width: 780px) {
  /* Still fixed, not sticky: sticky parks the bar at the end of the page, so
     on a long view you had to scroll to the bottom to change tabs. The bar is
     navigation — it must be reachable from anywhere on the page. */
  .tabbar {
    max-width: var(--maxw);
    margin: 0 auto;
    border-radius: var(--radius) var(--radius) 0 0;
  }
  .tiles { grid-template-columns: repeat(3, 1fr); }
}

/* Very narrow phones (360px and under is the target device). Nothing may clip:
   Spanish labels run ~20% longer than English and this is where they land. */
@media (max-width: 380px) {
  .tab { font-size: .64rem; }
  .tab svg { width: 20px; height: 20px; }
  .field-secret input { padding-right: 88px; }
  .identity-mark { width: 50px; height: 50px; font-size: 1.3rem; }
}

/* ---- Study (KERYGMA, proxied) -------------------------------------------
   The frame hosts a page this app does not own and cannot restyle, so the
   job here is only to give it a calm, correctly-sized hole to live in. */
.study-lede { margin: 0 0 14px; max-width: 46ch; }

.study-frame-wrap {
  position: relative;
  border: 1px solid var(--hairline, rgba(217, 164, 65, 0.26));
  border-radius: 14px;
  overflow: hidden;
  background: var(--paper, #F6F2EA);
  /* Fill what is left of the viewport under the header and above the tab bar.
     A fixed pixel height leaves either a stranded gap on a tall phone or a
     two-inch letterbox on a short one, and the proxied page scrolls itself. */
  height: calc(100dvh - var(--study-chrome, 260px));
  min-height: 380px;
}

.study-frame {
  width: 100%;
  height: 100%;
  border: 0;
  display: block;
}

.study-loading {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 10px;
  padding: 24px;
  text-align: center;
  background: var(--paper, #F6F2EA);
  color: var(--navy, #13275C);
}
.study-loading[hidden] { display: none; }
.study-loading p { margin: 0; max-width: 34ch; }
.tiny { font-size: .82rem; }

/* ---------------------------------------------------------------------------
   Add a book: three steps, one question each
   ---------------------------------------------------------------------------
   Each step is a real <fieldset>, so the grouping is in the document rather
   than in the styling: a screen reader announces "Step 1 of ..." from <legend>
   with no aria-label to keep in sync with the visible text.

   `--line` and not `--hairline`: hairline inverts on `.paper` (near-white, for
   drawing on the dark shell), so a border using it would vanish the moment this
   card landed on a light surface. `--line` is scoped for both grounds. */
.step {
  border: 1px solid var(--line);
  border-radius: 12px;
  padding: .85rem 1rem 1rem;
  margin: 0 0 1rem;
}
.step > legend {
  padding: 0 .45rem;
  font-weight: 600;
  font-size: .78rem;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--ink-faint);
}
.step .field:last-child { margin-bottom: 0; }
.step .warn-box { margin-top: .85rem; }

/* The "that file will not work" line under the picker. Danger red because it
   is the one hint on the screen that stops the leader going any further. */
.hint-bad { color: var(--brand-pulse); font-weight: 600; }

.lib-tabs { margin-bottom: 1rem; }

/* ---------------------------------------------------------------------------
   Directory
   ---------------------------------------------------------------------------
   A list of faces has one job: let somebody find a person fast. So the row is
   mostly touch target, the name carries the weight, and everything optional is
   allowed to be absent without leaving a hole — most profiles will be nearly
   empty for a while, and the screen has to look deliberate that way. */
.people { list-style: none; margin: 0; padding: 0; }
.person-row + .person-row { border-top: 1px solid var(--line); }
.person-link {
  display: flex;
  align-items: center;
  gap: .75rem;
  padding: .6rem .2rem;
  min-height: 56px;          /* a comfortable thumb target, not a text link */
  text-decoration: none;
  color: inherit;
}
.person-who { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.person-name {
  font-weight: 600;
  /* Long names truncate rather than wrapping the row to two lines and making
     the list jump around as you scroll it. */
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

.face {
  width: 44px; height: 44px;
  flex: 0 0 44px;
  border-radius: 999px;
  object-fit: cover;
  background: var(--surface-3);
  box-shadow: 0 1px 0 var(--hairline) inset;
}
.face-blank {
  display: inline-flex; align-items: center; justify-content: center;
  font-weight: 700; color: var(--ink-dim);
}
.face-lg { width: 76px; height: 76px; flex-basis: 76px; font-size: 1.6rem; }

.person-head { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
.person-title { margin: 0; font-size: 1.35rem; }

/* One fact per line: label above value, because a Spanish label and an English
   one are different widths and a two-column grid would ragged badly between
   languages. */
.fact { padding: .55rem 0; border-top: 1px solid var(--line); }
.fact-label {
  display: block;
  font-size: .74rem; letter-spacing: .05em; text-transform: uppercase;
  color: var(--ink-faint);
}
.fact-value { display: block; word-break: break-word; }

.check-small { display: block; margin-top: .4rem; font-size: .8rem; color: var(--ink-faint); }

/* ---------------------------------------------------------------------------
   Modals — the install offer and the join wizard
   ---------------------------------------------------------------------------
   Fixed, not absolute: on a phone the page behind can be several screens tall,
   and an absolutely-positioned dialog would sit at the top of the DOCUMENT
   rather than in front of what the member is looking at.

   `dvh` and not `vh`: mobile Safari's `vh` is the height WITHOUT the address
   bar, so a 100vh overlay is taller than the visible viewport and the buttons
   at the bottom of the dialog land under the browser chrome — exactly where a
   member cannot reach them. */
.modal-back {
  position: fixed;
  inset: 0;
  z-index: 200;
  display: flex;
  align-items: flex-end;             /* thumb-reachable on a phone */
  justify-content: center;
  padding: 1rem;
  background: rgba(11, 5, 5, .72);
  backdrop-filter: blur(3px);
  animation: modal-in 180ms var(--ease, ease-out);
}
@media (min-width: 620px) { .modal-back { align-items: center; } }

.modal {
  width: 100%;
  max-width: 30rem;
  max-height: 88dvh;
  overflow-y: auto;
  padding: 1.25rem;
  border-radius: 20px;
  background: var(--brand-navy);
  border: 1px solid var(--line);
  box-shadow: 0 -2px 40px -8px rgba(0,0,0,.7);
  /* Clear the iPhone home indicator, which otherwise overlaps the last button. */
  padding-bottom: calc(1.25rem + env(safe-area-inset-bottom, 0px));
}
.modal h2 { margin: 0 0 .6rem; font-size: 1.2rem; }
.modal .btn-row { margin-top: 1rem; }
.modal .field { margin-bottom: .5rem; }

/* The page behind must not scroll while a dialog is up — a modal you can
   scroll past reads as a banner that failed to load. */
body.has-modal { overflow: hidden; }

@keyframes modal-in { from { opacity: 0; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .modal-back { animation: none; } }

.install-mark { text-align: center; margin-bottom: .75rem; }
.install-mark img { border-radius: 16px; box-shadow: var(--elev-2); }

.steps-list { margin: .5rem 0 0; padding-left: 1.15rem; line-height: 1.55; }
.steps-list li { margin-bottom: .4rem; }

.step-count {
  margin: 0 0 .6rem;
  font-size: .74rem; letter-spacing: .06em; text-transform: uppercase;
  color: var(--ink-faint);
}

/* "Set one up" reads as prose, so it is a button that looks like a link rather
   than a link that does not navigate. */
.join-invite { margin-top: .9rem; text-align: center; }
.join-invite .linkish {
  background: none; border: 0; padding: 0;
  font: inherit; color: var(--accent); cursor: pointer;
}

/* Unlisting is a different promise from hiding one field, so it does not sit
   in the same run of checkboxes. */
.unlist-box { margin-top: 1rem; }

/* Shelf management, on the Books tab */
.shelf-list { list-style: none; margin: 0 0 1rem; padding: 0; }
.shelf-list li {
  display: flex; align-items: center; gap: .5rem; flex-wrap: wrap;
  padding: .5rem 0; border-top: 1px solid var(--line);
}
.shelf-name { font-weight: 600; flex: 1; min-width: 8rem; }

/* The move picker sits with the remove button. Full width on a phone so a
   thumb can hit it, inline once there is room. */
.book-move { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
.book-move select { flex: 1; min-width: 10rem; }

/* ---------------------------------------------------------------------------
   Ideas board
   ---------------------------------------------------------------------------
   The author's name is the FIRST thing on every row, not a footnote. The whole
   point of this board is answering "who asked for that?", so it gets the weight
   rather than the date. */
.ideas { list-style: none; margin: 0 0 1rem; padding: 0; }
.idea {
  padding: .7rem .85rem;
  margin-bottom: .55rem;
  border-radius: 12px;
  background: var(--panel-deep, rgba(0,0,0,.16));
  border: 1px solid var(--line);
}
.idea-head { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-bottom: .3rem; }
.idea-who { font-weight: 700; }
.idea-body { margin: 0; }
/* What the run said it did — set apart so it never reads as part of the ask. */
.idea-result {
  margin: .45rem 0 0;
  padding-left: .6rem;
  border-left: 2px solid var(--accent);
  color: var(--ink-faint);
  font-size: .88rem;
}
.idea-failed { border-color: var(--brand-pulse); }
.idea-cycle { margin-top: .8rem; font-weight: 600; }
.idea-history { margin-top: 1rem; }
.idea-history summary { cursor: pointer; color: var(--accent); font-weight: 600; }

/* =====================================================================
   Message — this week, past messages, my notes            (2026-07-30)
   =====================================================================
   Field vs object, the same split the rest of the app uses: the oxblood
   field stays, every object standing on it is a blue panel — EXCEPT the
   outline itself, which is `.card.paper`. That is deliberate. The outline
   is the thing somebody reads for forty minutes on a Sunday morning, so it
   belongs on the warm reading surface with the logo's own navy ink, next
   to the trivia card and the library detail for the same reason.

   ⚠ Inside `.paper`, `--accent` is BRONZE (#8A6014), not gold and not the
   structural red. Anything below that reaches for `var(--accent)` gets the
   right ink on whichever surface it lands on, which is why none of these
   rules name a hue directly. */

/* ---- the switcher: This Week | Past | My Notes ---- */
.msg-tabs { margin-bottom: var(--s4); }
/* The one-line cue above a PAST message: "you are not reading this week's". */
.msg-back { margin: 0 0 var(--s3); }
.msg-back .chip-btn { text-decoration: none; display: inline-block; }

/* ---- hero ---- */
.msg-hero {
  position: relative;
  margin: 0 0 var(--s4);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  overflow: hidden;
  background-color: var(--surface-2);
  background-image: linear-gradient(180deg, var(--panel-lift), var(--panel-deep));
  box-shadow: 0 1px 0 var(--hairline) inset, var(--elev-2);
}
/* ---- the title slide, framed at 16:9 (Chance, 2026-07-30) ----
   ⚠ THIS REVERSES AN EARLIER DELIBERATE DECISION, and the earlier reasoning was
   not wrong — it is recorded here because it still describes the failure mode.
   It read:

     "The slide keeps its own aspect ratio rather than being cropped to a band:
      a sermon graphic has the title baked into it, and cover-cropping at a
      narrow viewport is what cuts the words off."

   True, and it is exactly why `height: auto` was right while the app named no
   target ratio. Cropping an arbitrary upload to a fixed band cuts the title.

   What changed is that the app now ASKS for 16:9 (admin.msg.art_hint says
   1920x1080). For the ordinary case — a slide exported from ProPresenter, Canva
   or Keynote, all of which default to 16:9 — `cover` crops exactly nothing, and
   the page stops being shoved around by whatever aspect a leader happened to
   upload. Cropping now only bites a file that already contradicts the stated
   target, and it is warned about before it is sent.

   ⚠⚠ THE WARNING IS LOAD-BEARING, NOT POLISH. This rule and the upload-time
   ratio check in the admin screen MUST ship together. On its own, this rule is
   strictly worse than what it replaced: it silently crops the 4:3 slide the old
   comment was protecting, with nothing telling the leader it happened. If you
   are about to deploy this without that check, deploy neither. */
.msg-art {
  display: block;
  line-height: 0;
  background: var(--brand-void);
  aspect-ratio: 16 / 9;
  overflow: hidden;
}
.msg-art img {
  width: 100%;
  height: 100%;
  display: block;
  object-fit: cover;
  object-position: center;
}
.msg-head { padding: var(--pad); }
.msg-head h1 { margin: .2rem 0 .35rem; font-size: var(--t-2xl); line-height: 1.15; }
.msg-passage {
  margin: 0 0 .3rem;
  color: var(--accent);
  font-weight: 700;
  font-size: var(--t-md);
  letter-spacing: .01em;
}

/* ---- translation honesty badge ----
   This app publishes machine translation unreviewed (Chance's call), so the
   badge is not decoration — it is the only thing telling a Spanish reader
   that a computer wrote these words and not the pastor. Quiet, but never
   absent. */
.tr-badge {
  margin: .45rem 0 0;
  padding: .28rem .6rem;
  display: inline-block;
  border: 1px dashed var(--line);
  border-radius: var(--radius-xs);
  color: var(--ink-faint);
  font-size: var(--t-xs);
  font-weight: 600;
}

/* ---- the outline ---- */
.msg-outline h2 { margin-bottom: .3rem; }
.msg-line { margin: 0 0 .1rem; }

/* Every line is a real button: it is tappable, so it must be reachable by
   keyboard and announce itself. Styled back down to look like text — a row
   of 30 buttons that look like buttons would read as a menu, not a sermon. */
.msg-line-btn {
  display: block;
  width: 100%;
  text-align: left;
  border: 0;
  background: transparent;
  color: inherit;
  font: inherit;
  padding: .3rem .5rem;
  margin: 0;
  border-radius: var(--radius-xs);
  cursor: pointer;
  transition: background-color 140ms linear;
}
.msg-line-btn:hover { background: rgba(11,26,63,.06); }
.msg-line-btn:focus-visible { outline: var(--focus); outline-offset: 2px; }

.msg-head > .msg-line-btn { padding-left: 0; }
.msg-line.msg-head  .msg-line-btn { font-weight: 800; margin-top: .7rem; letter-spacing: .01em; }
.msg-line.msg-sub   .msg-line-btn { padding-left: 1.3rem; }
.msg-line.msg-label .msg-line-btn { }
.msg-line.msg-text  .msg-line-btn { color: var(--ink-dim); }

/* ---- pins ---- */
.msg-pin {
  margin: .1rem 0 .35rem 1.3rem;
  padding: .4rem .6rem;
  border-left: 3px solid var(--accent);
  border-radius: 0 var(--radius-xs) var(--radius-xs) 0;
  background: rgba(138,96,20,.08);
  font-size: var(--t-sm);
}
.msg-pin-mark { color: var(--accent); font-weight: 700; }
.msg-pin-edit { margin: .2rem 0 .6rem 1.3rem; }
.msg-pin-box { width: 100%; }

/* ---- the note taker ---- */
.msg-taker textarea { width: 100%; min-height: 8rem; resize: vertical; }

/* Pins whose line no longer exists. Shown with the words they were written
   about, because attaching them to a neighbouring line would be a lie. */
.msg-detached-pin {
  margin: 0 0 .7rem;
  padding: .5rem .8rem;
  border-left: 3px solid var(--line);
  background: rgba(0,0,0,.10);
  border-radius: 0 var(--radius-xs) var(--radius-xs) 0;
}
.msg-detached-pin p:first-child { font-style: italic; margin: 0 0 .25rem; }
.msg-detached-pin p:last-child  { margin: 0; }

/* ---- lists: past messages, my notes, the leader's list ---- */
.msg-list { list-style: none; margin: 0; padding: 0; }
.msg-item {
  display: flex;
  align-items: center;
  gap: var(--s2);
  margin: 0 0 var(--s2);
  background-color: var(--surface-2);
  background-image: linear-gradient(180deg, rgba(255,255,255,.045), rgba(0,0,0,.06));
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: 0 1px 0 var(--hairline) inset, var(--elev-1);
}
.msg-item > a,
.msg-item-open {
  flex: 1;
  display: block;
  width: 100%;
  text-align: left;
  padding: var(--s3);
  border: 0;
  background: transparent;
  color: inherit;
  font: inherit;
  cursor: pointer;
  border-radius: var(--radius);
}
.msg-item > a:focus-visible,
.msg-item-open:focus-visible { outline: var(--focus); outline-offset: -2px; }
.msg-item-title { display: block; font-weight: 700; margin-bottom: .12rem; }
.msg-item-passage { display: block; color: var(--accent); font-size: var(--t-sm); font-weight: 600; }
.msg-item .faint { display: block; font-size: var(--t-xs); }
/* ⚠ `.chip-btn` had NEVER been defined anywhere in this stylesheet, and
   `actBtn()` has used it as its default class since the admin area was built —
   so every Reset / Lock / Mute button on the members screen has been rendering
   as raw browser chrome: a white box with black text, on a red-and-blue app.
   It only became obvious here because My Notes puts one in front of ordinary
   members rather than leaders.

   Same family as the ~122 classes that shipped undefined in Phases 2-6 and
   read as "the app looks bland". Defined once, here, so every existing caller
   is fixed with it. Deliberately a small ghost pill, not a primary: these are
   secondary row actions and must not compete with the gold Save. */
.chip-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  width: auto;
  min-height: 34px;
  padding: 6px 12px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background-color: transparent;
  background-image: linear-gradient(180deg, rgba(255,255,255,.07), rgba(255,255,255,0));
  color: var(--ink-dim);
  font: 600 var(--t-sm) var(--font);
  cursor: pointer;
  box-shadow: 0 1px 0 var(--hairline) inset;
  transition: color 140ms linear, border-color 140ms linear;
}
.chip-btn:hover { color: var(--ink); border-color: var(--accent); }
.chip-btn:focus-visible { outline: var(--focus); outline-offset: 2px; }
.chip-btn[disabled] { opacity: .55; cursor: default; }
/* On the warm reading surface the ink and hairline both flip; the rule above
   already reaches for tokens, so this only needs the ground. */
.paper .chip-btn { background-image: linear-gradient(180deg, rgba(11,26,63,.04), transparent); }

.msg-item .chip-btn { margin-right: var(--s3); flex: 0 0 auto; }
.admin-msg-item .badge { margin-right: var(--s3); flex: 0 0 auto; }

/* One line of what they wrote, so a member recognises the note without
   opening it. Clamped rather than truncated with an ellipsis in JS: the
   server already caps the excerpt, this just stops a long line wrapping
   three deep in the list. */
.note-excerpt {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  margin: .15rem 0;
  color: var(--ink-dim);
  font-size: var(--t-sm);
}
