:root {
  --navy-top: #1c4d85;
  --navy-deep: #0a1f47;
  /* Wood tones ported from WoodTileView.swift's WoodTilePalette (light "maple"
     grain fading to a darker "walnut" edge), so the PWA's tiles read as the
     same physical tile as the iOS/tvOS board. */
  --tile-face: #deb373;
  --tile-face-mid: #bd8a4f;
  --tile-face-2: #996938;
  --tile-edge: #613d1c;
  --tile-ink: #522e12;
  --tile-dim: 0.55;
  /* A few wavy grain strokes + a fine turbulence-noise pore texture, tiled
     over the 3-stop wood gradient below -- the CSS analog of
     WoodGrainOverlay's Canvas-drawn curves. One shared data URI (versus a
     network asset) decodes once and is reused across every tile, so this
     costs nothing extra on low-end Android beyond the first paint. */
  --wood-grain-svg: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCAxMDAgMTAwJyBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSdub25lJz4KICA8ZmlsdGVyIGlkPSduJz4KICAgIDxmZVR1cmJ1bGVuY2UgdHlwZT0nZnJhY3RhbE5vaXNlJyBiYXNlRnJlcXVlbmN5PScwLjkgMC4wNScgbnVtT2N0YXZlcz0nMicgc2VlZD0nMTEnIHJlc3VsdD0ndCcvPgogICAgPGZlQ29sb3JNYXRyaXggaW49J3QnIHR5cGU9J21hdHJpeCcgdmFsdWVzPScwIDAgMCAwIDAuMjQgIDAgMCAwIDAgMC4xNCAgMCAwIDAgMCAwLjA2ICAwIDAgMCAwLjQ1IDAnLz4KICA8L2ZpbHRlcj4KICA8cmVjdCB3aWR0aD0nMTAwJyBoZWlnaHQ9JzEwMCcgZmlsdGVyPSd1cmwoI24pJy8+CiAgPGcgc3Ryb2tlPScjNjEzRDFDJyBmaWxsPSdub25lJyBzdHJva2UtbGluZWNhcD0ncm91bmQnPgogICAgPHBhdGggZD0nTS0yLDEwIEMzMCw0IDYwLDE2IDEwMiw4JyBzdHJva2Utd2lkdGg9JzEuNicgb3BhY2l0eT0nLjMwJy8+CiAgICA8cGF0aCBkPSdNLTIsMjQgQzMwLDMwIDYwLDE4IDEwMiwyNicgc3Ryb2tlLXdpZHRoPScxLjQnIG9wYWNpdHk9Jy4yOCcvPgogICAgPHBhdGggZD0nTS0yLDQwIEMzMCwzNCA2MCw0NiAxMDIsMzgnIHN0cm9rZS13aWR0aD0nMS4zJyBvcGFjaXR5PScuMjUnLz4KICAgIDxwYXRoIGQ9J00tMiw1OCBDMzAsNjQgNjAsNTIgMTAyLDYwJyBzdHJva2Utd2lkdGg9JzEuNCcgb3BhY2l0eT0nLjI2Jy8+CiAgICA8cGF0aCBkPSdNLTIsNzQgQzMwLDY4IDYwLDgwIDEwMiw3Micgc3Ryb2tlLXdpZHRoPScxLjMnIG9wYWNpdHk9Jy4yNCcvPgogICAgPHBhdGggZD0nTS0yLDkwIEMzMCw5NiA2MCw4NCAxMDIsOTInIHN0cm9rZS13aWR0aD0nMS41JyBvcGFjaXR5PScuMjgnLz4KICA8L2c+Cjwvc3ZnPgo=");
  --wood-gradient: var(--wood-grain-svg),
    linear-gradient(135deg, var(--tile-face) 0%, var(--tile-face-mid) 55%, var(--tile-face-2) 100%);
  /* TrayHolderBackground.swift: a polished mahogany tile rack for the CURRENT-WORD TRAY --
     deliberately its own fixed wood tone rather than --wood-gradient (the player's chosen tile
     finish), same reasoning as native: a rack is a separate object from the tiles it holds, and
     needs to read clearly regardless of which tile material/background color is active. Same
     curved-streak SVG technique as --wood-grain-svg, but laid out to match WoodGrainOverlay's
     fixed 6 horizontal streaks (there's only ever one tray on screen, so nothing needs the
     per-tile grain variation --wood-grain-svg's turbulence noise gives board/tray tiles). */
  --tray-grain-svg: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHZpZXdCb3g9JzAgMCAxMDAgMTAwJyBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSdub25lJz4KICA8ZyBmaWxsPSdub25lJyBzdHJva2UtbGluZWNhcD0ncm91bmQnPgogICAgPHBhdGggZD0nTTAsMTYgQzMzLDEzIDY2LDE5IDEwMCwxNicgc3Ryb2tlPScjMDAwJyBzdHJva2Utd2lkdGg9JzAuOScgb3BhY2l0eT0nMC4xOCcvPgogICAgPHBhdGggZD0nTTAsMzAgQzMzLDI4IDY2LDMyIDEwMCwzMCcgc3Ryb2tlPScjZmZmJyBzdHJva2Utd2lkdGg9JzAuOScgb3BhY2l0eT0nMC4xMCcvPgogICAgPHBhdGggZD0nTTAsNDggQzMzLDQ0IDY2LDUyIDEwMCw0OCcgc3Ryb2tlPScjMDAwJyBzdHJva2Utd2lkdGg9JzAuOScgb3BhY2l0eT0nMC4xNicvPgogICAgPHBhdGggZD0nTTAsNjQgQzMzLDYxLjUgNjYsNjYuNSAxMDAsNjQnIHN0cm9rZT0nI2ZmZicgc3Ryb2tlLXdpZHRoPScwLjknIG9wYWNpdHk9JzAuMTAnLz4KICAgIDxwYXRoIGQ9J00wLDgwIEMzMyw3Ni41IDY2LDgzLjUgMTAwLDgwJyBzdHJva2U9JyMwMDAnIHN0cm9rZS13aWR0aD0nMC45JyBvcGFjaXR5PScwLjE3Jy8+CiAgICA8cGF0aCBkPSdNMCw5MiBDMzMsODkgNjYsOTUgMTAwLDkyJyBzdHJva2U9JyNmZmYnIHN0cm9rZS13aWR0aD0nMC45JyBvcGFjaXR5PScwLjA5Jy8+CiAgPC9nPgo8L3N2Zz4K");
  --tray-gradient: var(--tray-grain-svg),
    linear-gradient(to bottom, #85422e 0%, #663021 45%, #451f14 100%);
  --gold: #ffd666;
  --parchment: #f3ead9;
  --danger: #e0654f;
  --ok: #6bc78a;
  --font-display: Georgia, 'Times New Roman', serif;
  --font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  /* WoodTileView.swift draws tile letters in the system font's .rounded
     design (SF Rounded), not a serif -- ui-rounded is the CSS equivalent
     (Safari/iOS/macOS only; everything else falls through to the same
     sans-serif stack --font-body uses, which is the right fallback since
     there's no cross-browser "rounded" generic). */
  --font-tile: ui-rounded, 'SF Pro Rounded', var(--font-body);
  /* iOS launches this PWA edge-to-edge behind a translucent status bar (see
     index.html's viewport-fit=cover + apple-mobile-web-app-status-bar-style)
     so the WebView can draw under the clock/battery/notch/Dynamic Island --
     these resolve to 0px on browsers/devices with no inset (including
     Android, which reserves its own status-bar space and never overlays it),
     so applying them everywhere below is a no-op there, not a special case. */
  --safe-top: env(safe-area-inset-top, 0px);
  --safe-right: env(safe-area-inset-right, 0px);
  --safe-bottom: env(safe-area-inset-bottom, 0px);
  --safe-left: env(safe-area-inset-left, 0px);
}

* {
  box-sizing: border-box;
}

/* src/animate.js: a floating clone flown/faded between the board and tray.
   position: fixed here (not inline) so it isn't affected by any ancestor's
   transform/filter creating a new containing block. */
.flying-tile {
  position: fixed;
  z-index: 70;
  pointer-events: none;
}

html,
body {
  height: 100%;
}

body {
  margin: 0;
  font-family: var(--font-body);
  background: radial-gradient(ellipse at top, var(--navy-top) 0%, var(--navy-deep) 60%);
  color: var(--parchment);
  overflow: hidden;
  /* No text selection / callout / tap highlight -- this is a tap-driven board, not text content;
     the defaults cause an ugly grey flash + accidental selection on Android tap-and-hold. */
  -webkit-user-select: none;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
}

/* Capped at APP_MAX_WIDTH (layout.js -- keep the two in sync) so a wide desktop browser reads as
   one consistent phone-app-width column (header/tray/board/banners all sharing the same edges)
   instead of full-width chrome around a narrower, separately-centered board. Below that width
   (any real phone, or a narrower browser window) this is a no-op -- 100% already IS the viewport.
   The body's own radial gradient (see below) shows through on either side on a wide window;
   the shadow here is what visually separates the two rather than the app just floating on it. */
#app {
  display: flex;
  flex-direction: column;
  height: 100dvh;
  width: 100%;
  max-width: 480px;
  margin: 0 auto;
  box-shadow: 0 0 40px rgba(0, 0, 0, 0.35);
}

