/* Rope & Rung — "Safe Dial" system. Contract: DESIGN.md. NO ORANGE. */

:root{
  /* --muted darkened 2026-07-23 (was #8A7F6E, ~3.7:1 on --paper — fails WCAG
     AA); #6E6353 is 5.3:1 on --paper / 5.7:1 on --card, still clearly
     secondary next to --ink (15.5:1 on --paper) */
  --paper:#FAF3E8; --card:#FFFCF5; --card-rgb:255,252,245; --ink:#1F1B16; --muted:#6E6353;
  /* launch-screen UNDERLAY disc (owner: not a washed-out watermark — a slightly
     subtler version of the real icon sitting UNDER the page). Deliberately an
     OPAQUE per-theme hex, not a translucent accent: the knot's halo has to match
     the disc EXACTLY to kill the candy-cane outline, and you can only match a
     colour that doesn't change with whatever is behind it. Value = what
     accent #0F7A6D looked like composited at 50% over this theme's --paper, so
     the disc reads as before while the figure/orbit on top go full brightness. */
  --ul-disc:#85B7AB;
  --accent:#0F7A6D; --pine:#0B3B34; --volt:#C7F464;
  /* launch peak: ink in light, PAPER in dark — the asset carries no colour of
     its own (it is a density mask), so inverting the wash is a colour swap
     rather than a second image. Owner call 2026-08-08: "in dark mode the
     mountain should be inverted — white on teal". */
  --peak-ink:#0F7A6D; --peak-rgb:15,122,109; --studio-ink:#0B3B34;
  /* Route trail: ONE colour per theme, measured. Against the three grounds the
     route crosses in light mode (paper sky, peak unfilled, peak filled) ink is
     the only palette colour that never drops below 7:1. Dark mode uses paper,
     which holds 4.15:1 at its worst — over the FILLED peak, the hardest case.
     With a colour per theme the two-layer casing this needed before is gone. */
  --trail-ink:#1F1B16; --trail-launch:#1F1B16;
  --met:#2F7A4B; --rest:#5B7FA6; --excused:#C98A2B; --missed:#B23A2E; --pending:#D8CFBE;
  --hair:rgba(31,27,22,.14);
  --postit:#F0D48A; --postit-ink:#4A3A12; --postit-ink-soft:#8A6D1E; --postit-muted:#B99B4E; --postit-fold:#D8B759;
  --grad:linear-gradient(160deg,#0F7A6D,#0B3B34);
  --r-sm:10px; --r-md:14px; --r-lg:18px;
  --shadow-rgb:31,27,22; --texture-rgb:31,27,22;
  --shadow-card:0 2px 10px rgba(var(--shadow-rgb),.05),0 8px 28px rgba(var(--shadow-rgb),.05);
  --shadow-pop:0 10px 34px rgba(var(--shadow-rgb),.16);
  --ease-out:cubic-bezier(.22,1,.36,1);
  /* surfaces/text that sit on the fixed teal->pine gradient (buttons, avatars,
     hero cards) — these stay constant across themes since the gradient itself
     doesn't change, only the page around it does */
  --on-accent:#FFFCF5;
  --dial-ring:#EDE3D0; --chip-active-bg:#F1EADB;
  --tabbar-bg:rgba(255,252,245,.28);
}
@media (prefers-color-scheme: dark){
  :root{
    /* --muted checked 2026-07-23: #9FB3AC is 7.8:1 on --paper / 5.6:1 on
       --card here — already passes WCAG AA, no change needed */
    --paper:#081F1B; --card:#0B3B34; --card-rgb:11,59,52; --ink:#F5F0E3; --muted:#9FB3AC;
    --ul-disc:#0C4D44;
    --met:#52C285; --rest:#8FB8E8; --excused:#E8AC52; --missed:#E8695C; --pending:#6B8079;
    --hair:rgba(250,243,232,.14);
    --shadow-rgb:0,0,0; --texture-rgb:250,243,232;
    --dial-ring:#123F38; --chip-active-bg:#123F38;
    --peak-ink:#FAF3E8; --peak-rgb:250,243,232; --studio-ink:#FAF3E8;
    --trail-ink:#FAF3E8; --trail-launch:#0F7A6D;
    --tabbar-bg:rgba(11,59,52,.28);
    /* accent/pine/volt/grad/postit family intentionally unchanged: the brand
       gradient and the post-it's "physical yellow paper" both need to read
       the same regardless of theme */
  }
}
@media (prefers-reduced-motion: reduce){
  *,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}
}
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{height:100%}
html{overscroll-behavior:none}
body{
  background:var(--paper); color:var(--ink);
  font-family:"Instrument Sans",sans-serif;
  touch-action:manipulation;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)' opacity='0.035'/%3E%3C/svg%3E");
  overscroll-behavior:none;
  /* this is an app shell, not a document — a slightly-imprecise tap on a
     button or the dial shouldn't be able to turn into a text-selection
     drag and eat the click. Only .dial had this before; it needs to be
     everywhere. Inputs/textareas explicitly opt back in below. */
  -webkit-user-select:none; user-select:none;
  -webkit-touch-callout:none;
}
input, textarea{-webkit-user-select:text; user-select:text}
/* svh (not dvh): Safari's collapsing toolbar changes dvh mid-drag and re-flows
   the whole page — the "jitter" — while svh stays fixed */
#app{max-width:430px;margin:0 auto;min-height:100svh;display:flex;flex-direction:column}
.hidden{display:none!important}

.wordmark{display:block}
.wordmark.sm{flex:none}
/* a small lantern-glow travels the rope's own path, tip to tip, one full pass
   per 3 pushup reps (3 x 1.7s = 5.1s). Split into two elements living inside
   the under-/over-strand's own <g> (not a separate top layer) so it sits at
   the SAME depth as the rope itself there -- it disappears under the ROPE/
   RUNG lettering and under the strand crossing exactly like the rope does,
   instead of floating flatly on top of everything. Handoff point (55.6%) is
   the real under-strand/over-strand length ratio, measured via
   getTotalLength(), not eyeballed, so the speed feels roughly constant
   across the direction change. */
/* NOTE: these two ride the ROPE inside the 96x96 logomark, NOT the mountain
   route in the 900x805 onboarding art. The two paths have been confused by a
   bulk find-and-replace before; they are not interchangeable, and a route path
   dropped in here throws the glow clean off the mark. */
.rr-lantern-a{offset-path:path("M 6 34 C 18 34, 22 26, 32 24 C 44 22, 54 26, 52 34 C 50 42, 38 42, 36 34");animation:rrPulseA 5.1s linear infinite}
.rr-lantern-b{offset-path:path("M 36 34 C 34 26, 44 24, 50 28 C 58 33, 70 34, 90 34");animation:rrPulseB 5.1s linear infinite}
@keyframes rrPulseA{0%{offset-distance:0%;opacity:1}55.6%{offset-distance:100%;opacity:1}55.7%{opacity:0}100%{opacity:0;offset-distance:100%}}
@keyframes rrPulseB{0%,55.6%{opacity:0;offset-distance:0%}55.7%{opacity:1;offset-distance:0%}100%{opacity:1;offset-distance:100%}}
/* logo: pushup figure in the ring — feet planted, body pivots at the feet like a real rep.
   Selectors target the classes directly (not scoped under .logomark) since the
   markup now lives once in <symbol id="rr-logomark"> and is cloned into both
   header + onboarding instances via <use> — a plain class selector matches
   inside that clone reliably; a descendant selector anchored on the host
   .logomark element is not guaranteed to. */
.lm-body{transform-box:view-box;transform-origin:22px 63px;animation:lmPush 1.7s ease-in-out infinite}
@keyframes lmPush{50%{transform:rotate(7deg)}}
/* volt dot orbits the disc once per rep, synced to lmPush's 1.7s cycle */
.lm-orbit{transform-box:view-box;transform-origin:48px 48px;animation:lmOrbit 5.1s linear infinite}
@keyframes lmOrbit{to{transform:rotate(360deg)}}

.app-head{display:flex;justify-content:space-between;align-items:center;padding:calc(14px + env(safe-area-inset-top)) 22px 0}
.brand{display:flex;align-items:center;gap:8px}
.logomark{width:26px;height:26px}
/* onboarding: wordmark promoted (was sharing top billing with the .ob-logo
   badge, since retired along with the whole pushup watermark) —
   scoped class so the shared .wordmark base rule (used by the header's small
   mark and the celebration overlay) stays untouched. Given a soft brand-teal
   shadow for weight. Width capped BELOW .ob-inner's own ~327px content box
   (327 - overlay's 24px*2 padding on a 375px phone) on purpose: margin:auto
   only centers an element that's narrower than its container — a wider box
   (the old 340/350px attempts) silently collapses its auto-margins to 0 per
   spec and just overflows off the right edge instead of centering. 312px
   leaves real slack on both sides so centering actually holds; the real
   enlargement now comes from the scale(1.1) on the onboarding <use> itself
   (see the HTML comment beside that tag), not a viewBox crop. */
/* The knot's over-strand halo is --paper everywhere else in the app, which blends
   invisibly because those surfaces ARE --paper. On the launch screen the knot sits
   on the underlay disc instead, so there it must be the DISC's colour or it reads
   as a hard "candy-cane" outline (owner). Custom properties inherit across the
   <use> shadow boundary (plain selectors do not), so this retints ONLY this
   instance — the header badge and celebration overlay keep the --paper fallback.
   Scoped to the knot's own halo path; the ROPE/RUNG letter halos are untouched. */
/* .ob-wordmark / .ob-watermark retired 2026-08-08: the launch screen is now
   the Ascent lockup over the painted peak. #rr-wordmark still serves the header
   and the celebration overlay; #rr-underlay is defined but no longer used. */
/* full-screen ambient watermark (owner feedback 2026-07-24): the pushup figure
   that used to sit as a badge directly above the wordmark, now blown up huge,
   faint, and pinned behind every onboarding element instead of competing with
   the brand name for attention. Fixed (not absolute) so it's pinned to the
   actual phone viewport regardless of #onboarding's own layout; negative
   z-index (relative to #onboarding's own stacking context, z-index:50) keeps
   it under the non-positioned .ob-inner column without needing to touch that
   column's stacking. Centered a bit below screen-middle so it reads as sitting
   under the crew-code input step, not the wordmark up top. Reuses the real
   #rr-logomark (teal disc + paper figure + volt orbit dot) unmodified.
   Colour fix (owner: "grey in dark mode / milky in light mode"): earlier
   attempts chased this with mix-blend-mode (multiply in light, screen in
   dark, flipped per-theme) plus a pair of [stroke]/[fill] attribute-selector
   overrides trying to redirect the figure's hardcoded #FAF3E8 fill to
   var(--ink). Both approaches were fighting the same wall: #rr-logomark is
   only ever painted via <use>, which clones the <symbol>'s content into a
   shadow tree that outer-document CSS selectors (descendant or attribute)
   cannot match into — only inherited properties and custom properties
   (--x) cross that boundary. The [stroke=]/[fill=] overrides above never
   actually applied to the cloned paths; they're removed. The real fix
   doesn't need to reach inside the clone at all: mix-blend-mode:normal
   (i.e. no blend-mode override) at moderate opacity, with a mild
   filter:saturate() to pull it back a touch from full brand-colour
   intensity, reads correctly as a soft tint of the SAME colours in both
   themes — the teal disc gives the figure enough of its own contrast
   against both the light and dark --paper background that no per-theme
   blend-mode gymnastics are needed. ~7% desaturation (saturate(.93)) plus
   opacity .5 keeps it clearly present without overpowering the onboarding
   content sitting on top. Motion (lm-body/lm-orbit) is killed by the
   existing global prefers-reduced-motion rule at the top of this file,
   same as everywhere else. */

.head-actions{display:flex;align-items:center;gap:10px}
.head-right{display:flex;flex-direction:column;align-items:flex-end;gap:3px}
.head-date{font-size:13px;color:var(--muted);font-weight:600}
.date-override-tag{font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--on-accent);background:var(--accent);padding:2px 7px;border-radius:var(--r-sm)}
.menu-wrap{position:relative}
.menu-btn{width:34px;height:34px;display:flex;align-items:center;justify-content:center;border:none;background:transparent;color:var(--muted);cursor:pointer;border-radius:var(--r-sm);transition:background .15s,transform .15s}
.menu-btn svg{width:20px;height:20px;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round}
.menu-btn:active{transform:scale(.92);background:var(--hair)}
.menu-dropdown{position:absolute;right:0;top:calc(100% + 8px);background:rgba(var(--card-rgb),.55);border:1px solid var(--hair);
  backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);
  border-radius:var(--r-sm);box-shadow:var(--shadow-pop);z-index:70;min-width:140px;overflow:hidden}
.menu-item{display:block;width:100%;text-align:left;padding:11px 14px;font-size:14px;font-weight:600;color:var(--ink);
  background:none;border:none;cursor:pointer;font-family:"Instrument Sans",sans-serif}
.menu-item:active{background:var(--hair)}
#admin-modal{background:rgba(var(--shadow-rgb),.45)}
.admin-modal-input{width:100%;padding:12px;border:1px solid var(--hair);background:var(--paper);font-family:Archivo,sans-serif;
  font-weight:700;font-size:16px;letter-spacing:.04em;text-align:center;color:var(--ink);border-radius:var(--r-sm)}
.local-banner{margin:10px 22px 0;padding:8px 12px;font-size:12px;font-weight:600;color:var(--rest);border:1px dashed var(--rest);background:var(--card);border-radius:var(--r-sm)}
.install-hint{border-radius:var(--r-lg)}
.ob-inner input[type=text],.set-card input,.hist-add input,#hist-person{border-radius:var(--r-sm)}
.ob-existing button{border-radius:var(--r-md);box-shadow:var(--shadow-card);transition:transform .15s}
.ob-existing button:active{transform:scale(.97)}
.crew-invite{border-radius:var(--r-lg)}

.screen{flex:1;padding-bottom:16px;display:flex;flex-direction:column;animation:screenIn .34s var(--ease-out)}
@keyframes screenIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
.screen.slide-l{animation:slideL .32s var(--ease-out)}
.screen.slide-r{animation:slideR .32s var(--ease-out)}
@keyframes slideL{from{opacity:0;transform:translateX(40px)}to{opacity:1;transform:none}}
@keyframes slideR{from{opacity:0;transform:translateX(-40px)}to{opacity:1;transform:none}}

/* home dashboard */
.home-hero{padding:20px 22px 4px}
.hh-greet{font-family:Archivo,sans-serif;font-weight:800;font-size:30px;letter-spacing:-.02em;line-height:1.05}
.hh-sub{font-size:13px;font-weight:600;color:var(--muted);margin-top:6px}
/* Hero card goes translucent so the .app-peak watermark tints through it
   instead of the card sitting flatly on top, matching the frosted treatment
   the onboarding surfaces already use. It does NOT take the house
   rgba(var(--card-rgb),.28) recipe: this card's text is --on-accent
   (near-white #FFFCF5), and --card-rgb is 255,252,245 in light mode, so the
   house recipe would put white text on a near-white card. It keeps its own
   teal->pine gradient and gains alpha on both stops instead, so the white
   text keeps a dark ground in both themes.

   ALPHA IS PER-THEME, and that is not a style choice — it is a contrast floor.
   Light --paper (#FAF3E8) is bright enough that transparency eats the ground
   out from under the near-white text: measured against the paper+watermark
   ground, the gradient's TOP stop gives 3.37:1 at .72, which fails WCAG AA
   (4.5:1) for .hc-label / .hc-of / .hc-meta -- only .hc-tally, as large text,
   would have cleared. .92 is the first passing value (4.54:1). Dark mode has
   no such problem (6.28:1 at .72) and keeps the deeper transparency, since
   that is where the watermark actually reads through. Same one-theme-only
   correction pattern as .app-peak's dark override further down this file.
   Tune .72 (dark) freely; do not raise the light value above nor drop it
   below .92 without re-checking the ratio. */
.home-card{margin:16px 22px 0;padding:20px;border-radius:var(--r-lg);
  background:linear-gradient(160deg,rgba(15,122,109,.92),rgba(11,59,52,.92));
  backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);color:var(--on-accent);
  box-shadow:0 14px 34px rgba(15,122,109,.3);animation:rowIn .45s var(--ease-out)}
@media (prefers-color-scheme: dark){
  .home-card{background:linear-gradient(160deg,rgba(15,122,109,.72),rgba(11,59,52,.72))}
}
.hc-top{display:flex;align-items:center;justify-content:space-between}
.hc-label{font-size:11px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;opacity:.75}
.hc-main{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:8px}
.hc-nums{display:flex;align-items:baseline;gap:8px}
.hc-tally{font-family:Archivo,sans-serif;font-weight:800;font-size:64px;line-height:1;letter-spacing:-.03em;font-variant-numeric:tabular-nums}
.hc-of{font-size:18px;font-weight:600;opacity:.8}
.hc-meta{font-size:13px;font-weight:600;opacity:.85;margin-top:16px}
.hc-chips{display:flex;gap:8px;margin-top:14px}
.hc-chip{flex:1;min-height:40px;border:1px solid rgba(255,252,245,.3);background:rgba(255,252,245,.14);border-radius:999px;
  font-family:Archivo,sans-serif;font-weight:800;font-size:15px;color:var(--on-accent);cursor:pointer;transition:transform .15s var(--ease-out),background .2s}
.hc-chip:active{transform:scale(.94);background:rgba(255,252,245,.26)}
/* Wave 4: chip disabled for the split-second its own commit is in flight —
   guards the race where a rapid double-tap could read the same stale
   "before" tally and silently skip a deserved celebration (see homeQuickAdd) */
.hc-chip:disabled,.qchip:disabled{opacity:.5;cursor:default;transform:none}
.btn.hc-cta{width:100%;margin-top:12px;background:var(--volt);color:var(--pine);font-weight:800;border-radius:999px;box-shadow:0 6px 18px rgba(199,244,100,.3)}
.week-strip{padding:18px 22px 0}
.ws-head{display:flex;align-items:baseline;justify-content:space-between}
.ws-summary{font-size:12px;font-weight:600;color:var(--muted);font-variant-numeric:tabular-nums}
.ws-track{position:relative;margin-top:14px}
.ws-strand{position:absolute;left:5px;right:5px;top:26px;height:4px;border-radius:2px;background:var(--hair)}
.ws-days{position:relative;display:flex;justify-content:space-between}
.ws-day{display:flex;flex-direction:column;align-items:center;gap:8px;flex:1;cursor:pointer}
.ws-day.future{cursor:default}
.ws-dow{font-size:11px;font-weight:700;color:var(--muted);letter-spacing:.02em}
.ws-day.today .ws-dow{color:var(--ink)}
.ws-dot{position:relative;z-index:1;background:var(--paper)}
.ws-day.today .ws-dot{box-shadow:0 0 0 3px rgba(199,244,100,.55)}
.ws-day.future .ws-dot{width:7px;height:7px;border-radius:50%;background:transparent;border:1.5px dashed var(--hair)}
.home-crew{padding:20px 22px 0}
.home-crew .row{display:flex;align-items:center;gap:10px;background:rgba(var(--card-rgb),.78);border:1px solid var(--hair);border-radius:var(--r-md);
  box-shadow:var(--shadow-card);padding:11px 12px;margin-bottom:8px;animation:rowIn .4s var(--ease-out) backwards}
