/* =========================================================================
   hub.css — gamification layer for the logged-in hub (home page + HUD)
   =========================================================================
   Adds ONLY the new game UI: the status HUD (avatar/XP ring, level badge,
   Sparks coin, streak flame), the home directory tiles, and the daily card.

   It deliberately does NOT redefine the theme palettes. The four elemental
   themes (:root / [data-theme="…"]) live in style.css and are the single
   source of truth — everything here consumes those tokens (--bg, --accent,
   --grad, --text, …). Load style.css FIRST, then this file.

   The only globals added below are a few genuinely-new, additive tokens the
   game UI needs (Sparks gold + font shorthands) — they extend :root, they
   don't touch any theme's colors.
   ========================================================================= */

/* ---- Additive tokens (NOT theme palettes) ----
   Sparks are the spendable currency: the same gold in every theme so the
   coin always reads as "money" regardless of the active element. The
   streak's orange is fixed the same way — it's the one color a student
   learns means "your flame", so a theme never gets to reskin it. */
:root {
  --spark-1: #ffe79a;
  --spark-2: #f4b63a;
  --spark-bd: #d89a1c;
  --spark-ink: #3a2606;
  --streak: #ea580c;
  --fsans: "Hanken Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  --fdisplay: "Bricolage Grotesque", "Hanken Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  --fmono: ui-monospace, "SF Mono", Menlo, monospace;
  /* House motion curves (CLAUDE.md's Motion section) as tokens — js/motion.js
     and its new moments reference these by name instead of retyping the
     cubic-bezier per rule. Pre-existing animation (rankUp's, etc.) keeps its
     literal values; only new rules from here on use the token. */
  --ease-enter: cubic-bezier(0.22, 1, 0.36, 1);
  --ease-exit: cubic-bezier(0.4, 0, 1, 1);
  /* Radius scale (Phase 5, F5) — named after the 6 most-used literal values
     across the whole stylesheet set, so introducing the token is a pure
     rename with zero visual change. Values that don't already fall on one
     of these numbers are left as their own literal — snapping them to the
     nearest token is a design call for a later pass, not this one. 50%
     (circular avatars on square elements) is a distinct idiom, not an ad
     hoc radius, and stays literal on purpose. */
  --radius-xs: 4px;
  --radius-sm: 8px;
  --radius-md: 10px;
  --radius-lg: 12px;
  --radius-xl: 14px;
  --radius-pill: 99px;
  /* Spacing scale (Phase 5, F4) — same methodology as the radius scale
     above: named after the most-used literal margin/padding/gap values,
     so introducing the token is a pure rename with zero visual change.
     Adoption is exact-match only — a value that doesn't land on one of
     these numbers stays a literal on purpose, rather than getting bent
     onto the nearest neighbor. */
  --space-2: 2px;
  --space-4: 4px;
  --space-5: 5px;
  --space-6: 6px;
  --space-7: 7px;
  --space-8: 8px;
  --space-9: 9px;
  --space-10: 10px;
  --space-12: 12px;
  --space-14: 14px;
  --space-16: 16px;
  --space-18: 18px;
  /* Odd-value tier (F4, 2026-09-19) — the ≥20px values that recurred at
     least twice sitewide once the exact-match/1px-drift tiers above were
     already migrated. Singleton values (34px, 38px, 45px, 46px, 84px, …)
     stay literal — a token used once isn't a token, it's an alias. */
  --space-20: 20px;
  --space-22: 22px;
  --space-24: 24px;
  --space-26: 26px;
  --space-28: 28px;
  --space-32: 32px;
  --space-40: 40px;
  --space-44: 44px;
  --space-56: 56px;
}

@keyframes etac-shine { 0% { transform: translateX(-120%); } 55%, 100% { transform: translateX(240%); } }
@keyframes etac-ember { 0%, 100% { opacity: .5; transform: scale(1); } 50% { opacity: .85; transform: scale(1.08); } }
@keyframes etac-rise  { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }

/* ===================== APP SHELL ===================== */
/* container-type lets the HUD + main use container-query units (cqi) and the
   width breakpoints below, so the layout adapts to its column width rather
   than the raw viewport (useful when embedded beside a future sidebar). */
.etac-app {
  container-type: inline-size;
  background: var(--bg);
  color: var(--text);
  min-height: 100vh;
  transition: background .2s, color .2s;
}

/* The HUD toggles whole chunks with the `hidden` attribute, and several of
   those elements also carry display:flex classes — author display rules
   would otherwise override the browser's [hidden]{display:none} and "hidden"
   things (like the Sign out button while signed out) would still show.
   Make hidden always win. */
[hidden] { display: none !important; }

/* ---------- HUD ---------- */
/* The whole HUD is injected by hud.js into this host, which is its own
   container so the include works on any page (with or without .etac-app). */
.etac-hud-host { container-type: inline-size; }

.etac-hud {
  /* F17: a stable name so the cross-document view transition (style.css)
     treats the HUD as ONE persistent element across a navigation instead of
     cross-fading it with the rest of the page — the detail that makes
     clicking between pages read as staying in the same app. */
  view-transition-name: etac-hud;
  position: sticky; top: 0; z-index: 30;
  display: flex; align-items: center;
  gap: clamp(10px, 2.4cqi, 20px);
  padding: var(--space-10) clamp(13px, 3cqi, 22px);
  background: color-mix(in oklab, var(--bg) 86%, transparent);
  backdrop-filter: saturate(1.3) blur(10px);
  border-bottom: 1px solid var(--border);
}
.etac-hud__id { display: flex; align-items: center; gap: var(--space-12); min-width: 0; }

/* Signed-out: the Google sign-in button sits where the avatar/identity
   normally lives (top-left). margin-right:auto pushes the kebab to the far
   right while the identity + stats blocks are hidden. min-height keeps the
   HUD from collapsing before the GIS button finishes loading. */
.etac-hud__signin { display: flex; align-items: center; min-height: 40px; margin-right: auto; }

/* Avatar = link to the profile page (level ring + caveman) */
.etac-avatar {
  position: relative; flex-shrink: 0; display: inline-block;
  border: 0; background: none; padding: 0; cursor: pointer;
  text-decoration: none; -webkit-tap-highlight-color: transparent;
}
.etac-avatar:hover .etac-ring { filter: brightness(1.12); }
.etac-avatar:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 50%; }
/* Registering --pct as a <number> is what lets the browser tween it instead
   of stepping straight to the new value — every ring (HUD avatar, daily
   card, profile) shares this one registration. */
@property --pct {
  syntax: '<number>';
  inherits: true;
  initial-value: 0;
}
.etac-ring {
  /* display:block matters: as an inline <span> the conic gradient paints on
     fragmented line boxes and the ring shatters into choppy arc chunks. */
  display: block; width: 46px; height: 46px; border-radius: 50%; padding: 3px;
  background: conic-gradient(from -90deg, var(--accent) calc(var(--pct, 0) * 3.6deg), var(--border) 0);
  transition: --pct 600ms var(--ease-enter);
}
@media (prefers-reduced-motion: reduce) {
  .etac-ring { transition: none; }
}
.etac-ring > * {
  width: 100%; height: 100%; border-radius: 50%;
  background: var(--bg-elevated); overflow: hidden; display: grid; place-items: center;
}
.etac-id-meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.etac-id-name { font: 650 15px/1.15 var(--fsans); color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.etac-id-sub  { font: 600 12px/1.2 var(--fsans); letter-spacing: .06em; text-transform: uppercase; color: var(--text-dim); white-space: nowrap; }

.etac-stats { display: flex; align-items: center; gap: clamp(8px, 1.8cqi, 14px); margin-left: auto; }

/* XP — themed gradient meter (progression / transcript; only ever goes up) */
.etac-xp { display: flex; flex-direction: column; gap: var(--space-5); width: clamp(150px, 20cqi, 210px); }
.etac-xp__top { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-8); }
.etac-xp__k { font: 700 12px/1 var(--fmono); letter-spacing: .13em; color: var(--text-muted); }
.etac-xp__v { font: 600 11px/1 var(--fsans); color: var(--text); font-variant-numeric: tabular-nums; }
.etac-xp__track { height: 8px; border-radius: var(--radius-pill); background: var(--bg-input); border: 1px solid var(--border); overflow: hidden; }
.etac-xp__fill { width: 100%; height: 100%; border-radius: var(--radius-pill); background: var(--grad); transform: scaleX(0); transform-origin: left; transition: transform .35s cubic-bezier(.2, .8, .2, 1); }

/* Sparks — gold coin chip (spendable wallet) */
.etac-coin-wrap { display: flex; align-items: center; gap: var(--space-7); }
.etac-coin {
  display: inline-flex; align-items: center; gap: var(--space-6); padding: var(--space-6) var(--space-12) var(--space-6) var(--space-8); border-radius: var(--radius-pill);
  background: linear-gradient(160deg, var(--spark-1), var(--spark-2)); border: 1px solid var(--spark-bd);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .6), 0 1px 2px rgba(0, 0, 0, .25);
  position: relative; overflow: hidden; cursor: default;
}
.etac-coin__face {
  width: 18px; height: 18px; border-radius: 50%; display: grid; place-items: center;
  background: radial-gradient(circle at 35% 30%, #fff1c4, #eba516);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .45);
  color: #2a1c04; font: 900 12px/1 var(--fsans);
}
.etac-coin__n { font: 800 14px/1 var(--fsans); color: var(--spark-ink); font-variant-numeric: tabular-nums; }
.etac-coin--lg { padding: var(--space-8) 15px var(--space-8) var(--space-10); }
.etac-coin__n--lg { font-size: 16px; }
.etac-coin__sheen {
  position: absolute; inset: 0; width: 40%;
  background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .65), transparent);
  transform: translateX(-120%); animation: etac-shine 4.5s ease-in-out infinite; pointer-events: none;
}
.etac-stat-lab { font: 700 12px/1 var(--fsans); letter-spacing: 0.072em; color: var(--text-dim); text-transform: uppercase; }

/* Streak — ember flame counter */
.etac-streak {
  display: inline-flex; align-items: center; gap: var(--space-7); padding: var(--space-6) var(--space-12); border-radius: var(--radius-pill);
  background: var(--bg-elevated); border: 1px solid var(--border); position: relative;
}
/* Static on purpose — a HUD element that pulses forever reads as noise. */
.etac-streak__flame { font-size: 15px; line-height: 1; filter: drop-shadow(0 0 6px rgba(255, 138, 61, .5)); }
.etac-streak__n { font: 800 14px/1 var(--fsans); color: var(--text); font-variant-numeric: tabular-nums; }
/* "ends in 3h" — the last 6 hours of an unanswered streak with no Streak
   Freeze to cover it (hud.js paintFlame, off streakRisk). The flame's own
   orange, pulled toward --text so the words clear 3:1 on the tint. */
.etac-streak__ends {
  font: 750 11px/1 var(--fsans); white-space: nowrap;
  color: color-mix(in oklab, var(--streak), var(--text) 35%);
  background: color-mix(in oklab, var(--streak) 16%, transparent);
  border-radius: var(--radius-pill); padding: 3px 7px; margin-right: -4px;
}
.etac-streak.is-risk { border-color: color-mix(in oklab, var(--streak) 55%, var(--border)); }
/* A fire you had and lost — the end state of js/motion.js's streakLost(),
   stamped by hud.js until the streak is lit again. Same filter list as that
   moment's last frame, so it settles there without a jump. */
.etac-streak__flame.is-out { filter: grayscale(1) brightness(.85); opacity: .55; }

/* Right cluster — the account/menu button */
.etac-hud__right { display: flex; align-items: center; gap: var(--space-10); }
.etac-kebab {
  width: 34px; height: 34px; flex-shrink: 0; padding: 0; border: 1px solid var(--border);
  background: var(--bg-elevated); color: var(--text-muted); border-radius: 50%;
  cursor: pointer; display: flex; align-items: center; justify-content: center;
  transition: border-color .15s, color .15s;
}
.etac-kebab:hover { border-color: var(--border-hover); color: var(--text); }
.etac-kebab:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Panel / mobile drawer (account menu: XP, themes, sign out / sign in) */
.etac-panel {
  position: absolute; right: clamp(13px, 3cqi, 22px); top: calc(100% + 6px);
  width: min(280px, calc(100cqi - 26px));
  background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-xl);
  box-shadow: 0 18px 50px rgba(0, 0, 0, .4);
  padding: var(--space-12); display: flex; flex-direction: column; gap: var(--space-10);
  transform-origin: top right;
  opacity: 0; transform: translateY(-6px) scale(.97); pointer-events: none;
  transition: opacity .16s, transform .16s;
}
.etac-panel[data-open="true"] { opacity: 1; transform: none; pointer-events: auto; }
.etac-panel__row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-10); }
.etac-panel__title { font: 600 13px var(--fsans); color: var(--text); }
.etac-panel__sep { height: 1px; background: var(--border); margin: var(--space-2) 0; }
.etac-panel__lab { font: 700 11.5px/1 var(--fsans); letter-spacing: 0.072em; text-transform: uppercase; color: var(--text-dim); }
.etac-panel__link {
  display: flex; align-items: center; gap: var(--space-8); width: 100%; text-align: left;
  background: none; border: 0; border-radius: 9px; padding: var(--space-9);
  color: var(--text); font: 500 13px var(--fsans); cursor: pointer; text-decoration: none;
}
.etac-panel__link:hover { background: var(--bg-input); }
.etac-panel__link:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.etac-panel__signin { display: flex; justify-content: center; padding: var(--space-2); }
.etac-panel__gavatar { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }

/* Notification bell (hud.js) + inbox panel (inbox.js) */
.etac-bell { position: relative; overflow: visible; }
.etac-bell__ico { display: grid; place-items: center; }
.etac-bell__badge {
  position: absolute; top: -4px; right: -5px;
  min-width: 16px; height: 16px; padding: 0 var(--space-4); border-radius: var(--radius-pill);
  background: #ef4444; color: #fff;
  font: 800 12px/16px var(--fsans); text-align: center;
  font-variant-numeric: tabular-nums;
  box-shadow: 0 0 0 2px var(--bg-elevated);
  pointer-events: none;
}
.etac-bell__badge[hidden] { display: none; }

.etac-inbox {
  position: absolute; right: clamp(13px, 3cqi, 22px); top: calc(100% + 6px);
  width: min(360px, calc(100cqi - 26px));
  max-height: min(72vh, 540px); overflow-y: auto; overscroll-behavior: contain;
  background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-xl);
  box-shadow: 0 18px 50px rgba(0, 0, 0, .4);
  padding: var(--space-12); display: flex; flex-direction: column; gap: var(--space-8);
  transform-origin: top right;
  opacity: 0; transform: translateY(-6px) scale(.97); pointer-events: none;
  transition: opacity .16s, transform .16s;
}
.etac-inbox[data-open="true"] { opacity: 1; transform: none; pointer-events: auto; }
.etac-inbox__head { font: 700 14px var(--fsans); color: var(--text); padding: var(--space-2) var(--space-2) 0; }
.etac-inbox__list { display: flex; flex-direction: column; gap: var(--space-4); }
.etac-inbox__empty { font: 500 12.5px var(--fsans); color: var(--text-dim); padding: var(--space-6) var(--space-2); }
.etac-inbox__labrow { display: flex; align-items: center; justify-content: space-between; gap: var(--space-8); }
.etac-inbox__clear {
  font: 700 11.5px/1 var(--fsans); letter-spacing: .06em; text-transform: uppercase;
  color: var(--text-dim); background: none; border: 0; cursor: pointer; padding: var(--space-2);
  transition: color .15s;
}
.etac-inbox__clear:hover { color: var(--accent); }
.etac-inbox__clear:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.etac-inbox__dismiss {
  flex-shrink: 0; width: 20px; height: 20px; display: grid; place-items: center;
  font: 700 14px/1 var(--fsans); color: var(--text-dim);
  background: none; border: 0; border-radius: 6px; cursor: pointer;
  transition: background .15s, color .15s;
}
.etac-inbox__dismiss:hover { background: var(--bg-input); color: var(--text); }
.etac-inbox__dismiss:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

.etac-inbox__row {
  display: flex; align-items: center; gap: var(--space-9);
  padding: var(--space-7) var(--space-8); border-radius: var(--radius-md);
  width: 100%; text-align: left; font: inherit; color: inherit;
  background: none; border: 0;
}
/* Invite rows are buttons (tap to open the accept/decline modal). */
button.etac-inbox__row { cursor: pointer; transition: background .15s; }
button.etac-inbox__row:hover { background: var(--bg-input); }
button.etac-inbox__row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.etac-inbox__chev { flex-shrink: 0; color: var(--text-dim); font: 700 18px/1 var(--fsans); padding-right: var(--space-2); }
.etac-inbox__row.is-new { background: color-mix(in oklab, var(--accent) 9%, transparent); }
.etac-inbox__avatar {
  width: 28px; height: 28px; border-radius: 50%; flex-shrink: 0;
  display: grid; place-items: center; object-fit: cover;
  background: var(--bg-input); border: 1px solid var(--border);
  font: 700 12px var(--fsans); color: var(--text-muted);
}
.etac-inbox__avatar .etac-ico, .fr-row__avatar .etac-ico { width: 15px; height: 15px; background: currentColor; }
/* Personal alerts have no sender, so they get a glyph where an avatar
   would go — same footprint, so the rows still line up with club rows. */
.etac-inbox__alertico {
  width: 28px; height: 28px; border-radius: 50%; flex-shrink: 0;
  display: grid; place-items: center; font-size: 14px;
  background: var(--bg-input); border: 1px solid var(--border);
}
.etac-inbox__alertico .etac-ico { width: 15px; height: 15px; background: var(--text-muted); }
.etac-inbox__main { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1; }
.etac-inbox__name { font: 600 13px var(--fsans); color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.etac-inbox__meta { font: 500 11.5px var(--fsans); color: var(--text-dim); }
/* An alert's meta line is a full sentence, not a one-line "…clubbed you" —
   let it wrap instead of running under the dismiss button. */
.etac-inbox__row .etac-inbox__alertico ~ .etac-inbox__main .etac-inbox__meta {
  white-space: normal; line-height: 1.45;
}
.etac-inbox__alertlink {
  display: inline-block; margin-top: 3px; font: 700 11px var(--fmono);
  color: var(--accent); text-decoration: none; border-bottom: 1px dotted transparent;
}
.etac-inbox__alertlink:hover, .etac-inbox__alertlink:focus-visible { border-bottom-color: var(--accent); }
.etac-inbox__actions { display: flex; gap: var(--space-5); flex-shrink: 0; }
.etac-inbox__btn {
  font: 600 11.5px var(--fsans); color: var(--text); cursor: pointer;
  background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--radius-sm);
  padding: var(--space-5) var(--space-9); transition: border-color .15s, background .15s;
}
.etac-inbox__btn:hover { border-color: var(--border-hover); }
.etac-inbox__btn.is-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink, #fff); }
.etac-inbox__btn:disabled { opacity: .55; cursor: default; }
.etac-inbox__btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

.etac-inbox__news {
  padding: var(--space-7) var(--space-8); border-radius: var(--radius-md); display: flex; flex-direction: column; gap: 3px;
  width: 100%; text-align: left; background: none; border: 0; cursor: pointer;
  transition: background .15s;
}
.etac-inbox__news:hover { background: var(--bg-input); }
.etac-inbox__news:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.etac-inbox__news.is-new { background: color-mix(in oklab, var(--accent) 9%, transparent); }
.etac-inbox__news.is-new:hover { background: color-mix(in oklab, var(--accent) 14%, transparent); }
.etac-inbox__news-top { display: flex; align-items: center; justify-content: space-between; gap: var(--space-8); }
.etac-inbox__news-right { display: flex; align-items: center; gap: var(--space-4); flex-shrink: 0; }
.etac-inbox__kind { font: 700 11.5px/1 var(--fsans); letter-spacing: 0.058em; text-transform: uppercase; color: var(--accent); display: inline-flex; align-items: center; gap: var(--space-4); }
.etac-inbox__kind .etac-mico { width: 11px; height: 11px; }
.etac-inbox__when { font: 600 12.5px var(--fsans); color: var(--text-dim); }
.etac-inbox__news-title { font: 600 13px var(--fsans); color: var(--text); }
/* Panel preview is clamped to 2 lines; the full body lives in the modal. */
.etac-inbox__news-body {
  font: 500 12.5px/1.45 var(--fsans); color: var(--text-muted); overflow-wrap: anywhere;
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden;
}

/* On-login modals (inbox.js): unseen news, and clubs-while-you-were-away */
.etac-modal {
  position: fixed; inset: 0; z-index: 80;
  display: grid; place-items: center; padding: var(--space-18);
  background: rgba(0, 0, 0, .45);
  opacity: 0; transition: opacity .18s;
}
.etac-modal.is-open { opacity: 1; }
.etac-modal.is-leaving { opacity: 0; }
.etac-modal__card {
  width: min(520px, 92vw); max-height: min(78vh, 640px); overflow-y: auto;
  background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 18px;
  box-shadow: 0 24px 70px rgba(0, 0, 0, .5);
  padding: var(--space-20); display: flex; flex-direction: column; gap: var(--space-12);
  transform: translateY(10px) scale(.97); transition: transform .18s;
}
.etac-modal.is-open .etac-modal__card { transform: none; }
.etac-modal__head { display: flex; align-items: center; gap: var(--space-10); }
.etac-modal__emoji { font-size: 26px; line-height: 1; }
.etac-modal__emoji .etac-ico { width: 26px; height: 26px; }
.etac-modal__title { font: 700 19px var(--fsans); color: var(--text); margin: 0; }
.etac-modal__sub { font: 500 13px/1.5 var(--fsans); color: var(--text-muted); margin: 0; }
.etac-modal__list { display: flex; flex-direction: column; gap: var(--space-10); }
.etac-modal__item {
  display: flex; flex-direction: column; gap: var(--space-4);
  padding: 11px var(--space-12); border-radius: var(--radius-lg);
  background: var(--bg-input); border: 1px solid var(--border);
}
.etac-modal__item-title { font: 650 14.5px var(--fsans); color: var(--text); }
.etac-modal__item-body { font: 500 13px/1.5 var(--fsans); color: var(--text-muted); white-space: pre-wrap; overflow-wrap: anywhere; }
/* Full article body (expanded news / patch note) — whole thing, scrollable. */
.etac-modal__article-body { font: 500 14px/1.6 var(--fsans); color: var(--text-muted); white-space: pre-wrap; overflow-wrap: anywhere; }
.etac-modal__btns { display: flex; align-items: center; justify-content: space-between; gap: var(--space-10); }
.etac-modal__link { font: 600 13px var(--fsans); color: var(--accent); text-decoration: none; }
.etac-modal__link:hover { text-decoration: underline; }
.etac-modal__ok {
  align-self: flex-end; cursor: pointer;
  font: 700 13.5px var(--fsans); color: var(--accent-ink, #fff);
  background: var(--accent); border: 1px solid var(--accent); border-radius: var(--radius-md);
  padding: var(--space-9) var(--space-18); transition: filter .15s;
}
.etac-modal__ok:hover { filter: brightness(1.08); }
.etac-modal__ok:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Secondary action buttons inside a modal (e.g. invite Accept/Decline). */
.etac-modal__actions { display: flex; gap: var(--space-8); justify-content: flex-end; flex-wrap: wrap; }
.etac-modal__btn {
  cursor: pointer; font: 700 13.5px var(--fsans); color: var(--text);
  background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--radius-md);
  padding: var(--space-9) var(--space-18); transition: border-color .15s, filter .15s;
}
.etac-modal__btn:hover { border-color: var(--border-hover); }
.etac-modal__btn.is-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink, #fff); }
.etac-modal__btn.is-primary:hover { filter: brightness(1.08); }
.etac-modal__btn:disabled { opacity: .55; cursor: default; }
.etac-modal__btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Club window — the "nudge a friend" block on the daily card after you
   finish today's question (daily.js appends it into #daily-root). */
.etac-nudge {
  margin-top: var(--space-16); padding-top: var(--space-14); border-top: 1px dashed var(--border);
}
.etac-nudge__head {
  display: flex; align-items: center; gap: var(--space-7);
  font: 700 12px var(--fsans); color: var(--text-muted); margin-bottom: var(--space-9);
}
.etac-nudge__ico { font-size: 15px; line-height: 1; }
.etac-nudge__list { display: flex; flex-direction: column; gap: var(--space-6); }
.etac-nudge__row {
  display: flex; align-items: center; gap: var(--space-9);
  padding: var(--space-6) var(--space-8); border-radius: var(--radius-md); background: var(--bg-elevated);
  border: 1px solid var(--border);
}
.etac-nudge__avatar {
  width: 26px; height: 26px; border-radius: 50%; flex-shrink: 0;
  display: grid; place-items: center; object-fit: cover;
  background: var(--bg-input); border: 1px solid var(--border);
  font: 700 12px var(--fsans); color: var(--text-muted);
}
.etac-nudge__name { flex: 1; min-width: 0; font: 600 13px var(--fsans); color: var(--text);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.etac-nudge__btn {
  flex-shrink: 0; cursor: pointer; font: 600 12px var(--fsans); color: var(--text);
  background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--radius-sm);
  padding: var(--space-6) 11px; transition: border-color .15s;
}
.etac-nudge__btn:hover:not(:disabled) { border-color: var(--border-hover); }
.etac-nudge__btn:disabled { opacity: .55; cursor: default; }
.etac-nudge__btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

/* Invite speech bubble — points up at the bell from just under the HUD */
.etac-bubble {
  position: absolute; right: clamp(13px, 3cqi, 22px); top: calc(100% + 10px);
  display: flex; align-items: center; gap: var(--space-2);
  background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-lg);
  box-shadow: 0 10px 30px rgba(0, 0, 0, .35);
  padding: var(--space-4) var(--space-6) var(--space-4) var(--space-4);
  opacity: 0; transform: translateY(-5px);
  transition: opacity .2s, transform .2s;
}
.etac-bubble.is-in { opacity: 1; transform: none; }
.etac-bubble::before {
  content: ''; position: absolute; top: -5px; right: 52px;
  width: 9px; height: 9px; transform: rotate(45deg);
  background: var(--bg-elevated);
  border-left: 1px solid var(--border); border-top: 1px solid var(--border);
}
.etac-bubble__body {
  font: 600 12.5px var(--fsans); color: var(--text); cursor: pointer;
  background: none; border: 0; padding: var(--space-5) var(--space-6); border-radius: var(--radius-sm);
}
.etac-bubble__body:hover { background: var(--bg-input); }
.etac-bubble__x {
  width: 22px; height: 22px; flex-shrink: 0; cursor: pointer;
  font: 600 14px/1 var(--fsans); color: var(--text-dim);
  background: none; border: 0; border-radius: 50%;
  display: grid; place-items: center;
}
.etac-bubble__x:hover { color: var(--text); background: var(--bg-input); }

/* "Your 17-day streak went out…" (hud.js paintStreakLost): the invite
   bubble's shell with the text allowed to wrap, its caret moved over the
   flame (--caret-r, measured in hud.js). On home's two-column layout the
   fire card beside the daily says the same line, so the bubble steps aside
   there (.is-home) — below 1001px that card has stacked under the daily and
   the bubble is the one on screen. */
.etac-bubble--streak { max-width: min(330px, calc(100% - 26px)); align-items: flex-start; }
.etac-bubble--streak::before { right: var(--caret-r, 52px); }
.etac-bubble--streak .etac-bubble__body {
  display: block; text-align: left; text-decoration: none; cursor: default;
  font: 500 13px/1.45 var(--fsans); color: var(--text-muted);
}
.etac-bubble--streak a.etac-bubble__body { cursor: pointer; }
.etac-bubble--streak .etac-bubble__body b { color: var(--text); font-weight: 700; }
.etac-bubble--streak.is-leaving {
  opacity: 0; transform: translateY(-4px);
  transition: opacity 200ms var(--ease-exit), transform 200ms var(--ease-exit);
}
@media (min-width: 1001px) { .etac-bubble--streak.is-home { display: none; } }

@media (prefers-reduced-motion: reduce) {
  .etac-modal, .etac-modal__card, .etac-bubble { transition: none !important; }
}

/* Daily card's "join your class" cue — a static banner inside
   .etac-daily__pad (never inside #daily-root, which daily.js's renders
   overwrite). Same dismiss idiom as .etac-bubble but laid out inline, not
   absolutely positioned off the HUD. */
.etac-classcue {
  display: flex; align-items: center; gap: var(--space-8);
  margin-top: var(--space-12); padding: var(--space-9) var(--space-10) var(--space-9) var(--space-12);
  background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--radius-md);
  opacity: 0; transform: translateY(-4px);
  transition: opacity .2s, transform .2s;
}
.etac-classcue.is-in { opacity: 1; transform: none; }
.etac-classcue__body {
  flex: 1; min-width: 0; font: 600 12.5px/1.4 var(--fsans); color: var(--text-muted);
  text-decoration: none;
}
.etac-classcue__body:hover { color: var(--text); text-decoration: underline; }
.etac-classcue__x {
  width: 22px; height: 22px; flex-shrink: 0; cursor: pointer;
  font: 600 14px/1 var(--fsans); color: var(--text-dim);
  background: none; border: 0; border-radius: 50%;
  display: grid; place-items: center;
}
.etac-classcue__x:hover { color: var(--text); background: var(--bg-elevated); }
@media (prefers-reduced-motion: reduce) {
  .etac-classcue { transition: none !important; }
}
.etac-home .etac-classcue { margin-top: 0; padding: var(--space-8) var(--space-8) var(--space-8) var(--space-16); border-radius: var(--radius-lg); }
.etac-home .etac-classcue__body { font: 650 14px/1.4 var(--fsans); color: var(--text); }
.etac-home .etac-classcue__body { display: inline-flex; align-items: center; gap: var(--space-4); }
.etac-home .etac-classcue__body .etac-chev { color: var(--accent); }
.etac-home .etac-classcue__x { width: 32px; height: 32px; font-size: 16px; }

/* Theme picker inside the panel — four elemental discs */
.etac-themes { display: flex; align-items: center; gap: var(--space-6); flex-wrap: wrap; }
.etac-themes button {
  width: 30px; height: 30px; border: 0; background: var(--bg-input); border-radius: 50%; padding: 0;
  cursor: pointer; font-size: 15px; line-height: 1; display: flex; align-items: center; justify-content: center;
  opacity: .6; transition: opacity .15s, background .15s, transform .1s, box-shadow .15s;
}
.etac-themes .etac-ico { width: 15px; height: 15px; background: var(--text); }
.etac-themes button:hover { opacity: 1; }
.etac-themes button[aria-pressed="true"] { opacity: 1; background: var(--accent-soft); box-shadow: inset 0 0 0 1.5px var(--accent); }
.etac-themes button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Stats never squish on small screens */
.etac-avatar, .etac-coin-wrap, .etac-coin, .etac-streak, .etac-kebab { flex-shrink: 0; }

/* Width-driven visibility (container queries against the HUD host) */
@container (max-width: 720px) {
  .etac-id-sub { display: none; }
  .etac-xp.etac-wide { display: none; }
}
@container (max-width: 480px) {
  .etac-stat-lab { display: none; }
  .etac-id-name { max-width: 90px; }
  .etac-hud { gap: var(--space-9); }
  .etac-stats { gap: var(--space-8); }
}

/* ===================== MAIN / HOME ===================== */
.etac-main { max-width: 1000px; margin: 0 auto; padding: clamp(18px, 3.2cqi, 34px) clamp(14px, 3cqi, 24px) 60px; }
.etac-eyebrow { font: 700 11px/1 var(--fsans); letter-spacing: 0.115em; text-transform: uppercase; color: var(--accent); }

/* ---------- Daily card (streak driver — non-interactive until the daily
     MCQ backend ships; see hud.js/roadmap. No fake XP is ever awarded.) ---------- */
.etac-daily {
  position: relative; overflow: hidden; border: 1px solid var(--border); border-radius: 18px;
  background: var(--bg-elevated);
}
.etac-daily__pad { padding: clamp(20px, 3cqi, 30px); position: relative; }
/* Pre-fetch placeholder in #daily-root — stem lines + five answer bars, so
   the card doesn't jump when the question lands. Reuses .etac-skel (F13). */
.etac-daily__skel { display: grid; gap: var(--space-8); margin-top: var(--space-14); }
/* The card is already --bg-elevated, the skeleton's base color — tint from
   --bg-input instead so the bars read against it. */
.etac-daily__skel .etac-skel { background-image: linear-gradient(90deg, var(--bg-input) 25%, var(--border) 37%, var(--bg-input) 63%); }
@media (prefers-reduced-motion: reduce) { .etac-daily__skel .etac-skel { background: var(--bg-input); } }
.etac-daily__head { display: flex; align-items: center; gap: var(--space-10); flex-wrap: wrap; margin-bottom: var(--space-14); }
.etac-chip-unit {
  font: 600 12px/1 var(--fsans); color: var(--text-muted);
  background: var(--bg-input); border: 1px solid var(--border); padding: var(--space-5) var(--space-10); border-radius: var(--radius-pill);
}
.etac-soon-tag {
  margin-left: auto; font: 700 12px/1 var(--fsans); letter-spacing: 0.072em; text-transform: uppercase;
  color: var(--text-muted); background: var(--bg-input); border: 1px solid var(--border-hover);
  border-radius: var(--radius-pill); padding: var(--space-6) 11px;
}
.etac-daily__body { display: flex; gap: var(--space-26); align-items: center; flex-wrap: wrap; }
.etac-daily__copy { flex: 1 1 320px; min-width: 0; }
/* h1 (promoted from h2 so the page has exactly one — see index.html). F10
   dropped the gradient text-fill this used to reset away from (sitewide
   h1 is plain now, and the accent .g span is gone) — plain display type. */
.etac-daily h1 { margin: 0 0 var(--space-8); font: 700 clamp(24px, 3.4cqi, 32px)/1.1 var(--fdisplay); letter-spacing: -.01em; color: var(--text); text-wrap: balance; }
.etac-daily__sub { margin: 0 0 var(--space-16); font: 400 15px/1.55 var(--fsans); color: var(--text-muted); max-width: 46ch; }
.etac-streaknudge {
  display: inline-flex; align-items: center; gap: var(--space-9); padding: var(--space-8) 13px; border-radius: var(--radius-lg);
  background: var(--accent-soft); border: 1px solid color-mix(in oklab, var(--accent) 35%, transparent); margin-bottom: var(--space-18);
}
.etac-streaknudge b { color: var(--text); font: 700 14px var(--fsans); }
.etac-streaknudge span { color: var(--text-muted); font: 500 13px var(--fsans); }
.etac-rewards { display: flex; gap: var(--space-8); flex-wrap: wrap; margin-bottom: var(--space-20); }
.etac-reward { display: inline-flex; align-items: center; gap: var(--space-6); font: 700 12px var(--fmono); padding: var(--space-6) 11px; border-radius: var(--radius-pill); }
.etac-reward.xp { color: var(--accent); border: 1px solid color-mix(in oklab, var(--accent) 45%, transparent); background: var(--accent-soft); }
.etac-reward.sp { color: var(--spark-ink); background: linear-gradient(160deg, var(--spark-1), var(--spark-2)); border: 1px solid var(--spark-bd); }

.etac-btn {
  font: 650 15px var(--fsans); border-radius: var(--radius-lg); padding: var(--space-14) 22px; cursor: pointer;
  border: 1px solid transparent; transition: transform .06s, filter .15s, background .15s, border-color .15s;
}
.etac-btn:active { transform: translateY(1px); }
.etac-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.etac-btn--primary {
  color: var(--accent-ink, #fff); background: var(--accent); border-color: var(--accent);
  /* Crisp lift only — a wide accent-tinted shadow here reads as a blurry
     glow around every CTA. */
  box-shadow: 0 2px 6px color-mix(in oklab, var(--accent) 20%, transparent);
}
/* Re-assert bg/border on hover: style.css's generic `button:hover` (loaded
   alongside hub.css on the hub pages) otherwise repaints this white. */
.etac-btn--primary:hover { filter: brightness(1.07); background: var(--accent); border-color: var(--accent); }
.etac-btn--ghost { color: var(--text); background: var(--bg-input); border-color: var(--border); }
.etac-btn--ghost:hover { border-color: var(--border-hover); }
.etac-btn:disabled { opacity: .55; cursor: not-allowed; box-shadow: none; filter: none; }
.etac-btn--sm { font-size: 13px; padding: var(--space-9) var(--space-16); }
.etac-btn--xs { font-size: 12px; padding: var(--space-7) 13px; }
a.etac-btn { text-decoration: none; display: inline-flex; align-items: center; }

.etac-daily__art {
  flex: 0 0 auto; width: 128px; height: 128px; border-radius: 20px; position: relative;
  background: radial-gradient(circle at 50% 64%, color-mix(in oklab, var(--accent) 30%, transparent), transparent 62%), var(--bg-input);
  border: 1px solid var(--border); display: grid; place-items: center;
}
.etac-daily__art .ring { width: 124px; height: 124px; border-radius: 50%; padding: var(--space-5);
  background: conic-gradient(from -90deg, var(--accent) calc(var(--pct, 0) * 3.6deg), var(--border) 0);
  transition: --pct 600ms var(--ease-enter); }
@media (prefers-reduced-motion: reduce) {
  .etac-daily__art .ring { transition: none; }
}
/* Inner padding keeps the label lines off the circle's curved edge. */
.etac-daily__art .ring > div { width: 100%; height: 100%; border-radius: 50%; background: var(--bg-elevated); display: grid; place-items: center; text-align: center; padding: var(--space-14); }
.etac-daily__art .num { font: 800 28px/1 var(--fsans); color: var(--text); }
.etac-daily__art .lab { font: 700 11.5px/1.3 var(--fsans); letter-spacing: 0.072em; color: var(--text-dim); text-transform: uppercase; margin-top: 3px; }
/* Self-set goal target under the streak count — the ring fills toward it. */
.etac-daily__art .lab.goal { color: var(--accent); margin-top: var(--space-4); font-size: 11px; letter-spacing: .06em; }

/* ---------- Daily MCQ (rendered by daily.js into #daily-root) ---------- */
.etac-q__stem { margin: 0 0 var(--space-14); font: 400 15px/1.6 var(--fsans); color: var(--text); max-width: 62ch; }
.etac-q__choices { display: grid; gap: var(--space-8); margin-bottom: var(--space-16); max-width: 62ch; }
.etac-q__choice {
  display: flex; align-items: baseline; gap: var(--space-12); width: 100%; text-align: left; cursor: pointer;
  font: 500 14px/1.45 var(--fsans); color: var(--text);
  background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: var(--space-12) var(--space-14);
  transition: border-color .12s, background .12s, transform .06s;
}
.etac-q__choice:hover:not(:disabled) { border-color: var(--border-hover); }
.etac-q__choice:active:not(:disabled) { transform: translateY(1px); }
.etac-q__choice:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.etac-q__choice.is-selected {
  border-color: var(--accent); background: var(--accent-soft);
  box-shadow: 0 0 0 1px var(--accent) inset;
}
.etac-q__choice.is-locked { cursor: default; opacity: .75; }
.etac-q__choice.is-locked.is-correct {
  opacity: 1; border-color: var(--success-fg); color: var(--text);
  background: color-mix(in oklab, var(--success-fg) 12%, var(--bg-input));
}
.etac-q__choice.is-locked.is-correct .etac-q__key { background: var(--success-fg); color: var(--bg-elevated); border-color: transparent; }
.etac-q__choice.is-locked.is-wrong {
  opacity: 1; border-color: var(--warning-bd); background: var(--warning-bg);
}
.etac-q__choice.is-locked.is-wrong .etac-q__key { background: var(--warning-fg); color: var(--bg-elevated); border-color: transparent; }
.etac-q__key {
  flex: 0 0 auto; display: inline-grid; place-items: center; width: 22px; height: 22px;
  border-radius: 7px; font: 700 11px var(--fmono); align-self: center;
  color: var(--text-muted); background: var(--bg-elevated); border: 1px solid var(--border);
}
.etac-q__choice.is-selected .etac-q__key { background: var(--accent); color: var(--accent-ink, #fff); border-color: transparent; }
.etac-q__ctext { min-width: 0; }
.etac-reward.streak { color: var(--text-muted); border: 1px solid var(--border); background: var(--bg-input); }

.etac-q__verdict {
  display: flex; align-items: baseline; gap: var(--space-9); flex-wrap: wrap;
  border-radius: var(--radius-lg); padding: 11px var(--space-14); margin-bottom: var(--space-12); max-width: 62ch;
}
.etac-q__verdict b { font: 700 14px var(--fsans); color: var(--text); }
.etac-q__verdict.is-correct {
  background: color-mix(in oklab, var(--success-fg) 12%, var(--bg-input));
  border: 1px solid color-mix(in oklab, var(--success-fg) 45%, transparent);
}
.etac-q__verdict.is-wrong { background: var(--warning-bg); border: 1px solid var(--warning-bd); }
/* Streak-goal completed — gold like the Sparks it pays out. */
.etac-q__verdict.is-goal {
  background: linear-gradient(160deg, var(--spark-1), var(--spark-2));
  border: 1px solid var(--spark-bd);
}
.etac-q__verdict.is-goal b, .etac-q__verdict.is-goal .etac-q__vawards { color: var(--spark-ink); }
.etac-q__vemoji { font-size: 17px; align-self: center; color: var(--text); }
.etac-q__verdict.is-correct .etac-q__vemoji { color: var(--success-fg); }
.etac-q__verdict.is-wrong .etac-q__vemoji { color: var(--warning-fg); }
.etac-q__verdict.is-goal .etac-q__vemoji { color: var(--spark-ink); }
.etac-q__vawards { font: 700 12px var(--fmono); color: var(--text-muted); }
.etac-q__verdict.is-correct .etac-q__vawards { color: var(--success-fg); }
.etac-q__why {
  font: 400 14px/1.6 var(--fsans); color: var(--text-muted); max-width: 62ch;
  background: var(--bg-input); border: 1px solid var(--border);
  border-radius: var(--radius-md); padding: 11px var(--space-14); margin-bottom: var(--space-12);
}
.etac-q__why b { color: var(--text); }
.etac-q__note { margin: 0 0 var(--space-14); font: 500 13px/1.5 var(--fsans); color: var(--text-muted); }
.etac-q__note.is-error { color: var(--warning-fg); margin-top: var(--space-10); }

/* Fresh-answer celebration + the "sign in up top" nudge. Both sit still
   for reduced-motion users. */
@keyframes etac-q-pop {
  0% { transform: scale(.92); opacity: 0; }
  60% { transform: scale(1.03); }
  100% { transform: scale(1); opacity: 1; }
}
.etac-q__verdict.pop { animation: etac-q-pop .38s cubic-bezier(.2, .9, .3, 1.2) both; }
@keyframes etac-nudge-pulse {
  0%, 100% { box-shadow: 0 0 0 0 transparent; }
  35% { box-shadow: 0 0 0 5px color-mix(in oklab, var(--accent) 45%, transparent); }
}
.etac-nudge { animation: etac-nudge-pulse 1s ease 2; border-radius: var(--radius-lg); }
@media (prefers-reduced-motion: reduce) {
  .etac-q__verdict.pop, .etac-nudge { animation: none; }
}

/* ===================== HOME (index.html) =====================
   Home v3, "Today" — docs/plans/home-redesign/SPEC-v3.md is the rulebook and
   v3-today.dc.html beside it is the design. It supersedes SPEC.md's page order.
   Everything is scoped under .etac-home: .etac-daily/.etac-daily__* are
   still about.html's and 404.html's hero, .etac-q__* is still practice.js's
   question card, and site.js's status bar owns its own .etac-chip.

     Layout    a missions strip, then the question (left) beside a 300px
               stats rail (right: fire, unit mastery, rank + class), then one
               row of doors to every feature. The question is the page.
     Surfaces  .etac-raised (the question) / .etac-home-card (the rail)
     Themes    Nothing here names a color: it all reads the theme tokens, so
               all six themes come for free. --accent is the ONE theme voice
               (progress, your row, the tally, the picked answer, the primary
               button). Fixed on purpose, same in every theme, because color
               is a vocabulary: gold = Sparks, --frost = a Streak Freeze,
               green/red = right/wrong, the tier's own color = rank.
     Type      display = --fdisplay, numbers tabular, nothing under 12px
     Never     glow, gradient text, emoji icons, arrow glyphs in links (the
               SVG .etac-chev instead), card hover-grow
     Contrast  --border-hover on --bg-input is under 3:1 in five themes, so
               control ledges and letter badges use --text-dim. */
.etac-home {
  /* 1120px of content plus the 24px side gutters .etac-main already has. */
  max-width: calc(1120px + 2 * var(--space-24));
  display: flex; flex-direction: column; gap: var(--space-24);
  padding-top: var(--space-24); padding-bottom: 48px;
  font: 400 16px/1.45 var(--fsans); color: var(--text);
  --accent-deep: color-mix(in oklab, var(--accent), #000 32%);
  /* A Streak Freeze's ice — fixed like Sparks gold, so a frozen day reads the
     same in every theme. The ink is pulled toward --text so it clears 3:1 on
     the light themes' cards and the dark ones alike. */
  --frost: #3b8fd1;
  --frost-ink: color-mix(in oklab, var(--frost), var(--text) 30%);
}
.etac-home [hidden] { display: none !important; }
.etac-home h2, .etac-home h3, .etac-home p { margin: 0; }
.etac-home .etac-num { font-variant-numeric: tabular-nums; }
.etac-home .etac-muted { color: var(--text-muted); }
.etac-home .etac-muted b { color: var(--text); }
.etac-home .etac-display { font-family: var(--fdisplay); font-weight: 700; letter-spacing: -.01em; }

.etac-home .etac-raised,
.etac-home-card {
  background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 16px;
  box-shadow: 0 1px 0 var(--border), 0 8px 24px rgba(0, 0, 0, .12);
}

/* Masked PNG icons tinted with a token (.etac-ico is 24px accent). */
.etac-home .etac-ico { flex: none; }

.etac-home .etac-chip {
  display: inline-flex; align-items: center; height: 28px; padding: 0 var(--space-12);
  border-radius: var(--radius-pill); border: 1px solid var(--border);
  font: 650 12px/1 var(--fsans); color: var(--text-muted); white-space: nowrap;
}

.etac-home .etac-link {
  display: inline-flex; align-items: center; gap: var(--space-4);
  font: 700 14px var(--fsans); color: var(--accent); text-decoration: none;
}
.etac-home a.etac-link:hover span { text-decoration: underline; text-underline-offset: 3px; }
.etac-home .etac-link:focus-visible,
.etac-dock__item:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius-sm); }
.etac-chev {
  width: 16px; height: 16px; flex: none;
  fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;
}

/* Progress bar — the fill is full-width and scaled, never sized. */
.etac-home .etac-bar {
  position: relative; display: block; height: 8px; border-radius: var(--radius-pill);
  background: var(--bg-input); border: 1px solid var(--border); overflow: hidden;
}
.etac-home .etac-bar__fill {
  display: block; width: 100%; height: 100%; border-radius: var(--radius-pill);
  background: var(--accent); transform: scaleX(0); transform-origin: left;
  transition: transform .6s var(--ease-enter);
}

/* ---------- Hello + today's missions ----------
   The greeting is always there. The mission track is the quests system's
   surface: hidden (and the strip a plain greeting) until home.js is handed
   quests — see "Quests" in home.js for the payload. */
.etac-quests { display: flex; flex-direction: column; gap: var(--space-4); }
.etac-quests__hi { font: 700 28px/1.15 var(--fdisplay); letter-spacing: -.01em; color: var(--text); overflow-wrap: anywhere; }
.etac-quests__line { font-size: 14px; color: var(--text-muted); }
.etac-quests.is-live {
  display: grid; grid-template-columns: 264px minmax(0, 1fr); align-items: center; gap: var(--space-24);
  padding: 20px var(--space-32); overflow: hidden;
  background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 16px;
  box-shadow: 0 1px 0 var(--border), 0 8px 24px rgba(0, 0, 0, .12);
}
.etac-quests.is-live .etac-quests__hi { font-size: 24px; }
.etac-quests__hello { display: flex; flex-direction: column; gap: var(--space-4); min-width: 0; }
/* N missions + the chest = N+1 equal columns. The rail runs from the first
   column's centre to the last's; its fill is scaled, never sized. */
.etac-quests__track {
  --n: 3;
  position: relative; display: grid; grid-template-columns: repeat(calc(var(--n) + 1), minmax(0, 1fr)); align-items: start;
}
.etac-quests__rail, .etac-quests__fill {
  position: absolute; top: 17px; height: 4px; border-radius: 2px;
  left: calc(50% / (var(--n) + 1)); right: calc(50% / (var(--n) + 1));
}
.etac-quests__rail { background: var(--bg-input); box-shadow: inset 0 0 0 1px var(--border); }
.etac-quests__fill { background: var(--accent); transform: scaleX(var(--done-frac, 0)); transform-origin: left; transition: transform .6s var(--ease-enter); }
/* Every node sits centred in its column, so the rail (which runs from the
   first column's centre to the last's) meets each dot and the chest. */
.etac-quest, .etac-quests__chest { position: relative; display: flex; flex-direction: column; align-items: center; text-align: center; gap: var(--space-8); min-width: 0; }
.etac-quest__dot {
  display: grid; place-items: center; width: 38px; height: 38px; box-sizing: border-box; border-radius: 50%;
  background: var(--bg-elevated); border: 2px solid var(--text-dim); color: var(--text-muted);
  font: 800 15px/1 var(--fsans);
}
.etac-quest.is-done .etac-quest__dot { background: var(--accent); border-color: var(--accent); color: var(--accent-ink, #fff); }
.etac-quest__check { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
.etac-quest__title, .etac-quests__chest b { display: block; font: 700 14px/1.25 var(--fsans); color: var(--text-muted); }
.etac-quest.is-done .etac-quest__title { color: var(--text); }
/* A mission still to do that lives on another page (practice) links there. */
a.etac-quest__title {
  color: var(--text); text-decoration: underline; text-decoration-color: var(--border-hover);
  text-decoration-thickness: 2px; text-underline-offset: 3px;
}
a.etac-quest__title:hover { text-decoration-color: var(--accent); }
.etac-quest__sub, .etac-quests__chest span.etac-quest__sub { display: block; font: 400 12px/1.3 var(--fsans); color: var(--text-dim); }
/* The chest: Sparks gold like every reward, its lid state carried by data-state. */
.etac-quests__chestbox {
  display: grid; place-items: center; width: 38px; height: 38px; box-sizing: border-box; border-radius: 12px;
  background: var(--spark-2); border: 1px solid var(--spark-bd); border-bottom-width: 3px; color: var(--spark-ink);
}
.etac-home .etac-quests__chestbox .etac-ico { width: 20px; height: 20px; }
.etac-quests__chest[data-state="locked"] .etac-quests__chestbox { background: var(--bg-input); border-color: var(--border); color: var(--text-dim); }
.etac-quests__chest[data-state="opened"] .etac-quests__chestbox { background: var(--bg-input); border-color: var(--spark-bd); color: var(--spark-bd); }
/* Ready to open: a real button (home.js; daily.js takes the tap), with a
   ring that breathes outward (transform + opacity only). The whole chest
   column answers a click, not just the box. */
.etac-quests__chest[data-state="ready"] { cursor: pointer; }
button.etac-quests__chestbox { padding: 0; font: inherit; cursor: pointer; }
button.etac-quests__chestbox:focus-visible { outline: 2px solid var(--accent); outline-offset: 5px; }
.etac-quests__chest[data-state="ready"] .etac-quests__chestbox { position: relative; cursor: pointer; }
.etac-quests__chest[data-state="ready"] .etac-quests__chestbox::after {
  content: ""; position: absolute; inset: -5px; border-radius: 15px; border: 2px solid var(--spark-2);
  animation: etac-chest-ring 2.2s var(--ease-enter) infinite;
}
@keyframes etac-chest-ring { 0% { transform: scale(.9); opacity: .9; } 70%, 100% { transform: scale(1.18); opacity: 0; } }

/* ---------- The stage: the question beside its stats rail ---------- */
.etac-stage { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: var(--space-24); align-items: stretch; }
.etac-rail { display: flex; flex-direction: column; gap: 20px; min-width: 0; }

/* ---------- Daily question card ---------- */
.etac-home .etac-daily { display: flex; flex-direction: column; border-radius: 16px; overflow: visible; }
.etac-home .etac-daily__pad { display: flex; flex-direction: column; flex: 1 1 auto; padding: 0; }
/* #daily-root IS the card: a band (the caveman talking + the unit), then the
   question in booklet order — stem, then (A)–(E) directly under it. */
.etac-home .etac-daily__body { display: flex; flex-direction: column; flex: 1 1 auto; flex-wrap: nowrap; align-items: stretch; gap: 0; min-width: 0; }
.etac-home .etac-daily__pad > .etac-classcue { margin: 0 var(--space-24) var(--space-24); }
.etac-home .etac-daily__body > .etac-nudge { margin: 0 var(--space-32) var(--space-32); }
.etac-home .etac-daily__sub { margin: 0; font: 400 16px/1.5 var(--fsans); color: var(--text-muted); max-width: 46ch; }
.etac-home .etac-daily__skel { margin: 0; gap: var(--space-8); }

.etac-dq__band {
  display: flex; align-items: center; gap: var(--space-12);
  padding: var(--space-12) var(--space-24);
  background: var(--accent-soft); border-bottom: 1px solid var(--border);
  border-radius: 15px 15px 0 0;
}
.etac-dq__band .etac-chip { margin-left: auto; background: var(--bg-elevated); }
.etac-dq__band > .etac-chip:only-child { margin-left: 0; }
/* Answered: the attempt folds away behind this button. */
.etac-dq__see {
  align-self: flex-start; padding: var(--space-8) var(--space-16); cursor: pointer;
  border: 1px solid var(--border); border-radius: var(--radius-md);
  background: var(--bg-input); color: var(--text); font: 700 15px var(--fsans);
}
.etac-dq__see:hover { border-color: var(--border-hover); }

/* Answered and folded: the caveman resting by a fire, filling the space the
   question leaves. All scenery; nothing here is interactive. */
.etac-dq__rest { display: flex; flex: 1 1 auto; flex-direction: column; align-items: center; justify-content: center; gap: var(--space-12); padding: var(--space-16) 0; min-height: 200px; }
.etac-dq__rest[hidden] { display: none; }
.etac-dq__scene {
  position: relative; display: flex; align-items: flex-end; justify-content: center; gap: var(--space-8);
  padding: var(--space-24) var(--space-32) 0; border-bottom: 3px solid var(--border); border-radius: 2px;
  background: radial-gradient(60% 70% at 70% 88%, color-mix(in oklab, var(--streak) 26%, transparent), transparent 70%);
}
.etac-dq__restcave { position: relative; display: block; }
.etac-dq__restcave .caveman-svg { width: 150px; height: auto; margin: 0; color: var(--text); animation-duration: 6s; }
.etac-dq__fire { width: 96px; height: auto; margin-bottom: -2px; overflow: visible; }
.etac-dq__fire .etac-dq__flame { transform-box: fill-box; transform-origin: 50% 100%; animation: etac-dq-flicker 1.6s ease-in-out infinite; stroke: none; }
.etac-dq__flame.is-back { fill: var(--streak); }
.etac-dq__flame.is-mid { fill: color-mix(in oklab, var(--streak), #ffd45a 55%); animation-delay: -.5s; animation-duration: 1.3s; }
.etac-dq__flame.is-core { fill: #fff3c4; animation-delay: -.9s; animation-duration: 1.1s; }
.etac-dq__logs rect { fill: color-mix(in oklab, var(--text-dim), #5b3a1e 55%); stroke: none; }
.etac-dq__ember { fill: var(--streak); animation: etac-dq-ember 2.6s ease-out infinite; }
.etac-dq__ember.is-b { animation-delay: -.9s; }
.etac-dq__ember.is-c { animation-delay: -1.7s; }
@keyframes etac-dq-flicker {
  0%, 100% { transform: scale(1, 1); }
  35% { transform: scale(.94, 1.07); }
  70% { transform: scale(1.04, .95); }
}
@keyframes etac-dq-ember {
  0% { opacity: 0; transform: translateY(8px); }
  20% { opacity: .9; }
  100% { opacity: 0; transform: translateY(-26px); }
}
/* Sleepy z's drifting off his head. */
.etac-dq__zz {
  position: absolute; top: 4px; right: 6px; font: 800 20px/1 var(--fdisplay); font-style: normal;
  color: var(--text-dim); opacity: 0; animation: etac-dq-zz 3.6s ease-out infinite;
}
.etac-dq__zz.is-b { right: -8px; top: -14px; font-size: 15px; animation-delay: 1.4s; }
@keyframes etac-dq-zz {
  0% { opacity: 0; transform: translate(0, 6px); }
  25% { opacity: .85; }
  100% { opacity: 0; transform: translate(10px, -22px); }
}
.etac-dq__restnote { margin: 0; font: 600 15px/1.4 var(--fsans); color: var(--text-muted); text-align: center; }
/* Under the scene, the handoff: the same chunky button as Lock in, as a link
   into a practice set, and what that set pays beneath it. */
.etac-home .etac-dq__next { display: flex; flex-direction: column; align-items: stretch; gap: var(--space-10); width: 100%; max-width: 360px; }
.etac-home a.etac-dq__more { display: flex; align-items: center; justify-content: center; margin-top: var(--space-4); text-decoration: none; }
.etac-home p.etac-dq__pays { margin: 0; text-align: center; }
@media (prefers-reduced-motion: reduce) {
  .etac-dq__fire .etac-dq__flame, .etac-dq__ember, .etac-dq__zz, .etac-dq__restcave .caveman-svg { animation: none; }
  .etac-dq__zz { opacity: .7; }
}
.etac-dq__review { display: flex; flex-direction: column; gap: var(--space-16); }
.etac-dq__review[hidden], .etac-dq__see[hidden] { display: none; }
.etac-dq__main { display: flex; flex-direction: column; flex: 1 1 auto; gap: var(--space-16); min-width: 0; padding: 28px var(--space-32) var(--space-32); }
.etac-home .etac-daily__body > .etac-dq__solo { padding: var(--space-32); }
/* The stem as the exam sets it: one paragraph of reading type, the ask as its
   last sentence, nothing pulled out. Bigger than a passage usually gets: the
   question is the page. */
.etac-home .etac-dq__stem {
  margin: 0; max-width: 64ch;
  font: 500 22px/1.5 var(--fsans); letter-spacing: 0;
  color: var(--text); text-wrap: pretty;
}

/* The caveman: a bust in a tile, beside the line he's saying. */
.etac-dq__cave { display: flex; align-items: center; gap: var(--space-12); min-width: 0; flex: 1 1 auto; }
.etac-dq__mascot {
  position: relative; flex: none; display: grid; place-items: center;
  width: 56px; height: 56px; overflow: hidden; border-radius: 16px;
  background: var(--bg-elevated); border: 1px solid var(--border);
}
.etac-dq__mascot .caveman-svg { position: relative; width: 50px; height: auto; margin: 0; color: var(--text); }
.etac-dq__mascot .caveman-svg .eye { fill: var(--bg-elevated); }
/* The worried pose (caveman.js): brows cut into the head, and the flame he's
   holding out in the streak's own orange. */
.etac-dq__mascot .caveman-svg .cut { fill: none; stroke: var(--bg-elevated); stroke-width: 3; stroke-linecap: round; }
.etac-dq__mascot .caveman-svg .flame { fill: var(--streak); stroke: none; }
/* The caveman's reaction to an answer (js/motion.js mascotReact). */
.etac-dq__mascot.is-motion-hop .caveman-svg { animation: etac-motion-hop calc(640ms * var(--etac-pace, 1)) cubic-bezier(.34, 1.56, .64, 1) both; }
@keyframes etac-motion-hop {
  0%   { transform: translateY(0) scale(1, 1); }
  16%  { transform: translateY(0) scale(1.07, .9); }
  44%  { transform: translateY(-6px) scale(.95, 1.06); }
  72%  { transform: translateY(0) scale(1.05, .94); }
  100% { transform: translateY(0) scale(1, 1); }
}
.etac-dq__mascot.is-motion-slump .caveman-svg { animation: etac-motion-slump calc(720ms * var(--etac-pace, 1)) var(--ease-enter) both; }
@keyframes etac-motion-slump {
  0%   { transform: translateY(0) rotate(0deg); }
  35%  { transform: translateY(3px) rotate(-4deg) scale(1, .97); }
  100% { transform: translateY(0) rotate(0deg) scale(1, 1); }
}
/* …and to a streak that went out (js/motion.js streakLost): a small dip,
   then chin up — encouragement, never a slump. */
.etac-dq__mascot.is-motion-nod .caveman-svg { animation: etac-motion-nod calc(860ms * var(--etac-pace, 1)) var(--ease-enter) both; transform-origin: 50% 90%; }
@keyframes etac-motion-nod {
  0%   { transform: translateY(0) rotate(0deg) scale(1, 1); }
  22%  { transform: translateY(2px) rotate(-3deg) scale(1, .97); }
  55%  { transform: translateY(-4px) rotate(2deg) scale(1, 1.03); }
  80%  { transform: translateY(1px) rotate(0deg) scale(1, .99); }
  100% { transform: translateY(0) rotate(0deg) scale(1, 1); }
}
@media (prefers-reduced-motion: reduce) {
  .etac-dq__mascot.is-motion-hop .caveman-svg, .etac-dq__mascot.is-motion-slump .caveman-svg,
  .etac-dq__mascot.is-motion-nod .caveman-svg { animation: none; }
}
/* In the band the bubble is just his line: the tile beside it already says who. */
.etac-dq__bubble { margin: 0; font: 600 16px/1.4 var(--fsans); color: var(--text); min-width: 0; }
.etac-home .etac-dq__bubble b { font: inherit; font-weight: 800; color: var(--text); }
.etac-home .etac-dq__bubble.etac-dq__ok b { color: var(--success-fg); }

/* The verdict is the line + award chips — restyled from practice's banner,
   classes kept for js/motion.js. */
.etac-home .etac-dq__cave .etac-q__verdict {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-4) var(--space-12);
  min-width: 0; max-width: none; margin: 0; padding: 0; background: none; border: 0; border-radius: 0;
}
.etac-home .etac-q__vawards { font: 700 14px var(--fsans); }
.etac-home .etac-dq__cave .etac-q__vawards {
  display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-8); color: var(--text-muted);
}
.etac-home .etac-dq__aside { font-weight: 400; margin-left: var(--space-4); }
.etac-award {
  display: inline-flex; align-items: center; height: 28px; padding: 0 var(--space-12);
  border-radius: var(--radius-sm); background: var(--bg-input); border: 1px solid var(--border);
  font: 800 13px var(--fsans); color: var(--text); font-variant-numeric: tabular-nums;
}
.etac-award.is-spark { background: var(--spark-1); border-color: var(--spark-bd); color: var(--spark-ink); }
.etac-award.is-xp { background: var(--accent-soft); border-color: color-mix(in oklab, var(--accent) 50%, transparent); }
.etac-award.is-muted { color: var(--text-muted); }

/* What a right answer pays, under Lock in (numbers are the server's own). */
.etac-dq__pays { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: var(--space-8); font-size: 13px; color: var(--text-muted); }
.etac-dq__pays .etac-award { height: 24px; font-size: 12px; padding: 0 var(--space-8); }
.etac-award .etac-mico { width: 12px; height: 12px; margin-right: var(--space-4); }

/* Goal-complete and rank-change lines under the reveal — flat fills. */
.etac-home .etac-dq__main .etac-q__verdict.is-goal,
.etac-home .etac-dq__main .etac-q__verdict.is-drop {
  margin: 0; max-width: none; align-items: center; gap: var(--space-8) var(--space-12);
  padding: var(--space-12) var(--space-16); border-radius: var(--radius-lg);
  display: flex; flex-wrap: wrap;
}
.etac-home .etac-dq__main .etac-q__verdict.is-goal { background: var(--spark-1); border: 1px solid var(--spark-bd); color: var(--spark-ink); }
.etac-home .etac-dq__main .etac-q__verdict.is-goal b { color: var(--spark-ink); }
.etac-home .etac-dq__main .etac-q__verdict.is-drop { background: var(--bg-input); border: 1px solid var(--border); }
.etac-home .etac-dq__rankline img.etac-q__vemoji { width: 32px; height: 32px; object-fit: contain; }

/* Choices: 64px, 1.5px border, a 3px --text-dim ledge that drops on press. */
.etac-home .etac-q__choices { gap: 10px; margin: 0; max-width: none; }
.etac-home .etac-q__choice {
  align-items: center; gap: var(--space-16); min-height: 64px; padding: var(--space-12) 18px;
  background: var(--bg-input); border: 1.5px solid var(--border-hover); border-radius: 14px;
  box-shadow: 0 3px 0 var(--text-dim); font: 500 17px/1.35 var(--fsans); color: var(--text);
  transition: transform .08s var(--ease-enter), box-shadow .08s var(--ease-enter), border-color .12s;
}
.etac-home .etac-q__choice:hover:not(:disabled) { background: var(--bg-input); border-color: var(--text-dim); }
.etac-home .etac-q__choice:active:not(:disabled) { transform: translateY(2px); box-shadow: 0 1px 0 var(--text-dim); }
.etac-home .etac-q__choice:disabled { opacity: 1; cursor: default; }
.etac-home .etac-q__ctext { flex: 1 1 auto; }
.etac-home .etac-q__key {
  width: 36px; height: 36px; border-radius: 10px; background: transparent;
  border: 1.5px solid var(--text-dim); font: 800 15px var(--fsans); color: var(--text-muted);
}
.etac-home .etac-q__choice.is-selected,
.etac-home .etac-q__choice.is-selected:hover:not(:disabled) {
  border-color: var(--accent); background: var(--accent-soft); box-shadow: 0 3px 0 var(--accent);
}
.etac-home .etac-q__choice.is-selected .etac-q__key { background: var(--accent); border-color: var(--accent); color: var(--accent-ink, #fff); }
.etac-home .etac-q__choice.is-locked { opacity: .55; }
.etac-home .etac-q__choice.is-locked.is-correct,
.etac-home .etac-q__choice.is-locked.is-wrong { opacity: 1; background: var(--bg-input); }
.etac-home .etac-q__choice.is-locked.is-correct { border-color: var(--success-fg); box-shadow: 0 3px 0 var(--success-fg); }
.etac-home .etac-q__choice.is-locked.is-wrong { border-color: var(--warning-fg); box-shadow: 0 3px 0 var(--warning-fg); }
/* Letters on the verdict colors use --bg-input, not --bg-elevated: the
   lighter ink clears 4.5:1 on --success-fg in all four light themes. */
.etac-home .etac-q__choice.is-locked.is-correct .etac-q__key { background: var(--success-fg); border-color: var(--success-fg); color: var(--bg-input); }
.etac-home .etac-q__choice.is-locked.is-wrong .etac-q__key { background: var(--warning-fg); border-color: var(--warning-fg); color: var(--bg-input); }
.etac-q__mark {
  width: 24px; height: 24px; flex: none;
  fill: none; stroke-width: 2.6; stroke-linecap: round; stroke-linejoin: round;
}
.etac-q__choice.is-correct .etac-q__mark { stroke: var(--success-fg); }
.etac-q__choice.is-wrong .etac-q__mark { stroke: var(--warning-fg); }

/* Primary button. Ready: accent face on a 5px darker ledge. Pressed: the
   ledge collapses to 1px and a transparent 4px top border takes up the
   slack, so the face drops without the button changing height. Disabled
   ("Pick an answer"): a flat dashed outline, no ledge, no fill. */
.etac-dq__go {
  display: block; width: 100%; height: 58px; margin-top: auto; padding: 0;
  border: 0 solid transparent; border-bottom: 5px solid color-mix(in oklab, var(--accent), #000 32%);
  border-radius: 14px; background: var(--accent); background-clip: padding-box;
  color: var(--accent-ink, #fff); font: 700 18px var(--fdisplay); cursor: pointer;
}
.etac-dq__go:hover:not(:disabled) { background: var(--accent); border-color: transparent; border-bottom-color: color-mix(in oklab, var(--accent), #000 32%); }
.etac-dq__go:active:not(:disabled) { transform: none; border-top-width: 4px; border-bottom-width: 1px; }
.etac-dq__go:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.etac-dq__go:disabled { opacity: 1; }
.etac-dq__go:disabled:not(.is-busy) {
  background: transparent; border: 1.5px dashed var(--border-hover);
  color: var(--text-muted); font-weight: 700; cursor: not-allowed;
}
.etac-dq__go.is-busy { cursor: progress; }

.etac-home .etac-q__note { margin: 0; font: 400 14px/1.5 var(--fsans); color: var(--text-muted); }
.etac-home .etac-q__note.is-error { color: var(--warning-fg); margin-top: var(--space-4); }
.etac-home .etac-q__why {
  margin: var(--space-4) 0 0; max-width: none; padding: var(--space-16) 18px;
  display: flex; flex-direction: column; gap: var(--space-4);
  border-radius: 14px; background: var(--bg-input); border: 1px solid var(--border);
  font: 400 15px/1.55 var(--fsans); color: var(--text-muted);
}
.etac-home .etac-q__why h3 { font: 700 16px var(--fdisplay); color: var(--text); }

/* ---------- The stats rail ---------- */
.etac-home-card { display: flex; flex-direction: column; gap: var(--space-12); padding: 20px; min-width: 0; }
.etac-home-card__head { display: flex; align-items: baseline; gap: var(--space-8); }
.etac-home-card__head h2, .etac-home-card > h2 { font: 700 17px/1.2 var(--fdisplay); color: var(--text); flex: 1 1 auto; }
.etac-home-card__sub { font-size: 13px; }
.etac-home-card__big { font-size: 22px; line-height: 1; color: var(--accent); }
.etac-home-card__foot {
  display: flex; align-items: baseline; gap: var(--space-8);
  padding-top: var(--space-12); border-top: 1px solid var(--border);
}
.etac-home-card__foot > .etac-link { margin-left: auto; font-size: 13px; }
.etac-home-card__foot > span { font-size: 13px; }

/* Your fire */
.etac-home-fire { gap: var(--space-16); }
.etac-fire__top { display: flex; align-items: center; gap: var(--space-16); }
/* The tally: a caveman's count on the cave wall. Hand-drawn strokes in the
   theme's own color; today's mark stays dashed until you answer. */
.etac-fire__tally { flex: none; overflow: visible; height: 30px; width: auto; max-width: 130px; fill: none; stroke: var(--accent); stroke-width: 3.8; stroke-linecap: round; }
.etac-fire__tally .is-open { stroke-dasharray: 3 5; opacity: .6; }
.etac-fire__count { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.etac-fire__n { font-size: 28px; line-height: 1; }
/* daily.js's state line ("Ends in 3h", "Next one in 5h", "Answer to keep it
   going") and hud.js's "2 days to your 14-day goal", side by side. */
.etac-fire__status { display: flex; flex-direction: column; gap: 2px; font: 700 12px/1.3 var(--fsans); color: var(--text-muted); }
.etac-home .etac-daily__state { display: block; }
.etac-home .etac-daily__state:empty { display: none; }
.etac-home .etac-daily__state > span { display: block; }
.etac-home .etac-daily__state b { color: var(--text); font-weight: 800; }
.etac-home .etac-daily__stateico { display: inline-block; width: 13px; height: 13px; margin-right: var(--space-4); vertical-align: -2px; }
.etac-home svg.etac-daily__stateico { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; }
.etac-home .etac-daily__state.is-risk { color: var(--text); }
.etac-home .etac-daily__goal { font: 400 12px/1.3 var(--fsans); color: var(--text-muted); }
.etac-fire__status.is-answered .etac-daily__goal { display: none; }

/* A streak that went out (home.js paintLost): the caveman pointing at the
   daily, the line, a ×. An accent wash, not a warning colour — it's a way
   back, not an error. */
.etac-fire__lost {
  display: flex; align-items: center; gap: var(--space-12);
  padding: var(--space-10) var(--space-6) var(--space-10) var(--space-10);
  background: var(--accent-soft); border: 1px solid color-mix(in oklab, var(--accent) 30%, transparent);
  border-radius: var(--radius-lg);
}
.etac-fire__lostcave { width: 44px; height: 44px; border-radius: 12px; }
.etac-fire__lostcave .caveman-svg { width: 40px; }
.etac-home .etac-fire__losttext { flex: 1 1 auto; min-width: 0; font: 400 13px/1.45 var(--fsans); color: var(--text-muted); }
.etac-fire__losttext b { color: var(--text); font-weight: 700; }
.etac-fire__lostx {
  align-self: flex-start; width: 36px; height: 36px; margin: -4px -2px 0 0; flex-shrink: 0; cursor: pointer;
  font: 600 18px/1 var(--fsans); color: var(--text-muted);
  background: none; border: 0; border-radius: 50%;
  display: grid; place-items: center;
}
.etac-fire__lostx:hover { color: var(--text); background: var(--bg-elevated); }
.etac-fire__lostx:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.etac-fire__lost.is-leaving {
  opacity: 0; transform: translateY(-4px);
  transition: opacity 200ms var(--ease-exit), transform 200ms var(--ease-exit);
}
@media (prefers-reduced-motion: reduce) { .etac-fire__lost.is-leaving { transition: none; } }
/* The same spot while a lost streak is being won back (progress.rekindle):
   a goal in progress, so no × (home.js hides it) and its practice link
   sits inline with the line. */
.etac-fire__losttext .etac-link { white-space: nowrap; }
/* Practice results while winning a streak back (practice.js rekindleNote):
   an edge in the flame's own fixed colour (--streak), text kept on the
   theme's ramp so it clears contrast in every theme. */
.etac-practice__rekindle {
  max-width: 62ch; box-sizing: border-box;
  padding: var(--space-8) var(--space-12); margin-top: var(--space-12);
  border-left: 3px solid var(--streak); border-radius: var(--radius-xs);
  background: color-mix(in oklab, var(--streak) 8%, transparent);
}
.etac-practice__rekindle b { color: var(--text); font-weight: 700; }
/* The free first Streak Freeze's note under the verdict (daily.js giftNote),
   its icon in a freeze's ice like every other freeze on the page. */
.etac-home .etac-dq__gift .etac-mico { color: var(--frost-ink); }

.etac-fire__week { display: flex; flex-direction: column; gap: var(--space-8); padding-top: var(--space-16); border-top: 1px solid var(--border); }
.etac-fire__week > h3 { font: 700 13px var(--fsans); color: var(--text-muted); }
.etac-week { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 6px; margin: 0; padding: 0; list-style: none; }
.etac-week__day { display: flex; flex-direction: column; align-items: center; gap: 5px; }
.etac-week__cell {
  display: grid; place-items: center; width: 100%; max-width: 32px; aspect-ratio: 1; box-sizing: border-box;
  border-radius: 7px; border: 1.5px solid transparent;
}
.etac-week__cell.is-right { background: var(--accent); }
.etac-week__cell.is-wrong { background: color-mix(in oklab, var(--accent) 40%, var(--bg-elevated)); }
.etac-week__cell.is-missed { background: var(--bg-input); border-color: var(--border); }
.etac-week__cell.is-future { border: 1.5px dashed var(--border); }
.etac-week__cell.is-today { border: 2px dashed var(--accent); background: var(--accent-soft); }
.etac-week__cell.is-frozen {
  background: color-mix(in oklab, var(--frost) 22%, var(--bg-elevated));
  border-color: color-mix(in oklab, var(--frost) 60%, transparent);
}
.etac-home .etac-week__cell .etac-ico { width: 18px; height: 18px; background: var(--frost-ink); }
.etac-week__dow { font: 700 11px/1 var(--fsans); color: var(--text-dim); }
.etac-week__day.is-today .etac-week__dow { color: var(--accent); }
.etac-legend { display: flex; flex-wrap: wrap; gap: var(--space-4) var(--space-12); margin: 0; padding: 0; list-style: none; font: 700 11px/1 var(--fsans); color: var(--text-dim); }
.etac-legend li { display: inline-flex; align-items: center; gap: var(--space-4); }
.etac-legend i { width: 9px; height: 9px; border-radius: 2px; }
.etac-legend .is-right { background: var(--accent); }
.etac-legend .is-wrong { background: color-mix(in oklab, var(--accent) 40%, var(--bg-elevated)); }
.etac-legend .is-frozen { background: color-mix(in oklab, var(--frost) 45%, var(--bg-elevated)); }
.etac-fire__freeze { display: flex; align-items: center; gap: 10px; padding-top: var(--space-16); border-top: 1px solid var(--border); font-size: 13px; }
.etac-fire__freeze b { font-weight: 700; white-space: nowrap; }
.etac-fire__freezeico { display: grid; place-items: center; flex: none; width: 30px; height: 30px; border-radius: 8px; background: color-mix(in oklab, var(--frost) 18%, transparent); }
.etac-home .etac-fire__freezeico .etac-ico { width: 17px; height: 17px; background: var(--frost-ink); }
.etac-fire__freeze .etac-muted { margin-left: auto; font-size: 12px; color: var(--text-dim); text-align: right; white-space: nowrap; }
.etac-fire__freeze .etac-link { font-size: 13px; }
.etac-fire__freeze { position: relative; }
.etac-fire__pop { position: absolute; left: 0; bottom: calc(100% + 6px); z-index: 20; width: 230px; display: flex; flex-direction: column; gap: 6px; padding: var(--space-12) var(--space-14); background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-md); box-shadow: 0 8px 24px rgba(0,0,0,.25); font-size: 12px; line-height: 1.4; color: var(--text-muted); text-align: left; opacity: 0; transform: translateY(6px) scale(.97); transform-origin: bottom left; visibility: hidden; pointer-events: none; transition: opacity .2s cubic-bezier(0.4,0,1,1), transform .2s cubic-bezier(0.4,0,1,1), visibility 0s .2s; }
.etac-fire__pop b { color: var(--text); font-size: 13px; }
.etac-fire__pop::after { content: ""; position: absolute; left: 0; right: 0; top: 100%; height: 10px; }
.etac-fire__freeze:hover .etac-fire__pop, .etac-fire__freeze:focus-within .etac-fire__pop { opacity: 1; transform: none; visibility: visible; pointer-events: auto; transition: opacity .35s cubic-bezier(0.22,1,0.36,1), transform .35s cubic-bezier(0.22,1,0.36,1), visibility 0s; }
@media (prefers-reduced-motion: reduce) { .etac-fire__pop, .etac-fire__freeze:hover .etac-fire__pop, .etac-fire__freeze:focus-within .etac-fire__pop { transition: none; transform: none; } }

/* Unit mastery — the unit today's question is from. One column per CED
   topic (accuracy), today's topic ringed; a topic you haven't touched is
   dashed. Columns scale on Y, never height. */
.etac-topics { display: grid; grid-template-columns: repeat(var(--n, 7), minmax(0, 1fr)); gap: 6px; }
.etac-topic { display: flex; flex-direction: column; align-items: center; gap: var(--space-4); min-width: 0; }
.etac-topic__col {
  position: relative; display: block; width: 100%; height: 48px; overflow: hidden; box-sizing: border-box;
  border-radius: 5px; background: var(--bg-input); border: 1px solid var(--border);
}
.etac-topic__col i { position: absolute; inset: 0; background: var(--accent); transform-origin: bottom; transition: transform .6s var(--ease-enter); }
.etac-topic.is-empty .etac-topic__col { background: transparent; border: 1.5px dashed var(--border-hover); }
.etac-topic.is-today .etac-topic__col { box-shadow: 0 0 0 2px var(--bg-elevated), 0 0 0 3.5px var(--accent); }
.etac-topic__n { font: 700 11px/1 var(--fsans); color: var(--text-dim); }
.etac-topic.is-today .etac-topic__n { color: var(--accent); }

/* Rank + class. The badge sits on a flat tile of its own tier color. */
.etac-home-rank__id { display: flex; align-items: center; gap: var(--space-12); min-width: 0; }
.etac-home-rank__tile {
  display: grid; place-items: center; flex: none; width: 48px; height: 48px; border-radius: 12px;
  background: color-mix(in oklab, var(--tier, var(--accent)) 16%, transparent);
  border: 1px solid color-mix(in oklab, var(--tier, var(--accent)) 38%, transparent);
}
.etac-home-rank__badge { width: 36px; height: 36px; flex: none; object-fit: contain; }
.etac-home-rank__who { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1 1 auto; }
.etac-home-rank__name { font: 700 18px/1.1 var(--fdisplay); letter-spacing: -.01em; color: var(--text); }
.etac-home-rank__togo { font-size: 12px; color: var(--text-muted); }
.etac-home-rank__togo b { color: var(--text); font-weight: 700; }
.etac-home-rank__pct { flex: none; font: 700 12px var(--fsans); color: var(--accent); }
/* Three divisions, I–II–III, as one segmented bar: done, filling, ahead. */
.etac-home-rank__meter { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 3px; }
.etac-home-rank__seg { position: relative; display: block; height: 8px; overflow: hidden; border-radius: 4px; background: var(--bg-input); box-shadow: inset 0 0 0 1px var(--border); }
.etac-home-rank__seg i {
  display: block; width: 100%; height: 100%; background: color-mix(in oklab, var(--tier, var(--accent)), var(--text) 40%);
  transform: scaleX(0); transform-origin: left; transition: transform .6s var(--ease-enter);
}
.etac-home-class { display: flex; flex-direction: column; gap: var(--space-8); padding-top: var(--space-12); border-top: 1px solid var(--border); }
.etac-home-class__head { display: flex; align-items: baseline; }
.etac-home-class__head h3 { font: 700 13px var(--fsans); color: var(--text-muted); }
.etac-home-class__head span { margin-left: auto; font: 700 12px var(--fsans); color: var(--text-dim); }
.etac-home-board { display: flex; flex-direction: column; gap: 2px; }
.etac-home-board__row {
  display: grid; grid-template-columns: 14px minmax(0, 1fr) 56px 28px; align-items: center; gap: var(--space-8);
  padding: 5px var(--space-8); border-radius: 8px; font-size: 13px;
}
.etac-home-board__row b { font: 700 13px var(--fdisplay); color: var(--text-dim); }
.etac-home-board__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
.etac-home-board__bar { display: block; height: 5px; overflow: hidden; border-radius: var(--radius-pill); background: var(--bg-input); box-shadow: inset 0 0 0 1px var(--border); }
.etac-home-board__bar i { display: block; width: 100%; height: 100%; background: var(--text-dim); transform-origin: left; transition: transform .6s var(--ease-enter); }
.etac-home-board__xp { font: 700 12px var(--fsans); text-align: right; color: var(--text-muted); }
.etac-home-board__row.is-you { background: var(--accent-soft); }
.etac-home-board__row.is-you b { color: var(--accent); }
.etac-home-board__row.is-you .etac-home-board__name { font-weight: 800; }
.etac-home-board__row.is-you .etac-home-board__bar i { background: var(--accent); }
.etac-home-board__row.is-you .etac-home-board__xp { font-weight: 800; color: var(--accent); }
.etac-home-class__foot { display: flex; align-items: baseline; gap: var(--space-8); font-size: 12px; }
.etac-home-class__foot .etac-link { margin-left: auto; font-size: 13px; }
.etac-home-class__note { font-size: 13px; line-height: 1.45; }

/* ---------- Signed-out pitch (home.js paintGuest) — the rail's only card ---------- */
.etac-home-guest { gap: var(--space-16); }
.etac-home-guest h2 { font: 700 20px/1.25 var(--fdisplay); color: var(--text); }
.etac-home-guest p { font-size: 15px; line-height: 1.5; }
.etac-home-guest__btn { min-height: 44px; }
/* Wood to Obsidian, climbing: each badge sits a step higher than the last. */
.etac-home-guest__tiers { display: flex; align-items: flex-end; justify-content: space-between; margin: 0; padding: var(--space-16) 0 0; list-style: none; }
.etac-home-guest__tiers li { display: block; }
.etac-home-guest__tiers li:nth-child(2) { transform: translateY(-4px); }
.etac-home-guest__tiers li:nth-child(3) { transform: translateY(-8px); }
.etac-home-guest__tiers li:nth-child(4) { transform: translateY(-12px); }
.etac-home-guest__tiers li:nth-child(5) { transform: translateY(-16px); }
.etac-home-guest__tiers li:nth-child(6) { transform: translateY(-20px); }
.etac-home-guest__tiers img { display: block; width: 36px; height: 36px; object-fit: contain; }

/* ---------- The dock: a door to every feature, each carrying one live number ---------- */
.etac-dock { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: var(--space-8); }
.etac-dock__item {
  display: flex; align-items: center; gap: var(--space-8); min-width: 0; padding: var(--space-10);
  border-radius: 10px; background: var(--bg-elevated); border: 1.5px solid var(--border-hover);
  box-shadow: 0 3px 0 var(--text-dim); color: var(--text); text-decoration: none;
  transform-origin: 50% 100%;
  transition: transform .45s cubic-bezier(.3, 1.9, .5, 1), box-shadow .2s var(--ease-enter), border-color .12s;
}
.etac-dock__item:hover { border-color: var(--accent); }
/* Jelly. Hover perks the button up with an elastic overshoot (it wobbles past
   its resting size and settles) while its icon does a little wiggle; press
   squashes it flat against its ledge from the bottom edge, and letting go
   springs it back past where it was. Still small — growth stays inside the
   8px gap between buttons, so a stray hover never covers a neighbour. All
   transform, no layout. */
@media (hover: hover) {
  .etac-dock__item:not(.is-soon):hover {
    transform: translateY(-3px) scale(1.05) rotate(-.6deg); box-shadow: 0 6px 0 var(--text-dim);
  }
  .etac-dock__item:not(.is-soon):hover .etac-dock__ico { transform: scale(1.1) rotate(-5deg); animation: etac-dock-wiggle .5s ease-out; }
}
@keyframes etac-dock-wiggle {
  0% { transform: scale(1) rotate(0deg); }
  25% { transform: scale(1.28) rotate(-14deg); }
  50% { transform: scale(1.02) rotate(9deg); }
  75% { transform: scale(1.16) rotate(-7deg); }
  100% { transform: scale(1.1) rotate(-5deg); }
}
.etac-dock__ico { transition: transform .4s cubic-bezier(.3, 1.9, .5, 1); }
.etac-dock__item:not(.is-soon):active {
  transform: translateY(3px) scale(1.06, .88); box-shadow: 0 0 0 var(--text-dim); transition-duration: .07s;
}
.etac-dock__item:not(.is-soon):active .etac-dock__ico { transform: scale(.86, .9); animation: none; transition-duration: .07s; }
.etac-dock__ico { display: grid; place-items: center; flex: none; width: 34px; height: 34px; border-radius: 8px; background: var(--accent-soft); color: var(--accent); }
.etac-home .etac-dock__ico .etac-ico { width: 19px; height: 19px; background: currentColor; }
.etac-dock__ico.is-gold { background: color-mix(in oklab, var(--spark-2) 20%, transparent); color: color-mix(in oklab, var(--spark-bd), var(--text) 25%); }
.etac-dock__txt { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.etac-dock__txt b { font: 700 14px/1.2 var(--fsans); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.etac-dock__txt span { font: 500 12px/1.3 var(--fsans); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Not built yet: dashed, no ledge, and it says so. */
.etac-dock__item.is-soon { border: 1.5px dashed var(--border-hover); box-shadow: none; }
.etac-dock__item.is-soon:active { transform: none; }
.etac-dock__item.is-soon:active .etac-dock__ico { transform: none; }
.etac-dock__item.is-soon .etac-dock__ico { background: var(--bg-input); color: var(--text-muted); }

.etac-home-foot {
  margin-top: var(--space-8); display: flex; justify-content: center; flex-wrap: wrap;
  gap: var(--space-8) var(--space-24); font-size: 12px;
}
.etac-home-foot a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
.etac-home-foot a:hover { color: var(--text); }

/* Narrower than the design: the stats rail drops under the question as a row
   of cards, and the dock wraps. The phone layout proper is its own pass. */
@media (max-width: 1000px) {
  .etac-stage { grid-template-columns: minmax(0, 1fr); }
  .etac-rail { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); align-items: start; }
  .etac-dock { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
@media (max-width: 760px) {
  .etac-home { padding-top: var(--space-16); gap: var(--space-16); }
  .etac-quests.is-live { grid-template-columns: minmax(0, 1fr); padding: var(--space-16); gap: var(--space-16); }
  .etac-quests__hi { font-size: 24px; }
  .etac-dq__band { flex-wrap: wrap; padding: var(--space-12) var(--space-16); }
  .etac-dq__band .etac-chip { margin-left: 0; }
  .etac-dq__main { padding: 20px var(--space-16) var(--space-24); }
  .etac-home .etac-daily__body > .etac-nudge { margin: 0 var(--space-16) var(--space-16); }
  .etac-home .etac-dq__stem { font-size: 19px; }
  .etac-home .etac-q__choice { font-size: 16px; min-height: 60px; padding: var(--space-12) var(--space-16); }
  .etac-dock { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (prefers-reduced-motion: reduce) {
  .etac-home .etac-bar__fill, .etac-quests__fill, .etac-topic__col i, .etac-home-rank__seg i, .etac-home-board__bar i,
  .etac-home .etac-q__choice, .etac-dock__item, .etac-dock__ico { transition: none; }
  .etac-dock__item:not(.is-soon):hover, .etac-dock__item:not(.is-soon):hover .etac-dock__ico,
  .etac-dock__item:not(.is-soon):active, .etac-dock__item:not(.is-soon):active .etac-dock__ico { transform: none; animation: none; }
  .etac-quests__chest[data-state="ready"] .etac-quests__chestbox::after { animation: none; opacity: .6; }
}

/* ---------- Mastery Practice (rendered by practice.js into #practice-root) ----------
   Reuses the Daily MCQ's .etac-q__* classes above for the question card
   itself; these are just the pieces the daily doesn't need: a progress-dot
   row (jump between the 5 questions) and the Prev/results nav bar. */
.etac-pdots { display: flex; gap: var(--space-7); margin-bottom: var(--space-14); }
.etac-pdot {
  width: 10px; height: 10px; padding: 0; border-radius: 50%; cursor: pointer;
  background: var(--bg-input); border: 1px solid var(--border);
  transition: background .12s, border-color .12s, transform .06s;
}
.etac-pdot:hover:not(:disabled) { border-color: var(--border-hover); }
.etac-pdot:active:not(:disabled) { transform: scale(.9); }
.etac-pdot:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.etac-pdot.is-answered { background: var(--accent); border-color: var(--accent); }
.etac-pdot.is-current { box-shadow: 0 0 0 3px var(--accent-soft); }
.etac-practice__nav { display: flex; align-items: center; gap: var(--space-10); margin-top: var(--space-4); }
.etac-practice__review {
  max-width: 62ch; padding-top: var(--space-16); margin-top: var(--space-16); border-top: 1px solid var(--border);
}
.etac-practice__review:first-of-type { border-top: none; margin-top: var(--space-4); padding-top: 0; }

/* ---------- Streak-goal modal (goal.js) ----------
   Commitment popup shown when a new streak starts (or a goal completes),
   plus the profile's "Set a streak goal" button. No skip button by design;
   an option must be HELD (~2.5s) to lock in — its tier color floods the
   button left → right while it charges, then the whole card ignites (canvas
   flame burst in goal.js) and burns away. Tier outlines: yellow → light
   orange → dark orange → red, darker the longer the promise. */
.etac-goalmodal {
  position: fixed; inset: 0; z-index: 100; display: grid; place-items: center;
  padding: var(--space-18); background: color-mix(in oklab, var(--bg) 62%, transparent);
  backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
  animation: etac-rise .22s ease both;
}
/* Once lit, the page below is hands-off until the flames finish… */
.etac-goalmodal.is-burning { pointer-events: none; }
/* …and the dim clears out under the last embers. */
.etac-goalmodal.is-clearing {
  background: transparent;
  backdrop-filter: blur(0px); -webkit-backdrop-filter: blur(0px);
  transition: background .55s ease, backdrop-filter .55s ease, -webkit-backdrop-filter .55s ease;
}
/* Explicit 100% size is load-bearing: canvas is a REPLACED element, so
   inset:0 alone doesn't stretch it — it renders at its bitmap size (W×dpr),
   which on Retina screens shoved the whole burst toward the bottom corner. */
.etac-goalmodal__fx { position: fixed; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.etac-goalmodal__card {
  width: min(420px, 100%); border-radius: 18px; padding: 22px 20px var(--space-18);
  background: var(--bg-elevated); border: 1px solid var(--border);
  box-shadow: 0 24px 60px rgba(0, 0, 0, .35); text-align: center;
  animation: etac-q-pop .38s cubic-bezier(.2, .9, .3, 1.2) both;
}
/* The hand-drawn flame icon, masked so a fire gradient pours into it. */
.etac-goalmodal__flame {
  display: block; width: 46px; height: 46px; margin: 0 auto var(--space-10);
  background: linear-gradient(to top, #facc15, #fb923c 45%, #dc2626);
  -webkit-mask: url('assets/icons/streak-flame.png') center / contain no-repeat;
  mask: url('assets/icons/streak-flame.png') center / contain no-repeat;
  animation: etac-ember 2.2s ease-in-out infinite;
}
.etac-goalmodal__card h3 { margin: 0 0 var(--space-6); font: 800 20px var(--fdisplay); color: var(--text); }
.etac-goalmodal__sub { margin: 0 0 var(--space-16); font: 400 13.5px/1.55 var(--fsans); color: var(--text-muted); }
.etac-goalmodal__opts { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-9); margin-bottom: var(--space-12); }
/* --tier is stamped inline per option (goal.js): outline, hold-fill, and
   the flame burst all share that one color. */
.etac-goalmodal__opt {
  position: relative; overflow: hidden;
  display: grid; gap: var(--space-5); justify-items: center; padding: 13px var(--space-8) 11px;
  border-radius: 13px; border: 1.5px solid var(--tier, var(--border));
  background: var(--bg-input); cursor: pointer;
  touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;
  transition: box-shadow .15s ease, transform .15s ease;
}
.etac-goalmodal__opt > span { position: relative; }
/* Hold-to-lock-in fill: goal.js drives --hold 0→1 across the hold (and back
   down on early release); the tier color sweeps left → right behind the text. */
.etac-goalmodal__opt::before {
  content: ""; position: absolute; inset: 0;
  background: linear-gradient(90deg,
    color-mix(in oklab, var(--tier) 38%, var(--bg-input)),
    color-mix(in oklab, var(--tier) 62%, var(--bg-input)));
  transform: scaleX(var(--hold, 0)); transform-origin: left center;
}
.etac-goalmodal__opt:hover, .etac-goalmodal__opt.is-holding {
  box-shadow: 0 0 0 1px var(--tier),
              0 0 20px color-mix(in oklab, var(--tier) 32%, transparent);
}
.etac-goalmodal__opt.is-holding { transform: scale(1.05); }
.etac-goalmodal__opt:focus-visible { outline: 2px solid var(--tier); outline-offset: 2px; }
/* Fill complete, waiting on the server (a blink or two) — smolder. */
.etac-goalmodal__opt.is-primed { animation: etac-goal-smolder .4s ease-in-out infinite alternate; }
@keyframes etac-goal-smolder { from { filter: brightness(1); } to { filter: brightness(1.4); } }
/* The whole card trembles softly while a goal is being held down. */
@keyframes etac-goal-quake {
  0%   { transform: translate(0.6px, -0.4px); }
  25%  { transform: translate(-0.7px, 0.5px); }
  50%  { transform: translate(0.5px, 0.7px); }
  75%  { transform: translate(-0.5px, -0.6px); }
  100% { transform: translate(0.6px, -0.4px); }
}
.etac-goalmodal__card.is-quaking { animation: etac-goal-quake .16s linear infinite; }
.etac-goalmodal__days { font: 500 12px var(--fsans); color: var(--text-muted); }
.etac-goalmodal__days b { font: 800 22px/1 var(--fsans); color: var(--text); margin-right: 3px; }
.etac-goalmodal__prize { font: 700 12.5px var(--fmono); color: var(--text-muted); }
.etac-goalmodal__hint { margin: var(--space-2) 0 0; font: 700 12px var(--fsans); letter-spacing: 0.072em; text-transform: uppercase; color: var(--text-dim); }
/* Send-off: the card flashes white-hot and scales out while the canvas
   flame burst (goal.js) covers it. */
@keyframes etac-goal-ignite {
  0%   { transform: scale(1);    opacity: 1; filter: brightness(1); }
  45%  { transform: scale(1.04); opacity: 1; filter: brightness(1.9) saturate(1.25); }
  100% { transform: scale(1.14); opacity: 0; filter: brightness(3); }
}
.etac-goalmodal__card.is-igniting { animation: etac-goal-ignite .34s ease-in both; pointer-events: none; }
.etac-goalmodal__card.is-fading { transition: opacity .24s ease; opacity: 0; pointer-events: none; }
@media (prefers-reduced-motion: reduce) {
  .etac-goalmodal, .etac-goalmodal__card, .etac-goalmodal__flame,
  .etac-goalmodal__opt.is-primed, .etac-goalmodal__card.is-quaking { animation: none; }
  .etac-goalmodal__opt.is-holding { transform: none; }
  .etac-goalmodal__card.is-igniting { animation: none; opacity: 0; }
}

/* ---------- Edit-profile modal (namemodal.js) ----------
   Shared by profile.html and social.html, so it lives here rather than in
   profile.css. Same overlay shape as .etac-goalmodal above — dim + blur,
   centered card — kept simpler (a form, no hold-to-lock-in / canvas burst). */
.etac-namemodal {
  position: fixed; inset: 0; z-index: 100; display: grid; place-items: center;
  padding: var(--space-18); background: color-mix(in oklab, var(--bg) 62%, transparent);
  backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
  animation: etac-rise .22s ease both;
}
.etac-namemodal__card {
  width: min(420px, 100%); border-radius: 18px; padding: 22px 20px var(--space-18);
  background: var(--bg-elevated); border: 1px solid var(--border);
  box-shadow: 0 24px 60px rgba(0, 0, 0, .35);
  animation: etac-q-pop .38s cubic-bezier(.2, .9, .3, 1.2) both;
}
.etac-namemodal__card h3 { margin: 0 0 var(--space-4); font: 800 19px var(--fdisplay); color: var(--text); }
.etac-namemodal__avatar { display: flex; align-items: center; gap: var(--space-12); margin-top: var(--space-16); }
.etac-namemodal__avpreview { flex: none; width: 56px; height: 56px; border-radius: 50%; overflow: hidden; display: grid; place-items: center; background: var(--bg-input); border: 1px solid var(--border); }
.etac-namemodal__avimg { width: 100%; height: 100%; object-fit: cover; display: block; }
.etac-namemodal__avactions { display: flex; flex-wrap: wrap; gap: var(--space-7); }
.etac-namemodal__avactions .etac-btn { font-size: 11.5px; padding: var(--space-6) 11px; }
.etac-namemodal__field { margin-top: var(--space-14); }
.etac-namemodal__field label {
  display: block; margin-bottom: var(--space-5); font: 700 12.5px var(--fsans);
  letter-spacing: .06em; text-transform: uppercase; color: var(--text-dim);
}
.etac-namemodal__hint { margin: var(--space-5) 0 0; font: 500 11.5px/1.5 var(--fsans); color: var(--text-muted); }
.etac-namemodal__error {
  margin: var(--space-10) 0 0; padding: var(--space-8) var(--space-10); border-radius: 9px;
  background: color-mix(in oklab, #dc2626 14%, transparent);
  border: 1px solid color-mix(in oklab, #dc2626 40%, transparent);
  font: 600 12px/1.4 var(--fsans); color: var(--text);
}
.etac-namemodal__actions { display: flex; justify-content: flex-end; gap: var(--space-9); margin-top: var(--space-18); }
.etac-namemodal__source { margin: var(--space-4) 0 0; font: 500 11px var(--fsans); color: var(--text-dim); }
.etac-namemodal__source button {
  font: inherit; color: var(--accent); background: none; border: none; padding: 0;
  cursor: pointer; text-decoration: underline; text-underline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
  .etac-namemodal, .etac-namemodal__card { animation: none; }
}

/* ---------- Sign-up walkthrough (onboarding.js) ----------
   Shared chrome: this loads on every page, so it lives here beside the other
   two modals rather than in a stylesheet of its own (which would mean a new
   <link> on all 17 pages). Reuses .etac-namemodal__field / __hint for the
   form rows and the global .etac-btn family for the buttons — only the
   walkthrough-specific pieces (scroll pane, step dots, name chips) are new.

   Edge-to-edge FULLSCREEN, not a floating panel: no card, no border/radius/
   shadow, a solid field the same near-black-reads-different-from-page way
   rankUp's does, with the actual content held to a centred column
   (.etac-onboard__card itself — see below) so a wide screen doesn't strand
   a title and a right-aligned action row at 1600px. `.etac-onboard` is the
   scroll boundary now (was the card): a tall step on a short viewport
   scrolls the whole screen instead of a boxed pane.

   This is the only screen a signed-up student ever sees ALONE — no HUD, no
   page behind it — so everything on it is sized roughly double what the same
   element would be inside a card on a normal page. A walkthrough that fills
   a third of a laptop screen reads as a dialog that wandered out of its box;
   this one is meant to read as the app itself, briefly. */
.etac-onboard {
  position: fixed; inset: 0; z-index: 200;
  display: flex; background: var(--bg);
  /* Vertical only. The rank ladder's hover fan reaches well past its row and
     nothing here is ever meant to be scrolled to sideways, so a horizontal
     bar appearing under a decoration is only ever a glitch. */
  overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain;
  /* BACKWARDS, not both. `both` keeps the finished animation filling forever,
     which leaves an identity transform sitting on this element permanently —
     and any transform at all makes it the containing block for every
     `position: fixed` child instead of the viewport. Since this is also a
     SCROLL container, that shifted js/motion.js's burst layers and goal.js's
     flame canvas down by the scroll offset: the press cloud landed hundreds
     of pixels clear of the button it came off. `backwards` still hides the
     first frame (all this animation was ever wanted for) and then gets out
     of the way — its `to` state is the resting style anyway. */
  animation: etac-rise .22s ease backwards;
}
.etac-onboard.is-preview::before {
  content: 'PREVIEW \2014 nothing here saves';
  position: fixed; top: 0; left: 0; right: 0; z-index: 2;
  padding: var(--space-8) 20px; background: var(--accent); color: #1a1206;
  font: 800 11px var(--fmono); letter-spacing: .04em; text-align: center;
}
.etac-onboard.is-preview .etac-onboard__card { padding-top: var(--space-56); }
.etac-onboard__card {
  width: min(900px, 100%); margin: auto; padding: 48px clamp(20px, 4vw, 40px);
  /* No surface of its own any more — .etac-onboard's solid field IS the
     surface. Only layout (the centred column + its scroll-margin) survives
     from the old card. */
  background: transparent; border: none; border-radius: 0; box-shadow: none;
  animation: none;
  /* Above the pledge step's fire canvas, which is a sibling inside the same
     overlay (goal.js appends it to the host it's handed). The card has no
     background of its own, so the burst still shows THROUGH the words rather
     than over them. */
  position: relative; z-index: 1;
  /* Step-transition fade (onboarding.js's swap()) — asymmetric per house
     rule: exit .2s below, entrance .42s here. The card node itself never
     changes across steps (only its innerHTML), so this is a class dance on
     one persistent element rather than an enter/exit pair of nodes. */
  transition: opacity .42s var(--ease-enter), transform .42s var(--ease-enter);
}
.etac-onboard__card.is-swap-out {
  opacity: 0; transform: translateY(-10px);
  transition: opacity .2s var(--ease-exit), transform .2s var(--ease-exit);
}
.etac-onboard__card.is-swap-in { opacity: 0; transform: translateY(10px); transition: none; }

/* Step dots. Same read as .etac-pdot (practice nav) but deliberately spans,
   not buttons — walkthrough steps aren't jumpable, and a round thing that
   looks clickable but isn't is worse than one that doesn't. */
.etac-onboard__steps { display: flex; gap: var(--space-10); margin-bottom: var(--space-20); }
.etac-onboard__dot {
  width: 12px; height: 12px; border-radius: 50%;
  background: var(--bg-input); border: 1px solid var(--border);
}
.etac-onboard__dot.is-done { background: var(--accent); border-color: var(--accent); }
.etac-onboard__dot.is-current {
  background: var(--accent); border-color: var(--accent);
  box-shadow: 0 0 0 4px var(--accent-soft);
}

.etac-onboard__eyebrow {
  margin: 0 0 var(--space-9); font: 700 13px var(--fsans);
  letter-spacing: .06em; text-transform: uppercase; color: var(--text-dim);
}
.etac-onboard__title { margin: 0 0 var(--space-10); font: 800 clamp(26px, 4.2vw, 38px)/1.2 var(--fdisplay); color: var(--text); text-wrap: balance; }
.etac-onboard__sub { margin: 0 0 var(--space-20); font: 500 17px/1.55 var(--fsans); color: var(--text-muted); }
.etac-onboard__body { margin-top: var(--space-2); }
.etac-onboard__hint { margin: 0; font: 500 14px/1.5 var(--fsans); color: var(--text-muted); }
/* Every form row inside the walkthrough runs at the same doubled scale as
   the prose around it — .etac-namemodal__field is shared with the small
   in-page name modal, so this scopes the bump instead of moving it. */
.etac-onboard__card .etac-namemodal__field { margin-top: var(--space-22); }
.etac-onboard__card .etac-namemodal__field label { margin-bottom: var(--space-8); font-size: 12.5px; }
.etac-onboard__card .etac-namemodal__hint { margin-top: var(--space-8); font-size: 14px; }
.etac-onboard__card input[type="text"],
.etac-onboard__card select {
  width: 100%; padding: var(--space-14) var(--space-16); font-size: 19px; border-radius: 11px;
}
/* The age step's Month / (Day) / Year dropdowns — one row, month widest. */
.etac-onboard__birth { display: flex; gap: var(--space-12); }
.etac-onboard__birth .etac-namemodal__field { flex: 1 1 0; min-width: 0; }
.etac-onboard__birth .etac-namemodal__field:first-child { flex-grow: 1.6; }
@media (max-width: 420px) {
  .etac-onboard__birth { flex-wrap: wrap; }
  .etac-onboard__birth .etac-namemodal__field:first-child { flex-basis: 100%; }
}
.etac-onboard__card .etac-btn { font-size: 17px; padding: var(--space-16) 28px; border-radius: var(--radius-xl); }

/* The scroll pane. `contain` stops a flick at the bottom from scrolling the
   card (and then the page) behind it. */
.etac-onboard__doc {
  max-height: min(52vh, 480px); overflow-y: auto; overscroll-behavior: contain;
  padding: var(--space-22) var(--space-26); border-radius: 16px;
  /* Elevated, not the field's own color — the fullscreen shell dropped the
     card, so this pane is the one surface on screen that still needs to
     read as distinct from the background it sits on. */
  background: var(--bg-elevated); border: 1px solid var(--border);
  font: 500 16px/1.7 var(--fsans); color: var(--text-muted);
}
.etac-onboard__doc:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.etac-onboard__doc h3 {
  margin: 22px 0 var(--space-8); font: 700 17px var(--fsans); color: var(--text);
}
.etac-onboard__doc h3:first-child { margin-top: 0; }
.etac-onboard__doc p { margin: 0 0 var(--space-12); }
.etac-onboard__doc a { color: var(--accent); }
.etac-onboard__updated { font: 600 13px var(--fmono); color: var(--text-dim); }
.etac-onboard__loading { color: var(--text-dim); }
/* The same prose, read full-width at /terms and /privacy instead of through
   the walkthrough's scroll pane. Sits on .etac-card, so only typography. */
.lg-doc [data-lg-body] { font: 500 13.5px/1.7 var(--fsans); color: var(--text-muted); }
.lg-doc h3 { margin: 22px 0 var(--space-7); font: 700 14px var(--fsans); color: var(--text); }
.lg-doc [data-lg-body] > :first-child { margin-top: 0; }
.lg-doc p { margin: 0 0 var(--space-10); }
.lg-doc a { color: var(--accent); }

/* The scroll gate's status line, sitting between pane and button. */
.etac-onboard__gate {
  margin: var(--space-14) 0 0; font: 600 14px var(--fsans); color: var(--text-dim);
  transition: color .2s ease;
}
.etac-onboard__gate.is-clear { color: var(--success-fg); }

/* Rolled cave-name suggestions. */
.etac-onboard__roll {
  margin-top: var(--space-22); padding: var(--space-18); border-radius: 16px;
  background: var(--bg-elevated); border: 1px solid var(--border);
}
.etac-onboard__rollhead {
  display: flex; align-items: center; justify-content: space-between; gap: var(--space-12);
  margin-bottom: var(--space-14); font: 700 12.5px var(--fsans);
  letter-spacing: .06em; text-transform: uppercase; color: var(--text-dim);
}
/* Icon only. The glyph IS the verb here — "roll three more" spelled out beside
   a reroll arrow says the same thing twice, and a text link in a row of
   uppercase mono label reads as a second heading. */
.etac-onboard__reroll {
  display: grid; place-items: center; width: 36px; height: 36px; flex: none;
  font: 600 19px/1 var(--fsans); color: var(--accent);
  background: var(--bg-input); border: 1px solid var(--border); border-radius: 50%;
  padding: 0; cursor: pointer; text-transform: none; letter-spacing: 0;
  transition: border-color .15s ease, background .15s ease;
}
.etac-onboard__reroll:hover { border-color: var(--accent); background: var(--accent-soft); }
/* One-shot spin per click — same keyframe the home hero's beam fan spins
   forever (etac-rank-spin), just a single .5s pass instead of an infinite
   loop, so it isn't re-defined a second time for one button. Linear suits
   that ambient loop; this is a discrete one-shot flourish, so it settles
   into the stop with the house entrance curve instead. */
.etac-onboard__reroll.is-spinning { animation: etac-rank-spin .5s var(--ease-enter); }
@media (prefers-reduced-motion: reduce) {
  .etac-onboard__reroll.is-spinning { animation: none; }
}
.etac-onboard__chips { display: flex; flex-wrap: wrap; gap: var(--space-10); }
.etac-onboard__chip {
  font: 600 16px var(--fmono); color: var(--text);
  background: var(--bg-input); border: 1px solid var(--border);
  border-radius: 999px; padding: var(--space-10) var(--space-18); cursor: pointer;
  transition: border-color .15s ease, background .15s ease, transform .15s ease;
}
.etac-onboard__chip:hover {
  border-color: var(--accent); background: var(--accent-soft); transform: translateY(-1px);
}

/* The cave-name field turns green the moment what's typed would actually be
   accepted, and pulses ONCE on the way in (onboarding.js adds .is-checked,
   then .is-checking for one animation). The pulse is on the field's own
   border rather than a glow floating over it — a blob on top of the text is
   exactly the thing you can't see while you're still looking at the text. */
.etac-onboard__card input[type="text"].is-checked {
  border-color: var(--success-fg);
}
.etac-onboard__card input[type="text"].is-checking {
  animation: etac-onboard-check 1s var(--ease-enter) both;
}
@keyframes etac-onboard-check {
  0%   { box-shadow: 0 0 0 0 color-mix(in oklab, var(--success-fg) 65%, transparent); }
  40%  { box-shadow: 0 0 0 7px color-mix(in oklab, var(--success-fg) 0%, transparent); }
  60%  { box-shadow: 0 0 0 0 color-mix(in oklab, var(--success-fg) 50%, transparent); }
  100% { box-shadow: 0 0 0 7px color-mix(in oklab, var(--success-fg) 0%, transparent); }
}

.etac-onboard__memberof { margin-bottom: var(--space-18); }
.etac-onboard__error {
  margin: var(--space-16) 0 0; padding: var(--space-12) var(--space-14); border-radius: 11px;
  background: color-mix(in oklab, #dc2626 14%, transparent);
  border: 1px solid color-mix(in oklab, #dc2626 40%, transparent);
  font: 600 15px/1.45 var(--fsans); color: var(--text);
}
.etac-onboard__actions {
  display: flex; justify-content: flex-end; gap: var(--space-12); margin-top: var(--space-26);
}
/* :not(.is-pressing) is load-bearing, not a flourish. setBusy() disables every
   control the moment a step saves — including the button that was just pressed
   — and a 45%-opaque button lets its own sparkle cloud show straight THROUGH
   its face, which reads as the burst being painted on top of it. The pressed
   button keeps a solid face for the length of its save; its label already says
   "Saving…", which is the clearer busy signal anyway. Everything else in the
   row still dims. */
.etac-onboard__actions .etac-btn[disabled]:not(.is-pressing) {
  opacity: .45; cursor: not-allowed;
}
/* Explicit, because style.css's blanket `button:disabled { opacity: .5 }` is
   the other half of the same problem and would otherwise still show the cloud
   through the face. */
.etac-onboard__card .etac-btn.is-pressing[disabled] { opacity: 1; cursor: progress; }

@media (max-width: 480px) {
  .etac-onboard__card { padding: 26px var(--space-16); }
  .etac-onboard.is-preview .etac-onboard__card { padding-top: 46px; }
  .etac-onboard__sub { font-size: 15px; }
  /* Shorter pane on a phone so the gate line and button stay on screen — a
     gate you have to scroll the whole screen to reach reads as a dead button. */
  .etac-onboard__doc { max-height: 38vh; font-size: 14.5px; padding: var(--space-16) var(--space-18); }
  .etac-onboard__actions .etac-btn { flex: 1; }
  .etac-onboard__card .etac-btn { font-size: 15px; padding: var(--space-14) 20px; }
}
@media (prefers-reduced-motion: reduce) {
  .etac-onboard__card input[type="text"].is-checking { animation: none; }
}
@media (prefers-reduced-motion: reduce) {
  .etac-onboard, .etac-onboard__card { animation: none; transition: none; }
  .etac-onboard__chip { transition: none; }
}

/* ---------- Gamified walkthrough steps (onboarding.js) ----------
   hello / rank-intro / caveman / pledge / placement / launch. Sits beside
   the walkthrough block above rather than in a file of its own — same
   reasoning (shared chrome, every page). */

/* The mascot — sized down for steps that also carry a ranked list or a
   picker grid, full-size for the hello/launch bookends. */
.etac-onboard__mascot { display: grid; place-items: center; margin: var(--space-8) 0 var(--space-18); }
.etac-onboard__mascot .caveman-svg { width: 200px; height: auto; }
.etac-onboard__mascot--sm .caveman-svg { width: 128px; }

/* Rank ladder preview (rank-intro step, scene A). At rest a row is a stack
   of 3 shingled division icons on the left and the tier's name on the RIGHT
   edge — the name lives out there specifically to leave the whole middle of
   the row empty, because that's where the icons go on hover.

   Hovering fans the 3 apart AND blows them up ~4x, walking them rightward
   into that empty middle, while every other row dims. These are small
   pieces of art nobody can read at 26px, and this list is the only place in
   the app that ever shows all three divisions of a tier together — so the
   hover is the actual feature here, not a highlight.

   The fan overflows the row in both directions (rows are 10px apart; a
   blown-up icon reaches ~50px past the row's own edge), so each row needs
   its own stacking context and the list needs padding deep enough that the
   first and last rows' fans stay inside the card. */
.etac-onboard__ranks { list-style: none; margin: var(--space-26) 0 var(--space-24); padding: var(--space-32) 0; display: grid; gap: var(--space-10); }
.etac-onboard__ranks li {
  position: relative; z-index: 0;
  display: flex; align-items: center; justify-content: space-between; gap: var(--space-16);
  padding: var(--space-14) var(--space-18);
  border-radius: var(--radius-xl); background: var(--bg-elevated); border: 1px solid var(--border);
  font: 700 18px var(--fsans); color: var(--text-muted);
  transition: opacity .2s var(--ease-exit), box-shadow .2s var(--ease-enter);
}
.etac-onboard__ranks li:hover { z-index: 2; }
.etac-onboard__ranks:hover li:not(:hover) { opacity: .35; }
.etac-onboard__ranks li:hover {
  color: var(--text);
  box-shadow: 0 0 0 1px var(--tier), 0 0 28px color-mix(in oklab, var(--tier) 35%, transparent);
}
.etac-onboard__rankname { flex: none; }
.etac-onboard__rankicon { width: 16px; height: 18px; object-fit: contain; flex: none; }
.etac-onboard__sub--tight { margin: -8px 0 var(--space-14); text-align: center; }
.etac-onboard__launchname { margin: 0; text-align: center; font: 700 22px var(--fsans); color: var(--text); }
.etac-onboard__summary { margin: 0 0 var(--space-20); display: grid; gap: var(--space-12); }
.etac-onboard__summary-row {
  margin: 0; display: flex; align-items: center; justify-content: center;
  gap: var(--space-12); font: 500 18px var(--fsans); color: var(--text-muted);
}
.etac-onboard__summary-row strong { color: var(--tier, var(--text)); font-weight: 700; }
.etac-onboard__summary-row .etac-onboard__rankicon { width: 30px; height: 34px; }
/* LETS GO — its own row, not the right-aligned .etac-onboard__actions every
   other step uses. This is the one button that ends the whole flow, so it
   gets the visual weight of sitting alone, bottom-center. */
.etac-onboard__launchgo { display: flex; justify-content: center; margin-top: 34px; }
.etac-onboard__card .etac-onboard__launchgo .etac-btn {
  padding: var(--space-20) var(--space-56); font-size: 22px; letter-spacing: .05em;
}
/* Rest: I behind II behind III, shingled right, low opacity to high. Hover
   fans all 3 out across the row's empty middle at ~4x, full opacity, on top
   of every sibling stack (z-index 4 beats the row's own :hover z-index of 2).
   Scale is applied about each icon's own center, so the translate that goes
   with it has to account for the ~50px of half-width the icon gains — hence
   the first icon moving RIGHT on hover rather than staying put. */
.etac-onboard__rankstack { position: relative; display: inline-block; width: 60px; height: 30px; flex: none; }
.etac-onboard__rankstack .etac-onboard__rankicon {
  position: absolute; top: 0; left: 0; width: 26px; height: 30px;
  /* Slow, for a hover: this is a thing to look at, not a state change to
     acknowledge, so it unfolds rather than snapping open. */
  transition: transform .55s var(--ease-enter), opacity .55s var(--ease-enter);
}
.etac-onboard__rankstack .etac-onboard__rankicon--1 { z-index: 1; opacity: .55; transform: translate(0, 0); }
.etac-onboard__rankstack .etac-onboard__rankicon--2 { z-index: 2; opacity: .8; transform: translate(14px, 0); }
.etac-onboard__rankstack .etac-onboard__rankicon--3 { z-index: 3; opacity: 1; transform: translate(28px, 0); }
.etac-onboard__ranks li:hover .etac-onboard__rankicon--1 { transform: translate(50px, 0) scale(2.7); opacity: 1; z-index: 4; }
.etac-onboard__ranks li:hover .etac-onboard__rankicon--2 { transform: translate(140px, -4px) scale(3); opacity: 1; z-index: 5; }
.etac-onboard__ranks li:hover .etac-onboard__rankicon--3 { transform: translate(230px, 0) scale(2.7); opacity: 1; z-index: 4; }
@media (max-width: 720px) {
  /* Less fan on a narrow screen — the row's empty middle isn't there to
     expand into, so the icons grow less and stay closer together. */
  .etac-onboard__ranks li:hover .etac-onboard__rankicon--1 { transform: translate(20px, 0) scale(1.9); }
  .etac-onboard__ranks li:hover .etac-onboard__rankicon--2 { transform: translate(74px, -4px) scale(2.1); }
  .etac-onboard__ranks li:hover .etac-onboard__rankicon--3 { transform: translate(128px, 0) scale(1.9); }
  .etac-onboard__ranks { padding: var(--space-26) 0; }
}

/* Rank ladder ring (rank-intro step, scene B) — every division, worst→best
   clockwise from 12 o'clock. Each badge's placement (rotate+translate) is
   static; the ring's own slow spin and each badge's counter-spin are two
   opposite, perfectly-synced infinite animations, so their sum is always
   zero and the art stays upright forever regardless of where the ring has
   turned to — see the spin/counter keyframe pair below. */
.etac-onboard__rankring {
  position: relative; width: min(460px, 74vmin); height: min(460px, 74vmin);
  margin: var(--space-14) auto 26px;
  /* Scales with the container itself (min(), not a fixed px) so a narrow
     phone's smaller ring doesn't push badges outside its own bounds. */
  --ring-r: min(198px, 32vmin);
}
.etac-onboard__rankring-spin {
  position: absolute; inset: 0;
  animation: etac-rankring-spin 90s linear infinite;
}
.etac-onboard__rankring-badge {
  position: absolute; top: 50%; left: 50%; width: 42px; height: 48px; margin: -24px 0 0 -21px;
  opacity: 0;
  animation: etac-rankring-assemble .58s var(--ease-enter) calc(var(--i, 0) * 45ms) both;
}
/* Each badge lands with its own small pop rather than simply fading up —
   18 of them arriving 45ms apart reads as a ring building itself out of
   individual pieces, which is the whole point of showing it as a ring. */
@keyframes etac-rankring-assemble {
  0%   { opacity: 0; transform: rotate(var(--ang)) translateY(calc(-1 * var(--ring-r))) scale(.2); }
  55%  { opacity: 1; transform: rotate(var(--ang)) translateY(calc(-1 * var(--ring-r))) scale(1.32); }
  78%  { transform: rotate(var(--ang)) translateY(calc(-1 * var(--ring-r))) scale(.94); }
  100% { opacity: 1; transform: rotate(var(--ang)) translateY(calc(-1 * var(--ring-r))) scale(1); }
}
/* The exit, stamped by onboarding.js when "Climb the ranks!" is pressed:
   every badge is thrown radially out past the edges of the screen while the
   hero in the middle drops away. Same --ang each badge was placed at, so
   each one leaves along the line it was already sitting on. */
.etac-onboard__rankring.is-blowout .etac-onboard__rankring-badge {
  animation: etac-rankring-blow .52s var(--ease-exit) calc(var(--i, 0) * 12ms) both;
}
@keyframes etac-rankring-blow {
  0%   { opacity: 1; transform: rotate(var(--ang)) translateY(calc(-1 * var(--ring-r))) scale(1); }
  22%  { transform: rotate(var(--ang)) translateY(calc(-.82 * var(--ring-r))) scale(.86); }
  100% { opacity: 0; transform: rotate(var(--ang)) translateY(calc(-7 * var(--ring-r))) scale(1.7); }
}
.etac-onboard__rankring.is-blowout .etac-onboard__rankring-hero {
  animation: etac-rankring-hero-out .42s var(--ease-exit) both;
}
@keyframes etac-rankring-hero-out {
  0%   { opacity: 1; transform: translate(-50%, -50%) scale(1); }
  100% { opacity: 0; transform: translate(-50%, -50%) scale(.5); }
}
.etac-onboard__rankring-counter {
  display: block; width: 100%; height: 100%;
  animation: etac-rankring-upright 90s linear infinite;
}
.etac-onboard__rankring-counter img { display: block; width: 100%; height: 100%; object-fit: contain; }
@keyframes etac-rankring-spin { to { transform: rotate(360deg); } }
/* Cancels BOTH this badge's own static placement angle (--ang) and the
   parent's ongoing spin — the `to` keyframe subtracts a further full turn on
   the same 90s clock as .spin, so at any instant the two contributions sum
   to exactly zero on top of the (also-cancelled) static angle. */
@keyframes etac-rankring-upright {
  from { transform: rotate(calc(-1 * var(--ang, 0deg))); }
  to   { transform: rotate(calc(-1 * var(--ang, 0deg) - 360deg)); }
}
/* The rank in the middle of the ring is the one the student actually starts
   on, so it gets the home page's live rank treatment rather than a flat
   icon: .etac-rank-hero__badge / __glow / __sparkles / __icon are reused
   verbatim (see the Home rank hero block below), which is what makes "this
   is your rank" mean the same thing in both places. Only the size is
   overridden — the hub's badge sits in a row of stats; this one is the
   center of a 460px ring. */
.etac-onboard__rankring-hero {
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%) scale(.6);
  display: flex; flex-direction: column; align-items: center; gap: var(--space-10);
  opacity: 0;
  animation: etac-rankring-hero-in .6s var(--ease-enter) .9s both;
}
.etac-onboard__rankring-hero .etac-rank-hero__badge { width: 132px; height: 148px; }
.etac-onboard__rankring-hero .etac-rank-hero__sparkles span { width: 8px; height: 8px; }
.etac-onboard__rankring-hero > span { font: 700 17px var(--fsans); color: var(--text); }
@keyframes etac-rankring-hero-in {
  0% { opacity: 0; transform: translate(-50%, -50%) scale(.6); }
  70% { transform: translate(-50%, -50%) scale(1.08); }
  100% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
@media (prefers-reduced-motion: reduce) {
  .etac-onboard__ranks li, .etac-onboard__rankstack .etac-onboard__rankicon { transition: none; }
  .etac-onboard__rankring-spin, .etac-onboard__rankring-counter,
  .etac-onboard__rankring-badge, .etac-onboard__rankring-hero { animation: none; }
  .etac-onboard__rankring.is-blowout .etac-onboard__rankring-badge,
  .etac-onboard__rankring.is-blowout .etac-onboard__rankring-hero { animation: none; opacity: 0; }
  .etac-onboard__rankring-badge {
    opacity: 1; transform: rotate(var(--ang)) translateY(calc(-1 * var(--ring-r))) scale(1);
  }
  .etac-onboard__rankring-hero { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}

/* Caveman picker (caveman step). */
.etac-onboard__cavemen { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--space-16); margin-bottom: var(--space-4); }
.etac-onboard__cave {
  display: grid; gap: var(--space-7); justify-items: center; padding: 22px var(--space-12) var(--space-18);
  border-radius: 18px; border: 1.5px solid var(--border); background: var(--bg-input);
  cursor: pointer; transition: border-color .15s ease, transform .15s ease, box-shadow .15s ease;
}
.etac-onboard__cave:hover, .etac-onboard__cave:focus-visible {
  border-color: var(--accent); transform: translateY(-2px) scale(1.06);
  box-shadow: 0 0 0 1px var(--accent-soft);
}
.etac-onboard__caveart { display: block; }
.etac-onboard__caveart .caveman-svg { width: 116px; height: auto; }
.etac-onboard__cave b { font: 700 17px var(--fsans); color: var(--text); }
.etac-onboard__cave > span:last-child { font: 500 14px var(--fsans); color: var(--text-dim); text-align: center; }
/* The pick: the other two gray out and dim; the chosen one pops with
   overshoot and gets a colored ring instead of the plain hover one. */
.etac-onboard__cave.is-unpicked { filter: grayscale(1); opacity: .4; pointer-events: none; }
.etac-onboard__cave.is-picked {
  border-color: var(--accent);
  box-shadow: 0 0 0 2px var(--accent-soft), 0 0 24px color-mix(in oklab, var(--accent) 35%, transparent);
  animation: etac-cave-pick .5s var(--ease-enter) both;
}
@keyframes etac-cave-pick {
  0% { transform: scale(1); }
  45% { transform: scale(1.12); }
  100% { transform: scale(1); }
}

/* Streak-goal pledge (pledge step) — same hold-to-commit visual language as
   .etac-goalmodal__opt (goal.js), reusing its keyframes; only the class
   prefix differs since this lives in the onboarding card, not a second
   floating modal. */
.etac-onboard__opts { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-16); margin: var(--space-8) 0 var(--space-10); }
.etac-onboard__opt {
  position: relative; overflow: hidden;
  display: grid; gap: var(--space-8); justify-items: center; padding: 24px var(--space-14) 20px;
  border-radius: 18px; border: 1.5px solid var(--tier, var(--border));
  background: var(--bg-input); cursor: pointer;
  touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;
  transition: box-shadow .15s ease, transform .15s ease;
}
.etac-onboard__opt > span { position: relative; }
.etac-onboard__opt::before {
  content: ""; position: absolute; inset: 0;
  background: linear-gradient(90deg,
    color-mix(in oklab, var(--tier) 38%, var(--bg-input)),
    color-mix(in oklab, var(--tier) 62%, var(--bg-input)));
  transform: scaleX(var(--hold, 0)); transform-origin: left center;
}
.etac-onboard__opt:hover, .etac-onboard__opt.is-holding {
  box-shadow: 0 0 0 1px var(--tier), 0 0 20px color-mix(in oklab, var(--tier) 32%, transparent);
}
.etac-onboard__opt.is-holding { transform: scale(1.05); }
.etac-onboard__opt:focus-visible { outline: 2px solid var(--tier); outline-offset: 2px; }
.etac-onboard__opt.is-primed { animation: etac-goal-smolder .4s ease-in-out infinite alternate; }
.etac-onboard__card.is-quaking { animation: etac-goal-quake .16s linear infinite; }
.etac-onboard__days { font: 500 17px var(--fsans); color: var(--text-muted); }
.etac-onboard__days b { font: 800 36px/1 var(--fsans); color: var(--text); margin-right: var(--space-5); }
.etac-onboard__prize { font: 700 14px var(--fmono); color: var(--text-muted); }

/* The pledge send-off — the walkthrough's own version of the goal modal's
   is-burning/is-igniting (goal.js's ignite()), which the walkthrough never
   applied before: the card used to just sit there fully opaque IN FRONT of
   the canvas burst. Two things fix that, and neither is a bigger effect:

     * the fire canvas is a sibling of the card inside this overlay, so it
       now paints UNDER it (see the card's z-index above) — the burst reads
       as the screen catching light behind the words rather than a sheet of
       flame thrown over the top of them, and
     * the card itself flashes and lifts out of the way on the bang, leaving
       the fire alone on screen.

   There is deliberately no screen shake here any more. The bang is loud
   enough on its own, and a whole viewport jumping under a burst this size
   read as a glitch rather than an impact. */
.etac-onboard.is-burning { pointer-events: none; }
.etac-onboard .etac-goalmodal__fx { z-index: 0; }
.etac-onboard__card.is-anticipating {
  transition: filter .2s var(--ease-exit), transform .2s var(--ease-exit);
  filter: brightness(.78); transform: scale(.98);
}
.etac-onboard__card.is-igniting { animation: etac-onboard-ignite .42s ease-in both; pointer-events: none; }
@keyframes etac-onboard-ignite {
  0%   { transform: scale(1);    opacity: 1; filter: brightness(1); }
  40%  { transform: scale(1.03); opacity: 1; filter: brightness(1.6) saturate(1.15); }
  100% { transform: scale(1.08); opacity: 0; filter: brightness(2.2); }
}

/* LETS GO (js/motion.js's launch()). One continuous move: the card settles a
   hair, then lifts and fades while the screen goes black underneath it on the
   same clock. The tiny sideways jitter in the first fifth is the only rumble
   left in this flow — small enough to feel rather than watch. */
body.is-motion-launch .etac-onboard__card {
  animation: etac-onboard-liftoff 1150ms var(--ease-enter) both;
}
@keyframes etac-onboard-liftoff {
  0%   { transform: translate(0, 0) scale(1); opacity: 1; }
  6%   { transform: translate(-1.5px, 3px) scale(.99); }
  13%  { transform: translate(1.5px, 5px) scale(.985); opacity: 1; }
  100% { transform: translate(0, -34px) scale(1.05); opacity: 0; }
}

@media (max-width: 480px) {
  .etac-onboard__cavemen { grid-template-columns: 1fr 1fr; }
}
@media (prefers-reduced-motion: reduce) {
  .etac-onboard__cave, .etac-onboard__opt { transition: none; }
  .etac-onboard__opt.is-primed, .etac-onboard__card.is-quaking,
  .etac-onboard__cave.is-picked { animation: none; }
  .etac-onboard__opt.is-holding { transform: none; }
  .etac-onboard__card.is-anticipating { transition: none; filter: none; transform: none; }
  .etac-onboard__card.is-igniting,
  body.is-motion-launch .etac-onboard__card { animation: none; }
}

/* ---------- Home rank hero (index.html, between the daily and the tiles) --
   hud.js hides the whole section while signed out via [data-hud-rank-row]
   (same attribute the profile card uses), so this never shows a guest a
   fake Wood I. Beams only unhide on a division-III rank (hud.js). */
/* Grid, not flex — a 3-up flex row can't pin the outer two to the edges
   while keeping the middle one truly centered once their widths differ.
   1fr/auto/1fr does, via each column's own justify-self. */
.etac-rank-hero {
  position: relative; display: grid; grid-template-columns: 1fr auto 1fr;
  align-items: center; gap: var(--space-14); margin: var(--space-18) auto; padding: var(--space-8) clamp(8px, 3vw, 32px);
  max-width: 75%;
}
.etac-rank-hero__mascot { justify-self: start; align-self: end; flex: none; width: 92px; height: 110px; }
.etac-rank-hero__mascot .caveman-svg { width: 100%; height: 100%; margin: 0; color: var(--text-dim); }
.etac-rank-hero__main {
  position: relative; display: flex; flex-direction: column; align-items: center; gap: var(--space-8);
}
.etac-rank-hero__standing {
  justify-self: end; display: flex; flex-direction: column; align-items: flex-end; gap: var(--space-2);
}
.etac-rank-hero__standing-label {
  font: 700 11.5px var(--fsans); letter-spacing: 0.058em; text-transform: uppercase; color: var(--text-dim);
}
.etac-rank-hero__standing-pos { font: 800 26px var(--fsans); color: var(--text); line-height: 1.1; }
.etac-rank-hero__standing-pct { font: 600 11px var(--fmono); color: var(--text-muted); }
@media (max-width: 640px) {
  .etac-rank-hero { grid-template-columns: 1fr; justify-items: center; gap: var(--space-10); max-width: 100%; }
  .etac-rank-hero__mascot { order: 2; justify-self: center; align-self: center; width: 64px; height: 76px; }
  .etac-rank-hero__standing { order: 3; align-items: center; }
}
.etac-rank-hero__badge { position: relative; width: 104px; height: 116px; display: flex; align-items: center; justify-content: center; }
.etac-rank-hero__icon {
  position: relative; z-index: 2; width: 100%; height: 100%; object-fit: contain;
  filter: drop-shadow(0 6px 18px rgba(0, 0, 0, .4));
  animation: etac-rank-bob 4.5s ease-in-out infinite;
}
.etac-rank-hero__glow {
  position: absolute; inset: -18%; z-index: 0; border-radius: 50%;
  background: radial-gradient(closest-side, color-mix(in srgb, var(--tier, var(--accent)) 55%, transparent), transparent 72%);
  filter: blur(2px); animation: etac-rank-pulse 3.2s ease-in-out infinite;
}
/* Sparkles — six small diamonds scattered around the badge, each twinkling
   on its own offset so they never blink in unison. */
.etac-rank-hero__sparkles { position: absolute; inset: 0; z-index: 1; pointer-events: none; }
.etac-rank-hero__sparkles span {
  position: absolute; width: 5px; height: 5px; background: color-mix(in srgb, var(--tier, var(--accent)) 70%, white);
  clip-path: polygon(50% 0%, 61% 39%, 100% 50%, 61% 61%, 50% 100%, 39% 61%, 0% 50%, 39% 39%);
  opacity: 0; animation: etac-rank-twinkle 2.6s ease-in-out infinite;
}
.etac-rank-hero__sparkles span:nth-child(1) { top: 6%;  left: 12%; animation-delay: 0s; }
.etac-rank-hero__sparkles span:nth-child(2) { top: 18%; left: 84%; animation-delay: .4s; }
.etac-rank-hero__sparkles span:nth-child(3) { top: 52%; left: 2%;  animation-delay: .9s; }
.etac-rank-hero__sparkles span:nth-child(4) { top: 62%; left: 92%; animation-delay: 1.3s; }
.etac-rank-hero__sparkles span:nth-child(5) { top: 88%; left: 22%; animation-delay: 1.8s; }
.etac-rank-hero__sparkles span:nth-child(6) { top: 82%; left: 74%; animation-delay: 2.2s; }
/* NOTE: the resting hero has no beam fan. A spinning wedge fan is the
   rank-up overlay's crown for a division III arrival (.etac-rankup__fan) and
   nothing else — parking one here permanently spent the payoff before the
   celebration could land. */
.etac-rank-hero__label { position: relative; z-index: 2; font: 800 18px var(--fsans); color: var(--text); }
.etac-rank-hero__meter {
  position: relative; z-index: 2; width: min(280px, 100%); height: 10px; border-radius: var(--radius-pill);
  background: var(--bg-input); border: 1px solid var(--border); overflow: hidden;
}
.etac-rank-hero__fill { display: block; position: relative; width: 100%; height: 100%; overflow: hidden; background: var(--grad); border-radius: var(--radius-pill); transform: scaleX(0); transform-origin: left; transition: transform .35s cubic-bezier(.2, .8, .2, 1); }
.etac-rank-hero__shimmer {
  position: absolute; inset: 0; width: 40%;
  background: linear-gradient(100deg, transparent, rgba(255, 255, 255, .8), transparent);
  animation: etac-shine 2.4s ease-in-out infinite;
}
.etac-rank-hero__sub { position: relative; z-index: 2; margin: 0; font: 500 12px var(--fsans); color: var(--text-muted); }
.etac-rank-hero__sub b { color: var(--text); }
.etac-rank-hero__go {
  position: relative; z-index: 2; display: inline-block; margin-top: var(--space-6);
  font: 700 11px var(--fmono); color: var(--tier, var(--accent)); text-decoration: none;
  border-bottom: 1px dotted transparent;
}
.etac-rank-hero__go:hover, .etac-rank-hero__go:focus-visible { border-bottom-color: currentColor; }
.etac-rank-hero__go:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }

@keyframes etac-rank-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }
@keyframes etac-rank-pulse { 0%, 100% { opacity: .55; transform: scale(1); } 50% { opacity: .85; transform: scale(1.08); } }
@keyframes etac-rank-twinkle {
  0%, 100% { opacity: 0; transform: scale(.4) rotate(0deg); }
  50% { opacity: 1; transform: scale(1) rotate(25deg); }
}
@keyframes etac-rank-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }

@media (prefers-reduced-motion: reduce) {
  .etac-rank-hero__icon, .etac-rank-hero__glow, .etac-rank-hero__sparkles span,
  .etac-rank-hero__shimmer { animation: none; }
  .etac-rank-hero__glow { opacity: .55; }
  .etac-rank-hero__sparkles span { opacity: .8; }
}

/* ---------- Rank-up moment (js/motion.js owns the timing) ----------
   A FULL-SCREEN takeover, not a dialog. The page goes dark, the rank being
   left behind stands alone in the middle of the screen, and the promotion
   plays out across the whole viewport — beams run off the edges, the
   detonation whites the screen out. There is no card and no window chrome.

   Nothing auto-dismisses: the moment holds until Continue is pressed. That
   is why every beat below is ~2x the length it would need to be if it were
   racing a timeout — the student has as long as they want to look at it, so
   the animation is paced to be READ rather than caught.

   js/motion.js stamps phase classes onto .etac-rankup and every rule here is
   a reaction to one of them — nothing in this file decides WHEN, only what
   each beat looks like.

     .is-in         the screen goes dark, the old badge fades up
     .is-hold       old badge braces — the anticipation beat
     .is-charging   tier crossings only: beams fire out one at a time
     .is-compress   tier crossings only: the intake before the bang
     .is-filling    division promotions: a band of light climbs the badge
                    face while the screen gathers light from the bottom up
     .is-blast      flash + shockwaves + shards
     .is-arrive     the new mark. On a division promotion it lands on the
                    same tick as .is-blast, so the swap happens under the
                    flash and is never seen as a crossfade; on a tier
                    crossing the badge is born out of the blast
     .is-named      name/sub rise beneath it   .is-cheer  caveman reacts
     .is-crowned    landed on a III — the beam fan parks behind the badge
     .is-settled    landed: the badge breathes and Continue appears
     .is-calm       reduced motion: every end state at once, no beats
     .is-leaving    exit

   The field is near-black in BOTH themes on purpose: beams and a detonation
   flash are light, and light only reads against dark. Going full screen is
   what lets that be true without parking a dark box on a light page — and it
   means every word here is light-on-dark, so nothing below may use --text or
   --text-muted (near-black on a light theme, invisible here).

   No blur anywhere on the badges or the field. Blur was what made the old
   version hard to follow: a blurred backdrop plus blurred badge transitions
   left nothing with a crisp edge. Energy is expressed with brightness,
   scale and drop-shadow instead. The only survivors are the decorative
   light behind the badge (glow/fan), which have no edge to lose. */
.etac-rankup {
  position: fixed; inset: 0; z-index: 110; overflow: hidden;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  padding: var(--space-20); cursor: pointer;
  /* Same reason --text is unusable in here (see the block note further down):
     this field is near-black in BOTH themes, so a reward title needs the
     light halo even when the page behind it is on a light theme. */
  --title-edge: rgba(255, 255, 255, .5);
  /* Opaque, not a scrim. A translucent overlay reads as "a window over the
     page"; this has to read as the screen itself becoming the moment.
     Tinted with the OLD rank's color, not the new one — the field is what's
     on screen for the whole charge/hold beat, and tinting it with a rank the
     student hasn't reached yet made the rank they're leaving look washed out
     for however long it sits there. It switches to --tier on .is-arrive,
     the same tick as the flash (is-blast), so the swap happens under cover
     of the bang like every other old→new handoff here. */
  background:
    radial-gradient(64% 54% at 50% 44%, color-mix(in srgb, var(--tier-from, var(--tier)) 30%, transparent), transparent 76%),
    #06070b;
  opacity: 0; transition: opacity .8s var(--ease-enter);
}
.etac-rankup.is-in { opacity: 1; }
.etac-rankup.is-arrive {
  background:
    radial-gradient(64% 54% at 50% 44%, color-mix(in srgb, var(--tier) 30%, transparent), transparent 76%),
    #06070b;
}
.etac-rankup.is-leaving { opacity: 0; transition: opacity .26s var(--ease-exit); }
/* Layout column only — no surface, no border, no shadow. */
.etac-rankup__inner {
  position: relative; z-index: 1;
  display: flex; flex-direction: column; align-items: center; text-align: center;
  max-width: min(560px, 92vw);
  opacity: 0; transform: scale(.98);
  transition: opacity .42s ease, transform .5s var(--ease-enter);
}
.etac-rankup.is-in .etac-rankup__inner { opacity: 1; transform: none; }

/* ---- the badge stack: fan → glow → rays → shock → icons → flash ----
   .etac-rankup__badge is a transparent ANCHOR, not a stage: it has no
   background and no overflow clip, so everything rooted at its centre is
   free to run clear off the edges of the screen. Its size is only what
   reserves room for the icon in the column flow. */
.etac-rankup__badge {
  position: relative;
  width: min(300px, 58vmin); height: min(320px, 38vh, 62vmin);
}
/* Second, wider shockwave — tier crossings only. Lives on the badge (which
   never transforms) so its scale can't compound with .__shock's. */
.etac-rankup__badge::before {
  content: ""; position: absolute; top: 50%; left: 50%; z-index: 1;
  width: 300px; height: 300px; margin: -150px 0 0 -150px;
  border-radius: 50%; border: 2px solid color-mix(in srgb, var(--tier) 55%, white);
  opacity: 0; transform: scale(.2); pointer-events: none;
}
/* Fills the slot (inset, not translate) — transform belongs to the beats, so
   it can never be spent on centring. Sizing therefore lives in one place,
   on the slot above, and scales with the viewport without a media query. */
.etac-rankup__icon {
  position: absolute; inset: 0; z-index: 3;
  width: 100%; height: 100%; object-fit: contain;
  filter: drop-shadow(0 12px 34px rgba(0, 0, 0, .6));
}
/* Old badge — arrives, crouches, then either LEAPS UP out of frame (a
   division promotion: you moved up one rung) or charges and detonates (a
   tier crossing). It gets a full second on screen before anything happens
   to it, so you can see which rank you're leaving. */
.etac-rankup__icon.is-old {
  opacity: 0; transform: scale(1.06);
  transition: opacity .8s ease, transform .7s var(--ease-enter), filter .4s ease;
}
.etac-rankup.is-badge-in .etac-rankup__icon.is-old { opacity: 1; transform: scale(1); }
/* The brace. Scale only, in both variants — the tremble keyframe a tier
   crossing runs next has to be able to hold this transform without the badge
   popping when charging starts. */
.etac-rankup.is-hold .etac-rankup__icon.is-old { transform: scale(.93); }
.etac-rankup.is-charging .etac-rankup__icon.is-old {
  animation: etac-rankup-tremble .11s linear infinite;
  filter: brightness(1.5)
          drop-shadow(0 0 20px rgba(255, 255, 255, .85))
          drop-shadow(0 0 44px color-mix(in srgb, var(--tier-from, var(--tier)) 92%, transparent));
}
.etac-rankup.is-compress .etac-rankup__icon.is-old {
  animation: none; transform: scale(.8);
  filter: brightness(2.4)
          drop-shadow(0 0 34px rgba(255, 255, 255, .95))
          drop-shadow(0 0 70px color-mix(in srgb, var(--tier-from, var(--tier)) 95%, transparent));
  transition: transform .2s var(--ease-exit), filter .2s ease;
}
.etac-rankup.is-tiercross.is-blast .etac-rankup__icon.is-old {
  animation: none; opacity: 0; transform: scale(1.9); filter: brightness(4);
  transition: opacity .2s ease, transform .22s var(--ease-exit), filter .18s ease;
}
/* The layer that faces the cursor once the badge has landed (trackTilt in
   js/motion.js writes --rx/--ry/--sc). It exists so the pointer and the
   arrival keyframes aren't both writing `transform` on the <img>: the tilt
   lives out here, the bloom and the idle breath stay in there, and nesting
   composes them. perspective() is a transform function rather than the
   `perspective` property on the badge, because the property would make the
   badge a stacking context and trap .etac-rankup__flash (z-index 4) below
   the words it has to white out.

   No pointer-events: hover is measured as a distance from the badge centre
   in JS, so the hit area is a circle around the art instead of the corners
   of its slot. */
.etac-rankup__tilt {
  position: absolute; inset: 0; z-index: 3; pointer-events: none;
  /* perspective() leads the list so it applies to the whole 3D subtree, not
     just this element's own rotation — that's what gives the slab layers
     below real depth instead of a parallel-projected shear. */
  transform: perspective(900px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
  transform-style: preserve-3d;
  /* The return to facing you — slow, and it settles rather than snaps. */
  transition: transform .55s var(--ease-enter);
}
/* Following a cursor is a different job from settling back, and one duration
   can't do both: this one has to keep up. */
.etac-rankup__tilt.is-tracking { transition: transform .14s var(--ease-enter); }

/* The hover grow, on its own layer purely so it can run at its own speed:
   slow up, slow down, symmetric. Sharing the rotation's transform would
   force the grow to the cursor-tracking duration, which is where it snapped. */
.etac-rankup__lift {
  position: absolute; inset: 0; transform-style: preserve-3d;
  transform: scale(var(--sc, 1));
  transition: transform .62s cubic-bezier(.4, 0, .25, 1);
}

/* The object itself: slab, face and sheen move as one body, so the arrival
   and the idle float belong here rather than on the face. */
.etac-rankup__solid {
  position: absolute; inset: 0; transform-style: preserve-3d;
  opacity: 0; transform: none;
  transition: opacity .09s linear;
}
/* The side wall — copies of the art stepped backwards in Z and darkened with
   depth (motion.js writes --z/--b per layer). At rest each one hides behind
   the face; a tilt fans them out along the silhouette. */
.etac-rankup__slab { position: absolute; inset: 0; transform-style: preserve-3d; pointer-events: none; }
.etac-rankup__slab img {
  position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain;
  transform: translateZ(var(--z, 0));
  filter: brightness(var(--b, .4));
}
/* A specular that slides toward the cursor, masked to the badge's silhouette
   the same way .etac-rankup__fill is. Geometry alone still reads as cardboard
   with thickness; a highlight that moves when the object turns is what makes
   it read as something solid catching light. */
.etac-rankup__sheen {
  position: absolute; inset: 0; pointer-events: none;
  -webkit-mask-image: var(--icon-new); mask-image: var(--icon-new);
  -webkit-mask-size: contain; mask-size: contain;
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  -webkit-mask-position: center; mask-position: center;
  opacity: 0; transition: opacity .45s ease;
}
.etac-rankup__tilt.is-tracking .etac-rankup__sheen { opacity: 1; }
.etac-rankup__sheen::before {
  content: ""; position: absolute; top: 50%; left: 50%;
  width: 150%; height: 150%; margin: -75% 0 0 -75%;
  background: radial-gradient(closest-side,
    rgba(255, 255, 255, .5), rgba(255, 255, 255, .13) 42%, transparent 70%);
  transform: translate(var(--lx, 0%), var(--ly, 0%));
  transition: transform .14s var(--ease-enter);
}

/* New badge. A tier crossing is born small out of the blast and grows. A
   division promotion is already in place under the flash — it doesn't travel,
   it BLOOMS: it appears oversized at the instant of the bang and settles back,
   which is what gives the swap an impact instead of a crossfade. */
.etac-rankup.is-tiercross .etac-rankup__solid {
  transform: scale(.28);
  transition: opacity .26s ease, transform .82s cubic-bezier(.26, 1.5, .44, 1);
}
.etac-rankup.is-arrive .etac-rankup__solid { opacity: 1; transform: none; }
/* Dropped once .is-settled lands so the idle breath below can take the
   element over — without that this rule outranks it and it never breathes. */
.etac-rankup:not(.is-tiercross).is-arrive:not(.is-settled) .etac-rankup__solid {
  animation: etac-rankup-bloom .78s var(--ease-enter) both;
}
@keyframes etac-rankup-bloom {
  0%   { transform: scale(1.28); }
  34%  { transform: scale(.982); }
  66%  { transform: scale(1.014); }
  100% { transform: scale(1); }
}
/* A slow breath once it has landed. It holds this indefinitely now — the
   overlay never times out — so a frozen badge would read as a stuck image
   rather than as a moment being offered to you. */
.etac-rankup.is-settled .etac-rankup__solid {
  animation: etac-rankup-settle 4.2s ease-in-out infinite;
}
.etac-rankup__glow {
  position: absolute; top: 50%; left: 50%; z-index: 0;
  width: 440px; height: 440px; margin: -220px 0 0 -220px; border-radius: 50%;
  background: radial-gradient(closest-side, color-mix(in srgb, var(--tier) 72%, transparent), transparent 70%);
  opacity: 0; transform: scale(.6);
  transition: opacity .6s ease, transform .7s var(--ease-enter);
}
.etac-rankup.is-arrive .etac-rankup__glow {
  opacity: .9; transform: none;
  animation: etac-rankup-breathe 3.4s ease-in-out .7s infinite;
}

/* ---- the charge: beams out of the old badge, one at a time ----
   Each ray is a wedge rooted at the badge centre; motion.js hands it an
   angle (--a) and its own delay (--d), and those delays TIGHTEN as the
   sequence runs, so it reads as pressure building rather than a metronome.
   Full screen is what makes this beat work — at 65vmax each beam crosses the
   whole viewport instead of poking at the edge of a card.
   Tier crossings only; a division promotion never charges. */
.etac-rankup__rays { position: absolute; inset: 0; z-index: 1; pointer-events: none; }
.etac-rankup__rays i {
  position: absolute; top: 50%; left: 50%;
  /* Every beam is thrown its own angle, length (--len), thickness (--w),
     brightness (--lit) and growth time (--rt) by motion.js. Eighteen
     identical spokes on even centres read as a printed star no matter how
     they're timed, so none of those five are the same twice. */
  width: calc(40px * var(--w, 1)); height: 78vmax;
  margin: -78vmax 0 0 calc(-20px * var(--w, 1));
  transform-origin: 50% 100%; transform: rotate(var(--a)) scaleY(0);
  opacity: 0;
  /* A beam, not a plank. Three things keep it from reading as painted
     cardboard, and it needs all three:
       1. the wedge flares outward (the element's BASE sits at the badge
          centre, so the narrow end of the clip is the bottom one),
       2. the light falls off HARD along its length — white where it leaves
          the badge, tier-tinted through the middle, gone well before the
          tip, so nothing ends in a visible cut edge, and
       3. a horizontal mask feathers both long edges. That's what a hard
          clip-path alone can't do, and it's cheaper and crisper than
          blurring the whole element — which is the thing this rework is
          getting rid of. */
  clip-path: polygon(0% 0%, 100% 0%, 62% 100%, 38% 100%);
  /* The whole falloff has to finish INSIDE the viewport. Run it out to the
     screen edge and every beam ends in a hard bright line against the frame,
     which is what made these read as planks rather than light.

     The white-hot core has to peak just OUTSIDE the badge rim (~15-20% of
     the beam's length, since the badge is ~300px of a ~1000px ray). Peak it
     any earlier and the hottest part is hidden behind the badge art, so all
     that emerges is the cooled tail — which is exactly what made these look
     like tan sticks instead of light bursting out. */
  background: linear-gradient(to top,
    transparent 0%,
    color-mix(in srgb, var(--tier-from, var(--tier)) 20%, white) 12%,
    #fff 19%,
    color-mix(in srgb, var(--tier-from, var(--tier)) 45%, white) 30%,
    color-mix(in srgb, var(--tier-from, var(--tier)) 55%, transparent) 44%,
    color-mix(in srgb, var(--tier-from, var(--tier)) 22%, transparent) 56%,
    transparent 70%);
  -webkit-mask-image: linear-gradient(to right, transparent 2%, #000 34%, #000 66%, transparent 98%);
          mask-image: linear-gradient(to right, transparent 2%, #000 34%, #000 66%, transparent 98%);
  filter: drop-shadow(0 0 18px color-mix(in srgb, var(--tier-from, var(--tier)) 60%, white));
}
/* Fired one at a time and then HELD lit — they accumulate into a full fan,
   which is what makes the detonation feel like a release rather than just
   the next beat. .is-compress pulls them in, .is-blast consumes them. */
.etac-rankup.is-charging .etac-rankup__rays i {
  animation: etac-rankup-ray var(--rt, .4s) cubic-bezier(.16, 1, .3, 1) var(--d) both;
}
.etac-rankup.is-compress .etac-rankup__rays i {
  animation: etac-rankup-ray-suck .22s var(--ease-exit) both;
}
.etac-rankup.is-blast .etac-rankup__rays i {
  animation: etac-rankup-ray-blow .55s cubic-bezier(.16, 1, .3, 1) both;
}

/* ---- the bang ----
   Rings expand past the edges of the screen rather than dying inside a
   frame, which is most of what makes the full-screen version land harder
   than the card one did. */
.etac-rankup__shock {
  position: absolute; top: 50%; left: 50%; z-index: 2;
  width: 220px; height: 220px; margin: -110px 0 0 -110px;
  border-radius: 50%; border: 3px solid color-mix(in srgb, var(--tier) 85%, white);
  opacity: 0; transform: scale(.2); pointer-events: none;
}
/* BOTH variants leave the screen entirely — a division promotion is the one
   a student actually gets, seven times more often than a tier crossing, so
   it earns a real bang too. The difference between them is the build-up
   (a 1.2s charge vs a climb) and the size of the flash below, not whether
   the impact is felt. */
.etac-rankup.is-blast .etac-rankup__shock {
  animation: etac-rankup-shock-wide 1.1s cubic-bezier(.16, 1, .3, 1) both;
}
/* Second, wider ring, just behind the first. */
.etac-rankup.is-blast .etac-rankup__badge::before {
  animation: etac-rankup-shock-wide 1.4s cubic-bezier(.16, 1, .3, 1) .1s both;
}
.etac-rankup__flash {
  position: absolute; top: 50%; left: 50%; z-index: 4;
  width: 165vmax; height: 165vmax; margin: -82.5vmax 0 0 -82.5vmax;
  border-radius: 50%; opacity: 0; pointer-events: none;
  background: radial-gradient(closest-side, #fff 0%, color-mix(in srgb, var(--tier) 70%, white) 34%, transparent 68%);
}
.etac-rankup.is-blast .etac-rankup__flash { animation: etac-rankup-flash-big .68s ease-out both; }
/* A tier crossing whites the whole screen out, and holds it longer. */
.etac-rankup.is-tiercross .etac-rankup__flash {
  width: 230vmax; height: 230vmax; margin: -115vmax 0 0 -115vmax;
}
/* The pre-burst whiteout — held at solid white for a beat before the flash
   keyframe below is allowed to fire. A transition, not a keyframe, so it
   just holds at opacity 1 once done instead of animating back out on its
   own; .is-blast (a tick after) is what actually lets it recede. */
.etac-rankup.is-tiercross.is-whiteout .etac-rankup__flash {
  opacity: 1; transform: scale(1); background: #fff;
  transition: opacity .2s ease, transform .2s ease, background .2s ease;
}
/* Picks up FROM the whiteout's held opacity: 1 instead of animating up from
   0 like the division flash does — using the plain etac-rankup-flash-big
   keyframe here would snap the flash back to invisible the instant this
   animation takes over, undoing the whiteout in one frame. */
.etac-rankup.is-tiercross.is-blast .etac-rankup__flash {
  animation: etac-rankup-flash-big-tiercross .95s ease-out both;
}
.etac-rankup__shards { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
.etac-rankup__shards i {
  position: absolute; top: 50%; left: 50%; width: 13px; height: 13px; margin: -6.5px 0 0 -6.5px;
  background: color-mix(in srgb, var(--tier) 62%, white); opacity: 0;
  clip-path: polygon(50% 0%, 61% 39%, 100% 50%, 61% 61%, 50% 100%, 39% 61%, 0% 50%, 39% 39%);
}
.etac-rankup__shards i:nth-child(1)  { --sa: 8deg;   --sd: 0ms; }
.etac-rankup__shards i:nth-child(2)  { --sa: 41deg;  --sd: 34ms; }
.etac-rankup__shards i:nth-child(3)  { --sa: 72deg;  --sd: 12ms; }
.etac-rankup__shards i:nth-child(4)  { --sa: 106deg; --sd: 52ms; }
.etac-rankup__shards i:nth-child(5)  { --sa: 138deg; --sd: 21ms; }
.etac-rankup__shards i:nth-child(6)  { --sa: 171deg; --sd: 66ms; }
.etac-rankup__shards i:nth-child(7)  { --sa: 203deg; --sd: 6ms; }
.etac-rankup__shards i:nth-child(8)  { --sa: 236deg; --sd: 44ms; }
.etac-rankup__shards i:nth-child(9)  { --sa: 268deg; --sd: 17ms; }
.etac-rankup__shards i:nth-child(10) { --sa: 299deg; --sd: 59ms; }
.etac-rankup__shards i:nth-child(11) { --sa: 328deg; --sd: 28ms; }
.etac-rankup__shards i:nth-child(12) { --sa: 352deg; --sd: 71ms; }
.etac-rankup.is-blast .etac-rankup__shards i {
  animation: etac-rankup-shard 1.25s cubic-bezier(.16, 1, .3, 1) var(--sd) both;
}
.etac-rankup.is-tiercross.is-blast .etac-rankup__shards i { animation-duration: 1.6s; }

/* ---- the crown: a division III arrival parks a spinning beam fan behind
   the badge — the one place in the app a beam fan appears, so it reads as the
   reward for topping a tier rather than as decoration the hero already wore.
   Sized for a full-screen takeover, not an ornament:

     * sized in vmax, not px. 160vmax guarantees the square still covers the
       furthest corner (a viewport's diagonal is at most √2 · vmax, and the
       badge sits above centre), so the spokes leave the screen on every
       aspect ratio instead of stopping in mid-air on a wide one.
     * repeating-conic-gradient with wide ramps instead of the hero's hard
       stops. At this size the hero's 2%-wide edges would need a huge blur
       radius to soften, and a heavy blur on a layer this large is exactly
       what drops frames on a school Chromebook. Soft stops cost nothing.
     * a radial mask so the beams DISSOLVE outward rather than running to the
       edge at full strength, which reads as light rather than as pie slices.

   It spins from mount and only fades in, so it's never caught at 0deg. ---- */
.etac-rankup__fan {
  position: absolute; top: 50%; left: 50%; z-index: 0;
  width: 160vmax; height: 160vmax; margin: -80vmax 0 0 -80vmax; pointer-events: none;
  background: repeating-conic-gradient(from 0deg,
    transparent 0deg,
    color-mix(in srgb, var(--tier) 72%, transparent) 13deg,
    transparent 26deg,
    transparent 60deg);
  -webkit-mask-image: radial-gradient(closest-side, #000 6%, rgba(0, 0, 0, .92) 34%, rgba(0, 0, 0, 0) 96%);
          mask-image: radial-gradient(closest-side, #000 6%, rgba(0, 0, 0, .92) 34%, rgba(0, 0, 0, 0) 96%);
  opacity: 0; filter: blur(6px);
  transition: opacity .9s ease;
  animation: etac-rank-spin 16s linear infinite;
}
.etac-rankup.is-crowned .etac-rankup__fan { opacity: .5; }

/* ---- the words ----
   Light on dark in every theme — see the header note. The kicker is NOT part
   of the reveal: it labels the moment from the frame it opens, so the screen
   is never just an unexplained badge. */
.etac-rankup__name, .etac-rankup__crown, .etac-rankup__sub {
  opacity: 0; transform: translateY(12px);
}
/* The beams sweep straight through where this text sits, and they are
   POSITIONED (z-index 1) while a plain heading is not — so without a stacking
   order of its own the copy paints underneath them and gets sliced. The
   shadow does the rest of the work where a beam passes just behind. */
.etac-rankup__kicker, .etac-rankup__words {
  position: relative; z-index: 6;
}
.etac-rankup__kicker, .etac-rankup__name, .etac-rankup__sub {
  text-shadow: 0 2px 14px rgba(0, 0, 0, .85);
}
.etac-rankup__kicker {
  margin: 0 0 var(--space-20); font: 800 13px var(--fsans); letter-spacing: 0.245em; text-transform: uppercase;
  color: color-mix(in srgb, var(--tier) 42%, #fff);
}
.etac-rankup__name {
  margin: var(--space-6) 0 0; font: 800 clamp(32px, 6.4vmin, 46px) var(--fsans); line-height: 1.08;
  color: #f4f6fa;
  transform: translateY(14px) scale(.96);
  transition: opacity .42s ease, transform .62s cubic-bezier(.22, 1.42, .36, 1);
}
.etac-rankup__name span { color: color-mix(in srgb, var(--tier) 62%, white); }
.etac-rankup__crown {
  margin: var(--space-12) 0 0; padding: var(--space-4) 13px; border-radius: var(--radius-pill);
  font: 700 12px var(--fsans); letter-spacing: 0.086em; text-transform: uppercase;
  color: color-mix(in srgb, var(--tier) 45%, white);
  background: color-mix(in srgb, var(--tier) 20%, transparent);
  border: 1px solid color-mix(in srgb, var(--tier) 42%, transparent);
  transition: opacity .42s ease .14s, transform .55s var(--ease-enter) .14s;
}
.etac-rankup__sub {
  margin: var(--space-12) 0 0; max-width: 32ch; font: 500 14px/1.55 var(--fsans); color: rgba(255, 255, 255, .62);
  transition: opacity .42s ease .24s, transform .55s var(--ease-enter) .24s;
}
/* Deliberately subdued — light-on-dark like every other word here (--text/
   --text-muted are unusable on this near-black field), but never competing
   with Continue for the eye. Lands last, a beat after the sub line. */
.etac-rankup__ranklink {
  display: inline-block; margin: var(--space-14) 0 0; font: 700 11px var(--fmono); letter-spacing: .03em;
  color: rgba(255, 255, 255, .5); text-decoration: none; border-bottom: 1px dotted transparent;
  opacity: 0; transform: translateY(12px);
  transition: opacity .42s ease .34s, transform .55s var(--ease-enter) .34s,
              color .15s ease, border-color .15s ease;
}
.etac-rankup__ranklink:hover, .etac-rankup__ranklink:focus-visible {
  color: color-mix(in srgb, var(--tier) 62%, white); border-bottom-color: currentColor;
}
.etac-rankup__ranklink:focus-visible { outline: 2px solid var(--tier); outline-offset: 3px; border-radius: 3px; }
.etac-rankup.is-named .etac-rankup__crown,
.etac-rankup.is-named .etac-rankup__sub,
.etac-rankup.is-named .etac-rankup__ranklink { opacity: 1; transform: none; }
.etac-rankup.is-named .etac-rankup__name { opacity: 1; transform: none; }
/* Bottom-left of the SCREEN now, not tucked in a card corner — he's watching
   the thing happen from the edge of the frame. Light on dark, so he reads as
   a silhouette in the same light the beams are throwing. */
.etac-rankup__mascot {
  position: absolute; z-index: 5;
  left: clamp(14px, 6vw, 92px); bottom: clamp(14px, 7vh, 78px);
  width: 104px; height: 124px;
  opacity: 0; transform: translateY(20px) scale(.9);
  transition: opacity .45s ease, transform .65s cubic-bezier(.34, 1.5, .64, 1);
}
.etac-rankup__mascot .caveman-svg {
  width: 100%; height: 100%; margin: 0; color: rgba(255, 255, 255, .6);
}
.etac-rankup.is-cheer .etac-rankup__mascot { opacity: 1; transform: none; }

/* ---- Continue ----
   Load-bearing, not decoration: nothing here times out, so this is the way
   out. It's a real button so it can take focus and be pressed by keyboard
   and screen readers; clicking anywhere on the field also works. */
.etac-rankup__go {
  position: absolute; z-index: 7; left: 50%; bottom: clamp(22px, 8vh, 76px);
  padding: var(--space-12) 30px; border-radius: 999px; cursor: pointer;
  font: 700 13px var(--fsans); letter-spacing: .02em; color: rgba(255, 255, 255, .94);
  background: rgba(255, 255, 255, .08); border: 1px solid rgba(255, 255, 255, .24);
  opacity: 0; transform: translate(-50%, 14px); pointer-events: none;
  transition: opacity .5s ease, transform .6s var(--ease-enter),
              background .18s ease, border-color .18s ease;
}
.etac-rankup.is-settled .etac-rankup__go { opacity: 1; transform: translate(-50%, 0); pointer-events: auto; }
.etac-rankup__go:hover { background: rgba(255, 255, 255, .16); border-color: rgba(255, 255, 255, .4); }
.etac-rankup__go:focus-visible {
  outline: 2px solid color-mix(in srgb, var(--tier) 70%, white); outline-offset: 3px;
}
/* Disabled for the length of the reward reveal (js/motion.js's
   enterRewardStage) — real button disabled state, so there's no way to
   skip ahead of the drift/tiles via click, Enter/Space, or Escape (all
   three route through the same dismiss(), which refuses to proceed while
   this is set). Dimmed rather than hidden — still reads as "the way out",
   just not yet. */
.etac-rankup__go:disabled { opacity: .45; cursor: default; }
.etac-rankup__go:disabled:hover { background: rgba(255, 255, 255, .08); border-color: rgba(255, 255, 255, .24); }

@keyframes etac-rankup-tremble {
  0%, 100% { transform: scale(.93) translate(0, 0); }
  25% { transform: scale(.93) translate(-2px, 1.5px); }
  50% { transform: scale(.93) translate(1.5px, -2px); }
  75% { transform: scale(.93) translate(2px, 1.5px); }
}
/* --len (reach) and --lit (brightness) are per-beam and must be carried
   through all three ray keyframes — the suck and the blow start from where
   the growth left off, so a beam that grew to .7 has to be pulled in and
   thrown out from .7, not from 1. */
@keyframes etac-rankup-ray {
  0% { transform: rotate(var(--a)) scaleY(0); opacity: 0; }
  22% { opacity: var(--lit, 1); }
  74% { transform: rotate(var(--a)) scaleY(calc(var(--len, 1) * 1.06)); }
  100% { transform: rotate(var(--a)) scaleY(var(--len, 1)); opacity: calc(var(--lit, 1) * .85); }
}
@keyframes etac-rankup-ray-suck {
  0% { transform: rotate(var(--a)) scaleY(var(--len, 1)); opacity: calc(var(--lit, 1) * .85); }
  100% { transform: rotate(var(--a)) scaleY(calc(var(--len, 1) * .72)); opacity: var(--lit, 1); }
}
@keyframes etac-rankup-ray-blow {
  0% { transform: rotate(var(--a)) scaleY(calc(var(--len, 1) * .72)); opacity: var(--lit, 1); }
  100% { transform: rotate(var(--a)) scaleY(calc(var(--len, 1) * 1.9)); opacity: 0; }
}
@keyframes etac-rankup-shock-wide {
  0% { opacity: .9; transform: scale(.2); }
  100% { opacity: 0; transform: scale(11); }
}
/* Peaks hard, then gets OUT of the way fast. The new badge is arriving
   underneath it, and a flash that lingers at half opacity turns the payoff
   into a grey wash — the whiteout is the punctuation, not the reveal. */
@keyframes etac-rankup-flash-big {
  0% { opacity: 0; transform: scale(.3); }
  8% { opacity: 1; transform: scale(.78); }
  24% { opacity: .4; transform: scale(1); }
  52% { opacity: .07; transform: scale(1.22); }
  100% { opacity: 0; transform: scale(1.4); }
}
/* Same shape as the flash above, minus the rise — a tier crossing is already
   fully white when this starts (the .is-whiteout hold just before it), so
   this only ever recedes. */
@keyframes etac-rankup-flash-big-tiercross {
  0% { opacity: 1; transform: scale(1); }
  28% { opacity: .4; transform: scale(1.1); }
  55% { opacity: .07; transform: scale(1.3); }
  100% { opacity: 0; transform: scale(1.45); }
}
@keyframes etac-rankup-shard {
  0% { opacity: 0; transform: rotate(var(--sa)) translateY(0) scale(.4); }
  12% { opacity: 1; }
  100% { opacity: 0; transform: rotate(var(--sa)) translateY(-46vmin) scale(.7); }
}
@keyframes etac-rankup-breathe {
  0%, 100% { opacity: .9; transform: scale(1); }
  50% { opacity: 1; transform: scale(1.07); }
}
@keyframes etac-rankup-settle {
  0%, 100% { transform: scale(1) translateY(0); }
  50% { transform: scale(1) translateY(-7px); }
}

/* The badge needs no breakpoint — its slot is already viewport-relative and
   the icon fills it; nor does the fan, which is sized in vmax. Only the
   fixed-size glow and the mascot do. */
@media (max-width: 560px) {
  .etac-rankup__glow { width: 320px; height: 320px; margin: -160px 0 0 -160px; }
  .etac-rankup__mascot { width: 76px; height: 92px; }
}

/* ---------- the fill: how a same-tier promotion happens ----------
   A division promotion changes ONLY the roman numeral — same wood, same
   frame, I → II → III. So the badge doesn't go anywhere and nothing is
   swapped in front of you: light climbs the face until it tops out, and the
   mark has changed under the flash.

   The mask has to stay on a box the size of the badge or it stops lining up
   with the art, so the climbing band is a CHILD travelling inside it. Putting
   the gradient on the masked box itself just brightens the whole badge at
   once, which reads as a wash rather than as a fill. */
.etac-rankup__fill {
  position: absolute; z-index: 4; inset: 0; overflow: hidden;
  -webkit-mask-image: var(--icon-old); mask-image: var(--icon-old);
  -webkit-mask-size: contain; mask-size: contain;
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  -webkit-mask-position: center; mask-position: center;
  opacity: 0;
}
.etac-rankup.is-filling .etac-rankup__fill { opacity: 1; }
.etac-rankup__fill::before {
  content: ""; position: absolute; left: 0; right: 0;
  height: 60%; bottom: -60%;
  background: linear-gradient(to top,
    transparent 0%,
    color-mix(in srgb, var(--tier) 60%, white) 26%,
    #fff 52%,
    color-mix(in srgb, var(--tier) 60%, white) 74%,
    transparent 100%);
}
.etac-rankup.is-filling .etac-rankup__fill::before {
  animation: etac-rankup-climb .72s cubic-bezier(.4, 0, .55, 1) both;
}
@keyframes etac-rankup-climb {
  0%   { transform: translateY(0); }
  100% { transform: translateY(-370%); }
}
/* The badge lights up from within as the band passes through it. */
.etac-rankup.is-filling .etac-rankup__icon.is-old {
  filter: brightness(1.4) drop-shadow(0 0 30px color-mix(in srgb, var(--tier) 70%, white));
  transition: filter .72s ease;
}
/* Gone in 90ms, under the flash — see .is-arrive's timing in motion.js. */
.etac-rankup.is-blast .etac-rankup__icon.is-old {
  opacity: 0; transition: opacity .09s linear;
}

/* ---- the screen gathers light with it ----
   The climb is a small event happening in the middle of a big dark screen,
   so the screen itself joins in: light pools up from the bottom edge in step
   with the band, spikes when it tops out, then falls away to a low ember.
   Deliberately subtle — it's the room reacting, not a second effect.

   It's an ::after on the overlay (z-index 0, under __inner at 1) rather than
   another element, and the spike is an ANIMATION because .is-blast and
   .is-arrive land on the same tick — a transition-based spike would be
   cancelled by whichever rule the cascade happened to prefer. */
.etac-rankup:not(.is-tiercross)::after {
  content: ""; position: absolute; inset: 0; z-index: 0; pointer-events: none;
  background: linear-gradient(to top,
    color-mix(in srgb, var(--tier) 72%, transparent) 0%,
    color-mix(in srgb, var(--tier) 32%, transparent) 30%,
    transparent 74%);
  opacity: 0; transform: translateY(26%);
  transition: opacity .72s ease, transform .8s cubic-bezier(.4, 0, .55, 1);
}
.etac-rankup:not(.is-tiercross).is-filling::after { opacity: .46; transform: none; }
.etac-rankup:not(.is-tiercross).is-blast::after {
  animation: etac-rankup-swell 1.5s var(--ease-enter) both;
}
@keyframes etac-rankup-swell {
  0%   { opacity: .46; transform: none; }
  9%   { opacity: .9; transform: none; }
  100% { opacity: .18; transform: none; }
}


/* Reduced motion — motion.js also adds .is-calm and stamps every end-state
   class at once, so the moment still READS (new badge, new name, caveman,
   Continue). It just doesn't move to get there. Like every other viewer it
   waits to be dismissed; there is no timeout path left to diverge from. */
.etac-rankup.is-calm .etac-rankup__icon.is-old { display: none; }
@media (prefers-reduced-motion: reduce) {
  .etac-rankup, .etac-rankup::after, .etac-rankup__inner, .etac-rankup__icon,
  .etac-rankup__glow, .etac-rankup__fan, .etac-rankup__kicker, .etac-rankup__name,
  .etac-rankup__crown, .etac-rankup__sub, .etac-rankup__ranklink, .etac-rankup__mascot,
  .etac-rankup__go, .etac-rankup__solid { transition: none; }
  .etac-rankup::after, .etac-rankup__icon, .etac-rankup__rays i, .etac-rankup__shards i,
  .etac-rankup__shock, .etac-rankup__flash, .etac-rankup__fan, .etac-rankup__fill::before,
  .etac-rankup__badge::before, .etac-rankup.is-arrive .etac-rankup__glow,
  .etac-rankup.is-settled .etac-rankup__solid { animation: none; }
  .etac-rankup__icon.is-old { display: none; }
  .etac-rankup__rays, .etac-rankup__shards, .etac-rankup__shock,
  .etac-rankup__flash, .etac-rankup__fill { display: none; }
}

/* =========================================================================
   Cosmetics — titles & banners (COSMETICS in api/_lib/progress.js)
   =========================================================================
   Two collectable types, rendered from server data everywhere a peer's name
   or card appears — this file owns the LOOK, hud.js/profile.html/friends-
   page.js/classes-page.js own WHERE. A title/banner object always arrives
   as {id, kind, name, style, c1, c2?} (see COSMETICS' header comment);
   every rule below reads only style/c1/c2 off the element it's set on —
   nothing here is per-cosmetic, so a 21st title never needs a new rule.

   A title sits on a small, faint rounded RECTANGLE — a plate, not the 99px
   capsule this started as, which read as a badge competing with the row's
   own chips rather than as a decoration on someone's name.

   Every effect below still lives INSIDE the glyphs — extra layers on the
   same `background-clip: text` fill the gradient styles already use, never
   an overlay box across the plate. The plate is a surface to sit on, not a
   surface to sweep light over: a highlight crossing it would light up the
   gaps between letters as brightly as the letters, which is exactly how the
   first version of shine/sparkle looked wrong.

   Titles: five `style` recipes cover all twenty.
     solid    flat color text
     grad     two-stop gradient text (c1→c2)
     shine    grad + a slow sheen sweeping through the letters
     sparkle  grad + glitter drifting inside the letters, and a big
              thin-rayed star that pops somewhere on it now and then
     magma    grad + a hot spot bouncing across, in the glyphs and (the
              part that reads as a glow) spilling softly behind them

   A `grad`-family entry may also carry axis: 'v' for a top-to-bottom ramp
   instead of the default diagonal — .etac-title--vert, one variable.

   Banners are PLACEHOLDER FILLS ONLY (see COSMETICS' header note) — one
   plain two-stop wash regardless of `style`; that field is stored for the
   later art pass to key off, not read here yet. */
.etac-title {
  --c2: var(--c1);
  /* Gradient axis, so all four gradient recipes below read one value rather
     than hardcoding an angle four times. A catalog entry with `axis: 'v'`
     gets .etac-title--vert (hud.js) — the only per-cosmetic switch here, and
     it's still data, not a rule per title. */
  --title-axis: 120deg;
  position: relative; isolation: isolate;
  display: inline-flex; align-items: center; max-width: 100%;
  white-space: nowrap; vertical-align: middle;
  font: 800 12.5px var(--fmono); letter-spacing: .04em;
  /* A small rounded rectangle, not a pill — a plate the title sits on
     rather than a badge competing with the row's own chips, which is what
     the 99px capsule read as. Faint on purpose: it's there to give the
     glyphs a consistent surface (and the near-black end of the catalog
     something to sit against), not to draw attention itself. Keyed to the
     theme, never to --c1 — a plate tinted with Iridescent's own black is no
     plate at all on dark theme, which is what the first version did. */
  padding: 3px var(--space-9); border-radius: var(--radius-sm);
  background: var(--title-plate);
  border: 1px solid var(--title-plate-bd);
}
.etac-title--vert { --title-axis: 180deg; }
.etac-title--inline { margin-left: var(--space-7); }
.etac-title--lg { font-size: 13.5px; letter-spacing: .05em; padding: var(--space-5) var(--space-12); border-radius: var(--radius-md); }
/* The rank-up reward reveal's own size (js/motion.js) — a fresh title needs
   to visibly BE the reward, not a caption next to the real one (its own
   Equip button, styled deliberately quiet in contrast — see
   .etac-rankup__reward-tile__equip). Every visual effect on .etac-title is
   sized in em off the label's own font-size (see the sparkle/shine/magma
   comments below), so this scales all of them correctly rather than
   needing its own copies. */
.etac-title--xl { font-size: 22px; letter-spacing: .03em; padding: var(--space-9) 19px; border-radius: var(--radius-lg); }
.etac-title__label {
  position: relative; z-index: 1; overflow: hidden; text-overflow: ellipsis;
  /* A hairline contour on the glyphs themselves, coloured against the
     GROUND rather than the title (--title-edge, style.css): light on the
     dark themes, dark on the light ones. The catalog runs to both extremes
     — Iridescent is near-black, SHINY and Lil Thunen are near-white — so a
     fixed-colour edge could only ever save half of them.

     An outline, not an offset drop: a drop is a second copy of the word a
     pixel away, which at 10.5px reads as a ghost behind the letters rather
     than a defined edge. This hugs the glyph on every side instead, and at
     .6px it's thinner than the stroke it's tracing, so it defines the shape
     without thickening it.

     `paint-order: stroke fill` keeps the stroke UNDER the fill — without it
     a centred stroke eats .3px off the inside of every letter, which at
     this size is a visible chunk of the colour. */
  -webkit-text-stroke: .6px var(--title-edge);
  paint-order: stroke fill;
}
.etac-title--solid .etac-title__label { color: var(--c1); }
.etac-title--grad .etac-title__label,
.etac-title--shine .etac-title__label,
.etac-title--sparkle .etac-title__label,
.etac-title--magma .etac-title__label {
  background-image: linear-gradient(var(--title-axis), var(--c1), var(--c2));
  background-repeat: no-repeat;
  -webkit-background-clip: text; background-clip: text;
  -webkit-text-fill-color: transparent; color: transparent;
}

/* shine — a highlight band riding the clipped fill. The band layer is 3×
   the element's width, so background-position 100% parks it off the left
   and 0% off the right (an oversized background layer moves the opposite
   way percentages read on a small one) — that inversion is the whole
   reason these keyframes look backwards. */
.etac-title--shine .etac-title__label {
  background-image:
    linear-gradient(105deg, transparent 44%, rgba(255, 255, 255, .92) 50%, transparent 56%),
    linear-gradient(var(--title-axis), var(--c1), var(--c2));
  background-size: 300% 100%, 100% 100%;
  background-position: 100% 0, 0 0;
  animation: etac-title-sheen 2.8s ease-in-out infinite;
}
@keyframes etac-title-sheen {
  0%, 18% { background-position: 100% 0, 0 0; }
  58%, 100% { background-position: 0% 0, 0 0; }
}

/* sparkle — glitter, not confetti. Seven pinprick sparks wandering slowly
   inside the letters (background layers, so they're points of light clipped
   to the glyphs and never dots floating in the gaps), plus two big stars
   that pop in on their own long, offset clocks — 5s apart and half a cycle
   out of phase, so one fires roughly every 2.5s and never twice in the same
   place running. The stars keep the long thin rays real glitter throws: a
   near-degenerate cross, not a chunky asterisk.

   Everything here is sized in `em`, not px — the same title renders at 10.5px
   in a roster row and 13.5px on the profile card, and a spark that reads at
   one size is a smudge or a speck at the other. */
.etac-title--sparkle .etac-title__label {
  background-image:
    radial-gradient(circle closest-side, #fff 0 52%, rgba(255, 255, 255, .45) 74%, transparent 100%),
    radial-gradient(circle closest-side, #fff 0 52%, rgba(255, 255, 255, .45) 74%, transparent 100%),
    radial-gradient(circle closest-side, #fff 0 52%, rgba(255, 255, 255, .45) 74%, transparent 100%),
    radial-gradient(circle closest-side, #fff 0 50%, rgba(255, 255, 255, .4) 72%, transparent 100%),
    radial-gradient(circle closest-side, #fff 0 50%, rgba(255, 255, 255, .4) 72%, transparent 100%),
    radial-gradient(circle closest-side, #fff 0 50%, rgba(255, 255, 255, .35) 72%, transparent 100%),
    radial-gradient(circle closest-side, #fff 0 50%, rgba(255, 255, 255, .35) 72%, transparent 100%),
    linear-gradient(var(--title-axis), var(--c1), var(--c2));
  background-size:
    .3em .3em, .24em .24em, .28em .28em, .2em .2em,
    .26em .26em, .18em .18em, .22em .22em, 100% 100%;
  animation: etac-title-sparkle-drift 11s linear infinite;
}
@keyframes etac-title-sparkle-drift {
  0%   { background-position: 5% 20%, 19% 76%, 33% 30%, 47% 70%, 61% 24%, 75% 74%, 89% 34%, 0 0; }
  25%  { background-position: 13% 72%, 27% 26%, 41% 74%, 55% 28%, 69% 70%, 83% 30%, 97% 72%, 0 0; }
  50%  { background-position: 21% 28%, 35% 70%, 49% 24%, 63% 76%, 77% 32%, 91% 70%, 7% 26%, 0 0; }
  75%  { background-position: 9% 74%, 23% 32%, 37% 72%, 51% 26%, 65% 74%, 79% 24%, 93% 68%, 0 0; }
  100% { background-position: 5% 20%, 19% 76%, 33% 30%, 47% 70%, 61% 24%, 75% 74%, 89% 34%, 0 0; }
}
.etac-title--sparkle::before,
.etac-title--sparkle::after {
  content: ""; position: absolute; z-index: 2; pointer-events: none;
  top: 24%; left: 21%; width: 1.4em; height: 1.4em; margin: -.7em 0 0 -.7em;
  background: #fff;
  clip-path: polygon(50% 0%, 53% 47%, 100% 50%, 53% 53%, 50% 100%, 47% 53%, 0% 50%, 47% 47%);
  filter: drop-shadow(0 0 .18em rgba(255, 255, 255, .95));
  opacity: 0; transform: scale(.15);
  animation: etac-title-sparkle-pop 5s ease-in-out infinite;
}
.etac-title--sparkle::after {
  top: 74%; left: 71%; width: 1em; height: 1em; margin: -.5em 0 0 -.5em;
  animation-delay: 2.5s;
}
@keyframes etac-title-sparkle-pop {
  0%, 66%, 100% { opacity: 0; transform: scale(.15) rotate(-30deg); }
  74% { opacity: 1; transform: scale(1.15) rotate(10deg); }
  82% { opacity: .95; transform: scale(.88) rotate(30deg); }
  92% { opacity: 0; transform: scale(.2) rotate(60deg); }
}

/* magma — one hot spot crossing the title twice on the same clock: once
   inside the glyphs (same oversized-layer inversion as shine above) and
   once as a soft blob BEHIND them. The blob is what makes it a glow rather
   than a highlight — it's the only light that spills past the letters — and
   it fades up and out at both ends so it never shows a hard edge where the
   text stops. It's transform+opacity, so the spill costs nothing to move.

   BOUNCES rather than loops: `alternate` walks the same keyframes backwards
   on every other run, so the heat travels out and back instead of snapping
   to the left edge to start again. The ease-in-out then lands where it
   belongs — decelerating into each end, accelerating out of it — which is
   the whole reason this reads as molten and not as a repeating wipe. Both
   layers share the duration, so they never drift apart. */
.etac-title--magma .etac-title__label {
  background-image:
    radial-gradient(closest-side, #fffdf2 0 22%, rgba(255, 205, 120, .92) 52%, rgba(255, 150, 40, .35) 78%, transparent 100%),
    linear-gradient(var(--title-axis), var(--c1), var(--c2));
  background-size: 300% 260%, 100% 100%;
  background-position: 100% 50%, 0 0;
  animation: etac-title-magma-sweep 3.2s ease-in-out infinite alternate;
  /* Keeps the shared outline (inherited), adds a permanent ember halo — the
     travelling blob below is the pulse, this is the ambient heat. */
  filter: drop-shadow(0 0 .3em rgba(249, 115, 22, .5));
}
/* The spill stays ON THE PLATE: inset to the title's own box, 42% wide,
   travelling 0 → 138% of its own width — which lands its right edge exactly
   on the box's right edge (58% of the box ÷ 42% per unit of translate).
   Running it off the ends, which is what -110%/190% did, put a loose orange
   cloud on the page half a title-width clear of any letter; it read as a
   stray light rather than heat coming off the text. It fades in and out at
   the two ends instead, and never leaves the plate in any direction. */
.etac-title--magma::after {
  content: ""; position: absolute; z-index: 0; pointer-events: none;
  top: 0; bottom: 0; left: 0; width: 42%; border-radius: inherit;
  background: radial-gradient(closest-side, rgba(255, 146, 40, .5), transparent 100%);
  opacity: 0; transform: translateX(0);
  animation: etac-title-magma-glow 3.2s ease-in-out infinite alternate;
}
@keyframes etac-title-magma-sweep {
  from { background-position: 100% 50%, 0 0; }
  to { background-position: 0% 50%, 0 0; }
}
@keyframes etac-title-magma-glow {
  0% { opacity: 0; transform: translateX(0); }
  22%, 78% { opacity: 1; }
  100% { opacity: 0; transform: translateX(138%); }
}

@media (prefers-reduced-motion: reduce) {
  .etac-title--shine .etac-title__label,
  .etac-title--sparkle .etac-title__label,
  .etac-title--magma .etac-title__label,
  .etac-title--sparkle::before, .etac-title--sparkle::after,
  .etac-title--magma::after { animation: none; }
  /* Snap to the end state rather than an empty one: the sparks and the hot
     spot are what the title IS, so they sit mid-sweep instead of vanishing. */
  .etac-title--shine .etac-title__label { background-position: 50% 0, 0 0; }
  .etac-title--magma .etac-title__label { background-position: 50% 50%, 0 0; }
  .etac-title--sparkle::before, .etac-title--sparkle::after { opacity: .9; transform: scale(1); }
  .etac-title--magma::after { opacity: .8; transform: translateX(40%); }
}

/* BANNER SURFACES (.p-banner in profile.css, .etac-usercard in friends.css)
   are the two places a banner renders behind something rather than as a tile.

   The art is an <img> child that applyBanner() (hud.js) inserts as the FIRST
   child — deliberately not a background-image, which Chrome rasterises once
   and then holds, freezing any animated banner on a single frame. Each
   surface keeps its own two-stop c1/c2 wash as its background, underneath
   this, so a slow or missing file degrades to the wash rather than a bare
   card.

   Because the img is a real element it has to be ordered against the scrims
   each surface already paints. Those surfaces state every layer's z-index
   explicitly rather than leaning on DOM order, since ::before counts as the
   first child and would otherwise land under the art it exists to darken.

   Display is gated on the parent's class, not on whether the img exists: the
   user card is one reused element whose class is cleared directly by
   ucHide(), so art left in the DOM must go quiet on its own. */
.etac-banner-art {
  display: none;
  position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: cover; z-index: 0; pointer-events: none;
}
.has-banner > .etac-banner-art { display: block; }

/* Small owned collection tile — profile.html's Titles/Banners cards reuse
   .p-collect's grid (profile.css) but need their own tile face, since
   .p-chip is sized/styled for a round caveman avatar. Owned only: hud.js
   renders nothing for what isn't earned yet (see renderCosmeticsCollections),
   so there's no locked/disabled face here. */
.etac-cosmetic-tile {
  display: flex; flex-direction: column; align-items: center; gap: var(--space-6);
  padding: var(--space-10) var(--space-6); border-radius: var(--radius-lg); border: 1px solid var(--border);
  background: var(--bg-input); cursor: pointer; font: inherit; text-align: center;
  transition: border-color .15s ease, background .15s ease;
}
/* A title's name IS its art, so its tile is as wide as the name rather than
   a fixed cell that would ellipsize "Geothermal Geographer" into nothing.
   Pairs with .p-collect--titles (profile.css), which wraps instead of grids.
   The tile face itself goes away: a title already carries its own plate, and
   a bordered box around a bordered box reads as a mistake. Hover and the
   equipped state bring the tile's own border back to do the affording. */
.etac-cosmetic-tile--title {
  padding: var(--space-4); background: none; border-color: transparent;
}
.etac-cosmetic-tile--title:hover { background: var(--bg-input); }
/* The only thing that disables a tile now is the equip PATCH being in flight
   (hud.js) — never a locked cosmetic, which simply isn't rendered. */
.etac-cosmetic-tile:disabled { cursor: default; opacity: .6; }
.etac-cosmetic-tile:hover { border-color: var(--border-hover); }
.etac-cosmetic-tile.is-equipped { border-color: var(--accent); background: var(--accent-soft); }
/* Square, not the banner's own 4:1 — an 82px grid cell (.p-collect,
   profile.css) renders the wide face as an unreadable sliver, and the icon
   art is drawn for exactly this shape. Matches .p-chip, the cavemen card's
   tile directly above it on the same page. The c1/c2 wash stays as the
   backstop for a banner with no icon shipped. */
.etac-cosmetic-tile__swatch {
  width: 100%; aspect-ratio: 1; border-radius: var(--radius-sm); overflow: hidden;
  display: grid; place-items: center;
  background: linear-gradient(135deg, var(--c1), var(--c2, var(--c1)));
}
/* The icon carries its own rounded corners, so it sits edge to edge and the
   swatch's radius never actually clips it. */
.etac-cosmetic-tile__icon { display: block; width: 100%; height: 100%; }
/* The banner card's "Default" tile — previews the plain neutral background
   a banner-less surface actually gets elsewhere (e.g. .etac-usercard's
   var(--bg-elevated) in friends.css), not a color of its own. */
.etac-cosmetic-tile__swatch--default { background: var(--bg-elevated); border: 1px solid var(--border); }
.etac-cosmetic-tile__cap {
  font: 600 12px var(--fmono); color: var(--text-muted); text-align: center;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;
}
.etac-cosmetic-tile.is-equipped .etac-cosmetic-tile__cap { color: var(--accent); font-weight: 700; }

/* The Golden Caveman (COSMETICS['caveman-golden']) — a recolor of whichever
   starter's art already renders (caveman.js's own POSES[x] || POSES.original
   fallback already handles the id not being a real starter), not new art.
   `!important` because .caveman-svg's color is set ambiently by whichever
   container it's in (the rank-hero mascot, the rank-up overlay's mascot) and
   this has to win regardless of which one it lands in. */
.is-golden-caveman .caveman-svg {
  color: #eab308 !important;
  filter: drop-shadow(0 0 8px rgba(234, 179, 8, .5));
}

/* ---------- Rank-up rewards reveal (js/motion.js) ----------
   Fires only when the grade response carried `rewards` — with none, the
   overlay behaves exactly as it always has. The badge and the reward grid
   occupy the SAME grid cell (see .etac-rankup__stage) so the swap is a
   crossfade in place, never a layout jump: the badge shrinks and fades away
   (anticipation: it braces at .94 first, the same beat every arrival here
   uses) while the tiles rise in behind it, staggered, each overshooting a
   hair before settling — never opacity alone. */
.etac-rankup__stage { display: grid; }
.etac-rankup__stage > * { grid-area: 1 / 1; }
.etac-rankup.is-reward-stage .etac-rankup__badge {
  opacity: 0; transform: scale(.94); pointer-events: none;
  transition: opacity .32s var(--ease-exit), transform .38s var(--ease-exit);
}
/* The big badge+name (.etac-rankup__words below) hand off to the docked
   .etac-rankup__mini header the instant the reward stage opens — "you got
   here" shrinks out of the way while "here's what that paid out" takes the
   main stage. Both fade+lift together; .etac-rankup__mini fades+settles in
   at the same time (see its own block further down). */
.etac-rankup__kicker, .etac-rankup__words {
  transition: opacity .3s var(--ease-exit), transform .36s var(--ease-exit);
}
.etac-rankup.is-reward-stage .etac-rankup__kicker,
.etac-rankup.is-reward-stage .etac-rankup__words {
  opacity: 0; transform: translateY(-10px); pointer-events: none;
}
.etac-rankup__rewards {
  align-self: center; justify-self: center;
  display: flex; flex-direction: column; align-items: center; gap: var(--space-16);
  opacity: 0; transform: scale(.94); pointer-events: none;
  transition: opacity .42s var(--ease-enter), transform .5s var(--ease-enter);
}
.etac-rankup.is-reward-stage .etac-rankup__rewards { opacity: 1; transform: none; pointer-events: auto; }
.etac-rankup__rewards-kicker {
  font: 800 12px var(--fsans); letter-spacing: 0.202em; text-transform: uppercase;
  color: rgba(255, 255, 255, .62); text-shadow: 0 2px 10px rgba(0, 0, 0, .7);
}
.etac-rankup__rewards-grid {
  position: relative; /* anchors .etac-rankup__reward-fan's centering below */
  display: flex; flex-wrap: wrap; justify-content: center; gap: var(--space-14);
  max-width: min(480px, 86vw);
}
.etac-rankup__reward-tile {
  /* z-index alone is enough here (no position needed) — flex items paint in
     z-index order even while position: static, unlike normal block layout —
     so this is what keeps every tile above .etac-rankup__reward-fan
     regardless of DOM order. */
  z-index: 1;
  display: flex; flex-direction: column; align-items: center; gap: var(--space-10);
  padding: var(--space-18) 22px; border-radius: 15px; min-width: 119px;
  background: rgba(255, 255, 255, .06); border: 1px solid rgba(255, 255, 255, .16);
  opacity: 0; transform: translateY(16px) scale(.9);
  transition: opacity .5s var(--ease-enter), transform .6s var(--ease-enter);
}
/* Staggered 70ms apart, per CLAUDE.md's motion rules — up to 5 tiles is
   more than any single grant produces today (obsidian-3 tops out at 2). */
.etac-rankup.is-reward-stage .etac-rankup__reward-tile:nth-child(1) { transition-delay: 0ms; }
.etac-rankup.is-reward-stage .etac-rankup__reward-tile:nth-child(2) { transition-delay: 70ms; }
.etac-rankup.is-reward-stage .etac-rankup__reward-tile:nth-child(3) { transition-delay: 140ms; }
.etac-rankup.is-reward-stage .etac-rankup__reward-tile:nth-child(4) { transition-delay: 210ms; }
.etac-rankup.is-reward-stage .etac-rankup__reward-tile:nth-child(5) { transition-delay: 280ms; }
.etac-rankup.is-reward-stage .etac-rankup__reward-tile { opacity: 1; transform: none; }
/* A small grow on hover — same specificity as the nth-child stagger rules
   above (4 class-level selectors each) so source order is what lets this
   win once settled, instead of inheriting one of their entrance delays. */
.etac-rankup.is-reward-stage .etac-rankup__reward-tile:hover {
  transform: scale(1.07);
  transition: transform .22s var(--ease-exit);
}
.etac-rankup__reward-tile__swatch {
  width: 82px; height: 82px; border-radius: 17px;
  background: linear-gradient(135deg, var(--c1), var(--c2, var(--c1)));
  box-shadow: 0 7px 22px rgba(0, 0, 0, .4);
}
/* A banner's icon brings its own corners and its own edge, so the box stops
   drawing both — the drop-shadow moves onto the icon's real silhouette
   instead of a rounded rect that no longer matches it. */
.etac-rankup__reward-tile__swatch:has(.etac-rankup__reward-tile__icon) {
  background: none; box-shadow: none;
}
.etac-rankup__reward-tile__icon {
  display: block; width: 100%; height: 100%;
  filter: drop-shadow(0 7px 22px rgba(0, 0, 0, .55));
}
/* A title tile's swatch is the chip itself, at rest size (.etac-title--xl,
   further up this file) — no separate swatch box, since the title IS its
   own piece of art and is already the eye-catching element on its own. */
.etac-rankup__reward-tile__swatch:has(.etac-title) {
  width: auto; height: auto; background: none; box-shadow: none; padding: var(--space-6) 0;
}
.etac-rankup__reward-tile__label {
  font: 700 11px var(--fsans); color: #fff; text-align: center;
  text-shadow: 0 1px 3px rgba(0, 0, 0, .5);
}
.etac-rankup__reward-tile__kind {
  font: 700 11.5px var(--fsans); letter-spacing: 0.072em; text-transform: uppercase;
  color: rgba(255, 255, 255, .5);
}
/* Every tile is a cosmetic (COSMETICS only ever has kind title/banner/
   caveman), so every tile gets one. Click wiring — and the Equip →
   Equipping… → Equipped ✓ states this drives — lives in js/motion.js, not
   hud.js's generic equip delegate; see the comment there for why.

   Deliberately the quiet element in this tile now that the cosmetic itself
   (.etac-title--xl above, or the 82px swatch) carries all the visual
   weight — a plain rounded RECTANGLE (6px, not a capsule) in the monospace
   meta-label voice .etac-rankup__reward-tile__kind already uses, so it
   reads as a UI control sitting under the reward rather than a second
   thing competing to BE the reward. */
.etac-rankup__reward-tile__equip {
  margin-top: var(--space-5); padding: var(--space-5) 13px; border-radius: 6px; cursor: pointer;
  font: 700 12px var(--fsans); letter-spacing: .058em; text-transform: uppercase;
  color: rgba(255, 255, 255, .55);
  background: rgba(255, 255, 255, .05); border: 1px solid rgba(255, 255, 255, .14);
  transition: background .15s ease, border-color .15s ease, color .15s ease, opacity .15s ease;
}
.etac-rankup__reward-tile__equip:hover {
  color: rgba(255, 255, 255, .88);
  background: rgba(255, 255, 255, .1); border-color: rgba(255, 255, 255, .28);
}
.etac-rankup__reward-tile__equip:disabled { opacity: .55; cursor: default; }
.etac-rankup__reward-tile__equip:focus-visible {
  outline: 2px solid color-mix(in srgb, var(--tier) 70%, white); outline-offset: 2px;
}
/* Landed state — sticks once equipped, distinguished from the plain
   disabled dimming above by picking up the arrived tier's color instead of
   just fading. Still the quiet element: tinted, not solid-filled. */
.etac-rankup__reward-tile__equip.is-done {
  opacity: 1; color: color-mix(in srgb, var(--tier) 65%, white);
  background: color-mix(in srgb, var(--tier) 16%, transparent);
  border-color: color-mix(in srgb, var(--tier) 40%, transparent);
}

/* ---- the Sparks/XP line ----
   Same numbers, same glyphs (＋N XP · ＋N ✦) hud.js/daily.js already use for
   a reward banner elsewhere — kept small and secondary under the tiles,
   since the collectibles are this stage's headline. Sparks keeps its own
   never-reskinned gold (--spark-*, same rule as the HUD coin); XP borrows
   the arrived tier's color rather than --accent, which isn't guaranteed
   legible against this near-black field in every theme (see the header note
   on this file's light-on-dark rule). */
.etac-rankup__reward-stats {
  display: flex; gap: var(--space-8); opacity: 0; transform: translateY(8px);
  transition: opacity .4s var(--ease-enter) .3s, transform .48s var(--ease-enter) .3s;
}
.etac-rankup.is-reward-stage .etac-rankup__reward-stats { opacity: 1; transform: none; }
.etac-rankup__reward-stat {
  font: 700 11px var(--fmono); letter-spacing: .02em; padding: var(--space-4) 11px; border-radius: var(--radius-pill);
}
.etac-rankup__reward-stat.is-xp {
  color: color-mix(in srgb, var(--tier) 55%, white);
  background: color-mix(in srgb, var(--tier) 18%, transparent);
  border: 1px solid color-mix(in srgb, var(--tier) 40%, transparent);
}
.etac-rankup__reward-stat.is-sp {
  color: var(--spark-ink); background: linear-gradient(160deg, var(--spark-1), var(--spark-2));
  border: 1px solid var(--spark-bd);
}

/* ---- the mini fan ----
   The same conic-beam recipe a division III arrival parks behind the badge
   (.is-crowned .etac-rankup__fan, further up this file) — not the big
   __rays charge, which is a one-shot detonation build-up and the wrong
   register for this beat. A calmer, smaller cousin: sized and positioned
   off the CARD (.etac-rankup__rewards-grid, which it's nested inside —
   js/motion.js — and which carries the position:relative this centers
   against), not the viewport, and lighter than the crown treatment. It
   sways gently rather than spinning — a full rotation reads as "an ongoing
   event somewhere behind the tiles"; a small sway reads as ambient light,
   which is the register this wants. Stays lit for as long as the rewards
   are up. z-index left at auto/0 — .etac-rankup__reward-tile's z-index: 1
   (a flex sibling, not this element's stacking context) is what keeps the
   cards above it regardless of which one is later in the markup. */
.etac-rankup__reward-fan {
  position: absolute; top: 50%; left: 50%; pointer-events: none;
  width: 272px; height: 272px; margin: -136px 0 0 -136px;
  background: repeating-conic-gradient(from 0deg,
    transparent 0deg,
    color-mix(in srgb, var(--tier) 65%, transparent) 13deg,
    transparent 26deg,
    transparent 60deg);
  -webkit-mask-image: radial-gradient(closest-side, #000 6%, rgba(0, 0, 0, .82) 38%, rgba(0, 0, 0, 0) 90%);
          mask-image: radial-gradient(closest-side, #000 6%, rgba(0, 0, 0, .82) 38%, rgba(0, 0, 0, 0) 90%);
  opacity: 0; filter: blur(4px);
  transition: opacity 1s ease;
  animation: etac-rankup-reward-fan-sway 7s ease-in-out infinite;
}
.etac-rankup.is-reward-stage .etac-rankup__reward-fan { opacity: .2; }
@keyframes etac-rankup-reward-fan-sway {
  0%, 100% { transform: rotate(-10deg); }
  50% { transform: rotate(10deg); }
}

/* ---- the docked header ----
   Where .etac-rankup__kicker + .etac-rankup__words WERE, parked at the top
   of the screen instead — the "you got here" half of the moment, still
   visible (at 2.5x this file's usual small-label size — this is the one
   thing left to read once the tiles take over, so it earns the extra
   weight) while the rewards grid (the "here's what that paid out" half)
   owns the center. .etac-rankup__mini is layout ONLY (position + a fixed
   anchor point); the icon and name each carry their own opacity fade AND
   get their transform driven live by js/motion.js's flip() — it inverts
   them to visually start at the big badge/name's current rect, then
   releases the transform in one step so the browser animates the real
   on-screen delta (the drift) rather than this file guessing a travel
   distance in vh. That's also why there's no transform here beyond the
   static centering translateX: JS owns the moving part completely. */
.etac-rankup__mini {
  position: absolute; z-index: 8; top: clamp(18px, 6vh, 46px); left: 50%;
  display: flex; align-items: center; gap: var(--space-22);
  transform: translateX(-50%); pointer-events: none;
}
.etac-rankup__mini-icon, .etac-rankup__mini-name {
  opacity: 0; transition: opacity .3s var(--ease-exit);
}
.etac-rankup.is-reward-stage .etac-rankup__mini-icon,
.etac-rankup.is-reward-stage .etac-rankup__mini-name {
  opacity: 1;
}
.etac-rankup__mini-icon {
  width: 80px; height: 80px;
  filter: drop-shadow(0 4px 16px color-mix(in srgb, var(--tier) 65%, transparent));
}
.etac-rankup__mini-name {
  font: 800 35px var(--fsans); color: #f4f6fa; letter-spacing: .01em;
  text-shadow: 0 3px 14px rgba(0, 0, 0, .8);
}

@media (prefers-reduced-motion: reduce) {
  .etac-rankup__badge, .etac-rankup__rewards, .etac-rankup__reward-tile,
  .etac-rankup__kicker, .etac-rankup__words, .etac-rankup__reward-stats,
  .etac-rankup__mini-icon, .etac-rankup__mini-name { transition: none !important; }
  .etac-rankup__reward-fan { animation: none !important; transition: none !important; }
  .etac-rankup.is-reward-stage .etac-rankup__reward-fan { opacity: .2; }
}

/* =========================================================================
   The interactive feature tour (js/tour.js) — "The First Expedition"
   =========================================================================
   NOT a modal takeover like .etac-rankup above: the real page stays live
   and visible underneath. A scrim dims everything, the one real control
   being taught is lifted onto its own z-index (js/tour.js sets this
   inline, same z-index dance js/motion.js's behindHost() already does),
   and a small card sits beside it. The scrim is the SAME near-black
   .etac-rankup uses in both themes — light only reads against dark — so
   every color inside .etac-tour__card is light-on-dark and --text/
   --text-muted are unusable here, same trap CLAUDE.md flags for rankUp.

   Positioning (left/top/width/height) is set inline by js/tour.js via
   getBoundingClientRect; this file owns only the transition curve, so a
   WITHIN-chapter stop change (the spotlight traveling to a new control)
   animates smoothly on the same card/spot elements, while a CHAPTER change
   fades the card out/in instead (.is-swap-out/.is-swap-in, identical
   convention to onboarding.js's own step swap). */
/* The hard lock: js/tour.js toggles this class on <body> for as long as
   the tour is open. pointer-events:none on the whole page removes every
   OTHER element from hit-testing outright — a real fix, not a z-index bet,
   for the exact bug a z-index-only scheme had: raising the real target's
   z-index only orders it against SIBLINGS within its own nearest
   stacking-context ancestor. .etac-hud (the HUD bar every avatar/XP/
   Sparks/streak/bell/menu stop targets) is `position: sticky`, which
   starts one of those contexts — so the child's raised z-index never
   actually competed against this overlay's scrim at all; the WHOLE HUD
   bar did, at its own (lower) z-index, and lost. pointer-events:none
   sidesteps that same class of problem: it's not a stacking comparison,
   so nothing outside these two exceptions can ever receive a pointer
   event while the tour holds this lock, regardless of any ancestor. */
body.etac-tour-lock { pointer-events: none; }
/* The two holes in that lock: the tour's own overlay, and (only on a
   waitClick stop, per raise()'s `real` argument) the one real control
   js/tour.js's raise() marked — see .etac-tour__target below. Explicit
   pointer-events on the element itself always wins over an inherited
   value, no matter what its ancestors say. The overlay's hole is its
   card here, NOT the .etac-tour root: that root is a full-viewport
   inset:0 box, so making IT clickable blankets the spotlight hole too
   and swallows the very press a waitClick stop is waiting for (the
   Social Hub's "tap the Leaderboard tab" stop did nothing). The scrim
   slabs already carry their own pointer-events:auto. */
body.etac-tour-lock .etac-tour__card { pointer-events: auto; }

.etac-tour {
  /* Above html[data-launching]::after (z-index 9999, style.css/this file) —
     a chapter hop lands here right as that generic page-arrival cover is
     still fading out, and without this the freshly-opened tour (card
     included) painted BEHIND that cover for the length of its fade,
     reading as "the info card is behind the highlight." The cover's own
     job (hide the destination page assembling itself) still gets done
     underneath — this overlay carries its own near-identical near-black
     scrim, so nothing looks wrong about the tour winning that stacking
     order from its very first frame. */
  position: fixed; inset: 0; z-index: 10000; pointer-events: none;
  opacity: 0; transition: opacity .28s var(--ease-enter);
}
.etac-tour.is-in { opacity: 1; }

/* Four slabs tiling the viewport AROUND the spotlighted rect (js/tour.js's
   layoutScrim) — not one full-bleed scrim with the real target raised
   above it. A hole has no failure mode a stacking context can create:
   if no scrim pixel ever paints over that rect, whatever's under it reads
   at its own natural brightness, full stop — no z-index race to lose. The
   four rects also mean the scrim itself can transition (left/top/width/
   height) as the spotlight travels between stops, the same slide the glow
   ring already did, rather than snapping. */
.etac-tour__scrim {
  position: fixed; background: rgba(6, 7, 11, .74); pointer-events: auto;
  transition: left .22s var(--ease-enter), top .22s var(--ease-enter),
              width .22s var(--ease-enter), height .22s var(--ease-enter);
}
/* The lit rectangle around the real control — decorative only
   (pointer-events: none), so it never intercepts a click meant for
   whatever's inside it. Position/size set inline by js/tour.js; this owns
   only the look and the TRAVEL animation between stops. border-radius is
   also set inline per stop (js/tour.js's spotRadius) so the ring traces
   the actual shape of whatever it's highlighting — a pill control gets a
   pill spotlight, a square-cornered card gets square corners — the 14px
   here is only the fallback for a centered stop with no real target. */
/* Corner patches that round off layoutScrim's rectangular hole to match
   whatever corner radius the glow ring itself is tracing (js/tour.js's
   layoutHoleCorners) — a plain rounded-rect stop leaves them at 0×0 (a
   no-op), a pill stop (999px radius) is where they actually do work,
   painting the hole's square corners back to scrim color so the lit area
   never reads as wider than the pill it's framing. Position/size/the
   radial-gradient itself are all set inline; this owns only the travel
   transition, same convention as the scrim slabs beside it. */
.etac-tour__hole-corner {
  position: fixed; pointer-events: none;
  transition: left .22s var(--ease-enter), top .22s var(--ease-enter),
              width .22s var(--ease-enter), height .22s var(--ease-enter);
}
.etac-tour__hole-corner[hidden] { display: none; }
.etac-tour__spot {
  position: fixed; z-index: 2001; pointer-events: none;
  border-radius: var(--radius-xl); box-shadow: 0 0 0 3px var(--accent), 0 0 30px 6px color-mix(in oklab, var(--accent) 45%, transparent);
  transition: left .22s var(--ease-enter), top .22s var(--ease-enter),
              width .22s var(--ease-enter), height .22s var(--ease-enter),
              border-radius .22s var(--ease-enter), opacity .2s var(--ease-enter);
}
.etac-tour__spot[hidden] { opacity: 0; display: block; }
/* A second ring that breathes in place — transform + opacity only (the
   perf rule above), never box-shadow, so this is free even on the
   Chromebooks this app is built for. Gives the spotlight some life without
   fighting the travel transition on the base box-shadow. */
.etac-tour__spot::after {
  content: ""; position: absolute; inset: -6px; border-radius: inherit;
  border: 2px solid color-mix(in oklab, var(--accent) 70%, transparent);
  animation: etac-tour-breathe 2.4s var(--ease-enter) infinite;
}
@keyframes etac-tour-breathe {
  0%, 100% { transform: scale(1); opacity: .55; }
  50% { transform: scale(1.07); opacity: .12; }
}
/* The real element itself — only classed here on a waitClick stop whose
   target actually exists (raise()'s `real` argument). A plain spotlight
   stop leaves the element unclassed: fully lit by the hole above, but
   still genuinely un-clickable under body.etac-tour-lock, which is what
   stops a big spotlighted container like .etac-grid from letting every
   tile inside it be tapped on a stop that only meant to point at it. */
.etac-tour__target { position: relative; pointer-events: auto; }

/* A thin --grad stripe along the top edge is the one place this near-black
   overlay borrows the active theme's own palette — same idea as the rank
   badges, just a sliver of it rather than a field color (60/30/10 still
   holds: the card itself stays dark). */
.etac-tour__card {
  position: fixed; z-index: 2002; max-width: 320px; width: min(320px, calc(100vw - 32px));
  background: #101319; border: 1px solid color-mix(in oklab, var(--accent) 30%, rgba(255, 255, 255, .12));
  border-radius: 18px; overflow: hidden;
  padding: 19px var(--space-18) var(--space-14); box-shadow: 0 24px 60px -12px rgba(0, 0, 0, .6);
  color: #f4f2ee;
  transition: left .22s var(--ease-enter), top .22s var(--ease-enter);
  animation: etac-tour-pop .5s var(--ease-enter) both;
}
.etac-tour__card::before {
  content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; background: var(--grad);
}
@keyframes etac-tour-pop {
  0% { opacity: 0; transform: translateY(14px) scale(.92); }
  58% { opacity: 1; transform: translateY(-4px) scale(1.03); }
  100% { opacity: 1; transform: translateY(0) scale(1); }
}
.etac-tour__card.is-swap-out {
  opacity: 0; transform: translateY(6px) scale(.97);
  transition: opacity .2s var(--ease-exit), transform .2s var(--ease-exit);
}
.etac-tour__card.is-swap-in {
  opacity: 0; transform: translateY(6px) scale(.97);
}
.etac-tour__card:not(.is-swap-out):not(.is-swap-in) {
  opacity: 1; transform: translateY(0) scale(1);
  transition: left .22s var(--ease-enter), top .22s var(--ease-enter),
              opacity .45s var(--ease-enter), transform .45s var(--ease-enter);
}

.etac-tour__rail {
  display: flex; gap: var(--space-5); margin-bottom: var(--space-8);
}
.etac-tour__bead {
  flex: 1 1 0; height: 4px; border-radius: var(--radius-pill); background: rgba(255, 255, 255, .16);
  position: relative; overflow: hidden;
}
.etac-tour__bead.is-done { background: var(--accent); }
.etac-tour__bead.is-current { box-shadow: 0 0 8px 0 color-mix(in oklab, var(--accent) 65%, transparent); }
.etac-tour__bead.is-current::after {
  content: ""; position: absolute; inset: 0 auto 0 0; width: var(--fill, 0%);
  background: var(--accent); border-radius: var(--radius-pill); transition: width .3s var(--ease-exit);
}
.etac-tour__chapter {
  margin: 0 0 var(--space-9); font: 700 11px/1 var(--fsans); text-transform: uppercase;
  letter-spacing: .06em; color: color-mix(in oklab, #f4f2ee 55%, transparent);
  display: flex; align-items: center; gap: var(--space-7);
}
.etac-tour__chapter::before {
  content: ""; flex: none; width: 6px; height: 6px; border-radius: 2px;
  background: var(--grad); transform: rotate(45deg);
}
.etac-tour__say {
  margin: 0 0 var(--space-14); font: 600 15px/1.4 var(--fsans); color: #f4f2ee;
}
.etac-tour__hint {
  margin: 0 0 var(--space-10); font: 500 12px/1.4 var(--fsans);
  color: color-mix(in oklab, #f4f2ee 60%, transparent); font-style: italic;
}
.etac-tour__actions { margin-bottom: var(--space-10); display: flex; justify-content: flex-end; }
.etac-tour__actions .etac-btn {
  width: auto; padding: var(--space-9) var(--space-18); font-size: 13px; border-radius: 999px;
  transition: transform .18s var(--ease-enter), filter .15s var(--ease-enter), box-shadow .18s var(--ease-enter);
}
.etac-tour__actions .etac-btn:hover { transform: translateY(-1px) scale(1.03); }
.etac-tour__actions .etac-btn:active { transform: translateY(0) scale(.96); }
.etac-tour__skips { display: flex; justify-content: space-between; gap: var(--space-10); flex-wrap: wrap; }
.etac-tour__skip {
  background: none; border: none; padding: var(--space-2) 0; cursor: pointer;
  font: 600 12px/1 var(--fsans); color: color-mix(in oklab, #f4f2ee 58%, transparent);
  text-decoration: underline; text-decoration-color: transparent;
  transition: text-decoration-color .15s var(--ease-exit), color .15s var(--ease-exit);
}
.etac-tour__skip:hover { color: #f4f2ee; text-decoration-color: currentColor; }

@media (max-width: 460px) {
  .etac-tour__card { padding: var(--space-16) 15px var(--space-12); }
  .etac-tour__say { font-size: 14px; }
}
@media (prefers-reduced-motion: reduce) {
  .etac-tour, .etac-tour__scrim, .etac-tour__hole-corner, .etac-tour__spot, .etac-tour__card,
  .etac-tour__card.is-swap-out, .etac-tour__card.is-swap-in,
  .etac-tour__card:not(.is-swap-out):not(.is-swap-in),
  .etac-tour__actions .etac-btn { transition: none !important; }
  .etac-tour__card { animation: none !important; }
  .etac-tour__spot::after { animation: none !important; opacity: .4; }
  .etac-tour__bead.is-current::after { transition: none; }
}

/* ---------- Motion primitives (js/motion.js) ----------
   Shared by every named moment beyond rankUp: ringOut/dust/confetti/launch
   (onboarding.js) all emit into a fresh .etac-motion-fx layer per burst
   rather than a page-wide one, so concurrent bursts (unlikely today, but
   cheap to allow) never fight over z-index or cleanup timing.

   EVERY one of those layers is a BACK layer — z-index 0, stacked under the
   content of whatever container it was parented to (see behindHost() in
   js/motion.js). There is no in-front variant on purpose: a celebration
   painted over the thing that caused it covers what the person is looking
   at, and one moment doing that while the others go behind reads as a bug.

   Viewport-sized and clipped, not a zero-size origin point, so a burst
   thrown past the edge of a scrolling container can't grow its scroll area —
   that flashed a scrollbar down the side of the screen for the length of
   every press. Particles inside therefore carry viewport coordinates.

   Every particle is positioned with inline left/top (the anchor point
   js/motion.js computed via getBoundingClientRect) and animates ONLY
   transform + opacity, reading --d (start delay) / --t (travel time) /
   --dx / --dy / --rot off itself — the same per-particle custom-prop shape
   as store.js's spawnFx and script.js's .sparkle. */
.etac-motion-fx {
  position: fixed; inset: 0; pointer-events: none;
}
.etac-motion-fx--under { z-index: 0; overflow: hidden; }
/* dust()'s three layers — see js/motion.js for what each one is for. The
   whole cloud is gold: --spark-1/-2/-bd are the Sparks palette, deliberately
   identical in every theme, so the press moment looks the same on a light
   Chromebook as it does on a dark one.

   Grit: 3px, pale, barely there. What sells it is the count and the spread,
   never the opacity of any one mote. */
.etac-motion-mote {
  position: absolute; width: 3px; height: 3px; margin: -1.5px 0 0 -1.5px;
  border-radius: 50%; background: var(--spark-bd); opacity: 0;
  animation: etac-motion-mote var(--t) var(--d) var(--ease-enter) both;
}
@keyframes etac-motion-mote {
  0% { opacity: 0; transform: translate(0, 0) scale(.4); }
  16% { opacity: .45; }
  60% { opacity: .26; }
  100% { opacity: 0; transform: translate(var(--dx), var(--dy)) scale(1.5); }
}
/* Haze: the soft body of the cloud, and the only layer that glows. Its size
   is set per-particle by js/motion.js, so the gradient is closest-side. */
.etac-motion-haze {
  position: absolute; border-radius: 50%; opacity: 0;
  background: radial-gradient(closest-side,
    color-mix(in oklab, var(--spark-2) 80%, transparent), transparent 72%);
  animation: etac-motion-haze var(--t) var(--d) var(--ease-enter) both;
}
@keyframes etac-motion-haze {
  0% { opacity: 0; transform: translate(0, 0) scale(.3); }
  26% { opacity: .3; }
  100% { opacity: 0; transform: translate(var(--dx), var(--dy)) scale(1.55); }
}
/* Stars: thin-spiked four-point glints. Each one twinkles ON a fifth of the
   way through its own travel rather than starting lit, which is what keeps
   the cloud catching light the whole time it spreads instead of flashing
   once at the start and then merely drifting. */
.etac-motion-star {
  position: absolute; background: var(--spark-2); opacity: 0;
  clip-path: polygon(50% 0%, 55% 45%, 100% 50%, 55% 55%, 50% 100%, 45% 55%, 0% 50%, 45% 45%);
  animation: etac-motion-star var(--t) var(--d) var(--ease-enter) both;
}
@keyframes etac-motion-star {
  0%   { opacity: 0; transform: translate(0, 0) scale(0) rotate(0deg); }
  22%  { opacity: 1;
         transform: translate(calc(var(--dx) * .26), calc(var(--dy) * .26))
                    scale(1.2) rotate(calc(var(--rot) * .3)); }
  60%  { opacity: .8; }
  100% { opacity: 0; transform: translate(var(--dx), var(--dy)) scale(.12) rotate(var(--rot)); }
}
/* Shaped to the element it came out from (js/motion.js copies its width,
   height and corner radius onto this), so it reads as that button's own
   shockwave rather than a circle that happens to be near it. */
.etac-motion-ring {
  position: absolute; border: 2px solid var(--accent); opacity: 0;
  animation: etac-motion-ring var(--t) var(--d, 0ms) var(--ease-enter) both;
}
@keyframes etac-motion-ring {
  0% { opacity: .9; transform: scale(1); }
  100% { opacity: 0; transform: scale(2.6); }
}
/* confetti()'s pieces — the one moment here that isn't gold, because it isn't
   a press: it's the reward for reaching the bottom of a tablet, and it borrows
   the accent + Sparks palette the rest of the app celebrates with. */
.etac-motion-confetti {
  position: absolute; width: 7px; height: 10px; margin: -5px 0 0 -3.5px;
  opacity: 0;
  animation: etac-motion-confetti var(--t) var(--d) var(--ease-enter) both;
}
@keyframes etac-motion-confetti {
  0% { opacity: 0; transform: translate(0, 0) rotate(0deg); }
  14% { opacity: 1; }
  100% { opacity: 0; transform: translate(var(--dx), var(--dy)) rotate(var(--rot)); }
}
/* unlock()'s stone chips — confetti's flight, but heavier: they drop as they
   fly (the +40px on --dy) rather than drifting. */
.etac-motion-chip {
  position: absolute; width: 7px; height: 6px; margin: -3px 0 0 -3px; opacity: 0;
  clip-path: polygon(0 28%, 58% 0, 100% 42%, 74% 100%, 12% 84%);
  animation: etac-motion-chip var(--t) cubic-bezier(.2, .7, .4, 1) var(--d) both;
}
@keyframes etac-motion-chip {
  0% { opacity: 1; transform: translate(0, 0) rotate(0deg); }
  70% { opacity: 1; }
  100% { opacity: 0; transform: translate(var(--dx), calc(var(--dy) + 40px)) rotate(var(--rot)); }
}
/* sparksEarned()'s "+n" label — rises off the coin and fades, gold to match
   the burst underneath it. */
.etac-motion-float {
  position: absolute; transform: translate(-50%, 0);
  font: 800 13px/1 var(--fmono); white-space: nowrap; opacity: 0;
  animation: etac-motion-float 620ms var(--ease-enter) both;
}
.etac-motion-float--gold { color: var(--spark-1); text-shadow: 0 0 8px color-mix(in oklab, var(--spark-2) 60%, transparent); }
@keyframes etac-motion-float {
  0% { opacity: 0; transform: translate(-50%, 4px) scale(.85); }
  20% { opacity: 1; transform: translate(-50%, 0) scale(1); }
  75% { opacity: 1; }
  100% { opacity: 0; transform: translate(-50%, -22px) scale(1); }
}
/* xpFly()'s traveling mote — a small glow that covers fromEl → toEl on one
   curve-free straight line (--dx/--dy set by js/motion.js), then the
   is-motion-pulse class below fires on arrival. */
.etac-motion-orb {
  position: absolute; width: 10px; height: 10px; margin: -5px 0 0 -5px;
  border-radius: 50%; background: var(--grad); opacity: 0;
  box-shadow: 0 0 10px 2px color-mix(in oklab, var(--accent) 65%, transparent);
  animation: etac-motion-orb var(--t) var(--ease-enter) both;
}
@keyframes etac-motion-orb {
  0% { opacity: 0; transform: translate(0, 0) scale(.4); }
  12% { opacity: 1; }
  85% { opacity: 1; }
  100% { opacity: 0; transform: translate(var(--dx), var(--dy)) scale(.7); }
}
/* Generic one-shot reactions js/motion.js toggles on the real element rather
   than a layer behind it — a brief scale pop (sparksEarned's coin) and a
   brief bright pulse (xpFly's landing pulse on the XP track). Both self-
   remove via setTimeout in js/motion.js, so no keyframe here holds `both`. */
.is-motion-pop { animation: etac-motion-pop calc(360ms * var(--etac-pace, 1)) var(--ease-exit); }
@keyframes etac-motion-pop {
  0%, 100% { transform: scale(1); }
  40% { transform: scale(1.18); }
}
.is-motion-pulse { animation: etac-motion-pulse calc(420ms * var(--etac-pace, 1)) var(--ease-exit); }
@keyframes etac-motion-pulse {
  0% { filter: brightness(1); }
  35% { filter: brightness(1.6) saturate(1.3); }
  100% { filter: brightness(1); }
}
/* streakExtend()'s flame: anticipation (scale down) then an overshoot pop —
   the ring itself comes from ringOut(), fired alongside is-motion-flame-pop. */
.is-motion-flame-anticipate { animation: etac-motion-flame-anticipate calc(100ms * var(--etac-pace, 1)) var(--ease-enter) both; }
@keyframes etac-motion-flame-anticipate { 100% { transform: scale(.9); } }
.is-motion-flame-pop { animation: etac-motion-flame-pop calc(560ms * var(--etac-pace, 1)) cubic-bezier(.34, 1.56, .64, 1); }
@keyframes etac-motion-flame-pop {
  0% { transform: scale(.9); }
  45% { transform: scale(1.35); }
  100% { transform: scale(1); }
}
/* launch()'s screen cover — fades to the same near-black rankUp uses (light
   only reads against dark), then the page underneath swaps or redirects.
   The transition is set inline by js/motion.js, which owns the timing. */
.etac-motion-fade {
  position: fixed; inset: 0; z-index: 251; pointer-events: none;
  background: #06070b; opacity: 0;
}
.etac-motion-fade.is-in { opacity: 1; }
/* The far side of that cover, on the page launch() navigated TO. index.html's
   <head> sets data-launching before anything paints and flips it to "lift"
   once the page has loaded, so the hub fades up out of the same black rather
   than appearing under it. Nothing but that one page ever sets this. */
html[data-launching]::after {
  content: ""; position: fixed; inset: 0; z-index: 9999; pointer-events: none;
  background: #06070b; opacity: 1;
  /* A hop into a new tour chapter (e.g. the explainer) is the slowest,
     most-seen path through this cover — longer than the sign-up LETS GO
     fade it also serves, so this reads as a real fade rather than a cut.
     The matching setTimeout that removes data-launching in each hop
     page's <head> script tracks this number — keep them in step. */
  transition: opacity 1.15s var(--ease-enter);
}
html[data-launching="lift"]::after { opacity: 0; }

/* ---------- Unlock reveal (js/motion.js's unlock()) ----------
   Full-screen present-opening moment for a single cosmetic grant — see the
   "Unlock reveal" beat sheet in CLAUDE.md's Motion section, and unlock()'s
   own header comment for the sequence. Same "near-black in both themes"
   rule as .etac-rankup above (light only reads against dark), and it sits
   ABOVE .etac-tour (z-index 10000) — the tour is very likely still open
   underneath when this fires, and this has to win that stacking order
   outright rather than bet on it the way the tour's own target-raise once
   did (see .etac-tour's own comment for that history). */
.etac-unlock {
  position: fixed; inset: 0; z-index: 10500; overflow: hidden;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: var(--space-16); padding: var(--space-20); cursor: pointer;
  /* The rock's size — everything in the geode block scales off it. */
  --rock: min(320px, 64vw, 46vh);
  /* The light inside the cracks — js steps it up per tap (CRACK_RAMP). */
  --crack: #f4b63a;
  touch-action: manipulation;
  /* Explicit, not inherited: the tour's payout stop fires this while
     body.etac-tour-lock (pointer-events: none) is still on, and the rock has
     to take taps before Collect ever appears. */
  pointer-events: auto;
  /* A warm near-black — a cave, not a void. */
  background: #0b0906; opacity: 0;
  transition: opacity .3s var(--ease-enter);
}
.etac-unlock.is-in { opacity: 1; }
.etac-unlock.is-leaving { opacity: 0; transition: opacity .2s var(--ease-exit); }

/* The geode (js/motion.js's unlock()). Every tap sets --heat (0→1), the
   strike vector (--kx/--ky/--kr) and --crack on the overlay; the rules below
   read those rather than stepping through a class per tap.

   Punchy, and escalating: every tap is a springy hop with an overshoot
   landing, and every tap visibly UPGRADES the rock — brighter crack light,
   a bigger glow, a gold ripple — the Duolingo-chest / Brawl-Stars-drop
   pattern. Flashes stay on the rock; nothing strobes the whole screen.

   All the light is Sparks gold (--spark-*), never --accent: it's fixed
   across themes like the currency, and an accent glow (pink on air, dark
   brown on light) round a gold-lit rock is what read as off-brand.

   The rock is stacked as nested wrappers so their transforms compose:
     zoom (stage grows per tap) > rock <button>
       > strain (the idle wiggle) > press (squash while held) > hit (the hop)
   The chip/star layer (behindHost, z-index 0) passes behind the box; the
   prize, label and pips sit at z-index 1; the flash on top. */
.etac-unlock__aura {
  position: absolute; inset: 0; pointer-events: none;
  background: radial-gradient(ellipse at 50% 50%, rgba(244, 182, 58, .34), rgba(244, 182, 58, .08) 34%, transparent 62%);
  opacity: calc(.35 + var(--heat, 0) * .45);
  transition: opacity .25s ease-out;
}
.etac-unlock.is-hot .etac-unlock__aura { opacity: 1; transition: opacity .7s ease-in; }
.etac-unlock.is-open .etac-unlock__aura { opacity: .55; transition: opacity 1.2s var(--ease-exit); }

.etac-unlock__box {
  /* Out of flow and pinned to the dead centre of the screen — the same
     point the orb sits on and every reward jumps out from. */
  position: absolute; left: 50%; top: 50%; z-index: 1;
  width: var(--rock); height: calc(var(--rock) * .9);
  margin: calc(var(--rock) * -.45) 0 0 calc(var(--rock) / -2);
  display: grid; place-items: center;
}
/* Gone for good once the chunks have fallen — an invisible, still-hoverable
   button parked mid-screen is what drew the stray circle under the cursor. */
.etac-unlock.is-open .etac-unlock__box { pointer-events: none; }
.etac-unlock.is-bursting .etac-unlock__box { visibility: hidden; transition: visibility 0s linear .7s; }

/* Light leaking out of the cracks: a beam fan behind the rock, stepping up
   with every tap. Outer = size/brightness, inner = spin. */
.etac-unlock__rays {
  position: absolute; left: 50%; top: 50%; width: calc(var(--rock) * 2.8); height: calc(var(--rock) * 2.8);
  margin: calc(var(--rock) * -1.4) 0 0 calc(var(--rock) * -1.4); pointer-events: none;
  /* Kept faint on purpose — they're atmosphere; the light that matters is
     the light coming out of the cracks. */
  opacity: calc(var(--heat, 0) * .16); transform: scale(calc(.6 + var(--heat, 0) * .45));
  transition: opacity .25s ease-out, transform .45s cubic-bezier(.34, 1.56, .64, 1);
}
.etac-unlock.is-hot .etac-unlock__rays { opacity: .26; transform: scale(1.3); transition: opacity .3s ease-in, transform .7s ease-in; }
/* Through the lone-core beat the rays ease back, then go as the prize rises. */
.etac-unlock.is-open .etac-unlock__rays { opacity: .2; transform: scale(1.15); transition: opacity .5s ease-out, transform .8s var(--ease-enter); }
.etac-unlock.is-rising .etac-unlock__rays { opacity: 0; transform: scale(1.6); transition: opacity .5s var(--ease-exit), transform .6s var(--ease-exit); }
.etac-unlock__rays-spin,
.etac-unlock__items::before {
  background: repeating-conic-gradient(from 0deg,
    transparent 0deg, rgba(255, 223, 140, .5) 5deg, transparent 10deg, transparent 27deg);
  -webkit-mask-image: radial-gradient(closest-side, #000 8%, rgba(0, 0, 0, .7) 35%, transparent 92%);
          mask-image: radial-gradient(closest-side, #000 8%, rgba(0, 0, 0, .7) 35%, transparent 92%);
}
.etac-unlock__rays-spin {
  position: absolute; inset: 0; border-radius: 50%;
  animation: etac-unlock-spin 26s linear infinite;
}
@keyframes etac-unlock-spin { to { transform: rotate(360deg); } }

.etac-unlock__heat {
  position: absolute; inset: -18%; pointer-events: none;
  opacity: calc(.25 + var(--heat, 0) * .75); transform: scale(calc(.8 + var(--heat, 0) * .4));
  transition: opacity .25s ease-out, transform .45s cubic-bezier(.34, 1.56, .64, 1);
}
.etac-unlock.is-hot .etac-unlock__heat { opacity: 1; transform: scale(1.5); transition: opacity .3s ease-in, transform .7s ease-in; }
.etac-unlock.is-open .etac-unlock__heat { opacity: .8; transform: scale(1.3); transition: opacity .5s ease-out, transform .8s var(--ease-enter); }
.etac-unlock.is-rising .etac-unlock__heat { opacity: 0; transform: scale(2); transition: opacity .5s var(--ease-exit), transform .6s var(--ease-exit); }
.etac-unlock__glow {
  position: absolute; inset: 0; border-radius: 50%;
  background: radial-gradient(circle, rgba(255, 214, 120, .55), rgba(244, 182, 58, .18) 42%, transparent 66%);
}

/* The stage grows a notch per tap — with a little overshoot, so the rock
   feels like it's getting bigger and more charged, not just zooming. */
.etac-unlock__zoom {
  position: relative; display: block; width: 100%; height: 100%;
  transform: scale(calc(1 + var(--heat, 0) * .08));
  transition: transform .4s cubic-bezier(.34, 1.56, .64, 1);
}
.etac-unlock.is-hot .etac-unlock__zoom { transform: scale(1.1); }

.etac-unlock__rock,
.etac-unlock__rock:hover, .etac-unlock__rock:active, .etac-unlock__rock:focus, .etac-unlock__rock:focus-visible {
  /* Every one of these beats a site-wide style.css button rule — :hover's
     background is the one that showed through as a blob. No focus ring: it
     takes focus programmatically so a screen reader lands on it, and
     Enter/Space count as taps from anywhere. */
  display: block; width: 100%; height: 100%; margin: 0; padding: 0;
  border: 0; border-radius: 0; background: none; box-shadow: none; outline: none;
  transform: none; cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.etac-unlock__strain, .etac-unlock__press, .etac-unlock__hit { display: block; width: 100%; height: 100%; }
.etac-unlock__hit { position: relative; }
/* The idle wiggle: still, then a quick side-to-side rock on its base —
   "tap me". Faster once it's one tap from breaking. */
.etac-unlock__strain { transform-origin: 50% 92%; }
.etac-unlock.is-in:not(.is-hot) .etac-unlock__strain { animation: etac-unlock-wiggle 2.4s ease-in-out 1s infinite; }
.etac-unlock.is-critical .etac-unlock__strain { animation-duration: 1.3s; animation-delay: .5s; }
@keyframes etac-unlock-wiggle {
  0%, 76%, 100% { transform: none; }
  80% { transform: rotate(-4deg); }
  85% { transform: rotate(3.5deg); }
  90% { transform: rotate(-2deg); }
  95% { transform: rotate(.8deg); }
}
.etac-unlock__press {
  transform-origin: 50% 92%;
  transition: transform .3s cubic-bezier(.34, 1.56, .64, 1);
}
.etac-unlock__press.is-pressed { transform: scale(1.06, .9); transition: transform .08s ease-out; }
/* The hop: squashed flat on impact, springs up (stretched, tipped away from
   the strike), lands with a squash and settles with an overshoot. */
.etac-unlock__hit { transform-origin: 50% 96%; }
.etac-unlock__hit.is-hit { animation: etac-unlock-hop .5s linear; }
@keyframes etac-unlock-hop {
  0% { transform: translate(calc(var(--kx, 0px) * .4), 0) scale(1.12, .84); animation-timing-function: cubic-bezier(.2, .8, .4, 1); }
  32% { transform: translate(calc(var(--kx, 0px) * .6), calc(-14px - var(--heat, 0) * 10px)) scale(.94, 1.08) rotate(var(--kr, 4deg)); animation-timing-function: cubic-bezier(.6, 0, .9, .6); }
  56% { transform: translate(0, 0) scale(1.08, .9) rotate(calc(var(--kr, 4deg) * -.3)); animation-timing-function: cubic-bezier(.2, .8, .4, 1); }
  76% { transform: scale(.98, 1.03); }
  100% { transform: none; }
}
/* The charge: a rumble that only gets worse, then the anticipation squeeze
   right before the burst. */
.etac-unlock.is-hot .etac-unlock__hit { animation: etac-unlock-rumble .7s linear both; }
@keyframes etac-unlock-rumble {
  0% { transform: none; }
  8% { transform: translate(-2px, 0) rotate(-1deg); }
  16% { transform: translate(2px, 0) rotate(1deg) scale(1.01); }
  24% { transform: translate(-3px, 0) rotate(-1.5deg) scale(1.02); }
  32% { transform: translate(3px, 0) rotate(1.5deg) scale(1.03); }
  40% { transform: translate(-4px, 0) rotate(-2deg) scale(1.04); }
  48% { transform: translate(4px, 0) rotate(2deg) scale(1.05); }
  56% { transform: translate(-5px, 0) rotate(-2.5deg) scale(1.06); }
  64% { transform: translate(5px, 0) rotate(2.5deg) scale(1.07); }
  72% { transform: translate(-5px, 0) rotate(-2.5deg) scale(1.08); }
  80% { transform: translate(0, 0) scale(1.08); }
  100% { transform: scale(.9, .86); }
}

/* The rock art — flat shapes, like the icon set. */
.etac-unlock__geode { display: block; width: 100%; height: 100%; overflow: visible; }
.etac-unlock__shadow { fill: rgba(0, 0, 0, .5); transition: opacity .2s var(--ease-exit); }
.etac-unlock.is-cracked .etac-unlock__shadow { opacity: 0; }
.etac-unlock__glint { fill: none; stroke: rgba(255, 255, 255, .5); stroke-width: 7; stroke-linecap: round; }
.etac-unlock__glint-dot { fill: rgba(255, 255, 255, .5); }
.etac-unlock__art.is-gone { display: none; }
/* Each strike warms the whole face for an instant. */
.etac-unlock__flashface { fill: #ffe7a8; opacity: 0; }
.etac-unlock__art.is-struck .etac-unlock__flashface { animation: etac-unlock-faceflash .22s ease-out; }
@keyframes etac-unlock-faceflash { 0% { opacity: .35; } 100% { opacity: 0; } }
/* Lit gold from the inside through the charge. */
.etac-unlock__whitehot { fill: #ffcf5a; opacity: 0; }
.etac-unlock.is-hot .etac-unlock__whitehot { opacity: .3; transition: opacity .7s ease-in; }

/* Cracks are light coming THROUGH the stone, not lines drawn on it: no
   outline, a band of light (--crack, stepped up per tap) with a white-hot
   core, a flare as each one opens — and each one runs open along its own
   path (the mask's stroke undashes, .etac-unlock__run), from where it
   started toward where it ends. */
.etac-unlock__cracks .is-light { fill: var(--crack); transition: fill .25s ease-out; }
.etac-unlock__cracks .is-core { fill: #fffaf0; opacity: .9; }
.etac-unlock__cracks .is-flare { fill: #fff; opacity: 0; animation: etac-unlock-flare .45s ease-out; }
@keyframes etac-unlock-flare { 0% { opacity: .5; } 100% { opacity: 0; } }
.etac-unlock__run {
  fill: none; stroke: #fff; stroke-width: 44; stroke-linejoin: round;
  stroke-dasharray: 1 1; stroke-dashoffset: 1;
  animation: etac-unlock-run 240ms cubic-bezier(.3, .6, .4, 1) both;
}
@keyframes etac-unlock-run { to { stroke-dashoffset: 0; } }
/* The stretch of a fault line that was already open before this redraw. */
.etac-unlock__run.is-open { stroke-dasharray: none; stroke-dashoffset: 0; animation: none; }

/* The light the cracks throw onto the stone round them: a blurred gold copy
   of every crack on its own layer over the rock (js explains why it's a
   separate <svg>), breathing slowly — faster once it's about to go. It's
   what makes the stone look LIT by the cracks rather than drawn on. */
.etac-unlock__halo {
  position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none;
  mix-blend-mode: screen; will-change: opacity;
  animation: etac-unlock-halo 2.2s ease-in-out infinite;
}
.etac-unlock__halos { fill: rgba(255, 190, 80, .5); }
.etac-unlock.is-critical .etac-unlock__halo { animation-duration: 1.1s; }
.etac-unlock.is-hot .etac-unlock__halo { animation: none; opacity: 1; }
.etac-unlock.is-cracked .etac-unlock__halo { opacity: 0; transition: opacity .25s ease-out; }
@keyframes etac-unlock-halo { 0%, 100% { opacity: .6; } 50% { opacity: 1; } }

/* Light streaming out of the cracks (js shaft()): each beam is anchored on
   a crack, aimed out from the rock, and grows in once the crack has run past
   it (--sd). Outer span = aim + length (--grow, which the charge and the
   cracked-open beat push out further); <b> = the beam, gently pulsing
   (--sp); <i> = a mote of light drifting out along it on a loop (--sm
   offsets each one). Transform/opacity only. */
.etac-unlock__shafts {
  position: absolute; inset: 0; pointer-events: none; mix-blend-mode: screen;
  opacity: calc(.55 + var(--heat, 0) * .45); transition: opacity .3s ease-out;
}
.etac-unlock.is-open .etac-unlock__shafts { opacity: 0; transition: opacity .3s var(--ease-exit); }
.etac-unlock__shaft {
  position: absolute; width: var(--len); height: calc(var(--len) * .36); margin-top: calc(var(--len) * -.18);
  transform-origin: 0 50%; transform: rotate(var(--a)) scaleX(0); opacity: 0;
  transition: transform .7s cubic-bezier(.22, 1, .36, 1) var(--sd, 0ms), opacity .4s ease-out var(--sd, 0ms);
}
.etac-unlock__shaft.is-lit { transform: rotate(var(--a)) scaleX(var(--grow, 1)); opacity: 1; }
.etac-unlock.is-hot .etac-unlock__shaft { --grow: 1.3; --sd: 0ms; }
.etac-unlock.is-cracked .etac-unlock__shaft { --grow: 1.7; }
.etac-unlock__shaft b {
  position: absolute; inset: 0; transform-origin: 0 50%;
  background: radial-gradient(ellipse 100% 44% at 0 50%, rgba(255, 244, 210, .95), rgba(255, 204, 100, .45) 38%, transparent 74%);
  /* Fades in from its anchor, so the beam's box never shows a hard end. */
  -webkit-mask-image: linear-gradient(to right, transparent, #000 22%);
          mask-image: linear-gradient(to right, transparent, #000 22%);
  animation: etac-unlock-stream var(--sp, 1.7s) ease-in-out infinite alternate;
}
@keyframes etac-unlock-stream {
  0% { opacity: .45; transform: scaleX(.85) scaleY(.8); }
  100% { opacity: 1; transform: scaleX(1.08) scaleY(1.05); }
}
.etac-unlock__shaft i {
  position: absolute; left: 0; top: 50%; width: 6px; height: 6px; margin: -3px 0 0 -3px; border-radius: 50%;
  background: #fff4d0; box-shadow: 0 0 6px 2px rgba(255, 210, 110, .7); opacity: 0;
  animation: etac-unlock-mote 1.5s cubic-bezier(.3, .5, .5, 1) var(--sm, 0ms) infinite;
}
@keyframes etac-unlock-mote {
  0% { opacity: 0; transform: translateX(0) scale(.6); }
  20% { opacity: 1; }
  100% { opacity: 0; transform: translateX(var(--len)) scale(1.2); }
}

/* The orb: an actual ball of light, pinned to the dead centre of the
   screen under the rock — drawn like the rock, not as a lens flare: a
   cel-shaded sphere in three flat golds (hard-stopped rings offset toward
   the top-left light) with one rounded highlight, glowing. Nested so each
   job owns its own transform:
     orb    the beats — glimpsed as the rock cracks, springs in after the
            blast, squeezes (.is-rising), then collapses into the rewards
     float  a slow bob while it waits
     ball   the sphere itself
   and the bloom behind it only ever animates opacity. */
.etac-unlock__orb {
  position: absolute; left: 50%; top: 50%; z-index: 0; pointer-events: none;
  width: calc(var(--rock) * .36); aspect-ratio: 1; margin: calc(var(--rock) * -.18) 0 0 calc(var(--rock) * -.18);
  opacity: 0; transform: scale(.4);
}
.etac-unlock__orb-bloom {
  position: absolute; inset: -85%; border-radius: 50%;
  background: radial-gradient(circle, rgba(255, 214, 120, .5), rgba(244, 182, 58, .16) 38%, transparent 66%);
  animation: etac-unlock-orb-glow 1.4s ease-in-out infinite alternate;
}
@keyframes etac-unlock-orb-glow { 0% { opacity: .55; } 100% { opacity: 1; } }
.etac-unlock__orb-float { position: absolute; inset: 0; animation: etac-unlock-orb-float 2.2s ease-in-out infinite alternate; }
@keyframes etac-unlock-orb-float { 0% { transform: translateY(3px); } 100% { transform: translateY(-5px); } }
.etac-unlock__orb-ball {
  position: absolute; inset: 0; border-radius: 50%;
  background: radial-gradient(circle at 40% 36%, #fff3cc 0 30%, #ffd66e 31% 60%, #f3ac3a 61%);
  box-shadow: 0 0 18px 4px rgba(255, 210, 110, .7), 0 0 50px 14px rgba(244, 182, 58, .3);
}
/* The highlight — the same rounded glint the rock wears. */
.etac-unlock__orb-ball i {
  position: absolute; left: 20%; top: 15%; width: 34%; height: 15%; border-radius: 999px;
  background: rgba(255, 255, 255, .85); transform: rotate(-32deg);
}
.etac-unlock.is-cracked .etac-unlock__orb { opacity: 1; transform: scale(.75); transition: opacity .2s ease-out, transform .3s var(--ease-enter); }
.etac-unlock.is-open .etac-unlock__orb { animation: etac-unlock-orb-in .6s linear both; }
.etac-unlock.is-rising .etac-unlock__orb { animation: etac-unlock-orb-squeeze .13s cubic-bezier(.4, 0, 1, 1) both; }
.etac-unlock.is-bursting .etac-unlock__orb { animation: etac-unlock-orb-give .42s cubic-bezier(.4, 0, .6, 1) both; }
@keyframes etac-unlock-orb-in {
  0% { opacity: 1; transform: scale(.75); animation-timing-function: cubic-bezier(.2, .9, .3, 1); }
  30% { transform: scale(1.18); animation-timing-function: cubic-bezier(.4, 0, .6, 1); }
  60% { transform: scale(.95); animation-timing-function: cubic-bezier(.4, 0, .6, 1); }
  100% { opacity: 1; transform: none; }
}
@keyframes etac-unlock-orb-squeeze {
  0% { opacity: 1; transform: none; }
  100% { opacity: 1; transform: scale(.78, .74); }
}
@keyframes etac-unlock-orb-give {
  0% { opacity: 1; transform: scale(.78, .74); }
  20% { opacity: 1; transform: scale(1.14); }
  100% { opacity: 0; transform: scale(0); }
}

/* The burst (js shatter()): the outer <g> pops a few units apart on
   .is-cracked and holds there; the inner <g> is the BLAST on .is-open —
   thrown far out from the centre along its own angle, spinning and growing
   a touch as if coming at you, then gravity takes it off the bottom. */
.etac-unlock__frag, .etac-unlock__chunk { transform-box: view-box; }
.etac-unlock__frag { transition: transform .3s cubic-bezier(.34, 1.56, .64, 1); }
.etac-unlock__frag.is-popped { transform: translate(var(--px), var(--py)); }
.etac-unlock.is-open .etac-unlock__chunk { animation: etac-unlock-fall 1.2s var(--fd, 0ms) both; }
@keyframes etac-unlock-fall {
  0% { transform: none; opacity: 1; animation-timing-function: cubic-bezier(.05, .75, .25, 1); }
  30% { transform: translate(var(--fx), var(--fy)) rotate(calc(var(--fr) * .4)) scale(1.12); animation-timing-function: cubic-bezier(.5, 0, 1, .6); }
  86% { opacity: 1; }
  100% { transform: translate(calc(var(--fx) * 1.2), calc(var(--fy) + 480px)) rotate(var(--fr)) scale(1.05); opacity: 0; }
}
/* The chunks leave white-hot and cool to stone as they fall. */
.etac-unlock__frag .etac-unlock__whitehot { animation: etac-unlock-cool .9s ease-out both; }
@keyframes etac-unlock-cool { 0% { opacity: .5; } 100% { opacity: 0; } }
/* The clones are copies of a finished rock — nothing in them replays. */
.etac-unlock__frag .etac-unlock__cracks .is-flare { animation: none; }
.etac-unlock__break { fill: none; stroke: rgba(255, 216, 138, .7); stroke-width: 2.5; stroke-linejoin: round; }

/* The strike point: a Sparks star that pops and spins out over the rock. */
.etac-unlock__impact {
  position: absolute; z-index: 2; width: 96px; height: 96px; margin: -48px 0 0 -48px;
  pointer-events: none;
  background: radial-gradient(circle, #fff 22%, var(--spark-1) 48%, var(--spark-2) 70%);
  -webkit-mask: url('assets/icons/sparks.png') center / contain no-repeat;
          mask: url('assets/icons/sparks.png') center / contain no-repeat;
  animation: etac-unlock-impact .36s cubic-bezier(.22, 1, .36, 1) both;
}
.etac-unlock__impact.is-big { width: 240px; height: 240px; margin: -120px 0 0 -120px; animation-duration: .42s; }
@keyframes etac-unlock-impact {
  0% { opacity: 1; transform: scale(.2) rotate(-30deg); }
  45% { opacity: 1; transform: scale(1.1) rotate(8deg); }
  100% { opacity: 0; transform: scale(1.25) rotate(20deg); }
}
/* Sparkles off the cracks and the burst are the Sparks icon too — fatter
   than the thin shared .etac-motion-star glint, and a lot more on-brand. */
.etac-unlock-spark {
  clip-path: none;
  -webkit-mask: url('assets/icons/sparks.png') center / contain no-repeat;
          mask: url('assets/icons/sparks.png') center / contain no-repeat;
}
/* Every tap sends a gold ripple out from the rock — the "it just levelled
   up" wave — stronger as it heats up. */
.etac-unlock__ripple {
  position: absolute; left: 50%; top: 50%; z-index: 0; width: var(--rock); height: var(--rock);
  margin: calc(var(--rock) / -2) 0 0 calc(var(--rock) / -2); border-radius: 50%; pointer-events: none;
  border: 3px solid rgba(255, 214, 120, .9); opacity: 0;
}
.etac-unlock__ripple.is-ripple { animation: etac-unlock-ripple .6s cubic-bezier(.22, 1, .36, 1); }
@keyframes etac-unlock-ripple {
  0% { opacity: calc(.35 + var(--heat, 0) * .5); transform: scale(.7); }
  100% { opacity: 0; transform: scale(calc(1.5 + var(--heat, 0) * .6)); }
}

/* Tap-progress UI: a fixed hint (it never changes) and one Sparks-star pip
   per tap. */
.etac-unlock__crackui {
  position: absolute; left: 0; right: 0; top: calc(50% + var(--rock) * .45 + 26px); z-index: 1;
  display: flex; flex-direction: column; align-items: center; gap: var(--space-12);
  transition: opacity .2s var(--ease-exit), transform .2s var(--ease-exit);
}
.etac-unlock.is-hot .etac-unlock__crackui { opacity: 0; transform: translateY(8px); pointer-events: none; }
.etac-unlock__hint {
  margin: 0; font: 800 clamp(18px, 2.4vw, 22px)/1 var(--fdisplay); letter-spacing: .01em; color: rgba(255, 244, 222, .92);
}
.etac-unlock__pips { display: flex; gap: var(--space-8); }
.etac-unlock__pips i {
  width: 18px; height: 18px; background: rgba(255, 244, 222, .22);
  -webkit-mask: url('assets/icons/sparks.png') center / contain no-repeat;
          mask: url('assets/icons/sparks.png') center / contain no-repeat;
}
.etac-unlock__pips i.is-on {
  background: linear-gradient(160deg, var(--spark-1), var(--spark-2));
  animation: etac-unlock-pip .45s cubic-bezier(.34, 1.56, .64, 1);
}
@keyframes etac-unlock-pip { 0% { transform: scale(.4) rotate(-45deg); } 100% { transform: none; } }

/* The burst itself: a gold flash off the core (big, but not a white-out),
   a small camera quake, two gold rings, and a light column the prize rides
   up. */
.etac-unlock__flash {
  position: absolute; left: 50%; top: 50%; z-index: 4; width: 160vmax; height: 160vmax; margin: -80vmax 0 0 -80vmax;
  border-radius: 50%; pointer-events: none; opacity: 0;
  background: radial-gradient(circle, #fff 0%, #fff 8%, #fff3cf 16%, rgba(255, 214, 120, .5) 28%, rgba(244, 182, 58, .14) 42%, transparent 56%);
}
/* The release: it slams to near-white in a couple of frames, then clears. */
.etac-unlock.is-open .etac-unlock__flash { animation: etac-unlock-flash .75s cubic-bezier(.22, 1, .36, 1) both; }
@keyframes etac-unlock-flash {
  0% { opacity: 0; transform: scale(.1); }
  7% { opacity: .9; transform: scale(.6); }
  100% { opacity: 0; transform: scale(.9); }
}
.etac-unlock.is-open:not(.is-calm) { animation: etac-unlock-quake .5s linear; }
@keyframes etac-unlock-quake {
  0% { transform: translate(0, 0); }
  6% { transform: translate(-13px, 9px); }
  14% { transform: translate(12px, -10px); }
  24% { transform: translate(-9px, 7px); }
  36% { transform: translate(7px, -5px); }
  50% { transform: translate(-4px, 3px); }
  66% { transform: translate(2px, -1px); }
  82%, 100% { transform: none; }
}
/* A smaller kick when the orb pops. */
.etac-unlock.is-bursting:not(.is-calm) { animation: etac-unlock-kick .3s linear; }
@keyframes etac-unlock-kick {
  0% { transform: none; }
  15% { transform: translate(0, 6px) scale(1.012); }
  35% { transform: translate(0, -4px); }
  60% { transform: translate(0, 2px); }
  100% { transform: none; }
}
/* The quake shoves the whole overlay — this keeps the page underneath from
   peeking in at the edge it moved away from. Dropped for Collect, where the
   backdrop fades out. */
.etac-unlock.is-open:not(.is-collecting) { box-shadow: 0 0 0 24px #0b0906; }
.etac-unlock__shock {
  position: absolute; left: 50%; top: 50%; z-index: 0; width: var(--rock); height: var(--rock);
  margin: calc(var(--rock) / -2) 0 0 calc(var(--rock) / -2); border-radius: 50%; pointer-events: none;
  border: 4px solid rgba(255, 214, 120, .85); opacity: 0;
}
.etac-unlock.is-open .etac-unlock__shock { animation: etac-unlock-shock .7s cubic-bezier(.1, .8, .3, 1) both; }
.etac-unlock__shock--late { border-width: 3px; }
.etac-unlock.is-open .etac-unlock__shock--late { animation: none; }
.etac-unlock.is-bursting .etac-unlock__shock--late { animation: etac-unlock-shock .8s cubic-bezier(.1, .8, .3, 1) both; }
@keyframes etac-unlock-shock {
  0% { opacity: 1; transform: scale(.3); }
  100% { opacity: 0; transform: scale(3.8); }
}
.etac-unlock:not(.is-open) .etac-unlock__prize,
.etac-unlock:not(.is-open) .etac-unlock__label { visibility: hidden; }
.etac-unlock.is-calm .etac-unlock__box, .etac-unlock.is-calm .etac-unlock__crackui,
.etac-unlock.is-calm .etac-unlock__flash, .etac-unlock.is-calm .etac-unlock__shock,
.etac-unlock.is-calm .etac-unlock__orb { display: none; }

/* The prize — every reward is a list of items (a lone title is a list of
   one), and the items carry all the motion, so the container only fades. */
.etac-unlock__prize {
  position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: var(--space-16);
  opacity: 0; transition: opacity .2s ease-out;
}
.etac-unlock.is-open .etac-unlock__prize { opacity: 1; }
.etac-unlock__sparks {
  margin: 0; display: flex; align-items: center; gap: var(--space-6);
  font: 800 clamp(24px, 3.6vw, 32px)/1 var(--fdisplay); color: var(--spark-1);
  text-shadow: 0 0 16px rgba(244, 182, 58, .6);
  opacity: 0; transform: translateY(10px) scale(.9);
  transition: opacity .4s var(--ease-enter), transform .5s cubic-bezier(.34, 1.56, .64, 1);
}
.etac-unlock.is-named .etac-unlock__sparks { opacity: 1; transform: none; }
.etac-unlock__spark-icon {
  display: inline-block; width: .8em; height: .8em;
  background: linear-gradient(160deg, var(--spark-1), var(--spark-2));
  -webkit-mask: url('assets/icons/sparks.png') center / contain no-repeat;
          mask: url('assets/icons/sparks.png') center / contain no-repeat;
}

.etac-unlock__label {
  position: relative; z-index: 1; margin: 0; font: 800 clamp(14px, 1.7vw, 17px)/1 var(--fdisplay); text-transform: uppercase; letter-spacing: 0.08em;
  color: rgba(255, 244, 222, .72);
  opacity: 0; transform: translateY(10px);
  transition: opacity .45s var(--ease-enter), transform .45s var(--ease-enter);
}
.etac-unlock.is-named .etac-unlock__label { opacity: 1; transform: translateY(0); }

/* Continue's own sibling — same ghost-pill look as .etac-rankup__go, load-
   bearing (not decoration) for the same reason: nothing here auto-dismisses. */
.etac-unlock__go {
  position: relative; z-index: 1;
  margin-top: var(--space-6); padding: var(--space-16) 46px; border-radius: 999px; cursor: pointer;
  font: 800 16px var(--fsans); letter-spacing: .03em; color: rgba(255, 255, 255, .95);
  background: rgba(255, 255, 255, .1); border: 1px solid rgba(255, 255, 255, .28);
  opacity: 0; transform: translateY(12px); pointer-events: none;
  transition: opacity .5s ease, transform .5s var(--ease-enter),
              background .18s ease, border-color .18s ease;
}
.etac-unlock.is-settled .etac-unlock__go { opacity: 1; transform: translateY(0); pointer-events: auto; }
.etac-unlock__go:hover { background: rgba(255, 255, 255, .18); border-color: rgba(255, 255, 255, .45); }
.etac-unlock__go:focus-visible { outline: 2px solid color-mix(in oklab, var(--spark-2) 70%, white); outline-offset: 3px; }

@media (prefers-reduced-motion: reduce) {
  .etac-motion-mote, .etac-motion-haze, .etac-motion-star,
  .etac-motion-ring, .etac-motion-confetti, .etac-motion-chip,
  .etac-motion-float, .etac-motion-orb { animation: none; display: none; }
  .etac-motion-fade { transition: none; }
  html[data-launching]::after { transition: none; }
  .is-motion-pop, .is-motion-pulse,
  .is-motion-flame-anticipate, .is-motion-flame-pop { animation: none; }
  .etac-unlock, .etac-unlock.is-leaving, .etac-unlock__box, .etac-unlock__frag,
  .etac-unlock__prize, .etac-unlock__label, .etac-unlock__go { transition: none !important; }
  .etac-unlock__glow { animation: none !important; }
  .is-motion-anticipate, .is-motion-choice-correct, .is-motion-shake,
  .is-motion-pop-in, .is-motion-rise-in, .is-motion-flame-milestone,
  .is-motion-freeze-pulse { animation: none; }
  .etac-unlock__item, .etac-unlock__hop { animation: none !important; opacity: 1; transform: none; }
}

/* ---------- Daily/practice answer reactions (js/motion.js's correct(),
   incorrect(), goalComplete(), practiceResults(), streakMilestone()) ----------
   Every class here is only ever ADDED by js/motion.js after its own
   reduceMotion() check, so — unlike the primitives above — nothing here
   needs a reduced-motion override of its own: the static reveal daily.js/
   practice.js already rendered stays the whole story for that viewer. The
   media-query entries just above are belt-and-suspenders. */
.is-motion-anticipate { animation: etac-motion-anticipate calc(100ms * var(--etac-pace, 1)) var(--ease-enter) both; }
@keyframes etac-motion-anticipate { 100% { transform: scale(.95); } }

/* correct()'s picked (= right) choice: an overshoot pop with a green glow
   ring — the ring itself is ringOut(), fired alongside this class. */
.is-motion-choice-correct { animation: etac-motion-choice-correct calc(480ms * var(--etac-pace, 1)) cubic-bezier(.34, 1.56, .64, 1); }
@keyframes etac-motion-choice-correct {
  0% { transform: scale(.95); box-shadow: 0 0 0 0 transparent; }
  55% { transform: scale(1.035); box-shadow: 0 0 0 3px color-mix(in oklab, var(--success-fg) 55%, transparent); }
  100% { transform: scale(1); box-shadow: 0 0 0 0 transparent; }
}
/* incorrect()'s picked (= wrong) choice: firm, not scary — a small shake,
   no red flash, no particles. */
.is-motion-shake { animation: etac-motion-shake calc(300ms * var(--etac-pace, 1)) var(--ease-exit); }
@keyframes etac-motion-shake {
  0%, 100% { transform: translateX(0); }
  20% { transform: translateX(-6px); }
  40% { transform: translateX(5px); }
  60% { transform: translateX(-3px); }
  80% { transform: translateX(2px); }
}
/* Generic pop-in — a verdict/goal-complete icon scaling up with a slight
   overshoot rotation, opacity riding along (never alone, per CLAUDE.md). */
.is-motion-pop-in { animation: etac-motion-pop-in calc(420ms * var(--etac-pace, 1)) cubic-bezier(.34, 1.56, .64, 1) both; }
@keyframes etac-motion-pop-in {
  0% { opacity: 0; transform: scale(.5) rotate(-8deg); }
  60% { opacity: 1; transform: scale(1.12) rotate(2deg); }
  100% { opacity: 1; transform: scale(1) rotate(0deg); }
}
/* Generic rise-in — a fade + short translateY, for a reveal one beat behind
   another (incorrect()'s correct-choice reveal, correct()'s award line). */
.is-motion-rise-in { animation: etac-motion-rise-in calc(380ms * var(--etac-pace, 1)) var(--ease-enter) both; }
@keyframes etac-motion-rise-in {
  0% { opacity: 0; transform: translateY(8px); }
  100% { opacity: 1; transform: translateY(0); }
}
/* practiceResults()'s review rows: same rise-in, staggered by nth-of-type
   rather than a per-row JS timeout — up to 5 rows, one per practice set. */
.etac-practice__review.is-motion-row-in { animation: etac-motion-rise-in 380ms var(--ease-enter) both; }
.etac-practice__review.is-motion-row-in:nth-of-type(1) { animation-delay: 0ms; }
.etac-practice__review.is-motion-row-in:nth-of-type(2) { animation-delay: 60ms; }
.etac-practice__review.is-motion-row-in:nth-of-type(3) { animation-delay: 120ms; }
.etac-practice__review.is-motion-row-in:nth-of-type(4) { animation-delay: 180ms; }
.etac-practice__review.is-motion-row-in:nth-of-type(5) { animation-delay: 240ms; }
/* streakMilestone()'s bigger flame pop — streakExtend's overshoot, taller,
   with a brightness surge riding along (the ember burst is emit(), not CSS). */
.is-motion-flame-milestone { animation: etac-motion-flame-milestone calc(900ms * var(--etac-pace, 1)) cubic-bezier(.34, 1.56, .64, 1); }
@keyframes etac-motion-flame-milestone {
  0% { transform: scale(.85); filter: brightness(1) drop-shadow(0 0 6px rgba(255, 138, 61, .5)); }
  15% { transform: scale(.82); }
  55% { transform: scale(1.55); filter: brightness(1.65) drop-shadow(0 0 18px rgba(255, 138, 61, .9)); }
  100% { transform: scale(1); filter: brightness(1) drop-shadow(0 0 6px rgba(255, 138, 61, .5)); }
}
/* purchase()'s Streak Freeze mote landing: the flame reads cold for a beat,
   same idea as is-motion-pulse's brightness surge but hue-shifted toward
   blue instead of gold. */
.is-motion-freeze-pulse { animation: etac-motion-freeze-pulse calc(520ms * var(--etac-pace, 1)) var(--ease-exit); }
@keyframes etac-motion-freeze-pulse {
  0% { filter: brightness(1) hue-rotate(0deg) saturate(1); }
  40% { filter: brightness(1.5) hue-rotate(150deg) saturate(1.4); }
  100% { filter: brightness(1) hue-rotate(0deg) saturate(1); }
}
/* unlock()'s prizes. Each item LAUNCHES out of the geode's core: --fx/--fy
   are the offset from its landing slot back to the rock's center (measured
   in JS), and the keyframe arcs it home and lands it with a squash, --i
   staggering each one 80ms behind the last. Then, 3+ items only
   (.is-many), the inner .etac-unlock__hop runs ONE stadium-wave hop — and
   once that's done (.is-landed) the same element grows under the cursor,
   with a soft light fading up behind it (::before). Nested so the launch's
   held end state (fill: both) can't block the wave or the hover, which
   both live on the inner layer. */
.etac-unlock__items { position: relative; display: flex; flex-wrap: wrap; justify-content: center; gap: clamp(12px, 3vw, 24px); }
/* Slow gold rays behind the landed prize — the same beam fan that leaked
   out of the cracks (shared background above), now standing behind what
   came out. Absolutely positioned, so it takes no part in the flex row. */
.etac-unlock__items::before {
  content: ''; position: absolute; left: 50%; top: 50%; z-index: -1;
  width: calc(var(--rock) * 1.9); aspect-ratio: 1; margin: calc(var(--rock) * -.95) 0 0 calc(var(--rock) * -.95);
  border-radius: 50%; pointer-events: none;
  /* `scale`, not transform: the spin keyframe owns transform. */
  opacity: 0; scale: .5;
  transition: opacity .6s var(--ease-enter) .25s, scale .8s cubic-bezier(.34, 1.56, .64, 1) .25s;
  animation: etac-unlock-spin 34s linear infinite;
}
.etac-unlock.is-bursting .etac-unlock__items::before { opacity: .2; scale: 1; }
.etac-unlock.is-collecting .etac-unlock__items::before { opacity: 0; transition: opacity .2s var(--ease-exit); }
.etac-unlock__item { position: relative; display: block; opacity: 0; isolation: isolate; }
/* Reduced motion (.is-calm): no launch, the prizes are simply there. The
   reduced-motion block above says opacity: 1 too, but the rule just above
   comes later at the same specificity and won — every calm reveal (store,
   tour, mission chest) showed its label and Collect with no prize. */
.etac-unlock.is-calm .etac-unlock__item { opacity: 1; }
.etac-unlock.is-bursting .etac-unlock__item {
  animation: etac-unlock-launch .76s linear calc(var(--i) * 90ms) both;
}
/* JUMPS out of the orb: shot up out of the dead centre, big and fast,
   hangs at the top of its arc, drops onto its slot with a hard squash and
   springs back. Per-segment easing, so the shot out is explosive and the
   drop is gravity. */
@keyframes etac-unlock-launch {
  0% { opacity: 0; transform: translate(var(--fx, 0), var(--fy, 0)) scale(.1); animation-timing-function: cubic-bezier(.1, .8, .3, 1); }
  6% { opacity: 1; }
  38% { opacity: 1; transform: translate(calc(var(--fx, 0) * .35), calc(var(--fy, 0) * .35 - 120px)) scale(1.4) rotate(-5deg); animation-timing-function: cubic-bezier(.5, 0, .9, .5); }
  66% { opacity: 1; transform: translate(0, 0) scale(1.22, .78); animation-timing-function: cubic-bezier(.2, .8, .4, 1.4); }
  84% { opacity: 1; transform: scale(.94, 1.07); }
  100% { opacity: 1; transform: none; }
}
.etac-unlock__hop { display: block; transform-origin: 50% 100%; }
.etac-unlock.is-many.is-waving .etac-unlock__hop {
  animation: etac-unlock-wave .9s var(--ease-enter) calc(var(--i) * 90ms);
}
@keyframes etac-unlock-wave {
  0%, 100% { transform: none; }
  22% { transform: scale(1.08, .9); }
  50% { transform: translateY(-24px) scale(.95, 1.07); }
  78% { transform: scale(1.07, .92); }
}
/* Hover: the item lifts and grows, and a faint pool of light fades up
   behind it — kept subtle, and eased rather than switched on. */
.etac-unlock__item::before {
  content: ''; position: absolute; left: 50%; top: 50%; z-index: -1;
  width: 190%; aspect-ratio: 1; border-radius: 50%; pointer-events: none;
  background: radial-gradient(circle, color-mix(in oklab, var(--accent) 45%, #fff 15%) 0%, color-mix(in oklab, var(--accent) 22%, transparent) 30%, transparent 64%);
  opacity: 0; transform: translate(-50%, -50%) scale(.55);
  transition: opacity .7s cubic-bezier(.4, 0, .2, 1), transform .8s cubic-bezier(.22, 1, .36, 1);
}
.etac-unlock.is-landed .etac-unlock__hop { transition: transform .45s cubic-bezier(.34, 1.56, .64, 1); }
.etac-unlock.is-landed:not(.is-collecting) .etac-unlock__item:hover .etac-unlock__hop { transform: translateY(-8px) scale(1.14); }
.etac-unlock.is-landed:not(.is-collecting) .etac-unlock__item:hover::before { opacity: .6; transform: translate(-50%, -50%) scale(1); }
.etac-unlock .etac-unlock__item .etac-title { font-size: clamp(20px, 4.6vw, 32px); padding: 14px 30px; }
.etac-unlock__theme-tile {
  width: clamp(68px, 17vw, 112px); aspect-ratio: 1; border-radius: 26%; display: grid; place-items: center;
  /* Fixed, not --bg-elevated: this overlay is near-black on every theme,
     and a light theme's elevated surface turned the white icon invisible. */
  background: linear-gradient(160deg, #262a33, #14161c); border: 1px solid rgba(255, 255, 255, .22);
  box-shadow: 0 10px 30px rgba(0, 0, 0, .45), inset 0 1px 0 rgba(255, 255, 255, .12);
}
/* Sized through font-size (.etac-mico is 1em square) — a % height against
   the tile's auto-sized grid row collapses to 0 and the icon vanishes. */
.etac-unlock__theme-tile { font-size: clamp(30px, 7.6vw, 52px); }
.etac-unlock__theme-tile .etac-mico { background: #fff; }
/* The mission chest's prize (questChest): a Sparks coin, in the currency's
   own fixed gold rather than the theme tiles' white. */
.etac-unlock__theme-tile.is-sparks .etac-mico { background: linear-gradient(160deg, var(--spark-1), var(--spark-2)); }

/* Collect: the backdrop falls away while each item dips (anticipation),
   then accelerates up into the HUD target js measured (--cx/--cy), shrinking
   as it goes; the Sparks line does the same into the coin. Everything else
   drops out fast underneath. */
.etac-unlock.is-collecting { background-color: transparent; pointer-events: none; transition: background-color .45s ease-in .1s; }
.etac-unlock.is-collecting .etac-unlock__aura,
.etac-unlock.is-collecting .etac-unlock__label,
.etac-unlock.is-collecting .etac-unlock__go {
  opacity: 0; transform: translateY(14px); transition: opacity .2s var(--ease-exit), transform .2s var(--ease-exit);
}
.etac-unlock.is-collecting .etac-unlock__item {
  animation: etac-unlock-collect .62s cubic-bezier(.55, 0, .8, .2) calc(var(--i) * 60ms) both;
}
.etac-unlock.is-collecting .etac-unlock__sparks {
  animation: etac-unlock-collect .62s cubic-bezier(.55, 0, .8, .2) 80ms both;
}
@keyframes etac-unlock-collect {
  0% { opacity: 1; transform: none; }
  22% { opacity: 1; transform: translateY(12px) scale(1.06); }
  88% { opacity: 1; }
  100% { opacity: 0; transform: translate(var(--cx, 0), var(--cy, -60vh)) scale(.18); }
}

@media (prefers-reduced-motion: reduce) {
  .etac-unlock__aura, .etac-unlock__sparks,
  .etac-unlock__hop, .etac-unlock__item::before { transition: none !important; animation: none !important; }
  .etac-unlock__rays-spin, .etac-unlock__strain, .etac-unlock__ripple,
  .etac-unlock__impact, .etac-unlock-spark { animation: none !important; }
  /* The rays behind the prize still show — standing still. */
  .etac-unlock__items::before { animation: none !important; transition: none !important; }
}

/* ---------- Section heading ---------- */
.etac-sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-12); margin: 34px var(--space-2) var(--space-14); }
.etac-sec-head h2 { margin: 0; font: 700 16px var(--fdisplay); color: var(--text); }
.etac-sec-head .hint { font: 500 12px var(--fsans); color: var(--text-dim); }

/* ---------- Destination tiles ---------- */
.etac-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(252px, 100%), 1fr)); gap: var(--space-14); }
.etac-tile {
  position: relative; display: flex; flex-direction: column; gap: var(--space-9); padding: var(--space-18); border-radius: 15px;
  background: var(--bg-elevated); border: 1px solid var(--border); text-decoration: none; color: var(--text);
  transition: border-color .14s; overflow: hidden;
}
.etac-tile:hover { border-color: var(--border-hover); }
.etac-tile:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.etac-tile__ico {
  width: 42px; height: 42px; border-radius: var(--radius-lg); display: flex; align-items: center; justify-content: center;
  font-size: 21px; line-height: 1; text-align: center;
  background: var(--accent-soft); border: 1px solid color-mix(in oklab, var(--accent) 22%, transparent);
}
/* Masked feature icons: the PNG's alpha is the shape, the theme's accent is
   the color — so they stay visible on light AND dark themes. */
.etac-ico {
  display: block; width: 24px; height: 24px; background: var(--accent);
  -webkit-mask: var(--ico) center / contain no-repeat;
  mask: var(--ico) center / contain no-repeat;
}
/* Inline streak flame — the hand-drawn icon with a fire gradient poured in,
   em-sized so it rides along with whatever text it sits in (replaces 🔥
   wherever the streak is shown). */
.etac-flame-ico {
  display: inline-block; width: 1.1em; height: 1.1em; vertical-align: -0.16em;
  background: linear-gradient(to top, #facc15, #fb923c 45%, #dc2626);
  -webkit-mask: url('assets/icons/streak-flame.png') center / contain no-repeat;
  mask: url('assets/icons/streak-flame.png') center / contain no-repeat;
}
.etac-tile.is-store .etac-ico { background: var(--spark-ink); }
/* Inline text icons: em-sized, tinted currentColor, so a single glyph can
   drop into a run of text (a link, a button label, a tag chip) wherever an
   emoji used to sit — unlike .etac-ico this never fights the surrounding
   color. */
.etac-mico {
  display: inline-block; width: 1em; height: 1em; vertical-align: -0.15em;
  background: currentColor; flex-shrink: 0;
  -webkit-mask: var(--ico) center / contain no-repeat;
  mask: var(--ico) center / contain no-repeat;
}
/* Full-color inline icons (the crown, the bone) — no mask, since the art's
   own color is the point. */
.etac-mico-c {
  display: inline-block; width: 1.1em; height: 1.1em; vertical-align: -0.2em;
  object-fit: contain; flex-shrink: 0;
}
.etac-tile__t { font: 650 15.5px var(--fsans); color: var(--text); display: flex; align-items: center; gap: var(--space-8); }
.etac-tile__d { font: 400 13px/1.5 var(--fsans); color: var(--text-muted); margin: 0; }
.etac-tile__go { margin-top: auto; font: 700 12.5px var(--fsans); color: var(--accent); }
.etac-tile.is-store .etac-tile__ico { background: linear-gradient(160deg, var(--spark-1), var(--spark-2)); border-color: var(--spark-bd); }

/* coming-soon tiles: veiled but clickable — they link to preview pages.
   Hovering (or focusing) lifts the veil, like peeking under the tarp. */
.etac-tile.is-soon::after {
  content: ""; position: absolute; inset: 0; z-index: 1; border-radius: inherit;
  background: color-mix(in oklab, var(--bg) 55%, transparent);
  transition: opacity .18s; pointer-events: none;
}
.etac-tile.is-soon:hover::after,
.etac-tile.is-soon:focus-visible::after { opacity: 0; }
.etac-tile.is-soon .etac-tile__go { position: relative; z-index: 2; margin-top: var(--space-6); }
.etac-tile__soon { display: none; }
.etac-tile.is-soon .etac-tile__soon {
  display: inline-flex; align-items: center; gap: var(--space-6); align-self: flex-start; margin-top: auto;
  position: relative; z-index: 2; font: 700 12px/1 var(--fsans); letter-spacing: 0.072em; text-transform: uppercase;
  color: var(--text-muted); background: var(--bg-input); border: 1px solid var(--border-hover); border-radius: var(--radius-pill); padding: var(--space-6) 11px;
}
/* non-link tiles (e.g. Store items): normal cursor, no hover lift */
.etac-tile--static, .etac-tile--static:hover { cursor: default; transform: none; box-shadow: none; border-color: var(--border); }
.etac-tile--btn { text-align: left; cursor: pointer; }

/* ---------- Store page (store.html + store.js) ---------- */
/* Elements-bundle tile: the four theme gradients as swatch chips. They
   double as preview buttons — tapping one wipes the whole page into that
   theme for a few seconds (store.js), like trying it on at the register. */
.st-swatches { display: flex; gap: var(--space-7); margin-bottom: var(--space-4); }
.st-swatches > * {
  width: 34px; height: 34px; border-radius: var(--radius-md); padding: 0;
  display: grid; place-items: center; font-size: 15px;
  border: 1px solid color-mix(in oklab, #000 18%, transparent);
}
.st-swatches button {
  cursor: pointer; color: inherit;
  transition: transform .12s, box-shadow .12s;
}
.st-swatches button:hover { box-shadow: 0 0 0 2px var(--bg-elevated), 0 0 0 3.5px var(--border-hover); }
.st-swatches button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.st-swatches button.is-previewing {
  box-shadow: 0 0 0 2px var(--bg-elevated), 0 0 0 3.5px var(--accent);
}
.st-swatches .etac-ico { width: 18px; height: 18px; background: rgba(255, 255, 255, .92); }
.st-prevhint { margin: 0 0 var(--space-4); font: 500 12px var(--fsans); color: var(--text-dim); }
.etac-theme-swatch--fire { background: linear-gradient(135deg, #ffd84a, #ff8a3d, #ff4d4d); }
.etac-theme-swatch--air { background: linear-gradient(135deg, #f9a8d4, #ec4899, #a21caf); }
.etac-theme-swatch--water { background: linear-gradient(135deg, #7dd3fc, #0ea5e9, #0c4a6e); }
.etac-theme-swatch--earth { background: linear-gradient(135deg, #d4b896, #8b5a3c, #4a2c1a); }

/* The "trying on" pill — floats above the bottom nav while a preview is
   live, with the countdown and an early exit. */
.st-preview-pill {
  position: fixed; left: 50%; bottom: 64px; transform: translate(-50%, 8px); z-index: 90;
  display: flex; align-items: center; gap: var(--space-10); padding: var(--space-7) var(--space-8) var(--space-7) 15px;
  font: 550 13px var(--fsans); color: var(--text-muted);
  background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-pill);
  box-shadow: 0 8px 24px rgba(0, 0, 0, .22);
  opacity: 0; pointer-events: none; transition: opacity .18s, transform .18s;
}
.st-preview-pill.is-show { opacity: 1; transform: translate(-50%, 0); pointer-events: auto; }
.st-preview-pill b { color: var(--text); font-weight: 700; }
.st-preview-pill button {
  font: 650 12px var(--fsans); color: var(--text); cursor: pointer;
  background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--radius-pill); padding: var(--space-5) 11px;
}
.st-preview-pill button:hover { border-color: var(--accent); color: var(--accent); }
.st-preview-pill button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Theme-preview wipes (View Transitions API). The old theme's snapshot sits
   still while the new one is revealed behind an element-shaped clip-path —
   each theme arrives in character: fire burns up from the bottom, air gusts
   across, water floods up, earth crumbles down. store.js stamps data-vt on
   <html> with the incoming theme (or "plain" for dark/light) for the swap.
   Scoped to html.st-vt-wipe so any other view transition keeps its default
   crossfade. Browsers without the API — and reduced-motion users — get an
   instant switch from store.js instead.

   All keyframes of one animation keep the SAME polygon point count, so the
   browser interpolates the edge smoothly instead of snapping. */
html.st-vt-wipe::view-transition-old(root) { animation: none; mix-blend-mode: normal; }
html.st-vt-wipe::view-transition-new(root) { mix-blend-mode: normal; }

html.st-vt-wipe[data-vt="plain"]::view-transition-new(root) {
  animation: st-wipe-plain 1s cubic-bezier(.45, 0, .2, 1) both;
}
html.st-vt-wipe[data-vt="fire"]::view-transition-new(root) {
  animation: st-wipe-fire 1s cubic-bezier(.3, .1, .25, 1) both;
}
html.st-vt-wipe[data-vt="air"]::view-transition-new(root) {
  animation: st-wipe-air 1s cubic-bezier(.2, .55, .25, 1) both;
}
html.st-vt-wipe[data-vt="water"]::view-transition-new(root) {
  animation: st-wipe-water 1s cubic-bezier(.35, 0, .25, 1) both;
}
html.st-vt-wipe[data-vt="earth"]::view-transition-new(root) {
  /* steps() turns the interpolation into discrete lurches — stop-motion
     chunks of rock stacking in, rather than a smooth slide. */
  animation: st-wipe-earth 1.05s steps(4, jump-end) both;
}

/* Plain — the original slanted sweep (dark/light, and wiping back home). */
@keyframes st-wipe-plain {
  from { clip-path: polygon(-22% 0, 0 0, -22% 100%, -44% 100%); }
  to   { clip-path: polygon(0 0, 122% 0, 100% 100%, -22% 100%); }
}

/* FIRE — living flames climb from the bottom. The edge is five gaussian
   tongues (computed, 21 points) whose heights AND centers drift between
   keyframes, so the flames sway and flicker rather than repeat a zigzag. */
@keyframes st-wipe-fire {
  0%   { clip-path: polygon(0% 115%, 5% 115%, 10% 115%, 15% 115%, 20% 115%, 25% 115%, 30% 115%, 35% 115%, 40% 115%, 45% 115%, 50% 115%, 55% 115%, 60% 115%, 65% 115%, 70% 115%, 75% 115%, 80% 115%, 85% 115%, 90% 115%, 95% 115%, 100% 115%, 100% 130%, 0% 130%); }
  30%  { clip-path: polygon(0% 79.5%, 5% 75%, 10% 65.4%, 15% 67.1%, 20% 71.2%, 25% 59.1%, 30% 57.5%, 35% 68.8%, 40% 77.5%, 45% 70.9%, 50% 61.4%, 55% 66.3%, 60% 68.1%, 65% 56.2%, 70% 56.2%, 75% 68.1%, 80% 77%, 85% 75.3%, 90% 66.4%, 95% 67.9%, 100% 76.7%, 100% 130%, 0% 130%); }
  55%  { clip-path: polygon(0% 46.3%, 5% 31%, 10% 28.3%, 15% 43.8%, 20% 50.7%, 25% 45.8%, 30% 38.4%, 35% 38.4%, 40% 45.4%, 45% 29.4%, 50% 24.3%, 55% 39.8%, 60% 49.7%, 65% 43.4%, 70% 35.9%, 75% 37%, 80% 45%, 85% 32.6%, 90% 30.2%, 95% 44.5%, 100% 51.2%, 100% 130%, 0% 130%); }
  80%  { clip-path: polygon(0% 23.2%, 5% 17.4%, 10% 6.9%, 15% 10.5%, 20% 16.4%, 25% 3%, 30% -2.6%, 35% 8.6%, 40% 19.9%, 45% 15.9%, 50% 3.7%, 55% 5.7%, 60% 13.7%, 65% 1.9%, 70% 0.3%, 75% 11.3%, 80% 20.6%, 85% 16.7%, 90% 7.4%, 95% 12.4%, 100% 21.5%, 100% 130%, 0% 130%); }
  100% { clip-path: polygon(0% -45%, 5% -45%, 10% -45%, 15% -45%, 20% -45%, 25% -45%, 30% -45%, 35% -45%, 40% -45%, 45% -45%, 50% -45%, 55% -45%, 60% -45%, 65% -45%, 70% -45%, 75% -45%, 80% -45%, 85% -45%, 90% -45%, 95% -45%, 100% -45%, 100% 130%, 0% 130%); }
}

/* WATER — a real flood: the level rises while a smooth sine wave travels
   across the surface (phase advances each keyframe) and the whole waterline
   tilts one way then the other, like water sloshing in a filling tank. */
@keyframes st-wipe-water {
  0%   { clip-path: polygon(0% 116%, 5% 116%, 10% 116%, 15% 116%, 20% 116%, 25% 116%, 30% 116%, 35% 116%, 40% 116%, 45% 116%, 50% 116%, 55% 116%, 60% 116%, 65% 116%, 70% 116%, 75% 116%, 80% 116%, 85% 116%, 90% 116%, 95% 116%, 100% 116%, 100% 130%, 0% 130%); }
  30%  { clip-path: polygon(0% 65%, 5% 67.9%, 10% 71.8%, 15% 75.1%, 20% 76%, 25% 74%, 30% 69.8%, 35% 64.8%, 40% 60.9%, 45% 59.6%, 50% 61.1%, 55% 64.6%, 60% 68.5%, 65% 71%, 70% 70.8%, 75% 67.7%, 80% 63%, 85% 58.2%, 90% 55.2%, 95% 55%, 100% 57.5%, 100% 130%, 0% 130%); }
  60%  { clip-path: polygon(0% 41.8%, 5% 42.2%, 10% 40.7%, 15% 38.1%, 20% 35.6%, 25% 34.6%, 30% 35.5%, 35% 38.4%, 40% 42%, 45% 45.3%, 50% 47%, 55% 46.6%, 60% 44.5%, 65% 41.8%, 70% 39.8%, 75% 39.5%, 80% 41.3%, 85% 44.6%, 90% 48.3%, 95% 51%, 100% 51.9%, 100% 130%, 0% 130%); }
  85%  { clip-path: polygon(0% 16.1%, 5% 14.2%, 10% 13.4%, 15% 13.8%, 20% 15.2%, 25% 16.8%, 30% 17.9%, 35% 17.8%, 40% 16.6%, 45% 14.5%, 50% 12.4%, 55% 10.9%, 60% 10.5%, 65% 11.4%, 70% 13%, 75% 14.5%, 80% 15.1%, 85% 14.6%, 90% 13%, 95% 10.7%, 100% 8.8%, 100% 130%, 0% 130%); }
  100% { clip-path: polygon(0% -22%, 5% -22%, 10% -22%, 15% -22%, 20% -22%, 25% -22%, 30% -22%, 35% -22%, 40% -22%, 45% -22%, 50% -22%, 55% -22%, 60% -22%, 65% -22%, 70% -22%, 75% -22%, 80% -22%, 85% -22%, 90% -22%, 95% -22%, 100% -22%, 100% 130%, 0% 130%); }
}

/* AIR — one smooth wind front sweeps left→right with three gust lobes
   bulging ahead of it; the top lobe leads first, then the middle surges
   past (curved edge, not bands — reads as gusts, not stripes). */
@keyframes st-wipe-air {
  0%   { clip-path: polygon(-32% 0%, -32% 5%, -32% 10%, -32% 15%, -32% 20%, -32% 25%, -32% 30%, -32% 35%, -32% 40%, -32% 45%, -32% 50%, -32% 55%, -32% 60%, -32% 65%, -32% 70%, -32% 75%, -32% 80%, -32% 85%, -32% 90%, -32% 95%, -32% 100%, -40% 100%, -40% 0%); }
  45%  { clip-path: polygon(43.7% 0%, 50.7% 5%, 59% 10%, 63.9% 15%, 61.7% 20%, 54.2% 25%, 46.7% 30%, 42.7% 35%, 42.2% 40%, 43.4% 45%, 44% 50%, 43.3% 55%, 41.9% 60%, 41.5% 65%, 43.6% 70%, 48.1% 75%, 52.6% 80%, 53.9% 85%, 50.9% 90%, 45.8% 95%, 41.5% 100%, -40% 100%, -40% 0%); }
  75%  { clip-path: polygon(79.8% 0%, 81.9% 5%, 84.5% 10%, 86% 15%, 85.4% 20%, 83.6% 25%, 83.1% 30%, 86.3% 35%, 93.8% 40%, 102.2% 45%, 106% 50%, 102.2% 55%, 93.9% 60%, 86.8% 65%, 84.4% 70%, 86.1% 75%, 89.1% 80%, 89.9% 85%, 87.7% 90%, 83.9% 95%, 80.6% 100%, -40% 100%, -40% 0%); }
  100% { clip-path: polygon(142% 0%, 142% 5%, 142% 10%, 142% 15%, 142% 20%, 142% 25%, 142% 30%, 142% 35%, 142% 40%, 142% 45%, 142% 50%, 142% 55%, 142% 60%, 142% 65%, 142% 70%, 142% 75%, 142% 80%, 142% 85%, 142% 90%, 142% 95%, 142% 100%, -40% 100%, -40% 0%); }
}

/* EARTH — terraced rock strata descend from the top: ten flat ledges with
   vertical cliffs between them, each ledge leading or lagging differently
   every keyframe (with the stepped timing above, the whole face crumbles
   down in stop-motion lurches). */
@keyframes st-wipe-earth {
  0%   { clip-path: polygon(0% -40%, 100% -40%, 100% -18%, 88% -18%, 88% -18%, 77% -18%, 77% -18%, 66% -18%, 66% -18%, 58% -18%, 58% -18%, 47% -18%, 47% -18%, 38% -18%, 38% -18%, 28% -18%, 28% -18%, 17% -18%, 17% -18%, 9% -18%, 9% -18%, 0% -18%); }
  25%  { clip-path: polygon(0% -40%, 100% -40%, 100% 13%, 88% 13%, 88% 20%, 77% 20%, 77% 11%, 66% 11%, 66% 26%, 58% 26%, 58% 14%, 47% 14%, 47% 23%, 38% 23%, 38% 10%, 28% 10%, 28% 21%, 17% 21%, 17% 12%, 9% 12%, 9% 25%, 0% 25%); }
  50%  { clip-path: polygon(0% -40%, 100% -40%, 100% 52%, 88% 52%, 88% 38%, 77% 38%, 77% 51%, 66% 51%, 66% 42%, 58% 42%, 58% 54%, 47% 54%, 47% 40%, 38% 40%, 38% 50%, 28% 50%, 28% 39%, 17% 39%, 17% 52%, 9% 52%, 9% 41%, 0% 41%); }
  75%  { clip-path: polygon(0% -40%, 100% -40%, 100% 71%, 88% 71%, 88% 83%, 77% 83%, 77% 73%, 66% 73%, 66% 81%, 58% 81%, 58% 69%, 47% 69%, 47% 79%, 38% 79%, 38% 70%, 28% 70%, 28% 84%, 17% 84%, 17% 72%, 9% 72%, 9% 82%, 0% 82%); }
  100% { clip-path: polygon(0% -40%, 100% -40%, 100% 118%, 88% 118%, 88% 118%, 77% 118%, 77% 118%, 66% 118%, 66% 118%, 58% 118%, 58% 118%, 47% 118%, 47% 118%, 38% 118%, 38% 118%, 28% 118%, 28% 118%, 17% 118%, 17% 118%, 9% 118%, 9% 118%, 0% 118%); }
}

/* ---------- Wipe particle FX ----------
   store.js drops a one-shot .st-fx layer on <body> during a preview wipe:
   embers for fire, bubbles for water, wind streaks for air, tumbling rocks
   + dust for earth. The layer carries its own view-transition-name so the
   root wipe's clip-path can't clip it — particles fly OVER the wipe edge.
   Reduced-motion users never get the layer (store.js skips it). */
.st-fx {
  position: fixed; inset: 0; z-index: 95; pointer-events: none;
  overflow: hidden; view-transition-name: st-fx;
}
html.st-vt-wipe::view-transition-group(st-fx),
html.st-vt-wipe::view-transition-image-pair(st-fx),
html.st-vt-wipe::view-transition-new(st-fx) { animation: none; }
html.st-vt-wipe::view-transition-old(st-fx) { animation: none; opacity: 0; }
.st-fx i { position: absolute; display: block; will-change: transform, opacity; }

/* Accent rim — a thin band of the incoming theme's accent riding the wipe
   edge (same keyframes re-traced as a closed outline), so the wipe stays
   visible even when the two themes are near-identical shades. It animates
   on the ELEMENT (not the snapshot), so it also works without the View
   Transitions API. */
.st-fx .fx-rim { inset: 0; width: auto; height: auto; background: var(--accent); opacity: .85; }
.st-fx .fx-rim[data-rim="plain"] { animation: st-rim-plain 1s cubic-bezier(.45, 0, .2, 1) both; }
.st-fx .fx-rim[data-rim="fire"]  { animation: st-rim-fire 1s cubic-bezier(.3, .1, .25, 1) both; }
.st-fx .fx-rim[data-rim="water"] { animation: st-rim-water 1s cubic-bezier(.35, 0, .25, 1) both; }
.st-fx .fx-rim[data-rim="air"]   { animation: st-rim-air 1s cubic-bezier(.2, .55, .25, 1) both; }
.st-fx .fx-rim[data-rim="earth"] { animation: st-rim-earth 1.05s steps(4, jump-end) both; }

@keyframes st-rim-plain {
  0%   { clip-path: polygon(0% 0%, 1% 0%, -20.9% 100%, -22% 100%); }
  100% { clip-path: polygon(122% 0%, 123.1% 0%, 101.1% 100%, 100% 100%); }
}
@keyframes st-rim-fire {
  0%   { clip-path: polygon(0% 115%, 5% 115%, 10% 115%, 15% 115%, 20% 115%, 25% 115%, 30% 115%, 35% 115%, 40% 115%, 45% 115%, 50% 115%, 55% 115%, 60% 115%, 65% 115%, 70% 115%, 75% 115%, 80% 115%, 85% 115%, 90% 115%, 95% 115%, 100% 115%, 100% 115.8%, 95% 115.8%, 90% 115.8%, 85% 115.8%, 80% 115.8%, 75% 115.8%, 70% 115.8%, 65% 115.8%, 60% 115.8%, 55% 115.8%, 50% 115.8%, 45% 115.8%, 40% 115.8%, 35% 115.8%, 30% 115.8%, 25% 115.8%, 20% 115.8%, 15% 115.8%, 10% 115.8%, 5% 115.8%, 0% 115.8%); }
  30%  { clip-path: polygon(0% 79.5%, 5% 75%, 10% 65.4%, 15% 67.1%, 20% 71.2%, 25% 59.1%, 30% 57.5%, 35% 68.8%, 40% 77.5%, 45% 70.9%, 50% 61.4%, 55% 66.3%, 60% 68.1%, 65% 56.2%, 70% 56.2%, 75% 68.1%, 80% 77%, 85% 75.3%, 90% 66.4%, 95% 67.9%, 100% 76.7%, 100% 77.5%, 95% 68.7%, 90% 67.2%, 85% 76.1%, 80% 77.8%, 75% 68.9%, 70% 57%, 65% 57%, 60% 68.9%, 55% 67.1%, 50% 62.2%, 45% 71.7%, 40% 78.3%, 35% 69.6%, 30% 58.3%, 25% 59.9%, 20% 72%, 15% 67.9%, 10% 66.2%, 5% 75.8%, 0% 80.3%); }
  55%  { clip-path: polygon(0% 46.3%, 5% 31%, 10% 28.3%, 15% 43.8%, 20% 50.7%, 25% 45.8%, 30% 38.4%, 35% 38.4%, 40% 45.4%, 45% 29.4%, 50% 24.3%, 55% 39.8%, 60% 49.7%, 65% 43.4%, 70% 35.9%, 75% 37%, 80% 45%, 85% 32.6%, 90% 30.2%, 95% 44.5%, 100% 51.2%, 100% 52%, 95% 45.3%, 90% 31%, 85% 33.4%, 80% 45.8%, 75% 37.8%, 70% 36.7%, 65% 44.2%, 60% 50.5%, 55% 40.6%, 50% 25.1%, 45% 30.2%, 40% 46.2%, 35% 39.2%, 30% 39.2%, 25% 46.6%, 20% 51.5%, 15% 44.6%, 10% 29.1%, 5% 31.8%, 0% 47.1%); }
  80%  { clip-path: polygon(0% 23.2%, 5% 17.4%, 10% 6.9%, 15% 10.5%, 20% 16.4%, 25% 3%, 30% -2.6%, 35% 8.6%, 40% 19.9%, 45% 15.9%, 50% 3.7%, 55% 5.7%, 60% 13.7%, 65% 1.9%, 70% 0.3%, 75% 11.3%, 80% 20.6%, 85% 16.7%, 90% 7.4%, 95% 12.4%, 100% 21.5%, 100% 22.3%, 95% 13.2%, 90% 8.2%, 85% 17.5%, 80% 21.4%, 75% 12.1%, 70% 1.1%, 65% 2.7%, 60% 14.5%, 55% 6.5%, 50% 4.5%, 45% 16.7%, 40% 20.7%, 35% 9.4%, 30% -1.8%, 25% 3.8%, 20% 17.2%, 15% 11.3%, 10% 7.7%, 5% 18.2%, 0% 24%); }
  100% { clip-path: polygon(0% -45%, 5% -45%, 10% -45%, 15% -45%, 20% -45%, 25% -45%, 30% -45%, 35% -45%, 40% -45%, 45% -45%, 50% -45%, 55% -45%, 60% -45%, 65% -45%, 70% -45%, 75% -45%, 80% -45%, 85% -45%, 90% -45%, 95% -45%, 100% -45%, 100% -44.2%, 95% -44.2%, 90% -44.2%, 85% -44.2%, 80% -44.2%, 75% -44.2%, 70% -44.2%, 65% -44.2%, 60% -44.2%, 55% -44.2%, 50% -44.2%, 45% -44.2%, 40% -44.2%, 35% -44.2%, 30% -44.2%, 25% -44.2%, 20% -44.2%, 15% -44.2%, 10% -44.2%, 5% -44.2%, 0% -44.2%); }
}
@keyframes st-rim-water {
  0%   { clip-path: polygon(0% 116%, 5% 116%, 10% 116%, 15% 116%, 20% 116%, 25% 116%, 30% 116%, 35% 116%, 40% 116%, 45% 116%, 50% 116%, 55% 116%, 60% 116%, 65% 116%, 70% 116%, 75% 116%, 80% 116%, 85% 116%, 90% 116%, 95% 116%, 100% 116%, 100% 116.8%, 95% 116.8%, 90% 116.8%, 85% 116.8%, 80% 116.8%, 75% 116.8%, 70% 116.8%, 65% 116.8%, 60% 116.8%, 55% 116.8%, 50% 116.8%, 45% 116.8%, 40% 116.8%, 35% 116.8%, 30% 116.8%, 25% 116.8%, 20% 116.8%, 15% 116.8%, 10% 116.8%, 5% 116.8%, 0% 116.8%); }
  30%  { clip-path: polygon(0% 65%, 5% 67.9%, 10% 71.8%, 15% 75.1%, 20% 76%, 25% 74%, 30% 69.8%, 35% 64.8%, 40% 60.9%, 45% 59.6%, 50% 61.1%, 55% 64.6%, 60% 68.5%, 65% 71%, 70% 70.8%, 75% 67.7%, 80% 63%, 85% 58.2%, 90% 55.2%, 95% 55%, 100% 57.5%, 100% 58.3%, 95% 55.8%, 90% 56%, 85% 59%, 80% 63.8%, 75% 68.5%, 70% 71.6%, 65% 71.8%, 60% 69.3%, 55% 65.4%, 50% 61.9%, 45% 60.4%, 40% 61.7%, 35% 65.6%, 30% 70.6%, 25% 74.8%, 20% 76.8%, 15% 75.9%, 10% 72.6%, 5% 68.7%, 0% 65.8%); }
  60%  { clip-path: polygon(0% 41.8%, 5% 42.2%, 10% 40.7%, 15% 38.1%, 20% 35.6%, 25% 34.6%, 30% 35.5%, 35% 38.4%, 40% 42%, 45% 45.3%, 50% 47%, 55% 46.6%, 60% 44.5%, 65% 41.8%, 70% 39.8%, 75% 39.5%, 80% 41.3%, 85% 44.6%, 90% 48.3%, 95% 51%, 100% 51.9%, 100% 52.7%, 95% 51.8%, 90% 49.1%, 85% 45.4%, 80% 42.1%, 75% 40.3%, 70% 40.6%, 65% 42.6%, 60% 45.3%, 55% 47.4%, 50% 47.8%, 45% 46.1%, 40% 42.8%, 35% 39.2%, 30% 36.3%, 25% 35.4%, 20% 36.4%, 15% 38.9%, 10% 41.5%, 5% 43%, 0% 42.6%); }
  85%  { clip-path: polygon(0% 16.1%, 5% 14.2%, 10% 13.4%, 15% 13.8%, 20% 15.2%, 25% 16.8%, 30% 17.9%, 35% 17.8%, 40% 16.6%, 45% 14.5%, 50% 12.4%, 55% 10.9%, 60% 10.5%, 65% 11.4%, 70% 13%, 75% 14.5%, 80% 15.1%, 85% 14.6%, 90% 13%, 95% 10.7%, 100% 8.8%, 100% 9.6%, 95% 11.5%, 90% 13.8%, 85% 15.4%, 80% 15.9%, 75% 15.3%, 70% 13.8%, 65% 12.2%, 60% 11.3%, 55% 11.7%, 50% 13.2%, 45% 15.3%, 40% 17.4%, 35% 18.6%, 30% 18.7%, 25% 17.6%, 20% 16%, 15% 14.6%, 10% 14.2%, 5% 15%, 0% 16.9%); }
  100% { clip-path: polygon(0% -22%, 5% -22%, 10% -22%, 15% -22%, 20% -22%, 25% -22%, 30% -22%, 35% -22%, 40% -22%, 45% -22%, 50% -22%, 55% -22%, 60% -22%, 65% -22%, 70% -22%, 75% -22%, 80% -22%, 85% -22%, 90% -22%, 95% -22%, 100% -22%, 100% -21.2%, 95% -21.2%, 90% -21.2%, 85% -21.2%, 80% -21.2%, 75% -21.2%, 70% -21.2%, 65% -21.2%, 60% -21.2%, 55% -21.2%, 50% -21.2%, 45% -21.2%, 40% -21.2%, 35% -21.2%, 30% -21.2%, 25% -21.2%, 20% -21.2%, 15% -21.2%, 10% -21.2%, 5% -21.2%, 0% -21.2%); }
}
@keyframes st-rim-air {
  0%   { clip-path: polygon(-32% 0%, -32% 5%, -32% 10%, -32% 15%, -32% 20%, -32% 25%, -32% 30%, -32% 35%, -32% 40%, -32% 45%, -32% 50%, -32% 55%, -32% 60%, -32% 65%, -32% 70%, -32% 75%, -32% 80%, -32% 85%, -32% 90%, -32% 95%, -32% 100%, -32.7% 100%, -32.7% 95%, -32.7% 90%, -32.7% 85%, -32.7% 80%, -32.7% 75%, -32.7% 70%, -32.7% 65%, -32.7% 60%, -32.7% 55%, -32.7% 50%, -32.7% 45%, -32.7% 40%, -32.7% 35%, -32.7% 30%, -32.7% 25%, -32.7% 20%, -32.7% 15%, -32.7% 10%, -32.7% 5%, -32.7% 0%); }
  45%  { clip-path: polygon(43.7% 0%, 50.7% 5%, 59% 10%, 63.9% 15%, 61.7% 20%, 54.2% 25%, 46.7% 30%, 42.7% 35%, 42.2% 40%, 43.4% 45%, 44% 50%, 43.3% 55%, 41.9% 60%, 41.5% 65%, 43.6% 70%, 48.1% 75%, 52.6% 80%, 53.9% 85%, 50.9% 90%, 45.8% 95%, 41.5% 100%, 40.8% 100%, 45.1% 95%, 50.2% 90%, 53.2% 85%, 51.9% 80%, 47.4% 75%, 42.9% 70%, 40.8% 65%, 41.2% 60%, 42.6% 55%, 43.3% 50%, 42.7% 45%, 41.5% 40%, 42% 35%, 46% 30%, 53.5% 25%, 61% 20%, 63.2% 15%, 58.3% 10%, 50% 5%, 43% 0%); }
  75%  { clip-path: polygon(79.8% 0%, 81.9% 5%, 84.5% 10%, 86% 15%, 85.4% 20%, 83.6% 25%, 83.1% 30%, 86.3% 35%, 93.8% 40%, 102.2% 45%, 106% 50%, 102.2% 55%, 93.9% 60%, 86.8% 65%, 84.4% 70%, 86.1% 75%, 89.1% 80%, 89.9% 85%, 87.7% 90%, 83.9% 95%, 80.6% 100%, 79.9% 100%, 83.2% 95%, 87% 90%, 89.2% 85%, 88.4% 80%, 85.4% 75%, 83.7% 70%, 86.1% 65%, 93.2% 60%, 101.5% 55%, 105.3% 50%, 101.5% 45%, 93.1% 40%, 85.6% 35%, 82.4% 30%, 82.9% 25%, 84.7% 20%, 85.3% 15%, 83.8% 10%, 81.2% 5%, 79.1% 0%); }
  100% { clip-path: polygon(142% 0%, 142% 5%, 142% 10%, 142% 15%, 142% 20%, 142% 25%, 142% 30%, 142% 35%, 142% 40%, 142% 45%, 142% 50%, 142% 55%, 142% 60%, 142% 65%, 142% 70%, 142% 75%, 142% 80%, 142% 85%, 142% 90%, 142% 95%, 142% 100%, 141.3% 100%, 141.3% 95%, 141.3% 90%, 141.3% 85%, 141.3% 80%, 141.3% 75%, 141.3% 70%, 141.3% 65%, 141.3% 60%, 141.3% 55%, 141.3% 50%, 141.3% 45%, 141.3% 40%, 141.3% 35%, 141.3% 30%, 141.3% 25%, 141.3% 20%, 141.3% 15%, 141.3% 10%, 141.3% 5%, 141.3% 0%); }
}
@keyframes st-rim-earth {
  0%   { clip-path: polygon(100% -18%, 88% -18%, 88% -18%, 77% -18%, 77% -18%, 66% -18%, 66% -18%, 58% -18%, 58% -18%, 47% -18%, 47% -18%, 38% -18%, 38% -18%, 28% -18%, 28% -18%, 17% -18%, 17% -18%, 9% -18%, 9% -18%, 0% -18%, 0% -18.7%, 9% -18.7%, 9% -18.7%, 17% -18.7%, 17% -18.7%, 28% -18.7%, 28% -18.7%, 38% -18.7%, 38% -18.7%, 47% -18.7%, 47% -18.7%, 58% -18.7%, 58% -18.7%, 66% -18.7%, 66% -18.7%, 77% -18.7%, 77% -18.7%, 88% -18.7%, 88% -18.7%, 100% -18.7%); }
  25%  { clip-path: polygon(100% 13%, 88% 13%, 88% 20%, 77% 20%, 77% 11%, 66% 11%, 66% 26%, 58% 26%, 58% 14%, 47% 14%, 47% 23%, 38% 23%, 38% 10%, 28% 10%, 28% 21%, 17% 21%, 17% 12%, 9% 12%, 9% 25%, 0% 25%, 0% 24.3%, 9% 24.3%, 9% 11.3%, 17% 11.3%, 17% 20.3%, 28% 20.3%, 28% 9.3%, 38% 9.3%, 38% 22.3%, 47% 22.3%, 47% 13.3%, 58% 13.3%, 58% 25.3%, 66% 25.3%, 66% 10.3%, 77% 10.3%, 77% 19.3%, 88% 19.3%, 88% 12.3%, 100% 12.3%); }
  50%  { clip-path: polygon(100% 52%, 88% 52%, 88% 38%, 77% 38%, 77% 51%, 66% 51%, 66% 42%, 58% 42%, 58% 54%, 47% 54%, 47% 40%, 38% 40%, 38% 50%, 28% 50%, 28% 39%, 17% 39%, 17% 52%, 9% 52%, 9% 41%, 0% 41%, 0% 40.3%, 9% 40.3%, 9% 51.3%, 17% 51.3%, 17% 38.3%, 28% 38.3%, 28% 49.3%, 38% 49.3%, 38% 39.3%, 47% 39.3%, 47% 53.3%, 58% 53.3%, 58% 41.3%, 66% 41.3%, 66% 50.3%, 77% 50.3%, 77% 37.3%, 88% 37.3%, 88% 51.3%, 100% 51.3%); }
  75%  { clip-path: polygon(100% 71%, 88% 71%, 88% 83%, 77% 83%, 77% 73%, 66% 73%, 66% 81%, 58% 81%, 58% 69%, 47% 69%, 47% 79%, 38% 79%, 38% 70%, 28% 70%, 28% 84%, 17% 84%, 17% 72%, 9% 72%, 9% 82%, 0% 82%, 0% 81.3%, 9% 81.3%, 9% 71.3%, 17% 71.3%, 17% 83.3%, 28% 83.3%, 28% 69.3%, 38% 69.3%, 38% 78.3%, 47% 78.3%, 47% 68.3%, 58% 68.3%, 58% 80.3%, 66% 80.3%, 66% 72.3%, 77% 72.3%, 77% 82.3%, 88% 82.3%, 88% 70.3%, 100% 70.3%); }
  100% { clip-path: polygon(100% 118%, 88% 118%, 88% 118%, 77% 118%, 77% 118%, 66% 118%, 66% 118%, 58% 118%, 58% 118%, 47% 118%, 47% 118%, 38% 118%, 38% 118%, 28% 118%, 28% 118%, 17% 118%, 17% 118%, 9% 118%, 9% 118%, 0% 118%, 0% 117.3%, 9% 117.3%, 9% 117.3%, 17% 117.3%, 17% 117.3%, 28% 117.3%, 28% 117.3%, 38% 117.3%, 38% 117.3%, 47% 117.3%, 47% 117.3%, 58% 117.3%, 58% 117.3%, 66% 117.3%, 66% 117.3%, 77% 117.3%, 77% 117.3%, 88% 117.3%, 88% 117.3%, 100% 117.3%); }
}

/* Embers ride up with the flames, swaying and shrinking as they cool */
.st-fx .fx-ember {
  left: var(--x); bottom: -3vh; width: var(--s); height: var(--s); border-radius: 50%;
  background: radial-gradient(circle at 35% 30%, #ffe79a, #ff8a3d 60%, rgba(255, 77, 77, 0));
  animation: st-fx-ember var(--t) cubic-bezier(.25, .4, .4, 1) var(--d) both;
}
@keyframes st-fx-ember {
  0%   { transform: translate(0, 0) scale(1); opacity: 0; }
  12%  { opacity: 1; }
  55%  { transform: translate(var(--sw), -55vh) scale(.75); opacity: .9; }
  100% { transform: translate(calc(var(--sw) * -.4), -108vh) scale(.25); opacity: 0; }
}

/* Bubbles wobble up through the flood and pop at the top */
.st-fx .fx-bubble {
  left: var(--x); bottom: -4vh; width: var(--s); height: var(--s); border-radius: 50%;
  background: radial-gradient(circle at 35% 30%, rgba(255, 255, 255, .55), rgba(125, 211, 252, .18) 65%);
  border: 1.5px solid rgba(255, 255, 255, .65);
  animation: st-fx-bubble var(--t) ease-in var(--d) both;
}
@keyframes st-fx-bubble {
  0%   { transform: translate(0, 0) scale(.6); opacity: 0; }
  15%  { opacity: 1; }
  60%  { transform: translate(var(--sw), -48vh) scale(1); }
  92%  { transform: translate(calc(var(--sw) * -.5), -82vh) scale(1.05); opacity: .9; }
  100% { transform: translate(calc(var(--sw) * -.5), -86vh) scale(1.6); opacity: 0; }
}

/* Wind streaks zip across ahead of the front (accent-tinted so they read
   on light AND dark origins) */
.st-fx .fx-streak {
  top: var(--y); left: -30vw; width: var(--s); height: 3px; border-radius: var(--radius-pill);
  background: linear-gradient(90deg, transparent, color-mix(in oklab, var(--accent) 55%, #fff), transparent);
  animation: st-fx-streak var(--t) cubic-bezier(.2, .5, .3, 1) var(--d) both;
}
@keyframes st-fx-streak {
  0%   { transform: translateX(0); opacity: 0; }
  12%  { opacity: .9; }
  85%  { opacity: .7; }
  100% { transform: translateX(165vw); opacity: 0; }
}

/* Rocks tumble down with the strata; dust puffs where they land */
.st-fx .fx-rock {
  left: var(--x); top: -6vh; width: var(--s); height: var(--s); border-radius: 22%;
  background: color-mix(in oklab, var(--accent) 72%, #241505);
  animation: st-fx-rock var(--t) cubic-bezier(.45, .05, .9, .6) var(--d) both;
}
@keyframes st-fx-rock {
  0%   { transform: translateY(0) rotate(0deg); opacity: 0; }
  10%  { opacity: 1; }
  100% { transform: translateY(118vh) rotate(var(--rot)); opacity: 1; }
}
.st-fx .fx-dust {
  left: var(--x); bottom: 4vh; width: var(--s); height: var(--s); border-radius: 50%;
  background: radial-gradient(circle, color-mix(in oklab, var(--accent) 30%, transparent), transparent 70%);
  animation: st-fx-dust .7s ease-out var(--d) both;
}
@keyframes st-fx-dust {
  0%   { transform: scale(.3); opacity: 0; }
  30%  { opacity: .8; }
  100% { transform: scale(2.6); opacity: 0; }
}
/* Freeze inventory line */
.st-held { margin: 0 0 var(--space-4); font: 650 12px var(--fmono); color: var(--text-muted); }
/* Owned tile: accent edge + calm button */
.etac-tile.is-owned { border-color: color-mix(in oklab, var(--accent) 45%, transparent); }
/* Armed (are-you-sure) buy button */
[data-store-buy].is-armed { animation: st-arm .3s ease; }
@keyframes st-arm { 0% { transform: scale(.96); } 100% { transform: scale(1); } }
/* Coming-soon teasers: dimmed with a corner tag */
.st-soon { position: relative; opacity: .62; }
.st-soon__tag {
  position: absolute; top: 12px; right: 12px;
  font: 700 11.5px var(--fsans); letter-spacing: 0.086em; text-transform: uppercase;
  color: var(--text-muted); background: var(--bg-input);
  border: 1px solid var(--border-hover); border-radius: var(--radius-pill); padding: var(--space-5) var(--space-10);
}
/* lock badge for future paywalled tiles */
.etac-lock {
  position: absolute; z-index: 2; top: 12px; right: 12px; width: 28px; height: 28px; border-radius: 9px;
  display: grid; place-items: center; background: var(--bg-input); border: 1px solid var(--border-hover); color: var(--text);
}
.etac-lock svg { display: block; }

/* ---------- Footer ---------- */
.etac-foot { margin-top: var(--space-40); padding-top: var(--space-18); border-top: 1px solid var(--border); text-align: center; }
.etac-foot__nav { display: flex; gap: var(--space-7); justify-content: center; align-items: center; flex-wrap: wrap; font: 500 13px var(--fsans); }
.etac-foot__nav a { color: var(--text-muted); text-decoration: none; border-bottom: 1px dotted var(--text-dim); padding-bottom: 1px; }
.etac-foot__nav a:hover { color: var(--accent); border-bottom-color: var(--accent); }
.etac-foot__nav a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
.etac-foot__nav .sep { color: var(--text-dim); opacity: .5; }
.etac-foot__credit { margin: var(--space-12) 0 0; font: 500 12.5px var(--fsans); color: var(--text-dim); }
.etac-foot__credit .credit-version { white-space: nowrap; }
/* Legal links under the credit line, on every page — a privacy policy nobody
   can find is worse than none. Muted to match the credit, not the accent:
   they're a legal obligation, not a call to action. */
.etac-foot__credit a, .page-footer .credit a {
  color: var(--text-dim); text-decoration: underline; text-underline-offset: 2px;
}
.etac-foot__credit a:hover, .page-footer .credit a:hover { color: var(--text-muted); }
.etac-foot__credit br, .page-footer .credit br { line-height: 2.4; }
/* Same treatment as .etac-foot__nav .sep — a raw "·" reads as typed
   punctuation; giving it its own dim, decorative span reads as a rule. */
.etac-foot__credit .sep, .page-footer .credit .sep { opacity: .5; margin: 0 var(--space-2); }

/* ===================== SHARED PAGE UTILITIES =====================
   Used by the profile + feature-preview pages (mastery/store/friends/cram). */

/* Back-to-home link above page content */
.etac-back {
  /* F15: was only 16px tall (12px mono text, no padding) — under the 24px
     AA floor (SC 2.5.8). padding-top grows the box to 24px; margin-top
     of the same size cancels it back out of the flow, so the text's
     on-screen position and the gap below don't move — only the invisible
     hit area above it grows. */
  display: inline-flex; align-items: center; gap: var(--space-7); margin-top: -8px;
  padding-top: 8px; margin-bottom: var(--space-14);
  font: 600 13px var(--fsans); color: var(--text-muted);
  text-decoration: none; border-bottom: 1px dotted var(--text-dim); padding-bottom: 1px;
}
.etac-back:hover { color: var(--accent); border-bottom-color: var(--accent); }
.etac-back:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }

/* Generic elevated card (profile sections, feature previews) */
.etac-card { padding: var(--space-18); border: 1px solid var(--border); border-radius: 16px; background: var(--bg-elevated); }
.etac-card--full { flex: 1 1 100%; min-width: 0; }
.etac-card--grow { flex: 1 1 320px; min-width: 0; }
.etac-stat-row { display: flex; gap: var(--space-18); margin-bottom: var(--space-10); }
.etac-stat__value { font: 800 24px/1 var(--fsans); color: var(--text); font-variant-numeric: tabular-nums; }
.etac-stat__label { margin-top: var(--space-4); font: 700 11px var(--fsans); letter-spacing: .072em; text-transform: uppercase; color: var(--text-dim); }
.etac-stat-note { margin: 0; font: 400 13px/1.55 var(--fsans); color: var(--text-muted); }
.etac-caveman-svg { display: block; color: var(--caveman); fill: currentColor; stroke: currentColor; }
.etac-col-stack { display: grid; gap: var(--space-14); }
.etac-grid--wide { grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr)); align-items: start; }
.ab-prose--btn { margin-bottom: var(--space-12); }
.etac-feat-list--tight { margin-bottom: 0; }
.ab-code { background: var(--bg-input); border: 1px solid var(--border); padding: 1px var(--space-6); border-radius: var(--radius-xs); font-size: 12px; font-family: var(--fmono); }
.ab-note--soon { margin: var(--space-14) 0 0; font: italic 500 12.5px var(--fsans); color: var(--text-dim); }

/* Small caption/hint paragraph under a component — composed from a base plus
   size/weight/color/margin modifiers rather than one rule per page, since the
   same shape (dim or muted 12-13px caption) recurs with slightly different
   spacing/weight per caller. */
.etac-hint { font-family: var(--fsans); font-weight: 500; font-size: 12px; color: var(--text-dim); }
.etac-hint--lh15 { line-height: 1.5; }
.etac-hint--muted { color: var(--text-muted); }
.etac-hint--w400 { font-weight: 400; }
.etac-hint--125 { font-size: 12.5px; }
.etac-hint--13 { font-size: 13px; }
.etac-hint--mt5 { margin: var(--space-5) 0 0; }
.etac-hint--mt7 { margin: var(--space-7) 0 0; }
.etac-hint--mt12 { margin: var(--space-12) 0 0; }
.etac-hint--mt14 { margin: var(--space-14) 0 0; }
.etac-hint--mt16-2 { margin: var(--space-16) var(--space-2) 0; }
.etac-hint--mt18 { margin: var(--space-18) 0 0; }
.etac-hint--mb12 { margin: 0 0 var(--space-12); }
.etac-404-card { display: flex; flex-direction: column; align-items: center; text-align: center; gap: var(--space-16); padding: var(--space-44) var(--space-20); margin: var(--space-12) 0 140px; }
.etac-404-card h1 { margin: 0 0 var(--space-8); font: 800 24px var(--fsans); color: var(--text); }
.etac-404-card p { margin: 0; max-width: 44ch; font: 400 14px/1.6 var(--fsans); color: var(--text-muted); }
.etac-ring-slot { display: grid; place-items: center; width: 100%; height: 100%; background: var(--bg-elevated); border-radius: 50%; }
.etac-svg-block { display: block; }
.etac-grid--300 { grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr)); }

/* Exam Cram lobby hero — start panel + countdown ring */
.etac-cram-hero { display: flex; flex-wrap: wrap; gap: var(--space-20); align-items: center; justify-content: space-between; position: relative; overflow: hidden; }
.etac-cram-hero__bar { position: absolute; inset: 0 0 auto 0; height: 3px; background: var(--grad); }
.etac-cram-hero__info { flex: 1 1 280px; min-width: 0; }
.etac-cram-hero__info h3 { margin: 0 0 var(--space-6); font: 700 17px var(--fsans); color: var(--text); }
.etac-cram-hero__info p { margin: 0 0 var(--space-14); font: 400 13.5px/1.55 var(--fsans); color: var(--text-muted); }
.etac-cram-hero__clock { flex: 0 0 auto; text-align: center; }
.etac-cram-hero__clock-inner { display: flex; flex-direction: column; align-items: center; }

.etac-row--empty { justify-content: center; padding: 22px var(--space-14); }
.etac-empty-note { text-align: center; font: 500 12.5px/1.6 var(--fsans); color: var(--text-dim); }
.etac-empty-note__ico { display: block; margin-bottom: var(--space-6); }
.etac-empty-note__ico img { width: 28px; height: 28px; }
.etac-empty-note b { color: var(--text-muted); }
.etac-card__head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-10); margin-bottom: var(--space-14); flex-wrap: wrap; }
.etac-card__head h2, .etac-card__head h3 { margin: 0; font: 700 15px var(--fdisplay); color: var(--text); }
.etac-card__hint { font: 600 12px var(--fsans); color: var(--text-dim); }
.etac-card__hint--btn { margin: 0 0 var(--space-12); }

/* Generic themed meter (mastery bars etc.) — fill scaleX set inline on the span at runtime */
.etac-meter { height: 8px; border-radius: var(--radius-pill); background: var(--bg-input); border: 1px solid var(--border); overflow: hidden; }
.etac-meter > span { display: block; width: 100%; height: 100%; background: var(--grad); border-radius: var(--radius-pill); transform: scaleX(0); transform-origin: left; transition: transform .35s cubic-bezier(.2, .8, .2, 1); }

/* "Sample data" marker — anything wearing this tag is display-only stub */
.etac-preview-tag {
  font: 700 11.5px/1 var(--fsans); letter-spacing: .058em; text-transform: uppercase; color: var(--text-dim);
  border: 1px dashed var(--border-hover); border-radius: var(--radius-pill); padding: var(--space-4) var(--space-8); white-space: nowrap;
}

/* Feature bullet list on preview pages */
.etac-feat-list { margin: 0 0 var(--space-20); padding: 0; list-style: none; display: flex; flex-direction: column; gap: var(--space-9); }
.etac-feat-list li { display: flex; gap: var(--space-10); align-items: flex-start; font: 400 14px/1.5 var(--fsans); color: var(--text-muted); }
.etac-feat-list li::before { content: "•"; color: var(--accent); font-size: 14px; line-height: 1.3; flex-shrink: 0; }
.etac-feat-list b { color: var(--text); font-weight: 650; }

/* CTA row under a hero */
.etac-cta-row { display: flex; gap: var(--space-8); flex-wrap: wrap; align-items: center; }
.etac-cta-row .etac-btn { text-decoration: none; display: inline-flex; align-items: center; }
.etac-cta-row--push { margin-top: auto; }
.etac-grid--mb22 { margin-bottom: var(--space-22); }
.etac-mt10 { margin-top: var(--space-10); }
.etac-mt12 { margin-top: var(--space-12); }
.etac-mb14 { margin-bottom: var(--space-14); }
.etac-inline-form { display: flex; gap: var(--space-8); flex-wrap: wrap; }
.etac-input--flex { flex: 1; min-width: 160px; }
.etac-input--code { flex: 1; min-width: 120px; text-transform: uppercase; font-family: var(--fmono); }
.etac-ml-auto { margin-left: auto; }
.etac-list-reset { list-style: none; margin: 0; padding: 0; }
.etac-ring-stack { display: flex; flex-direction: column; align-items: center; }

/* Mastery Map: readiness ring + per-unit bars */
.mas-row { display: flex; flex-wrap: wrap; gap: var(--space-14); align-items: stretch; }
.mas-readiness { flex: 1 1 230px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--space-12); text-align: center; }
.mas-readiness__title { font: 700 14px var(--fsans); color: var(--text); }
.mas-units { flex: 2 1 320px; min-width: 0; }
.etac-unit-list { display: flex; flex-direction: column; gap: var(--space-14); }

/* Mascot prop badge pinned to the hero art ring (caveman charm) */
.etac-feat-prop {
  position: absolute; bottom: -8px; right: -8px; font-size: 30px; line-height: 1;
  filter: drop-shadow(0 2px 4px rgba(0, 0, 0, .35));
}

/* ---------- Page header (compact, replaces the big hero on inner pages) ---------- */
.etac-pagehead { display: flex; align-items: center; gap: var(--space-14); margin: var(--space-4) var(--space-2) var(--space-18); flex-wrap: wrap; }
.etac-pagehead__ico {
  width: 46px; height: 46px; border-radius: 13px; flex-shrink: 0;
  display: flex; align-items: center; justify-content: center; font-size: 23px; line-height: 1;
  background: var(--accent-soft); border: 1px solid color-mix(in oklab, var(--accent) 25%, transparent);
}
/* Pagehead boxes hold the hand-drawn feature icons (masked .etac-ico spans,
   same trick as the home tiles) — a touch bigger than the 24px tile size. */
.etac-pagehead__ico .etac-ico { width: 27px; height: 27px; }
.etac-pagehead__ico--spark { background: linear-gradient(160deg, var(--spark-1), var(--spark-2)); border-color: var(--spark-bd); }
.etac-pagehead .caveman-svg { width: 54px; margin: 0; flex-shrink: 0; }
.etac-pagehead__txt { min-width: 0; flex: 1; }
.etac-pagehead h1 { margin: 0; font: 700 clamp(23px, 4vw, 29px) var(--fdisplay); text-align: left; text-wrap: balance; }
.etac-pagehead .sub { margin: 3px 0 0; font: 500 13px/1.4 var(--fsans); color: var(--text-muted); }
.etac-pagehead .etac-soon-tag { margin-left: 0; }

/* ---------- Coming-soon notice strip (slim, replaces full marketing heroes) ---------- */
.etac-notice {
  display: flex; align-items: center; gap: var(--space-10); padding: var(--space-10) var(--space-14); margin-bottom: var(--space-16);
  border: 1px dashed color-mix(in oklab, var(--accent) 40%, transparent); border-radius: var(--radius-lg);
  background: var(--accent-soft); font: 500 13px/1.45 var(--fsans); color: var(--text-muted);
}
.etac-notice .ico { font-size: 17px; line-height: 1; flex-shrink: 0; }
.etac-notice b { color: var(--text); }

/* ---------- XL progress ring (readiness score, timers) ---------- */
.etac-ring--xl { width: 112px; height: 112px; padding: var(--space-6); }
.etac-ring--xl .etac-ring__val { font: 800 24px/1 var(--fsans); color: var(--text); font-variant-numeric: tabular-nums; }
.etac-ring--xl .etac-ring__lab { font: 700 11.5px/1 var(--fsans); letter-spacing: 0.079em; text-transform: uppercase; color: var(--text-dim); margin-top: var(--space-4); }

/* ---------- Unit progress rows (mastery map, profile) ---------- */
.etac-unit { display: grid; grid-template-columns: 1fr auto; gap: var(--space-5) var(--space-10); align-items: center; }
.etac-unit__name { font: 600 13px var(--fsans); color: var(--text-muted); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.etac-unit__side { display: flex; align-items: center; gap: var(--space-10); }
.etac-unit__pct { font: 700 11px var(--fsans); color: var(--text-dim); font-variant-numeric: tabular-nums; }
/* F15: the link text alone was 14px tall — under the 24px AA floor. Unlike
   .etac-back, this sits in a dense repeating row (etac-unit-list), so the
   padding-in/margin-out trick isn't available without eating the row's own
   gap — real vertical padding here, so each row grows a few px taller. */
.etac-unit__go { display: inline-flex; align-items: center; padding: 6px 0; font: 700 11px var(--fmono); color: var(--accent); text-decoration: none; white-space: nowrap; border-bottom: 1px dotted transparent; }
.etac-unit__go:hover { border-bottom-color: var(--accent); }
.etac-unit__go:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
.etac-unit .etac-meter { grid-column: 1 / -1; }
/* Evidence subline under a bar ("12 answered · 75% right · last Jul 3") —
   only the Mastery Map's rows carry it; profile rows skip the element. */
.etac-unit__note { grid-column: 1 / -1; margin-top: -1px; font: 500 12.5px var(--fsans); color: var(--text-dim); }
/* PREMIUM_GATING_ENABLED only — hidden by default in the HTML; hud.js fills
   text and unhides only when the flag is on. Accent color distinguishes it
   from the plain evidence line above it. */
.etac-unit__note--gate { color: var(--accent); }

/* ---------- Empty state with a real action (F14) -------------------------
   The copy in most empty states already names the next action ("No friends
   yet — search above to add some") but used to render as bare text — the
   student had to notice the hint and go find the control themselves. This
   pairs the same illustration icon (assets/icons/empty-explainer.png, the
   one place it was already used) with the existing headline and a real
   button that performs the action in place — focuses the field, doesn't
   navigate away. Same dashed-card language as .fr-empty, so a bare-text
   empty state and an actionable one still read as the same family. */
.etac-empty {
  border: 1px dashed var(--border-hover); border-radius: var(--radius-lg); padding: 22px var(--space-16);
  text-align: center; background: color-mix(in oklab, var(--bg-input) 60%, transparent);
  display: flex; flex-direction: column; align-items: center; gap: var(--space-8);
}
.etac-empty__ico { width: 30px; height: 30px; opacity: .75; }
.etac-empty__text { margin: 0; color: var(--text-dim); font: 500 12px/1.5 var(--fsans); max-width: 34ch; }
.etac-empty__btn { margin-top: var(--space-4); }

/* ---------- Skeletons (F13) ---------------------------------------------
   The CLS fix: every async surface used to render "Loading…" — short text
   at a small font — then swap in taller real content, shifting everything
   below it. A skeleton matches the real content's dimensions up front so
   the swap changes pixels, not layout. Set --skel-w/--skel-h/--skel-r per
   use (defaults read as a line of body text). */
.etac-skel {
  display: block; width: var(--skel-w, 100%); height: var(--skel-h, 13px);
  border-radius: var(--skel-r, 6px); flex-shrink: 0;
  background: linear-gradient(90deg, var(--bg-elevated) 25%, var(--border) 37%, var(--bg-elevated) 63%);
  background-size: 400% 100%;
  animation: etac-skel-shimmer 1.4s ease-in-out infinite;
}
@keyframes etac-skel-shimmer {
  0% { background-position: 100% 50%; }
  100% { background-position: 0 50%; }
}
@media (prefers-reduced-motion: reduce) {
  .etac-skel { animation: none; background: var(--bg-elevated); }
}
/* One skeleton row, same box model as .etac-row, so a list of these takes
   the exact height the real rows will take once they land. */
.etac-row.is-skel { pointer-events: none; }
.etac-row.is-skel .etac-row__rank.etac-skel { --skel-w: 20px; --skel-h: 13px; }
.etac-row.is-skel .etac-row__main.etac-skel { --skel-w: 46%; --skel-h: 13px; }
.etac-row.is-skel .etac-row__side.etac-skel { --skel-w: 64px; --skel-h: 12px; margin-left: auto; }

/* ---------- Stacked list rows (leaderboards, ledgers, run history) ---------- */
.etac-rows { display: flex; flex-direction: column; gap: 1px; border: 1px solid var(--border); border-radius: 11px; overflow: hidden; }
.etac-row { display: flex; align-items: center; gap: var(--space-12); padding: var(--space-12) var(--space-14); background: var(--bg-input); }
.etac-row__rank { font: 800 13px var(--fsans); color: var(--text-dim); width: 20px; flex-shrink: 0; font-variant-numeric: tabular-nums; }
.etac-row__rank.gold { color: var(--spark-2); }
.etac-row__main { flex: 1; min-width: 0; font: 600 13px var(--fsans); color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.etac-row__main.you { color: var(--accent); }
.etac-row__side { font: 700 12px var(--fsans); color: var(--text-muted); white-space: nowrap; font-variant-numeric: tabular-nums; }

/* ---------- Filter pills (store categories etc. — disabled until wired) ---------- */
.etac-pills { display: flex; gap: var(--space-6); flex-wrap: wrap; }
.etac-pills button {
  font: 600 12px var(--fsans); color: var(--text-muted); background: var(--bg-elevated);
  border: 1px solid var(--border); border-radius: var(--radius-pill); padding: var(--space-7) 13px; cursor: pointer;
}
.etac-pills button[aria-pressed="true"] { color: var(--accent); background: var(--accent-soft); border-color: color-mix(in oklab, var(--accent) 45%, transparent); font-weight: 700; }
.etac-pills button:disabled { cursor: not-allowed; opacity: .75; }
.etac-pills button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* ===================== CONTENT PAGES (patch notes / about) ===================== */

/* Patch-notes timeline: accent node + rail down the left edge */
.pn-timeline { display: flex; flex-direction: column; gap: var(--space-14); }
.pn-item { position: relative; padding-left: var(--space-32); }
.pn-item::before {
  content: ""; position: absolute; left: 9px; top: 34px; bottom: -16px; width: 2px;
  background: var(--border);
}
.pn-item:last-child::before { display: none; }
.pn-item::after {
  content: ""; position: absolute; left: 3px; top: 20px; width: 8px; height: 8px; border-radius: 50%;
  background: var(--bg-elevated); border: 3px solid var(--accent); box-shadow: 0 0 0 3px var(--accent-soft);
}
.pn-card { position: relative; overflow: hidden; }
.pn-card--latest { border-color: color-mix(in oklab, var(--accent) 45%, transparent); }
.pn-card--latest::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; background: var(--grad); }
.pn-head { display: flex; align-items: center; gap: var(--space-10); flex-wrap: wrap; margin-bottom: var(--space-12); }
.pn-ver {
  font: 800 14px var(--fmono); color: var(--accent); background: var(--accent-soft);
  border: 1px solid color-mix(in oklab, var(--accent) 40%, transparent); border-radius: var(--radius-sm); padding: var(--space-4) var(--space-9);
}
.pn-date { font: 600 12.5px var(--fsans); letter-spacing: .05em; text-transform: uppercase; color: var(--text-dim); }
.pn-latest-tag {
  margin-left: auto; font: 700 11.5px var(--fsans); letter-spacing: 0.072em; text-transform: uppercase;
  color: var(--accent-ink, #fff); background: var(--accent); border-radius: var(--radius-pill); padding: var(--space-4) var(--space-9);
}
.pn-card ul { margin: 0; padding-left: 1.15rem; list-style: disc; font: 400 14px/1.65 var(--fsans); color: var(--text); }
.pn-card li { margin-bottom: .4rem; }
.pn-card li:last-child { margin-bottom: 0; }
.pn-card li strong { color: var(--accent); }
.pn-card code {
  background: var(--bg-input); border: 1px solid var(--border); padding: 1px var(--space-6);
  border-radius: var(--radius-xs); font-size: 12px; font-family: var(--fmono);
}

/* Patch-note editing (patchnotes.js) — owner only, only while "Edit site
   text" is on (body.pn-editing). Mirrors the roadmap editor's controls. */
.pn-empty { list-style: none; font-size: 13px; font-style: italic; color: var(--text-dim); padding-left: var(--space-32); }
.pn-item--add::before, .pn-item--add::after { display: none; }
.pn-add {
  font: 600 13px var(--fsans); padding: var(--space-8) var(--space-14);
  border-radius: var(--radius-sm); border: 1px dashed var(--border);
  background: transparent; color: var(--text-muted); cursor: pointer;
  transition: border-color .15s, color .15s;
}
.pn-add:hover { border-color: var(--accent); color: var(--accent); }
body.pn-editing .pn-card:not(.is-editing) { border-style: dashed; }
.pn-card.is-editing { border-color: var(--accent); }
.pn-ctl {
  display: flex; align-items: center; gap: var(--space-6);
  margin-top: var(--space-12); padding-top: var(--space-10); border-top: 1px dashed var(--border);
}
.pn-ctl__btn {
  font: 600 12px var(--fsans); min-width: 26px; padding: var(--space-4) var(--space-8);
  border-radius: 7px; border: 1px solid var(--border); background: var(--bg-input);
  color: var(--text-muted); cursor: pointer; transition: border-color .15s, color .15s;
}
.pn-ctl__btn:hover { border-color: var(--border-hover); color: var(--text); }
.pn-ctl__btn:focus-visible, .pn-add:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.pn-ctl__btn.is-edit:hover { color: var(--accent); border-color: var(--accent); }
.pn-ctl__btn.is-del:hover { color: var(--warning-fg); border-color: var(--warning-bd); }
.pn-ctl__btn.is-confirm { background: var(--warning-bg); color: var(--warning-fg); border-color: var(--warning-bd); }
.pn-ctl__ask { font-size: 12px; color: var(--text-muted); margin-right: var(--space-2); }
.pn-form { display: flex; flex-direction: column; gap: var(--space-10); }
.pn-form__row { display: flex; gap: var(--space-8); flex-wrap: wrap; }
.pn-form__version, .pn-form__date, .pn-form__points {
  min-width: 0; padding: var(--space-7) 11px; border: 1px solid var(--border);
  background: var(--bg-input); color: var(--text); border-radius: 7px;
  font-family: inherit; outline: none; transition: border-color .15s;
}
.pn-form__version { flex: 1 1 160px; font: 800 14px var(--fmono); }
.pn-form__date { flex: 0 1 auto; font-size: 13px; color-scheme: light dark; }
.pn-form__points { width: 100%; font-size: 13px; line-height: 1.6; resize: vertical; }
.pn-form__version:focus, .pn-form__date:focus, .pn-form__points:focus { border-color: var(--accent); }
.pn-form__hint { margin: 0; font-size: 12px; color: var(--text-dim); }
.pn-form__preview:not(:empty) { padding: var(--space-10) var(--space-12); border-radius: 7px; background: var(--bg); border: 1px solid var(--border); }
.pn-form__actions { display: flex; align-items: center; gap: var(--space-8); }
.pn-form__save { font-size: 13px; padding: var(--space-7) var(--space-16); }
.pn-form__actions .pn-ctl__btn.is-cancel { padding: var(--space-7) var(--space-12); }

/* About page: numbered how-to steps */
.ab-steps { margin: 0; padding: 0; list-style: none; counter-reset: step; display: flex; flex-direction: column; gap: var(--space-12); }
.ab-steps li { counter-increment: step; display: flex; gap: var(--space-12); font: 400 14px/1.55 var(--fsans); color: var(--text); }
.ab-steps li::before {
  content: counter(step); flex-shrink: 0; width: 22px; height: 22px; margin-top: 1px; border-radius: 7px;
  display: flex; align-items: center; justify-content: center;
  background: var(--accent-soft); color: var(--accent); font: 700 12px var(--fmono);
}
.ab-prose { margin: 0; font: 400 14px/1.65 var(--fsans); color: var(--text); }
.ab-prose + .ab-prose { margin-top: .6rem; }
.ab-card--warn { border-color: var(--warning-bd); background: var(--warning-bg); }
.ab-card--warn h3 { color: var(--warning-fg); }
.ab-card--warn p { color: var(--text); }

/* Pronunciation bubble on the creator's name (about.html wraps it in JS).
   The bubble text comes from data-pron via ::after — pure CSS, no extra DOM
   text — with a ::before square rotated into a speech-bubble tail. */
.ab-pron {
  position: relative;
  border-bottom: 1.5px dotted color-mix(in oklab, var(--accent) 60%, transparent);
  cursor: help;
}
.ab-pron::after {
  content: attr(data-pron);
  position: absolute; left: 50%; bottom: calc(100% + 8px);
  transform: translate(-50%, 4px);
  white-space: nowrap;
  font: 650 12px var(--fsans); color: var(--text);
  background: var(--bg-elevated);
  border: 1px solid var(--border); border-radius: var(--radius-md);
  padding: var(--space-5) var(--space-10);
  box-shadow: 0 6px 18px rgba(0, 0, 0, .18);
  opacity: 0; pointer-events: none;
  transition: opacity .15s, transform .15s;
  z-index: 6;
}
.ab-pron::before {
  content: "";
  position: absolute; left: 50%; bottom: calc(100% + 4px);
  width: 8px; height: 8px;
  transform: translate(-50%, 4px) rotate(45deg);
  background: var(--bg-elevated);
  border-right: 1px solid var(--border); border-bottom: 1px solid var(--border);
  opacity: 0; pointer-events: none;
  transition: opacity .15s, transform .15s;
  z-index: 7;
}
.ab-pron:hover::after, .ab-pron:focus-visible::after { opacity: 1; transform: translate(-50%, 0); }
.ab-pron:hover::before, .ab-pron:focus-visible::before { opacity: 1; transform: translate(-50%, 0) rotate(45deg); }

/* ---------- Full-bleed HUD pages ----------
   style.css gives <body> flex centering + page padding; body.has-hud strips
   the padding so the HUD runs edge-to-edge on EVERY app page (hub, profile,
   feature previews, explain). explain.html's .container gets its padding
   back below, and its fixed usage counter drops beneath the HUD. */
body.has-hud { padding: 0; }
body.has-hud .container { padding: 1rem 1rem 6rem; }
body.has-hud .usage-counter { top: 4.9rem; }
@media (max-width: 560px) {
  body.has-hud .container { padding: 1.25rem 0.75rem 5rem; }
  body.has-hud .usage-counter { top: 4.6rem; }
}

/* ---------- Motion preference ---------- */
@media (prefers-reduced-motion: reduce) {
  .etac-app *, .etac-app *::before, .etac-app *::after,
  .etac-hud-host *, .etac-hud-host *::before, .etac-hud-host *::after {
    animation-duration: .001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .001ms !important;
  }
  /* The preview pill mounts on <body>, outside the scopes above. (The wipe
     itself is already skipped in store.js for reduced-motion users.) */
  .st-preview-pill { transition: none; }
}

/* ---- Owner tester mode bar (hud.js paintTesterBar) -------------------------
   Sits above the signup walkthrough (z-index 200) so its Exit is always
   reachable — onboarding.js leaves it out of the inert sweep. */
.etac-testerbar {
  position: fixed; left: var(--space-16); bottom: var(--space-16); z-index: 210;
  max-width: calc(100vw - 2 * var(--space-16));
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-8);
  padding: var(--space-8) var(--space-8) var(--space-8) var(--space-14);
  background: var(--bg-elevated); color: var(--text);
  border: 1px solid var(--warning-bd); border-radius: var(--radius-pill);
  box-shadow: 0 6px 24px rgba(0, 0, 0, .35);
  font: 13px/1.3 var(--fsans);
}
.etac-testerbar__txt { color: var(--text-muted); }
.etac-testerbar__txt b { color: var(--warning-fg); font-weight: 700; }
.etac-testerbar__btn {
  font: inherit; font-weight: 600; cursor: pointer;
  padding: var(--space-4) var(--space-12);
  background: transparent; color: var(--text);
  border: 1px solid var(--border); border-radius: var(--radius-pill);
}
.etac-testerbar__btn:hover { border-color: var(--border-hover); }
.etac-testerbar__btn--exit { border-color: var(--warning-bd); color: var(--warning-fg); }
.etac-testerbar__btn:disabled { opacity: .5; cursor: default; }
.etac-testerbar__btn[hidden] { display: none; }
.etac-testerbar__sparks { display: inline-flex; align-items: center; gap: var(--space-4); }
.etac-testerbar__num {
  width: 76px; font: inherit; padding: var(--space-4) var(--space-8);
  background: var(--bg-input); color: var(--text);
  border: 1px solid var(--border); border-radius: var(--radius-pill);
}
@media (max-width: 520px) {
  /* Full width here, so lift it clear of the centered .version-badge nav. */
  .etac-testerbar { border-radius: var(--radius-lg); right: var(--space-16); bottom: 72px; }
}

/* Class organizer spotlight (js/motion.js spotlight()) — a soft accent wash
   that flashes over one row. Opacity + transform only; removed when done. */
.etac-motion-spot { position: absolute; inset: 0; border-radius: inherit; pointer-events: none; background: color-mix(in oklab, var(--accent) 24%, transparent); }
