/* WEFT Phase A — state machine layout (tokens.css required) */

*, *::before, *::after { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }

html, body {
  margin: 0;
  padding: 0;
  font-family: var(--font);
  font-size: var(--text-base);
  line-height: 1.45;
  color: var(--ink);
  min-height: 100dvh;
  /* No rubber-band / pull-to-refresh: without this the page bounces the moment a board drag
     reaches an edge, which reads as the "camera" sliding around while you play. */
  overscroll-behavior: none;
  -webkit-font-smoothing: antialiased;
  background:
    radial-gradient(120% 80% at 15% -10%, color-mix(in oklch, var(--word-1) 22%, transparent), transparent 60%),
    radial-gradient(120% 80% at 90% 0%, color-mix(in oklch, var(--word-4) 20%, transparent), transparent 55%),
    var(--bg);
  background-attachment: fixed;
}

.app {
  max-width: var(--app-max);
  margin: 0 auto;
  min-height: 100dvh;
  /* Edge-to-edge (G3, option a): the background paints under the status bar and the chrome reserves
     the inset. ADD the inset to the padding, never max() it. An inset is an OBSTRUCTION, not an
     alternative gutter — with max(), a 48px Android status bar yields exactly 48px of top padding,
     all of it beneath the clock, so the header starts at the clock's bottom pixel with zero gap.
     Android 15+/16 removed setBackgroundColor and enforce edge-to-edge (see capacitor-native-chrome.js),
     so this padding is now the ONLY thing keeping the wordmark and gear out from under the clock.
     The `, 0px` fallback matches the house pattern (components.css) and keeps calc() valid.
     Desktop and non-notched devices report 0, so they are unchanged at the plain token value. */
  padding:
    calc(env(safe-area-inset-top, 0px) + var(--space-4))
    calc(env(safe-area-inset-right, 0px) + var(--space-4))
    calc(env(safe-area-inset-bottom, 0px) + var(--space-6))
    calc(env(safe-area-inset-left, 0px) + var(--space-4));
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}

[data-ui-state="playing"] .solve-panel { display: none !important; }

[data-ui-state="solved"] .play-shell { display: none !important; }

/* Win screen fills the viewport — avoids a sparse “void” on wide desktop (P5). */
.app[data-ui-state="solved"] {
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
  background:
    radial-gradient(ellipse 90% 55% at 50% -10%, color-mix(in oklch, var(--brand-pop) 10%, transparent), transparent 65%),
    var(--bg);
}
.app[data-ui-state="solved"] .play-header { flex-shrink: 0; }
.app[data-ui-state="solved"] .solve-panel {
  flex: 1;
  width: 100%;
  padding-top: var(--space-2);
}

/* Setup owns the whole screen: no board, no win panel, no in-game chrome. */
[data-ui-state="setup"] .play-shell,
[data-ui-state="setup"] .solve-panel,
[data-ui-state="setup"] .play-action-bar,
[data-ui-state="setup"] .play-header-center { display: none !important; }
[data-ui-state="setup"] .setup-panel { display: block; }
.setup-panel[hidden] { display: none !important; }

/* ── Section axis (Spielen · Wörter · Fortschritt) ──────────────────────────────────
   Orthogonal to data-ui-state. data-section picks the visible top-level view. When a
   non-Play section is active the whole Play cluster (setup/board/solve) steps aside and
   the chosen section view shows; the game state underneath is preserved (never torn down),
   so returning to Spielen restores the board exactly as left. */
.section-view { display: none; padding-bottom: var(--space-10); }
/* A shown section fades + slides up as it mounts — the soft cross-section transition. Because the
   rule only applies while its data-section is active, the animation fires once on entering the
   section and never replays during in-section work. Reduced-motion disables it below. */
.app[data-section="woerter"] #woerterView,
.app[data-section="fortschritt"] #fortschrittView,
.app[data-section="wiederholen"] #reviewView {
  display: block;
  animation: section-in var(--duration-normal) var(--ease-out);
}
.app[data-section="woerter"] .setup-panel,
.app[data-section="woerter"] .play-shell,
.app[data-section="woerter"] .solve-panel,
.app[data-section="wiederholen"] .setup-panel,
.app[data-section="wiederholen"] .play-shell,
.app[data-section="wiederholen"] .solve-panel,
.app[data-section="fortschritt"] .setup-panel,
.app[data-section="fortschritt"] .play-shell,
.app[data-section="fortschritt"] .solve-panel { display: none !important; }
/* The footer is a sibling of the Play cluster; the playing-state rule hides it (board-hero
   reclaim). On a browse section no board competes for height, so bring the footer back. */
.app[data-section="woerter"] .app-footer,
.app[data-section="wiederholen"] .app-footer,
.app[data-section="fortschritt"] .app-footer { display: flex; }
@keyframes section-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }

/* Top-level section nav — explicit-choice links (like the DE/EN flag buttons), grouped in
   the centre of the header between the wordmark and the control cluster. */
.app-nav { display: flex; align-items: center; gap: var(--space-1); flex-wrap: wrap; }
.app-nav-link {
  display: inline-flex; align-items: center; gap: var(--space-2);
  height: var(--touch-min); padding: 0 var(--space-3);
  border-radius: var(--radius-pill);
  color: var(--text-muted); font: 500 var(--text-sm)/1 var(--font);
  text-decoration: none; white-space: nowrap;
  transition: color var(--duration-fast) var(--ease-out), background var(--duration-fast) var(--ease-out),
              transform var(--duration-fast) var(--ease-out);
}
.app-nav-link:hover { color: var(--text); background: var(--surface); }
/* Tactile press: the tab dips under the finger, then springs back on release — the small physical
   cue that makes navigation feel like a native app rather than a set of links. */
.app-nav-link:active { transform: scale(0.95); }
.app-nav-link[aria-current="page"] {
  color: var(--text); background: var(--surface-raised);
  box-shadow: inset 0 0 0 1px var(--line-ui);
}
.app-nav-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.app-nav-ico { font-size: var(--text-base); position: relative; display: inline-flex;
  transition: transform var(--duration-normal) var(--ease-out); }
/* Live streak badge riding the Progress tab's icon. Gold = reward/progress (palette rule), so the
   streak count reads as an earned number, not an interactive control. */
.app-nav-badge {
  position: absolute;
  top: -0.5em;
  left: 100%;
  transform: translateX(-40%);
  min-width: 1.15em;
  height: 1.15em;
  padding: 0 0.25em;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font: 700 var(--text-xs)/1 var(--font);
  color: var(--bg);
  background: var(--brand-pop);
  border-radius: var(--radius-pill);
  box-shadow: 0 0 0 2px var(--bg);
}
.app-nav-badge[hidden] { display: none; }
/* Review-due badge = a call to act (go review), so it takes the interactive teal — visually
   distinct from the gold streak badge, which is an earned count. */
.app-nav-badge--due { background: var(--accent); color: var(--selected-ink); box-shadow: 0 0 0 2px var(--bg); }
/* Phone/tablet-portrait: the section nav becomes a FIXED BOTTOM TAB BAR — the platform-standard
   thumb-zone for switching views. position:fixed lifts it out of the header flow, so the header
   collapses to just wordmark + controls and the board rises to the top of the screen (the game is
   the hero). The real #/… hash anchors and the hashchange router are unchanged; only presentation
   moves. Equal-width flex columns can never orphan an item, which also retires the old one-row
   padding hack. */
@media (max-width: 720px) {
  .app-nav {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 40;
    gap: 0;
    flex-wrap: nowrap;
    justify-content: space-around;
    /* Extra bottom padding clears the iOS home indicator via the safe-area inset. */
    padding: var(--space-1) var(--space-2) calc(var(--space-1) + env(safe-area-inset-bottom));
    background: color-mix(in oklch, var(--surface) 92%, transparent);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    border-top: 1px solid var(--line-ui);
  }
  .app-nav-link {
    flex: 1 1 0;
    min-width: 0;
    flex-direction: column;
    gap: 2px;
    height: auto;
    min-height: var(--touch-min);
    padding: var(--space-1) 0;
    border-radius: var(--radius-md);
    font-size: var(--text-xs);
    justify-content: center;
  }
  .app-nav-ico { font-size: var(--text-lg); }
  /* Tab-bar active state: teal glyph + label (no pill), the standard selected-tab treatment. The
     selected icon lifts a couple px so switching tabs has the native "the current tab pops" cue. */
  .app-nav-link[aria-current="page"] {
    color: var(--accent);
    background: transparent;
    box-shadow: none;
  }
  .app-nav-link[aria-current="page"] .app-nav-ico { transform: translateY(-2px); }
  .app-nav-link:hover { background: transparent; }
  /* Reserve the bar's height so the fixed nav never covers the footer or the last action button. */
  .app { padding-bottom: calc(58px + env(safe-area-inset-bottom)); }
  /* Bottom tabs already expose Play / Words / Progress — duplicating footer links adds dead scroll. */
  .app[data-section="woerter"] .app-footer,
  .app[data-section="wiederholen"] .app-footer,
  .app[data-section="fortschritt"] .app-footer { display: none !important; }
  .section-empty {
    margin: var(--space-6) var(--space-4) var(--space-4);
    padding: var(--space-8) var(--space-6);
    background: var(--surface);
    border: 1px solid var(--line-ui);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-raised);
  }
  .section-empty .btn-primary { width: auto; min-width: 200px; }
  .progress-label {
    font-size: var(--text-sm);
    font-weight: 600;
    color: var(--text);
  }
}

/* Section-view shared shell (Wörter + Fortschritt). Card grids and stat tiles are styled
   with the section-specific rules further down. */
.section-head { display: flex; align-items: baseline; gap: var(--space-3); flex-wrap: wrap; margin-bottom: var(--space-2); }
.section-title { margin: 0; font-size: var(--text-2xl); letter-spacing: -0.02em; }
.section-count { font: 600 var(--text-base)/1 var(--mono); color: var(--gold-text); }
.section-sub { color: var(--text-muted); font-size: var(--text-sm); margin: 0 0 var(--space-6); }
.section-note { color: var(--faint); font-size: var(--text-xs); margin-top: var(--space-4); }
.section-empty { text-align: center; padding: var(--space-12) var(--space-4); }
.section-empty-title { font-size: var(--text-lg); font-weight: 600; margin: 0 0 var(--space-2); }
.section-empty-body { color: var(--text-muted); margin: 0 0 var(--space-6); }

/* Wörter — dictionary (sub-project A). Each card wraps the canonical lessonHtml() body. */
.woerter-filters { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; margin-bottom: var(--space-6); }
.woerter-filters[hidden] { display: none; }
.woerter-search {
  flex: 1 1 220px; min-width: 180px; height: var(--touch-min);
  background: var(--surface); border: 1px solid var(--line-ui); border-radius: var(--radius-sm);
  color: var(--text); padding: 0 var(--space-4); font: 400 var(--text-sm) var(--font);
}
.woerter-search:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.woerter-levels { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.lvl-chip {
  height: 34px; padding: 0 var(--space-3); cursor: pointer;
  border-radius: var(--radius-pill); border: 1px solid var(--line-ui); background: var(--surface);
  color: var(--text-muted); font: 600 var(--text-xs)/1 var(--mono);
}
.lvl-chip[aria-pressed="true"] { color: var(--text); border-color: var(--accent); background: var(--accent-soft); }
.lvl-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.woerter-sort {
  margin-left: auto; height: 34px; cursor: pointer; background: transparent; border: 0;
  color: var(--accent); font: 500 var(--text-xs) var(--font); white-space: nowrap;
  display: inline-flex; align-items: center; gap: 4px;
}
/* The sort affordance lives here, not in the label string the painter writes — see woerter.js.
   currentColor keeps it on the same teal token as the label, including the disabled/hover states. */
.woerter-sort::after {
  content: ""; width: 1em; height: 1em; flex: none; background: currentColor;
  -webkit-mask: var(--ico-sort) center / contain no-repeat;
  mask: var(--ico-sort) center / contain no-repeat;
  --ico-sort: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 4v16M8 4L5 7.5M8 4l3 3.5'/%3E%3Cpath d='M16 20V4m0 16l3-3.5M16 20l-3-3.5'/%3E%3C/svg%3E");
}
.dict-grid { display: grid; gap: var(--space-4); grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
.dict-card {
  background: var(--surface); border: 1px solid var(--line-ui); border-radius: var(--radius-md);
  padding: var(--space-4); box-shadow: var(--shadow-raised);
  display: flex; flex-direction: column; gap: var(--space-3);
}
.dict-card-meta {
  margin-top: auto; padding-top: var(--space-2); border-top: 1px solid var(--line-ui);
  display: flex; justify-content: space-between;
  font: 400 var(--text-xs) var(--mono); color: var(--faint);
}
.woerter-nomatch { grid-column: 1 / -1; color: var(--text-muted); padding: var(--space-8) 0; text-align: center; }

/* Flashcard review (SM-2). Entry button lives in the Wörter section-head; the session runs in a
   modal. Palette: the button is interactive → teal (--accent); the due-count badge reports how
   many words await → gold (--brand-pop), matching .section-count. */
.review-open-btn {
  margin-left: auto; display: inline-flex; align-items: center; gap: var(--space-2);
  height: var(--touch-min); padding: 0 var(--space-4); cursor: pointer;
  border-radius: var(--radius-pill); border: 1px solid var(--accent); background: var(--accent-soft);
  color: var(--accent); font: 600 var(--text-sm) var(--font);
}
.review-open-btn[hidden] { display: none; }
.review-open-btn:hover { background: var(--accent); color: var(--selected-ink); }
.review-open-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.review-open-ico { font-size: 1.1em; line-height: 1; }
.review-open-due {
  min-width: 20px; height: 20px; padding: 0 6px; border-radius: var(--radius-pill);
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--brand-pop); color: #1a1400; font: 700 var(--text-xs)/1 var(--mono);
}
.review-open-due:empty { display: none; }
.review-due { color: var(--gold-text); font: 600 var(--text-sm) var(--mono); }
.review-body { min-height: 220px; display: flex; flex-direction: column; gap: var(--space-4); }
/* Inline deck lives in a section, so centre it and give it room for the taller back face. */
.review-body--section { max-width: 480px; margin: var(--space-5) auto 0; min-height: 340px; }
/* ── 3D flip card ─────────────────────────────────────────────────────────────────
   Both faces are mounted at once; tapping the front rotates the inner wrapper 180° (real flip,
   not an innerHTML swap). The back face is position:relative so the wrapper sizes to it (the taller
   side), and the absolute front overlays it. backface-visibility hides whichever face points away. */
.review-flip { perspective: 1200px; flex: 1 1 auto; display: flex; }
.review-flip-inner {
  position: relative; width: 100%; transform-style: preserve-3d;
  transition: transform var(--duration-slow, 520ms) var(--ease-out);
}
.review-flip-inner.is-flipped { transform: rotateY(180deg); }
.review-face { backface-visibility: hidden; -webkit-backface-visibility: hidden; }
.review-face--front { position: absolute; inset: 0; }
.review-face--back { position: relative; transform: rotateY(180deg); cursor: default; align-items: stretch; text-align: left; }
/* Card entering from the deck / leaving after a rating — the "worked stack" feel. */
.review-flip.is-entering { animation: review-card-in var(--duration-normal) var(--ease-out); }
.review-flip.is-exiting { animation: review-card-out var(--duration-normal) var(--ease-out) forwards; }
@keyframes review-card-in { from { opacity: 0; transform: translateX(26px); } to { opacity: 1; transform: none; } }
@keyframes review-card-out { from { opacity: 1; transform: none; } to { opacity: 0; transform: translateX(-26px); } }
/* Reduced motion: keep the flip instant (backface-visibility still swaps faces) and drop the
   section + card slide animations entirely. */
@media (prefers-reduced-motion: reduce) {
  .review-flip-inner { transition: none; }
  .review-flip.is-entering,
  .review-flip.is-exiting,
  .app[data-section="woerter"] #woerterView,
  .app[data-section="fortschritt"] #fortschrittView,
  .app[data-section="wiederholen"] #reviewView { animation: none; }
}
.review-card {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: var(--space-3); min-height: 200px; padding: var(--space-6);
  background: var(--surface); border: 1px solid var(--line-ui); border-radius: var(--radius-md);
  box-shadow: var(--shadow-raised); cursor: pointer; text-align: center;
}
.review-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* German compounds (Verantwortungsbewusstsein) are long, space-less tokens: under the default
   overflow-wrap:normal they can't break and spill ~150px past each edge of the card on a phone
   ("card out of the box"). max-width caps the word to the padded content box and overflow-wrap:
   anywhere lets it break inside the compound as a last resort, so it wraps to 2–3 lines and stays
   inside the flashcard at every width. */
.review-word {
  font: 600 var(--text-3xl) var(--mono); color: var(--text); letter-spacing: -0.01em;
  max-width: 100%; overflow-wrap: anywhere; hyphens: auto;
}
.review-hint { font-size: var(--text-xs); color: var(--faint); }
.review-lesson { width: 100%; }
.review-rate { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-2); }
.review-rate[hidden] { display: none; }
.review-rate-btn {
  height: var(--touch-min); cursor: pointer; border-radius: var(--radius-sm);
  font: 600 var(--text-sm) var(--font);
}
.review-rate--again { background: var(--surface); border: 1px solid var(--line-ui); color: var(--text-muted); }
.review-rate--good { background: var(--accent-soft); border: 1px solid var(--accent); color: var(--accent); }
.review-rate--easy { background: var(--accent); border: 1px solid var(--accent); color: var(--selected-ink); }
.review-rate-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.review-done { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--space-5); min-height: 180px; }
.review-done-msg { font-size: var(--text-lg); font-weight: 600; margin: 0; }
.review-done-btn {
  display: inline-flex; align-items: center; justify-content: center; text-decoration: none;
  height: var(--touch-min); padding: 0 var(--space-6); cursor: pointer;
  border-radius: var(--radius-sm); border: 1px solid var(--accent);
  background: var(--accent); color: var(--selected-ink); font: 600 var(--text-sm) var(--font);
}
.review-done-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Fortschritt — dashboard (sub-project B). Stat tiles + per-level mastery bars. Palette rule:
   gold (--brand-pop) = reward/progress numbers, teal (--accent) = the live streak; the level
   fill is gold because it reports learning progress, never interaction. */