.home-crew .row .avatar{width:32px;height:32px;border-radius:50%;background:var(--grad);color:var(--on-accent);display:flex;align-items:center;justify-content:center;font-size:15px}
.home-crew .row .nm{font-weight:700;font-size:15px}
.home-crew .row .nums{margin-left:auto;font-family:Archivo,sans-serif;font-weight:800;font-size:17px;font-variant-numeric:tabular-nums}
.home-crew .row .nums small{color:var(--muted);font-family:"Instrument Sans",sans-serif;font-weight:600;font-size:12px}
.home-crew .row .sdot{width:10px;height:10px;border-radius:5px}
.home-postit{display:flex;justify-content:flex-end;padding:14px 22px 0}
/* Screen titles take the launch screen's display face. Bebas is caps-only and
   tracks tight by default, so it needs the same .13em + matching text-indent
   the ASCENT lockup uses - Bebas adds its tracking to the RIGHT of each glyph,
   which shunts a centred or left-aligned word off its own edge without it. */
.screen-title{font-family:"Bebas Neue",Archivo,sans-serif;font-weight:400;font-size:34px;
  line-height:1;letter-spacing:.09em;text-transform:uppercase;color:var(--ink);
  padding:20px 22px 6px}

/* labels / text */
/* Field labels and card titles move to the mono, matching CREW CODE on the
   launch screen. Weight drops from 700 to 500 because JetBrains Mono at 700 in
   tracked caps reads as shouting next to Bebas. */
.lbl{display:block;font-family:"JetBrains Mono",ui-monospace,monospace;font-weight:500;
  font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);margin:14px 0 6px}
.l-title{font-family:"JetBrains Mono",ui-monospace,monospace;font-weight:500;
  font-size:10.5px;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);margin-bottom:10px}
.hint{font-size:13px;color:var(--muted);margin-top:10px}
.err{font-size:13px;color:var(--missed);margin-top:8px}
.ink{color:var(--ink)}

/* buttons */
.btn{font-family:"Instrument Sans",sans-serif;font-weight:700;font-size:16px;padding:15px 18px;border:none;text-align:center;cursor:pointer;color:var(--ink);background:transparent;
  border-radius:999px;transition:transform .15s var(--ease-out),box-shadow .2s,background .2s}
.btn:active{transform:scale(.965)}
.btn-primary{background:var(--accent);color:var(--on-accent);letter-spacing:.02em;box-shadow:0 6px 18px rgba(15,122,109,.28)}
.btn-grad{background:var(--grad)}
.btn-primary:disabled{background:var(--pending);color:var(--muted);cursor:default;box-shadow:none}
.btn-ghost{border:1px solid var(--hair);background:var(--card);font-weight:600;font-size:14px;padding:12px 18px;box-shadow:var(--shadow-card)}
.btn-outline{border:2px solid var(--accent);background:transparent;color:var(--accent);font-weight:700;font-size:14px;padding:11px 18px}
.btn-outline:active{background:rgba(15,122,109,.08)}
.mini-btn{border:1px solid var(--hair);background:var(--card);width:34px;height:34px;font-size:18px;cursor:pointer;border-radius:var(--r-sm);transition:transform .15s}
.mini-btn:active{transform:scale(.92)}

/* rope */
.rope-row{display:flex;align-items:center;gap:8px;padding:14px 22px 0}
.r-lbl{font-size:11px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--muted)}
/* twisted-rope strand + fray threads — the athletic rope language */
.strand{flex:1;height:4px;border-radius:2px;opacity:.75;
  background:repeating-linear-gradient(65deg,rgba(var(--texture-rgb),.42) 0 2.5px,rgba(var(--texture-rgb),.08) 2.5px 6px)}
