/* =========================================================================
   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. */
:root {
  --spark-1: #ffe79a;
  --spark-2: #f4b63a;
  --spark-bd: #d89a1c;
  --spark-ink: #3a2606;
  --fsans: -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-in: cubic-bezier(.22, 1, .36, 1);
  --ease-out: cubic-bezier(.4, 0, 1, 1);
}

@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 {
  position: sticky; top: 0; z-index: 30;
  display: flex; align-items: center;
  gap: clamp(10px, 2.4cqi, 20px);
  padding: 10px 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: 11px; 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%; }
.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);
}
.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 10px/1.2 var(--fmono); 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: 5px; width: clamp(150px, 20cqi, 210px); }
.etac-xp__top { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.etac-xp__k { font: 700 9.5px/1 var(--fmono); letter-spacing: .13em; color: var(--text-muted); }
.etac-xp__v { font: 600 11px/1 var(--fmono); color: var(--text); font-variant-numeric: tabular-nums; }
.etac-xp__track { height: 8px; border-radius: 99px; background: var(--bg-input); border: 1px solid var(--border); overflow: hidden; }
.etac-xp__fill { height: 100%; border-radius: 99px; background: var(--grad); transition: width .35s cubic-bezier(.2, .8, .2, 1); }

/* Sparks — gold coin chip (spendable wallet) */
.etac-coin-wrap { display: flex; align-items: center; gap: 7px; }
.etac-coin {
  display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px 6px 8px; border-radius: 99px;
  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__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 9.5px/1 var(--fmono); letter-spacing: .1em; color: var(--text-dim); text-transform: uppercase; }

/* Streak — ember flame counter */
.etac-streak {
  display: inline-flex; align-items: center; gap: 7px; padding: 6px 12px; border-radius: 99px;
  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; }

/* Right cluster — the account/menu button */
.etac-hud__right { display: flex; align-items: center; gap: 10px; }
.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: 14px;
  box-shadow: 0 18px 50px rgba(0, 0, 0, .4);
  padding: 12px; display: flex; flex-direction: column; gap: 10px;
  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: 10px; }
.etac-panel__title { font: 600 13px var(--fsans); color: var(--text); }
.etac-panel__sep { height: 1px; background: var(--border); margin: 2px 0; }
.etac-panel__lab { font: 700 9px/1 var(--fmono); letter-spacing: .1em; text-transform: uppercase; color: var(--text-dim); }
.etac-panel__link {
  display: flex; align-items: center; gap: 8px; width: 100%; text-align: left;
  background: none; border: 0; border-radius: 9px; padding: 9px;
  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: 2px; }
.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 4px; border-radius: 99px;
  background: #ef4444; color: #fff;
  font: 800 9.5px/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: 14px;
  box-shadow: 0 18px 50px rgba(0, 0, 0, .4);
  padding: 12px; display: flex; flex-direction: column; gap: 8px;
  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: 2px 2px 0; }
