/* ═══════════════════════════════════════════════════════════════════════
   WORD BATTLE — SHARED DESIGN SYSTEM
   Task 1 of the UI rebuild. Foundation only: tokens, type, and the
   recurring furniture lifted out of design/mocks/. No screen is built here.

   Every value in this file was read out of the mock CSS. Where two mocks
   disagreed, design/README.md's token table settles it — see the note on
   --tray below. Nothing here was eyeballed or rounded.

   The React components that emit these classes live in design-system.jsx,
   which is compiled into public/app.js alongside the app. Class names are
   prefixed `ds-` so nothing here can collide with the existing "Volt" UI,
   which is still on its own palette until tasks 2–14 replace it.
   ═══════════════════════════════════════════════════════════════════════ */

:root {
  /* ── Palette ────────────────────────────────────────────────────────
     The whole of it. No colour outside this block may appear in the UI.
     Anything that looks like a new colour is one of these at an alpha —
     see the --ds-* derivations below, which are the only ones sanctioned. */
  --cream:     #F5EFE4;
  --cream2:    #EAE0CC;
  /* design/README.md and mocks 09–13 say #FBF7EF; mocks 01–08 still carry
     the earlier #F7F2E8. The README's table and the newer mocks agree, so
     #FBF7EF is the value. */
  --tray:      #FBF7EF;

  --ink:       #2B2118;
  --ink2:      #1C150F;
  --soft:      #6B5F4F;
  --faint:     #9A8E7B;

  --forest:    #1F4A32;
  --forest-lo: #2C6343;

  --gold:      #A8801A;
  --gold-lit:  #E3BE5A;

  --terra:     #B4462B;
  --terra-lo:  #8F3722;

  /* The one value that is not in the palette. The brief specifies "terra
     with white text", and terra is too dark for cream to sit on it at
     button weight. Used for label text on terra and nowhere else. */
  --on-terra:  #FFFFFF;

  /* ── Derived: the palette at alpha ──────────────────────────────────
     Named so a screen never has to write a raw rgba() and drift. */
  --ink-a10:   rgba(43, 33, 24, .10);
  --ink-a13:   rgba(43, 33, 24, .13);
  --ink-a14:   rgba(43, 33, 24, .14);
  --ink-a16:   rgba(43, 33, 24, .16);
  --ink-a18:   rgba(43, 33, 24, .18);
  --ink-a20:   rgba(43, 33, 24, .20);
  --ink-a22:   rgba(43, 33, 24, .22);
  --ink-a26:   rgba(43, 33, 24, .26);
  --ink-a28:   rgba(43, 33, 24, .28);
  --ink-a30:   rgba(43, 33, 24, .30);
  /* The worn tile in the character picker (mock 17), which sits a shade
     heavier than the inset shadow because it is lifted rather than pressed. */
  --ink-a32:   rgba(43, 33, 24, .32);
  --ink2-a40:  rgba(28, 21, 15, .40);
  --ink2-a42:  rgba(28, 21, 15, .42);
  --ink2-a60:  rgba(28, 21, 15, .60);
  --cream-a14: rgba(245, 239, 228, .14);
  --cream-a16: rgba(245, 239, 228, .16);
  --cream-a30: rgba(245, 239, 228, .30);
  --cream-a32: rgba(245, 239, 228, .32);
  --cream-a50: rgba(245, 239, 228, .50);
  --cream-a55: rgba(245, 239, 228, .55);
  --cream-a60: rgba(245, 239, 228, .60);
  --cream-a70: rgba(245, 239, 228, .70);
  /* The halo the winning row holds. Two stops of the same gold, because a
     halo that pulses between one alpha and none reads as a light going out
     rather than as something breathing. */
  --gold-a12:  rgba(227, 190, 90, .12);
  --gold-a28:  rgba(227, 190, 90, .28);
  --gold-a42:  rgba(227, 190, 90, .42);
  --terra-a42: rgba(180, 70, 43, .42);

  /* ── Division identity ──────────────────────────────────────────────
     NOT part of the palette, and deliberately kept out of it.

     The palette above is the rule the whole app obeys: no colour outside
     it may appear in the UI. These six pairs are not UI colour — they are
     identity, the way a team wears a strip, and they appear only on the
     two league screens, only on that division's own furniture. Loosening
     the palette rule to admit them would have cost the rule; giving them
     their own named set costs nothing and says what they are.

     Each is a light and a dark, used as a gradient down a crest and along
     the spine of the ladder. Read out of design/mocks/16-league-ladder.html,
     where they are authored per step as --c and --d. */
  --div-bronze:        #C4623F;  --div-bronze-dk:    #7E3620;
  --div-silver:        #D9D2C4;  --div-silver-dk:    #9A9284;
  --div-gold:          #F0CE72;  --div-gold-dk:      #A8801A;
  --div-platinum:      #8FC9C5;  --div-platinum-dk:  #3E6F6B;
  --div-diamond:       #7FB6EC;  --div-diamond-dk:   #2F5F8F;
  --div-champion:      #C79BE8;  --div-champion-dk:  #6A4590;

  /* The night the ladder is drawn on. One screen, and the only place in
     the app that is not warm paper — a league is meant to feel like
     somewhere else. */
  --div-night:         #17111D;
  --div-night-lo:      #3A2A4D;

  /* ── Type ───────────────────────────────────────────────────────────
     ONE FAMILY, Baloo 2, weights 500–800. wb-059 cut Archivo, Fredoka and
     Nunito: four faces is four network requests before anything can be
     measured, and a card whose height depends on which of them arrived
     first is a card that cannot be worked out on paper.

     Both token names stay. Nothing has to be rewritten to change a face
     again — display and body simply point at the same family now, and the
     difference between them is weight: 800 for titles, names, numbers and
     buttons, 600 for body copy, 700 for small uppercase labels. */
  --ds-display: 'Baloo 2', system-ui, sans-serif;
  --ds-body:    'Baloo 2', system-ui, sans-serif;

  /* ── Elevation ──────────────────────────────────────────────────────
     Hard offset shadows, no blur. The card alpha varies between .13 and
     .34 across the mocks; .26 is the value design/README.md states and is
     the default here. A screen whose mock differs overrides it locally. */
  --ds-shadow-card:   0 5px 0 var(--ink-a26);
  --ds-shadow-button: 0 4px 0 var(--ink);
  --ds-shadow-inset:  0 3px 0 var(--ink-a30);

  --ds-card-radius:   17px;   /* mocks run 16–18; 17 is the middle and the
                                 most common. Override per card. */

  /* ── Motion ─────────────────────────────────────────────────────────
     THE WHOLE VOCABULARY. Six durations, two rhythms and four curves, and
     nothing in the app may write a duration or an easing that is not one of
     these — test/motion.test.js scans for it.

     The reason is the same as the palette's. Nine greens and no way to tell
     which was meant is what the palette rule prevents; nine durations
     between 200ms and 400ms is the same problem in time, and it is harder
     to see, because a screen that feels slightly wrong reads as a taste
     problem rather than as a value somebody typed once.

     The names say what a thing IS, not how long it takes, so a retune moves
     every use of it at once.

     ── SMOOTH, from design/09-motion.html option B ──────────────────────

     These are that mock's five moments, to the millisecond. It is slower
     than what was here and it does not overshoot anywhere, which is the
     whole choice: nothing in the app springs except money.

       button press   160ms   --t-instant
       screen change  420ms   --t-normal
       a row arriving 450ms   --t-enter
       tile reveal    500ms   --t-slow
       balance count  900ms   --t-event

     --t-enter is the sixth duration, and it is here because the mock gives
     a row and a screen different times AND different curves. They are two
     motions, not one number somebody rounded. */
  --t-instant: 160ms;   /* a press, a toggle */
  --t-quick:   180ms;   /* a chip changing, a value ticking */
  --t-normal:  420ms;   /* a screen arriving, a sheet opening */
  --t-enter:   450ms;   /* a row, a card, a skeleton filling in */
  --t-slow:    500ms;   /* a reveal, a flip */
  --t-event:   900ms;   /* a celebration beat, the balance counting */

  /* The gap between one thing starting and the next, where a set of them
     goes off in order: the tiles of a guess, the keys that follow it.

     A rhythm rather than a duration, which is why it is far below the
     shortest of them and why it is a token at all. Everything above answers
     "how long does this take"; this one answers "how far apart". Written
     down for the same reason as the rest: the board's stagger and the
     keyboard's must be the same number, or the keys read as a second,
     unrelated event. */
  --t-stagger:  70ms;

  /* The other rhythm: things following one another through the air, rather
     than turning over in place. A dozen gems 70ms apart is a dribble that
     lasts well over a second; the same dozen at 55ms is a stream. Two
     rhythms, two jobs — and no third without a reason.

     55ms because that is what the claim mock deals its eight gems at. */
  --t-trail:    55ms;

  /* Arriving. The smooth standard, and the one the whole app is set to. */
  --e-out: cubic-bezier(.4, 0, .2, 1);
  /* A row or a card arriving. Off the line faster than --e-out and settling
     over the same distance, which is what stops eight of them reading as a
     queue. Straight out of the motion mock, where the row and the screen are
     deliberately not the same move. */
  --e-enter: cubic-bezier(.22, .61, .36, 1);
  /* Leaving: slow to commit, then gone. Nothing needs to watch it go. */
  --e-in:  cubic-bezier(.55, .06, .68, .19);
  /**
   * MONEY, AND ONLY MONEY.
   *
   * The one curve in the app that overshoots. Everything else is smooth and
   * lands where it was going; the balance counting up swings past and comes
   * back, which is what makes it read as a win rather than as an update.
   *
   * Do not reach for this because something feels flat. One bouncing thing
   * is a signature; two is a house style, and then the balance is saying
   * nothing.
   */
  --e-pop: cubic-bezier(.18, 1.8, .35, 1);

  /* System-bar insets. Capacitor injects --safe-area-inset-* because
     env() is 0 on Android WebView < 140; env() is the iOS/browser path.
     Every chrome that sits against a cutout reads these, not env() bare. */
  --sa-top:    var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
  --sa-right:  var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
  --sa-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
  --sa-left:   var(--safe-area-inset-left, env(safe-area-inset-left, 0px));

  /* Portrait height of the column. A short landscape viewport overrides
     this, then scales the column into the screen. */
  --app-h: 100dvh;
}