.knot{position:relative}
.knot.fray::after{content:"";position:absolute;left:100%;top:50%;width:12px;height:8px;transform:translateY(-50%);
  background:repeating-linear-gradient(0deg,transparent 0 1.5px,rgba(178,58,46,.55) 1.5px 2.5px);
  -webkit-mask:linear-gradient(90deg,#000,transparent);mask:linear-gradient(90deg,#000,transparent)}
.rope-title{display:flex;align-items:center;gap:10px}
.rope-title::after{content:"";flex:0 0 46px;height:3.5px;border-radius:2px;opacity:.6;
  background:repeating-linear-gradient(65deg,rgba(var(--texture-rgb),.42) 0 2.2px,rgba(var(--texture-rgb),.08) 2.2px 5.5px)}
.knots{display:flex;gap:12px}
.knot{width:11px;height:11px;border-radius:6px;background:var(--ink)}
.knot.volt{background:var(--accent);box-shadow:0 0 0 3px rgba(199,244,100,.7)}
.knot.fray{background:transparent;border:1.5px dashed var(--missed)}
.knot.fray.at-risk{animation:frayShake 1.6s ease-in-out infinite;transform-origin:center}
.knot.fray.urgent{animation-duration:.7s;border-width:2px}
@keyframes frayShake{0%,100%{transform:rotate(0) translateY(0)}25%{transform:rotate(14deg) translateY(-1px)}75%{transform:rotate(-14deg) translateY(1px)}}
/* week-scaled knots (Wave 4): once a streak runs past its first week, the rope
   shows one knot per completed week instead of one per day — a 60-day streak
   now looks nothing like a 7-day one. Milestone weeks (every 4th = 28 days,
   matching the celebration copy) land heavier/bigger; the week still in
   progress renders as a light dashed "forming" knot; a run past the display
   cap (renderRope's WEEK_CAP) condenses into one "+N weeks" chip so the rope
   stays compact even at 100+ days. A streak still inside its first week keeps
   the original one-knot-per-day read (handled in renderRope, n<=7). */
.knot.week{width:15px;height:15px;border-radius:8px}
.knot.week.milestone{width:22px;height:22px;border-radius:11px;box-shadow:0 0 0 3px rgba(199,244,100,.35)}
.knot.week.partial{background:transparent;border:1.5px dashed var(--ink);opacity:.6;width:11px;height:11px}
.knot.week.partial.volt{border-color:var(--accent);opacity:.9}
.knot.week.overflow{width:28px;height:16px;border-radius:8px;background:var(--grad);color:var(--on-accent);
  display:flex;align-items:center;justify-content:center;font-family:Archivo,sans-serif;font-weight:800;font-size:9px}
.excuse-tear{width:100%;margin-top:8px;font-size:13px;font-weight:600;color:var(--missed);background:transparent;border:1px dashed var(--missed);border-radius:999px;padding:10px}

/* dial */
.dial-float{position:absolute;top:24%;left:50%;transform:translateX(-50%);font-family:Archivo,sans-serif;font-weight:900;font-size:34px;
  color:var(--accent);pointer-events:none;z-index:4;animation:dialFloat .95s var(--ease-out) forwards}
@keyframes dialFloat{0%{opacity:0;transform:translateX(-50%) translateY(14px) scale(.7)}
  25%{opacity:1;transform:translateX(-50%) translateY(0) scale(1.08)}
  100%{opacity:0;transform:translateX(-50%) translateY(-46px) scale(1)}}
.dial.chip-pulse .ring{animation:chipRingPulse .6s var(--ease-out)}
@keyframes chipRingPulse{0%{box-shadow:0 0 0 0 rgba(15,122,109,.5)}100%{box-shadow:0 0 0 22px rgba(15,122,109,0)}}
.dial{position:relative;width:min(332px,86vw);aspect-ratio:1;margin:14px auto 0;touch-action:none;user-select:none;
  filter:drop-shadow(0 22px 44px rgba(15,122,109,.16))}
.dial::before{content:"";position:absolute;inset:-40px;border-radius:50%;pointer-events:none;
  background:radial-gradient(closest-side,rgba(15,122,109,.10),transparent 72%)}
.dial .ring{position:absolute;inset:0;border-radius:50%;background:var(--dial-ring)}
.dial .ring::after{content:"";position:absolute;inset:24px;border-radius:50%;background:var(--card);border:1px solid var(--hair);
  box-shadow:inset 0 4px 18px rgba(var(--shadow-rgb),.06)}
.dial .ticks{position:absolute;inset:4px;border-radius:50%;pointer-events:none;
  background:repeating-conic-gradient(rgba(var(--texture-rgb),.32) 0 .6deg,transparent .6deg 18deg);
  -webkit-mask:radial-gradient(closest-side,transparent calc(100% - 13px),#000 calc(100% - 12px));
  mask:radial-gradient(closest-side,transparent calc(100% - 13px),#000 calc(100% - 12px));}
.dial .progress-ring{position:absolute;inset:27px;border-radius:50%;pointer-events:none;z-index:1;
  -webkit-mask:radial-gradient(closest-side,transparent calc(100% - 7px),#000 calc(100% - 6px));
  mask:radial-gradient(closest-side,transparent calc(100% - 7px),#000 calc(100% - 6px));}
/* The knob must sit ON TOP of the progress arc. Lifting .knob-arm is the WHOLE
   fix — an earlier attempt also gave .dial .ring a z-index, which made the ring
   a stacking context and buried the tally, the target text and the progress arc
   underneath it. Touch this element only. */
.knob-arm{position:absolute;inset:0;pointer-events:none;z-index:3}
.dial:not(.dragging) .knob-arm{transition:transform .3s var(--ease-out)}
.knob{position:absolute;top:-14px;left:50%;transform:translateX(-50%);
  width:54px;height:54px;border-radius:50%;background:var(--volt);
  border:4px solid var(--on-accent);box-shadow:0 0 0 1px var(--hair),0 6px 16px rgba(var(--shadow-rgb),.22);
  display:flex;align-items:center;justify-content:center;color:var(--pine);font-weight:900;font-size:22px;
  pointer-events:none;transition:box-shadow .2s,transform .2s}
.dial.dragging .knob{box-shadow:0 0 0 1px var(--hair),0 8px 22px rgba(var(--shadow-rgb),.3),0 0 0 10px rgba(199,244,100,.22)}
.dial.dragging .knob{background:var(--pine)}
.dial-center{position:absolute;inset:44px;border-radius:50%;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center}
/* the dial is an input device — what you're actively spinning in (compose)
   is the number that should dominate; the already-banked total (tally) is
   reference context, so it reads smaller underneath */
.compose{font-family:Archivo,sans-serif;font-weight:800;font-size:clamp(84px,29vw,116px);line-height:.95;
  letter-spacing:-.04em;font-variant-numeric:tabular-nums;color:var(--accent);min-height:calc(84px * .95)}
.compose.neg{color:var(--missed)}
.tally{font-family:Archivo,sans-serif;font-weight:800;font-size:22px;font-variant-numeric:tabular-nums}
.tally.met{color:var(--met)}
.target-label{display:flex;gap:8px;align-items:center;justify-content:center;margin-top:8px;font-size:12px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--muted)}
.target-label .rule{width:34px;height:2px;background:var(--accent)}
@keyframes voltflash{0%{box-shadow:0 0 0 0 rgba(199,244,100,.9)}100%{box-shadow:0 0 0 46px rgba(199,244,100,0)}}
.dial.smashed .ring{animation:voltflash .55s ease-out}

/* first-run dial hint: teaches the drag gesture, gone for good after the
   first successful crank (or a manual dismiss) — hand-made cue, not a SaaS
   coach-mark bubble: a dashed chalk-arc + a faint volt "ghost" of the knob
   sweeping the path it wants you to trace. */
.dial-hint{position:absolute;inset:0;pointer-events:none;z-index:2}
.dial-hint-ring{position:absolute;inset:18px;border-radius:50%;border:2px dashed rgba(15,122,109,.4);
  animation:dialHintSpin 7s linear infinite}
@keyframes dialHintSpin{to{transform:rotate(360deg)}}
.dial-hint-ghost{position:absolute;top:0;left:50%;width:26px;height:26px;margin:-13px 0 0 -13px;
  border-radius:50%;background:var(--volt);opacity:.55;box-shadow:0 0 0 4px var(--on-accent);
  offset-path:circle(42% at 50% 50%);
  animation:dialHintOrbit 3.4s cubic-bezier(.4,0,.2,1) infinite,dialHintPulse 1.7s ease-in-out infinite}
@keyframes dialHintOrbit{0%,10%{offset-distance:0%}62%,100%{offset-distance:64%}}
@keyframes dialHintPulse{0%,100%{transform:scale(.85);opacity:.4}50%{transform:scale(1.18);opacity:.75}}
.dial-hint-note{display:flex;align-items:center;justify-content:center;gap:8px;
  font-size:13px;font-weight:600;color:var(--muted);text-align:center;margin:12px 22px 0}
.dial-hint-note button{border:none;background:none;color:var(--muted);font-size:13px;line-height:1;
  cursor:pointer;padding:6px;flex:none}

.quick-chips{display:flex;gap:10px;justify-content:center;padding:20px 22px 0}
.qchip{flex:0 1 96px;min-height:44px;border:1px solid var(--hair);background:rgba(var(--card-rgb),.78);border-radius:999px;
  font-family:Archivo,sans-serif;font-weight:800;font-size:16px;color:var(--accent);cursor:pointer;
  box-shadow:var(--shadow-card);transition:transform .15s var(--ease-out),background .2s}
.qchip:active{transform:scale(.94);background:var(--chip-active-bg)}
/* council a11y fixes */
.state-chip.bg-pending,.state-chip.bg-excused{color:var(--ink)}
.mini-btn{width:44px;height:44px}
#ih-close{padding:12px;min-width:44px;min-height:44px}
/* Home order is fixed: your own card first, then the team (owner decision — solo is first-class) */
/* bank-btn is now an icon disc, not a width:100% block button — centre it
   instead of letting it default to flex-start */
.bank-zone{padding:14px 22px 0;display:flex;flex-direction:column;align-items:center;gap:9px}
/* matches .ob-nf-cap so the two disc+caption moments in the app read the same */
.bank-cap{font-family:"Instrument Sans",sans-serif;font-weight:600;font-size:12.5px;
  color:var(--muted);text-align:center;transition:color .15s}
.bank-zone:has(#bank-btn:not(:disabled)) .bank-cap{color:var(--ink)}

/* ledger — the "ledger" in fridge-ledger: banked sets as real, full-width ink
   tally marks (4 verticals + a diagonal 5th strike), not a 3px sliver hidden
   behind overflow:hidden (council: "throwing away the concept the app is
   named after"). The head row (reps/late/time) sits above the stamp strip so
   the strip is free to wrap under it on a big (100+) day without breaking
   the row or overflowing the card. */
.ledger{padding:18px 22px 0;position:relative}
.l-rows{display:flex;flex-direction:column;gap:10px}
.l-row{display:flex;flex-direction:column;gap:10px;background:rgba(var(--card-rgb),.78);border:1px solid var(--hair);padding:11px 13px;
  border-radius:var(--r-sm);box-shadow:var(--shadow-card);animation:rowIn .4s var(--ease-out) backwards}
.l-row:nth-child(1){animation-delay:.02s}.l-row:nth-child(2){animation-delay:.07s}.l-row:nth-child(3){animation-delay:.12s}.l-row:nth-child(4){animation-delay:.17s}
@keyframes rowIn{from{opacity:0;transform:translateY(8px)}to{opacity:1}}
.l-row:nth-child(odd){transform:rotate(-.35deg)}
.l-row:nth-child(even){transform:rotate(.35deg)}
.l-row-head{display:flex;align-items:center;gap:10px}
.l-row .reps{font-family:Archivo,sans-serif;font-weight:800;font-size:19px;font-variant-numeric:tabular-nums;min-width:34px}
.l-row .reps.neg{color:var(--missed)}
.l-row .t{font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums;white-space:nowrap;margin-left:auto}
.l-row .late{font-size:9px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--excused);border:1px solid var(--excused);padding:1px 5px}
.stamps{display:flex;gap:5px 6px;align-items:flex-end;width:100%;flex-wrap:wrap}
.stamp{width:4.5px;height:22px;background:var(--ink);opacity:.85;border-radius:1.5px;transform:rotate(-2deg);flex:none}
.stamp:nth-child(2n){transform:rotate(3deg)}
.stamp:nth-child(5n){margin-right:10px}
.stamp.five{width:30px;height:3.5px;transform:translateX(-13px) rotate(-58deg);margin-left:-28px;margin-right:10px;align-self:center}
/* thump: a fresh stamp snaps in the moment a new set lands — scale/rotate
   settle, staggered per-tick via an inline animation-delay set in app.js.
   Sets already on the ledger the first time a day is rendered never carry
   this class (seen-once guard in app.js keeps it a one-time entrance, not a
   replay on every re-render). */
@keyframes stampThump{0%{transform:scale(.3) rotate(-24deg);opacity:0}58%{transform:scale(1.2) rotate(5deg);opacity:1}100%{transform:scale(1) rotate(-1deg);opacity:1}}
.stamp.thump{animation:stampThump .36s var(--ease-out) backwards}
@keyframes stampThumpFive{0%{transform:scale(.3) translateX(-13px) rotate(-58deg);opacity:0}58%{transform:scale(1.25) translateX(-13px) rotate(-58deg);opacity:1}100%{transform:scale(1) translateX(-13px) rotate(-58deg);opacity:1}}
.stamp.five.thump{animation:stampThumpFive .36s var(--ease-out) backwards}
.l-empty{font-size:14px;color:var(--muted);padding:6px 0}

/* post-it */
.postit{font-family:Caveat,cursive;font-weight:600;font-size:18px;line-height:1.15;
  background:var(--postit);color:var(--postit-ink);padding:12px 14px 14px;width:170px;
  border-radius:2px;box-shadow:0 8px 20px rgba(var(--shadow-rgb),.14);animation:peelIn .5s var(--ease-out) .15s backwards;transform-origin:top center}
@keyframes peelIn{from{opacity:0;transform:rotate(-8deg) translateY(-14px) scale(.9)}to{opacity:1}}
.postit small{display:block;font-family:"Instrument Sans",sans-serif;font-weight:700;font-size:9px;letter-spacing:.1em;text-transform:uppercase;color:var(--postit-ink-soft);margin-bottom:4px}
#mate-postit-zone{display:flex;justify-content:flex-end;margin-top:10px}
#mate-postit-zone .postit{transform:rotate(3deg)}

.today-actions{display:flex;gap:10px;padding:16px 22px 12px;margin-top:auto}
.today-actions .btn{flex:1}

/* Section heading for the crew corkboard, now that Home and Crew are one
   screen and the strip above it carries its own "This week" title. Needs a
   padded wrapper of its own because .l-title carries no gutter — everywhere
   else it already sits inside a padded block (.ledger, .week-strip,
   .crew-invite), and as a bare child of the flex-column .screen it would
   render flush to the viewport edge, 22px out of line with the rest of the
   screen. Same 20px/22px step .home-crew used when this list lived on Home. */
.crew-head{padding:20px 22px 0}

/* crew — the corkboard/fridge-door (Wave 4 rebuild): avatars are the
   dominant visual per member card, a coloured ring on the avatar reads
   today's state at a glance (met/rest/excused/missed/pending, matching the
   day-state colours + shapes used everywhere else), and any excuse renders
   as a genuinely pinned post-it — real Caveat scale, not a tucked-away
   corner chip. Each card gets a top pin + a slight rotation jitter so the
   whole screen reads as tacked to a board, not a flat settings list. */
#crew-cards{display:flex;flex-direction:column;gap:18px;padding:14px 22px 0}
.crew-card{background:rgba(var(--card-rgb),.78);border:1px solid var(--hair);padding:18px;position:relative;
  border-radius:var(--r-lg);box-shadow:var(--shadow-card);animation:rowIn .45s var(--ease-out) backwards}
.crew-card:nth-child(odd){transform:rotate(-.6deg)}
.crew-card:nth-child(even){transform:rotate(.6deg)}
.crew-card:nth-child(2){animation-delay:.08s}.crew-card:nth-child(3){animation-delay:.16s}.crew-card:nth-child(4){animation-delay:.24s}
/* corner pin — reads as tacked to the board, echoes the post-it pin below */
.crew-card::before{content:"";position:absolute;top:-7px;left:28px;width:13px;height:13px;border-radius:50%;
  background:radial-gradient(circle at 32% 30%,rgba(255,255,255,.9),var(--ink) 65%);
  box-shadow:0 2px 4px rgba(var(--shadow-rgb),.4);z-index:2}
.crew-card .cc-top{display:flex;align-items:center;gap:14px}
.crew-card .avatar.cc-avatar{width:88px;height:88px;border-radius:50%;background:var(--grad);color:var(--on-accent);
  display:flex;align-items:center;justify-content:center;font-size:40px;flex:none;position:relative}
.crew-card .avatar.cc-avatar::after{content:"";position:absolute;inset:-4px;border-radius:50%;border:3px solid transparent;pointer-events:none}
.crew-card .avatar.cc-avatar.st-met::after{border-color:var(--met)}
.crew-card .avatar.cc-avatar.st-rest::after{border-color:var(--rest)}
.crew-card .avatar.cc-avatar.st-excused::after{border-color:var(--excused)}
.crew-card .avatar.cc-avatar.st-missed::after{border-color:var(--missed)}
.crew-card .avatar.cc-avatar.st-pending::after{border-color:var(--pending);border-style:dashed}
.cc-info{flex:1;min-width:0}
.cc-name-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.crew-card .nm{font-family:Archivo,sans-serif;font-weight:800;font-size:19px;letter-spacing:-.01em}
.crew-card .sub{font-size:12px;color:var(--muted);font-weight:600;margin-top:4px}
.crew-card .big{display:flex;align-items:baseline;gap:8px;margin-top:6px}
.crew-card .big .n{font-family:Archivo,sans-serif;font-weight:800;font-size:44px;letter-spacing:-.03em;font-variant-numeric:tabular-nums;line-height:1}
.crew-card .big .of{font-size:13px;color:var(--muted);font-weight:600}
.state-chip{font-family:"JetBrains Mono",ui-monospace,monospace;font-weight:500;font-size:9.5px;
  letter-spacing:.12em;text-transform:uppercase;color:var(--on-accent);padding:3px 8px;margin-left:auto}
.strip{display:flex;gap:6px;margin-top:14px}
.strip .cell{flex:1;height:9px}
.cc-postit{display:flex;justify-content:flex-end;margin-top:14px}
.crew-card .postit{position:static;width:auto;max-width:210px;font-size:20px;transform:rotate(2.5deg)}
.crew-invite{margin:18px 22px 0;padding:16px;border:1px dashed var(--hair)}
.crew-invite p{font-size:13.5px;color:var(--muted);margin-bottom:10px}

/* history */
.hist-controls{display:flex;justify-content:space-between;align-items:center;padding:10px 22px}
#hist-person{font-family:"Instrument Sans",sans-serif;font-weight:600;font-size:14px;padding:8px 10px;border:1px solid var(--hair);background:var(--card);color:var(--ink)}
.hist-nav{display:flex;align-items:center;gap:10px}
#hist-month{font-weight:700;font-size:14px;min-width:96px;text-align:center}
.hist-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:6px;padding:4px 22px}
.hist-grid .dow{font-size:10px;font-weight:700;color:var(--muted);text-align:center;text-transform:uppercase}
.hist-cell{aspect-ratio:1;background:rgba(var(--card-rgb),.78);border:1px solid var(--hair);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;font-size:11px;font-weight:600;color:var(--muted);cursor:pointer;
  border-radius:var(--r-sm);transition:transform .15s var(--ease-out),box-shadow .2s}
.hist-cell:active{transform:scale(.9)}
.hist-detail{border-radius:var(--r-lg);box-shadow:var(--shadow-card);animation:rowIn .35s var(--ease-out)}
.hist-cell .d{font-variant-numeric:tabular-nums}
.hist-cell .st{width:8px;height:8px;border-radius:4px}
.hist-cell.blank{visibility:hidden}
.hist-cell.future{opacity:.4;cursor:default}
.hist-detail{margin:12px 22px;padding:14px;background:rgba(var(--card-rgb),.78);border:1px solid var(--hair)}
.hist-detail .dd{font-family:Archivo,sans-serif;font-weight:800;font-size:17px;margin-bottom:6px}
.hist-detail .rows{font-size:13.5px;line-height:1.7}
.hist-detail .excuse-quote{font-family:Caveat,cursive;font-size:20px;color:var(--postit-ink);background:var(--postit);display:inline-block;padding:6px 10px;transform:rotate(-1.5deg);margin-top:6px}
.hist-add{margin-top:10px;display:flex;gap:8px}
.hist-add input{width:80px;padding:8px;border:1px solid var(--hair);font-family:Archivo,sans-serif;font-weight:700;font-size:16px;text-align:center;background:var(--card)}
.legend{display:flex;gap:12px;justify-content:center;padding:12px 22px;font-size:11px;color:var(--muted)}
.legend .dot{display:inline-block;width:8px;height:8px;border-radius:4px;margin-right:4px}
.dot.met{background:var(--met)}.dot.rest{background:var(--rest)}.dot.excused{background:var(--excused)}.dot.missed{background:var(--missed)}.dot.pending{background:var(--pending)}
/* colourblind-safe: each day-state gets a distinct SHAPE, not just a colour */
.ws-dot{width:9px;height:9px;border-radius:4px}
.hist-cell .st.bg-met,.legend .dot.met,.ws-dot.bg-met,.rt.bg-met{border-radius:50%}
.hist-cell .st.bg-rest,.legend .dot.rest,.ws-dot.bg-rest,.rt.bg-rest{border-radius:1.5px}
.hist-cell .st.bg-excused,.legend .dot.excused,.ws-dot.bg-excused,.rt.bg-excused{border-radius:1.5px;transform:rotate(45deg)}
.hist-cell .st.bg-missed,.legend .dot.missed,.ws-dot.bg-missed,.rt.bg-missed{width:11px;height:3px;border-radius:1.5px}
.hist-cell .st.bg-pending,.legend .dot.pending,.ws-dot.bg-pending,.rt.bg-pending{background:transparent;border:2px solid var(--pending);width:7px;height:7px}
.local-banner{display:flex;align-items:center;gap:8px;justify-content:space-between}
#lb-close{border:none;background:none;color:var(--rest);font-size:13px;cursor:pointer;padding:6px;min-width:32px}
.bg-met{background:var(--met)}.bg-rest{background:var(--rest)}.bg-excused{background:var(--excused)}.bg-missed{background:var(--missed)}.bg-pending{background:var(--pending)}

/* settings */
.set-card{margin:12px 22px 0;padding:16px;background:rgba(var(--card-rgb),.78);border:1px solid var(--hair);border-radius:var(--r-lg);box-shadow:var(--shadow-card)}
.set-card .lbl{display:flex;justify-content:space-between;align-items:center;text-transform:none;letter-spacing:0;font-size:14px;font-weight:600;color:var(--ink);margin:10px 0 0}
.set-card input{width:130px;padding:9px;border:1px solid var(--hair);background:var(--card);font-family:Archivo,sans-serif;font-weight:700;font-size:15px;text-align:center;color:var(--ink)}
.set-card input[type=date],#set-crewname,#set-name{font-family:"Instrument Sans",sans-serif;font-weight:600;text-align:left}
.set-card .avatars,.set-card .color-dots{margin-top:12px}
#affirm-list{width:100%;margin-top:10px;padding:11px;border:1px solid var(--hair);border-radius:var(--r-sm);
  background:rgba(var(--card-rgb),.6);color:var(--ink);font-family:"Instrument Sans",sans-serif;
  font-size:14px;line-height:1.55;resize:vertical}
.affirm-actions{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:12px}
#affirm-count.over{color:var(--missed);font-weight:700}
/* set-profile-save/set-save are now icon discs, not width:100% block
   buttons — .set-action-row lays each one out beside its feedback hint
   (#set-profile-msg / #set-msg) instead of stacking hint below a full-width
   button. Two contexts need two margins: inside .set-card the card's own
   16px padding already provides the horizontal inset; outside it (the
   set-save row sits directly in #screen-settings) the row needs its own
   horizontal margin to match the screen's other content. */
.set-action-row{display:flex;align-items:center;gap:14px}
.set-action-row .hint{margin-top:0}
.set-card .set-action-row{margin-top:16px}
#screen-settings>.set-action-row{margin:16px 22px 0}
#set-switch{margin:10px 22px 0}
.danger-zone{margin-top:28px;border-color:rgba(178,58,46,.35)}
.danger-zone .l-title{color:var(--missed)}
.danger-zone .hint{margin-top:4px}
.btn.danger{width:100%;margin-top:10px;color:var(--missed);border-color:rgba(178,58,46,.4)}
.btn.danger:active{background:rgba(178,58,46,.08)}
#sim-date-card #set-sim-clear,#state-dump-card #state-dump-refresh{width:100%;margin-top:10px}
.state-dump-pre{margin-top:10px;padding:10px;max-height:260px;overflow:auto;background:var(--paper);border:1px solid var(--hair);border-radius:var(--r-sm);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;line-height:1.5;color:var(--ink);white-space:pre-wrap;word-break:break-word}

/* tabbar */
/* floating tab pill: fixed dock, blurred paper glass, active tab glows teal->pine */
.tabbar{position:fixed;left:50%;transform:translateX(-50%);bottom:calc(14px + env(safe-area-inset-bottom));z-index:40;
  display:flex;gap:2px;align-items:flex-end;padding:8px 10px;border:1px solid var(--hair);border-radius:999px;
  background:var(--tabbar-bg);-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px);
  box-shadow:0 10px 34px rgba(var(--shadow-rgb),.16)}
.tab{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;width:56px;padding:7px 0;border-radius:999px;
  transition:color .25s,background .25s,box-shadow .25s,transform .28s var(--ease-out)}
.tab svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
.tab.on{color:var(--on-accent);background:var(--grad);transform:translateY(-2px) scale(1.06);
  box-shadow:0 6px 16px rgba(15,122,109,.32),0 0 0 2px #1BC79E,0 0 7px 1px rgba(27,199,158,.75)}
.tab.on svg{animation:tabPop .42s var(--ease-out)}
.tab:active{transform:scale(.9)}
@keyframes tabPop{0%{transform:scale(.7)}55%{transform:scale(1.25)}100%{transform:scale(1)}}
.screen{padding-bottom:calc(124px + env(safe-area-inset-bottom))}
body.ih-open .screen{padding-bottom:calc(224px + env(safe-area-inset-bottom))}
.tab{flex:1;text-align:center;font-family:"Instrument Sans",sans-serif;font-size:11px;font-weight:700;color:var(--muted);border:none;background:none;cursor:pointer;letter-spacing:.02em}

/* install hint */
.install-hint{position:fixed;left:12px;right:12px;bottom:calc(84px + env(safe-area-inset-bottom));z-index:45;
  max-width:406px;margin:0 auto;display:flex;align-items:center;gap:10px;
  background:rgba(var(--card-rgb),.55);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);
  color:var(--ink);padding:14px 14px 14px 16px;box-shadow:0 6px 24px rgba(var(--shadow-rgb),.25)}
.install-hint .ih-body{display:flex;flex-direction:column;gap:3px;font-size:13.5px;line-height:1.35}
.install-hint .ih-body>b{font-family:Archivo,sans-serif;font-weight:800;font-size:15px;letter-spacing:.01em}
/* real iOS share-sheet glyph (box + upward arrow), inline SVG — replaces the
   old rotated ⎋ (Unicode ESCAPE key) fake, which never actually looked like
   Apple's icon. Stroke weight matches the tab-bar/menu icon line style. */
.install-hint .ih-icon{display:inline-flex;vertical-align:-3px;width:15px;height:15px;flex:none}
.install-hint .ih-icon svg{width:100%;height:100%;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
.install-hint b{color:var(--accent)}
#ih-close{margin-left:auto;border:none;background:transparent;color:var(--ink);font-size:16px;cursor:pointer;padding:6px}

/* knot shimmer: the live-streak knot pulses volt (native brand motion, not a spark effect) */
.knot.volt{animation:knotShimmer 2.6s ease-in-out infinite}
@keyframes knotShimmer{50%{box-shadow:0 0 0 5px rgba(199,244,100,.45)}}

/* animated avatar art — bold, full-shape motion (Wave 7 amplification pass).
   Convention: `.av svg .aa-x{...}` (never bare `[class^="aa-"]`) whenever a
   rule needs a CUSTOM transform-origin — the bare-selector specificity trap
   is documented in HANDOVER.md. Bare `.aa-x{animation:...}` is fine when the
   element's own fill-box center (the generic fallback below) is the right
   pivot. Everything here dies under prefers-reduced-motion (top of file). */
.av{display:inline-flex;width:100%;height:100%;align-items:center;justify-content:center}
.av svg{width:78%;height:78%;fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;stroke-linejoin:round}
.av svg [class^="aa-"]{transform-box:fill-box;transform-origin:center}
.av svg .aa-pump{transform-box:view-box;transform-origin:9px 33px;animation:aaPump 1.2s ease-in-out infinite}
@keyframes aaPump{0%,100%{transform:rotate(0deg)}50%{transform:rotate(16deg)}}
/* forearm pivots at the drawn elbow (26,24) so it never detaches from the upper arm */
.av svg .aa-flex{transform-box:view-box;transform-origin:26px 24px;animation:aaFlex 1.5s ease-in-out infinite}
@keyframes aaFlex{50%{transform:rotate(-32deg)}}
/* grit + beast (Wave 7): the old 1.4px brow nudge was imperceptible at 88px.
   Brow now furrows hard; grit additionally head-shakes with gritted teeth,
   beast head-bobs with wiggling horns and an opening-mouth roar. */
.aa-brow{animation:aaBrow 1.6s ease-in-out infinite}
@keyframes aaBrow{0%,100%{transform:translateY(0) scaleY(1)}50%{transform:translateY(3px) scaleY(1.2)}}
.aa-sweat{animation:aaSweat 2s ease-in infinite}
@keyframes aaSweat{0%{transform:translateY(0);opacity:0}25%{opacity:1}100%{transform:translateY(11px);opacity:0}}
.aa-bolt{fill:currentColor;fill-opacity:.15;animation:aaPulse 1.2s ease-in-out infinite}
@keyframes aaPulse{0%,100%{transform:scale(1);fill-opacity:.15}50%{transform:scale(1.22);fill-opacity:.45}}
.aa-gritShake{animation:aaGritShake 1.6s ease-in-out infinite}
@keyframes aaGritShake{0%,100%{transform:translateX(0) rotate(0deg)}20%{transform:translateX(-1.8px) rotate(-2deg)}40%{transform:translateX(1.6px) rotate(2deg)}60%{transform:translateX(-1.3px) rotate(-1.2deg)}80%{transform:translateX(1px) rotate(1deg)}}
.aa-beastShake{animation:aaBeastShake 1.4s ease-in-out infinite}
@keyframes aaBeastShake{0%,100%{transform:translateY(0) rotate(0deg) scale(1)}25%{transform:translateY(-1.6px) rotate(-2.5deg) scale(1.03)}50%{transform:translateY(.5px) rotate(2.5deg)}75%{transform:translateY(-1px) rotate(-1.2deg)}}
.av svg .aa-hornL{transform-box:view-box;transform-origin:17px 11px;animation:aaHornL 1.4s ease-in-out infinite}
@keyframes aaHornL{0%,100%{transform:rotate(0deg)}50%{transform:rotate(-12deg)}}
.av svg .aa-hornR{transform-box:view-box;transform-origin:31px 11px;animation:aaHornR 1.4s ease-in-out infinite}
@keyframes aaHornR{0%,100%{transform:rotate(0deg)}50%{transform:rotate(12deg)}}
.aa-roar{animation:aaRoar 1.4s ease-in-out infinite}
@keyframes aaRoar{0%,100%{transform:scaleY(1)}50%{transform:scaleY(1.7)}}
/* star jump: pencil pose at rest, limbs snap out to the star at the jump's apex */
.av svg .aa-sprBody{animation:aaJump 1.1s ease-in-out infinite}
@keyframes aaJump{50%{transform:translateY(-3.5px)}}
.av svg .aa-sprArmL{transform-box:view-box;transform-origin:24px 18px;animation:aaSprAL 1.1s ease-in-out infinite}
@keyframes aaSprAL{0%,100%{transform:rotate(-124deg)}50%{transform:rotate(0deg)}}
.av svg .aa-sprArmR{transform-box:view-box;transform-origin:24px 18px;animation:aaSprAR 1.1s ease-in-out infinite}
@keyframes aaSprAR{0%,100%{transform:rotate(124deg)}50%{transform:rotate(0deg)}}
.av svg .aa-sprLegL{transform-box:view-box;transform-origin:24px 27px;animation:aaSprLL 1.1s ease-in-out infinite}
@keyframes aaSprLL{0%,100%{transform:rotate(-34deg)}50%{transform:rotate(0deg)}}
.av svg .aa-sprLegR{transform-box:view-box;transform-origin:24px 27px;animation:aaSprLR 1.1s ease-in-out infinite}
@keyframes aaSprLR{0%,100%{transform:rotate(34deg)}50%{transform:rotate(0deg)}}
/* zen (Wave 7 rework): whole seated figure genuinely levitates off its base
   with a visible breath swell; the ground shadow squashes/fades in the
   opposite phase to sell the float — the old torso-only 1.6px nudge read
   as static at 88px. */
.av svg .aa-zenBody{transform-box:view-box;transform-origin:24px 20px;animation:aaZenFloat 3.2s ease-in-out infinite}
@keyframes aaZenFloat{0%,100%{transform:translateY(0) scale(1)}50%{transform:translateY(-5px) scale(1.06)}}
.aa-zenShadow{animation:aaZenShadow 3.2s ease-in-out infinite}
@keyframes aaZenShadow{0%,100%{transform:scaleX(1);opacity:.35}50%{transform:scaleX(.6);opacity:.15}}
/* bell (Wave 7 rework): the whole housing rocks hard, an inner clapper swings
   independently (layered secondary motion), sound-lines pop at the corners
   on each ring — the old ±9deg rock alone read as subtle. */
.av svg .aa-rock{transform-box:view-box;transform-origin:24px 33px;animation:aaRock 1.5s ease-in-out infinite}
@keyframes aaRock{0%,100%{transform:rotate(-16deg)}50%{transform:rotate(16deg)}}
.av svg .aa-clapper{transform-box:view-box;transform-origin:24px 21px;animation:aaClapper .75s ease-in-out infinite}
@keyframes aaClapper{0%,100%{transform:rotate(20deg)}50%{transform:rotate(-20deg)}}
.aa-ringL,.aa-ringR{animation:aaRing 1.5s ease-in-out infinite}
@keyframes aaRing{0%,15%{opacity:0;transform:scale(.6)}30%{opacity:1;transform:scale(1.25)}50%,100%{opacity:0;transform:scale(.6)}}
/* flame (Wave 7 rework): the outer tongue flickers with squash/skew, two side
   tongues lick at offset phases (separate paths, desynced timing), and the
   inner core pulses independently — reads as a living fire, not a single
   subtle sway. */
.av svg .aa-flick{transform-box:view-box;transform-origin:24px 30px;animation:aaFlick 1.5s ease-in-out infinite}
@keyframes aaFlick{0%,100%{transform:scaleY(1) skewX(0deg) scaleX(1)}30%{transform:scaleY(1.2) skewX(4deg) scaleX(.92)}60%{transform:scaleY(.86) skewX(-5deg) scaleX(1.08)}80%{transform:scaleY(1.1) skewX(2deg) scaleX(.96)}}
.av svg .aa-tongueL{transform-box:view-box;transform-origin:17px 29px;animation:aaTongueL 1.8s ease-in-out infinite}
@keyframes aaTongueL{0%,100%{transform:scaleY(.8) rotate(-5deg);opacity:.45}50%{transform:scaleY(1.3) rotate(7deg);opacity:.85}}
.av svg .aa-tongueR{transform-box:view-box;transform-origin:31px 29px;animation:aaTongueR 2s ease-in-out infinite;animation-delay:-.7s}
@keyframes aaTongueR{0%,100%{transform:scaleY(.8) rotate(5deg);opacity:.45}50%{transform:scaleY(1.3) rotate(-7deg);opacity:.85}}
.aa-core{animation:aaCore 1s ease-in-out infinite}
@keyframes aaCore{0%,100%{transform:scale(1);opacity:.75}50%{transform:scale(1.3);opacity:1}}
.aa-twinkle{animation:aaTwinkle 1.8s ease-in-out infinite}
@keyframes aaTwinkle{0%,100%{transform:rotate(0deg) scale(1)}50%{transform:rotate(18deg) scale(1.22)}}
.aa-glintA{animation:aaGlint 1.8s ease-in-out infinite}
.aa-glintB{animation:aaGlint 1.8s ease-in-out infinite;animation-delay:-.9s}
@keyframes aaGlint{0%,40%{opacity:0;transform:scale(.5)}55%{opacity:1;transform:scale(1.2)}70%,100%{opacity:0;transform:scale(.5)}}
/* peak (Wave 7 rework): the flag genuinely snaps in a gust (two overlapping
   layers, desynced, fake a real ripple) and the pole flexes at its base —
   the old single skewY sway was nearly static. */
.av svg .aa-pole{transform-box:view-box;transform-origin:20px 14px;animation:aaPoleFlex 2.2s ease-in-out infinite}
@keyframes aaPoleFlex{0%,100%{transform:rotate(0deg)}50%{transform:rotate(5deg)}}
.av svg .aa-flagA{transform-box:view-box;transform-origin:20px 9px;animation:aaFlagRipple 1.1s ease-in-out infinite}
.av svg .aa-flagB{transform-box:view-box;transform-origin:20px 9px;animation:aaFlagRipple 1.1s ease-in-out infinite;animation-delay:-.35s}
@keyframes aaFlagRipple{0%,100%{transform:skewY(0deg) scaleX(1)}25%{transform:skewY(16deg) scaleX(.82)}50%{transform:skewY(-5deg) scaleX(1.1)}75%{transform:skewY(11deg) scaleX(.9)}}
/* runner: legs scissor alternately at the hip (22,26); torso stays put */
.av svg .aa-runLegF{transform-box:view-box;transform-origin:22px 26px;animation:aaRunLeg .9s ease-in-out infinite}
.av svg .aa-runLegB{transform-box:view-box;transform-origin:22px 26px;animation:aaRunLeg .9s ease-in-out infinite;animation-delay:-.45s}
@keyframes aaRunLeg{0%,100%{transform:rotate(-16deg)}50%{transform:rotate(16deg)}}
/* crown: seesaw about its base midpoint, baseline stays level */
.av svg .aa-seesaw{transform-box:view-box;transform-origin:24px 34px;animation:aaSeesaw 1.8s ease-in-out infinite}
@keyframes aaSeesaw{0%,100%{transform:rotate(-8deg)}50%{transform:rotate(8deg)}}
/* wave: paths extend one 14-unit wavelength past the viewBox so a linear
   14px translate loops seamlessly; the two lines run in opposite directions */
.av svg .aa-slide{animation:aaSlide 2.4s linear infinite}
.av svg .aa-slide2{animation:aaSlide 3s linear infinite reverse}
@keyframes aaSlide{to{transform:translateX(-14px)}}

/* ---- Wave 7: 6 new non-fitness avatars (additive; existing 14 ids/order untouched) ---- */
/* rocket: whole body rumbles on the pad, exhaust stretches, twin puffs billow */
.aa-rocketBody{animation:aaRocketRumble 1.7s ease-in-out infinite}
@keyframes aaRocketRumble{0%,100%{transform:translate(0,0) rotate(0deg)}20%{transform:translate(-1px,.5px) rotate(-2deg)}40%{transform:translate(1px,-.5px) rotate(2deg)}60%{transform:translate(-.8px,.5px) rotate(-1.5deg)}80%{transform:translate(.6px,0) rotate(1deg)}}
.av svg .aa-flameMain{transform-box:view-box;transform-origin:24px 29px;animation:aaFlameMain 1.7s ease-in-out infinite}
@keyframes aaFlameMain{0%,100%{transform:scaleY(1) scaleX(1)}50%{transform:scaleY(1.45) scaleX(.78)}}
.aa-puffL{animation:aaPuff 1.7s ease-in-out infinite}
.aa-puffR{animation:aaPuff 1.7s ease-in-out infinite;animation-delay:-.55s}
@keyframes aaPuff{0%,100%{opacity:0;transform:translateY(0) scale(.5)}40%{opacity:.7;transform:translateY(4px) scale(1)}80%{opacity:0;transform:translateY(9px) scale(1.3)}}
/* paw: gentle stamp — presses down and springs back, like a fresh print landing */
.av svg .aa-pawStamp{transform-box:fill-box;transform-origin:center;animation:aaPawStamp 1.6s ease-in-out infinite}
@keyframes aaPawStamp{0%,60%,100%{transform:scale(1)}75%{transform:scale(.88)}88%{transform:scale(1.04)}}
/* robot: antenna bobs on its spring (ball travels with it in the same group), eyes blink on a slower cadence */
.av svg .aa-antenna{transform-box:view-box;transform-origin:24px 14px;animation:aaAntenna 1.6s ease-in-out infinite}
@keyframes aaAntenna{0%,100%{transform:rotate(0deg)}25%{transform:rotate(-14deg)}55%{transform:rotate(10deg)}80%{transform:rotate(-5deg)}}
.aa-blink{animation:aaBlink 2.6s ease-in-out infinite}
@keyframes aaBlink{0%,92%,100%{transform:scaleY(1)}96%{transform:scaleY(.15)}}
/* coffee: two steam wisps curl and rise, fading out, offset phase */
.av svg .aa-steamA{transform-box:view-box;transform-origin:18px 20px;animation:aaSteam 2.4s ease-in-out infinite}
.av svg .aa-steamB{transform-box:view-box;transform-origin:28px 20px;animation:aaSteam 2.4s ease-in-out infinite;animation-delay:-1.2s}
@keyframes aaSteam{0%{transform:translateY(3px) scaleY(.7);opacity:0}30%{opacity:.85}70%{transform:translateY(-6px) scaleY(1.15);opacity:.5}100%{transform:translateY(-10px) scaleY(1.3);opacity:0}}
/* controller: one action button pulses to suggest a button press */
.aa-ctrlBtn{animation:aaCtrlPulse 1.3s ease-in-out infinite}
@keyframes aaCtrlPulse{0%,100%{transform:scale(1)}50%{transform:scale(1.35)}}
/* headphones: three EQ bars bounce at different rates, unsynced like a real meter */
.av svg .aa-eqA{transform-box:view-box;transform-origin:20px 34px;animation:aaEq .7s ease-in-out infinite}
.av svg .aa-eqB{transform-box:view-box;transform-origin:24px 36px;animation:aaEq .55s ease-in-out infinite;animation-delay:-.2s}
.av svg .aa-eqC{transform-box:view-box;transform-origin:28px 34px;animation:aaEq .85s ease-in-out infinite;animation-delay:-.4s}
@keyframes aaEq{0%,100%{transform:scaleY(.4)}50%{transform:scaleY(1.3)}}
/* last rung: the spoon stands on its handle and rocks — slow and a bit sorry
   for itself, deliberately the least energetic motion in the set. Pivot is the
   handle's foot (24,42), so `.av svg .aa-x` per the convention above. */
/* ---- achievement marks (owner-approved 2026-08-31) ----
   Supersedes aa-spoonWobble, which styled the retired outlined spoon. The new
   mark is solid with the bowl at the BOTTOM and pivots on its ground contact,
   so the old 24px 42px origin would have swung it about its own middle. */
/* ---- achievement marks: motion (2026-09-06) ----
   Six marks read as static in the gallery — three had no animation at all and
   three were too faint to see. Every custom pivot below uses the
   `.av svg .aa-x` form, NOT a bare `.aa-x{}`: the shipped rule
   `.av svg [class^="aa-"]` sets transform-box:fill-box + origin:center at
   (0,2,1) specificity and silently overrides a bare class, which is the trap
   already documented against .aa-pump and the avatar fixes. */

/* a rosette hangs from its pin, so it swings from the TOP, not its middle */
.av svg .aa-rosetteSwing{transform-box:view-box;transform-origin:24px 5.3px;animation:aaRosetteSwing 3.4s ease-in-out infinite}
@keyframes aaRosetteSwing{0%,100%{transform:rotate(-4deg)}50%{transform:rotate(4deg)}}

/* the climber swings from the BAR. Pivot on the bar, not the figure, or he
   pinwheels about his own navel instead of hanging from his hands. */
.av svg .aa-hangSwing{transform-box:view-box;transform-origin:24px 9px;animation:aaHangSwing 3.2s ease-in-out infinite}
@keyframes aaHangSwing{0%,100%{transform:rotate(-6deg)}50%{transform:rotate(6deg)}}

/* the sloop heels; the waterline is OUTSIDE this group so the sea stays level */
.av svg .aa-boatHeel{transform-box:view-box;transform-origin:24px 43px;animation:aaBoatHeel 4.2s ease-in-out infinite}
@keyframes aaBoatHeel{0%,100%{transform:rotate(-5.5deg)}50%{transform:rotate(5.5deg)}}

/* the bird cocks its head (owner, 2026-09-06). Pivot is the NECK, at the bottom
   of the skull, so the beak swings and the crown stays put — pivoting at the
   centre would slide the whole head sideways. The worm group is nested inside
   this one, so the worm travels with the beak tip and keeps its own wiggle on
   top; animating them as siblings detaches the worm from the beak. */
.av svg .aa-birdPeck{transform-box:view-box;transform-origin:20px 35px;animation:aaBirdPeck 2.8s ease-in-out infinite}
@keyframes aaBirdPeck{0%,100%{transform:rotate(-6deg)}45%{transform:rotate(5deg)}60%{transform:rotate(3deg)}}

/* two staggered drips. One 1.8px dot was invisible at 44px. */
.av svg .aa-kegDripB{transform-box:view-box;transform-origin:44px 30px;animation:aaKegDrip 2.6s ease-in infinite;animation-delay:-1.3s}

/* the eagle settles on its feet — a weight shift, not a flap. Pivot is the
   talon, so it rocks on the foot rather than about its own middle. */
.av svg .aa-eagleSettle{transform-box:view-box;transform-origin:23px 42px;animation:aaEagleSettle 3.2s ease-in-out infinite}
@keyframes aaEagleSettle{0%,100%{transform:rotate(-5deg)}50%{transform:rotate(5deg)}}

.av svg .aa-spoonRock{transform-box:view-box;transform-origin:24px 44px;animation:aaSpoonRock 3.4s ease-in-out infinite}
@keyframes aaSpoonRock{0%,100%{transform:rotate(-5deg)}50%{transform:rotate(5.5deg)}}

.av svg .aa-wormWiggle{transform-box:view-box;transform-origin:44px 25px;animation:aaWormWiggle 1.1s ease-in-out infinite}
@keyframes aaWormWiggle{0%,100%{transform:rotate(-8deg)}50%{transform:rotate(8deg)}}


/* the breath (owner, 2026-08-31). Only the LOBES scale — a trachea that
   inflates is wrong, and scaling it would shimmer the ribbed slots. Origin is
   the bronchial split, so the lobes fill from the stem the way lungs do. */
.av svg .aa-lungBreath{transform-box:view-box;transform-origin:24px 16px;animation:aaLungBreath 4s ease-in-out infinite}
@keyframes aaLungBreath{0%,100%{transform:scale(1)}45%{transform:scale(1.09,1.12)}}


.aa-kegDrip{animation:aaKegDrip 2.6s ease-in infinite}
@keyframes aaKegDrip{0%{transform:translateY(-2px);opacity:0}20%{opacity:1}75%{opacity:1}100%{transform:translateY(13px);opacity:0}}
.av-emoji{font-size:inherit}
.avatars .av{color:var(--ink)}
.avatars button.sel .av{color:var(--on-accent)}
.color-dots{display:flex;gap:10px;margin-top:12px}
.color-dots button{width:34px;height:34px;border-radius:50%;border:2px solid transparent;cursor:pointer;
  box-shadow:0 0 0 1px var(--hair);transition:transform .15s var(--ease-out)}
.color-dots button.sel{border-color:var(--on-accent);box-shadow:0 0 0 2.5px var(--ink);transform:scale(1.12)}

/* onboarding entrance choreography (animation only) */
#onboarding .ob-inner>*{animation:fadeUp .55s var(--ease-out) backwards}
#onboarding .ob-inner>*:nth-child(1){animation-delay:1.94s}
#onboarding .ob-inner>*:nth-child(2){animation-delay:2.05s}
#onboarding .ob-inner>*:nth-child(3){animation-delay:.27s}
#onboarding .ob-inner>*:nth-child(4){animation-delay:.38s}
#onboarding .ob-inner>*:nth-child(5){animation-delay:.49s}
@keyframes fadeUp{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
/* step-to-step transition (crew-code -> profile), separate from the initial
   ob-inner fadeUp stagger above */
@keyframes obStepIn{from{opacity:0;transform:translateX(26px)}to{opacity:1;transform:translateX(0)}}
.ob-step-in{animation:obStepIn .4s var(--ease-out)}
.ob-existing button,.avatars button{animation:fadeUp .45s var(--ease-out) backwards}
.avatars button:nth-child(1){animation-delay:.05s}.avatars button:nth-child(2){animation-delay:.1s}
.avatars button:nth-child(3){animation-delay:.15s}.avatars button:nth-child(4){animation-delay:.2s}
.avatars button:nth-child(5){animation-delay:.25s}.avatars button:nth-child(6){animation-delay:.3s}
.avatars button:nth-child(7){animation-delay:.35s}.avatars button:nth-child(8){animation-delay:.4s}

/* overlays / onboarding / modal */
.overlay{position:fixed;inset:0;background:var(--paper);z-index:50;display:flex;align-items:center;justify-content:center;padding:24px;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)' opacity='0.035'/%3E%3C/svg%3E")}
#excuse-modal{background:rgba(var(--shadow-rgb),.45)}
#confirm-modal{background:rgba(var(--shadow-rgb),.45)}
.ob-inner{width:100%;max-width:340px}

/* ============ LAUNCH SCREEN — the Ascent lockup over the peak ============
   Scoped entirely to #onboarding. Everything behind this door is Rope & Rung.
   Column, not the .overlay centring: lockup at the head, the live step
   optically centred (owner: "the code needs to sit centre of the screen"),
   studio signature at the foot. flex:1 on .ob-inner does the centring, which
   survives the step swapping between the short code field and the much taller
   profile picker — an absolutely-positioned block would not. */
#onboarding{--ob-title:clamp(54px,15vw,74px);
  flex-direction:column;align-items:center;justify-content:flex-start;
  padding:7vh 24px calc(26px + env(safe-area-inset-bottom))}
#onboarding .ob-inner{flex:none;margin-top:5vh;position:relative;z-index:2}
/* the old 15vh lift is what the flex centring now does properly.
   The code step's own vertical placement is set further down, against the
   summit — see the #ob-step-code rule under the launch-screen block. */


/* ---- the art: the route draws, and the painting arrives behind it ----
   Sized in vh so the summit lands at 52vh on every phone regardless of width. */
.ob-art{position:absolute;inset:0;z-index:0}

.ob-peak{position:absolute;left:50%;bottom:-10vh;transform:translateX(-50%);
  height:58vh;width:auto;aspect-ratio:900/805;z-index:1;pointer-events:none}
.ob-peak-ink{position:absolute;inset:0;background:var(--peak-ink);
  -webkit-mask:url("assets/peak.png") no-repeat center/contain;
  mask:url("assets/peak.png") no-repeat center/contain;
  animation:obPeakReveal 1400ms linear 200ms both}
/* The reveal edge tracks the route's head: 16.9% when the line starts, 59.6%
   when it tops out, then the far flank fills in behind it. */
@keyframes obPeakReveal{from{clip-path:inset(100% 0 0 0)}to{clip-path:inset(0 0 0 0)}}

/* overflow:visible is load-bearing, and a stray closing brace above this rule
   used to eat it. A stray closing brace at the top level is not discarded on
   its own: the parser takes it as the start of the next rule's prelude, so it
   and the .ob-trail selector merged into one invalid selector and this whole
   declaration block was dropped. The svg still LOOKED right — its default
   width:100% plus the viewBox aspect happen to land on the same box that
   .ob-peak's aspect-ratio gives it — so the only symptom was the UA default
   overflow:hidden coming back and slicing the summit lantern flat across its
   top edge. Braces are deliberately spelled out rather than written literally
   in this comment: a plain brace count is the obvious way to hunt the next
   stray one, and comment braces are what make that count lie. */
.ob-trail{position:absolute;inset:0;width:100%;height:100%;overflow:visible}
/* the route, styled as a map track rather than a drawn line */
/* Launch trail inverts against the peak, which is at FULL strength on that
   screen — measured: ink holds 3.28:1 on the solid teal mountain and 15.5:1 on
   the paper sky; teal holds 4.73:1 on the solid paper mountain and 3.29:1 on
   the pine sky. Those are the only two palette colours that clear 3:1 on BOTH
   the mountain and the sky in their own theme. --peak-ink was almost the same
   value as the mountain it was drawn on, which is why it disappeared. */
/* Bolder, and lifted off the painting with a drop shadow instead of a second
   stroke underneath — the owner wanted the white casing line gone but the dash
   to still pop. A shadow does that without adding a visible second line. */
.ob-route{fill:none;stroke:var(--trail-launch);stroke-width:7.5;stroke-linecap:butt;
  stroke-dasharray:26 17;stroke-linejoin:round;opacity:.95;
  filter:drop-shadow(0 1.5px 2.5px rgba(0,0,0,.5));vector-effect:non-scaling-stroke;
  /* The route no longer fades. It was disappearing a beat after it drew, which
     made it impossible to see where the drawn line sits against the painted
     ridge underneath — the exact thing that needs judging. It stays. */
  }
.ob-route-wipe{stroke-dasharray:100;stroke-dashoffset:100;
  animation:obRouteDraw 3400ms cubic-bezier(.33,0,.24,1) 1650ms forwards}
@keyframes obRouteDraw{to{stroke-dashoffset:0}}






.ob-lockup{position:relative;z-index:2;text-align:center;flex:none}
/* --ob-title lives on #onboarding, not here, because #ob-step-code's placement
   has to do arithmetic on it — the title is the one thing above the code field
   whose height moves with viewport WIDTH, so a step positioned against the
   summit drifts on wide phones unless it accounts for this exact value. */
.ob-ascent{font-family:"Bebas Neue",Archivo,sans-serif;font-weight:400;
  font-size:var(--ob-title);line-height:.86;letter-spacing:.13em;
  /* Bebas tracks from the RIGHT of each glyph, so heavy tracking pushes the
     word left of centre; the matching indent puts it back. */
  text-indent:.13em;text-transform:uppercase;color:var(--ink);margin:0}
.ob-rule{width:132px;height:2px;background:var(--accent);opacity:.85;margin:16px auto 0;
  transform-origin:50% 50%}
.ob-product{font-family:"JetBrains Mono",ui-monospace,monospace;font-weight:500;
  font-size:14px;letter-spacing:.22em;text-transform:uppercase;
  color:var(--muted);margin:18px 0 0}

.ob-studio{position:absolute;left:0;right:0;bottom:calc(22px + env(safe-area-inset-bottom));
  z-index:2;text-align:center;pointer-events:none;
  font-family:"JetBrains Mono",ui-monospace,monospace;font-weight:500;
  font-size:10.5px;letter-spacing:.34em;text-transform:uppercase;
  color:var(--studio-ink);opacity:.72}

/* Entrance: the range resolves, then the door. The peak rises last-but-slowest
   so the type lands on a settling image rather than a static one. */
@keyframes ruleDraw{from{opacity:0;transform:scaleX(0)}to{opacity:1;transform:scaleX(1)}}
.ob-lockup>*{animation:fadeUp .64s var(--ease-out) both}
.ob-ascent{animation-delay:1.50s}
.ob-rule{animation:ruleDraw .7s var(--ease-out) 1.66s both}
.ob-product{animation-delay:1.78s}
.ob-tag{animation:fadeUp .64s var(--ease-out) 2.80s both}
.ob-studio{animation:fadeUp .64s var(--ease-out) 2.02s both}
/* The code step is placed AGAINST THE SUMMIT, not against the top of the
   screen — owner: the enter disc should read like a flag planted on the peak,
   standing about 2cm clear of it. Because the peak is sized in vh, a fixed
   offset would only be right on one phone; so the padding is derived from
   where the summit actually lands and every term is a measured quantity:

     --summit      52.5vh, where .ob-peak's summit sits (58vh tall, bottom -10vh)
     --flagpole    the owner's 2cm of daylight between disc and peak
     --above       what stands above the step in flow: #onboarding's 7vh top
                   padding + .ob-inner's 5vh margin, then the lockup. The
                   lockup is NOT a constant: .ob-ascent is line-height .86 on
                   --ob-title, which is 15vw between its clamps, so it grows
                   with viewport width. 68px is the fixed remainder (rule,
                   product line, tag). Treating the whole lockup as a constant
                   put the disc 7px low at 430 wide while reading perfectly at
                   375 — the error only shows up if you change WIDTH, which is
                   why it survived the first check.
     --disc-centre centre of the enter disc, measured down from the step's top

   Verified at 375x667, 375x812, 390x844, 430x932: the disc lands within a
   pixel of summit - 2cm on all four. max() so a very short viewport clamps to
   0 rather than dragging the field up under the wordmark.

   Padding on the CODE step, not margin on .ob-inner, so the placement is
   scoped automatically: the step is display:none on the profile step, whose
   much taller content keeps the original centring. Applying it to .ob-inner
   instead pushed the profile step's content past the overlay's top padding
   (measured innerTop 12 vs 24px padding). Padding, not margin, so it cannot
   margin-collapse out. */
#ob-step-code{
  --summit:52.5vh;
  --flagpole:2cm;
  --above:calc(12vh + 68px + .86 * var(--ob-title));
  --disc-centre:47px;
  padding-top:max(0px,calc(var(--summit) - var(--flagpole)
                           - var(--above) - var(--disc-centre)));
  padding-bottom:0}


/* long-form description, demoted from the top of .ob-inner (owner: keep the
   short .ob-tag up near the wordmark, make the longer copy "a little floating
   tab under all that") to a frosted "tab" card at the end of the column.
   IN-FLOW (not position:absolute) — an earlier absolute/bottom-pinned version
   overlapped the Tie-in button's caption on the profile step (lead-verified:
   caption bottom=718px vs tab top=689px), because that step's content (name
   input + 20 avatars + 6 colours + start button) is tall enough to reach the
   pin point. As the last child of .ob-inner it flows below whichever step is
   active, so it can never overlap the start button or the crew-code input.
   Keeps the same frosted-glass recipe as .ob-notfound / the crew-code input
   (rgba card tint + blur(10px)) plus --shadow-card so it still reads as a
   distinct floating card, not a bare paragraph, even though it's in-flow. */
.ob-fine-tab{margin:22px auto 8px;max-width:100%;
  background:rgba(var(--card-rgb),.42);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);
  border-radius:var(--r-md);padding:14px;box-shadow:var(--shadow-card);
  font-size:12.5px;color:var(--muted);line-height:1.5;text-align:center}
/* crew-code "not found" — a distinct panel, never a relabeled Enter button
   (council fix: a typo must never be one absent-minded tap from forking a
   new crew). Dashed hairline like the crew-invite card; ink-stamp corner
   marks it as a decision point, not an error toast. Frosted background
   matches .ob-inner input[type=text] (owner: "share the frosted ... input
   parameters" too) — same rgba(var(--card-rgb),.28) + blur(10px), so every
   surface floating over the onboarding watermark reads as one material
   instead of some panels being glass and others solid. */
.ob-notfound{margin-top:14px;padding:16px;border:1.5px dashed var(--hair);
  background:rgba(var(--card-rgb),.28);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);
  border-radius:var(--r-md);animation:fadeUp .3s var(--ease-out)}
.ob-notfound-msg{font-weight:700;font-size:14.5px;color:var(--ink)}
#boot-offline{z-index:96}
.boot-offline-card{max-width:340px;display:flex;flex-direction:column;gap:10px;text-align:center}
.ob-notfound-msg b{font-family:Archivo,sans-serif;letter-spacing:.03em}
.ob-notfound-sub{font-size:13px;color:var(--muted);margin-top:4px}
/* both notfound actions converted to captioned icon discs (owner: "these two
   buttons should also be round to match the others ... more uniform" with
   the crew-code enter disc) — a horizontal row of two, matching .btn-disc's
   existing fill/halo recipe instead of a bespoke pill/outline treatment. */
.ob-notfound-btns{display:flex;flex-direction:row;gap:24px;margin-top:14px;justify-content:center}
/* solo fallback reuses the .ob-notfound panel shell, but its single action is
   the only control that completes onboarding here — same reasoning as the
   "Tie in" button, so it stays a plain full-width primary, not a disc. */
#ob-solo .btn{margin-top:16px}
.ob-nf-opt{display:flex;flex-direction:column;align-items:center;gap:7px}
.ob-nf-cap{font-family:"Instrument Sans",sans-serif;font-weight:600;font-size:12.5px;color:var(--ink);text-align:center}
/* translucent + blurred (not solid var(--card)) so the full-screen watermark
   behind #onboarding tints through instead of the input just sitting flatly
   on top of it (owner feedback 2026-07-24) — backdrop-filter keeps the
   blurred watermark from reading as visual noise under the typed text.
   Opacity round-tripped .55->.82->.42->.28 (owner: more opaque, then "more
   transparent not opaque my bad", then more transparent again) — .28 leans
   further toward see-through while backdrop-filter still keeps typed text
   legible. Shared literal values intentionally duplicated on .ob-notfound
   above so both onboarding surfaces read as one consistent material. */
.ob-inner input[type=text]{width:100%;padding:14px;border:1px solid var(--hair);
  background:rgba(var(--card-rgb),.28);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);
  font-family:Archivo,sans-serif;font-weight:700;font-size:18px;letter-spacing:.06em;color:var(--ink)}
.ob-inner .btn{width:100%;margin-top:12px}
/* Tie-in button reverted to a circular start-button disc (owner: "original
   start button idea"), no caption span. .btn-disc's outer display is flex
   (block-level outside), so margin:auto centres it in .ob-new same as a
   block element would. */
#ob-create-btn{margin:20px auto 0}
/* crew-code row: input shrunk to a sane width for the actual code length
   (exactly CODE_LENGTH = 8, enforced both by maxlength and by app.js's
   click handler; this comment previously said "6-char minimum, no maximum"
   and cited a 9-char placeholder, neither of which is true any more)
   with a circular icon button beside it instead of a full-width
   "Enter" bar underneath (owner: buttons should "mimic the shape of the
   dock" — .btn-disc reuses the exact fill/halo recipe from .tab.on in the
   bottom nav for visual consistency between the two "circular icon control"
   moments in the app). Scoped selector deliberately placed after the base
   .ob-inner input[type=text] rule above so it wins the width override at
   equal specificity via cascade order, not a specificity hack. */
.ob-code-row{display:flex;gap:10px;align-items:center}
.ob-code-row input[type=text]{width:auto;flex:1;max-width:210px}
/* A placeholder must not be able to pass for a value. There was no
   ::placeholder rule here at all, so the hint inherited the input's own
   700-weight, 18px, .06em-tracked value face and sat there looking like a
   code someone had already typed. Lighter, dimmer, and barely tracked, so it
   reads as chrome; --muted carries its own light/dark values, so this holds
   in both themes without a second rule. */
/* The crew's name on the Crew tab. Sits directly under "The Crew" as the
   subject of that heading, in the same mono/tracked register the launch
   screen uses for "ROPE & RUNG" under "ASCENT" — deliberately the same
   heading-and-subject relationship, not a new pattern. Not a control: no
   border, no fill, nothing that reads as tappable, because renaming is an
   admin job done in Settings. */
/* Crew/Home merge: the heading above the plate is now .crew-head's
   "Keeping tabs", not the deleted <h2 class="screen-title">The Crew</h2>. The
   old -6px top margin existed only to cancel .screen-title's 6px bottom
   padding; with that heading gone it was pulling the plate up over the week
   strip. Top margin is 0 now — the 10px gap the plate sits at is .l-title's
   own margin-bottom, i.e. the same heading-to-content step every other
   labelled block on the screen uses, and nothing here can overlap upward. */
.crew-name-plate{margin:0 22px 18px;font-family:"JetBrains Mono",ui-monospace,monospace;
  font-weight:500;font-size:13px;letter-spacing:.2em;text-transform:uppercase;
  color:var(--accent);overflow-wrap:anywhere}

/* New-crew handover. The code is the hero here — it is the one thing the
   founder has to carry out of this screen — so it gets the Bebas/tracked
   treatment the wordmark uses, not the input face. Selectable on purpose:
   copying it is the single most likely thing to do on this screen. */
.ob-nc-lead{margin:0 0 14px;font-size:14px;line-height:1.45;color:var(--muted)}
.ob-nc-code{margin:0 0 22px;font-family:"JetBrains Mono",ui-monospace,monospace;
  font-weight:500;font-size:29px;letter-spacing:.22em;color:var(--ink);
  user-select:all;-webkit-user-select:all}

/* The second path off the launch screen. Deliberately NOT a button-shaped
   control: the disc beside the input is the screen's one action, and a second
   filled control next to it would read as an equal choice when joining a
   friend's crew is the main road. Underlined text so it still reads as
   tappable, sized to the 44px minimum tap target via padding rather than
   font-size. */
.ob-alt{display:block;margin:9px 0 0;padding:15px 2px;background:none;border:0;
  font-family:"JetBrains Mono",ui-monospace,monospace;font-size:12px;
  letter-spacing:.12em;text-transform:uppercase;color:var(--muted);
  /* Derived from currentColor, NOT from a surface variable: this sits over
     the painting, so an underline mixed toward --card went dark-on-dark and
     vanished. It needs the affordance — the mono-caps face is the same one
     .ob-product wears directly above, which is not tappable at all. */
  text-decoration:underline;text-underline-offset:4px;
  text-decoration-color:color-mix(in srgb,currentColor 45%,transparent);
  cursor:pointer}
.ob-alt:hover,.ob-alt:focus-visible{color:var(--ink);text-decoration-color:var(--accent)}

.ob-code-row input[type=text]::placeholder{
  font-weight:500;font-size:16px;letter-spacing:.02em;
  color:var(--muted);opacity:.85}
.btn-disc{flex:none;width:52px;height:52px;border-radius:50%;display:flex;align-items:center;justify-content:center;
  background:var(--grad);color:var(--on-accent);border:none;cursor:pointer;padding:0;
  /* Neon ring removed 2026-08-08 (owner). Was a 2px #1BC79E ring plus a
     rgba(27,199,158,.75) halo, inherited from .tab.on in the dock — at disc
     scale it read as a lit outline stuck around every control rather than as
     emphasis, and it fought the launch screen's painted peak. The soft teal
     drop shadow stays: it gives the disc its lift without drawing an edge. */
  box-shadow:0 6px 16px rgba(15,122,109,.32);
  transition:transform .15s}
.btn-disc svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;transition:transform .2s}
.btn-disc:active{transform:scale(.92)}
/* size modifier for the app's single most important action (Bank) — same
   fill/halo recipe as the base disc, just bigger so it reads as the primary
   tap target on the Today screen */
