/* ══════════════════════════════════════════════════════════════════════════
   mcubed — editor chrome

   Neutral, monochrome, low-ceremony. The interface should disappear behind the
   model: no warm cast, no coloured accent competing with the material preview,
   and only enough contrast to separate a pane from its neighbour.

   Three themes, switched by [data-theme] on <html>. Only the tokens change.
   ══════════════════════════════════════════════════════════════════════════ */

:root,
:root[data-theme='mono'] {
  --bg-app:    #0f0f10;
  --bg-panel:  #161617;
  --bg-raised: #1c1c1e;
  --bg-input:  #212123;
  --bg-hover:  #262628;

  --line:        rgba(255, 255, 255, 0.075);
  --line-strong: rgba(255, 255, 255, 0.15);

  /*
   * Three steps, and every one of them readable.
   *
   * `--text-faint` used to be #66666b — 3.17:1 on the panel, below the 4.5:1
   * that small text needs, and it is what section titles, counts, shortcuts,
   * the status line and every hint are set in. The whole interface read as
   * greyed out. The steps are now roughly 14:1 / 7.6:1 / 5.4:1 on the panel:
   * still a clear hierarchy, but the quietest of them is legible rather than
   * decorative. Every pair in all three themes is checked by
   * `tools/check-contrast.mjs`, which `npm run check` runs.
   */
  --text:       #e4e4e6;
  --text-dim:   #a8a8ad;
  --text-faint: #8c8c90;

  /* Monochrome accent: selection reads as brightness, not hue. */
  --accent:      #e8e8ea;
  --accent-soft: rgba(255, 255, 255, 0.10);
  --accent-line: rgba(255, 255, 255, 0.45);
  --accent-fg:   #0f0f10;

  --sel-r: 1;    --sel-g: 1;    --sel-b: 1;
  --hov-r: 0.35; --hov-g: 0.75; --hov-b: 1;
  --era-r: 0.85; --era-g: 0.35; --era-b: 0.28;

  /*
   * Lines in the viewport, by role rather than by one shared dimming factor.
   *
   * Three strengths that differ by a tenth of an alpha read as one grey: the
   * cage, the block grid and the sub-grid all landed on top of each other, and
   * an empty block was a wash of identical faint strokes. They are separated
   * here on purpose — the cage says where the block ends and is nearly solid,
   * the block lines are clearly readable, and the sub-grid is the only one that
   * stays quiet, because it is the one you are not measuring against.
   */
  --grid-l: 0.78;
  --cage-a: 0.92;
  --grid-a: 0.46;
  --grid-minor-a: 0.17;

  --stage-1: #1a1a1c;
  --stage-2: #0d0d0e;

  /*
   * Two reds, because the colour does two jobs.
   *
   * `--danger` is a fill with white text on it — one red cannot be both dark
   * enough for that (3.59:1 before) and light enough to read as text on a dark
   * panel. `--danger-text` is the one for words and for the marks that have to
   * be seen against the panel.
   */
  --danger:      #b8503c;
  --danger-text: #d98872;
  --warn:   #d3a95f;
  --ok:     #7fae8e;
}

:root[data-theme='vscode'] {
  --bg-app:    #1e1e1e;
  --bg-panel:  #252526;
  --bg-raised: #2d2d30;
  --bg-input:  #3c3c3c;
  --bg-hover:  #37373d;

  --line:        rgba(255, 255, 255, 0.08);
  --line-strong: #3c3c3c;

  /* VS Code's own greys, lifted one step each: this theme's input background is
     #3c3c3c, and against it the originals fell to 2.16:1. Kept in the same
     family, so it still reads as the editor it quotes. */
  --text:       #d4d4d4;
  --text-dim:   #b8b8b8;
  --text-faint: #a8a8a8;

  --accent:      #0e639c;
  --accent-soft: rgba(14, 99, 156, 0.30);
  --accent-line: #007acc;
  --accent-fg:   #ffffff;

  --sel-r: 0.0;  --sel-g: 0.48; --sel-b: 0.80;
  --hov-r: 0.30; --hov-g: 0.70; --hov-b: 1;
  --era-r: 0.95; --era-g: 0.30; --era-b: 0.30;
  --grid-l: 0.76;
  --cage-a: 0.92;
  --grid-a: 0.46;
  --grid-minor-a: 0.17;

  --stage-1: #232326;
  --stage-2: #161617;

  /* VS Code's own pair: #a1260d is what it fills a destructive button with,
     #f48771 what it writes an error in. */
  --danger:      #a1260d;
  --danger-text: #f48771;
  --warn:   #cca700;
  --ok:     #89d185;
}

:root[data-theme='light'] {
  --bg-app:    #f4f4f5;
  --bg-panel:  #fbfbfc;
  --bg-raised: #ffffff;
  --bg-input:  #ffffff;
  --bg-hover:  #eeeef0;

  --line:        rgba(0, 0, 0, 0.09);
  --line-strong: rgba(0, 0, 0, 0.18);

  --text:       #1b1b1d;
  --text-dim:   #4a4a50;
  --text-faint: #6c6c71;

  --accent:      #1b1b1d;
  --accent-soft: rgba(0, 0, 0, 0.07);
  --accent-line: rgba(0, 0, 0, 0.55);
  --accent-fg:   #ffffff;

  --sel-r: 0.1;  --sel-g: 0.1;  --sel-b: 0.12;
  --hov-r: 0.05; --hov-g: 0.42; --hov-b: 0.85;
  --era-r: 0.70; --era-g: 0.16; --era-b: 0.10;
  /* Dark lines on a pale stage: strength is depth of ink, not brightness. */
  --grid-l: 0.20;
  --cage-a: 0.80;
  --grid-a: 0.34;
  --grid-minor-a: 0.13;

  --stage-1: #e9e9ec;
  --stage-2: #d9d9de;

  /* Dark enough on white to read as text and to carry white text itself, so
     here the two roles are genuinely one colour. */
  --danger:      #b3281a;
  --danger-text: #b3281a;
  --warn:   #8a6100;
  --ok:     #2d6a44;
}

:root {
  --gutter: 10px;
  /* Room a menu keeps on its right for the chevron drawn under `select`. */
  --select-arrow: 18px;
  /* Height reserved at the bottom of the stage for the tool island. */
  --island-band: calc(52px * var(--ui-scale));

  /* Nested radii: an outer corner equals the inner corner plus the padding
     between them, so concentric boxes stay visually parallel. */
  --pad: 2px;
  --r-sm: 2px;
  --r: 4px;                              /* controls: --r-sm + --pad */
  --r-lg: 6px;                           /* panels:   --r + --pad */

  /* Type scale. `--fs-base` is the body size in px and everything is a fixed
     step off it, so the setting reads as a real font size rather than a percent.
     `--ui-scale` follows it so control heights keep pace with the text. */
  --fs-base: 13px;
  --ui-scale: 1;
  --fs-xs: calc(var(--fs-base) - 3px);
  --fs-sm: calc(var(--fs-base) - 2px);
  --fs-md: calc(var(--fs-base) - 1px);
  --fs-lg: var(--fs-base);
  --row: calc(25px * var(--ui-scale));

  --font-ui: system-ui, -apple-system, 'Segoe UI', sans-serif;
  --ui: var(--font-ui);
  /*
   * What the world is set in: the game's own lettering.
   *
   * Always this stack, and no setting. Standing in the world is a mode you
   * enter deliberately and leave deliberately, and everything on screen there
   * is the game's furniture — so the font is part of the mode rather than a
   * preference to go and find. Outside it nothing refers to `--hud`, so the
   * editor is untouched.
   *
   * The fallback is the interface font, not a generic. The atlases cover Latin,
   * Cyrillic, Greek and a good deal besides, but not everything — unifont, the
   * game's own fallback for the rest, is a separate 16 MB archive and is not
   * built in — and on a machine with no Minecraft the `@font-face` never
   * resolves at all. Either way the next family carries it.
   */
  --hud: 'Minecraft', var(--font-ui);
  --mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace;
  --ease: cubic-bezier(0.32, 0.72, 0, 1);
}

/*
 * The game's own lettering, for the world's own HUD.
 *
 * Minecraft ships no vector font — its type is bitmap atlases inside the client
 * jar — so this file is assembled by the server from the copy already installed
 * on this machine and nothing is redistributed. On a machine with no game the
 * route 404s, the `@font-face` never resolves, and the next family in `--hud`
 * carries the world: no check to write and nothing to switch off.
 *
 * `font-display: block` rather than `swap`: the world's HUD is a few short
 * labels, and a chat line that arrives in one face and reflows into another a
 * moment later is worse than one that arrives a moment late.
 */
/*
 * And where the game is not installed: Monocraft, under the SIL Open Font
 * License.
 *
 * Minecraft ships **no vector font** — the letters are pixels in three PNG
 * atlases inside the jar, and `mcfont.ts` traces them into a `glyf` table on
 * the machine that owns the game. A host has no jar, so it has no letters to
 * trace, and putting the traced result on a public domain would be handing
 * Mojang's own artwork to strangers.
 *
 * Monocraft is drawn from scratch to look like it and may be redistributed, so
 * it is what the hosted editor wears. Not a replacement: where the real one can
 * be built it is built, because it is the game's own metrics to the pixel.
 * `bootstrap` says which host this is (`mcFont`) and the client points `--hud`
 * at one or the other.
 */
@font-face {
  font-family: 'Monocraft';
  src: url('/font/monocraft.ttf') format('truetype');
  font-weight: 400;
  font-style: normal;
  font-display: block;
}

@font-face {
  font-family: 'Minecraft';
  src: url('/font/minecraft.ttf') format('truetype');
  font-weight: 400;
  font-style: normal;
  font-display: block;
}

:root[data-font='inter']  { --font-ui: 'Inter', system-ui, sans-serif; }
:root[data-font='golos']  { --font-ui: 'Golos Text', system-ui, sans-serif; }
:root[data-font='plex']   { --font-ui: 'IBM Plex Sans', system-ui, sans-serif; }
:root[data-font='system'] { --font-ui: system-ui, -apple-system, 'Segoe UI', sans-serif; }
/*
 * The fallback after 'Minecraft' is the interface font, not a generic.
 *
 * The atlases cover Latin, Cyrillic, Greek and a good deal besides, but they do
 * not cover everything — unifont, the game's own fallback for the rest, is a
 * separate 16 MB archive and is not built in. A codepoint the pixel font lacks
 * therefore falls to the next family in this stack, and the next family should
 * be whatever the rest of the editor is set in.
 */
/*
 * In the world, every text is the game's text.
 *
 * Done by pointing `--ui` and `--mono` at `--hud` rather than by listing the
 * elements: the world holds a chat, a hotbar, a held-item label, a hint, an
 * inventory and a chat field, and a list of them is a list that goes stale the
 * next time something is added. `body.in-world` is already the class that says
 * the whole window belongs to the world — see `updatePlayerChrome` — so the
 * typeface is simply part of what that means.
 *
 * **One size, which is the game's arrangement and not a shortcut.** Minecraft's
 * HUD has exactly one type size, so the four steps of the editor's scale
 * collapse to one here. Everything is counted in **game pixels** — `--hud-px`
 * is what one of them is worth in CSS, and `hudPixel()` picks it so that it
 * comes out a whole number of *device* pixels. That distinction is the whole
 * arithmetic: measured in CSS pixels the only sizes available are 8, 16 and 24,
 * and the HUD is then either shouting over the editor or unreadable, with
 * nothing in between; measured in device pixels a 2× display also has 12.
 *
 * **Smoothing off**, because antialiasing a shape already made of squares only
 * blurs its edges — the same argument as NEAREST magnification in `texArray`.
 */
body.in-world {
  --ui: var(--hud);
  --mono: var(--hud);
  --fs-base: calc(8 * var(--hud-px));
  --fs-xs: var(--fs-base);
  --fs-sm: var(--fs-base);
  --fs-md: var(--fs-base);
  --fs-lg: var(--fs-base);
  -webkit-font-smoothing: none;
  -moz-osx-font-smoothing: grayscale;
}

/*
 * Nine pixels between lines, which is `Font.lineHeight`.
 *
 * Only on the two that are the game's own text. Everything else in here sets
 * its font with the `font` shorthand, which resets line-height to `normal`
 * whatever is inherited — so a rule on the body would reach exactly the
 * elements that have no opinion, and these two have one.
 */
body.in-world .chat__line {
  line-height: calc(var(--chat-line) * var(--hud-px));
}
body.in-world .held-name {
  line-height: calc(9 * var(--hud-px));
}

/*
 * The slot digit is the one thing that does not take the HUD's size.
 *
 * It is a corner mark on a 38-pixel square, and a full-size numeral fills it.
 * Six game pixels rather than eight — which is not a whole device pixel down
 * and does not need to be: a digit is five pixels tall and flat-sided, so it is
 * the one glyph in the HUD that shows nothing at a half-pixel edge.
 */
body.in-world .hotbar__num {
  font-size: calc(6 * var(--hud-px));
  line-height: 1;
}

/* Held back until the interface has its text; see the inline script in the
   document head for why. */
html:not(.is-ready) body { visibility: hidden; }

* { box-sizing: border-box; }

html, body { height: 100%; margin: 0; overflow: hidden; }
/*
 * The viewport a phone is actually showing.
 *
 * On iOS `100%` resolves against the layout viewport, which includes the strip
 * behind Safari's own bars — so the interface is laid out taller than the
 * screen, the bottom of it lives under the toolbar, and what is on screen is
 * everything shifted up with a band of nothing beneath. `dvh` is the viewport
 * as it stands right now, and it changes as those bars come and go.
 */
@supports (height: 100dvh) {
  html, body { height: 100dvh; }
}

body {
  display: grid;
  /*
   * Named rows, because a hidden bar is not in the flow.
   *
   * With auto-placement the three tracks are handed out in document order, so
   * taking either bar away slid everything after it up a row: hiding the status
   * line put nothing in the last track, which is harmless — and hiding the top
   * bar, which the world does on a phone, put the whole editor in the 40px row
   * meant for the bar and gave the 1fr to the status line. The stage was
   * squashed into a strip at the top with an empty screen under it.
   *
   * Each child says which row it is in, so removing any of them leaves the rest
   * where they are. The same fix as `.shell`'s columns, one axis over.
   */
  grid-template-rows: auto 1fr auto;
  grid-template-areas: "bar" "main" "status";
  background: var(--bg-app);
  color: var(--text);
  font: 400 var(--fs-lg)/1.45 var(--ui);
  -webkit-font-smoothing: antialiased;
  user-select: none;
}

/* Restrained section label — uppercase, but normal width and modest tracking. */
h2, h3, .lbl {
  font-family: var(--ui);
  text-transform: uppercase;
  letter-spacing: 0.07em;
  font-weight: 600;
  font-size: var(--fs-xs);
  color: var(--text-faint);
  margin: 0;
}

kbd {
  font: 500 var(--fs-xs)/1 var(--mono);
  padding: 1px 4px;
  border: 1px solid var(--line-strong);
  border-radius: var(--r);
  background: var(--bg-input);
  color: var(--text-dim);
  white-space: nowrap;
}

/* ─────────────────────────────────────────────────────────── bars */

.bar {
  display: flex;
  align-items: center;
  /* The height is the bar's own now that the rows are `auto`; the grid used to
     be the only thing setting it. */
  height: calc(40px * var(--ui-scale));
  gap: calc(6px * var(--ui-scale));
  padding: 0 10px;
  background: var(--bg-panel);
  border-bottom: 1px solid var(--line);
  /* Rather than wrap into a second row — which would shift the whole canvas —
     the bar scrolls when the controls no longer fit. */
  overflow-x: auto;
  scrollbar-width: none;
}
.bar::-webkit-scrollbar { display: none; }
.bar > * { flex: none; }
.bar__spacer { flex: 1 1 auto; min-width: 4px; }
.bar--top { grid-area: bar; }

/*
 * Room for the window's own buttons.
 *
 * The desktop shell puts the traffic lights *inside* the top bar rather than on
 * a title bar above it — that is what stops the application looking like a web
 * page in a frame. They are drawn by the system on top of the page, though, so
 * without a reserved strip they land on whatever the bar starts with: in the
 * first build they sat squarely on the brand mark and the breadcrumb.
 *
 * **Every bar that can be the top one needs it.** There are two: the editor's
 * and the pack browser's, and the browser is what opens first — reserving the
 * strip on only one of them fixes the half you happen to be looking at.
 *
 * The inset is generous rather than exact. Three buttons from x=16 end near
 * 86px; a strip measured to the pixel leaves the brand touching the green one,
 * which reads as an overlap even when it is not.
 *
 * Declared on the class rather than on `:root`: the contrast check reads the
 * themes out of the `:root` blocks, and an extra one holding a length is a
 * theme with no colours in it as far as that check is concerned.
 */
.is-app { --titlebar-inset: 104px; }

.is-app .bar--top,
.is-app .home__bar { padding-left: var(--titlebar-inset); }

/*
 * The bar is also the window's drag handle, the way it is in an editor with no
 * title bar. Everything you can actually press opts back out, or the press
 * starts dragging the window instead.
 */
.is-app .bar--top,
.is-app .home__bar { -webkit-app-region: drag; }
.is-app .bar--top button,
.is-app .bar--top input,
.is-app .bar--top select,
.is-app .bar--top .split,
.is-app .bar--top a,
.is-app .home__bar button,
.is-app .home__bar input,
.is-app .home__bar select,
.is-app .home__bar a { -webkit-app-region: no-drag; }
.bar--status {
  grid-area: status;
  height: calc(24px * var(--ui-scale));
  border-bottom: 0;
  border-top: 1px solid var(--line);
  gap: 0;
  background: var(--bg-app);
}

.brand { display: flex; align-items: center; gap: 7px; }
/*
 * The mark is the application's own logo, not a glyph drawn in CSS.
 *
 * It used to be three stacked rules — a fine abstract mark, and one that had
 * nothing to do with the icon in the dock. The same object in both places is
 * what makes a window recognisable as an application's rather than as a page's.
 *
 * No plate behind it: the icon's black tile is right in a dock, where it sits
 * against a wallpaper, and wrong here, where it would be a hole punched in the
 * bar. `tools/icon.ts` renders that bare form separately.
 */
.brand__mark {
  /* Alone in the corner it carries the name, so it is sized to be read as a
     logo rather than as a bullet beside one. */
  width: calc(21px * var(--ui-scale));
  height: calc(21px * var(--ui-scale));
  background: url('mark.png') center / contain no-repeat;
}

/*
 * Nothing in the bar shrinks.
 *
 * Letting items flex down looked tidy until the window got narrow: the name
 * collapsed to zero width while its input kept its intrinsic size and painted
 * straight over the save indicator. A fixed width plus an ellipsis is honest,
 * and the bar scrolls when the controls genuinely do not fit.
 */
.doc-name { flex: none; display: flex; align-items: center; }
/*
 * The field is exactly as wide as its text, measured in `sizeNameField`.
 * A fixed width left a gap between the name and the save indicator that read as
 * a layout bug — and only revealed itself as an input on hover.
 */
.doc-name input {
  width: 0;
  min-width: calc(40px * var(--ui-scale));
  max-width: calc(280px * var(--ui-scale));
  text-overflow: ellipsis;
  background: transparent;
  border: 1px solid transparent;
  border-radius: var(--r);
  color: var(--text-dim);
  font: 400 var(--fs-md) var(--ui);
  padding: 3px 6px;
  outline: none;
}
/*
 * The underline is always there.
 *
 * A field that only becomes a field on hover is a field nobody finds: the name
 * read as a label, and renaming a block was a thing you had to be told about.
 * One rule is enough — the rest of the box still waits for the pointer.
 */
.doc-name input { border-bottom-color: var(--line-strong); border-radius: 0; }
.doc-name input:hover { border-color: var(--line); border-bottom-color: var(--text-faint); border-radius: var(--r); }
.doc-name input:focus {
  border-color: var(--accent-line); border-radius: var(--r);
  color: var(--text); background: var(--bg-input);
}

/* ─────────────────────────────────────────────────────────── tools */

/*
 * No `overflow: hidden` here.
 *
 * It was clipping the children to the group's rounded corners, and clipping the
 * tooltips along with them — every tool inside a group was silently muted while
 * the standalone eraser worked. The end buttons carry the radius themselves.
 */
.toolgroup { display: flex; border: 1px solid var(--line-strong); border-radius: var(--r); }
.toolgroup > :first-child { border-radius: calc(var(--r) - 1px) 0 0 calc(var(--r) - 1px); }
.toolgroup > :last-child  { border-radius: 0 calc(var(--r) - 1px) calc(var(--r) - 1px) 0; }
.toolgroup > :only-child  { border-radius: calc(var(--r) - 1px); }

.tool {
  display: flex; align-items: center; gap: 6px;
  height: var(--row); padding: 0 9px;
  background: transparent;
  border: 0;
  border-right: 1px solid var(--line);
  color: var(--text-dim);
  font: 400 var(--fs-sm) var(--ui);
  cursor: pointer;
  transition: color .1s var(--ease), background .1s var(--ease);
}
/*
 * Only inside a group. The separator between segments belongs to the group, and
 * `.tool:last-child` also caught a standalone toggle that happened to be last
 * in the island — which is what left the Animation button with three sides once
 * it moved to the end of the row.
 */