/* Desktop-only decorative phone bezel (main.js's applyDeviceFrame/isDesktopLikeViewport) -- a
   generic Android-style frame, not modeled on any specific real device (avoids both the extra
   asset work and any trademark-silhouette concern a specific one would raise). A player opts in
   via Settings' Device Frame picker (#device-frame-select, hidden entirely on a real phone --
   see that field-row's own comment); off ("None") by default, purely cosmetic.

   Applied straight to #app as a border + border-radius rather than a separate wrapping element:
   #app already has box-sizing: border-box (the global `*` rule above), so the border eats into
   its own box instead of growing past height:100dvh/max-width:480px -- no second height/width
   budget to keep in sync with appWidth()'s own math the way a wrapper's inner padding would need.
   overflow: hidden is safe to add here since #app itself is just a flex column layout, never a
   scroll container in its own right (Full Bag's #board-viewport handles all internal scrolling) --
   this only clips #app's own now-square content corners to match the new rounded border. */
#app.device-frame-punchhole,
#app.device-frame-notch {
  border: 16px solid #161616;
  border-radius: 44px;
  overflow: hidden;
  box-shadow: 0 0 60px rgba(0, 0, 0, 0.55), inset 0 0 0 2px rgba(255, 255, 255, 0.06);
}

/* The punch-hole/notch camera cutout itself: a separate fixed-position sibling of #app (see
   index.html), not a pseudo-element ON #app -- #app's own overflow: hidden above (needed to clip
   its square content corners to the round border) would just as happily clip a ::before/::after
   living in that same box, even one deliberately positioned to sit OUTSIDE the content area, in
   the border strip itself. left: 50% + translateX(-50%) mirrors #app's own margin: 0 auto
   centering, so the two stay aligned regardless of viewport width. main.js's applyDeviceFrame
   sets this element's class to the same device-frame-punchhole/device-frame-notch value as
   #app's own, so the two always agree without any CSS combinator deriving one from the other. */
#device-frame-cutout {
  display: none;
  position: fixed;
  left: 50%;
  transform: translateX(-50%);
  background: #000;
  z-index: 30;
  pointer-events: none;
}

#device-frame-cutout.device-frame-punchhole {
  display: block;
  top: 4px;
  width: 10px;
  height: 10px;
  border-radius: 50%;
}

#device-frame-cutout.device-frame-notch {
  display: block;
  top: 5px;
  width: 110px;
  height: 18px;
  border-radius: 9px;
}

/* ---------- Header ---------- */

/* #561: the wordmark moved off its own standalone strip and into #header-top-row's own centered
   slot below, sharing one row with the menu button and score badge -- same as
   GameChromeView.swift's post-#561 layout (menu-left/title-center/score-right). */
#brand-link {
  display: inline-flex;
  flex: 1 1 auto;
  min-width: 0;
  justify-content: center;
  text-decoration: none;
}

/* The wordmark's own single "L" tile -- same .tile/.tile-points rules the real board uses (wood
   gradient, edge, ink color all come along for free), just fixed-size instead of the board's own
   dynamically-computed tileSize, since this never needs to respond to viewport/column count.
   "exitaire" alongside it is plain chrome text (LexitaireWordmark.swift's drop-cap treatment) --
   the original spelled all 9 letters as tiles (still in git history), which read as unmistakably
   "this game" but made the whole strip much wider than this thin bar ever wanted. */
.wordmark {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 5px;
}
.tile.wordmark-tile {
  width: 22px;
  height: 22px;
  font-size: 13px;
  flex: none;
}
.wordmark-text {
  font-family: var(--font-body);
  font-weight: 700;
  font-size: 15px;
  color: var(--parchment);
}

/* About modal: the same treatment, just bigger -- there's no header-width constraint here. */
.about-wordmark {
  justify-content: center;
  gap: 8px;
  margin-bottom: 8px;
}
.about-wordmark .wordmark-tile {
  width: 34px;
  height: 34px;
  font-size: 20px;
}
.about-wordmark .wordmark-text {
  font-size: 22px;
}

.about-meta {
  margin: 0;
  font-size: 12px;
  text-align: center;
  color: rgba(243, 234, 217, 0.7);
}

/* #header is now the topmost element in #app (the old separate #brand-bar strip is gone -- see
   #561), so IT carries the safe-area-inset-top pad that keeps content out from under the iOS
   status bar (clock/battery/signal). Left/right still cover the notch/Dynamic Island cutout in
   landscape, where the inset moves to a side instead of the top. Stacks #header-top-row (always
   visible) above #header-second-row/#words-remaining/#challenge-stat (each conditional) in a
   plain column -- same total footprint as before, just reshuffled into rows. */
#header {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 6px calc(6px + var(--safe-right)) 6px calc(6px + var(--safe-left));
  padding-top: calc(6px + var(--safe-top));
  flex: 0 0 auto;
  background: rgba(0, 0, 0, 0.18);
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}

/* Mirrors GameChromeView.swift's post-#561 top bar: menu pinned leading, the wordmark centered in
   the space between the two side controls, and the score badge pinned trailing -- the
   conventional menu-left/title-center/score-right nav bar. */
#header-top-row {
  display: flex;
  align-items: center;
  gap: 8px;
}

/* Countdown clock + pause button (timed games only) -- no longer fit alongside menu/title/score in
   the top row itself, so they get their own centered row underneath it (see main.js's
   startCountdownIfNeeded, which toggles this row's `hidden` alongside its children's). */
#header-second-row {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
}

#header-second-row[hidden] {
  display: none;
}

/* Shared by #score-stat and #streak-stat -- both are a small label line stacked over a bold value
   line, right-aligned like a nav-bar badge. No background/capsule (unlike .stat below), matching
   the plain VStack text GameChromeView.swift's badges use now instead of a pill. Kept as shared
   classes (not per-ID rules) so the two badges' fonts stay pixel-identical by construction rather
   than by two separately-maintained copies of the same numbers.

   .chrome-icon-btn's glyph sits well inside its own 40px tap target (~9px of invisible inset on
   top of #header's own padding), but this badge's text has no equivalent buffer -- without the
   extra padding-right below, the score reads visibly closer to the trailing edge than the
   hamburger glyph is to the leading edge, even though both sides get the same #header padding. */
.stat-badge {
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  line-height: 1.15;
  padding-right: 8px;
}

.badge-label {
  font-size: 12px;
  color: rgba(243, 234, 217, 0.7);
}

.badge-value {
  font-variant-numeric: tabular-nums;
  font-size: 18px;
  font-weight: 700;
  color: var(--parchment);
}

#streak-stat[hidden] {
  display: none;
}

/* Positioning context for menu-btn's dropdown (see .dropdown-menu below). */
.menu-wrap {
  position: relative;
  flex: 0 0 auto;
}

/* Borderless icon buttons matching GameChromeView.swift's menuButton/shareButton -- no filled
   background/capsule, just a glyph in a comfortable square tap target. */
.chrome-icon-btn {
  appearance: none;
  border: none;
  background: transparent;
  color: var(--parchment);
  width: 40px;
  height: 40px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 8px;
  cursor: pointer;
  touch-action: manipulation;
}

.chrome-icon-btn:active {
  background: rgba(255, 255, 255, 0.14);
}

/* The menu pop-up itself -- a stand-in for the native pull-down UIMenu (see
   GameChromeView.swift's menuButton) SwiftUI gets for free and the web doesn't. */
.dropdown-menu {
  position: absolute;
  top: calc(100% + 4px);
  min-width: 220px;
  display: flex;
  flex-direction: column;
  gap: 1px;
  background: var(--navy-deep);
  border: 1px solid rgba(255, 255, 255, 0.16);
  border-radius: 12px;
  padding: 6px;
  box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
  z-index: 30;
}

.dropdown-menu.hidden {
  display: none;
}

/* menu-dropdown hangs under the leading menu-btn, inline-start aligned so it never overflows off
   the edge of a narrow screen. Logical (not physical left) so it stays anchored under menu-btn
   when the header's own flex row reverses under an RTL UI language (see main.js's `dir`
   assignment) -- menu-btn swaps screen sides automatically with the row, and this needs to follow. */
