/* Cache Only: the shared tokens and the one set of parts every page is built from.

   Dark, monochrome, no accent colour: what stands out is solid white (--ink). A game whose
   registry.json entry says light: true is light while Settings allows it (shared/theme.js,
   :root[data-theme="light"] below), where the white turns black. The visual language is the
   logo's rounded-square pixels: progress is pixels coming into focus, and glass with progressive
   blur sits over art.
   Every button, header, card, sheet and menu is defined here; a game's own stylesheet only
   draws its board or arena.

   Corner radii are concentric: an outer radius is the inner radius plus the inset between them.
   Nothing is a pill or a circle: every button starts from the icon button's 8.
      8  every button and control: icon buttons (their 9 x 9 pixel icon inset 7, plus its
         pixels' own corner, 0.93), buttons, small buttons, number keys, side-menu rows, the segmented
         control, the slider, the switch track, fields; and the rows and cards that sit beside them
      4  a chip inset 4 inside a control: the selected segment and the slider's thumb (8 - 4)
      5  the switch knob, inset 3 (8 - 3)
     24  a sheet, holding its close button and buttons inset 16 (8 + 16)
     18  a launcher game tile (8 art + 10), and the suggested game's panel (8 button + 10)
   Pixels (the logo's and the icons') are rounded squares of their own: 34% of a pixel. */

:root {
  color-scheme: dark;

  --bg: #0b0b0b;
  --card: #151515;
  --raised: #1d1d1d;
  --quiet: #4a4a4a;
  --muted: #a8a8a8;
  --ink: #efefef;
  --on-ink: #0b0b0b;
  --ink-press: #d6d6d6; /* a solid --ink button while pressed */
  --red-soft: #f0a39d; /* the one coloured button, .btn-red */
  --shade: 11, 11, 11; /* the background's channels, for shades that fade to it */
  --shade-deep: 0, 0, 0; /* the side menu's darker shade */

  /* Glass: a faint white fill, a hairline edge and a backdrop blur. */
  --glass: rgba(255, 255, 255, 0.07);
  --glass-press: rgba(255, 255, 255, 0.14);
  --glass-line: rgba(255, 255, 255, 0.14);
  --glass-line-strong: rgba(255, 255, 255, 0.22);
  --hairline: rgba(255, 255, 255, 0.08);
  --panel: rgba(24, 24, 24, 0.55); /* a notice laid over a board or arena */
  --sheet: rgba(21, 21, 21, 0.4); /* a sheet is glass: it blurs what's behind, barely tinted (the suggested game's panel too) */
  --blur: 20px;
  --blur-panel: 24px;
  --blur-sheet: 40px;
  --scrim: rgba(0, 0, 0, 0.25); /* the page dimmed behind any open sheet */
  --blur-scrim: 14px; /* and blurred too, behind a sheet opened with blur (a card up close) */

  --r-btn: 8px;
  --r-chip: 4px;
  --r-sheet: 24px;
  --r-game-tile: 18px;
  --r-game-art: 8px;
  --r-pixel: 34%;

  --font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Helvetica, "Segoe UI", Roboto, Arial, sans-serif;
  --font-display: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Helvetica Neue", Helvetica, "Segoe UI", Roboto, Arial, sans-serif;
  --font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, "Roboto Mono", monospace;

  --tap: 44px;
  /* The top safe area takes in the status bar's strip (Header, below): nothing goes under it but
     backgrounds, as at the sides. */
  --safe-t: max(env(safe-area-inset-top, 0px), var(--status-h));
  --status-h: 0px; /* the status bar's strip: what a light along the top edge starts under */
  --safe-r: env(safe-area-inset-right, 0px);
  --safe-b: env(safe-area-inset-bottom, 0px);
  --safe-l: env(safe-area-inset-left, 0px);
  /* The header: an icon-button row right under the status bar. */
  --bar-top: calc(max(var(--safe-t), 8px) + 8px);
  --bar-total: calc(var(--bar-top) + 44px);
  --ease: cubic-bezier(0.2, 0.7, 0.2, 1);
  --t-blur: 0.24s; /* blur in and blur out (Motion, below) */
  --t-twinkle: 1.2s; /* the same, slowed down for the install page's pixel field */
  --blur-fade: 8px;
  /* A sheet coming and going (Sheets, below): in and out, and complete, from the very bottom. */
  --t-sheet: 0.26s;
  --t-sheet-out: 0.2s;
  --t-sheet-full: 0.42s;
  --t-sheet-full-out: 0.5s;
  --ease-back: cubic-bezier(0.6, 0, 0.8, 0.3); /* --ease played backwards, a little sooner off */
}

/* Light: the same parts with the greys turned over, for a game that has a light mode while Settings
   allows it (shared/theme.js sets data-theme). Glass turns to a faint dark fill, shades fade to the
   light background, and the ink is black, so whatever is solid white is solid black. */
:root[data-theme="light"] {
  color-scheme: light;

  --bg: #efefef;
  --card: #ffffff;
  --raised: #e2e2e2;
  --quiet: #c2c2c2;
  --muted: #666666;
  --ink: #121212;
  --on-ink: #f7f7f7;
  --ink-press: #3a3a3a;

  --glass: rgba(0, 0, 0, 0.05);
  --glass-press: rgba(0, 0, 0, 0.1);
  --glass-line: rgba(0, 0, 0, 0.1);
  --glass-line-strong: rgba(0, 0, 0, 0.16);
  --hairline: rgba(0, 0, 0, 0.08);
  --panel: rgba(247, 247, 247, 0.6);
  --sheet: rgba(247, 247, 247, 0.55);
  --scrim: rgba(0, 0, 0, 0.15);
  --shade: 239, 239, 239;
  --shade-deep: 239, 239, 239;
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

/* Feel like an app, not a web page: no pinch or double-tap zoom (scrolling still works;
   game boards that need raw touches set touch-action: none), no text selection or long-press
   callouts outside of fields, no rubber-banding. app.js blocks the zoom gestures iOS still allows. */
* {
  touch-action: pan-x pan-y;
}

/* No scrollbar indicators anywhere: the page, the landscape strip, sheets and menus all still
   scroll, without the bar. */
* {
  scrollbar-width: none;
}

*::-webkit-scrollbar {
  display: none;
}

html {
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
  background: var(--bg);
  overscroll-behavior: none;
}

body {
  margin: 0;
  min-height: 100vh;
  min-height: 100dvh;
  background: var(--bg);
  color: var(--ink);
  font: 400 15px/1.45 var(--font-sans);
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
  overscroll-behavior: none;
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
}

input,
textarea {
  user-select: text;
  -webkit-user-select: text;
  -webkit-touch-callout: default;
  /* The caret, and on iOS the selection's highlight and handles, in the ink (white, black on a
     light page), not the system's blue. */
  caret-color: var(--ink);
}

::selection {
  background: color-mix(in srgb, var(--ink) 30%, transparent);
}

img,
svg,
a {
  -webkit-user-drag: none;
}

[hidden] {
  display: none !important;
}

h1, h2, h3, p, ol, ul, dl, dd {
  margin: 0;
}

ol, ul {
  padding: 0;
  list-style: none;
}

button {
  font: inherit;
  color: inherit;
}

/* No highlight on tap anywhere, and no focus ring unless the keyboard is in use: shared/app.js
   sets data-input="keyboard" on a key press and "pointer" on any touch or click. */
* {
  -webkit-tap-highlight-color: transparent;
}

:focus,
:focus-visible {
  outline: none;
}

:root[data-input="keyboard"] :focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 2px;
}

