/* zpl-pdf — the Advanced designer (playground/designer/)
 *
 * NAMING: this file and its page are called "designer", not "adv". Ad blockers (AdGuard, uBlock
 * and anything else on EasyList) match the `adv` token in a URL path and cancel the request, so
 * css/adv.css and js/adv.js were blocked outright in the browser while base.css loaded — the
 * page rendered as unstyled HTML for anyone running a blocker. Do not reintroduce `adv`, `ad`,
 * `ads`, `banner` or `sponsor` into any filename, directory or asset path served to users.
 *
 * A three-pane IDE shell: editor, command dictionary, canvas.
 * Added 2026-09-15; rebuilt the same day for density. The editor has a file tab strip again as a
 * commercial affordance: Free opens one file, while the plus previews where paid multi-tab work
 * belongs.
 *
 * WHY THIS FILE LOOKS NOTHING LIKE THE REST OF THE SITE
 * The other five pages are Apple marketing surfaces: 17px type, 110px section padding, white
 * cards floating on grey with no borders. The first version of this page inherited that ramp and
 * the owner's verdict was that it did not read as an editor at all — "UI ít nhất cũng same same
 * Xcode hay vscode chứ". It was right. An IDE is the opposite of a marketing page on every axis
 * that matters:
 *
 *   marketing page          IDE chrome
 *   17px body               11-13px, and 12px mono in the editor
 *   110px section padding   4-8px, nothing is ever centred in air
 *   borderless cards        every region framed, separators everywhere
 *   one message per screen  maximum information per screen
 *
 * So this file deliberately overrides the house ramp inside .ide. It is scoped — every selector
 * here is a descendant of .ide, so nothing leaks to the other pages.
 *
 * Light mode only. base.css declares `color-scheme: light` and the site ships no dark palette.
 */

/* ─────────────── tokens ───────────────
 * Rebuilt 2026-09-15 against ui-research/apple/. The rule broken before was
 * implementation.md §1: "Use the tokens, never literals" — this file had invented sixteen greys
 * of its own (#F3F3F3, #E1E1E4, #C8C8CE, #8E8E93 …) that agreed with nothing else on the site.
 *
 * Now every IDE surface is derived from the house tokens and the measured ladders:
 *   - grounds from measurements.md §3D.6, which found the whole of apple.com runs on FOUR:
 *     #FFFFFF, #F5F5F7, #000, #1D1D1F. The IDE needs one step BETWEEN white and #F5F5F7 for
 *     chrome, so --bg-tint (#FBFBFD, already in base.css) does that work rather than a new grey.
 *   - ink from §3D.5's alpha ladder: #1D1D1F primary, rgba(0,0,0,.72) secondary,
 *     #6E6E73 tertiary "where the ground is known and opaque" — which is exactly this app.
 *   - lines from the existing --line / --line-soft; §3.3 is titled "borders do not exist", so
 *     the hairlines here are the site's own, not heavier ones invented for density.
 */
.ide {
  /* The inset only reads if the ground BEHIND the panels is darker than the panels. A first pass
     mapped these the other way round — chrome on --bg-tint, panels on --bg — and the render showed
     the canvas pane dissolving into its own backdrop. Ordered darkest-to-lightest now:
     --bg (#F5F5F7) behind, --bg-tint (#FBFBFD) for panel chrome, white for the editor. */
  --ide-chrome: var(
    --bg-tint
  ); /* panel bars and status bars, one step off white */
  --ide-well: var(--bg); /* the ground behind the panels */
  --ide-side: var(--bg-tint); /* navigator and canvas ground */
  --ide-edge: var(--line-soft); /* pane separators */
  --ide-edge-2: var(--line); /* control borders, the site hairline */
  --ide-canvas: var(
    --bg
  ); /* canvas backdrop, so the white sheet reads as paper */
  --ide-sel: rgba(0, 113, 227, 0.1); /* selection, accent at the §3A.3 alpha */
  --ide-hover: rgba(
    0,
    0,
    0,
    0.04
  ); /* row hover — alpha, so it works on any ground */
  --ide-ink-2: rgba(0, 0, 0, 0.72); /* §3D.5 secondary */
  --ide-command: var(--accent); /* command tokens use one colour everywhere */
  --zpl-control: #af52de; /* systemPurple: ^ and ~ introducers */
  --zpl-number: #bf5a00; /* warm numeric literal */
  --zpl-data: #248a3d; /* readable green for ^FD payloads */
  --zpl-comment: var(--ink-faint); /* quiet comment ink */
  --zpl-symbol: #8a6d00; /* commas and parameter separators */
  --ide-row: 26px; /* the command-list row height everything else is tuned to */
  --ide-reference-h: 150px; /* fixed Reference panel height */

  height: calc(100dvh - var(--nav-h));
  display: grid;
  /* editor | command dictionary | canvas.
     There is no activity bar: this editor has one useful navigator, the command/tools pane. The
     editor tab strip is inside the editor pane where multi-file editing will eventually live. */
  /* A near 2:1 between editor and canvas, with the navigator tightened by 20px so the preview gets
     the extra working room. A drag handle lived here briefly; it was removed because the proportion
     below is the one worth having and an adjustable split only invited people to find it again. */
  grid-template-columns: minmax(320px, 2fr) 228px minmax(300px, 1.05fr);
  background: var(--ide-well);
  overflow: hidden;
  /* The inset. Each pane floats on the window chrome with a gap around and between, which is what
     lets a rounded corner read at all — a panel flush against the viewport edge has nothing
     behind it for the curve to show against, so the radius set before this was invisible. This is
     Xcode's own construction: the navigator is a rounded panel INSIDE the window, not a column
     butted up to its frame. */
  padding: 8px;
  gap: 8px;
  /* The whole app steps down to IDE type. Everything inside inherits from here.
     12px is the scale's floor (measurements.md §5, corrected 2026-09-14); this page lives on the
     bottom step and nothing below it. */
  font-size: 12px;
  letter-spacing: 0;
}

/* ─────────────── entrance ───────────────
 * The other five pages reveal on scroll: [data-reveal] fades up 34px as a section enters the
 * viewport. That pattern cannot come here unaltered, and copying it literally would be the
 * mistake principles.md §2 names — marketing treatment on a utility page. The reason is
 * structural rather than stylistic: reveal-on-scroll animates a DOCUMENT, and this page has no
 * document scroll. Its three panes are pinned to the viewport, so a 34px rise would slide the
 * whole IDE once on load and never fire again.
 *
 * What does carry over is the motion itself — the same --dur and --ease measured on 11 of 12
 * Apple pages (§3D.1), which implementation.md §1 calls the cheapest thing that makes a page feel
 * like one object. The panes settle in on load, staggered left to right so the eye lands on the
 * dictionary first, and travel 8px rather than 34px: a pinned panel that moves as far as a
 * marketing section reads as a page that has not finished loading.
 *
 * Nothing is hidden without script, matching base.css's own guard — the panes are visible by
 * default and animate only when .js-reveal is present, so a blocked script leaves a working page
 * rather than three blank rectangles. */