.toolgroup > .tool:last-child { border-right: 0; }
/*
 * The shortcut is mono; the name is not.
 *
 * This used to be `.tool span`, written when the markup carried the key in a
 * `<span data-key>`. `applyIcon` replaced that markup with a `.tool__label`
 * span, so the selector kept matching and started dressing the *name* — every
 * docked tool in mono, two sizes down and faint, while the shape buttons, which
 * are `.board-mode` rather than `.tool`, kept the interface font. Naming the
 * part instead of the tag is what stops that happening again.
 */
.tool:hover { background: var(--bg-hover); color: var(--text); }
.tool.is-active { color: var(--accent-fg); background: var(--accent); }
.tool.is-active .tool__key { color: var(--accent-fg); opacity: .7; }

.snap { display: flex; align-items: center; gap: 4px; flex: none; min-width: 0; }
.snap select { padding: 0 var(--select-arrow) 0 4px; }
/* The label gives the width up before the field does: the value is the part you
   read, and "СЕТКА" is the same word whether or not its last letters show. */
.snap .lbl { letter-spacing: .04em; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
select, .btn {
  height: var(--row);
  background: var(--bg-input);
  border: 1px solid var(--line-strong);
  border-radius: var(--r);
  color: var(--text);
  font: 400 var(--fs-sm) var(--ui);
  padding: 0 7px;
  outline: none;
  cursor: pointer;
}
/*
 * The arrow is drawn here, and the room for it is real padding.
 *
 * A native menu keeps its arrow inside the box and lays the text straight under
 * it once the box is narrow — the grid field in a docked tools panel read
 * "32×32×32" through its own chevron. WebKit goes the other way and drops the
 * arrow entirely as soon as the background is styled, so the control stopped
 * saying it was a menu at all. Two gradients rather than an inline SVG, because
 * a `data:` URI cannot reach `--text-faint` and would fix one colour for both
 * themes.
 */
select {
  font-family: var(--mono); font-size: var(--fs-sm);
  appearance: none; -webkit-appearance: none;
  padding-right: var(--select-arrow);
  background-image:
    linear-gradient(45deg, transparent 50%, var(--text-faint) 50%),
    linear-gradient(135deg, var(--text-faint) 50%, transparent 50%);
  background-position: right 10px center, right 6px center;
  background-size: 4px 4px;
  background-repeat: no-repeat;
}
select:focus, .btn:focus-visible { border-color: var(--accent-line); }
.btn { padding: 0 11px; color: var(--text-dim); }
.btn:hover { background: var(--bg-hover); color: var(--text); }
.btn--primary {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-fg);
  font-weight: 500;
}
.btn--primary:hover { background: var(--accent); color: var(--accent-fg); filter: brightness(1.12); }
.btn--ghost { background: transparent; }
.btn--icon { padding: 0 8px; font-family: var(--mono); font-size: var(--fs-sm); }
/* A bar button that is an icon rather than a word: square, and the glyph sits
   on its optical centre rather than on a text baseline. */
.btn--glyph { width: var(--row); padding: 0; display: inline-flex; align-items: center; justify-content: center; }
/* Height, not width: the figure is twice as tall as it is wide, and forcing it
   into a square would squash it. */
.btn--glyph svg { width: auto; height: 20px; }
.btn.is-active { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }

/* ─────────────────────────────────────────────────────────── shell */

.shell {
  grid-area: main;
  position: relative;
  display: grid;
  /* Panels scale with the type so bigger text means more room, not tighter
     columns; clamped so they never eat the canvas on a small screen. */
  /* A dragged width wins; until then the columns follow the type scale, so
     making the text bigger gives the panels room rather than squeezing them. */
  /* Named so a panel can be taken out of the layout without restating the
     whole track list: an empty one collapses its column to nothing. */
  --col-left: var(--left-w, clamp(200px, calc(228px * var(--ui-scale)), 34vw));
  --col-right: var(--right-w, clamp(228px, calc(258px * var(--ui-scale)), 36vw));
  grid-template-columns: auto var(--col-left) auto minmax(0, 1fr) auto var(--col-right) auto;
  /*
   * Named tracks, because a hidden panel is not in the flow.
   *
   * With auto-placement, taking one panel out of the grid slid everything after
   * it up a column: emptying the right-hand panel put the stage in the stage's
   * track and nothing in the last one, which is harmless — but emptying the
   * left put the stage in the *left* track, a fixed width, and handed the whole
   * 1fr to the panel next to it. Each child says which track it belongs to, so
   * removing any of them leaves the rest where they are.
   */
  grid-template-areas: "edge-l left inner-l stage inner-r right edge-r";
  min-height: 0;
}
/*
 * The outermost columns are the island's upright shelves.
 *
 * They are tracks of the shell, not of the stage, and they sit just inside the
 * panels: the shelf lands against the panel's own edge, so the panel keeps the
 * window's side and the tools stand next to it. With no panel on that side the
 * track is the window's edge by itself. An empty auto track collapses to
 * nothing, so the columns cost nothing while the island is elsewhere.
 */
.panel--left { grid-area: left; }
.stage { grid-area: stage; }
.panel--right { grid-area: right; }
.shell.no-left { --col-left: 0px; }
.shell.no-right { --col-right: 0px; }

.panel { position: relative; display: flex; flex-direction: column; background: var(--bg-panel); min-height: 0; overflow: hidden; }
/*
 * The panel's own handle: the strip above its first section.
 *
 * Free space, and not by accident — the first section has no sash, because
 * there is nothing above it to resize against. What is drawn is a line along
 * the very top when the pointer is on it; what is grabbed is seven pixels, the
 * same split between mark and catch the rail and the splitters make.
 */
.panel__grab {
  position: absolute; top: 0; left: 0; right: 0;
  height: 7px;
  z-index: 11;
  cursor: grab;
}
.panel__grab::after {
  content: ''; position: absolute; left: 0; right: 0; top: 0;
  height: 2px;
  background: transparent; transition: background 90ms ease-out;
}
.panel__grab:hover::after,
.panel.is-moving .panel__grab::after { background: var(--accent); }
.panel.is-moving { opacity: .6; }
.panel.is-moving .panel__grab { cursor: grabbing; }
.panel--left { border-right: 1px solid var(--line); }
.panel--right { border-left: 1px solid var(--line); }
/*
 * An empty panel takes no width.
 *
 * Move both sections out of one and what is left is a blank column with a
 * border and a splitter, holding width away from the model to show nothing.
 * `display: none` rather than zero width, so its border goes too; the edge it
 * stood on still accepts a drop and brings it back — `edgeAt` in app.js.
 */
.panel.is-empty { display: none; }
.splitter.is-hidden { display: none; }
/* Taken out of the bar by the scene menu, which carries the same controls. */
.is-off { display: none !important; }
/* Where a section dropped on the edge would put the panel back. */
.stage.is-edge-left::before,
.stage.is-edge-right::before {
  content: '';
  position: absolute; top: 0; bottom: 0;
  width: 3px;
  background: var(--accent);
  z-index: 20;
  pointer-events: none;
}
.stage.is-edge-left::before { left: 0; }
.stage.is-edge-right::before { right: 0; }
/* Where an upright shelf would stand: the outermost edge of the window. */
.shell { position: relative; }
.shell.is-edge-left::before,
.shell.is-edge-right::before {
  content: '';
  position: absolute; top: 0; bottom: 0;
  width: 3px;
  background: var(--accent);
  z-index: 20;
  pointer-events: none;
}
.shell.is-inner-left::before,
.shell.is-inner-right::before {
  content: '';
  position: absolute; top: 0; bottom: 0;
  width: 3px;
  background: var(--accent);
  z-index: 20;
  pointer-events: none;
}
/* Outside the panels, and inside them: the mark stands where the shelf will. */
.shell.is-edge-left::before { left: 0; }
.shell.is-edge-right::before { right: 0; }
.shell.is-inner-left::before { left: var(--left-edge, 0px); }
.shell.is-inner-right::before { right: var(--right-edge, 0px); }

/*
 * The handle is wider than the line it sits on.
 *
 * A 1px border is an impossible target and a 6px handle drawn as 6px of colour
 * is a bar through the interface; this is the same split the layer rail makes
 * between what is drawn and what is grabbed.
 */
.splitter {
  position: absolute; top: 0; bottom: 0;
  width: 9px;
  z-index: 8;
  cursor: col-resize;
  background: transparent;
  transition: background 90ms ease-out;
}
.splitter::after {
  content: ''; position: absolute; inset: 0 4px;
  background: transparent; transition: background 90ms ease-out;
}
/* `is-hot` is the far edge of a corner drag: the one you are not pointing at
   but are about to move anyway, lit so the corner reads as two edges. */
.splitter:hover::after,
.splitter.is-dragging::after,
.splitter.is-hot::after { background: var(--accent-line); }
/* Where a horizontal edge meets a vertical one, the drag takes both. */
.splitter.is-corner, .acc__sash.is-corner { cursor: move; }
.splitter--left { left: calc(var(--left-edge, 228px) - 4px); }
.splitter--right { right: calc(var(--right-edge, 258px) - 4px); }
.splitter--h {
  top: -4px; bottom: auto; left: 0; right: 0;
  width: auto; height: 9px;
  cursor: row-resize;
}
.splitter--h::after { inset: 4px 0; }

.count { font: 400 var(--fs-xs) var(--mono); color: var(--text-faint); }

/*
   View switch in the section header, tucked in before the count.

   The count is the last thing on the row, against the edge, because that is
   where every list in this interface puts its number — a control sitting
   outside it made the materials header the one place the eye had to hunt. */
/*
 * The header's own controls, as one strip.
 *
 * They were two absolutely positioned things a fixed distance apart, and the
 * distance was a guess about how wide the count would be — at a narrow panel
 * that guess left a hole between the switch and the number while the title
 * behind them ran out of room. One flex row, one gap.
 */
.acc__tail {
  position: absolute; top: 0; right: var(--gutter);
  display: flex; align-items: center; gap: calc(5px * var(--ui-scale));
  height: calc(26px * var(--ui-scale));
  /* Above the sash, which lies across the top of the section and would
     otherwise take the first few pixels of these buttons. */
  z-index: 10;
}
.acc__views { display: flex; align-items: center; gap: 1px; }
.acc__view {
  width: calc(20px * var(--ui-scale)); height: calc(18px * var(--ui-scale));
  display: grid; place-items: center;
  background: none; border: 1px solid transparent; border-radius: var(--r-sm);
  color: var(--text-faint); cursor: pointer; padding: 0;
}
.acc__view:hover { color: var(--text); background: var(--bg-hover); }
.acc__view.is-active { color: var(--text); border-color: var(--line-strong); }
.acc__view svg { width: 11px; height: 11px; fill: currentColor; }
/* Room for the strip: two buttons, a gap and the count. */
.acc[data-acc='materials'] .acc__head { padding-right: calc(var(--gutter) + 62px * var(--ui-scale)); }
.acc__tail .count { min-width: calc(14px * var(--ui-scale)); text-align: right; }
/*
 * Folded, the view switch goes: it changes how a list is laid out, and there is
 * no list. The count stays — how much is in there is exactly what you want from
 * a section you have put away — and the title takes back the room.
 */
.acc.is-collapsed .acc__views { display: none; }
.acc.is-collapsed[data-acc='materials'] .acc__head { padding-right: calc(var(--gutter) + 22px * var(--ui-scale)); }

/* ─────────────────────────────────────────────────────────── accordion

   Sidebar sections behave like VS Code's: a header row that collapses its
   content. Exactly one section per panel carries `--grow`, so the panel always
   has something that fills the leftover height instead of floating mid-air. */

/*
 * Every expanded section shares the leftover height, so collapsing one hands
 * its space to its neighbours — the behaviour VS Code's sidebar has. A section
 * marked `--grow` simply takes a larger share of it.
 */
.acc {
  display: flex;
  flex-direction: column;
  min-height: 0;
  /*
   * The share, not the height.
   *
   * Sections are resized against each other by moving the boundary between two
   * of them, and the result has to survive the panel itself changing size — so
   * what a drag writes is each section's share of the column, in a custom
   * property rather than in `flex` outright: `.acc.is-collapsed` has to be able
   * to win, and an inline `flex` would outrank it.
   */
  flex: var(--acc-grow, 1) 1 0;
  border-bottom: 1px solid var(--line);
  /*
   * Collapsing is animated, which needs both states to be numbers.
   *
   * `flex: 0 0 auto` cannot be interpolated from `1 1 0`, so a collapsed
   * section states its basis outright — the header's own height — and the
   * transition then has two ends to travel between. The body is clipped by the
   * section rather than switched off, because `display: none` has no middle.
   */
  overflow: hidden;
  transition: flex-grow .17s var(--ease), flex-basis .17s var(--ease);
}
.acc--grow { --acc-grow: 2; }
.acc.is-collapsed { flex: 0 0 calc(26px * var(--ui-scale)); }
/* A drag has to track the pointer, and an eased flex lags a whole frame behind
   it — the boundary would arrive where the pointer had been. */
.panel.is-sizing .acc { transition: none; }

/*
 * The boundary between two sections is a handle, as it is in VS Code.
 *
 * Without it the top pixels of a header were the only thing there, and the
 * header is a drag handle for reordering — so reaching for the edge between two
 * sections started carrying the lower one away instead of resizing the upper.
 * Drawn nowhere: the border already draws the line, and this is only the catch
 * around it, wider than the line the way every other handle here is.
 */
.acc__sash {
  position: absolute; left: 0; right: 0; top: 0;
  height: 7px;
  z-index: 9;
  cursor: row-resize;
  background: transparent;
}
.acc__sash::after {
  content: ''; position: absolute; left: 0; right: 0; top: 3px;
  height: 1px;
  background: transparent; transition: background 90ms ease-out;
}
.acc__sash:hover::after,
.acc__sash.is-dragging::after,
.acc__sash.is-hot::after { background: var(--accent-line); }
/* The first section in a panel has nothing above it to resize against, and a
   collapsed one has a height of its own that a drag must not argue with. */
.acc:first-child > .acc__sash,
.acc.is-collapsed > .acc__sash { display: none; }
/*
 * A closing section stops taking clicks at once, but only goes `hidden` after
 * it has finished sliding — hiding it on the first frame would make the content
 * vanish and leave an empty box shrinking, which is not the same gesture.
 */
.acc__body { transition: visibility 0s; }
.acc.is-collapsed .acc__body { pointer-events: none; visibility: hidden; transition: visibility 0s linear .17s; }

@media (prefers-reduced-motion: reduce) {
  .acc { transition: none; }
}

.acc__head {
  display: flex; align-items: center; gap: 6px;
  width: 100%;
  height: calc(26px * var(--ui-scale));
  padding: 0 var(--gutter) 0 6px;
  background: none;
  border: 0;
  cursor: pointer;
  flex: none;
}
.acc__head:hover { background: var(--bg-hover); }
/*
 * The title gives way first.
 *
 * A flex item refuses to shrink below its own text, so a long title in a narrow
 * panel simply grew past the header and ran under the controls pinned to its
 * right edge — the view switch printed over the middle of МАТЕРИАЛЫ. `min-width`
 * lets it shrink and the ellipsis says that it did.
 */
