/* povrayer "optical bench" design system. Shared by the UI page (body.ui)
   and the REPL page (body.repl). Monospace only, no webfonts (COEP rule).

   Identity rules this file enforces:
   - --accent appears ONLY on: the render/run action, the REPL prompt + the
     result figures' provenance edge, links, the focus ring + progress bar,
     and the wordmark (orb + "pov" span). Everything else is grey.
   - Square corners everywhere; the .orb brand mark is the only circle.
   - The rendered image is the hero: the only shadow, the only textured
     (checker mat) background.
   - Every transition/animation declaration lives inside
     @media (prefers-reduced-motion: no-preference); outside it, states
     still apply, instantly. */

/* Registering --repl-indent as a <length> absolutizes the ch at the element
   that declares it (#scrollback, 13px), so the 12px-type error/info blocks
   inherit the same pixel indent instead of recomputing 4ch at their own
   font size. Unsupported browsers fall back to per-use-site ch (~2px drift). */
@property --repl-indent {
  syntax: '<length>';
  inherits: true;
  initial-value: 0px;
}

:root {
  --bg: #0b0d10; /* page */
  --panel: #14171c; /* inputs, log, plates, error box bg */
  --border: #2b303a; /* decorative hairlines on non-interactive panels */
  --border-strong: #606a7a; /* form-control + secondary-button boundaries (WCAG 1.4.11) */
  --text: #d7dbe0;
  --dim: #98a1ab;
  --accent: #ffd23f;
  --accent-ink: #0b0d10; /* text on accent buttons */
  --error: #ff6b5e;
  --mat-a: #181b21; /* checker mat squares (decorative) */
  --mat-b: #121419;

  /* Syntax-highlight palette for the editor overlay (web/highlight.js). Muted,
     low-saturation, harmonious on the near-black bg: a cool blue->teal->green
     sweep for keyword/builtin/string, control-flow violet for directives, one
     desaturated warm rose for numbers, slate grey for comments. None are
     yellow/gold (so they never fight --accent) and none coral (so they stay
     clear of --error). All are clearly distinct from --text and --dim so UI
     chrome never reads as syntax. */
  --syn-comment: #7a8493; /* slate grey, italic; recedes by design (4.75:1 on --panel, the quietest token that still clears WCAG AA) */
  --syn-keyword: #7fa6c9; /* desaturated steel blue, structural vocabulary */
  --syn-builtin: #5fb3a1; /* muted sea-teal, math/vector fns + x/y/z/clock/pi */
  --syn-string: #9bb56b; /* soft sage/olive, strings + include paths */
  --syn-number: #c890a0; /* dusty rose, dense numerics */
  --syn-directive: #a98fd0; /* muted violet, #declare/#for/#if/#macro */

  /* Type scale: 4 steps, monospace only. */
  --fs-brand: 20px; /* / 1.2, w700, wordmark only */
  --fs-body: 13px; /* / 1.45, editor, REPL src+input, controls, status */
  --fs-out: 12px; /* / 1.5, log, error box, figcaptions */
  --fs-chrome: 11px; /* / 1.4, footer, #repl-status */

  --t-fast: 120ms; /* color/border on chrome */
  --t-med: 200ms; /* progress width, image opacity */
  --t-slow: 260ms; /* hero settle + completion payoff */
  --ease-out: cubic-bezier(
    0.2,
    0,
    0,
    1
  ); /* decelerate, no overshoot (y stays in [0,1], no bounce) */

  /* The renderer's own alpha-mat convention; shows through transparent
     pixels. Use as: background: var(--mat) 0 0 / 16px 16px */
  --mat: repeating-conic-gradient(var(--mat-a) 0% 25%, var(--mat-b) 0% 50%);
}

* {
  box-sizing: border-box;
}

/* Author display rules below must never beat the `hidden` attribute. */
[hidden] {
  display: none !important;
}

/* Visually-hidden but exposed to the accessibility tree (e.g. the editor's
   Esc-then-Tab escape instructions, referenced via aria-describedby). The clip
   pattern keeps it off-screen without dropping it from the a11y tree the way
   display:none would. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
}

html,
body {
  margin: 0;
  padding: 0;
  /* Pin text to its declared size on phones: iOS Safari and Android Chrome
     "text autosizing" otherwise inflates large text blocks past the size we
     set, most visibly the editor textarea and its <pre> syntax overlay, which
     reads as "the editor font is huge". 100% (not `none`) leaves user
     pinch-zoom intact, so it's not an accessibility regression. */
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
}

body {
  background: var(--bg);
  color: var(--text);
  font-family:
    ui-monospace, 'SF Mono', Menlo, Consolas, 'Cascadia Mono', 'DejaVu Sans Mono',
    'Liberation Mono', monospace;
  font-variant-ligatures: none;
  font-size: var(--fs-body);
  line-height: 1.45;
}

a {
  color: var(--accent);
}

pre {
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  margin: 0;
}

.error {
  color: var(--error);
}

.info {
  color: var(--dim);
}

/* ---- chrome shared by both pages ---- */

header {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px;
  border-bottom: 1px solid var(--border);
}

header h1 {
  margin: 0;
  font-size: var(--fs-brand);
  line-height: 1.2;
  font-weight: 700;
  white-space: nowrap;
}

/* Shared by both pages; the REPL nav holds a bordered button (source toggle)
   next to an underlined link, which need a defined gap to read as siblings
   rather than a collision. Flex centering also keeps the 28px button and the
   one-line link on a common vertical axis. */
header nav {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: 12px;
}

/* Brand mark: a ray-traced sphere. The only circle in the interface. */
.orb {
  display: inline-block;
  width: 0.8em;
  height: 0.8em;
  border-radius: 50%;
  background: radial-gradient(circle at 33% 28%, #fff 0%, var(--accent) 38%, #15151a 78%);
}

h1 .orb {
  margin-right: 8px;
  vertical-align: -0.08em; /* optically centers the orb on the lowercase wordmark */
}

.brand-accent {
  color: var(--accent);
}

.brand-page {
  color: var(--dim);
}

footer {
  /* env() is 0 in normal browsing contexts; it pads above the iOS home
     indicator when standalone/viewport-fit=cover hands out real insets. */
  padding: 8px 12px calc(8px + env(safe-area-inset-bottom, 0px));
  border-top: 1px solid var(--border);
  color: var(--dim);
  font-size: var(--fs-chrome);
  line-height: 1.4;
}

#iso-warning {
  width: 100%;
  padding: 8px 12px;
  background: #5c1e1e;
  color: #ffd8d3;
}

/* ---- form controls ---- */

button,
input,
select,
textarea {
  font: inherit;
  border-radius: 0;
}

input,
select,
textarea {
  color: var(--text);
  background: var(--panel);
  border: 1px solid var(--border-strong);
  padding: 4px 8px;
}

input,
select {
  height: 28px; /* the shared control height; one row datum, no wobble */
}

::placeholder {
  color: var(--dim);
  opacity: 1;
}

/* Field polish, applied app-wide so every input/select reads as one family.
   Selects shed the OS-native dropdown chrome (rounded, platform-specific) for a
   flat square field with our own chevron, matching the optical-bench look. The
   chevron stroke is the --dim (#98a1ab) label grey, hardcoded because a data-URI
   can't read a CSS var, so it stays greyscale and matches the field's dim label. */
select {
  appearance: none;
  -webkit-appearance: none;
  padding-right: 26px;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%2398a1ab' stroke-width='1.5'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 9px center;
}

/* Drop the native number spinners: they vary per browser and clutter the tidy
   size / threads / frames fields. Keyboard arrows and the step attr still work. */
input[type='number'] {
  appearance: textfield;
  -webkit-appearance: textfield;
}

input[type='number']::-webkit-outer-spin-button,
input[type='number']::-webkit-inner-spin-button {
  -webkit-appearance: none;
  margin: 0;
}

/* Quiet hover affordance: brighten the boundary one step (border-strong -> dim)
   so a field invites interaction without borrowing the accent. Instant, no
   transition (motion stays confined to the gated census). */
input:hover:not(:disabled):not(:focus),
select:hover:not(:disabled):not(:focus) {
  border-color: var(--dim);
}

/* Secondary button (Cancel, zoom toggle, ...): quiet until hovered. */
button {
  background: transparent;
  color: var(--text);
  border: 1px solid var(--border-strong);
  height: 28px;
  padding: 0 12px;
  cursor: pointer;
}

/* Hard invert, deliberately instant (no transition on the invert). */
button:hover:not(:disabled) {
  background: var(--text);
  border-color: var(--text);
  color: var(--bg);
}

button:disabled {
  color: var(--dim);
  cursor: default;
}

/* A button that reads as a link (the restore-scene undo sits inline in a dim
   note). Gold like a link, no border/fill, no tap-target growth, so it flows
   inline rather than rendering as a grey box. Accent-on-links is sanctioned
   (identity rule 1). */
.linkish {
  height: auto;
  min-height: 0;
  padding: 0;
  border: 0;
  background: none;
  color: var(--accent);
  text-decoration: underline;
}

.linkish:hover:not(:disabled) {
  background: none;
  color: var(--accent);
  text-decoration: none;
}

/* Primary action: the only filled-accent, only uppercase element. */
#render-btn,
#run-btn {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-ink);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.06em;
}

#render-btn:hover:not(:disabled),
#run-btn:hover:not(:disabled) {
  background: #fff;
  border-color: #fff;
  color: var(--accent-ink);
}