.stat-tiles {
  display: grid; gap: var(--space-4); margin-bottom: var(--space-8);
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.stat-tile {
  background: var(--surface); border: 1px solid var(--line-ui); border-radius: var(--radius-md);
  padding: var(--space-5); box-shadow: var(--shadow-raised);
}
.stat-tile-n { font: 600 var(--text-3xl)/1 var(--mono); letter-spacing: -0.02em; }
.stat-tile-n.gold { color: var(--gold-text); }
.stat-tile-n.teal { color: var(--accent); }
.stat-tile-l { display: block; margin-top: var(--space-2); color: var(--text-muted); font-size: var(--text-sm); }
/* Live "cards due" widget — an action CTA, so it leans on the interactive teal, never gold.
   The .is-done state calms to a neutral surface once the queue is empty. */
.fortschritt-review {
  display: flex; align-items: center; gap: var(--space-4);
  padding: var(--space-4) var(--space-5); margin-bottom: var(--space-5);
  background: var(--accent-soft); border: 1px solid var(--accent);
  border-radius: var(--radius-md); box-shadow: var(--shadow-raised);
}
.fortschritt-review[hidden] { display: none; }
.fortschritt-review.is-done { background: var(--surface); border-color: var(--line-ui); box-shadow: none; }
.freview-main { display: flex; align-items: center; gap: var(--space-4); flex: 1 1 auto; min-width: 0; }
.freview-count {
  font: 700 var(--text-3xl)/1 var(--mono); color: var(--accent);
  min-width: 1.6ch; text-align: center; letter-spacing: -0.02em;
}
.fortschritt-review.is-done .freview-count { color: var(--text-muted); }
.freview-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.freview-label { font: 600 var(--text-base) var(--font); }
.freview-sub { font-size: var(--text-sm); color: var(--text-muted); }
.freview-btn {
  flex: 0 0 auto; height: var(--touch-min); padding: 0 var(--space-5); cursor: pointer;
  border-radius: var(--radius-pill); border: 1px solid var(--accent); background: var(--accent);
  color: var(--selected-ink); font: 600 var(--text-sm) var(--font);
}
.freview-btn:hover { filter: brightness(1.06); }
.freview-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.fortschritt-review.is-done .freview-btn { background: var(--accent-soft); color: var(--accent); }
@media (max-width: 480px) {
  .fortschritt-review { flex-wrap: wrap; }
  .freview-btn { width: 100%; }
}
.fortschritt-panel { padding: var(--space-5); }
.fortschritt-panel-title { margin: 0 0 var(--space-4); font-size: var(--text-base); }
.lvlbar-row { display: grid; grid-template-columns: 40px 1fr 48px; gap: var(--space-3); align-items: center; margin-bottom: var(--space-3); }
.lvlbar-row:last-child { margin-bottom: 0; }
.lvlbar-badge { font: 600 var(--text-xs)/1 var(--mono); color: var(--text-muted); }
.lvlbar-track { height: 8px; background: var(--bg); border-radius: var(--radius-pill); overflow: hidden; }
.lvlbar-fill { height: 100%; background: var(--brand-pop); border-radius: var(--radius-pill); transition: width var(--duration-normal) var(--ease-out); }
.lvlbar-pct { font: 600 var(--text-xs) var(--mono); color: var(--gold-text); text-align: right; }
/* Trickiest-words panel — read-only mono chips. Neutral (not gold=reward, not teal=interactive):
   these flag attention, so they sit on the raised surface with a plain UI edge. */
.fortschritt-weak-sub { margin: 0 0 var(--space-4); font-size: var(--text-sm); color: var(--text-muted); }
.fortschritt-weak-list { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.weak-chip { font: 600 var(--text-sm)/1 var(--mono); color: var(--text);
  padding: var(--space-2) var(--space-3); border-radius: var(--radius-pill);
  border: 1px solid var(--line-ui); background: var(--surface-raised); }

/* "Dieses Gerät" panel — reuses the modal's .leaderboard-row / .history-row look. */
.fortschritt-sub { margin: var(--space-4) 0 var(--space-2); font-size: var(--text-sm); color: var(--text-muted); }
.fortschritt-sub:first-of-type { margin-top: 0; }
.fortschritt-empty { color: var(--text-muted); font-size: var(--text-sm); margin: 0; }

.setup-panel {
  display: none;
  margin: auto 0;
}

/* Brand landmark. A centred gold "W"-snake (WEFT_BRAND) crowns the home screen, echoing the board's
   solved-word snakes so the mark and the mechanic read as one idea. The stage caps the SVG's size;
   a soft gold drop-shadow lifts it off the page as a proper landmark, not a decorative doodle. */
.brand-hero {
  text-align: center;
  margin: 0 0 var(--space-6);
}
.brand-snake-stage {
  display: flex;
  justify-content: center;
  margin: 0 auto var(--space-3);
}
.brand-snake {
  width: 100%;
  max-width: 200px;
  height: auto;
  filter: drop-shadow(0 4px 14px rgba(255, 206, 0, 0.28));
  animation: bsFloat 6s ease-in-out infinite;
}
/* The travelling white glint is the signature "effect": a short dash chases along the whole body,
   like light running down a scale. pathLength="100" on the SVG path normalises the dash maths, so
   the same 6/94 split works no matter the mark's real length. */
.brand-snake .bs-glint {
  stroke-dasharray: 6 94;
  stroke-dashoffset: 100;
  animation: bsGlint 3.6s linear infinite;
}
/* The forked tongue flicks: mostly tucked, darting out on a slow beat — the small life-sign that
   makes the mark feel like a creature rather than a logo. */
.brand-snake .bs-tongue {
  animation: bsFlick 2.8s ease-in-out infinite;
}
@keyframes bsFloat {
  0%,
  100% {
    transform: translateY(0);
  }
  50% {
    transform: translateY(-5px);
  }
}
@keyframes bsGlint {
  to {
    stroke-dashoffset: 0;
  }
}
@keyframes bsFlick {
  0%,
  70%,
  100% {
    opacity: 0.25;
  }
  80%,
  92% {
    opacity: 1;
  }
}
/* Respect the OS "reduce motion" pledge: the mark stays fully drawn and legible, just still. */
@media (prefers-reduced-motion: reduce) {
  .brand-snake,
  .brand-snake .bs-glint,
  .brand-snake .bs-tongue {
    animation: none;
  }
  .brand-snake .bs-glint {
    opacity: 0;
  }
  .brand-snake .bs-tongue {
    opacity: 1;
  }
}
.brand-story-title {
  font-size: var(--text-lg);
  font-weight: 600;
  margin: 0 0 var(--space-1);
}
.brand-story {
  color: var(--text-muted);
  font-size: var(--text-sm);
  margin: 0 auto;
  max-width: 34ch;
}

/* Roadmap teaser on the home screen. Reads as a soft banner, not a button: a gold left-border ties
   it to the brand/reward palette, the "Bald" badge marks it as upcoming, and the whole thing stays
   low-contrast so it announces the next feature without competing with the level cards below it. */
.feature-teaser {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  margin: 0 0 var(--space-6);
  padding: var(--space-3) var(--space-4);
  background: var(--surface);
  border: 1px solid var(--line-ui);
  border-left: 3px solid var(--brand-pop);
  border-radius: var(--radius-md);
}
/* `display:flex` above outranks the UA `[hidden]{display:none}`, so the attribute alone can't hide
   it. This scoped rule (higher specificity than the flex rule) restores `hidden` for the teaser. */
.feature-teaser[hidden] {
  display: none;
}
/* Solid gold pill with near-black ink. A gold-tint-on-gold-text badge went pale on the light theme;
   a filled pill reads punchy on BOTH themes and matches how a real "coming soon" tag wants to draw
   a glance. Dark ink because gold is a light hue — dark text is the high-contrast pairing. */
.teaser-badge {
  flex-shrink: 0;
  padding: 2px var(--space-2);
  border-radius: var(--radius-pill);
  background: var(--brand-pop);
  color: var(--flag-ink);
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}
.teaser-text {
  color: var(--text-muted);
  font-size: var(--text-sm);
}

.setup-title {
  font-size: var(--text-xl);
  margin: 0 0 var(--space-2);
}

.setup-quick-start {
  display: block;
  width: 100%;
  max-width: 420px;
  margin: 0 auto var(--space-2);
  min-height: var(--touch-min);
}
.setup-quick-sub {
  margin: 0 auto var(--space-4);
  max-width: 420px;
  text-align: center;
  color: var(--text-muted);
  font-size: var(--text-sm);
  line-height: 1.45;
}

.setup-persona-topic {
  display: block;
  width: 100%;
  max-width: 420px;
  margin: 0 auto var(--space-5);
  min-height: var(--touch-min);
}
.setup-persona-topic[hidden] {
  display: none !important;
}

.setup-sub {
  color: var(--text-muted);
  font-size: var(--text-sm);
  margin: 0 0 var(--space-3);
}

/* Mirrors landing TrustBar — same promise at the app door (POSITIONING-2026 §6). */
.setup-trust {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--space-2);
  margin: 0 0 var(--space-6);
  padding: 0;
  list-style: none;
}
.setup-trust li {
  padding: var(--space-1) var(--space-3);
  border-radius: var(--radius-pill);
  border: 1px solid var(--line-ui);
  background: var(--surface);
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.level-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
  gap: var(--space-3);
}
/* On desktop the tighter frame (--app-max) fits 5 auto-fit columns, which would orphan the 6th
   card on a second row. A fixed 2×3 matrix is deliberate instead of ragged and gives each card a
   larger, more inviting footprint. */
@media (min-width: 1024px) {
  .level-grid { grid-template-columns: repeat(3, 1fr); }
}

.level-card {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-2);
  padding: var(--space-4);
  text-align: left;
  background: var(--surface);
  border: 1px solid var(--line-ui);
  border-radius: var(--radius-md);
  color: var(--text);
  cursor: pointer;
  font: inherit;
  /* A fixed floor keeps every card the same height, so the grid reads as an even matrix instead
     of a ragged one where cards with longer descriptions grow taller than their row-mates. */
  min-height: 150px;
  transition: border-color var(--duration-fast) var(--ease-out),
              transform var(--duration-fast) var(--ease-out);
}

.level-card:hover:not(:disabled) {
  border-color: var(--accent);
  transform: translateY(-2px);
}
/* Press settles the lifted card back down and in — a tactile "commit" before the level loads. */
.level-card:active:not(:disabled) {
  transform: translateY(0) scale(0.985);
}

.level-card:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.level-card[aria-current="true"] {
  border-color: var(--accent);
  background: var(--accent-soft);
}

.level-card:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

/* The chooser arrives with a gentle staggered rise — the same entrance grammar the welcome card and
   win-screen stats use, so every first surface shares one motion language. Uses fill-mode:backwards
   (NOT both) so once the cards land they revert to their normal styles and the :hover lift / :active
   press still work; both would pin transform:none and kill those. Only plays when the setup panel is
   the active state; silenced under reduced-motion. */
[data-ui-state="setup"] .level-card { animation: level-card-in 0.42s var(--ease-out) backwards; }
[data-ui-state="setup"] .level-card:nth-child(1) { animation-delay: 0.05s; }
[data-ui-state="setup"] .level-card:nth-child(2) { animation-delay: 0.10s; }
[data-ui-state="setup"] .level-card:nth-child(3) { animation-delay: 0.15s; }
[data-ui-state="setup"] .level-card:nth-child(4) { animation-delay: 0.20s; }
[data-ui-state="setup"] .level-card:nth-child(5) { animation-delay: 0.25s; }
[data-ui-state="setup"] .level-card:nth-child(6) { animation-delay: 0.30s; }
@keyframes level-card-in {
  0% { opacity: 0; transform: translateY(10px); }
  100% { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  [data-ui-state="setup"] .level-card { animation: none; }
}

/* The growth-arc glyph (seed → crown) gives each card an instant, friendly visual anchor and makes
   the selector scannable before a single word is read. Sized as the card's hero mark, above the
   CEFR tag. Decorative only (aria-hidden), so the badge still carries the real label. */
.level-emoji {
  font-size: 1.9rem;
  line-height: 1;
}

/* A fixed square so A1…C2 all occupy the same footprint — the badges line up across the grid. */
.level-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 40px;
  height: 28px;
  padding: 0 var(--space-2);
  font-family: var(--mono);
  font-size: var(--text-sm);
  font-weight: 600;
  border-radius: var(--radius-sm);
  background: var(--accent-soft);
  color: var(--accent);
}

.level-name { font-weight: 600; }

.level-desc,
.level-count {
  color: var(--text-muted);
  font-size: var(--text-sm);
}

/* Pins the word-count to the bottom edge so it aligns across cards of differing description length. */
.level-count {
  margin-top: auto;
  font-variant-numeric: tabular-nums;
}

/* Topic packs: a second, subordinate chooser under the CEFR grid. Separated by a hairline rule
   and a smaller heading so it reads as "or, a themed detour" rather than a rival to the levels —
   the level grid stays the primary call. */
.topic-block {
  margin-top: var(--space-6);
  padding-top: var(--space-5);
  border-top: 1px solid var(--line-ui);
}

.topic-title {
  margin: 0 0 var(--space-2);
  font-size: var(--text-md);
  font-weight: 600;
  color: var(--text);
}

.topic-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  gap: var(--space-3);
}

/* Column cards (emoji, then label, then count), shorter than the level cards so the topic row
   reads as the lighter, secondary option. A column stack — not a horizontal row — because topic
   names are long and often a single unbreakable word (Wohnungssuche); the full card width lets
   them wrap instead of overrunning a right-aligned count. Interactive, so teal on hover/focus
   per the palette rule; the emoji is the scannable anchor. */
.topic-card {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-2);
  padding: var(--space-3) var(--space-4);
  text-align: left;
  background: var(--surface);
  border: 1px solid var(--line-ui);
  border-radius: var(--radius-md);
  color: var(--text);
  cursor: pointer;
  font: inherit;
  min-height: 108px;
  transition: border-color var(--duration-fast) var(--ease-out),
              transform var(--duration-fast) var(--ease-out);
}

.topic-card:hover {
  border-color: var(--accent);
  transform: translateY(-2px);
}

.topic-card:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.topic-card[aria-current="true"] {
  border-color: var(--accent);
  background: var(--accent-soft);
}

.topic-card--focus {
  border-color: var(--accent);
  box-shadow: 0 0 0 1px var(--accent-soft);
}

.topic-emoji {
  font-size: 1.6rem;
  line-height: 1;
}

.topic-name {
  font-weight: 600;
}

/* Pinned to the bottom edge so the count lines up across cards of differing name length. */
.topic-count {
  margin-top: auto;
  color: var(--text-muted);
  font-size: var(--text-sm);
  font-variant-numeric: tabular-nums;
}

/* Tappable: this is the fastest route to the level selector. It used to be a loud filled-teal pill
   that competed with the wordmark. Now it rests calm — neutral outline on the surface — and only
   reveals its interactive teal on hover/focus (teal = interactive-only per the palette rule). The
   level itself (A1…C2) is the one highlight, printed in brand gold to tie it to the logo. */
.chip-level {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  min-height: var(--touch-min);
  padding: var(--space-1) var(--space-2);
  border: 1px solid var(--line-ui);
  border-radius: var(--radius-pill);
  background: var(--surface);
  color: var(--text-muted);
  cursor: pointer;
  transition: background var(--duration-fast) var(--ease-out),
              border-color var(--duration-fast) var(--ease-out),
              color var(--duration-fast) var(--ease-out);
}

.chip-level:hover {
  border-color: color-mix(in srgb, var(--accent) 45%, transparent);
  color: var(--text);
}
.chip-level:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.chip-level-label {
  font-size: var(--text-xs);
  font-weight: 500;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  opacity: 0.75;
}

.chip-level-value {
  font-family: var(--mono);
  font-size: var(--text-sm);
  font-weight: 700;
  letter-spacing: 0.02em;
  color: var(--gold-text);
}

.chip-level[hidden] { display: none !important; }

/* The header reads as ONE bar, not three floating clusters: a hairline border-bottom draws the
   band edge-to-edge and a whisper of surface tint lifts it a step off the board below, so the
   nav feels like an intentional 2026 top-bar. The wordmark halo still works over the faint tint
   (it traces the glyph, not the plate). */
.play-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  min-height: var(--header-h);
  padding: 0 var(--space-2);
  flex-shrink: 0;
  /* Frameless (north star #1): no surface tint, no boxy band. The brand floats on the page and is
     separated from the board by a single faint hairline only — the flag wordmark already carries a
     halo to lift it off the near-black bg, so it needs no plate behind it. */
  background: transparent;
  border-bottom: 1px solid color-mix(in oklch, var(--line-ui) 60%, transparent);
}

/* The controls (timer, level, gear) sit as a bare row in the header — no wrapping
   pill/box behind them. This is a layout-only flex container: it keeps the elements in order and
   positions them, but draws no surface, border or radius of its own, so each control's own pill
   reads directly against the header. */
.play-hud {
  display: flex;
  align-items: center;
  /* This gap is the space BETWEEN the two clusters (STATUS: timer+level | ACTIONS:
     gear); each cluster keeps its own tighter 8px internal gap. Making the
     between-group gap wider than the within-group gap stops the timer from crowding the
     gear on the phone header and makes the two groups read as distinct. */
  gap: var(--space-4);
  min-width: 0;
  /* Base (phone / narrow): the row hugs its content and sits at the right edge. The controls
     don't shrink (timer/gear have min-widths), so stretching them here would overflow the 16px app
     gutter — which is exactly what pushed the gear past the bezel. The row only STRETCHES on the
     wide desktop header where a real void exists (see the min-width:1024px override below). */
  flex: 0 1 auto;
  justify-content: flex-end;
}

/* The wordmark is live text now (not a PNG) so each letter can carry its own German-flag
   gradient. background-clip:text pours the schwarz-rot-gold through the glyph shapes; the top
   band is --flag-ink, which the theme swaps (warm-white on dark, black on light) so the flag
   never vanishes. The tagline sits directly under the name as the brand promise. */
.play-header h1.wordmark {
  margin: 0;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 6px;
  line-height: 1;
  flex-shrink: 0;
}
/* Wordmark backing = NONE. The flag is true black-red-gold on both themes (see --flag-ink), and on
   the near-black dark header the black band is lifted by a faint LIGHT HALO on .wm-word below — a
   drop-shadow that traces the glyph edge — instead of a plate. This span is a bare structural
   wrapper only, which is what finally removes the "awful white background" the plate kept
   reintroducing. */
.wm-plate {
  display: inline-flex;
  align-items: center;
  /* Positioning context for the .wm-snake flourish, which is absolutely placed over the letters so
     it can never add width to the header (the tools cluster must stay reachable on a 390px phone). */
  position: relative;
}
.wm-word {
  display: flex;
  font-family: var(--font);
  font-weight: 800;
  /* Fluid: 30px on a phone, growing to 54px on a wide screen so the brand truly owns the header
     hero band instead of reading as a small label floating in empty space. */
  font-size: clamp(30px, 4.4vw, 54px);
  /* line-height:1 made the gradient box bottom sit at the baseline, so the 'g' descender fell
     below the 100% gold stop and rendered transparent (an invisible tail). A touch of leading
     plus bottom padding drops the box below the baseline so descenders stay inside the fill. */
  line-height: 1.12;
  padding-bottom: 0.06em;
  letter-spacing: -0.02em;
  /* Dark theme (default): a faint warm-white halo separates the black flag-band from the near-black
     header without any box. drop-shadow traces the painted glyph, so it edges the black letters even
     though the fill is a clipped gradient. The light theme drops this — black reads on cream alone. */
  filter: drop-shadow(0 0 1px rgba(242, 239, 230, 0.6))
    drop-shadow(0 1px 1.5px rgba(242, 239, 230, 0.35));
}
[data-theme="hell"] .wm-word {
  filter: none;
}
.wm-word span {
  background: linear-gradient(
    180deg,
    var(--flag-ink) 0 40%,
    var(--flag-red) 48% 60%,
    var(--flag-gold) 68% 100%
  );
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}
.wm-tag {
  font-family: var(--font);
  font-size: clamp(12px, 1.5vw, 17px);
  font-weight: 600;
  letter-spacing: 0.01em;
  color: var(--text-muted);
  white-space: nowrap;
}
/* The brand promise stays on EVERY screen (user: "must be everywhere"). On tight phones it can no
   longer stay a single nowrap line without stretching the wordmark column into the tools, so it is
   allowed to wrap and shrinks a step — present and legible instead of hidden. */
@media (max-width: 479px) {
  .wm-tag {
    white-space: normal;
    font-size: 11px;
    line-height: 1.25;
  }
}

/* Header logo snake — a small living flourish curling over the wordmark, echoing the board's
   solved-word snakes and the setup hero's "W". Sized in em so it tracks the fluid wordmark; placed
   absolutely inside .wm-plate so it never widens the header. Colours are set here (not on the SVG)
   so the mark follows --flag-gold across both themes, and the travelling glint reuses the shared
   bsGlint keyframe. All motion is disabled under prefers-reduced-motion. */