.acc__head h2 { flex: 1; min-width: 0; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.acc__head:hover h2 { color: var(--text-dim); }

.acc__chev {
  width: 10px; height: 10px;
  flex: none;
  position: relative;
  transition: transform .13s var(--ease);
}
.acc__chev::before {
  content: '';
  position: absolute; left: 2px; top: 1px;
  width: 5px; height: 5px;
  border-right: 1.5px solid var(--text-faint);
  border-bottom: 1.5px solid var(--text-faint);
  transform: rotate(45deg);
}
.acc.is-collapsed .acc__chev { transform: rotate(-90deg); }

.library, .outliner { max-height: none; }

.acc { position: relative; }

.acc__body {
  display: flex;
  flex-direction: column;
  min-height: 0;
  flex: 1 1 auto;
  overflow: hidden;
}

.search { padding: 7px var(--gutter); border-bottom: 1px solid var(--line); flex: none; }
.search input {
  width: 100%;
  background: var(--bg-input);
  border: 1px solid var(--line);
  border-radius: var(--r);
  color: var(--text);
  font: 400 var(--fs-sm) var(--ui);
  padding: 4px 7px;
  outline: none;
}
.search input::placeholder { color: var(--text-faint); }
.search input:focus { border-color: var(--accent-line); }

/* ─────────────────────────────────────────────────────────── material swatches */

/* Two densities: a grid when you are choosing by look, a list when you are
   choosing by name. Same markup, different layout. */
.swatches[data-view='list'] {
  display: flex;
  flex-direction: column;
  gap: 1px;
}
.swatches[data-view='list'] .swatch {
  flex-direction: row;
  align-items: center;
  gap: 8px;
  padding: 3px;
}
.swatches[data-view='list'] .swatch__chip {
  width: calc(22px * var(--ui-scale));
  aspect-ratio: 1;
  flex: none;
}
.swatches[data-view='list'] .swatch__name { padding: 0; }
.swatches[data-view='list'] .swatch__meta {
  display: block;
  margin-left: auto;
  padding: 0 4px 0 0;
  font: 400 var(--fs-xs) var(--mono);
  color: var(--text-faint);
  text-transform: uppercase;
}

/* The palette is a column of groups; the swatches inside each are the grid. */
.swatches {
  display: flex; flex-direction: column;
  gap: 2px;
  padding: var(--gutter);
  overflow-y: auto;
  flex: 1;
}
.matgroup__items {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 4px;
  align-content: start;
  margin-bottom: 4px;
}
.swatches[data-view='list'] .matgroup__items { display: flex; flex-direction: column; gap: 1px; }

.matnew {
  display: flex; align-items: center; gap: 6px;
  margin-bottom: 2px; padding: 4px 6px;
  background: none;
  border: 1px dashed var(--line-strong); border-radius: var(--r);
  color: var(--text-dim); font: 400 var(--fs-xs) var(--ui);
  text-align: left; cursor: pointer;
}
.matnew:hover { color: var(--text); border-color: var(--accent-line); background: var(--bg-hover); }
.matnew__plus { font: 500 var(--fs-md) var(--ui); line-height: 1; }

.matgroup {
  display: flex; align-items: center; gap: 6px;
  padding: 4px 2px;
  background: none; border: 0; border-radius: var(--r-sm);
  color: var(--text-dim); font: 500 var(--fs-xs) var(--ui);
  text-align: left; cursor: pointer;
}
.matgroup:hover { color: var(--text); background: var(--bg-hover); }
.matgroup__count { margin-left: auto; font: 400 var(--fs-xs) var(--mono); color: var(--text-faint); }
/* A triangle rather than a glyph: it turns, and a rotating character wobbles
   on its own baseline. */
.matgroup__chev {
  width: 0; height: 0;
  border-left: 4px solid currentColor;
  border-top: 3.5px solid transparent;
  border-bottom: 3.5px solid transparent;
  transform: rotate(90deg);
  transition: transform 110ms ease-out;
}
.matgroup.is-folded .matgroup__chev { transform: rotate(0deg); }

/*
 * A <button> lays its children out inside an anonymous box, so a child with
 * `aspect-ratio` will happily overflow it — which silently pushed the labels
 * out of view. Explicit flex layout makes the button size to its content.
 */
.swatch {
  position: relative;
  display: flex;
  flex-direction: column;
  border: 1px solid var(--line);
  border-radius: var(--r);
  background: var(--bg-raised);
  cursor: pointer;
  padding: var(--pad);
  text-align: left;
  transition: border-color .1s var(--ease);
}
.swatch:hover { border-color: var(--line-strong); }

/* Selected state as a ring outside the swatch: unmistakable, and it does not
   crop the texture the way an inset border does. */
.swatch.is-active { border-color: var(--accent-line); }
.swatch.is-active::after {
  content: '';
  position: absolute;
  inset: calc(-1 * var(--pad) - 1px);
  border: 2px solid var(--accent-line);
  border-radius: calc(var(--r) + var(--pad));
  pointer-events: none;
}

.swatch__chip {
  display: block;
  aspect-ratio: 1;
  image-rendering: pixelated;
  background-size: 200% 200%;
  background-position: center;
  border-radius: var(--r-sm);
  flex: none;
}
.swatch__name {
  display: block;
  padding: 4px 1px 0;
  font-size: var(--fs-xs);
  line-height: 1.25;
  color: var(--text-dim);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.swatch__meta { display: none; }

/* Same hairline cross as the new-project tile, so "add" reads the same way in
   both grids rather than being a plus in one place and a button in another. */
.swatch--new .swatch__chip { position: relative; background: none; }
.swatch--new .swatch__chip::before,
.swatch--new .swatch__chip::after {
  content: '';
  position: absolute; top: 50%; left: 50%;
  background: var(--text-faint);
  transition: background-color var(--t) var(--ease);
}
.swatch--new .swatch__chip::before { width: 13px; height: 1px; transform: translate(-50%, -50%); }
.swatch--new .swatch__chip::after  { width: 1px; height: 13px; transform: translate(-50%, -50%); }
.swatch--new:hover .swatch__chip::before,
.swatch--new:hover .swatch__chip::after { background: var(--text); }
.swatch[data-render='translucent'] .swatch__chip {
  background-image:
    repeating-linear-gradient(45deg, rgba(255,255,255,.08) 0 3px, transparent 3px 6px),
    var(--chip);
}

/* ─────────────────────────────────────────────────────────── library */

.library { max-height: none; overflow-y: auto; padding: 3px 0; flex: 1 1 0; min-height: 0; }
.lib-item {
  display: flex; align-items: baseline; gap: 6px;
  width: 100%; padding: 3px var(--gutter);
  background: none; border: 0;
  color: var(--text-dim); font: 400 var(--fs-sm) var(--ui);
  cursor: pointer; text-align: left;
}
.lib-item:hover { background: var(--bg-hover); color: var(--text); }
.lib-item em { font: 400 9.5px var(--mono); font-style: normal; color: var(--text-faint); margin-left: auto; }

/* ─────────────────────────────────────────────────────────── stage & panes */

.stage {
  position: relative;
  background: radial-gradient(120% 90% at 50% 38%, var(--stage-1) 0%, var(--stage-2) 100%);
  min-height: 0; overflow: hidden;
  cursor: crosshair;
}
#gl { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }

/*
 * Profile mode splits the stage the way Easymetric does: draw flat on the left,
 * watch the solid assemble on the right. The 3D view is not a place to draw —
 * it is the feedback.
 */
.pixel { display: none; }

/*
 * Profile mode is a four-up drawing board, not a split screen: front, top and
 * side are places to draw, and the isometric view is the result. Working from
 * three orthogonal silhouettes is how the object gets its shape — one flat view
 * can only ever give it a profile.
 */
.stage.is-profile .pixel {
  display: block;
  position: absolute;
  /* Both dimensions stated: a <canvas> is a replaced element, so `top: 0;
     bottom: 0` with an auto height does not stretch — it falls back to the
     intrinsic 150px. */
  width: 50%;
  height: 50%;
  z-index: 4;
  background: var(--bg-app);
  cursor: crosshair;
}
/*
 * The four cells stop short of the bottom so the tool island gets a band of its
 * own. Floating it over the lower views cut off exactly the part of the grid you
 * reach for last.
 */
.stage.is-profile { --board-row: calc((100% - var(--island-band) - var(--dock-top-h, 0px)) / 2); }
.stage.is-profile .pixel { height: var(--board-row); }
.stage.is-profile .pixel--front { left: 0;   top: 0;                 border-right: 1px solid var(--line-strong); border-bottom: 1px solid var(--line-strong); }
.stage.is-profile .pixel--top   { left: 0;   top: var(--board-row);  border-right: 1px solid var(--line-strong); border-bottom: 1px solid var(--line-strong); }
.stage.is-profile .pixel--side  { left: 50%; top: var(--board-row);  border-bottom: 1px solid var(--line-strong); }

/* The isometric result keeps the top-right cell. */
.stage.is-profile #gl { left: 50%; top: 0; width: 50%; height: var(--board-row); }
.stage.is-profile .stage__hud { left: calc(50% + 10px); }
.stage.is-profile .viewcube { display: none; }
.stage.is-profile .island { bottom: calc((var(--island-band) - 37px) / 2); }

.pane-tag {
  position: absolute;
  z-index: 5;
  padding: 2px 6px;
  font: 400 var(--fs-xs) var(--mono);
  color: var(--text-faint);
  background: color-mix(in srgb, var(--bg-app) 78%, transparent);
  border: 1px solid var(--line);
  border-radius: var(--r);
  pointer-events: none;
}

.stage.is-orbiting { cursor: grabbing; }
.stage.tool-select { cursor: default; }

/* Quad-view dividers and per-pane labels, drawn in HTML over the single canvas. */
/* Above the drawing canvases: `z-index` on a child is resolved inside its own
   parent's stacking context, so a caption at 5 inside a container at 2 still
   lost to a canvas at 4. */
.panes { position: absolute; inset: 0; pointer-events: none; z-index: 6; }
.pane-label {
  position: absolute;
  padding: 2px 6px;
  font: 400 var(--fs-xs) var(--mono);
  color: var(--text-faint);
  background: color-mix(in srgb, var(--bg-app) 72%, transparent);
  border: 1px solid var(--line);
  border-radius: var(--r);
  letter-spacing: .02em;
}
.pane-label.is-active { color: var(--text); border-color: var(--line-strong); }
.pane-divider { position: absolute; background: var(--line-strong); }

.stage__hud {
  /* Top-left. The bottom belongs to the island, and on a narrow window the
     chips slide under it there. Long help text lives in the properties
     panel, which has room for it. */
  position: absolute;
  left: 10px; top: 10px;
  max-width: 46%;
  display: flex; flex-wrap: wrap; gap: 5px;
  z-index: 3;
  pointer-events: none;
}
.hud-chip--anim.is-track { color: var(--text); border-color: var(--accent-line); }
/* Lit while layer mode is on: it explains why most of the model went quiet. */
.hud-chip.is-on { color: var(--text); border-color: var(--accent-line); background: var(--accent-soft); }
.hud-chip {
  padding: 2px 7px;
  background: color-mix(in srgb, var(--bg-app) 80%, transparent);
  border: 1px solid var(--line);
  border-radius: var(--r);
  font: 400 var(--fs-xs) var(--mono);
  color: var(--text-faint);
}

.stage__readout {
  position: absolute; z-index: 4;
  padding: 3px 7px;
  background: color-mix(in srgb, var(--bg-app) 92%, transparent);
  border: 1px solid var(--accent-line);
  border-radius: var(--r);
  font: 400 10.5px var(--mono);
  color: var(--text);
  pointer-events: none; white-space: nowrap;
  transform: translate(-50%, -150%);
}

.viewcube {
  position: absolute; top: 10px; right: 10px;
  display: flex; flex-direction: column;
  border: 1px solid var(--line-strong);
  border-radius: var(--r);
  background: color-mix(in srgb, var(--bg-app) 80%, transparent);
  overflow: hidden;
  z-index: 3;
}
.viewcube button {
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  padding: 4px 8px;
  background: none; border: 0; border-bottom: 1px solid var(--line);
  color: var(--text-faint); font: 400 var(--fs-xs) var(--mono);
  cursor: pointer; text-align: left;
}
.viewcube button:last-child { border-bottom: 0; }
.viewcube button:hover { color: var(--text); background: var(--bg-hover); }
/* No opacity: the colour is already the quiet one, and halving it on top took
   the shortcut hint down to about 2:1. */
.viewcube button i { font-style: normal; }

/* ─────────────────────────────────────────────────────────── tool island

   Tools live over the canvas rather than in the window chrome, and they bring
   only the parameters the active tool actually uses. The top bar was carrying
   snap, cell size and extrusion depth at all times even though each belongs to
   one mode — which is why it kept running out of room. */

.island {
  position: absolute;
  left: 50%; bottom: 16px;
  transform: translateX(-50%);
  z-index: 6;
  display: flex; align-items: center; gap: 8px;
  padding: 6px;
  max-width: calc(100% - 24px);
  background: color-mix(in srgb, var(--bg-panel) 92%, transparent);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-lg);
  box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
  backdrop-filter: blur(10px);
}
/*
 * Tool groups are segmented controls, exactly like the board switch.
 *
 * These rules used to strip the group's border and round every button on its
 * own, which turned a segmented control into a row of loose chips — two
 * controls doing the same job with two different shapes.
 */
/*
 * The island can be moved.
 *
 * It sits over the model, and where it sits is in the way of something —
 * which something depends on what you are building, so it is a choice rather
 * than a default worth arguing about. A dragged position is absolute and the
 * centring transform is dropped, or the island would jump half its width.
 */
.island.is-placed { left: var(--island-x); top: var(--island-y); bottom: auto; transform: none; }
/*
 * A moved island keeps its own `top`, so nothing may give it a `bottom`.
 *
 * An absolutely positioned box with both edges pinned and no height stretches
 * to fill what is between them — which is why entering a mode that repositions
 * the island made it suddenly grow to the height of the stage. Every rule that
 * sets `bottom` on the island needs its `is-placed` counterpart; there are two,
 * and the board is the one that was missing.
 */
.stage.is-animating .island.is-placed,
.stage.is-profile .island.is-placed { bottom: auto; }
/*
 * Docked: a list of tools, one per line, with names.
 *
 * A grid of paired icons was tried first and came out ragged — the buttons are
 * not one size, so pairs left holes. A panel is a column of rows, and that is
 * what everything else in it already is: the material groups, the object list,
 * the properties. The tools join them rather than sitting in a shape of their
 * own.
 */
/* The tools list scrolls like any other long list in a panel: the section body
   already clips, so the island inside it has to be the thing that scrolls. */
.acc[data-acc='tools'] .acc__body { overflow-y: auto; }

.island.is-docked {
  position: static;
  transform: none;
  max-width: none;
  margin: 0;
  padding: 4px var(--gutter) var(--gutter);
  flex-direction: column;
  align-items: stretch;
  gap: 4px;
  border: 0;
  border-radius: 0;
  box-shadow: none;
  backdrop-filter: none;
  background: transparent;
}
.island.is-docked .toolgroup,
.island.is-docked .board-modes {
  display: flex; flex-direction: column;
  border-radius: var(--r);
  overflow: hidden;
}
.island.is-docked .toolgroup > *,
.island.is-docked .board-modes > * {
  border-radius: 0 !important;
  border-right: 0;
  border-bottom: 1px solid var(--line);
}
.island.is-docked .toolgroup > :last-child,
.island.is-docked .board-modes > :last-child { border-bottom: 0; }
.island.is-docked .tool,
.island.is-docked .board-mode {
  justify-content: flex-start;
  width: 100%;
  height: calc(21px * var(--ui-scale));
  padding: 0 7px;
  text-align: left;
}
/*
 * In a list, active is a marked row rather than a filled bar.
 *
 * The floating island is a strip of small buttons, where a solid fill reads as
 * "this one" at a glance. The same fill across a panel's full width is a slab
 * of colour per selected tool — three of them at once, shouting over the model
 * they are meant to serve.
 */
.island.is-docked .tool.is-active,
.island.is-docked .board-mode.is-active,
.island.is-docked .tool--toggle.is-active {
  background: var(--accent-soft);
  color: var(--text);
  border-color: transparent;
  box-shadow: inset 2px 0 0 var(--accent);
}
.island.is-docked .tool.is-active .tool__key,
.island.is-docked .tool--toggle.is-active .tool__key { color: var(--text); opacity: 1; }
.island.is-docked .tool--toggle.is-active { box-shadow: inset 2px 0 0 var(--danger-text); }
.island.is-docked .tool svg,
.island.is-docked .board-mode svg { opacity: .85; }
.island.is-docked .tool__label { flex: 1 1 auto; }
.island.is-docked .tool__key { margin-left: auto; padding-left: 8px; }
/* Shape buttons are drawn by their own code and have no gap of their own. */
.island.is-docked .board-mode { display: flex; align-items: center; gap: 6px; }
/* The separators were making four gaps in a short list. */
.island.is-docked .island__sep { margin: 0; opacity: .6; }
.island.is-docked { gap: 3px; }
.island.is-docked .tool__label,
.island.is-docked .tool__key { display: inline; }
.island.is-docked .island__sep { width: auto; height: 1px; align-self: auto; margin: 2px 0; }
.island.is-docked .snap { width: 100%; }
.island.is-docked .snap select { flex: 1 1 auto; min-width: 0; }
/* The grip belongs to the floating island; docked, the section header is the
   handle, the way every other panel section works. */
.island.is-docked .island__grip { display: none; }

/* Hidden while floating: there the row is icons and tooltips, and the shortcut
   lives in the tooltip. Both come back when the island is docked into a panel. */
.tool__label,
.tool__key { display: none; }
.tool__label { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tool__key { font: 500 var(--fs-xs)/1 var(--mono); font-style: normal; color: var(--text-faint); }

/*
 * One tiled dot, not six placed ones.
 *
 * The dots used to be six gradients at fixed coordinates, which is right for
 * exactly one size of grip — and the island now has five shapes to be gripped
 * in. Off that size the pattern was cropped to whatever fitted, so the same
 * handle showed six dots in one place and two in another. A tile repeats to
 * fill whatever it is given.
 */
.island__grip {
  /* Upright beside a horizontal row of tools: the island's usual shape. */
  width: 10px; height: calc(var(--row) - 3px);
  margin: 0 2px 0 0;
  border-radius: var(--r-sm);
  cursor: grab;
  /*
   * Six dots, placed from the middle rather than from the corner.
   *
   * Fixed coordinates were right for one size of grip and cropped in the other
   * four; a repeating tile filled every one of them and made a field of dots.
   * Measuring from the centre keeps the same six wherever the handle is.
   */
  background-image:
    radial-gradient(circle 1px at calc(50% - 2px) calc(50% - 5px), var(--text-faint) 100%, transparent 0),
    radial-gradient(circle 1px at calc(50% + 2px) calc(50% - 5px), var(--text-faint) 100%, transparent 0),
    radial-gradient(circle 1px at calc(50% - 2px) 50%, var(--text-faint) 100%, transparent 0),
    radial-gradient(circle 1px at calc(50% + 2px) 50%, var(--text-faint) 100%, transparent 0),
    radial-gradient(circle 1px at calc(50% - 2px) calc(50% + 5px), var(--text-faint) 100%, transparent 0),
    radial-gradient(circle 1px at calc(50% + 2px) calc(50% + 5px), var(--text-faint) 100%, transparent 0);
}
.stage.is-animating .island.is-docked { bottom: auto; }
/*
 * Docked along the bottom: the island's own row, held the width of the stage.
 *
 * Not a section — a panel's sections are a column of named rows, and this is
 * the row of icons kept exactly as it is, only stood still. It overlays the
 * stage the way the frame strip does, and the strip rides on top of it.
 *
 * Left-aligned, not centred: what is in the row depends on the tool — shapes
 * come and go, the board switch appears with the silhouette — and centred, every
 * one of those arrivals shifts every button that was already there. Against the
 * left edge the buttons you use most keep their place.
 */
.island.is-docked-bottom {
  position: absolute;
  left: 0; right: 0; bottom: 0; top: auto;
  transform: none;
  max-width: none;
  justify-content: flex-start;
  padding: 5px var(--gutter);
  border: 0;
  border-top: 1px solid var(--line-strong);
  border-radius: 0;
  background: var(--bg-panel);
  box-shadow: none;
  backdrop-filter: none;
}
/* The same shelf, hung from the top instead: the seam is its lower edge. */
.island.is-docked-top {
  position: absolute;
  left: 0; right: 0; top: 0; bottom: auto;
  transform: none;
  max-width: none;
  justify-content: flex-start;
  padding: 5px var(--gutter);
  border: 0;
  border-bottom: 1px solid var(--line-strong);
  border-radius: 0;
  background: var(--bg-panel);
  box-shadow: none;
  backdrop-filter: none;
}
/*
 * The same shelf stood on its end.
 *
 * Carried past the panels and pressed to a side, the island stands up: the
 * strip of icons becomes a column of them. There are two places to stand on
 * each side — outside the panel, against the window, and inside it, between
 * the panel and the model — and they differ only in which grid track they take,
 * so everything else is written once against `is-upright`.
 *
 * It is the island's own shape either way. What a panel makes of it is a column
 * of named rows, which is a different thing and is what docking into a panel is
 * for.
 */
.island.is-upright {
  position: static;
  transform: none;
  max-width: none;
  flex-direction: column;
  justify-content: flex-start;
  align-items: center;
  padding: var(--gutter) 5px;
  border: 0;
  border-radius: 0;
  background: var(--bg-panel);
  box-shadow: none;
  backdrop-filter: none;
  overflow-y: auto;
}
.island.is-docked-edge-left { grid-area: edge-l; border-right: 1px solid var(--line-strong); }
.island.is-docked-edge-right { grid-area: edge-r; border-left: 1px solid var(--line-strong); }
.island.is-docked-inner-left { grid-area: inner-l; border-right: 1px solid var(--line-strong); }
.island.is-docked-inner-right { grid-area: inner-r; border-left: 1px solid var(--line-strong); }

/* Groups run the other way, or a segmented control lies across its column. */
.island.is-upright .toolgroup,
.island.is-upright .board-modes { flex-direction: column; overflow: hidden; }
/*
 * Corners follow the direction the group runs in.
 *
 * A segmented control rounds its two ends; turned on its side it was still
 * rounding left and right, so the column had four square corners and two stray
 * curves in the middle of it.
 */
.island.is-upright .toolgroup > *,
.island.is-upright .board-modes > * {
  border-radius: 0 !important;
  border-right: 0;
  border-bottom: 1px solid var(--line);
}
.island.is-upright .toolgroup > :last-child,
.island.is-upright .board-modes > :last-child { border-bottom: 0; }
.island.is-upright .island__sep {
  width: 18px; height: 1px; align-self: center; margin: 3px 0;
}
/* A label beside a field has no room in a column this narrow; the field keeps
   its tooltip, which says the same thing. */
.island.is-upright .snap .lbl { display: none; }
.island.is-upright .snap select { width: calc(46px * var(--ui-scale)); }
/* Flat across the top of the column, since the column itself is upright —
   and the six dots turn with it. */
.island.is-upright .island__grip {
  width: calc(var(--row) - 2px); height: 10px;
  margin: 0 0 4px;
  background-image:
    radial-gradient(circle 1px at calc(50% - 5px) calc(50% - 2px), var(--text-faint) 100%, transparent 0),
    radial-gradient(circle 1px at calc(50% - 5px) calc(50% + 2px), var(--text-faint) 100%, transparent 0),
    radial-gradient(circle 1px at 50% calc(50% - 2px), var(--text-faint) 100%, transparent 0),
    radial-gradient(circle 1px at 50% calc(50% + 2px), var(--text-faint) 100%, transparent 0),
    radial-gradient(circle 1px at calc(50% + 5px) calc(50% - 2px), var(--text-faint) 100%, transparent 0),
    radial-gradient(circle 1px at calc(50% + 5px) calc(50% + 2px), var(--text-faint) 100%, transparent 0);
}

/* Centred instead, when asked for in the island's own menu. */
.island.is-docked-bottom.is-align-center,
.island.is-docked-top.is-align-center { justify-content: center; }
/* The top shelf stands where the view chips and the boards do, so they move. */
.stage__hud { top: calc(10px + var(--dock-top-h, 0px)); }
.stage.is-profile #gl,
.stage.is-profile .pixel--front { top: var(--dock-top-h, 0px); }
.stage.is-profile .pixel--top,
.stage.is-profile .pixel--side { top: calc(var(--board-row) + var(--dock-top-h, 0px)); }
/* Both of the rules that move the floating island have to let a shelf be. */
.stage.is-animating .island.is-docked-bottom,
.stage.is-profile .island.is-docked-bottom { bottom: 0; }
.stage.is-animating .island.is-docked-top,
.stage.is-profile .island.is-docked-top { bottom: auto; top: 0; }
/* The strip sits on the shelf rather than under it; `--dock-h` is measured. */
.stage.is-animating .frames { bottom: var(--dock-h, 0px); }
/*
 * Upright, because here the row is horizontal and the grip is its left end.
 *
 * The dots are placed against the middle rather than from the top: the floating
 * grip is a 10px bar whose height is its own, while this one is as tall as a
 * button, and dots measured downward from the top of that sat in its upper
 * third with the rest of the grip empty below them.
 */
.island.is-docked-bottom .island__grip,
.island.is-docked-top .island__grip {
  width: 10px; height: calc(var(--row) - 2px);
  margin: 0 2px 0 0;
  cursor: grab;
}

/* Where a drop would land: the island going into a panel takes the whole
   panel, since it is joining everything in it. */
.panel.is-dock-target { outline: 2px solid var(--accent-line); outline-offset: -2px; }
/* A panel going to the other side marks only the outer edge it will stand on. */
.panel.is-edge-target::before {
  content: '';
  position: absolute; top: 0; bottom: 0;
  width: 3px;
  background: var(--accent);
  z-index: 20;
}
.panel--left.is-edge-target::before { left: 0; }
.panel--right.is-edge-target::before { right: 0; }
/*
 * A shelf's seam is a line, so the line is what lights up — the status bar's
 * top edge for the floor, the top bar's bottom edge for the ceiling.
 */
.bar--status.is-dock-target { border-top-color: var(--accent); box-shadow: inset 0 1px 0 var(--accent); }
.bar--top.is-dock-target { border-bottom-color: var(--accent); box-shadow: inset 0 -1px 0 var(--accent); }

/*
 * Nothing under a carried island answers the pointer.
 *
 * The panels kept lighting their buttons as it passed over: a swatch or the
 * "new material" tile glowing under an island in flight promises a click that
 * is not going to happen. Where the drop lands is decided from coordinates
 * rather than from events, so taking the panels out of the pointer's way costs
 * the gesture nothing.
 */
body.is-carrying .panel,
body.is-carrying .bar { pointer-events: none; }
.acc.is-dragging { opacity: .45; }
/*
 * Above or below, decided by which half of the section the pointer is in.
 *
 * With only "before", a section could be dropped anywhere except last — the
 * end of a panel had nothing to aim at — and every move meant dragging to the
 * exact section it should precede.
 */
.acc.is-drop-before { box-shadow: inset 0 2px 0 var(--accent); }
.acc.is-drop-after { box-shadow: inset 0 -2px 0 var(--accent); }

/* Undock, in the header of the docked tools. */
/* Positioned against the header, not the section: against the section it sat
   halfway down the list of tools, over whatever row happened to be there. */
.acc__head { position: relative; }
.acc__pop {
  /*
   * Aligned by the glyph, not by the button.
   *
   * The count is text against `--gutter`; this is an icon centred in a button,
   * so the button's own padding leaves the icon short of that edge. Pulling the
   * button out by half the difference lines the two up where the eye reads
   * them: at the right-hand end of the header.
   */
  position: absolute;
  right: calc(var(--gutter) - (20px * var(--ui-scale) - 13px) / 2);
  top: 50%;
  transform: translateY(-50%);
  display: flex; align-items: center; justify-content: center;
  width: calc(20px * var(--ui-scale)); height: calc(20px * var(--ui-scale));
  padding: 0;
  background: none; border: 0; border-radius: var(--r-sm);
  color: var(--text-faint); cursor: pointer;
  z-index: 2;
}
.acc__pop:hover { background: var(--bg-hover); color: var(--text); }
.acc__pop svg { width: 13px; height: 13px; }
/* The button is positioned against the header, so the header has to keep the
   room for it — as the materials header already keeps room for its own two. */
.acc[data-acc='tools'] .acc__head { padding-right: calc(var(--gutter) + 20px * var(--ui-scale)); }
/* Carried by the pointer: out of the stage, so nothing clips it. */
.island.is-carried {
  position: fixed;
  bottom: auto; transform: none;
  z-index: 60;
  pointer-events: none;
}
/* On its way into a panel. */
.island.is-docking { opacity: .45; transition: opacity 90ms ease-out; }

/* While a section is over the model, say that letting go floats it. */
.stage.is-undock-target { outline: 2px dashed var(--accent-line); outline-offset: -6px; }
.acc__head { cursor: grab; }
.acc__head:active { cursor: grabbing; }

.island__grip:active { cursor: grabbing; }

.island .toolgroup > .tool + .tool { margin-left: 0; }
.island .snap { padding-right: 2px; }
.island .snap .lbl { color: var(--text-faint); }

/* The eraser is deliberately outside the groups: it modifies whichever tool is
   active rather than being one of them, so it carries its own outline. */
.tool--toggle {
  border: 1px solid var(--line-strong);
  border-radius: var(--r);
}
.tool--toggle.is-active { background: var(--danger); color: #fff; border-color: var(--danger); }
.tool--toggle.is-active .tool__key { color: #fff; opacity: .75; }

.board-modes { display: flex; border: 1px solid var(--line-strong); border-radius: var(--r); }
.board-modes > :first-child { border-radius: calc(var(--r) - 1px) 0 0 calc(var(--r) - 1px); }
.board-modes > :last-child  { border-radius: 0 calc(var(--r) - 1px) calc(var(--r) - 1px) 0; }
.board-mode {
  height: var(--row); padding: 0 9px;
  background: none; border: 0; border-right: 1px solid var(--line);
  color: var(--text-dim); font: 400 var(--fs-sm) var(--ui); cursor: pointer;
}
.board-mode:last-child { border-right: 0; }
.board-mode:hover { background: var(--bg-hover); color: var(--text); }
.board-mode.is-active { background: var(--accent); color: var(--accent-fg); }
.board-modes[hidden] { display: none; }

.island__sep {
  width: 1px;
  align-self: stretch;
  background: var(--line-strong);
  margin: 2px 0;
}
/* Parameters that the current tool does not use are removed, not dimmed: a
   greyed control still costs the width that caused the crowding. */
.island .snap[hidden], .island__sep[hidden] { display: none; }

/* ─────────────────────────────────────────────────────────── properties */

.props { padding: var(--gutter); overflow-y: auto; flex: 1; min-height: 0; }
.empty { margin: 20px 0; color: var(--text-faint); font-size: var(--fs-sm); text-align: center; line-height: 1.9; }

/*
 * A list of gestures, set as a list: flush left, one per line.
 *
 * Centred lines are for a line or two of placeholder — which is what `.empty`
 * still is. A column of them has a ragged edge on both sides and no two lines
 * starting in the same place, so nothing tells the eye where an item begins.
 * The hanging indent finishes that: the one gesture long enough to wrap turns
 * under its own first word, clear of the item below.
 */
.hints {
  margin: 14px 0 0;
  padding: 0;
  list-style: none;
  color: var(--text-faint);
  font-size: var(--fs-sm);
  line-height: 1.5;
}
.hints li { padding-left: 10px; text-indent: -10px; }
.hints li + li { margin-top: 3px; }

.field-group { margin-bottom: 12px; }
.field-group > .lbl { display: block; margin-bottom: 5px; }
.axis-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1px; }

.num {
  display: flex; align-items: center;
  border: 1px solid var(--line);
  border-radius: var(--r);
  background: var(--bg-input);
  height: var(--row);
  cursor: ew-resize;
}
.num:hover { border-color: var(--line-strong); }
.num:focus-within { border-color: var(--accent-line); }
/*
 * On the scale, not at a size of its own.
 *
 * 9.5px flat meant the axis letter stayed where it was while everything around
 * it grew with `--fs-base` — at a large text size a full-height figure stood
 * beside a letter half its height, which reads as a mistake rather than as a
 * quiet label. Its column scales too, or the letter drifts off centre in it.
 */
.num__key {
  width: calc(15px * var(--ui-scale));
  text-align: center;
  font: 400 var(--fs-xs) var(--mono);
  color: var(--text-faint);
  flex: none;
}
.num input {
  width: 100%; min-width: 0;
  background: none; border: 0; color: var(--text);
  font: 400 var(--fs-sm) var(--mono); padding: 0 4px 0 0;
  outline: none; cursor: ew-resize;
}
.num input:focus { cursor: text; }

.faces { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1px; }
.face {
  position: relative; aspect-ratio: 1.25;
  border: 1px solid var(--line); border-radius: var(--r);
  background-size: 260% 260%; background-position: center;
  image-rendering: pixelated; cursor: pointer; padding: 0;
}
.face:hover { border-color: var(--accent-line); }
.face-key {
  position: absolute; left: 3px; bottom: 1px;
  font: 500 8px var(--mono); color: #fff;
  text-shadow: 0 1px 2px rgba(0,0,0,.9); pointer-events: none;
}
.face.is-inherited::after {
  content: ''; position: absolute; inset: 0;
  background: repeating-linear-gradient(135deg, transparent 0 4px, rgba(0,0,0,.32) 4px 5px);
  pointer-events: none;
}

/* ─────────────────────────────────────────────────────────── outliner */

.outliner { max-height: none; overflow-y: auto; flex: 1 1 0; min-height: 0; padding: 2px 0; }
.obj {
  display: flex; align-items: center; gap: 7px;
  padding: 2px var(--gutter);
  cursor: pointer; color: var(--text-dim); font-size: var(--fs-sm);
}
.obj:hover { background: var(--bg-hover); }
.obj.is-selected { background: var(--accent-soft); color: var(--text); }
.obj__dot {
  width: 9px; height: 9px; flex: none;
  border: 1px solid rgba(0,0,0,.3);
  image-rendering: pixelated; background-size: 300% 300%; background-position: center;
}
.obj__name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.obj__size { font: 400 9.5px var(--mono); color: var(--text-faint); }

/*
   Row actions.

   The eye holds its column always, so the names stay on one left edge whether
   or not anything is hidden; the bin appears on hover, because a delete you can
   hit by accident on a list you are only reading is worse than one more click.
   A hidden row dims but keeps its size and its material, so it still reads as
   the object it is rather than as a gap. */
.obj__eye, .obj__drop {
  width: 16px; height: 16px; flex: none; padding: 0;
  display: grid; place-items: center;
  background: none; border: 0; border-radius: var(--r-sm);
  color: var(--text-faint); cursor: pointer;
}
.obj__eye svg, .obj__drop svg { width: 12px; height: 12px; }
.obj__eye:hover, .obj__drop:hover { color: var(--text); background: var(--bg-hover); }
.obj__drop:hover { color: var(--danger-text); }
.obj__drop { opacity: 0; }
.obj:hover .obj__drop { opacity: 1; }
.obj__drop:focus-visible { opacity: 1; }
.obj.is-hidden { color: var(--text-faint); }
.obj.is-hidden .obj__dot { opacity: .4; }
.obj.is-hidden .obj__eye { color: var(--text-dim); }

/* Merged cubes are shown for reach, not for editing: no switches to press. */
.obj__eye--none, .obj__drop--none { pointer-events: none; }
.obj--derived .obj__name { font-style: italic; color: var(--text-faint); }

/* ─────────────────────────────────────────────────────────── status */

.status__item { font: 400 var(--fs-xs) var(--mono); color: var(--text-faint); }
.status__sep { width: 1px; height: 10px; background: var(--line); margin: 0 10px; }
.status__msg { font: 400 var(--fs-xs) var(--mono); color: var(--text-faint); }
.status__msg[data-kind='error'] { color: var(--danger-text); }
.status__msg[data-kind='warn'] { color: var(--warn); }
.status__msg[data-kind='ok'] { color: var(--ok); }

/* ─────────────────────────────────────────────────────────── sheet */

/*
 * Above the project browser (60), below dialogs (80).
 *
 * Building a pack is now something you do from the browser, and at z-index 50
 * its result sheet opened *underneath* the browser: the build ran, reported
 * nothing, and looked like a dead button.
 */
.sheet {
  position: fixed; inset: 0;
  display: grid; place-items: center;
  background: color-mix(in srgb, var(--bg-app) 78%, transparent);
  z-index: 70;
}
.sheet[hidden] { display: none; }
.sheet__panel {
  width: min(720px, 88vw); max-height: 82vh;
  display: flex; flex-direction: column;
  background: var(--bg-panel);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-lg);
  box-shadow: 0 18px 48px rgba(0,0,0,.45);
  animation: rise .18s var(--ease);
}
@keyframes rise { from { opacity: 0; transform: translateY(6px); } }
.sheet__head {
  display: flex; align-items: center; justify-content: space-between;
  padding: 9px 13px; border-bottom: 1px solid var(--line);
}
.sheet__body { padding: 13px; overflow-y: auto; }
.sheet__body img { display: block; max-width: 100%; image-rendering: pixelated; border: 1px solid var(--line); }

.diag { display: flex; gap: 9px; padding: 6px 0; border-bottom: 1px solid var(--line); font-size: var(--fs-sm); }
.diag:last-child { border-bottom: 0; }
.diag__sev {
  flex: none; width: 44px; padding-top: 1px;
  font: 500 calc(var(--fs-base) - 4px) var(--mono); text-transform: uppercase; letter-spacing: .06em;
}
.diag[data-sev='error'] .diag__sev { color: var(--danger-text); }
.diag[data-sev='warn'] .diag__sev { color: var(--warn); }
.diag[data-sev='info'] .diag__sev { color: var(--text-faint); }
.diag__body { color: var(--text-dim); }
.diag__fix { display: block; margin-top: 2px; color: var(--text-faint); font-size: var(--fs-sm); }
.diag__fix::before { content: '→ '; }

/* ─────────────────────────────────────────────────────────── settings */

/* Settings and shortcuts are read by section, so each one is titled and set
   apart rather than running into the next. */
.settings-group { margin-bottom: 22px; }
.settings-group:last-child { margin-bottom: 0; }
/*
 * One scale down the sheet, strictly.
 *
 * It used to run the other way: the sheet title and the section titles were
 * the smallest type on screen (--fs-xs, as every uppercase label in this
 * interface is), while the rows under them were two steps larger — a heading
 * physically smaller than what it heads. The labels keep their case and their
 * tracking, and take the size that matches their rank instead: sheet 13,
 * section 12, row 11, hint 10.
 */
.sheet__head h3 { font-size: var(--fs-lg); color: var(--text); }
.settings-group__title {
  font: 600 var(--fs-md) var(--ui);
  letter-spacing: .08em; text-transform: uppercase;
  color: var(--text-faint);
  padding-bottom: 6px; margin-bottom: 6px;
  border-bottom: 1px solid var(--line-strong);
}

/*
 * Label and control belong to each other, and have to look it.
 *
 * No rule per row — the section already has one under its title, and a line
 * under every setting turns a short list into a fence. But with the rules gone
 * and the control pinned to the right of a 720px sheet, the eye had to travel
 * an empty half-row to find out that 'Язык' and the menu beside it were one
 * Two answers, neither of them a line: the control column has a width of its
 * own, so the gap between a label and its control never grows past it, and the
 * whole row lights up under the pointer.
 */
.settings-row {
  display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 200px);
  align-items: center; gap: 14px;
  padding: 7px 8px;
  margin: 0 -8px;
  border-radius: var(--r);
}
.settings-row:hover { background: color-mix(in srgb, var(--text) 5%, transparent); }
/* Filling its column keeps every control on one vertical, which is the other
   half of the pairing: the eye learns where answers live. */
.settings-row > select,
.settings-row > .btn { width: 100%; }
.settings-row__label { font-size: var(--fs-sm); color: var(--text); }
.settings-row__hint { display: block; font-size: var(--fs-xs); color: var(--text-faint); margin-top: 1px; }

/*
 * Rows, not a two-column grid of loose cells.
 *
 * `display: contents` on the row put the name and the binding straight into the
 * grid, and the rule under each of them was painted on the cells themselves —
 * so the line stopped at the button, and the buttons, having no height of their
 * own, stacked into one unbroken column of boxes. A row that owns its own rule
 * draws it the whole way across, and the button sits inside it with air above
 * and below.
 */
.keymap { display: flex; flex-direction: column; }
/*
 * Bigger than the rows it heads, and quieter than the section above it.
 *
 * It was the smallest type in the sheet — `--fs-xs`, under rows set in
 * `--fs-sm` — so a heading was physically smaller than the things it governed.
 * Uppercase is taken: that is the section title ("HOTKEYS"), and repeating the
 * treatment one level down flattens the two into one. So this level keeps
 * sentence case and takes a size and a weight instead.
 */
.keymap__group {
  font: 600 var(--fs-sm) var(--ui);
  color: var(--text);
  padding: 16px 0 4px;
}
.keymap__group:first-child { padding-top: 4px; }
/* Narrower than a settings row: a binding is a few characters, and a button
   200px wide for "?" is a slab. */
.keyrow {
  display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 116px);
  align-items: center; gap: 14px;
  padding: 3px 8px;
  margin: 0 -8px;
  border-radius: var(--r);
}
.keyrow:hover { background: color-mix(in srgb, var(--text) 5%, transparent); }
.keyrow__bind { width: 100%; }
.keyrow__name { font-size: var(--fs-sm); color: var(--text-dim); }
.keyrow__bind {
  display: inline-flex; align-items: center; justify-content: center;
  height: calc(22px * var(--ui-scale));
  min-width: calc(72px * var(--ui-scale));
  padding: 0 8px;
  background: var(--bg-input);
  border: 1px solid var(--line-strong);
  border-radius: var(--r);
  font: 500 var(--fs-xs) var(--mono);
  color: var(--text);
}
/* In the help sheet the binding is a `span` that only states the key; only the
   settings sheet's `button` can be clicked to change it. */