#menu-dropdown {
  inset-inline-start: 0;
}

.dropdown-item {
  appearance: none;
  border: none;
  background: transparent;
  color: var(--parchment);
  font-family: inherit;
  font-size: 14px;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 12px;
  border-radius: 8px;
  cursor: pointer;
  touch-action: manipulation;
  white-space: nowrap;
}

.dropdown-item svg {
  flex: 0 0 auto;
  opacity: 0.85;
}

.dropdown-item:active {
  background: rgba(255, 255, 255, 0.12);
}

/* How to Play/Scoring Tips/FAQ/Privacy Policy are <a> elements (external links, unlike every other
   .dropdown-item here, which are <button>s wired to an in-page action) -- buttons never get a
   default underline, so this only needs stating for the anchor case. */
a.dropdown-item {
  text-decoration: none;
}

/* Separates the Hints trio (see index.html) from the plain menu items around it -- a stand-in for
   the visual break a native nested Menu gets for free between it and its siblings. */
.dropdown-separator {
  height: 1px;
  margin: 4px 6px;
  background: rgba(255, 255, 255, 0.14);
}

.stat {
  font-variant-numeric: tabular-nums;
  font-size: 14px;
  padding: 4px 10px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.08);
  white-space: nowrap;
}

.stat.timer {
  color: var(--gold);
  font-weight: 600;
}

.stat.timer.low {
  color: var(--danger);
}

/* GameChromeView.swift's pause button -- a smaller .chrome-icon-btn (that one's 40px square tap
   target is sized for the header's own corner buttons; here it sits inline with the timer pill). */
.pause-btn {
  width: 28px;
  height: 28px;
  color: var(--gold);
}

/* .chrome-icon-btn sets `display: flex` (author-origin), which the `[hidden]` UA rule alone can't
   override -- same reasoning as #pause-overlay[hidden] below. */
#pause-btn[hidden] {
  display: none;
}

/* GameSession.refreshChallengeBanner's chrome text ("<name>'s challenge: beat <score>"). Sits
   directly in #header now (see #561), whose column layout defaults to stretching flex children
   full-width -- align-self keeps this a shrink-wrapped, centered pill instead. */
.stat.challenge {
  align-self: center;
  color: var(--gold);
  font-weight: 600;
  max-width: 45vw;
  overflow: hidden;
  text-overflow: ellipsis;
}

.icon-btn {
  appearance: none;
  border: 1px solid rgba(255, 255, 255, 0.25);
  background: rgba(255, 255, 255, 0.06);
  color: var(--parchment);
  border-radius: 8px;
  padding: 6px 10px;
  font-size: 13px;
  font-family: inherit;
  cursor: pointer;
  touch-action: manipulation;
}

.icon-btn:active {
  background: rgba(255, 255, 255, 0.16);
}

/* ---------- Banner / toast ---------- */

#banner {
  flex: 0 0 auto;
  text-align: center;
  font-size: 13px;
  padding: 6px;
  color: var(--gold);
  opacity: 0;
  transition: opacity 0.25s ease;
  pointer-events: none;
}

#banner.show {
  opacity: 1;
}

/* #need-help-btn ("Hint"): always visible during a game (main.js's showNeedHelpButton/
   hideNeedHelpButton), in its own row just below #banner -- unlike #banner itself, this is a
   real, permanently-interactive control, not a fading toast, so it gets its own element rather
   than sharing #banner's click-through/opacity-transition styling. A real (if quiet) button --
   same rgba-white-on-parchment chrome as .btn-secondary elsewhere -- rather than a text link, so
   it actually reads as tappable; sized to its content and centered in its row instead of
   .btn-secondary's usual full-width dialog placement, so it doesn't compete visually with the
   tray/board below it the way a full-width bar would. */
.need-help-btn {
  display: block;
  width: fit-content;
  flex: 0 0 auto;
  margin: 6px auto;
  background: rgba(255, 255, 255, 0.1);
  border: none;
  border-radius: 10px;
  padding: 8px 20px;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  text-align: center;
  color: var(--parchment);
  cursor: pointer;
  touch-action: manipulation;
  transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}

.need-help-btn:active {
  transform: scale(0.94);
}

/* ---------- Pull-to-refresh indicator (src/pull-to-refresh.js) ---------- */

#ptr-indicator {
  position: fixed;
  top: calc(4px + var(--safe-top));
  left: 50%;
  width: 34px;
  height: 34px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  background: var(--navy-top);
  border: 1px solid rgba(255, 255, 255, 0.25);
  box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35);
  opacity: 0;
  transform: translateX(-50%) translateY(-60px) scale(0.6);
  z-index: 50;
  pointer-events: none;
}

#ptr-spinner {
  width: 16px;
  height: 16px;
  border-radius: 50%;
  border: 2.5px solid rgba(255, 255, 255, 0.25);
  border-top-color: var(--gold);
}

/* While actively dragging, JS drives opacity/transform/rotation directly
   (per-frame, no transition) for 1:1 tracking with the finger; only the
   ready state gets a color cue here, and the spin animation only runs once
   released (see .refreshing), matching a native refresh control. */
#ptr-indicator.ready #ptr-spinner {
  border-top-color: var(--ok);
}

#ptr-indicator.refreshing #ptr-spinner {
  animation: ptr-spin 0.7s linear infinite;
}

@keyframes ptr-spin {
  to {
    transform: rotate(360deg);
  }
}

/* ---------- Board ---------- */

#board-wrap {
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 10px;
  min-height: 0;
  overflow: hidden;
  /* Top padding is 9px, not 4px like the others -- the hint ring's pulsing outward halo
     (.tile.hint-target's animated box-shadow, see hint-ring-pulse below) reaches up to 14px past
     a row-0 tile's border box mid-pulse, and would otherwise get clipped by this container's own
     overflow:hidden right as it's fading out anyway. The persistent part of the ring is a
     different story -- an INSET box-shadow, immune to this entirely (see hint-ring-pulse's own
     comment) -- so this padding is only softening the outward pulse's clip, not load-bearing for
     the ring itself. Right/bottom/left don't need the same bump: they already clear several px in
     virtually every board configuration (horizontal centering slack, and --safe-bottom
     respectively). */
  padding: 9px calc(4px + var(--safe-right)) calc(4px + var(--safe-bottom)) calc(4px + var(--safe-left));
  /* The board's column order is a physical layout, not text -- forced back to ltr regardless of
     the active UI language/direction, same reasoning (and the same forced-ltr treatment) native's
     own GameBoardView.swift gives it via `.environment(\.layoutDirection, .leftToRight)` for
     Hebrew/Arabic/Farsi. Without this, `dir="rtl"` on <html> (see main.js) would mirror the whole
     grid, turning column 1 into the rightmost instead of the leftmost. */
  direction: ltr;
  /* Anchors #full-bag-zoom-fit-btn's absolute positioning (below) -- otherwise unused, every other
     child here lays out in the normal flex flow. */
  position: relative;
}

/* Full Bag's fold/pinch-zoom viewport (main.js's own section header comment has the full design).
   `display: contents` is the pass-through default every other board size gets -- #board stays the
   direct flex child of #board-wrap it's always been, completely unaffected by either wrapper
   existing in the markup. main.js toggles `.full-bag` on #board-viewport once per deal, and ONLY
   ever sets these elements' inline width/height/transform while that class is present. */
#board-viewport {
  display: contents;
}

#board-viewport.full-bag {
  display: block;
  overflow: auto;
  -webkit-overflow-scrolling: touch;
  /* Allows native single/multi-finger PANNING (so #board-viewport's own overflow:auto scrolling,
     including momentum, needs no custom gesture code at all) while telling the browser NOT to
     also try to pinch-zoom the page itself -- main.js's pointer handlers do that instead, with
     full programmatic control (needed for "reset zoom to fit on deal", which the browser's own
     native pinch-zoom has no API for). */
  touch-action: pan-x pan-y;
}

#board-zoom {
  display: contents;
}

#board-viewport.full-bag #board-zoom {
  display: block;
}

#board-viewport.full-bag #board {
  transform-origin: top left;
}

/* #full-bag-zoom-fit-btn (BoardGridView's "Zoom to Fit" button): a floating icon button in the
   viewport's corner, matching every other .chrome-icon-btn's look -- shown/hidden by main.js's
   updateFullBagZoomFitButton, never touched by CSS alone. */
#full-bag-zoom-fit-btn {
  position: absolute;
  right: calc(12px + var(--safe-right));
  bottom: calc(12px + var(--safe-bottom));
  z-index: 5;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  border: 1px solid rgba(255, 255, 255, 0.18);
  background: rgba(10, 31, 71, 0.85);
  color: var(--parchment);
  display: flex;
  align-items: center;
  justify-content: center;
}