.wm-snake {
  position: absolute;
  /* left/top are ignored while offset-path is active (the token rides the path anchored at its own
     centre); they only matter in the @supports-not fallback below. */
  left: 0;
  top: 0;
  width: 1.55em;
  height: auto;
  overflow: visible;
  pointer-events: none;
  z-index: 1;
  filter: drop-shadow(0 1px 1.5px rgba(255, 206, 0, 0.35));
  /* The closed loop: a rounded RECTANGLE (inset with a corner radius) sized in % of the .wm-plate
     padding box, so the snake traces a SQUARE path hugging the wordmark — straight along the edges,
     turning at the rounded corners — and it auto-scales with the fluid logo. Negative insets push
     the loop just OUTSIDE the letters; the top inset is kept small so the top edge never climbs past
     the header's top padding (the token is ~1.5em tall). offset-rotate:auto turns the head-right
     token so it always leads and visibly banks around each corner. */
  offset-path: inset(-1% -1% -28% -1% round 13px);
  offset-rotate: auto;
  offset-distance: 0%;
  /* Rounder corners (13px) let the head glide through each bend instead of snapping 90°, so it reads
     as a creature crawling rather than a token dragged around a hard box. Travel stays LINEAR — a
     seamless loop, no speed pulse at the seam — and the livelier body wave below carries the life. */
  animation: wmSnakeOrbit 14s linear infinite;
}
.wm-snake-halo {
  fill: none;
  stroke: var(--flag-gold);
  stroke-width: 6;
  stroke-linecap: round;
  stroke-linejoin: round;
  opacity: 0.16;
  animation: wmSnakeSlither 1.15s ease-in-out infinite;
}
.wm-snake-body {
  fill: none;
  stroke: var(--flag-gold);
  stroke-width: 4.2;
  stroke-linecap: round;
  stroke-linejoin: round;
  /* The body d-morphs through a 4-phase travelling wave, so the snake undulates as it travels —
     a real slither, not a rigid token sliding along the path. Halo shares the same motion above. */
  animation: wmSnakeSlither 1.15s ease-in-out infinite;
}
.wm-snake-head ellipse {
  fill: var(--flag-gold);
}
.wm-snake-eye {
  fill: #fff;
}
.wm-snake-pupil {
  fill: #1a1a1a;
}
.wm-snake-tongue {
  fill: none;
  stroke: #e23b3b;
  stroke-width: 1;
  stroke-linecap: round;
  animation: bsFlick 2.8s ease-in-out infinite;
}
@keyframes wmSnakeOrbit {
  to {
    offset-distance: 100%;
  }
}
/* Travelling wave: the crest migrates head-ward across the body each cycle so the snake undulates.
   On-curve points (x=3,13,24) stay at y=8; only the control points move, so the ends stay pinned to
   the tail origin and the neck of the head. Body + halo run this in lock-step. Browsers without the
   animatable `d` property simply keep the static base curve — still a snake, just not slithering. */
@keyframes wmSnakeSlither {
  0%,
  100% {
    d: path("M3 8 C6 4 10 4 13 8 C16 12 20 12 24 8");
  }
  25% {
    d: path("M3 8 C6 8 10 4 13 8 C16 8 20 12 24 8");
  }
  50% {
    d: path("M3 8 C6 12 10 12 13 8 C16 4 20 4 24 8");
  }
  75% {
    d: path("M3 8 C6 8 10 12 13 8 C16 8 20 4 24 8");
  }
}
/* Fallback for browsers without motion-path support: no orbit — the snake simply perches at the
   left of the wordmark as a static flourish (still theme-aware, still adds no layout width). */
@supports not (offset-path: inset(0% round 1px)) {
  .wm-snake {
    left: -0.15em;
    top: 0.05em;
    width: 2em;
    animation: none;
  }
}
@media (prefers-reduced-motion: reduce) {
  .wm-snake {
    animation: none;
    /* Rest at the upper-right of the loop — a calm glide-over pose, not mid-descent. */
    offset-distance: 82%;
  }
  .wm-snake-body,
  .wm-snake-halo,
  .wm-snake-tongue {
    animation: none;
  }
}

/* Progress dashboard — three glanceable stats above the board. Gold = the learning number
   (words), teal = the interactive/retention number (streak), neutral = puzzles solved. */
.stat-strip {
  display: flex;
  gap: var(--space-2);
  margin: 0 0 var(--space-4);
}
.stat-strip .stat {
  flex: 1;
  min-width: 0;
  background: var(--surface);
  border: 1px solid var(--line-ui);
  border-radius: var(--radius-md);
  padding: var(--space-2) var(--space-3);
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
}
/* Number + label travel as one block so the tile lays out as [icon][text] and the whole cluster
   is centered — no more content stranded top-left in a wide flex:1 box. */
.stat-body {
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.stat-n {
  font-family: var(--font);
  font-weight: 800;
  font-size: var(--text-xl);
  line-height: 1;
  color: var(--text);
  font-variant-numeric: tabular-nums;
}
.stat-n--gold { color: var(--gold-text); }
.stat-n--teal { color: var(--accent); }
/* Topic glyph above each number — a quiet emoji cue (puzzle/brain/streak) that makes the
   dashboard scannable at a glance without competing with the tabular number below it. */
.stat-ico {
  font-size: var(--text-sm);
  line-height: 1;
  opacity: 0.85;
}
.stat-l {
  font-family: var(--font);
  font-size: var(--text-xs);
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Mobile compaction. On a phone the whole play area (stats + word list + coach + board + actions)
   overran the fold, so the board's bottom row sat below the screen edge before you could scroll.
   Here the stat tiles collapse from three stacked rows (icon / number / label) to two — icon+number
   share a baseline, the label wraps under — and the vertical rhythm tightens. Nothing is removed;
   the board just rises far enough that the full grid reads in one glance. Desktop keeps the taller,
   airier dashboard. */
@media (max-width: 1023px) {
  .stat-strip { margin-bottom: var(--space-3); }
  .stat-strip .stat { padding: var(--space-2); gap: var(--space-2); }
  .stat-n { font-size: var(--text-lg); }
  .play-layout { gap: var(--space-3); }
}
/* Phone: the progress dashboard leaves the play screen entirely. A newcomer's very first frame was
   three "0" stat cards (empty-state gamification reads as "you've done nothing" — demotivating), and
   they pushed the board — the actual game and the reward loop — below the fold. The same numbers now
   surface where they mean something: as a celebration on the solve screen (peak-end), and on the
   Fortschritt tab (one thumb-tap away in the bottom bar). Tablet/desktop keep the inline dashboard. */
@media (max-width: 720px) {
  .stat-strip { display: none; }
}

/* The theme and menu buttons travel together as ONE flex item. Dropping a second button
   straight into .play-header would have made it a fourth child of a space-between row, which
   spreads the wordmark and chips apart instead of keeping the controls in the corner. */
.play-header-tools {
  display: flex;
  align-items: center;
  gap: var(--space-1);
  flex-shrink: 0;
}

/* Two-flag language switch — a segmented pill that shows DE and EN at once so the choice is
   explicit (a globe hid which language a tap gave you). Sits in the tools cluster at the same
   44px height as the gear button so the row stays one consistent band of controls. */
.lang-switch {
  display: inline-flex;
  align-items: center;
  height: var(--touch-min);
  padding: 3px;
  gap: 2px;
  border: 1px solid var(--line-ui);
  border-radius: var(--radius-pill);
  background: var(--surface);
  flex-shrink: 0;
}
.lang-opt {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  height: calc(var(--touch-min) - 8px);
  padding: 0 var(--space-2);
  border: none;
  border-radius: var(--radius-pill);
  background: transparent;
  color: var(--text-muted);
  font: inherit;
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: 0.02em;
  cursor: pointer;
  transition: background var(--duration-fast) var(--ease-out),
    color var(--duration-fast) var(--ease-out);
}
.lang-opt:hover { color: var(--text); }
/* Active language: aria-pressed is the single source of truth for both state and style. */
.lang-opt[aria-pressed="true"] {
  background: var(--accent-soft);
  color: var(--accent);
}
.lang-opt:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.lang-flag { font-size: 1.05em; line-height: 1; }
/* On the tightest phones the flag glyph alone carries the meaning (and on Windows the flag itself
   renders AS the letters "DE"/"EN"), so the separate code label can drop to reclaim header width. */
@media (max-width: 400px) {
  .lang-code { display: none; }
  .lang-opt { padding: 0 var(--space-1); }
}

.play-header-center {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  /* margin-left:auto pulls the clock/level cluster to the right so it sits beside the tools
     instead of floating dead-center in a void between the wordmark and the menu. The wordmark
     owns the left; timer · level · tools read as one grouped control band on the right. */
  margin-left: auto;
  /* A flex item defaults to min-width:auto, so this refused to shrink below its content and
     pushed the gear button off the right edge at 320px — the menu became unreachable. */
  min-width: 0;
}

/* Below ~480px the header is tight. Priorities: the wordmark, the timer, and the right tools
   cluster — where the streak now rides as a compact "🔥 N", the star of the retention loop that
   is the whole reason to come back. The level chip drops here: the board caption already prints
   the level (e.g. "7×7 B2"), and "Niveau ändern" still lives in the gear menu, so nothing is
   lost. Without this the center collapsed to width:0 and its contents overlapped both neighbours. */
@media (max-width: 479px) {
  #levelChip { display: none; }
  /* On the narrowest phones, trim the header's side padding and the wordmark↔tools gap so the
     controls band sits inside the 16px app gutter again. */
  .play-header { padding: 0 var(--space-1); gap: var(--space-1); }
  .play-header-tools { gap: 2px; }
}

/* The clock is a reference number, not the point of the game — so it reads quiet: small, muted,
   tabular (no width jitter as digits change). But it was TOO quiet — a bare grey number that read as
   plain text, not "a timer". A ⏱ glyph (::before, so the JS textContent update never wipes it) plus a
   soft inset pill now make it legible AS a clock while staying calm: no brand colour, no bold. The
   board caption and the solve screen still carry the "valuable" numbers; this just keeps time. */
.timer {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-family: var(--mono);
  font-size: var(--text-xs);
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  color: var(--text-muted);
  padding: 3px var(--space-2);
  border-radius: var(--radius-pill);
  /* Same hairline-pill base as the level chip / gear, so the whole control cluster
     reads as one pill family instead of a translucent grey blob breaking the row. */
  background: var(--surface);
  border: 1px solid var(--line-ui);
  min-width: 56px;
  justify-content: center;
}
/* No ⏱ glyph: the timer is display:none in every state (see the note below), so this decorated
   only dead markup — and it was one more emoji with no font behind it on Android. G4. */
/* Timer-as-pressure removed from the chrome ENTIRELY (every state). A running stopwatch reframes
   vocabulary practice as a race, which fights the "friendly, take your time" promise; on the solve
   screen it also duplicated the ZEIT stat (north star: one home per fact). The clock keeps ticking
   in the background (elapsedMs still feeds renderSolveStats on the win screen) — #timer just never
   renders. Learning, not the leaderboard, is the point. */
.timer { display: none; }

/* Buttons, chips, progress — see components.css */

.visually-hidden-tools {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
}

.play-shell {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-height: 0;
}

.play-layout {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  /* No flex-grow: the layout is content-height. It used to be flex:1 so it filled the play-shell,
     but the board no longer derives its height from that leftover space (see the aspect-ratio /
     width-driven sizing below). Growing it only stranded empty grid space beneath the content and
     shoved the sibling .play-action-bar to the very bottom of a tall viewport — the "huge gap
     between the puzzle and the Tipp button". Content-height keeps the action row directly under the
     board at every width; surplus viewport height falls below the bar instead. */
}

/* Desktop Learning Workspace: three columns — you-rail | board hero | word-rail.
   Below 1024px the existing single/two-column responsive layout is untouched and the
   you-rail is hidden (its data also lives in the stat-strip + solve screen on mobile). */
.you-rail { display: none; }

@media (min-width: 1024px) {
  /* Direction B "Hero + two-column": the play area now fills the whole capped app (--app-max),
     not a second 1040px sub-cap, so the board and rail spread into the width instead of stranding
     a small board in a void. The .app container is already centred at --app-max, so its side
     margins read as one balanced gutter. */
  .play-shell {
    max-width: 100%;
    width: 100%;
  }
  /* Three-column workspace: you-rail | board hero | word-rail. The board column takes all
     remaining width (minmax(0,1fr)) so it shrinks cleanly without overflowing.
     Option C: a faint unifying band holds the three columns as one workspace instead of three
     things floating in a void. It is kept much fainter than the rail-cards (which are solid
     --surface) so the cards and the board panel still read as elevated ABOVE it. The padding
     shrinks the board-column track, which resizeGrid() measures directly, so tile sizing stays
     correct. */
  /* Board-hero layout: the puzzle is the point, so it owns the whole left column (minmax(0,1fr))
     while the word-list and the progress rail STACK in one slim right column. Two fat side rails
     used to strand the board at ~52px tiles; giving it the full left width nearly doubles it.
     grid-template-areas relocates the you-rail (first in DOM) under the word-rail with zero HTML
     change; .board-column spans both rows so it stays a single tall hero. */
  /* Frameless & airy: the workspace is no longer a bordered band holding three panels — the
     three columns sit directly on the page background, so the BOARD is the only elevated
     surface. gap keeps them from touching; the unifying band/border/radius are gone. */
  /* Layout contract: cap the board track at --board-max and CENTRE the whole two-column cluster
     (board + rail) as a unit. The old minmax(0,1fr) let the board track grow to the full left
     width (~864px at --app-max) while the board itself caps at 560px, so the board floated in a
     ~150px lake on each side. Capping the track at --board-max and justify-content:center pushes
     the leftover width to balanced OUTER gutters instead of an inner lake. The .play-action-bar
     margin calc below resolves to that same gutter, so it stays aligned under the board. */
  .play-layout {
    display: grid;
    grid-template-columns: minmax(0, var(--board-max)) 324px;
    grid-template-areas:
      "board words"
      "board wcard"
      "board yourail"
      "actions .";
    align-items: start;
    justify-content: center;
    gap: var(--space-5);
  }
  /* The board column stretches to fill its full grid row and CENTRES the board inside it, so a
     capped board sits balanced in the middle of the left track instead of pinned top-left in a
     void. Centring both axes is what converts the leftover space into framing. */
  .board-column {
    grid-area: board;
    min-width: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
  }
  .word-rail { grid-area: words; }
  .wortkarte-card { grid-area: wcard; }
  .you-rail { grid-area: yourail; display: flex; flex-direction: column; gap: var(--space-4); }
  /* Frameless: the board floats on the page with no panel behind it — only its tiles carry
     elevation. resizeGrid() reads .grid-wrap's padding from computed style, so with padding: 0
     the grid simply fills the full column box (no board-math change needed). */
  .grid-wrap {
    background: transparent;
    border: 0;
    border-radius: 0;
    padding: 0;
    box-shadow: none;
  }
  /* Every element in the board stack — coach, caption, the board itself, progress bar and status —
     shares ONE capped width and centres as a unit, so the coach line and status bar line up flush
     with the board edges instead of stretching the full column while the board floats capped in the
     middle. width:100% fills up to the cap; align-items:center on .board-column centres the block. */
  .board-column > .coach,
  .board-column > .board-caption,
  .board-column > .grid-wrap,
  .board-column > .progress-wrap,
  .board-column > .status {
    width: 100%;
    max-width: var(--board-max);
  }

  /* ── Board hero sizing (no viewport lock) ────────────────────────────────────────────────
     The old design pinned the whole app to 100dvh with overflow:hidden and gave the board its
     height from flex:1 (the leftover viewport space). That trio caused the three bugs the user
     screenshotted: on a tall monitor the board stranded small at the TOP of a huge void, the rails
     grew an INNER scrollbar, and the action bar floated pinned at the very bottom. The page now
     flows and scrolls naturally (min-height:100dvh, no overflow:hidden), and the board takes its
     size from its own CAPPED WIDTH via aspect-ratio — not from viewport height. That breaks the
     resizeGrid() circularity without the lock: .grid-wrap has a definite width (100% of the track,
     capped at --board-max), aspect-ratio:1 derives a definite height from it, and resizeGrid()
     measures that stable square. fitTileSize() picks the short side, so a non-square grid simply
     centres inside the square with no overflow. */
  .app[data-ui-state="playing"][data-section="spielen"] {
    min-height: 100dvh;
  }
  .app[data-ui-state="playing"] .grid-wrap {
    aspect-ratio: 1;
  }
  /* Rails align to the TOP of the board row and grow with their own content — the page scrolls,
     not the rail — so a long word list or an expanded lesson never traps an inner scrollbar. */
  .app[data-ui-state="playing"] .word-rail,
  .app[data-ui-state="playing"] .you-rail {
    align-self: start;
  }
  /* Slim the active-play rail to the two things that change WITH the current game — the streak and
     the daily goal. Mastery and the solved-word gallery are reference panels that never move
     mid-trace and already have a permanent home in the Fortschritt view, so during play they are
     pure rail bloat that pushed the streak card down and stretched the rail. Hidden only while
     playing; setup/solved still show the full rail. */
  .app[data-ui-state="playing"] #masteryCard,
  .app[data-ui-state="playing"] #galleryCard {
    display: none;
  }
  /* The bar now lives INSIDE .play-layout (it has to, for Hint to precede the reveal card on
     mobile — backlog F/(ii)), so it is a grid item in the `actions` row of the board column and
     needs no width arithmetic. The old rule re-derived the board gutter by hand
     (`calc((100% − 324px − gap − --board-max) / 2)`); that number was only ever a restatement of
     the board track's own width, and it silently went wrong the moment either track changed.
     The track already caps at --board-max, so the bar simply fills it. */
  .play-action-bar {
    grid-area: actions;
    margin-top: var(--space-4);
  }

  /* Brand hero band. The header stops being a thin static strip: it gets height and air so the
     enlarged wordmark + tagline own the left, and the timer/level cluster balances the right.
     Brand LEFT · section nav CENTERED · actions RIGHT via a 3-column grid (1fr auto 1fr): the two
     1fr side tracks are always equal, so the nav stays dead-centered no matter how wide the brand
     or the actions cluster grow. This turns the old lopsided ~300px void on wide monitors into two
     symmetric halves that read as intentional nav negative space instead of one stranded hole.
     Base .play-header stays flex for <1024px (nav in flow 721–1023, fixed bottom bar ≤720). */
  .play-header {
    display: grid;
    grid-template-columns: 1fr auto 1fr;
    align-items: center;
    min-height: 92px;
    padding: var(--space-3) var(--space-4);
  }
  .play-header h1.wordmark { justify-self: start; }
  /* Playing = the BOARD is the hero, not the brand. The header steps down to a quiet nav strip
     and hands its vertical space back to the board (this is what feeds the 100dvh no-scroll lock
     in Task 3). Setup/solved keep the full brand hero above — there is no board competing for the
     height there. The tagline shrinks but stays (locked "brand promise everywhere"), it does not
     get removed. */
  .app[data-ui-state="playing"] .play-header {
    min-height: 60px;
    padding: var(--space-2) var(--space-4);
  }
  .app[data-ui-state="playing"] .wm-word {
    font-size: clamp(24px, 2.6vw, 34px);
  }
  .app[data-ui-state="playing"] .wm-tag {
    font-size: var(--text-xs);
  }
  /* Desktop nav shape (2026-08-23): the section nav is the CENTER grid track (justify-self:center),
     dead-centered between the brand and the actions cluster. The account + gear ACTIONS live in the
     right track (justify-self:end) and keep justify-content:flex-end so they read as one tight
     right cluster (pinned by test/ui-contract.test.js). Comfortable 8px between the nav links so the
     trio reads as a real menu, not a cramped cluster. (Supersedes the earlier margin-left:auto
     "grouped-left + one void" shape, which left a lopsided ~300px hole on wide monitors.) */
  .app-nav { gap: var(--space-2); justify-self: center; }
  .play-hud {
    flex: 0 1 auto;
    justify-content: flex-end;
    gap: var(--space-5);
    justify-self: end;
  }
  .play-header-center { gap: var(--space-2); }
  .play-header-tools { gap: var(--space-2); }
  /* Timer grows from a whisper to a legible clock pill so it fills the right side comfortably
     without stealing brand colour (still muted, still tabular). Height is pinned to --touch-min so
     it sits at the exact same 44px as the level/gear controls — the whole cluster then
     reads as ONE clean 44px nav band instead of one slightly-short pill breaking the line. */
  .timer {
    font-size: var(--text-base);
    padding: 0 var(--space-4);
    min-width: 92px;
    height: var(--touch-min);
  }

  /* Dashboard stats become a proper hero strip: bigger numbers and icons, roomier tiles, so the
     three reference figures read as a real dashboard rather than tiny captions. */
  .stat-strip { gap: var(--space-3); margin-bottom: var(--space-4); }
  .stat-strip .stat { padding: var(--space-3) var(--space-4); gap: var(--space-3); }
  .stat-n { font-size: var(--text-2xl); }
  .stat-ico { font-size: var(--text-2xl); opacity: 0.9; }
  .stat-l { font-size: var(--text-sm); }
  /* Cheap win: the streak already lives richly in the you-rail (streakCard + 7-day dots), so its
     third tile here is pure duplication on desktop. Drop only that tile — Solved and Words-learned
     are unique to the strip, so they stay. */
  .stat-strip .stat:nth-child(3) { display: none; }

  /* Playing = board-hero: the top dashboard strip and the footer are reference chrome that does
     not change mid-trace, so they yield their vertical space to the board. This is the reclaim
     that lets the 100dvh no-scroll lock (Task 3) keep the board large instead of shrinking it
     to fit a stacked column. Both return on setup/solved, where no board competes for height. */
  .app[data-ui-state="playing"] .stat-strip,
  .app[data-ui-state="playing"] .app-footer { display: none; }

  /* ── Desktop section pages (Wörter · Fortschritt) ─────────────────────────────────────────
     These views were pure mobile skin on desktop: the heading hugged the far-left app edge and the
     content ran the whole ~1208px width, so a short empty state stranded a giant full-bleed button
     in a sea of black. Contain each section in a centred reading column so it reads as a composed
     desktop page. The dict-grid inside still flows into as many card columns as fit within it. */
  .section-view {
    /* width:100% is load-bearing: .app is a flex column, and margin-inline:auto on a flex item
       disables the default stretch and collapses it to content width (~2 card columns). A definite
       width:100% restores a full-width box that max-width then caps at 960 and the auto margins
       centre — giving the intended 3-column reading measure. */
    width: 100%;
    max-width: 960px;
    margin-inline: auto;
  }
  /* The empty state becomes a centred CARD floating in the column instead of loose centred text
     with a full-width banner. It gets a real surface + border + shadow so it reads as an intentional
     "nothing here yet" panel, and vertical margin lifts it off the header into the page. */
  .section-empty {
    max-width: 460px;
    margin: var(--space-10) auto;
    padding: var(--space-10) var(--space-8);
    background: var(--surface);
    border: 1px solid var(--line-ui);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-raised);
  }
  /* The CTA is a BUTTON, not a banner: drop the mobile width:100% for an auto width with a
     comfortable min, centred by the card's text-align. This is the single change that most kills
     the "phone app blown up to 1080p" feeling the user flagged. */
  .section-empty .btn-primary {
    width: auto;
    min-width: 220px;
  }
  /* On the browse sections (Wörter · Fortschritt) there is no board competing for height, so the
     header is allowed to breathe: it drops the play-screen's compact 60px squeeze and gives the
     wordmark real room instead of jamming the flag glyphs against the top edge. During actual play
     the compact header still wins (board is the hero). */
  .app[data-section="woerter"] .play-header,
  .app[data-section="fortschritt"] .play-header {
    min-height: 84px;
    padding: var(--space-4);
  }
}