.etac-inbox__list { display: flex; flex-direction: column; gap: 4px; }
.etac-inbox__empty { font: 500 12.5px var(--fsans); color: var(--text-dim); padding: 6px 2px; }
.etac-inbox__labrow { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.etac-inbox__clear {
  font: 700 9px/1 var(--fmono); letter-spacing: .06em; text-transform: uppercase;
  color: var(--text-dim); background: none; border: 0; cursor: pointer; padding: 2px;
  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: 9px;
  padding: 7px 8px; border-radius: 10px;
  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: 2px; }
.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);
}
/* 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__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: 5px; 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: 8px;
  padding: 5px 9px; 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: 7px 8px; border-radius: 10px; 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: 8px; }
.etac-inbox__news-right { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.etac-inbox__kind { font: 700 9px/1 var(--fmono); letter-spacing: .08em; text-transform: uppercase; color: var(--accent); }
.etac-inbox__when { font: 600 10.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: 18px;
  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: 20px; display: flex; flex-direction: column; gap: 12px;
  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: 10px; }
.etac-modal__emoji { font-size: 26px; line-height: 1; }
.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: 10px; }
.etac-modal__item {
  display: flex; flex-direction: column; gap: 4px;
  padding: 11px 12px; border-radius: 12px;
  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: 10px; }
.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: 10px;
  padding: 9px 18px; 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: 8px; 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: 10px;
  padding: 9px 18px; 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: 16px; padding-top: 14px; border-top: 1px dashed var(--border);
}
.etac-nudge__head {
  display: flex; align-items: center; gap: 7px;
  font: 700 12px var(--fsans); color: var(--text-muted); margin-bottom: 9px;
}
.etac-nudge__ico { font-size: 15px; line-height: 1; }
.etac-nudge__list { display: flex; flex-direction: column; gap: 6px; }
.etac-nudge__row {
  display: flex; align-items: center; gap: 9px;
  padding: 6px 8px; border-radius: 10px; 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: 8px;
  padding: 6px 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: 2px;
  background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 12px;
  box-shadow: 0 10px 30px rgba(0, 0, 0, .35);
  padding: 4px 6px 4px 4px;
  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: 5px 6px; border-radius: 8px;
}
.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); }

@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: 8px;
  margin-top: 12px; padding: 9px 10px 9px 12px;
  background: var(--bg-input); border: 1px solid var(--border); border-radius: 10px;
  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; }
}

/* Theme picker inside the panel — four elemental discs */
.etac-themes { display: flex; align-items: center; gap: 6px; 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 button:hover { opacity: 1; transform: scale(1.08); }
.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: 9px; }
  .etac-stats { gap: 8px; }
}

/* ===================== 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(--fmono); letter-spacing: .16em; 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); box-shadow: 0 10px 34px rgba(0, 0, 0, .18);
}
.etac-daily::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; background: var(--grad); }
.etac-daily::after {
  content: ""; position: absolute; right: -80px; top: -80px; width: 240px; height: 240px; border-radius: 50%;
  background: radial-gradient(circle, var(--accent-soft), transparent 70%); pointer-events: none;
}
.etac-daily__pad { padding: clamp(20px, 3cqi, 30px); position: relative; }
.etac-daily__head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; }
.etac-chip-unit {
  font: 600 11px/1 var(--fmono); letter-spacing: .04em; color: var(--text-muted);
  background: var(--bg-input); border: 1px solid var(--border); padding: 5px 10px; border-radius: 99px;
}
.etac-soon-tag {
  margin-left: auto; font: 700 9.5px/1 var(--fmono); letter-spacing: .1em; text-transform: uppercase;
  color: var(--text-muted); background: var(--bg-input); border: 1px solid var(--border-hover);
  border-radius: 99px; padding: 6px 11px;
}
.etac-daily__body { display: flex; gap: 26px; 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) sits
   under the sitewide `h1{ background:var(--grad); ...clip:text }` rule
   used by every .etac-pagehead — reset it here so only the .g span (not
   the whole heading) picks up the gradient, matching this card's own look. */
.etac-daily h1 { margin: 0 0 8px; font: 700 clamp(24px, 3.4cqi, 32px)/1.1 var(--fsans); letter-spacing: -.01em; color: var(--text);
  background: none; -webkit-background-clip: border-box; background-clip: border-box; -webkit-text-fill-color: currentColor; }
.etac-daily h1 .g { background: var(--grad); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
.etac-daily__sub { margin: 0 0 16px; font: 400 15px/1.55 var(--fsans); color: var(--text-muted); max-width: 46ch; }
.etac-streaknudge {
  display: inline-flex; align-items: center; gap: 9px; padding: 8px 13px; border-radius: 12px;
  background: var(--accent-soft); border: 1px solid color-mix(in oklab, var(--accent) 35%, transparent); margin-bottom: 18px;
}
.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: 8px; flex-wrap: wrap; margin-bottom: 20px; }
.etac-reward { display: inline-flex; align-items: center; gap: 6px; font: 700 12px var(--fmono); padding: 6px 11px; border-radius: 99px; }
.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: 12px; padding: 13px 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: #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-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: 5px;
  background: conic-gradient(from -90deg, var(--accent) calc(var(--pct, 0) * 3.6deg), var(--border) 0); }