button.keyrow__bind { cursor: pointer; }
button.keyrow__bind:hover { border-color: var(--accent-line); }
/*
 * Waiting for a key: a word longer than any binding, so the button has to take
 * it without growing or spilling. A step down in size and an ellipsis if even
 * that is not enough — the row's width is fixed, and a button that stretches
 * would drag its neighbour's column with it.
 */
.keyrow__bind.is-capturing {
  border-color: var(--accent-line);
  color: var(--text-faint);
  font-size: calc(var(--fs-xs) - 1px);
  letter-spacing: -0.01em;
}
.keyrow__bind { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }

/* ─────────────────────────────────────────────────────────── home / projects

   A full-cover screen rather than a modal: opening a project is a mode change,
   not a dialog, and the editor behind it is meaningless until one is chosen. */

.home {
  position: fixed; inset: 0; z-index: 60;
  display: flex; flex-direction: column;
  background: var(--bg-app);
}
.home[hidden] { display: none; }

/*
 * The way in, on a host that asks.
 *
 * A screen rather than a dialog: there is nothing behind it to look at yet, and
 * a modal over an empty editor only raises the question of what is underneath.
 * Above `.home`, because it stands in front of everything including the pack
 * browser — which has not been told about anybody's packs at this point.
 */
.signin {
  position: fixed; inset: 0; z-index: 90;
  display: grid; place-items: center;
  padding: 24px;
  background: var(--bg-app);
}
.signin__card {
  display: flex; flex-direction: column; align-items: center; gap: 14px;
  width: min(420px, 100%);
  text-align: center;
}
/* The mark at the size it is on the splash: this is the first thing anybody
   sees of the editor, and it should look like the thing they opened. */
.signin__mark {
  font: 600 calc(var(--fs-xl) * 2.2) var(--ui);
  letter-spacing: 0.02em;
  color: var(--text);
}
.signin__line { color: var(--text-dim); margin: 0; max-width: 34ch; }
.signin__go { min-width: 220px; }
.signin__code {
  display: flex; flex-direction: column; align-items: center; gap: 6px;
  color: var(--text-faint);
  font-size: var(--fs-sm);
}
.signin__code[hidden] { display: none; }
/* The code is there to be copied by hand when the link does not open, so it is
   selectable and set in the face figures are set in. */
.signin__codeValue {
  user-select: all;
  padding: 4px 10px;
  border: 1px solid var(--line);
  border-radius: 4px;
  background: var(--bg-input);
  font: 500 var(--fs-sm) var(--mono);
  color: var(--text);
}
.signin__state { margin: 0; min-height: 1.4em; }

/* The name and the way out, quiet: it answers a question rather than offering
   an action, and the button beside it is the only thing there to press. */
.home__who { display: flex; align-items: center; gap: 8px; }
.home__who[hidden] { display: none; }
.home__whoName { color: var(--text-dim); font-size: var(--fs-sm); }

/* The address is long and is there to be copied, so it takes the width it has
   and selects itself whole on a click. */
.settings-addr {
  flex: 1 1 auto;
  min-width: 0;
  font: 400 calc(var(--fs-sm) * var(--ui-scale)) var(--mono);
  cursor: text;
}

.home__bar {
  display: flex; align-items: center; gap: 10px;
  height: calc(48px * var(--ui-scale));
  padding: 0 20px;
  border-bottom: 1px solid var(--line);
  flex: none;
}

.crumbs { display: flex; align-items: center; gap: 6px; margin-left: 14px; }
.crumb {
  background: none; border: 0; padding: 2px 4px;
  color: var(--text-faint); font: 400 var(--fs-sm) var(--ui);
  cursor: pointer; border-radius: var(--r);
}
.crumb:hover { color: var(--text); background: var(--bg-hover); }
.crumb--current { color: var(--text); cursor: default; }
.crumb--current:hover { background: none; }
.crumb__sep { color: var(--text-faint); opacity: .5; }

.home__body { flex: 1; overflow-y: auto; padding: 26px 20px 40px; }

/* Title on the left, "new" on the right; the pack's own actions sit in the bar. */
.home__head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.home__acts { display: flex; gap: 6px; }
.home__acts:empty { display: none; }
.home__title {
  margin: 0 0 16px;
  font-size: var(--fs-lg);
  text-transform: uppercase;
  letter-spacing: 0.07em;
  font-weight: 600;
  color: var(--text-faint);
}

.home__grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(216px, 1fr));
  gap: 14px;
}

.pcard {
  position: relative;
  display: flex; flex-direction: column;
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  background: var(--bg-panel);
  overflow: hidden;
  cursor: pointer;
  text-align: left;
  padding: 0;
  transition: border-color var(--t) var(--ease), background-color var(--t) var(--ease);
}
.pcard:hover { border-color: var(--line-strong); background: var(--bg-raised); }

.pcard__thumb {
  aspect-ratio: 4 / 3;
  background: var(--bg-app) center / contain no-repeat;
  border-bottom: 1px solid var(--line);
  flex: none;
}
.pcard__thumb--empty {
  display: grid; place-items: center;
  color: var(--text-faint);
  font: 400 var(--fs-xs) var(--mono);
}