/* Frameless: rail sections are separated by a hairline divider instead of stacked cards, so the
   rail reads as one calm list on the page rather than several floating boxes. */
.rail-card {
  background: transparent;
  border: 0;
  border-top: 1px solid var(--line-ui);
  border-radius: 0;
  padding: var(--space-4) 0 0;
}
.rail-card:first-child { border-top: 0; padding-top: 0; }

.board-column {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  min-width: 0;
}

.board-caption {
  margin: 0;
  text-align: center;
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: var(--space-1) var(--space-2);
}

.board-caption #puzzleName {
  font-size: var(--text-base);
  font-weight: 700;
  color: var(--text);
  letter-spacing: 0.02em;
}

/* ── Idle coach bar (guidance above the board) ─────────────────────────────────────────────
   Three looks, chosen by the data-level attribute the JS sets: guide (quiet), nudge (teal,
   pairs with the pulsing Tipp button it points at), reveal (gold — help delivered). Palette
   rule kept: teal = interactive cue, gold = brand-pop highlight. */
.coach {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-1) var(--space-3);
  border: 1px solid var(--line-ui);
  border-left: 3px solid var(--line-ui);
  border-radius: var(--radius-sm);
  background: var(--surface-raised);
  transition: border-color var(--duration-normal) var(--ease-out),
    background var(--duration-normal) var(--ease-out);
}
.coach[hidden] { display: none; }
.coach-icon { font-size: var(--text-base); line-height: 1; flex-shrink: 0; }
/* The tip and cheer lines carry their OWN leading/trailing emoji (🖼️, 💪 …), so the fixed 💡 would
   double up (the "two emojis look awkward" report). Drop the fixed icon on exactly those two tiers;
   guide/nudge/reveal keep it because their strings have no emoji of their own. */
.coach[data-level="tip"] .coach-icon,
.coach[data-level="cheer"] .coach-icon { display: none; }
.coach-text {
  margin: 0;
  /* Legible but compact: full --text (not muted) at the small size + medium weight so the line
     reads as real guidance without stealing vertical space from the board (the hero). The
     countdown stays muted to keep hierarchy. */
  font-size: var(--text-sm);
  line-height: 1.35;
  font-weight: 500;
  color: var(--text);
}
.coach-countdown {
  margin-left: auto;
  font-size: var(--text-xs);
  font-variant-numeric: tabular-nums;
  color: var(--text-muted);
  white-space: nowrap;
}
/* On phones the coach line is the height-hog above the board (the hero). The countdown is a
   decorative "a tip is coming" cue — the idle-escalation timer fires regardless — so drop it here
   and let the guidance text use the full width, keeping the coach to one or two lines. */
@media (max-width: 479px) {
  .coach-countdown { display: none; }
}
.coach[data-level="nudge"] {
  border-color: color-mix(in oklch, var(--accent) 40%, var(--line-ui));
  border-left-color: var(--accent);
  background: var(--accent-soft);
}
.coach[data-level="reveal"] {
  border-color: color-mix(in oklch, var(--brand-pop) 40%, var(--line-ui));
  border-left-color: var(--gold-text);
  background: color-mix(in srgb, var(--brand-pop) 8%, transparent);
}
/* First-word micro-win: subtle board pulse (mobile + desktop). */
@media (prefers-reduced-motion: no-preference) {
  .grid-wrap.grid-celebrate {
    animation: grid-celebrate 0.55s var(--ease-out);
  }
}
@keyframes grid-celebrate {
  0%, 100% { transform: scale(1); }
  45% { transform: scale(1.025); }
}

/* In-play article probe (backlog F). Shares the coach's slot above the board: the coach is an
   idle guide and this fires on a solve, so they are never wanted simultaneously and the strip
   costs no extra vertical space. Teal border = interactive, matching .gender-chip; it must NOT
   take the gold reward treatment, because at render time the question is unanswered — gold
   arrives on .gender-chip.is-correct, which is the answer. */
/* ONE row, and the 44px chips are the whole height. Measured at 375x812: the board ends at 792 with
   20px of slack, and hiding the coach returns its 26px — so the probe's entire budget is 46px. A
   two-row strip measured 85px and pushed the board's last row 39px off screen at the exact moment of
   a solve, which trades the reveal-card defect for a worse one: the board is the hero. Hence no
   vertical padding (the chips' own min-height sets the box) and nowrap. */
.article-probe {
  display: flex;
  flex-wrap: nowrap;
  align-items: center;
  gap: var(--space-2);
  padding: 0 var(--space-2);
  border: 1px solid color-mix(in oklch, var(--accent) 40%, var(--line-ui));
  border-left: 3px solid var(--accent);
  border-radius: var(--radius-sm);
  background: var(--accent-soft);
}
.article-probe[hidden] { display: none; }
/* The probe and the coach share ONE slot, and the probe outranks it: the question — and the gold
   answer it resolves to — is the lesson, while the coach's nudge is decoration. Two strips stacked
   here push the board, the hero, down; measured at 375x812 the pair clipped it 40px off screen.
   Declarative on purpose. The first attempt hid the coach from JS whenever a question stood, but
   setCoach() sets `box.hidden = false` from an idle timer that fires long afterwards, so the coach
   reclaimed a slot the resolved strip was still occupying. Ownership belongs to whoever is actually
   in the box — a rendering fact — so CSS decides it, no timer can race it, and lib/coach.js needs
   no change. Releases itself the moment clearArticleProbe() hides the strip. */
.board-column:has(.article-probe:not([hidden])) .coach { display: none; }
/* Overrides the inline-flex + margin the quiz carries as an inline pill inside .wcard — here it
   is a row item, not a run-in before a word. */
.article-probe .lesson-gender-quiz { margin-right: 0; }
.article-probe-ask {
  margin: 0;
  display: flex;
  flex-wrap: nowrap;
  align-items: baseline;
  gap: var(--space-2);
  min-width: 0;
}
.article-probe-word {
  font-weight: 700;
  font-size: var(--text-base);
  color: var(--text);
}
/* The word is never truncated — it IS the thing being asked about, and an ellipsised prompt is not a
   prompt. So the question text is what yields when the row is narrow: word 91px + question 75px +
   chips 142px + gaps = 324px against 319px of content width at 375px. It is hidden below 420px, not
   deleted: the `aria-label` on .lesson-gender-quiz states the question on every viewport, so screen
   readers never hear three bare buttons, and a sighted learner sees "Auswirkung [der][die][das]" —
   the word plus its three candidate articles, which reads as the question it is. */
.article-probe-q { display: none; }
@media (min-width: 420px) {
  .article-probe-q {
    display: inline;
    font-size: var(--text-xs);
    color: var(--text-muted);
  }
}

/* Reward tier: shown for a few seconds after each solved word (gold = reward, palette rule). */
.coach[data-level="cheer"] {
  border-color: color-mix(in oklch, var(--brand-pop) 40%, var(--line-ui));
  border-left-color: var(--gold-text);
  background: color-mix(in srgb, var(--brand-pop) 8%, transparent);
}

/* The Tipp button pulses while the coach is nudging toward it (tier 1). */
@keyframes hint-pulse {
  0%, 100% { box-shadow: 0 0 0 0 color-mix(in oklch, var(--accent) 55%, transparent); }
  50% { box-shadow: 0 0 0 6px color-mix(in oklch, var(--accent) 0%, transparent); }
}
.hint-pulse {
  border-color: var(--accent);
  color: var(--accent);
  animation: hint-pulse 1.4s var(--ease-out) infinite;
}
@media (prefers-reduced-motion: reduce) {
  .hint-pulse { animation: none; }
}

/* One pill per fact. Size and level are the two the player scans for, so they carry
   weight; everything else stays quiet so it does not compete with the board. */
.puzzle-tags {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--space-1);
}

.puzzle-tag {
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  padding: 3px var(--space-2);
  border-radius: var(--radius-pill);
  border: 1px solid transparent;
  color: var(--text-muted);
  background: var(--surface-2, var(--surface));
}

.puzzle-tag--size {
  font-family: var(--mono);
  letter-spacing: 0.02em;
  text-transform: none;
  color: var(--text);
  border-color: var(--line-ui);
  background: transparent;
}

.puzzle-tag--level {
  font-family: var(--mono);
  letter-spacing: 0.02em;
  text-transform: none;
  color: var(--accent);
  background: var(--accent-soft);
  border-color: color-mix(in srgb, var(--accent) 35%, transparent);
}

/* Topic-mode caption tag: a plain named label (e.g. "Behörde & Aufenthalt"), so drop the
   uppercase/tracking a CEFR code wants and let it read as prose on a neutral outlined pill. */
.puzzle-tag--topic {
  letter-spacing: 0.02em;
  text-transform: none;
  color: var(--text);
  border-color: var(--line-ui);
  background: transparent;
}

.grid-wrap {
  position: relative;
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
}

/* Board generation runs on the visitor's own device and blocks the main thread — measured
   at 130-500ms on desktop, several times that on a phone. The status line is
   screen-reader-only, so without this the player stares at an empty board and assumes the
   game is broken. Absolutely positioned so it never enters resizeGrid()'s measurement of
   .grid-wrap.

   The 120ms delay is what stops a fast board from flashing an overlay nobody can read. It
   has to live here rather than in a setTimeout, because generation freezes the main thread:
   a JS timer could only fire once generation is already over. Opacity animations run on the
   compositor thread, which keeps ticking while JS is blocked. */
.board-loader {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-3);
  background: color-mix(in oklch, var(--bg) 72%, transparent);
  border-radius: var(--radius-md);
  opacity: 0;
  animation: board-loader-in var(--duration-fast) var(--ease-out) 120ms forwards;
}

.board-loader[hidden] {
  display: none;
}

.board-loader-text {
  font-size: var(--text-sm);
  color: var(--text-muted);
}

.board-loader-dots {
  display: flex;
  gap: var(--space-2);
}

.board-loader-dots i {
  width: 10px;
  height: 10px;
  border-radius: var(--radius-pill);
  background: var(--accent);
  animation: board-loader-pulse 1s var(--ease-out) infinite;
}

.board-loader-dots i:nth-child(2) { animation-delay: 0.15s; }
.board-loader-dots i:nth-child(3) { animation-delay: 0.3s; }

@keyframes board-loader-in {
  to { opacity: 1; }
}

@keyframes board-loader-pulse {
  0%, 100% { opacity: 0.25; transform: scale(0.8); }
  50%      { opacity: 1;    transform: scale(1); }
}

/* The tokens zero out --duration-* under reduced motion, but a keyframe animation ignores
   those; it needs its own opt-out or it keeps pulsing. The overlay itself keeps its
   animation — its duration is already 0ms here, so it just snaps in after the delay. */
@media (prefers-reduced-motion: reduce) {
  .board-loader-dots i {
    animation: none;
    opacity: 0.6;
  }
}

.board {
  position: relative;
  touch-action: none;
  width: 100%;
  max-width: 100%;
  overflow: hidden;
}

/* While a finger is actively tracing a word, freeze the page so the board cannot pan under the
   drag. The play view can be taller than a small phone's viewport, so without this a downward
   drag scrolls the whole screen instead of extending the path. The root element is the scroll
   container, so the lock lives on <html> (locking only <body> leaves the page scrollable).
   overflow:hidden preserves the current scroll position — no jump — and lasts only for the
   gesture, so between moves the page scrolls normally and the word rail stays reachable.
   Toggled in onPointerDown/Up. */
html.board-dragging,
html.board-dragging body {
  overflow: hidden;
  touch-action: none;
}

.grid {
  --grid-gap: 6px;
  display: grid;
  gap: var(--grid-gap);
  user-select: none;
  touch-action: none;
  grid-template-columns: repeat(var(--cols, 5), var(--tile-size, 60px));
  grid-template-rows: repeat(var(--rows, 5), var(--tile-size, 60px));
  position: relative;
  z-index: 1;
  margin: 0 auto;
  width: fit-content;
  transform-origin: center center;
}

.line-svg {
  position: absolute;
  pointer-events: none;
  z-index: 0;
  overflow: visible;
}

.cell {
  display: flex;
  align-items: center;
  justify-content: center;
  /* Use the dedicated tile tokens (previously the cell borrowed flat --surface, which on dark
     sat only ~4% above the board field and read as a dead cell). --tile + --elev-tile give an
     inset top-light + whisper shadow so each cell reads as a tactile raised key. */
  background: var(--tile);
  border: 1px solid var(--tile-border);
  border-radius: var(--radius-tile);
  box-shadow: var(--elev-tile);
  transition: background var(--duration-fast) var(--ease-out),
    transform var(--duration-fast) var(--ease-out);
  font-family: var(--mono);
  /* Scales with the tile. The old 24px floor was taller than a small tile's inner box, so a
     shrunk cell would have clipped its own letter — including Ä/Ö/Ü, whose umlaut sits above
     the cap height and is the first thing to get cut. */
  font-size: max(15px, calc(var(--tile-size, 60px) * 0.42));
  font-weight: 600;
  color: var(--ink);
  cursor: pointer;
  position: relative;
  touch-action: none;
  user-select: none;
  z-index: 1;
}

/* WCAG 2.2 SC 2.4.11 — keyboard/AT focus must be visible on the interactive board tiles. */
.cell:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* Desktop hover lift — gated to hover-capable pointers so a touch never leaves a stuck hover
   state. The transform is suppressed under prefers-reduced-motion (durations already zero, but
   translate is a movement, so drop it explicitly). */
@media (hover: hover) {
  .cell:hover { background: var(--tile-hover); transform: translateY(-1px); }
}
@media (prefers-reduced-motion: reduce) {
  .cell:hover { transform: none; }
}

.cell.wall {
  background: transparent;
  border: none;
  pointer-events: none;
  visibility: hidden;
}

/* Wend-style connected path: the bold colour is drawn as an SVG capsule + region band by
   drawPathLine(), so a whole word reads as one flowing "worm". The TILES on a path therefore go
   transparent (no per-tile fill or border to break the band) and the dark letter rides on top of
   the capsule. Idle tiles keep their surface + grid border. */
.cell.selected {
  background: transparent;
  border-color: transparent;
  box-shadow: none;
  /* Letters are the heart of the puzzle — a light glyph with a dark outline stays legible on ANY
     snake hue and over the head/eyes, where the old dark var(--bg) letter vanished. */
  color: var(--snake-ink);
  font-weight: 800;
  text-shadow: 0 0 1px rgba(0, 0, 0, 0.9), 0 0 3px rgba(0, 0, 0, 0.55), 0 1px 1.5px rgba(0, 0, 0, 0.7);
  z-index: 3;
}

.cell.selected.first { font-weight: 800; }

/* A solved word keeps its capsule colour and earns a scale "pop" on lock — the most satisfying
   beat in a path puzzle. */
.cell.locked {
  background: transparent;
  border-color: transparent;
  box-shadow: none;
  cursor: default;
  /* Same legibility treatment as .selected: white glyph + dark outline reads on every capsule
     colour and over the eyed head. The letter must never be lost in the snake. */
  color: var(--snake-ink);
  font-weight: 800;
  text-shadow: 0 0 1px rgba(0, 0, 0, 0.9), 0 0 3px rgba(0, 0, 0, 0.55), 0 1px 1.5px rgba(0, 0, 0, 0.7);
  z-index: 2;
  animation: cell-lock-pop 0.26s ease-out;
}

@keyframes cell-lock-pop {
  0% { transform: scale(1); }
  45% { transform: scale(1.14); }
  100% { transform: scale(1); }
}

/* The lock pop is a 14% scale jump on every solved tile at once — the largest movement on the board.
   It slipped past the global guard because its duration is hardcoded rather than token-driven, and
   the token durations are what get zeroed. Nothing is lost by dropping it: a locked tile already
   flips to a white bold glyph on a coloured capsule inside the snake ribbon, so the state change is
   fully carried by colour and weight.
   This block MUST stay below `.cell.locked` above: a media query adds no specificity, so with the
   tie at (0,2,0) only source order decides, and an override placed earlier is silently discarded. */