.js-reveal .ide > .pane {
  opacity: 0;
  transform: translateY(8px);
  animation: paneIn var(--dur) var(--ease) forwards;
}
.js-reveal .ide > .pane:nth-child(2) {
  animation-delay: var(--delay-ui);
}
.js-reveal .ide > .pane:nth-child(3) {
  animation-delay: calc(var(--delay-ui) * 2);
}
@keyframes paneIn {
  to {
    opacity: 1;
    transform: none;
  }
}

/* base.css collapses every duration to ~0 under prefers-reduced-motion, which lands the panes
   finished rather than easing in. This states the end position explicitly so they can never be
   stranded at opacity 0 if the animation does not run at all. */
@media (prefers-reduced-motion: reduce) {
  .js-reveal .ide > .pane {
    opacity: 1;
    transform: none;
    animation: none;
  }
}

/* ─────────────── panes ─────────────── */

/* Every pane is its own rounded panel now. The separators between them are gone: with a gap of
   chrome between panels there is nothing to separate, and a hairline plus a gap says the same
   thing twice. The hairline moves to the panel's own outline instead. */
.pane {
  display: flex;
  flex-direction: column;
  min-height: 0;
  min-width: 0;
  border: 1px solid var(--ide-edge);
  /* --r-sm, not a hand-picked 10px. §3D.7's panel steps are 8/11px and the token set rounds that
     to 12px; it is also the softer corner the owner asked for. */
  border-radius: var(--r-sm);
  /* Children run to the panel's edge — the title row, the footer bar, the status bar — so the
     panel has to clip them to its own corners. */
  overflow: hidden;
}

/* The command dictionary, measured from Xcode's navigator (owner's reference screenshot, 2026-09-15).
 *
 * What that panel actually does, and what an imitation usually gets wrong: the tree is NOT a
 * bordered card. It sits flush on the navigator ground with no outline of its own — the rounding
 * belongs to the window, not to the list. The one rounded object in the whole navigator is the
 * filter field at the foot, a pill sitting in its own footer bar.
 *
 * So the list keeps its flat ground with no outline of its own, and the one rounded thing inside
 * the panel is the filter. The panel's own corners come from .pane, which rounds all three. */
.pane--cmds {
  background: var(--ide-side);
}
.pane--editor {
  background: var(--bg-white);
}
/* The canvas pane must not take --ide-well: that is the ground behind the panels, and painting a
   panel with it makes the panel vanish into its own backdrop — which is exactly what the first
   token pass did. It takes the navigator ground, a shade off the editor's white. */
.pane--canvas {
  background: var(--ide-side);
}

/* ─────────────── the navigator selector ───────────────
 * Xcode puts a row of icon tabs across the top of its navigator rather than a word. Replacing the
 * "Commands" caption with it, 2026-09-15 at the owner's direction.
 *
 * Two details carry the look. The bar is chrome, a shade off the panel it heads, with a hairline
 * under it — so it reads as a control strip rather than as the first row of the list. And the
 * selected tab is marked by the icon going full-ink against --ide-sel, not by a bottom rule:
 * Xcode's selector tints the glyph, and a bottom rule here would collide with the hairline that
 * already closes the bar.
 *
 * The icons are 17px in a 26px box. principles.md §4 asks that the accent be applied judiciously
 * and reserved for status, so an unselected tab is --ink-dim and only the active one takes ink. */
.side-tabs {
  flex: none;
  display: flex;
  align-items: center;
  gap: 3px;
  height: 34px;
  padding: 4px 7px;
  background: var(--ide-chrome);
  border-bottom: 1px solid var(--ide-edge);
}
.side-tab {
  width: 30px;
  height: 26px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: 1px solid transparent;
  border-radius: 7px;
  background: transparent;
  color: var(--ink-dim);
  cursor: pointer;
  transition:
    background-color var(--dur-fast) var(--ease),
    border-color var(--dur-fast) var(--ease),
    box-shadow var(--dur-fast) var(--ease),
    color var(--dur-fast) var(--ease);
}
.side-tab svg {
  width: 17px;
  height: 17px;
}
.side-tab:hover {
  background: var(--ide-hover);
  border-color: var(--ide-edge-2);
  color: var(--ink);
}
.side-tab[aria-selected="true"] {
  background: var(--ide-sel);
  border-color: rgba(0, 113, 227, 0.34);
  box-shadow:
    0 0 0 1px rgba(0, 113, 227, 0.08),
    inset 0 1px 0 rgba(255, 255, 255, 0.7);
  color: var(--accent);
}

.pane-body {
  flex: 1;
  min-height: 0;
  overflow: auto;
}
.cmd-view[hidden] {
  display: none !important;
}
#cmd-list {
  scrollbar-width: none; /* Firefox: keep scrolling, hide the bar. */
  -ms-overflow-style: none; /* Old Edge. */
}
#cmd-list::-webkit-scrollbar {
  width: 0;
  height: 0;
}
.tools-panel,
.outputs-panel {
  background: var(--ide-side);
  scrollbar-width: none;
  -ms-overflow-style: none;
}
.tools-panel::-webkit-scrollbar,
.outputs-panel::-webkit-scrollbar {
  width: 0;
  height: 0;
}

/* ─────────────── command dictionary ─────────────── */

/* The command filter sits just under the navigator icons: it spans the full width, sits in its own
   chrome strip, and narrows the list before the user starts scanning rows. */
.cmd-search {
  flex: none;
  display: flex;
  align-items: center;
  padding: 8px;
  border-bottom: 1px solid var(--ide-edge);
  background: linear-gradient(
    180deg,
    var(--ide-chrome),
    rgba(255, 255, 255, 0.88)
  );
}
.cmd-search-field {
  display: flex;
  align-items: center;
  width: 100%;
  height: 30px;
  padding: 0 11px;
  border: 1px solid rgba(0, 113, 227, 0.42);
  border-radius: var(--r-pill);
  background: rgba(255, 255, 255, 0.92);
  box-shadow:
    0 0 0 1px rgba(0, 113, 227, 0.08),
    inset 0 1px 0 rgba(255, 255, 255, 0.95),
    0 1px 1px rgba(0, 0, 0, 0.04);
  transition:
    background-color var(--dur-fast) var(--ease),
    border-color var(--dur-fast) var(--ease),
    box-shadow var(--dur-fast) var(--ease);
}
.cmd-search-field:hover {
  background: var(--bg-white);
  border-color: rgba(0, 113, 227, 0.52);
}
.cmd-search-field:focus-within {
  background: var(--bg-white);
  border-color: rgba(0, 113, 227, 0.72);
  box-shadow:
    0 0 0 3px rgba(0, 113, 227, 0.15),
    inset 0 1px 0 rgba(255, 255, 255, 0.95),
    0 1px 2px rgba(0, 0, 0, 0.05);
}
.cmd-search-field input {
  flex: 1;
  min-width: 0;
  font-family: var(--sans);
  font-size: 13px;
  color: var(--ink);
  background: transparent;
  border: 0;
  padding: 0;
  height: 100%;
}
.cmd-search-field input::placeholder {
  color: var(--ink-dim);
}
.cmd-search-field input:focus {
  outline: none;
}