#full-bag-zoom-fit-btn.hidden {
  display: none;
}

/* Full Bag's MoreTilesBelowBadge: overlays the bottom of a column whose real depth exceeds the
   fold (main.js's renderBoard) -- .column needs `position: relative` as its anchor, which no other
   board size's columns are ever tall enough to need or notice. */
.column {
  display: flex;
  flex-direction: column;
  gap: 1px;
  position: relative;
}

.fold-badge {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 2px;
  text-align: center;
  font-size: 11px;
  font-weight: 700;
  color: var(--gold);
  background: rgba(0, 0, 0, 0.4);
  border-radius: 4px;
  line-height: 1.5;
  pointer-events: none;
}

.fold-badge.hidden {
  display: none;
}

#board {
  display: flex;
  gap: 1px;
}

.tile {
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
  border-radius: 15%;
  border: 1px solid var(--tile-edge);
  background: var(--wood-gradient);
  background-size: 100% 100%;
  background-repeat: no-repeat;
  /* Inset highlight/shadow for the tile's own bevel, plus an outer drop
     shadow (gameTileShadow(): shadow(color: .black.opacity(.28), radius:
     1.5, y: 1)) so tiles read as physically raised off the board. */
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35), inset 0 -2px 3px rgba(0, 0, 0, 0.2),
    0 1px 2px rgba(0, 0, 0, 0.28);
  font-family: var(--font-tile);
  font-weight: 700;
  color: var(--tile-ink);
  line-height: 1;
  /* TilePressStyle: spring(response: 0.28, dampingFraction: 0.55) -- CSS has
     no spring primitive, so a short bouncy overshoot cubic-bezier stands in. */
  transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.12s ease;
}

.tile.top {
  cursor: pointer;
}

.tile.top:active {
  transform: scale(0.92);
}

.tile.stacked {
  opacity: var(--tile-dim);
}

.tile.empty {
  background: none;
  border: none;
  box-shadow: none;
}

/* TutorialTapIndicator's pulsing ring, reused for the "reveal next letter" hint -- points at the
   column that continues the word in the tray (see main.js's revealNextLetterHintClicked). Clears
   itself the next renderBoard() call wipes it (any tap/undo/apply/deal fully rebuilds every tile's
   className). --ok (green) rather than --gold: the tile face itself is already a warm gold/tan
   (--tile-face), so a gold ring on a gold tile was too low-contrast to notice (issue #441
   follow-up) -- green reads clearly against every tile color the board uses. The persistent ring
   (each keyframe's first, unanimated shadow) is an INSET box-shadow, not an outline -- a hint tile
   in Full Bag's row 0 sits flush against #board-viewport.full-bag's own overflow:auto edge with
   zero clearance (that scroll viewport has no top padding of its own, unlike #board-wrap further
   out), so anything drawn OUTSIDE the tile's own border box there -- an outline, an outset
   box-shadow -- gets clipped flat with no visible ring at all, regardless of how much room
   #board-wrap gives it. Inset is immune to that: it never leaves the tile's own box, so no
   ancestor's overflow clipping can touch it, in Full Bag or anywhere else. Only the pulse's
   outward halo (the second, animated shadow) still extends past the tile -- accepted as a minor
   cosmetic miss in that one row-0-of-Full-Bag case, since it fades to transparent near its own
   max size anyway, unlike the always-visible persistent ring this was actually reported against. */
@keyframes hint-ring-pulse {
  0% {
    box-shadow: inset 0 0 0 3px var(--ok), 0 0 0 0 rgba(107, 199, 138, 0.8);
  }
  70% {
    box-shadow: inset 0 0 0 3px var(--ok), 0 0 0 14px rgba(107, 199, 138, 0);
  }
  100% {
    box-shadow: inset 0 0 0 3px var(--ok), 0 0 0 0 rgba(107, 199, 138, 0);
  }
}

.tile.hint-target {
  position: relative;
  z-index: 2;
  animation: hint-ring-pulse 1.1s ease-out infinite;
}

.tile-points {
  position: absolute;
  right: 8%;
  bottom: 6%;
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 0.28em;
  opacity: 0.75;
}

/* The on-board insight carousel that used to sit here (below the board -- see #board-wrap) was
   removed entirely to keep the game screen calm; .panel-empty below is kept because
   #game-details-empty/#my-games-empty still reuse its look. */
.panel-empty {
  margin: 0;
  font-size: 13px;
  color: rgba(243, 234, 217, 0.55);
}

/* ---------- Tray (TrayHolderBackground/TrayLipView/TrayPerspectiveOverlay, GameBoardView.swift) ---------- */

/* The whole assembled rack (#tray's groove + #tray-lip's raised lip, stacked below it) -- rounded,
   bordered, and shadowed as ONE unit, matching trayHolderChrome()'s "clip then shadow the clipped
   shape" order (clip first, or the shadow's outward bleed would get cut off along with everything
   else). GameMetrics.trayGapAbove's equivalent (the margin below) is the only spacing #tray-wrap
   itself still owns; BoardLayout.margin()'s equivalent (horizontal inset) lives here too, same as
   native's own .padding(.horizontal, BoardLayout.margin()) on the assembled rack. */
#tray-wrap {
  flex: 0 0 auto;
  margin: 12px calc(10px + var(--safe-right)) 0 calc(10px + var(--safe-left));
  border-radius: 10px;
  overflow: hidden;
  border: 1px solid rgba(0, 0, 0, 0.45);
  box-shadow: 0 2px 3px rgba(0, 0, 0, 0.35);
  /* Deliberately NOT forced to ltr the way #board-wrap is -- the board is a fixed physical grid
     (column order means nothing about reading direction), but the tray IS the word actually being
     spelled (renderTray() in main.js appends tapped tiles to #tray-tiles in tap order). Left at
     ltr's default, it inherits <html>'s own `dir` (see main.js) -- so for Arabic the first tile
     tapped lands at the RIGHT and each subsequent tap extends the word LEFTWARD, matching real
     Arabic reading/writing direction, instead of spelling every word backwards for an RTL reader.
     #word-score-row below is a plain (non-forced-ltr) flex row for the same reason -- its
     justify-content: space-between flips the word score/checkmark to the correct edges on its own
     once <html dir> goes rtl. */
}

/* The recessed groove itself: TrayHolderBackground's mahogany gradient + grain (own background),
   ::before is its inner shadow hugging the top edge ("light from above can't reach the far wall of
   a rack tilted back"), ::after is TrayPerspectiveOverlay (ambient tilt shading over the tile row
   plus the seam where the tile floor steps down to meet #tray-lip). #tray-scroll/.tray-score get
   z-index:1 below so they always paint above these two decorative layers regardless of DOM/flex
   stacking quirks. */
#tray {
  position: relative;
  display: flex;
  align-items: center;
  min-height: 40px;
  padding: 0 10px;
  background: var(--tray-gradient);
  background-size: 100% 100%;
  background-repeat: no-repeat;
}

#tray::before {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(to bottom, rgba(0, 0, 0, 0.42), transparent 45%);
  pointer-events: none;
}

#tray::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(to bottom, rgba(0, 0, 0, 0.14) 0%, transparent 55%, rgba(255, 199, 128, 0.1) 100%),
    linear-gradient(to bottom, transparent calc(100% - 1.5px), rgba(0, 0, 0, 0.35) calc(100% - 1.5px));
  pointer-events: none;
}

/* TrayLipView: the raised front lip a real tile rack uses to keep its tiles from sliding out --
   brightest along its own top edge where it catches the most light, settling to the rack's wood
   tone by its base. */
#tray-lip {
  position: relative;
  height: 12px;
  background: linear-gradient(to bottom, #995233, #57261a);
}

#tray-lip::before {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 50%;
  background: linear-gradient(to bottom, rgba(235, 173, 107, 0.9), transparent);
}

#tray-scroll {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  gap: 4px;
  flex: 1 1 auto;
  min-width: 0;
  overflow-x: auto;
}

#tray-tiles {
  display: flex;
  gap: 4px;
  flex: 0 0 auto;
}

.tray-tile {
  flex: 0 0 auto;
  width: 34px;
  height: 34px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 6px;
  border: 1px solid var(--tile-edge);
  background: var(--wood-gradient);
  background-size: 100% 100%;
  background-repeat: no-repeat;
  color: var(--tile-ink);
  font-family: var(--font-tile);
  font-weight: 700;
  font-size: 17px;
  cursor: pointer;
  touch-action: manipulation;
  transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}

.tray-tile:active {
  transform: scale(0.92);
}