#render-btn:disabled,
#run-btn:disabled {
  background: var(--panel);
  border-color: var(--border-strong);
  color: var(--dim);
}

/* Focus ring. Never outline: none. */
button:focus-visible,
input:focus,
select:focus,
textarea:focus {
  outline: 2px solid var(--accent);
  outline-offset: -1px;
}

a:focus-visible,
summary:focus-visible,
[tabindex]:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* The two primary CTAs are accent-filled, so an accent ring is invisible on
   them (accent-on-accent). Use a dark inset ring (accent-ink on accent) so the
   focus indicator stays high-contrast on the most important controls. */
#render-btn:focus-visible,
#run-btn:focus-visible {
  outline-color: var(--accent-ink);
  outline-offset: -4px;
}

/* A pressed toggle inverts to a light fill (see .toggle-btn[aria-pressed='true']
   below), so the accent-gold ring all but vanishes on it (~1:1). Mirror the CTA
   treatment with the dark ink ring (~14:1 on the light fill). still/loop default
   to pressed, so this is the ring keyboard users actually land on. (#live-toggle
   opts back into the gold ring: its quiet on-state keeps the dark bg.) */
.toggle-btn[aria-pressed='true']:focus-visible {
  outline-color: var(--accent-ink);
}

label {
  color: var(--dim);
}

/* ---- status indicator (#status / #repl-status) ----
   JS contract: data-state is one of idle|busy|done|error|cancelled. */

#status,
#repl-status {
  color: var(--dim);
}

#status[data-state='done'],
#repl-status[data-state='done'] {
  color: var(--text); /* the payoff line stops camouflaging as a label */
}

#status[data-state='error'],
#repl-status[data-state='error'],
#status.error {
  color: var(--error);
}

/* Draft-preview status: calm and accent-free. It is a held, low-res preview,
   not the full render payoff, so it reads like a secondary chip. */
#status[data-state='draft'] {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  color: var(--dim);
}

#status[data-state='draft']::before {
  content: '';
  flex: none;
  width: 7px;
  height: 7px;
  border: 1px solid var(--border-strong);
  background: var(--border);
}

/* Pulsing trailing ellipsis: alive is distinguishable from hung. The busy
   text already reads "rendering…"; this appends the animated tail. The pulse
   animation itself is gated in the motion block below. */
#status[data-state='busy']::after,
#repl-status[data-state='busy']::after,
figure.result.pending figcaption::after {
  content: '…';
}

/* ---- progress bar (#progress / #repl-progress) ----
   JS contract: `hidden` while idle. Indeterminate sweep by default; once the
   first percent event arrives, set class .determinate (or
   data-mode="determinate") and drive --pct (0-100, monotonic per render). */

#progress,
#repl-progress {
  position: relative;
  height: 2px;
  overflow: hidden;
}

#progress::before,
#repl-progress::before {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 40%;
  background: var(--accent);
}

#progress.determinate::before,
#progress[data-mode='determinate']::before,
#repl-progress.determinate::before,
#repl-progress[data-mode='determinate']::before {
  width: calc(var(--pct, 0) * 1%);
}

/* ---- shared artifact states ---- */

/* Outdated render: applied to #output + #download-btn on error and during
   re-render; removed on success and on cancel (kept image is legitimate). */
.stale {
  opacity: 0.45;
  filter: saturate(0.6);
}

/* 1:1 zoom; the plate/figure scrolls. */
.zoom-1x,
#output.zoom-1x,
figure.result img.preview.zoom-1x {
  max-width: none;
  image-rendering: pixelated;
  cursor: zoom-out;
}

/* ---- disclosure ---- */

summary {
  color: var(--dim);
  font-size: var(--fs-out);
  line-height: 1.5;
  cursor: pointer; /* marker stays the default UA triangle */
}

/* ---- UI page (body.ui) ---- */

body.ui {
  min-height: 100dvh;
  display: flex;
  flex-direction: column; /* pins the footer to the viewport bottom */
}

body.ui main {
  flex: 1;
  display: grid;
  grid-template-columns: 1fr;
  gap: 16px;
  padding: 16px;
}

/* Grid items default to min-width auto (= min-content), so a long example
   title in the select would push the column past a narrow viewport. */
body.ui main > * {
  min-width: 0;
}

/* ---- example browser (editable-combobox popover replacing the select) ----
   Tokens only: --panel/--bg/--border/--border-strong/--text/--dim, --accent on
   the focus ring + the one footer link. Square corners, no shadow, no gradient;
   the rendered image stays the only shadow on the page. */

/* Example field: the shared column datum (28px + 8px, same as #status). The
   relative box anchors the absolutely-positioned popover. */
#example-field {
  position: relative;
  display: flex;
  align-items: center;
  gap: 6px;
  /* min-height, not a hard height: the @media (pointer: coarse) block grows
     #example-trigger to a 44px tap target, and a locked 28px row would clip it
     and eat the margin-bottom. align-items:center keeps the taller row centered. */
  min-height: 28px;
  margin-bottom: 8px;
}

#example-label {
  color: var(--dim);
}

/* Secondary-button treatment; truncates a long scene title like the old select. */
#example-trigger {
  flex: 1;
  min-width: 0;
  display: inline-flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  text-align: left;
}

#gallery-btn {
  flex: none;
}

.ex-trigger-text {
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

.ex-caret {
  flex: none;
  color: var(--dim);
}

#scene-toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px;
  margin-bottom: 8px;
  padding: 4px 0;
  border-block: 1px solid var(--border);
}

.scene-dirty {
  color: var(--dim);
  font-size: var(--fs-chrome);
  min-width: 5em;
  margin-right: auto;
  text-transform: uppercase;
  letter-spacing: 0.06em;
}

.scene-dirty[data-dirty='true'] {
  color: var(--text);
}

#scene-toolbar button {
  height: 24px;
  padding: 0 8px;
  font-size: var(--fs-chrome);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

/* Opaque popover panel: strong hairline, square, no shadow. z-index clears the
   textarea's z-index:1. */
#example-browser {
  position: absolute;
  top: calc(100% + 4px);
  left: 0;
  right: 0;
  z-index: 20;
  display: flex;
  flex-direction: column;
  /* The px cap is tuned so the DEFAULT list (one expanded category of five
     scenes) cuts off at a row border instead of mid-glyph; other expansion
     states still clip mid-row, which the strong .ex-attr edge absorbs. */
  max-height: min(60vh, 448px);
  background: var(--panel);
  border: 1px solid var(--border-strong);
}

.ex-search-row {
  flex: none;
  display: grid;
  grid-template-columns: minmax(0, 1fr) 28px;
  gap: 6px;
  margin: 8px 8px 6px;
}

#example-search {
  width: 100%;
  min-width: 0;
}

#example-clear {
  width: 28px;
  height: 28px;
  padding: 0;
  border: 1px solid var(--border);
  background: var(--panel-2);
  color: var(--dim);
  font-size: var(--fs-ui);
  line-height: 1;
}

#example-clear:hover {
  color: var(--text);
  border-color: var(--border-strong);
}

.ex-filter-row {
  flex: none;
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 6px;
  margin: 0 8px 8px;
}

.ex-filter-row select {
  min-width: 0;
  width: 100%;
  height: 26px;
  padding: 0 18px 0 6px;
  font-size: var(--fs-chrome);
}

#example-listbox {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  /* Flicking the scene list never scroll-chains the page behind the popover
     (the open panel owns the gesture); momentum scroll for older iOS. */
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
}

/* Category head: a disclosure toggle (role=button, aria-expanded). Grey,
   square, accent-free chrome (identity rule 1: the accordion never borrows the
   render accent). Sticky so the section label stays pinned while its rows
   scroll under it. */
.ex-group-head {
  position: sticky;
  top: 0;
  z-index: 1; /* over the scrolling rows */
  display: flex;
  align-items: center;
  gap: 8px;
  background: var(--panel);
  color: var(--dim);
  font-size: var(--fs-out);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  padding: 6px 8px;
  cursor: pointer;
  user-select: none;
  border-bottom: 1px solid var(--border);
}

.ex-group-label {
  flex: 1;
  min-width: 0;
}

/* Disclosure caret: a grey triangle, square, NO accent. Collapsed points right;
   the expanded indicator is a rotation under no-preference and a glyph swap
   under reduced motion (both in the motion blocks below). */
.ex-group-caret {
  flex: none;
  color: var(--dim);
  font-size: 0.8em;
  line-height: 1;
}

.ex-group-caret::before {
  content: '\25b8'; /* right-pointing small triangle */
}

/* Scene-count tally: a small grey square chip, no accent, no rounding. */
.ex-group-count {
  flex: none;
  color: var(--dim);
  font-size: var(--fs-chrome);
  padding: 0 5px;
  border: 1px solid var(--border-strong);
}

/* Head hover/roving share the quiet grey emphasis the rows use (no accent); the
   active head also carries the square inset edge marker, same as .ex-option. */
.ex-group-head:hover,
.ex-group-head.is-active {
  background: var(--border);
  color: var(--text);
}

.ex-group-head.is-active {
  box-shadow: inset 3px 0 0 var(--border-strong);
}

.ex-option {
  display: grid;
  grid-template-columns: 64px minmax(0, 1fr);
  gap: 1px;
  column-gap: 8px;
  align-items: center;
  padding: 6px 8px;
  cursor: pointer;
  border-bottom: 1px solid var(--border);
}