.btn-disc.lg{width:68px;height:68px}
.btn-disc.lg svg{width:28px;height:28px}
/* Bank disc "remove" state (negative compose = reverse-crank): flips the
   deposit-arrow icon 180deg so it reads as pulling back out instead of
   banking in, without needing a second icon asset */
.btn-disc.reverse svg{transform:rotate(180deg)}
/* disabled: same muted look .btn-primary:disabled uses, so a dead disc is
   just as obviously inert as the old disabled text button was */
.btn-disc:disabled{background:var(--pending);color:var(--muted);cursor:default;
  box-shadow:none}
.ob-existing{display:flex;flex-direction:column;gap:8px;margin-bottom:18px}
.ob-existing button{display:flex;align-items:center;gap:10px;padding:12px;border:1px solid var(--hair);background:var(--card);font-family:"Instrument Sans",sans-serif;font-weight:700;font-size:16px;cursor:pointer;color:var(--ink)}
.ob-existing .avatar{width:32px;height:32px;border-radius:50%;background:var(--grad);color:var(--on-accent);display:flex;align-items:center;justify-content:center;font-size:16px}
.avatars{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
.avatars button{width:44px;height:44px;border-radius:50%;border:1px solid var(--hair);background:var(--card);font-size:20px;cursor:pointer}
.avatars button.sel{background:var(--accent);border-color:var(--accent)}
.modal{background:var(--card);border:1px solid var(--hair);padding:20px;width:100%;max-width:340px;
  border-radius:var(--r-lg);box-shadow:var(--shadow-pop);animation:popIn .3s var(--ease-out)}
@keyframes popIn{from{opacity:0;transform:scale(.92) translateY(12px)}to{opacity:1;transform:none}}
.postit-modal{background:var(--postit);border:none}
.postit-modal .l-title{color:var(--postit-ink-soft)}
.excuse-input{width:100%;border:none;background:transparent;font-family:Caveat,cursive;font-weight:600;font-size:24px;line-height:1.25;color:var(--postit-ink);resize:none;outline:none}
.excuse-input::placeholder{color:var(--postit-muted)}
.modal-btns{display:flex;gap:10px;margin-top:14px}
.modal-btns .btn{flex:1;padding:12px}

/* branded confirm sheet: replaces window.confirm — same card/paper system as
   the admin modal, cancel stays a quiet ghost button, confirm is always the
   visually louder one (teal by default, missed/danger red when it's a
   one-way door like the data wipe) */
.confirm-modal{padding-top:22px}
.confirm-msg{font-size:15.5px;font-weight:600;line-height:1.5;color:var(--ink)}
.btn-danger{background:var(--missed);color:var(--on-accent);box-shadow:0 6px 18px rgba(178,58,46,.28)}

/* celebration overlay (Wave 3): the header wordmark's engineered lantern-glow
   finally gets a real stage — full-screen, one-shot, over a dimmed backdrop.
   Reuses <symbol id="rr-wordmark"> at onboarding scale (300px) via <use>;
   the lantern-glow keyframes (rr-lantern-a/b in the wordmark styles above)
   apply unchanged since it's the same symbol instance, just bigger. Sits
   above every other overlay/modal (z-index 95 vs 50/70) so it can never be
   trapped behind the confirm sheet or the dial hint; tap-anywhere or the
   auto-dismiss timer always closes it, so it can never trap the user. */
#celebrate-overlay{z-index:95;display:block;padding:0;overflow:hidden;cursor:pointer;
  background-color:var(--paper);color:var(--ink);
  /* The flare is the MEMBER'S colour in both themes, never a grey dot. JS sets
     --flare from the profile; this is only the fallback. --peak is the mountain
     and the confetti's second colour: teal on paper, paper on pine. */
  --flare:var(--accent);--peak:var(--peak-ink)}