.visually-hidden {
  position: absolute !important;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

/* A pixel icon from shared/icons.js: <svg class="icon"><use href="#i-name"/></svg>. */
.icon {
  width: 20px;
  height: 20px;
  flex: none;
  fill: currentColor;
  stroke: none;
}

/* ---------- Type ---------- */

.t-display {
  font: 800 52px/1 var(--font-display);
  letter-spacing: -0.045em;
}

.t-title {
  font: 800 30px/1 var(--font-display);
  letter-spacing: -0.04em;
}

.t-heading {
  font: 750 18px/1.25 var(--font-display);
  letter-spacing: -0.02em;
}

.t-body {
  font-size: 15px;
  line-height: 1.45;
  color: var(--muted);
}

.t-note {
  font-size: 13px;
  line-height: 1.45;
  color: var(--muted);
}

/* Numbers, sizes, versions and section labels. */
.eyebrow {
  font: 500 12px/1.2 var(--font-mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
}

.mono {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
}

/* ---------- Buttons: one shape ---------- */

/* Secondary: glass. Everything that is a button and isn't the one thing the screen is for. */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  height: 48px;
  min-width: 48px;
  padding: 0 22px;
  border: 1px solid var(--glass-line-strong);
  border-radius: var(--r-btn);
  background: var(--glass);
  -webkit-backdrop-filter: blur(var(--blur));
  backdrop-filter: blur(var(--blur));
  color: var(--ink);
  font: 650 16px/1 var(--font-sans);
  white-space: nowrap;
  text-decoration: none;
  cursor: pointer;
}

.btn .icon {
  width: 18px;
  height: 18px;
}

.btn:active {
  background: var(--glass-press);
}

/* Primary: solid. At most one per screen. */
.btn-primary {
  border-color: var(--ink);
  background: var(--ink);
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
  color: var(--on-ink);
}

.btn-primary:active {
  background: var(--ink-press);
}

/* Red: a solid pastel red in place of the primary, for the move that ends something and passes play
   on (Ktan's End turn), and beside it for turning something down (Ktan's Decline of an offer). The
   one colour among the buttons. */
.btn-red {
  border-color: var(--red-soft);
  background: var(--red-soft);
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
  color: var(--on-ink);
}

.btn-red:active {
  background: #e3938d;
}

.btn-block {
  width: 100%;
}

/* Icon only: the same button, square, its pixel icon 7 in from the edge like an icon button's
   (34 in a 48). */
.btn-icon {
  width: 48px;
  padding: 0;
}

.btn-icon .icon {
  width: 34px;
  height: 34px;
}

/* Disabled is the same button at 40%, never a new style. */
.btn:disabled,
.btn[aria-disabled="true"],
.btn-small:disabled,
.icon-btn:disabled,
.tool:disabled,
.key:disabled {
  opacity: 0.4;
  cursor: default;
}

.btn:disabled:active,
.btn-small:disabled:active {
  background: var(--glass);
}

.btn-primary:disabled:active {
  background: var(--ink);
}

.btn-red:disabled:active {
  background: var(--red-soft);
}

/* Small: a 32 tall button in a section header, next to a 12px label. Its tap area stays 44 tall. */
.btn-small {
  display: inline-flex;
  align-items: center;
  height: var(--tap);
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
}

.btn-small > span {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  height: 32px;
  padding: 0 14px;
  border-radius: var(--r-btn);
  background: var(--ink);
  color: var(--on-ink);
  font: 650 13px/1 var(--font-sans);
  white-space: nowrap;
}

.btn-small .icon {
  width: 14px;
  height: 14px;
}

/* Icon: a 44 glass rounded square. Home, settings, close, and the moves under a board. Its 9 x 9
   pixel icon sits 7 in from the edge, 30 wide: the radius of 8 is that inset plus a pixel's own
   corner (0.93), so the icon's corner pixels nest in the button's corners. */
.icon-btn {
  display: inline-grid;
  place-items: center;
  flex: none;
  width: var(--tap);
  height: var(--tap);
  padding: 0;
  border: 1px solid var(--glass-line);
  border-radius: var(--r-btn);
  background: var(--glass);
  -webkit-backdrop-filter: blur(var(--blur));
  backdrop-filter: blur(var(--blur));
  color: var(--ink);
  text-decoration: none;
  cursor: pointer;
}

.icon-btn .icon {
  width: 30px;
  height: 30px;
}

.icon-btn:active,
.tool:active .icon-btn {
  background: var(--glass-press);
}

.icon-btn[aria-pressed="true"],
.tool[aria-pressed="true"] .icon-btn {
  border-color: var(--ink);
  background: var(--ink);
  color: var(--on-ink);
}

/* Primary: the same 44 square, solid like .btn-primary, for a header action that is the screen's
   one primary (Tanks' map editor: Play, beside the home button and its size). */
.icon-btn-primary {
  border-color: var(--ink);
  background: var(--ink);
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
  color: var(--on-ink);
}

.icon-btn-primary:active {
  background: var(--ink-press);
}

.icon-btn-primary:disabled:active {
  background: var(--ink);
}

/* News: one of the logo's pixels on the button's top right corner, in the colour of what's new
   (--news: K-TAN's chat wears the colour of whoever wrote last), ringed in the page's background. */
.icon-btn.has-news {
  position: relative;
}

.icon-btn.has-news::after {
  content: '';
  position: absolute;
  top: -3px;
  right: -3px;
  width: 10px;
  height: 10px;
  border-radius: var(--r-pixel);
  background: var(--news, var(--ink));
  box-shadow: 0 0 0 2px var(--bg);
}

/* A move under a board: an icon button with its name below; the whole column is the button. */
.tool {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  min-width: var(--tap);
  padding: 0;
  border: 0;
  background: none;
  color: var(--muted);
  font: 400 12px/1.2 var(--font-sans);
  cursor: pointer;
}

.tool .icon-btn {
  pointer-events: none;
}

.tools {
  flex: none;
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: minmax(0, 1fr);
  justify-items: center;
}

/* Number keys: flat cards, radius 8 like every button. */
.keypad {
  --key-gap: 8px;
  --t-key: 0.22s; /* a mode key turning on or off, and the numbers changing with it */
  container-type: inline-size; /* its width, for a mode key's face (100cqw) */
  flex: none;
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--key-gap);
}

.key {
  height: 56px;
  padding: 0;
  border: 0;
  border-radius: var(--r-btn);
  background: var(--card);
  color: var(--ink);
  font: 650 24px/1 var(--font-sans);
  font-variant-numeric: tabular-nums;
  cursor: pointer;
}

.key:active {
  background: var(--ink);
  color: var(--on-ink);
}

.key.is-done {
  opacity: 0.4;
}

/* A number key shows its digit twice, as a number (::before) and as a note (::after), and Notes
   turns one into the other in place: the number shrinks to the note's size and blurs out as the
   note blurs in from the number's size. The key's own text stays for screen readers, at no size. */
.key[data-d] {
  position: relative;
  font-size: 0;
}

.key[data-d]::before,
.key[data-d]::after {
  content: attr(data-d);
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  transition: opacity var(--t-key) var(--ease), filter var(--t-key) var(--ease),
    transform var(--t-key) var(--ease);
}

.key[data-d]::before {
  font-size: 24px;
}

.key[data-d]::after {
  font: 500 19px/1 var(--font-mono);
  color: var(--muted);
  opacity: 0;
  filter: blur(var(--blur-fade));
  transform: scale(1.26); /* 24 / 19 */
}

.keypad.is-notes .key[data-d]::before {
  opacity: 0;
  filter: blur(var(--blur-fade));
  transform: scale(0.79); /* 19 / 24 */
}

.keypad.is-notes .key[data-d]::after {
  opacity: 1;
  filter: none;
  transform: none;
}

/* Tools in the keypad (Sudoku's Notes and Erase): a first row over the numbers, each a number key's
   card holding its icon (a smooth shape), with its name or alone. A mode that is on is solid white,
   like a pressed icon button. The mode (.key-mode, Notes) is as wide as the pad; while it is on it
   narrows to the left to two keys' width, uncovering the tool that goes with it (.key-mode-tool,
   Erase) in the third column, which sits under it all along, out of reach until it shows. Its
   face (icon and name) is as wide as the pad and only slides, by a transform, to stay centred:
   centred again by layout every frame as the key narrowed, it jittered. A mode is a switch: it
   turns white when it turns on, with no flash while pressed (that flashed white, dark, white on a
   tap). */
.key-tool {
  grid-row: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  font: 600 15px/1 var(--font-sans);
}

.key-tool .icon {
  width: 22px;
  height: 22px;
}

.key-tool[aria-pressed="true"] {
  background: var(--ink);
  color: var(--on-ink);
}

.key-mode {
  position: relative;
  z-index: 1;
  grid-column: 1 / -1;
  justify-self: start;
  width: 100%;
  overflow: hidden;
  transition: width var(--t-key) var(--ease), background-color var(--t-key) var(--ease),
    color var(--t-key) var(--ease);
}

.key-mode:not([aria-pressed="true"]):active {
  background: var(--card);
  color: var(--ink);
}

.key-mode[aria-pressed="true"] {
  width: calc((200% - var(--key-gap)) / 3);
}

.key-face {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 100cqw;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  transition: transform var(--t-key) var(--ease);
}

/* Half of what the key gives up (a key and a gap). */
.key-mode[aria-pressed="true"] .key-face {
  transform: translateX(calc((100cqw + var(--key-gap)) / -6));
}

/* Hidden only once the mode has covered it again (a visibility transition shows at once and hides
   at its end). Inherit, so a keypad stepping away hides it too. */
.key-mode-tool {
  grid-column: -2 / -1;
  visibility: hidden;
  transition: visibility var(--t-key);
}

.key-mode[aria-pressed="true"] + .key-mode-tool {
  visibility: inherit;
}

@media (max-height: 740px) {
  .key {
    height: 48px;
  }
  .keypad {
    --key-gap: 6px;
  }
}

/* ---------- Controls ---------- */

.segmented {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: minmax(0, 1fr);
  gap: 4px;
  padding: 4px;
  border-radius: var(--r-btn);
  background: var(--card);
}

.segmented button {
  position: relative;
  height: 40px;
  padding: 0 8px;
  border: 0;
  border-radius: var(--r-chip);
  background: none;
  color: var(--muted);
  font: 600 14px/1 var(--font-sans);
  white-space: nowrap;
  cursor: pointer;
}

/* The 4px padding around the segments still takes taps: 48 tall in all. */
.segmented button::after {
  content: "";
  position: absolute;
  inset: -4px -2px;
}

.segmented button[aria-pressed="true"] {
  background: var(--ink);
  color: var(--on-ink);
  font-weight: 650;
}

/* More choices than fit in a row (Tanks' test range picks one of a dozen enemy kinds): the same
   control in rows of six. Its corners stay 8 = 4 segment + 4 padding. */
.segmented.is-grid {
  grid-auto-flow: row;
  grid-template-columns: repeat(6, minmax(0, 1fr));
}

/* Six choices with longer labels (Tanks' map sizes): rows of three. */
.segmented.is-grid.is-thirds {
  grid-template-columns: repeat(3, minmax(0, 1fr));
}

/* A list to pick one from (Tanks' own maps): the same control, one choice per row, its text
   starting where a side-menu row's does. */
.segmented.is-list {
  grid-auto-flow: row;
}

.segmented.is-list button {
  overflow: hidden;
  padding: 0 14px;
  text-align: left;
  text-overflow: ellipsis;
}

/* Slider: one of a row of steps (Sudoku's difficulty), in the segmented control's box (48 tall,
   radius 8). Each step is a pixel on the track; the thumb is the selected segment's chip (radius
   4, inset 4) and glides from step to step with the value written in it. A drag anywhere on the
   track moves it, and so does a tap. setupSlider() in shared/ui.js builds and runs it. */
.slider {
  --thumb-w: 48px;
  position: relative;
  display: block;
  height: 48px;
  border-radius: var(--r-btn);
  background: var(--card);
  touch-action: none;
  cursor: pointer;
}

.slider[aria-disabled="true"] {
  opacity: 0.4;
  cursor: default;
}

/* Thumb and steps sit on the same line: a step's centre is where the thumb's centre stops. */
.slider-step,
.slider-thumb {
  position: absolute;
  left: calc(4px + var(--at, 0) * (100% - 8px - var(--thumb-w)));
}

.slider-step {
  top: 21px;
  width: 6px;
  height: 6px;
  margin-left: calc(var(--thumb-w) / 2 - 3px);
  border-radius: var(--r-pixel);
  background: var(--quiet);
}

.slider-thumb {
  top: 4px;
  display: grid;
  place-items: center;
  width: var(--thumb-w);
  height: 40px;
  border-radius: var(--r-chip);
  background: var(--ink);
  color: var(--on-ink);
  font: 650 15px/1 var(--font-sans);
  font-variant-numeric: tabular-nums;
  transition: left 0.18s var(--ease);
}