/* A sticky section header for each bucket in the navigator.
   It is forced to one line so long titles cannot wrap and leave fragments under the next sticky
   header at scroll boundaries.

   One rule for both navigator views. The commands list and the preview tool palette are the same
   object at different times — the side tabs swap which one fills the pane below the filter, and
   the tool palette was built to "follow the command outline" deliberately. The two headers were
   declared separately with identical bodies; merged 2026-09-16, so restyling the navigator is one
   edit rather than two that must agree. */
.cmd-group,
.tool-group {
  position: sticky;
  top: 0;
  z-index: 2;
  display: flex;
  gap: 4px;
  align-items: center;
  height: 24px;
  padding: 6px 8px 3px;
  margin: 0;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  line-height: 1.2;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  color: var(--ink-dim);
  background: var(--ide-side);
}
/* The twisty. A CSS triangle, because an SVG per header is four more nodes for one glyph. */
.cmd-group::before,
.tool-group::before {
  flex: none;
  content: "";
  border-left: 4px solid currentColor;
  border-top: 3.5px solid transparent;
  border-bottom: 3.5px solid transparent;
  transform: rotate(90deg);
  opacity: 0.7;
}

.cmd-list {
  list-style: none;
  margin: 0;
  padding: 0;
}
/* The navigator row, shared by the command list and the downloads list. Same height, same tree
   indent, same hit area — they are rows in the same pane, and were identical declarations in two
   places until 2026-09-16. Their selected/focus states differ and stay separate below. */
.cmd-item,
.output-item {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  height: var(--ide-row);
  padding: 0 8px 0 20px; /* indented under its group, like a tree child */
  border: none;
  background: transparent;
  text-align: left;
  cursor: pointer;
  white-space: nowrap;
}
/* Hover and selection are alpha, not hex — §3D.5's lesson that Apple's own secondary tones are
   alpha black so they survive whatever ground they land on. The selected row takes the accent at
   10%, the same recipe the canvas uses for a dropped element. */
.cmd-item:hover {
  background: var(--ide-hover);
}
.cmd-item[aria-current="true"] {
  background: var(--ide-sel);
}
/* min-width, not width.
 *
 * Six of the 217 commands carry both forms of the same instruction — "^HL ~HL", "^CC ~CC",
 * "^CD ~CD", "^CT ~CT", "^PH ~PH", "^PP ~PP" — which is seven characters against a column sized
 * 30px for the three-character majority (210 of 217). They were clipped to "^HL ~H".
 *
 * A fixed width wide enough for seven glyphs would cost all 210 short rows 20px of description in
 * a 248px command pane. min-width keeps the column tight for the majority and lets the six long codes
 * take the room they need instead. */
.cmd-code {
  font-family: var(--mono);
  font-size: 13px;
  color: var(--ide-command);
  flex: none;
  min-width: 30px;
}
/* The HIG's outline-views page: "Consider using a centered ellipsis to truncate cell text instead
   of clipping it. An ellipsis in the middle preserves the beginning and end of the cell text."
   CSS has no centred ellipsis, so the description is clipped at the end as before — but the row
   now carries its full text as a title, which is the part that actually recovers the ending. */
.cmd-desc {
  font-family: var(--sans);
  font-size: 13px;
  color: var(--ink-dim);
  overflow: hidden;
  text-overflow: ellipsis;
}
/* Reference-only commands still use the command colour. The row title and the reference panel
   carry support status, while the visible command token stays visually consistent. */
.cmd-item.is-unsupported .cmd-code {
  color: var(--ide-command);
}

.cmd-empty {
  padding: 10px 8px;
  font-size: 12px;
  color: var(--ink-faint);
}

.output-list {
  list-style: none;
  margin: 0;
  padding: 6px 0;
}
.output-list li[hidden] {
  display: none !important;
}
/* .output-item's box is declared with .cmd-item, beside .cmd-list — the two are the same row.
   Only its own states follow here. */
.output-item:hover {
  background: var(--ide-hover);
}
.output-item:focus-visible {
  outline: none;
  background: var(--ide-sel);
}
/* 34px, not the command list's 30px: "JSON" is the one four-character format code here, and at
   13px SF Mono it overruns a column sized for "ZPL"/"PDF"/"SVG"/"PNG", shunting its description
   right and breaking the alignment the other four rows share. */
.output-code {
  flex: none;
  min-width: 34px;
  font-family: var(--mono);
  font-size: 13px;
  color: var(--ide-command);
}
.output-desc {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  font-family: var(--sans);
  font-size: 13px;
  color: var(--ink-dim);
}

/* ─────────────── editor tabs ─────────────── */

.editor-tabs {
  flex: none;
  display: flex;
  align-items: flex-end;
  gap: 2px;
  height: 32px;
  padding: 4px 6px 0;
  background: var(--ide-chrome);
  border-bottom: 1px solid var(--ide-edge);
}
/* The strip scrolls on its own so the + button never leaves the strip, however many tabs are
   open. The scrollbar is hidden: at 28px tall it would eat half the tab. */
.editor-tab-strip {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  align-items: flex-end;
  gap: 2px;
  overflow-x: auto;
  scrollbar-width: none;
}
.editor-tab-strip::-webkit-scrollbar {
  display: none;
}
.editor-tab {
  flex: none;
  max-width: 190px;
  height: 28px;
  display: flex;
  align-items: center;
  gap: 7px;
  min-width: 0;
  padding: 0 8px 0 10px;
  border: 1px solid transparent;
  border-right-color: var(--ide-edge);
  border-radius: 6px 6px 0 0;
  background: transparent;
  color: var(--ink-dim);
  font-family: var(--sans);
  font-size: 13px;
  text-align: left;
  cursor: pointer;
  margin-bottom: -1px;
}
.editor-tab:hover {
  background: var(--ide-hover);
}
/* The active tab is the one joined to the editor below it: white, and its bottom border painted
   out so the seam disappears. */
.editor-tab.is-active {
  background: var(--bg-white);
  border-bottom-color: var(--bg-white);
  color: var(--ink);
  cursor: default;
}
.editor-tab-icon {
  flex: none;
  width: 15px;
  height: 15px;
  color: var(--ink-dim);
}
.editor-tab.is-active .editor-tab-icon {
  color: var(--accent);
}
.editor-tab-name {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* A span, not a button: it lives inside the tab button and nested buttons are invalid HTML.
   It carries role="button" and its own key handling in js/designer.js instead. */
.editor-tab-close {
  flex: none;
  width: 18px;
  height: 18px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--ink-dim);
  border-radius: 4px;
  cursor: pointer;
}
.editor-tab-close svg {
  width: 12px;
  height: 12px;
}
.editor-tab-close:not(.is-disabled):hover {
  background: var(--ide-edge-2);
  color: var(--ink);
}
.editor-tab-close:focus-visible {
  outline: none;
  box-shadow: 0 0 0 2px rgba(0, 113, 227, 0.35);
}
/* One tab left: the x stays in place so the tab never changes shape, but it is visibly inert —
   the editor always keeps a file open. */