#celebrate-overlay.playing{animation:celOverlayIn 520ms ease-out both}
#celebrate-overlay.leaving{opacity:0;transition:opacity 600ms ease-in}
@keyframes celOverlayIn{from{opacity:0}to{opacity:1}}

/* ---- the celebration: design/celebration/index.html, integrated ----
   The stage is the prototype's own 390x800 frame, bottom-anchored and scaled to
   fit the screen (--cel-s, set in showNextCelebration). Every offset inside it
   is absolute px — 300px of climb, 150px spokes, 245px of confetti — and was
   tuned by eye at exactly this size, so the frame is scaled rather than the
   numbers. The overlay does the clipping; the mountain runs past the stage's
   sides on a wide screen, which is just more mountain.

   Compressed from 12.8s to ~10s (owner, 2026-09-11). The launch and the burst
   are the approved take untouched, to the millisecond. The cuts are all after
   the line: the dead beat before the ember set off is gone (it now leaves as
   the line fades), the smoke tail is tighter, and the ember's fall and the
   camera's return are shortened TOGETHER — they are locked, the ember touches
   down as the peak settles, now at 9600ms instead of 11800ms.

   Rules paid for in the prototype that must survive any future edit:
   travel and sway live on separate wrappers (.cel-rise/.cel-drop carry the
   translateY, the child carries opacity + sway) · per-keyframe timing
   functions, never one bezier stretched across a multi-stop move · `forwards`,
   never `both`, on anything that appears after a delay · never two lights on
   the summit at once · two colours only, --flare and --peak. */