/* TrayView.swift: no separate Apply button -- tapping a tray tile undoes back to it (see
   handleTrayTileTap), and this row is the apply control, directly under the tray -- word score on
   the leading edge, checkmark on the trailing edge (Logical properties, not physical left/right, so
   this flips correctly under an RTL UI language same as the tray itself -- see #tray-wrap's
   comment). Hidden ([hidden], not just an empty label) until the tray spells a real word, matching
   TrayView.swift's isWordValid gate. */
#word-score-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin: 8px calc(10px + var(--safe-right)) 0 calc(10px + var(--safe-left));
}

.word-score-value {
  font-variant-numeric: tabular-nums;
  font-weight: 600;
  font-size: 14px;
  color: var(--parchment);
}

.word-score-value.empty {
  font-weight: 500;
  color: rgba(243, 234, 217, 0.45);
}

.apply-btn {
  appearance: none;
  border: none;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  background: var(--ok);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  touch-action: manipulation;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
  flex: 0 0 auto;
  transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}

.apply-btn:active {
  transform: scale(0.92);
}

.apply-btn[hidden] {
  display: none;
}

.btn {
  appearance: none;
  border: none;
  border-radius: 10px;
  padding: 12px 14px;
  font-size: 15px;
  font-weight: 600;
  font-family: inherit;
  cursor: pointer;
  touch-action: manipulation;
}

.btn:disabled {
  opacity: 0.4;
  cursor: default;
}

.btn-secondary {
  background: rgba(255, 255, 255, 0.1);
  color: var(--parchment);
  flex: 0 0 auto;
}

.btn-primary {
  background: var(--gold);
  color: #1a1a1a;
  flex: 1 1 auto;
}

/* #game-over-challenge-btn: an icon alongside the label, unlike every other plain-text .btn. */
.btn-icon-label {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
}

/* ---------- Words-remaining warning ---------- */

/* Mirrors GameChromeView.swift's low-words-left count. Used to sit alongside an "I give up" flag
   button here too (.give-up-flag, removed -- too easy to tap with no confirmation on a
   freshly-dealt board, and no longer present in native either; see index.html's own comment on
   this row for the full reasoning) -- just the text now. */
#words-remaining {
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  font-size: 12px;
  color: var(--danger);
  min-height: 16px;
  margin-top: 4px;
}

/* ---------- Timed-game pause overlay (GameView.swift's pause overlay) ---------- */

/* A solid full-viewport button (not a translucent scrim) so no tile shape/letter is discernible
   underneath -- the whole thing IS the resume control, sitting above every other layer (including
   the header's own buttons) so a paused game can't be peeked at or played through by tapping
   around it. Highest z-index in the sheet on purpose -- .flying-tile's 70 is the next highest, and
   this needs to sit above even a tile mid-flight when the player pauses mid-animation. */
#pause-overlay {
  position: fixed;
  inset: 0;
  z-index: 90;
  appearance: none;
  border: none;
  background: #000;
  color: var(--parchment);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 12px;
  padding: calc(16px + var(--safe-top)) calc(16px + var(--safe-right)) calc(16px + var(--safe-bottom))
    calc(16px + var(--safe-left));
  cursor: pointer;
  touch-action: manipulation;
  text-align: center;
}

/* Same author-origin-`display`-beats-`[hidden]` reasoning as #pause-btn[hidden] above. */
#pause-overlay[hidden] {
  display: none;
}

#pause-overlay-title {
  font-size: 22px;
  font-weight: 600;
}

#pause-overlay-hint {
  font-size: 15px;
  color: rgba(243, 234, 217, 0.7);
}

/* ---------- Modal dialogs ---------- */

.modal-scrim {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.55);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: calc(16px + var(--safe-top)) calc(16px + var(--safe-right)) calc(16px + var(--safe-bottom))
    calc(16px + var(--safe-left));
  z-index: 10;
}

.modal-scrim.hidden {
  display: none;
}

.modal {
  position: relative; /* anchors #give-up-confirm-close's absolute positioning below */
  background: var(--navy-top);
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: 16px;
  padding: 22px;
  width: min(400px, 100%);
  max-height: 85vh;
  overflow-y: auto;
  box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
}

/* #give-up-confirm-modal's corner close button (GameDialogView.swift's DialogChrome onClose,
   rendered there as a topTrailing xmark.circle.fill) -- a circular icon button rather than
   reusing .icon-btn's bordered-pill shape, both because it needs to be absolutely positioned
   (.icon-btn is inline) and because a small round glyph reads as "dismiss this corner" more
   clearly than a rectangular pill would in that spot. */
.modal-close-btn {
  position: absolute;
  top: 12px;
  right: 12px;
  width: 28px;
  height: 28px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.08);
  color: rgba(243, 234, 217, 0.75);
  font-size: 14px;
  line-height: 1;
  cursor: pointer;
  touch-action: manipulation;
}

.modal-close-btn:active {
  background: rgba(255, 255, 255, 0.18);
}

/* Sits immediately to #give-up-confirm-close's left, same corner (28px width + 6px gap). Filled
   background while explanations are showing (the "on" state) -- see main.js's
   toggleGiveUpCaptions -- otherwise identical to .modal-close-btn. */
.modal-help-toggle-btn {
  right: 46px;
  font-weight: 700;
}

.modal-help-toggle-btn.is-active {
  background: rgba(255, 255, 255, 0.22);
  color: rgba(243, 234, 217, 1); /* same rgb as .modal-close-btn's default, just fully opaque */
}

/* Keeps a longer/translated title from running under the corner button(s) regardless of locale --
   scoped to just this one dialog's h2 since it's the only modal with corner buttons. Wide enough
   for both #give-up-confirm-close and #give-up-confirm-help-toggle. */
#give-up-confirm-modal h2 {
  padding-right: 66px;
}

.modal h2 {
  font-family: var(--font-display);
  margin: 0 0 6px;
  font-size: 22px;
}

.modal p {
  font-size: 14px;
  color: rgba(243, 234, 217, 0.8);
  line-height: 1.5;
}

/* ---------- Rewarded ad (src/ads.js) ---------- */

/* #ad-watch starts hidden in the markup until ads.js reveals it (ad actually ready to play) --
   see the "Every other toggled-hidden element ... gets its own scoped `.hidden` rule" comment
   further below for why this isn't a shared utility class. */
#ad-watch.hidden {
  display: none;
}

#ad-modal-body.hidden {
  display: none;
}

/* DreamHost affiliate-banner fallback (see #ad-house-ad's own comment in index.html). A plain
   clickable image link, not a video player -- no autoplay/mute controls to style. */
#ad-house-ad.hidden {
  display: none;
}

/* #ad-modal's .modal widens to this while the house ad is showing (see ads.js's setAdModalState)
   -- the banner's native 780x90 reads as a thin sliver at the dialog's usual 400px width; this
   gets it much closer to native size while still fitting comfortably on a phone screen. */
.modal.house-ad-wide {
  width: min(640px, 100%);
}

.house-ad {
  display: block;
  margin: 4px 0 14px;
  border-radius: 12px;
  overflow: hidden;
  border: 1px solid rgba(255, 255, 255, 0.12);
}

.house-ad img {
  display: block;
  width: 100%;
  height: auto;
}

.house-ad-caption {
  display: flex;
  justify-content: space-between;
  padding: 6px 10px;
  background: rgba(0, 0, 0, 0.25);
  font-size: 11px;
  color: rgba(243, 234, 217, 0.6);
  font-variant-numeric: tabular-nums;
}

/* ---------- Persistent DreamHost banner (index.html's own comment has the full "why") ---------- */

#dreamhost-banner {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 4px calc(8px + var(--safe-right)) calc(6px + var(--safe-bottom)) calc(8px + var(--safe-left));
}

#dreamhost-banner.hidden {
  display: none;
}

#dreamhost-banner-link {
  flex: 1 1 auto;
  min-width: 0;
  max-width: 480px;
  display: block;
  border-radius: 10px;
  overflow: hidden;
  border: 1px solid rgba(255, 255, 255, 0.12);
}

#dreamhost-banner-link img {
  display: block;
  width: 100%;
  height: auto;
}

#dreamhost-banner-dismiss {
  flex: none;
}

/* ---------- Challenge QR code (src/qrcode.js) ---------- */

.qr-wrap {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  margin: 16px 0;
}

.qr-wrap canvas {
  /* White regardless of theme -- a QR code needs strong light/dark
     contrast to scan reliably, and the code's own quiet-zone margin (see
     renderQrCode) is drawn in the same light color, so this card can't be
     any color that would blend into it. */
  background: #fff;
  border-radius: 10px;
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
  /* renderQrCode grows the canvas past its nominal target size for an
     unusually long challenge link (see MIN_CELL_SIZE) -- scale back down to
     fit a narrow phone's modal rather than overflowing it. The canvas's own
     backing resolution (device pixels) is unaffected by this, so it stays
     crisp even scaled down. */
  max-width: 100%;
  height: auto;
}