.editor-tab-close.is-disabled {
  opacity: 0.35;
  cursor: default;
}
.editor-tab-add {
  width: 28px;
  height: 26px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  margin-bottom: 1px;
  border: 1px solid transparent;
  border-radius: 7px;
  background: transparent;
  color: var(--ink-dim);
  cursor: pointer;
  transition:
    background-color var(--dur-fast) var(--ease),
    border-color var(--dur-fast) var(--ease),
    box-shadow var(--dur-fast) var(--ease),
    color var(--dur-fast) var(--ease);
}
.editor-tab-add svg {
  width: 15px;
  height: 15px;
}
.editor-tab-add:hover {
  background: var(--ide-hover);
  border-color: var(--ide-edge-2);
  color: var(--accent);
}
.editor-tab-add:focus-visible {
  outline: none;
  border-color: rgba(0, 113, 227, 0.4);
  box-shadow: 0 0 0 2px rgba(0, 113, 227, 0.1);
  color: var(--accent);
}
/* Guest and Free: the + is dimmed but still live — it answers with the upgrade notice rather than
   opening a tab. Not [disabled], because a disabled button takes no click and no focus, so the one
   person who most needs the explanation is the one who cannot reach it. The cursor stays a pointer
   for the same reason: something does happen. */
.editor-tab-add.is-locked {
  opacity: 0.45;
}
.editor-tab-add.is-locked:hover {
  background: var(--ide-hover);
  border-color: transparent;
  color: var(--ink-dim);
  opacity: 0.6;
}

/* ─────────────── the doc card above the editor ─────────────── */

/* The reference panel sits BELOW the editor, where VS Code puts its terminal — moved there
   2026-09-15. Above the editor it pushed the code down by its own height every time a command was
   picked, so the line you were reading moved under you; below, the editor keeps its origin and
   only loses height from the bottom. */
.cmd-doc {
  flex: none;
  display: flex;
  flex-direction: column;
  height: var(--ide-reference-h);
  min-height: 0;
  border-top: 1px solid var(--ide-edge);
  background: var(--bg-white);
}
.cmd-doc[hidden] {
  display: none;
}

/* The panel's own tab strip, as the terminal has: a label on the left, a close on the right. */
.cmd-doc-bar {
  flex: none;
  display: flex;
  align-items: center;
  height: 28px;
  padding: 0 4px 0 12px;
  background: var(--ide-chrome);
  border-bottom: 1px solid var(--ide-edge);
}
.cmd-doc-tabs {
  display: flex;
  align-items: center;
  gap: 10px;
  min-width: 0;
}
.cmd-doc-tab {
  border: none;
  background: transparent;
  font-family: var(--sans);
  font-size: 13px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-dim);
  border-bottom: 1px solid transparent;
  padding: 4px 2px 3px;
  margin-bottom: -1px;
  cursor: pointer;
}
.cmd-doc-tab.is-active {
  color: var(--ink);
  border-bottom-color: var(--accent);
}
.cmd-doc-tab[hidden] {
  display: none;
}
.cmd-doc-tab:focus-visible {
  outline: none;
  color: var(--accent);
}
.cmd-doc-x {
  margin-left: auto;
  width: 22px;
  height: 22px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: none;
  border-radius: 5px;
  background: transparent;
  color: var(--ink-dim);
  cursor: pointer;
  transition:
    background-color var(--dur-fast) var(--ease),
    color var(--dur-fast) var(--ease);
}
.cmd-doc-x svg {
  width: 12px;
  height: 12px;
}
.cmd-doc-x:hover {
  background: var(--ide-hover);
  color: var(--ink);
}

/* The content scrolls on its own, so a long description cannot push the panel past its cap. */
.cmd-doc-body {
  flex: 1;
  min-height: 0;
  overflow: auto;
  padding: 10px 14px 12px;
}
.cmd-doc-body[hidden] {
  display: none;
}

.cmd-doc-head {
  display: flex;
  align-items: baseline;
  gap: 9px;
  min-width: 0;
}
.cmd-doc-code {
  flex: none;
  font-family: var(--mono);
  font-size: 14px;
  color: var(--ide-command);
}
.cmd-doc-name {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 14px;
  font-weight: 600;
  color: var(--ink);
}
.cmd-doc-sig {
  flex: none;
  margin-left: auto;
  font-family: var(--mono);
  font-size: 13px;
  color: var(--ink-dim);
  background: var(--bg);
  border: 1px solid var(--ide-edge);
  border-radius: 5px;
  padding: 1px 6px;
}
.cmd-doc p {
  margin: 7px 0 0;
  font-size: 14px;
  line-height: 1.5;
  color: var(--ink-dim);
}
.cmd-doc-notice .cmd-doc-code {
  color: var(--warn);
}
.notice-link {
  text-decoration: none;
  color: var(--link-blue);
  background: var(--ide-sel);
  border-color: rgba(0, 113, 227, 0.24);
}
.notice-link:hover {
  border-color: rgba(0, 113, 227, 0.42);
  background: rgba(0, 113, 227, 0.14);
}

/* ─────────────── the editor ───────────────
   The gutter, syntax layer, and textarea share one type ramp so their rows align. font-size,
   line-height and vertical padding must stay identical in all three rules — change one and the
   numbers drift off their lines. */
.editor {
  flex: 1;
  min-height: 0;
  display: flex;
  overflow: hidden;
  background: var(--bg-white);
  /* The containing block for .editor-drop, which covers this panel during a file drag. */
  position: relative;
}

/* File-drop overlay styles live in zpl-editor.css. */
/* ─────────────── the modal dialog ───────────────
   One shared shell, currently used only by the replace-on-drop confirmation. Native <dialog>, so
   the focus trap, the Escape key and the top-layer stacking come from the browser rather than
   from script that would have to reimplement all three. */
.ide-dialog {
  width: min(420px, calc(100vw - 32px));
  padding: 0;
  border: 1px solid var(--ide-edge-2);
  border-radius: 12px;
  background: var(--bg-white);
  color: var(--ink);
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
}
.ide-dialog::backdrop {
  background: rgba(0, 0, 0, 0.32);
}
.ide-dialog-form {
  padding: 18px 20px 16px;
}
.ide-dialog-title {
  margin: 0;
  font-size: 15px;
  font-weight: 600;
  line-height: 1.3;
}
.ide-dialog-body {
  margin: 8px 0 0;
  font-size: 13px;
  line-height: 1.5;
  color: var(--ink-dim);
}
.ide-dialog-note {
  margin: 8px 0 0;
  font-size: 12px;
  line-height: 1.5;
  color: var(--ink-faint);
}
.ide-dialog-actions {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  margin-top: 16px;
}
.ide-dialog-btn {
  font: inherit;
  font-size: 13px;
  padding: 6px 14px;
  border: 1px solid var(--ide-edge-2);
  border-radius: 7px;
  background: var(--bg-white);
  color: var(--ink);
  cursor: pointer;
  transition:
    background-color var(--dur-fast) var(--ease),
    border-color var(--dur-fast) var(--ease);
}
.ide-dialog-btn:hover {
  background: var(--ide-hover);
}
/* Replace is the destructive half, so it is the one that reads as a warning rather than as the
   safe default — Cancel keeps the plain treatment and the initial focus.

   NOT var(--warn): that token is #FF9500, systemOrange, which carries white text at about 2.2:1 —
   below the 4.5:1 body-text floor. systemRed is both the correct semantic for a destructive
   confirm and the one that passes. */