.cel-stage{position:absolute;left:50%;bottom:0;width:390px;height:800px;margin-left:-195px;
  transform-origin:50% 100%;transform:scale(var(--cel-s,1))}
/* The CAMERA moves; the mountain does not. transform-origin is the summit so the
   push-in converges on the peak rather than the centre of frame. The kick has
   its own wrapper so it never fights the camera over one transform. */
.cel-shake{position:absolute;inset:0;transform-origin:50% 50%}
.cel-cam{position:absolute;inset:0;transform-origin:59.56% 34%;will-change:transform}
.cel-mtn{position:absolute;left:50%;bottom:-6%;transform:translateX(-50%);
  width:145%;aspect-ratio:900/805;background:var(--peak);opacity:.5;
  -webkit-mask:url("assets/peak.png") no-repeat center/contain;
  mask:url("assets/peak.png") no-repeat center/contain}
.cel-summit{position:absolute;left:50%;bottom:-6%;transform:translateX(-50%);width:145%;aspect-ratio:900/805;pointer-events:none}
.cel-rise,.cel-drop{position:absolute;left:59.56%;top:.87%;width:0;height:0}
.cel-flare,.cel-ember{position:absolute;left:0;top:0;width:9px;height:9px;margin:-4.5px 0 0 -4.5px;border-radius:50%;
  background:var(--flare);box-shadow:0 0 16px 5px var(--flare);opacity:0}
/* pinned at the launch point, grows upward behind the flare — exhaust, not a bar */
.cel-streak{position:absolute;left:59.56%;top:calc(.87% - 300px);width:4px;height:300px;margin:0 0 0 -2px;
  border-radius:2px;transform-origin:50% 100%;filter:blur(1.5px);
  background:linear-gradient(to top,transparent 0%,color-mix(in srgb,var(--flare) 55%,transparent) 55%,var(--flare) 100%);
  opacity:0}
.cel-burst{position:absolute;left:59.56%;top:.87%;width:0;height:0}
.cel-spark{position:absolute;left:0;top:0;width:2px;height:52px;margin:-26px 0 0 -1px;transform-origin:50% 50%;
  background:linear-gradient(to top,transparent,var(--flare));opacity:0}
.cel-flecks{position:absolute;left:0;top:0;width:0;height:0}
.cel-fleck{position:absolute;left:0;top:0;width:5px;height:9px;margin:-4px 0 0 -2px;border-radius:1px;
  background:var(--fc);opacity:0;transform:translateY(-300px)}
.cel-shock{position:absolute;left:0;top:0;width:24px;height:24px;margin:-12px 0 0 -12px;border-radius:50%;
  border:1.5px solid var(--flare);opacity:0}
.cel-whiteout{position:absolute;inset:-50%;background:var(--flare);opacity:0;pointer-events:none;z-index:5}
.cel-ring{position:absolute;left:0;top:0;width:16px;height:16px;margin:-8px 0 0 -8px;border-radius:50%;
  border:2px solid var(--flare);opacity:0}
.cel-flash{position:absolute;left:0;top:0;width:12px;height:12px;margin:-6px 0 0 -6px;border-radius:50%;
  background:var(--flare);box-shadow:0 0 34px 14px var(--flare);opacity:0}
.cel-lantern{position:absolute;left:59.56%;top:.87%;width:26px;height:26px;margin:-13px 0 0 -13px;border-radius:50%;
  background:var(--flare);filter:blur(7px);opacity:0}
.cel-lantern-core{position:absolute;left:59.56%;top:.87%;width:7px;height:7px;margin:-3.5px 0 0 -3.5px;border-radius:50%;
  background:var(--flare);opacity:0}
/* Smoke, not haze: an feTurbulence displacement breaks the edge and it turns
   slowly as it rises, so the form changes as it drifts. */
.cel-smoke{position:absolute;left:50%;top:20%;width:420px;height:300px;margin-left:-210px;border-radius:46% 54% 58% 42%/48% 44% 56% 52%;
  background:radial-gradient(ellipse at 46% 58%,var(--flare) 0%,transparent 66%);
  opacity:0;filter:blur(18px) url(#smokeChurn);z-index:6;pointer-events:none}
.cel-smoke.s2{top:12%;width:520px;margin-left:-260px;height:360px;filter:blur(26px) url(#smokeChurn);
  border-radius:56% 44% 48% 52%/42% 58% 42% 58%}
/* identical treatment to the ASCENT lockup: Bebas at .13em, with the matching
   text-indent (Bebas tracks from the right of each glyph) */
.cel-title{position:absolute;left:0;right:0;top:24%;padding:0 18px;text-align:center;opacity:0;z-index:5}
.cel-title h2{margin:0;font-family:"Bebas Neue",Archivo,sans-serif;font-weight:400;font-size:58px;
  line-height:.9;letter-spacing:.13em;text-indent:.13em;text-transform:uppercase;color:var(--ink)}
/* a streak line runs to ~35 characters; at 58px that is four lines */
.cel-title.long h2{font-size:44px}
.cel-title p{margin:12px 0 0;font-family:"JetBrains Mono",monospace;font-size:11px;letter-spacing:.24em;
  text-transform:uppercase;color:var(--muted);opacity:0}
.cel-rule{width:90px;height:2px;background:var(--accent);margin:14px auto 0;transform:scaleX(0)}
.cel-halo{position:absolute;left:50%;top:34%;width:320px;height:320px;margin:-160px 0 0 -160px;border-radius:50%;
  background:radial-gradient(circle,var(--flare) 0%,transparent 62%);opacity:0;z-index:-1}

/* ---- the timeline (ms) ----
   0     beacon lights on the summit; camera holds
   826   push in to 1.75x on the peak
   2560  beacon hands off as the flare leaves (never two lights at once)
   2600  flare climbs, camera pulls back and pans up after it
   4660  flash · kick · spokes · shockwaves · rings · confetti
   4950  the line lands under the burst; fades from 6700
   6600  the ember sets off as the line goes (was 7100 — the dead beat)
   7300  camera leaves the sky and comes home with it (was 9204)
   9600  ember touches down as the peak settles (was 11800)
   9350  lantern relights, just before touchdown, so they merge
   9900  overlay fades back to the app (JS) */
.playing .cel-cam{animation:celCamera 9600ms linear both}
.playing .cel-shake{animation:celShake 950ms cubic-bezier(.2,.8,.3,1) 4680ms both}
.playing .cel-lantern.pre{animation:celLanternOn 600ms ease-out 200ms both,celFlick 3.1s ease-in-out 800ms 2,
  celHandoff 420ms ease-in 2560ms forwards}
.playing .cel-lantern-core.pre{animation:celLanternOn 600ms ease-out 200ms both,celFlick2 2.3s ease-in-out 800ms 2,
  celHandoff 420ms ease-in 2560ms forwards}
.playing .cel-rise{animation:celRiseY 2100ms cubic-bezier(.16,.5,.3,1) 2600ms both}
.playing .cel-flare{animation:celFlareLit 2100ms linear 2600ms both,celSwayA 1400ms ease-in-out 2600ms both}
.playing .cel-streak{animation:celStreakUp 2100ms cubic-bezier(.16,.5,.3,1) 2600ms both}
.playing .cel-flash{animation:celFlash 720ms ease-out 4660ms forwards}
.playing .cel-whiteout{animation:celWhiteout 700ms ease-out 4670ms forwards}
.playing .cel-spark{animation:celSpark 1000ms cubic-bezier(.1,.72,.28,1) 4700ms forwards}
.playing .cel-ring{animation:celRing 1000ms cubic-bezier(.1,.72,.28,1) 4700ms forwards}
.playing .cel-ring.r2{animation:celRing 1150ms cubic-bezier(.1,.72,.28,1) 4880ms forwards}
.playing .cel-ring.r3{animation:celRing 1350ms cubic-bezier(.1,.72,.28,1) 5060ms forwards}
.playing .cel-shock{animation:celShockW 1500ms cubic-bezier(.08,.72,.24,1) 4700ms forwards}
.playing .cel-shock.sh2{animation:celShockW 1900ms cubic-bezier(.08,.72,.24,1) 4880ms forwards}
.playing .cel-fleck{animation:celFleck var(--fd) cubic-bezier(.12,.62,.3,1) var(--fdel) forwards}
.playing .cel-halo{animation:celHaloA 2200ms cubic-bezier(.2,.7,.3,1) 4700ms both}
.playing .cel-halo.h2x{animation:celHaloB 3000ms cubic-bezier(.15,.6,.25,1) 5150ms both}
.playing .cel-halo.h3x{animation:celHaloC 3200ms ease-out 5750ms both}
.playing .cel-title{animation:celTitleIn 900ms cubic-bezier(.18,.9,.3,1.08) 4950ms both,celTitleOut 900ms ease-in 6700ms forwards}
.playing .cel-rule{animation:celRuleIn 800ms cubic-bezier(.2,.7,.3,1) 5350ms both}
.playing .cel-title p{animation:celSubIn 700ms ease-out 5600ms both}
.playing .cel-smoke{animation:celSmokeA 3400ms ease-out 5300ms both}
.playing .cel-smoke.s2{animation:celSmokeB 3700ms ease-out 5700ms both}
.playing .cel-drop{animation:celDropY 3000ms linear 6600ms forwards}
.playing .cel-ember{animation:celEmberLit 3000ms linear 6600ms forwards,celSwayB 3400ms ease-in-out 6600ms infinite}
.playing .cel-lantern{animation:celLanternOn 700ms ease-out 9350ms forwards,celFlick 3.1s ease-in-out 10050ms infinite}
.playing .cel-lantern-core{animation:celLanternOn 700ms ease-out 9350ms forwards,celFlick2 2.3s ease-in-out 10050ms infinite}

/* ONE continuous camera move, per-keyframe easing. The stops are the
   prototype's own milliseconds through the pan (826 · 2950 · 4484 · 6844) —
   only the hold on the sky and the return are shorter.
     0–826      hold: the beacon lights, the camera has not moved
     826–2950   push in, only to 1.75x — at 2.5x the mountain was lost
     2950–6844  pull back out while panning up after the flare; the peak slides
                to the bottom of frame but never leaves it
     6844–7300  hold on the sky for the line
     7300–9600  come home, exactly to where we started */
@keyframes celCamera{
  0%     {transform:scale(1) translateY(0);      animation-timing-function:linear}
  8.6%   {transform:scale(1) translateY(0);      animation-timing-function:cubic-bezier(.45,0,.5,1)}
  30.73% {transform:scale(1.75) translateY(3%);  animation-timing-function:cubic-bezier(.3,0,.45,1)}
  46.71% {transform:scale(1.6) translateY(14%);  animation-timing-function:linear}
  71.29% {transform:scale(1.16) translateY(44%); animation-timing-function:linear}
  76.04% {transform:scale(1.14) translateY(46%); animation-timing-function:cubic-bezier(.4,0,.3,1)}
  100%   {transform:scale(1) translateY(0)}
}
@keyframes celShake{
  0%{transform:translate(0,0) scale(1)}   9%{transform:translate(0,11px) scale(1.014)}
  24%{transform:translate(-6px,-7px) scale(1.006)} 41%{transform:translate(5px,4px)}
  60%{transform:translate(-3px,-2px)}  80%{transform:translate(2px,1px)}
  100%{transform:translate(0,0) scale(1)}
}
@keyframes celRiseY{from{transform:translateY(0)}to{transform:translateY(-300px)}}
/* The fall stays FLAT: only the residual lift off the burst and the landing are
   eased. One bezier over the whole drop accelerates like a stone. */
@keyframes celDropY{
  0%  {transform:translateY(-300px); animation-timing-function:cubic-bezier(.25,.6,.45,1)}
  14% {transform:translateY(-310px); animation-timing-function:cubic-bezier(.5,.02,.6,1)}
  62% {transform:translateY(-160px); animation-timing-function:cubic-bezier(.4,0,.45,1)}
  100%{transform:translateY(0)}
}
/* opacity and scale only — the travel lives on the wrapper */
@keyframes celFlareLit{0%{opacity:0;transform:scale(.4)}8%{opacity:1;transform:scale(1)}90%{opacity:1;transform:scale(.92)}100%{opacity:0;transform:scale(.85)}}
@keyframes celEmberLit{0%{opacity:0;transform:scale(1.25)}6%{opacity:1}93%{opacity:.95;transform:scale(.92)}100%{opacity:0;transform:scale(.86)}}
@keyframes celSwayA{0%{margin-left:-4.5px}50%{margin-left:-3.2px}100%{margin-left:-4.5px}}
@keyframes celSwayB{0%{margin-left:-4.5px}50%{margin-left:3.5px}100%{margin-left:-4.5px}}
@keyframes celStreakUp{
  0%  {opacity:0;transform:scaleY(0)}
  10% {opacity:.95;transform:scaleY(.12)}
  70% {opacity:.7;transform:scaleY(.8)}
  100%{opacity:0;transform:scaleY(1)}
}
@keyframes celHandoff{to{opacity:0;transform:scale(.35)}}
@keyframes celSpark{
  0%{opacity:0;transform:translateY(-300px) rotate(var(--a)) translateY(0) scaleY(.3)}
  16%{opacity:1}
  100%{opacity:0;transform:translateY(-300px) rotate(var(--a)) translateY(-150px) scaleY(1)}
}
@keyframes celRing{0%{opacity:.9;transform:translateY(-300px) scale(.3)}100%{opacity:0;transform:translateY(-300px) scale(17)}}
@keyframes celFlash{0%{opacity:0;transform:translateY(-300px) scale(.4)}18%{opacity:1;transform:translateY(-300px) scale(1.7)}100%{opacity:0;transform:translateY(-300px) scale(2.8)}}
@keyframes celShockW{0%{opacity:.9;transform:translateY(-300px) scale(.15)}
  60%{opacity:.22}100%{opacity:0;transform:translateY(-300px) scale(22)}}
@keyframes celWhiteout{0%{opacity:0}9%{opacity:.22}100%{opacity:0}}
@keyframes celFleck{
  0%  {opacity:0;transform:translateY(-300px) translate(0,0) rotate(0deg) scale(.6)}
  10% {opacity:1}
  70% {opacity:1}
  100%{opacity:0;transform:translateY(-300px) translate(var(--fx),var(--fy)) rotate(var(--fr)) scale(1)}
}
@keyframes celSmokeA{0%{opacity:0;transform:translateY(0) scale(.65) rotate(0deg)}22%{opacity:.30}100%{opacity:0;transform:translateY(-96px) scale(1.75) rotate(26deg)}}
@keyframes celSmokeB{0%{opacity:0;transform:translate(0,0) scale(.85) rotate(0deg)}28%{opacity:.22}100%{opacity:0;transform:translate(34px,-150px) scale(2.1) rotate(-34deg)}}
/* three genuinely different blooms — identical timing reads as a pattern */
@keyframes celHaloA{0%{opacity:0;transform:scale(.35)}22%{opacity:.34}100%{opacity:0;transform:scale(1.5)}}
@keyframes celHaloB{0%{opacity:0;transform:scale(.8)}34%{opacity:.20}100%{opacity:0;transform:scale(2.3)}}
@keyframes celHaloC{0%{opacity:0;transform:scale(1.2)}46%{opacity:.12}100%{opacity:0;transform:scale(3.1)}}
@keyframes celTitleIn{0%{opacity:0;transform:translateY(20px) scale(.86)}100%{opacity:1;transform:none}}
@keyframes celSubIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
@keyframes celRuleIn{from{transform:scaleX(0)}to{transform:scaleX(1)}}
@keyframes celTitleOut{to{opacity:0;transform:translateY(-10px)}}
@keyframes celLanternOn{to{opacity:1}}
@keyframes celFlick{0%,100%{opacity:.85;transform:scale(1)}41%{opacity:1;transform:scale(1.12)}78%{opacity:.6;transform:scale(.93)}}
@keyframes celFlick2{0%,100%{opacity:.95;transform:scale(1)}24%{opacity:1;transform:scale(1.1)}71%{opacity:.7;transform:scale(.9)}}

/* council polish round (Expansionist QoL, 2026-07-20) */
/* dial: volt discharge each full revolution */
.dial.discharge .ring{animation:voltflash .55s ease-out}
/* rope: slack loop once today is banked — safety has a look, not just danger */
.knot.fray.slack{border-style:solid;border-color:var(--accent);opacity:.55;
  animation:slackSway 3.2s ease-in-out infinite;transform-origin:top center}
@keyframes slackSway{50%{transform:translateY(1.5px) rotate(6deg)}}
/* ledger: every 10th strike lands heavier — legible at the rebuilt full-size
   stamp scale (Wave 4), not just a 1px nudge on a 3px sliver */
.stamp.five.ten{height:5px;opacity:1;box-shadow:0 0 1.5px rgba(var(--shadow-rgb),.8)}
/* history: perfect month joins the dots into a thin ink line */
.hist-cell{position:relative}
.hist-grid.perfect .hist-cell.ink::before{content:"";position:absolute;left:50%;top:calc(50% + 7px);
  width:calc(100% + 6px);height:2px;border-radius:1px;background:var(--accent);opacity:.45}
/* history swap-in: tiles fall into place like they're dropping off a wall.
   Only active while `.hist-anim` sits on #hist-calendar-view (app.js gates it
   to an actual calendar<->ladder view change, not every re-render). Staggered
   via the inline --i index set per-cell in renderHistory(); kept short (12ms
   /cell) so a full 35-cell month still resolves in well under a second. */
@keyframes histTileFall{from{opacity:0;transform:translateY(-14px) rotate(-3deg)}to{opacity:1;transform:none}}
.hist-anim .hist-cell{animation:histTileFall .38s var(--ease-out) backwards;animation-delay:calc(var(--i,0) * 12ms)}
/* post-it aging (Wave 4 — council reversal): the accountability artifact
   gets LOUDER with age, not quieter. An unaddressed excuse escalates its
   rotation, scale, and shadow the longer it sits, and picks up a second pin
   once it's genuinely overdue — replaces the old curl-1/2/3 decay mechanic
   (a curling, wilting corner read as the opposite of accountability).
   postitAgeClass() in app.js still buckets by day-age 0-3; only the visual
   language changed. The overrides below beat the site-specific rotate()
   rules on Today's mate zone and Crew cards via matching specificity, so
   aging always wins over the resting tilt once an excuse is old enough.
   Applied wherever excuses render: Home, Today, Crew. */
.postit{position:relative}
.postit::before{content:"";position:absolute;top:-6px;left:50%;width:11px;height:11px;margin-left:-5.5px;
  border-radius:50%;background:radial-gradient(circle at 32% 30%,rgba(255,255,255,.9),var(--ink) 65%);
  box-shadow:0 2px 3px rgba(var(--shadow-rgb),.45);z-index:2}
.postit.aged-1,#mate-postit-zone .postit.aged-1,.crew-card .postit.aged-1{
  transform:rotate(-7deg) scale(1.04);box-shadow:0 10px 24px rgba(var(--shadow-rgb),.2)}
.postit.aged-2,#mate-postit-zone .postit.aged-2,.crew-card .postit.aged-2{
  transform:rotate(9deg) scale(1.09);box-shadow:0 13px 30px rgba(var(--shadow-rgb),.28)}
.postit.aged-3,#mate-postit-zone .postit.aged-3,.crew-card .postit.aged-3{
  transform:rotate(-12deg) scale(1.15);box-shadow:0 16px 36px rgba(var(--shadow-rgb),.36)}