.pcard__body { padding: 9px 11px 11px; }
.pcard__name {
  display: block;
  font-size: var(--fs-md);
  color: var(--text);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.pcard__meta {
  display: block; margin-top: 3px;
  font: 400 var(--fs-xs) var(--mono);
  color: var(--text-faint);
}

/* Actions stay hidden until the card is hovered: a grid of cards should read as
   thumbnails, not as a wall of buttons. */
.pcard__actions {
  position: absolute; top: 7px; right: 7px;
  display: flex; gap: 3px;
  opacity: 0;
  transition: opacity var(--t) var(--ease);
}
.pcard:hover .pcard__actions, .pcard:focus-within .pcard__actions { opacity: 1; }

.pcard__act {
  width: 24px; height: 24px;
  display: grid; place-items: center;
  border: 1px solid var(--line-strong);
  border-radius: var(--r);
  background: color-mix(in srgb, var(--bg-app) 86%, transparent);
  color: var(--text-dim);
  font: 400 var(--fs-xs) var(--mono);
  cursor: pointer;
}
.pcard__act:hover { color: var(--text); background: var(--bg-hover); }
.pcard__act--danger:hover { color: var(--danger-text); border-color: var(--danger-text); }

.home__empty {
  grid-column: 1 / -1;
  padding: 48px 0;
  text-align: center;
  color: var(--text-faint);
  font-size: var(--fs-md);
}

.save-state {
  font: 400 var(--fs-xs) var(--mono);
  color: var(--text-faint);
  white-space: nowrap;
  flex: none;
}
.save-state[data-state="dirty"] { color: var(--warn); }
.save-state[data-state="saved"] { color: var(--ok); }

/* Version history rows inside the sheet. */
.vrow {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  padding: 7px 0;
  border-bottom: 1px solid var(--line);
}
.vrow:last-child { border-bottom: 0; }
.vrow__when { font: 400 var(--fs-sm) var(--mono); color: var(--text-dim); }

/* ─────────────────────────────────────────────────────────── dialogs

   The application's own windows, not the operating system's: same typeface,
   same palette, and able to show a colour swatch or a live preview. */

.dlg {
  position: fixed; inset: 0; z-index: 80;
  display: grid; place-items: center;
  background: color-mix(in srgb, var(--bg-app) 72%, transparent);
}
.dlg__panel {
  width: min(380px, 88vw);
  background: var(--bg-panel);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-lg);
  box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45);
  animation: rise .16s var(--ease);
  overflow: hidden;
}
.dlg__head { padding: 12px 14px 0; }
.dlg__head h3 { font-size: var(--fs-md); text-transform: none; letter-spacing: 0; color: var(--text); }
.dlg__body { padding: 12px 14px 14px; display: flex; flex-direction: column; gap: 11px; }
.dlg__message { margin: 0; color: var(--text-dim); font-size: var(--fs-sm); line-height: 1.5; }

.dlg__field { display: flex; flex-direction: column; gap: 4px; }
/* `display: flex` beats the `hidden` attribute, so a conditional field needs this. */
.dlg__field[hidden] { display: none; }
.dlg__field input, .dlg__field select, .dlg__field textarea {
  width: 100%;
  height: var(--row);
  background: var(--bg-input);
  border: 1px solid var(--line-strong);
  border-radius: var(--r);
  color: var(--text);
  font: 400 var(--fs-sm) var(--ui);
  padding: 0 8px;
  outline: none;
}
.dlg__field input:focus, .dlg__field select:focus, .dlg__field textarea:focus {
  border-color: var(--accent-line);
}

/* The shared padding above would put the text back under the chevron. */
.dlg__field select { padding-right: var(--select-arrow); }

/*
 * Width, height and depth as three boxes on one row.
 *
 * Each carries its axis under it rather than beside it: beside, the three
 * labels make a sentence that has to be read left to right to find the one you
 * want, and under, the label belongs to the box above it at a glance.
 */
.dlg__size { display: flex; gap: 8px; }
.dlg__size-cell { display: flex; flex-direction: column; gap: 3px; flex: 1 1 0; min-width: 0; }
.dlg__size-cell input {
  width: 100%;
  height: var(--row);
  padding: 0 8px;
  background: var(--bg-input);
  border: 1px solid var(--line-strong);
  border-radius: var(--r);
  color: var(--text);
  font: 400 var(--fs-sm) var(--mono);
  outline: none;
}
.dlg__size-cell input:focus { border-color: var(--accent-line); }
.dlg__size-axis {
  color: var(--text-faint);
  font: 400 var(--fs-xs) var(--ui);
  text-align: center;
}

/* A description runs to several lines, so it sets its own height. */
.dlg__field textarea { height: auto; padding: 6px 8px; resize: vertical; line-height: 1.45; }

/*
 * A file input draws its own button, and that button keeps the platform's
 * colours unless it is styled explicitly — one white slab in an otherwise dark
 * dialog.
 */
.dlg__field input[type="file"] { height: auto; padding: 5px 6px; cursor: pointer; }
.dlg__field input[type="file"]::file-selector-button {
  background: var(--bg-raised);
  border: 1px solid var(--line-strong);
  border-radius: calc(var(--r) - 1px);
  color: var(--text);
  font: 400 var(--fs-sm) var(--ui);
  padding: 3px 9px;
  margin-right: 8px;
  cursor: pointer;
}
.dlg__field input[type="file"]::file-selector-button:hover { border-color: var(--accent-line); }
.dlg__color { padding: 2px !important; cursor: pointer; }
.dlg__hint { font-size: var(--fs-xs); color: var(--text-faint); }

.dlg__foot { display: flex; justify-content: flex-end; gap: 7px; margin-top: 3px; }
.btn--danger {
  background: var(--danger);
  border-color: var(--danger);
  color: #fff;
  font-weight: 500;
}
.btn--danger:hover { filter: brightness(1.1); background: var(--danger); color: #fff; }

/* Overview rows in the properties panel. */
.ov { display: flex; flex-direction: column; gap: 1px; margin-bottom: 10px; }
.ov__row {
  display: flex; align-items: baseline; justify-content: space-between; gap: 8px;
  padding: 3px 0;
  border-bottom: 1px solid var(--line);
}
.ov__row:last-child { border-bottom: 0; }
.ov__k { font-size: var(--fs-xs); color: var(--text-faint); text-transform: uppercase; letter-spacing: .06em; }
/*
 * Words in the interface font, figures still in columns.
 *
 * Half of what this list reports is a name — the active material — and set in
 * the mono face a two-word name is wide enough to take a second line in a panel
 * where it fits on one. `tabular-nums` keeps the thing the mono face was there
 * for: digits of one width, so the counts line up down the right edge.
 */
.ov__v {
  font: 400 var(--fs-sm) var(--ui);
  font-variant-numeric: tabular-nums;
  color: var(--text-dim);
  text-align: right;
}

/* The "new project" tile leads the grid so creating one is the first thing you see. */
/*
 * The new-project tile is a card, not a dropzone. Same anatomy as its
 * neighbours — thumbnail slot, name, meta — so the grid reads as one row of
 * peers. The mark is two hairlines rather than a typographic "+", which would
 * be the only oversized glyph anywhere in the interface.
 */
.pcard--new .pcard__thumb {
  position: relative;
  background: none;
}
.pcard--new .pcard__thumb::before,
.pcard--new .pcard__thumb::after {
  content: '';
  position: absolute;
  top: 50%; left: 50%;
  background: var(--text-faint);
  transition: background-color var(--t) var(--ease);
}
.pcard--new .pcard__thumb::before { width: 15px; height: 1px; transform: translate(-50%, -50%); }
.pcard--new .pcard__thumb::after  { width: 1px; height: 15px; transform: translate(-50%, -50%); }
.pcard--new:hover .pcard__thumb::before,
.pcard--new:hover .pcard__thumb::after { background: var(--text); }

/* ─────────────────────────────────────────────────────────── motion

   One duration, one curve, and only on properties that are cheap to animate.
   Movement here is feedback — it confirms a click landed — so it stays short
   enough to feel instant rather than decorative. */

:root { --t: 120ms; }

.btn, select, .tool, .swatch, .face, .num, .keyrow__bind,
.lib-item, .obj, .acc__head, .viewcube button, .hud-chip,
.doc-name input, .search input {
  transition:
    background-color var(--t) var(--ease),
    border-color var(--t) var(--ease),
    color var(--t) var(--ease),
    box-shadow var(--t) var(--ease);
}

/* Pressing a control should register physically, not just visually. */
.btn:active, .tool:active, .viewcube button:active { transform: translateY(0.5px); }

.swatch::after { transition: opacity var(--t) var(--ease); }
.acc__chev { transition: transform var(--t) var(--ease); }
.stage__readout, .pane-label { transition: opacity var(--t) var(--ease); }

@media (prefers-reduced-motion: reduce) {
  * { transition-duration: 1ms !important; animation-duration: 1ms !important; }
}

/* ─────────────────────────────────────────────────────────── scrollbars */

::-webkit-scrollbar { width: 9px; height: 9px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--line-strong); border: 2px solid var(--bg-panel); border-radius: 5px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-faint); }


/* Back to the block list. The arrow was a text glyph: it sat off the optical
   centre and changed shape with the interface font. A drawn chevron aligns. */
.btn--back {
  display: inline-flex; align-items: center; gap: 5px;
  padding: 0 10px 0 7px;
  color: var(--text-dim);
}
.btn--back svg { width: 11px; height: 11px; flex: none; opacity: .7; }
.btn--back:hover { color: var(--text); }
.btn--back:hover svg { opacity: 1; }


/* ─────────────────────────────────────────────────────────── layer rail */

/* A rail per flat view, on the view's own edge. A single shared control would
   have to guess which view it belonged to; this one cannot be ambiguous. */
/*
 * The working floor's handle.
 *
 * A dot standing on the plane it moves, dragged up and down like a gizmo in any
 * modeller — rather than the rail down the side of the viewport this replaces,
 * which in quad view lay across the other three panes and, even alone, put the
 * control for the scene outside the scene.
 *
 * Its catch is bigger than its mark, the split this interface makes everywhere:
 * an 18px target around a 9px dot.
 */
/* ─────────────────────────────────────────────────────────── player mode */

/*
 * The crosshair.
 *
 * Inverted rather than coloured, which is what makes it legible on a white wall
 * and on a dark one without ever being a colour of its own — the game solves it
 * the same way, and any fixed colour fails against itself somewhere.
 *
 * It sits in the middle of the *stage*, which is the middle of the pane because
 * player mode is single-view; nothing here has to track a pane rectangle.
 */
.crosshair {
  position: absolute;
  left: 50%; top: 50%;
  width: 15px; height: 15px;
  margin: -7.5px 0 0 -7.5px;
  z-index: 6;
  pointer-events: none;
  mix-blend-mode: difference;
}
.crosshair[hidden] { display: none; }
.crosshair::before, .crosshair::after {
  content: '';
  position: absolute;
  background: #fff;
}
.crosshair::before { left: 7px; top: 0; width: 1px; height: 15px; }
.crosshair::after { left: 0; top: 7px; width: 15px; height: 1px; }

/* The island and the corner cube are controls, and there is no cursor to reach
   them with while the pointer is locked — so they are screen and nothing else. */
.stage.is-player .island,
.stage.is-player .viewcube,
.stage.is-player .stage__readout { display: none; }

/*
 * In the world the window is the world — on every device, and for as long as
 * the world is on rather than only while the pointer is locked.
 *
 * The lock goes out for things that are not leaving: Escape, a window swap, and
 * above all opening the chat line, which *has* to release the mouse so there is
 * a caret to type into. Tying the chrome to the lock meant that pressing `T`
 * threw the panels, the top bar and the frame strip back over the scene mid-
 * sentence — the editor reassembling itself around a person who had not gone
 * anywhere. Everything comes back on the way out, since this is one class.
 *
 * What stays is what belongs to the world: the hotbar, the crosshair, the
 * chat, the hint line, and on a phone the pad.
 */
body.in-world .bar--top,
body.in-world .bar--status,
body.in-world .panel,
body.in-world .splitter,
body.in-world .frames { display: none; }
/* The columns go with the panels, or the stage keeps the gap one left behind. */
body.in-world .shell { --col-left: 0px; --col-right: 0px; }

.hud-chip--player { color: var(--text); border-color: var(--accent-line); }
/*
 * The hint is read once and then it is in the way.
 *
 * Five seconds and out — see `updatePlayerChrome`, which restarts it only when
 * the text actually changes. It used to fade on a phone alone, back when a
 * desktop showed it beside the view instead of over it; the world takes the
 * whole window now, and in the game's own face at the game's own size ten key
 * names are two lines and a quarter of the screen. `?` is where the full list
 * lives, and it is always there.
 *
 * `visibility` as well as opacity, so a faded banner is not an invisible slab
 * over the top of the scene catching nothing but still being laid out.
 */
.hud-chip--player { transition: opacity .6s var(--ease), visibility 0s linear .6s; }
.hud-chip--player.is-faded { opacity: 0; visibility: hidden; }
/*
 * A block of a material, drawn the way the game draws one in a slot.
 *
 * Not a flat swatch: flat reads as *colour*, and these are surfaces — a grain, a
 * weave, a tile pattern, none of which a square of average hue says anything
 * about. Not an item render either, because a material has no item form; it is
 * one tile that gets box-projected onto whatever shape is drawn with it.
 *
 * Three faces of a real cube, then, at the isometric angle, each carrying the
 * material's own tile and each at its own brightness — the same three
 * brightnesses the viewport's own key light gives a cube. Built with 3D
 * transforms rather than clipped parallelograms so the texture is skewed along
 * with the face, which is the difference between a cube and a hexagon.
 */
.cube {
  --face: calc(20px * var(--ui-scale));
  position: relative;
  width: calc(var(--face) * 1.5);
  height: calc(var(--face) * 1.5);
  display: grid;
  place-items: center;
  pointer-events: none;
}
.cube__box {
  position: relative;
  width: var(--face);
  height: var(--face);
  transform-style: preserve-3d;
  transform: rotateX(-30deg) rotateY(45deg);
}
.cube__face {
  position: absolute;
  inset: 0;
  background-image: var(--tex);
  background-size: 100% 100%;
  /* Pixel art stays pixel art: these tiles are 16 to 64 across and smoothing
     them at icon size turns a weave into a smudge. */
  image-rendering: pixelated;
  backface-visibility: hidden;
}
/*
 * Which three faces are the visible ones is arithmetic, not taste.
 *
 * Turned by `rotateY(45deg)`, a face's normal goes +Z → (0.71, 0, 0.71) and
 * +X → (0.71, 0, −0.71): the first leans toward the viewer and lands on the
 * right of the drawing, the second leans away and is not seen at all. So the
 * left-hand face is −X — `rotateY(-90deg)` — and not +X, which is what it was
 * first and why one side of the cube came out blank.
 *
 * The shading is the viewport's own key light, which points up and a little to
 * the left: the top takes it square on, the −X side at a slant, the +Z side at
 * a steeper one. Darkened with an inset shadow rather than `filter`, because a
 * filter makes its element a grouping and some engines flatten a grouped child
 * out of its parent's 3D space — the cube collapses into a flat square.
 */
.cube__face--top   { transform: rotateX(90deg) translateZ(calc(var(--face) / 2)); }
.cube__face--left  { transform: rotateY(-90deg) translateZ(calc(var(--face) / 2)); }
.cube__face--right { transform: translateZ(calc(var(--face) / 2)); }
.cube__face--left  { box-shadow: inset 0 0 0 999px rgb(0 0 0 / 0.22); }
.cube__face--right { box-shadow: inset 0 0 0 999px rgb(0 0 0 / 0.4); }
/* Glass says so by being glass, which a brightness ramp alone cannot do. */
.cube.is-translucent .cube__face { opacity: 0.68; }

/*
 * The hotbar.
 *
 * Centred along the bottom because that is where the hand expects it and
 * because the island, which lives down there too, is hidden while the pointer
 * is locked — the two never share the strip.
 */
.hotbar {
  position: absolute;
  left: 50%;
  bottom: calc(12px * var(--ui-scale));
  transform: translateX(-50%);
  display: flex;
  gap: 2px;
  padding: 2px;
  /* Below the inventory, because while that is open the bar is *inside* it. */
  z-index: 6;
  background: color-mix(in srgb, var(--bg-app) 72%, transparent);
  border: 1px solid var(--line);
  border-radius: var(--r);
}
.hotbar[hidden] { display: none; }

/*
 * The name of what is in your hand, over the bar.
 *
 * `Hud.extractSelectedItemName`, read out of the installed 26.2 jar rather than
 * recalled — in 26.x the HUD has moved out of `Gui` into `Hud`, and what was
 * remembered about it was wrong twice.
 *
 * **White with a drop shadow and no plate**, which is not a simplification: the
 * game calls `textWithBackdrop`, and that draws its `fill` only when
 * `Options.getBackgroundColor(0)` is non-zero — which resolves through
 * `backgroundForChatOnly`, on by default, to an opacity of exactly the 0 that is
 * passed in. So by default there is no plate and the text is `text(…, dropShadow
 * true)`. A backdrop appears only for somebody who has set Text Background to
 * Everywhere. Centred on the *window*, which is where the game centres it.
 *
 * **The height is the game's creative height.** `y = guiHeight - 59`, and then
 * `if (!gameMode.canHurtPlayer()) y += 14` — creative cannot be hurt, so the
 * label drops fourteen of the game's pixels to `guiHeight - 45`, the 59 being
 * the survival figure that clears an experience bar and two rows of hearts.
 * This world is creative, so 45 is the number, and it puts the bottom of the
 * text 14 gui px above the top of the hotbar.
 *
 * That gap is what transfers, not the pixel count: a gui pixel is whatever a
 * hotbar slot is divided by the game's 20, so here it is `38 / 20` of
 * `--ui-scale`, and 14 of them is 26.6. The bar's top edge is `12 + 38` up plus
 * its own 6 of padding and border.
 */
.held-name {
  position: absolute;
  left: 50%;
  bottom: calc(76.6px * var(--ui-scale) + 6px);
  transform: translateX(-50%);
  z-index: 6;
  pointer-events: none;
  max-width: min(46ch, 60%);
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
  color: #fff;
  font: 400 var(--fs-sm) var(--ui);
  text-shadow: 1px 1px 0 rgb(0 0 0 / 0.95);
}
.held-name[hidden] { display: none; }
.hotbar__slot {
  position: relative;
  width: calc(38px * var(--ui-scale));
  height: calc(38px * var(--ui-scale));
  display: grid;
  place-items: center;
  border: 1px solid transparent;
  border-radius: 2px;
  background: color-mix(in srgb, var(--bg-panel) 60%, transparent);
  cursor: pointer;
}
/*
 * An empty slot is a hole, not a tile.
 *
 * Filled at the same tone as a slot with a block in it, a slot with nothing in
 * it reads as a slot with something pale in it — a concrete cube, a white
 * painted one — and the row stops saying which of the nine are free. The fill
 * goes away and the well stays: darker than the bar it sits in, with the light
 * caught on its top edge, which is how every inset control in this interface
 * says it is empty. Its digit goes quiet with it.
 */
.hotbar__slot.is-empty {
  background: color-mix(in srgb, var(--bg-app) 45%, transparent);
  box-shadow: inset 0 1px 2px rgb(0 0 0 / 0.28);
}
.hotbar__slot.is-empty .hotbar__num { opacity: 0.45; }
/*
 * The selection is a frame *outside* the slot, as it is in the game.
 *
 * Drawn inside, it eats two pixels off every edge of the icon and the block
 * appears to shrink when you select it — which reads as the slot flinching.
 */