.ide-dialog-btn.is-primary {
  border-color: transparent;
  background: #d70015;
  color: #fff;
}
.ide-dialog-btn.is-primary:hover {
  filter: brightness(0.94);
}
.ide-dialog-btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
/* ─────────────── the minimap ───────────────
 * VS Code's overview strip: the whole file drawn as one bar per line at --mini-lh, with a
 * translucent slider over the part currently on screen. Drag it, or click anywhere, to scroll.
 *
 * The bars are plain divs whose width encodes each line's length and whose colour encodes what
 * kind of line it is (command, data, comment). It is not a rendering of the text — at 2px a line
 * no glyph would be legible anyway, and VS Code's own minimap is a block approximation for the
 * same reason. What the eye actually uses it for is shape: where the blank runs are, where a
 * dense block sits, how far down the file you are.
 *
 * Sized by the line count, not by a transform of the editor: a scaled clone would blur, and would
 * have to be kept in sync with the textarea's own layout. One bar per line is cheaper and exact.
 */
.minimap {
  --mini-lh: 3px; /* the height of one line in the map */
  flex: none;
  position: relative;
  width: 76px;
  background: var(--bg-white);
  border-left: 1px solid var(--ide-edge);
  overflow: hidden;
  cursor: pointer;
  /* The map is a navigation aid, not a text surface: never let a drag select anything. */
  user-select: none;
}
.mini-lines {
  position: absolute;
  inset: 0;
  padding: 8px 6px 8px 8px;
}

/* One line. The width is set per-bar from the line's length; everything else is shared. */
.mini-line {
  height: var(--mini-lh);
  margin-bottom: 1px;
  border-radius: 1px;
  background: var(--line);
}
/* Colour carries the same information the editor's syntax colours would: a ^ command, the data
   after ^FD, and a ^FX comment each read differently at a glance.
 *
 * These were three invented pastels (#9AB8DC, #B8C9A8, #D8D8DE). They are now the site's own
 * semantic colours at low alpha — accent for a command, --ok for data, and the plain hairline for
 * a comment. principles.md §4 says to apply the accent judiciously and reserve it for primary
 * actions and status; at 3px a line and 45% alpha this is status, not decoration, and it means
 * the minimap agrees with the rest of the site instead of running its own palette. */
.mini-line.is-cmd {
  background: rgba(0, 113, 227, 0.45);
}
.mini-line.is-data {
  background: rgba(52, 199, 89, 0.45);
}
.mini-line.is-comment {
  background: var(--line-soft);
}
.mini-line.is-blank {
  background: transparent;
}

/* The viewport slider. Sits above the bars, and is the only part that moves on scroll. */
.mini-view {
  position: absolute;
  left: 0;
  right: 0;
  background: rgba(0, 0, 0, 0.08);
  border-top: 1px solid rgba(0, 0, 0, 0.06);
  border-bottom: 1px solid rgba(0, 0, 0, 0.06);
  pointer-events: none;
}
.minimap:hover .mini-view {
  background: rgba(0, 0, 0, 0.12);
}

/* The minimap is NOT hidden at any width any more.
 *
 * It used to disappear below 1400px to give the text more room. That became unsafe the moment the
 * textarea's scrollbar was hidden: the map is now the only visible scroll affordance, so hiding it
 * on a narrow window would leave a long file with no indication that there is more of it, and no
 * way to drag to it. It narrows instead. */
@media (max-width: 1400px) {
  .minimap {
    width: 52px;
  }
}
@media (max-width: 1100px) {
  .minimap {
    width: 40px;
  }
}

/* The status bar. VS Code's, at its real height, reduced to what this editor can honestly say. */
.statusbar {
  flex: none;
  display: flex;
  align-items: center;
  gap: 16px;
  height: 22px;
  padding: 0 10px;
  background: var(--ide-chrome);
  border-top: 1px solid var(--ide-edge);
  font-family: var(--sans);
  font-size: 12px;
  color: var(--ink-dim);
}
.statusbar .sb-right {
  margin-left: auto;
  display: flex;
  gap: 16px;
  align-items: center;
}
.statusbar .is-over-limit {
  color: var(--danger);
}
.sb-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--ok);
  display: inline-block;
  margin-right: 5px;
}

/* ─────────────── canvas ─────────────── */

/* The settings strip. Compact labelled controls in one row, as in the reference tool. */
/* The canvas bar — settings and readout in the one strip that closes the pane.
 *
 * It replaces a labelled settings row plus a button row that together cost about 70px of height
 * to hold six controls and two buttons. In a bar the controls carry their meaning in a `title`
 * and in their own displayed value, so the standing labels were the only part actually consuming
 * the room. The height that frees goes to the canvas, which is the thing worth looking at.
 *
 * Slightly taller than the editor's 22px bar because it holds real controls rather than text. */
/* Same height as the editor's bar (22px, from .statusbar) so the two panes are closed by one
   line, not by two bars of different weight. It holds controls rather than text, so those shrink
   to suit the bar instead of the bar growing to suit them. */
.statusbar--canvas {
  gap: 5px;
  padding: 0 8px;
  flex-wrap: nowrap;
  overflow-x: auto;
  scrollbar-width: none;
}
.statusbar--canvas::-webkit-scrollbar {
  height: 0;
}

/* Nothing in this bar may wrap or be squeezed. Without this the readout group collapsed onto two
   lines inside a 30px bar — "x 1218" stacked under "812" with the element count clipped — because
   the base .statusbar's 16px gap plus flexible text let the group shrink below its content.
   Every child keeps its intrinsic width and the bar scrolls instead. */
.statusbar--canvas > *,
.statusbar--canvas .sb-right > * {
  flex: none;
  white-space: nowrap;
}
.statusbar--canvas .sb-right {
  gap: 8px;
  flex: none;
  align-items: center;
}
/* When the bar runs out of room, items drop in order of how little they are missed. The settings
   and Render/Clear are the reason the bar exists and never yield.
 *
 * These thresholds are deliberately generous. They are window widths, but what actually overflows
 * is the CANVAS PANE, which is a fraction of the window — roughly (viewport - 228px dictionary) / 2.
 * A first attempt at 1500/1360px still clipped the zoom group at a 1280px window for exactly that
 * reason: the pane there is only ~500px wide. Each item drops well before its own pane gets tight
 * rather than at the width where the arithmetic says it must. */
@media (max-width: 1560px) {
  .statusbar--canvas #canvas-count {
    display: none;
  }
}
.statusbar--canvas #canvas-count {
  font-variant-numeric: tabular-nums;
}