/* 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: 14px; }
.etac-daily__art .num { font: 800 28px/1 var(--fsans); color: var(--text); }
.etac-daily__art .lab { font: 700 8.5px/1.3 var(--fmono); letter-spacing: .1em; 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: 4px; font-size: 8px; letter-spacing: .06em; }

/* ---------- Daily MCQ (rendered by daily.js into #daily-root) ---------- */
.etac-q__stem { margin: 0 0 14px; font: 400 15px/1.6 var(--fsans); color: var(--text); max-width: 62ch; }
.etac-q__choices { display: grid; gap: 8px; margin-bottom: 16px; max-width: 62ch; }
.etac-q__choice {
  display: flex; align-items: baseline; gap: 11px; 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: 12px; padding: 11px 13px;
  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: #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: 9px; flex-wrap: wrap;
  border-radius: 12px; padding: 11px 14px; margin-bottom: 12px; 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; }
.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-left: 3px solid var(--accent);
  border-radius: 10px; padding: 11px 14px; margin-bottom: 12px;
}
.etac-q__why b { color: var(--text); }
.etac-q__note { margin: 0 0 14px; font: 500 13px/1.5 var(--fsans); color: var(--text-muted); }
.etac-q__note.is-error { color: var(--warning-fg); margin-top: 10px; }

/* 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: 12px; }
@media (prefers-reduced-motion: reduce) {
  .etac-q__verdict.pop, .etac-nudge { animation: none; }
}

/* ---------- 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: 7px; margin-bottom: 14px; }
.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: 10px; margin-top: 4px; }
.etac-practice__review {
  max-width: 62ch; padding-top: 16px; margin-top: 16px; border-top: 1px solid var(--border);
}
.etac-practice__review:first-of-type { border-top: none; margin-top: 4px; 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: 18px; 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 18px;
  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 10px;
  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 6px; font: 800 20px var(--fsans); color: var(--text); }
.etac-goalmodal__card h3 .g { background: var(--grad); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
.etac-goalmodal__sub { margin: 0 0 16px; font: 400 13.5px/1.55 var(--fsans); color: var(--text-muted); }
.etac-goalmodal__opts { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin-bottom: 12px; }
/* --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: 5px; justify-items: center; padding: 13px 8px 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 10.5px var(--fmono); color: var(--text-muted); }
.etac-goalmodal__hint { margin: 2px 0 0; font: 700 9.5px var(--fmono); letter-spacing: .1em; 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 friends.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: 18px; 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 18px;
  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 4px; font: 800 19px var(--fsans); color: var(--text); }
.etac-namemodal__avatar { display: flex; align-items: center; gap: 12px; margin-top: 16px; }
.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: 7px; }
.etac-namemodal__avactions .etac-btn { font-size: 11.5px; padding: 6px 11px; }
.etac-namemodal__field { margin-top: 14px; }
.etac-namemodal__field label {
  display: block; margin-bottom: 5px; font: 700 10.5px var(--fmono);
  letter-spacing: .06em; text-transform: uppercase; color: var(--text-dim);
}
.etac-namemodal__hint { margin: 5px 0 0; font: 500 11.5px/1.5 var(--fsans); color: var(--text-muted); }
.etac-namemodal__error {
  margin: 10px 0 0; padding: 8px 10px; 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: 9px; margin-top: 18px; }
.etac-namemodal__source { margin: 4px 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: 8px 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: 56px; }
.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-in), transform .42s var(--ease-in);
}
.etac-onboard__card.is-swap-out {
  opacity: 0; transform: translateY(-10px);
  transition: opacity .2s var(--ease-out), transform .2s var(--ease-out);
}
.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: 10px; margin-bottom: 20px; }
.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 9px; font: 700 13px var(--fmono);
  letter-spacing: .06em; text-transform: uppercase; color: var(--text-dim);
}
.etac-onboard__title { margin: 0 0 10px; font: 800 clamp(26px, 4.2vw, 38px)/1.2 var(--fsans); color: var(--text); }
.etac-onboard__title--grad {
  background: var(--grad);
  -webkit-background-clip: text;
          background-clip: text;
  -webkit-text-fill-color: transparent;
}
.etac-onboard__sub { margin: 0 0 20px; font: 500 17px/1.55 var(--fsans); color: var(--text-muted); }
.etac-onboard__body { margin-top: 2px; }
.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: 22px; }
.etac-onboard__card .etac-namemodal__field label { margin-bottom: 8px; font-size: 12.5px; }
.etac-onboard__card .etac-namemodal__hint { margin-top: 8px; font-size: 14px; }
.etac-onboard__card input[type="text"] {
  width: 100%; padding: 14px 16px; font-size: 19px; border-radius: 11px;
}
.etac-onboard__card .etac-btn { font-size: 17px; padding: 15px 28px; border-radius: 14px; }

/* 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: 22px 26px; 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 8px; font: 700 17px var(--fsans); color: var(--text);
}
.etac-onboard__doc h3:first-child { margin-top: 0; }
.etac-onboard__doc p { margin: 0 0 11px; }
.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 7px; font: 700 14px var(--fsans); color: var(--text); }
.lg-doc [data-lg-body] > :first-child { margin-top: 0; }
.lg-doc p { margin: 0 0 10px; }
.lg-doc a { color: var(--accent); }

/* The scroll gate's status line, sitting between pane and button. */
.etac-onboard__gate {
  margin: 14px 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: 22px; padding: 18px; 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: 12px;
  margin-bottom: 13px; font: 700 12.5px var(--fmono);
  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-in); }