.ex-thumb {
  width: 64px;
  height: 48px;
  object-fit: cover;
  border: 1px solid var(--border-strong);
  background: var(--bg);
}

.ex-text {
  min-width: 0;
  display: grid;
  gap: 1px;
}

.ex-title {
  color: var(--text);
}

.ex-desc {
  color: var(--dim);
  font-size: var(--fs-out);
}

.ex-by {
  color: var(--dim);
  font-size: var(--fs-chrome);
}

/* Active (roving aria-activedescendant) + hover share the quiet grey fill, NOT
   the accent. The loaded scene is bolded by a data attribute, never coloured.
   The active option additionally carries a square grey inset edge as a
   non-color affordance: the bare --border fill alone is only 1.36:1 vs the
   panel (fails WCAG 1.4.11), so the marker (3.28:1, square, no accent) is what
   actually distinguishes the roving option. Kept below the pressed-toggle fill
   so it never reads as "selected/loaded". */
.ex-option:hover {
  background: var(--border);
}
.ex-option.is-active {
  background: var(--border);
  box-shadow: inset 3px 0 0 var(--border-strong);
}

.ex-option[data-loaded='true'] .ex-title {
  font-weight: 700;
}

.ex-empty {
  color: var(--dim);
  font-size: var(--fs-out);
  padding: 8px;
}

/* Footer: attribution text + the lone accent link (links are already accent).
   The top edge is --border-strong, not the row hairline: the max-height'd list
   usually clips a row mid-glyph right above it, and a strong boundary makes
   that cut read as "panel ends here, more scrolls beneath" rather than a
   rendering bug. (No shadow/fade: the rendered image owns the only shadow.) */
.ex-attr {
  flex: none;
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  padding: 8px;
  border-top: 1px solid var(--border-strong);
  color: var(--dim);
  font-size: var(--fs-out);
}

.ex-attr-src {
  color: var(--accent);
}

/* Gutter correctness contract: the textarea has wrap="off" (one source line
   = one visual row), gutter/editor share font, line-height, and padding-top,
   and JS mirrors the textarea's scrollTop onto the gutter. */
#editor-wrap {
  display: flex;
  align-items: stretch;
  background: var(--panel);
  border: 1px solid var(--border-strong);
}

#gutter {
  flex: none;
  min-width: 3ch;
  padding: 8px;
  border-right: 1px solid var(--border);
  color: var(--dim);
  text-align: right;
  white-space: pre;
  line-height: 1.45;
  overflow: hidden; /* scroll position driven from the textarea via JS */
  user-select: none;
}

#gutter:empty {
  display: none;
}

#editor-wrap #editor {
  border: none; /* the wrap carries the control boundary */
  /* The textarea text goes transparent so the colored overlay behind it shows
     through; the caret and the native ::selection stay in this (z-index:1)
     layer so both remain visible. */
  position: relative;
  z-index: 1;
  background: transparent;
  color: transparent;
  caret-color: var(--text);
}

/* Selected glyphs must stay transparent too: without an explicit ::selection
   color the UA repaints them in a visible color, doubling over the syntax
   overlay. Show only a translucent band; the colored layer reads through it. */
#editor::selection {
  color: transparent;
  -webkit-text-fill-color: transparent;
  background: rgba(122, 132, 147, 0.4);
}
#editor::-moz-selection {
  color: transparent;
  -webkit-text-fill-color: transparent;
  background: rgba(122, 132, 147, 0.4);
}

#editor {
  display: block;
  flex: 1;
  min-width: 0;
  width: 100%;
  min-height: 48vh;
  resize: vertical;
  tab-size: 2;
  padding: 8px;
  line-height: 1.45;
  overflow: auto; /* wrap="off": horizontal scroll is the honest trade */
}

/* Syntax overlay: web/highlight.js paints a colored layer that sits behind the
   transparent-text textarea inside #editor-stack. Absolutely positioned so it
   never affects layout (the textarea alone sizes the stack via min-height +
   resize, and the overlay fills it via inset:0). It never scrolls itself; JS
   keeps it aligned by translating #editor-code (a GPU transform, so big scenes
   scroll without repainting the whole layer) and repaints its text on every
   input. Every metric on #editor-code MUST match #editor exactly (font,
   line-height, padding, tab-size, white-space, letter-spacing) or the colored
   text drifts off the caret. */
#editor-stack {
  position: relative;
  flex: 1;
  min-width: 0;
  display: flex;
}

#editor-highlight {
  position: absolute;
  inset: 0;
  margin: 0;
  padding: 0; /* padding moves to #editor-code so its translate() matches the textarea */
  border: 0;
  overflow: hidden; /* the clip; JS translates #editor-code, this never scrolls */
  white-space: pre; /* matches the textarea's wrap="off" */
  pointer-events: none;
  color: var(--text);
  font-family: inherit; /* override the <pre> UA monospace; match the textarea */
  font-size: inherit;
  line-height: 1.45;
  letter-spacing: normal;
  tab-size: 2;
  contain: content; /* isolate paint/layout so scroll-syncing stays cheap */
}

#editor-code {
  display: block; /* a block box so the padding + scroll-sync transform apply */
  padding: 8px; /* the textarea padding, moved here from #editor-highlight */
  font: inherit; /* override the <code> UA monospace */
  white-space: pre;
  will-change: transform; /* compositor layer: the scroll-sync translate is GPU-only */
}

/* Find / go-to-line bar: a slim strip pinned over the editor's top edge inside
   #editor-stack. Same opaque-panel treatment as the other editor overlays
   (--panel fill, strong hairline, square, no shadow); z-index clears the
   textarea (1) and the completion popup (5). The dim counter ("3/17") is the
   only chrome beside the input. */
#find-bar {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: 7;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 4px 8px;
  background: var(--panel);
  border-bottom: 1px solid var(--border-strong);
}

#find-input {
  flex: 1;
  min-width: 0;
}

#find-count {
  flex: none;
  color: var(--dim);
  font-size: var(--fs-out);
  font-variant-numeric: tabular-nums;
}

/* The error-line marker: a full-width red-tinted band behind the colored text at
   the line a failed render blamed. It lives inside the clipping #editor-highlight
   and ui.js translates it with the editor scroll, so it tracks the line. The faint
   tint stays under the syntax colors; the 2px --error edge is the strong cue. */
#error-line {
  position: absolute;
  left: 0;
  right: 0;
  background: rgba(255, 107, 94, 0.12);
  border-left: 2px solid var(--error);
  pointer-events: none;
}

#error-line[hidden] {
  display: none;
}

/* When the error box carries a line reference, it's a click-to-jump affordance. */
#error.has-line {
  cursor: pointer;
}

/* Token colors: only the language vocabulary lights up. Punctuation, operators,
   vectors (< >), and the user's own #declare/#macro names stay --text so dense
   scenes don't turn garish. */
/* Shared by the editor overlay and the REPL source slide-out (#source-code):
   both render highlight()'s tok-* spans, so the palette must reach both. The
   palette is scoped to these two roots so plain UI chrome never reads as syntax. */
#editor-highlight .tok-comment,
#source-code .tok-comment {
  color: var(--syn-comment);
  font-style: italic;
}
#editor-highlight .tok-keyword,
#source-code .tok-keyword {
  color: var(--syn-keyword);
}
#editor-highlight .tok-builtin,
#source-code .tok-builtin {
  color: var(--syn-builtin);
}
#editor-highlight .tok-string,
#source-code .tok-string {
  color: var(--syn-string);
}
#editor-highlight .tok-number,
#source-code .tok-number {
  color: var(--syn-number);
}
#editor-highlight .tok-directive,
#source-code .tok-directive {
  color: var(--syn-directive);
}

/* Autocomplete popup. Anchored at the caret inside #editor-stack, above the
   transparent textarea (z-index:1). Opaque --panel + a --border-strong edge
   separate it from the editor WITHOUT a shadow (the rendered image owns the
   only shadow). The active row borrows the example browser's exact language:
   grey --border fill plus a square --border-strong inset marker, never the
   accent. */
#complete {
  position: absolute;
  z-index: 5;
  margin: 0;
  padding: 0;
  list-style: none;
  min-width: 20ch;
  max-width: 44ch;
  max-height: 15.5em;
  overflow-y: auto;
  background: var(--panel);
  border: 1px solid var(--border-strong);
  font-size: var(--fs-out);
  line-height: 1.5;
}

#complete[hidden] {
  display: none;
}

.cmp-item {
  display: flex;
  align-items: baseline;
  gap: 1.5ch;
  padding: 2px 8px;
  white-space: nowrap;
  cursor: pointer;
}

.cmp-item:hover {
  background: var(--border);
}

.cmp-item.is-active {
  background: var(--border);
  box-shadow: inset 3px 0 0 var(--border-strong);
}

/* The name takes the slack and truncates; the kind + file stay pinned to the
   right at their natural width, so a long macro signature never shoves the
   provenance out of view. */
.cmp-name {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  color: var(--text);
}

.cmp-sig,
.cmp-meta,
.cmp-file {
  flex: none;
  color: var(--dim);
}

/* Drag-and-drop asset import. While a file is dragged over the editor it lights
   the accent edge (a focus-like "ready to receive" state, the one accent use the
   identity permits here) and shows a centered hint over the editor. Staged files
   then show as removable chips below it, in the same grey-chip language as the
   rest of the chrome. */