/* Controls sized to the bar: no border until hovered, so a row of six reads as one strip rather
   than six boxes. The value is the label — "8 dpmm (203 dpi)" says what a "DPI" caption would
   have. */
.statusbar--canvas select,
.statusbar--canvas input,
.statusbar--canvas button {
  flex: none;
  font-family: var(--sans);
  font-size: 12px;
  color: var(--ink);
  background: transparent;
  border: 1px solid transparent;
  border-radius: 5px;
  /* 18px inside a 22px bar: the controls fit the bar's height rather than setting it. */
  height: 18px;
  padding: 0 3px;
  cursor: pointer;
}
.statusbar--canvas button {
  width: 22px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
}
.statusbar--canvas button svg {
  width: 14px;
  height: 14px;
}
.statusbar--canvas .orientation-btn.is-landscape,
.ruler-corner.is-active {
  color: var(--accent);
  background: var(--ide-sel);
}
/* Sized by measurement, not by eye.
 *
 * "4,00" was clipping to "4,0(". The owner asked for one more pixel; a probe showed 48px was
 * still short — scrollWidth 47 against clientWidth 46, so the content overflowed by 1px even
 * after the increase. Guessing a second time would have been the same mistake twice.
 *
 * Four tabular digits plus a separator at 12px need ~40px before the number spinner takes its own
 * space. 72px leaves the glyphs clear with room for a comma-decimal locale. */
.statusbar--canvas input {
  width: 72px;
  padding: 0 2px;
  font-variant-numeric: tabular-nums;
  text-align: right;
  cursor: text;
}
.statusbar--canvas select:hover,
.statusbar--canvas input:hover,
.statusbar--canvas button:hover {
  border-color: var(--ide-edge-2);
  background: var(--bg-white);
}
.statusbar--canvas select:focus,
.statusbar--canvas input:focus,
.statusbar--canvas button:focus-visible {
  outline: none;
  border-color: var(--accent);
  background: var(--bg-white);
}

/* The × between the two size fields. */
/* 11px like everything else in the bar — the editor's status text sets that size, and a bar with
   two type sizes in it reads as two bars. */
.sb-x {
  color: var(--ink-faint);
  font-size: 12px;
  flex: none;
}

.canvas-body {
  flex: 1;
  min-height: 0;
  display: flex;
  overflow: hidden;
  background: var(--ide-canvas);
}

/* The preview tool palette now lives under the navigator filter. It follows the command outline:
   sticky group headers, rows beneath them, command-blue glyphs and the same 13px list type.
   Its group header and twisty are declared with .cmd-group above, since they were identical. */
.tools {
  display: block;
}
.tool-list {
  list-style: none;
  margin: 0;
  padding: 0;
}
.tool-group[hidden],
.tool-list[hidden],
.tool-list li[hidden] {
  display: none !important;
}
.tool {
  width: 100%;
  height: var(--ide-row);
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 0 8px 0 20px;
  border: none;
  border-radius: 0;
  background: transparent;
  color: var(--ink-dim);
  font-family: var(--sans);
  font-size: 13px;
  text-align: left;
  cursor: grab;
  white-space: nowrap;
  transition:
    background-color var(--dur-fast) var(--ease),
    color var(--dur-fast) var(--ease);
}
/* Hover, per measurements.md §3A.3 — the section that separates a page which looks Apple-like
   from one that feels it. Their measured button hover is a 10-unit RGB lift and NOTHING else
   moves; the doc names the failure mode outright: "most imitations lift by 40-60 units, add a
   shadow, and scale the element, which reads as a toy."

   So this lifts the ground by one small step (--ide-hover is rgba(0,0,0,.04), about ten units on
   white) and leaves the glyph alone. The ink used to change at the same time, which made two
   things move for one gesture — the same over-reaction at a smaller scale. */
.tool:hover {
  background: var(--ide-hover);
}
.tool:focus-visible {
  outline: none;
  background: var(--ide-sel);
}
/* Pressed is the one place a second property earns its keep: the cursor has already committed, so
   the ground steps once more to acknowledge it. Still one step, not a scale or a shadow. */
.tool:active {
  cursor: grabbing;
  background: var(--ide-sel);
  color: var(--ink);
}
.tool svg {
  flex: none;
  width: 30px;
  height: 18px;
  color: var(--ide-command);
  transform: translateX(-6px);
}
.tool span {
  min-width: 0;
  font-family: var(--sans);
  font-size: 13px;
  color: var(--ink-dim);
  overflow: hidden;
  text-overflow: ellipsis;
}