.slider-thumb:empty {
  display: none;
}

/* Switch: track 8, knob 5 inset 3. */
.switch {
  appearance: none;
  -webkit-appearance: none;
  position: relative;
  flex: none;
  width: 52px;
  height: 32px;
  margin: 0;
  border: 0;
  border-radius: var(--r-btn);
  background: var(--quiet);
  cursor: pointer;
}

.switch::after {
  content: "";
  position: absolute;
  top: 3px;
  left: 3px;
  width: 26px;
  height: 26px;
  border-radius: calc(var(--r-btn) - 3px);
  background: var(--ink);
  transition: transform 0.15s var(--ease);
}

.switch:checked {
  background: var(--ink);
}

.switch:checked::after {
  background: var(--on-ink);
  transform: translateX(20px);
}

/* Toggle row: a setting's name on the left and its switch on the right, in the segmented
   control's box (48 tall, radius 8), the switch inset 8 like a side-menu row's. The row is the
   switch's label, so a tap anywhere on it flips it. */
.toggle-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  min-height: 48px;
  padding: 0 8px 0 18px;
  border-radius: var(--r-btn);
  background: var(--card);
  font-size: 15px;
  cursor: pointer;
}

.field {
  width: 100%;
  min-height: 7.5em;
  padding: 14px 18px;
  border: 1px solid var(--hairline);
  border-radius: var(--r-btn);
  background: var(--card);
  color: var(--ink);
  font: 500 16px/1.5 var(--font-mono);
  letter-spacing: 0.08em;
  resize: none;
}

/* One line of text (a name): as tall as a button, in the body face rather than the code face. */
.field.is-line {
  min-height: 0;
  height: 48px;
  padding: 0 18px;
  font: 500 16px/1 var(--font-sans);
  letter-spacing: 0;
}

/* ---------- Surfaces ---------- */

/* Card: flat. Lists, facts, rows. */
.card {
  border-radius: var(--r-btn);
  background: var(--card);
}

/* Glass: only over art or content. */
.glass {
  border: 1px solid var(--glass-line);
  background: var(--glass);
  -webkit-backdrop-filter: blur(var(--blur));
  backdrop-filter: blur(var(--blur));
}

/* Panel: glass over art, shaped like a game tile (18 = 8 + 10): its button sits inset 10 with the
   tile art's radius 8, and its text starts where a tile's title does. It blurs what's behind it
   the way a sheet does (the sheet's tint and blur). */
.panel {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 10px 10px 10px 18px;
  border: 1px solid var(--glass-line);
  border-radius: var(--r-game-tile);
  background: var(--sheet);
  -webkit-backdrop-filter: blur(var(--blur-sheet));
  backdrop-filter: blur(var(--blur-sheet));
}

.panel-text {
  min-width: 0;
}

.panel .btn {
  border-radius: var(--r-game-art);
}

/* A glass row in a list (install steps): an icon in a glass tile, then an eyebrow over a line of
   text, on a panel that blurs what's behind it like a sheet. The panel's radius is the tile's 8
   plus the 8 it sits in. */
.glass-row {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 8px 16px 8px 8px;
  border: 1px solid var(--glass-line);
  border-radius: calc(var(--r-btn) + 8px);
  background: var(--sheet);
  -webkit-backdrop-filter: blur(var(--blur-sheet));
  backdrop-filter: blur(var(--blur-sheet));
}

.glass-row-icon {
  display: grid;
  place-items: center;
  flex: none;
  width: var(--tap);
  height: var(--tap);
  border-radius: var(--r-btn);
  background: var(--glass);
}

.glass-row-icon .icon {
  width: 30px;
  height: 30px;
}

.glass-row-text {
  display: flex;
  flex-direction: column;
  gap: 4px;
  font: 600 16px/1.3 var(--font-sans);
}

/* Notice: a glass text card laid over a board or arena for a moment (Tanks' mission banners). */
.notice {
  display: grid;
  justify-items: center;
  gap: 6px;
  padding: 18px 20px;
  border: 1px solid var(--glass-line);
  border-radius: var(--r-btn);
  background: var(--panel);
  -webkit-backdrop-filter: blur(var(--blur-panel));
  backdrop-filter: blur(var(--blur-panel));
  text-align: center;
}

.notice > :empty {
  display: none;
}

/* In a row of buttons, in their place: as tall as a button (K-TAN's accepted offer, and Wait for
   your turn in place of the moves). Words too long for one line make it taller. */
.notice.is-row {
  min-height: 48px;
  padding: 0 16px;
  align-content: center;
}

/* Facts: a card of label / value rows. */
.facts {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  margin: 0;
  border-radius: var(--r-btn);
  background: var(--card);
  font-size: 15px;
}

.facts dt {
  padding: 14px 0 14px 18px;
}

.facts dd {
  margin: 0;
  padding: 14px 18px;
  color: var(--muted);
  text-align: right;
  overflow-wrap: anywhere;
}

.facts dd.is-num {
  font: 500 13px/1.6 var(--font-mono);
  font-variant-numeric: tabular-nums;
}

.facts dt:not(:nth-last-child(2)),
.facts dd:not(:last-child) {
  border-bottom: 1px solid var(--hairline);
}

/* ---------- Progressive blur ---------- */

/* Stacked backdrop-filter layers, each masked to reach less far the harder it blurs, then a
   shade. Used at the top and bottom edges of scrolling screens, behind the side menu and under
   a game tile's title. Nothing that wraps the layers may set opacity, filter or mask: that would
   cut the blur off from what is behind, so each layer fades itself. */
.pblur {
  pointer-events: none;
}

.pblur > i,
.pblur > b {
  position: absolute;
  inset: 0;
}