.postit.aged-2::after,.postit.aged-3::after{content:"";position:absolute;top:-4px;right:22%;width:9px;height:9px;
  border-radius:50%;background:radial-gradient(circle at 32% 30%,rgba(255,255,255,.9),var(--ink) 65%);
  box-shadow:0 2px 3px rgba(var(--shadow-rgb),.4);transform:rotate(14deg);z-index:2}
/* personal best chip beside the all-time total */
.pb-badge{display:inline-block;background:var(--volt);color:var(--pine);font-family:"Instrument Sans",sans-serif;
  font-weight:800;font-size:10px;letter-spacing:.04em;padding:1px 7px;border-radius:9px;vertical-align:1px}
/* motion budget: honour reduced-motion everywhere, not just the odometer */
@media (prefers-reduced-motion: reduce){
  *,*::before,*::after{animation-duration:.01ms !important;animation-iteration-count:1 !important;
    transition-duration:.01ms !important;scroll-behavior:auto !important}
}

/* ---------- Wave 5: ladder view ---------- ------------------------------
   "Rung" made literal: the escalating weekly targets as a climbable ladder,
   toggled alongside the existing calendar (History screen). Reuses the
   day-state colour vocabulary (bg-met/bg-rest/bg-excused/bg-missed/
   bg-pending + the shape rules above) so it never invents a second palette.
   Design choice: the two rails are fixed verticals (structural — that's what
   reads as "a ladder" rather than a bar chart), so a week's target escalates
   the RUNG'S THICKNESS + its number's type-size (a heavier week looks and
   sits heavier), not the rails' spacing. */
.hv-toggle{position:relative;display:flex;gap:3px;margin:10px auto 4px;width:max-content;padding:3px;background:var(--card);
  border:1px solid var(--hair);border-radius:999px;box-shadow:var(--shadow-card)}
.hv-thumb{position:absolute;top:3px;bottom:3px;left:3px;width:56px;border-radius:999px;
  background:var(--grad);box-shadow:0 2px 8px rgba(15,122,109,.3);transition:transform .32s var(--ease-out);z-index:0}
.hv-toggle.v-ladder .hv-thumb{transform:translateX(59px)}
.hv-btn{position:relative;z-index:1;display:flex;align-items:center;justify-content:center;
  width:56px;border:none;background:none;padding:7px 0;
  color:var(--muted);border-radius:999px;cursor:pointer;
  transition:color .2s}
.hv-btn svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
.hv-btn:active{transform:scale(.97)}
.hv-btn.on{color:var(--on-accent)}

.ladder-wrap{margin:8px 22px 0;padding:16px 8px 12px;background:rgba(var(--card-rgb),.78);border:1px solid var(--hair);
  border-radius:var(--r-lg);box-shadow:var(--shadow-card)}
/* scrolls WITHIN the card (never the page) so a 30+ week challenge stays legible */
.ladder-scroll{position:relative;max-height:min(58vh,480px);overflow-y:auto;overflow-x:hidden;
  padding:6px 4px;-webkit-overflow-scrolling:touch;overscroll-behavior:contain}
.ladder-rail{position:absolute;top:6px;bottom:6px;width:3px;border-radius:2px;opacity:.55;pointer-events:none;
  /* same fibrous-rope texture trick as .strand, so the rails read as the same
     material as the rest of the brand's rope language, not a plain CSS line */
  background:repeating-linear-gradient(180deg,rgba(var(--texture-rgb),.5) 0 3px,rgba(var(--texture-rgb),.12) 3px 7px)}
.ladder-rail.l{left:38px}
.ladder-rail.r{right:36px}
.ladder-rungs{position:relative;display:flex;flex-direction:column}
.rung-row{position:relative;display:flex;align-items:center;gap:8px;min-height:44px;z-index:1}
.rung-wk{flex:0 0 30px;font-size:10px;font-weight:700;color:var(--muted);text-align:right;
  font-variant-numeric:tabular-nums}
.rung-zone{flex:1;position:relative;display:flex;flex-direction:column;align-items:stretch;
  justify-content:center;gap:4px;padding:0 4px;min-width:0}
.rung-ticks{display:flex;justify-content:space-between;gap:2px;padding:0 2px}
.rt{width:6px;height:6px;flex:none}
/* the rung itself: ink = climbed, volt = the current week (exactly volt's
   job per the brand), dashed pending-grey = future. Thickness (set inline
   per-row from targetFor) is the "heavier target, heavier rung" read; a tiny
   deterministic rotation jitter (also inline) keeps it hand-made, not a ruler
   line — same irregularity language as the ledger's tilted stamp rows. */
.rung-bar{width:100%;border-radius:3px;background:var(--ink);transform-origin:center;transition:opacity .2s}
.rung-row.past .rung-bar{opacity:.88}
.rung-row.current .rung-bar{background:var(--volt);
  box-shadow:0 0 0 3px rgba(199,244,100,.4),0 0 14px rgba(199,244,100,.35)}
.rung-row.future .rung-bar{background:transparent;border:1.5px dashed var(--pending);min-height:3px}
/* subtle cap treatment: the week the climb first plateaus gets a small
   ceiling tag instead of an ever-thicker rung from then on */
.rung-cap-tag{align-self:center;font-size:8.5px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;
  color:var(--muted);border-top:1.5px dashed var(--hair);padding-top:3px;margin-top:1px}
.rung-target{flex:0 0 30px;font-family:Archivo,sans-serif;font-weight:800;color:var(--ink);
  font-variant-numeric:tabular-nums;text-align:left;letter-spacing:-.02em}
.rung-row.current .rung-target{color:var(--pine)}
.rung-row.future .rung-target{color:var(--muted);font-weight:700}
/* ladder swap-in: rungs build bottom-up, like being laid down as you climb.
   Only active while `.hist-anim` sits on #hist-ladder-view (app.js gates it
   to an actual calendar<->ladder view change). renderLadder() emits highest
   week first (top) / week 1 last (bottom), so the stagger index is reversed
   in app.js (--i = totalWeeks-1-idx) to make the bottom rung go first. */
@keyframes ladderRungBuild{from{opacity:0;transform:translateY(10px) scaleX(.86)}to{opacity:1;transform:none}}
.hist-anim .rung-row{animation:ladderRungBuild .42s var(--ease-out) backwards;animation-delay:calc(var(--i,0) * 55ms)}
.ladder-legend{margin-top:8px;padding:8px 4px 0}

/* summit lantern — ignites as the marker tops out, then keeps flickering while
   you sit on this screen typing a code */
/* Ignites as the climber TOPS OUT, not on a fixed guess. The climb runs
   1650ms + 3400ms = 5050ms, so the flame comes up at 4950ms and the two read
   as one event. Retimed when the climb was slowed; if the climb timing moves
   again, this must move with it. */
.ob-summit-flame{opacity:0;animation:obFlameIn 900ms ease-out 4950ms forwards}
@keyframes obFlameIn{to{opacity:1}}
/* One ink, two densities: a soft blurred mass with a dense core inside it,
   which is how the rest of this screen is painted. The old volt/#F5FFC8 pair
   put the only foreign hue on the launch screen right at its focal point. */
/* fill-opacity, not opacity: obFlickerA animates `opacity` frame by frame, so
   a base opacity here would simply be overwritten. These compose instead. */
.ob-flame-glow{fill:var(--peak-ink);filter:blur(9px);fill-opacity:.55;
  transform-box:fill-box;transform-origin:center;
  animation:obFlickerA 3.1s ease-in-out 5350ms infinite}
.ob-flame-core{fill:var(--peak-ink);transform-box:fill-box;transform-origin:center;
  animation:obFlickerB 2.3s ease-in-out 5350ms infinite}
/* irregular steps on purpose: evenly spaced keyframes read as a pulse, not a flame */
@keyframes obFlickerA{
  0%{opacity:.80;transform:scale(1)}      9%{opacity:.52;transform:scale(.90)}
  17%{opacity:.95;transform:scale(1.08)}  28%{opacity:.60;transform:scale(.95)}
  41%{opacity:1;transform:scale(1.13)}    53%{opacity:.68;transform:scale(.97)}
  66%{opacity:.88;transform:scale(1.05)}  78%{opacity:.55;transform:scale(.92)}
  89%{opacity:.86;transform:scale(1.02)}  100%{opacity:.80;transform:scale(1)}
}
@keyframes obFlickerB{
  0%{opacity:.9;transform:scale(1)}       13%{opacity:.7;transform:scale(.86)}
  24%{opacity:1;transform:scale(1.1)}     38%{opacity:.75;transform:scale(.93)}
  55%{opacity:.98;transform:scale(1.05)}  71%{opacity:.66;transform:scale(.88)}
  86%{opacity:.94;transform:scale(1.03)}  100%{opacity:.9;transform:scale(1)}
}

/* Surfaces are translucent (--card at 78%). It was 62%, which let more of the
   watermark through but capped how dense the mountain could be before --muted
   text over it dropped under 4.5:1 AA. 78% buys ~2x the filled/unfilled step
   (1.3:1 -> 1.9:1 light, 1.6:1 -> 2.4:1 dark) at the cost of a fainter
   show-through. The mountain mostly sits in open space below the content
   anyway, so the trade favours the mountain.
   (--card at 78%) rather than solid so the peak reads
   faintly through every tile. Alpha only, NO backdrop-filter: blur on this many
   scrolling surfaces forces a continuous GPU repaint on mobile, which is the one
   thing that would make the watermark expensive. The tab bar keeps its blur
   because it is fixed and there is exactly one of it. */

/* NUMERALS STAY ARCHIVO, deliberately. The tally counts up live while the dial
   turns and the ledger/History columns align on digits; Archivo has
   font-variant-numeric: tabular-nums, Bebas has no tabular figures at all, so
   swapping them would make the number visibly jitter as it changes width. */

/* ---- header: member avatar + plain wordmark ---- */
.head-avatar{width:28px;height:28px;flex:none;display:block}
/* There is no base .avatar rule in this stylesheet - every call site (home row,
   crew corkboard) styles its own chip. The header chip therefore inherited no
   border-radius at all and rendered as a bare square. */
.head-avatar .avatar{width:28px;height:28px;border-radius:50%;overflow:hidden;flex:none;
  display:flex;align-items:center;justify-content:center;
  background:var(--grad);color:var(--on-accent)}
.head-wordmark{font-family:"JetBrains Mono",ui-monospace,monospace;font-weight:500;
  font-size:11px;letter-spacing:.20em;text-transform:uppercase;color:var(--muted);
  white-space:nowrap}

/* ---- the peak, carried through the app ----
   Faint enough to sit under real content at every screen's density. Fixed, not
   absolute, so it does not scroll with the page and never triggers a repaint.
   --peak-fill (0%-100%, set from app.js in renderAll) is today's tally/target
   as climbed height on the mountain. An element-level `opacity` can only ever
   express ONE density for the whole shape, so the two densities (climbed vs.
   unclimbed) live inside the background itself instead: a two-stop gradient,
   filled colour up to --peak-fill and the original faint colour above it. The
   mask (the ink-density painting) is unchanged and still does all the shaping
   — the gradient just supplies what colour gets masked through at each height. */
/* .app-peak and .app-lantern share one geometry rule so the masked mountain
   and the unmasked lantern box can never drift apart - see the lantern
   comment below for why they must stay two separate elements at all. */
.app-peak,.app-lantern{position:fixed;left:50%;bottom:-6vh;transform:translateX(-50%);
  height:46vh;width:auto;aspect-ratio:900/805;z-index:0;pointer-events:none}
/* --peak-fill is REGISTERED, not just a bare custom property. Unregistered
   custom properties are untyped tokens: a gradient built on one is not
   reliably re-evaluated when only that token changes, which is why the fill
   appeared to stick — removing a set left the painted gradient stale while the
   lantern's class toggle (classes always invalidate) kept working, so the
   beacon updated and the mountain did not. Registering it gives the property a
   real <percentage> type, which both fixes invalidation and makes it genuinely
   animatable — so the fill can now be transitioned directly instead of
   transitioning `background`, which never worked: CSS cannot interpolate
   between two linear-gradient() values at all. */
/* inherits:TRUE, and that is load-bearing. --peak-fill/--peak-pct are set on
   .app-peak and .app-lantern but READ by .al-track and .al-walker, which are
   CHILDREN of .app-lantern. Registered with inherits:false they resolved to the
   initial value in those children, so the track never drew and the walker sat
   at base camp forever no matter how many pushups were banked. */
@property --peak-fill{syntax:"<percentage>";inherits:true;initial-value:0%}
/* Number twin of --peak-fill. stroke-dashoffset takes a <number> against
   pathLength=100; a <percentage> there would resolve against the viewport
   diagonal instead, which is not the same thing at all. Both are set from the
   same maths in updatePeakFill() so they cannot disagree. */
@property --peak-pct{syntax:"<number>";inherits:true;initial-value:0}
.app-peak{
  --peak-fill:0%;
  /* Title-screen palette ONLY — no volt. Climbed ground is the same --peak-ink
     as the launch screen's mountain, just denser. The earlier "too subtle"
     problem was never the hue, it was the alpha: .32 filled against .085
     unfilled is a 1.3:1 step, invisible. .55 against .13 is 1.87:1 and the
     unfilled mass is now legible against the page too. Affordable because
     tiles moved to .78 — see the tile rule for that trade. */
  /* Flat faint wash now. The bottom-up gradient fill is gone: progress is
     expressed by the mountain BLOOMING around the climbed trail instead of by
     a horizontal edge sweeping up it, which read as a level rather than a
     climb. See .al-bloom. */
  background:rgba(var(--peak-rgb),.13);
  transition:--peak-fill 1.4s cubic-bezier(.33,0,.24,1),--peak-pct 1.4s cubic-bezier(.33,0,.24,1);
  -webkit-mask:url("assets/peak.png") no-repeat center/contain;
  mask:url("assets/peak.png") no-repeat center/contain}
/* .app-lantern paints right after .app-peak in the DOM at the same z-index,
   so it sits just above the masked mountain without needing a mask or
   background of its own - it is an empty positioning box only. */
/* Dark filled alpha is .28, NOT .42. Measured: at .42 a tile sitting over the
   FILLED part of the peak drops --muted text to 3.90:1, under the 4.5:1 AA
   floor — a contrast regression that only appears once someone is near their
   target, i.e. exactly when nobody is testing. .28 restores 4.6:1 with the
   filled/unfilled step still clearly readable. Light mode measured at .32 and
   passes (4.77:1), so it is left alone. */
@media (prefers-color-scheme: dark){.app-peak{background:rgba(var(--peak-rgb),.16)}}
/* every screen already sits in normal flow above it, but the tab bar and any
   fixed furniture need to stay clear of it explicitly */