.qr-hint {
  margin: 0;
  font-size: 12px;
  color: rgba(243, 234, 217, 0.55);
  text-align: center;
}

/* ---------- Camera scan (src/scan.js) ---------- */

.scan-video-wrap {
  width: 100%;
  aspect-ratio: 1;
  border-radius: 12px;
  overflow: hidden;
  background: #000;
  margin: 16px 0;
}

.scan-video-wrap video {
  width: 100%;
  height: 100%;
  /* Mirrors the wrap's square crop onto whatever aspect ratio the camera
     actually streams at, instead of letterboxing -- fills the frame the
     same way a native camera viewfinder would. */
  object-fit: cover;
}

.field-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin: 14px 0;
  gap: 12px;
}

/* #duration-row, #language-field-row, and #device-frame-row are all .field-row (display: flex)
   AND toggled via the plain `hidden` attribute (main.js's timed-toggle handler,
   showNewGameDialog's Multilingual check, and showSettingsDialog's isDesktopLikeViewport check,
   respectively) -- author rules beat the user-agent stylesheet's own `[hidden] { display: none }`
   at equal specificity, so .field-row's `display: flex` was silently winning and leaving the row
   visibly rendered even while "hidden". */
#duration-row[hidden],
#language-field-row[hidden],
#device-frame-row[hidden] {
  display: none;
}

/* .field-row-label: #duration-row's group-naming <span> (see index.html's comment on it) needs
   the same treatment as every other field-row's real <label> -- kept as one shared rule rather
   than a duplicate so the two never drift apart. */
.field-row label,
.field-row .field-row-label {
  font-size: 15px;
  /* Keeps the label on one line so a wide sibling control (e.g. App Language's <select>) shrinks
     first instead -- without this, adding an info-btn next to a label already close to its
     field-row's available width (see .field-row-label-group) was enough to tip it over into an
     ugly mid-word wrap. */
  white-space: nowrap;
}

/* A short explanatory line under a field-row whose control isn't self-explanatory (e.g. App
   Language, which is easy to confuse with the New Game dialog's gameplay-language picker) --
   same small-muted-caption treatment as .qr-hint, left-aligned to match the field rows above it
   instead of that dialog's centered QR code. Collapsed behind its field-row's own .info-btn until
   tapped (main.js's delegated click handler) -- one shared rule rather than each field's own
   scoped id selector (contrast #duration-row[hidden] above) since three different field-hints in
   the Settings dialog now toggle identically; still scoped to the .field-hint component itself,
   not a bare `.hidden` utility applied wherever. */
.field-hint {
  margin: -6px 0 14px;
  font-size: 12px;
  color: rgba(243, 234, 217, 0.55);
}

.field-hint.hidden {
  display: none;
}

/* Wraps a field-row's <label> together with its info-btn disclosure toggle so the pair sits as
   one unit against the field-row's own space-between (the switch/select stays the sole thing
   pushed to the far end). */
.field-row-label-group {
  display: flex;
  align-items: center;
  gap: 6px;
}

/* Small borderless circle-i button that reveals a field-hint on tap -- .chrome-icon-btn's 40px
   square target is sized for the header bar, way oversized for an inline label annotation, so
   this gets its own compact variant instead. */
.info-btn {
  appearance: none;
  border: none;
  background: transparent;
  color: rgba(243, 234, 217, 0.55);
  width: 22px;
  height: 22px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  cursor: pointer;
  touch-action: manipulation;
  flex: 0 0 auto;
}

.info-btn:active {
  background: rgba(255, 255, 255, 0.14);
  color: var(--parchment);
}

.name-field {
  width: 150px;
  min-width: 0;
  padding: 8px 10px;
  border-radius: 8px;
  border: 1px solid rgba(255, 255, 255, 0.25);
  background: rgba(255, 255, 255, 0.08);
  color: var(--parchment);
  font-size: 14px;
  font-family: inherit;
  text-align: end;
}

/* ---------- Settings dialog: background color ---------- */

.color-field {
  display: flex;
  align-items: center;
  gap: 10px;
  flex: 0 0 auto;
}

/* The native <input type="color"> swatch/chrome varies a lot by browser --
   this just strips its default border/padding so it reads as a small,
   tappable color chip consistent with the rest of the dialog's controls. */
.color-field input[type="color"] {
  appearance: none;
  width: 36px;
  height: 26px;
  padding: 0;
  border: 1px solid rgba(255, 255, 255, 0.25);
  border-radius: 6px;
  background: none;
  cursor: pointer;
}

.color-field input[type="color"]::-webkit-color-swatch-wrapper {
  padding: 0;
}

.color-field input[type="color"]::-webkit-color-swatch {
  border: none;
  border-radius: 5px;
}

.color-field input[type="color"]::-moz-color-swatch {
  border: none;
  border-radius: 5px;
}

.link-btn {
  appearance: none;
  border: none;
  background: none;
  color: var(--gold);
  font-size: 13px;
  font-family: inherit;
  padding: 4px 0;
  cursor: pointer;
}

.link-btn:active {
  opacity: 0.7;
}

/* Language (Settings dialog): a plain <select> rather than .segmented below -- 22 options as
   segmented buttons wrap into an unwieldy multi-row grid, where a dropdown stays a single compact
   row. appearance: none strips each browser's own default select
   chrome (a big native arrow/inset that clashes with this dialog's other borderless-dark controls)
   so a custom chevron -- the same currentColor-independent parchment stroke color as the rest of
   this dialog's icons -- can be drawn in its place via background-image instead. */
.select-field {
  appearance: none;
  -webkit-appearance: none;
  -moz-appearance: none;
  padding: 8px 30px 8px 10px;
  border-radius: 8px;
  border: 1px solid rgba(255, 255, 255, 0.25);
  background-color: rgba(255, 255, 255, 0.08);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='10' height='10' fill='none' stroke='%23f3ead9' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 10px center;
  color: var(--parchment);
  font-size: 14px;
  font-family: inherit;
  cursor: pointer;
  max-width: 60%;
}

/* Most browsers render the open dropdown list via the OS's own native popup, which ignores most
   CSS -- these mainly matter for the browsers (Firefox in particular) that DO honor them, so the
   list reads as dark/parchment there too instead of falling back to a jarring white-on-white row
   that's inconsistent with the (otherwise fully-styled) closed control sitting above it. */
.select-field option {
  background: var(--navy-top);
  color: var(--parchment);
}

.segmented {
  display: flex;
  gap: 6px;
  flex-wrap: wrap;
}

.segmented button {
  appearance: none;
  border: 1px solid rgba(255, 255, 255, 0.25);
  background: transparent;
  color: var(--parchment);
  border-radius: 8px;
  padding: 8px 12px;
  font-size: 14px;
  font-family: inherit;
  cursor: pointer;
}

.segmented button.selected {
  background: var(--gold);
  color: #1a1a1a;
  border-color: var(--gold);
}

.switch {
  position: relative;
  width: 44px;
  height: 26px;
  flex: 0 0 auto;
}

.switch input {
  opacity: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  cursor: pointer;
}

.switch .track {
  position: absolute;
  inset: 0;
  background: rgba(255, 255, 255, 0.2);
  border-radius: 999px;
  transition: background 0.15s ease;
  pointer-events: none;
}

.switch .track::after {
  content: '';
  position: absolute;
  top: 3px;
  left: 3px;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: white;
  transition: transform 0.15s ease;
}

.switch input:checked + .track {
  background: var(--gold);
}

.switch input:checked + .track::after {
  transform: translateX(18px);
}

.modal-actions {
  display: flex;
  gap: 10px;
  margin-top: 18px;
}

/* MissedWordsDialogView's VStack of stacked, full-width buttons -- used where a modal offers more
   than the usual cancel/confirm pair (see #give-up-confirm-modal). */
.modal-actions-column {
  flex-direction: column;
}

.modal-actions-column .btn {
  flex: 1 1 auto;
}

/* #name-entry-input now only ever appears inside .game-over-name-entry's flex row (see the
   "Unified end-of-game results" section below), alongside Skip/Save. */
#name-entry-input {
  flex: 1 1 auto;
  min-width: 0;
  padding: 10px 12px;
  border-radius: 8px;
  border: 1px solid rgba(255, 255, 255, 0.25);
  background: rgba(255, 255, 255, 0.08);
  color: var(--parchment);
  font-size: 15px;
  font-family: inherit;
}

.score-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.score-list li {
  display: flex;
  gap: 10px;
  padding: 8px 0;
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  font-size: 14px;
  align-items: baseline;
}

.score-list li .rank {
  width: 20px;
  color: rgba(243, 234, 217, 0.55);
  font-variant-numeric: tabular-nums;
}