@media (prefers-reduced-motion: reduce) {
  .etac-onboard__reroll.is-spinning { animation: none; }
}
.etac-onboard__chips { display: flex; flex-wrap: wrap; gap: 10px; }
.etac-onboard__chip {
  font: 600 16px var(--fmono); color: var(--text);
  background: var(--bg-input); border: 1px solid var(--border);
  border-radius: 999px; padding: 10px 18px; 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-in) 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: 18px; }
.etac-onboard__error {
  margin: 16px 0 0; padding: 12px 14px; 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: 12px; margin-top: 26px;
}
/* :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 15px; }
  .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: 16px 18px; }
  .etac-onboard__actions .etac-btn { flex: 1; }
  .etac-onboard__card .etac-btn { font-size: 15px; padding: 13px 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: 8px 0 18px; }
.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: 26px 0 24px; padding: 32px 0; display: grid; gap: 10px; }
.etac-onboard__ranks li {
  position: relative; z-index: 0;
  display: flex; align-items: center; justify-content: space-between; gap: 16px;
  padding: 14px 18px;
  border-radius: 14px; background: var(--bg-elevated); border: 1px solid var(--border);
  font: 700 18px var(--fsans); color: var(--text-muted);
  transition: opacity .2s var(--ease-out), box-shadow .2s var(--ease-in);
}
.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 14px; 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 20px; display: grid; gap: 12px; }
.etac-onboard__summary-row {
  margin: 0; display: flex; align-items: center; justify-content: center;
  gap: 11px; 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: 20px 56px; 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-in), opacity .55s var(--ease-in);
}
.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: 26px 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: 14px 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-in) 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 cubic-bezier(.4, 0, 1, 1) 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 cubic-bezier(.4, 0, 1, 1) 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: 10px;
  opacity: 0;
  animation: etac-rankring-hero-in .6s var(--ease-in) .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: 16px; margin-bottom: 4px; }
.etac-onboard__cave {
  display: grid; gap: 7px; justify-items: center; padding: 22px 12px 18px;
  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-in) 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: 16px; margin: 8px 0 10px; }
.etac-onboard__opt {
  position: relative; overflow: hidden;
  display: grid; gap: 8px; justify-items: center; padding: 24px 14px 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: 5px; }
.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-out), transform .2s var(--ease-out);
  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-in) 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: 14px; margin: 18px auto; padding: 8px 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: 8px;
}
.etac-rank-hero__standing {
  justify-self: end; display: flex; flex-direction: column; align-items: flex-end; gap: 2px;
}
.etac-rank-hero__standing-label {
  font: 700 9px var(--fmono); letter-spacing: .08em; 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: 10px; 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: 99px;
  background: var(--bg-input); border: 1px solid var(--border); overflow: hidden;
}
.etac-rank-hero__fill { display: block; position: relative; height: 100%; overflow: hidden; background: var(--grad); border-radius: 99px; }
.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: 6px;
  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: 20px; 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 cubic-bezier(.22, 1, .36, 1);
}
.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 cubic-bezier(.4, 0, 1, 1); }
/* 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 cubic-bezier(.22, 1, .36, 1);
}
.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 cubic-bezier(.22, 1, .36, 1), 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 cubic-bezier(.4, 0, 1, 1), 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 cubic-bezier(.4, 0, 1, 1), 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 cubic-bezier(.22, 1, .36, 1);
}
/* 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 cubic-bezier(.22, 1, .36, 1); }

/* 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 cubic-bezier(.22, 1, .36, 1);
}

/* 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 cubic-bezier(.22, 1, .36, 1) 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 cubic-bezier(.22, 1, .36, 1);
}
.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 cubic-bezier(.4, 0, 1, 1) 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 20px; font: 800 13px var(--fmono); letter-spacing: .34em; text-transform: uppercase;
  color: color-mix(in srgb, var(--tier) 42%, #fff);
}
.etac-rankup__name {
  margin: 6px 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: 12px 0 0; padding: 4px 13px; border-radius: 99px;
  font: 700 10px var(--fmono); letter-spacing: .12em; 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 cubic-bezier(.22, 1, .36, 1) .14s;
}
.etac-rankup__sub {
  margin: 12px 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 cubic-bezier(.22, 1, .36, 1) .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: 14px 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 cubic-bezier(.22, 1, .36, 1) .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: 12px 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 cubic-bezier(.22, 1, .36, 1),
              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 cubic-bezier(.22, 1, .36, 1) 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 10.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 9px; border-radius: 8px;
  background: var(--title-plate);
  border: 1px solid var(--title-plate-bd);
}
.etac-title--vert { --title-axis: 180deg; }
.etac-title--inline { margin-left: 7px; }
.etac-title--lg { font-size: 13.5px; letter-spacing: .05em; padding: 5px 12px; border-radius: 10px; }
/* 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: 9px 19px; border-radius: 12px; }
.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: 6px;
  padding: 10px 6px; border-radius: 12px; 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: 4px; 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: 8px; 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 9.5px 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-out), transform .38s var(--ease-out);
}
/* 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-out), transform .36s var(--ease-out);
}
.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: 16px;
  opacity: 0; transform: scale(.94); pointer-events: none;
  transition: opacity .42s var(--ease-in), transform .5s var(--ease-in);
}
.etac-rankup.is-reward-stage .etac-rankup__rewards { opacity: 1; transform: none; pointer-events: auto; }
.etac-rankup__rewards-kicker {
  font: 800 12px var(--fmono); letter-spacing: .28em; 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: 14px;
  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: 10px;
  padding: 19px 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-in), transform .6s var(--ease-in);
}
/* 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-out);
}
.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: 6px 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 8.5px var(--fmono); letter-spacing: .1em; 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: 5px; padding: 5px 13px; border-radius: 6px; cursor: pointer;
  font: 700 9.5px var(--fmono); letter-spacing: .08em; 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: 8px; opacity: 0; transform: translateY(8px);
  transition: opacity .4s var(--ease-in) .3s, transform .48s var(--ease-in) .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: 4px 11px; border-radius: 99px;
}
.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: 22px;
  transform: translateX(-50%); pointer-events: none;
}
.etac-rankup__mini-icon, .etac-rankup__mini-name {
  opacity: 0; transition: opacity .3s var(--ease-out);
}
.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; }
}

/* ---------- 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-in) 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-in) 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-in) 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-in) 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-in) 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)); }
}
/* 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;
  transition: opacity .78s var(--ease-in);
}
html[data-launching="lift"]::after { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
  .etac-motion-mote, .etac-motion-haze, .etac-motion-star,
  .etac-motion-ring, .etac-motion-confetti { animation: none; display: none; }
  .etac-motion-fade { transition: none; }
  html[data-launching]::after { transition: none; }
}

/* ---------- Section heading ---------- */
.etac-sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 34px 2px 14px; }
.etac-sec-head h2 { margin: 0; font: 700 16px var(--fsans); 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: 14px; }
.etac-tile {
  position: relative; display: flex; flex-direction: column; gap: 9px; padding: 18px; border-radius: 15px;
  background: var(--bg-elevated); border: 1px solid var(--border); text-decoration: none; color: var(--text);
  transition: transform .14s, border-color .14s, box-shadow .14s; overflow: hidden;
}
.etac-tile:hover { transform: translateY(-3px); border-color: var(--border-hover); box-shadow: 0 12px 30px rgba(0, 0, 0, .2); }
.etac-tile:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.etac-tile__ico {
  width: 42px; height: 42px; border-radius: 12px; 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); }