.hotbar__slot.is-active { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
/* The frame wins over the well, or an empty slot cannot show it is chosen. */
.hotbar__slot.is-empty.is-active {
  box-shadow: 0 0 0 2px var(--accent), inset 0 1px 2px rgb(0 0 0 / 0.28);
}
/*
 * Its own name, because `.num` is taken.
 *
 * `.num` is the properties panel's number field — a bordered, filled box with
 * `ew-resize` on it so the value can be dragged. Calling the slot's digit that
 * gave every slot a pill of input chrome in its corner and a resize cursor over
 * it: a control that was not there, offering a gesture that did nothing.
 */
/* A tool reads as a tool: a stroke icon, not a textured solid. */
.hotbar__tool {
  display: grid;
  place-items: center;
  width: calc(24px * var(--ui-scale));
  height: calc(24px * var(--ui-scale));
  color: var(--text);
  pointer-events: none;
}
.hotbar__tool svg { width: 100%; height: 100%; }

/*
 * Over the block, not behind it.
 *
 * `.cube` is a positioned element too, so with both at `z-index: auto` the one
 * later in the DOM wins and the digit went under the icon — visible only where
 * the cube's silhouette left a corner of it showing. The game draws its counts
 * over the item for the same reason there is nowhere else to put them, and
 * solves legibility the same way: an outline, so the figure reads on a pale
 * tile and a dark one alike without a panel behind it.
 */
.hotbar__num {
  position: absolute;
  z-index: 2;
  right: 3px; bottom: 1px;
  font: 400 var(--fs-xs) var(--mono);
  color: #fff;
  text-shadow:
    0 1px 0 rgb(0 0 0 / 0.8), 0 -1px 0 rgb(0 0 0 / 0.8),
    1px 0 0 rgb(0 0 0 / 0.8), -1px 0 0 rgb(0 0 0 / 0.8);
  pointer-events: none;
}

/*
 * Chat: bottom left, above the hotbar, the way the game stacks them.
 *
 * Each line carries its own plate rather than the column carrying one, because
 * lines are different lengths and a block of background the width of the
 * longest one is a panel — which is exactly what this must not become. It sits
 * over the world and has to be readable on a white wall and a dark floor alike,
 * so the plate is dark and the type is white in every theme: this is the one
 * place in the interface that is not on a surface of ours.
 */
.chat {
  position: absolute;
  z-index: 6;
  display: flex;
  flex-direction: column;
  pointer-events: none;
  /*
   * The chat's shape is `ChatComponent`'s, in the game's own pixels.
   *
   * Every number here is transcribed and every one of them is the player's own
   * setting run through the game's arithmetic — `applyAppearance` does that
   * conversion, which is why these are plain counts multiplied by `--hud-px`
   * rather than lengths. Width `floor(chatWidth · 280 + 40)`, 320 at the
   * default. Bottom 40, which is where `extractRenderState` puts the last line:
   * `guiHeight - 40`. Left 0 — the game starts the text column at the very edge
   * and the 4-pixel indent is the *plate's*, not the column's.
   *
   * `--chat-scale` is the chat's own zoom, separate from the GUI scale, because
   * the game keeps them separate: somebody who wants small chat in a big HUD
   * sets that one.
   */
  left: 0;
  bottom: calc(40 * var(--hud-px));
  width: calc(var(--chat-w) * var(--hud-px) * var(--chat-scale));
  gap: 0;
}
.chat[hidden] { display: none; }
.chat__lines { display: flex; flex-direction: column; align-items: stretch; gap: 0; }
/*
 * White on black, with the game's own hard shadow.
 *
 * What makes text readable over a world rather than over a surface is not the
 * plate behind it — the plate is a quarter-opaque and the world shows through
 * it — it is the shadow: one pixel down and right, opaque black, no blur. That
 * is what the game draws and it is the reason its chat is legible on snow and
 * on stone alike. A soft glow instead reads as a smudge at small sizes, and a
 * grey ink over a bright floor reads as nothing at all.
 */
.chat__line {
  /*
   * One entry of the game's chat, to its own measurements.
   *
   * A line is `9 · (chatLineSpacing + 1)` tall and the plate fills all of it —
   * no gap between rows, no rounding — because the game draws a continuous
   * column of `fill` calls and the seams between them are invisible. The
   * 4-pixel left inset is `MESSAGE_INDENT`: the plate starts at the edge and
   * the text is indented into it.
   *
   * The plate's alpha is `textBackgroundOpacity` and the ink's is
   * `chatOpacity · 0.9 + 0.1` — carried as custom properties so the player's
   * own settings reach them. `--chat-fade` is the per-line ramp, which
   * `renderChat` writes from the game's curve.
   */
  padding: 0 0 0 calc(4 * var(--hud-px));
  border-radius: 0;
  background: rgb(0 0 0 / calc(var(--chat-bg) * var(--chat-fade, 1)));
  color: rgb(255 255 255 / calc(var(--chat-ink) * var(--chat-fade, 1)));
  font: 400 var(--fs-sm) var(--ui);
  /*
   * The shadow is one *game* pixel down and right, opaque black, no blur.
   *
   * What makes text readable over a world rather than over a surface is not the
   * plate — that is half-opaque and the world shows through it — it is this. A
   * soft glow reads as a smudge at these sizes, and grey ink over a bright
   * floor reads as nothing at all. It fades with the line, or a message on its
   * way out leaves its own shadow behind.
   */
  text-shadow:
    var(--hud-px) var(--hud-px) 0 rgb(0 0 0 / calc(0.95 * var(--chat-fade, 1)));
  overflow-wrap: anywhere;
}
/*
 * What the room says about itself, as against what a person says.
 *
 * Said with a mark, not with a colour. It was set in grey italics, which is how
 * an aside is written on a panel and is unreadable over a world: the one thing
 * every line here has to survive is being drawn over whatever colour happens to
 * be behind it, so every line is white and the difference is carried by the
 * bullet in front of it.
 */
.chat__line--system::before {
  content: '· ';
  color: #ffd98a;
}
/*
 * Arriving and leaving: `§e`, which is 255 255 85 and nothing else in the
 * game's chat. Written with the line's own fade rather than a flat colour, or
 * it would be the one line in the column that does not go out with the rest.
 */
.chat__line--room { color: rgb(255 255 85 / calc(var(--chat-ink) * var(--chat-fade, 1))); }
.chat__who { font-weight: 600; }
/*
 * How many times the room said the same thing running.
 *
 * Dimmed rather than coloured, and `tabular-nums` so a count ticking 2 · 3 · 4
 * under the cursor does not shift the line it sits on. Dimming here is alpha on
 * white and not a `--text-faint`, because this column has no surface behind it:
 * the plate is a quarter-opaque and what carries the text is the hard shadow,
 * which the span keeps.
 */
.chat__times {
  margin-left: 0.4em;
  color: rgb(255 255 255 / 0.62);
  font-variant-numeric: tabular-nums;
}
/*
 * The line being typed: across the foot of the window, as in the game.
 *
 * Full width because a message is as long as it is — a box that stops halfway
 * is a box people write around — and it is the one thing in this corner that
 * takes the mouse, since it has a caret in it.
 */
.chat-bar {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 7;
  padding: 2px;
}
.chat-bar[hidden] { display: none; }
.chat-bar__input {
  width: 100%;
  padding: 4px 6px;
  background: rgb(0 0 0 / 0.55);
  border: 1px solid rgb(255 255 255 / 0.2);
  border-radius: 2px;
  color: #fff;
  font: 400 var(--fs-sm) var(--ui);
}
.chat-bar__input:focus { outline: none; border-color: rgb(255 255 255 / 0.45); }

/*
 * The send button, on touch only.
 *
 * It sits *in* the line rather than under it, so the two together read as one
 * control: type here, press that. Sized to a thumb — the field beside it is
 * only as wide as what is left, which is the right way round, since a message
 * that runs past the edge scrolls and a button that cannot be hit does not.
 */
body.is-mobile .chat-bar {
  display: flex;
  align-items: stretch;
  gap: 6px;
}
body.is-mobile .chat-bar__input { flex: 1 1 auto; width: auto; }
.chat-bar__send {
  flex: 0 0 auto;
  min-width: var(--tap);
  padding: 4px 12px;
  background: rgb(255 255 255 / 0.14);
  border: 1px solid rgb(255 255 255 / 0.28);
  border-radius: 2px;
  color: #fff;
  font: 500 var(--fs-sm) var(--ui);
}
.chat-bar__send:active { background: rgb(255 255 255 / 0.26); }

/*
 * The completions, stacked on the line.
 *
 * Narrow rather than full width: it is a list of words, and a box the width of
 * the window for three short words reads as a dialog that has opened. The
 * highlighted one is white on a lighter plate and the rest are grey on the
 * same black — which is the game's own way round, and the reason it can be read
 * at a glance while the hand is still typing.
 */
.chat-suggest {
  position: absolute;
  left: 2px;
  bottom: 100%;
  margin-bottom: 2px;
  min-width: 120px;
  max-width: 60%;
  background: rgb(0 0 0 / 0.72);
  border: 1px solid rgb(255 255 255 / 0.18);
  border-radius: 2px;
  overflow: hidden;
}
.chat-suggest[hidden] { display: none; }
.chat-suggest__item {
  padding: 2px 7px;
  color: #b9c0cc;
  font: 400 var(--fs-sm) var(--ui);
  text-shadow: 1px 1px 0 rgb(0 0 0 / 0.95);
  cursor: pointer;
  white-space: nowrap;
}
.chat-suggest__item.is-on { background: rgb(255 255 255 / 0.16); color: #fff; }
/* Open, the column stands on the input rather than over the hotbar. */
.chat.is-typing { bottom: calc(34px * var(--ui-scale)); }
body.is-mobile .chat { bottom: calc(112px * var(--ui-scale)); }

/* ── the inventory */

.inventory {
  position: absolute;
  inset: 0;
  z-index: 8;
  display: grid;
  place-items: center;
  background: color-mix(in srgb, var(--bg-app) 55%, transparent);
  backdrop-filter: blur(2px);
}
.inventory[hidden] { display: none; }
.inventory__panel {
  display: flex;
  flex-direction: column;
  width: min(760px, 88%);
  max-height: 78%;
  background: var(--bg-panel);
  border: 1px solid var(--line);
  border-radius: var(--r);
  box-shadow: 0 12px 40px rgb(0 0 0 / 0.35);
}
.inventory__head {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 10px;
  border-bottom: 1px solid var(--line);
}
.inventory__head h3 { margin: 0; font: 600 var(--fs-md) var(--ui); }
.inventory__search {
  flex: 1;
  min-width: 0;
  height: var(--row);
  padding: 0 8px;
  background: var(--bg-app);
  border: 1px solid var(--line);
  border-radius: 3px;
  color: var(--text);
  font: 400 var(--fs-sm) var(--ui);
}
/*
 * `min-height: 0`, or there is nothing to scroll.
 *
 * A flex item's automatic minimum size is its *content*, so a column item that
 * holds a long list refuses to become shorter than that list — the panel's
 * `max-height` then clips it and `overflow: auto` never has an overflow to act
 * on. The list simply runs off the bottom of the panel and cannot be reached,
 * which on a phone, where the panel is the whole screen, means the palette
 * stops at whatever fits.
 */
.inventory__body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  padding: 8px 10px;
}
.inventory__group { font: 600 var(--fs-xs) var(--ui); color: var(--text-faint); margin: 10px 0 4px; }
.inventory__group:first-child { margin-top: 0; }
.inventory__grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(calc(46px * var(--ui-scale)), 1fr));
  gap: 3px;
}
.inventory__item {
  display: grid;
  place-items: center;
  padding: 2px;
  border: 1px solid transparent;
  border-radius: 2px;
  background: color-mix(in srgb, var(--bg-app) 70%, transparent);
  cursor: pointer;
}
.inventory__item:hover { border-color: var(--accent-line); }
.inventory__item.is-active { border-color: var(--accent); }
/*
 * The hotbar's row inside the panel.
 *
 * The same slot markup, so a slot looks and behaves the same in both places —
 * it is one bar that moves, not two that resemble each other.
 */
.inventory__bar {
  display: flex;
  justify-content: center;
  gap: 2px;
  padding: 8px 10px 2px;
  border-top: 1px solid var(--line);
}
.inventory__bar:empty { display: none; }

.inventory__cursor {
  position: absolute;
  z-index: 10;
  pointer-events: none;
  transform: translate(-50%, -50%);
  filter: drop-shadow(0 3px 6px rgb(0 0 0 / 0.5));
}
.inventory__cursor[hidden] { display: none; }

.inventory__hint {
  margin: 0;
  padding: 7px 10px;
  border-top: 1px solid var(--line);
  font: 400 var(--fs-xs) var(--ui);
  color: var(--text-faint);
}

/* Quiet while the mouse is back on the page: it is a way out, not a warning. */
.hud-chip--player.is-loose { border-color: var(--line); color: var(--text-faint); }

.floor-grip {
  position: absolute;
  width: 18px; height: 18px;
  margin: -9px 0 0 -9px;
  z-index: 5;
  cursor: ns-resize;
  touch-action: none;
}
.floor-grip[hidden] { display: none; }
.floor-grip::after {
  content: '';
  position: absolute; inset: 4.5px;
  border-radius: 50%;
  background: var(--bg-panel);
  border: 2px solid var(--text-faint);
  transition: border-color 90ms ease-out, transform 90ms ease-out;
}
.floor-grip:hover::after { border-color: var(--text); }
.floor-grip.is-on::after { border-color: var(--accent); }
.floor-grip.is-dragging::after { border-color: var(--accent); transform: scale(1.25); }

.rail {
  position: absolute;
  /*
   * Wider than the column it draws: the extra pixels either side are live, so
   * the rail is caught before the cursor reaches the cells. On a deep view the
   * cells are only a few pixels tall, and every pixel of slack counts.
   */
  width: 30px;
  z-index: 6;
  pointer-events: auto;
  touch-action: none;
  /* Cells are picked, not dragged along a track. */
  cursor: pointer;
}
/*
 * One cell per layer, drawn on a canvas. Every pixel of the control belongs to
 * a layer, which is what the two round handles it replaced could not manage.
 */
.rail__cells { position: absolute; inset: 0; width: 100%; height: 100%; }
/*
 * The layer under the cursor, named.
 *
 * A view forty-eight layers deep gives a cell three pixels, and no amount of
 * highlighting tells you which of three-pixel neighbours you are on. The number
 * does, and it sits clear of the column so the cursor never covers it.
 */
.rail__tip {
  position: absolute;
  right: calc(100% - 4px);
  transform: translateY(-50%);
  padding: 1px 5px;
  border-radius: var(--r);
  background: var(--bg-raised);
  border: 1px solid var(--line-strong);
  font: 400 var(--fs-xs) var(--mono);
  color: var(--text);
  white-space: nowrap;
  pointer-events: none;
  z-index: 1;
}
.rail__label {
  /*
   * Beside the column, not above it. Sitting above only works until the span
   * reaches the top of its travel, and then the cells land between the digits.
   * Off to one side there is no position the column can reach that collides.
   */
  position: absolute; top: -2px; right: calc(50% + 10px);
  text-align: right;
  font: 400 var(--fs-xs) var(--mono);
  color: var(--text-faint);
  white-space: nowrap;
}


/* ─────────────────────────────────────────────────────────── icon tools */

.tool--icon {
  width: var(--row);
  padding: 0;
  justify-content: center;
}
.tool--icon svg { width: 15px; height: 15px; }

/*
 * Tooltip.
 *
 * The native `title` attribute waits about a second and renders in the
 * platform's own style, which is exactly wrong for a toolbar you scan. This one
 * appears immediately and matches the interface.
 */
/*
 * Tooltips live in the body, not in the element they describe.
 *
 * As `::after` on the target they were clipped by every ancestor that scrolls —
 * the frame strip clips its tracks, a track clips its frames — and they lost to
 * anything stacking above them, which on the island meant the tooltip opened
 * behind the toolbar it belonged to. Positioned in the body, nothing clips them
 * and one z-index settles the order.
 */
.tip {
  position: fixed;
  z-index: 90;
  padding: 3px 7px;
  background: var(--bg-raised);
  border: 1px solid var(--line-strong);
  border-radius: var(--r);
  color: var(--text);
  font: 400 var(--fs-sm)/1.2 var(--ui);
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  transition: opacity 90ms ease-out;
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
}
.tip.is-on { opacity: 1; }

/* Still visible-overflow: the island holds popovers of its own. */
.island { overflow: visible; }


/* A toggle that cannot apply to the current tool reads as unavailable rather
   than merely inactive — otherwise it looks like it simply failed to switch. */
.tool--toggle.is-disabled {
  opacity: .32;
  cursor: not-allowed;
  pointer-events: none;
}


/* Card actions carry drawn icons, so they need a box to centre in rather than
   inheriting a text baseline. */
.pcard__act { display: inline-flex; align-items: center; justify-content: center; }
.pcard__act svg { width: 18px; height: 18px; }


/* Shape buttons carry icons rather than words. */
.board-mode[data-shape] {
  width: var(--row);
  padding: 0;
  display: inline-flex; align-items: center; justify-content: center;
}
.board-mode[data-shape] svg { width: 14px; height: 14px; }
.board-modes[hidden] { display: none; }


/* The caption of the view being pointed at, lit to match the slice in the solid. */
.pane-tag.is-hovered { color: var(--text); border-color: var(--accent-line); }

/* ─────────────────────────────────────────────────────────── split button */

/*
 * One action with variants, not several buttons.
 *
 * Export is pressed constantly and installing is occasional; side by side in the
 * bar they read as equals and both compete for the same glance.
 */
/* The bar's own gap spaces these like every other control in it. The extra
   margin that used to be here was compensating for markup that nested one
   split inside the other; with that fixed it is simply a hole. */
.split { position: relative; display: inline-flex; }
/*
 * One control with a seam, not two buttons with a gap.
 *
 * The halves were held apart by a margin, which the accent button hid — both
 * halves are the same blue — and the plain one did not: the render button and
 * its chevron read as two separate controls that happened to be adjacent. They
 * meet now, and a hairline says where one ends.
 */
.split__main { border-radius: var(--r) 0 0 var(--r); border-right-color: transparent; }
.split__more {
  border-radius: 0 var(--r) var(--r) 0;
  padding: 0 6px;
  margin-left: 0;
  border-left: 1px solid var(--line);
  display: inline-flex; align-items: center;
}
.split__main.btn--primary { border-right-color: var(--accent); }
/* On the accent pair the seam is a shade of the accent, not a grey line. */
.split__more.btn--primary { border-left-color: color-mix(in srgb, var(--accent-fg) 22%, var(--accent)); }
/* Hovering either half lights that half only, so the seam still reads. */
.split:hover .split__main:not(:hover):not(.btn--primary) { background: transparent; }
.split__more svg { width: 11px; height: 11px; }

.split__menu {
  /* Placed against the window by `placeMenu`; see app.js for why. */
  position: fixed;
  min-width: 230px;
  padding: 4px;
  background: var(--bg-raised);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-lg);
  box-shadow: 0 10px 32px rgba(0, 0, 0, .4);
  z-index: 60;
}
.split__menu[hidden] { display: none; }

/*
 * Items are told apart by rhythm, not by rules.
 *
 * Each row is a title with a line of explanation under it, and both used to sit
 * in the same tone at the same spacing, so four rows read as eight lines of
 * text. Lines between them would answer that, and would also put a grid through
 * a menu that is mostly quiet — so the answer is proximity instead: the two
 * lines of one item are pressed together (line-height 1.3, 2px between them)
 * and the items are pushed apart (4px of margin on top of the padding). The
 * title takes the weight, the explanation stays at the quiet tone.
 */
.split__item {
  display: block; width: 100%;
  padding: 6px 9px;
  background-color: transparent; border: 0; border-radius: var(--r);
  color: var(--text); font: 400 var(--fs-md)/1.3 var(--ui);
  text-align: left; cursor: pointer;
}
/* Only rows that carry an explanation need the extra air; a radio in a submenu
   is one line, and spacing those out makes a list look unfinished. `--sub` rows
   also hold an `em`, but it is the current value sitting on the same line. */
.split__item:has(em):not(.split__item--sub) { margin-top: 4px; }
.split__item:has(em):not(.split__item--sub):first-child { margin-top: 0; }
.split__item:has(em):not(.split__item--sub) > span:first-child { font-weight: 500; }
/* , not the shorthand: the tick column is a background
   image on the same element, and a shorthand here would wipe it on hover. */
.split__item:hover:not(:disabled) { background-color: var(--bg-hover); }
.split__item:disabled { color: var(--text-faint); cursor: default; }
/*
 * A tick in the margin, the way a menu marks a setting that is on.
 *
 * An empty square on every row draws a column of boxes down a menu that is
 * mostly not about boxes; a tick appears only where something is switched on,
 * and the row itself carries the state as well.
 */
.split__item--check { position: relative; padding-left: 26px; }
/*
 * A column for the tick, in the render menu only.
 *
 * That menu is a set of options that combine — four switches you read as a
 * group — and the band gives the marks a place to line up in. The submenus
 * elsewhere are lists of alternatives where exactly one row is ticked, and a
 * band down those draws a column of empty grey next to a single mark.
 *
 * A background image rather than a border or a box: the row's own hover colour
 * keeps changing underneath it, and the tone is mixed from the text colour so
 * the band stays visible over that hover and in all three themes.
 */
#renderMenu .split__item--check {
  background-image: linear-gradient(to right,
    color-mix(in srgb, var(--text) 9%, transparent) 0 22px,
    transparent 22px);
}
/*
 * Drawn, not typed.
 *
 * The glyph "✓" comes out of the interface font at text weight and in whatever
 * shape that font has for it — thin, soft, and a size smaller than it looks
 * like it should be. Two borders on a rotated box give a tick of a stated
 * thickness that matches the rest of the interface's line work.
 */
.split__item--check::before {
  content: '';
  position: absolute; left: 11px; top: 50%;
  width: 4px; height: 8px;
  border: solid currentColor;
  border-width: 0 2px 2px 0;
  /*
   * Centred on the band by its own middle, then lifted a little.
   *
   * Offsetting it by hand put it wherever the numbers happened to land; and a
   * tick's optical centre sits above its geometric one, because the long stroke
   * runs up and to the right — so -58% rather than -50% is what looks level.
   */
  transform: translate(-50%, -58%) rotate(45deg);
  opacity: 0;
  transition: opacity 90ms ease-out;
}
.split__item--check.is-on::before { opacity: 1; }
.split__item--check.is-on { color: var(--text); }
.split__item--check.is-on em { color: var(--text-dim); }
.split__rule { height: 1px; margin: 3px 6px; background: var(--line); }
/*
 * A row that opens a submenu: the question on the left, its current answer on
 * the right, and the arrow that says there is more.
 *
 * The answer is in the row because a menu of questions with no answers makes
 * you open every branch to find out where you are.
 */
.split__item--sub {
  display: flex; align-items: center; gap: 10px;
  padding-right: 22px;
  position: relative;
}
.split__item--sub em {
  margin-left: auto;
  /* The block rules above are for an explanation under a title; here the `em`
     is the current value, on the same line as the label. */
  margin-top: 0; max-width: none;
  color: var(--text-faint); font: 400 var(--fs-xs) var(--ui); font-style: normal;
  white-space: nowrap;
}
/*
 * The row a branch is hanging off stays marked for as long as it stands open.
 *
 * Its own hover is gone the moment the pointer steps into the submenu — so
 * without this the panel hangs off a row in the same tone as every other one,
 * and nothing on screen says which question it is answering. Same tone as
 * hover rather than a louder one: the cursor is elsewhere now, and this is
 * where it came from, not where it is.
 */
