/* Issue #283: split out of index.html's two inline <style> blocks.
   Merged into one file since the @font-face rules and the main
   ruleset have no cascade-order dependency on each other. */
  /* Variable-weight files (wght 300-700, latin subset) — one file per
     family covers every weight the UI uses, bundled locally so the app
     stays offline-capable. */
  @font-face {
    font-family: 'Fredoka';
    font-style: normal;
    font-weight: 300 700;
    font-display: swap;
    src: url('./fonts/Fredoka-Variable.woff2') format('woff2');
  }
  @font-face {
    font-family: 'Quicksand';
    font-style: normal;
    font-weight: 300 700;
    font-display: swap;
    src: url('./fonts/Quicksand-Variable.woff2') format('woff2');
  }

  :root {
    --paper: #e8d9b0;
    --paper-dark: #d3bd88;
    --ink: #2a2015;
    --accent: #a13a2a;
    --rule: #2a2015;
    /* Structural tokens (shape/type/depth) — theme-independent, shared
       by every dialog theme. Colours stay in the --theme-* vars below. */
    --font-display: 'Fredoka', 'Quicksand', system-ui, sans-serif;
    --font-ui: 'Quicksand', -apple-system, system-ui, 'Segoe UI', Roboto, sans-serif;
    --radius-key: 12px;
    --radius-field: 14px;
    --radius-btn: 16px;
    --radius-card: 18px;
    --radius-menu: 20px;
    --radius-dialog: 28px;
    /* Raised ("keycap") buttons: a solid bottom edge --edge-depth tall
       that the button sinks --press-depth into while pressed. */
    --edge-depth: 3px;
    --press-depth: 2px;
    --touch-min: 44px;
    --shadow-raised: 0 8px 24px rgba(12, 6, 28, 0.4);
    --shadow-overlay: 0 24px 60px rgba(8, 4, 20, 0.65);
    --focus-ring-color: #ffd166;
    /* Fixed (not per-theme) attention/badge hues. */
    --attention-bg: #1565c0;
    --attention-glow: rgba(21, 101, 192, 0.35);
    --attention-text: #fff;
    --badge-bg: #ffd400;
    --secret-cta-bg: #ffd166;
    --secret-cta-bg-pressed: #f2c14e;
    --secret-cta-edge: #c99a1e;
    --secret-cta-text: #3a2a00;
    --secret-cta-glow: rgba(255, 209, 102, 0.22);
    /* Ring around every cell sharing the number a cell dialog is open
       for (.cell-entry-target). */
    --theme-cell-target-ring: var(--theme-dialog-primary-btn-bg);
    --theme-cell-target-glow: rgba(124, 92, 255, 0.8);
    /* Issue #351: single shared timing for every menu/dialog/tooltip
       entrance animation in the app — was a mix of one-off durations
       (some animated at 0.34s, most others not animated at all).
       0.22s is a deliberate speed-up from the old 0.34s value that
       every "slide up" menu already used. */
    --ui-anim-duration: 0.22s;
    /* Shared stacked-button spacing for dialogs (e.g. New Game's size
       list, Friends List's actions row) — kept as named vars instead of
       hardcoded px so every dialog's spacing stays visually consistent. */
    --dialog-smallbuttongap: 10px;
    --dialog-standardbuttongap: 20px;
    /* Issue #155: theme-driven values, set at runtime by applyTheme()
       from THEME_REGISTRY. Defaults here match the Lava Lamp theme (the
       default and, while THEMES_LOCKED, only board theme) so the page
       renders correctly even before the startup script runs. */
    --theme-cell-bg: rgba(247, 242, 255, 0.97);
    /* Keycap bottom edge and text colour of an ordinary tile. */
    --theme-cell-keycap-edge: #b3a2e0;
    --theme-cell-ink: #241b3c;
    --theme-block-cell: transparent;
    --theme-hint-cell: #b79cf5;
    /* Darker shade of each special fill — the tile's keycap edge (and
       its outline, on a board style that draws one). Pinned per theme
       or derived in applyTheme(). */
    --theme-hint-cell-border: #7d5fcb;
    --theme-secret-cell: #ffe08a;
    --theme-secret-cell-border: #d6a11e;
    --theme-maybe-cell-border: #dd8fb0;
    /* Issue #524: fixed (not per-theme) accent ring so secret cells always
       read as "golden" regardless of theme — every theme's secretCell fill
       above is already some shade of tan/gold, but the fill alone can get
       lost against busy board art. */
    --secret-outline-color: #ffd700;
    --secret-outline-width: 2px;
    --theme-maybe-cell: #ffc7dd;
    --theme-last-entered-accent: #d81b9a;
    /* Issue #488/#493: purple font for the opponent's revealed letters at
       battle end — fixed pre-JS default, unconditionally overwritten by
       applyTheme() (see themeApply.js) with the same value so there's no
       color flash. Not theme-derived; it's a fixed "opponent's mark" hue
       across every theme. */
    --theme-battle-delta-letter: #7c5cff;
    /* Issue #160: border BETWEEN ordinary cells — derived per-theme from
       cellBg (slightly darker variant), independent of
       --theme-cell-edge-border below, which only governs the outer grid
       ring/block-cell edges and stays tied to hideGridBorder. */
    --theme-cell-border: rgba(193, 189, 199, 0.97);
    --theme-cell-edge-border: transparent;
    --theme-grid-border-color: transparent;
    --theme-grid-box-shadow: none;
    --theme-grid-filter: none;
    /* Issue #219/#296: board-style-driven values, set at runtime by
       applyBoardStyle() from activeBoardStyle. Defaults here match
       Modern, the only board style, same discipline as the --theme-*
       defaults above (#155), so the page renders correctly before the
       startup script runs. --board-cell-gap/--board-cell-border-radius
       are the two exceptions applyBoardStyle() never sets directly (see
       renderGrid()'s comment) — 0px here is just a neutral pre-layout
       fallback, not a real style value. */
    --board-cell-gap: 0px;
    --board-cell-border-width: 0px;
    --board-cell-border-radius: 0px;
    --board-cell-dash-angle: 15deg;
    --last-entered-ring-width: 3px;
    --board-cell-edge-depth: 2px;
    --board-cell-shadow: 0 3px 3px rgba(16, 8, 36, 0.3);
    --board-cell-blank-border-width: 0px;
    --board-cell-blank-shadow: none;
    --board-cell-number-top: 3px;
    --board-cell-number-left: 4px;
    --board-grid-border-width: 2px;
    /* Issue #252: alpha-tracker vars, set at runtime alongside the
       --board-cell-* vars above by applyBoardStyle(). Defaults here match
       Modern (today's alpha-row look), same discipline as the block
       comment above. */
    --alpha-border-radius: 9px;
    --alpha-row-gap: 3px;
    --alpha-tracker-gap: 3px;
    /* Padding between the alpha buttons and the edge of their panel. */
    --alpha-panel-pad: 8px;
    /* Alpha-tracker colours — aliases of the dialog theme's chrome
       tokens, so the tracker restyles with the menus/corner buttons. */
    --theme-alpha-panel-bg: var(--theme-dialog-corner-btn-bg);
    --theme-alpha-panel-border: var(--theme-dialog-corner-btn-border);
    --theme-alpha-bg: var(--theme-dialog-secondary-btn-bg);
    --theme-alpha-text: var(--theme-dialog-ink);
    --theme-alpha-checked-bg: var(--theme-dialog-primary-btn-bg);
    --theme-alpha-checked-text: var(--theme-dialog-primary-btn-text);
    --theme-alpha-checked-edge: var(--theme-btn-primary-edge);
    /* Issue #442: tunable via the admin-only #notification-pill-adjuster
       panel (app.js) — defaults here are the generic/fallback profile for
       any device without its own entry in ISLAND_PILL_DEVICE_PROFILES
       (app.js), which overrides these per detected device type at
       startup. Keep these as the best "works reasonably everywhere"
       values, not a specific device's pixel-perfect numbers. --width
       stays 'auto' (fit-to-content) here on purpose — it's only ever
       pinned to a px value by a device profile that wants to match that
       device's Dynamic Island exactly; the generic fallback keeps
       fit-to-content so untuned devices never clip. */
    --island-notification-top-offset: -6px;
    --island-notification-font-size: 14px;
    --island-notification-padding-top: 28px;
    --island-notification-padding-bottom: 30px;
    --island-notification-width: auto;
    /* Issue #455: fade phase before the slide/split reveal below. 0ms
       (the generic default) means no separate fade — pill slides
       immediately, matching pre-#455 behavior on any device without
       its own profile entry. */
    --island-notification-fade-duration: 0ms;
    /* Issue #455: live-toggled by the admin adjuster panel's
       Translucent button (app.js) — 1 (fully opaque) is the default/
       unaffected value; the panel flips it to 0.5 for testing. */
    --island-notification-bg-alpha: 1;
    /* Issue #455 (S22 Ultra follow-up): text-alignment/padding-left/
       right generalized into vars so a device profile can left-align
       text (e.g. for a circle-expand reveal) without hardcoding that
       to any specific device selector. Defaults below are today's
       previously-hardcoded values (centered text, 16px each side) —
       unaffected for any profile that doesn't set these fields. */
    --island-notification-justify-content: center;
    --island-notification-padding-left: 16px;
    --island-notification-padding-right: 16px;
    /* Derived, not device-set: the diameter a circle-expand reveal
       clips to, so it always matches whatever font-size/padding a
       profile is currently using. */
    --island-notification-circle-diameter: calc(var(--island-notification-font-size) + var(--island-notification-padding-top) + var(--island-notification-padding-bottom));
    --theme-body-bg-color: #7b2a66;
    --theme-body-bg-image: none;
    /* Optional wash painted over the background image (a theme's
       background.scrim) — transparent when the theme has none. */
    --theme-body-bg-scrim: linear-gradient(transparent, transparent);
    --theme-body-bg-position: center;
    /* Issue #158: device-tilt background parallax offset, driven at
       runtime by updateBackgroundTiltForTheme()/handleDeviceOrientation()
       — 0px means "no tilt applied yet / feature off". */
    --bg-tilt-x: 0px;
    --bg-tilt-y: 0px;
    /* Issue #222: dialog/menu-theme-driven values, set at runtime by
       applyDialogTheme() from DIALOG_THEME_REGISTRY. Defaults here match
       the Twilight (menus-purple) theme — the default and, while
       THEMES_LOCKED, only dialog theme — so the boot screen and first
       paint already render in it before the startup script runs. Keep in
       sync with that registry entry (same discipline as #155 above). */
    --theme-dialog-bg: #241b3c;
    --theme-dialog-ink: #e8e3f5;
    --theme-dialog-heading-ink: rgba(232, 227, 245, 0.85);
    --theme-dialog-border: #34275c;
    --theme-dialog-key-border: rgba(232, 227, 245, 0.12);
    --theme-dialog-key-border-ghosted: rgba(232, 227, 245, 0.4);
    --theme-dialog-option-bg: #34275c;
    --theme-dialog-group-bg: #2c2150;
    --theme-dialog-scrim: rgba(12, 6, 28, 0.62);
    /* Lighter backdrop for phone bottom sheets, which leave the board
       on screen above them on purpose. */
    --theme-dialog-scrim-light: rgba(12, 6, 28, 0.38);
    --theme-dialog-secondary-btn-bg: rgba(232, 227, 245, 0.1);
    --theme-dialog-primary-btn-bg: #6f4ef2;
    --theme-dialog-primary-btn-text: #ffffff;
    --theme-dialog-link-text: #b8a6ff;
    --theme-dialog-positive-bg: rgba(76, 201, 149, 0.18);
    --theme-dialog-positive-bg-strong: rgba(76, 201, 149, 0.32);
    --theme-dialog-positive-border: #4cc995;
    --theme-dialog-negative-bg: rgba(224, 67, 90, 0.18);
    --theme-dialog-negative-bg-strong: rgba(224, 67, 90, 0.32);
    --theme-dialog-negative-border: #e0435a;
    --theme-dialog-subtext: rgba(232, 227, 245, 0.7);
    --theme-dialog-subtext-strong: rgba(232, 227, 245, 0.85);
    --theme-dialog-subtext-italic: rgba(232, 227, 245, 0.55);
    --theme-dialog-label: rgba(232, 227, 245, 0.5);
    --theme-dialog-label-strong: rgba(232, 227, 245, 0.6);
    --theme-dialog-divider: rgba(232, 227, 245, 0.08);
    --theme-dialog-divider-strong: rgba(232, 227, 245, 0.14);
    --theme-dialog-warning-text: #ff8a9a;
    --theme-dialog-input-bg: rgba(232, 227, 245, 0.14);
    --theme-dialog-affirm-bg: rgba(63, 142, 119, 0.9);
    --theme-dialog-affirm-bg-pressed: rgba(63, 142, 119, 0.7);
    --theme-dialog-affirm-text: #fff;
    --theme-dialog-pending-bg: #d9a022;
    --theme-dialog-pending-text: #1a1614;
    --theme-skip-tutorial-bg: #315358;
    --theme-skip-tutorial-bg-pressed: #28444a;
    --theme-skip-tutorial-border: #4cc995;
    --theme-dialog-danger-bg: #8f2740;
    --theme-dialog-danger-text: #fff;
    --theme-dialog-danger2-bg: #e0435a;
    --theme-dialog-danger2-text: #fff;
    --theme-dialog-ghosted-btn-bg: rgba(232, 227, 245, 0.1);
    --theme-dialog-ghosted-btn-text: rgba(232, 227, 245, 0.45);
    /* Bottom-edge colour of each raised button role — derived per theme
       in applyDialogTheme() as a darker shade of that role's fill. */
    --theme-btn-primary-edge: #4b35a5;
    --theme-btn-option-edge: #17122a;
    --theme-btn-affirm-edge: rgba(43, 97, 81, 0.9);
    --theme-btn-danger-edge: #611b2c;
    --theme-btn-danger2-edge: #982e3d;
    --theme-btn-pending-edge: #946d17;
    --theme-pillmenu-bg: #241b3c;
    --theme-pillmenu-border: #34275c;
    --theme-pillmenu-text: #e8e3f5;
    --theme-pillmenu-heading: rgba(232, 227, 245, 0.5);
    --theme-pillmenu-pressed-bg: rgba(232, 227, 245, 0.12);
    --theme-pillmenu-ghosted-text: rgba(232, 227, 245, 0.45);
    --theme-pillmenu-warning-text: #ff8a9a;
    --theme-tooltip-bg: #3d2b66;
    --theme-tooltip-text: #e8e3f5;
    --theme-tooltip-btn-bg: #e8e3f5;
    --theme-tooltip-btn-text: #241b3c;
    /* Issue #344: corner-chrome buttons (settings/login/mode-badge/font-test
       panel) share one pill look — wired as dialog-theme vars so a dialog
       theme can restyle them. */
    --theme-dialog-corner-btn-bg: #241b3c;
    --theme-dialog-corner-btn-bg-strong: #241b3c;
    --theme-dialog-corner-btn-bg-pressed: #170f28;
    --theme-dialog-corner-btn-border: #34275c;
    --theme-dialog-corner-btn-text: var(--theme-dialog-ink);
    /* Issue #344: bright-affirm intentionally tracks the board's hint-cell
       green by default, but through a dialog var so a future dialog theme
       can override it independently of the board theme. */
    --theme-dialog-affirm-bright-bg: var(--theme-hint-cell);
    --theme-dialog-affirm-bright-text: var(--theme-cell-ink);
  }
  * { box-sizing: border-box; }
  /* Issue #343: a long-press/long-tap anywhere on the board, alpha row,
     or menus was selecting text and popping the browser's own copy/
     paste callout — previously patched one component at a time
     (.alpha-btn per #168, board cells per #186, .letter-key), which
     kept missing whatever wasn't touched yet (settings/login menu
     items, dialog headings/buttons, etc. — also reported on desktop
     Windows Chrome, not just iOS long-press). Applied universally
     instead of chasing each one individually; the explicit textarea/
     input override right below keeps the Feature Request box (the
     only real text-entry field in the app) normally selectable/
     editable. */
  * {
    -webkit-touch-callout: none;
    -webkit-user-select: none;
    user-select: none;
  }
  textarea, input {
    -webkit-touch-callout: default;
    -webkit-user-select: text;
    user-select: text;
  }
  /* Form controls don't inherit the page font by default — without this
     any button/input lacking its own font-family rule falls back to the
     browser's system font. */
  button, input, select, textarea {
    font-family: var(--font-ui);
  }
  button {
    font-weight: 600;
  }
  /* Keyboard focus only (:focus-visible never fires for touch/mouse). */
  button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible, [role="button"]:focus-visible {
    outline: 3px solid var(--focus-ring-color);
    outline-offset: 2px;
  }
  /* Themes every scrollbar (page + dialog lists like .version-list and
     #version-history-issue-status) to match the active dialog theme
     instead of the browser's default gray. scrollbar-color covers
     Firefox and Chromium; the ::-webkit-scrollbar-* rules cover
     Safari/Chrome specifically (and iOS Safari 18.2+). */
  html {
    scrollbar-color: var(--theme-dialog-option-bg) var(--theme-dialog-bg);
    scrollbar-width: thin;
    /* Issue #345: body already had overflow:hidden, but html itself
       didn't — on iOS Safari (especially standalone/installed PWA
       mode) that's enough on its own to let the whole page rubber-band
       scroll, dragging the fixed-size background along with it and
       revealing plain space past its edge. Locking html too, not just
       body, is what actually stops that outer bounce-scroll. */
    overflow: hidden;
  }
  *::-webkit-scrollbar {
    width: 10px;
    height: 10px;
  }
  *::-webkit-scrollbar-track {
    background: var(--theme-dialog-bg);
  }
  *::-webkit-scrollbar-thumb {
    background: var(--theme-dialog-option-bg);
    border-radius: 5px;
    border: 2px solid var(--theme-dialog-bg);
  }
  *::-webkit-scrollbar-thumb:hover {
    background: var(--theme-dialog-label-strong);
  }
  html, body {
    margin: 0;
    padding: 0;
    min-height: 100%;
    min-height: 100dvh;
    /* dvh alone doesn't reliably extend into the safe area on iOS (the
       home indicator strip) the way a plain background color does —
       add it back explicitly so the layout box itself reaches the true
       bottom edge, not just wherever dvh happens to measure. Issue
       #345: tried removing this (it looks redundant against the
       padding-bottom below on paper), which fixed the page-scroll
       symptom but brought back the original gap-at-the-bottom bug this
       was written for — so it's back, and #345's actual scroll problem
       is solved below via position:fixed on body instead. */
    min-height: calc(100dvh + env(safe-area-inset-bottom, 0px));
    background: var(--theme-body-bg-color);
    color: var(--ink);
    font-family: var(--font-ui);
    font-weight: 500;
    /* Belt-and-braces alongside the viewport meta tag's maximum-scale
       (the fix that actually works on real iOS devices for double-tap
       zoom — see the <meta name="viewport"> comment in <head>). */
    touch-action: manipulation;
  }
  body {
    /* Issue #345: body is DELIBERATELY taller than the visual viewport
       (see the env(safe-area-inset-bottom) addition above) so its
       background reaches the true bottom edge under iOS's home
       indicator — but that same extra height is exactly what let the
       page rubber-band scroll on iOS, which html's own overflow:hidden
       (below) didn't reliably stop on its own. position:fixed pins
       body directly to the viewport instead: a fixed-position element
       has no scroll position to begin with, so it's immune to that
       bounce-scroll regardless of its own height being "too tall".
       Only top/left/right are constrained (not bottom) so the existing
       height rule still determines how far down it extends. */
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    display: flex;
    flex-direction: column;
    height: 100dvh;
    height: calc(100dvh + env(safe-area-inset-bottom, 0px));
    /* Extra bottom padding clears the fixed version badge so it doesn't
       sit on top of the grid's own bottom row. */
    padding: calc(10px + env(safe-area-inset-top, 0px)) 10px calc(66px + env(safe-area-inset-bottom, 0px));
    overflow: hidden;
  }
  /* Issue #158: the theme background lives on its own layer behind body's
     real content instead of painted directly on body, so it can be
     panned by device tilt (Modern theme, tablet/mobile) without ever
     moving the board itself. inset is negative (bigger than the
     viewport) so panning within --bg-tilt-x/y's clamped range never
     reveals a bare edge. position:absolute (not fixed) is enough since
     body is already the fixed containing block.
     Issue #173: floored at 30px so narrow phones (where 8% alone can
     dip under the tilt range) still keep a safe buffer. */
  body::before {
    content: '';
    position: absolute;
    inset: calc(-1 * max(8%, 30px));
    z-index: -1;
    pointer-events: none;
    background-color: var(--theme-body-bg-color);
    background-image: var(--theme-body-bg-scrim), var(--theme-body-bg-image);
    background-size: cover;
    background-position: var(--theme-body-bg-position, center);
    background-repeat: no-repeat;
    transform: translate3d(var(--bg-tilt-x, 0px), var(--bg-tilt-y, 0px), 0);
    will-change: transform;
    /* Issue #407: resolveBackgroundImageColor() re-applies this var once
       an image-theme's first-ever color sample resolves, swapping the
       #C19A6B placeholder for the real average color. Transitioning just
       background-color fades that swap instead of snapping it. */
    transition: background-color 0.4s ease;
  }
  #alphabet-tracker {
    display: flex;
    flex-direction: column;
    position: relative;
    z-index: 0;
    gap: var(--alpha-tracker-gap);
    /* Room for the panel drawn by ::before below, which extends
       --alpha-panel-pad past the buttons on every side. Padding only
       narrows the row the buttons flex within — none of the #155
       layout rules below are affected. */
    padding: var(--alpha-panel-pad);
    margin-bottom: 12px;
  }
  /* Issue #292/#155: the unified row shadow lives on this pseudo-element,
     NOT as a box-shadow directly on #alphabet-tracker. #alphabet-tracker
     stretches to body's full width (default align-items:stretch, no
     override — see the Issue #155 block below for why that's load-
     bearing and must stay untouched), so a box-shadow on the tracker
     itself would span the full screen instead of hugging the buttons.
     An align-self:center attempt to shrink-wrap the tracker was tried
     and reverted — it broke iOS Safari (flex-basis:0% resolves to
     "content" under indefinite/shrink-to-fit sizing, so aspect-ratio
     squashed the buttons). This absolutely-positioned pseudo-element is
     removed from flex layout entirely so it never touches how the real
     buttons are measured.
     Follow-up: the static calc() math this used (matching .alpha-btn's
     assumed ideal geometry) assumed real buttons always render at their
     un-squashed max-width — true on real tablets and on portrait/2-row,
     but false on an Android phone in landscape (index.html's UA check
     grants tablet-rotatable-class to every Android device, not just real
     tablets) or a touch-enabled desktop window narrower than the ideal
     26-button width: buttons flex-shrink smaller than assumed, and the
     shadow box (still assumed-size) overhangs below the actual squashed
     row. syncAlphaTrackerShadow() in app.js now measures the real
     rendered .alpha-btn bounding box and drives these vars directly; the
     calc()s below only serve as the pre-JS-paint fallback (var()'s
     2nd arg) for the first frame.
     Issue #295 — READ THIS if the shadow is desynced on 1-row/landscape
     again: this JS-measurement fix (above) was still NOT the actual fix
     for that case. The real bug was in app.js's applyBoardStyle(), which
     changes --alpha-row-gap/--alpha-tracker-gap/--alpha-border-radius
     *after* syncAlphaTrackerShadow()'s one-time measurement, with no
     resync — its ResizeObserver only happens to catch this in 2-row mode
     (where --alpha-tracker-gap is part of the tracker's own height), not
     in 1-row (tracker width is stretch-locked, height is pure button
     size, neither depends on these vars). Fixed by an explicit
     syncAlphaTrackerShadow() call inside applyBoardStyle() itself — see
     that function's comment in app.js. Don't re-diagnose this as a CSS
     issue; it never was one. */
  #alphabet-tracker::before {
    content: '';
    position: absolute;
    left: var(--alpha-shadow-left, 0);
    top: var(--alpha-shadow-top, 0);
    z-index: -1;
    /* The box itself is still exactly the buttons' measured bounding
       box (so none of the sizing below changes) — the panel is drawn
       around it as box-shadow spread, --alpha-panel-pad wide, plus a
       1px ring and the drop shadow. Spread follows border-radius, so
       the panel's outer corners come out pad-px rounder than the
       buttons' own. */
    border-radius: var(--alpha-border-radius);
    background: var(--theme-alpha-panel-bg);
    box-shadow:
      0 0 0 var(--alpha-panel-pad) var(--theme-alpha-panel-bg),
      0 0 0 calc(var(--alpha-panel-pad) + 1px) var(--theme-alpha-panel-border),
      0 8px 24px var(--alpha-panel-pad) rgba(12, 6, 28, 0.4);
    pointer-events: none;
    /* 32px here must match .alpha-btn's max-width below. */
    width: var(--alpha-shadow-width, calc(13 * 32px + 12 * var(--alpha-row-gap)));
    height: var(--alpha-shadow-height, calc(2 * 32px + var(--alpha-tracker-gap)));
  }
  .alpha-row {
    display: flex;
    gap: var(--alpha-row-gap);
    justify-content: center;
  }
  .alpha-btn {
    position: relative;
    flex: 1;
    max-width: 32px;
    aspect-ratio: 1;
    border: none;
    background: var(--theme-alpha-bg);
    color: var(--theme-alpha-text);
    font-family: var(--font-ui);
    font-weight: 700;
    font-size: 13px;
    border-radius: var(--alpha-border-radius);
    cursor: default;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0;
    /* Issue #168: iOS Safari's long-press text-selection/Copy-Paste
       callout was firing alongside the fill-shortcut menu on these
       buttons (the letter is a text node, so a long-press reads as
       "select this text" by default) — now handled by #343's
       universal rule instead; kept touch-action here since that's
       specific to this button's own tap-vs-zoom behavior. */
    touch-action: manipulation;
  }
  .alpha-btn.checked {
    background: var(--theme-alpha-checked-bg);
    color: var(--theme-alpha-checked-text);
    box-shadow: 0 2px 0 var(--theme-alpha-checked-edge);
    cursor: pointer;
  }
  /* Issue #57: best-effort generation doesn't always cover all 26
     letters — any letter that isn't anywhere on this game's board
     shows ghosted so it's clear there's nothing to find for it. */
  .alpha-btn.missing {
    opacity: 0.3;
  }
  /* Issue #155 — READ THIS BEFORE TOUCHING THE ALPHA ROW LAYOUT AGAIN.
     Burned a large number of iterations on this; the eventual fix is
     simple, but every intermediate step looked reasonable and still
     broke something. Save yourself the cycles:

     THE FEATURE: tablet landscape has plenty of width to spare — the
     two 13-letter rows (A-M / N-Z) read better as one continuous row
     of 26 than stacked, with bigger (63px) buttons. Below that (2-row
     stacked, 32px buttons) is the plain default, unconditional, no
     media query — untouched since before this feature existed.

     THE ROOT MISTAKE, discovered only at the end: this was never a
     CSS rendering bug. Every "broken square" report (Firefox/Ubuntu
     non-square buttons, Windows Chrome flattened rectangles, desktop
     overflow cutting off the last letter) happened because a *desktop
     browser window* — not a real tablet — was matching this same
     (orientation: landscape) and (min-height: 500px) media query
     without a real tablet's guaranteed screen space. A resizable
     window can be landscape-shaped and 500px+ tall while still being
     too narrow for 26 buttons at 63px, and different browsers degrade
     that shrink differently (Chrome: align-items:stretch fights
     aspect-ratio; Firefox: per-letter uneven min-content widths). On
     every REAL tablet actually tested live (Galaxy Tab S7 FE, Lenovo
     Tab One), this plain media query — no min-width:0, no
     align-items override, no JS sizing of any kind — always rendered
     correctly. Confirmed again after the final fix below.

     WHAT DOESN'T WORK (all tried, all reverted — don't retry these):
     1. min-width:0/min-height:0 + align-items:center on the base
        rules, to fix the shrink rendering — treats the symptom, still
        breaks somewhere (fixed Chrome's rectangles but not Galaxy
        Tab's fit, or vice versa depending on the exact combination).
     2. JS computing an exact-fit pixel width from clientWidth/gap
        arithmetic — the manual math was ITSELF wrong (overflowed on
        a maximized desktop window, under-fit on a real tablet), and
        is unnecessary complexity for a problem that doesn't exist on
        real hardware.
     3. Native flex-shrink (flex:1 1 0%, min-width:0) + JS measuring
        the rendered width and forcing an explicit height to match —
        closer, but still solving for the wrong target (a desktop
        window that was never supposed to get this layout at all).

     THE ACTUAL FIX: stop trying to make a desktop window render this
     layout well, and instead make sure it never gets the layout in
     the first place. html.tablet-rotatable-class is set once in
     <head> (Android/iOS always qualify; a Windows/Mac/Linux/ChromeOS device only
     qualifies if it also reports real touch support, i.e. is a
     genuine 2-in-1). Scope the media query behind that class. A plain
     mouse-driven desktop window, maximized or not, now always gets
     the 2-row layout — which never had a rendering bug on any real
     device — and no JS is involved in this layout at all.

     MUST come after the base #alphabet-tracker/.alpha-btn rules above
     — a media query doesn't add specificity, so with equal-
     specificity selectors the LATER rule in source order wins
     regardless of which media query is actually active. */
  @media (orientation: landscape) and (min-height: 500px) {
    html.tablet-rotatable-class #alphabet-tracker {
      flex-direction: row;
      justify-content: center;
    }
    html.tablet-rotatable-class .alpha-btn {
      max-width: 63px;
      font-size: 24px;
    }
    /* 63px here must match .alpha-btn's max-width just above. Only the
       pre-JS-paint fallback (see the ::before comment above) — JS
       overrides this breakpoint the same as the 2-row one once real
       button geometry is measured. If the live shadow is desynced here
       specifically (1-row mode), see the Issue #295 note on the main
       ::before rule above — it's an app.js applyBoardStyle() ordering
       bug, not this fallback calc(). */
    html.tablet-rotatable-class #alphabet-tracker::before {
      width: var(--alpha-shadow-width, calc(26 * 63px + 24 * var(--alpha-row-gap) + var(--alpha-tracker-gap)));
      height: var(--alpha-shadow-height, 63px);
    }
  }
  /* Issue #71/#73: New Game moved to a fixed bottom-left corner
     (mirroring #version-badge's fixed bottom-right placement) so the
     header no longer needs to permanently reserve space for it or the
     old size-chooser pill. #73 added the settings icon to its left,
     inside the same fixed wrapper so they move together. */
  /* The bottom-left buttons sit together in one pill-shaped dock: the
     container carries the fill/ring/shadow, the buttons inside it are
     plain transparent 44px (--touch-min) circles. */
  #bottom-left-controls {
    position: fixed;
    bottom: 8px;
    /* Issue #73 follow-up: nudged in from the edge — iPhone's rounded
       corner made the settings icon feel cramped right at 12px. 17px +
       the dock's own 4px padding and 1px ring lands the first button
       where the old 22px offset put it. */
    left: 17px;
    z-index: 10;
    display: flex;
    align-items: center;
    gap: 4px;
    padding: 4px;
    border-radius: 27px;
    border: 1px solid var(--theme-dialog-corner-btn-border);
    background: var(--theme-dialog-corner-btn-bg);
    box-shadow: var(--shadow-raised);
  }
  /* Wizard-hints/orientation/emote buttons share #settings-btn's exact
     icon-button look — deliberately their own selectors here (not
     .corner-pill), so they never participate in the corner pill's
     shrink animation. */
  #settings-btn, #feedback-tools-btn, #switch-orientation-btn, #emote-btn {
    /* Issue #348: positioning context for #settings-update-badge's own
       position:absolute (matches #login-btn-wrap's role for the avatar
       badges, just directly on the button itself instead of a wrapper). */
    position: relative;
    width: var(--touch-min);
    height: var(--touch-min);
    border-radius: 50%;
    border: none;
    background: transparent;
    color: var(--theme-dialog-corner-btn-text);
    font-size: 18px;
    font-family: inherit;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0;
    cursor: pointer;
  }
  /* Line icons inside the corner buttons — drawn in the button's own
     text colour, so they follow the dialog theme. */
  .corner-icon {
    width: 22px;
    height: 22px;
    fill: none;
    stroke: currentColor;
    stroke-width: 2;
    stroke-linecap: round;
    stroke-linejoin: round;
    pointer-events: none;
  }
  /* #emote-btn lives alone in the bottom-right corner, outside the
     dock, so it carries the dock's fill/ring/shadow itself. */
  #emote-btn {
    border: 1px solid var(--theme-dialog-corner-btn-border);
    background: var(--theme-dialog-corner-btn-bg);
    box-shadow: var(--shadow-raised);
  }
  #settings-update-badge {
    background: var(--attention-bg);
    color: #fff;
    font-weight: 700;
    /* Issue: font-weight alone barely thickens a plain Unicode arrow
       glyph (↑) in most typefaces — that character usually has no
       distinct bold variant drawn for it, unlike letters/digits. A
       text-stroke fattens the actual rendered outline instead, which
       works regardless of the font's own weight variants. */
    -webkit-text-stroke: 1px #fff;
  }
  /* Issue #415: same .avatar-badge shape/corner as #settings-update-badge
     above (top-right, same as #admin-badge on the login avatar) — a
     Battle invite takes priority over a pending update, so this stacks
     above #settings-update-badge via z-index when both are visible. */
  #battle-invite-badge {
    z-index: 1;
    background: var(--theme-dialog-danger2-bg);
    color: #fff;
  }
  /* Issue #497: same corner as #battle-invite-badge above, stacked one
     level higher via z-index — a Co-op invite takes priority display over
     both a pending update and a pending Battle invite when more than one
     happens to be true at once. Affirm green, not Battle's warning red. */
  #coop-invite-badge {
    z-index: 2;
    background: var(--theme-dialog-affirm-bg);
    color: #fff;
  }
  #settings-btn:active, #switch-orientation-btn:active, #feedback-tools-btn:active, #emote-btn:active {
    background: var(--theme-dialog-corner-btn-bg-pressed);
  }
  /* Issue: shown on #switch-orientation-btn after a tablet's landscape
     lock silently fails to restore itself post-update-reload (see
     maybeNudgeOrientationRecovery() in app.js) — the button is always
     on-screen for tablet users already, so a scale-only pulse risks
     being missed; the glow ring (same #1565c0 blue as
     #settings-update-badge's "something needs attention" accent) makes
     it read as a distinct alert. Finite iteration count (not infinite)
     so it stops on its own even if the player never taps it; JS also
     removes the class the instant they do. */
  @keyframes orientation-nudge-pulse {
    0%, 100% { transform: scale(1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); }
    50% { transform: scale(1.15); box-shadow: 0 0 0 8px var(--attention-glow), 0 2px 8px rgba(0, 0, 0, 0.2); }
  }
  #switch-orientation-btn.orientation-nudge {
    animation: orientation-nudge-pulse 1.6s ease-in-out 3;
  }
  /* Issue #67: same 40px circle as #settings-btn, dark red like
     #secret-word-reopen-btn — relative positioning context for the
     admin/tester badges, same role as #settings-btn has for its own
     update badge. Issue #490: fixed top-right corner, deliberately
     allowed to overlap #alphabet-tracker if space is tight — no other
     fixed element claims that corner today (was previously anchored
     via JS to #bottom-left-controls, which was getting cramped).
     z-index:9999 is a deliberate ceiling well
     above the current max (#font-test-panel's 3200), not another +100
     increment in that sequence — this button must stay reachable no
     matter what future overlay gets added, since it exists to
     screenshot a bug at the moment it's on screen (a dialog, the tip
     spotlight, a context menu, etc). */
  /* Issue #222: standardized onto --theme-dialog-danger-* (was its own
     --accent-based red, a different shade than "Clear this letter
     only"/.dialog-danger-btn) — now matches the app's one warning red. */
  #debug-screenshot-btn {
    position: fixed;
    top: calc(8px + env(safe-area-inset-top, 0px));
    right: 8px;
    z-index: 9999;
    width: 40px;
    height: 40px;
    border-radius: 50%;
    /* Issue #357: fixed black, not --theme-dialog-ink — this button sits
       outside any dialog, always over the same dark-red danger bg, so its
       outline shouldn't swap to white on light-ink themes like Twilight. */
    border: 1px solid #000;
    background: var(--theme-dialog-danger-bg);
    color: var(--theme-dialog-danger-text);
    font-size: 18px;
    font-family: inherit;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 0;
    cursor: pointer;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
  }
  /* Issue #7: same pill treatment as #mode-badge (height matches
     #settings-btn's 40px) while signed out; .signed-in below switches
     it to a circular avatar button instead, same footprint. */
  /* Issue: same round 40px treatment as #settings-btn now, in both
     signed-in (avatar) and signed-out (😶‍🌫️ emoji) states — both
     show a single icon now that the sign-in action moved to the
     dropdown, so the wide text-pill shape no longer applies. */
  #login-btn {
    width: var(--touch-min);
    height: var(--touch-min);
    display: flex;
    align-items: center;
    justify-content: center;
    background: transparent;
    border: none;
    color: var(--theme-dialog-corner-btn-text);
    border-radius: 50%;
    padding: 0;
    font-size: 13px;
    font-weight: 600;
    font-family: var(--font-ui);
    cursor: pointer;
  }
  #login-btn:active {
    background: var(--theme-dialog-corner-btn-bg-pressed);
  }
  #login-btn.signed-in {
    overflow: hidden;
  }
  #login-btn-avatar {
    width: 36px;
    height: 36px;
    border-radius: 50%;
    display: block;
  }
  /* Signed-out shows the globe; the user icon is the signed-in fallback
     for when the avatar image itself fails to load (data-state is set
     in googleAuth.js). */
  #login-btn-label {
    display: flex;
  }
  #login-btn-label[data-state="signed-out"] .corner-icon-signed-in,
  #login-btn-label[data-state="signed-in"] .corner-icon-signed-out {
    display: none;
  }
  #login-btn-wrap {
    position: relative;
    display: inline-flex;
  }
  /* Issue #200/#216/#317: shared shape/position for both the admin and
     tester badges on the avatar's top-right corner — only the color/
     emoji differ per badge (set on the specific #id below), and only
     one is ever visible at a time (admin takes precedence server-side,
     see /api/badge-status). Sized as a PERCENTAGE of #login-btn-wrap
     (the badge's own containing block, which sizes to #login-btn — see
     that button's own width/height) rather than a fixed px, per #317 —
     so it stays proportional if that button's size ever changes,
     instead of needing a manual re-tune. 54% ≈ 18px's old fixed size
     bumped up ~20%, per #317's explicit ask. */
  .avatar-badge {
    position: absolute;
    /* Issue #320/#321: negative offsets push it out past the button's
       own corner (up + right, 45 degrees out) instead of sitting flush
       inside it — the usual notification-badge look. Percentage-based
       like width/height above, so it stays proportional too. -12.36%
       isn't a round-number guess — it's the exact offset that puts the
       badge's CENTER on the avatar circle's own outline at the 45°
       point: for a circle of radius r centered in the container, that
       point sits at r + r·cos45° from the container's near edge, and
       the badge (radius = 27% of the container, i.e. half of its 54%
       width/height) needs to be pulled back by its own radius from
       there. Reduce to r·(1+cos45°) - badgeRadius, as a % of the
       container, to re-derive if width/height ever change. */
    top: -12.36%;
    right: -12.36%;
    width: 54%;
    height: 54%;
    border-radius: 50%;
    border: 1px solid var(--ink);
    display: none;
    align-items: center;
    justify-content: center;
    font-size: 13px;
    line-height: 1;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
    cursor: pointer;
  }
  #admin-badge, #debug-admin-badge {
    background: var(--badge-bg);
  }
  /* Issue #468: same corner/shape as #admin-badge/#tester-badge above —
     stacks above both via z-index (same relationship #battle-invite-badge
     has with #settings-update-badge), same blue as #settings-update-badge
     per the issue's own wording. */
  #friend-request-badge {
    z-index: 1;
    background: var(--attention-bg);
    color: #fff;
  }
  #tester-badge, #debug-tester-badge {
    background: #3399ff;
  }
  /* Issue #264: same yellow-circle admin badge look as .avatar-badge
     above, but for sitting inline next to a settings row's label text
     instead of pinned to an avatar/button corner — fixed px size and
     static inline-flex layout instead of % sizing + absolute position. */
  .inline-admin-badge {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 18px;
    height: 18px;
    margin-left: 6px;
    border-radius: 50%;
    border: 1px solid var(--ink);
    background: var(--badge-bg);
    font-size: 11px;
    line-height: 1;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
  }
  /* Issue #229/#252: per-word "look up definition" badge on the
     greyed-out post-win board — same circular treatment/colors as
     #admin-badge above, just smaller (board cells can be much smaller
     than the 40px avatar). Appended as a DIRECT CHILD OF #grid (not
     nested inside the target cell) and positioned in JS via
     getBoundingClientRect() math — #229 originally nested it inside
     the cell div, but every "#grid div" has its own explicit z-index:0
     (issue #45, to scope .cell-answer's z-index:-1), which makes EVERY
     cell its own stacking context. That trapped the badge's z-index
     inside its cell instead of comparing it against
     #grid.board-solved::after's z-index:5 — the cell itself (z-index:0)
     always lost to the overlay, so the badge was visible but never
     actually clickable. Living directly under #grid instead puts it in
     the same stacking context as ::after, where z-index:6 legitimately
     beats 5. top/left are set inline per badge in JS, not here. */
  /* Issue: width/height/font-size set inline per badge in JS now — a
     flat 16px read as tiny on desktop's much bigger cells (cellSize
     scales with board size/viewport, this badge didn't). Scaled as a
     percentage of currentCellSize instead, see addWordDefinitionBadges(). */
  .word-def-badge {
    position: absolute;
    border-radius: 50%;
    border: 1px solid var(--ink);
    background: var(--badge-bg);
    display: flex;
    align-items: center;
    justify-content: center;
    font-weight: 700;
    line-height: 1;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
    cursor: pointer;
    z-index: 6;
  }
  /* Testing-tools "word suggestions" badge (feedback-tools-show-suggestions)
     — same circular treatment/positioning approach as .word-def-badge
     above (see its comment), just blue instead of yellow, with white
     text for contrast against the darker fill. #1565c0 is the same blue
     already used elsewhere for "needs attention" accents
     (#settings-update-badge, .orientation-nudge glow). */
  .word-suggestion-badge {
    position: absolute;
    border-radius: 50%;
    border: 1px solid var(--ink);
    background: var(--attention-bg);
    color: #fff;
    display: flex;
    align-items: center;
    justify-content: center;
    font-weight: 700;
    line-height: 1;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
    cursor: pointer;
    z-index: 6;
  }
  /* Issue #339: the badge only partially overlaps its cell's corner, so a
     slightly mis-aimed tap fell through to the cell underneath and opened
     the letter-entry dialog. This invisible ::before extends the actual
     clickable area past the visible circle (--hit-pad set inline per
     badge in JS, scaled off badgeSize) without changing how the badge
     looks — a tap anywhere in the expanded margin still hit-tests to this
     span, not the cell beneath it. */
  .word-suggestion-badge::before {
    content: '';
    position: absolute;
    inset: calc(-1 * var(--hit-pad, 8px));
  }
  /* #328: word-suggestions dialog content — scroll wrap follows the same
     capped-height treatment as #wordsets-overlay's list (see its comment
     above); chip background reuses .wordset-option's bg var so it matches
     theme automatically without any hardcoded colours (#356: no border). */
  .word-suggestions-list-wrap {
    max-height: 40vh;
    max-height: 40dvh;
    overflow-y: auto;
    margin-top: 8px;
  }
  .word-suggestions-list {
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
    margin: 0;
    padding: 0;
    list-style: none;
  }
  .word-suggestions-list li {
    border-radius: 6px;
    padding: 4px 10px;
    background: var(--theme-dialog-option-bg);
    font-family: var(--font-ui);
    font-size: 14px;
    color: var(--theme-dialog-ink);
  }
  /* Issue #7: same floating-menu treatment as #settings-menu below,
     positioned the same way since #login-btn sits at the same left
     edge of #bottom-left-controls — just a single Sign Out item. */
  #login-overlay .g_id_signin {
    display: flex;
    justify-content: center;
    margin-top: 4px;
  }
  /* Issue #170: matches the Google button's own data-width="300" above
     — .dialog-close-btn's default width:100% would otherwise stretch
     Cancel to the full dialog width, wider than the button above it. */
  #login-cancel {
    appearance: none;
    -webkit-appearance: none;
    border: none;
    border-radius: var(--radius-btn);
    min-height: 50px;
    padding: 12px 14px;
    font-size: 16px;
    font-weight: 700;
    cursor: pointer;
    font-family: var(--font-ui);
    width: 300px;
    max-width: 100%;
    margin: 16px auto 0;
  }
  /* Issue #144: same centered 300px sizing as #login-cancel above, so
     the "Let's play!" button lines up the same way the Google button
     does when it's showing instead. */
  #login-lets-play {
    appearance: none;
    -webkit-appearance: none;
    border: none;
    border-radius: var(--radius-btn);
    min-height: 50px;
    padding: 12px 14px;
    font-size: 16px;
    font-weight: 700;
    cursor: pointer;
    font-family: var(--font-ui);
    width: 300px;
    max-width: 100%;
    margin: 16px auto 0;
  }
  /* Issue: .dialog-actions-row buttons stretch to the dialog-box's full
     width — cap the row itself and center it to match the rest of this
     dialog's controls. */
  #welcome-overlay .dialog-actions-row {
    width: 300px;
    max-width: 100%;
    margin: 12px auto 0;
  }
  /* Issue #73: a small floating menu above the settings icon, rather
     than opening a dialog directly — bottom offset clears the button
     plus the fixed wrapper's own 8px bottom inset. */
  /* One shared box for all four floating menus (settings, login, wizard
     hints, emote) — each still computes its own --tail-left in JS from
     its own trigger button's position. #login-menu's narrower min-width
     and #emote-menu's right anchoring are set below. */
  #settings-menu, #login-menu, #feedback-tools-menu, #emote-menu {
    display: none;
    position: fixed;
    bottom: 68px;
    left: 22px;
    z-index: 500;
    flex-direction: column;
    gap: 2px;
    min-width: 170px;
    background: var(--theme-pillmenu-bg);
    border: 1px solid var(--theme-pillmenu-border);
    border-radius: var(--radius-menu);
    padding: 8px;
    box-shadow: var(--shadow-raised);
  }
  /* Issue #189 follow-up: the whole bubble slides/fades up as one unit
     from the cog, rather than appearing instantly with items rendering
     in afterward. transform-origin is pinned to the tail's horizontal
     position (bottom left, --tail-left) so it reads as growing out of
     the cog specifically, not just the box's own corner. */
  @keyframes settingsMenuSlideUp {
    from {
      opacity: 0;
      transform: translateY(18px) scale(0.85);
    }
    to {
      opacity: 1;
      transform: translateY(0) scale(1);
    }
  }
  /* Issue #351: shared entrance for anything WITHOUT a directional
     anchor point to slide from (dialogs, tooltips) — same
     --ui-anim-duration/ease-out as settingsMenuSlideUp above, just a
     plain centered pop instead of a tail-anchored slide. */
  @keyframes fadeScaleIn {
    from {
      opacity: 0;
      transform: scale(0.94);
    }
    to {
      opacity: 1;
      transform: scale(1);
    }
  }
  @keyframes fadeIn {
    from { opacity: 0; }
    to { opacity: 1; }
  }
  #settings-menu.open, #login-menu.open, #feedback-tools-menu.open, #emote-menu.open {
    display: flex;
    transform-origin: var(--tail-left, 20px) bottom;
    animation: settingsMenuSlideUp var(--ui-anim-duration) ease-out;
  }
  /* Issue #189: a small tail bridging the gap down to #settings-btn so
     the menu visually reads as opening FROM the cog rather than just
     floating above it. Classic bubble-tail trick — a square rotated 45°
     with only its right+bottom borders drawn forms a corner pointing
     down; positioned so half of it pokes out past the box's own bottom
     edge. #settings-btn isn't at a fixed offset itself — it sits in a
     flex row AFTER #login-btn, whose width varies (text pill when
     signed out vs. a 40px circle when signed in), so its screen
     position shifts. --tail-left is set in JS from
     settings-btn.getBoundingClientRect() right before the menu opens,
     so the tail always lines up with wherever the cog actually is. */
  #settings-menu::after, #login-menu::after, #feedback-tools-menu::after, #emote-menu::after {
    content: '';
    position: absolute;
    bottom: -7px;
    left: var(--tail-left, 20px);
    width: 14px;
    height: 14px;
    background: var(--theme-pillmenu-bg);
    border-right: 1px solid var(--theme-pillmenu-border);
    border-bottom: 1px solid var(--theme-pillmenu-border);
    transform: rotate(45deg);
  }
  /* Issue #430: emote nudge menu, opened from #emote-btn — right-anchored
     to match #bottom-right-controls (the box stays at a static right
     offset; only the tail's --tail-left tracks #emote-btn's position). */
  #emote-menu {
    left: auto;
    right: 22px;
  }
  #login-menu {
    min-width: 140px;
  }
  /* Issue #193: switched from Trebuchet MS to Quicksand (bold) to match
     dialog headings (.dialog-box h2), at a smaller size to fit menus. */
  .settings-menu-item {
    /* Issue: BUG FIX — the disabled "💡 Filling all letters..." item
       still looked emoji-and-all ghosted even after :disabled switched
       from opacity to a text color, because Chrome/Safari's native
       default appearance for a disabled <button> applies its own
       dimming underneath any author CSS unless appearance is reset.
       Explicit appearance:none removes that native layer entirely, so
       our own color-only ghosting is the only thing affecting it. */
    appearance: none;
    -webkit-appearance: none;
    border: none;
    background: none;
    text-align: left;
    padding: 7px 12px;
    font-size: 16px;
    font-weight: 700;
    font-family: var(--font-ui);
    color: var(--theme-pillmenu-text);
    border-radius: 10px;
    cursor: pointer;
  }
  /* Issue #352: "Update available!" reads dark red while pending,
     instead of blending in as plain ink text. Brightened from the
     .dialog-danger-btn shade (#7a2820) to the app's own --accent red —
     still clearly a dark red, not a bright alarm red. */
  .settings-menu-item.update-pending {
    color: var(--theme-pillmenu-warning-text);
  }
  /* Issue #478: "Join Battle!" reads dark red while an invite is
     pending, same treatment/variable as the #352 update-pending item
     above. */
  .settings-menu-item.battle-invite-pending {
    color: var(--theme-pillmenu-warning-text);
  }
  /* Issue #486: "Friends List" reads in the app's standard affirm green
     while a friend-request badge is pending, same treatment as the two
     rules above but reusing the accept/affirm green instead of the
     warning red. */
  .settings-menu-item.friend-request-pending {
    color: var(--theme-dialog-affirm-bg);
  }
  /* Issue #497: "Join co-op game!" reads in the same affirm green as
     friend-request-pending above (a Co-op invite is a positive/green
     action, unlike Battle's warning-red "Join Battle!"). */
  .settings-menu-item.coop-invite-pending {
    color: var(--theme-dialog-affirm-bg);
  }
  .settings-menu-item:active {
    background: var(--theme-pillmenu-pressed-bg);
  }
  /* Issue: the alpha-row/board-cell context menus' informational "not
     available on this board size" state (💡 Filling all letters...).
     Uses a muted text color rather than opacity — opacity fades the
     whole item uniformly, including its emoji, which reads as broken
     rather than "disabled"; color emoji ignore CSS `color` entirely,
     so the emoji stays full-brightness while the wording ghosts. */
  .settings-menu-item:disabled {
    color: var(--theme-pillmenu-ghosted-text);
    cursor: default;
  }
  .settings-menu-item:disabled:active {
    background: none;
  }
  /* Issue: tighter item spacing on phones specifically (the menu's
     grown a lot of items — Notifications, Local Scoreboard's rename,
     etc. — and phone screens have the least room to spare). Same
     max-width: 480px breakpoint as #219's phone-only styling above.
     Headings are untouched on purpose — only requested for items. */
  @media (max-width: 480px) {
    .settings-menu-item {
      padding: 5px 12px;
    }
  }
  /* Issue #211: purely cosmetic category labels — small all-caps,
     muted, no click behavior of their own. */
  .settings-menu-heading {
    font-size: 10px;
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--theme-pillmenu-heading);
    font-family: var(--font-ui);
    /* Issue #258 follow-up: a little extra breathing room above a
       heading that follows other items (e.g. "Game Updates" after the
       Game Controls group) — first-child (the very top heading) stays
       flush, no gap to add there. */
    padding: 14px 12px 0 8px;
  }
  .settings-menu-heading:first-child {
    padding-top: 0;
  }
  /* Group wrappers inside #settings-menu (index.html) — layout-neutral
     here, so on tablets/desktop the items stay direct flex children of
     the popup exactly as before. The phone sheet below gives them real
     boxes. */
  .settings-menu-play, .settings-menu-group {
    display: contents;
  }
  /* Phone bottom sheets for the four floating menus — same
     phone-screen-class gate and slide-up as .dialog-sheet above. The
     popup's tail has nothing to point at once the sheet covers the
     dock, so ::after becomes the grabber bar instead. Tapping the dimmed
     backdrop (body::after, below) lands on body and so closes the menu
     through each menu's existing click-away listener. */
  html.phone-screen-class #settings-menu,
  html.phone-screen-class #login-menu,
  html.phone-screen-class #feedback-tools-menu,
  html.phone-screen-class #emote-menu {
    left: 0;
    right: 0;
    bottom: 0;
    min-width: 0;
    max-height: 85vh;
    max-height: 85dvh;
    overflow-y: auto;
    gap: 4px;
    border-width: 1px 0 0;
    border-radius: var(--radius-dialog) var(--radius-dialog) 0 0;
    padding: 28px 16px calc(16px + env(safe-area-inset-bottom, 0px));
    box-shadow: var(--shadow-overlay);
  }
  html.phone-screen-class #settings-menu.open,
  html.phone-screen-class #login-menu.open,
  html.phone-screen-class #feedback-tools-menu.open,
  html.phone-screen-class #emote-menu.open {
    animation: sheetSlideUp var(--ui-anim-duration) ease-out;
  }
  html.phone-screen-class #settings-menu::after,
  html.phone-screen-class #login-menu::after,
  html.phone-screen-class #feedback-tools-menu::after,
  html.phone-screen-class #emote-menu::after {
    top: 10px;
    bottom: auto;
    left: 50%;
    width: 40px;
    height: 5px;
    margin-left: -20px;
    border: none;
    border-radius: 3px;
    background: var(--theme-pillmenu-heading);
    opacity: 0.6;
    transform: none;
  }
  html.phone-screen-class body:has(#settings-menu.open, #login-menu.open, #feedback-tools-menu.open, #emote-menu.open)::after {
    content: '';
    position: fixed;
    inset: 0;
    z-index: 499;
    background: var(--theme-dialog-scrim-light);
    animation: fadeIn var(--ui-anim-duration) ease-out;
  }
  /* Full-height touch rows inside a sheet (the popup's compact phone
     padding above was only ever about fitting a non-scrolling box). */
  html.phone-screen-class :is(#settings-menu, #login-menu, #feedback-tools-menu, #emote-menu) .settings-menu-item {
    display: flex;
    align-items: center;
    min-height: 48px;
    padding: 0 14px;
    border-radius: var(--radius-key);
  }
  html.phone-screen-class :is(#settings-menu, #login-menu, #feedback-tools-menu, #emote-menu) .settings-menu-heading {
    padding: 12px 6px 4px;
    font-size: 12px;
  }
  html.phone-screen-class #settings-menu .settings-menu-heading:first-child {
    padding-top: 0;
  }
  /* Settings sheet: New Game as the full-width primary card, Battle and
     Co-op side by side under it, everything else as grouped rows. The
     hide-opponent-markers row (only shown mid-battle) is ordered last so
     it never splits the Battle/Co-op pair. */
  html.phone-screen-class .settings-menu-play {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 8px;
    padding-bottom: var(--edge-depth);
  }
  html.phone-screen-class #settings-menu .settings-menu-play .settings-menu-item {
    min-height: 56px;
    border-radius: var(--radius-card);
    background: var(--theme-dialog-option-bg);
    box-shadow: 0 var(--edge-depth) 0 var(--theme-btn-option-edge);
    /* Sized so "New Battle Game" / "New Co-op Game" stay on one line
       in a half-width card at a 390px-wide phone. */
    padding: 0 10px;
    font-size: 14px;
    line-height: 1.2;
  }
  html.phone-screen-class #settings-menu #settings-menu-newgame {
    grid-column: 1 / -1;
    padding: 0 18px;
    background: var(--theme-dialog-primary-btn-bg);
    color: var(--theme-dialog-primary-btn-text);
    box-shadow: 0 var(--edge-depth) 0 var(--theme-btn-primary-edge);
    font-family: var(--font-display);
    font-weight: 600;
    font-size: 19px;
  }
  html.phone-screen-class #settings-menu #settings-menu-hide-opponent-markers {
    grid-column: 1 / -1;
    order: 1;
  }
  html.phone-screen-class #settings-menu .settings-menu-play .settings-menu-item:active {
    transform: translateY(var(--press-depth));
    box-shadow: 0 calc(var(--edge-depth) - var(--press-depth)) 0 var(--theme-btn-option-edge);
  }
  html.phone-screen-class #settings-menu #settings-menu-newgame:active {
    box-shadow: 0 calc(var(--edge-depth) - var(--press-depth)) 0 var(--theme-btn-primary-edge);
  }
  html.phone-screen-class .settings-menu-group {
    display: flex;
    flex-direction: column;
    border-radius: var(--radius-card);
    background: var(--theme-dialog-group-bg);
    overflow: hidden;
  }
  html.phone-screen-class #settings-menu .settings-menu-group .settings-menu-item {
    padding: 0 16px;
    border-radius: 0;
  }
  html.phone-screen-class #settings-menu .settings-menu-group .settings-menu-item + .settings-menu-item {
    border-top: 1px solid var(--theme-dialog-divider);
  }
  /* Issue #73: each option shows its own explanation, so a description
     is baked into the button instead of a separate list + submit step —
     tapping one applies and closes the dialog immediately. */
  #difficulty-options {
    display: flex;
    flex-direction: column;
    gap: 10px;
    margin-bottom: 20px;
    text-align: left;
  }
  .difficulty-option {
    display: flex;
    flex-direction: column;
    gap: 4px;
    border: none;
    border-radius: var(--radius-btn);
    padding: 14px;
    font-family: var(--font-ui);
    cursor: pointer;
    appearance: none;
    -webkit-appearance: none;
  }
  /* Issue #358: fixed per-difficulty tint (Easy always green, Hard
     always red/warm) instead of a shared "selected = green" state,
     which made whichever difficulty was currently active turn green
     regardless of which one it was. Selection is now shown as a
     stronger shade of the same hue. */
  .difficulty-option[data-difficulty="easy"] {
    background: var(--theme-dialog-positive-bg);
  }
  .difficulty-option[data-difficulty="hard"] {
    background: var(--theme-dialog-negative-bg);
  }
  .difficulty-option[data-difficulty="easy"].selected {
    background: var(--theme-dialog-positive-bg-strong);
  }
  .difficulty-option[data-difficulty="hard"].selected {
    background: var(--theme-dialog-negative-bg-strong);
  }
  .difficulty-option strong {
    font-size: 16px;
    color: var(--theme-dialog-heading-ink);
  }
  .difficulty-option span {
    font-size: 14px;
    font-weight: 500;
    line-height: 1.35;
    color: var(--theme-dialog-subtext);
  }
  /* Issue #205: .difficulty-option strong above is sized (15px) for the
     "Easy"/"Hard" heading — the <strong>correctly</strong> nested
     inside the description span (#193) inherited that same size
     instead of the span's own 13px, reading as bigger than its
     surrounding sentence rather than just bolder. */
  .difficulty-option span strong {
    font-size: 14px;
  }
  /* Issue #113: same list-of-rows treatment as #difficulty-options, but
     checkboxes (multi-select) instead of single-select buttons. */
  #wordsets-options, #notifications-options {
    display: flex;
    flex-direction: column;
    gap: 8px;
    margin-bottom: 20px;
    text-align: left;
  }
  #ui-testing-image-set-options, .theme-tab-panel {
    display: flex;
    flex-direction: column;
    gap: 8px;
    text-align: left;
  }
  #ui-testing-image-set-options {
    gap: 4px;
  }
  /* Issue #210: without this, the author-origin `.theme-tab-panel` display:flex
     rule above beats the UA default `[hidden] { display: none }` at equal
     specificity, so the inactive tab's `hidden` panel (set by
     setActiveThemeTab) stayed visible — both tabs' rows showed at once. */
  .theme-tab-panel[hidden] {
    display: none;
  }
  /* Issue #207/#354: Classic/Wallpaper top tabs — box wrapping the tab
     list, the active panel, and the shared Solid-blank-cells row below it
     (which stays visible regardless of which tab is active); no outline. */
  .theme-tabs {
    display: flex;
    flex-direction: column;
    gap: 8px;
    border-radius: 6px;
    padding: 10px 12px;
    background: var(--theme-dialog-option-bg);
    margin-bottom: 20px;
  }
  .theme-tab-list {
    display: flex;
  }
  /* Issue #366: buttons overlap (instead of a gapped row) so switching
     reads as one tab sliding on top of the other, driven purely by the
     existing aria-selected attribute + a transition — no JS/markup change. */
  .theme-tab {
    position: relative;
    z-index: 1;
    flex: 1 1 50%;
    padding: 8px 10px;
    border: 1px solid var(--theme-dialog-ink);
    border-radius: 6px;
    background: transparent;
    color: var(--theme-dialog-ink);
    font-family: var(--font-ui);
    font-size: 15px;
    cursor: pointer;
    opacity: 0.55;
    transition: transform 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease, color 0.2s ease, opacity 0.2s ease;
  }
  .theme-tab + .theme-tab {
    margin-left: -14px;
  }
  .theme-tab[aria-selected="true"] {
    z-index: 2;
    background: var(--theme-dialog-ink);
    color: var(--theme-dialog-option-bg);
    opacity: 1;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
  }
  /* Issue #305: extra breathing room above the options list specifically
     in the Notifications dialog, separating it from the toggle row above. */
  #notifications-options {
    margin-top: 18px;
  }
  .wordset-option {
    display: flex;
    align-items: center;
    gap: 10px;
    border: 1px solid var(--theme-dialog-key-border);
    border-radius: var(--radius-field);
    padding: 12px;
    background: var(--theme-dialog-option-bg);
    font-family: var(--font-ui);
    font-size: 15px;
    font-weight: 600;
    color: var(--theme-dialog-ink);
    cursor: pointer;
  }
  .wordset-option input[type="checkbox"], .wordset-option input[type="radio"] {
    width: 18px;
    height: 18px;
    accent-color: var(--theme-dialog-affirm-bg);
    flex-shrink: 0;
  }
  /* Issue #205: classic colour placeholders — 5 radios sharing one
     button-sized row instead of the usual one-radio-per-row layout. */
  .classic-color-row {
    justify-content: space-between;
  }
  .classic-color-choice {
    display: flex;
    align-items: center;
    gap: 4px;
    cursor: pointer;
  }
  .classic-color-choice:has(input:disabled) {
    opacity: 0.35;
    cursor: not-allowed;
  }
  /* Issue #145: scaffolding-only Achievements list — plain rows, same
     box/spacing language as .wordset-option above. No badge shape,
     color-from-emoji, or progress-bar fill; those are follow-up work
     once the data model/storage this pass adds is validated. */
  #achievements-list {
    display: flex;
    flex-direction: column;
    gap: 8px;
    margin: 12px 0;
    text-align: left;
  }
  .achievement-row {
    display: flex;
    align-items: center;
    gap: 10px;
    border-radius: var(--radius-field);
    padding: 10px 12px;
    background: var(--theme-dialog-option-bg);
    font-family: var(--font-ui);
    color: var(--theme-dialog-ink);
  }
  .achievement-emoji {
    font-size: 22px;
    flex-shrink: 0;
  }
  .achievement-info {
    display: flex;
    flex-direction: column;
    flex: 1 1 auto;
    min-width: 0;
  }
  .achievement-desc {
    font-size: 13px;
    opacity: 0.75;
  }
  .achievement-progress {
    font-size: 14px;
    font-weight: 600;
    flex-shrink: 0;
    white-space: nowrap;
  }
  /* All checkbox/radio ticks use the app's standard affirm green
     (see .wordset-option input rule above) instead of the shared
     --accent red — was previously opted-in per dialog (#438, #440),
     now the uniform default everywhere. */
  /* Issue #312: a disabled checkbox's row previously looked identical
     to an enabled one — nothing hinted it couldn't be tapped. Covers
     both the always-disabled "major only" placeholder and "detailed"
     when notifications are off. Excludes .classic-color-row: that row
     bundles 5 radios (one .wordset-option, per-swatch ghosting instead
     handled by .classic-color-choice:has(input:disabled) above, #205) —
     without the exclusion, any one disabled swatch would ghost the
     whole row, including the active 🟡 choice. */
  .wordset-option:has(input:disabled):not(.classic-color-row) {
    opacity: 0.5;
    cursor: default;
  }
  /* #389: Word Sets rows only — no outline, tighter vertical spacing.
     #442: also cap the list height and scroll within it (same treatment
     as .version-list) so admin/tester views with all geo rows visible
     don't push the Cancel/Start buttons off-screen. No scrollbar when
     the rows already fit. #446: native scrollbar is hidden here — mobile
     browsers only reveal an overlay scrollbar while actively touching/
     scrolling, so a custom always-visible track/thumb (below) is drawn
     over the list instead. #102: the old fixed 280px cap is gone — the
     list now flexes to fill whatever room the #wordsets-overlay
     .dialog-box's 85vh cap leaves it, so it grows on tall screens
     instead of staying cramped. */
  #wordsets-options {
    gap: 2px;
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    scrollbar-width: none;
  }
  #wordsets-options::-webkit-scrollbar {
    display: none;
  }
  #wordsets-options .wordset-option {
    border: none;
    padding-top: 8px;
    padding-bottom: 8px;
    margin-right: 9px;
  }
  /* Issue #168/#207: rows nested inside the Themes-dialog tab box don't
     need their own boxed-outline treatment — the .theme-tabs box provides
     it once for the whole widget. */
  .theme-tabs .wordset-option {
    border: none;
  }
  /* #351: Notifications dialog Game Updates rows don't need the standard
     .wordset-option boxed outline. */
  #notifications-options .wordset-option {
    border: none;
  }
  /* #446: custom always-visible scrollbar indicator for #wordsets-options,
     sized/positioned by updateWordsetsScrollbar() in JS. */
  /* #273: this wrap was never actually display:flex, so #wordsets-options'
     own flex:1 1 auto/min-height:0 (above) had no flex parent to apply
     against and did nothing — the list just sized to its full content
     height regardless of #wordsets-scroll-wrap's own (correctly flex-
     shrunk) height, spilling out past it and over the Cancel/Start row
     whenever content didn't fit the available space (tablet landscape's
     tight height + several rows visible). display:flex here is what
     actually lets #wordsets-options shrink to fit and scroll; the
     absolutely-positioned scrollbar track below is unaffected by it. */
  #wordsets-scroll-wrap {
    position: relative;
    display: flex;
    flex-direction: column;
    flex: 1 1 auto;
    min-height: 0;
  }
  #wordsets-scrollbar-track {
    display: none;
    position: absolute;
    top: 0;
    bottom: 0;
    right: 2px;
    width: 4px;
    border-radius: 2px;
    background: var(--theme-dialog-divider-strong);
  }
  #wordsets-scrollbar-thumb {
    position: absolute;
    left: 0;
    right: 0;
    border-radius: 2px;
    background: var(--theme-dialog-ink);
    opacity: 0.35;
  }
  /* Issue #71: one full-width button per board size, replacing the old
     ‹/› stepper — same list styling as the letter-entry/confirm dialogs. */
  #size-menu-list {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--dialog-smallbuttongap);
    margin-bottom: var(--dialog-standardbuttongap);
  }
  /* Issue #128: tablet-only orientation switch, shown above the size list. */
  #size-menu-orientation {
    margin-bottom: 14px;
  }
  .size-menu-orientation-status {
    text-align: center;
    font-size: 12px;
    color: var(--theme-dialog-label-strong);
    margin: 6px 0 0;
    min-height: 14px;
  }
  /* Cancel reads as the neutral way out here — the size rows above it
     are the dialog's real choices — so it takes the secondary look
     instead of .dialog-close-btn's default primary fill. */
  #size-menu-cancel {
    background: var(--theme-dialog-secondary-btn-bg);
    color: var(--theme-dialog-heading-ink);
    --btn-edge: transparent;
  }
  /* Each row: size glyph, name, then the cols × rows badge pushed to the
     right edge (spans built in buildSizeMenu()). */
  .size-menu-btn {
    width: 100%;
    display: flex;
    align-items: center;
    gap: 12px;
    border: none;
    border-radius: var(--radius-btn);
    min-height: 54px;
    padding: 8px 14px;
    text-align: left;
    box-sizing: border-box;
    font-size: 16px;
    font-weight: 700;
    font-family: var(--font-ui);
    background: var(--theme-dialog-option-bg);
    color: var(--theme-dialog-heading-ink);
    --btn-edge: var(--theme-btn-option-edge);
    cursor: pointer;
  }
  .size-menu-glyph {
    flex: none;
    width: 28px;
    height: 28px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 22px;
  }
  /* --size-tier (0-based, set inline per row) grows the square so the
     glyph itself shows relative board size. */
  .size-menu-square {
    width: calc(11px + var(--size-tier, 0) * 4px);
    height: calc(11px + var(--size-tier, 0) * 4px);
    border-radius: calc(3px + var(--size-tier, 0) * 1px);
    background: var(--theme-dialog-link-text, var(--theme-dialog-heading-ink));
  }
  .size-menu-name {
    flex: 1 1 auto;
    min-width: 0;
  }
  .size-menu-dims {
    flex: none;
    padding: 5px 10px;
    border-radius: 10px;
    background: var(--theme-dialog-secondary-btn-bg);
    font-size: 13px;
    color: var(--theme-dialog-subtext-strong);
    white-space: nowrap;
  }
  #grid-wrap {
    flex: 1;
    display: flex;
    /* flex-start (not center) so the grid always starts immediately
       after the alphabet tracker — centering here left slack space
       split above and below the grid, showing as a gap on iOS. */
    align-items: flex-start;
    justify-content: center;
    min-height: 0;
    /* While a phone bottom sheet is open for a cell it would cover, the
       board slides up by --board-lift (set in liftBoardAboveSheet(),
       board.js) so that cell stays visible. The transform itself only
       applies via body.board-lifted, so the board forms no stacking
       context of its own the rest of the time. The alphabet tracker
       fades out for the duration — the lifted rows would otherwise
       slide over it, and the sheet's own keypad already shows which
       letters are taken. */
    transition: transform var(--ui-anim-duration) ease-out;
  }
  body.board-lifted #grid-wrap {
    transform: translateY(var(--board-lift, 0px));
  }
  #alphabet-tracker {
    transition: opacity var(--ui-anim-duration) ease-out;
  }
  body.board-lifted #alphabet-tracker {
    opacity: 0;
  }
  #grid {
    display: grid;
    /* Needed so JS-positioned overlay badges (.word-def-badge,
       .word-suggestion-badge) anchor their getBoundingClientRect() math
       to #grid regardless of game state — previously only set
       conditionally via #grid.board-solved (see its comment), but
       nothing else relies on #grid staying position:static. */
    position: relative;
    gap: var(--board-cell-gap);
    border-width: var(--board-grid-border-width);
    border-style: solid;
    border-color: var(--theme-grid-border-color);
    box-shadow: var(--theme-grid-box-shadow);
    /* Issue #114/#155: per-cell box-shadow can't produce "shadow only on
       a section's true outer edge" — outset blur is isotropic and
       bleeds onto neighbors regardless of offset axis (b607991),
       shrinking it via negative spread kills visibility (#113/de9c77a),
       and inset shadows avoid bleed but paint inside each cell instead
       of outside the section (7a6671d). filter:drop-shadow() instead
       rasterizes this whole subtree's alpha silhouette and shadows THAT
       shape, so touching opaque letter cells merge into one outline
       with no seam shadow, and shadow can never paint over source
       content (it always composites under the original). Themes with
       an image background set --theme-grid-filter to this drop-shadow
       (offset diagonal, blur(1.5) under offset/1.5=2 so it never
       reaches back past x=0/y=0) instead of the plain border/box-shadow
       above, which would otherwise clash with a busy background. */
    filter: var(--theme-grid-filter);
  }
  #grid div {
    position: relative;
    /* Issue #45 fix: position:relative alone doesn't establish a
       stacking context, so .cell-answer's z-index:-1 was escaping past
       this cell entirely and painting behind the whole page background
       — invisible everywhere. z-index:0 here scopes it correctly. */
    z-index: 0;
    border-width: var(--board-cell-border-width);
    border-style: solid;
    border-color: var(--theme-cell-border);
    border-radius: var(--board-cell-border-radius);
    /* Keycap look: a solid bottom edge in a darker shade of the tile's
       own fill, over the board style's soft lift. Each coloured state
       below (hint/secret/maybe) repeats this with its own edge colour —
       --board-cell-shadow resolves on :root, so a per-cell custom
       property can't be threaded through it instead. */
    box-shadow: 0 var(--board-cell-edge-depth) 0 var(--theme-cell-keycap-edge), var(--board-cell-shadow);
    display: flex;
    align-items: center;
    justify-content: center;
    font-family: var(--font-ui);
    font-weight: 700;
    color: var(--theme-cell-ink);
    background: var(--theme-cell-bg);
    /* Issue #186: board cells support a long-press (mark-as-maybe) —
       text-selection/Copy-Paste callout suppression now handled by
       #343's universal rule instead. */
    touch-action: manipulation;
  }
  #grid div.block {
    background: var(--theme-block-cell);
    /* Issue #160: blocks keep the old edge-border var (not the new
       --theme-cell-border above) so they stay fully invisible/blended
       with the background on themes like Modern that hide it. */
    border-color: var(--theme-cell-edge-border);
    /* Issue #250: blank cells get their own border-width/shadow vars,
       zeroed out by applyBoardStyle(), so they sit flat instead of
       looking like floating tiles with nothing on them (unlike filled
       cells, which get a real border + shadow via --board-cell-*). */
    border-width: var(--board-cell-blank-border-width);
    box-shadow: var(--board-cell-blank-shadow);
  }
  #grid div.block.block-nb-top { border-top-color: var(--theme-cell-edge-border); }
  #grid div.block.block-nb-right { border-right-color: var(--theme-cell-edge-border); }
  #grid div.block.block-nb-bottom { border-bottom-color: var(--theme-cell-edge-border); }
  #grid div.block.block-nb-left { border-left-color: var(--theme-cell-edge-border); }
  /* Board-perimeter borders belong to whichever cells (letter or
     blank) sit on the outer edge — nulling only #grid's own border
     (above) still left this 0.5px cell-border ring visible around the
     whole board, so it needs clearing separately here. */
  #grid div.cell-edge-top { border-top-color: var(--theme-cell-edge-border); }
  #grid div.cell-edge-right { border-right-color: var(--theme-cell-edge-border); }
  #grid div.cell-edge-bottom { border-bottom-color: var(--theme-cell-edge-border); }
  #grid div.cell-edge-left { border-left-color: var(--theme-cell-edge-border); }
  #grid div.secret {
    background: var(--theme-secret-cell);
    /* Issue #211: own border-color instead of inheriting the generic
       --theme-cell-border — stops adjacent secret cells bleeding together. */
    border-color: var(--theme-secret-cell-border);
    /* Issue #524: golden ring layered on top of the normal cell shadow.
       An inset box-shadow (not outline — outline was tried and abandoned
       for a different cell decoration below because it silently failed to
       render on desktop Chrome) draws entirely inside this cell's own
       border-box, so with zero cell gap it can't bleed into neighbors. */
    box-shadow:
      inset 0 0 0 var(--secret-outline-width) var(--secret-outline-color),
      0 var(--board-cell-edge-depth) 0 var(--theme-secret-cell-border),
      var(--board-cell-shadow);
  }
  /* Issue #29: after a letter is entered for a number, the cell shows
     both — number shrunk into the top-left corner, guessed letter filling
     the bottom-right as large as it fits. Positioned absolutely so they
     don't interact with the cell's flex centering used for the plain
     number-only state. */
  .cell-num {
    position: absolute;
    top: var(--board-cell-number-top);
    left: var(--board-cell-number-left);
    font-weight: 600;
    line-height: 1;
    opacity: 0.75;
    /* Issue #64: fallback for the unmarked state, which doesn't set an
       inline pixel size like the marked/filled state does — a filled
       cell's inline style always wins over this regardless. */
    font-size: 0.65em;
  }
  /* Issue: a narrow letter (I) and a wide one (E, M, W) both anchored
     by their own left edge would sit at visually different optical
     centers — since the span shrink-wraps to its glyph's actual
     advance width, min-width (a floor, not a fixed width — a
     genuinely wide letter still grows past it and renders exactly as
     before) + text-align:center keeps every letter centered within a
     consistent-width box before the translate(-50%, -50%) below
     centers that box in the cell. em is relative to this element's own
     font-size (set inline per cell in JS), so it scales automatically.
     Issue: moved from a bottom-right anchor to true dead-center (both
     axes) — top/left 50% + translate(-50%, -50%) instead of
     bottom/right offsets, so it no longer depends on the letter's own
     box dimensions the way a corner anchor did. */
  .cell-letter {
    position: absolute;
    top: 50%;
    left: 50%;
    min-width: 0.8em;
    text-align: center;
    line-height: 1;
    /* Issue #354 follow-up: Fredoka is the permanent board-letter font
       (same sizing/weight as before — only the family changed). No
       longer overridable by #font-test-panel — that panel now targets
       dialog headings instead (see .dialog-box h2) now that the board
       font itself is a settled decision. */
    font-family: var(--font-display);
    transform: translate(-50%, -50%);
  }
  /* Issue #58: the one free starting hint fills the whole cell with a
     bright green background — the number/letter text stays the normal
     ink color (inherited from #grid div below) since it already reads
     fine against green. #grid div.hint-cell (not just .hint-cell)
     matches the base rule's specificity so this actually overrides it. */
  #grid div.hint-cell {
    background: var(--theme-hint-cell);
    /* Own border-color instead of inheriting the generic
       --theme-cell-border (always a sandy shade) — same fix as .secret
       above, keeps the outline a darker shade of the hint fill itself. */
    border-color: var(--theme-hint-cell-border);
    box-shadow: 0 var(--board-cell-edge-depth) 0 var(--theme-hint-cell-border), var(--board-cell-shadow);
  }
  /* Issue #186: right-click/long-press "Mark as maybe" — a pale pink
     note distinct from the yellow secret-word cells and green hint
     cells, applied to every cell sharing a marked number. Never
     applied to .hint-cell — enforced in JS, not CSS, since a hint
     cell's own background always needs to win regardless. */
  #grid div.cell-maybe {
    background: var(--theme-maybe-cell);
    box-shadow: 0 var(--board-cell-edge-depth) 0 var(--theme-maybe-cell-border), var(--board-cell-shadow);
  }
  /* Issue #488: opponent's revealed letter at battle end — font color
     only, cell background/border stay as-is (per the issue: matching
     letters "just keep black"). */
  #grid div.cell-delta-letter .cell-letter {
    color: var(--theme-battle-delta-letter);
  }
  /* Every cell sharing the number a cell dialog (Enter Letter / Number
     Already Used / Letter) is currently open for — set and cleared in
     board.js alongside those dialogs. A ::before overlay just outside
     the tile, so it stacks with whatever box-shadow/::after state the
     cell already has instead of replacing it. */
  #grid div.cell-entry-target {
    z-index: 1;
  }
  #grid div.cell-entry-target::before {
    content: '';
    position: absolute;
    inset: -3px;
    border: 3px solid var(--theme-cell-target-ring);
    border-radius: calc(var(--board-cell-border-radius) + 3px);
    box-shadow: 0 0 12px 2px var(--theme-cell-target-glow);
    pointer-events: none;
  }
  /* Issue #201/#follow-up: a secret-word cell that's also marked
     "maybe" needs both facts visible — a diagonal split fill (yellow
     upper-left = Secret, pink lower-right = Maybe) instead of a
     border, so the number (absolutely positioned, unaffected) still
     reads cleanly in the top-left corner. Two-class selector (higher
     specificity than either rule alone) targets only this
     combination, leaving plain (non-secret) maybe-marked cells pink
     as before. */
  #grid div.cell-maybe.secret {
    background: linear-gradient(to bottom right, var(--theme-secret-cell) 50%, var(--theme-maybe-cell) 50%);
    box-shadow:
      inset 0 0 0 var(--secret-outline-width) var(--secret-outline-color),
      0 var(--board-cell-edge-depth) 0 var(--theme-secret-cell-border),
      var(--board-cell-shadow);
  }
  /* Issue #188/#295: every board size — tracks the most recently
     entered letter so it's easy to find again. Needs a
     dashed line, which box-shadow can't draw. Tried outline + negative
     outline-offset first — didn't render at all on desktop Chrome for
     reasons that weren't worth chasing further. A real ::after overlay
     (inset a couple px, z-index above the cell's own content) is more
     predictable across browsers: it's just a normal box confined
     within this cell's own bounds via inset, not relying on outline's
     outward-then-pulled-back positioning model at all.
     Issue #337: "marching ants" — a real border's dash pattern can't
     be animated/offset at all, so the dashes are a repeating conic
     gradient masked down to a ring (content-box layer excluded from
     the border-box layer). The mask follows border-radius, so the
     dashes run round the tile's rounded corners instead of being
     clipped off them. Animating the gradient's start angle by one dash
     period reads as the whole dashed line rotating clockwise; the angle
     has to be a registered property to interpolate. */
  @property --last-entered-angle {
    syntax: '<angle>';
    inherits: false;
    initial-value: 0deg;
  }
  #grid div.last-entered-cell::after {
    content: '';
    position: absolute;
    /* Issue #266: inset:0 resolves against the cell's padding edge, not
       its border edge — with box-sizing:border-box and a real border
       (1.5px), that left this dashed overlay inset from the
       cell's true rendered edge, showing a sliver of the border/bg
       underneath. Negative inset by the border width pulls it out to
       the actual border-box edge instead. */
    inset: calc(-1 * var(--board-cell-border-width));
    border-radius: inherit;
    pointer-events: none;
    z-index: 1;
    padding: var(--last-entered-ring-width);
    background: repeating-conic-gradient(
      from var(--last-entered-angle),
      var(--theme-last-entered-accent) 0deg calc(var(--board-cell-dash-angle) / 2),
      transparent 0deg var(--board-cell-dash-angle)
    );
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
    mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    mask-composite: exclude;
    animation: last-entered-cell-march 0.6s linear infinite;
  }
  @keyframes last-entered-cell-march {
    to {
      --last-entered-angle: var(--board-cell-dash-angle);
    }
  }
  /* Issue #219: thinner line on the smaller cells a phone screen
     renders. Dash length needs no override — renderGrid() derives
     --board-cell-dash-angle from the cell size. */
  @media (max-width: 480px) {
    #grid div.last-entered-cell::after {
      --last-entered-ring-width: 2px;
    }
  }
  /* Issue #497 (live lock/sync): opponent has this cell's dialog open —
     per-cell version of #grid.board-solved::after's pointer-events:auto
     tap-intercept trick below, scoped to one cell instead of the whole
     board, positioned the same inset-by-border-width way as
     .last-entered-cell::after above. z-index sits between that (1) and
     board-solved (5) so the whole-board dim always wins once solved. */
  #grid div.cell-locked::after {
    content: '';
    position: absolute;
    inset: calc(-1 * var(--board-cell-border-width));
    border-radius: inherit;
    background: rgba(40, 36, 32, 0.35);
    pointer-events: auto;
    cursor: default;
    z-index: 2;
  }
  /* Font-size is set inline from JS (applyCoopCellLock), same convention
     as .cell-letter's own inline sizing — not a fixed CSS value. The
     icon itself is a <span> (like .cell-letter), living inside the
     locked cell <div> targeted by the ::after rule above. */
  #grid div.cell-locked .cell-lock-icon {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 3;
    pointer-events: none;
  }
  /* Issue #522: Battle opponent-cell ghost — same inset/border-radius/
     z-index shape as .cell-locked::after above, but pointer-events:none
     is the deliberate, required divergence: unlike Co-op's lock (a tap-
     intercept, see handleCellClick's matching early-return), this ghost
     must have zero effect on how the ghosted player's own board operates,
     so it's purely visual and never blocks a click. */
  #grid div.cell-battle-ghost::after {
    content: '';
    position: absolute;
    inset: calc(-1 * var(--board-cell-border-width));
    border-radius: inherit;
    background: var(--theme-battle-lock-cell);
    pointer-events: none;
    z-index: 2;
  }
  /* Issue #199 follow-up: per-cell opacity washed out each cell's own
     colors individually (secret yellow, hint green, maybe pink all
     faded differently) — a single flat grey layer over the whole board
     instead, so every cell dims by exactly the same amount and stays
     legible underneath. #grid's own position:relative (base rule above)
     anchors this ::after — used to be set here conditionally, moved up
     once .word-suggestion-badge needed the same anchoring pre-solve. */
  #grid.board-solved::after {
    content: '';
    position: absolute;
    inset: 0;
    background: rgba(40, 36, 32, 0.35);
    /* auto (not none): physically intercepts clicks/taps on EVERYTHING
       underneath, including the decorative block emoji (their own
       click handler has no secretWordSolved guard, and previously
       wasn't blocked by anything) — one overlay covers both regular
       cells and the emoji instead of needing a separate JS guard on
       each. The existing secretWordSolved checks elsewhere stay as
       belt-and-braces, this is just the layer that actually catches
       the emoji case. */
    pointer-events: auto;
    cursor: default;
    z-index: 5;
  }
  #grid.board-solved div {
    cursor: default;
  }
  /* Issue #53/#296: read-only demo board inside the Welcome dialog — same
     look as #grid (gap/radius/keycap edge/shadow and tile colours, via
     the shared --board-cell-* and --theme-cell-* vars) minus anything
     interactive (no cursor:pointer, no touch-action). Blank cells are
     always transparent here, so the tiles float on the dialog itself. */
  #welcome-demo-board-wrap {
    display: flex;
    justify-content: center;
    margin: 14px 0;
  }
  #welcome-demo-board {
    position: relative;
    display: grid;
    gap: var(--board-cell-gap);
  }
  #welcome-demo-board div {
    position: relative;
    border-style: solid;
    border-color: var(--theme-cell-border);
    border-width: var(--board-cell-border-width);
    border-radius: var(--board-cell-border-radius);
    box-shadow: 0 var(--board-cell-edge-depth) 0 var(--theme-cell-keycap-edge), var(--board-cell-shadow);
    display: flex;
    align-items: center;
    justify-content: center;
    font-family: var(--font-ui);
    font-weight: 700;
    color: var(--theme-cell-ink);
    background: var(--theme-cell-bg);
  }
  #welcome-demo-board div.block {
    background: transparent;
    border-width: var(--board-cell-blank-border-width);
    box-shadow: var(--board-cell-blank-shadow);
  }
  #welcome-demo-board div.secret {
    background: var(--theme-secret-cell);
    box-shadow:
      inset 0 0 0 var(--secret-outline-width) var(--secret-outline-color),
      0 var(--board-cell-edge-depth) 0 var(--theme-secret-cell-border),
      var(--board-cell-shadow);
  }
  /* Issue #304 (same class of bug as #199's #grid.board-solved fix):
     a single overlay anchored to the board container, not one per cell,
     so it covers every cell's own border too — no per-cell dimming means
     no seams show through between cells. Pulse every secret cell in
     place, raised above the overlay so it stays lit. */
  #welcome-demo-board::after {
    content: '';
    position: absolute;
    /* Reaches past the board's own box far enough to also cover the
       bottom row's keycap edge and shadow. */
    inset: -2px -2px -6px;
    border-radius: var(--board-cell-border-radius);
    background: rgba(0, 0, 0, 0.75);
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.4s ease;
    z-index: 5;
  }
  #welcome-demo-board.welcome-demo-spotlighting::after {
    opacity: 1;
  }
  #welcome-demo-board.welcome-demo-spotlighting div.secret {
    z-index: 6;
    animation: welcome-demo-secret-pulse 1.6s ease-in-out infinite;
  }
  @keyframes welcome-demo-secret-pulse {
    0%, 100% { transform: scale(1); }
    50% { transform: scale(1.08); }
  }
  /* Issue #53: the added demo board can push dialog content taller
     than a short landscape phone viewport — scroll instead of clip. */
  #welcome-overlay .dialog-box {
    max-height: calc(100vh - 48px);
    overflow-y: auto;
  }
  /* Issue #45/#59: internal debug watermark — every cell's true letter,
     shown as a pale watermark behind the normal black number/guess
     (z-index: -1 keeps it behind the cell's own in-flow/positioned
     content without affecting sibling cells). Trigger intentionally
     not documented here — see #59. */
  .cell-answer {
    position: absolute;
    bottom: 1px;
    right: 3px;
    z-index: -1;
    color: #b0b0b0;
    font-weight: 700;
    font-size: 0.55em;
    line-height: 1;
    pointer-events: none;
  }
  /* Issue #78: shares a fixed bottom-right wrapper with the secret-word
     reopen button, same pattern as #bottom-left-controls — the reopen
     button used to sit bottom-left, covering the settings icon. */
  #bottom-right-controls {
    position: fixed;
    /* 13px (not the dock's 8px) so these 44px pills center on the same
       line as the buttons inside the taller bottom-left dock. */
    bottom: 13px;
    /* Issue #109: nudged in from the edge, same reasoning as the
       bottom-left controls (#73 follow-up) — iPhone's rounded corner
       made the Back to Secret button feel cramped right at 12px. */
    right: 22px;
    z-index: 10;
    display: flex;
    align-items: center;
    gap: 8px;
  }
  /* Issue #354 follow-up: admin-only dialog-heading font/size testing
     panel — sits above #bottom-right-controls (68px vs its 13px) so the
     two never overlap when both are visible. Hidden by default; shown
     only once initFontTestPanel() confirms isAdmin. z-index above
     .dialog-overlay (1000) and the alpha/cell context menus (up to
     3100) — the whole point of this panel now is comparing candidate
     fonts against a real dialog heading on screen, so it must stay
     clickable/visible while a dialog is open, not buried behind its
     backdrop. */
  #font-test-panel {
    display: none;
    position: fixed;
    bottom: 68px;
    right: 22px;
    z-index: 3200;
    flex-direction: column;
    gap: 6px;
    background: var(--theme-dialog-corner-btn-bg-strong);
    border: 1px solid var(--theme-dialog-corner-btn-border);
    border-radius: 8px;
    padding: 8px 10px;
    font-family: var(--font-ui);
    font-size: 12px;
    color: var(--theme-dialog-corner-btn-text);
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
  }
  #font-test-panel label {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
  }
  #font-test-panel select,
  #font-test-panel input[type="range"] {
    font-family: inherit;
  }
  /* Issue #85: no longer shown on screen — #version-label stays in the
     DOM (hidden) since it's still the source of truth other code reads
     from (update comparisons, changelog "new since" lookup) and where
     the version-bump tooling always edits. */
  #version-badge {
    display: none;
  }
  /* Issue #44 follow-up: mirrors #version-badge's pill styling on the
     opposite corner. Hidden by default — shown only while every yellow
     cell is filled and the secret word hasn't been solved yet. */
  /* Issue #106: height matches #settings-btn's 40px, same treatment as
     the mode badge (#88); text color changed from accent (red) to ink
     (black) to match the border, consistent with the other corner
     controls. Issue: faded red background added back so it's obvious
     this button gets you back to entering the secret, without going
     back to solid accent-colored text (#106 still applies to that). */
  /* Issue #202: previous faded/translucent red (0.22 alpha) read as too
     subtle to notice — solid, near-opaque red instead, with white text
     since dark var(--ink) text was unreadable against a strong red
     background. */
  /* Issue #248: draws the eye back to it — easy to forget it's there
     once you've wandered off to check the board mid-challenge. */
  @keyframes secret-word-reopen-pulse {
    0%, 100% { transform: scale(1); }
    50% { transform: scale(1.08); }
  }
  /* Issue #377 tier 2: standard button pill for the live Friend Battle
     clock, using the app's standard dialog-danger red/white theme tokens
     (same tokens as .dialog-danger-btn) instead of a hardcoded color. */
  /* Issue #496: grid slot the timer and the reopen button both occupy
     (grid-area 1/1 below) so they stack exactly on top of each other
     instead of sitting side by side — the implicit column auto-sizes to
     whichever of the two is wider, and both stretch to fill it, so they
     always share one fixed width without a guessed px value. */
  #friendbattle-timer-slot {
    position: relative;
    display: grid;
    height: var(--touch-min);
  }
  #friendbattle-timer {
    display: none;
    align-items: center;
    justify-content: center;
    height: var(--touch-min);
    padding: 0 16px;
    border-radius: 22px;
    background: var(--theme-dialog-danger-bg);
    color: var(--theme-dialog-danger-text);
    font-size: 15px;
    font-weight: 700;
    font-family: var(--font-ui);
    font-variant-numeric: tabular-nums;
    grid-area: 1 / 1;
    width: 100%;
    box-sizing: border-box;
  }
  #friendbattle-timer.visible {
    display: inline-flex;
  }
  #secret-word-reopen-btn {
    display: none;
    height: var(--touch-min);
    align-items: center;
    justify-content: center;
    background: var(--secret-cta-bg);
    border: none;
    color: var(--secret-cta-text);
    border-radius: 22px;
    padding: 0 16px;
    font-size: 14px;
    font-weight: 700;
    font-family: var(--font-ui);
    cursor: pointer;
    box-shadow: 0 var(--edge-depth) 0 var(--secret-cta-edge), 0 2px 8px rgba(0, 0, 0, 0.2);
    animation: secret-word-reopen-pulse 1.6s ease-in-out infinite;
    grid-area: 1 / 1;
    width: 100%;
    box-sizing: border-box;
  }
  #secret-word-reopen-btn:active {
    background: var(--secret-cta-bg-pressed);
  }
  /* Issue #434: shared shrink/expand pill mechanics, factored out of
     #mode-badge so any corner-style pill button can opt in by adding
     this class (plus a .corner-pill-text label span) instead of
     duplicating the animation plumbing. Height/radius/padding here
     match #settings-btn's 40px circle, since shrinking always lands on
     that size.
     Issue #402: only actually animates on a portrait phone, once
     body.corner-controls-compressed is added below — harmless
     elsewhere since padding never otherwise changes on this element.
     max-width (not width) is what's transitioned: width's own base
     value is implicit "auto", and a transition can't interpolate
     to/from auto, so it used to snap instantly.
     Issue #492: JS sets an inline max-width to this element's actual
     rendered width right before adding the compressed class (see
     updateCornerControlsCompressedState), so the transition's start
     point is the real pixel width instead of this 240px cap — a cap
     wider than the real content meant most of the duration elapsed
     before the box visibly moved, then linear/ease made the leftover
     window look like a snap. Also linear (not ease) for a uniform
     collapse rate.
     Issue #402 (round 2): border-radius and min-width used to be
     animated too. border-radius:50% resolves per-axis (against
     current width and current height separately), so while this box
     was still wider than its 40px height, 50% rendered as an actual
     ellipse — the "oval" look. border-radius:20px is already exactly
     right at every width (it's half the fixed 40px height, so it's a
     correct stadium cap for any width and a perfect circle once
     width reaches 40px), so it's left constant instead. min-width
     used to transition from an unset ("auto") base value, which
     can't interpolate and so flipped discretely mid-transition
     instead of easing — dropped from the transition list; it still
     applies instantly as a floor below, which is enough since
     max-width is always >= 40px until the very end anyway.
     Issue #402 (round 3): shrink never actually animated — the
     compressed rule's .corner-pill-text{display:none} fired the
     instant the class toggled, instantly collapsing this element's
     shrink-to-fit width to just the emoji, so the still-animating
     max-width above stopped being the binding constraint for the
     rest of the transition (grow was fine since max-width starts
     smallest there and stays binding as it climbs). Fix: keep the
     text in flow (no more display:none — see the compressed rule
     below) and clip it here instead, so max-width stays the one
     thing driving the box's rendered width in both directions. */
  .corner-pill {
    height: 40px;
    display: flex;
    align-items: center;
    border-radius: 20px;
    padding: 0 14px;
    cursor: pointer;
    max-width: 240px;
    overflow: hidden;
    white-space: nowrap;
    transition: max-width 1s linear, padding 1s linear;
  }
  /* Drive-sync debug status pill — admin/tester-only, top-right corner
     (confirmed clear of every other fixed element: #bottom-left-controls
     is bottom-left, #skip-hints-btn is bottom-right). Standalone fixed
     element for the same reason as #debug-screenshot-btn: needs to stay
     clickable above dialogs at any time, not capped by a container's
     own stacking context. */
  #sync-debug-pill {
    position: fixed;
    /* Temporary testing relocation (iOS Drive-sync diagnostic): the
       default top:8px/right:8px sits under the Dynamic Island/status
       bar icons, unreadable on iPhone. Full-width + no truncation so the
       diagnostic log text is actually legible. Pinned just below
       #alphabet-tracker (issue #398: screen-centered placement covered
       the New Game dialog's board-size buttons) rather than screen-
       centered. Revert to the corner placement once diagnostics are done. */
    top: 140px;
    left: 8px;
    right: 8px;
    z-index: 9999;
    max-width: none;
    overflow: visible;
    white-space: pre-line;
    height: auto;
    /* Issue #401 follow-up: stacks the status text above the nested
       "Copy diag" button instead of .corner-pill's default single-line
       row, now that this is a div hosting a real child button. */
    flex-direction: column;
    gap: 8px;
    text-align: center;
    background: var(--theme-dialog-corner-btn-bg);
    border: 2px solid var(--theme-dialog-corner-btn-border);
    color: var(--theme-dialog-corner-btn-text);
    font-size: 12px;
    font-weight: 600;
    font-family: var(--font-ui);
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
  }
  #sync-debug-pill:active {
    background: var(--theme-dialog-corner-btn-bg-pressed);
  }
  /* Issue #401 follow-up: "Copy diag" button nested inside #sync-debug-pill
     — small pill using the app's standard affirm colors so it stays
     theme-integrated (no hardcoded colors), distinct from the pill's own
     neutral status-readout background. */
  #sync-debug-copy-btn {
    border: none;
    border-radius: 12px;
    padding: 4px 10px;
    font-size: 12px;
    font-weight: 600;
    font-family: var(--font-ui);
    background: var(--theme-dialog-affirm-bg);
    color: var(--theme-dialog-affirm-text);
    cursor: pointer;
  }
  /* Issue #402: on a portrait phone, once the player has taken their
     first turn this game, the mode badge shrinks to a circle matching
     #settings-btn's 40x40 size, showing only its emoji — the
     header/corner real estate matters more on phones than the label
     does once the player is underway. Gated on phone-screen-class
     (real hardware, see the head script) rather than viewport width,
     same reasoning as #rotate-overlay. Issue #426: #skip-hints-btn no
     longer participates — it always stays full-size with visible text.
     Issue #434: generalized from #mode-badge to any .corner-pill. */
  @media (orientation: portrait) {
    html.phone-screen-class body.corner-controls-compressed .corner-pill {
      max-width: 40px;
      min-width: 40px;
      height: 40px;
      padding: 0;
      justify-content: center;
    }
  }
  /* Issue #429: once the shrink transition above has actually finished
     (see the transitionend listener near updateCornerControlsCompressedState),
     the label text is hidden outright so the circle shows a clean emoji
     like #settings-btn, instead of a clipped off-center text sliver.
     Kept as a separate class (rather than folding into the compressed
     rule) so growing back can remove it immediately — before any width
     transition starts — without waiting on a matching end event.
     Issue #434: generalized from #mode-badge/.mode-badge-text to any
     .corner-pill/.corner-pill-text. */
  .corner-pill.pill-text-hidden .corner-pill-text {
    display: none;
  }
  /* Issue #359: hide background chrome while the Welcome dialog is up. */
  body:has(#welcome-overlay[style*="flex"]) #alphabet-tracker,
  body:has(#welcome-overlay[style*="flex"]) #grid-wrap,
  body:has(#welcome-overlay[style*="flex"]) #bottom-left-controls,
  body:has(#welcome-overlay[style*="flex"]) #login-btn-wrap,
  body:has(#welcome-overlay[style*="flex"]) #settings-btn,
  body:has(#welcome-overlay[style*="flex"]) #skip-hints-btn {
    display: none !important;
  }
  .dialog-overlay {
    display: none;
    position: fixed;
    inset: 0;
    background: var(--theme-dialog-scrim);
    z-index: 1000;
    align-items: center;
    justify-content: center;
    padding: 24px;
    box-sizing: border-box;
    /* Issue #351: backdrop fades in; .dialog-box below pops in on top
       of it at the same duration, same "framework" as every menu. */
    animation: fadeIn var(--ui-anim-duration) ease-out;
  }
  .dialog-box {
    animation: fadeScaleIn var(--ui-anim-duration) ease-out;
    background: var(--theme-dialog-bg);
    border: 1px solid var(--theme-dialog-border);
    border-radius: var(--radius-dialog);
    padding: 26px 20px 22px;
    max-width: 420px;
    width: 100%;
    box-sizing: border-box;
    text-align: center;
    box-shadow: var(--shadow-overlay);
    font-family: var(--font-ui);
    font-weight: 500;
    color: var(--theme-dialog-ink);
  }
  /* #102: Word Sets dialog grows to use available viewport height
     (capped so it never hangs off-screen) instead of being sized by
     the checklist's old fixed 280px cap — same flex-column treatment
     as #manage-issues-overlay (#239). */
  #wordsets-overlay .dialog-box {
    max-height: 85vh;
    /* #273: tablet landscape's shown address bar/toolbar eats a large
       fraction of the already-tight vertical space; plain vh resolves
       against the large viewport (ignoring that chrome), so the flex
       budget below thought it had more room than was actually visible
       and never shrank the list into its scroll area — dvh overrides
       with the real visible height, same fallback pairing as html/body
       above. */
    max-height: 85dvh;
    display: flex;
    flex-direction: column;
  }
  .dialog-box h2 {
    margin: 0 0 8px;
    /* Dialog titles share the board letters' display face (Fredoka) —
       supersedes #354's Quicksand-bold heading choice. */
    font-family: var(--font-display);
    font-weight: 600;
    font-size: 24px;
    line-height: 1.15;
    color: var(--theme-dialog-heading-ink);
  }
  /* Phone bottom sheets: the cell-action dialogs (.dialog-sheet, set in
     index.html) dock to the bottom edge on a phone instead of centering,
     so the board stays visible above them and the keys sit under the
     thumb. Gated on phone-screen-class (real hardware, see the head
     script) like #rotate-overlay — tablets and desktop keep the centred
     dialog untouched. */
  @keyframes sheetSlideUp {
    from { transform: translateY(100%); }
    to { transform: translateY(0); }
  }
  html.phone-screen-class .dialog-overlay.dialog-sheet {
    align-items: flex-end;
    padding: 0;
    background: var(--theme-dialog-scrim-light);
  }
  html.phone-screen-class .dialog-sheet .dialog-box {
    position: relative;
    max-width: none;
    max-height: 92vh;
    max-height: 92dvh;
    overflow-y: auto;
    border-width: 1px 0 0;
    border-radius: var(--radius-dialog) var(--radius-dialog) 0 0;
    /* The overlay reaches the true bottom edge, under iOS's home
       indicator — pad the content back up out of it. */
    padding: 30px 20px calc(18px + env(safe-area-inset-bottom, 0px));
    animation: sheetSlideUp var(--ui-anim-duration) ease-out;
  }
  /* Grabber bar — the usual visual cue that this panel is a sheet. */
  html.phone-screen-class .dialog-sheet .dialog-box::before {
    content: '';
    position: absolute;
    top: 10px;
    left: 50%;
    width: 40px;
    height: 5px;
    margin-left: -20px;
    border-radius: 3px;
    background: var(--theme-dialog-label);
    opacity: 0.6;
  }
  /* Flick-down-to-close (js/ui/sheetDrag.js): the drag itself writes an
     inline transform; this only eases the release, back to rest or off
     the bottom edge. overscroll-behavior keeps a downward drag at the
     top of a sheet from chaining into the page's pull-to-refresh. */
  .sheet-settling {
    transition: transform var(--ui-anim-duration) ease-out;
  }
  html.phone-screen-class .dialog-sheet .dialog-box,
  html.phone-screen-class #settings-menu,
  html.phone-screen-class #login-menu,
  html.phone-screen-class #feedback-tools-menu,
  html.phone-screen-class #emote-menu {
    overscroll-behavior: contain;
  }
  /* Issue #107: install dialog now shows a sequence of platform-
     specific screenshots instead of one image. */
  .ios-install-dialog-box {
    max-height: 85vh;
    overflow-y: auto;
  }
  .ios-install-img-sequence {
    position: relative;
    container-type: inline-size;
    display: flex;
    flex-direction: column;
    gap: 16px;
    margin: 4px 0 16px;
  }
  .ios-install-img {
    display: block;
    width: 100%;
    border-radius: 8px;
    box-shadow: -4px 4px 10px rgba(0, 0, 0, 0.3);
  }
  .ios-install-img-wrap {
    position: relative;
    display: block;
  }
  /* Issue #116: a single shared tap cue lives in the sequence
     container (not per-image) and JS slides it — via the --tap-x/
     --tap-y custom properties driving a transform transition — from
     one screenshot's tap point to the next, so the player can follow
     one finger through the whole flow in order instead of watching
     independent cues blink on/off per image. */
  .ios-tap-indicator {
    position: absolute;
    left: 0;
    top: 0;
    transform: translate(var(--tap-x, 0px), var(--tap-y, 0px)) translate(-50%, -50%);
    transition: transform 0.5s ease-in-out;
    pointer-events: none;
    opacity: 0;
  }
  .ios-tap-indicator.active {
    opacity: 1;
  }
  .ios-tap-ripple {
    position: absolute;
    left: 50%;
    top: 50%;
    width: 9cqw;
    height: 9cqw;
    background: rgba(255, 255, 255, 0.6);
    border-radius: 50%;
    transform: translate(-50%, -50%) scale(0);
    animation: none;
  }
  /* Issue #116: .tapping (not .active) drives the press/ripple
     keyframes, so the slide (position) and the tap (animation) can be
     sequenced independently by JS. */
  .ios-tap-indicator.tapping .ios-tap-ripple {
    animation: ios-tap-ripple-pulse 1.8s ease-out 1;
  }
  .ios-tap-finger {
    position: relative;
    display: inline-block;
    font-size: 15.75cqw;
    filter: drop-shadow(0 2px 3px rgba(0,0,0,0.5));
    animation: none;
  }
  .ios-tap-indicator.tapping .ios-tap-finger {
    animation: ios-tap-finger-press 1.8s ease-in-out 1;
  }
  @keyframes ios-tap-ripple-pulse {
    0%, 15% { opacity: 0; transform: translate(-50%, -50%) scale(0.4); }
    30% { opacity: 0.9; transform: translate(-50%, -50%) scale(1); }
    55%, 100% { opacity: 0; transform: translate(-50%, -50%) scale(1.35); }
  }
  @keyframes ios-tap-finger-press {
    0%, 15% { transform: scale(1) translateY(0); }
    30% { transform: scale(0.75) translateY(6%); }
    50% { transform: scale(1) translateY(0); }
    100% { transform: scale(1) translateY(0); }
  }
  /* Issue #46: the win celebration should feel like a much bigger deal
     than a routine dialog — larger box, much larger title, plus a fun
     GIF. Loaded from Tenor, so it needs a network connection; harmless
     if that fails (onerror hides it, the text celebration still shows). */
  #secret-word-result-box.celebrate {
    max-width: 480px;
    position: relative;
    z-index: 1;
    box-shadow: 0 0 0 4px var(--secret-cta-glow), var(--shadow-overlay);
  }
  #secret-word-result-box.celebrate #secret-word-result-title {
    font-size: 32px;
  }
  #secret-word-result-box.celebrate #secret-word-result-message {
    font-size: 17px;
  }
  /* The solved word as a row of gold secret tiles. --tile-count (set in
     renderWinResultVisuals()) shrinks the letters for a long word so the
     row always fits the dialog's width. */
  #secret-word-result-word {
    display: flex;
    justify-content: center;
    gap: 6px;
    margin: -6px 0 18px;
  }
  #secret-word-result-word:empty {
    display: none;
  }
  .secret-word-result-tile {
    flex: 0 1 52px;
    min-width: 0;
    aspect-ratio: 13 / 14;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 24%;
    background: var(--theme-secret-cell);
    color: var(--theme-cell-ink);
    box-shadow:
      inset 0 0 0 var(--secret-outline-width) var(--secret-outline-color),
      0 3px 0 var(--theme-secret-cell-border);
    font-family: var(--font-display);
    font-weight: 600;
    font-size: min(30px, calc((100vw - 100px) / var(--tile-count, 5) * 0.6));
    line-height: 1;
  }
  #secret-word-result-score strong {
    font-family: var(--font-display);
    font-weight: 600;
    font-size: 20px;
    color: var(--secret-cta-bg);
  }
  /* Confetti: a dozen small pieces dropped over the win dialog by
     launchWinConfetti() (position/colour/timing set inline per piece),
     removed again when the dialog closes. In front of the dialog (which
     fills most of a phone screen) but never intercepting taps. */
  .win-confetti {
    position: absolute;
    inset: 0;
    z-index: 2;
    overflow: hidden;
    pointer-events: none;
  }
  .win-confetti span {
    position: absolute;
    top: -24px;
    width: 10px;
    height: 16px;
    border-radius: 3px;
    animation: winConfettiFall 3s linear 3 both;
  }
  .win-confetti span:nth-child(3n) {
    width: 11px;
    height: 11px;
    border-radius: 50%;
  }
  @keyframes winConfettiFall {
    from { transform: translateY(0) rotate(0deg); }
    to { transform: translateY(110vh) rotate(540deg); }
  }
  /* Full dialog width at the GIF's own aspect ratio, but never taller
     than 40% of the viewport — a tall portrait GIF is letterboxed
     (object-fit: contain) instead of pushing the Close button off a
     short phone screen. */
  #secret-word-result-gif {
    display: none;
    width: 100%;
    height: auto;
    max-height: 40vh;
    object-fit: contain;
    border-radius: var(--radius-field);
    margin: 4px 0 16px;
  }
  #secret-word-result-box.celebrate #secret-word-result-gif {
    display: block;
  }
  /* Issue #63: fixed square box (the actual Tenor GIFs are square) —
     previously width:100%/height:160px letterboxed a square GIF into a
     wide rectangle. */
  #generating-gif {
    display: none;
    width: 160px;
    height: 160px;
    object-fit: cover;
    border-radius: 8px;
    margin: 4px auto;
  }
  .dialog-box p {
    color: var(--theme-dialog-subtext);
    font-size: 15px;
    margin: 0 0 20px;
    line-height: 1.4;
  }
  /* Progress bar for the large-board Generating dialog — track mirrors
     .difficulty-option's unselected background/border, fill reuses the
     theme's hint-cell green, so both stay in sync with the active theme
     instead of introducing new hardcoded colors. */
  .generating-progress-track {
    width: 100%;
    height: 21px;
    background: var(--theme-dialog-option-bg);
    border: 1px solid var(--theme-dialog-key-border);
    border-radius: 11px;
    overflow: hidden;
    box-sizing: border-box;
    margin: 4px 0 20px;
  }
  .generating-progress-fill {
    height: 100%;
    width: 0%;
    background: var(--theme-hint-cell);
  }
  /* Issue #210: the gap before the ⚠️ warning line read as slightly
     too wide at the default 20px — trimmed just for this one pairing. */
  #update-intro {
    margin-bottom: 10px;
  }
  /* Issue #196: openInfoDialog() sets this via textContent (never
     innerHTML, so no markup injection risk from any caller), but plain
     text collapses newlines by default — pre-line preserves them as
     actual line breaks without needing to switch to innerHTML just for
     one caller that wants a blank line before its last sentence. */
  #info-message {
    white-space: pre-line;
  }
  /* Issue #6: feature-request dialog. Issue #67: debug screenshot dialog
     reuses this same textarea/counter/status shape (see the combined
     selectors below), just with its own ids since it's a separate
     overlay/flow with a different submit endpoint and extra preview
     image. */
  #feedback-text, #debug-screenshot-text {
    width: 100%;
    box-sizing: border-box;
    min-height: 100px;
    resize: vertical;
    background: var(--theme-dialog-input-bg);
    border: 1px solid var(--theme-dialog-key-border);
    border-radius: var(--radius-field);
    color: var(--theme-dialog-ink);
    font-family: var(--font-ui);
    font-size: 15px;
    font-weight: 500;
    padding: 12px;
    margin-bottom: 4px;
  }
  #feedback-counter, #debug-screenshot-counter {
    text-align: right;
    color: var(--theme-dialog-label);
    font-size: 12px;
    margin-bottom: 16px;
  }
  #feedback-counter.limit-near, #debug-screenshot-counter.limit-near {
    color: var(--theme-dialog-pending-bg);
  }
  #feedback-counter.limit-reached, #debug-screenshot-counter.limit-reached {
    color: var(--theme-dialog-warning-text);
  }
  #feedback-status, #debug-screenshot-status {
    margin: 12px 0 0;
    font-size: 13px;
    min-height: 16px;
  }
  /* Issue #245: filename shown under the "Add picture..." button — no
     preview image, unlike #debug-screenshot-preview above. Issue #364:
     #feedback-logs-filename is the same idea under "Attach debug logs". */
  #feedback-picture-filename, #feedback-logs-filename {
    color: var(--theme-dialog-label);
    font-size: 13px;
    margin: 4px 0 16px;
    word-break: break-all;
  }
  /* Issue #67: html2canvas capture preview, shown above the textarea. */
  #debug-screenshot-preview {
    display: block;
    width: 100%;
    max-height: 40vh;
    object-fit: contain;
    object-position: center;
    border: 1px solid var(--theme-dialog-key-border);
    border-radius: var(--radius-field);
    margin-bottom: 10px;
    background: var(--theme-dialog-secondary-btn-bg);
  }
  .version-list {
    list-style: none;
    margin: 0 0 20px;
    padding: 0;
    max-height: 280px;
    overflow-y: auto;
    text-align: left;
  }
  .version-list li {
    display: flex;
    align-items: baseline;
    gap: 10px;
    padding: 10px 0;
    border-bottom: 1px solid var(--theme-dialog-divider-strong);
  }
  .version-list li:last-child { border-bottom: none; }
  .version-list .version-number {
    color: var(--theme-dialog-warning-text);
    font-weight: 700;
    font-size: 12px;
    flex: 0 0 auto;
  }
  .version-list .version-notes {
    font-size: 13px;
    line-height: 1.4;
    color: var(--theme-dialog-subtext-strong);
  }
  .dialog-close-btn {
    width: 100%;
    border: none;
    border-radius: var(--radius-btn);
    min-height: 50px;
    padding: 12px 14px;
    font-size: 16px;
    font-weight: 700;
    cursor: pointer;
    font-family: var(--font-ui);
    background: var(--theme-dialog-primary-btn-bg);
    color: var(--theme-dialog-primary-btn-text);
    --btn-edge: var(--theme-btn-primary-edge);
  }
  /* Issue #239 follow-up: the Manage Issues dialog's own box — bounded
     height + flex layout so a long issues list scrolls WITHIN the box
     instead of growing the box past the viewport (the original bug:
     on a long list the box hung off the screen on iOS and desktop and
     the Close button became unreachable). h2/Close stay fixed; only
     #version-history-issue-status scrolls. */
  #manage-issues-overlay .dialog-box {
    max-height: 85vh;
    display: flex;
    flex-direction: column;
  }
  #manage-issues-overlay #version-history-issue-status {
    overflow-y: auto;
    flex: 1 1 auto;
    min-height: 0;
  }
  /* Issue #239: admin/tester-only issue-status section — headings +
     pills matching GitHub's own label colors (Working #0E8A16,
     ReadyForTesting #FFD700, same hex values as this repo's actual
     labels). */
  #version-history-issue-status h3 {
    margin: 0 0 8px;
    font-size: 13px;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--theme-dialog-label-strong);
  }
  #version-history-issue-status ul {
    list-style: none;
    margin: 0 0 16px;
    padding: 0;
    text-align: left;
  }
  /* Issue #243 follow-up: two-column row — a narrow left column
     (issue number, Close issue button underneath) and a wide right
     column (title) that gets the rest of the row's width, instead of
     the title losing space to a right-aligned button beside it. */
  #version-history-issue-status li {
    display: flex;
    flex-direction: row;
    /* Issue #330: centers the title (col B) against the full stacked
       height of the number + Close button (col A), instead of both
       columns hugging the top of the row. */
    align-items: center;
    gap: 8px;
    padding: 6px 0;
    font-size: 13px;
    color: var(--theme-dialog-subtext-strong);
  }
  .issue-col-a {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 4px;
    flex: 0 0 auto;
  }
  .issue-col-b {
    flex: 1 1 auto;
    min-width: 0;
  }
  /* Issue #239 follow-up: shown once per group, inline inside its h3
     (e.g. "7 Issues READY FOR TESTING"), instead of repeating on every
     row. */
  .issue-status-pill {
    display: inline-block;
    vertical-align: middle;
    border-radius: 10px;
    padding: 2px 8px;
    font-size: 10px;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.03em;
  }
  .issue-status-pill.working {
    background: #0e8a16;
    color: #fff;
  }
  .issue-status-pill.ready-for-testing {
    background: #ffd700;
    color: #1a1614;
  }
  /* Issue #239 follow-up: replaces the old "done testing" checkbox —
     a clearer, more deliberate action than an easy-to-mis-tap checkbox.
     Red signals it's a destructive/closing action, not the app's usual
     --accent brick-red (kept distinct so it doesn't blend with normal
     UI accents). Right-aligned within the li via margin-left: auto. */
  /* Issue #243 follow-up: two lines ("Close"/"issue") instead of one —
     narrower footprint next to the issue title on small screens. */
  .close-issue-btn {
    flex: 0 0 auto;
    /* Issue #296: fixed so the two-line "Close issue" and one-line
       "Closed" (#294) states render at the same width instead of each
       sizing to its own text — wide enough that "Closed" (the longer
       single-line state) never wraps at this font size. */
    width: 56px;
    border: none;
    border-radius: 6px;
    padding: 3px 8px;
    font-size: 10px;
    font-weight: 700;
    line-height: 1.3;
    text-align: center;
    cursor: pointer;
    font-family: var(--font-ui);
    background: var(--theme-dialog-danger-bg);
    color: var(--theme-dialog-danger-text);
  }
  .close-issue-btn:disabled {
    opacity: 0.5;
    cursor: default;
  }
  /* Issue #294: a closed row's button stays fully visible (not the
     dimmed disabled look above) — it's meant to read at a glance, not
     fade into the background. */
  .close-issue-btn.closed {
    background: #0E8A16;
    opacity: 1;
  }
  /* Issue #130: scoreboard — local-only for now, kept separate from
     .version-list's timeline styling since these are label/value stat
     rows rather than a scrolling history. */
  .scoreboard-section {
    text-align: left;
    margin: 0 0 18px;
  }
  .scoreboard-section h3 {
    margin: 0 0 6px;
    font-size: 12px;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--theme-dialog-label);
  }
  .scoreboard-row {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    padding: 6px 0;
    font-size: 14px;
    color: var(--theme-dialog-subtext-strong);
    border-bottom: 1px solid var(--theme-dialog-divider);
  }
  .scoreboard-row:last-child {
    border-bottom: none;
  }
  .scoreboard-row strong {
    color: var(--theme-dialog-ink);
    font-size: 16px;
  }
  .scoreboard-subtext {
    margin: -4px 0 16px;
    font-size: 12px;
    font-style: italic;
    color: var(--theme-dialog-subtext-italic);
  }
  /* Issue: the local-vs-global explainer isn't a stylistic aside like
     the scoring tip below it — reads better upright. */
  .scoreboard-subtext.no-italic {
    font-style: normal;
  }
  .scoreboard-table {
    width: 100%;
    border-collapse: collapse;
    margin: 0 0 18px;
    font-size: 14px;
  }
  .scoreboard-table th {
    text-align: center;
    font-size: 11px;
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.03em;
    color: var(--theme-dialog-label);
    padding: 0 0 6px;
  }
  .scoreboard-table td {
    padding: 8px 4px;
    border-bottom: 1px solid var(--theme-dialog-divider);
    color: var(--theme-dialog-subtext-strong);
  }
  .scoreboard-table td:first-child {
    text-align: left;
  }
  .scoreboard-table td:not(:first-child) {
    text-align: center;
    font-weight: 700;
    color: var(--theme-dialog-ink);
    font-size: 16px;
  }
  .scoreboard-table tr:last-child td {
    border-bottom: none;
  }
  /* Issue #414: same simple max-height + overflow-y pattern as
     .version-list, sized for the Friend Battle Scoreboard's table
     rather than Word Sets' custom-scrollbar variant (unneeded here). */
  #friendbattle-scoreboard-scroll {
    max-height: 280px;
    overflow-y: auto;
  }
  #friendbattle-scoreboard-scroll thead th {
    position: sticky;
    top: 0;
    background: var(--theme-dialog-bg);
  }
  /* Issue #497: same scroll/sticky-header treatment as
     #friendbattle-scoreboard-scroll above, for the Co-op Scoreboard. */
  #coop-scoreboard-scroll {
    max-height: 280px;
    overflow-y: auto;
  }
  #coop-scoreboard-scroll thead th {
    position: sticky;
    top: 0;
    background: var(--theme-dialog-bg);
  }
  .scoreboard-table td.points-positive {
    color: var(--theme-dialog-positive-border);
  }
  .scoreboard-table td.points-negative {
    color: var(--theme-dialog-negative-border);
  }
  /* Issue #130 follow-up: matches the app's established "positive/
     selected" green (same as .difficulty-option.selected) rather than
     the usual ink-colored dialog-close-btn — this button leads back
     into another game, not a neutral dismissal.
     Issue #222: standardized onto the shared affirm-button vars — was
     previously its own solid #2e7d32 with no text-color override, so it
     rendered pale-yellow text (inherited from dialog-close-btn) on a
     different green than every other positive button in the app. */
  #scoreboard-close {
    background: var(--theme-dialog-affirm-bg);
    color: var(--theme-dialog-affirm-text);
    --btn-edge: var(--theme-btn-affirm-edge);
  }
  /* Issue #382: same green affirm styling as #scoreboard-close. */
  #friendbattle-close {
    background: var(--theme-dialog-affirm-bg);
    color: var(--theme-dialog-affirm-text);
    --btn-edge: var(--theme-btn-affirm-edge);
  }
  #letter-entry-keys {
    margin-bottom: 20px;
  }
  .letter-entry-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 16px;
    margin-bottom: 18px;
    text-align: left;
  }
  .letter-entry-heading {
    min-width: 0;
  }
  .letter-entry-header h2 {
    margin-bottom: 4px;
  }
  .letter-entry-header p {
    margin-bottom: 0;
  }
  /* The tapped number, drawn as one of the board's own tiles (same
     --theme-cell-* tokens) wearing the .cell-entry-target ring. */
  .letter-entry-chip {
    flex: none;
    min-width: 52px;
    height: 52px;
    padding: 0 6px;
    box-sizing: border-box;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 13px;
    background: var(--theme-cell-bg);
    color: var(--theme-cell-ink);
    box-shadow: 0 0 0 3px var(--theme-cell-target-ring);
    font-family: var(--font-display);
    font-weight: 600;
    font-size: 24px;
    line-height: 1;
  }
  .letter-key-row {
    display: flex;
    gap: 6px;
    justify-content: center;
    margin-bottom: 6px;
  }
  .letter-key {
    flex: 1;
    max-width: 52px;
    aspect-ratio: 1;
    border: 1px solid var(--theme-dialog-key-border);
    border-radius: var(--radius-key);
    background: var(--theme-dialog-option-bg);
    color: var(--theme-dialog-heading-ink);
    --btn-edge: var(--theme-btn-option-edge);
    font-family: var(--font-display);
    font-weight: 600;
    font-size: 22px;
    cursor: pointer;
    padding: 0;
    /* Text-selection/Copy-Paste callout suppression now handled by
       #343's universal rule instead. */
    touch-action: manipulation;
  }
  .letter-key.selected {
    background: var(--theme-dialog-positive-bg-strong);
    border-color: var(--theme-dialog-positive-border);
  }
  /* (#335) invisible same-size filler so a short row (e.g. V-Z) shares
     the 7-slot flex-basis of the full A-G/H-N/O-U rows above it —
     without this, a row with fewer buttons needs less width to hit
     max-width:52px than a 7-button row does, so it renders larger. */
  .letter-key-spacer {
    flex: 1;
    max-width: 52px;
    aspect-ratio: 1;
    visibility: hidden;
    pointer-events: none;
  }
  /* Issue: same size/row as the letter keys now (was a separate pill
     button below), but keeps its own faded shade so it doesn't read
     as just another letter. */
  .secret-word-backspace-key {
    background: var(--theme-dialog-secondary-btn-bg);
    border-color: var(--theme-dialog-key-border);
    --btn-edge: transparent;
  }
  .letter-key.ghosted {
    opacity: 0.3;
    cursor: default;
    border-color: var(--theme-dialog-key-border-ghosted);
    --btn-edge: transparent;
  }
  .dialog-actions-row {
    display: flex;
    gap: 10px;
  }
  #friendslist-overlay .dialog-actions-row,
  #friendbattle-invite-overlay .dialog-actions-row {
    margin-top: var(--dialog-smallbuttongap);
    margin-bottom: var(--dialog-smallbuttongap);
  }
  /* Issue #136: the hint-choice dialog needs 3 stacked options instead
     of 2 side-by-side ones — same button styling, different layout. */
  .dialog-actions-row.stacked {
    flex-direction: column;
  }
  #hint-word-login-row {
    display: flex;
    flex-direction: column;
    width: 100%;
    gap: 4px;
    margin-top: 8px;
    text-align: center;
  }
  #hint-word-login-row p {
    margin: 0;
  }
  #hint-word-login-row button {
    width: 100%;
  }
  .dialog-actions-row button {
    /* Issue: same appearance reset as .settings-menu-item, and for the
       same reason — a disabled button's native default rendering
       otherwise adds its own dimming on top of our text-color-only
       ghosting (see #clear-letter-fill-btn's disabled state). */
    appearance: none;
    -webkit-appearance: none;
    flex: 1;
    border: none;
    border-radius: var(--radius-btn);
    min-height: 50px;
    padding: 12px 14px;
    font-size: 16px;
    font-weight: 700;
    cursor: pointer;
    font-family: var(--font-ui);
  }
  /* Raised ("keycap") button mechanics, shared by every dialog button:
     a solid bottom edge in --btn-edge (set per role below; unset = flat)
     that the button sinks into while pressed. margin-bottom reserves the
     edge's height so it never overlaps whatever sits underneath. */
  .dialog-actions-row button,
  .dialog-close-btn,
  .size-menu-btn,
  .letter-key,
  .tip-bubble button,
  #login-cancel,
  #login-lets-play {
    box-shadow: 0 var(--edge-depth) 0 var(--btn-edge, transparent);
    margin-bottom: var(--edge-depth);
    transition: transform 0.06s ease-out, box-shadow 0.06s ease-out;
  }
  .dialog-actions-row button:active:not(:disabled),
  .dialog-close-btn:active:not(:disabled),
  .size-menu-btn:active,
  .letter-key:active:not(.ghosted),
  .tip-bubble button:active:not(:disabled),
  #login-cancel:active,
  #login-lets-play:active {
    transform: translateY(var(--press-depth));
    box-shadow: 0 calc(var(--edge-depth) - var(--press-depth)) 0 var(--btn-edge, transparent);
  }
  .dialog-secondary-btn {
    background: var(--theme-dialog-secondary-btn-bg);
    color: var(--theme-dialog-heading-ink);
  }
  /* Issue: BUG FIX — #clear-letter-fill-btn (.dialog-secondary-btn) had
     no disabled treatment at all, so the "only available on larger
     boards" ghosted state and the enabled "Fill all" state looked
     visually identical. Uses a muted text color rather than opacity —
     opacity fades the whole button uniformly, including its emoji
     (💡), which reads as broken/half-rendered rather than "disabled".
     A color change only touches the actual text glyphs; color emoji
     ignore CSS `color` entirely, so 💡 stays at full brightness while
     the wording around it visibly ghosts. */
  .dialog-secondary-btn:disabled {
    color: var(--theme-dialog-ghosted-btn-text);
    cursor: default;
  }
  #welcome-skip-tutorial {
    background: var(--theme-dialog-option-bg);
  }
  #welcome-skip-tutorial:disabled {
    opacity: 1;
    background: var(--theme-dialog-option-bg);
  }
  .dialog-primary-btn {
    background: var(--theme-dialog-primary-btn-bg);
    color: var(--theme-dialog-primary-btn-text);
    --btn-edge: var(--theme-btn-primary-edge);
  }
  /* Issue #368: single shared rule for every plain-text hyperlink inside
     a dialog (outrageous-note, About link, feedback/debug-screenshot
     GitHub issue links), so all of them stay themed together instead of
     needing a per-id rule each. */
  .dialog-box a {
    color: var(--theme-dialog-link-text, revert);
  }
  .dialog-primary-btn:disabled {
    opacity: 0.4;
    cursor: default;
    --btn-edge: transparent;
  }
  /* Issue #307: the app's standard affirm/danger button colors — same
     pastel green as #secret-word-submit/#skip-hints-btn, same dark red
     as .close-issue-btn — reused here for Enable/Disable notifications. */
  .dialog-affirm-btn {
    background: var(--theme-dialog-affirm-bg);
    color: var(--theme-dialog-affirm-text);
    --btn-edge: var(--theme-btn-affirm-edge);
  }
  /* Issue #385: yellow "pending" state for Friends List invites (an
     incoming Accept button, or an outgoing/accepted inert label) —
     own color pair so it can't be forced to the neutral ghosted look
     that .dialog-affirm-btn:disabled below applies to accept/danger
     buttons; these render as <span> when inert, so that rule never
     applies to them anyway, but the color still needs its own name. */
  .dialog-pending-btn {
    background: var(--theme-dialog-pending-bg);
    color: var(--theme-dialog-pending-text);
    --btn-edge: var(--theme-btn-pending-edge);
  }
  /* Issue: brighter green for "Add hint word" — distinct enough from
     "Add hint letter"'s .dialog-affirm-btn to read as the bigger of
     the two hint actions — tracks --theme-hint-cell so it stays the
     same shade as #grid div.hint-cell across classic colour variants,
     confirmed readable (with --ink text, not white). */
  .dialog-affirm-btn-bright {
    background: var(--theme-dialog-affirm-bright-bg);
    color: var(--theme-dialog-affirm-bright-text);
    --btn-edge: var(--theme-hint-cell-border);
  }
  .dialog-danger-btn {
    background: var(--theme-dialog-danger-bg);
    color: var(--theme-dialog-danger-text);
    --btn-edge: var(--theme-btn-danger-edge);
  }
  /* Issue: disabled state (e.g. #letter-locked-clear-btn when the only
     occurrence of this letter is a hint, or a hint-choice button once
     its use is exhausted) switches to the same neutral ghosted look as
     .dialog-secondary-btn:disabled instead of just dimming the vivid
     color — a still-vivid button reads as "still available," not
     "disabled." Color-only for the text (not opacity) for the same
     reason as elsewhere: any emoji in the label should stay
     full-color, not fade with the text. */
  .dialog-danger-btn:disabled,
  .dialog-affirm-btn:disabled,
  .dialog-affirm-btn-bright:disabled {
    background: var(--theme-dialog-ghosted-btn-bg);
    color: var(--theme-dialog-ghosted-btn-text);
    cursor: default;
    --btn-edge: transparent;
  }
  /* Issue: brighter red for the more consequential "Clear all N" action
     (clears every occurrence on the board, not just this one cell) —
     same family as .dialog-danger-btn, deliberately more saturated so
     it reads as the bigger of the two destructive actions. */
  .dialog-danger-btn-bright {
    background: var(--theme-dialog-danger2-bg);
    color: var(--theme-dialog-danger2-text);
    --btn-edge: var(--theme-btn-danger2-edge);
  }
  /* Issue #385: .dialog-affirm-btn/.dialog-danger-btn/.dialog-pending-btn
     above only set color, so layout/shape for the compact per-row
     buttons (unlike the full-width .dialog-close-btn they're usually
     paired with) is defined here instead. */
  .friendslist-list {
    list-style: none;
    margin: 0;
    padding: 0;
  }
  .friendslist-invite-row {
    display: flex;
    align-items: center;
    gap: 8px;
    margin-bottom: 4px;
  }
  .friendslist-invite-input {
    flex: 1 1 auto;
    min-width: 0;
    padding: 10px 12px;
    border-radius: var(--radius-key);
    border: 1px solid var(--theme-dialog-key-border);
    background: var(--theme-dialog-input-bg);
    color: var(--theme-dialog-ink);
    font-size: 15px;
    font-weight: 500;
    font-family: var(--font-ui);
  }
  .friendslist-contacts-btn,
  .friendslist-invite-row .dialog-primary-btn {
    flex: 0 0 auto;
    border: none;
    border-radius: var(--radius-key);
    padding: 10px 12px;
    font-size: 14px;
    font-weight: 600;
    cursor: pointer;
    font-family: var(--font-ui);
  }
  .friendslist-section-heading {
    color: var(--theme-dialog-label);
    font-size: 12px;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.02em;
    padding: 10px 0 4px;
  }
  .friendslist-row {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 6px 0;
  }
  .friendslist-name {
    flex: 1 1 auto;
    text-align: left;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 14px;
  }
  .friendslist-accept-btn,
  .friendslist-remove-btn {
    flex: 0 0 auto;
    border: none;
    border-radius: var(--radius-key);
    padding: 8px 10px;
    font-size: 13px;
    font-weight: 600;
    cursor: pointer;
    font-family: var(--font-ui);
  }
  .friendslist-accept-btn:not(button) {
    cursor: default;
  }
  /* Issue #468: Accept/Sent (yellow, dialog-pending-btn) and Accepted
     (green, dialog-affirm-btn) share this class but previously sized to
     content only, so the longer "Accepted" label rendered wider than
     "Accept"/"Sent" — min-width fits "Accepted" plus its own padding,
     scoped here (not on .friendslist-remove-btn, which shares the box
     rule above but must stay unaffected). */
  .friendslist-accept-btn {
    min-width: 72px;
    text-align: center;
  }
  /* Issue #408: same 280px scroll cap as .version-list, kept as its own
     rule instead of applying .version-list itself (its margin/padding
     would fight .friendslist-list's reset on the same element). */
  .blocked-users-list {
    max-height: 280px;
    overflow-y: auto;
  }
  /* Issue: same pastel green as #skip-hints-btn — this is the app's
     "confirm/submit a guess" action, distinct enough from the usual
     dark .dialog-primary-btn to feel like a deliberate go-ahead. */
  #secret-word-submit {
    background: var(--theme-dialog-affirm-bg);
    color: var(--theme-dialog-affirm-text);
    --btn-edge: var(--theme-btn-affirm-edge);
  }
  /* Issue #172: tracks --theme-hint-cell, same bright green as #grid
     div.hint-cell — confirmed readable with plain --ink text against
     it there. Only applied on a win (see openSecretWordResult) — a
     wrong guess keeps the normal dark "Close" button. */
  #secret-word-result-close.win-close-btn {
    background: var(--theme-hint-cell);
    color: var(--theme-cell-ink);
    --btn-edge: var(--theme-hint-cell-border);
  }
  /* Issue #121: "Select 2 categories" runs longer than "Cancel" — give
     Cancel less room so the ghosted label doesn't wrap/crowd it. */
  #wordsets-cancel {
    flex: 0.7;
  }
  #wordsets-start {
    flex: 1.3;
  }
  /* Issue #33: iOS Safari has no Screen Orientation Lock API and ignores
     the manifest's "orientation": "portrait" for installed home-screen
     apps — a true lock isn't possible there. The practical fallback
     every mobile web app uses instead: cover the screen with a "please
     rotate back" message whenever the viewport is in landscape.

     Scoped by max-height rather than max-width: in landscape, height
     equals the device's portrait *width*, which stays 375-430px across
     every iPhone including the Pro Max models. max-width was wrong here
     — a landscape iPhone's *width* (the long edge, ~930px on Pro Max) is
     close to a small desktop browser window's width, so that version
     missed the largest phones entirely. Height doesn't have that
     collision: any real desktop window is comfortably taller than a
     phone held sideways. */
  #rotate-overlay {
    display: none;
    position: fixed;
    inset: 0;
    z-index: 2000;
    background: var(--theme-dialog-bg);
    align-items: center;
    justify-content: center;
    text-align: center;
    padding: 24px;
    box-sizing: border-box;
  }
  #rotate-overlay .rotate-icon {
    font-size: 56px;
  }
  #rotate-overlay p {
    font-family: var(--font-ui);
    font-size: 21px;
    font-weight: 600;
    color: var(--theme-dialog-ink);
    max-width: 320px;
    margin: 12px auto 0;
    line-height: 1.4;
  }
  /* Issue: second line is a bit less prominent than the main message —
     smaller, muted color, same treatment as .dialog-box p elsewhere. */
  #rotate-overlay p.rotate-overlay-subtext {
    font-size: 15px;
    color: var(--theme-dialog-subtext);
    margin-top: 10px;
  }
  /* Issue: also requires .phone-screen-class (set in <head> from
     screen.width/height, the actual display resolution) — max-height
     alone reacts to the current VIEWPORT, which docking DevTools, a
     resized window, or any other temporary squeeze shrinks on any
     device, not just an actual phone. See the inline script in <head>
     for why screen dimensions are the more reliable signal here. */
  @media (orientation: landscape) and (max-height: 500px) {
    html.phone-screen-class #rotate-overlay {
      display: flex;
    }
  }
  /* Issue #481: covers the empty-board/pre-theme flash between first paint
     and renderGrid() completing, and the fatal-boot-error state — visible
     by DEFAULT (unlike #rotate-overlay, which needs a media query) since it
     must show with zero JS having run yet; js/ui/bootScreen.js (or, as a
     backstop, the inline script right after this element in index.html)
     sets display:none once boot succeeds. z-index above #rotate-overlay so
     a fatal error always wins over the orientation warning, below
     #script-blocked-warning's 99999 (the most fundamental failure). */
  #boot-screen-overlay {
    display: flex;
    position: fixed;
    inset: 0;
    z-index: 9999;
    background: var(--theme-dialog-bg);
    align-items: center;
    justify-content: center;
    text-align: center;
    padding: 24px;
    box-sizing: border-box;
  }
  @keyframes boot-screen-pulse {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.5; }
  }
  .boot-screen-icon {
    font-size: 56px;
    animation: boot-screen-pulse 1.6s ease-in-out infinite;
  }
  #boot-screen-overlay p {
    font-family: var(--font-ui);
    font-size: 21px;
    font-weight: 600;
    color: var(--theme-dialog-ink);
    max-width: 320px;
    margin: 12px auto 0;
    line-height: 1.4;
  }
  #boot-screen-overlay #boot-report-bug-status {
    font-size: 15px;
    color: var(--theme-dialog-subtext);
  }
  /* Issue #484: needs an explicit width (not just max-width) so a row with
     only one flex:1 button (Force Reload / Send Debug Logs are two
     separate rows) stretches to fill it instead of shrink-wrapping to
     that button's own text length — same pattern as
     #welcome-overlay .dialog-actions-row above. Without this the two
     buttons ended up different widths. */
  #boot-screen-overlay .dialog-actions-row {
    width: 320px;
    max-width: 100%;
    margin: 14px auto 0;
    justify-content: center;
  }
  /* Issue #481: same specificity gotcha as #210's .theme-tab-panel[hidden]
     fix above — the author-origin .dialog-actions-row display:flex rule
     beats the UA [hidden]{display:none} default, so bootScreen's JS
     hidden-gating (5s-delayed reveal, error-only) never actually hid this
     row; it showed on every page load, instantly. */
  #boot-report-bug-row[hidden] {
    display: none;
  }
  #boot-screen-overlay .dialog-actions-row a,
  #boot-screen-overlay .dialog-actions-row button {
    appearance: none;
    -webkit-appearance: none;
    text-decoration: none;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    border: none;
    border-radius: var(--radius-btn);
    padding: 12px 14px;
    font-size: 16px;
    font-weight: 700;
    cursor: pointer;
    font-family: var(--font-ui);
  }
  /* Issue #38/#48: a speech-bubble tooltip pointing at some element on
     screen, shown once ever (tracked in localStorage). Positioned in JS
     via getBoundingClientRect on the target — top/left here are just
     placeholders until then. Shared by #newgame-tip-bubble (points at
     the New Game button) and the other tip bubbles. */
  /* Issue #53: transparent — no visual change to the page, just blocks
     pointer events from reaching anything underneath while a tooltip
     is up. z-index sits below .tip-bubble's 3000 so each tooltip's own
     button (and only that) remains clickable on top of it. */
  #tip-backdrop {
    display: none;
    position: fixed;
    inset: 0;
    z-index: 2900;
    background: transparent;
  }
  /* Ghosts out everything except the tip's target (or the tip bubble
     itself, for the targetless centered tip) — a transparent box whose
     box-shadow fills the rest of the viewport, sized/positioned in JS
     by showTipSpotlight(). Sits above #tip-backdrop, below .tip-bubble. */
  #tip-spotlight {
    display: none;
    position: fixed;
    z-index: 2950;
    pointer-events: none;
    border-radius: 10px;
    box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.75);
    transition: left 0.2s, top 0.2s, width 0.2s, height 0.2s;
  }
  /* Multi-hole sibling of #tip-spotlight (see showMultiSpotlight) — same
     z-index since the two are never shown at the same time. */
  #tip-multi-spotlight {
    display: none;
    position: fixed;
    inset: 0;
    z-index: 2950;
    width: 100vw;
    height: 100vh;
    pointer-events: none;
  }
  /* Issue #218: admin/tester-only shortcut, bottom-right corner —
     z-index above every .tip-bubble's 3000 so it stays clickable no
     matter which tip is currently showing/where it's positioned.
     Issue: bottom was 12px + safe-area-inset-bottom, sitting visibly
     higher than #bottom-left-controls (Settings/login) on iPhone —
     that cluster uses a flat 8px with no inset added at all and is
     already proven to clear the home-indicator/rounded-corner area
     fine, so matching it exactly here keeps both aligned instead of
     re-deriving a second safe-area formula that happens to disagree. */
  #skip-hints-btn {
    display: none;
    position: fixed;
    right: 20px;
    bottom: 13px;
    z-index: 3100;
    align-items: center;
    background: var(--theme-skip-tutorial-bg);
    color: var(--theme-dialog-affirm-text);
    border: 1px solid var(--theme-skip-tutorial-border);
    border-radius: 20px;
    padding: 8px 14px;
    font-size: 13px;
    font-weight: 600;
    font-family: var(--font-ui);
    cursor: pointer;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
    /* Issue #426: always shown full-size with visible text — no
       shrink/collapse animation (see #mode-badge for that system,
       which this button no longer participates in). */
    height: var(--touch-min);
    max-width: 240px;
    overflow: hidden;
    white-space: nowrap;
  }
  #skip-hints-btn:active {
    background: var(--theme-skip-tutorial-bg-pressed);
  }
  /* Generic slide-down notification (window.showIslandNotification()).
     Issue #436: top:0 + padding-top:env(safe-area-inset-top) (the original
     approach) tried to bleed the box's black background into the unsafe
     area so it would merge with the OS-drawn island/notch pill — but the
     box's own border-radius rounds its top corners too, so that rounded
     edge just sits flush with the true physical screen edge instead of
     blending with anything. Docking the whole pill entirely below the
     safe area (top: inset + 8px) overcorrected — on a Dynamic Island
     device it read as sitting ~20px too low, well clear of the island.
     No browser API exposes the island's real pixel bounds, so instead of
     guessing again, split the difference between the two known-bad
     extremes: previous top was 0, the overcorrected top was inset + 8px,
     so the midpoint is inset/2 + 4px. Confirmed on iPhone 17 Pro Max:
     still ~10px too high, nudged down to inset/2 + 14px (other iOS devices/versions
     to be checked in a later round). Pill enlarged by +20px top/bottom
     padding; top reduced by the same 20px so the icon/text's on-screen
     position and the pill's center stay put while it grows both ways.
     Issue #435: reveals via clip-path instead of translating the whole
     box down from off-screen.
     Issue #455: fade-in timer + animation type per device. Default
     (no data-island-anim attribute) is the original top-down slide,
     unaffected. --island-notification-fade-duration defaults to 0ms
     generically, so the opacity transition/clip-path transition-delay
     below are both no-ops until a device profile (app.js) sets a
     nonzero value — same "generic fallback stays exactly as before"
     discipline as the other --island-notification-* vars above.
     Issue #458: transition is split between this base rule (used when
     .show is removed, i.e. closing — CSS resolves an element's
     transition from its target state's rule) and .show below (opening),
     so the two directions can be sequenced/eased independently instead
     of sharing one declaration. Close collapses the clip-path first,
     then fades (reverse order of open's fade-then-expand), and uses
     ease-in instead of open's ease-out so the shrink doesn't front-load
     its visual change and read as much faster than the reveal. */
  #island-notification {
    position: fixed;
    top: calc(env(safe-area-inset-top, 12px) / 2 + var(--island-notification-top-offset));
    left: 50%;
    z-index: 3100;
    display: flex;
    align-items: center;
    justify-content: var(--island-notification-justify-content);
    gap: 6px;
    width: var(--island-notification-width);
    max-width: calc(100vw - 32px);
    background: rgba(0, 0, 0, var(--island-notification-bg-alpha));
    color: #fff;
    border-radius: 24px;
    padding-top: var(--island-notification-padding-top);
    padding-right: var(--island-notification-padding-right);
    padding-bottom: var(--island-notification-padding-bottom);
    padding-left: var(--island-notification-padding-left);
    font-family: var(--font-ui);
    font-weight: 700;
    font-size: var(--island-notification-font-size);
    pointer-events: none;
    transform: translateX(-50%);
    opacity: 0;
    -webkit-clip-path: inset(0 0 100% 0);
    clip-path: inset(0 0 100% 0);
    transition: clip-path var(--ui-anim-duration) ease-in,
      -webkit-clip-path var(--ui-anim-duration) ease-in,
      opacity var(--island-notification-fade-duration) ease-in var(--ui-anim-duration);
  }
  #island-notification.show {
    opacity: 1;
    -webkit-clip-path: inset(0 0 0% 0);
    clip-path: inset(0 0 0% 0);
    transition: opacity var(--island-notification-fade-duration) ease-out,
      clip-path var(--ui-anim-duration) ease-out var(--island-notification-fade-duration),
      -webkit-clip-path var(--ui-anim-duration) ease-out var(--island-notification-fade-duration);
  }
  /* Issue #455: 'slide-split' devices (e.g. centered circular notch)
     reveal by expanding left+right from center instead of sliding
     down from the top. Attribute selector keeps specificity above the
     base rules, so devices without it (the default) are untouched. */
  #island-notification[data-island-anim="slide-split"] {
    -webkit-clip-path: inset(0 50% 0 50%);
    clip-path: inset(0 50% 0 50%);
  }
  #island-notification[data-island-anim="slide-split"].show {
    -webkit-clip-path: inset(0 0% 0 0%);
    clip-path: inset(0 0% 0 0%);
  }
  /* Issue #455 (S22 Ultra follow-up): 'expand-right' devices reveal as
     a circle (clipped to a --island-notification-circle-diameter-wide
     square slice, rounded into a circle by the existing border-radius)
     anchored at a fixed left edge, then expand rightward only to the
     profile's full width — instead of slide-split's center-out growth
     or the default's top-down slide. left/transform are overridden so
     the circle's on-screen position stays put as the box's fixed
     (already-laid-out) width is progressively revealed. */
  #island-notification[data-island-anim="expand-right"] {
    left: calc(50% - (var(--island-notification-circle-diameter) / 2));
    transform: none;
    -webkit-clip-path: inset(0 calc(100% - var(--island-notification-circle-diameter)) 0 0 round calc(var(--island-notification-circle-diameter) / 2));
    clip-path: inset(0 calc(100% - var(--island-notification-circle-diameter)) 0 0 round calc(var(--island-notification-circle-diameter) / 2));
  }
  #island-notification[data-island-anim="expand-right"].show {
    -webkit-clip-path: inset(0 0 0 0 round calc(var(--island-notification-circle-diameter) / 2));
    clip-path: inset(0 0 0 0 round calc(var(--island-notification-circle-diameter) / 2));
  }
  #island-notification-icon:empty {
    display: none;
  }
  #island-notification-icon {
    font-size: 16px;
    line-height: 1;
  }
  /* Issue #442: admin-only floating adjuster for the pill above. Standalone
     fixed element (same rationale as #sync-debug-pill/#debug-screenshot-btn)
     so it stays clickable above dialogs, sitting above the pill's own
     z-index:3100. Hidden by default; toggled via .open in app.js. */
  #notification-pill-adjuster {
    display: none;
    position: fixed;
    bottom: calc(env(safe-area-inset-bottom, 12px) + 12px);
    left: 50%;
    transform: translateX(-50%);
    z-index: 3200;
    flex-direction: column;
    gap: 8px;
    background: var(--theme-dialog-corner-btn-bg);
    border: 2px solid var(--theme-dialog-corner-btn-border);
    color: var(--theme-dialog-corner-btn-text);
    border-radius: 16px;
    padding: 12px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
    font-family: var(--font-ui);
  }
  #notification-pill-adjuster.open {
    display: flex;
  }
  .notification-pill-adjuster-device {
    font-size: 11px;
    opacity: 0.7;
    text-align: center;
  }
  .notification-pill-adjuster-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
  }
  .notification-pill-adjuster-label {
    font-size: 13px;
    font-weight: 600;
    flex: 1;
  }
  .notification-pill-adjuster-value {
    font-size: 13px;
    font-variant-numeric: tabular-nums;
    min-width: 3.5em;
    text-align: center;
  }
  .notification-pill-adjuster-btn {
    border: none;
    border-radius: 12px;
    width: 28px;
    height: 28px;
    line-height: 1;
    font-size: 16px;
    font-weight: 700;
    background: var(--theme-dialog-corner-btn-bg-pressed);
    color: var(--theme-dialog-corner-btn-text);
  }
  #notification-pill-adjuster-close {
    margin-top: 4px;
  }
  /* Issue #234: was a custom non-modal pill — now a standard
     .tip-bubble (see the HTML), so no bespoke styling needed here
     anymore, just points at the word like any other tip. */
  .tip-bubble {
    display: none;
    position: fixed;
    top: 0;
    left: 0;
    z-index: 3000;
    width: 260px;
    max-width: calc(100vw - 32px);
    background: var(--theme-tooltip-bg);
    color: var(--theme-tooltip-text);
    border-radius: var(--radius-card);
    padding: 14px;
    font-family: var(--font-ui);
    font-weight: 600;
    box-shadow: var(--shadow-raised);
    /* Issue #351: same fade+pop entrance as dialogs, same duration —
       tip bubbles previously appeared instantly, the odd one out. */
    animation: fadeScaleIn var(--ui-anim-duration) ease-out;
  }
  /* Issue #98/#99: --arrow-left is set per-tooltip in JS (positionTipBubble)
     to the target's actual horizontal center relative to the bubble's
     final rendered position — a fixed 50% only pointed at the target
     when the bubble happened to render perfectly centered on it, which
     isn't true once the edge-clamping below shifts the bubble away
     from center. Defaults to 50% for anything that isn't positioned
     via positionTipBubble (there is nothing at the moment, but a bare
     default here is harmless). */
  .tip-bubble::after {
    content: '';
    position: absolute;
    top: -8px;
    left: var(--arrow-left, 50%);
    transform: translateX(-50%);
    border-left: 8px solid transparent;
    border-right: 8px solid transparent;
    border-bottom: 8px solid var(--theme-tooltip-bg);
  }
  /* Issue #97: for a target near the bottom of the screen (e.g. the
     settings icon), the bubble renders above it instead of below —
     arrow flips to the bottom edge, pointing down at the target. */
  .tip-bubble-above::after {
    top: auto;
    bottom: -8px;
    border-bottom: none;
    border-top: 8px solid var(--theme-tooltip-bg);
  }
  .tip-bubble p {
    margin: 0 0 12px;
    font-size: 16px;
    line-height: 1.4;
  }
  .tip-bubble button {
    width: 100%;
    border: none;
    border-radius: var(--radius-field);
    min-height: var(--touch-min);
    padding: 8px 12px;
    font-size: 15px;
    font-weight: 700;
    cursor: pointer;
    font-family: inherit;
    background: var(--theme-tooltip-btn-bg);
    color: var(--theme-tooltip-btn-text);
  }
  /* Issue #52: dimmed/ghosted during the 3-2-1 countdown before the
     real dismiss label appears. */
  .tip-bubble button:disabled {
    opacity: 0.5;
    cursor: default;
  }
  /* Issue #51: the final onboarding tooltip isn't pointing at any one
     element, so it's centered on screen instead of positioned via
     positionTipBubble, and has no arrow tail. */
  .tip-bubble-centered {
    top: 50% !important;
    left: 50% !important;
    transform: translate(-50%, -50%) !important;
    width: 280px;
    text-align: center;
  }
  .tip-bubble-centered::after {
    display: none;
  }
  /* Issue #44: the secret-word guess-in-progress, e.g. "M O _ _ E". */
  #secret-word-display {
    font-family: var(--font-display);
    font-weight: 600;
    font-size: 26px;
    letter-spacing: 6px;
    text-align: center;
    color: var(--theme-dialog-ink);
    margin: 4px 0 16px;
    min-height: 32px;
    word-break: break-all;
  }
  #secret-word-keys {
    margin-bottom: 16px;
  }
  /* Entrance pops/slides and the button press-sink are decorative —
     dropped when the OS asks for reduced motion. Functional cues (the
     last-entered marching outline, the secret-word pulse) are untouched. */
  @media (prefers-reduced-motion: reduce) {
    .dialog-overlay,
    .dialog-box,
    .tip-bubble,
    html.phone-screen-class .dialog-sheet .dialog-box,
    html.phone-screen-class #settings-menu.open,
    html.phone-screen-class #login-menu.open,
    html.phone-screen-class #feedback-tools-menu.open,
    html.phone-screen-class #emote-menu.open,
    html.phone-screen-class body:has(#settings-menu.open, #login-menu.open, #feedback-tools-menu.open, #emote-menu.open)::after,
    #settings-menu.open, #login-menu.open, #feedback-tools-menu.open, #emote-menu.open {
      animation: none;
    }
    .dialog-actions-row button,
    .dialog-close-btn,
    .size-menu-btn,
    .letter-key,
    .tip-bubble button,
    #login-cancel,
    #login-lets-play,
    .sheet-settling {
      transition: none;
    }
    #grid-wrap, #alphabet-tracker {
      transition: none;
    }
    .win-confetti {
      display: none;
    }
  }