.etac-tile__t { font: 650 15.5px var(--fsans); color: var(--text); display: flex; align-items: center; gap: 8px; }
.etac-tile__d { font: 400 13px/1.5 var(--fsans); color: var(--text-muted); margin: 0; }
.etac-tile__go { margin-top: auto; font: 700 12px var(--fmono); color: var(--accent); opacity: 0; transform: translateX(-4px); transition: opacity .15s, transform .15s; }
.etac-tile:hover .etac-tile__go, .etac-tile:focus-visible .etac-tile__go { opacity: 1; transform: none; }
.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: 6px; }
.etac-tile__soon { display: none; }
.etac-tile.is-soon .etac-tile__soon {
  display: inline-flex; align-items: center; gap: 6px; align-self: flex-start; margin-top: auto;
  position: relative; z-index: 2; font: 700 9.5px/1 var(--fmono); letter-spacing: .1em; text-transform: uppercase;
  color: var(--text-muted); background: var(--bg-input); border: 1px solid var(--border-hover); border-radius: 99px; padding: 6px 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); }

/* ---------- 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: 7px; margin-bottom: 4px; }
.st-swatches > * {
  width: 34px; height: 34px; border-radius: 10px; 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 { transform: translateY(-2px) scale(1.06); }
.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-prevhint { margin: 0 0 4px; font: 500 11px var(--fmono); color: var(--text-dim); }

/* 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: 10px; padding: 7px 8px 7px 15px;
  font: 550 13px var(--fsans); color: var(--text-muted);
  background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 99px;
  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: 99px; padding: 5px 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: 99px;
  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 4px; 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 9px var(--fmono); letter-spacing: .12em; text-transform: uppercase;
  color: var(--text-muted); background: var(--bg-input);
  border: 1px solid var(--border-hover); border-radius: 99px; padding: 5px 10px;
}
/* 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: 40px; padding-top: 18px; border-top: 1px solid var(--border); text-align: center; }
.etac-foot__nav { display: flex; gap: 7px; justify-content: center; align-items: center; flex-wrap: wrap; font: 500 13px var(--fmono); }
.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: 12px 0 0; font: 500 12px var(--fmono); color: var(--text-dim); }
/* 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; }

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

/* Back-to-home link above page content */
.etac-back {
  display: inline-flex; align-items: center; gap: 7px; margin-bottom: 14px;
  font: 600 12px var(--fmono); letter-spacing: .04em; 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: 18px; border: 1px solid var(--border); border-radius: 16px; background: var(--bg-elevated); }