.score-list li .name {
  flex: 1 1 auto;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.score-list li .score {
  font-weight: 700;
  color: var(--gold);
  font-variant-numeric: tabular-nums;
}

.score-list li.mine {
  background: rgba(255, 214, 102, 0.12);
  border-radius: 6px;
  padding-inline-start: 6px;
}

/* Top Scores rows open Game Details on tap (see main.js's renderTopScores) -- Missed Words shares
   .score-list's base styling but isn't tappable, so this stays a separate modifier rather than
   living on .score-list li itself. */
.top-score-row-tappable {
  cursor: pointer;
  border-radius: 6px;
  margin: 0 -4px;
  padding: 0 4px;
}

.top-score-row-tappable:active {
  background: rgba(255, 255, 255, 0.08);
}

/* ---------- My Games (#455) ---------- */
/* .score-list gives these rows their base layout/border -- these just add the extra info column
   (language/board size/timer/relative time) and the delete button non-current rows get. */

.my-game-row {
  flex-wrap: wrap;
}

.my-game-info {
  flex: 1 1 auto;
  min-width: 0;
}

.my-game-score {
  font-weight: 700;
  color: var(--gold);
}

.my-game-subtitle {
  font-size: 12px;
  color: rgba(243, 234, 217, 0.65);
  margin-top: 2px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.my-game-delete {
  flex: 0 0 auto;
  padding: 4px 9px;
}

/* ---------- Game details (GameDetailsView.swift) ---------- */

.game-details-subtitle {
  margin-top: -4px !important;
}

.game-details-word-list {
  max-height: 45vh;
  overflow-y: auto;
}

/* Every other toggled-hidden element in this codebase gets its own scoped `.hidden` rule rather
   than a shared utility class (see .word-check-result.hidden, .missed-words-searching.hidden,
   .word-info-stem.hidden, ...) -- #game-details-empty reuses .panel-empty for its base look, but
   needs this one of its own, since .panel-empty itself is never otherwise toggled (panel.js only
   ever inserts it fresh when there's nothing to show, never adds/removes a class on it in place).
   Without this rule, showGameDetailsDialog's classList.toggle('hidden', ...) changed the class but
   nothing actually hid the element -- the empty-state text stayed visible under a real word list. */
#game-details-empty.hidden {
  display: none;
}

/* Each row opens that word's info (see main.js's showGameDetailsDialog) -- .challenge-result-row's
   own name/score-time/date layout is otherwise unchanged, just made tappable. */
.game-details-row-tappable {
  cursor: pointer;
}

.game-details-row-tappable:active {
  background: rgba(255, 255, 255, 0.08);
}

/* ---------- Words You Missed (MissedWordsView.swift) ---------- */

.missed-words-list {
  max-height: 40vh;
  overflow-y: auto;
}

.missed-words-list li .word {
  flex: 1 1 auto;
  font-weight: 600;
}

/* The trailing "still searching" row -- shown while the worker's search is still checkpointing
   in more words, hidden once it finishes (see main.js's renderMissedWords). */
.missed-words-searching {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 0 0;
  font-size: 13px;
  color: rgba(243, 234, 217, 0.6);
}

.missed-words-searching.hidden {
  display: none;
}

.spinner-small {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  border: 2px solid rgba(255, 255, 255, 0.25);
  border-top-color: var(--gold);
  animation: ptr-spin 0.7s linear infinite;
  flex: 0 0 auto;
}

/* ---------- Unified end-of-game results, a full-screen takeover (EndOfGameView.swift) ---------- */

/* #give-up-confirm-modal's "Watch a short ad to keep playing." -- sits between the prominent
   "Hint" button and the "Give up" button below it, so it reads as that button's own caption
   rather than a second paragraph of body text. */
.modal-caption {
  margin: -4px 0 4px;
  font-size: 12px;
  text-align: center;
  color: rgba(243, 234, 217, 0.6);
}

/* #game-over-modal fills the whole viewport rather than centering a card -- once a game has
   genuinely ended there's no board left worth going back to (see index.html's own comment), so
   this is the only screen in the app that isn't a dismissable dialog. Solid background rather than
   .modal-scrim's own dimmed one, since nothing of the board is ever visible behind it anyway. ID
   selectors here so these win over the plain .modal-scrim/.modal rules above without needing an
   extra class. */
#game-over-modal {
  padding: 0;
  align-items: stretch;
  justify-content: stretch;
  background: var(--navy-top);
}

#game-over-modal .modal {
  width: 100%;
  max-width: none;
  height: 100%;
  max-height: none;
  border-radius: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  overflow-y: auto;
}

/* hero: EndOfGameView.swift's hero -- icon circle + score + "points in <time>" one-liner +
   "Started <date>" caption (main.js's renderGameOverHero). Neutral (gray icon, parchment score) for
   a timeout or a give-up; only a fully celebratory ending (every word found) gets the gold
   treatment below, matching native's isNeutralOutcome split. */
.game-over-hero {
  position: relative;
  flex: 0 0 auto;
  overflow: hidden;
  text-align: center;
  padding: calc(28px + var(--safe-top)) 24px 24px;
  background: var(--navy-deep);
}

.game-over-hero.celebratory {
  background: linear-gradient(to bottom, rgba(255, 214, 102, 0.16), var(--navy-top));
}

.game-over-hero-icon {
  width: 56px;
  height: 56px;
  margin: 0 auto 8px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(255, 255, 255, 0.16);
  color: var(--parchment);
}

.game-over-hero.celebratory .game-over-hero-icon {
  background: var(--gold);
  color: #1a1a1a;
}

.game-over-hero-score-row {
  display: flex;
  align-items: baseline;
  justify-content: center;
  gap: 6px;
}

.game-over-score {
  font-family: var(--font-display);
  font-size: 44px;
  font-weight: 800;
  line-height: 1;
  color: var(--parchment);
}

.game-over-hero.celebratory .game-over-score {
  color: var(--gold);
}

.game-over-hero-time {
  font-size: 13px;
  font-weight: 600;
  color: rgba(243, 234, 217, 0.7);
}

.game-over-started {
  margin-top: 4px;
  font-size: 11px;
  color: rgba(243, 234, 217, 0.45);
}

/* A one-shot confetti burst, only rendered (renderGameOverConfetti) for a celebratory hero -- CSS
   keyframes standing in for native's ConfettiOverlay animation. Confined to the hero's own bounds
   (overflow: hidden above) same as native's GeometryReader-scoped ZStack. */
.game-over-confetti {
  position: absolute;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
}

.confetti-piece {
  position: absolute;
  top: -10px;
  width: 6px;
  height: 6px;
  border-radius: 1.5px;
  animation: confetti-fall linear forwards;
}

@keyframes confetti-fall {
  to {
    transform: translateY(220px) rotate(var(--confetti-rotation, 220deg));
    opacity: 0;
  }
}

.game-over-actions {
  flex: 0 0 auto;
  padding-bottom: 16px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}

/* .btn-icon-label's own `display: inline-flex` otherwise beats the plain [hidden] attribute (same
   origin/specificity fight `.dropdown-menu.hidden`/`.modal-scrim.hidden` above already solve the
   same way) -- #game-over-challenge-btn is toggled via this class, not the `hidden` property. */
.btn-icon-label.hidden {
  display: none;
}

.game-over-body {
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  gap: 14px;
  padding: 16px;
}

.game-over-card {
  padding: 16px;
  background: rgba(255, 255, 255, 0.05);
  border-radius: 16px;
}

/* #game-over-words-played-section / #game-over-missed-words-section / #game-over-top-score-card
   headings -- tighter top margin than a generic .modal h3 would need, since .game-over-card's own
   padding (and .game-over-body's row gap) already provide the surrounding spacing. */
.game-over-card h3 {
  font-family: var(--font-display);
  margin: 0 0 6px;
  font-size: 15px;
  color: rgba(243, 234, 217, 0.85);
}

.game-over-card-title {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 10px;
  font-family: var(--font-display);
  font-weight: 700;
  color: var(--gold);
}

/* #game-over-name-entry (name field + Skip/Save) and #game-over-top-score-outcome ("Saved as X"/
   "Score not saved this time") are two views of one decision -- see index.html's own comment on
   #game-over-top-score-card. */
.game-over-name-entry {
  display: flex;
  gap: 8px;
}

.game-over-name-entry.hidden {
  display: none;
}

.game-over-card-subtitle {
  margin: 0;
  font-size: 13px;
  color: rgba(243, 234, 217, 0.7);
}

/* EndOfGameView.swift's gold-medal treatment for the game's highest-scoring word(s) -- a plain
   text/color badge rather than a native-style icon, matching every other list row here. */
.top-word .word {
  color: var(--gold);
}