#editor-wrap.drag-over {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

/* Centered "drop to import" overlay; visible only during a drag-over. Sits above
   the editor + completion popup, with pointer-events off so the drop still lands
   on the textarea. The translucent backdrop matches the --bg token (the lone
   place, alongside ::selection, a literal rgba is used for a wash). */
#drop-hint {
  position: absolute;
  inset: 0;
  z-index: 6;
  display: none;
  align-items: center;
  justify-content: center;
  padding: 8px;
  text-align: center;
  background: rgba(11, 13, 16, 0.85);
  color: var(--dim);
  font-size: var(--fs-out);
  pointer-events: none;
}

#editor-wrap.drag-over #drop-hint {
  display: flex;
}

.assets {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px;
  margin-top: 8px;
}

.assets[hidden] {
  display: none;
}

.assets-label,
.assets-note {
  color: var(--dim);
  font-size: var(--fs-out);
}

#asset-chips {
  display: inline-flex;
  flex-wrap: wrap;
  gap: 6px;
}

.asset-chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 2px 6px;
  border: 1px solid var(--border);
  background: var(--panel);
  font-size: var(--fs-out);
}

.asset-name {
  color: var(--text);
}

.asset-remove {
  border: 0;
  padding: 0 2px;
  background: none;
  color: var(--dim);
  font-size: 14px;
  line-height: 1;
  cursor: pointer;
}

.asset-remove:hover {
  color: var(--text);
}

/* "Couldn't import X" feedback for a rejected/unreadable drop. */
.asset-note {
  margin: 6px 0 0;
  color: var(--error);
  font-size: var(--fs-out);
}

/* The restore-scene note borrows .asset-note's layout but NOT its --error red:
   a replace is undoable, not a failure, so the text is the quiet --dim (the gold
   sits only on the `restore` link, per the accent census). */
#restore-note {
  color: var(--dim);
}

.asset-note[hidden] {
  display: none;
}

/* Scene params: the auto-sliders are the scene's OWN knobs, not app settings, so
   they sit in a labeled disclosure set off by a hairline divider and an indent.
   That boundary is what keeps the scene's content from reading as another tier of
   render chrome (the two used identical typography before). Same quiet dim summary
   as #advanced / the log, so the page speaks one disclosure language. */
#scene-params {
  margin-top: 12px;
  border-top: 1px solid var(--border);
  padding-top: 10px;
}

.disclosure-count {
  margin-left: 6px;
  color: var(--dim);
  font-variant-numeric: tabular-nums;
}

/* Auto-sliders for `#declare NAME = <number>` params. The ranges take the same
   grey hand-built treatment as the player scrubber (see .scrubber): three gold
   tracks sitting right above the Render button would dilute the accent census.
   Names dim, values tabular like the rest of the readouts. The left indent
   reinforces "these belong to the scene above, not the toolbar". The wide 24px
   column gap keeps each trailing reset ↺ visually owned by ITS slider instead
   of floating between it and the next row's label. */
#sliders {
  display: flex;
  flex-wrap: wrap;
  gap: 8px 24px;
  margin-top: 10px;
  padding-left: 12px;
}

.slider-row {
  display: inline-flex;
  align-items: center;
  gap: 8px;
}

.slider-name {
  color: var(--dim);
  font-size: var(--fs-out);
}

.slider-row input[type='range'] {
  width: 120px; /* appearance/track/thumb come from the shared .scrubber rules */
}

.slider-value {
  min-width: 5ch;
  color: var(--text);
  font-size: var(--fs-out);
  font-variant-numeric: tabular-nums;
}

.slider-reset {
  border: 0;
  padding: 0 2px;
  background: none;
  color: var(--dim);
  font-size: var(--fs-out);
  line-height: 1;
  cursor: pointer;
}

.slider-reset:hover {
  color: var(--text);
}

/* Three-level spacing rhythm: 6px label-to-control (pair), 10px within a
   .ctl-group (below), 20px between groups here. Row-gap 12px so wrapped tiers
   read as deliberate rows, not an accidental overflow. */
#controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 12px;
  margin-top: 8px;
  padding: 8px 0;
  border-block: 1px solid var(--border);
}

#controls label {
  display: inline-flex;
  align-items: center;
  gap: 6px;
}

#controls input[type='number'],
.advanced-body input[type='number'] {
  width: 4.5em;
}

/* frames/fps cap at 3 digits (max 240 / 60), so the shared 4.5em sized for the
   4-digit dimension fields is dead air here, and in animate mode that slack is
   what tips the toolbar into a second wrap row at ~1440px. The #controls scope
   is for specificity only (it must outrank the [type='number'] rule above). */
#controls #frames,
#controls #fps {
  width: 3.5em;
}

/* "size  512 × 384": width and height read as one dimension control, not two
   stray fields. The dim label leads, the dim × ties the pair, tighter inner gap
   than the inter-group rhythm so it groups optically. */
#size-group {
  gap: 8px;
}

.ctl-label {
  color: var(--dim);
}

.ctl-x {
  color: var(--dim);
}

/* Width/height swap: the standard secondary button slimmed to a glyph chip, so
   the size pair still reads as one dimension control with a small action on its
   tail rather than three peer fields. */
#swap-size {
  padding: 0 7px;
}

/* Cluster the controls into legible groups (mode/behavior, dimensions,
   quality) so the row reads as deliberate pairs/groups instead of one long
   undifferentiated run, and wraps fall on group boundaries. The tighter
   within-group gap is the middle tier of the rhythm above. flex-wrap lets a
   wide group break internally on a narrow screen instead of overflowing. */
.ctl-group {
  display: inline-flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 10px;
}

/* Quiet hairline separating the live-draft toggle from the still/animate
   mutex so live stops reading as a third mode segment. --border weight (the
   non-interactive hairline used in the header/footer), square, no accent;
   still-only so it hides with live in animate mode. */
.ctl-rule {
  align-self: stretch;
  width: 1px;
  background: var(--border);
}

/* Render + Cancel + Copy Link wrap as a tight action unit. Wider viewports push
   the unit to the row end; phones make Render the stable thumb-width action. */
.actions {
  display: flex;
  gap: 6px;
}

@media (min-width: 700px) {
  .actions {
    margin-left: auto;
  }

  #render-btn {
    min-width: 8em;
  }
}

/* Hold the Copy Link button's width across the transient "Copied" label flip so
   the action row doesn't reflow narrower for ~1.2s. Pure width hold, no color. */
#copy-link-btn {
  min-width: 8em;
}

/* Advanced disclosure: the render-tuning controls (quality / antialias / threads)
   plus the raw-flags escape hatch, collapsed by default so the toolbar stays a
   short, calm row (these matter every few renders to a power user, ~never to a
   newcomer). ui.js persists the open state, so a power user opens it once and it
   stays. Same quiet dim marker as the log disclosure. */
#advanced {
  margin-top: 12px;
}

/* Stack the demoted tuning controls above the raw-flags field; each row reads as
   one concept (render fidelity, then the engine escape hatch). */
.advanced-body {
  margin-top: 10px;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

/* Scene history: the same quiet disclosure language as scene-params / advanced.
   The list is compact full-width rows, each a borderless button that loads that
   rendered version back into the editor. */
#history {
  margin-top: 8px;
  border-top: 1px solid var(--border);
  padding-top: 6px;
}

#history-list {
  display: flex;
  flex-direction: column;
  max-height: min(28vh, 240px);
  margin-top: 6px;
  overflow: auto;
  border-block: 1px solid var(--border);
  scrollbar-gutter: stable;
}

/* Desktop rows are dense on purpose: history can hold many rendered versions,
   so the preview leads and the numeric metadata stays pinned to the right. */
.history-entry {
  flex: none;
  height: auto;
  min-height: 24px;
  border: 0;
  padding: 4px 6px;
  background: none;
  color: var(--text);
  font-size: var(--fs-out);
  line-height: 1.25;
  text-align: left;
  cursor: pointer;
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 8px;
  align-items: baseline;
  overflow: hidden;
}

.history-entry:hover:not(:disabled) {
  background: var(--border);
  color: var(--text);
}

.history-meta {
  display: inline-flex;
  flex: none;
  align-items: baseline;
  justify-content: flex-end;
  gap: 8px;
  min-width: 13ch;
}

.history-time {
  min-width: 6ch;
  color: var(--dim);
  font-variant-numeric: tabular-nums;
  text-align: right;
}

.history-preview {
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

/* Per-entry line delta vs the current editor text ("+2 −5", or "current"),
   pinned to the row's right edge in the same dim numeric treatment as the
   timestamp. ui.js recomputes it when the panel (re)opens, not per keystroke. */
.history-delta {
  color: var(--dim);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* The flags field stretches to the editor width: long +flag strings need room,
   and a full-width text input reads clearly as "type engine flags here". The
   monospace is inherited; a touch smaller so the flag soup stays legible. */
#flags-field {
  display: flex;
  align-items: center;
  gap: 8px;
}

#flags-field > span {
  color: var(--dim);
  white-space: nowrap;
}

#flags {
  flex: 1;
  min-width: 0;
  font-size: var(--fs-out);
}

/* The single-row, ellipsis-truncated treatment is two-column only (see the
   min-width: 900px block, where it aligns with the example row's top edge). In
   one column the done line is allowed to wrap so the rays/thread payoff stays
   visible on phones instead of truncating to "…rays". */