@media (prefers-reduced-motion: reduce) {
  .cell.locked { animation: none; }
}

.cell.locked::after { display: none; }

.progress-wrap {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  padding: 0 var(--space-2);
}

/* Count on the left, percentage on the right — the percentage is the number people
   actually read, so it gets the accent colour and the heavier weight. */
.progress-meta {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-2);
}

.progress-label {
  font-family: var(--mono);
  font-size: var(--text-xs);
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
}

.progress-pct {
  font-family: var(--mono);
  font-size: var(--text-sm);
  font-weight: 700;
  color: var(--accent);
  font-variant-numeric: tabular-nums;
  transition: color var(--duration-fast) var(--ease-out);
}

.progress-wrap[data-complete="true"] .progress-pct { color: var(--ok, var(--accent)); }

/* Frameless: the word rail is a bare list on the page, not a bordered card. Padding keeps it off
   the board's gutter; the hairline dividers inside (.rail-card) carry any needed separation. */
.word-rail {
  background: transparent;
  border: 0;
  border-radius: 0;
  padding: 0;
}

.word-rail-title {
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  margin: 0 0 var(--space-3);
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  /* Section heading — must clear WCAG AA 4.5:1. --faint read at only 4.38:1 (measured by the
     ui-audit tool); --text-muted is the strongest muted token and clears it on both themes. */
  color: var(--text-muted);
}
/* Label + its 📋 glyph travel as ONE unit — the count that used to sit at the right edge moved to
   the progress bar under the board, so the heading is now just the icon + word. */
.rail-head {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
}
.rail-ico { font-size: var(--text-base); line-height: 1; }
/* One-line legend: the coloured slot-dot = that word's traced path on the board. Quiet, sits just
   under the heading; loses its top margin so it hugs the title as a caption. */
.word-rail-hint {
  margin: calc(-1 * var(--space-2)) 0 var(--space-3);
  font-size: var(--text-xs);
  color: var(--text-muted);
}

/* Word list is ALWAYS a vertical crossword-style list (every viewport). */
.word-slot-list {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}

@media (min-width: 1024px) {
  /* The rail holds only ~6 short slots, so beside a big board it used to pin to the top and leave a
     tall empty column beneath it ("hanging, not harmonic"). Centring it in its grid track — the row
     is as tall as the board column — seats it as a balanced panel level with the middle of the board.
     Sticky is dropped: the short list never needs to follow the scroll, and sticky fought the centring. */
  .word-rail {
    align-self: center;
    max-height: calc(100dvh - var(--space-8));
    overflow-y: auto;
  }
}

/* The rail is the ONLY place that tells a player how many words to look for and how long each
   one is — and on a phone it sat under the board, the progress bar and the status line, i.e.
   off-screen until you scrolled. You would start dragging with no idea what you were hunting.
   Above the board it is the first thing you read; a wrapping row is what makes that affordable
   in vertical space, since a column of six slots costs ~280px the board needs. */
@media (max-width: 1023px) {
  .word-rail {
    order: -1;
    padding: var(--space-3);
  }

  .word-rail-title { margin-bottom: var(--space-2); }

  .word-slot-list {
    flex-direction: row;
    flex-wrap: wrap;
  }

  .word-slot-row { min-width: 0; }

  .word-slot {
    grid-template-columns: auto minmax(0, auto) auto;
    gap: var(--space-2);
    padding: var(--space-2) var(--space-3);
  }

  /* Hidden word = a compact chip of length dots (from .slot-text), NOT a bare number. The old rule
     hid .slot-text and left only .slot-len, which read as a mysterious "5 / 6". Show the dots and
     hide the number so the chip reads as "how many letters" at a glance. */
  .word-slot:not(.done) .slot-len,
  .word-slot:not(.done) .slot-check { display: none; }
  .word-slot:not(.done) { grid-template-columns: auto auto; }
  .word-slot:not(.done) .slot-text { letter-spacing: 0.15em; color: var(--text-muted); }

  .slot-len { min-width: 20px; padding: 2px var(--space-1); }
}

.word-slot-row {
  display: flex;
  flex-direction: column;
}

.word-slot {
  display: grid;
  grid-template-columns: 26px 1fr auto;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3);
  border-radius: var(--radius-sm);
  border: 1px solid var(--line-ui);
  border-left: 4px solid var(--slot-color, var(--line-ui));
  background: var(--surface-raised);
  transition: background var(--duration-fast) var(--ease-out),
    border-color var(--duration-fast) var(--ease-out);
}

/* A solved slot is a <button>, so it needs the browser's button defaults undone; an unsolved
   one is a <div> and has nothing to reveal. */
button.word-slot {
  width: 100%;
  font: inherit;
  text-align: left;
  cursor: pointer;
}

button.word-slot[aria-expanded="true"] {
  border-bottom-left-radius: 0;
  border-bottom-right-radius: 0;
}

/* Folding accordion body. Collapsed = a single grid row track at 0fr; open = 1fr. Animating the
   track (not height:auto, which is not animatable) gives a real fold, and the inner wrapper clips
   its overflow so nothing spills while the track is short. The panel stays in the DOM in both
   states so aria-controls always resolves and the transition has something to interpolate. */
.word-slot-detail {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows var(--duration-fast) var(--ease-out);
}
.word-slot-detail.open {
  grid-template-rows: 1fr;
}
.word-slot-detail-inner {
  overflow: hidden;
  min-height: 0;
}
/* Calm continuation of the solved slot: a neutral card that carries only the same left identity
   stripe, not a second full colour-mixed border + tinted fill. The chrome lives on the inner
   wrapper so a collapsed (0fr) panel shows no stray hairline. */
.word-slot-detail.open .word-slot-detail-inner {
  padding: var(--space-3);
  border: 1px solid var(--line-ui);
  border-top: none;
  border-left: 4px solid var(--slot-color, var(--line-ui));
  border-radius: 0 0 var(--radius-sm) var(--radius-sm);
  background: var(--surface-raised);
}
/* Respect reduced-motion: the fold snaps open/closed with no track animation. */
@media (prefers-reduced-motion: reduce) {
  .word-slot-detail { transition: none; }
}

/* Compact rail lesson — grammar (short, fixed) and example (long) side by side. The old RAM grid
   (repeat/auto-fit/minmax) split a wide slot into equal columns and, with only two items, left a
   whole empty column while the sentence wrapped in its cramped track. Flex instead lets grammar hug
   its content and the example GROW into all the leftover width, only dropping to its own full-width
   row once the remaining space falls below its ~260px basis — one column in the narrow desktop rail,
   full-width example in the wide mobile slot, no media query and no wasted space. */
.lesson-compact {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-4);
  align-items: start;
}
.lesson-compact .lesson-grammar {
  flex: 0 0 auto;
}
.lesson-compact .lesson-usage {
  flex: 1 1 260px;
  min-width: 0;
}
.lesson-grammar {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-wrap: wrap;
}
.lesson-plural-inline {
  font-size: var(--text-sm);
  color: var(--text-muted);
}
.lesson-usage .lesson-example {
  margin: 0;
  padding: 0;
  border-top: none;
  font-size: var(--text-sm);
  line-height: 1.5;
}
.lesson-usage .lesson-example-en {
  margin: 2px 0 0;
}

.word-slot-detail.c1 { --slot-color: var(--w1); }
.word-slot-detail.c2 { --slot-color: var(--w2); }
.word-slot-detail.c3 { --slot-color: var(--w3); }
.word-slot-detail.c4 { --slot-color: var(--w4); }
.word-slot-detail.c5 { --slot-color: var(--w5); }
.word-slot-detail.c6 { --slot-color: var(--w6); }

.word-slot-detail .lesson-word { font-size: var(--text-base); }

/* Word identity colour, shown ALWAYS (colourful even before solving) */
.word-slot.c1 { --slot-color: var(--w1); }
.word-slot.c2 { --slot-color: var(--w2); }
.word-slot.c3 { --slot-color: var(--w3); }
.word-slot.c4 { --slot-color: var(--w4); }
.word-slot.c5 { --slot-color: var(--w5); }
.word-slot.c6 { --slot-color: var(--w6); }

.word-slot.done {
  border-color: var(--line-ui);
  border-left-color: var(--slot-color);
  background: var(--surface-raised);
}

/* The 4px left stripe already carries each word's identity colour (the SAME --wN token its puzzle
   capsule is drawn with), so a second coloured dot beside it only doubled the signal and added to
   the buzz. Hidden — one calm colour per row. */
.slot-dot { display: none; }

.slot-len {
  font-family: var(--mono);
  font-size: var(--text-xs);
  font-weight: 700;
  color: var(--text-muted);
  text-align: center;
  padding: 2px 0;
}

.slot-text {
  /* min-width:0 lets this 1fr grid cell shrink below its content; flex-wrap then drops the
     inline translation onto its own line — each of the word and its gloss stays a whole,
     unbroken unit — instead of the long mono word overflowing and clipping in the 240px rail. */
  min-width: 0;
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  column-gap: var(--space-2);
  row-gap: 2px;
  font-family: var(--mono);
  font-size: var(--text-base);
  font-weight: 600;
  letter-spacing: 0.16em;
  color: var(--text-muted);
  line-height: 1.3;
}

.word-slot.done .slot-text {
  color: var(--text);
  letter-spacing: 0.08em;
}

/* The translation rides beside the word as a flex sibling (gap set on .slot-text): on a wide
   row it fills the empty right half; in the narrow rail it wraps to its own line, both whole. */
.slot-translation {
  font-family: var(--font);
  font-size: var(--text-sm);
  font-weight: 500;
  letter-spacing: 0;
  color: var(--text-muted);
}

.slot-check {
  font-size: var(--text-base);
  font-weight: 700;
  color: var(--text-muted);
}

.play-action-bar {
  display: flex;
  /* stretch, not center: the Tipp label is dynamic (refreshHintLabel) and wraps to 2–3 lines on a
     narrow phone, so a centred row left it taller than the single-line button beside it. Stretch
     makes both buttons share the tallest height, so each wrapped row stays even and harmonic. */
  align-items: stretch;
  justify-content: center;
  flex-wrap: wrap;
  gap: var(--space-2);
  padding: var(--space-3) 0;
  /* Frameless: the action row sits directly on the page — no container box. The buttons keep
     their own pill shapes and the deliberate even-tiling; only the surrounding frame is gone. */
}

/* Symmetric pair: the two quiet helpers (Tipp + Neues Rätsel) share the row width evenly and stay
   centred, so the bar reads as one balanced cluster. Tracing on the board is the real action, so
   both buttons are equal-weight ghosts here and neither out-shouts the hero. */
.play-action-bar #hintBtn,
.play-action-bar .btn-shuffle {
  /* No max-width cap: the two buttons grow to tile the full bar width evenly (with the flex gap
     between them) instead of floating as pills centred in a wide bar with dead margins each side.
     flex-basis 150px keeps them from collapsing too small when the bar is narrow. */
  flex: 1 1 150px;
  justify-content: center;
  /* Keep each label on ONE line so no button ever grows to two rows and towers over the others —
     the dynamic Tipp label ("Tipp: nächster Buchstabe") is the one that used to wrap and left the
     helper row taller than the primary CTA (inverted hierarchy). Every action stays a clean 44px. */
  white-space: nowrap;
}
.play-action-bar .btn-ghost.primary-accent { display: none; }

/* The tip button used to be a plain transparent ghost, so testers never noticed it. An
   accent tint + a lightbulb glyph make it read as the helpful affordance it is. The glyph is a
   ::before so it survives refreshHintLabel() overwriting the button's textContent.
   It is a MASKED SVG, not content:"\1F4A1". A CSS content escape is still an emoji — it just
   hides from any grep of the markup, which is how this one survived the first sweep. Hint is the
   rescue control; it cannot be the one thing that renders at a foreign size on Android. G4. */
#hintBtn {
  border-color: color-mix(in oklch, var(--accent) 55%, var(--line-ui));
  background: color-mix(in oklch, var(--accent) 12%, transparent);
  color: var(--text);
  font-weight: 600;
}
#hintBtn::before {
  content: "";
  width: 1.1em;
  height: 1.1em;
  margin-right: 4px;
  flex: none;
  background: currentColor;
  -webkit-mask: var(--ico-bulb) center / contain no-repeat;
  mask: var(--ico-bulb) center / contain no-repeat;
  --ico-bulb: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 18h6'/%3E%3Cpath d='M10 22h4'/%3E%3Cpath d='M15.1 14a5 5 0 1 0-6.2 0c.6.5.9 1.2 1 1.9h4.2c.1-.7.4-1.4 1-1.9z'/%3E%3C/svg%3E");
}
#hintBtn:hover {
  background: color-mix(in oklch, var(--accent) 20%, transparent);
}

/* The game's only text feedback surface: wrong word, word locked, hint applied, cleared.
   It reserves its line height so text arriving never shifts the board underneath it, and it
   never auto-dismisses — a timer that erases the reason a word was rejected is worse than
   leaving the message up, and the next interaction replaces it anyway. */
/* The live feedback bar. A persistent surface (not floating muted text) so the zone below the
   board always reads as an intentional place where the game talks back — the traced word, a
   confirmed find, a wrong path. The success/win/error states below repaint it accordingly. */
.status {
  min-height: calc(var(--text-sm) * 1.5);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-sm);
  border: 1px solid var(--line-ui);
  background: var(--surface);
  font-size: var(--text-sm);
  line-height: 1.5;
  color: var(--text-muted);
  text-align: center;
  font-variant-numeric: tabular-nums;
  transition: color var(--dur-fast, 0.15s) ease, background var(--dur-fast, 0.15s) ease,
    border-color var(--dur-fast, 0.15s) ease;
}

.status.error {
  color: var(--danger);
  border-color: color-mix(in oklch, var(--danger) 35%, transparent);
  background: color-mix(in oklch, var(--danger) 10%, transparent);
}

.status.success,
.status.win {
  color: var(--accent);
  border-color: color-mix(in oklch, var(--accent) 35%, transparent);
  background: var(--accent-soft);
}

.status-live {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
}

/* Frameless like the Wörter/Fortschritt section views (north star #1): no surrounding card, just
   content on the app background. The inner tip/comeback/recap use border-top hairlines, so they
   read as full-width page sections. The celebratory centre alignment is the only thing that sets
   the win screen apart from the left-aligned browse pages. */
.solve-panel {
  padding: var(--space-2) 0 var(--space-10);
  text-align: center;
}

.solve-panel[hidden] { display: none !important; }

/* Trophy header: the peak-end hero. For a generated board this reads "🏆 Rätsel #0147 gelöst"
   (the board's collectible id), so the win screen ties back to the caption's puzzle identity. */
.solve-title {
  margin: 0 0 var(--space-2);
  font-size: var(--text-xl);
  font-weight: 700;
  text-wrap: balance;
}

/* Frameless result stats (north star: rich moment, one card). No nested boxes — the numbers sit
   directly on the panel card, separated only by whitespace. All three are gold: on the win screen
   every number is something you EARNED (level %, new words, time), so gold = reward is consistent. */
.solve-stats {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin: 0 0 var(--space-4);
  justify-content: center;
}
.solve-stat--streak .solve-stat-n--teal { color: var(--accent); }
.solve-stat {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  padding: var(--space-2) var(--space-1);
  background: transparent;
  border: none;
}
.solve-stat-n {
  font-family: var(--font);
  font-weight: 800;
  font-size: var(--text-2xl);
  line-height: 1;
  color: var(--text);
  font-variant-numeric: tabular-nums;
}
.solve-stat-n--gold { color: var(--gold-text); }
.solve-stat-l {
  font-family: var(--font);
  font-size: var(--text-xs);
  color: var(--muted);
  text-align: center;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

/* Rotating learning-science tip — now a quiet muted line, not a tinted gold box. The panel is
   already the single card; nesting a second bordered box inside it broke the frameless rhythm. */
.solve-tip {
  margin: 0 0 var(--space-3);
  padding: var(--space-3) 0 0;
  border-top: 1px solid var(--line-ui);
  font-size: var(--text-sm);
  line-height: 1.5;
  color: var(--text-muted);
  text-align: center;
}
.solve-tip:empty { display: none; }

/* Daily come-back block: hairline-divided, no tinted card. The streak is gold (reward), never teal
   (teal is interactive-only) — so returning tomorrow reads as the earned prize it is. */
.solve-comeback {
  margin: 0 0 var(--space-3);
  padding: var(--space-3) 0 0;
  border-top: 1px solid var(--line-ui);
  text-align: center;
}
.solve-comeback[hidden] { display: none !important; }
.solve-comeback-streak {
  margin: 0 0 var(--space-1);
  font-size: var(--text-lg);
  font-weight: 700;
  color: var(--gold-text);
  text-wrap: balance;
}
.solve-comeback-next {
  margin: 0 0 var(--space-3);
  font-size: var(--text-sm);
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

/* Daily 1:1 share nudge on the win screen — warm card, primary CTA fires native share. */
.solve-share-nudge {
  margin: 0 0 var(--space-3);
  padding: var(--space-4) var(--space-3);
  border: 1px solid color-mix(in oklch, var(--flag-gold) 35%, var(--line-ui));
  border-radius: var(--radius-md);
  background: color-mix(in oklch, var(--flag-gold) 8%, var(--surface-raised));
  text-align: center;
}
.solve-share-nudge[hidden] { display: none !important; }
.solve-share-nudge-q {
  margin: 0 0 var(--space-3);
  font-size: var(--text-md);
  font-weight: 600;
  color: var(--text);
  text-wrap: balance;
}
.solve-share-nudge-btn { width: 100%; max-width: 280px; }

.solve-recap {
  margin: 0 0 var(--space-4);
  padding-top: var(--space-3);
  border-top: 1px solid var(--line-ui);
  text-align: left;
}

.solve-recap[hidden] { display: none !important; }

.solve-recap-title {
  margin: 0 0 var(--space-1);
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-muted);
  text-align: center;
}

.solve-recap-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}

/* The recap is the only screen whose job is reading, so it is generously spaced and the
   list never scrolls independently — a nested scrollbar would hide the vocabulary that is
   the entire point of the screen. The page scrolls; the list does not. */
/* Standard card, identical to the Wörter section's .dict-card: neutral surface, hairline border,
   rounded, raised — NO per-word colour tint. The recap now reads as the same "your words" surface
   the learner already knows from Wörter, instead of the old tinted board-colour slabs. */
.solve-recap-item {
  padding: var(--space-4);
  border: 1px solid var(--line-ui);
  border-radius: var(--radius-md);
  background: var(--surface);
  box-shadow: var(--shadow-raised);
}

/* —— E5 quadrant card ——
   The shared lesson card emitted by lessonHtml(). Default is a single stacked column (the 240px
   word-rail and the mobile expanded slot); the wide two-column quadrant is layered on only for the
   desktop recap, scoped to .solve-recap-item exactly as the old hero-word rule was. Sections are
   separated by 8px spacing when stacked; the sentence keeps a faint board-hue top rule. */
.wcard {
  display: grid;
  gap: var(--space-2);
}
.wcard-word {
  margin: 0;
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  flex-wrap: wrap;
}
/* Headword is Archivo sans (not terminal mono): a dictionary entry, not code. */
.wcard-word .lesson-word {
  font-family: var(--font);
  font-size: 1.7rem;
  font-weight: 700;
  line-height: 1.1;
  letter-spacing: -0.01em;
  color: var(--text);
  overflow-wrap: anywhere;   /* long compounds (max 14 chars) never punch out */
  hyphens: none;
}
/* Pronounce button — a quiet affordance next to the headword; teal (interactive) on hover, and a
   gold (reward) pulse while it speaks. 44px min tap target for the mobile reveal card. */