/* A phone on its side is shorter than the portrait column (the board spends
   344px on chrome before the first tile). Freeze the column at a phone
   height and scale it into the viewport. A landscape tablet is taller than
   560px and keeps the normal column, already capped at 430px wide.
   The scale is a transform, so position:fixed hangs from .n-col — fixedOrigin
   divides by that scale. Insets are grown by the inverse so they still match
   the real bars after the scale. */
@media (orientation: landscape) and (max-height: 560px) {
  :root {
    --app-h: 844px;
    --sa-top:    calc(var(--safe-area-inset-top, env(safe-area-inset-top, 0px)) * 844px / 100dvh);
    --sa-right:  calc(var(--safe-area-inset-right, env(safe-area-inset-right, 0px)) * 844px / 100dvh);
    --sa-bottom: calc(var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)) * 844px / 100dvh);
    --sa-left:   calc(var(--safe-area-inset-left, env(safe-area-inset-left, 0px)) * 844px / 100dvh);
  }
  .n-room {
    height: 100dvh;
    overflow: hidden;
    align-items: flex-start;
  }
  .n-col {
    height: 844px;
    min-height: 844px;
    flex: none;
    transform: scale(calc(100dvh / 844px));
    transform-origin: top center;
  }
}

/* ═══════════════════════════════════════════════════════════════════════
   MOTION
   One global rule. When the player has asked their system for less motion,
   nothing in the app animates — this covers anything a later task adds,
   including animation the author forgot to guard.
   ═══════════════════════════════════════════════════════════════════════ */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation: none !important;
    scroll-behavior: auto !important;
    /* Transitions too. The rule used to stop at `animation`, which left every
       transition running — and the motion primitives added in the animation
       phase are transitions and Web Animations, not keyframes, so without
       this line "reduced motion" would have covered almost none of what this
       app actually moves. */
    transition-duration: 1ms !important;
    transition-delay: 0ms !important;
    /* `animation: none` above already drops any delay. This is here for the
       same reason the transition line is: a later rule that switches the
       shorthand back to a duration would otherwise leave a staggered reveal
       still waiting its way across a row. */
    animation-delay: 0s !important;
  }
  /* The gesture clip does not play. The still portrait is what is shown, and
     the sequence continues past it — see dsPrefersReducedMotion. */
  .ds-gesture-video { display: none !important; }
}

/* ═══════════════════════════════════════════════════════════════════════
   TYPE HELPERS
   ═══════════════════════════════════════════════════════════════════════ */

/* Any number whose length can change. A gem count going 999 → 1,000 must
   not shift the things beside it. */
.ds-num { font-variant-numeric: tabular-nums; }

.ds-display { font-family: var(--ds-display); }
.ds-body    { font-family: var(--ds-body); }

/* ═══════════════════════════════════════════════════════════════════════
   SECTION LABEL
   The small all-caps rule above a group. From 10-profile `.ct`, which is
   the value design/README.md states: 8.5px / 800 / .16em / faint.
   ═══════════════════════════════════════════════════════════════════════ */
.ds-section-label {
  font-family: var(--ds-body);
  font-size: 8.5px;
  font-weight: 800;
  letter-spacing: .16em;
  color: var(--faint);
  text-transform: uppercase;
}
/* On forest. From 02-lobby-strip `.gl`, which sits on the HUD. */
.ds-section-label--on-dark {
  font-size: 8px;
  color: var(--cream-a50);
}

/* ═══════════════════════════════════════════════════════════════════════
   CARD
   3px ink, tray fill, hard shadow. `overflow:hidden` is the default
   because most cards in the mocks carry a coloured band that has to be
   clipped by the radius; the padded variant is the plain content card.
   ═══════════════════════════════════════════════════════════════════════ */
.ds-card {
  border: 3px solid var(--ink);
  border-radius: var(--ds-card-radius);
  background: var(--tray);
  box-shadow: var(--ds-shadow-card);
  overflow: hidden;
}
.ds-card--pad { padding: 13px; }

/* ═══════════════════════════════════════════════════════════════════════
   BUTTON
   Three fills and nothing else. All 3px ink, radius 12–14, hard shadow.
   ═══════════════════════════════════════════════════════════════════════ */
.ds-btn {
  display: inline-block;
  border: 3px solid var(--ink);
  border-radius: 12px;
  padding: 8px 14px;
  font-family: var(--ds-display);
  font-weight: 900;
  font-size: 16px;
  line-height: 1.05;
  font-variant-numeric: tabular-nums;
  box-shadow: var(--ds-shadow-button);
  cursor: pointer;
  text-align: center;
  -webkit-appearance: none;
  appearance: none;
}

.ds-btn--forest { background: var(--forest);   color: var(--cream); }
.ds-btn--gold   { background: var(--gold-lit); color: var(--ink); }
.ds-btn--terra  { background: var(--terra);    color: var(--on-terra); }

/* Full-width footer button. From 10-profile `.fb`. */
.ds-btn--lg {
  display: block;
  width: 100%;
  border-radius: 13px;
  padding: 12px 0;
}
/* The button that sits in a card tray. From 01-lobby `.play`. */
.ds-btn--sm {
  padding: 8px 0;
  font-size: 17px;
}