/* Status text and the render spinner share one row: text left (truncates in
   two-column, see the 900px block), spinner pinned right. */
#status-row {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 8px;
}

#status {
  flex: 1 1 auto;
  min-width: 0; /* let the text truncate instead of shoving the spinner off */
}

/* Render spinner: a square ring whose one accent edge orbits while a render or
   live draft is in flight. Square (corners stay sharp) so it's the one moving
   mark that still reads optical-bench; the rotation itself is gated in the
   motion block, so under reduced-motion it sits as a static accent tick. JS
   toggles the `hidden` attribute off exactly when work is in flight. */
.spinner {
  flex: none;
  box-sizing: border-box;
  width: 13px;
  height: 13px;
  border: 2px solid var(--border-strong);
  border-top-color: var(--accent);
}

/* The prominent render-stop, pinned to the right of #status-row beside the
   spinner. The toolbar Cancel only stops explicit renders and is easy to miss on
   mobile while a render runs, so this rides next to the live status line and JS
   keeps it in lockstep with the spinner (visible exactly while a render or live
   draft is in flight). A SECONDARY control: the base grey-bordered, square,
   accent-free button treatment carries it (accent stays on Render), and the
   @media (pointer: coarse) block already grows every button to a 44px tap
   target. flex:none so the label never shrinks under the flexible status text. */
#stop-btn {
  flex: none;
}

#progress {
  margin-bottom: 8px;
}

/* The error voice; .error supplies the text color on the old markup. */
#error {
  background: var(--panel);
  border: 1px solid var(--error);
  color: var(--error);
  padding: 8px;
  margin-bottom: 8px;
  font-size: var(--fs-out);
  line-height: 1.5;
}

/* Quiet draft-error variant: a live-draft parse error keeps the last good
   image and surfaces the message non-modally, without the loud red box or the
   rise entrance. */
#error.draft {
  border-color: var(--border-strong);
  color: var(--dim);
  animation: none;
}

/* Full-height specimen stage: the pane is a flex column so the plate (the
   hero surface) absorbs the column's spare height and the meta-row/stats/log
   pin to the pane's bottom edge. In one-column mode the pane is content-
   height, so the rule is inert there. */
#output-pane {
  display: flex;
  flex-direction: column;
}

/* The plate: the one shadow and the one textured background in the app. */
#output-plate {
  background: var(--mat) 0 0 / 16px 16px;
  border: 1px solid var(--border);
  padding: 12px;
  box-shadow: 6px 6px 0 rgb(0 0 0 / 0.55);
  margin-bottom: 16px; /* clears the shadow */
  overflow: auto; /* 1:1/4x zoom scrolls inside the plate */
  /* flex-basis auto + no shrink floor the plate at its content height, so an
     opening render log or stats row never squeezes the hero (the page grows
     instead, exactly as it would without the stage). */
  flex: 1 0 auto;
  /* The plate is itself a flex column so the hero's auto margins center it on
     BOTH axes; unlike place-items: center, auto margins collapse to 0 when a
     zoomed image overflows the scrolling plate, keeping the top-left corner
     reachable at scroll origin. */
  display: flex;
  flex-direction: column;
}

#output {
  display: block;
  max-width: 100%;
  height: auto;
  margin: auto; /* optical center of the stage; collapses to 0 on overflow */
  flex: none; /* never flex-squeezed: fit scaling is max-width's job alone */
  cursor: zoom-in; /* pointer shortcut to the fit/1:1/4x cycle button */
}

/* Empty state: an aspect-ratio box (JS overrides from the w/h inputs) with
   one centered dim sentence on the mat. width 100% + auto margins center the
   box vertically on the full-height stage like the rendered image. */
#output-plate .hint {
  display: grid;
  place-items: center;
  aspect-ratio: 4 / 3;
  width: 100%;
  margin: auto;
  flex: none;
  padding: 16px;
  color: var(--dim);
  text-align: center;
}

#output-plate:has(img:not([hidden])) .hint {
  display: none;
}

/* The editor page's zoom cycle is fit -> 1:1 -> 4x -> fit, so at 1:1 the next
   click goes further IN; the shared .zoom-1x zoom-out cursor only tells the
   truth at the 4x step. (The REPL has no cycle and keeps the shared rule.) */
#output.zoom-1x {
  cursor: zoom-in;
}

#output.zoom-4x {
  cursor: zoom-out;
}

/* While a zoom step is engaged the plate caps at one viewport so the PLATE
   (not the page) is the scroll container on both axes: the click-anchor and
   the drag-pan write plate.scrollLeft/scrollTop, which need real overflow to
   act on. Unzoomed, the full-height stage rule above is back in charge. */
#output-plate:has(#output.zoom-1x) {
  max-height: 100dvh;
}

/* Drag-to-pan affordance: the plate is the scroll container, so it shows grab
   while a zoomed image can be panned, and grabbing (also beating the image's
   zoom cursor) only while a drag is actually moving it. The grabbing arm
   repeats the :has() so it outweighs the grab rule above (a pan only ever
   runs while a zoom step is engaged, so the selectors describe one state). */
#output-plate:has(#output.zoom-1x) {
  cursor: grab;
}

#output-plate.panning:has(#output.zoom-1x),
#output-plate.panning #output {
  cursor: grabbing;
}

/* Download anchor + fit/1:1 zoom toggle. */
.meta-row {
  display: flex;
  flex-wrap: wrap; /* download + zoom stack instead of overflowing on narrow panes */
  align-items: center;
  gap: 8px;
  margin-bottom: 8px;
}

/* Secondary-button treatment for the download anchor. */
#download-btn {
  display: inline-flex;
  align-items: center;
  height: 28px;
  padding: 0 12px;
  border: 1px solid var(--border-strong);
  color: var(--text);
  text-decoration: none;
}

#download-btn:hover {
  background: var(--text);
  border-color: var(--text);
  color: var(--bg);
}

/* Stats readout: a row of dim label/value chips promoted from the render log's
   numbers (pixels, timings, rays, threads). Resolution and total time live in
   the status done-line, so the chips don't repeat them. Square + monospace like
   the rest; tabular-nums so the figures hold a steady column as they update.
   Wraps to as many rows as the pane needs. JS unhides it after a still render. */
.stats {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 16px;
  margin: 0 0 12px;
  font-size: var(--fs-out);
}

.stats .stat {
  display: flex;
  align-items: baseline;
  gap: 6px;
}

.stats dt {
  color: var(--dim);
}

.stats dd {
  margin: 0;
  color: var(--text);
  font-variant-numeric: tabular-nums;
}

/* Diagnostics collapse behind the disclosure; JS unhides #log-details once
   the first line arrives (an :empty trick dies on the progress node). */
#log {
  background: var(--panel);
  border: 1px solid var(--border);
  padding: 8px;
  margin-top: 4px;
  color: var(--dim);
  font-size: var(--fs-out);
  line-height: 1.5;
  max-height: 40vh;
  overflow-y: auto;
  overflow-wrap: break-word; /* stats columns wrap at spaces, not mid-number */
}

/* ---- animate mode: mode toggle, frame/fps inputs, the player ----
   data-mode lives on <body> (seeded in the markup so nothing flashes before
   ui.js runs). The two chrome sets are mutually exclusive: still mode hides
   the player + frame/fps inputs, animate mode hides the PNG download/zoom row.
   These display rules must not beat the `hidden` attribute (the JS gates the
   player and its inputs further), hence the same !important the base rule uses. */
body.ui[data-mode='still'] .animate-only {
  display: none !important;
}

body.ui[data-mode='animate'] .still-only {
  display: none !important;
}

/* Segmented toggles (still/animate, loop). Grey + square, butted together;
   the pressed segment inverts to read as selected WITHOUT borrowing the
   accent (identity rule 1: accent stays on Render). Scoped to .toggle-btn so
   the aria-pressed zoom toggle keeps its quiet treatment. */
.seg-group {
  display: inline-flex;
}

.seg-group .toggle-btn + .toggle-btn {
  margin-left: -1px; /* collapse the shared 1px border into one hairline */
}

.toggle-btn[aria-pressed='true'] {
  background: var(--text);
  border-color: var(--text);
  color: var(--bg);
}

/* Hovering an UNpressed segment must not borrow the selected segment's solid
   fill (the generic button:hover invert) or both segments read as "on" the
   moment the pointer approaches the mode selector. A border + text emphasis on
   a transparent bg keeps the affordance grey/square/accent-free while staying
   clearly distinct from the inverted pressed state. */
.toggle-btn[aria-pressed='false']:hover:not(:disabled) {
  background: transparent;
  border-color: var(--text);
  color: var(--text);
}

/* Auto preview is a persistent preference, not a mode segment. Keep the main
   button quiet and put the state in a tiny on/off chip, so the control is
   explicit without competing with still/animate or Render. */
#live-toggle {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  min-width: 112px;
  justify-content: space-between;
  padding: 0 6px 0 9px;
}

#live-toggle[aria-pressed='true'] {
  background: transparent;
  border-color: var(--border-strong);
  color: var(--text);
}

/* The transparent on-state keeps the dark bg, so the gold focus ring stays
   visible; undo the pressed-toggle ink-ring override (ink would vanish here). */
#live-toggle[aria-pressed='true']:focus-visible {
  outline-color: var(--accent);
}

#live-toggle:hover:not(:disabled) {
  background: var(--border);
  border-color: var(--text);
  color: var(--text);
}