.lesson-speak {
  flex-shrink: 0;
  align-self: center;
  min-width: 44px;
  min-height: 44px;
  padding: 0 var(--space-1);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: var(--text-lg);
  line-height: 1;
  background: none;
  border: none;
  border-radius: var(--radius-sm, 8px);
  color: var(--muted);
  cursor: pointer;
  transition: color 0.15s ease, transform 0.15s ease;
}
.lesson-speak:hover { color: var(--accent); }
.lesson-speak:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.lesson-speak.speaking { color: var(--flag-gold); animation: lesson-speak-pulse 0.9s ease-in-out infinite; }
@keyframes lesson-speak-pulse {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.18); }
}
@media (prefers-reduced-motion: reduce) {
  .lesson-speak.speaking { animation: none; }
}

/* The meaning is the payoff — one notch below the headword, full contrast, not fine print. */
.wcard-gloss {
  margin: var(--space-1) 0 0;
  font-size: var(--text-lg);
  font-weight: 600;
  color: var(--text);
}
/* H1.2 — tap-to-reveal meaning (retrieval practice). Summary is interactive teal; gloss uses reward gold once open. */
.wcard-gloss-details {
  margin-top: var(--space-2);
}
.wcard-gloss-details > summary {
  cursor: pointer;
  list-style: none;
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: var(--space-1) var(--space-3);
  border-radius: var(--radius-sm);
  border: 1px solid var(--accent);
  color: var(--accent);
  font-size: var(--text-sm);
  font-weight: 600;
  user-select: none;
}
.wcard-gloss-details > summary::-webkit-details-marker { display: none; }
.wcard-gloss-details[open] > summary {
  margin-bottom: var(--space-1);
  border-color: transparent;
  color: var(--muted);
  padding-left: 0;
  border: none;
  min-height: 0;
}
.wcard-gloss-details[open] .wcard-gloss {
  color: var(--brand-pop);
}
.wcard-plural {
  margin: var(--space-2) 0 0;
  font-size: var(--text-sm);
  font-family: var(--mono);
  color: var(--muted);
}
.wcard-plural-to {
  color: var(--brand-pop);
  font-weight: 700;
}
.wcard-tags {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}
.wcard-themes,
.wcard-meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
}
/* Theme chip = gold brand reward (palette rule). Gold is theme-invariant, so a fixed near-black
   ink reads on both dark and hell. */
.tag--theme {
  display: inline-flex;
  align-items: center;
  font-size: var(--text-xs);
  font-weight: 700;
  line-height: 1;
  padding: var(--space-1) var(--space-2);
  border-radius: var(--radius-pill);
  color: var(--flag-ink);
  background: var(--brand-pop);
}
/* Level chip = neutral mono pill (never teal — teal is interactive-only). */
.tag--level {
  display: inline-flex;
  align-items: center;
  font-family: var(--mono);
  font-size: var(--text-xs);
  font-weight: 600;
  line-height: 1;
  padding: var(--space-1) var(--space-2);
  border-radius: var(--radius-pill);
  color: var(--text-muted);
  border: 1px solid var(--line-ui);
  background: var(--surface);
}
/* Rektion row: reflexivity + the governed preposition/case for verbs. Its own row in the tags
   cell so it never crowds the level/class chips. The single highest-value grammar cue, so the
   case pill is a LOUD solid (like the gender pill), not a quiet tinted chip. */
.wcard-rektion {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
}
/* "sich" marker: a quiet italic annotation pill, deliberately neutral so it reads as a
   grammatical note distinct from the coloured case pill beside it. */
.lesson-refl {
  display: inline-flex;
  align-items: center;
  font-size: var(--text-xs);
  font-weight: 600;
  font-style: italic;
  line-height: 1;
  padding: var(--space-1) var(--space-2);
  border-radius: var(--radius-pill);
  color: var(--text);
  border: 1px solid var(--line-ui);
  background: var(--surface-raised);
}
/* Governed preposition + case, e.g. "auf · Akk" (or "+ Dat" when the verb governs directly).
   Solid white-ink pill hued by case: --kasus is set by the modifier below. */
.tag--rektion {
  --kasus: var(--text-muted);
  display: inline-flex;
  align-items: center;
  font-size: var(--text-xs);
  font-weight: 700;
  line-height: 1;
  padding: var(--space-1) var(--space-2);
  border-radius: var(--radius-pill);
  color: var(--kasus-ink);
  background: var(--kasus);
  white-space: nowrap;
}
.tag--kasus-akk { --kasus: var(--kasus-akk); }
.tag--kasus-dat { --kasus: var(--kasus-dat); }
.tag--kasus-gen { --kasus: var(--kasus-gen); }
/* Sentence cell: when stacked, a faint board-hue top rule is the only separator. The desktop
   quadrant below replaces this with a full --line-ui hairline and its own padding. */
.wcard-ex {
  padding-top: var(--space-2);
  border-top: 1px solid color-mix(in oklch, var(--slot-color, var(--line-ui)) 30%, transparent);
}
.wcard-ex .lesson-example {
  margin: 0;
  padding-top: 0;
  border-top: none;
}

/* —— One word's lesson: gender, headword, plural, meaning, sentence ——
   Emitted by lessonHtml() and used by BOTH the win screen and the expanded word rail, so
   the two can never drift into showing different amounts about the same word. Every line
   is absent whenever its data is, which for the legacy list is most of them. */
.lesson-head {
  margin: 0;
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  text-wrap: pretty;
}

/* der/die/das gender pill — the classic teaching mnemonic (blue/red/green). It only appears
   for nouns (words that carry WEFT_DE_FORMS), so its presence itself teaches "this is a noun".
   Colours come from tokens so the light theme can darken them for contrast. */
.lesson-gender {
  flex-shrink: 0;
  align-self: center;
  font-size: var(--text-xs);
  font-weight: 700;
  line-height: 1;
  padding: var(--space-1) var(--space-2);
  border-radius: var(--radius-pill);
  color: var(--gender-ink);
  text-transform: lowercase;
}
.lesson-gender--der { background: var(--gender-der); }
.lesson-gender--die { background: var(--gender-die); }
.lesson-gender--das { background: var(--gender-das); }

/* Class + level badge row — shown on every card so "what is this word / how hard" is always
   visible. In the compact rail card it is a grid child, so it spans every column and sits on
   its own row above the grammar/example. The class chip is a neutral info tag (word class is
   not an action, so it stays off the teal interactive colour); the level chip reuses the mono
   .weft-badge rating look (accent-soft tint) that already marks CEFR-style values elsewhere. */
.lesson-meta {
  /* Own its own row above grammar+example: flex-basis 100% in the compact flex row, and the
     grid-column keeps it spanning in the full (grid) lesson layout on the win screen. */
  grid-column: 1 / -1;
  flex-basis: 100%;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
}
/* Word-class chip: a SOFT tinted pill whose hue encodes the part of speech. --pos is set by the
   per-class modifier below and drives the text, a leading colour dot, a faint fill and a matching
   border in one place — so a new class only needs a token + a one-line modifier. Defaults to the
   neutral muted tone when no modifier matches (e.g. an untagged legacy word, which emits no chip
   anyway). */
.lesson-class {
  --pos: var(--text-muted);
  display: inline-flex;
  align-items: center;
  gap: 0.4em;
  font-size: var(--text-xs);
  font-weight: 600;
  line-height: 1;
  padding: var(--space-1) var(--space-2);
  border: 1px solid color-mix(in oklab, var(--pos) 45%, transparent);
  border-radius: var(--radius-pill);
  background: color-mix(in oklab, var(--pos) 12%, var(--surface));
  color: var(--pos);
  white-space: nowrap;
}
/* The dot gives the class an at-a-glance colour cue even before the label is read. */
.lesson-class::before {
  content: "";
  width: 0.5em;
  height: 0.5em;
  border-radius: 50%;
  background: var(--pos);
  flex: none;
}
.lesson-class--nomen { --pos: var(--pos-nomen); }
.lesson-class--verb { --pos: var(--pos-verb); }
.lesson-class--adjektiv { --pos: var(--pos-adjektiv); }
.lesson-class--adverb { --pos: var(--pos-adverb); }
.lesson-class--konnektor { --pos: var(--pos-konnektor); }
.lesson-class--sonstiges { --pos: var(--pos-sonstiges); }

.lesson-word {
  font-size: var(--text-lg);
  font-weight: 700;
  color: var(--text);
}

.lesson-plural {
  font-size: var(--text-sm);
  color: var(--text);
}

.lesson-gloss {
  margin: var(--space-1) 0 0;
  font-size: var(--text-base);
  /* The gloss IS the learning payoff (the meaning), so it reads at full text contrast rather
     than the muted secondary tone — the earlier muted colour was the "too dark" complaint. */
  color: var(--text);
}

.lesson-example {
  margin: var(--space-3) 0 0;
  padding-top: var(--space-3);
  border-top: 1px solid color-mix(in oklch, var(--slot-color, var(--line-ui)) 30%, transparent);
  font-size: var(--text-base);
  line-height: 1.6;
  color: var(--text);
  text-wrap: pretty;
}

/* The point of the sentence is seeing the word behave in a clause, so the word is marked
   inside it — in its own board colour, tying it back to the path that was drawn. */
.lesson-example strong {
  font-weight: 700;
  color: var(--slot-color, var(--accent));
}

.lesson-example-en {
  margin: var(--space-1) 0 0;
  font-size: var(--text-sm);
  line-height: 1.5;
  font-style: italic;
  color: var(--text-muted);
}

/* Lifetime vocabulary line (backlog #19): was small muted grey, read as an error/empty caption.
   Now a centred reward chip — a growing collection worth coming back to. Gold = brand reward
   (palette rule), so it sits apart from the teal interactive CTA below it. */
.solve-recap-total {
  display: inline-block;
  margin: var(--space-5) auto 0;
  padding: var(--space-2) var(--space-4);
  border-radius: var(--radius-pill);
  border: 1px solid color-mix(in oklch, var(--brand-pop) 40%, var(--line-ui));
  background: color-mix(in oklch, var(--brand-pop) 10%, transparent);
  font-size: var(--text-base);
  font-weight: 600;
  color: var(--text);
  text-align: center;
}
/* .solve-recap centres text, so the inline-block chip needs a block wrapper context; it already
   sits in a text-align:left recap, so force its own centring line. */
.solve-recap { text-align: center; }
.solve-recap-list { text-align: left; }

.solve-recap-total[hidden] { display: none !important; }

/* —— "Was this puzzle good?" ——
   One tap and it is gone. The answer is only worth collecting because it leaves for
   analytics with the level and size attached: "boards are boring" is not actionable,
   "7x7 A1 boards are boring" points straight at a content gap. */
.solve-rating {
  margin: 0 0 var(--space-5);
  padding-top: var(--space-4);
  border-top: 1px solid var(--line-ui);
}

.solve-rating-q {
  margin: 0 0 var(--space-2);
  font-size: var(--text-sm);
  color: var(--text-muted);
}

.solve-rating-stars {
  display: flex;
  justify-content: center;
  gap: var(--space-1);
}

.rating-star {
  border: none;
  background: transparent;
  cursor: pointer;
  padding: var(--space-1) var(--space-2);
  min-width: var(--touch-min);
  min-height: var(--touch-min);
  font-size: var(--text-xl);
  line-height: 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* Resting stars must READ as tappable, so use --text-muted (a real readable token) not
     --line-ui (a 1px-separator colour that vanishes on dark). Gold fill arrives on hover. */
  color: var(--text-muted);
  transition: color var(--duration-fast) var(--ease-out),
    transform var(--duration-fast) var(--ease-out);
}

/* The star is a masked SVG, not the "★" dingbat the painter used to write into textContent.
   U+2605 has no guaranteed font on Android, so it fell through to whatever OEM face was
   installed at a foreign size and weight — five stars that didn't match each other is exactly
   the "damaged symbols" report (G4). Crucially this is `background: currentColor` + mask, so
   the entire hover cascade above — which works purely by changing `color` — keeps working
   with no edits: the fill IS the colour. Sized in em so font-size: var(--text-xl) still rules. */
.rating-star::before {
  content: "";
  width: 1em;
  height: 1em;
  background: currentColor;
  -webkit-mask: var(--ico-star) center / contain no-repeat;
  mask: var(--ico-star) center / contain no-repeat;
  --ico-star: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 2.6l2.9 5.9 6.5.95-4.7 4.58 1.11 6.47L12 17.45 6.19 20.5 7.3 14.03 2.6 9.45l6.5-.95L12 2.6z'/%3E%3C/svg%3E");
}

/* Hovering a star lights every star up to it, so the control reads as a 1-5 scale rather
   than five separate buttons. The sibling combinator dims stars to the RIGHT of the hovered
   one, leaving a filled run on the left. Gold = the rating highlight. */
.solve-rating-stars:hover .rating-star { color: var(--gold-text); }
.solve-rating-stars .rating-star:hover ~ .rating-star { color: var(--text-muted); }
.rating-star:hover { transform: scale(1.15); }
.rating-star:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* The feedback ask mirrors .solve-rating's divider + muted-question framing so it reads as one
   more calm section of the win screen, not an ad. Hidden by default; maybeShowFeedback() reveals. */
.solve-feedback {
  margin: 0 0 var(--space-5);
  padding-top: var(--space-4);
  border-top: 1px solid var(--line-ui);
}
.solve-feedback[hidden] { display: none !important; }

.solve-feedback-q {
  margin: 0 0 var(--space-3);
  font-size: var(--text-sm);
  color: var(--text-muted);
  text-align: center;
}

.solve-feedback-form[hidden] { display: none !important; }

/* Inline note field: calm, full-width, teal focus ring (teal = interactive, per palette law). */
.solve-feedback-text {
  width: 100%;
  box-sizing: border-box;
  margin: 0 0 var(--space-3);
  padding: var(--space-3);
  font: inherit;
  font-size: var(--text-sm);
  color: var(--text);
  background: var(--surface);
  border: 1px solid var(--line-ui);
  border-radius: var(--radius-md);
  resize: vertical;
  min-height: 2.75rem;
}
.solve-feedback-text:focus-visible {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 30%, transparent);
}

.solve-feedback-actions {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-2);
}
.solve-feedback-actions .btn-primary { text-align: center; text-decoration: none; }

.solve-feedback-thanks {
  margin: 0;
  font-size: var(--text-sm);
  color: var(--text-muted);
  text-align: center;
}
.solve-feedback-thanks[hidden] { display: none !important; }

/* iOS add-to-home-screen hint. Same calm divider as the feedback ask; one dismiss and it's gone. */
.solve-a2hs {
  margin: 0 0 var(--space-5);
  padding-top: var(--space-4);
  border-top: 1px solid var(--line-ui);
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  align-items: stretch;
}
.solve-a2hs[hidden] { display: none !important; }
.solve-a2hs-q {
  margin: 0;
  font-size: var(--text-sm);
  color: var(--text-muted);
  text-align: center;
}
.solve-a2hs-actions {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-2);
}
.solve-a2hs--install .solve-a2hs-actions {
  grid-template-columns: 1fr 1fr;
}

/* Closing action band (backlog #19). It was reading as a dead footer; a top rule + breathing
   room makes it a deliberate "what next" zone that ends the screen on momentum. */
.solve-actions {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  margin-top: var(--space-5);
  padding-top: var(--space-5);
  border-top: 1px solid var(--line-ui);
}

/* The primary CTA is the single most important control on the screen — "keep going". It was a
   standard-height button lost among ghosts. Now it is tall, bold, and carries a → that slides on
   hover (motion, not emoji — the emoji rule keeps action buttons glyph-free). Stays teal because
   it is INTERACTIVE (palette rule); gold is reserved for reward surfaces. */
.solve-actions #solveNextBtn {
  min-height: calc(var(--touch-min) + 16px);
  font-size: var(--text-lg);
  font-weight: 700;
  letter-spacing: 0.01em;
  box-shadow: 0 6px 18px color-mix(in oklch, var(--accent) 32%, transparent);
}
.solve-actions #solveNextBtn::after {
  content: "\2192"; /* → forward arrow: a directional affordance, not a decorative emoji */
  font-size: 1.1em;
  transition: transform var(--duration-fast) var(--ease-out);
}
.solve-actions #solveNextBtn:hover {
  filter: brightness(1.08);
  box-shadow: 0 8px 24px color-mix(in oklch, var(--accent) 42%, transparent);
}
.solve-actions #solveNextBtn:hover::after { transform: translateX(4px); }
@media (prefers-reduced-motion: reduce) {
  .solve-actions #solveNextBtn::after { transition: none; }
  .solve-actions #solveNextBtn:hover::after { transform: none; }
}

.solve-actions-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-2);
}

/* —— DESKTOP solve screen: two-pane split ——
   A wide viewport turned the old single column into a tall, narrow scroller with a sparse right
   margin (the user's recurring dead-space complaint). Now the panel is a centred two-pane grid:
   a compact SUMMARY RAIL on the left (trophy, stats, tip, come-back, rating, actions — so "Next"
   stays visible without scrolling past every card) beside a scrollable "Du hast gelernt" WORD GRID
   on the right that tiles the cards 2-up and fills the width. The recap is explicitly placed in
   column 2 spanning every row; everything else auto-flows down column 1 in DOM order. Mobile is
   untouched — it keeps the single stacked column from the base rules. */
@media (min-width: 1024px) {
  .solve-panel {
    display: grid;
    grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
    column-gap: var(--space-8);
    align-items: start;
    max-width: 1080px;
    margin-inline: auto;
  }
  /* The learned-word grid owns the right pane, top-aligned and as tall as the summary rail needs. */
  .solve-recap {
    grid-column: 2;
    grid-row: 1 / span 30;
    align-self: start;
    margin: 0;
    padding-top: 0;
    border-top: none;
    text-align: left;
  }
  .solve-recap-title { text-align: left; }
  /* Cards tile to fill the pane; each stays a single stacked column at this ~300px width — the
     old full-width quadrant would be too cramped once the cards are only half the panel wide. */
  .solve-recap-list {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
    gap: var(--space-3);
  }
  /* Everything that is not the recap stacks down the left summary rail. */
  .solve-title,
  .solve-stats,
  .solve-tip,
  .solve-comeback,
  .solve-rating,
  .solve-feedback,
  .solve-a2hs,
  .solve-actions {
    grid-column: 1;
  }
}

.gear-backdrop {
  position: fixed;
  inset: 0;
  z-index: 90;
  background: transparent;
}
.gear-backdrop.hidden { display: none; }

/* Full-page menu: on a phone this is a proper screen you return from via the back button, not a
   dropdown floating over the board. On desktop the full-bleed overlay centres a narrow column so
   the list stays a focused, harmonised menu instead of a sea of buttons. */
.gear-menu {
  position: fixed;
  inset: 0;
  z-index: 95;
  display: flex;
  flex-direction: column;
  background: var(--bg);
  overflow-y: auto;
  /* Inset is ADDED, not max()'d — see .app. This one matters most: the gear menu is position:fixed
     over the whole screen, so with max() its close button sat directly under the Android clock. */
  padding: calc(env(safe-area-inset-top, 0px) + var(--space-5))
           calc(env(safe-area-inset-right, 0px) + var(--space-4))
           calc(env(safe-area-inset-bottom, 0px) + var(--space-5))
           calc(env(safe-area-inset-left, 0px) + var(--space-4));
}
.gear-menu.hidden { display: none; }

.gear-menu-panel {
  width: 100%;
  max-width: 460px;
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}
.gear-menu-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding-bottom: var(--space-3);
  border-bottom: 1px solid var(--line-ui);
}
.gear-menu-title {
  font-size: var(--text-lg);
  font-weight: 700;
  color: var(--text);
}
.gear-menu-close {
  width: var(--touch-min);
  height: var(--touch-min);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: var(--text-lg);
  border: 1px solid var(--line-ui);
  border-radius: var(--radius-sm);
  background: var(--surface);
  color: var(--text);
  cursor: pointer;
  transition: border-color var(--duration-fast) var(--ease-out);
}
.gear-menu-close:hover { border-color: var(--accent); }
.gear-menu-list { display: flex; flex-direction: column; gap: var(--space-1); }