/* Interaction states. Not in the mocks — the mocks are static — but a
   button with a hard shadow and no press has to be given one, and a
   disabled control has to read as disabled without relying on opacity
   over an unknown background. Both are drawn from the palette. */
.ds-btn:active:not(:disabled) {
  transform: translateY(2px);
  box-shadow: 0 2px 0 var(--ink);
}
.ds-btn:disabled {
  background: var(--cream2);
  color: var(--faint);
  box-shadow: 0 4px 0 var(--ink-a30);
  cursor: default;
}

/* ═══════════════════════════════════════════════════════════════════════
   PROGRESS BAR
   Two tones. `--dark` is the level bar on the forest HUD (02-lobby-strip
   `.lvbar`); `--light` is the distribution bar on a card (10-profile
   `.dbar`). Height comes in as --ds-progress-h so a caller never has to
   restate the rest.
   ═══════════════════════════════════════════════════════════════════════ */
.ds-progress {
  --ds-progress-h: 7px;
  height: var(--ds-progress-h);
  border-radius: 4px;
  overflow: hidden;
}
.ds-progress--dark {
  background: rgba(28, 21, 15, .30);
  border: 1.5px solid var(--ink2-a60);
}
.ds-progress--light {
  --ds-progress-h: 20px;
  border-radius: 6px;
  background: var(--ink-a10);
}
.ds-progress__fill {
  display: block;
  height: 100%;
  border-radius: inherit;
  background: var(--gold-lit);
}
.ds-progress__fill--forest    { background: var(--forest); }
.ds-progress__fill--forest-lo { background: var(--forest-lo); }
.ds-progress__fill--terra     { background: var(--terra); }

/* ═══════════════════════════════════════════════════════════════════════
   STAT TILE
   From 10-profile `.tile` (value over label) and `.ptile` (label over
   value, tighter). Both shapes, one component.
   ═══════════════════════════════════════════════════════════════════════ */
.ds-stat {
  border: 2.5px solid var(--ink-a20);
  border-radius: 13px;
  background: var(--cream2);
  padding: 11px;
}
.ds-stat__value {
  display: block;
  font-family: var(--ds-display);
  font-weight: 900;
  font-size: 28px;
  line-height: 1;
  font-variant-numeric: tabular-nums;
  color: var(--ink);
}
.ds-stat__label {
  display: block;
  font-family: var(--ds-body);
  font-size: 10.5px;
  font-weight: 800;
  color: var(--soft);
  margin-top: 3px;
}

/* Label-first: the label becomes the small caps rule and the value tightens. */
.ds-stat--label-first { padding: 9px 11px; }
.ds-stat--label-first .ds-stat__value { font-size: 26px; line-height: 1.05; }
.ds-stat--label-first .ds-stat__label {
  font-size: 8px;
  letter-spacing: .13em;
  color: var(--faint);
  text-transform: uppercase;
  margin-top: 0;
  margin-bottom: 1px;
}

.ds-stat--forest { background: var(--forest); border-color: var(--ink); }
.ds-stat--forest .ds-stat__value { color: var(--gold-lit); }
.ds-stat--forest .ds-stat__label { color: var(--cream-a70); }

.ds-stat--gold { background: var(--gold-lit); border-color: var(--ink); }
.ds-stat--gold .ds-stat__label { color: rgba(43, 33, 24, .6); }

/* ═══════════════════════════════════════════════════════════════════════
   CURRENCY HUD
   Forest, 3px ink underneath it, avatar tile + level bar + gem count.
   From 02-lobby-strip / 03-1v1 / 09-rewards, which are the three mocks
   carrying the avatar-tile-and-level-bar HUD the brief describes.
   ═══════════════════════════════════════════════════════════════════════ */
.ds-hud {
  flex: none;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: calc(10px + var(--sa-top)) 12px 10px;
  background: var(--forest);
  border-bottom: 3px solid var(--ink);
}
/* ── avatar ─────────────────────────────────────────────────────────── */
/* Circular, and the art is contained inside it rather than cropped to fill:
   the letterform reaches the edge of its own file, so a cover crop clips it.
   The 70% width is set on the image itself, from the circle's diameter. */
.ds-avatar {
  display: inline-grid;
  place-items: center;
  flex: none;
  box-sizing: border-box;
  border-radius: 50%;
  overflow: hidden;
  background: var(--cream2);
  border: 2.5px solid var(--ink);
}
/* <picture> is the WebP/PNG switch and must not become a layout box of its
   own — the grid centres it, the img inside carries the size. */
.ds-avatar picture {
  display: block;
  line-height: 0;
}
.ds-letter-art {
  display: block;
  object-fit: contain;
  pointer-events: none;
  user-select: none;
}
/* Kept as its own class: the picker draws letter art in square tiles that are
   not avatars, and both read from .ds-letter-art. */
.ds-avatar__art {
  display: block;
  object-fit: contain;
}

.ds-hud__avatar {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: var(--n-face-1);
  border: 2.5px solid var(--ink);
  display: grid;
  place-items: center;
  font-size: 21px;
  flex: none;
  box-shadow: var(--ds-shadow-inset);
  padding: 0;
  cursor: pointer;
  overflow: hidden;
}

/* The avatar clips its art, so anything that has to sit ON its edge — the
   unseen-letter dot — hangs off a wrapper instead of off the button. */
.ds-hud__avatar-wrap { position: relative; flex: none; }
.ds-hud__avatar-dot {
  position: absolute;
  top: -1px;
  right: -1px;
  width: 11px;
  height: 11px;
  border-radius: 50%;
  background: var(--terra);
  border: 2px solid var(--ink);
  pointer-events: none;
}
/* One pulse, when a letter lands here. Not a loop: an idle throb on the
   avatar is the exact thing phase 0 is meant not to have. */
.ds-hud__avatar-wrap.is-pulsing { animation: dsPulseOnce var(--t-normal) var(--e-out) both; }
@keyframes dsPulseOnce {
  0%   { transform: scale(1); }
  45%  { transform: scale(1.18); }
  100% { transform: scale(1); }
}

.ds-hud__level { flex: 1; min-width: 0; }
.ds-hud__level-top {
  display: flex;
  align-items: baseline;
  gap: 7px;
  min-width: 0;
}
.ds-hud__level-name {
  font-family: var(--ds-display);
  font-weight: 800;
  font-size: 14px;
  color: var(--cream);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}
.ds-hud__level-note {
  font-family: var(--ds-body);
  font-size: 11px;
  font-weight: 700;
  color: var(--cream-a50);
}
.ds-hud__level .ds-progress { margin-top: 4px; }
.ds-hud__gems { flex: none; text-align: right; }
.ds-hud__gems-label {
  display: block;
  font-family: var(--ds-body);
  font-size: 8px;
  font-weight: 800;
  letter-spacing: .16em;
  color: var(--cream-a50);
  text-transform: uppercase;
}
.ds-hud__gems-count {
  font-family: var(--ds-display);
  font-weight: 900;
  font-size: 19px;
  color: var(--gold-lit);
  font-variant-numeric: tabular-nums;
}

/* ═══════════════════════════════════════════════════════════════════════
   BOTTOM NAV
   Forest, 3px ink on top, five items, line icons, active item gold-lit.
   Two shapes appear in the mocks: flat (10-profile) and one with the
   centre item raised into a gold bump (01-lobby, 08-shop). Both are here
   because both are approved screens; `raised` picks the bump.
   ═══════════════════════════════════════════════════════════════════════ */