.live-toggle-state {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 2.4em;
  height: 17px;
  padding: 0 5px;
  border: 1px solid var(--border-strong);
  color: var(--dim);
  font-size: var(--fs-chrome);
  line-height: 1;
  text-transform: uppercase;
}

#live-toggle[aria-pressed='true'] .live-toggle-state {
  background: var(--text);
  border-color: var(--text);
  color: var(--bg);
}

#live-toggle[aria-pressed='false'] .live-toggle-state {
  background: transparent;
  border-color: var(--border-strong);
  color: var(--dim);
}

/* The player canvas is the animate-mode hero, drawn into the same #output-plate
   (mat + shadow) the still image uses. Scales down to fit like #output. */
#player-canvas {
  display: block;
  max-width: 100%;
  height: auto;
  margin: auto; /* both-axis optical center on the full-height stage */
  flex: none;
}

#player-controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin-bottom: 16px; /* clears the plate shadow, like .meta-row */
}

/* Hold the width across the Play/Pause label flip (sized to "Pause", the wider
   label) so the flex-1 scrubber beside it doesn't jiggle on every toggle. Same
   move as #copy-link-btn's "Copied" hold. */
#play-btn {
  min-width: 5.5em;
}

/* Hand-built range treatment: a 2px track echoing the progress bar, with a
   small square grey thumb (no rounded corners, no accent). The base
   input:focus accent ring still applies. Shared by the player scrubbers
   (.scrubber, not #scrubber, so the REPL's inline player range matches) AND
   the scene-param sliders: every range control in the app is grey, square,
   accent-free (identity rule 1: the accent census doesn't include sliders).
   Layout (flex/width) stays per-context below. */
.scrubber,
.slider-row input[type='range'] {
  height: 28px;
  padding: 0;
  background: transparent;
  border: none;
  cursor: pointer;
  -webkit-appearance: none;
  appearance: none;
}

.scrubber {
  flex: 1;
  min-width: 8em;
}

.scrubber::-webkit-slider-runnable-track,
.slider-row input[type='range']::-webkit-slider-runnable-track {
  height: 2px;
  background: var(--border-strong);
}

.scrubber::-moz-range-track,
.slider-row input[type='range']::-moz-range-track {
  height: 2px;
  background: var(--border-strong);
}

.scrubber::-webkit-slider-thumb,
.slider-row input[type='range']::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 10px;
  height: 16px;
  margin-top: -7px; /* center the 16px thumb on the 2px track */
  background: var(--text);
  border: none;
}

.scrubber::-moz-range-thumb,
.slider-row input[type='range']::-moz-range-thumb {
  width: 10px;
  height: 16px;
  background: var(--text);
  border: none;
  border-radius: 0;
}

.player-readout {
  color: var(--dim);
  font-size: var(--fs-out);
  white-space: nowrap;
}

/* Export cluster: the format picker + the Export button, kept together so they
   wrap as a unit when the player controls run out of room. The select matches
   the quality/antialias selects; the button is the standard secondary treatment
   (accent stays on Render). */
.export-field {
  display: inline-flex;
  align-items: center;
  gap: 6px;
}

/* ---- REPL page ---- */

body.repl,
body:has(#scrollback) {
  display: flex;
  flex-direction: column;
  height: 100dvh;
  overflow: hidden;
}

/* The transcript landmark wrapper: a real <main> carries the landmark while the
   inner #scrollback stays role="log" (the two responsibilities split so neither
   overrides the other). The flex fill lives here; #scrollback fills it. */
body.repl > main {
  flex: 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

#scrollback {
  /* prompt (4ch of the 13px body type) + entry gap (8px); see @property
     registration at the top of the file */
  --repl-indent: calc(4ch + 8px);
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: 12px;
}

#scrollback > * {
  margin-bottom: 12px;
  max-width: 100ch;
}

.prompt {
  color: var(--accent);
  user-select: none;
}

.entry {
  display: flex;
  align-items: flex-start;
  gap: 8px;
}

.entry pre.src {
  flex: 1;
  min-width: 0;
}

figure.result {
  /* 2px border + 10px padding = 12px: content sits exactly at --repl-indent
     (under the source text) while the accent edge carries provenance back
     toward the prompt column. */
  margin: 0 0 12px calc(var(--repl-indent) - 12px);
  border-left: 2px solid var(--accent);
  padding-left: 10px;
}

/* Small plate treatment: 6px mat, hairline, no shadow at this scale. */
figure.result img.preview {
  display: block;
  max-width: 100%;
  height: auto;
  padding: 6px;
  border: 1px solid var(--border);
  background: var(--mat) 0 0 / 16px 16px;
  /* No zoom-in cursor: the REPL never wires a click/keyboard zoom for the
     inline preview (the source slide-out is its "see the scene" path), so the
     affordance would lie. The shared .zoom-1x block still serves the editor. */
}

figure.result:has(img.zoom-1x) {
  overflow-x: auto;
}

figure.result figcaption {
  color: var(--dim);
  font-size: var(--fs-out);
  line-height: 1.5;
  margin-top: 4px;
}

#scrollback pre.error,
#scrollback pre.info {
  margin-left: var(--repl-indent);
}

/* REPL error block shares the error-box voice. */
#scrollback pre.error {
  background: var(--panel);
  border: 1px solid var(--error);
  padding: 8px;
  font-size: var(--fs-out);
  line-height: 1.5;
}

/* The transcript never lies about scene state. */
.rolled-back {
  opacity: 0.5;
}

/* Pending result: figure.result.pending wraps a placeholder box (sized from
   settings by JS, swapped for the img on success) + a "rendering…" caption
   whose ::after pulses (see the busy-pulse rules above / motion block). */
figure.result.pending > div {
  border: 1px dashed var(--border-strong);
  background: var(--panel);
}

#input-form {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  padding: 12px;
  border-top: 1px solid var(--border);
}

#input-form .prompt {
  padding-top: 5px; /* textarea first line: 4px padding + 1px border */
}

/* Prompt dims while a render is in flight (input goes readOnly, not disabled). */
#input-form:has(#input[readonly]) .prompt {
  color: var(--dim);
}

#input {
  flex: 1;
  min-width: 0;
  min-height: 28px;
  resize: none; /* auto-grows via rows from JS */
}

#repl-status {
  /* Same env() story as the page footer: free in browsers, load-bearing in
     standalone/cover contexts where the home indicator overlaps. */
  padding: 8px 12px calc(8px + env(safe-area-inset-bottom, 0px));
  border-top: 1px solid var(--border);
  font-size: var(--fs-chrome);
  line-height: 1.4;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* ---- REPL scene-source slide-out ----
   A disclosure panel mirroring the assembled scene (highlight()'d) over the
   fixed REPL column. Square, no shadow (reserved for the rendered image), no
   radius, no gradient; the border-left is the only floating-surface edge.
   #source-toggle (in the header nav) owns aria-expanded as the single source of
   truth; body.source-open drives the slide. The transform/visibility resting
   states are here (outside the motion block) so reduced-motion swaps instantly. */
#repl-source {
  position: fixed;
  inset-block: 0;
  right: 0;
  z-index: 30; /* above #input-form/#repl-progress; the editor's popover sits at 20 */
  width: min(560px, 92vw); /* desktop cap; the vw peek on phones signals "overlay" */
  display: flex;
  flex-direction: column;
  background: var(--panel);
  border-left: 1px solid var(--border-strong);
  transform: translateX(100%); /* parked off-screen */
  visibility: hidden; /* drop closed content from the tab order + a11y tree */
}

body.source-open #repl-source {
  transform: none;
  visibility: visible;
}

/* div, not a nested <header>: the element-typed header {}/header h1 {} rules
   would leak wordmark sizing + the chrome border in. */
.source-head {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 12px;
  border-bottom: 1px solid var(--border);
}

.source-title {
  color: var(--dim);
}

#source-close {
  height: 28px;
  padding: 0 10px;
}

.source-pre {
  flex: 1;
  min-width: 0;
  overflow: auto;
  margin: 0;
  padding: 12px;
  font-size: var(--fs-body);
}

#source-code {
  font: inherit; /* beat the UA monospace on <pre>/<code>, same as #editor-code */
}

/* Disclosure open-state: mirror the segmented toggles' light fill WITHOUT
   borrowing --accent (identity rule 1). */
#source-toggle[aria-expanded='true'] {
  background: var(--text);
  border-color: var(--text);
  color: var(--bg);
}

/* The gold ring vanishes on the light fill (~1:1); the dark ink ring restores
   contrast, mirroring the pressed-toggle treatment. */
#source-toggle[aria-expanded='true']:focus-visible {
  outline-color: var(--accent-ink);
}

/* ---- responsive ---- */

/* Editor/output splitter handle: an 8px grid column between the panes, shown
   only at the two-column breakpoint (below it the panes stack and #split-handle
   is display:none). The visible mark is a centered 2px hairline; the full 8px
   strip is the hit/drag target. Focus keeps the global ring (it's a tabbable
   separator); hover/drag brighten the line one step, never the accent. */
#split-handle {
  display: none;
}