/* —— First-visit rules card —— */
.intro-card--warm {
  border: 1px solid color-mix(in oklch, var(--accent) 18%, var(--line-ui));
  box-shadow: var(--shadow-pop);
}
.intro-more {
  margin: 0 0 var(--space-4);
  text-align: left;
}
.intro-more summary {
  cursor: pointer;
  color: var(--accent);
  font-weight: 600;
  font-size: var(--text-sm);
  margin-bottom: var(--space-2);
  list-style: none;
}
.intro-more summary::-webkit-details-marker { display: none; }
.intro-more[open] summary { margin-bottom: var(--space-3); }

.intro-backdrop {
  position: fixed;
  inset: 0;
  z-index: 120;
  display: flex;
  align-items: center;
  justify-content: center;
  /* Inset is ADDED, not max()'d — see .app. */
  padding: calc(env(safe-area-inset-top, 0px) + var(--space-4))
           calc(env(safe-area-inset-right, 0px) + var(--space-4))
           calc(env(safe-area-inset-bottom, 0px) + var(--space-4))
           calc(env(safe-area-inset-left, 0px) + var(--space-4));
  background: oklch(0% 0 0 / 0.66);
}
.intro-backdrop.hidden { display: none; }

.intro-card {
  width: min(420px, 100%);
  /* The card must scroll rather than push its button off a short screen: at 320x568 in
     landscape there is not enough height for the diagram AND four rules AND the button. */
  max-height: 100%;
  overflow-y: auto;
  background: var(--surface-elevated);
  border: 1px solid var(--line-ui);
  border-radius: var(--radius-lg);
  padding: var(--space-6);
  box-shadow: var(--shadow-raised);
  text-align: center;
}
/* A gentle rise-in when the card appears — enough to feel intentional and modern, not showy. Scoped
   to the visible state so it replays each open; silenced entirely under reduced-motion. */
.intro-backdrop:not(.hidden) .intro-card { animation: introRise 320ms var(--ease-out) both; }
@keyframes introRise {
  from { opacity: 0; transform: translateY(12px); }
  to   { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  .intro-backdrop:not(.hidden) .intro-card { animation: none; }
}

.intro-title {
  margin: 0 0 var(--space-2);
  font-size: var(--text-xl);
}

/* The value-prop line: what this is and why to play, before the mechanics demo. */
.intro-lead {
  margin: 0 0 var(--space-4);
  font-size: var(--text-sm);
  line-height: 1.5;
  color: var(--text-muted);
}

.intro-demo { display: flex; justify-content: center; margin-bottom: var(--space-2); }
.intro-card .intro-lead {
  margin: 0 0 var(--space-4);
  font-size: var(--text-sm);
  color: var(--text-muted);
}
.intro-demo svg { width: 132px; height: 132px; }
.intro-demo-empty rect { fill: var(--surface); stroke: var(--tile-border); stroke-width: 1; }
.intro-demo-used rect { fill: none; stroke: var(--word-1); stroke-width: 2; }
/* The faint guide thread DRAWS ITSELF along the bending H→A→U→S path each time the card opens, so a
   newcomer SEES the one mechanic that words struggle to convey: your finger travels the letters and
   the path turns a corner. Length is normalised with pathLength so the dash maths is exact regardless
   of the polyline's real units. Reduced-motion users get the finished path with no animation. */
.intro-demo-thread {
  fill: none;
  stroke: var(--word-1);
  stroke-width: 12;
  stroke-linecap: round;
  stroke-linejoin: round;
  opacity: 0.34;
  stroke-dasharray: 100;
  stroke-dashoffset: 100;
  animation: introTrace 900ms var(--ease-out) 220ms forwards;
}
@keyframes introTrace {
  from { stroke-dashoffset: 100; opacity: 0.34; }
  60%  { opacity: 0.5; }
  to   { stroke-dashoffset: 0; opacity: 0.34; }
}
@media (prefers-reduced-motion: reduce) {
  .intro-demo-thread { animation: none; stroke-dashoffset: 0; }
}
.intro-demo-letter text {
  fill: var(--ink);
  font-family: var(--mono);
  font-size: 19px;
  font-weight: 600;
  text-anchor: middle;
  dominant-baseline: central;
}

.intro-rules {
  margin: 0 0 var(--space-5);
  padding: 0;
  list-style: none;
  text-align: left;
  display: grid;
  gap: var(--space-3);
}
/* The rules ARE the content of this card, not secondary caption text, so they read at full --text
   contrast (same principle the coach guidance follows, pinned by the §6.5 contract test) instead of
   the muted grey they used to wear. */
.intro-rules li {
  position: relative;
  padding-left: var(--space-5);
  font-size: var(--text-sm);
  line-height: 1.45;
  color: var(--text);
}
.intro-rules li::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0.5em;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--word-1);
}
/* The payoff line is the whole point — solving a word teaches its Artikel, Plural and a sentence.
   Gold is the reward hue in this palette (never interactive teal), so a gold marker + a touch more
   weight quietly frames this bullet as "here is what you get" without shouting. */
.intro-rules li.intro-rule-reward { font-weight: 600; }
.intro-rules li.intro-rule-reward::before { background: var(--brand-pop); box-shadow: 0 0 0 3px color-mix(in oklch, var(--brand-pop) 22%, transparent); }

.intro-start {
  width: 100%;
  min-height: 48px;
  font: inherit;
  font-weight: 600;
  color: var(--bg);
  background: var(--accent);
  border: 0;
  border-radius: var(--radius-md);
  cursor: pointer;
}
.intro-start:hover { filter: brightness(1.08); }

/* —— First-visit welcome (language + name) —— reuses .intro-backdrop / .intro-card shells —— */
/* The waving hand is this card's signature glyph, so it actually waves on open (pivoting at the
   "wrist" so the rotation reads as a greeting, not a spin) then settles after two beats. Silenced
   under reduced-motion. The card itself already rises in via the shared .intro-card entrance.
   It is a drawn SVG rather than 👋: this is the very first thing a new player sees, and an emoji
   here rendered at a different size and colour on every platform. G4. */
.welcome-wave {
  font-size: 40px;
  line-height: 1;
  margin-bottom: var(--space-2);
  display: inline-block;
  color: var(--brand-pop);
  transform-origin: 70% 70%;
  animation: welcomeWave 1.6s var(--ease-out) 260ms 2;
}
.welcome-wave svg { width: 1em; height: 1em; display: block; }
/* "EN" / "DE" in place of flag emoji — mono so the two codes are the same width and the buttons
   don't jitter, and muted so the code reads as a marker next to the real label. */
.welcome-lang-code {
  font: 600 var(--text-xs)/1 var(--mono);
  letter-spacing: 0.06em;
  opacity: 0.7;
}
@keyframes welcomeWave {
  0%, 60%, 100% { transform: rotate(0); }
  15%, 45% { transform: rotate(14deg); }
  30% { transform: rotate(-8deg); }
}
@media (prefers-reduced-motion: reduce) { .welcome-wave { animation: none; } }
.welcome-card .intro-lead { margin-bottom: var(--space-5); }
.welcome-field { text-align: left; margin-bottom: var(--space-4); }
.welcome-label {
  display: block;
  margin-bottom: var(--space-2);
  font-size: var(--text-sm);
  font-weight: 600;
  color: var(--text);
}
.welcome-langs { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-2); }
.welcome-lang {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  min-height: 48px;
  font: inherit;
  font-weight: 600;
  border: 1px solid var(--line-ui);
  border-radius: var(--radius-md);
  background: var(--surface);
  color: var(--text-muted);
  cursor: pointer;
  transition: border-color var(--duration-fast) var(--ease-out),
    color var(--duration-fast) var(--ease-out),
    background var(--duration-fast) var(--ease-out);
}
.welcome-lang:hover { color: var(--text); }
/* Chosen language: aria-pressed is the single source of truth for state + style. */
.welcome-lang[aria-pressed="true"] {
  border-color: var(--accent);
  background: var(--accent-soft);
  color: var(--accent);
}
.welcome-lang:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.welcome-input {
  width: 100%;
  min-height: 48px;
  padding: 0 var(--space-3);
  font: inherit;
  color: var(--text);
  background: var(--surface);
  border: 1px solid var(--line-ui);
  border-radius: var(--radius-md);
}
.welcome-input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
.welcome-card .intro-start { margin-top: var(--space-2); }

.gear-menu-list button {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  width: 100%;
  text-align: left;
  font: inherit;
  font-size: var(--text-base);
  padding: var(--space-3);
  min-height: 52px;
  border: none;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--text);
  cursor: pointer;
}
.gear-menu-list button:hover { background: var(--tile-hover); }

/* Section labels group the rows into Fortschritt / Spiel / Einstellungen (north star #1/#2):
   quiet uppercase caption, no box, first one loses its top margin. */
.gear-menu-group-label {
  margin: var(--space-3) 0 var(--space-1);
  padding: 0 var(--space-3);
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
}
.gear-menu-group-label:first-child { margin-top: 0; }

/* ONE monochrome icon system (north star #3): each row's glyph is a masked SVG painted in
   currentColor, so it adapts to theme + hover with zero recolouring and never clashes like the old
   multicolour emoji did. Living in ::before means applyI18N() rewriting the label can't wipe it. */
.gear-menu-list button::before {
  content: "";
  flex: none;
  width: 1.25em;
  height: 1.25em;
  background: currentColor;
  opacity: 0.8;
  -webkit-mask: var(--ico) center / contain no-repeat;
  mask: var(--ico) center / contain no-repeat;
}
#gearStatsBtn { --ico: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 3v18h18'/%3E%3Cpath d='M7 16v-5'/%3E%3Cpath d='M12 16V8'/%3E%3Cpath d='M17 16v-8'/%3E%3C/svg%3E"); }
#gearWordsBtn { --ico: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 19.5A2.5 2.5 0 0 1 6.5 17H20'/%3E%3Cpath d='M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z'/%3E%3C/svg%3E"); }
#gearShareBtn { --ico: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='18' cy='5' r='3'/%3E%3Ccircle cx='6' cy='12' r='3'/%3E%3Ccircle cx='18' cy='19' r='3'/%3E%3Cpath d='m8.6 13.5 6.8 4'/%3E%3Cpath d='m15.4 6.5-6.8 4'/%3E%3C/svg%3E"); }
#gearLevelBtn { --ico: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6'/%3E%3C/svg%3E"); }
#gearRulesBtn { --ico: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3'/%3E%3Cpath d='M12 17h.01'/%3E%3C/svg%3E"); }
#gearResetBtn { --ico: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 2v6h6'/%3E%3Cpath d='M3.51 15a9 9 0 1 0 2.13-9.36L3 8'/%3E%3C/svg%3E"); }
#gearUserBtn { --ico: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2'/%3E%3Ccircle cx='12' cy='7' r='4'/%3E%3C/svg%3E"); }
/* Theme, language and sound used to be the three exceptions to the rule above: they rendered a
   JS-painted text glyph via content: attr(data-ico) — ☾ / ☀ / 🔊 / 🇬🇧. Every one of those is a
   character with no guaranteed font behind it, so on Android they fell through the stack to an
   arbitrary OEM face at a foreign baseline (and the flags to bare "GB"/"DE" letter boxes, since
   stock AOSP ships no regional-indicator glyphs). They now use the same masked-SVG system as the
   rest of the menu, keyed off state attributes their painters already have to write. G4. */
#gearLangBtn { --ico: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M2 12h20'/%3E%3Cpath d='M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z'/%3E%3C/svg%3E"); }
#themeToggleBtn[data-mode="dark"] { --ico: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z'/%3E%3C/svg%3E"); }
#themeToggleBtn[data-mode="light"] { --ico: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='4'/%3E%3Cpath d='M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M6.3 17.7l-1.4 1.4M19.1 4.9l-1.4 1.4'/%3E%3C/svg%3E"); }
#gearSoundBtn { --ico: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M11 5 6 9H2v6h4l5 4z'/%3E%3Cpath d='M15.5 8.5a5 5 0 0 1 0 7'/%3E%3Cpath d='M19 5a10 10 0 0 1 0 14'/%3E%3C/svg%3E"); }
#gearSoundBtn[data-muted="true"] { --ico: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M11 5 6 9H2v6h4l5 4z'/%3E%3Cpath d='m22 9-6 6M16 9l6 6'/%3E%3C/svg%3E"); }

.modal-backdrop {
  position: fixed;
  inset: 0;
  background: var(--overlay);
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-5);
}
.modal-backdrop.hidden { display: none; }

.modal {
  background: var(--surface);
  padding: var(--space-6) var(--space-5);
  max-width: 480px;
  width: 100%;
  max-height: 88dvh;   /* dvh (not vh) so the modal never hides its scroll edge behind the mobile toolbar */
  overflow-y: auto;
  position: relative;
  border: 1px solid var(--line-ui);
  border-radius: var(--radius-lg);
}
/* Same gentle rise-in the how-to card uses, so every overlay in the app shares one entrance grammar.
   Scoped to the shown state so it replays each open; silenced under reduced-motion. */
.modal-backdrop:not(.hidden) .modal { animation: introRise 320ms var(--ease-out) both; }
@media (prefers-reduced-motion: reduce) {
  .modal-backdrop:not(.hidden) .modal { animation: none; }
}

.modal h2 {
  margin: 0 0 var(--space-4);
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

.modal .close-btn {
  position: absolute;
  top: var(--space-4);
  right: var(--space-4);
  background: transparent;
  border: none;
  font-size: 18px;
  color: var(--faint);
  cursor: pointer;
}

.modal .add-row { display: flex; border: 1px solid var(--line-ui); margin-bottom: var(--space-4); }
.modal input[type=text], .modal textarea, .share-box input {
  flex: 1;
  padding: var(--space-3);
  border: none;
  background: var(--surface);
  color: var(--ink);
  font: inherit;
}
.modal .add-row button, .modal .row button, .share-box button {
  font: inherit;
  font-size: var(--text-xs);
  padding: var(--space-3) var(--space-4);
  border: none;
  background: var(--accent);
  color: var(--selected-ink);
  cursor: pointer;
}
.modal .note { font-size: var(--text-xs); color: var(--faint); margin-top: var(--space-4); }
.modal .word-list { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.modal .word-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  padding: var(--space-1) var(--space-2);
  border: 1px solid var(--line-ui);
  font-size: var(--text-xs);
  font-family: var(--mono);
}
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 var(--space-5); }
.stats-grid > div {
  display: flex;
  justify-content: space-between;
  font-size: var(--text-sm);
  padding: var(--space-2) 0;
  border-bottom: 1px solid var(--line-ui);
}
.share-box { display: flex; border: 1px solid var(--line-ui); margin: var(--space-3) 0; border-radius: var(--radius-md); overflow: hidden; }
.share-dialog .share-intro { margin: 0; color: var(--text-muted); font-size: var(--text-sm); line-height: 1.45; }
.share-dialog .share-note { margin: 0 0 var(--space-2); }
.share-dialog .modal-warm-copy h2 {
  margin: 0 0 var(--space-2);
  font-size: var(--text-md);
  font-weight: 700;
  letter-spacing: 0.02em;
  text-transform: none;
  line-height: 1.3;
}
.share-dialog .share-box input { border: none; border-radius: 0; margin: 0; }
.share-dialog .modal-actions { margin-top: 0; }
.legal-dialog .legal-body {
  margin: 0;
  white-space: pre-line;
  color: var(--text-muted);
  font-size: var(--text-sm);
  line-height: 1.5;
}
.legal-dialog .modal-warm-copy h2 {
  margin: 0 0 var(--space-2);
  font-size: var(--text-md);
  font-weight: 700;
  letter-spacing: 0.02em;
  text-transform: none;
  line-height: 1.3;
}
.dashboard-dialog { max-width: 440px; }
.dashboard-dialog .modal-warm-copy h2 {
  margin: 0;
  font-size: var(--text-md);
  font-weight: 700;
  letter-spacing: 0.02em;
  text-transform: none;
  line-height: 1.3;
}
.dashboard-dialog .modal-warm-head { margin-bottom: var(--space-3); }

.leaderboard-row, .history-row {
  display: flex;
  justify-content: space-between;
  padding: var(--space-2) 0;
  font-size: var(--text-xs);
  border-bottom: 1px solid var(--line-ui);
}

/* ==================== ENGAGEMENT MICRO-EFFECTS ====================
   Tasteful, board-is-hero delight. Every keyframe here is disabled under prefers-reduced-motion,
   and every colour follows the palette rule: teal = interactive feedback, gold = earned reward. */

/* Tile press ripple — a momentary teal pulse under the fingertip so a tap feels tactile. The span
   is appended in onPointerDown and self-removes; low opacity keeps the bold letter legible under it. */
.cell-ripple {
  position: absolute;
  inset: 0;
  margin: auto;
  width: 62%;
  height: 62%;
  border-radius: 50%;
  background: radial-gradient(circle, color-mix(in oklch, var(--accent) 55%, transparent) 0%, transparent 70%);
  pointer-events: none;
  z-index: 1;
  animation: cell-ripple 0.42s ease-out forwards;
}
@keyframes cell-ripple {
  0% { transform: scale(0.35); opacity: 0.5; }
  100% { transform: scale(1.6); opacity: 0; }
}

/* Win celebration — the solve panel rises in and the trophy title gives one gold flare (reward). */
.solve-panel:not([hidden]) { animation: solve-rise 0.5s cubic-bezier(0.16, 1, 0.3, 1) both; }
.solve-panel:not([hidden]) .solve-title { animation: solve-flare 1.1s ease-out 0.15s both; }
@keyframes solve-rise {
  0% { opacity: 0; transform: translateY(14px) scale(0.985); }
  100% { opacity: 1; transform: none; }
}
@keyframes solve-flare {
  0% { text-shadow: 0 0 0 transparent; }
  35% { text-shadow: 0 0 18px color-mix(in oklch, var(--brand-pop) 70%, transparent); }
  100% { text-shadow: 0 0 0 transparent; }
}
/* The three earned numbers arrive one after another (after the panel has risen and the trophy has
   flared) — a brief "counted out" choreography so each stat reads as awarded, not merely printed.
   The peak-end reward moment is exactly where this sequencing pays off. Silenced under reduced-motion. */
.solve-panel:not([hidden]) .solve-stat { animation: solve-stat-pop 0.5s var(--ease-out) both; }
.solve-panel:not([hidden]) .solve-stat:nth-child(1) { animation-delay: 0.30s; }
.solve-panel:not([hidden]) .solve-stat:nth-child(2) { animation-delay: 0.42s; }
.solve-panel:not([hidden]) .solve-stat:nth-child(3) { animation-delay: 0.54s; }
@keyframes solve-stat-pop {
  0% { opacity: 0; transform: translateY(8px) scale(0.9); }
  100% { opacity: 1; transform: none; }
}

/* Streak advanced today — one gold pulse on the live streak badge (the Progress-tab number) to mark
   the +1. Class is added in bumpStreakChip() only when the daily streak actually changed, and removed
   on animationend. Generic selector so it works on whatever element currently carries the streak. */
.streak-bump { animation: streak-bump 0.7s ease-out; }
@keyframes streak-bump {
  0% { transform: scale(1); box-shadow: 0 0 0 0 color-mix(in oklch, var(--brand-pop) 55%, transparent); }
  45% { transform: scale(1.12); box-shadow: 0 0 0 7px color-mix(in oklch, var(--brand-pop) 0%, transparent); }
  100% { transform: scale(1); box-shadow: 0 0 0 0 transparent; }
}

/* Global leaderboard liveliness — rows fade+rise with a slight stagger (--i per row), the signed-in
   player's row keeps a persistent teal edge, and the earned puzzle count reads gold (reward). */