.preview-workbench {
  flex: 1;
  min-width: 0;
  min-height: 0;
  display: grid;
  /* One gutter size for both rulers: the horizontal one sets it, and the vertical one is exactly
     as wide as that one is tall, so the corner stays square and neither edge looks heavier. */
  grid-template-columns: 24px minmax(0, 1fr);
  grid-template-rows: 24px minmax(0, 1fr);
  overflow: hidden;
  background: var(--bg-white);
  /* The containing block for .preview-drop, which covers this pane during a file drag. */
  position: relative;
}
.ruler-corner,
.ruler {
  background: linear-gradient(#fff, var(--ide-chrome));
  color: var(--ink-faint);
  user-select: none;
}
.ruler-corner {
  grid-column: 1;
  grid-row: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  appearance: none;
  border-right: 1px solid var(--ide-edge);
  border-bottom: 1px solid var(--ide-edge);
  border-left: 0;
  border-top: 0;
  padding: 0;
  color: var(--ink);
  font-size: 12px;
  font-variant-numeric: tabular-nums;
  cursor: pointer;
  transition:
    background-color var(--dur-fast) var(--ease),
    color var(--dur-fast) var(--ease);
}
.ruler-corner:hover {
  background: var(--ide-hover);
}
.ruler-corner:focus-visible {
  outline: none;
  background: var(--ide-sel);
}
.ruler-corner:active {
  color: var(--accent);
  background: var(--ide-sel);
}
.ruler-corner__cursor {
  width: 16px;
  height: 16px;
  display: block;
  shape-rendering: crispEdges;
}
.ruler-corner__cursor-fill,
.ruler-corner__box-fill {
  fill: currentColor;
}
.ruler {
  position: relative;
  overflow: hidden;
  font-size: 12px;
  font-variant-numeric: tabular-nums;
  line-height: 1;
  cursor: grab;
}
.ruler--x {
  grid-column: 2;
  grid-row: 1;
  border-bottom: 1px solid var(--ide-edge);
}
.ruler--y {
  grid-column: 1;
  grid-row: 2;
  border-right: 1px solid var(--ide-edge);
}
.ruler__tick {
  position: absolute;
  background: var(--ide-edge-2);
  pointer-events: none;
}
.ruler__tick--x {
  bottom: 0;
  width: 1px;
  height: 4px;
}
.ruler__tick--y {
  right: 0;
  width: 4px;
  height: 1px;
}
.ruler__tick.is-major {
  background: rgba(0, 0, 0, 0.32);
}
.ruler__tick--x.is-major {
  height: 8px;
}
.ruler__tick--y.is-major {
  width: 8px;
}
.ruler__label {
  position: absolute;
  color: var(--ide-ink-2);
  pointer-events: none;
  white-space: nowrap;
}
.ruler__label--x {
  top: 3px;
  transform: translateX(4px);
}
.ruler__label--y {
  left: 3px;
  transform: translateY(3px);
}
.preview-workbench.is-panning .ruler {
  cursor: grabbing;
}

/* The label sheet — the one object here that is the product, so it gets the only real shadow.
   The wrap is a locked preview viewport; the stage is the transform layer, so hand-panning feels
   like a design app canvas instead of a scroll box with hard edges. */
.sheet-wrap {
  --preview-pan-x: 0px;
  --preview-pan-y: 0px;
  /* Declared here, next to the pan, because script writes all three onto this element and the
     stage inherits them. Declaring it on .sheet-stage instead would shadow the inherited value. */
  --preview-scale: 1;
  flex: 1;
  grid-column: 2;
  grid-row: 2;
  width: 100%;
  height: 100%;
  min-width: 0;
  min-height: 0;
  position: relative;
  overflow: hidden;
  cursor: grab;
  touch-action: none;
  scrollbar-width: none;
  -ms-overflow-style: none;
}
.sheet-wrap.is-panning {
  cursor: grabbing;
}
/* During a gesture the sheet is promoted to its own layer so the GPU resamples it instead of the
   browser repainting it each frame. Only while the gesture runs: a permanent hint would keep a
   rasterised copy of the sheet in memory and blur nothing back into crisp text when it settles.
   The sheet is rasterised at its layout size, so text stays sharp up to that layer's resolution
   and the class coming off at the end of the pinch is what repaints it crisply at the new size. */
.sheet-wrap.is-zooming .sheet {
  will-change: transform;
}
.sheet-wrap.is-panning * {
  cursor: grabbing;
}
.sheet-wrap::-webkit-scrollbar {
  width: 0;
  height: 0;
}
/* Pan AND zoom both live in this one transform, and that is the whole point.
 *
 * Zoom used to be written as the sheet's `height`, which meant every frame of a pinch relaid out
 * the sheet, every .el positioned inside it, and the rulers. Layout cannot be done on the GPU, so
 * the gesture could never be smooth however much of the JS was trimmed — which is why the earlier
 * caching and rAF work helped the numbers but not the feel.
 *
 * As a scale it is a composited transform: the browser rasterises the sheet once and the GPU
 * resamples it per frame, the way macOS itself zooms. The sheet keeps a fixed layout size (its
 * 100% fit), so nothing inside it relaid out while the gesture runs.
 *
 * Order matters: translate first, then scale, so the pan stays in screen pixels instead of being
 * multiplied by the zoom. */
.sheet-stage {
  position: absolute;
  left: 50%;
  top: 50%;
  width: max-content;
  height: max-content;
  display: flex;
  align-items: center;
  justify-content: center;
  transform: translate(-50%, -50%)
    translate(var(--preview-pan-x), var(--preview-pan-y))
    scale(var(--preview-scale));
  transform-origin: center;
  will-change: transform;
}

/* The sheet is sized by HEIGHT, not by a fixed width.
 *
 * It used to be max-width: 300px, which made 100% a small sheet floating in a large pane — the
 * owner had to zoom to 190% before it was comfortable to look at, every session. 100% now means
 * "as tall as the pane allows", so the default is the size you actually want and zoom is there
 * for the exceptions. --fit is written by script from the measured pane height; the 4/6 aspect
 * ratio then decides the width, so the sheet stays correct for any label shape. */
.sheet {
  --label-ar: 4 / 6;
  --fit: 300px;
  aspect-ratio: var(--label-ar);
  height: var(--fit);
  width: auto;
  max-width: none;
  flex: none;
  background: var(--bg-white);
  border: 1px solid var(--ide-edge);
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
  position: relative;
  overflow: hidden;
  touch-action: none;
  transform-origin: center;
}
.preview-workbench.is-inspecting .sheet {
  cursor: crosshair;
}
.preview-inspect-box {
  position: absolute;
  z-index: 4;
  pointer-events: none;
  border: 1px solid rgba(0, 113, 227, 0.8);
  background: rgba(0, 113, 227, 0.14);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.55);
}
/* Transform ONLY, and that is deliberate.
 *
 * The lift used to be written as a box-shadow keyframed alongside the rotation. A shadow cannot be
 * composited: every keyframe of it repainted the sheet's whole shadow on the main thread, which is
 * the one thread the rotation has to share with the relayout the new label shape has just caused.
 * The animation ran, but the first frames arrived late — the small hitch the owner could feel.
 *
 * The owner asked for no shadow on the spin at all, which settles it: the sheet keeps its resting
 * shadow untouched for the duration, so nothing about the animation is ever repainted. The only
 * property that changes is the transform, and the compositor runs that on its own. */
.sheet.is-rotating-right {
  animation: sheetRotateRight 560ms cubic-bezier(0.22, 1, 0.36, 1);
  will-change: transform;
}
@keyframes sheetRotateRight {
  0% {
    transform: rotate(-90deg) scale(0.965);
  }
  38% {
    transform: rotate(-24deg) scale(0.985);
  }
  68% {
    transform: rotate(2.4deg) scale(1.008);
  }
  84% {
    transform: rotate(-0.7deg) scale(0.998);
  }
  100% {
    transform: rotate(0deg) scale(1);
  }
}
@media (prefers-reduced-motion: reduce) {
  .sheet.is-rotating-right {
    animation: none;
  }
}
.sheet.over {
  outline: 2px dashed var(--accent);
  outline-offset: -4px;
}

/* ─────────────── dropped artwork ───────────────
   One PDF page, SVG or PNG laid over the label to design against. It fills the sheet because the
   sheet IS the label, and sits at z-index 0 so dropped elements stay above it.

   pointer-events: none is what keeps click-and-drag panning working across the artwork: without
   it an <object> holding a PDF swallows the pointer, and the preview stops panning wherever the
   artwork covers it. */
.sheet-art {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  overflow: hidden;
}
.sheet-art[hidden] {
  display: none;
}
/* contain, never cover or fill: artwork whose proportions disagree with the label letterboxes
   rather than stretching. A stretched proof is worse than an obviously mismatched one — it looks
   correct while placing every element in the wrong spot. */
.sheet-art-img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  display: block;
}
.sheet-art-frame {
  width: 100%;
  height: 100%;
  border: 0;
  display: block;
  background: transparent;
}
/* ─────────────── the preview drop target ─────────────── */
.preview-drop {
  position: absolute;
  inset: 0;
  z-index: 8;
  display: none;
  align-items: center;
  justify-content: center;
  pointer-events: none;
  background: rgba(245, 245, 247, 0.86);
  outline: 2px dashed var(--accent);
  outline-offset: -6px;
}
.preview-workbench.is-file-drop .preview-drop {
  display: flex;
}
.preview-drop-card {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  padding: 18px 26px;
  text-align: center;
  color: var(--accent);
}
.preview-drop-card svg {
  width: 26px;
  height: 26px;
}
.preview-drop-title {
  font-size: 14px;
  font-weight: 600;
  color: var(--ink);
}
.preview-drop-hint {
  font-size: 12px;
  color: var(--ink-dim);
}