.top-word .word::before {
  /* \2605 (star) followed by an explicit \0020 (space) -- a single trailing space after a hex
     escape is consumed as its own terminator, not part of the content, so it can't be relied on
     to separate the star from the word that follows. */
  content: '\2605\0020';
}

/* EndOfGameView.swift's onSelectWord/onSelectMissedWord -- each row opens that word's info dialog
   (see main.js's makeGameOverWordRow). Matches .game-details-row-tappable's plain (no outdent)
   treatment rather than .top-score-row-tappable's, since both this and Game Details' own list sit
   inside a scrolling (overflow-y: auto) container -- .top-score-row-tappable's negative-margin
   outdent trick is only safe in a non-scrolling list: overflow-x computes to `auto` too wherever
   overflow-y isn't `visible`, so a row a few px wider than its list (from a negative margin) gets
   silently clipped instead of just visually outdented. */
.game-over-word-row-tappable {
  cursor: pointer;
}

.game-over-word-row-tappable:active {
  background: rgba(255, 255, 255, 0.08);
}

/* ---------- Check a Word (WordCheckView.swift) ---------- */

/* Pill-shaped field with a trailing "x" to clear in one tap -- matches native's capsule-styled
   text field (WordCheckView's textFieldRow). */
.word-check-field {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 8px 6px 14px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.08);
  border: 1px solid rgba(255, 255, 255, 0.2);
}

#word-check-input {
  flex: 1 1 auto;
  min-width: 0;
  border: none;
  background: none;
  color: var(--parchment);
  font-size: 17px;
  font-family: inherit;
  padding: 6px 0;
}

#word-check-input:focus {
  outline: none;
}

.word-check-clear {
  appearance: none;
  border: none;
  background: rgba(255, 255, 255, 0.12);
  color: var(--parchment);
  width: 22px;
  height: 22px;
  border-radius: 50%;
  font-size: 14px;
  line-height: 1;
  cursor: pointer;
  flex: 0 0 auto;
}

.word-check-clear.hidden {
  display: none;
}

.word-check-result {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  margin-top: 14px;
  padding: 12px 14px;
  border-radius: 10px;
  background: rgba(255, 255, 255, 0.06);
}

.word-check-result.hidden {
  display: none;
}

.word-check-result-icon {
  font-size: 20px;
  line-height: 1.3;
  flex: 0 0 auto;
}

.word-check-result-icon.valid {
  color: var(--ok);
}

.word-check-result-icon.invalid {
  color: var(--danger);
}

.word-check-result-word {
  font-size: 16px;
  font-weight: 700;
}

.word-check-result-caption,
.word-check-result-score {
  font-size: 13px;
  color: rgba(243, 234, 217, 0.7);
  margin-top: 2px;
}

.word-check-result-score.hidden {
  display: none;
}

.word-check-history {
  margin-top: 16px;
}

.word-check-history.hidden {
  display: none;
}

.word-check-history-label {
  font-size: 13px;
  font-weight: 600;
  color: rgba(243, 234, 217, 0.7);
  margin-bottom: 6px;
}

.word-check-history ul {
  list-style: none;
  margin: 0;
  padding: 0;
}

.word-check-history li {
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}

.word-check-history li button {
  appearance: none;
  border: none;
  background: none;
  color: var(--parchment);
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 8px 2px;
  font-size: 14px;
  font-family: inherit;
  cursor: pointer;
  text-transform: lowercase;
}

.word-check-history li button::after {
  content: '↩';
  color: rgba(243, 234, 217, 0.5);
  font-size: 13px;
  text-transform: none;
}

/* ---------- Word info (WordLookupView.swift) ---------- */

.word-info-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 0;
}

.word-info-header h2 {
  margin: 0;
}

.word-info-score {
  font-size: 13px;
  color: rgba(243, 234, 217, 0.7);
  margin-top: 4px;
}

.word-info-definition {
  margin: 16px 0;
}

.word-info-definition p {
  margin: 0;
}

.word-info-stem {
  margin-top: 8px !important;
  font-size: 13px;
  color: rgba(243, 234, 217, 0.65);
}

.word-info-stem.hidden {
  display: none;
}

.word-info-links {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.word-info-links a {
  display: block;
  padding: 10px 12px;
  border-radius: 8px;
  background: rgba(255, 255, 255, 0.08);
  color: var(--parchment);
  font-size: 14px;
  text-decoration: none;
}

.word-info-links a:active {
  background: rgba(255, 255, 255, 0.16);
}

/* ---------- Challenge result (ChallengeResultDialogView) ---------- */

.challenge-result-rows {
  list-style: none;
  margin: 10px 0;
  padding: 0;
}

.challenge-result-row {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 10px;
  padding: 8px 0;
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  font-size: 14px;
}

.challenge-result-row .name {
  font-weight: 700;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.challenge-result-row .detail {
  text-align: end;
  flex: 0 0 auto;
}

.challenge-result-row .score-time {
  font-variant-numeric: tabular-nums;
  color: var(--gold);
  font-weight: 700;
}

.challenge-result-row .date {
  display: block;
  font-size: 11px;
  color: rgba(243, 234, 217, 0.55);
  margin-top: 2px;
}

.tabs {
  display: flex;
  gap: 6px;
  margin-bottom: 10px;
  flex-wrap: wrap;
}

.tabs button {
  appearance: none;
  border: none;
  background: rgba(255, 255, 255, 0.08);
  color: var(--parchment);
  border-radius: 999px;
  padding: 6px 12px;
  font-size: 12px;
  font-family: inherit;
  cursor: pointer;
}

.tabs button.selected {
  background: var(--gold);
  color: #1a1a1a;
}

#install-banner {
  position: fixed;
  left: calc(10px + var(--safe-left));
  right: calc(10px + var(--safe-right));
  bottom: calc(10px + var(--safe-bottom));
  background: var(--navy-top);
  border: 1px solid rgba(255, 255, 255, 0.2);
  border-radius: 12px;
  padding: 10px 12px;
  display: flex;
  align-items: center;
  gap: 10px;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
  z-index: 20;
}

#install-banner.hidden {
  display: none;
}

#install-banner span {
  flex: 1 1 auto;
  font-size: 13px;
}

@media (min-width: 900px) {
  .tile {
    border-radius: 12%;
  }
}

/* ---------- Tutorial overlay (TutorialOverlay in Classes/GameTutorial.swift) ---------- */
/* Deliberately does NOT dim the board -- see src/tutorial.js's section in main.js -- just an accent
   spotlight border around whatever the current beat is talking about, plus a caption pinned to the
   bottom, over the live (still fully visible) game. pointer-events: auto on the overlay itself
   swallows stray taps on the real board/tray underneath (so they can't desync the scripted demo),
   same job as native's transparent Color.clear tap-catcher; the card's own buttons still work
   since they're painted on top and simply absorb the click before it can reach anything behind. */
.tutorial-overlay {
  position: fixed;
  inset: 0;
  z-index: 55; /* Below .flying-tile (70) so a scripted tap's ghost still flies visibly on top. */
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;
  pointer-events: auto;
}

.tutorial-overlay.hidden {
  display: none;
}

#tutorial-spotlights {
  position: fixed;
  inset: 0;
  pointer-events: none;
}

.tutorial-spotlight-rect {
  position: fixed;
  border: 3px solid var(--gold);
  border-radius: 12px;
  pointer-events: none;
  transition:
    top 0.25s ease,
    left 0.25s ease,
    width 0.25s ease,
    height 0.25s ease;
}

/* TutorialTapIndicator: a pulsing ring over the tile the demo is about to auto-tap. */
.tutorial-tap-indicator {
  position: fixed;
  border-radius: 8px;
  background: rgba(255, 214, 102, 0.22);
  border: 4px solid var(--gold);
  box-shadow: 0 0 8px 0 rgba(255, 214, 102, 0.7);
  pointer-events: none;
  animation: tutorial-tap-pulse 0.6s ease-in-out infinite alternate;
}

@keyframes tutorial-tap-pulse {
  from {
    transform: scale(1);
    opacity: 1;
  }
  to {
    transform: scale(1.18);
    opacity: 0.55;
  }
}

.tutorial-card {
  width: min(320px, calc(100% - 48px));
  background: var(--navy-top);
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: 16px;
  padding: 18px;
  margin-bottom: calc(60px + var(--safe-bottom));
  box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
  pointer-events: auto;
}

.tutorial-card h3 {
  margin: 0 0 10px;
  font-size: 16px;
  font-weight: 700;
  color: var(--parchment);
}

.tutorial-card p {
  margin: 0 0 14px;
  font-size: 14px;
  line-height: 1.4;
  color: var(--parchment);
}

.tutorial-actions {
  display: flex;
  gap: 10px;
}