.split__item--sub.is-open:not(:disabled) { background-color: var(--bg-hover); }
.split__item--sub::after {
  content: '›';
  position: absolute; right: 9px; top: 50%;
  transform: translateY(-50%);
  color: var(--text-faint);
}
.split__menu--sub { min-width: 170px; }

/*
 * In a context menu the tick goes on the right, in the margin.
 *
 * A column of ticks down the left pushes every label away from the edge for the
 * sake of the one row that is on — and in a menu of choices, where exactly one
 * row in each list is ticked, that column is mostly empty. On the right it sits
 * where a value would, which is what it is: the state of the row it ends.
 */
#islandMenu .split__item--check {
  padding-left: 9px;
  padding-right: 24px;
  background-image: none;
}
#islandMenu .split__item--check::before {
  left: auto; right: 12px;
  transform: translate(0, -58%) rotate(45deg);
}

/* A heading inside a menu: what the rows below it answer. */
.ctxmenu__group {
  padding: 5px 9px 3px;
  color: var(--text-faint);
  font: 600 var(--fs-xs) var(--ui);
  text-transform: uppercase; letter-spacing: .06em;
}
.split__item:disabled em { color: var(--text-faint); }
.split__item em {
  display: block;
  font: 400 var(--fs-xs)/1.35 var(--ui); font-style: normal;
  color: var(--text-faint); margin-top: 2px;
  /* An explanation that runs the full width of the menu reads as a paragraph
     and stops belonging to the title above it. */
  max-width: 32ch;
}

/*
 * Text in sheets and dialogs can be selected.
 *
 * The interface as a whole disables selection so a drag never leaves labels
 * highlighted — but a sheet is usually showing an error, and an error exists to
 * be copied somewhere else.
 */
/* A render is looked at whole: fitted to the sheet rather than scrolled. */
.sheet__body img {
  display: block;
  max-width: 100%;
  max-height: 66vh;
  margin: 0 auto;
  object-fit: contain;
  image-rendering: pixelated;
}

.sheet__body, .dlg__body, .sheet__body *, .dlg__body * { user-select: text; }
.sheet__actions { display: flex; align-items: center; gap: 4px; }

/* ───────────────────────────────────────────────────────────── frame strip

   A band along the bottom of the stage, the way every frame-based animator
   puts it. It appears only once a clip exists, and pushes the tool island up
   rather than sitting under it — the island is the thing you reach for while
   drawing a frame.
*/
/* `display: flex` outranks the browser's rule for [hidden], so the attribute
   has to be answered explicitly or the strip never goes away. */
.frames[hidden] { display: none; }
.frames {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  z-index: 5;
  display: flex; flex-direction: column; gap: 6px;
  padding: 8px 10px 10px;
  background: color-mix(in srgb, var(--bg-panel) 94%, transparent);
  border-top: 1px solid var(--line-strong);
  backdrop-filter: blur(10px);
}
/* The strip grows with the number of tracks, so the island is lifted by its
   measured height rather than by a guess — see the ResizeObserver in app.js. */
.stage.is-animating .island { bottom: calc(var(--frames-h, 120px) + 14px); }

.frames__controls { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.frames__btn {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 28px; height: 26px;
  padding: 0 6px;
  background: var(--bg-input); color: var(--text);
  border: 1px solid var(--line); border-radius: var(--r);
  font: 400 var(--fs-md) var(--ui); cursor: pointer;
}
.frames__btn:hover { background: var(--bg-hover); }
.frames__btn svg { width: 15px; height: 15px; }
/* The plus and minus on a track row are text, and they need the same weight as
   the icons beside them rather than the body size. */
.track__drop, .track__add { line-height: 1; }
.frames__btn.is-active { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
.frames__field {
  display: flex; align-items: center; gap: 4px;
  color: var(--text-faint); font: 400 var(--fs-xs) var(--ui);
}
/* Inputs inherit the OS look unless told otherwise, and an unstyled number
   field is a white box in a dark panel. */
.frames__field input[type="number"] {
  width: 52px;
  padding: 2px 4px;
  background: var(--bg-input);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-sm);
  color: var(--text);
  font: 400 var(--fs-xs) var(--mono);
}
.frames__field input[type="number"]:focus { outline: 1px solid var(--accent-line); outline-offset: -1px; }
.frames__field input[type="checkbox"] { accent-color: var(--accent); }
.frames__field--check { cursor: pointer; }
.frames__count { margin-left: auto; color: var(--text-faint); font: 400 var(--fs-xs) var(--ui); }

/* One cell per frame, scrolling sideways. Numbered, because at twenty frames
   a thumbnail is too small to tell one pendulum position from the next. */
/* One row per track, the base above them — the layer list of an animator. */
.frames__tracks {
  display: flex; flex-direction: column; gap: 3px;
  /* Dragged height wins; the default holds three or four tracks. */
  max-height: calc(var(--frames-max, 148px) - 46px);
  overflow-y: auto;
}
.track {
  display: flex; align-items: center; gap: 6px;
  padding: 2px 4px;
  border: 1px solid transparent; border-radius: var(--r);
}
.track.is-active { border-color: var(--accent); background: var(--accent-soft); }
.track__name {
  flex: 0 0 auto; min-width: 92px; max-width: 150px;
  padding: 3px 6px;
  background: none; border: 0; border-radius: var(--r-sm);
  color: var(--text); font: 500 var(--fs-xs) var(--ui);
  text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  cursor: pointer;
}
.track__name:hover { background: var(--bg-hover); }
.track__loop { font: 400 var(--fs-xs) var(--ui); }
.track__meta, .track__hint { color: var(--text-faint); font: 400 var(--fs-xs) var(--ui); white-space: nowrap; }
.track__drop {
  order: 9;             /* after the frames, not between them and the meta */
  margin-left: auto;
  width: 20px; height: 20px;
  background: none; border: 0; border-radius: var(--r-sm);
  color: var(--text-faint); cursor: pointer;
}
.track__drop:hover { background: var(--bg-hover); color: var(--text); }
.track__add {
  align-self: flex-start;
  margin-top: 2px; padding: 3px 8px;
  background: none; border: 1px dashed var(--line-strong); border-radius: var(--r);
  color: var(--text-dim); font: 400 var(--fs-xs) var(--ui); cursor: pointer;
}
.track__add:hover { color: var(--text); border-color: var(--accent); }
.track__frames {
  flex: 1 1 auto; min-width: 0;
  display: flex; gap: 4px;
  overflow-x: auto; overflow-y: hidden;
  padding-bottom: 2px;
  min-height: 34px;
}
.frame {
  flex: 0 0 auto;
  width: 36px; height: 30px;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
  background: var(--bg-input);
  border: 1px solid var(--line); border-radius: var(--r);
  color: var(--text-faint); font: 400 var(--fs-xs) var(--ui);
  cursor: pointer;
}
.frame:hover { background: var(--bg-hover); }
/* Where a track currently is — shown on every track, so playback steps
   visibly across all of them rather than only the selected one. */
.frame.is-now { border-color: var(--accent-line); color: var(--text); }
/* Where a stroke would land: the current frame of the track being edited. */
.frame.is-active { border-color: var(--accent); color: var(--text); box-shadow: inset 0 0 0 1px var(--accent); }
/* How long the frame is held, in ticks. Only shown when it is not the default,
   so a strip of ordinary frames stays quiet. */
.frame__hold { font-size: 9px; color: var(--text-dim); }

/* The plus that ends the strip, and the states a drag puts frames in. */
.frame--add { border-style: dashed; color: var(--text-dim); font-size: 15px; }
.frame.is-dragging { opacity: 0.4; }
.frame.is-drop { border-color: var(--accent); background: var(--accent-soft); }

/* A menu built per click, so it is positioned in viewport coordinates. */
.ctxmenu {
  position: fixed; z-index: 40;
  min-width: 150px;
  padding: 4px;
  display: flex; flex-direction: column;
  background: var(--bg-raised);
  border: 1px solid var(--line-strong); border-radius: var(--r);
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
}
.ctxmenu__item {
  padding: 5px 9px;
  background: none; border: 0; border-radius: var(--r-sm);
  color: var(--text); font: 400 var(--fs-md) var(--ui);
  text-align: left; cursor: pointer;
}
.ctxmenu__item:hover:not(:disabled) { background: var(--bg-hover); }
.ctxmenu__item:disabled { color: var(--text-faint); cursor: default; }

/* Said once, across the top, where it cannot be missed or mistaken for chrome. */
.stale {
  position: fixed; top: 0; left: 0; right: 0;
  z-index: 200;
  display: flex; align-items: center; justify-content: center; gap: 8px;
  padding: 5px 10px;
  background: var(--danger);
  color: #fff;
  font: 500 var(--fs-sm) var(--ui);
}
.stale__cmd {
  padding: 1px 6px;
  background: rgba(0, 0, 0, .25);
  border-radius: var(--r-sm);
  font: 400 var(--fs-sm) var(--mono);
  user-select: all;
}

/* ══════════════════════════════════════════════════════════════════════════
   Touch

   One stylesheet, one set of tokens, one markup tree — what changes below is
   the layout and the size of a target. Everything here hangs off `.is-mobile`
   on <body>, which `mobile.js` sets from the *pointer* rather than the width,
   so a narrow desktop window stays the desktop editor and a tablet gets this
   one at its full size.

   Three rules run through all of it:

   - a target is at least 44px, because that is a fingertip and no amount of
     precision makes one smaller;
   - nothing waits for a hover, because there is none — a card's actions are on
     the card, not revealed by pointing at it;
   - the phone's own furniture is respected: `env(safe-area-inset-*)` keeps the
     bar out from under a notch and the shelf above the home indicator.
   ══════════════════════════════════════════════════════════════════════════ */

body.is-mobile {
  /* Thumbs, not cursors: the whole scale steps up rather than each control
     being special-cased. */
  --tap: 44px;
  --safe-t: env(safe-area-inset-top, 0px);
  --safe-b: env(safe-area-inset-bottom, 0px);
  --safe-l: env(safe-area-inset-left, 0px);
  --safe-r: env(safe-area-inset-right, 0px);
}

/* ── the browser of packs and blocks ─────────────────────────────────────── */

body.is-mobile .home__bar {
  height: auto;
  min-height: var(--tap);
  padding: calc(6px + var(--safe-t)) calc(12px + var(--safe-r)) 6px calc(12px + var(--safe-l));
  gap: 8px;
}
/* The trail of names is the one thing that can outgrow a phone, so it scrolls
   on its own rather than pushing the actions off the end of the bar. */
body.is-mobile .crumbs {
  overflow-x: auto;
  scrollbar-width: none;
  flex: 1 1 auto;
  min-width: 0;
}
body.is-mobile .crumbs::-webkit-scrollbar { display: none; }
body.is-mobile .home__bar .bar__spacer { display: none; }
body.is-mobile .home__acts { flex: none; }
body.is-mobile .home__acts .btn { min-height: var(--tap); padding-inline: 12px; }

body.is-mobile .home__body {
  padding: 14px calc(12px + var(--safe-r)) calc(28px + var(--safe-b)) calc(12px + var(--safe-l));
}
/*
 * The title, then the action under it across the full width.
 *
 * Side by side, "Новый набор" is a small target squeezed against the right edge
 * — the least reachable corner of a phone held in one hand, and the one action
 * this screen exists to offer.
 */
body.is-mobile .home__head { display: block; }
body.is-mobile .home__title { margin-bottom: 10px; }
body.is-mobile .home__head > .btn {
  display: block;
  width: 100%;
  min-height: var(--tap);
  margin-bottom: 16px;
}

/* Two columns at any phone width: one is a list of billboards, three is a grid
   of thumbnails too small to tell apart. */
body.is-mobile .home__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
@media (max-width: 380px) {
  body.is-mobile .home__grid { grid-template-columns: minmax(0, 1fr); }
}

/*
 * A card's actions are always on it.
 *
 * On the desktop they appear on hover, so that a grid of packs reads as
 * thumbnails rather than as a wall of buttons. There is no hover here, and an
 * action nobody can reach is an action that does not exist — rename, duplicate
 * and delete would all be desktop-only features by accident.
 */
body.is-mobile .pcard__actions { opacity: 1; gap: 6px; top: 6px; right: 6px; }
body.is-mobile .pcard__act { width: 34px; height: 34px; font-size: var(--fs-sm); }
body.is-mobile .pcard__body { padding: 10px 12px 12px; }
body.is-mobile .pcard__name { font-size: var(--fs-md); }

/* ── dialogs, as a sheet off the bottom edge ─────────────────────────────── */

/*
 * A centred box is a desktop shape: it puts the fields under the notch and the
 * buttons in the middle of the screen, where a thumb does not reach. The sheet
 * is the phone's own answer — it rises from the edge the hand is already at,
 * and its buttons are the last thing above that edge.
 */
body.is-mobile .dlg { align-items: flex-end; padding: 0; }
body.is-mobile .dlg__panel {
  width: 100%;
  max-width: none;
  max-height: 88vh;
  border-radius: var(--r-lg) var(--r-lg) 0 0;
  padding-bottom: var(--safe-b);
}
body.is-mobile .dlg__body { overflow-y: auto; }
body.is-mobile .dlg__foot .btn { min-height: var(--tap); flex: 1 1 auto; }
body.is-mobile .dlg__body input,
body.is-mobile .dlg__body select,
body.is-mobile .dlg__body textarea {
  min-height: var(--tap);
  /* Under 16px, iOS Safari zooms the page on focus and never zooms back — the
     layout is then wrong for the rest of the session. */
  font-size: max(16px, var(--fs-md));
}

/* ── the editor's bar ────────────────────────────────────────────────────── */

/* In the markup everywhere, on screen only here: one bar to keep in step
   rather than two, and the stylesheet decides which half of it is shown. */
/*
 * `hidden` still means hidden.
 *
 * `body.is-mobile .mob-only` outranks `.tpad[hidden]` — two classes and a type
 * against one class and an attribute — so the world's pad, its D-pad and its
 * pause button stood on top of the editor at all times, over the tools and the
 * frame strip both. Anything shown by a class has to say `:not([hidden])`, or
 * the class quietly overrules the attribute every other line of this file
 * trusts.
 */
.mob-only { display: none; }
body.is-mobile .mob-only:not([hidden]) { display: flex; }

body.is-mobile .bar--top {
  /* Its own height, because the notch's inset is part of it — a fixed 40px row
     would put the controls under the camera on a phone that has one. */
  height: auto;
  min-height: calc(var(--tap) + var(--safe-t));
  padding: calc(4px + var(--safe-t)) calc(8px + var(--safe-r)) 4px calc(8px + var(--safe-l));
  gap: 6px;
}
/*
 * What the phone's bar does not carry.
 *
 * Layout, projection, the scale figure and the render/export splits are all in
 * the ⋯ menu — none of them is touched mid-stroke, and each was a target the
 * width of a word. The brand goes because a phone knows which app it is in, and
 * the document's name stays: it is the only thing here that says which block
 * you have open.
 */
body.is-mobile .bar--top .brand,
body.is-mobile #btnQuad,
body.is-mobile #projModes,
body.is-mobile #btnScale,
body.is-mobile #btnPlayer,
body.is-mobile #btnSettings,
body.is-mobile #renderSplit,
body.is-mobile #exportSplit { display: none; }

body.is-mobile .bar--top .btn { min-height: calc(var(--tap) - 8px); }
body.is-mobile .bar--top .btn--back { padding-inline: 8px; }
/* The name field takes what is left rather than sizing to its text: on a phone
   a long name would push the mode switch off the end of the bar. */
body.is-mobile .doc-name { flex: 1 1 auto; min-width: 40px; }
body.is-mobile .doc-name input { width: 100%; font-size: max(16px, var(--fs-md)); }
body.is-mobile .bar--top .bar__spacer { display: none; }

body.is-mobile #modeSwitch .board-mode {
  height: calc(var(--tap) - 8px);
  padding: 0 11px;
  font-size: var(--fs-md);
}
body.is-mobile #btnMore {
  min-width: calc(var(--tap) - 8px);
  justify-content: center;
  font-size: var(--fs-lg);
  line-height: 1;
}

/* A menu is a list of things to tap, so its rows are the size of a tap. */
body.is-mobile .split__menu { min-width: 220px; }
body.is-mobile .split__item,
body.is-mobile .ctxmenu__item { min-height: var(--tap); }

/* ── panels, as sheets ───────────────────────────────────────────────────── */

/*
 * The stage takes the whole window and the panels come up over it.
 *
 * Two columns of chrome beside a phone-width canvas leave no canvas. The same
 * panels, the same sections, the same markup — they are simply off-screen until
 * asked for, and they rise from the bottom edge because that is the edge a hand
 * is holding. Only one is up at a time: they answer different questions and the
 * screen has room for one answer.
 */
body.is-mobile .shell {
  grid-template-columns: minmax(0, 1fr);
  grid-template-areas: "stage";
}
body.is-mobile .splitter,
body.is-mobile .panel__grab { display: none; }

body.is-mobile .panel {
  position: fixed;
  left: 0; right: 0; bottom: 0;
  z-index: 70;
  height: min(62vh, 560px);
  padding-bottom: var(--safe-b);
  border: 1px solid var(--line-strong);
  border-bottom: 0;
  border-radius: var(--r-lg) var(--r-lg) 0 0;
  box-shadow: 0 -12px 34px rgba(0, 0, 0, .45);
  transform: translateY(102%);
  transition: transform var(--t) var(--ease);
  /* `.panel.is-empty` hides a panel the desktop has emptied by dragging its
     sections elsewhere; a sheet is never empty in that sense. */
  display: flex;
}
@media (prefers-reduced-motion: reduce) {
  body.is-mobile .panel { transition: none; }
}
body.is-mobile.sheet-left .panel--left,
body.is-mobile.sheet-right .panel--right { transform: none; }

/* The handle says the sheet can be pulled down, and is the target for doing
   it — the top strip of a panel is otherwise the first section's header. */
body.is-mobile .panel::before {
  content: '';
  position: absolute; top: 6px; left: 50%;
  width: 36px; height: 4px; margin-left: -18px;
  border-radius: 2px;
  background: var(--line-strong);
}
body.is-mobile .panel > .acc:first-child { margin-top: 12px; }

body.is-mobile .panelscrim {
  position: fixed; inset: 0;
  z-index: 69;
  background: color-mix(in srgb, var(--bg-app) 55%, transparent);
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--t) var(--ease);
}
body.is-mobile.sheet-left .panelscrim,
body.is-mobile.sheet-right .panelscrim { opacity: 1; pointer-events: auto; }

/* Rows inside a sheet are tap targets like everything else. */
body.is-mobile .acc__head { min-height: var(--tap); }
body.is-mobile .obj,
body.is-mobile .swatch,
body.is-mobile .lib__item { min-height: calc(var(--tap) - 6px); }
body.is-mobile .obj__eye,
body.is-mobile .obj__drop { width: 32px; height: 32px; }

/* ── the tool shelf ──────────────────────────────────────────────────────── */

/*
 * The island stands along the bottom, always, and scrolls if it must.
 *
 * On the desktop it has four homes and a person picks one; on a phone there is
 * one place a row of tools can be — above the thumb, across the width — and the
 * dragging that chooses between them is a gesture the stage needs for drawing.
 */
body.is-mobile .island {
  position: fixed;
  left: 0; right: 0;
  bottom: var(--safe-b);
  top: auto;
  z-index: 40;
  max-width: none;
  border-radius: 0;
  border-left: 0; border-right: 0; border-bottom: 0;
  overflow-x: auto;
  scrollbar-width: none;
  justify-content: flex-start;
}
body.is-mobile .island::-webkit-scrollbar { display: none; }
body.is-mobile .island__grip { display: none; }
body.is-mobile .island .tool { min-width: var(--tap); min-height: var(--tap); }
/* The sheets come up over the shelf, so the shelf must not sit above them. */
body.is-mobile.sheet-left .island,
body.is-mobile.sheet-right .island { opacity: 0; pointer-events: none; }

/* The status line is a desktop affordance — a row of numbers under a canvas
   that a phone needs for the canvas. What it says live goes to the HUD. */
body.is-mobile .bar--status { display: none; }

/* The palette button is a swatch of what is in hand — the image is set by
   `syncPaletteButton`, and the inset keeps it reading as a button. */
body.is-mobile .tool--swatch {
  background-position: center;
  background-size: cover;
  border: 1px solid var(--line-strong);
  box-shadow: inset 0 0 0 2px var(--bg-panel);
}

/*
 * The canvases own their gestures.
 *
 * Without this the browser claims a one-finger drag for scrolling and a pinch
 * for zooming the page, and cancels the pointer stream half way through — a
 * stroke that stops in the middle for no visible reason. There is nothing to
 * scroll here anyway: the stage is the window.
 */
/*
 * The canvases claim their gestures — and only the canvases.
 *
 * `touch-action` is resolved along the whole ancestor chain, so putting `none`
 * on the stage took the gesture away from everything standing *in* the stage as
 * well: the inventory would not scroll however long the list, the frame strip's
 * tracks would not slide, and the tool shelf could not be scrolled to its far
 * end. The canvas is the thing that needs the raw stream; nothing else in there
 * does.
 */