#fortschrittGlobalBoard .leaderboard-row {
  animation: lb-row-in 0.4s ease-out both;
  animation-delay: calc(var(--i, 0) * 45ms);
}
#fortschrittGlobalBoard .leaderboard-row.active {
  border-left: 3px solid var(--accent);
  padding-left: var(--space-2);
  background: color-mix(in oklch, var(--accent) 8%, transparent);
}
#fortschrittGlobalBoard .lb-count {
  font-variant-numeric: tabular-nums;
  font-weight: 700;
  color: var(--gold-text, var(--brand-pop));
}
/* The self-label on the signed-in player's own row (teal, matching the .active edge). */
.lb-you { color: var(--accent); font-weight: 600; font-size: var(--text-xs); }
/* One-tap resolver under the board — sign in / join. Interactive → teal (never gold). */
.lb-action {
  display: inline-flex; align-items: center; justify-content: center;
  align-self: flex-start;
  margin-top: var(--space-2);
  min-height: var(--touch-min);
  padding: 0 var(--space-4);
  border: 1px solid var(--accent);
  border-radius: var(--radius-md);
  background: var(--accent);
  color: var(--bg);
  font-weight: 600; cursor: pointer;
}
.lb-action:hover { filter: brightness(1.05); }
/* display:inline-flex above would defeat the [hidden] attribute — restore it explicitly. */
.lb-action[hidden] { display: none; }
@keyframes lb-row-in {
  0% { opacity: 0; transform: translateY(6px); }
  100% { opacity: 1; transform: none; }
}

@media (prefers-reduced-motion: reduce) {
  .cell-ripple { display: none; }
  .solve-panel:not([hidden]),
  .solve-panel:not([hidden]) .solve-title,
  .solve-panel:not([hidden]) .solve-stat,
  .streak-bump,
  #fortschrittGlobalBoard .leaderboard-row { animation: none; }
  /* No press-dip or selected-tab lift when motion is reduced — the state colour still changes. */
  .app-nav-link:active,
  .level-card:active:not(:disabled),
  .app-nav-link[aria-current="page"] .app-nav-ico { transform: none; }
}

/* Optional-account block inside the Konto & Daten modal. Empty (display:none via :empty) for
   local-only builds, so the modal is unchanged when Supabase is not configured. */
.dash-account { display: flex; flex-direction: column; gap: var(--space-2); margin: var(--space-3) 0; }
.dash-account:empty { display: none; }

/* Sign-in is the one primary interactive call here, so it wears the teal accent (never gold). */
.acc-signin {
  display: inline-flex; align-items: center; gap: var(--space-2);
  align-self: flex-start;
  min-height: var(--touch-min);
  padding: 0 var(--space-4);
  border: 1px solid var(--accent);
  border-radius: var(--radius-md);
  background: var(--surface-raised);
  color: var(--text);
  font-weight: 600;
  cursor: pointer;
}
.acc-signin:hover { background: color-mix(in oklch, var(--accent) 12%, var(--surface-raised)); }
.acc-hint { margin: 0; font-size: var(--text-xs); color: var(--text-muted); }
.acc-who { margin: 0; font-weight: 600; }
.acc-optin { display: flex; align-items: center; gap: var(--space-2); font-size: var(--text-sm); cursor: pointer; }
/* Toggle switch (not a bare checkbox): a settings opt-in reads more like a preference than a form
   field, and the sliding thumb makes the on/off state legible at a glance. Still a real checkbox
   underneath, so it stays keyboard- and screen-reader-accessible. Teal when on (interactive colour). */
.acc-optin input {
  appearance: none; -webkit-appearance: none;
  position: relative; flex: 0 0 auto;
  width: 40px; height: 24px; border-radius: var(--radius-pill);
  background: var(--line-ui); cursor: pointer;
  transition: background var(--duration-fast) var(--ease-out);
}
.acc-optin input::before {
  content: ""; position: absolute; top: 2px; left: 2px;
  width: 20px; height: 20px; border-radius: 50%;
  background: #fff;
  transition: transform var(--duration-fast) var(--ease-out);
}
.acc-optin input:checked { background: var(--accent); }
.acc-optin input:checked::before { transform: translateX(16px); }
.acc-optin input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.acc-signout {
  align-self: flex-start;
  min-height: var(--touch-min);
  padding: 0 var(--space-4);
  border: 1px solid var(--line-ui);
  border-radius: var(--radius-md);
  background: var(--surface);
  color: var(--text-muted);
  cursor: pointer;
}
.acc-signout:hover { color: var(--text); border-color: var(--text-muted); }

/* Header account control (2026-08-19 Shell & Account UX). Signed-out = a prominent teal sign-in
   CTA (teal = our interactive colour, so a filled button is palette-legal and does not fight the
   gold reward hue); signed-in = a neutral 44px avatar circle. Lives in the ACTIONS cluster next to
   the gear so sign-in is always visible top-right (research: docs/superpowers/research/2026-08-19-…). */
.account-chip {
  display: inline-flex; align-items: center; gap: var(--space-2);
  height: var(--touch-min); padding: 0 var(--space-3);
  border: none; border-radius: var(--radius-pill);
  background: var(--accent); color: var(--selected-ink);
  font: 600 var(--text-sm)/1 var(--font); cursor: pointer; white-space: nowrap;
  transition: filter var(--duration-fast) var(--ease-out), transform var(--duration-fast) var(--ease-out);
}
.account-chip:hover { filter: brightness(1.06); }
.account-chip:active { transform: scale(0.96); }
.account-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.account-chip[hidden] { display: none; }
/* Signed-in: drop the pill fill for a neutral avatar initial (matches .acc-avatar in the modal). */
.account-chip.is-signed-in {
  background: var(--surface-raised); color: var(--text);
  padding: 0; width: var(--touch-min); justify-content: center;
  box-shadow: inset 0 0 0 1px var(--line-ui);
}
.account-chip-avatar {
  display: inline-flex; align-items: center; justify-content: center;
  width: 100%; height: 100%; font: 700 var(--text-base)/1 var(--font);
}
/* Signed-out chip icon (neutral account glyph, NOT the old Google "G"): hidden by default so the
   chip is a clean "Anmelden" text button; shown only when the label collapses on narrow phones. */
.account-chip-ico { display: none; align-items: center; }

/* Narrow phones: brand (fixed ~170px) + the right tools cluster (help + labelled chip + gear) don't
   both fit, and because the cluster is right-aligned they OVERLAP the wordmark rather than overflow.
   Two-step relief that keeps both the "?" help and the sign-in affordance:
   - ≤399.98px: collapse the signed-out chip to its neutral 44px account icon (frees ~46px), the same
     footprint the signed-in avatar uses, so "?" still fits beside it on typical phones.
   - ≤359.98px: also drop "?" from the header (Spielregeln stays one tap away in the gear menu) so the
     smallest phones never collide. */
@media (max-width: 399.98px) {
  .account-chip:not(.is-signed-in) { width: var(--touch-min); padding: 0; justify-content: center; }
  .account-chip:not(.is-signed-in) .account-chip-label { display: none; }
  .account-chip:not(.is-signed-in) .account-chip-ico { display: inline-flex; }
}
@media (max-width: 359.98px) {
  .help-btn { display: none; }
}

/* Danger zone: the device-data reset, grouped under a "Daten" label + divider and marked
   destructive, so it reads as a deliberate action rather than a stray link. Bottom of Konto &
   Daten; the whole zone is hidden while signed in (progress is cloud-synced). */
.danger-zone { margin-top: var(--space-4); }
.danger-zone[hidden] { display: none; }
.danger-divider { border: none; border-top: 1px solid var(--line-ui); margin: 0 0 var(--space-3); }
.danger-group-label {
  margin: 0 0 var(--space-2);
  font: 600 var(--text-xs)/1 var(--font);
  text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted);
}
.reset-danger {
  min-height: var(--touch-min); padding: 0 var(--space-4);
  border: 1px solid color-mix(in oklch, var(--danger) 55%, var(--line-ui));
  border-radius: var(--radius-md);
  background: none; color: var(--danger);
  font: 600 var(--text-sm)/1 var(--font); cursor: pointer;
}
.reset-danger:hover { background: color-mix(in oklch, var(--danger) 12%, var(--surface-raised)); }
.reset-danger:focus-visible { outline: 2px solid var(--danger); outline-offset: 2px; }
.acc-privacy { margin: var(--space-3) 0 var(--space-2); }
#accDeleteBtn { display: block; width: 100%; margin-top: var(--space-2); }

/* askConfirm / askText — warm modal shell: icon + copy stack, paired action row. */
.modal-warm { max-width: 400px; padding: var(--space-5); }
.modal-warm-head {
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
  margin-bottom: var(--space-4);
}
.modal-warm-icon {
  flex: 0 0 auto;
  display: inline-grid;
  place-items: center;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: color-mix(in oklch, var(--accent) 14%, var(--surface-raised));
  border: 1px solid color-mix(in oklch, var(--accent) 28%, var(--line-ui));
  color: var(--accent);
  font-weight: 700;
  font-size: var(--text-lg);
  line-height: 1;
}
.modal-warm--danger .modal-warm-icon {
  background: color-mix(in oklch, var(--danger) 12%, var(--surface-raised));
  border-color: color-mix(in oklch, var(--danger) 40%, var(--line-ui));
  color: var(--danger);
}
.modal-warm-copy { min-width: 0; flex: 1 1 auto; }
.modal-warm-copy h2 {
  margin: 0 0 var(--space-2);
  font-size: var(--text-md);
  font-weight: 700;
  letter-spacing: 0.02em;
  text-transform: none;
  line-height: 1.3;
}
.modal-actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: var(--space-2);
  margin-top: var(--space-2);
}
.modal-action-ghost,
.modal-action-primary,
.modal-action-danger {
  min-height: var(--touch-min);
  padding: 0 var(--space-4);
  border-radius: var(--radius-md);
  font-weight: 600;
  font-size: var(--text-sm);
  cursor: pointer;
}
.modal-action-ghost {
  border: 1px solid var(--line-ui);
  background: var(--surface);
  color: var(--text-muted);
}
.modal-action-ghost:hover { color: var(--text); border-color: var(--text-muted); }
.modal-action-primary {
  border: 1px solid var(--accent);
  background: var(--accent);
  color: var(--bg);
}
.modal-action-primary:hover { filter: brightness(1.05); }
.modal-action-danger {
  border: 1px solid var(--danger);
  background: var(--danger);
  color: var(--bg);
}
.modal-action-danger:hover { filter: brightness(1.05); }

.confirm-dialog { max-width: 400px; }
.confirm-body { margin: 0; color: var(--text-muted); font-size: var(--text-sm); line-height: 1.45; }
.confirm-body[hidden] { display: none; }
#confirmCancel {
  min-height: var(--touch-min); padding: 0 var(--space-4);
  border: 1px solid var(--line-ui); border-radius: var(--radius-md);
  background: var(--surface); color: var(--text-muted); font-weight: 600; cursor: pointer;
}
#confirmCancel:hover { color: var(--text); border-color: var(--text-muted); }
#confirmOk.btn-danger {
  min-height: var(--touch-min); padding: 0 var(--space-4);
  border: 1px solid var(--danger); border-radius: var(--radius-md);
  background: var(--danger); color: var(--bg); font-weight: 600; cursor: pointer;
}
#confirmOk.btn-danger:hover { filter: brightness(1.05); }

/* Signed-in profile header: avatar initial + name + email + a synced-status line. Neutral chrome
   (avatar is identity, not a reward/interactive surface) — the only colour is the gold ✓ tick,
   which marks secured progress (gold = progress, per the palette rule). */
.acc-profile {
  display: flex; align-items: center; gap: var(--space-3);
  padding: var(--space-3);
  border: 1px solid var(--line-ui);
  border-radius: var(--radius-md);
  background: var(--surface-raised);
}
.acc-avatar {
  flex: 0 0 auto;
  display: inline-grid; place-items: center;
  width: 44px; height: 44px; border-radius: 50%;
  background: var(--surface-raised);
  border: 1px solid var(--line-ui);
  color: var(--text);
  font-weight: 700; font-size: var(--text-lg);
  text-transform: uppercase;
}
.acc-id { min-width: 0; }
.acc-name { margin: 0; font-weight: 700; font-size: var(--text-md); line-height: 1.2; }
.acc-email {
  margin: 2px 0 0; font-size: var(--text-xs); color: var(--text-muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;
}
.acc-synced { margin: 3px 0 0; font-size: var(--text-xs); color: var(--text-muted); }
.acc-synced-tick { color: var(--flag-gold); font-weight: 700; }

/* Styled text prompt (Rename / Switch user). Reuses the shared .modal shell; just needs the input. */
.text-prompt { max-width: 400px; }
.text-prompt-input {
  width: 100%; box-sizing: border-box;
  min-height: var(--touch-min);
  margin-top: var(--space-2);
  padding: 0 var(--space-3);
  border: 1px solid var(--line-ui);
  border-radius: var(--radius-md);
  background: var(--surface-raised);
  color: var(--text);
  font-size: var(--text-md);
}
.text-prompt-input:focus { outline: none; border-color: var(--accent); }
.text-prompt-ok {
  min-height: var(--touch-min);
  padding: 0 var(--space-4);
  border: 1px solid var(--accent);
  border-radius: var(--radius-md);
  background: var(--accent);
  color: var(--bg);
  font-weight: 600; cursor: pointer;
}
.text-prompt-ok:hover { filter: brightness(1.05); }

/* Benefit-driven sign-in card (logged-out state). Sells the login with concrete payoffs before the
   Google button, so signing in reads as a value exchange, not a chore. Gold ✓ = progress payoff. */
.acc-card {
  display: flex; flex-direction: column; gap: var(--space-2);
  padding: var(--space-3);
  border: 1px solid var(--line-ui);
  border-radius: var(--radius-md);
  background: var(--surface-raised);
}
.acc-card-title { margin: 0; font-weight: 700; font-size: var(--text-md); }
.acc-benefits { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 4px; }
.acc-benefits li {
  position: relative; padding-left: 1.4em;
  font-size: var(--text-sm); color: var(--text);
}
.acc-benefits li::before {
  content: "✓"; position: absolute; left: 0;
  color: var(--flag-gold); font-weight: 700;
}
.acc-card .acc-signin { align-self: stretch; justify-content: center; margin-top: var(--space-1); }

/* Signed-in payoff line on the win screen. Quiet by design — a reassurance, not a celebration. */
.solve-synced {
  margin: var(--space-2) 0 0; text-align: center;
  font-size: var(--text-xs); color: var(--text-muted);
}
.solve-synced-tick { color: var(--flag-gold); font-weight: 700; }

/* ── First-touch drag demo ──────────────────────────────────────────────────────────────────
 * A purely decorative ghost finger that traces a few neighbouring letters once, teaching the
 * drag gesture without any text. Owned entirely by WEFT_DRAGDEMO; aria-hidden, never interactive,
 * killed by any real pointerdown. Teal only (interactive affordance) — gold stays reserved for reward. */
.demo-overlay {
  position: absolute; inset: 0;
  pointer-events: none;
  z-index: 3;
  opacity: 1;
  transition: opacity 250ms var(--ease-out);
}
.demo-overlay.demo-fade { opacity: 0; }
.demo-finger {
  position: absolute;
  width: 18px; height: 18px;
  margin: -9px 0 0 -9px;              /* centre the dot on its (left,top) point */
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 0 6px color-mix(in oklch, var(--accent) 28%, transparent),
              0 2px 8px color-mix(in oklch, var(--accent) 40%, transparent);
  opacity: 0.85;
  transition: transform 360ms var(--ease-out);
  will-change: transform;
}
.demo-thread {
  position: absolute; inset: 0;
  overflow: visible;
  pointer-events: none;
}
.demo-thread polyline {
  fill: none;
  stroke: var(--accent);
  stroke-width: 6;
  stroke-linecap: round;
  stroke-linejoin: round;
  opacity: 0.55;
}
.cell.demo-glow {
  animation: demo-glow-pulse 420ms var(--ease-out);
}
@keyframes demo-glow-pulse {
  0%   { box-shadow: 0 0 0 0 color-mix(in oklch, var(--accent) 55%, transparent); }
  100% { box-shadow: 0 0 0 10px color-mix(in oklch, var(--accent) 0%, transparent); }
}
@media (prefers-reduced-motion: reduce) {
  .demo-finger { transition: none; }
  .demo-overlay { transition: none; }
  .cell.demo-glow { animation: none; }
}

/* Solo device-leaderboard warm state + relocated quiet reset (2026-09-07 A/B declutter). */
.leaderboard-solo { text-align: center; padding: 1.25rem 1rem; }
.leaderboard-solo .solo-star { display: block; font-size: 1.6rem; margin-bottom: 0.35rem; }
.leaderboard-solo .solo-title { margin: 0 0 0.15rem; font-weight: 600; color: var(--text); }
.leaderboard-solo .solo-count { margin: 0; color: var(--text-muted); }
.solo-cta { margin-top: 0.5rem; }
.progress-reset-row { margin-top: 1.25rem; text-align: center; }
.reset-quiet {
  background: none; border: 0; padding: 0.35rem 0.5rem;
  font: inherit; color: var(--text-muted); cursor: pointer;
}
.reset-quiet:hover, .reset-quiet:focus-visible { color: var(--text); text-decoration: underline; }

/* Recall probe — post-solve retention micro-check (2026-09-07). Gold=correct, teal=choice, no red. */
.recall-probe { margin-top: 1rem; padding: 0.9rem 1rem; text-align: center;
  border: 1px solid var(--border); border-radius: 12px; background: var(--surface); }
.recall-probe-title { margin: 0 0 0.25rem; color: var(--text-muted); font-size: 0.9rem; }
.recall-word { margin: 0 0 0.6rem; font-weight: 700; font-size: 1.2rem; color: var(--text); }
.recall-choices { display: flex; flex-wrap: wrap; gap: 0.4rem; justify-content: center; }
.recall-choice { font: inherit; padding: 0.45rem 0.8rem; border-radius: 999px; cursor: pointer;
  min-height: 44px; /* repo tap-target standard — was 39px, missed on every touch attempt */
  border: 1px solid var(--accent); background: none; color: var(--accent); }
.recall-choice:hover:not([disabled]), .recall-choice:focus-visible {
  background: var(--accent); color: var(--bg-main); }
.recall-choice[disabled] { cursor: default; opacity: 0.75; }
.recall-choice.is-correct { border-color: var(--flag-gold); color: var(--flag-gold);
  background: color-mix(in srgb, var(--flag-gold) 14%, transparent); opacity: 1; }
.recall-skip { margin-top: 0.55rem; background: none; border: 0; font: inherit;
  color: var(--text-muted); cursor: pointer; padding: 0.3rem; }
.recall-skip:hover, .recall-skip:focus-visible { color: var(--text); }

/* Article production (2026-09-12). Teal = the interactive choice, gold = the right answer.
   Never red for a miss — same non-punishing rule as the recall probe. */
.lesson-gender-quiz { display: inline-flex; gap: 0.3rem; vertical-align: middle; margin-right: 0.4rem; }
.gender-chip {
  font: inherit; font-size: 0.85rem; min-height: 44px; min-width: 44px; padding: 0 0.6rem;
  border-radius: 999px; cursor: pointer;
  border: 1px solid var(--accent); background: none; color: var(--accent);
}
.gender-chip:hover:not([disabled]), .gender-chip:focus-visible {
  background: var(--accent); color: var(--bg-main);
}
.gender-chip[disabled] { cursor: default; opacity: 0.45; }
.gender-chip.is-correct {
  border-color: var(--flag-gold); color: var(--flag-gold);
  background: color-mix(in srgb, var(--flag-gold) 14%, transparent); opacity: 1;
}