.etac-card__head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 13px; flex-wrap: wrap; }
.etac-card__head h2, .etac-card__head h3 { margin: 0; font: 700 15px var(--fsans); color: var(--text); }
.etac-card__hint { font: 600 11px var(--fmono); color: var(--text-dim); }

/* Generic themed meter (mastery bars etc.) — width set inline on the span */
.etac-meter { height: 8px; border-radius: 99px; background: var(--bg-input); border: 1px solid var(--border); overflow: hidden; }
.etac-meter > span { display: block; height: 100%; background: var(--grad); border-radius: 99px; }

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

/* Feature bullet list on preview pages */
.etac-feat-list { margin: 0 0 20px; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 9px; }
.etac-feat-list li { display: flex; gap: 10px; 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: 12px; margin-top: 2px; flex-shrink: 0; }
.etac-feat-list b { color: var(--text); font-weight: 650; }

/* CTA row under a hero */
.etac-cta-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.etac-cta-row .etac-btn { text-decoration: none; display: inline-flex; align-items: center; }

/* 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: 14px; margin: 4px 2px 18px; 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 .caveman-svg { width: 54px; margin: 0; flex-shrink: 0; }
.etac-pagehead__txt { min-width: 0; flex: 1; }
.etac-pagehead h1 { margin: 0; font-size: clamp(23px, 4vw, 29px); text-align: left; }
.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: 10px; padding: 10px 14px; margin-bottom: 16px;
  border: 1px dashed color-mix(in oklab, var(--accent) 40%, transparent); border-radius: 12px;
  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: 6px; }
.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 8.5px/1 var(--fmono); letter-spacing: .11em; text-transform: uppercase; color: var(--text-dim); margin-top: 4px; }

/* ---------- Unit progress rows (mastery map, profile) ---------- */
.etac-unit { display: grid; grid-template-columns: 1fr auto; gap: 5px 10px; align-items: center; }
.etac-unit__name { font: 600 11.5px var(--fmono); color: var(--text-muted); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.etac-unit__side { display: flex; align-items: center; gap: 10px; }
.etac-unit__pct { font: 700 11px var(--fmono); color: var(--text-dim); font-variant-numeric: tabular-nums; }
.etac-unit__go { 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 10.5px var(--fmono); 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); }

/* ---------- 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: 11px; padding: 11px 13px; background: var(--bg-input); }
.etac-row__rank { font: 800 13px var(--fmono); 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(--fmono); 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: 6px; 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: 99px; padding: 7px 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: 14px; }
.pn-item { position: relative; padding-left: 32px; }
.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: 10px; flex-wrap: wrap; margin-bottom: 11px; }
.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: 8px; padding: 4px 9px;
}
.pn-date { font: 600 10.5px var(--fmono); letter-spacing: .05em; text-transform: uppercase; color: var(--text-dim); }
.pn-latest-tag {
  margin-left: auto; font: 700 9px var(--fmono); letter-spacing: .1em; text-transform: uppercase;
  color: #fff; background: var(--accent); border-radius: 99px; padding: 4px 9px;
}
.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 6px;
  border-radius: 4px; font-size: 12px; font-family: var(--fmono);
}

/* About page: numbered how-to steps */
.ab-steps { margin: 0; padding: 0; list-style: none; counter-reset: step; display: flex; flex-direction: column; gap: 11px; }
.ab-steps li { counter-increment: step; display: flex; gap: 11px; 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: 10px;
  padding: 5px 10px;
  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; }
}