.ds-nav {
  flex: none;
  display: flex;
  background: var(--forest);
  border-top: 3px solid var(--ink);
  padding: 8px 0 calc(12px + var(--sa-bottom));
}
.ds-nav__item {
  position: relative;
  flex: 1;
  text-align: center;
  font-family: var(--ds-body);
  font-size: 10px;
  font-weight: 800;
  color: var(--cream-a60);
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
}

/* Unread state. No mock shows one, but the app has real counts to carry —
   prizes waiting, a gift ready — and terracotta is what the mocks use
   everywhere else for the thing that wants you now. */
.ds-nav__badge {
  position: absolute;
  top: -2px;
  right: 22%;
  min-width: 16px;
  height: 16px;
  padding: 0 4px;
  border-radius: 8px;
  border: 2px solid var(--forest);
  background: var(--terra);
  color: var(--on-terra);
  font-family: var(--ds-display);
  font-weight: 900;
  font-size: 9px;
  line-height: 12px;
  font-variant-numeric: tabular-nums;
}
.ds-nav__badge--dot {
  min-width: 10px;
  width: 10px;
  height: 10px;
  padding: 0;
  border-radius: 5px;
}
.ds-nav__item.is-active { color: var(--gold-lit); }
.ds-nav__icon {
  display: block;
  width: 26px;
  height: 26px;
  margin: 0 auto 3px;
}
/* .ds-nav__bump — the gold bump that lifted the centre tab — stood here.
   Nothing ever raised a tab, and wb-061 leaves no gold in this bar in any
   state, so the rule went with the branch that would have drawn it. */

/* ═══════════════════════════════════════════════════════════════════════
   TUMBLING TILES
   Letter tiles scattered behind content, cropped by the surface they sit
   in. From 01-lobby's approved "C · Tumbling tiles" variant.

   Placement, size and rotation are computed in design-system.jsx from a
   seed, never written by hand — see the note there for why.
   ═══════════════════════════════════════════════════════════════════════ */
.ds-tumble {
  position: absolute;
  inset: 0;
  pointer-events: none;
  overflow: hidden;
}
.ds-tumble__tile {
  position: absolute;
  border-radius: 7px;
  border: 2.5px solid var(--ink2-a40);
  display: grid;
  place-items: center;
  font-family: var(--ds-display);
  font-weight: 900;
  color: var(--ink2-a42);
  background: var(--cream-a16);
}
.ds-tumble__tile--gold  { background: var(--gold-a42); }
.ds-tumble__tile--terra { background: var(--terra-a42); }

/* A surface that hosts tumbling tiles has to establish a containing block
   and sit its own content above them. */
.ds-tumble-host { position: relative; overflow: hidden; }
.ds-tumble-host > *:not(.ds-tumble) { position: relative; z-index: 2; }


/* ═══════════════════════════════════════════════════════════════════════
   ══ NIGHT ═════════════════════════════════════════════════════════════

   wb-059. Everything above this line was the warm-paper direction; below
   it is the one the app actually wears. It is appended rather than woven
   in so the two never have to be untangled: the paper tokens stay
   declared, and anything that has not been moved across yet renders as a
   cream card on a dark room — obviously unfinished rather than invisible.

   Four rules carry the whole thing, and section 3 of the brief is mostly
   consequences of them:

     · every screen sits in a dark blue room;
     · gold means money, and appears only on a gem amount;
     · green means action, and appears only on a control that starts one;
     · nothing that repeats in a list may carry a `filter:`.

   The last one is not taste. drop-shadow, blur and backdrop-filter each
   force their own compositing layer, and forty of them in a scrolling
   list drops frames on a mid-range Android. text-shadow and box-shadow
   do the same work and do not.
   ═══════════════════════════════════════════════════════════════════════ */