body.is-mobile #gl,
body.is-mobile .pixel { touch-action: none; }
/* Sheets, menus and the strip still scroll, or half of each is unreachable. */
body.is-mobile .panel,
body.is-mobile .split__menu,
body.is-mobile .ctxmenu { touch-action: pan-y; }
body.is-mobile .island { touch-action: pan-x; }

/* ── the world, under two thumbs ─────────────────────────────────────────── */

/*
 * Pocket Edition's layout, because that is the one being imitated: the pad
 * under the left thumb, jump and flight under the right, the hotbar between
 * them, pause in the corner.
 *
 * The whole layer passes touches through and only the buttons catch them —
 * everywhere else *is* the look control, so any gap between two buttons has to
 * reach the canvas underneath.
 */
.tpad {
  position: absolute;
  inset: 0;
  z-index: 8;
  pointer-events: none;
}
.tpad[hidden] { display: none; }

.tpad__btn {
  pointer-events: auto;
  width: 52px; height: 52px;
  display: grid; place-items: center;
  border: 1px solid var(--line-strong);
  border-radius: var(--r);
  /* Translucent, so the world is still readable through the controls standing
     on it — the game's own answer to the same problem. */
  background: color-mix(in srgb, var(--bg-app) 52%, transparent);
  color: var(--text);
  font: 500 15px var(--ui);
  /* A held button is a held key; the long-press menu and the text selection
     that a phone would otherwise offer are both in the way of that. */
  -webkit-user-select: none; user-select: none;
  -webkit-touch-callout: none;
  touch-action: none;
}
.tpad__btn.is-on { background: var(--accent); color: var(--accent-fg); }
.tpad__btn svg { width: 26px; height: 26px; }
/* Sprinting is a state of the body, not of the button being held, so it is said
   differently from a pressed key. */
.tpad__btn.is-sprint { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }

/* Three by three with the middle empty: a thumb finds a cross by feel, and the
   diagonals of a full pad are a strafe nobody asked for. */
.tpad__move {
  position: absolute;
  left: calc(10px + var(--safe-l)); bottom: calc(10px + var(--safe-b));
  display: grid;
  grid-template-columns: repeat(3, 52px);
  grid-template-rows: repeat(3, 52px);
  gap: 4px;
}
.tpad__btn--f { grid-area: 1 / 2; }
.tpad__btn--l { grid-area: 2 / 1; }
.tpad__btn--r { grid-area: 2 / 3; }
.tpad__btn--b { grid-area: 3 / 2; }

.tpad__acts {
  position: absolute;
  right: calc(10px + var(--safe-r)); bottom: calc(10px + var(--safe-b));
  display: grid;
  grid-template-columns: 52px;
  gap: 4px;
}
/* The switch is dimmer than the directions until it is on: it is the one
   control of the three that holds a state rather than doing a thing. */
.tpad__btn--mode { color: var(--text-dim); }

.tpad__btn--exit {
  position: absolute;
  right: calc(10px + var(--safe-r)); top: calc(10px + var(--safe-t));
  width: 44px; height: 44px;
}
/* Beside the way out, because both are things you do *to the session* rather
   than to the body — and the top edge is the one strip no thumb rests on. */
.tpad__btn--chat {
  position: absolute;
  right: calc(60px + var(--safe-r)); top: calc(10px + var(--safe-t));
  width: 44px; height: 44px;
}
/*
 * The measure, beside the other two rather than opposite them.
 *
 * It started on the left, on the reasoning that those two are things you do to
 * the *session* and this is one you do to the body. That put it at the head of
 * the strip the chat column starts in — and the chat is what ends up there once
 * the bottom of the screen belongs to the controller. One cluster on the right,
 * the whole left edge for the words.
 */
.tpad__btn--scale {
  position: absolute;
  right: calc(110px + var(--safe-r)); top: calc(10px + var(--safe-t));
  width: 44px; height: 44px;
}

/*
 * On a phone the chat lives at the **top**, and it is not a preference.
 *
 * The game keeps its chat along the bottom edge and so does this, everywhere
 * there is a mouse. Under a thumb layout that same edge is the controller: the
 * D-pad is bottom-left, the jump column bottom-right, the hotbar in the band
 * between them — so a column of messages measured up from `guiHeight - 40`
 * lands squarely behind all three, and the *input* at `bottom: 0` lands under
 * the pad itself, where a tap reaches a movement button and the pad's own
 * `preventDefault` eats what is left. A line typed there is a line that appears
 * to send and never shows up, which is exactly how it read.
 *
 * The top strip is the one place no thumb rests, which is why the pad already
 * keeps its session buttons there — and it is also above the software keyboard,
 * so the line being typed stays visible while it is typed.
 */
body.is-mobile.in-world .chat {
  top: calc(var(--safe-t) + 62px);
  /* Never under the row of session buttons, whatever the chat's own width. */
  max-height: calc(100% - var(--safe-t) - var(--safe-b) - 180px);
  overflow: hidden;
  bottom: auto;
  left: calc(var(--safe-l) + 10px);
  /* Never under the buttons on that edge, whatever the player's chat width. */
  max-width: calc(100% - var(--safe-l) - var(--safe-r) - 20px);
}
body.is-mobile.in-world .chat-bar {
  top: calc(var(--safe-t) + 10px);
  bottom: auto;
  left: calc(var(--safe-l) + 8px);
  right: calc(var(--safe-r) + 8px);
}
/*
 * While a line is being typed the strip belongs to it.
 *
 * The field spans that edge, so the three buttons standing on it would sit on
 * top of the text. They are all things that can wait a sentence — and two of
 * them, chat and the way out, are what a keyboard's return and escape already
 * do.
 */
body.is-mobile.is-typing .tpad__btn--chat,
body.is-mobile.is-typing .tpad__btn--exit,
body.is-mobile.is-typing .tpad__btn--scale { display: none; }
/* The completions hang *below* the line here, there being nothing above it. */
body.is-mobile.in-world .chat-suggest {
  top: 100%;
  bottom: auto;
  margin: 2px 0 0;
}

/*
 * A slot is a finger wherever the row has the width for one.
 *
 * This is the size the inventory's own row keeps; the row in the world is
 * measured against the band between the thumbs instead — see further down.
 */
body.is-mobile .hotbar__slot { width: 44px; height: 44px; }

/*
 * In the world the shelf goes and the bar stays.
 *
 * The game would have neither, and the bar is the one thing here that a phone
 * cannot do without: there is no Escape key, so every way out of the world is
 * something on screen — the pause button in the corner, and the mode switch in
 * that bar. One of them is enough right up until it is the one that is broken,
 * and a mode with no way out of it is the worst failure this interface can
 * have.
 */
body.is-mobile.in-world .island { display: none; }

/*
 * A long press is a press, not a selection — everywhere, not just on the canvas.
 *
 * Holding a finger still is how the world breaks a block, and holding a button
 * is how the body walks; iOS answers either with a callout menu and the text
 * magnifier, which cancels the pointer stream. So the press stops doing
 * anything, a loupe hangs over the control, and the hold reads as the
 * interface ignoring the hand. This was set on the canvas alone, which left it
 * true of every button in the editor.
 *
 * Fields are exempt: selecting, copying and the magnifier are how text is
 * edited on a phone, and this page has names and numbers to type into.
 */
body.is-mobile {
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}
body.is-mobile input,
body.is-mobile textarea,
body.is-mobile [contenteditable] {
  -webkit-user-select: text;
  user-select: text;
  -webkit-touch-callout: default;
}

/* The inventory key, on the end of the bar, the way Pocket Edition has it. */
body.is-mobile .hotbar__slot--more {
  font: 500 20px var(--ui);
  color: var(--text-dim);
  line-height: 1;
}

/*
 * Full screen, and stretched to it — which is the part that was missing.
 *
 * `.inventory` centres its panel (`place-items: center`), so the panel is sized
 * by its own content and `height: 100%` on a centred grid item does not make it
 * fill anything. On a phone that means the panel grows taller than the screen,
 * its bottom is clipped by the stage's `overflow: hidden`, and the thing
 * overflowing is the *panel* — so the list inside it never overflows and has
 * nothing to scroll. Stretching the panel to the grid area is what gives the
 * list a fixed height to be longer than.
 *
 * It is also laid out as a grid rather than a flex column: `minmax(0, 1fr)` on
 * the middle row says outright that the list may be shorter than its content,
 * where a flex item has to be *told* that with `min-height: 0` and silently
 * refuses to shrink when it is not.
 */
body.is-mobile .inventory {
  place-items: stretch;
  /*
   * And the overlay scrolls too.
   *
   * Two things can be taller than the screen here — the panel and the list
   * inside it — and only one of them was ever scrollable. Whichever it turns
   * out to be, the finger now finds something that moves: the list if the
   * panel fits, the overlay if it does not.
   */
  overflow-y: auto;
  overscroll-behavior: contain;
  /* Safari has long-standing trouble scrolling inside a backdrop-filtered
     layer, and a full-screen blur on a phone is expensive for nothing. */
  backdrop-filter: none;
}
body.is-mobile .inventory__panel {
  display: grid;
  grid-template-rows: auto minmax(0, 1fr) auto;
  width: 100%;
  height: auto;
  max-height: none;
  /*
   * `min-height: 0` on the panel itself, and this is the one that mattered.
   *
   * A grid item's automatic minimum size is its content, so `stretch` could
   * not shrink the panel below the height of everything in it: measured on a
   * 390px screen it stood 919px tall, the list inside it got the 771px it
   * asked for, and nothing overflowed anything — which is why no amount of
   * `overflow-y` on the list did a thing. Told it may be shorter than its
   * content, the panel becomes 390, the list gets 242 of them, and 771px of
   * materials finally have somewhere to scroll.
   */
  min-height: 0;
  border-radius: 0;
  border: 0;
  padding: var(--safe-t) var(--safe-r) var(--safe-b) var(--safe-l);
}

/*
 * And it scrolls.
 *
 * `touch-action` is decided along the whole chain of ancestors, and the
 * inventory lives inside the stage — which claims `none` so that a drag draws
 * instead of scrolling the page. That claim reached the one panel in there
 * that is a list: the grid of materials simply would not move, however long it
 * was. Anything scrollable inside the stage has to hand the gesture back.
 */
body.is-mobile .inventory__body {
  overflow-y: auto;
  overscroll-behavior: contain;
  /* Left at the browser's own default: every restriction added here is one more
     thing that can refuse the gesture, and the stage no longer claims it. */
  touch-action: auto;
}
body.is-mobile .inventory__grid { grid-template-columns: repeat(auto-fill, minmax(56px, 1fr)); gap: 5px; }
body.is-mobile .inventory__item { min-height: 56px; }
body.is-mobile .inventory__head { min-height: var(--tap); }
body.is-mobile .inventory__search input { font-size: max(16px, var(--fs-md)); min-height: calc(var(--tap) - 8px); }

/* ── the frame strip, and what it stands on ──────────────────────────────── */

/*
 * On a phone the strip is a sheet, not a strip.
 *
 * It is a wide table — a row per track, a cell per frame, controls above — and
 * the phone's answer to a wide table is to let it scroll rather than to fold it
 * into something unreadable. It takes the bottom of the screen while it is
 * open, and the tool shelf steps aside for it: editing a clip and drawing are
 * two different sittings, and both fighting for the same 52 pixels is what put
 * the tools underneath the tracks.
 */
body.is-mobile .frames {
  position: fixed;
  left: 0; right: 0; bottom: 0;
  z-index: 41;
  max-height: 58vh;
  overflow: auto;
  overscroll-behavior: contain;
  touch-action: pan-x pan-y;
  padding: 8px 10px calc(10px + var(--safe-b));
}
body.is-mobile .frames__controls { gap: 8px; }
body.is-mobile .frames__btn { min-width: var(--tap); height: var(--tap); }
/* The tracks are as wide as the clip is long; the table scrolls, the page does
   not. */
body.is-mobile .frames__tracks { min-width: max-content; }
body.is-mobile .stage.is-animating .island { display: none; }

/*
 * The view cube is a keyboard's control.
 *
 * Its four buttons are the four angles, each labelled with the number key that
 * also reaches it — and on a phone there is no number key, while the corner it
 * stands in is the most valuable real estate on the screen. The angles are
 * still in ⋯ → Сцена, where the rest of the view lives.
 */
body.is-mobile .viewcube { display: none; }

/* The chips say what the camera and the working floor are doing; on a phone
   they are read, not aimed at, so they shrink and get out of the way. */
body.is-mobile .stage__hud {
  max-width: calc(100% - 20px);
  overflow-x: auto;
  scrollbar-width: none;
  font-size: var(--fs-xs);
}
body.is-mobile .stage__hud::-webkit-scrollbar { display: none; }

/* ── the world, on a phone, has the screen to itself ─────────────────────── */

/*
 * The frame strip is not part of the world.
 *
 * It was left open from editing the clip, and in the world it took the bottom
 * half of the screen — the half the pad and the hotbar stand in. Standing
 * inside the block is not when anyone retimes a pendulum, and the strip is
 * where it was left when you come back out.
 */
body.is-mobile.in-world .frames { display: none; }

/* The chips answer editor questions — which plane clicks land on, which frame
   is being edited — and name keys a phone does not have. */
body.is-mobile.in-world #hudPlane,
body.is-mobile.in-world #hudAnim { display: none; }
/* One line, because the controls are on screen and can be seen. */
body.is-mobile .hud-chip--player { max-width: 100%; font-size: var(--fs-xs); }

/*
 * Above everything the editor leaves behind.
 *
 * The pad was drawn under the frame strip and the sheets, so the buttons that
 * move the body were simply not there — and with the crosshair the only thing
 * left on screen, the world looked like it had started without any controls
 * at all.
 */
.tpad { z-index: 45; }
.tpad__btn { z-index: 46; }

/*
 * The hotbar keeps the bottom edge, whatever the screen is; the slots give up
 * the width instead.
 *
 * It used to stand 186px up — above the pad — on anything but a wide landscape,
 * because nine 44px slots need 420px of width and a portrait phone has 390. So
 * the row was at the bottom on a tablet and halfway up the screen on a phone,
 * and turning the phone moved it from one to the other mid-game: the bar you
 * reach for without looking is the last thing that should be somewhere else.
 * The game keeps it on the bottom edge at every size, and so does this.
 *
 * The band it stands in is measured rather than guessed. The pad is three 52px
 * rows and two 4px gaps — 164 — standing 10 from the left inset; the thumb
 * column is 52 standing 10 from the right. So the row runs between 174 and 62,
 * and it is centred in *that* rather than on the screen: an uneven pair of
 * margins means a screen-centred row is not a band-centred one, and centring on
 * the screen is what would put the slots back under the pad.
 */
body.is-mobile .hotbar {
  bottom: calc(10px + var(--safe-b));
  left: calc(174px + var(--safe-l));
  right: calc(62px + var(--safe-r));
  /*
   * The band says where the row may go; the plate is the size of the row.
   *
   * With `left` and `right` pinned to the band the element *was* the band, so
   * the bar's own background and border stretched from the pad to the thumb
   * column — a dark strip across the bottom of the screen with nine small
   * squares adrift in the middle of it, which reads as a panel rather than as a
   * hotbar. Absolutely positioned with both edges set, a `fit-content` width
   * and automatic margins is the one arrangement that hugs the slots *and*
   * centres them in the band, which is what has to be centred: the margins
   * either side are uneven, so a screen-centred row would sit under the pad.
   */
  width: fit-content;
  max-width: 100%;
  margin-inline: auto;
  transform: none;
  gap: 2px;
  /*
   * Ten cells share the band: nine slots and the inventory key. The padding,
   * the border and the nine gaps come off it first; what is left divides.
   *
   * The cap is the desktop size, so a wide band gives an ordinary hotbar rather
   * than nine enormous squares. There is no floor: below a certain width the
   * slots are small, which is the price of keeping the row where the hand
   * expects it. Scrolling was never the alternative — a hotbar you have to
   * scroll is not a hotbar.
   */
  --band: calc(100vw - var(--safe-l) - var(--safe-r) - 236px);
  --slot: min(calc(38px * var(--ui-scale)), calc((var(--band) - 24px) / 10));
}
body.is-mobile .hotbar .hotbar__slot { width: var(--slot); height: var(--slot); }
/*
 * Everything inside a slot is sized off the slot, not off the root font.
 *
 * `--face` has to stay a length: the cube's three faces are pushed apart with
 * `translateZ(var(--face) / 2)`, and a percentage is invalid in `translateZ` —
 * the cube would collapse into a flat square rather than merely mis-size.
 */
body.is-mobile .hotbar .cube { --face: calc(var(--slot) * 0.5); }
body.is-mobile .hotbar .hotbar__tool { width: calc(var(--slot) * 0.62); height: calc(var(--slot) * 0.62); }
body.is-mobile .hotbar .hotbar__slot--more { font-size: calc(var(--slot) * 0.5); }

/* iOS paints a blue wash over anything tapped, which on a held movement button
   reads as the button being stuck on. The controls say their own state. */
body.is-mobile button,
body.is-mobile .tool,
body.is-mobile .pcard,
body.is-mobile .hotbar__slot { -webkit-tap-highlight-color: transparent; }

/* ── the world is the whole screen ───────────────────────────────────────── */

/*
 * The bar is gone everywhere in the world now — see `body.in-world` above, which
 * takes the whole window on every device. It was a phone-only rule first, on the
 * reasoning that a desktop has screen to spare; what a desktop does not have to
 * spare is the *focus*, and an editor reassembling itself around somebody who
 * only pressed `T` costs exactly that.
 */

/*
 * The controls go when the inventory comes.
 *
 * A pad under an open inventory moves a body nobody is looking at, and its
 * buttons sit over the items being chosen between — the game takes them off
 * screen for exactly as long as the panel is up.
 */
body.is-mobile.inv-open .tpad,
body.is-mobile.inv-open .crosshair { display: none; }

/*
 * The island is a floating row, centred by a transform — and a transform
 * survives being given new edges.
 *
 * Pinning it left and right while `translateX(-50%)` was still on it pushed the
 * whole shelf half its own width off the side of the screen, which is the
 * "crooked" island: every tool shifted left, the last of them off the edge.
 */
body.is-mobile .island {
  transform: none;
  left: var(--safe-l); right: var(--safe-r);
  padding-left: max(6px, var(--safe-l));
  padding-right: max(6px, var(--safe-r));
}

/*
 * The notch, the rounded corners and the home indicator, everywhere.
 *
 * In landscape the cut-out is on one *side*, which is exactly where a phone
 * holds its tools — so a shelf that respects only the bottom inset has its
 * first button under the camera. Every fixed thing measures itself against the
 * same four insets.
 */
body.is-mobile .frames {
  padding-left: max(10px, var(--safe-l));
  padding-right: max(10px, var(--safe-r));
}
body.is-mobile .stage__hud {
  margin-left: var(--safe-l);
  margin-right: var(--safe-r);
}
body.is-mobile .panel {
  padding-left: var(--safe-l);
  padding-right: var(--safe-r);
}
body.is-mobile .hotbar { max-width: calc(100% - var(--safe-l) - var(--safe-r) - 20px); }

/* ── the world's controls, read against a bright floor ───────────────────── */

/*
 * Solid enough to be a control.
 *
 * At half opacity over the pale ground the buttons were paler than the floor
 * they stood on and the arrows in them nearly gone — a row of grey smudges
 * rather than the game's own crisp keys. Darker, with a light edge and a
 * bigger glyph, they read against anything the world puts behind them.
 */
.tpad__btn {
  background: color-mix(in srgb, #000 42%, transparent);
  border-color: rgba(255, 255, 255, 0.34);
  color: #f2f2f4;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
}
.tpad__btn svg { width: 30px; height: 30px; }
.tpad__btn.is-on {
  background: color-mix(in srgb, #fff 78%, transparent);
  color: #15151a;
}
.tpad__btn--mode { color: rgba(255, 255, 255, 0.72); }

/*
 * The hint says its piece and goes.
 *
 * Three gestures is the whole of it, and once they are known the line is a
 * banner across the top of the view it is describing — outlined in the accent
 * colour, no less, which in the world reads as something being wrong. It fades
 * out a few seconds in and comes back whenever the mode changes.
 */
body.is-mobile .hud-chip--player {
  border-color: var(--line-strong);
  color: var(--text-dim);
}

/*
 * No slot numbers on a phone.
 *
 * The digit is the key that reaches the slot, and there is no key — so it says
 * nothing a finger can act on, and on an empty slot it is the only thing in the
 * square, which makes a row of blanks read as a row of labelled somethings.
 * Pocket Edition has no numbers on its hotbar for the same reason.
 */
body.is-mobile .hotbar__num { display: none; }

/*
 * The hotbar inside the inventory needs its own outline.
 *
 * Floating over the world, a slot is told apart from the world by being a
 * darker square on it. Inside the panel it is a panel-coloured square on a
 * panel: the row reads as one long empty strip, and with the numbers gone from
 * the phone's hotbar there is nothing left to say where one slot ends and the
 * next begins — which matters more here than anywhere, because the slot being
 * tapped is the destination of everything chosen above it.
 */
.inventory__bar .hotbar__slot {
  border-color: var(--line-strong);
  background: var(--bg-app);
}