@media (min-width: 900px) {
  body.ui main {
    /* The middle 8px column is #split-handle; --split (an fr count set inline
       by ui.js, default 1fr = 50/50) sizes the editor pane against the output
       pane's 1fr. The minmax floors keep both panes usable at any split. */
    grid-template-columns: minmax(320px, var(--split, 1fr)) 8px minmax(320px, 1fr);
    column-gap: 8px; /* 8 + 8 + 8: the divider owns the inter-pane gutter */
  }

  #split-handle {
    display: block;
    position: relative;
    cursor: col-resize;
    /* touch-action none so a stray touch drag on the handle resizes instead of
       scrolling the page out from under the pointer capture. */
    touch-action: none;
  }

  #split-handle::before {
    content: '';
    position: absolute;
    left: 3px;
    top: 0;
    bottom: 0;
    width: 2px;
    background: var(--border);
  }

  #split-handle:hover::before,
  #split-handle:focus-visible::before {
    background: var(--border-strong);
  }

  /* Two-column only: the status stays a single aligned row whose top edge
     matches the example row. (One column lets it wrap, see the #status rule.) */
  #status {
    height: 28px;
    line-height: 28px;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  /* Stop exists because the toolbar Cancel is easy to miss on a phone mid-
     render (see #stop-btn). In two-column both controls sit on screen at once,
     so showing both reads as two different actions. Exactly one stop control
     per viewport: Cancel here, Stop below the breakpoint. */
  #stop-btn {
    display: none;
  }
}

/* Cap the editor at every width so controls and the output pane stay within a
   scroll-flick's reach (and the Render button never falls below the fold on a
   tall scene; two-column had no cap before). The #gutter cap is load-bearing:
   a flex row is as tall as its tallest item, so the uncapped gutter (one line
   per source line) would re-inflate #editor-wrap far past the editor's own
   max-height and leave a dead panel below the textarea. */
#editor,
#gutter {
  max-height: 60vh;
}

@media (max-width: 600px) {
  #editor,
  #gutter {
    max-height: 45vh; /* shrinks the swipe trap */
  }

  #editor {
    min-height: 35vh;
  }

  /* On a narrow screen, WRAP long lines instead of horizontal-scrolling them off
     the edge (the worst part of the desktop wrap=off editor on a phone). The
     syntax overlay can't stay glyph-aligned with a soft-wrapped textarea (their
     content widths differ by a scrollbar, so they break at different columns),
     so we drop the overlay + the now-meaningless line gutter and let the
     textarea paint its OWN text, wrapped: fully readable + editable beats
     highlighted-but-clipped. CSS white-space overrides the wrap="off" attribute.
     The color override matches the base's `#editor-wrap #editor` specificity. */
  #editor-wrap #editor {
    white-space: pre-wrap;
    overflow-wrap: anywhere;
    overflow-x: hidden;
    color: var(--text);
    -webkit-text-fill-color: var(--text);
  }

  #editor::selection {
    color: var(--text);
    -webkit-text-fill-color: var(--text);
  }
  #editor::-moz-selection {
    color: var(--text);
    -webkit-text-fill-color: var(--text);
  }

  #editor-highlight,
  #gutter {
    display: none;
  }

  /* The primary CTA becomes a full-width thumb bar on its own stable row. */
  .actions {
    width: 100%;
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto auto;
  }

  #render-btn {
    flex: 1;
  }

  #copy-link-btn {
    min-width: 0;
  }

  #history-list {
    max-height: 24vh;
  }

  .history-entry {
    min-height: 26px;
    padding-inline: 4px;
    gap: 6px;
  }

  .history-meta {
    gap: 6px;
    min-width: 11ch;
  }

  /* On a phone the 92vw cap leaves only a ~29px peek, which reads as cramped
     rather than "overlay". Go full-width: the × is an unambiguous close and the
     drawer still clearly covers the transcript. (Wider screens keep the peek.) */
  #repl-source {
    width: 100vw;
  }
}

/* Short / landscape viewports (e.g. a phone on its side): the editor is sized
   in vh, so the vh base + 48vh min-height alone push Render and the output
   plate below the fold. Cap it harder and drop the min-height floor so the
   editor yields room for the hero, and the resize handle can shrink it further.
   Placed after the max-width block so a small landscape phone (short but not
   narrow) still gets the min-height: 0 release. */
@media (max-height: 560px) {
  #editor,
  #gutter {
    max-height: 40vh;
  }

  #editor {
    min-height: 0;
  }
}

@media (pointer: coarse) {
  button,
  #download-btn {
    min-height: 44px;
    padding: 10px 16px;
  }

  input,
  select,
  textarea {
    font-size: 16px; /* kills iOS focus-zoom; the floor below is independent */
    min-height: 44px; /* meets the 44px coarse-pointer tap target */
  }

  /* Must track #editor (a textarea) or the line numbers / syntax overlay
     misalign. The overlay's base font-size:inherit would resolve to the body
     13px (it inherits from the stack, not the 16px textarea), so it needs the
     bump explicitly here. */
  #gutter,
  #editor-highlight {
    font-size: 16px;
  }

  /* The capped-height, wrap=off editor is a finicky touch scroll target. Keep
     its own scroll self-contained so dragging to the top/bottom of the source
     doesn't chain into a page-scroll jump (the "swipe trap"), and hand older
     iOS momentum scrolling. The textarea already owns its scroll (overflow:
     auto + the max-height cap); this just makes that scroll comfortable. */
  #editor {
    overscroll-behavior: contain;
    -webkit-overflow-scrolling: touch;
  }

  /* The REPL input is the page's primary control; its own min-height rule
     (an id selector) outranks the textarea floor above, so the 44px floor
     needs the same specificity. */
  #input {
    min-height: 44px;
  }

  /* The coarse block bumps #input to 16px; match the prompt so "pov>" stays on
     the input's baseline instead of riding a hair high. */
  #input-form .prompt {
    font-size: 16px;
  }

  /* Popover options are tap targets too: give them the 44px floor and center
     the stacked title/desc/by within it. */
  .ex-option {
    min-height: 44px;
    align-content: center;
  }

  /* Completion rows are tap targets on touch too: same 44px floor, vertically
     centered (they're a single line, unlike the stacked example options). */
  .cmp-item {
    min-height: 44px;
    align-items: center;
  }

  /* The category heads are disclosure buttons; give them the same tap floor
     (already center-aligned via align-items). */
  .ex-group-head {
    min-height: 44px;
  }

  /* The example trigger is the phone's entry point to the scene list. Size it
     like the 16px inputs (not the 13px chrome) so it reads as an obvious,
     comfortably-tappable control; the 44px tap floor already comes from the
     base button rule above. */
  #example-trigger {
    font-size: 16px;
  }

  /* Inline links (nav, footer, save png): vertical padding on inline boxes
     expands the tap target without moving line boxes, so the chrome stays
     visually identical while the hit area reaches ~44px. */
  header nav a,
  footer a,
  figure.result figcaption a {
    padding-block: 16px;
  }

  /* The log disclosure is a primary tap target after a render. summary is
     block-level, so this one genuinely grows the row. */
  summary {
    padding-block: 13px;
  }

  /* History is a scrollable list, not a sparse command cluster. Keep rows easier
     to tap than desktop without letting a full history dominate the phone page. */
  .history-entry {
    min-height: 34px;
    padding-block: 6px;
  }
}

/* ---- motion (the full census: progress, busy pulse, image settle,
        error entrance, link color) ---- */

@media (prefers-reduced-motion: no-preference) {
  a {
    transition: color var(--t-fast) var(--ease-out);
  }

  /* ease-in-out (symmetric) reads as "alive" rather than blinky; the 0.35
     trough in the keyframe is a softer dip than the old 0.2. */
  #status[data-state='busy']::after,
  #repl-status[data-state='busy']::after,
  figure.result.pending figcaption::after {
    animation: pulse 1.2s ease-in-out infinite;
  }

  #progress:not(.determinate):not([data-mode='determinate'])::before,
  #repl-progress:not(.determinate):not([data-mode='determinate'])::before {
    animation: sweep 0.9s linear infinite;
  }

  .spinner {
    animation: spin 0.8s linear infinite;
  }

  /* Progress width stays linear: easing a progress bar lies about render rate. */
  #progress.determinate::before,
  #progress[data-mode='determinate']::before,
  #repl-progress.determinate::before,
  #repl-progress[data-mode='determinate']::before {
    transition: width var(--t-med) linear;
  }

  /* Settle-in: the hero content (still image / animate canvas / REPL preview)
     develops into focus with a sub-1% rise. Composited transform + opacity, no
     reflow, square corners untouched. Replays on unhide/insertion (display:none
     restarts it). No panel or control scales. */
  #output,
  #player-canvas,
  figure.result img.preview {
    animation: settle var(--t-slow) var(--ease-out);
  }

  /* .stale settles both ways (the dim treatment, separate from the settle-in). */
  #output,
  #download-btn {
    transition:
      opacity var(--t-med) ease-out,
      filter var(--t-med) ease-out;
  }

  #error,
  #scrollback pre.error {
    animation: rise var(--t-med) var(--ease-out);
  }

  /* Completion beat: the status color crossfades, and a one-shot confirm
     replays on every completion (the element newly matches when data-state
     flips to done). Runs on --t-slow so it reads as one beat with the image
     settle. Resting opacity is 1, so reduced-motion shows full strength. */
  #status,
  #repl-status {
    transition: color var(--t-med) var(--ease-out);
  }
  #status[data-state='done'],
  #repl-status[data-state='done'] {
    animation: confirm var(--t-slow) var(--ease-out);
  }

  /* Example popover entrance; close stays instant (display:none). */
  #example-browser:not([hidden]) {
    animation: pop-in var(--t-med) var(--ease-out);
    transform-origin: top;
  }

  /* Option hover/roving glide: 120ms keeps keyboard roving responsive. */
  .ex-option {
    transition: background-color var(--t-fast) var(--ease-out);
  }

  /* Caret rotation on expand. The resting glyph + the reduced-motion glyph swap
     live outside this block, so the expanded state still reads without motion. */
  .ex-group-caret {
    transition: transform var(--t-fast) var(--ease-out);
  }
  .ex-group-head[aria-expanded='true'] .ex-group-caret {
    transform: rotate(90deg);
  }

  /* REPL transcript entries enter instead of popping; the figure's rise doubles
     as the "render starting" beat, then its img settles on load. */
  #scrollback > .entry,
  #scrollback > figure.result {
    animation: rise var(--t-med) var(--ease-out);
  }

  /* Source slide-out: hold visibility until the close slide finishes, reveal it
     immediately on open. The transform/visibility target states live OUTSIDE
     this block, so reduced-motion swaps them instantly. */
  #repl-source {
    transition:
      transform var(--t-med) var(--ease-out),
      visibility 0s linear var(--t-med);
  }
  body.source-open #repl-source {
    transition:
      transform var(--t-med) var(--ease-out),
      visibility 0s;
  }
}