.pblur > i {
  -webkit-backdrop-filter: blur(var(--b));
  backdrop-filter: blur(var(--b));
  -webkit-mask-image: linear-gradient(var(--dir), #000 var(--from), transparent var(--to));
  mask-image: linear-gradient(var(--dir), #000 var(--from), transparent var(--to));
}

.pblur-top { --dir: to bottom; }
.pblur-top > i:nth-child(1) { --b: 2px; --from: 55%; --to: 100%; }
.pblur-top > i:nth-child(2) { --b: 6px; --from: 40%; --to: 75%; }
.pblur-top > i:nth-child(3) { --b: 14px; --from: 25%; --to: 55%; }
.pblur-top > b { background: linear-gradient(to bottom, rgba(var(--shade), 0.92), rgba(var(--shade), 0.45) 50%, rgba(var(--shade), 0)); }

.pblur-bottom { --dir: to top; }
.pblur-bottom > i:nth-child(1) { --b: 2px; --from: 55%; --to: 100%; }
.pblur-bottom > i:nth-child(2) { --b: 6px; --from: 40%; --to: 75%; }
.pblur-bottom > i:nth-child(3) { --b: 14px; --from: 20%; --to: 50%; }
.pblur-bottom > b { background: linear-gradient(to top, rgba(var(--shade), 0.85), rgba(var(--shade), 0)); }

.pblur-tile { --dir: to top; }
.pblur-tile > i:nth-child(1) { --b: 2px; --from: 50%; --to: 100%; }
.pblur-tile > i:nth-child(2) { --b: 6px; --from: 35%; --to: 75%; }
.pblur-tile > i:nth-child(3) { --b: 14px; --from: 20%; --to: 55%; }
.pblur-tile > b { background: linear-gradient(to top, rgba(var(--shade), 0.75), rgba(var(--shade), 0.3) 55%, rgba(var(--shade), 0)); }

.pblur-left { --dir: to right; }
.pblur-left > i:nth-child(1) { --b: 1px; --from: 55%; --to: 100%; }
.pblur-left > i:nth-child(2) { --b: 4px; --from: 35%; --to: 76%; }
.pblur-left > i:nth-child(3) { --b: 12px; --from: 22%; --to: 60%; }
.pblur-left > i:nth-child(4) { --b: 32px; --from: 5%; --to: 40%; }
.pblur-left > b { background: linear-gradient(to right, rgba(var(--shade-deep), 0.78), rgba(var(--shade-deep), 0.55) 45%, rgba(var(--shade-deep), 0.15)); }

/* The side edges of a screen that scrolls sideways (the launcher's games in landscape). */
.pblur-side > i:nth-child(1) { --b: 2px; --from: 55%; --to: 100%; }
.pblur-side > i:nth-child(2) { --b: 6px; --from: 35%; --to: 75%; }
.pblur-side > i:nth-child(3) { --b: 14px; --from: 15%; --to: 50%; }
.pblur-side > b { background: linear-gradient(var(--dir), rgba(var(--shade), 0.85), rgba(var(--shade), 0)); }

/* Deep, with -top, -bottom or -side: a blur that deepens all the way to the edge (each layer stays
   on once it has come in), under a shade from transparent to the background there (its <b>; leave
   that out for the blur alone). The launcher's top edge on its home view. The Tanks arena's wall
   past its field has the same look, but baked into its canvas (games/tanks/render.js blurEdges,
   which copies these layers and this shade): for performance, since iOS blurs backdrop-filter
   layers again on every frame the canvas under them changes. */
:is(.pblur-deep, .launcher[data-view="home"] .edge-top) > i:nth-child(1) { --b: 3px; --from: 80%; --to: 100%; }
:is(.pblur-deep, .launcher[data-view="home"] .edge-top) > i:nth-child(2) { --b: 7px; --from: 45%; --to: 80%; }
:is(.pblur-deep, .launcher[data-view="home"] .edge-top) > i:nth-child(3) { --b: 14px; --from: 10%; --to: 45%; }
:is(.pblur-deep, .launcher[data-view="home"] .edge-top) > b {
  background: linear-gradient(var(--dir), var(--bg), rgba(var(--shade), 0.86) 24%, rgba(var(--shade), 0.68) 46%, rgba(var(--shade), 0.42) 66%, rgba(var(--shade), 0.18) 84%, rgba(var(--shade), 0));
}

/* The fixed edges of a scrolling screen: top and bottom, or left and right when it scrolls
   sideways (shown only there, by the page). */
.edge-top,
.edge-bottom {
  position: fixed;
  left: 0;
  right: 0;
  z-index: 5;
}

.edge-left,
.edge-right {
  display: none;
  position: fixed;
  top: 0;
  bottom: 0;
  z-index: 5;
}

.edge-left {
  --dir: to right;
  left: 0;
  width: calc(var(--safe-l) + 16px);
}

.edge-right {
  --dir: to left;
  right: 0;
  width: calc(var(--safe-r) + 16px);
}

.edge-top {
  top: 0;
  height: calc(var(--bar-total) + 24px);
}

.edge-bottom {
  bottom: 0;
  height: calc(var(--safe-b) + 64px);
}

@media (orientation: landscape) {
  .edge-top {
    height: calc(var(--safe-t) + 28px);
  }
}

/* ---------- The logo, pixel by pixel ---------- */

/* The 11 x 13 mark as a grid of rounded squares (shared/logo.js), so each pixel can come into
   focus on its own: progress is pixels going from a blurred grey to sharp white. A pixel is 3/4
   of the pitch and the gap 1/4, so 11 pixels and 10 gaps are 43 gaps wide. */
.logo {
  --logo-w: 22px;
  --logo-haze: 1.5px;
  display: grid;
  grid-template-columns: repeat(11, minmax(0, 1fr));
  gap: calc(var(--logo-w) / 43);
  width: var(--logo-w);
  flex: none;
}

.logo i {
  aspect-ratio: 1;
  border-radius: var(--r-pixel);
}

.logo i.lit {
  background: var(--ink);
  transition: background 0.5s var(--ease), filter 0.5s var(--ease);
}

.logo i.lit.soft {
  background: var(--quiet);
  filter: blur(var(--logo-haze));
}

.logo-md { --logo-w: 24px; --logo-haze: 1.5px; }
.logo-lg { --logo-w: 150px; --logo-haze: 3px; }
.logo-xs { --logo-w: 14px; }

/* The same mark, all white and blurred hard: a soft glow behind a big logo. */
.logo-glow {
  position: absolute;
  z-index: 0;
  filter: blur(46px);
  opacity: 0.4;
  pointer-events: none;
}

/* ---------- Pixel field (shared/pixel-field.js) ---------- */

/* The browser tab's background: the app icon's scatter of dim pixels across the whole page, with
   the mark lit white in it. Its pixels blur out and back in at random, slower than anything else
   (--t-twinkle). The GPU draws them on one canvas, with the blur out and blur in of Motion (below);
   without WebGL each pixel is an element (i) and they stay still. */
.pixel-field {
  --t-blur: var(--t-twinkle);
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  overflow: hidden;
  pointer-events: none;
}

.pixel-field canvas {
  display: block;
}

.pixel-field i {
  position: absolute;
  width: var(--dot);
  height: var(--dot);
  border-radius: var(--r-pixel);
}

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

/* The status bar (iOS, installed app), as Modern Web Weekly's "So what's up with the iOS 27 blur"
   has it (linked in CLAUDE.md): iOS 26 and later blur the top of the page under the status bar,
   unless a fixed element with a solid colour lies along the top, as wide as the screen and within
   4 px of it. Then the status bar takes that colour, solid, and the blur goes. This strip (added
   by shared/app.js) is the page's background, or whatever a page sets as --status on :root (Ktan's
   table: the sea under it; a phone at a table: the deep shade of the portal's colour). It is 6 px tall, the least the
   article found: under 10 px iOS reads its colour from the pixels there (fixed layers only), the
   strip's own. iOS reads the colour again only when a fixed element comes or goes, so app.js puts
   a fresh strip in when --status changes; an iPad keeps the first colour it read once the page has
   been touched. Only where a status bar shows: a phone in landscape has none. It is part of the
   top safe area (--safe-t), so only backgrounds lie under it. A browser tab keeps Safari's own.
   The status-bar style stays default, as in the article (its clock is dark): under
   black-translucent, iOS took no fixed element's colour at all (fixed bars from 0 to 480 px, tried
   on an iPhone on 2026-10-18) and kept its blur over the status bar and about 20 px below. */
.status-strip {
  display: none;
}

@supports (-webkit-touch-callout: none) {
  @media (display-mode: standalone) and (orientation: portrait), (display-mode: standalone) and (min-height: 600px) {
    :root {
      --status-h: 6px;
    }

    .status-strip {
      display: block;
      position: fixed;
      top: 0;
      left: 0;
      right: 0;
      z-index: 100;
      height: var(--status-h);
      background: var(--status, var(--bg));
    }
  }
}

/* Portrait: a row right under the status bar (a game's home button and its stats right-aligned;
   the launcher's logo centred and settings at the right). No page shows a title. Landscape: a
   game's header floats over the play area, home in the top-left corner and the stats down the
   top-right; the launcher's logo scrolls along with its games while settings floats at the top
   right (Launcher, below). */
.bar {
  position: relative;
  z-index: 10;
  flex: none;
  display: flex;
  align-items: center;
  gap: 12px;
  min-height: var(--bar-total);
  padding: var(--bar-top) calc(var(--safe-r) + 16px) 0 calc(var(--safe-l) + 16px);
}

.bar-stats {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 8px 16px;
  margin-left: auto;
}

.stat {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 2px;
  position: relative;
}

.stat-label {
  font: 500 11px/1.2 var(--font-mono);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
  white-space: nowrap;
}

.stat-value {
  font: 750 17px/1.1 var(--font-sans);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* Meters right under the stats: one small icon per thing you hold (a game's shells, its mines),
   filled while it's ready and hollow while it's in use. A row in portrait; in a landscape game
   header each meter runs down the right edge. */
.stat-meters {
  flex-basis: 100%;
  display: flex;
  justify-content: flex-end;
  gap: 12px;
}

.meter {
  display: flex;
  gap: 1px;
}

.meter .icon {
  width: 18px;
  height: 18px;
}

/* Lives (Sudoku's hearts): smooth hearts, full while a life is left and hollow once it's spent.
   In portrait the row sits in the middle of the header (.bar-center); in landscape it runs down
   the right edge under the stats, like a meter. A heart is whole (.life-full) until its life is
   lost; then it breaks: it cracks down the middle and its halves part, then fall away to either
   side, turning and blurring out, uncovering the hollow heart already in place under them. */
.lives {
  display: flex;
  gap: 6px;
}

.life {
  --life: 24px;
  display: grid;
  grid-template: var(--life) / var(--life);
}

/* Sized outright: an svg of no size of its own falls back to 150 tall. */
.life > *,
.life-half > .icon {
  display: block;
  grid-area: 1 / 1;
  width: var(--life);
  height: var(--life);
}

.life-empty {
  color: var(--muted);
  opacity: 0;
}

.life.is-used .life-empty {
  opacity: 1;
}

.life.is-used .life-full {
  opacity: 0;
}

/* The crack: one zigzag from the notch down to the point, each half's heart clipped to its side
   of it. The half moves and blurs around its clipped heart, so the blur softens the crack too. The
   halves show only while the heart breaks (their animation): side by side, the crack shows as a
   seam, so a heart at rest is the whole one. */
.life-half {
  opacity: 0;
  transform-origin: 50% 86%;
  pointer-events: none;
}

.life-half.is-left > .icon {
  clip-path: polygon(0 0, 50% 0, 50% 27%, 44% 42%, 55% 55%, 46% 69%, 50% 86%, 50% 100%, 0 100%);
}

.life-half.is-right > .icon {
  clip-path: polygon(50% 0, 100% 0, 100% 100%, 50% 100%, 50% 86%, 46% 69%, 55% 55%, 44% 42%, 50% 27%);
}

.life.is-breaking .life-half.is-left {
  animation: life-fall-left 0.8s both;
}

.life.is-breaking .life-half.is-right {
  animation: life-fall-right 0.8s both;
}

/* Parting at the point, quick; then falling, slow to start like a weight. */
@keyframes life-fall-left {
  0% {
    transform: none;
    opacity: 1;
    filter: blur(0);
    animation-timing-function: cubic-bezier(0.2, 0.7, 0.2, 1);
  }
  22% {
    transform: translateX(-10%) rotate(-12deg);
    opacity: 1;
    filter: blur(0);
    animation-timing-function: cubic-bezier(0.55, 0, 0.9, 0.55);
  }
  100% {
    transform: translate(-50%, 100%) rotate(-36deg);
    opacity: 0;
    filter: blur(4px);
  }
}

@keyframes life-fall-right {
  0% {
    transform: none;
    opacity: 1;
    filter: blur(0);
    animation-timing-function: cubic-bezier(0.2, 0.7, 0.2, 1);
  }
  22% {
    transform: translateX(10%) rotate(12deg);
    opacity: 1;
    filter: blur(0);
    animation-timing-function: cubic-bezier(0.55, 0, 0.9, 0.55);
  }
  100% {
    transform: translate(50%, 100%) rotate(36deg);
    opacity: 0;
    filter: blur(4px);
  }
}

/* Portrait: something in the middle of the header row, between the home button and the stats
   (Sudoku's hearts). Landscape: it joins the stats down the right edge. */
@media (orientation: portrait) {
  .bar-center {
    position: absolute;
    top: var(--bar-top);
    left: 50%;
    height: var(--tap);
    align-items: center;
    transform: translateX(-50%);
  }
}

/* An icon with two states: .full (a solid shape) and .empty (its outline, a pixel icon of its
   own such as #i-shell-empty). Put .is-used on the svg to show the outline. */
.slot .empty {
  display: none;
}

.slot.is-used .full {
  display: none;
}

.slot.is-used .empty {
  display: inline;
}

.meter .slot.is-used {
  color: var(--muted);
}

@media (orientation: landscape) {
  /* A game's header stays in the page (never fixed) and shares the shell's one grid cell with
     the play area, so it floats over it: home in the top-left corner, the stats stacked down the
     top-right, each in a gutter (--side-w) the play area keeps free on its side. */
  .game-page {
    --side-w: 72px; /* 16 + 44 + 12 */
  }
  .game-page .shell {
    display: grid;
    grid-template: minmax(0, 1fr) / minmax(0, 1fr);
  }
  .game-page .shell > * {
    grid-area: 1 / 1;
  }
  .game-page .bar {
    align-self: start;
    align-items: flex-start;
    min-height: 0;
    padding: max(var(--safe-t), 16px) calc(var(--safe-r) + 16px) 0 calc(var(--safe-l) + 16px);
    pointer-events: none;
  }
  .game-page .bar > * {
    pointer-events: auto;
  }
  .game-page .bar-stats {
    flex-direction: column;
    flex-wrap: nowrap;
    align-items: flex-end;
    gap: 14px;
  }
  .game-page .stat-meters {
    flex-direction: column;
    align-items: flex-end;
  }
  .game-page .meter,
  .game-page .lives {
    flex-direction: column;
  }
}

/* ---------- Game pages: a fixed shell that never scrolls ---------- */

.game-page {
  position: fixed;
  inset: 0;
  overflow: hidden;
  min-height: 0;
}

.shell {
  height: 100%;
  display: flex;
  flex-direction: column;
}

/* Portrait: header, then the board, then the moves. Landscape: the board on the left and the
   moves beside it, with the header floating over the corners. */
.play {
  flex: 1;
  min-height: 0;
  width: 100%;
  max-width: 560px;
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  gap: 18px;
  padding: 18px calc(var(--safe-r) + 16px) calc(max(var(--safe-b), 8px) + 12px) calc(var(--safe-l) + 16px);
}

.board-wrap {
  position: relative;
  flex: 1 1 auto;
  min-height: 0;
  container-type: size;
  display: grid;
  place-items: start center;
}

/* Portrait: the board is as wide as the page and gives up height only when the screen is short,
   so the moves sit right under it and any spare room stays at the bottom. */
@media (orientation: portrait) {
  .play > .board-wrap {
    flex: 0 1 auto;
    aspect-ratio: 1;
  }
}

.board {
  position: relative;
  width: var(--board-size, 100cqmin);
  height: var(--board-size, 100cqmin);
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
}

/* What sits under the board: the moves. */
.controls {
  flex: none;
  display: flex;
  flex-direction: column;
  gap: 20px;
}

@media (max-height: 740px) {
  .play {
    gap: 12px;
    padding-top: 12px;
  }
  .controls {
    gap: 14px;
  }
}

@media (orientation: landscape) {
  .play {
    max-width: none;
    flex-direction: row;
    align-items: center;
    justify-content: center;
    gap: 32px;
    padding: max(var(--safe-t), 16px) calc(var(--safe-r) + var(--side-w)) max(var(--safe-b), 16px) calc(var(--safe-l) + var(--side-w));
    /* The play area's height, and the board's: as tall as the play area, or 60% of its width if
       less (a percentage here is of the play area's width wherever it is used). */
    --play-h: calc(100vh - max(var(--safe-t), 16px) - max(var(--safe-b), 16px));
    --play-h: calc(100dvh - max(var(--safe-t), 16px) - max(var(--safe-b), 16px));
    --board-h: min(60%, var(--play-h));
  }
  /* The board's height given as a width WebKit can lay out: a width taken from aspect-ratio and a
     stretched height came out 0 there (a size container's content counts for nothing), and the
     board vanished in landscape on iPhones. */
  .play > .board-wrap {
    flex: none;
    width: var(--board-h);
    height: auto;
    aspect-ratio: 1;
    place-items: center;
  }
  .controls {
    flex: 0 1 340px;
    min-width: 0;
  }
}

/* A line of state laid over a board (Generating…). */
.board-note {
  position: absolute;
  inset: 0;
  z-index: 3;
  display: grid;
  place-items: center;
  pointer-events: none;
}

/* ---------- Side menu (a game's home button) ---------- */

/* A list of choices down the left edge, over the page blurred hardest at that edge and sharp
   again toward the right. As it opens, the home button that opened it blurs out, and it blurs
   back in as the menu closes (Resume, or a tap beside the list). The blur covers the whole
   screen, the header's strip too, so it never stops short under the home button; the list starts
   under the header. Opening it pauses the game. shared/ui.js runs it. */
.side-menu {
  position: fixed;
  inset: 0;
  z-index: 10;
  color: var(--ink);
}

.side-menu-blur {
  position: absolute;
  inset: 0;
  cursor: pointer;
}

.side-menu-blur > i,
.side-menu-blur > b {
  opacity: 0;
  transition: opacity 0.22s var(--ease);
}

.side-menu-list {
  position: absolute;
  top: 0;
  left: 0;
  width: min(84%, 320px);
  max-height: 100%;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: calc(var(--bar-total) + 14px) 0 calc(var(--safe-b) + 16px) calc(var(--safe-l) + 6px);
  opacity: 0;
  transform: translateX(-18px);
  transition: opacity 0.22s var(--ease), transform 0.22s var(--ease);
}

.side-menu.open .side-menu-blur > i,
.side-menu.open .side-menu-blur > b,
.side-menu.open .side-menu-list {
  opacity: 1;
  transform: none;
}

.side-item {
  display: flex;
  align-items: center;
  gap: 14px;
  min-height: 52px;
  padding: 0 12px;
  border: 0;
  border-radius: var(--r-btn);
  background: none;
  color: var(--ink);
  font: 700 19px/1.15 var(--font-display);
  text-align: left;
  text-decoration: none;
  cursor: pointer;
}

.side-item:active {
  background: color-mix(in srgb, var(--ink) 10%, transparent);
}

.side-item .icon {
  width: 22px;
  height: 22px;
  color: var(--muted);
}

.side-item .switch {
  margin-left: auto;
}

/* Leaving the game sits apart from what happens in it. */
.side-item.is-exit {
  margin-top: 10px;
}

/* Landscape: the list starts under the home button's spot in the top-left corner. */
@media (orientation: landscape) {
  .side-menu-list {
    padding: calc(max(var(--safe-t), 16px) + 56px) 0 calc(var(--safe-b) + 16px) calc(var(--safe-l) + 6px);
  }
}

/* ---------- Sheets ---------- */

/* Content (settings, confirmations, results): glass from the bottom, top corners 24, padding 16,
   a 44 close button at the top right (8 + 16 = 24). Every sheet dims the page behind it in black
   (--scrim) and takes its taps; openSheet's blur blurs the page as well (only Ktan's staged sheets).
   A sheet that belongs to the game state (a start screen or a result) is "docked": opened with
   show() instead of showModal(), so .sheet-scrim (openSheet in shared/ui.js) dims for it, and the
   header's home button stays above the dim, so the side menu still works. A game page stacks: the
   play area (up to 6), the header's other items while a docked sheet is open (7), the scrim (8),
   the docked sheet (9), the side menu (10), the header (11). .sheet-panel is the sheet inside a
   staged one (below). The sheet itself never scrolls: its content (.sheet-inner, below) does,
   under its head. */
dialog.sheet,
.sheet-panel {
  --sheet-l: calc(var(--safe-l) + 16px);
  --sheet-r: calc(var(--safe-r) + 16px);
  position: fixed;
  inset: auto 0 0 0;
  z-index: 9;
  width: 100%;
  max-width: 560px;
  max-height: calc(100vh - var(--safe-t) - 24px);
  max-height: calc(100dvh - var(--safe-t) - 24px);
  margin: 0 auto;
  padding: 0;
  border: 0;
  border-top: 1px solid var(--glass-line);
  border-radius: var(--r-sheet) var(--r-sheet) 0 0;
  background: var(--sheet);
  -webkit-backdrop-filter: blur(var(--blur-sheet));
  backdrop-filter: blur(var(--blur-sheet));
  color: var(--ink);
  overflow: hidden;
}

/* [open]: a closed dialog keeps the browser's display: none. */
dialog.sheet[open]:not(.sheet-staged),
.sheet-panel {
  display: flex;
  flex-direction: column;
}

/* Written out: older WebKit doesn't pass custom properties to ::backdrop. Keep it --scrim,
   --blur-scrim and the --t-sheet times. */
dialog.sheet::backdrop {
  background: rgba(0, 0, 0, 0.25);
  animation: sheet-dim-in 0.26s cubic-bezier(0.2, 0.7, 0.2, 1);
}

dialog.sheet.sheet-blur::backdrop {
  -webkit-backdrop-filter: blur(14px);
  backdrop-filter: blur(14px);
}

dialog.sheet.sheet-closing::backdrop {
  animation: sheet-dim-out 0.2s cubic-bezier(0.6, 0, 0.8, 0.3) forwards;
}

.sheet-scrim {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 8;
  background: var(--scrim);
}

body:has(dialog.sheet-docked[open]) > .sheet-scrim {
  display: block;
  animation: sheet-dim-in var(--t-sheet) var(--ease);
}

body:has(dialog.sheet-docked.sheet-blur[open]:not(.sheet-closing)) > .sheet-scrim {
  -webkit-backdrop-filter: blur(var(--blur-scrim));
  backdrop-filter: blur(var(--blur-scrim));
}

/* Only once every docked sheet is leaving: one closing as the next opens keeps the dim. */
body:not(:has(dialog.sheet-docked[open]:not(.sheet-closing))) > .sheet-scrim {
  animation: sheet-dim-out var(--t-sheet-out) var(--ease-back) forwards;
}

/* A game's header items sit over the play area and the side menu on their own (flex items take
   z-index without a position, which would move .bar-center's anchor), so a docked sheet's dim can
   pass over all of them but the home button. */
.game-page .bar {
  z-index: auto;
}

.game-page .bar > * {
  z-index: 11;
}

.game-page:has(dialog.sheet-docked[open]) .bar > :not(#homeBtn) {
  z-index: 7;
}

/* On a glass sheet, flat surfaces turn to glass too, so nothing on it reads as a solid block. */
dialog.sheet :is(.segmented, .slider, .toggle-row, .card, .facts, .field) {
  background: var(--glass);
}

/* A staged sheet (Ktan's card up close, its robber): the dialog covers the screen and draws the dim (and blur)
   itself, in its ::before, so it fades in and out with the sheet on every browser; a tap on it is a
   tap beside the sheet. .sheet-stage holds what the sheet is about, over the dim and the sheet
   (Ktan's card up close lays its stage under the sheet instead, so a card on its way back to its
   pile passes behind it), and .sheet-panel is the sheet. */
dialog.sheet.sheet-staged {
  inset: 0;
  width: 100%;
  max-width: none;
  height: 100%;
  max-height: none;
  padding: 0;
  border: 0;
  border-radius: 0;
  background: none;
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
  overflow: hidden;
}

dialog.sheet.sheet-staged::backdrop {
  background: none;
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
}

dialog.sheet-staged::before {
  content: "";
  position: absolute;
  inset: 0;
  background: var(--scrim);
  animation: sheet-dim-in var(--t-sheet) var(--ease);
}

dialog.sheet-staged.sheet-blur::before {
  -webkit-backdrop-filter: blur(var(--blur-scrim));
  backdrop-filter: blur(var(--blur-scrim));
}

dialog.sheet-staged > .sheet-panel {
  z-index: 1;
  max-height: 52%;
}

dialog.sheet-staged > .sheet-stage {
  position: absolute;
  z-index: 2;
}

/* A docked staged sheet (Ktan's robber) dims and blurs in its own ::before instead of the scrim,
   and the header's home button stays above it. */
body:has(dialog.sheet-docked.sheet-staged[open]) > .sheet-scrim {
  display: none;
}

/* Coming and going. A sheet fades in as it rises a few pixels into place (sheet-in) and fades out
   sinking them (sheet-out); a complete one (openSheet's complete, only a card up close) rises from
   the very bottom of the screen without fading (sheet-rise) and falls all the way back down
   (sheet-fall). closeSheet in shared/ui.js waits for sheet-out or sheet-fall before closing. */
dialog.sheet:not(.sheet-staged)[open],
dialog.sheet-staged[open] > .sheet-panel {
  animation: sheet-in var(--t-sheet) var(--ease);
}

dialog.sheet.sheet-complete:not(.sheet-staged)[open],
dialog.sheet-complete.sheet-staged[open] > .sheet-panel {
  animation: sheet-rise var(--t-sheet-full) var(--ease);
}

dialog.sheet.sheet-closing:not(.sheet-staged),
dialog.sheet-closing.sheet-staged > .sheet-panel {
  animation: sheet-out var(--t-sheet-out) var(--ease-back) forwards;
}

dialog.sheet.sheet-closing.sheet-complete:not(.sheet-staged),
dialog.sheet-closing.sheet-complete.sheet-staged > .sheet-panel {
  animation: sheet-fall var(--t-sheet-full-out) var(--ease-back) forwards;
}

dialog.sheet-staged.sheet-complete::before {
  animation-duration: var(--t-sheet-full);
}

dialog.sheet-staged.sheet-closing::before {
  animation: sheet-dim-out var(--t-sheet-out) var(--ease-back) forwards;
}

dialog.sheet-staged.sheet-closing.sheet-complete::before {
  animation-duration: var(--t-sheet-full-out);
}

dialog.sheet.sheet-complete::backdrop {
  animation-duration: 0.42s;
}

dialog.sheet.sheet-closing.sheet-complete::backdrop {
  animation-duration: 0.5s;
}

@keyframes sheet-in {
  from {
    transform: translateY(16px);
    opacity: 0;
  }
}

@keyframes sheet-out {
  to {
    transform: translateY(16px);
    opacity: 0;
  }
}

@keyframes sheet-rise {
  from {
    transform: translateY(100%);
  }
}

@keyframes sheet-fall {
  to {
    transform: translateY(100%);
  }
}

@keyframes sheet-dim-in {
  from {
    opacity: 0;
  }
}

@keyframes sheet-dim-out {
  to {
    opacity: 0;
  }
}

@media (orientation: landscape) {
  dialog.sheet,
  .sheet-panel {
    --sheet-l: 16px;
    --sheet-r: 16px;
    max-width: 700px;
    max-height: calc(100dvh - 22px);
  }
  dialog.sheet.sheet-docked {
    max-width: min(560px, calc(100% - 32px));
  }
  dialog.sheet.sheet-docked.sheet-staged {
    max-width: none;
  }
  dialog.sheet-docked.sheet-staged > .sheet-panel {
    max-width: min(560px, calc(100% - 32px));
  }
}

/* A sheet's content, and what scrolls when it doesn't fit: the one child of every sheet, a
   <form method="dialog"> where its buttons close it. Its children keep their size and scroll. */
.sheet-inner {
  --sheet-gap: 22px;
  flex: 0 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  gap: var(--sheet-gap);
  padding: 16px var(--sheet-r) calc(var(--safe-b) + 21px) var(--sheet-l);
  overflow: auto;
  overscroll-behavior: contain;
}

.sheet-inner > * {
  flex-shrink: 0;
}

.sheet-inner > p:empty {
  display: none;
}

/* The head: the sheet's title (.t-title, the same in every game) and its X. It is written as the
   first child of .sheet-inner, and openSheet (shared/ui.js) lifts it out to sit over the content,
   at the sheet's top, keeping its height in --sheet-head. So the content starts one gap under it,
   as if the head were in line, and scrolls away beneath it, fading out on the way (the mask: faint
   under the title, whole again one gap below the head). A drag on the head scrolls the content;
   only its buttons take a tap. */
.sheet-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  min-height: var(--tap);
  padding-left: 4px;
}

:is(dialog.sheet, .sheet-panel) > .sheet-head {
  position: absolute;
  top: 16px;
  left: var(--sheet-l);
  right: var(--sheet-r);
  z-index: 1;
  pointer-events: none;
}

:is(dialog.sheet, .sheet-panel) > .sheet-head :is(button, a) {
  pointer-events: auto;
}

:is(dialog.sheet, .sheet-panel) > .sheet-head + .sheet-inner {
  --sheet-top: calc(16px + var(--sheet-head, 44px));
  padding-top: calc(var(--sheet-top) + var(--sheet-gap));
  scroll-padding-top: calc(var(--sheet-top) + var(--sheet-gap));
  --sheet-fade: linear-gradient(to bottom,
    rgba(0, 0, 0, 0.05) 0,
    rgba(0, 0, 0, 0.07) calc(var(--sheet-top) * 0.5),
    rgba(0, 0, 0, 0.12) calc(var(--sheet-top) - 16px),
    rgba(0, 0, 0, 0.2) calc(var(--sheet-top) - 6px),
    rgba(0, 0, 0, 0.32) calc(var(--sheet-top) + var(--sheet-gap) * 0.2),
    rgba(0, 0, 0, 0.5) calc(var(--sheet-top) + var(--sheet-gap) * 0.45),
    rgba(0, 0, 0, 0.72) calc(var(--sheet-top) + var(--sheet-gap) * 0.7),
    rgba(0, 0, 0, 0.9) calc(var(--sheet-top) + var(--sheet-gap) * 0.88),
    #000 calc(var(--sheet-top) + var(--sheet-gap)));
  -webkit-mask-image: var(--sheet-fade);
  mask-image: var(--sheet-fade);
}

/* A sheet's X: only on a dismissable sheet (openSheet in shared/ui.js adds one where it's missing),
   always at the end of its head. */
.sheet-close {
  flex: none;
  margin-left: auto;
}

dialog.sheet:not(.sheet-dismissable) .sheet-close {
  display: none;
}

.sheet-section {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.sheet-section > .eyebrow,
.sheet-section > .t-body,
.sheet-section > .t-note,
.sheet-inner > .t-body,
.sheet-inner > .t-heading,
.sheet-inner > .eyebrow {
  padding: 0 4px;
}

.sheet-actions {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: minmax(0, 1fr);
  gap: 10px;
}

/* A sheet's foot: a field and its button side by side (K-TAN's chat), written after .sheet-inner,
   staying at the sheet's bottom while the content scrolls above it and fades out on its way, as it
   does under the head. The foot takes the safe area's room from the content. */
.sheet-foot {
  flex: none;
  display: flex;
  gap: 10px;
  padding: 4px var(--sheet-r) calc(var(--safe-b) + 16px) var(--sheet-l);
}

.sheet-foot > .field {
  flex: 1;
  min-width: 0;
}

.sheet-inner:has(+ .sheet-foot) {
  padding-bottom: 18px;
}

:is(dialog.sheet, .sheet-panel) > .sheet-head + .sheet-inner:has(+ .sheet-foot) {
  -webkit-mask-image: var(--sheet-fade), linear-gradient(to top, transparent, #000 14px);
  -webkit-mask-composite: source-in;
  mask-image: var(--sheet-fade), linear-gradient(to top, transparent, #000 14px);
  mask-composite: intersect;
}

/* While the keyboard is up (keyboardFit in shared/ui.js), a sheet with a foot sits on it, no taller
   than the room over it, and its foot leaves no room for the home indicator, which it covers. */
dialog.sheet.sheet-keyboard {
  bottom: var(--kb-bottom, 0px);
  max-height: calc(var(--kb-h, 100dvh) - 12px);
}

dialog.sheet.sheet-keyboard > .sheet-foot {
  padding-bottom: 10px;
}

/* Landscape sheets with a lot in them lay out in two columns. */
.sheet-cols {
  display: flex;
  flex-direction: column;
  gap: 22px;
}

.sheet-col {
  display: contents;
}

.sheet-last {
  order: 1;
}

@media (orientation: landscape) {
  .sheet-inner {
    --sheet-gap: 14px;
  }
  .sheet-cols {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    align-items: start;
    gap: 16px;
  }
  .sheet-col {
    display: flex;
    flex-direction: column;
    gap: 16px;
  }
  .sheet-last {
    order: 0;
  }
}

/* A line of feedback under an action. */
.msg {
  min-height: 1.45em;
  padding: 0 4px;
  font-size: 13px;
  line-height: 1.45;
  color: var(--muted);
}

.msg:empty {
  display: none;
}

.msg.is-error {
  color: var(--ink);
  font-weight: 600;
}

/* ---------- Launcher ---------- */

/* The header is only settings, floating at the top right over whatever scrolls under it; the
   rest of its row lets taps through. */
.launcher .bar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  justify-content: flex-end;
  pointer-events: none;
}

.launcher .bar-actions {
  pointer-events: auto;
}

/* Settings, and an update waiting for a reload. While one waits, the settings button turns white
   in place: the update icon takes the settings icon's spot (30, 7 in) and "Update" grows
   out to its left. It's the screen's one primary. */
.bar-actions {
  flex: none;
  display: flex;
  align-items: center;
}

.bar-update {
  display: inline-flex;
  align-items: center;
  flex: none;
  height: var(--tap);
  padding: 0 6px;
  border: 1px solid var(--ink);
  border-radius: var(--r-btn);
  background: var(--ink);
  color: var(--on-ink);
  font: 650 16px/1 var(--font-sans);
  white-space: nowrap;
  cursor: pointer;
  animation: bar-tint 240ms ease-out;
}

.bar-update:active {
  border-color: var(--ink-press);
  background: var(--ink-press);
}

/* The same room on both sides of the label, 10: the update glyph starts at the edge of its box. */
.bar-update-label {
  overflow: hidden;
  padding-left: 3px;
  margin-right: 10px;
  animation: bar-grow 240ms ease-out;
}

.bar-update .icon {
  flex: none;
  width: 30px;
  height: 30px;
}

/* From the settings button's glass to white. */
@keyframes bar-tint {
  from {
    border-color: var(--glass-line);
    background: var(--glass);
    color: var(--ink);
  }
}

@keyframes bar-grow {
  from {
    max-width: 0;
    padding-left: 0;
    margin-right: 0;
    opacity: 0;
  }
  to {
    max-width: 160px;
    opacity: 1;
  }
}

.home {
  position: relative;
  width: 100%;
  max-width: 640px;
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  gap: 12px; /* the suggested game sits as far above the grid as the tiles sit apart */
  padding: calc(var(--bar-total) + 22px) calc(var(--safe-r) + 16px) calc(var(--safe-b) + 72px) calc(var(--safe-l) + 16px);
  overflow-x: clip; /* the hero's bleed past the sides must not make the page scroll sideways */
}

/* The home view starts right under the status bar, with the big logo over the suggested game's
   art. The top edge is always there, from the top of the screen down to the foot of the settings
   button, and deep (.pblur-deep, above): a blur that starts at the button's foot and deepens all
   the way up, under a shade from transparent to the background at the top. The logo centres in
   the room below it, so it sits sharp. */
.launcher[data-view="home"] .home {
  padding-top: var(--safe-t);
}

.launcher[data-view="home"] .edge-top {
  height: var(--bar-total);
}

.launcher[data-view="home"] .home-mark {
  padding-top: calc(var(--bar-total) - var(--safe-t));
}

/* The logo and the note belong to the home view; first launch and the browser tab have their own
   big logo. */
.launcher:not([data-view="home"]) :is(.lead, .home-note) {
  display: none;
}

/* The suggested game's square: the logo over its art, the eyebrow and panel at its foot. As wide
   as the page in portrait, and in landscape the two-tile column, which is as tall as the screen
   allows. It holds no box of its own, in either orientation, so the art always measures from
   .home: a containing block that changed with a rotation made iOS lay the art out against the
   old one for a frame, and it jumped into place. */
.lead {
  display: flex;
  flex-direction: column;
  gap: 12px;
  aspect-ratio: 1;
}

/* The logo, large and centred over the suggested game's art, or alone while nothing is
   downloaded. It is the status: out of focus until the app is ready offline, then it comes into
   focus as games download. */
.home-mark {
  position: relative;
  z-index: 1;
  display: grid;
  place-items: center;
  flex: 1;
  min-height: 0;
}

.home-mark .logo {
  --logo-w: 120px;
  position: relative;
  z-index: 1;
}

.home-mark .logo-glow {
  --logo-w: 192px;
  position: absolute;
  z-index: 0;
}

/* The suggested game's board: a square that runs out to the screen's edges and never past them,
   so all of it is seen. In portrait it starts right under the status bar, as wide as the page
   (.home, through its safe-area padding), and fades out toward the panel; landscape is below.
   It keeps to the safe area at the top like the logo over it: rotating into portrait, iOS lays
   the page out under the status bar for a moment before it moves it below, and art drawn from
   the page's very top went up with it and then dropped into place. */
.hero {
  position: absolute;
  top: var(--safe-t);
  left: 0;
  right: 0;
  z-index: 0;
  aspect-ratio: 1;
  overflow: hidden;
  -webkit-mask-image: linear-gradient(to bottom, #000 55%, transparent 92%);
  mask-image: linear-gradient(to bottom, #000 55%, transparent 92%);
  pointer-events: none;
}

.home > section,
.lead > section {
  position: relative;
  z-index: 1;
}

.suggest {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.suggest > .eyebrow {
  text-align: center;
}

.suggest .panel-text {
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

/* Nothing downloaded yet: the same panel offers every game. The eyebrow keeps its room, so the
   layout doesn't move, and the title may take two lines rather than lose its end. */
.suggest.is-all > .eyebrow {
  visibility: hidden;
}

.suggest.is-all .panel-text {
  padding: 2px 0;
  white-space: normal;
  text-wrap: balance;
}

.games {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

/* Right under the suggested game, so the fillers never push it out of sight. */
.home-note {
  position: relative;
  z-index: 1;
  padding: 0 4px;
  text-align: center;
}

.home-note:empty {
  display: none;
}

/* Game tiles: square, radius 18, the game's own board as art inset 10 (radius 8), the title
   inside at the bottom-left over a progressive blur. Sharp means it's on this phone. */
.tile-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 12px;
}

.game-tile {
  position: relative;
  display: block;
  width: 100%;
  aspect-ratio: 1;
  padding: 0;
  border: 0;
  border-radius: var(--r-game-tile);
  background: var(--card);
  overflow: hidden;
  color: var(--ink);
  font: inherit;
  text-align: left;
  text-decoration: none;
  cursor: pointer;
}

.game-tile-art {
  position: absolute;
  inset: 10px;
  border-radius: var(--r-game-art);
  overflow: hidden;
  transition: filter 0.4s var(--ease), opacity 0.4s var(--ease);
}

.game-tile .pblur {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 76px;
}

.game-tile-name {
  position: absolute;
  left: 18px;
  right: 10px;
  bottom: 10px;
  height: 44px;
  display: flex;
  align-items: center;
  overflow: hidden;
  font: 750 18px/1.2 var(--font-display);
  letter-spacing: -0.02em;
  white-space: nowrap;
}

.game-tile-icon {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  color: var(--ink);
}

.game-tile-icon .icon {
  width: 30px;
  height: 30px;
}

.game-tile:active .game-tile-art {
  opacity: 0.85;
}

.game-tile.is-remote .game-tile-art,
.game-tile.is-waiting .game-tile-art {
  filter: blur(8px);
  opacity: 0.8;
}

/* Blurred, the art isn't clipped to its corners: Safari cuts a filtered element's blur off at its
   own rounded clip, which draws a hard outline around it. The blur spreads softly into the tile
   instead (the tile still clips at its own edge), and the art's corner cells are as round as the
   clip, so nothing shows once it's sharp. */
.game-tile:is(.is-remote, .is-waiting, .is-downloading, .is-unavailable) .game-tile-art {
  overflow: visible;
}

.game-tile.is-downloading {
  cursor: progress;
}

.game-tile.is-downloading .game-tile-art {
  filter: blur(var(--haze, 8px));
}

.game-tile.is-unavailable {
  cursor: default;
}

.game-tile.is-unavailable .game-tile-art {
  filter: blur(8px);
  opacity: 0.3;
}

.game-tile.is-unavailable .game-tile-icon {
  opacity: 0.4;
}

/* A filler for a game still to come: an empty tile labelled where a title would be, and nothing
   to press. */
.game-tile.is-soon {
  cursor: default;
}

.game-tile-soon {
  position: absolute;
  left: 18px;
  right: 10px;
  bottom: 10px;
  display: flex;
  align-items: center;
  height: 44px;
  white-space: nowrap;
}

/* Placeholders while the app gets ready. */
.ghost-tiles {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 12px;
  opacity: 0.35;
  filter: blur(3px);
}

.ghost-tiles span {
  aspect-ratio: 1;
  border-radius: var(--r-game-tile);
  background: var(--card);
}

/* First launch and the browser tab: a big logo, a title, a few lines. */
.intro {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 32px;
  text-align: center;
}

.intro-mark {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
  margin-top: 12px;
}

.intro-logo {
  position: relative;
  display: grid;
  place-items: center;
}

.intro-mark > p {
  position: relative;
  z-index: 1;
}

/* The browser tab has no header: the logo and name lockup stands in for it. */
.launcher[data-view="install"] {
  --bar-total: calc(max(var(--safe-t), 8px) + 8px);
}

.launcher[data-view="install"] .bar {
  display: none;
}

.intro-mark .logo-glow {
  --logo-w: 240px;
}

.intro-mark .logo:not(.logo-glow) {
  position: relative;
  z-index: 1;
}

.intro-text {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  max-width: 320px;
  margin: 0 auto;
  text-align: center;
}

.intro-side > .t-note {
  padding-top: 8px;
  text-align: center;
}

.intro-side > .install-version {
  padding-top: 8px;
  text-align: center;
}

.intro-side {
  display: flex;
  flex-direction: column;
  gap: 12px;
  width: 100%;
  text-align: left;
}

.steps {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.install .logo-lg {
  --logo-w: 176px;
}

/* The field draws the browser tab's logo; the logo itself only holds its place. */
.install .intro-logo .logo:not(.logo-glow) {
  visibility: hidden;
}

.install .intro-mark {
  gap: 20px;
}

/* The steps' heading, centred over them: a little over a title, well under the name above. */
.install .intro-side > .t-title {
  padding: 0 4px 4px;
  font-size: 32px;
  text-align: center;
}

/* Landscape: everything runs left to right in one strip that scrolls sideways, from the top of the
   screen down, with no header band: settings floats at the top right over it. The strip starts
   with the suggested game laid out as in portrait, in a column two tiles wide: its art bleeding in
   from the top, the logo large in the middle of it, the eyebrow and panel at its foot. The other
   tiles and the fillers follow in two rows, or more on a taller screen (below). */
@media (orientation: landscape) {
  .launcher {
    /* The rows of tiles and the gaps between them fill the height, and the suggested game's column
       is as wide as they are tall. */
    --rows: 2;
    --tile: max(96px, calc((100dvh - max(var(--safe-t), 16px) - max(var(--safe-b), 16px) - (var(--rows) - 1) * 12px) / var(--rows)));
    --lead: calc(var(--rows) * var(--tile) + (var(--rows) - 1) * 12px);
  }
  .home {
    max-width: none;
    padding: calc(var(--bar-total) + 8px) calc(var(--safe-r) + 24px) calc(var(--safe-b) + 56px) calc(var(--safe-l) + 24px);
  }
  .home > .intro {
    width: 100%;
    max-width: 920px;
    margin-left: auto;
    margin-right: auto;
  }

  /* The page itself never scrolls: the body is the sideways scroller, while everything fixed (the
     header, the edges) stays put. (The root's overflow keeps the body's from passing to the
     viewport.) */
  :root:has(> .launcher[data-view="home"]) {
    overflow: hidden;
  }
  .launcher[data-view="home"] {
    position: fixed;
    inset: 0;
    overflow: auto hidden;
  }
  .launcher[data-view="home"] :is(.edge-top, .edge-bottom) {
    display: none;
  }
  .launcher[data-view="home"] :is(.edge-left, .edge-right) {
    display: block;
  }
  .launcher[data-view="home"] .home {
    display: grid;
    grid-template: minmax(0, 1fr) auto / var(--lead) auto;
    gap: 0 12px;
    width: max-content;
    min-width: 100%;
    height: 100%;
    margin: 0;
    /* The strip ends with room for the floating settings button (44, a tile gap before it), so
       the last tiles clear it once scrolled to the end. */
    padding: max(var(--safe-t), 16px) calc(var(--safe-r) + 16px + 44px + 12px) max(var(--safe-b), 16px) calc(var(--safe-l) + 16px);
  }
  /* The column is as tall as the rows of tiles and their gaps, so it is square; a note under it
     takes its room from the logo. No top blur here, so the logo centres in the whole of it. */
  .lead {
    grid-area: 1 / 1;
    aspect-ratio: auto;
    min-height: 0;
  }
  .launcher[data-view="home"] .home-mark {
    padding-top: 0;
  }
  .home-note {
    grid-area: 2 / 1;
    padding-top: 20px;
  }
  .games {
    grid-area: 1 / 2 / -1 / 3;
    align-self: end;
  }
  /* The art runs from the screen's left edge (through the safe area) to the column's far gutter,
     as a square centred on the screen's height, never shorter than the screen, so it fills the
     height and a notch's wider safe area only spills it a little past the top and bottom. It fades
     out to the right, toward the tiles, the way it fades toward the panel in portrait, and to the
     left, to dark under the left edge's blur. */
  .hero {
    --hero-side: max(100dvh, calc(var(--safe-l) + var(--lead) + 32px));
    top: calc(50% - var(--hero-side) / 2);
    left: 0;
    right: auto;
    width: var(--hero-side);
    -webkit-mask-image: linear-gradient(to right, transparent, #000 calc(var(--safe-l) + 16px), #000 55%, transparent 92%);
    mask-image: linear-gradient(to right, transparent, #000 calc(var(--safe-l) + 16px), #000 55%, transparent 92%);
  }
  .tile-grid {
    grid-template-columns: none;
    grid-template-rows: repeat(var(--rows), var(--tile));
    grid-auto-flow: column;
    grid-auto-columns: var(--tile);
  }

  .intro {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    align-items: center;
    gap: 24px 48px;
    text-align: left;
    min-height: calc(100dvh - var(--bar-total) - 8px - var(--safe-b) - 56px);
  }
  .intro-mark {
    grid-area: mark;
    margin: 0;
  }
  .prepare {
    grid-template-areas: "mark side";
  }
  .prepare .intro-side {
    grid-area: side;
  }
  /* The browser tab fits the screen with nothing to scroll, so it has no edges to blur and keeps
     none of their room: the lockup and the steps share the whole height. The steps take the wider
     column, so their lines rarely wrap, and the name never wraps under the logo. */
  .launcher[data-view="install"] :is(.edge-top, .edge-bottom) {
    display: none;
  }
  .launcher[data-view="install"] .home {
    padding-top: max(var(--safe-t), 16px);
    padding-bottom: max(var(--safe-b), 16px);
  }
  .install {
    grid-template-columns: minmax(max-content, 0.85fr) minmax(0, 1.15fr);
    grid-template-areas: "mark side";
    align-content: center;
    column-gap: 40px;
    min-height: calc(100dvh - max(var(--safe-t), 16px) - max(var(--safe-b), 16px));
  }
  .install .intro-side {
    grid-area: side;
    gap: 10px;
  }
  .install .intro-side > .t-title,
  .install .intro-side > .install-version {
    padding-bottom: 0;
    padding-top: 0;
  }
  .install .intro-side > .t-note {
    text-align: center;
  }
  .intro-text {
    align-items: flex-start;
    margin: 0;
    text-align: left;
  }
  .intro-side > .t-note {
    text-align: left;
  }
  .intro .logo-lg {
    --logo-w: 120px;
  }
  /* The browser tab's logo stays as large as in portrait while the height allows (the lockup is
     the logo, 1.19 times as tall as wide, a 20 gap and the 52 name): the field runs on the logo's
     grid, so a smaller logo would fill the wide screen with twice the pixels to animate. */
  .install .logo-lg {
    --logo-w: clamp(120px, calc((100dvh - max(var(--safe-t), 16px) - max(var(--safe-b), 16px) - 72px) * 0.84), 176px);
  }
}

/* A screen tall enough that two rows would make the tiles too big (an iPad) takes a third row,
   and a taller one a fourth: a phone's landscape height is 440 at most, an iPad's 744 or more. */
@media (orientation: landscape) and (min-height: 600px) {
  .launcher {
    --rows: 3;
  }
}

@media (orientation: landscape) and (min-height: 1100px) {
  .launcher {
    --rows: 4;
  }
}

/* ---------- Board art (shared/game-art.js) ---------- */

/* The art's own gaps are measured against its frame (a tile's art box or the hero), and what is
   inside the art against the art. */
.game-tile-art,
.hero {
  container-type: inline-size;
}

.art {
  display: grid;
  width: 100%;
  height: 100%;
  container-type: inline-size;
}

.art > span,
.art-box > span,
.art-arena > span {
  display: grid;
  place-items: center;
  min-width: 0;
  min-height: 0;
  border-radius: var(--r-pixel);
}

.art-sudoku {
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 1.6cqi;
  aspect-ratio: 1;
  height: auto;
}

.art > .art-box,
.art > .art-arena {
  place-items: stretch;
  border-radius: 0;
}

.art-box {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: max(1px, 0.6cqi);
}

.art-box > span {
  aspect-ratio: 1;
  background: #121212;
  color: #8f8f8f;
  font: 500 6.4cqi/1 var(--font-sans);
}

.art-box > span.is-given {
  background: var(--raised);
  color: var(--ink);
  font-weight: 800;
}

.art-tanks {
  place-items: center;
}

.art-arena {
  position: relative;
  display: grid;
  grid-template-columns: repeat(15, minmax(0, 1fr));
  gap: max(1px, 0.5cqi);
  width: 100%;
}

.art-arena > span {
  aspect-ratio: 1;
  background: #1f1f1f;
}

.art-arena > .is-wall { background: #5a5a5a; }

/* The tanks, drawn over the arena in cells (one SVG unit each). */
.art-tanks-layer {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
}

.art-tank {
  --tank-tread: #4a4a4a;
  --tank-hull: #8f8f8f;
  --tank-top: #b5b5b5;
}

.art-tank.is-you {
  --tank-tread: #7a7a7a;
  --tank-hull: #cfcfcf;
  --tank-top: var(--ink);
}

.art-tread { fill: var(--tank-tread); }
.art-hull { fill: var(--tank-hull); }
.art-barrel,
.art-turret { fill: var(--tank-top); }

/* Ktan: the island in greys, one per resource, on a sea of the empty cells' grey. Roads and
   settlements sit in the gaps between tiles, yours white like your tank. */
.art-ktan {
  place-items: center;
}

.art-island {
  width: 100%;
  aspect-ratio: 1;
  overflow: visible;
}

.art-hex.is-sea { fill: #121212; }
.art-hex.is-wood { fill: #2a2a2a; }
.art-hex.is-ore { fill: #363636; }
.art-hex.is-wool { fill: #434343; }
.art-hex.is-brick { fill: #4f4f4f; }
.art-hex.is-grain { fill: #5c5c5c; }
.art-hex.is-desert { fill: #6b6b6b; }

.art-token circle { fill: #121212; }

.art-token text {
  fill: var(--muted);
  font: 600 0.42px/1 var(--font-sans);
}

.art-token.is-hot text {
  fill: var(--ink);
  font-weight: 800;
}

.art-robber { fill: #121212; }

.art-player {
  --piece: #a8a8a8;
}

.art-player.is-halo {
  --piece: #0b0b0b;
  --road: 0.27px;
  --town-edge: 0.2px;
}

.art-player.is-you {
  --piece: var(--ink);
}

.art-road {
  stroke: var(--piece);
  stroke-width: var(--road, 0.15px);
  stroke-linecap: round;
}

.art-town {
  fill: var(--piece);
  stroke: var(--piece);
  stroke-width: var(--town-edge, 0.08px);
  stroke-linejoin: round;
  paint-order: stroke;
}

.art-pixels {
  grid-template-columns: repeat(6, minmax(0, 1fr));
  gap: 4cqi;
  aspect-ratio: 1;
}

.art-pixels > span {
  aspect-ratio: 1;
}

.art-pixels > .p0 { background: var(--raised); }
.art-pixels > .p1 { background: #2c2c2c; }
.art-pixels > .p2 { background: var(--quiet); }
.art-pixels > .p3 { background: #8f8f8f; }
.art-pixels > .p4 { background: var(--ink); }

/* The hero is the same art, large and dimmed, behind the top of the launcher. */
.art-hero {
  height: auto;
  opacity: 0.45;
}

.art-hero.art-sudoku {
  gap: 1.2cqi;
}

/* ---------- Motion ---------- */

/* Blur out and blur in: the one way something steps away and comes back in place. Blur out fades
   it while it goes soft, and leaves it untouchable; blur in brings it back sharp. blurOut() and
   blurIn() in shared/ui.js swap the classes. */
@keyframes blur-out {
  from { opacity: 1; filter: blur(0); }
  to { opacity: 0; filter: blur(var(--blur-fade)); }
}

@keyframes blur-in {
  from { opacity: 0; filter: blur(var(--blur-fade)); }
  to { opacity: 1; filter: blur(0); }
}

.blur-out {
  animation: blur-out var(--t-blur) var(--ease) both;
  pointer-events: none;
}

.blur-in {
  animation: blur-in var(--t-blur) var(--ease) both;
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
  }
}