/* The watermark needs the app's own surfaces above it, but z-index here is
   load-bearing in a way that bit us: giving .app-head z-index:1 made it a
   stacking context, so #menu-dropdown's z-index:70 could no longer escape it —
   and #app>main, at the SAME z-index but later in the DOM, painted straight
   over the open dropdown. Tapping "Admin" hit main instead and nothing
   happened. The head must therefore outrank main, not tie with it. This rule
   also used to name .tabbar, which silently overrode its own z-index:40 (later
   rule, equal specificity) — dropped, the tab bar keeps its own. */
#app>main{position:relative;z-index:1}
.app-head{position:relative;z-index:3}

/* summit lantern for the in-app watermark — same visual language as the
   launch screen's .ob-summit-flame (glow + core, two independent flicker
   periods so they drift out of phase), reused via the same obFlickerA/B
   keyframes. This MUST stay a sibling of .app-peak (see .app-lantern above),
   not a child or ::before/::after of it: a CSS `mask` flattens an element
   and everything painted inside it - pseudo-elements included - into one
   composited group, and applies the mask's own alpha to that whole group.
   .app-peak's mask is near-transparent at the summit, so a glow hung on
   .app-peak (as an earlier attempt did) got clipped down to a crescent by
   the mask itself. DO NOT "tidy" these two <i> layers back into .app-peak's
   ::before/::after - that reintroduces the exact bug this fixed. Position is
   the same summit point the launch screen's flame sits at (x=536,y=7 of the
   900x805 asset, i.e. ~59.56%/0.87% of the shared box), expressed as calc()
   so the circle is centred on that point without touching `transform` —
   obFlickerA/B animate `transform:scale(...)`, which would otherwise clobber
   a transform-based centering trick. .is-lit toggles on .app-lantern (not
   .app-peak) from the same place --peak-fill is set - the fill still lives
   on .app-peak, only the lit state moved with the layer it belongs to. */
.app-lantern.is-lit .al-glow,.app-lantern.is-lit .al-core{
  position:absolute;border-radius:50%;pointer-events:none}
.app-lantern.is-lit .al-glow{
  left:calc(59.56% - 1.715vh);top:calc(.87% - 1.715vh);width:3.43vh;height:3.43vh;
  background:rgba(var(--peak-rgb),.55);filter:blur(9px);
  animation:obFlickerA 3.1s ease-in-out infinite}
.app-lantern.is-lit .al-core{
  left:calc(59.56% - .4vh);top:calc(.87% - .4vh);width:.8vh;height:.8vh;
  background:rgba(var(--peak-rgb),1);
  animation:obFlickerB 2.3s ease-in-out infinite}


/* ---- the route, walked ----
   The launch screen draws this same switchback once, as a film. In the app it
   becomes the progress indicator: the track reveals and the walker climbs in
   proportion to the day's tally, so 10 pushups is leaving base camp at the
   trees and the summit is the target. Same path data as the launch screen's
   .ob-route, so the two can never describe different mountains.
   Lives in .app-lantern (unmasked) — inside .app-peak the mask would clip it,
   the same trap the summit lantern hit. */
.al-route{position:absolute;inset:0;width:100%;height:100%;overflow:visible}
.al-reveal{fill:none;stroke:#fff;stroke-width:26;stroke-linecap:round;
  stroke-dasharray:100;stroke-dashoffset:calc(100 - var(--peak-pct));
  transition:stroke-dashoffset 1.4s cubic-bezier(.33,0,.24,1)}
/* DASHED, not dotted (owner). Butt caps and a real dash length give the
   walking-route look; round caps on a near-zero dash gave dots, which read as
   beads rather than a track. Dash lengths are in USER units, not pathLength
   units, so every dash is the same physical length however uneven the route's
   segments are — pathLength normalisation made them bunch at corners. Round
   joins soften the switchback angles without moving a single vertex. The
   Dash 26/17 against a 5px stroke, not 16/12 against 6.5 — at the tighter
   ratio each dash was about as wide as it was long and read as a row of
   blocks rather than a dashed line.
   The reveal mask keeps pathLength=100 because progress must stay normalised.
   Old note: the dash pattern is in
   pathLength units (100 across the whole route) so the dot spacing stays even
   however the SVG is scaled. Drawing is done by a MASK that wipes along the
   same path, because stroke-dashoffset is already spoken for by the dots —
   the same conflict the launch screen's track hit. */
/* The trail is PAPER in both themes, not --peak-ink. In dark those are the
   same colour, but in light --peak-ink is teal and the trail would disappear
   into the mountain it is drawn on. A map route is always the light mark. */
/* ONE stroke, no casing. A single colour across BOTH themes was impossible —
   measured, the best any palette colour managed was 1.28:1 against the six
   grounds this route crosses. A colour PER THEME solves it outright: ink never
   drops below 7.01:1 in light, paper never below 4.15:1 in dark. The casing
   and the halo rings existed only to work around the one-colour constraint and
   are gone with it. */

/* In-app trail wears the MEMBER'S avatar colour (--trail-me, set from JS), so
   your climb is drawn in your own colour. It needs the casing back: measured,
   every avatar colour bottoms out between 1.01:1 and 1.25:1 across the four
   in-app grounds, because they are all mid-tones and the peak runs from nearly
   invisible to nearly solid. The casing is the theme colour that DOES clear
   3:1 everywhere, so the trail is always readable and the colour is still
   theirs. Falls back to the theme colour when no member is loaded. */

.al-track{filter:drop-shadow(0 1.5px 2.5px rgba(0,0,0,.42));fill:none;stroke:var(--trail-me,var(--trail-ink));stroke-width:7.5;stroke-linecap:butt;
  stroke-dasharray:26 17;stroke-linejoin:round;mask:url(#alRouteReveal);
  opacity:1;vector-effect:non-scaling-stroke}
/* Positioned from JS with getPointAtLength() on the real path, NOT with CSS
   offset-path. offset-path was resolving against something that changes when
   the page is zoomed or the text scaled, so the marker drifted off the mountain
   at anything other than 100% — reported on a 3x desktop zoom. getPointAtLength
   returns a point in the SVG's own user space, which is the same space the path
   is drawn in, so the two cannot disagree at any scale. */
.al-walker{transition:transform 1.4s cubic-bezier(.33,0,.24,1)}
/* The mountain lights UP AROUND the trail as it is climbed: one very wide,
   heavily blurred stroke along the route, revealed by the same dashoffset as
   the track and clipped to the mountain's own silhouette. Soft by construction
   — no hard edge to tear, because there is no edge. */
/* The bloom is revealed by a MASK whose own stroke is blurred, not by cutting
   the bloom itself with stroke-dashoffset. Cutting it left a hard vertical edge
   at the head of the climb — a dashoffset end is a straight chop, and no amount
   of blurring the bloom softens the cut because the cut happens after the blur.
   Blurring the MASK feathers the reveal instead, so the light fades out ahead of
   the climber rather than stopping dead. */
.al-bloom-rev{stroke-dasharray:100;stroke-dashoffset:calc(100 - var(--peak-pct));
  transition:stroke-dashoffset 1.4s cubic-bezier(.33,0,.24,1)}
.al-bloom{fill:none;stroke:var(--peak-ink);stroke-width:150;stroke-linecap:round;
  stroke-linejoin:round;opacity:.5;filter:url(#alBloomBlur)}
/* target met: the whole mountain flares, then settles brighter than it was */
/* background-color, not the background SHORTHAND — the shorthand does not
   interpolate, which is why no flare was visible at all. And it settles at .62,
   not .34: at .34 the peak was still darker than the bloomed corridor, so the
   mountain read as half-shrouded after the target was already met. */
.app-peak.is-lit{animation:peakFlare 2.4s cubic-bezier(.2,.7,.3,1) both}
@keyframes peakFlare{
  0%{background-color:rgba(var(--peak-rgb),.13)}
  22%{background-color:rgba(var(--peak-rgb),.92)}
  100%{background-color:rgba(var(--peak-rgb),.62)}}
/* once the summit is reached the corridor has done its job — the whole
   mountain is lit, so the bloom fades out rather than staying as a bright
   stripe across an already-bright peak */
.app-lantern.is-lit .al-bloom{opacity:0;transition:opacity 1.2s ease-out .5s}

/* a glow, not a ring: the beacon should read as light on the mountain */
.alw-dot{fill:var(--trail-me,var(--trail-ink));filter:drop-shadow(0 0 6px var(--trail-me,var(--trail-ink)))}
/* nothing to walk yet: hide the dot at zero so it does not squat at base camp */
.app-lantern:not(.walking) .al-walker{opacity:0}

/* ---- admin: icon gallery ----
   Discs are the corkboard avatar's own recipe (--grad ground, --on-accent ink)
   at 88px and at 44px, so the gallery cannot flatter a mark the app exposes. */
.ig-controls{display:flex;flex-wrap:wrap;gap:14px;margin-top:12px}
.ig-toggle{display:flex;align-items:center;gap:7px;font-size:13px;font-weight:600;color:var(--ink)}
.ig-toggle input{width:auto;margin:0;accent-color:var(--accent)}
/* two-up on a phone: the card's inner width is ~290px at 375, so the track has
   to be under ~137px or every mark gets a row to itself and the gallery becomes
   a mile of scrolling. */
.icon-gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(122px,1fr));
  gap:18px 10px;margin-top:16px}
.ig-cell{margin:0;display:flex;flex-direction:column;align-items:center;gap:7px}
.ig-discs{display:flex;align-items:center;gap:9px}
.ig-disc{width:72px;height:72px;border-radius:50%;background:var(--grad);color:var(--on-accent);
  display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto}
.ig-disc.ig-sm{width:44px;height:44px}
.ig-cell figcaption{font-size:11px;line-height:1.3;color:var(--muted);text-align:center;
  word-break:break-word}
.ig-cell.is-ach figcaption{color:var(--accent);font-weight:600}
/* freeze wins over every per-mark rule, which is how the house test is run */
.icon-gallery.is-frozen *{animation:none!important}


/* ---- the unlock panel (achievements, spec rule 3) ----
   Deliberately NOT the celebration overlay. That one is a wordless moment for
   a thing you already know you did; this one has to be readable, because its
   entire job is telling you what you earned and why — the rules are never
   announced anywhere else. */
.unlock-card{width:min(340px,88vw);background:var(--card);border:1px solid var(--hair);
  border-radius:var(--r-lg);box-shadow:var(--shadow-card);padding:22px}
.unlock-list{display:flex;flex-direction:column;gap:14px;margin:14px 0 20px}
.unlock-row{display:flex;gap:14px;align-items:flex-start}
.unlock-mark{width:56px;height:56px;border-radius:50%;background:var(--grad);
  color:var(--on-accent);display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto}
.unlock-copy{min-width:0}
/* these are spans inside a flex column child, so they need to be told to be
   blocks or the name, the reason and the duration all run together on one line */
.unlock-name{display:block;font-family:Archivo,sans-serif;font-weight:800;font-size:16px;
  letter-spacing:-.015em;color:var(--ink);line-height:1.2}
.unlock-why{display:block;font-size:13px;color:var(--muted);line-height:1.45;margin-top:3px}
.unlock-worn{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.1em;
  color:var(--accent);font-weight:600;margin-top:6px}
/* an achievement whose mark is not drawn yet still gets a row — it is earned,
   it just has nothing to wear. A dashed well says "pending" without shouting. */
.unlock-mark.no-art{background:none;border:2px dashed var(--hair)}

/* ---- the achievement badge frame (v2-house-rules §4) ----
   The state ring already lives OUTSIDE the disc edge, so this one goes INSIDE
   and is TEXTURED rather than merely another colour — two solid rings at the
   same weight fight, a beaded ring against a solid one does not. Volt because
   DESIGN.md rations it to reward moments and it appears in no day-state
   palette, so it can never be misread as a state. */
.crew-card .avatar.cc-avatar.is-ach::before{content:"";position:absolute;inset:6px;
  border-radius:50%;pointer-events:none;border:2.5px dotted var(--volt);opacity:.85}
.crew-card .avatar.cc-avatar.is-ach .av{box-shadow:inset 0 0 0 2px rgba(199,244,100,.75);border-radius:50%}
/* the frame eats ~6px of the disc, so the glyph gives it back — without this
   the taller marks touch the inner ring */
.crew-card .avatar.cc-avatar.is-ach .av svg{width:70%;height:70%}

/* earned marks in the picker, so the wardrobe reads as a collection */
#set-avatars button.is-earned{position:relative}
#set-avatars button.is-earned::after{content:"";position:absolute;inset:-3px;border-radius:50%;
  border:1.5px dotted var(--volt);pointer-events:none}


/* ---- the remaining achievement marks: motion (2026-09-08) ----
   One moving part each. Custom pivots use the `.av svg .aa-x` form; the bare
   form is used only where the inherited centre origin is genuinely wanted
   (pure translate / scale / opacity), which is how the shipped set does it. */
.av svg .aa-moonDrift { animation: aa-moonDrift 2.6s ease-in-out infinite; }
@keyframes aa-moonDrift {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-4px); }
}


.av svg .aa-grainDrop { animation: aa-grainDrop 1.3s linear infinite; }
@keyframes aa-grainDrop {
  0% { transform: translateY(0); }
  70% { transform: translateY(4px); }
  100% { transform: translateY(0); }
}


/* firstRung: the new rung settles into place — a small press, not a spring. */

/* fullLedger: the ribbon is pinned at the top edge (31.5,6), not its own
   centre — needs the .av svg .aa-NAME form or it spins about its own middle. */
.av svg .aa-ledgerFlutter{transform-box:view-box;transform-origin:31.5px 6px;animation:aaLedgerFlutter 1.1s ease-in-out infinite}
@keyframes aaLedgerFlutter{0%,100%{transform:rotate(-22deg)}50%{transform:rotate(22deg)}}

/* steadfastGrip: the newest link snaps in and settles — scale is about its
   own bounding-box centre, which the default fill-box origin already gives. */
.aa-linkLock{animation:aaLinkLock 2.6s ease-in-out infinite}
@keyframes aaLinkLock{0%,55%,100%{transform:scale(1)}70%{transform:scale(1.1)}}

/* keptClimbing: the top rung reaches — still going, not finished. */
.aa-stepPulse{animation:aaStepPulse 1.3s ease-in-out infinite}
@keyframes aaStepPulse{0%,100%{transform:translateY(0)}50%{transform:translateY(-2.8px)}}


.av svg .aa-noteSwing{transform-box:view-box;transform-origin:24px 9px;animation:aaNoteSwing 3.6s ease-in-out infinite}
@keyframes aaNoteSwing{0%,100%{transform:rotate(-3deg)}50%{transform:rotate(3deg)}}
.av svg .aa-bubbleNod{transform-box:view-box;transform-origin:15px 39px;animation:aaBubbleNod 3.2s ease-in-out infinite}
@keyframes aaBubbleNod{0%,100%{transform:rotate(-2.6deg)}50%{transform:rotate(2.6deg)}}
.av svg .aa-signSwing{transform-box:view-box;transform-origin:20px 16px;animation:aaSignSwing 3.4s ease-in-out infinite}
@keyframes aaSignSwing{0%,100%{transform:rotate(-5deg)}50%{transform:rotate(5deg)}}
.av svg .aa-fingerTwitch{transform-box:view-box;transform-origin:32px 28px;animation:aaFingerTwitch 2.6s ease-in-out infinite}
@keyframes aaFingerTwitch{0%,100%{transform:rotate(-4deg)}50%{transform:rotate(4deg)}}


.aa-coinPop{animation:aaCoinPop 2.1s ease-in-out infinite}
@keyframes aaCoinPop{50%{transform:translateY(-3px)}}

.aa-fuseSpark{animation:aaFuseSpark .9s ease-in-out infinite}
@keyframes aaFuseSpark{50%{transform:translateY(-2.4px) scale(1.15)}}


.aa-motesDrift{animation:aaMotesDrift 2.4s ease-in-out infinite}
@keyframes aaMotesDrift{50%{transform:translate(2px,-2.4px);opacity:.35}}


/* the top stone settles — a cairn is stones balanced, so the smallest one on
   top is the only thing that would ever move */
.av svg .aa-cairnTop{transform-box:view-box;transform-origin:24px 14.6px;animation:aaCairnTop 3.4s ease-in-out infinite}
@keyframes aaCairnTop{0%,100%{transform:rotate(-3.5deg)}50%{transform:rotate(3.5deg)}}

/* both hands sit at twelve and tick together, so the clock reads midnight even
   while it moves — pivot is the spindle, which needs the .av svg form */
.av svg .aa-midnightHands{transform-box:view-box;transform-origin:24px 24px;animation:aaMidnightHands 2.4s ease-in-out infinite}
@keyframes aaMidnightHands{0%,100%{transform:rotate(-5deg)}50%{transform:rotate(5deg)}}

/* the first rung takes the weight */
.aa-rungSet{animation:aaRungSet 2.6s ease-in-out infinite}
@keyframes aaRungSet{0%,100%{transform:translateY(0)}50%{transform:translateY(1.6px)}}

/* the goat's beard — from v2-icons.js, shipped with the head-only goat */
.av svg .aa-goatBeard{transform-box:view-box;transform-origin:24px 36px;animation:aaGoatBeard 3.2s ease-in-out infinite}
@keyframes aaGoatBeard{0%,100%{transform:rotate(-6deg)}50%{transform:rotate(6deg)}}


/* ---- the celebration under prefers-reduced-motion ----
   The blanket rule at the top of this file cuts every animation to .01ms, which
   would snap the whole take to its last frame — and the last frame of the line
   is faded OUT, so the member would be told nothing. Reduce Motion is a request
   for less MOTION, not for no feedback. showNextCelebration() adds .reduced
   instead of .playing, and this is the static take: the mountain, the lantern
   lit on the summit, the line — arriving as one cross-fade, opacity only. The
   !important beats the blanket rule, not the user's preference. */
#celebrate-overlay.reduced .cel-lantern,#celebrate-overlay.reduced .cel-lantern-core,
#celebrate-overlay.reduced .cel-title,#celebrate-overlay.reduced .cel-title p{opacity:1}
#celebrate-overlay.reduced .cel-rule{transform:none}
/* the moving take reads the line with the camera panned up into the sky; at
   rest, 24% puts a two-line line's sub straight onto the summit lantern */
#celebrate-overlay.reduced .cel-title{top:11%}
@media (prefers-reduced-motion: reduce){
  #celebrate-overlay.reduced{animation:celebrateFade .28s ease-out !important}
  @keyframes celebrateFade{from{opacity:0}to{opacity:1}}
}