/* The artwork chip in the canvas status bar: names what is placed and clears it. */
.sb-art {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  max-width: 190px;
  font: inherit;
  color: var(--ink-dim);
  background: var(--ide-sel);
  border: 1px solid rgba(0, 113, 227, 0.24);
  border-radius: 5px;
  padding: 0 5px;
  cursor: pointer;
}
.sb-art[hidden] {
  display: none;
}
.sb-art:hover {
  color: var(--ink);
  border-color: rgba(0, 113, 227, 0.42);
}
.sb-art-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.sb-art svg {
  flex: none;
  width: 11px;
  height: 11px;
}
.sb-art:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}
body.is-preview-panning,
body.is-preview-panning * {
  cursor: grabbing !important;
  user-select: none;
}
/* No type of its own any more: it lives in the canvas status bar and inherits that bar's
   typography, exactly as #sb-pos does in the editor's. Styling it here would make the two bars
   disagree about what a status line looks like. */

.el {
  position: absolute;
  /* Above .sheet-art, so dropped elements stay visible over any artwork placed on the label. */
  z-index: 1;
  padding: 1px 4px;
  font-family: var(--mono);
  font-size: 12px;
  color: var(--ink);
  background: rgba(0, 113, 227, 0.1);
  border: 1px solid var(--accent);
  cursor: grab;
  white-space: nowrap;
}

/* ─────────────── narrow ───────────────
   Panes need width. Below 1180px the canvas goes, below 900px the command list too, leaving the
   editor — which is what you cannot work without. */
@media (max-width: 1180px) {
  .ide {
    grid-template-columns: minmax(0, 1fr) 210px;
  }
  .ide > .pane--canvas {
    display: none;
  }
}
/* ─────────────── the phone gate ───────────────
 * The designer is a desktop and tablet tool, by decision and not by omission: it is driven by
 * HTML5 drag-and-drop, which phones do not implement, so the canvas cannot be used there however
 * the panes are arranged. Below this width the dictionary pane was already gone and the canvas
 * with it, which left a lone editor pretending to be an IDE.
 *
 * So the page says so instead, on a viewport query rather than a user-agent test: a narrow window
 * on a desktop is equally unable to run a three-pane layout, and the notice is the honest answer
 * there too.
 *
 * THE 600px THRESHOLD, and why it is not 900px.
 * Tablets are in scope, and a portrait iPad is 834px CSS pixels wide — a plain `max-width: 900px`
 * would turn away exactly the device the owner asked to support. 600px is below every tablet in
 * portrait (iPad mini is the narrowest at 744px) and above every phone in landscape that matters
 * (iPhone 16 Pro Max is 440px portrait, 956px landscape — see the pointer rule below for why that
 * landscape width does not slip through).
 *
 * Between 600px and 900px the layout is the single-pane editor the breakpoint below produces.
 * That is a deliberate tablet-portrait fallback, not a phone experience: the canvas returns as
 * soon as the device is turned landscape.
 *
 * A phone held in landscape can be wider than 600px, so width alone is not enough. `pointer:
 * coarse` combined with a short viewport catches it: a tablet in landscape is ~1024px+ tall in
 * its shorter axis terms, while a landscape phone is under 500px tall. Desktops report `pointer:
 * fine` and never match it, and a touchscreen laptop is `pointer: fine` too because it has a
 * mouse as well.
 *
 * `display: none` on .ide rather than visibility or opacity — the editor must not stay in the
 * tab order behind the notice, and the canvas must not keep laying out off-screen.
 */
.too-small { display: none; }

@media (max-width: 600px), (pointer: coarse) and (max-height: 500px) {
  .too-small {
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 100vh;
    min-height: 100dvh;
    padding: 32px 20px;
    background: var(--bg);
  }
  /* min() and not a bare max-width: at 393px a fixed 380px box plus the wrapper's 20px side
     padding comes to 420px, and the overflow pushed the headline and buttons off the right edge.
     min() lets the measure shrink with the viewport while keeping the 380px cap on wider screens. */
  .too-small-inner {
    width: 100%;
    max-width: min(380px, 100%);
    text-align: center;
  }

  /* The mark is the only navigation on this page: the site nav is hidden below, and a visitor who
     landed here from a shared link still needs a way into the rest of the site. */
  .too-small-brand {
    display: inline-flex;
    color: var(--ink);
    margin-bottom: 40px;
  }
  .too-small-brand .brand-mark {
    width: 26px;
    height: 26px;
  }

  .too-small-art {
    color: var(--ink-faint);
    margin-bottom: 28px;
  }
  .too-small-art svg {
    width: 56px;
    height: 56px;
  }
  /* The slash is the one part that should read as a verdict rather than as line art, so it
     carries the site's own red and sits over the phone it cancels. */
  .too-small-slash {
    stroke: var(--danger);
  }

  .too-small-eyebrow {
    margin: 0 0 8px;
    font-size: 12px;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--ink-faint);
  }

  .too-small h1 {
    margin: 0 0 14px;
    font-size: 26px;
    line-height: 1.15;
    letter-spacing: -0.015em;
    color: var(--ink);
  }

  .too-small-lead {
    margin: 0 0 28px;
    font-size: 15px;
    line-height: 1.55;
    color: var(--ink-dim);
  }

  /* Stacked rather than side by side: at 393px two buttons in a row leave each too narrow to read
     as a target, and the order matters more than the symmetry. */
  .too-small-actions {
    display: flex;
    flex-direction: column;
    gap: 10px;
    margin-bottom: 18px;
  }
  /* .btn is already inline-flex and centred; only the full width is new, and the flex column
     above gives it that on its own. */

  .too-small-hint {
    margin: 0;
    font-size: 13px;
    line-height: 1.5;
    color: var(--ink-faint);
  }

  /* The mobile-app note. Separated by a hairline instead of a panel — §3.3, "borders do not
     exist": the change of position does the work a box would have done. */
  .too-small-note {
    margin: 32px 0 0;
    padding-top: 24px;
    border-top: 1px solid var(--line-soft);
    font-size: 13px;
    line-height: 1.6;
    color: var(--ink-dim);
  }
  .too-small-badge {
    display: inline-block;
    margin-right: 6px;
    padding: 2px 8px;
    border-radius: 999px;
    background: var(--accent-soft);
    color: var(--accent);
    font-size: 11px;
    font-weight: 600;
    letter-spacing: 0.02em;
    /* Sits on the text baseline of the sentence it introduces rather than starting a line of its
       own, so the note stays one paragraph. */
    vertical-align: baseline;
  }

  /* The IDE and the global chrome both stand down: the notice is the whole page, and the nav's
     links lead back out of it anyway. */
  .ide,
  .nav,
  .search-panel,
  .search-veil {
    display: none;
  }
}