@media (prefers-reduced-motion: reduce) {
  /* Indeterminate mode: a static full-width bar at 40% opacity, no sweep. */
  #progress:not(.determinate):not([data-mode='determinate'])::before,
  #repl-progress:not(.determinate):not([data-mode='determinate'])::before {
    width: 100%;
    opacity: 0.4;
  }

  /* No caret rotation under reduced motion: swap the glyph so the expanded
     state still reads (right triangle -> down triangle). */
  .ex-group-head[aria-expanded='true'] .ex-group-caret::before {
    content: '\25be'; /* down-pointing small triangle */
  }
}

@keyframes pulse {
  50% {
    opacity: 0.35;
  }
}

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

@keyframes sweep {
  from {
    transform: translateX(-100%);
  }
  /* 250% of the 40%-wide bar = one full track width */
  to {
    transform: translateX(250%);
  }
}

@keyframes settle {
  from {
    opacity: 0;
    transform: scale(0.992); /* 0.8% rise, composited; no reflow, square corners untouched */
  }
}

@keyframes confirm {
  from {
    opacity: 0.55;
  }
}

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

@keyframes rise {
  from {
    opacity: 0;
    transform: translateY(-4px);
  }
}

/* ---- :help reference card (REPL) ----------------------------------------
   Structured usage/description grids per section; replaces the flowed text
   wall. Lives at file end deliberately: layout passes edit the sections
   above, and these rules are purely additive. */
#scrollback div.help {
  margin-left: var(--repl-indent);
  margin-top: 4px;
  color: var(--dim);
  font-size: var(--fs-out);
  line-height: 1.5;
  background: var(--panel);
  border: 1px solid var(--border);
  padding: 10px 12px;
  /* Notes carry slash-joined identifier runs (global_settings/camera/...)
     longer than a phone column; without this they bleed past the panel. */
  overflow-wrap: anywhere;
}
.help .help-head {
  color: var(--text);
  margin: 12px 0 4px;
}
.help .help-head:first-child {
  margin-top: 0;
}
.help dl.help-grid {
  display: grid;
  grid-template-columns: max-content 1fr;
  column-gap: 18px;
  row-gap: 2px;
  margin: 0;
}
/* --text, not --accent: the accent census (identity rule 1) is the render/run
   action, REPL prompt, figure provenance edge, links, focus ring, progress bar,
   and wordmark. Command names in the help card are emphasized via the brighter
   --text against the --dim descriptions, not painted accent. */
.help dt {
  color: var(--text);
  white-space: nowrap;
}
.help dd {
  margin: 0;
}
.help p.help-note {
  margin: 0 0 6px;
  max-width: 70ch;
}
.help p.help-note:last-child {
  margin-bottom: 0;
}
/* Narrow screens: usage above description instead of side-by-side. */
@media (max-width: 480px) {
  .help dl.help-grid {
    grid-template-columns: 1fr;
  }
  .help dl.help-grid dd {
    margin: 0 0 6px 12px;
  }
}

/* ---- keyboard-shortcuts overlay (UI page) --------------------------------
   The example-popover panel language verbatim: opaque --panel, one strong
   hairline, square corners, no shadow (the rendered image keeps the page's
   only shadow). Lives at file end like the :help card: purely additive. */

/* <kbd> chips (the overlay rows + the footer hint): bordered, square,
   chrome-sized, --border-strong like every other control boundary. */
kbd {
  font-family: inherit;
  font-size: var(--fs-chrome);
  border: 1px solid var(--border-strong);
  padding: 1px 5px;
  white-space: nowrap;
}

/* Fixed + centered; z-index clears the example popover (z-index: 20). The
   max-height + scroll keeps every binding reachable on a short viewport. */
#shortcuts {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  z-index: 30;
  width: min(92vw, 560px);
  max-height: min(80vh, 600px);
  overflow-y: auto;
  background: var(--panel);
  border: 1px solid var(--border-strong);
  padding: 16px;
}

/* Quiet section head, same uppercase-dim treatment as the category heads. */
#shortcuts h2 {
  margin: 0 0 12px;
  font-size: var(--fs-body);
  font-weight: 400;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--dim);
}

.shortcuts-list {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 8px 16px;
  margin: 0;
  font-size: var(--fs-out);
  line-height: 1.5;
}

.shortcuts-list dt {
  white-space: nowrap;
}

.shortcuts-list dd {
  margin: 0;
  color: var(--dim);
  align-self: center;
}

/* Footer hint: link-shaped (no box) but dim, not accent; the kbd chip carries
   the visual weight, so the hint reads as chrome, not a navigation link. */
#shortcuts-hint {
  height: auto;
  padding: 0;
  border: 0;
  background: none;
  color: var(--dim);
  font-size: inherit;
}

#shortcuts-hint:hover:not(:disabled) {
  background: none;
  color: var(--text);
}

/* ---- example gallery overlay ---------------------------------------------
   A visual browser for the same examples as the compact picker. It stays modal
   and fixed, so the editor chrome remains quiet until the user asks for it. */
#gallery {
  position: fixed;
  inset: 5vh 4vw;
  z-index: 35;
  display: grid;
  grid-template-rows: auto auto minmax(0, 1fr) auto;
  gap: 12px;
  background: var(--panel);
  border: 1px solid var(--border-strong);
  padding: 14px;
}

.gallery-head {
  display: flex;
  align-items: center;
  gap: 12px;
}

.gallery-head h2 {
  margin: 0;
  flex: 1;
  min-width: 0;
  color: var(--dim);
  font-size: var(--fs-body);
  font-weight: 400;
  text-transform: uppercase;
  letter-spacing: 0.06em;
}

.gallery-filters {
  display: grid;
  grid-template-columns: minmax(180px, 1.5fr) repeat(4, minmax(96px, 1fr)) auto;
  gap: 6px;
}

.gallery-filters > * {
  min-width: 0;
}

#gallery-grid {
  min-height: 0;
  overflow: auto;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
  grid-auto-rows: auto;
  align-content: start;
  align-items: start;
  gap: 10px;
  padding: 0 2px 12px 0;
  overscroll-behavior: contain;
}

.gallery-card {
  display: grid;
  grid-template-columns: 96px minmax(0, 1fr);
  gap: 8px;
  height: auto;
  min-height: 96px;
  align-content: start;
  align-items: start;
  padding: 8px;
  text-align: left;
  color: inherit;
  background: var(--bg);
  border-color: var(--border);
  overflow: hidden;
}

.gallery-card:hover:not(:disabled),
.gallery-card:focus-visible {
  background: var(--bg);
  border-color: var(--border-strong);
  box-shadow: inset 0 0 0 1px var(--border-strong);
  color: inherit;
}

.gallery-card[data-loaded='true'] {
  border-color: var(--border-strong);
  box-shadow: inset 3px 0 0 var(--text);
}

.gallery-card[data-loaded='true']:hover:not(:disabled),
.gallery-card[data-loaded='true']:focus-visible {
  box-shadow:
    inset 3px 0 0 var(--text),
    inset 0 0 0 1px var(--border-strong);
}

.gallery-card img {
  display: block;
  width: 100%;
  height: 72px;
  object-fit: cover;
  border: 1px solid var(--border);
  background: var(--mat) 0 0 / 16px 16px;
}

.gallery-card > span {
  min-width: 0;
  display: grid;
  gap: 3px;
  align-content: start;
  overflow: hidden;
}

.gallery-title {
  display: -webkit-box;
  overflow: hidden;
  color: var(--text);
  line-height: 1.3;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
}

.gallery-meta,
.gallery-license {
  display: -webkit-box;
  overflow: hidden;
  color: var(--dim);
  font-size: var(--fs-chrome);
  line-height: 1.3;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
}

.gallery-empty {
  margin: 0;
  color: var(--dim);
  font-size: var(--fs-out);
}

@media (max-width: 720px) {
  #gallery {
    inset: 0;
    padding: 10px;
  }

  .gallery-filters {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }

  #gallery-search {
    grid-column: 1 / -1;
  }

  #gallery-grid {
    grid-template-columns: minmax(0, 1fr);
  }
}