:root {
  /* ── The ground ───────────────────────────────────────────────────
     A radial glow at the top of the screen over a vertical fall to
     near-black. Stated as one token so a screen never re-authors it and
     the two rooms that DO differ can be seen to differ, in one place. */
  --n-room:
    radial-gradient(120% 340px at 50% 0, rgba(58,124,214,.38), rgba(12,24,48,0) 62%),
    linear-gradient(180deg, #0C1729 0%, #080D18 420px, #05080F 100%);

  /* Word of the Day. Purple, so a free daily can never be mistaken for a
     paid arena — that confusion is what the ten-gem replay bug was. */
  --n-room-wotd:
    radial-gradient(120% 340px at 50% 0, rgba(140,96,232,.44), rgba(28,16,70,0) 62%),
    linear-gradient(180deg, #1B1440 0%, #120C2C 420px, #08050F 100%);

  /* League. The room takes the colour of the tier the player is in, so
     climbing one changes the whole screen. --n-tier-* are set per tier on
     the screen root; the room itself is written once, here. */
  --n-room-tier:
    radial-gradient(120% 340px at 50% 0, var(--n-tier-glow, rgba(70,200,230,.34)), rgba(6,26,36,0) 62%),
    linear-gradient(180deg, var(--n-tier-1, #062733) 0%, var(--n-tier-2, #04161F) 420px, var(--n-tier-3, #03080C) 100%);

  /* ── Ink ──────────────────────────────────────────────────────────
     The one dark. It is the outline on a gem amount, the ring round an
     avatar and the edge of a tick — never a surface. */
  --n-ink:     #04101F;

  /* ── Type ─────────────────────────────────────────────────────────
     Four steps and no more. A fifth grey is a fifth thing to choose
     between, and the difference stops being readable below this. */
  --n-text:    #F2F7FF;   /* screen titles, room names, player names */
  --n-bright:  #EAF1FB;   /* numbers and values inside a row */
  --n-body:    #A9C0DE;   /* body copy, secondary values */
  --n-dim:     #7E95B5;   /* sub-labels under a name */
  --n-faint:   #5F7899;   /* small uppercase labels */
  --n-quiet:   #4E6483;   /* an em dash where a prize would be */

  /* ── Edges and surfaces ───────────────────────────────────────────── */
  --n-line:    rgba(150,190,240,.10);
  --n-line2:   rgba(150,190,240,.16);
  --n-line3:   rgba(160,200,255,.22);
  --n-rule:    rgba(120,170,240,.16);

  /* The standard panel. One card, used everywhere a card is used. */
  --n-card:    linear-gradient(180deg, rgba(255,255,255,.055), rgba(255,255,255,.016));
  --n-card2:   rgba(255,255,255,.05);
  --n-well:    rgba(6,14,30,.42);
  --n-r:       12px;

  /* Chrome. A header sits in blue over the room; a foot sits in near-black
     under it, and is where the pinned entry fee and action live. */
  --n-header:  linear-gradient(180deg, rgba(28,54,98,.96), rgba(11,20,38,.72));
  /* OPAQUE, not the mock's .92. In a mock the foot sits over the room and
     an alpha reads as solid; in the app it sits over the last row of a
     scrolling list, and a green Play button showed straight through it. */
  --n-foot:    linear-gradient(180deg, #0C182D 0%, #05090F 100%);

  /* ── Green means action. Nothing else. ────────────────────────────
     Play, Enter, Challenge, Accept, Play again, and the correct tiles. */
  --n-grn:     linear-gradient(180deg, #8AEDA6, #28A85E);
  --n-grn-bd:  rgba(190,255,215,.55);
  --n-grn-ink: #0A3315;
  --n-grn-lit: #7FE0A0;
  --n-grn-gl:  0 8px 22px rgba(34,168,92,.36), inset 0 1px 0 rgba(255,255,255,.6);

  /* ── Gold means money coming to the player. ───────────────────────
     Claim, Claim all, and Settings on the profile. NOT Play — a button
     that pays you must not look like the button that spends. */
  --n-gld:     linear-gradient(180deg, #FBE49F, #C9992C);
  --n-gld-bd:  rgba(255,240,190,.6);
  --n-gld-ink: #3A2703;
  --n-gld-lit: #F0C95E;
  --n-gld-gl:  0 8px 22px rgba(201,153,44,.32), inset 0 1px 0 rgba(255,255,255,.6);

  /* ── Rust. Sign out, the 1v1 mode chip, and the few marks that borrow
     the same "neither money nor an action" meaning. ─────────────────

     FLAT, NOT THE GRADIENT THE BRIEF DRAWS. #F08A63 → #B23F1C is a lovely
     ramp and no ink survives it: near-white measures 2.23:1 on the light
     stop, 3.39:1 at the middle and only reaches 5.25:1 at the bottom,
     while dark ink runs the other way — 7.33:1 at the top and 3.11:1 at
     the bottom. There is no colour that clears AA across both ends, so
     everything rust carries text on is the ramp's own bottom stop, held
     flat, where near-white is 5.25:1. The light end survives as
     --n-rst-bd, the edge, and --n-rst-lit, which never has type on it. */
  --n-rst:     #B23F1C;
  --n-rst-bd:  rgba(255,160,120,.5);
  --n-rst-ink: #FFF1EA;
  --n-rst-lit: #FF9E7A;

  /* ── Rank tints on a row ──────────────────────────────────────────
     Gold, silver, bronze, and your own row in blue. Anything out of the
     money drops to .55 rather than taking a colour of its own. */
  --n-tint-g:    linear-gradient(180deg, rgba(214,168,60,.22), rgba(120,88,20,.06));
  --n-tint-g-bd: rgba(230,190,90,.34);
  --n-tint-s:    linear-gradient(180deg, rgba(190,205,225,.16), rgba(60,72,92,.10));
  --n-tint-s-bd: rgba(200,215,235,.26);
  --n-tint-b:    linear-gradient(180deg, rgba(200,120,60,.18), rgba(90,50,20,.08));
  --n-tint-b-bd: rgba(215,145,85,.28);
  --n-you:       linear-gradient(180deg, rgba(70,140,235,.28), rgba(20,50,105,.10));
  --n-you-bd:    rgba(120,180,255,.44);
  --n-rank-g:    #FFD86A;
  --n-rank-s:    #DCE6F4;
  --n-rank-b:    #E8A567;

  /* ── The board's three states ─────────────────────────────────────
     Named once because they are drawn in four places: the board, the
     keyboard, the tutorial's 40px demonstration and the reveal. Two sets
     of greens is how a demonstration stops demonstrating. */
  --n-hit:      linear-gradient(180deg,#7CEBA3,#26AE60);
  --n-hit-ink:  #062012;
  --n-near:     linear-gradient(180deg,#F8D878,#C79B2E);
  --n-near-bd:  rgba(255,238,180,.55);
  --n-near-ink: #2A1E02;
  --n-out:      rgba(255,255,255,.02);
  --n-out-bd:   rgba(150,190,240,.06);
  --n-out-ink:  #3E4E68;

  /* The panel a sheet, a coach card and a speech bubble are all cut from,
     and the near-black everything of that kind is dimmed against. */
  --n-sheet:    linear-gradient(180deg,#111F36,#0A1120);
  --n-dimmer:   #03070F;

  /* ── The five leagues ─────────────────────────────────────────────
     Identity, not palette — the same argument as --div-* above, and the
     same answer. A tier's colour is the room it plays in. */
  /* ── The gold gradient, and the greens and blues beside it ────────
     Every colour the night furniture draws is named here. The rule the
     palette block exists for is that a colour nobody named is a colour
     nobody can find again — and a re-skin is exactly when that goes
     wrong, because the values are being typed for the first time. */
  --n-gold-1:   #FFF7CD;   /* the money treatment, top to bottom */
  --n-gold-2:   #FFDC5E;
  --n-gold-3:   #EFA71B;
  --n-gold-4:   #FFEDAE;
  --n-ghost-ink:#CFE0F7;   /* the quiet button, and every back chevron */
  --n-foot-lab: #61789A;   /* the small caps in a pinned foot */
  --n-xp-1:     #D2A23A;   /* the level bar, dark to light */
  --n-xp-2:     #FFE7A8;
  --n-bar-1:    #2E8F55;   /* a progress bar that is not the level bar */
  --n-bar-2:    #5FD98A;
  --n-grn-txt:  #8CF0AE;   /* a number on a green tile */
  --n-grn-lab:  #9FDCB8;
  --n-gld-lab:  #E4C878;   /* a label on a gold tile */
  --n-face-1:   #F3F7FF;   /* an avatar circle, light to shade */
  --n-face-2:   #C9D8EC;
  --n-badge-bd: #0A1220;   /* the nav badge, ringed in the bar it sits on */
  --n-badge-ink:#2A0E04;
  /* The last few, named for the same reason as the rest. Each is used
     once; being used once is not a reason to leave a colour anonymous. */
  --n-foot-edge:rgba(130,180,255,.16);  /* the rule above a pinned foot */
  --n-rule-fade:rgba(120,160,220,.2);   /* the section rule, fading right */
  --n-xp-glow:  rgba(255,215,130,.5);   /* the light under the level bar */
  --n-inflight: #7FB3FF;                /* the light in the top line, while a
                                           request is in the air */
  /**
   * THE FOCUS RING. Blue, and not rust.
   *
   * The board takes one every round, because it is focused programmatically
   * so a screen reader lands on it — which is right and stays. What was
   * wrong is the colour: it was --accent, and rust in this app means sign
   * out or 1v1. A 3px rust rectangle round the board was the loudest thing
   * on the screen, every round, saying something it did not mean.
   *
   * design/15-focus-ring.html option B.
   */
  --n-focus:    #5FA8FF;

  /* The tab bar. A body, an accent and two label greys — and no gold, in
     any state. See design/16-tab-icons.html option C. */
  --n-tab-off:    #5A6E85;   /* every shape on an unselected tab */
  --n-tab-on:     #D7E7FA;   /* the body of the selected one */
  --n-tab-accent: #5FA8FF;   /* the part of it that carries the meaning */
  --n-tab-lab:    #63788F;
  --n-tab-lab-on: #DCE9F8;
  --n-grn-tile: rgba(38,174,96,.28);    /* a stat tile that is an achievement */
  --n-grn-edge: rgba(120,230,160,.35);

  --n-bronze:   #C8763A;
  --n-silver:   #B7C6D6;
  --n-gold-t:   #EFCB6A;
  --n-platinum: #8FDDF0;
  --n-diamond:  #C7B4FF;
}

/* ═══════════════════════════════════════════════════════════════════════
   THE ROOM
   Painted on the app shell, not on each screen. A screen that wants one of
   the two exceptions adds its own class; everything else inherits blue by
   saying nothing at all.
   ═══════════════════════════════════════════════════════════════════════ */
/* No background-attachment:fixed. A fixed layer repaints the whole gradient
   on every scroll frame, and this is the one element every screen scrolls
   inside. The gradient stretches to the element instead, and its last stop
   is the floor colour, so a tall screen simply ends in near-black. */
.n-room       { background: var(--n-room); }
.n-room--wotd { background: var(--n-room-wotd); }
/* RE-DECLARED HERE, NOT ONLY READ. (wb-072)
   A custom property that names other custom properties is resolved where it
   is DECLARED. Declared only on :root, --n-room-tier resolved every
   var(--n-tier-*) at :root — where none is set — and inherited the fallback,
   so every division was drawn in Platinum's teal whatever n-tier--* the
   screen carried. Declaring it again on the element that carries the tier
   class makes it resolve against that tier. */
.n-room--tier {
  --n-room-tier:
    radial-gradient(120% 340px at 50% 0, var(--n-tier-glow, rgba(70,200,230,.34)), rgba(6,26,36,0) 62%),
    linear-gradient(180deg, var(--n-tier-1, #062733) 0%, var(--n-tier-2, #04161F) 420px, var(--n-tier-3, #03080C) 100%);
  background: var(--n-room-tier);
}

/* Each tier's three stops and its glow. Set on the same element as
   .n-room--tier, so climbing a tier changes the whole screen at once. */
.n-tier--bronze   { --n-tier-1:#2E1608; --n-tier-2:#1C0D04; --n-tier-3:#0A0502; --n-tier-glow:rgba(200,118,58,.34);  --n-tier-edge:rgba(220,150,100,.28); }
.n-tier--silver   { --n-tier-1:#1B2530; --n-tier-2:#10171E; --n-tier-3:#06090C; --n-tier-glow:rgba(183,198,214,.30); --n-tier-edge:rgba(200,215,235,.26); }
.n-tier--gold     { --n-tier-1:#2C2208; --n-tier-2:#1A1404; --n-tier-3:#080602; --n-tier-glow:rgba(239,203,106,.32); --n-tier-edge:rgba(240,201,94,.30); }
.n-tier--platinum { --n-tier-1:#062733; --n-tier-2:#04161F; --n-tier-3:#03080C; --n-tier-glow:rgba(70,200,230,.34);  --n-tier-edge:rgba(110,215,240,.28); }
.n-tier--diamond  { --n-tier-1:#1B1338; --n-tier-2:#100A22; --n-tier-3:#06040C; --n-tier-glow:rgba(199,180,255,.34); --n-tier-edge:rgba(199,180,255,.30); }

/* wb-072, the league sheet. --n-tier-row is the pale tint a lit table row
   fades to from white. --n-tier-cap is the SOLID colour of the room where
   the sticky column header sits (40% down the ramp, with the glow), so the
   header can be opaque without reading as a band pasted over the ground. */
.n-tier--bronze   { --n-tier-row:#F6E2D0; --n-tier-cap:#32190A; }
.n-tier--silver   { --n-tier-row:#E1E9F2; --n-tier-cap:#222B35; }
.n-tier--gold     { --n-tier-row:#F6ECC6; --n-tier-cap:#33290D; }
.n-tier--platinum { --n-tier-row:#D6F1F6; --n-tier-cap:#0A2C38; }
.n-tier--diamond  { --n-tier-row:#E5DEFA; --n-tier-cap:#231B3E; }

/* ═══════════════════════════════════════════════════════════════════════
   THE MONEY TREATMENT
   Two stacked spans: a dark outline behind, a gold gradient in front. It
   appears on a gem amount and on nothing else — prize pool, entry fee,
   balance, what a win pays, the selected stake, a shop pack, the league
   prize column. Clock, player counts, level, guess count, crowns and
   scores stay plain.

   The stroke scales with the size, at roughly 14% of the font. A fixed
   stroke is what welded a 20px button label into a blob: at that size the
   outlines of neighbouring letters overlap and fill the counters. That is
   also why there is no size below xs, and why no button label may wear
   this at all — see .n-btn, which uses plain text.

   The drop shadow is a text-shadow, not a filter, because these appear in
   lists. See the note at the top of this section.
   ═══════════════════════════════════════════════════════════════════════ */
.m {
  position: relative;
  display: inline-block;
  line-height: 1;
  font-weight: 800;
  font-variant-numeric: tabular-nums;
}
.m .st {
  position: absolute;
  left: 0;
  top: 0;
  color: var(--n-ink);
  text-shadow: 0 3px 0 rgba(0,0,0,.5);
  -webkit-text-stroke: 3px var(--n-ink);
}
.m .fg {
  position: relative;
  background: linear-gradient(180deg, var(--n-gold-1) 8%, var(--n-gold-2) 40%,
                                      var(--n-gold-3) 72%, var(--n-gold-4) 96%);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}
.m--xl { font-size: 40px; } .m--xl .st { -webkit-text-stroke: 6px   var(--n-ink); }
.m--lg { font-size: 30px; } .m--lg .st { -webkit-text-stroke: 4.6px var(--n-ink); }
.m--md { font-size: 24px; } .m--md .st { -webkit-text-stroke: 3.6px var(--n-ink); }
.m--sm { font-size: 20px; } .m--sm .st { -webkit-text-stroke: 3px   var(--n-ink); }
.m--xs { font-size: 17px; } .m--xs .st { -webkit-text-stroke: 2.5px var(--n-ink); }

/* A gem amount and its gem, as one thing. The gem carries the same ink
   stroke so it belongs to the number rather than floating beside it. */
.n-money { display: inline-flex; align-items: center; gap: 5px; }
.n-money--lg { gap: 8px; }

/**
 * MONEY ARRIVING — the one thing in the app that bounces.
 *
 * scale(.5) → 1.2 → 1 on --e-pop, which overshoots and comes back. Every
 * other motion in the app is smooth and lands where it was going; this
 * swings past, and that is what makes a balance going up read as a win
 * rather than as a field updating. design/09-motion.html option C's pop,
 * which is the one exception the brief carves out of option B.
 *
 * The rotation is the mock's and it is small on purpose: eight degrees at
 * the start, three at the top, none at rest.
 */
@keyframes nMoneyPop {
  0%   { transform: scale(.5)  rotate(-8deg); }
  60%  { transform: scale(1.2) rotate(3deg); }
  100% { transform: scale(1)   rotate(0); }
}
.m--pop { animation: nMoneyPop var(--t-enter) var(--e-pop) both; }

/* The ring that opens out of the balance as the gems land. */
@keyframes nRingOut {
  0%   { opacity: .9; transform: scale(.82); }
  100% { opacity: 0;  transform: scale(1.25); }
}
.n-ring {
  position: absolute; inset: -9px -12px; border-radius: 12px;
  border: 2px solid var(--n-rank-g); opacity: 0; pointer-events: none;
  animation: nRingOut var(--t-slow) var(--e-out) both;
}

/* One gem in flight. Positioned and moved from JavaScript — the path is an
   arc and a spread, which CSS has no way to say. */
.n-fly { position: absolute; z-index: 40; pointer-events: none; will-change: transform; }

/* ═══════════════════════════════════════════════════════════════════════
   LOADING — design/13-loading.html, option C: "the room is already there"

   NO SCREEN MAY SHOW A BLANK AREA WHILE IT LOADS. Not a spinner in the
   middle of nothing, not an empty scroll — the frame of the thing is drawn
   with everything that is already known (its colour, its labels, its
   buttons) and only the values it is waiting for are blocks. A player then
   spends the second and a half reading the screen they are about to use
   instead of watching a hole.

   The blocks breathe rather than sweep: a sweep is a second animation with
   its own direction, and on a card that already has a shape the direction
   says nothing.
   ═══════════════════════════════════════════════════════════════════════ */
@keyframes nBreathe { 0%, 100% { opacity: .4 } 50% { opacity: .9 } }
/* ── THE DIVISION FRAME'S HIGHLIGHT SWEEP (wb-063 part 9) ─────────────
   design/21-division-frame.html. A band of light crossing the metal every
   few seconds, at PROFILE size only: on a 28px medal in a scrolling list
   there is nothing to catch and a glint is just movement.

   A CSS animation rather than a script-written transform, so the
   reduced-motion rule below reaches it the way it reaches everything else —
   and `.df-shine` is inside an svg, which is exactly the kind of thing a
   `transition: transform` on a button would never have found.
   ──────────────────────────────────────────────────────────────────── */
@keyframes dfSweep {
  0%, 62%   { transform: skewX(-18deg) translateX(0) }
  92%, 100% { transform: skewX(-18deg) translateX(180px) }
}
.df--profile .df-shine { animation: dfSweep 4.6s var(--e-out) infinite }
@media (prefers-reduced-motion: reduce) {
  .df--profile .df-shine { display: none }
}

.n-blk {
  display: block; border-radius: 7px; background: rgba(255,255,255,.09);
  animation: nBreathe var(--t-event) var(--e-out) infinite;
}

/* The 2px line along the top of a screen with a request in flight. It says
   "still working" without taking a pixel of the layout. */
.n-topline {
  position: absolute; left: 0; right: 0; top: 0; height: 2px; z-index: 8;
  overflow: hidden; background: rgba(255,255,255,.06); pointer-events: none;
}
.n-topline i {
  display: block; height: 100%; width: 38%; border-radius: 2px;
  background: linear-gradient(90deg, transparent, var(--n-inflight), transparent);
  animation: nTopline var(--t-event) var(--e-out) infinite;
}
@keyframes nTopline {
  0%   { transform: translateX(-100%) }
  100% { transform: translateX(360%) }
}

/* ═══════════════════════════════════════════════════════════════════════
   BUTTONS — plain labels, always
   White or dark text with one 1px highlight. No outline, no gradient fill
   on the label itself.
   ═══════════════════════════════════════════════════════════════════════ */
.n-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  border: 0;
  border-radius: 13px;
  padding: 0 18px;
  height: 50px;
  font-family: var(--ds-body);
  font-weight: 800;
  font-size: 19px;
  line-height: 1;
  cursor: pointer;
  -webkit-appearance: none;
  appearance: none;
  text-align: center;
}
.n-btn--grn {
  background: var(--n-grn);
  border: 1px solid var(--n-grn-bd);
  color: var(--n-grn-ink);
  text-shadow: 0 1px 0 rgba(255,255,255,.5);
  box-shadow: var(--n-grn-gl);
}
.n-btn--gld {
  background: var(--n-gld);
  border: 1px solid var(--n-gld-bd);
  color: var(--n-gld-ink);
  text-shadow: 0 1px 0 rgba(255,255,255,.45);
  box-shadow: var(--n-gld-gl);
}
.n-btn--rst {
  background: var(--n-rst);
  border: 1px solid var(--n-rst-bd);
  color: var(--n-rst-ink);
  text-shadow: 0 1px 2px rgba(0,0,0,.3);
}
/* The quiet one. Beside Claim, beside Play — the thing you can do, not the
   thing you came for. */
.n-btn--ghost {
  background: rgba(255,255,255,.05);
  border: 1px solid rgba(150,190,240,.22);
  color: var(--n-ghost-ink);
  font-size: 16px;
}
.n-btn:disabled { opacity: .5; cursor: default; box-shadow: none; }
.n-btn:active:not(:disabled) { transform: translateY(1px); }

/* ═══════════════════════════════════════════════════════════════════════
   THE PINNED FOOT
   Entry fee and the action, at the bottom of the screen, never a scroll
   away however many rows are above them. Sticky in normal flow — NOT an
   absolute layer inside a clipped box, which is the construction that had
   rows painting over each other during a scroll.
   ═══════════════════════════════════════════════════════════════════════ */
.n-foot {
  position: sticky;
  bottom: 0;
  z-index: 6;
  flex: none;
  padding: 11px 12px calc(13px + var(--sa-bottom));
  border-top: 1px solid var(--n-foot-edge);
  background: var(--n-foot);
}
.n-foot__row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 9px;
}
.n-foot__k {
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: .16em;
  color: var(--n-foot-lab);
  text-transform: uppercase;
}
.n-foot__pair { display: flex; gap: 8px; }

/* ═══════════════════════════════════════════════════════════════════════
   THE STANDARD PANEL, THE SMALL LABEL, THE SECTION RULE
   ═══════════════════════════════════════════════════════════════════════ */
.n-card {
  background: var(--n-card);
  border: 1px solid var(--n-line);
  border-radius: var(--n-r);
}
.n-k {
  font-size: 9.5px;
  font-weight: 700;
  letter-spacing: .18em;
  color: var(--n-faint);
  text-transform: uppercase;
}
.n-sechd {
  display: flex;
  align-items: center;
  gap: 9px;
  margin: 12px 2px 8px;
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: .2em;
  color: var(--n-faint);
  text-transform: uppercase;
}
.n-sechd:after {
  content: "";
  flex: 1;
  height: 1px;
  background: linear-gradient(90deg, var(--n-rule-fade), transparent);
}

/* ═══════════════════════════════════════════════════════════════════════
   THE SHARED FURNITURE, IN NIGHT DRESS
   Same class names, same structure, same components emitting them. Only
   the paint changes, so nothing above has to know this happened.
   ═══════════════════════════════════════════════════════════════════════ */
.ds-section-label { color: var(--n-faint); font-weight: 700; letter-spacing: .16em; }
.ds-section-label--on-dark { color: rgba(255,255,255,.5); }

.ds-card {
  border: 1px solid var(--n-line);
  border-radius: var(--n-r);
  background: var(--n-card);
  box-shadow: none;
}

.ds-btn {
  border: 1px solid var(--n-grn-bd);
  border-radius: 12px;
  font-family: var(--ds-body);
  font-weight: 800;
  box-shadow: none;
}
.ds-btn--forest { background: var(--n-grn); color: var(--n-grn-ink); text-shadow: 0 1px 0 rgba(255,255,255,.5); box-shadow: var(--n-grn-gl); }
.ds-btn--gold   { background: var(--n-gld); border-color: var(--n-gld-bd); color: var(--n-gld-ink); text-shadow: 0 1px 0 rgba(255,255,255,.45); box-shadow: var(--n-gld-gl); }
.ds-btn--terra  { background: var(--n-rst); border-color: var(--n-rst-bd); color: var(--n-rst-ink); box-shadow: none; }
.ds-btn:active:not(:disabled) { transform: translateY(1px) scale(.995); box-shadow: none; }

/**
 * THE PRESS, SAID ONCE.
 *
 * 160ms on the smooth curve — design/09-motion.html option B. It was
 * written out per screen at a dozen call sites and half of them had no
 * transition at all, so a button went down instantly and came back
 * instantly on some screens and eased on others. The transform stays local
 * (a keyboard key travels further than a card's CTA, deliberately); the
 * timing is one line.
 *
 * ── AND IT MUST NOT REACH ANYTHING A FINGER IS HOLDING ────────────────
 *
 * This broke the stake wheel, and it took a real drag on a real phone to
 * find it. The wheel's values are <button>s whose transform is written by
 * script sixty times a second; this rule quietly turned every one of those
 * writes into a 160ms eased animation, so the drum was always chasing a
 * position it never reached. It lagged the finger by about a sixth of a
 * second, which reads exactly as "stuck".
 *
 * A TRANSITION IS FOR A STATE CHANGE, NOT FOR A FRAME. `.n-live` marks a
 * surface whose transform belongs to script — a drag, a wheel, a slider,
 * anything following a finger in real time — and opts it out. Any new one
 * of those has to carry the class, and the class is here rather than a
 * `transition:none` buried in the screen's own stylesheet so that the
 * reason travels with it.
 */
button:not(.n-live), [role="button"]:not(.n-live) {
  transition: transform var(--t-instant) var(--e-out);
}
.n-live { transition: none !important; }
.ds-btn:disabled { background: rgba(255,255,255,.05); border-color: var(--n-line2); color: var(--n-quiet); box-shadow: none; }

.ds-progress--dark  { background: rgba(0,0,0,.40); border: 1px solid var(--n-line2); }
.ds-progress--light { background: rgba(0,0,0,.32); border: 1px solid var(--n-line); }
.ds-progress__fill  { background: linear-gradient(90deg, var(--n-xp-1), var(--n-xp-2)); box-shadow: 0 0 12px var(--n-xp-glow); }
.ds-progress__fill--forest,
.ds-progress__fill--forest-lo { background: linear-gradient(90deg, var(--n-bar-1), var(--n-bar-2)); box-shadow: none; }
.ds-progress__fill--terra     { background: var(--n-rst); box-shadow: none; }

.ds-stat { border: 1px solid var(--n-line); border-radius: 13px; background: var(--n-card); }
.ds-stat__value { color: var(--n-bright); font-family: var(--ds-body); font-weight: 800; }
.ds-stat__label { color: var(--n-dim); font-weight: 700; }
.ds-stat--label-first .ds-stat__label { color: var(--n-faint); }
.ds-stat--forest { background: linear-gradient(180deg, var(--n-grn-tile), rgba(12,58,32,.40)); border-color: var(--n-grn-edge); }
.ds-stat--forest .ds-stat__value { color: var(--n-grn-txt); }
.ds-stat--forest .ds-stat__label { color: var(--n-grn-lab); }
.ds-stat--gold { background: linear-gradient(180deg, rgba(214,168,60,.26), rgba(90,66,14,.34)); border-color: rgba(240,201,94,.36); }
.ds-stat--gold .ds-stat__value { color: var(--n-gld-lit); }
.ds-stat--gold .ds-stat__label { color: var(--n-gld-lab); }

/* The level bar. Avatar circle with an ink ring, level and XP, gems on the
   right in the gold treatment.

   The safe-area padding is restated rather than inherited from the rule
   above. Both rules apply and the later one only wins per property — so
   the inset would still reach the screen — but anybody reading the night
   section to find out what this box is would not see it, and deleting the
   warm section would take it away in silence. */
.ds-hud {
  padding: calc(10px + var(--sa-top)) 12px 10px;
  background: linear-gradient(180deg, rgba(24,45,82,.92), rgba(10,18,34,.62));
  border-bottom: 1px solid rgba(120,170,240,.14);
}
.ds-hud__avatar {
  background: linear-gradient(180deg, var(--n-face-1), var(--n-face-2));
  border: 2px solid var(--n-ink);
  box-shadow: none;
}
.ds-avatar { background: linear-gradient(180deg, var(--n-face-1), var(--n-face-2)); border: 2px solid var(--n-ink); }
/* THE PLATE STEPS ASIDE FOR THE DIVISION FRAME. (wb-063 part 9)
   The frame is itself a ring with ink on both edges; inside a bordered disc
   it read as two rings round one face, the outer one a flat blue saying
   nothing. The button keeps its size from the frame rather than from here,
   so the header's row height is decided by the artwork. */
.ds-hud__avatar--framed {
  width: auto; height: auto; border: 0; background: none;
  border-radius: 0; box-shadow: none; overflow: visible;
}
.ds-hud__avatar-dot { background: var(--n-rst-lit); border: 2px solid var(--n-ink); }
.ds-hud__level-name { color: var(--n-text); font-weight: 800; }
.ds-hud__level-note { color: var(--n-dim); }
.ds-hud__gems-label { color: var(--n-faint); letter-spacing: .2em; }
.ds-hud__gems-count { color: var(--n-gld-lit); }

/* ── TICKETS HELD (wb-065 §3) ─────────────────────────────────────────
   Beside the balance, and only ever there when the player holds one. It is
   a button because it goes somewhere; it is not styled as one because the
   header is a readout and a second green control up there would compete
   with Play.

   No gold anywhere in it: a ticket is not a gem amount. The count takes the
   plain bright ink and the ticket beside it carries its own colour, which is
   its lifetime. */
.ds-hud__tickets {
  flex: none; display: flex; flex-direction: column; align-items: flex-end;
  gap: 2px; padding: 2px 0 2px 4px; margin: 0;
  border: 0; background: none; cursor: pointer; font: inherit;
}
/* Named like the gems beside it: the same size, weight and tracking, because
   they are the same kind of fact. (wb-094 section 3) */
.ds-hud__tickets-label {
  display: block;
  font-family: var(--ds-body); font-size: 8px; font-weight: 800;
  letter-spacing: .2em; text-transform: uppercase; color: var(--n-faint);
  line-height: 10px;
}
.ds-hud__tickets-top { display: flex; align-items: center; gap: 5px; }
/* HOLDING NONE IS A STATE, NOT AN ABSENCE. The slot keeps its place so the
   gems figure never moves when a card arrives or lapses; the art greys and
   the figure dims, so "none" reads as none rather than as missing.
   A filter here is fine — the rule forbids it on anything that repeats down
   a list, and there is exactly one of these on the screen. */
.ds-hud__tickets.is-zero .ds-hud__tickets-count { color: var(--n-faint); }
.ds-hud__tickets.is-zero img,
.ds-hud__tickets.is-zero svg { filter: grayscale(1); opacity: .4; }
.ds-hud__tickets-count {
  font-family: var(--ds-body); font-weight: 800; font-size: 16px; line-height: 18px;
  color: var(--n-bright); font-variant-numeric: tabular-nums;
}
/* The expiry is the quietest thing in the header. It is a fact the player
   needs only when it is nearly gone, and it says so by being small. */
.ds-hud__tickets-left {
  font-family: var(--ds-body); font-weight: 700; font-size: 9px; line-height: 11px;
  letter-spacing: .04em; color: var(--n-faint); font-variant-numeric: tabular-nums;
}
.ds-hud__tickets:active { transform: translateY(1px); }

/* Restated for the same reason as .ds-hud above: the gesture-bar inset is
   part of what this box IS, and it should be readable here. */
.ds-nav {
  padding: 8px 0 calc(12px + var(--sa-bottom));
  background: var(--n-foot);
  border-top: 1px solid rgba(120,170,240,.14);
}
/* ── THE TAB BAR — design/16-tab-icons.html, option C ────────────────
   No gold in any state. The selected tab is a pale body with one blue
   accent on the part of the shape that carries its meaning, over a soft
   pool of the same blue. The Rewards dot keeps its rust and is now the only
   warm thing in the bar, which is the point. */
.ds-nav__item { color: var(--n-tab-lab); font-weight: 700; }
.ds-nav__item.is-active { color: var(--n-tab-lab-on); }
.ds-nav__iw { position: relative; display: block; width: 26px; margin: 0 auto 3px; }
/* Above the pool, so the icon is never washed out by it. */
.ds-nav__iw .ds-nav__icon { position: relative; z-index: 2; margin: 0; }
.ds-nav__pool {
  position: absolute; z-index: 1; top: -7px; left: 50%;
  transform: translateX(-50%); width: 50px; height: 38px; border-radius: 50%;
  background: radial-gradient(circle, rgba(95,168,255,.34), rgba(95,168,255,0) 70%);
  pointer-events: none;
}
.ds-nav__badge {
  background: var(--n-rst-lit);
  border: 2px solid var(--n-badge-bd);
  color: var(--n-badge-ink);
  font-family: var(--ds-body);
  font-weight: 800;
}

/* The tumbling letters behind a band. .32 in the mock, and they sit behind
   a prize pool — any heavier and the number stops being the loudest thing
   on the card, which is the only reason the card exists. */
.ds-tumble { opacity: .32; }
.ds-tumble__tile {
  border: 1px solid rgba(255,255,255,.18);
  border-radius: 8px;
  color: rgba(255,255,255,.85);
  background: rgba(255,255,255,.12);
  font-family: var(--ds-body);
  font-weight: 800;
}
.ds-tumble__tile--gold,
.ds-tumble__tile--terra { background: rgba(255,255,255,.12); }
