/* generated by tools/build_site.mjs — do not edit by hand */
@layer reset, tokens, base, layout, components, utilities;

@layer tokens {
  :root {
    color-scheme: light dark;

    /* azure */
    --azure-50: oklch(0.972 0.0210 245.42);
    --azure-100: oklch(0.938 0.0385 245.42);
    --azure-200: oklch(0.885 0.0700 245.42);
    --azure-300: oklch(0.812 0.1085 245.42);
    --azure-400: oklch(0.735 0.1487 245.42);
    --azure-500: oklch(0.664 0.1750 245.42);
    --azure-600: oklch(0.585 0.1750 245.42);
    --azure-700: oklch(0.505 0.1540 245.42);
    --azure-800: oklch(0.42 0.1260 245.42);
    --azure-900: oklch(0.33 0.0963 245.42);
    --azure-950: oklch(0.24 0.0700 245.42);
    --azure-975: oklch(0.18 0.0525 245.42);
    --azure-1000: oklch(0.13 0.0385 245.42);

    /* blue */
    --blue-50: oklch(0.972 0.0171 246.87);
    --blue-100: oklch(0.938 0.0313 246.87);
    --blue-200: oklch(0.885 0.0570 246.87);
    --blue-300: oklch(0.812 0.0883 246.87);
    --blue-400: oklch(0.735 0.1211 246.87);
    --blue-500: oklch(0.664 0.1425 246.87);
    --blue-600: oklch(0.585 0.1425 246.87);
    --blue-700: oklch(0.505 0.1254 246.87);
    --blue-800: oklch(0.42 0.1026 246.87);
    --blue-900: oklch(0.33 0.0784 246.87);
    --blue-950: oklch(0.24 0.0570 246.87);
    --blue-975: oklch(0.18 0.0427 246.87);
    --blue-1000: oklch(0.13 0.0313 246.87);

    /* red */
    --red-50: oklch(0.972 0.0268 29.08);
    --red-100: oklch(0.938 0.0491 29.08);
    --red-200: oklch(0.885 0.0892 29.08);
    --red-300: oklch(0.812 0.1383 29.08);
    --red-400: oklch(0.735 0.1896 29.08);
    --red-500: oklch(0.664 0.2231 29.08);
    --red-600: oklch(0.585 0.2231 29.08);
    --red-700: oklch(0.505 0.1963 29.08);
    --red-800: oklch(0.42 0.1606 29.08);
    --red-900: oklch(0.33 0.1227 29.08);
    --red-950: oklch(0.24 0.0892 29.08);
    --red-975: oklch(0.18 0.0669 29.08);
    --red-1000: oklch(0.13 0.0491 29.08);

    /* green */
    --green-50: oklch(0.972 0.0222 150.5);
    --green-100: oklch(0.938 0.0407 150.5);
    --green-200: oklch(0.885 0.0741 150.5);
    --green-300: oklch(0.812 0.1148 150.5);
    --green-400: oklch(0.735 0.1574 150.5);
    --green-500: oklch(0.664 0.1852 150.5);
    --green-600: oklch(0.585 0.1852 150.5);
    --green-700: oklch(0.505 0.1630 150.5);
    --green-800: oklch(0.42 0.1333 150.5);
    --green-900: oklch(0.33 0.1019 150.5);
    --green-950: oklch(0.24 0.0741 150.5);
    --green-975: oklch(0.18 0.0556 150.5);
    --green-1000: oklch(0.13 0.0407 150.5);

    /* amber */
    --amber-50: oklch(0.972 0.0201 83.44);
    --amber-100: oklch(0.938 0.0368 83.44);
    --amber-200: oklch(0.885 0.0669 83.44);
    --amber-300: oklch(0.812 0.1037 83.44);
    --amber-400: oklch(0.735 0.1422 83.44);
    --amber-500: oklch(0.664 0.1673 83.44);
    --amber-600: oklch(0.585 0.1673 83.44);
    --amber-700: oklch(0.505 0.1472 83.44);
    --amber-800: oklch(0.42 0.1205 83.44);
    --amber-900: oklch(0.33 0.0920 83.44);
    --amber-950: oklch(0.24 0.0669 83.44);
    --amber-975: oklch(0.18 0.0502 83.44);
    --amber-1000: oklch(0.13 0.0368 83.44);

    /* orange */
    --orange-50: oklch(0.972 0.0208 58.74);
    --orange-100: oklch(0.938 0.0382 58.74);
    --orange-200: oklch(0.885 0.0694 58.74);
    --orange-300: oklch(0.812 0.1076 58.74);
    --orange-400: oklch(0.735 0.1475 58.74);
    --orange-500: oklch(0.664 0.1735 58.74);
    --orange-600: oklch(0.585 0.1735 58.74);
    --orange-700: oklch(0.505 0.1527 58.74);
    --orange-800: oklch(0.42 0.1249 58.74);
    --orange-900: oklch(0.33 0.0954 58.74);
    --orange-950: oklch(0.24 0.0694 58.74);
    --orange-975: oklch(0.18 0.0520 58.74);
    --orange-1000: oklch(0.13 0.0382 58.74);

    /* violet */
    --violet-50: oklch(0.972 0.0228 303);
    --violet-100: oklch(0.938 0.0418 303);
    --violet-200: oklch(0.885 0.0760 303);
    --violet-300: oklch(0.812 0.1178 303);
    --violet-400: oklch(0.735 0.1615 303);
    --violet-500: oklch(0.664 0.1900 303);
    --violet-600: oklch(0.585 0.1900 303);
    --violet-700: oklch(0.505 0.1672 303);
    --violet-800: oklch(0.42 0.1368 303);
    --violet-900: oklch(0.33 0.1045 303);
    --violet-950: oklch(0.24 0.0760 303);
    --violet-975: oklch(0.18 0.0570 303);
    --violet-1000: oklch(0.13 0.0418 303);

    /* neutral */
    --neutral-50: oklch(0.972 0.0006 70);
    --neutral-100: oklch(0.938 0.0011 70);
    --neutral-200: oklch(0.885 0.0020 70);
    --neutral-300: oklch(0.812 0.0031 70);
    --neutral-400: oklch(0.735 0.0043 70);
    --neutral-500: oklch(0.664 0.0050 70);
    --neutral-600: oklch(0.585 0.0050 70);
    --neutral-700: oklch(0.505 0.0044 70);
    --neutral-800: oklch(0.42 0.0036 70);
    --neutral-900: oklch(0.33 0.0028 70);
    --neutral-950: oklch(0.24 0.0020 70);
    --neutral-975: oklch(0.18 0.0015 70);
    --neutral-1000: oklch(0.13 0.0011 70);

    /* Measured face colours — functional identifiers, never tuned. */
    --face-front: #E2231A;
    --face-back: #0074BD;
    --face-left: #F6B702;
    --face-right: #02B757;
    --face-up: #00A2FF;
    --face-down: #F68802;

    /* Semantic — for layout and prose. */
    --surface: light-dark(var(--neutral-50), var(--neutral-975));
    --surface-raised: light-dark(#fff, var(--neutral-950));
    --surface-sunken: light-dark(var(--neutral-100), var(--neutral-1000));
    --text: light-dark(var(--neutral-900), var(--neutral-50));
    --text-muted: light-dark(var(--neutral-600), var(--neutral-400));
    --text-faint: light-dark(var(--neutral-400), var(--neutral-500));
    --border: light-dark(var(--neutral-200), var(--neutral-900));
    --border-strong: light-dark(var(--neutral-300), var(--neutral-800));
    --accent: light-dark(var(--azure-600), var(--azure-400));
    --accent-hover: light-dark(var(--azure-700), var(--azure-300));
    --accent-contrast: light-dark(#fff, var(--neutral-900));
    --danger: light-dark(var(--red-600), var(--red-400));
    --success: light-dark(var(--green-600), var(--green-400));
    --warning: light-dark(var(--amber-600), var(--amber-400));
    --info: light-dark(var(--azure-600), var(--azure-400));

    /* Component — the only layer components may reference. */
    --control-bg: light-dark(#fff, var(--neutral-950));
    --control-bg-hover: light-dark(var(--neutral-100), var(--neutral-900));
    --control-bg-active: light-dark(var(--neutral-200), var(--neutral-800));
    --control-border: light-dark(var(--neutral-300), var(--neutral-800));
    --control-text: light-dark(var(--neutral-900), var(--neutral-100));
    --panel-bg: light-dark(#fff, var(--neutral-950));
    --panel-border: light-dark(var(--neutral-200), var(--neutral-900));
    --btn-primary-bg: light-dark(var(--azure-600), var(--azure-500));
    --btn-primary-bg-hover: light-dark(var(--azure-700), var(--azure-400));
    --btn-primary-text: light-dark(#fff, var(--neutral-1000));
    --swatch-ring: light-dark(var(--neutral-300), var(--neutral-800));
    --swatch-ring-selected: light-dark(var(--azure-600), var(--azure-400));
    --focus-ring: light-dark(var(--azure-600), var(--azure-400));
    --selection-bg: light-dark(var(--azure-100), var(--azure-900));
    --tier-free-tint: light-dark(var(--neutral-600), var(--neutral-400));
    --tier-free-wash: light-dark(var(--neutral-100), var(--neutral-900));
    --tier-free-edge: light-dark(var(--neutral-300), var(--neutral-800));
    --tier-creator-tint: light-dark(var(--azure-600), var(--azure-400));
    --tier-creator-wash: light-dark(var(--azure-50), var(--azure-950));
    --tier-creator-edge: light-dark(var(--azure-200), var(--azure-800));
    --tier-studio-tint: light-dark(var(--violet-600), var(--violet-400));
    --tier-studio-wash: light-dark(var(--violet-50), var(--violet-950));
    --tier-studio-edge: light-dark(var(--violet-200), var(--violet-800));

    /* Literal: alpha compositing and canvas surfaces. */
    --scrim: oklch(0.13 0 0 / 0.55);
    --canvas-baseplate: #A3A2A5;
    --canvas-checker-a: oklch(0.72 0 0);
    --canvas-checker-b: oklch(0.62 0 0);
    --disabled-opacity: 0.45;
    --border-width: 1px;

    --icon-size-sm: 16px;
    --icon-size-md: 20px;
    --icon-size-lg: 24px;
    --icon-stroke: 2;
    --icon-stroke-fine: 1.75;

    --control-height-sm: 28px;
    --control-height-md: 36px;
    --control-height-lg: 44px;
    --control-rail-width: 48px;
    --control-appbar-height: 48px;

    --focus-width: 2px;
    --focus-offset: 2px;

    --z-base: 0;
    --z-sticky: 10;
    --z-rail: 20;
    --z-panel: 30;
    --z-popover: 40;
    --z-modal: 50;
    --z-toast: 60;

    --radius-sm: 6px;
    --radius-md: 10px;
    --radius-lg: 14px;
    --radius-full: 999px;

    --space-3xs: 2px;
    --space-2xs: 4px;
    --space-xs: 8px;
    --space-sm: 12px;
    --space-md: 16px;
    --space-lg: 24px;
    --space-xl: 32px;
    --space-2xl: 48px;
    --space-3xl: 64px;

    --type-family-ui: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
    --type-family-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
    --type-size-xs: 0.75rem;
    --type-size-sm: 0.875rem;
    --type-size-md: 1rem;
    --type-size-lg: 1.125rem;
    --type-size-xl: 1.5rem;
    --type-size-2xl: 2rem;
    --type-leading-tight: 1.2;
    --type-leading-normal: 1.6;
    --type-weight-normal: 400;
    --type-weight-medium: 500;
    --type-weight-bold: 650;

    --motion-fast: 120ms;
    --motion-base: 160ms;
    --motion-slow: 240ms;
    --motion-ease: cubic-bezier(0.2, 0, 0, 1);

    --shadow-sm: 0 1px 2px oklch(0 0 0 / 0.06);
    --shadow-md: 0 2px 8px oklch(0 0 0 / 0.08);
    --shadow-lg: 0 8px 28px oklch(0 0 0 / 0.12);
  }
}

@layer base {
  * { box-sizing: border-box; }

  body {
    margin: 0;
    background: var(--surface);
    color: var(--text);
    font-family: var(--type-family-ui);
    font-size: var(--type-size-md);
    line-height: var(--type-leading-normal);
    -webkit-font-smoothing: antialiased;
  }

  main { max-width: 760px; margin: 0 auto; padding: var(--space-2xl) var(--space-lg) var(--space-lg); }
  h1 { font-size: var(--type-size-2xl); line-height: var(--type-leading-tight); margin: 0 0 var(--space-xs); letter-spacing: -0.02em; }
  h2 { font-size: var(--type-size-lg); margin: var(--space-xl) 0 var(--space-xs); letter-spacing: -0.01em; }
  p { margin: 0 0 var(--space-md); }

  a { color: inherit; text-decoration-color: var(--border-strong); text-underline-offset: 3px; transition: text-decoration-color var(--motion-fast) var(--motion-ease); }
  a:hover { text-decoration-color: var(--accent); }
  a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius-sm); }

  .lede { color: var(--text-muted); font-size: var(--type-size-lg); margin: 0 0 var(--space-lg); }

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

@layer components {
  /*
    The footer takes the page's measure, not its own.

    760px is right under prose. Under the account pages, which run wider, a
    760px footer centred beneath a full-width column sat visibly inboard of
    everything above it — two different pages stacked. Pages that want a
    different measure set --page-measure; everything else gets the default.
  */
  .site-footer {
    max-width: var(--page-measure, 760px);
    margin: var(--space-3xl) auto 0;
    padding: var(--space-lg) var(--space-lg) var(--space-2xl);
    border-top: 1px solid var(--border);
    font-size: var(--type-size-sm);
  }

  .footer-brand { display: flex; flex-wrap: wrap; gap: var(--space-xs); align-items: center; }
  .footer-mark { width: 22px; height: 22px; flex: none; }
  .wordmark { font-weight: var(--type-weight-bold); letter-spacing: -0.01em; }
  .byline { color: var(--text-muted); }

  .footer-social { display: flex; gap: var(--space-md); margin: var(--space-sm) 0 var(--space-md); }
  .footer-social a {
    display: inline-flex; align-items: center; gap: var(--space-2xs);
    color: var(--text-muted); text-decoration: none;
    transition: color var(--motion-fast) var(--motion-ease);
  }
  .footer-social a:hover { color: var(--text); }
  .footer-social svg { width: 15px; height: 15px; fill: currentColor; }

  .disclaimer { color: var(--text-faint); font-size: var(--type-size-xs); margin: 0; max-width: 60ch; }
}

@layer components {
  /* ---------------------------------------------------------------- button */

  .btn {
    display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2xs);
    min-height: 36px; padding: 0 var(--space-md);
    border: 1px solid var(--control-border);
    border-radius: var(--radius-md);
    background: var(--control-bg);
    color: var(--control-text);
    font: inherit; font-size: var(--type-size-sm); font-weight: var(--type-weight-medium);
    cursor: pointer;
    transition: background var(--motion-fast) var(--motion-ease),
                border-color var(--motion-fast) var(--motion-ease);
  }
  .btn:hover { background: var(--control-bg-hover); }
  .btn:active { background: var(--control-bg-active); }
  .btn:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
  .btn[disabled] { opacity: var(--disabled-opacity); cursor: not-allowed; }
  /* .btn sets display, which outranks the UA sheet's [hidden] { display: none }
     — so a hidden button stayed visible and kept its space. Third time this
     pattern has bitten in this file; fixed at the source rather than per-use. */
  .btn[hidden] { display: none; }

  .btn-primary {
    background: var(--btn-primary-bg);
    border-color: transparent;
    color: var(--btn-primary-text);
  }
  .btn-primary:hover { background: var(--btn-primary-bg-hover); }

  .btn-ghost { background: transparent; border-color: transparent; color: var(--text-muted); }
  .btn-ghost:hover { background: var(--control-bg-hover); color: var(--text); }

  /* ----------------------------------------------------------------- panel */

  /* Deliberately no accent stripe, no tinted header, no drop shadow. A panel
     sitting in the page is not floating. */
  .panel {
    background: var(--panel-bg);
    border: 1px solid var(--panel-border);
    border-radius: var(--radius-lg);
    padding: var(--space-md);
  }
  .panel-head {
    display: flex; align-items: center; justify-content: space-between;
    gap: var(--space-sm); margin-bottom: var(--space-sm);
  }
  .panel-title { font-size: var(--type-size-sm); font-weight: var(--type-weight-bold); margin: 0; }

  /* Only for genuinely floating surfaces — menus, dialogs, drag previews. */
  .floating { box-shadow: var(--shadow-lg); border-radius: var(--radius-lg); }

  /* ---------------------------------------------------------------- swatch */

  .swatch {
    inline-size: 28px; block-size: 28px; padding: 0;
    border-radius: var(--radius-sm);
    border: 1px solid var(--swatch-ring);
    cursor: pointer;
    transition: transform var(--motion-fast) var(--motion-ease);
  }
  .swatch:hover { transform: translateY(-1px); }
  .swatch[aria-pressed="true"] {
    outline: 2px solid var(--swatch-ring-selected);
    outline-offset: 2px;
  }

  /* ------------------------------------------------------------ face strip */

  /* The six measured face colours in template order. Brand motif and real
     legend at once — never colour alone, always paired with its label. */
  .face-strip { display: flex; gap: var(--space-3xs); }
  .face-strip > * {
    inline-size: 26px; block-size: 26px;
    border-radius: var(--radius-sm);
    display: grid; place-items: center;
    font-family: var(--type-family-mono); font-size: 10px; font-weight: 700;
    color: oklch(1 0 0 / 0.9);
  }

  /* ------------------------------------------------------------------ net */

  /* The 3x3 cube net from the logo, reused as the recurring identity motif. */
  .net { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2px; inline-size: 72px; aspect-ratio: 1; }
  .net > i { border-radius: 3px; background: var(--control-bg-hover); }
  .net > i.on { background: var(--accent); }

  /* ----------------------------------------------------------------- field */

  .field {
    inline-size: 100%; min-height: 36px;
    padding: 0 var(--space-sm);
    border: 1px solid var(--control-border);
    border-radius: var(--radius-md);
    background: var(--control-bg);
    color: var(--control-text);
    font: inherit; font-size: var(--type-size-sm);
  }
  .field:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -1px; border-color: transparent; }
  .field::selection { background: var(--selection-bg); }

  /* ------------------------------------------------------------------ chip */

  /* Only for state the user cannot otherwise see. Never a category label. */
  .chip {
    display: inline-flex; align-items: center;
    padding: 2px var(--space-2xs);
    border: 1px solid var(--control-border);
    border-radius: var(--radius-sm);
    font-size: var(--type-size-xs); color: var(--text-muted);
  }

  /* ------------------------------------------------------------------ icon */

  /* Sized and stroked to Lucide's 24px grid. currentColor throughout so an icon
     inherits whatever the control it sits in already resolved to. */
  .icon { inline-size: var(--icon-size-md); block-size: var(--icon-size-md); flex: none; stroke-width: var(--icon-stroke); stroke: currentColor; fill: none; }
  .icon-sm { inline-size: var(--icon-size-sm); block-size: var(--icon-size-sm); }
  .icon-lg { inline-size: var(--icon-size-lg); block-size: var(--icon-size-lg); }

  /* Icon-only control. Square, never a squashed .btn with the label removed. */
  .icon-btn {
    inline-size: var(--control-height-md); block-size: var(--control-height-md);
    display: grid; place-items: center; padding: 0;
    border: 1px solid transparent; border-radius: var(--radius-md);
    background: transparent; color: var(--text-muted); cursor: pointer;
    transition: background var(--motion-fast) var(--motion-ease), color var(--motion-fast) var(--motion-ease);
  }
  .icon-btn:hover { background: var(--control-bg-hover); color: var(--text); }
  .icon-btn:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: var(--focus-offset); }
  .icon-btn[aria-pressed="true"] { background: var(--control-bg-active); color: var(--accent); }

  /*
    Press feedback. A control that does not visibly react feels broken even when
    it worked — and on touch there is no hover state to carry that job at all.
    Scale rather than colour, so it reads on any background and costs no repaint.
  */
  .icon-btn:active { transform: scale(0.92); }
  .btn:active { transform: translateY(1px); }
  .icon-btn, .btn { transition:
    background var(--motion-fast) var(--motion-ease),
    color var(--motion-fast) var(--motion-ease),
    border-color var(--motion-fast) var(--motion-ease),
    transform 80ms var(--motion-ease); }

  /* Icons lead their control's colour change slightly, which is what makes a
     hover feel responsive rather than merely correct. */
  .icon-btn .icon { transition: transform var(--motion-fast) var(--motion-ease); }
  .icon-btn:hover .icon { transform: scale(1.08); }

  /* The one control that should feel weighty. */
  .btn-primary:hover { filter: brightness(1.06); }
  .btn-primary:active { filter: brightness(0.96); }

  /* ------------------------------------------------------------- rename */

  /* Inline-editable title. Reads as text until hovered or focused, so the app
     bar stays quiet — an always-bordered input in a title slot shouts. */
  .rename {
    border: 1px solid transparent; border-radius: var(--radius-md);
    background: transparent; color: var(--text);
    font: inherit; font-weight: var(--type-weight-medium);
    padding: var(--space-3xs) var(--space-2xs); margin-inline-start: calc(var(--space-2xs) * -1);
    min-inline-size: 6ch; max-inline-size: 32ch;
    transition: background var(--motion-fast) var(--motion-ease), border-color var(--motion-fast) var(--motion-ease);
  }
  .rename:hover { background: var(--control-bg-hover); }
  .rename:focus-visible {
    outline: none;
    background: var(--control-bg);
    border-color: var(--control-border);
  }

  /* ------------------------------------------------------------- numerals */

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

  /* Template previews are pixel art at 585x559. Smoothing them is both wrong
     and indistinguishable from every competitor. */
  .pixel { image-rendering: pixelated; }
}

@layer components {
  /* The editor is a mode, not a page: no marketing footer under a 100dvh app. */
  .shell-page .site-footer { display: none; }

  .shell {
    display: grid;
    grid-template-rows: var(--control-appbar-height) minmax(0, 1fr);
    block-size: 100dvh;
    background: var(--surface-sunken);
    overflow: hidden;
  }

  /* ---------------------------------------------------------------- app bar */

  /*
    Three groups, left to right: what this IS, what you can DO to it, who you ARE.
    Reading order matches importance, and the primary action sits where the eye
    lands last on a left-to-right scan.
  */
  .appbar {
    display: flex; align-items: center; gap: var(--space-sm);
    padding-inline: var(--space-xs);
    padding-inline-start: max(var(--space-xs), env(safe-area-inset-left));
    padding-inline-end: max(var(--space-xs), env(safe-area-inset-right));
    border-block-end: 1px solid var(--panel-border);
    background: var(--panel-bg);
    min-inline-size: 0;
  }

  /* Identity group. Shrinks first and truncates — the project name is the only
     thing here that can afford to lose characters. */
  .bar-doc {
    display: flex; align-items: center; gap: var(--space-2xs);
    min-inline-size: 0; flex: 1 1 auto;
  }
  .bar-doc .mark { inline-size: 22px; block-size: 22px; flex: none; }
  /*
    An <input> is sized by its size attribute, not its text — so the title sat
    left-aligned in a much wider box and the save state looked orphaned after it.
    field-sizing shrinks the box to the value; the size attribute is the fallback
    where it is unsupported.
  */
  .bar-doc .rename {
    field-sizing: content;
    min-inline-size: 6ch;
    max-inline-size: 28ch;
    flex: 0 1 auto;
  }

  /*
    Back to the list. A link, not a button: it navigates, and the browser's own
    middle-click and open-in-new-tab come free.

    Never shrinks — it is the way out, and a way out that becomes a mis-tap on a
    narrow window is not one. The name beside it shrinks instead.
  */
  .bar-back {
    flex: none;
    padding: var(--space-3xs) var(--space-xs);
    border-radius: var(--radius-sm);
    color: var(--text-muted);
    font-size: var(--type-size-sm);
    text-decoration: none;
    white-space: nowrap;
  }
  .bar-back:hover { background: var(--control-bg-hover); color: var(--text); }

  /* Sized to its value like the name beside it, so "Shirt" does not sit in a
     box wide enough for a sentence. */
  .garment-pick {
    flex: none;
    field-sizing: content;
    padding: var(--space-3xs) var(--space-2xs);
    border: 1px solid transparent;
    border-radius: var(--radius-sm);
    background: transparent;
    color: var(--text-muted);
    font: inherit;
    font-size: var(--type-size-sm);
    cursor: pointer;
  }
  .garment-pick:hover { background: var(--control-bg-hover); color: var(--text); }
  .garment-pick:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
  /* The popup itself is drawn by the OS, which does not inherit the page's
     colours — say them explicitly or the options are black on black. */
  .garment-pick option { background: var(--surface); color: var(--text); }
  /* The label is the first thing to go on a phone, where the toolbar is fighting
     for every pixel; the chevron still reads as "back". */
  .bar-back::before { content: "‹ "; }
  @media (width < 40rem) {
    .bar-back { font-size: 0; padding-inline: var(--space-2xs); }
    .bar-back::before { content: "‹"; font-size: var(--type-size-md); }
  }

  /* Action groups never shrink. A control that shrinks becomes a mis-tap. */
  .bar-actions, .bar-user { display: flex; align-items: center; gap: var(--space-3xs); flex: none; }

  .bar-sep {
    inline-size: 1px; block-size: 20px; flex: none;
    background: var(--panel-border); margin-inline: var(--space-2xs);
  }

  /*
    Save state is text, not a toast — routine success is not an event. It reads
    as belonging to the title rather than floating beside it, so it sits tight
    against the name with a dot doing the signalling and the word carrying the
    detail.
  */
  .save-state {
    display: inline-flex; align-items: center; gap: var(--space-2xs);
    color: var(--text-faint); font-size: var(--type-size-xs);
    white-space: nowrap; flex: none;
    padding-inline-start: var(--space-2xs);
  }
  /*
    The dot, its state rules and the pulse keyframe lived here and are gone.

    They styled a dot element inside static markup that always said "saved".
    The slot is filled by the HUD now, which renders words rather than a dot —
    "offline, saved on this device" and "read-only" have no obvious glyph, and
    a colour needs a legend where a word does not.

    (No backticks in here: this comment lives inside a template literal, so one
    ends the string and the generator stops parsing.)

    The keyframe also looped, which visual-language.md rules out.
  */

  /* Below ~900 the Export label goes, not the button. Losing the primary
     action to a menu is worse than losing its word. Import goes with it —
     they are a pair and should shrink together or the bar looks broken. */
  @media (width < 900px) {
    .btn-export .label, .btn-import .label { display: none; }
    .btn-export, .btn-import { inline-size: var(--control-height-md); padding: 0; }
  }

  /* ------------------------------------------------------------------ body */

  .shell-body {
    display: grid;
    grid-template-columns: var(--control-rail-width) minmax(0, 1fr);
    min-block-size: 0;
  }

  .rail {
    display: flex; flex-direction: column; align-items: center;
    gap: var(--space-3xs); padding-block: var(--space-xs);
    border-inline-end: 1px solid var(--panel-border);
    background: var(--panel-bg);
  }
  .rail hr { inline-size: 24px; block-size: 1px; border: 0; background: var(--panel-border); margin: var(--space-2xs) 0; }

  /* ------------------------------------------------------------- workspace */

  .workspace {
    display: grid;
    grid-template-rows: minmax(0, 1fr) auto;
    min-inline-size: 0; min-block-size: 0;
  }

  /*
    --split is set by the divider drag. Both tracks are minmax(0, …) so either
    pane can be driven to its floor without the row overflowing.
  */
  .stage {
    --split: 60%;
    display: grid;
    grid-template-columns: minmax(0, var(--split)) auto minmax(0, 1fr);
    min-inline-size: 0; min-block-size: 0;
  }

  .pane {
    container-type: inline-size;
    min-inline-size: 0; min-block-size: 0;
    position: relative;
    display: grid;
    /*
      Explicit minmax(0, 1fr) row, not the implicit auto.

      With an auto row the stage is sized to its content, so the canvas's
      max-block-size: 100% resolves against a height that grew to fit it — which
      constrains nothing, and the canvas spills over the panels. Caught at 900px.
    */
    grid-template-rows: minmax(0, 1fr);
  }
  .pane-2d { background: var(--surface-sunken); }
  .pane-3d { background: var(--surface); border-inline-start: 1px solid var(--panel-border); }

  .divider {
    inline-size: 5px; cursor: col-resize; background: transparent;
    border: 0; padding: 0; touch-action: none;
  }
  .divider:hover, .divider:focus-visible { background: var(--accent); outline: none; }

  /* The canvas fits in BOTH axes. Constraining only width is the usual bug —
     it overflows vertically the moment the window is short. */
  .canvas-stage {
    display: grid;
    place-items: center;
    padding: var(--space-md);
    min-block-size: 0;
    /* Constrain the implicit row too, or it sizes to the canvas and overflows
       its own box — measured at 900px: stage 512px, implicit row 559px. */
    grid-template-rows: minmax(0, 1fr);
    position: relative;
  }

  /* Drop target. The overlay is inert so it cannot eat the drop event it
     exists to advertise — dragleave would fire the instant it appeared. */
  .drop-veil {
    position: absolute; inset: var(--space-xs);
    display: grid; place-items: center;
    border: 2px dashed var(--accent);
    border-radius: var(--radius-lg);
    background: color-mix(in oklab, var(--accent) 12%, transparent);
    color: var(--text);
    font-size: var(--type-size-sm); font-weight: var(--type-weight-bold);
    pointer-events: none;
    opacity: 0; transition: opacity var(--motion-fast) var(--motion-ease);
  }
  .canvas-stage[data-dropping] .drop-veil { opacity: 1; }
  @media (prefers-reduced-motion: reduce) { .drop-veil { transition: none; } }
  .canvas {
    /*
      Auto sizes with both maxes is what actually preserves ratio here.

      aspect-ratio plus min-block-size: 0 let the two axes be clamped
      independently, and the template came out 585x480 instead of 585x559 —
      squashed, which for a pixel-accurate template preview is the one thing we
      cannot ship. With inline/block auto, the intrinsic ratio governs and the
      maxes just bound the box.

      A real <canvas> has no intrinsic ratio, so it sets aspect-ratio instead;
      the surrounding constraints are the same.
    */
    inline-size: auto;
    block-size: auto;
    max-inline-size: 100%;
    max-block-size: 100%;
    image-rendering: pixelated;
    /* Without this a touch drag scrolls the page instead of painting, and
       pointercancel fires mid-stroke. */
    touch-action: none;
    cursor: crosshair;
    /* The cursor is the only tool feedback visible during a stroke — the rail is
       in the corner of your eye and the canvas is not. */
    &[data-tool="eraser"] { cursor: cell; }
    background: var(--canvas-checker-a);
    border: 1px solid var(--panel-border);
    border-radius: var(--radius-sm);
  }

  .viewer-mount { inline-size: 100%; block-size: 100%; min-inline-size: 0; min-block-size: 0; }
  .viewer-fallback {
    display: grid; place-items: center; text-align: center;
    padding: var(--space-lg); color: var(--text-muted); font-size: var(--type-size-sm);
  }

  /* Sits next to the canvas dimensions rather than as a toast — the same rule
     the save state follows. */
  .export-status { margin-inline-start: var(--space-sm); color: var(--text-muted); }
  .export-status[data-bad] { color: var(--danger); }

  .pane-label {
    position: absolute; inset-block-start: var(--space-xs); inset-inline-start: var(--space-xs);
    font-size: var(--type-size-xs); color: var(--text-faint);
    font-family: var(--type-family-mono); pointer-events: none;
  }

  /* ---------------------------------------------------------------- panels */

  .panels {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    /*
      Constrain the implicit ROW as well as the columns.

      Without this the row is auto and sizes to its content, so a dock full of
      layers grew to 632px inside a 200px container and its scroll area never
      scrolled - it just overflowed. block-size: 200px on the parent does not
      help, because the row is what the child is measured against.

      Third time this exact bug has appeared: .stage sized to the canvas,
      .pane-3d to its children, now .panels. A grid that declares only columns
      has an implicit auto row, and any scroll container inside it silently
      stops scrolling.
    */
    grid-template-rows: minmax(0, 1fr);
    border-block-start: 1px solid var(--panel-border);
    background: var(--panel-bg);
    block-size: 200px;
  }
  .dock { display: grid; grid-template-rows: auto minmax(0, 1fr); min-inline-size: 0; }
  .dock + .dock { border-inline-start: 1px solid var(--panel-border); }
  .dock-head {
    display: flex; align-items: center; justify-content: space-between; gap: var(--space-xs);
    padding: var(--space-2xs) var(--space-sm);
    border-block-end: 1px solid var(--panel-border);
  }
  .dock-title { font-size: var(--type-size-xs); font-weight: var(--type-weight-bold); margin: 0; letter-spacing: 0.02em; }

  /* Commands that act on the selected layer. They live here rather than on the
     row because a row is 44px tall on a phone, sits beside a drag handle, and
     scrolls under the thumb — a destructive control there manufactures
     mis-taps. Photoshop, Procreate, Figma and Krita all keep it off the row.
     See docs/decisions/keys-and-controls.md. */
  .dock-actions { display: flex; align-items: center; gap: var(--space-3xs); }
  /* Delete is held apart from the two additive controls, so the button next to
     Duplicate is never the one that destroys a layer. */
  .dock-actions .is-danger { margin-inline-start: var(--space-xs); }
  .dock-actions .is-danger:not(:disabled):hover { color: var(--danger); }
  /* A disabled command stays visible and keeps its title, which is where its
     reason lives — hiding it would move the others under a moving cursor. */
  .dock-actions .icon-btn:disabled { opacity: 0.35; cursor: default; }
  .dock-body {
    overflow: auto;
    padding: var(--space-xs) var(--space-sm);
    /* An isolated scroll area: reaching the end of the layer list must not
       start scrolling whatever is behind it. On touch that chaining is what
       makes a panel feel like it is fighting you. */
    overscroll-behavior: contain;
  }

  /* position: relative makes this the offset parent for the drop indicator,
     so the line scrolls with the rows instead of floating over them. */
  /* Present to a screen reader, absent to everyone else. The clip-rect form
     rather than display:none, which removes it from the accessibility tree and
     would silence the live region entirely. */
  /* The shortcuts sheet. A native <dialog>, so the focus trap, Escape and the
     inert background come from the platform rather than from us. */
  .sheet {
    inline-size: min(560px, calc(100vw - 32px));
    max-block-size: min(70vh, 640px);
    padding: 0; border: var(--border-width) solid var(--border-subtle);
    border-radius: var(--radius-lg);
    background: var(--surface-raised); color: var(--text);
    overflow: hidden;
  }
  .sheet::backdrop { background: rgb(0 0 0 / 0.5); }
  .sheet-head {
    display: flex; align-items: center; justify-content: space-between;
    gap: var(--space-xs);
    padding: var(--space-xs) var(--space-sm);
    border-block-end: var(--border-width) solid var(--border-subtle);
  }
  .sheet-title { margin: 0; font-size: var(--type-size-sm); font-weight: var(--type-weight-bold); }
  .sheet-body {
    /* Its own scroll area: a long list must not scroll the editor behind it. */
    overflow: auto; overscroll-behavior: contain;
    max-block-size: calc(min(70vh, 640px) - 52px);
    padding: var(--space-sm);
    display: grid; gap: var(--space-md);
  }
  .sheet-group h3 {
    margin: 0 0 var(--space-2xs); font-size: var(--type-size-xs);
    font-weight: var(--type-weight-bold); color: var(--text-muted);
    letter-spacing: 0.02em; text-transform: uppercase;
  }
  .sheet-group dl {
    margin: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto;
    align-items: center; gap: var(--space-3xs) var(--space-sm);
  }
  .sheet-group dt { font-size: var(--type-size-sm); min-inline-size: 0; }
  .sheet-group dd { margin: 0; justify-self: end; }
  .sheet kbd {
    display: inline-block; padding: 2px var(--space-2xs);
    border: var(--border-width) solid var(--border-subtle);
    border-radius: var(--radius-xs);
    background: var(--surface-sunken);
    font-family: var(--font-mono); font-size: var(--type-size-xs);
    white-space: nowrap;
  }

  /* The brush ring.

     A white border with a dark outline, rather than one ring in one colour:
     the template is light, ink is dark, and a single-colour ring disappears
     into one of them. An outline rather than a shadow: this does not float, it
     is drawn on the work, and the design system reserves shadows for things
     that genuinely sit above the page. */
  .brush-ring {
    position: fixed; inset-block-start: 0; inset-inline-start: 0;
    border-radius: 50%;
    border: 1px solid rgb(255 255 255 / 0.95);
    outline: 1px solid rgb(0 0 0 / 0.55);
    pointer-events: none;
    z-index: 40;
    opacity: 0;
    will-change: transform;
  }
  .brush-ring[data-show] { opacity: 1; }
  /* Dashed for the eraser. A form difference, not a colour one: it survives
     colour blindness, a sunlit screen, and a glance. */
  .brush-ring[data-mode="erase"] { border-style: dashed; }
  /* No hover means no honest position for a ring. Belt and braces — the module
     already refuses to track touch pointers. */
  @media (hover: none) {
    .brush-ring { display: none; }
  }

  /* Toasts.

     Bottom centre, above the dock, so the layers panel and the tool rail both
     stay reachable while one is up. The list is the only fixed thing; each
     toast sizes to its own content, because the one rule here is that a
     message is never cut off. */
  .toasts {
    position: fixed; inset-block-end: var(--space-md); inset-inline: 0;
    margin: 0; padding-inline: var(--space-sm); list-style: none;
    display: grid; justify-items: center; align-content: end;
    gap: var(--space-2xs);
    /* The list never eats a click; each toast takes its own back. */
    pointer-events: none;
    z-index: 60;
  }

  .toast {
    pointer-events: auto;
    display: grid;
    grid-template-columns: auto minmax(0, 1fr) auto auto;
    /* start, not center: with two lines of text a centred icon floats between
       them. Aligned to the top it sits with the first line, where the eye
       enters. */
    align-items: start;
    gap: var(--space-2xs) var(--space-xs);
    inline-size: max-content;
    max-inline-size: min(440px, 100%);
    padding: var(--space-xs) var(--space-xs) var(--space-xs) var(--space-sm);
    border: var(--border-width) solid var(--border-subtle);
    /* Rounded, not a pill. A pill with three lines of text reads as a mistake,
       and the whole point is that three lines are allowed. */
    border-radius: var(--radius-lg);
    background: var(--surface-raised);
    color: var(--text);
    font-size: var(--type-size-sm);
  }

  /* Never truncate. No ellipsis, no nowrap, and anywhere-breaking so a long
     unbroken layer name wraps instead of widening the box off screen. */
  .toast-text {
    margin: 0;
    min-inline-size: 0;
    line-height: 1.45;
    white-space: normal;
    overflow-wrap: anywhere;
  }

  .toast-glyph { display: grid; place-items: center; block-size: 1.45em; color: var(--accent); }
  .toast-icon { inline-size: 18px; block-size: 18px; }

  /* Tone colours the glyph, the edge and a 12% wash of the surface — mixed in
     oklab so the tint stays the same lightness across all four hues rather
     than the yellow reading brighter than the red. Twelve percent and not
     more: a saturated card at this size fights the editor behind it. The icon
     shape carries the meaning regardless, for anyone who cannot separate the
     hues. */
  .toast[data-tone="success"] {
    border-color: var(--success);
    background: color-mix(in oklab, var(--success) 12%, var(--surface-raised));
  }
  .toast[data-tone="success"] .toast-glyph { color: var(--success); }
  .toast[data-tone="warn"] {
    border-color: var(--warning);
    background: color-mix(in oklab, var(--warning) 12%, var(--surface-raised));
  }
  .toast[data-tone="warn"] .toast-glyph { color: var(--warning); }
  .toast[data-tone="danger"] {
    border-color: var(--danger);
    background: color-mix(in oklab, var(--danger) 12%, var(--surface-raised));
  }
  .toast[data-tone="danger"] .toast-glyph { color: var(--danger); }
  .toast[data-tone="info"] {
    border-color: var(--accent);
    background: color-mix(in oklab, var(--accent) 10%, var(--surface-raised));
  }
  .toast[data-tone="info"] .toast-glyph { color: var(--accent); }

  .toast-action {
    flex: none; align-self: center;
    padding: var(--space-3xs) var(--space-2xs);
    border: 0; border-radius: var(--radius-sm);
    background: var(--control-bg); color: var(--accent);
    font: inherit; font-size: var(--type-size-sm); font-weight: var(--type-weight-bold);
    white-space: nowrap; cursor: pointer;
  }
  .toast-action:hover { background: var(--control-bg-hover); }

  .toast-close {
    flex: none; align-self: start;
    inline-size: 22px; block-size: 22px;
    display: grid; place-items: center;
    border: 0; border-radius: var(--radius-sm);
    background: none; color: var(--text-faint);
    cursor: pointer;
  }
  .toast-close .toast-icon { inline-size: 14px; block-size: 14px; }
  .toast-close:hover { color: var(--text); background: var(--control-bg-hover); }
  .toast:focus-within { outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px; }

  /* One phone rule: full width and a thumb-sized row, because a max-content
     bubble on a 360px screen is either tiny or edge to edge anyway. */
  @media (width < 640px) {
    .toast { inline-size: 100%; max-inline-size: none; }
  }
  @media (pointer: coarse) {
    .toast-close { inline-size: var(--control-height); block-size: var(--control-height); }
  }

  /* Enter and leave.

     A keyframe animation rather than a transition, because an element inserted
     into the document has no previous state to transition from — the usual
     workaround is a double requestAnimationFrame, and this is the version that
     does not need one. */
  @keyframes toast-in {
    from { opacity: 0; transform: translateY(8px) scale(0.97); }
    to { opacity: 1; transform: none; }
  }
  @keyframes toast-out {
    from { opacity: 1; transform: none; }
    to { opacity: 0; transform: translateY(4px) scale(0.98); }
  }
  @media (prefers-reduced-motion: no-preference) {
    .toast { animation: toast-in var(--motion-base) var(--motion-ease); }
    .toast[data-leaving] { animation: toast-out var(--motion-fast) var(--motion-ease) forwards; }
  }
  /* Without the animation the leaving state still has to disappear, or a
     reduced-motion user watches it sit there until the backstop timer. */
  @media (prefers-reduced-motion: reduce) {
    .toast[data-leaving] { opacity: 0; }
  }

  /* The fabric picker. Wider than the shortcuts sheet, because the swatches
     are the content and six to a row is what makes a library scannable. */
  /* Anchored left and barely dimmed, because the point of this sheet is the
     body on the right: a preview you cannot see is not a preview. The default
     centred, heavily dimmed modal would cover the one thing it exists for. */
  .fabric-sheet {
    inline-size: min(680px, calc(100vw - 32px));
    max-block-size: min(78vh, 760px);
    margin-inline-start: var(--space-md);
    margin-inline-end: auto;
  }
  .fabric-sheet::backdrop { background: rgb(0 0 0 / 0.18); }
  @media (width < 1024px) {
    /* No 3D pane below this width, so nothing to keep clear of. */
    .fabric-sheet { margin-inline: auto; }
  }
  /* The weave slider sits in the sheet head, between the title and the close,
     because it changes what every swatch below it means. */
  /* Base colour, left of the weave slider: you choose what colour the garment
     is before you choose what it is made of. */
  /* One row of controls, each labelled, sharing a rhythm: label, control,
     value. Unlabelled controls in a header read as decoration. */
  .fabric-tint { display: flex; align-items: center; gap: var(--space-2xs); margin-inline-start: auto; }
  .fabric-control-label {
    font-size: var(--type-size-xs); color: var(--text-muted);
    letter-spacing: 0.02em; white-space: nowrap;
  }
  .fabric-chip {
    inline-size: 26px; block-size: 26px; padding: 0;
    border-radius: var(--radius-full);
    border: var(--border-width) solid var(--border-strong);
    cursor: pointer;
    /* A native colour input draws its own bevelled well; stripping the chrome
       leaves the swatch, which is the only part worth showing. */
    -webkit-appearance: none; appearance: none; background: none;
  }
  .fabric-chip::-webkit-color-swatch-wrapper { padding: 0; }
  .fabric-chip::-webkit-color-swatch { border: 0; border-radius: var(--radius-full); }
  /* Empty reads as a checkerboard, the same "no colour here" the layer
     thumbnails use, rather than as a black swatch you might have chosen. */
  /* Unset must not read as a chosen colour. A native input always renders a
     swatch, so "none" is a slash across it — the convention every colour
     control uses, and unmistakable beside a chip that has been set. */
  /* A locked colour control stays visible and legible. Hiding it would remove
     the want along with the control; greying it to nothing would make the sheet
     look broken rather than gated. */
  /* Disabled so no palette can open, but the wrapper stays clickable and
     explains — a control that looks live and does nothing is indistinguishable
     from a broken one. */
  /* pointer-events: none, so the click lands on the wrapper that can explain.
     A disabled input swallows the event entirely — clicking the locked swatch
     did nothing at all, which is the exact confusion this was meant to end. */
  .fabric-chip:disabled { opacity: 0.45; pointer-events: none; }
  .fabric-tint:has(.fabric-chip:disabled) { cursor: pointer; }
  .fabric-chip[data-empty] {
    opacity: 0.5;
    background-image: linear-gradient(
      to bottom right,
      transparent calc(50% - 1px), var(--danger) calc(50% - 1px),
      var(--danger) calc(50% + 1px), transparent calc(50% + 1px)
    );
  }
  .fabric-chip-unused {
    background-color: var(--surface-sunken);
    background-image:
      linear-gradient(45deg, var(--surface-raised) 25%, transparent 25%),
      linear-gradient(-45deg, var(--surface-raised) 25%, transparent 25%),
      linear-gradient(45deg, transparent 75%, var(--surface-raised) 75%),
      linear-gradient(-45deg, transparent 75%, var(--surface-raised) 75%);
    background-size: 8px 8px;
    background-position: 0 0, 0 4px, 4px -4px, -4px 0;
  }
  .fabric-tint-lock {
    display: grid; place-items: center;
    inline-size: 18px; block-size: 18px;
    color: var(--text-muted);
  }
  .fabric-tint-clear {
    border: 0; background: none; cursor: pointer;
    padding: var(--space-3xs) var(--space-2xs);
    border-radius: var(--radius-sm);
    font: inherit; font-size: var(--type-size-xs); color: var(--text-muted);
  }
  .fabric-tint-clear:hover { color: var(--text); background: var(--control-bg-hover); }

  .fabric-scale {
    display: flex; align-items: center; gap: var(--space-xs);
    margin-inline-end: var(--space-xs);
  }
  .fabric-scale-value {
    font-size: var(--type-size-xs); color: var(--text-muted);
    font-variant-numeric: tabular-nums;
    /* Fixed width and tabular figures: the number changes on every drag, and
       a value that reflows drags the slider under the cursor with it. Wide
       enough for "256 px" so it never wraps to a second line. */
    inline-size: 6ch; text-align: end; white-space: nowrap;
  }
  .fabric-scale input[type="range"] { inline-size: 140px; accent-color: var(--accent); }
  @media (width < 640px) {
    .fabric-scale input[type="range"] { inline-size: 92px; }
  }

  .fabric-body { max-block-size: calc(min(78vh, 760px) - 52px); gap: var(--space-md); }
  .fabric-group h3 {
    margin: 0 0 var(--space-2xs); font-size: var(--type-size-xs);
    font-weight: var(--type-weight-bold); color: var(--text-muted);
    letter-spacing: 0.02em; text-transform: uppercase;
  }
  .fabric-grid {
    display: grid; gap: var(--space-xs);
    grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
  }
  .fabric-swatch {
    position: relative;
    display: grid; gap: var(--space-3xs); justify-items: stretch;
    padding: 0; border: 0; background: none; color: inherit;
    font: inherit; cursor: pointer; text-align: start;
  }
  .fabric-canvas {
    inline-size: 100%; aspect-ratio: 1; display: block;
    border-radius: var(--radius-md);
    border: var(--border-width) solid var(--border-subtle);
    /* The tile is drawn at panel scale, so the browser must not resample it
       again on the way to the screen - that would undo the honesty. */
    image-rendering: auto;
  }
  .fabric-swatch:hover .fabric-canvas { border-color: var(--accent); }
  /* What is currently on the garment. The sheet stays open so fabrics can be
     compared, which only works if it says which one you are looking at. */
  .fabric-swatch[data-applied] .fabric-canvas {
    border-color: var(--accent);
    outline: 2px solid var(--accent); outline-offset: 1px;
  }
  .fabric-swatch[data-applied] .fabric-name { color: var(--text); }
  .fabric-swatch:focus-visible { outline: none; }
  .fabric-swatch:focus-visible .fabric-canvas {
    outline: var(--focus-width) solid var(--focus-ring); outline-offset: 2px;
  }
  .fabric-name {
    font-size: var(--type-size-xs); color: var(--text-muted);
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  /* A locked swatch looks exactly as good as an unlocked one. Greyed things are
     invisible, and desire is the only thing that converts - so the lock is a
     badge, not a veil. See docs/decisions/fabric-picker-and-gating.md. */
  .fabric-lock {
    position: absolute; inset-block-start: var(--space-3xs);
    inset-inline-end: var(--space-3xs);
    display: grid; place-items: center;
    inline-size: 22px; block-size: 22px;
    border-radius: var(--radius-full);
    background: var(--surface-raised); color: var(--text);
    border: var(--border-width) solid var(--border-subtle);
  }
  @media (pointer: coarse) {
    .fabric-grid { grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); }
  }

  /* ---- the glowing call to action ---------------------------------------

     One implementation, driven by one custom property, so the loudest control
     in the app is not re-invented every time it appears:

         <button class="btn-glow" data-icon="arrow">Continue to Stripe</button>
         <button class="btn-glow" style="--glow-tint: var(--tier-studio-tint)">

     The face gradient, the cast light and the pressed state all derive from
     --glow-tint. Set that and the button follows; the numeric knobs below are
     for the rare instance that needs to be quieter or louder than the norm. */
  .btn-glow {
    --glow-tint: var(--accent);
    --glow-text: var(--btn-primary-text);
    --glow-radius: var(--radius-md);
    --glow-height: 46px;
    /* How far the light throws, and how much of it there is. */
    --glow-spread: 18px;
    --glow-strength: 42%;
    /* A threshold, not motion — which is why it is not a --motion token.
       Those say how fast a thing moves; this says how long we wait before
       admitting anything is happening at all. */
    --busy-delay: 140ms;

    position: relative;
    display: inline-flex; align-items: center; justify-content: center;
    gap: var(--space-xs);
    min-height: var(--glow-height);
    padding-inline: var(--space-lg);
    border: 0;
    border-radius: var(--glow-radius);
    /* Lit from the top, like every raised surface in the app. */
    background: linear-gradient(to bottom,
      color-mix(in oklab, var(--glow-tint) 88%, white), var(--glow-tint));
    color: var(--glow-text);
    font: inherit;
    font-size: var(--type-size-md);
    font-weight: var(--type-weight-bold);
    letter-spacing: -0.01em;
    cursor: pointer;
    box-shadow:
      0 1px 2px oklch(0 0 0 / 0.3),
      0 6px var(--glow-spread) color-mix(in oklab, var(--glow-tint) var(--glow-strength), transparent),
      inset 0 1px 0 oklch(1 0 0 / 0.22);
    transition:
      box-shadow var(--motion-fast) var(--motion-ease),
      filter var(--motion-fast) var(--motion-ease),
      transform var(--motion-fast) var(--motion-ease);
  }
  .btn-glow:hover:not(:disabled) {
    filter: brightness(1.05);
    box-shadow:
      0 1px 2px oklch(0 0 0 / 0.3),
      0 8px calc(var(--glow-spread) * 1.35) color-mix(in oklab, var(--glow-tint) var(--glow-strength), transparent),
      inset 0 1px 0 oklch(1 0 0 / 0.22);
  }
  /*
    Pressed, never dimmed.

    The dark flash this replaces was measurable rather than a matter of taste:
    opacity went 1 to 0.45 on the same tick as the click and back inside 60ms,
    so a bright button on a dark backdrop blinked near-black. A press should
    read as the surface going down and its light going with it — the one thing
    it must never read as is the control vanishing.
  */
  .btn-glow:active:not(:disabled) {
    transform: translateY(1px);
    filter: brightness(0.97);
    box-shadow:
      0 1px 1px oklch(0 0 0 / 0.3),
      0 2px 8px color-mix(in oklab, var(--glow-tint) var(--glow-strength), transparent),
      inset 0 1px 3px oklch(0 0 0 / 0.2);
  }
  .btn-glow:focus-visible {
    outline: var(--focus-width) solid var(--focus-ring);
    outline-offset: var(--focus-offset);
  }
  /* Disabled dims; busy does not. They are different states and only one of
     them means "you cannot use this". */
  .btn-glow:disabled:not([data-busy]) { opacity: var(--disabled-opacity); cursor: not-allowed; }

  .btn-glow[data-icon="arrow"]::after {
    content: "";
    inline-size: 17px; block-size: 17px;
    background: currentColor;
    mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 12h14M13 6l6 6-6 6'/%3E%3C/svg%3E") center / contain no-repeat;
    transition:
      opacity var(--motion-fast) var(--motion-ease),
      transform var(--motion-fast) var(--motion-ease);
  }

  /*
    Busy keeps its colour, and arrives late.

    actionButton() sets [data-busy] synchronously on click and clears it the
    moment the work resolves — for anything local, the very next frame. So a
    busy style that fires immediately is a flash carrying no information, which
    is exactly what the old one was.

    Two rules make this behave. It waits: below --busy-delay nothing is shown
    at all, so a fast action looks like a plain press. And it stays the button
    it was: quieter, never absent. The state this replaces went to 0.45 opacity
    and read as the control breaking rather than working.

    No spinner, deliberately. shared/visual-language.test.ts forbids looping
    animation, and the reason applies with full force here — this is an editor
    someone stares at for an hour, and a thing that rotates forever in the
    corner of it competes with the artwork it is supposed to serve.
  */
  .btn-glow[data-busy] {
    cursor: progress;
    opacity: 0.72;
    transition: opacity var(--motion-base) var(--motion-ease) var(--busy-delay);
  }
  /* The arrow steps out while the work is in flight: it points at a
     destination we have not arrived at yet. */
  .btn-glow[data-busy][data-icon]::after {
    opacity: 0;
    transition: opacity var(--motion-base) var(--motion-ease) var(--busy-delay);
  }
  @media (prefers-reduced-motion: no-preference) {
    .btn-glow[data-icon="arrow"]:hover:not(:disabled)::after { transform: translateX(2px); }
  }

  /* ---- plan identity -----------------------------------------------------

     One chip, wherever a plan is named — this prompt, the account menu, the
     billing panel, a locked control's explanation. Colour does the work that
     a repeated word otherwise has to: you learn violet means Studio once, and
     every later mention is free.

     Free is deliberately neutral. Giving it a colour of its own would make
     Creator read as a lateral move between two options rather than as a step
     up from no plan at all. */
  .tier-chip {
    --tier-tint: var(--tier-free-tint);
    --tier-wash: var(--tier-free-wash);
    --tier-edge: var(--tier-free-edge);
    display: inline-flex; align-items: center; gap: var(--space-2xs);
    padding: 3px var(--space-xs) 3px 5px;
    border: 1px solid var(--tier-edge);
    border-radius: var(--radius-full);
    background: var(--tier-wash);
    color: var(--tier-tint);
    font-size: var(--type-size-xs);
    font-weight: var(--type-weight-bold);
    line-height: 1.25;
    white-space: nowrap;
    vertical-align: middle;
  }
  .tier-chip[data-tier="creator"] {
    --tier-tint: var(--tier-creator-tint);
    --tier-wash: var(--tier-creator-wash);
    --tier-edge: var(--tier-creator-edge);
  }
  .tier-chip[data-tier="studio"] {
    --tier-tint: var(--tier-studio-tint);
    --tier-wash: var(--tier-studio-wash);
    --tier-edge: var(--tier-studio-edge);
  }
  .tier-chip .icon { inline-size: 13px; block-size: 13px; }

  /* Solid, for the one place per screen where a plan is the subject rather
     than a passing reference. Two weights is the whole scale — a third would
     mean deciding between them every time. */
  .tier-chip--solid {
    padding: 5px var(--space-sm) 5px 8px;
    border-color: transparent;
    background: linear-gradient(135deg,
      color-mix(in oklab, var(--tier-tint) 76%, white), var(--tier-tint));
    color: var(--accent-contrast);
    font-size: var(--type-size-sm);
    box-shadow: 0 2px 10px color-mix(in oklab, var(--tier-tint) 30%, transparent);
  }
  .tier-chip--solid .icon { inline-size: 15px; block-size: 15px; }

  /* The plan prompt.

     Shares the shape of the shortcuts sheet — same radius, same head, same
     dialog primitive — because a modal that looks like nothing else in the app
     reads as an advert rather than as part of the product. The plan content
     inside is the hud-upgrade panel, unchanged, so there is one pricing surface with
     two frames rather than two pricing surfaces. */
  .upsell {
    /* Wide enough for the panel it hosts. The plan panel caps itself at 34rem
       and its interval grid asks for two 16rem columns, so a narrower dialog
       does not make it reflow — it makes it overflow, and the prices and the
       reassurance were being clipped off the right edge. Sized to fit the
       content rather than asking the content to be smaller than it is. */
    inline-size: min(600px, calc(100vw - 32px));
    max-block-size: min(86vh, 760px);
    padding: 0;
    /* Literal 1px and a token that exists. --border-width and --border-subtle
       are referenced 20 times across this file and site.css and defined in
       neither, so every one of those declarations is dropped as invalid — this
       dialog has been running with no edge at all, which is a good part of why
       it read as flat. Filed separately; fixed here. */
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-lg);
    color: var(--text);
    overflow: hidden auto;
    overscroll-behavior: contain;

    /*
      One elevation ramp for the whole prompt.

      The flatness had a measurable cause rather than a stylistic one:
      --surface-raised and --hud-bg both resolve to --neutral-950, so the
      dialog and the plan card inside it were the same colour, and the only
      thing dividing them was a hairline. Three nested rounded rectangles in
      one fill reads as a screenshot of a box, not as a thing floating above
      the editor.

      So elevation is derived here, once, by mixing toward --text: lighter in
      dark mode, a touch greyer in light mode, correct in both without a
      hardcoded colour. Everything below picks a step off this ramp instead of
      inventing its own.
    */
    --lift-1: color-mix(in oklab, var(--surface-raised) 94%, var(--text));
    --lift-2: color-mix(in oklab, var(--surface-raised) 88%, var(--text));
    --wash: color-mix(in oklab, var(--surface-raised) 86%, var(--accent));
    --wash-edge: color-mix(in oklab, var(--border-strong) 45%, var(--accent));

    /* Top-lit, the way a raised surface is. Two stops, three percent apart —
       enough to read as a lit face, not enough to read as a gradient. */
    background:
      linear-gradient(to bottom, var(--lift-1), var(--surface-raised) 190px);
    /* Cast, then a hairline highlight along the top edge. The highlight is
       what actually sells the lift; the shadow alone just darkens the page. */
    box-shadow:
      0 24px 60px oklch(0 0 0 / 0.45),
      0 4px 12px oklch(0 0 0 / 0.28),
      inset 0 1px 0 oklch(1 0 0 / 0.07);
  }
  /* Darker than the old 0.5, and blurred: the editor behind this is busy with
     colour, and a prompt about money should not have a plaid shirt competing
     with its price. */
  .upsell::backdrop {
    background: oklch(0 0 0 / 0.62);
    backdrop-filter: blur(3px);
  }

  /* Enters rather than appears. A prompt that materialises reads as an
     interception; one that rises reads as an answer. Keyframes, not a
     transition, because an element inserted into the document has no previous
     state to transition from. */
  @keyframes upsell-in {
    from { opacity: 0; transform: translateY(10px) scale(0.985); }
    to { opacity: 1; transform: none; }
  }
  @keyframes upsell-backdrop-in { from { opacity: 0; } to { opacity: 1; } }
  @media (prefers-reduced-motion: no-preference) {
    .upsell[open] { animation: upsell-in var(--motion-base) var(--motion-ease); }
    .upsell[open]::backdrop { animation: upsell-backdrop-in var(--motion-base) var(--motion-ease); }
  }

  .upsell__head {
    display: flex; align-items: start; justify-content: space-between;
    gap: var(--space-sm);
    padding: var(--space-lg) var(--space-lg) var(--space-md);
  }
  /* Badge and words as one unit, so the lock is part of the sentence rather
     than an ornament parked next to it. */
  .upsell__title {
    display: flex; align-items: center; gap: var(--space-sm);
    min-inline-size: 0;
  }
  /*
    The lock, in a tinted tile.

    It earns its place by saying the one thing the heading cannot say in its
    own words without sounding like an advert: this is shut, and there is a
    key. It is also the same glyph as the padlock on the fabric swatch they
    just clicked, so the prompt is visibly the answer to that click.
  */
  .upsell__badge {
    flex: none;
    display: grid; place-items: center;
    inline-size: 36px; block-size: 36px;
    border: 1px solid var(--wash-edge);
    border-radius: var(--radius-md);
    background: var(--wash);
    color: var(--accent);
  }
  .upsell__badge .icon { inline-size: 18px; block-size: 18px; }
  /* The reason leads, and it is the largest thing here. Someone reading this
     did not come to shop — they reached for one specific thing, and naming it
     is the difference between an answer and an advert. */
  .upsell__reason {
    margin: 0;
    font-size: var(--type-size-lg);
    font-weight: var(--type-weight-bold);
    line-height: 1.3;
    letter-spacing: -0.01em;
    text-wrap: balance;
  }
  .upsell__close { flex: none; }
  /* min-inline-size: 0 so a stubborn child can never force the dialog to
     scroll sideways — a horizontal scrollbar on a payment prompt reads as
     broken, and the thing it hides is always the price. */
  .upsell__body { padding: 0 var(--space-lg) var(--space-lg); min-inline-size: 0; }

  /* ---- the plan panel, when it is inside the prompt ----------------------

     Scoped to .upsell throughout, so the standalone pricing page keeps the
     layout it was designed with. The panel is shared; only its setting
     changes. In a page it is a card on a background and needs its own frame.
     In here it is the entire content of a dialog that already has one — so
     the frame comes off and the dialog does that job alone. */
  .upsell .hud-upgrade {
    padding: 0;
    border: 0;
    border-radius: 0;
    background: none;
    box-shadow: none;
    /* Grouped rather than evenly spaced. Uniform --space-md between every
       block is why it read as a list of unrelated paragraphs: heading and
       tagline belong together, the price pair belongs with the button. */
    gap: var(--space-md);
  }
  /* The heading is a chip here, not a word. The dialog's own sentence already
     named the plan in prose, so repeating "Creator" as a second heading 40px
     below was two frames doing one job — as a coloured chip it stops being a
     repetition and starts being the plan's identity. */
  .upsell .hud-upgrade__heading { margin: 0; }
  .upsell .hud-upgrade__tagline {
    margin: calc(-1 * var(--space-xs)) 0 0;
    font-size: var(--type-size-md);
    color: var(--text);
  }

  /* Icons in tiles, in the accent. Muted glyphs the same colour as the text
     beside them do not read as icons — they read as specks, and three of them
     in a column read as dust on the screen. */
  .upsell .hud-upgrade__benefits { gap: var(--space-sm); font-size: var(--type-size-sm); }
  .upsell .hud-upgrade__benefits li {
    grid-template-columns: 30px 1fr;
    gap: var(--space-sm);
    align-items: center;
  }
  .upsell .hud-upgrade__benefits .icon {
    inline-size: 30px; block-size: 30px;
    margin: 0;
    padding: 7px;
    border-radius: var(--radius-sm);
    background: var(--wash);
    color: var(--accent);
  }

  /* Fill the dialog rather than stopping 8px short of it — a 544px block in a
     552px space looks like a mistake, not a measure. */
  .upsell .hud-upgrade__intervals { max-inline-size: none; gap: var(--space-sm); }

  /*
    Cards that look pressable, and a selection you can see at a glance.

    They were transparent with a hairline, which on an identically-coloured
    background is indistinguishable from a caption in a box. Both options get
    the same fill and the same weight — the rule that a toggle must not have a
    discouraged side still holds — and the chosen one is marked by an accent
    wash, an accent edge and a tick, which is three redundant signals for the
    same fact rather than one hue that colour-blind readers may not catch.
  */
  .upsell .hud-upgrade__interval {
    position: relative;
    padding: var(--space-md);
    border-color: var(--border-strong);
    border-radius: var(--radius-md);
    background: var(--lift-1);
    transition:
      background var(--motion-fast) var(--motion-ease),
      border-color var(--motion-fast) var(--motion-ease),
      box-shadow var(--motion-fast) var(--motion-ease),
      transform var(--motion-fast) var(--motion-ease);
  }
  .upsell .hud-upgrade__interval:hover { background: var(--lift-2); }
  .upsell .hud-upgrade__interval[data-selected="true"] {
    border-color: var(--wash-edge);
    background: var(--wash);
    box-shadow: 0 0 0 1px var(--wash-edge);
  }
  /* The tick. Absolute so it cannot reflow the price when selection moves. */
  .upsell .hud-upgrade__interval[data-selected="true"]::after {
    content: "";
    position: absolute; inset-block-start: 10px; inset-inline-end: 10px;
    inline-size: 15px; block-size: 15px;
    background: var(--accent);
    mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E") center / contain no-repeat;
  }
  .upsell .hud-upgrade__interval-label {
    font-size: var(--type-size-lg);
    font-weight: var(--type-weight-bold);
    letter-spacing: -0.015em;
  }
  /* Room for the tick, so a long saving line never runs under it. */
  .upsell .hud-upgrade__interval-detail { padding-inline-end: 18px; }

  /*
    The action row, laid out rather than wrapped.

    It was a flex row of three, and "Not now" had been pushed onto a line of
    its own by the wrap — which is why it looked stranded rather than offered.
    An explicit grid instead: commitment on the left, the way out on the
    right, and the amount spanning underneath, tight to the button it belongs
    to. The amount stays the same size it always was; what changed is that it
    now sits with the button rather than in the gap beside it.
  */
  .upsell .hud-upgrade__actions {
    display: grid;
    grid-template-columns: auto 1fr;
    gap: var(--space-xs) var(--space-sm);
    align-items: center;
    margin-block-start: var(--space-2xs);
  }
  .upsell .hud-upgrade__go { grid-area: 1 / 1; }
  .upsell .hud-upgrade__cancel { grid-area: 1 / 2; justify-self: end; }
  .upsell .hud-upgrade__charge { grid-area: 2 / 1 / 3 / 3; }

  /* The call to action is .btn-glow now — see above. It was written here
     first, then lifted out wholesale the moment a second place wanted it,
     which is the only honest time to generalise something. */

  /* A real button, not a link that lost its underline. Bordered so it reads as
     the second of two choices — an escape with no shape at all is the pattern
     people mean when they say a prompt felt like a trap. */
  .upsell .hud-upgrade__cancel {
    min-height: 46px;
    padding-inline: var(--space-md);
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-md);
    color: var(--text-muted);
    font-weight: var(--type-weight-medium);
    transition:
      background var(--motion-fast) var(--motion-ease),
      color var(--motion-fast) var(--motion-ease);
  }
  .upsell .hud-upgrade__cancel:hover { background: var(--lift-2); color: var(--text); }

  .upsell .hud-upgrade__charge { color: var(--text-muted); }

  /* The reassurance, as a footer rather than a fourth paragraph. A rule above
     it and a padlock beside it: it is the answer to "is this safe", and it
     should look like a footnote to the transaction, not like more selling. */
  .upsell .hud-upgrade__note {
    display: grid;
    grid-template-columns: 14px 1fr;
    gap: var(--space-xs);
    align-items: start;
    margin-block-start: var(--space-2xs);
    padding-block-start: var(--space-md);
    border-block-start: 1px solid var(--border);
    font-size: var(--type-size-xs);
    line-height: 1.5;
    text-wrap: pretty;
  }
  .upsell .hud-upgrade__note::before {
    content: "";
    inline-size: 14px; block-size: 14px;
    margin-block-start: 0.1em;
    background: currentColor;
    mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='10' width='16' height='11' rx='2'/%3E%3Cpath d='M8 10V7a4 4 0 0 1 8 0v3'/%3E%3C/svg%3E") center / contain no-repeat;
  }

  /* The offer sits below the decision, never above it: an inducement placed
     before the price reads as a distraction from the price. */
  .upsell__offer {
    display: flex; align-items: center; justify-content: space-between;
    gap: var(--space-sm);
    margin: 0 var(--space-sm) var(--space-sm);
    padding: var(--space-xs) var(--space-sm);
    border: 1px dashed var(--border-strong);
    border-radius: var(--radius-md);
  }
  .upsell__offer-text { min-inline-size: 0; }
  .upsell__offer-headline {
    margin: 0; font-size: var(--type-size-sm); font-weight: var(--type-weight-bold);
  }
  .upsell__offer-detail {
    margin: 2px 0 0; font-size: var(--type-size-xs); color: var(--text-muted);
    text-wrap: pretty;
  }
  .upsell__offer-action {
    flex: none;
    padding: var(--space-2xs) var(--space-sm);
    border: 1px solid var(--border-strong);
    border-radius: var(--radius-full);
    background: none; color: var(--text);
    font: inherit; font-size: var(--type-size-sm); font-weight: var(--type-weight-bold);
    cursor: pointer; white-space: nowrap;
  }
  .upsell__offer-action:hover { background: var(--control-bg-hover); }

  @media (width < 520px) {
    /* Full width and bottom-anchored on a phone, which is where a thumb is. */
    .upsell { inline-size: 100%; max-inline-size: none; margin-block-end: 0; border-radius: var(--radius-lg) var(--radius-lg) 0 0; }
    .upsell__offer { flex-direction: column; align-items: stretch; }
    .upsell__head { padding: var(--space-md) var(--space-md) var(--space-sm); }
    .upsell__body { padding: 0 var(--space-md) var(--space-md); }
    .upsell__reason { font-size: var(--type-size-md); }
    /* Both buttons full width and stacked, because a 46px target beside
       another 46px target on a 360px screen leaves neither of them room. The
       commitment stays on top: the way out should be reachable, not first. */
    .upsell .hud-upgrade__actions { grid-template-columns: 1fr; }
    .upsell .hud-upgrade__go { grid-area: auto; justify-content: center; }
    .upsell .hud-upgrade__cancel { grid-area: auto; justify-self: stretch; }
    .upsell .hud-upgrade__charge { grid-area: auto; text-align: center; }
  }

  /* ---- the colour dot on a fabric layer ---------------------------------

     Present only on rows that are still made of a fabric, so a list of hand
     drawn layers never grows a fourth control. Sized to the row's other
     targets rather than to the swatch it contains: it is a button first. */
  .layer-tint {
    flex: none;
    display: grid; place-items: center;
    inline-size: var(--control-height-sm);
    block-size: var(--control-height-sm);
    padding: 0;
    border: 0;
    border-radius: var(--radius-sm);
    background: none;
    cursor: pointer;
    transition: background var(--motion-fast) var(--motion-ease);
  }
  .layer-tint[hidden] { display: none; }
  .layer-tint:hover { background: var(--control-bg-hover); }
  .layer-tint:focus-visible {
    outline: var(--focus-width) solid var(--focus-ring);
    outline-offset: calc(-1 * var(--focus-width));
  }
  /* The swatch itself. A ring rather than a border so the colour reaches the
     edge — a 1px border on a 14px dot eats a fifth of the colour you are
     trying to judge. */
  .layer-tint::after {
    content: "";
    inline-size: 14px; block-size: 14px;
    border-radius: var(--radius-full);
    background: var(--dot, transparent);
    box-shadow: 0 0 0 1px var(--swatch-ring);
    transition: transform var(--motion-fast) var(--motion-ease);
  }
  @media (prefers-reduced-motion: no-preference) {
    .layer-tint:hover::after { transform: scale(1.12); }
  }
  /*
    "As it comes" — no tint chosen.

    A checkerboard rather than an empty circle or a slash, because that is
    already what this app draws wherever there is no colour: the swatches, the
    canvas backdrop, the picker's own clear control. A fourth invention for the
    same idea is how a visual language stops being one.
  */
  .layer-tint[data-bare="true"]::after {
    background:
      linear-gradient(45deg, var(--canvas-checker-b) 25%, transparent 25% 75%, var(--canvas-checker-b) 75%),
      linear-gradient(45deg, var(--canvas-checker-b) 25%, transparent 25% 75%, var(--canvas-checker-b) 75%),
      var(--canvas-checker-a);
    background-size: 8px 8px;
    background-position: 0 0, 4px 4px;
  }

  /* ---- the active tool carries its own settings -------------------------

     A corner caret on the selected tool, and nothing on the others. It says
     there is a second thing behind this button without adding a second button:
     the settings icon that used to sit beside the brush was a rail item that
     looked like a tool and was not one.

     Drawn only when pressed, so a rail of five tools does not become a rail of
     five carets — the affordance is for the one you can actually act on. */
  .rail .icon-btn[data-tool] { position: relative; }
  .rail .icon-btn[data-tool][aria-pressed="true"]::after {
    content: "";
    position: absolute;
    inset-block-end: 3px;
    inset-inline-end: 3px;
    inline-size: 0;
    block-size: 0;
    /* A triangle from borders: no glyph to load, no mask, scales with nothing. */
    border-block-end: 4px solid currentColor;
    border-inline-start: 4px solid transparent;
    opacity: 0.55;
  }
  .rail .icon-btn[data-tool][aria-pressed="true"]:hover::after { opacity: 1; }

  /* A control that is present but inert, with the reason in its title. Used by
     Flow at full hardness, where there are no stamps to accumulate. */
  .opts-row[data-inert="true"] { opacity: var(--disabled-opacity); }
  .opts-row[data-inert="true"] input { cursor: not-allowed; }

  .visually-hidden {
    position: absolute; inline-size: 1px; block-size: 1px;
    margin: -1px; padding: 0; border: 0; overflow: hidden;
    clip-path: inset(50%); white-space: nowrap;
  }

  .layer-list { display: grid; align-content: start; position: relative; }
  .layer-empty {
    margin: 0; padding: var(--space-sm) 0;
    font-size: var(--type-size-sm); color: var(--text-muted);
  }

  .layer-row {
    display: grid;
    grid-template-columns: auto auto minmax(0, 1fr) auto auto;
    align-items: center; gap: var(--space-3xs);
    padding-block: var(--space-3xs);
    border-radius: var(--radius-sm);
  }
  .layer-row.is-active { background: var(--surface-sunken); }
  /* The dragged row stays legible rather than becoming a ghost: it is the
     thing being aimed, and at 0.4 on a dark panel its name was unreadable. */
  .layer-row.is-dragging { opacity: 0.55; }
  /* Nothing on the row should invite a second gesture mid-drag. */
  .layer-row.is-dragging * { pointer-events: none; }
  .layer-row:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: -2px; }
  /* A hidden layer reads as switched off without becoming unreadable — it is
     the row you have to find in order to switch it back on. */
  .layer-row.is-hidden .layer-thumb, .layer-row.is-hidden .layer-name { opacity: 0.45; }

  /* The drop indicator sits between rows, spanning the list.

     It used to be an inset box-shadow on the hovered row, which inherited that
     row's border radius and read as a cut-off outline of the layer rather than
     a gap between two. A gap is the thing being communicated, so it is drawn in
     the gap. */
  .layer-drop {
    position: absolute; inset-inline: 0;
    block-size: 2px; margin-block-start: -1px;
    background: var(--accent); border-radius: 1px;
    pointer-events: none; opacity: 0;
  }
  .layer-drop[data-show] { opacity: 1; }
  /* The line glides between gaps rather than teleporting, which is what makes
     a drag read as continuous. Only the shown state animates: fading in from
     the previous gap would draw a line across rows on the way. */
  @media (prefers-reduced-motion: no-preference) {
    .layer-drop[data-show] { transition: top var(--motion-fast) var(--motion-ease); }
  }
  /* The cap marks the leading edge, the way Figma and Finder do — without it a
     2px line reads as a rendering artefact on a dark panel. */
  .layer-drop[data-show]::before {
    content: ""; position: absolute; inset-inline-start: 0;
    inset-block-start: -3px;
    inline-size: 8px; block-size: 8px; border-radius: 50%;
    background: var(--accent);
  }

  /* Checkerboard, because most of a clothing layer is transparent and a
     thumbnail on a flat background would be a blank square. */
  .layer-thumb {
    inline-size: 32px; block-size: 32px;
    display: grid; place-items: center;
    border-radius: var(--radius-xs);
    border: var(--border-width) solid var(--border-subtle);
    background-color: var(--surface-raised);
    background-image:
      linear-gradient(45deg, var(--surface-sunken) 25%, transparent 25%),
      linear-gradient(-45deg, var(--surface-sunken) 25%, transparent 25%),
      linear-gradient(45deg, transparent 75%, var(--surface-sunken) 75%),
      linear-gradient(-45deg, transparent 75%, var(--surface-sunken) 75%);
    background-size: 8px 8px;
    background-position: 0 0, 0 4px, 4px -4px, -4px 0;
    overflow: hidden;
  }
  .layer-thumb canvas { inline-size: 32px; block-size: 32px; display: block; }

  .layer-rename {
    inline-size: 100%; min-inline-size: 0;
    padding: 1px var(--space-3xs);
    font: inherit; font-size: var(--type-size-sm);
    color: var(--text); background: var(--surface);
    border: var(--border-width) solid var(--accent);
    border-radius: var(--radius-xs);
  }

  /* Drag from the grip only. Dragging from anywhere on the row steals the
     scroll gesture from a list that scrolls. */
  .layer-grip {
    display: grid; place-items: center;
    inline-size: 20px; color: var(--text-faint);
    cursor: grab; touch-action: none;
  }
  .layer-row.is-dragging .layer-grip { cursor: grabbing; }

  .layer-main {
    display: grid; gap: 1px; justify-items: start; text-align: start;
    background: none; border: 0; padding: var(--space-3xs) var(--space-2xs);
    font: inherit; color: inherit; cursor: pointer; min-inline-size: 0;
    inline-size: 100%;
  }
  .layer-name {
    font-size: var(--type-size-sm);
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
    max-inline-size: 100%;
  }
  .layer-where {
    font-size: var(--type-size-xs); color: var(--text-muted);
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
    max-inline-size: 100%;
  }
  /* A hidden layer still has to be readable - it is how you find it to unhide. */
  .layer-eye[aria-pressed="true"] { color: var(--text-faint); }

  /* Three visible targets per row, per docs/decisions/layers-plan.md. Anything
     rarer lives behind the menu. */
  @media (pointer: coarse) {
    .layer-row { min-block-size: var(--control-height-lg); }
    .layer-grip { inline-size: var(--control-height-lg); }
  }

  .menu {
    inline-size: max-content; min-inline-size: 180px;
    max-inline-size: min(260px, calc(100vw - 16px));
    padding: var(--space-3xs);
  }
  .menu[data-open] { gap: 0; }
  .menu-item {
    display: grid; justify-items: start; gap: 1px;
    inline-size: 100%; text-align: start;
    padding: var(--space-2xs) var(--space-xs);
    background: none; border: 0; border-radius: var(--radius-sm);
    font: inherit; font-size: var(--type-size-sm); color: var(--text);
    cursor: pointer;
  }
  .menu-item:hover:not(:disabled) { background: var(--control-bg-hover); }
  .menu-item:focus-visible { outline: var(--focus-width) solid var(--focus-ring); outline-offset: -2px; }
  .menu-item:disabled { color: var(--text-faint); cursor: default; }
  .menu-item[data-danger]:not(:disabled) { color: var(--danger); }
  /* A greyed item that does not say why is a dead end. */
  .menu-why { font-size: var(--type-size-xs); color: var(--text-faint); }
  @media (pointer: coarse) {
    .menu-item { min-block-size: var(--control-height-lg); align-content: center; }
  }

  /* A narrow dock drops the trailing control — the pane's own width decides,
     not the viewport's, because the divider moves independently of it. */
  @container (width < 260px) {
    /* The grip is the one to drop: reordering survives in the ⋯ menu, whereas
       the eye and the menu itself have no equivalent anywhere else. */
    .layer-row { grid-template-columns: minmax(0, 1fr) auto auto; }
    .layer-grip, .layer-thumb { display: none; }
  }

  .prop-row {
    display: grid; grid-template-columns: minmax(0, 84px) minmax(0, 1fr);
    align-items: center; gap: var(--space-xs); padding-block: var(--space-3xs);
    font-size: var(--type-size-sm);
  }
  .prop-row label { color: var(--text-muted); }

  /* ------------------------------------------------------------------ source */

  .source-hint {
    margin: 0; padding-block: var(--space-3xs);
    font-size: var(--type-size-sm); color: var(--text-muted);
    line-height: var(--type-leading-normal);
  }
  /* A button that reads as a link. Keeping it a <button> matters — it performs
     an action rather than navigating, and screen readers should say so. */
  .linkish {
    background: none; border: 0; padding: 0; font: inherit; cursor: pointer;
    color: var(--accent); text-decoration: underline; text-underline-offset: 2px;
  }
  .linkish:hover { color: var(--accent-hover); }

  .source-report { margin-block-start: var(--space-sm); font-size: var(--type-size-sm); }
  .source-line {
    display: grid; grid-template-columns: minmax(0, 84px) minmax(0, 1fr);
    align-items: baseline; gap: var(--space-xs); padding-block: var(--space-3xs);
  }
  .source-line dt { color: var(--text-muted); }
  .source-line dd { margin: 0; overflow-wrap: anywhere; }
  .source-note {
    margin: var(--space-xs) 0 0; padding: var(--space-xs) var(--space-sm);
    border-radius: var(--radius-md);
    background: var(--surface-sunken); color: var(--text-muted);
    line-height: var(--type-leading-normal);
  }
  .source-note[data-warn] {
    color: var(--text);
    background: color-mix(in oklab, var(--warning) 16%, var(--surface-sunken));
  }
  .live-dot {
    display: inline-block; inline-size: 7px; block-size: 7px; border-radius: 50%;
    background: var(--success); margin-inline-end: var(--space-2xs);
  }

  /* ------------------------------------------------------------ homepage */

  /* An anchor styled as a button still inherits the site's link underline,
     which reads as a mis-styled link. The role here is the button. */
  a.btn { text-decoration: none; }
  .btn-lg { min-height: 48px; padding-inline: var(--space-lg); font-size: var(--type-size-md); }

  .home { max-inline-size: 1100px; margin-inline: auto; padding: var(--space-xl) var(--space-lg); }
  .home section + section { margin-block-start: var(--space-2xl); }

  /* One decision above the fold. Text first in the source so it is the LCP
     candidate and reads first on a phone, where the columns collapse. */
  /* One column: the playground below is the hero's picture, and it is wider
     than any half-column could be.

     Two rhythms, not one. The words are a tight block; the gap that matters is
     the single break between them and the thing you can touch. Three equal gaps
     read as three unrelated sections. */
  .hero { display: grid; gap: var(--space-xl); }
  .hero__text { display: grid; gap: var(--space-sm); text-align: center; justify-items: center; }
  .hero .lede {
    margin: 0;
    /* Capped and balanced: centred text at full width kept dropping a single
       word onto its own line. */
    max-inline-size: 46ch;
    text-wrap: balance;
  }
  .hero__cta { justify-content: center; margin: 0; }
  .hero h1 {
    margin: 0 0 var(--space-sm);
    font-size: clamp(2rem, 1.2rem + 3.2vw, 3.25rem);
    line-height: 1.08; letter-spacing: -0.02em;
  }
  .hero .lede { margin: 0 0 var(--space-lg); max-inline-size: 46ch; }
  .hero__cta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-md); margin: 0; }
  .hero__note { font-size: var(--type-size-sm); color: var(--text-muted); }

  /* Facts, set as a line rather than three cards — the numbers are the point,
     and boxing them adds furniture without adding meaning. */
  .strip {
    display: flex; flex-wrap: wrap; gap: var(--space-lg);
    padding-block: var(--space-lg);
    border-block: 1px solid var(--border);
  }
  .strip p { margin: 0; color: var(--text-muted); font-size: var(--type-size-sm); }
  .strip b { display: block; color: var(--text); font-size: var(--type-size-lg); font-weight: 600; }

  .band { text-align: center; }
  .band h2 { margin: 0 0 var(--space-sm); font-size: clamp(1.5rem, 1rem + 2vw, 2.25rem); }
  .band__lede { margin: 0 auto; max-inline-size: 60ch; color: var(--text-muted); }
  .band__slot { margin-block-start: var(--space-lg); }
  .band__soon {
    display: flex; align-items: center; justify-content: center; gap: var(--space-xs);
    min-block-size: 220px; margin: 0;
    border: 1px dashed var(--border-strong); border-radius: var(--radius-lg);
    color: var(--text-faint);
  }

  .feats { display: grid; gap: var(--space-xl); }
  @media (width >= 48rem) { .feats { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
  .feat h3 { margin: var(--space-md) 0 var(--space-2xs); font-size: var(--type-size-lg); }
  .feat p { margin: 0; color: var(--text-muted); }

  /* Fixed aspect, so swapping a placeholder for a real capture — or a late
     image load — never shifts the page. CLS is a Core Web Vital. */
  .shot { margin: 0; }
  .shot__ph {
    display: grid; place-content: center; justify-items: center; gap: var(--space-2xs);
    aspect-ratio: 16 / 10;
    border: 1px dashed var(--border-strong); border-radius: var(--radius-lg);
    background: var(--surface-sunken);
    color: var(--text-faint); text-align: center; padding: var(--space-md);
  }
  .shot--wide .shot__ph { aspect-ratio: 8 / 5; }
  .shot__icon { inline-size: 28px; block-size: 28px; stroke: currentColor; fill: none; }
  .shot__ph small { font-size: var(--type-size-xs); text-transform: uppercase; letter-spacing: 0.08em; }

  .faq { max-inline-size: 68ch; }
  .faq dt { margin-block-start: var(--space-lg); font-weight: 600; }
  .faq dd { margin: var(--space-2xs) 0 0; color: var(--text-muted); line-height: var(--type-leading-normal); }

  .close { text-align: center; padding-block: var(--space-xl); }

  /* --------------------------------------------------------- seam slider */

  .seam { max-inline-size: 34rem; margin-inline: auto; }
  .seam__stage {
    position: relative;
    aspect-ratio: 585 / 559;
    border: 1px solid var(--border); border-radius: var(--radius-lg);
    background: var(--surface-sunken);
    overflow: hidden;
  }
  .seam[data-state="empty"] .seam__stage,
  .seam[data-state="loading"] .seam__stage { opacity: 0.5; }
  /* No wipe line over an empty stage — it would imply there is something to
     drag apart. */
  .seam[data-state="empty"] .seam__stage::after { display: none; }
  .seam__handle:disabled { cursor: default; }

  /* Both halves are the same image at the same scale and origin. Anything that
     scaled them independently would misalign the two sides of the wipe. */
  .seam__img {
    position: absolute; inset: 0;
    inline-size: 100%; block-size: 100%;
    object-fit: contain;
    image-rendering: pixelated;
    transform: scale(8);
    transform-origin: var(--seam-x) var(--seam-y);
  }
  .seam__after { position: absolute; inset: 0; }

  /* The handle is a real range input, stretched over the stage and made
     invisible: draggable, arrow-key operable and announced, none of which a div
     with a pointerdown listener gets for free. */
  .seam__handle {
    position: absolute; inset: 0;
    inline-size: 100%; block-size: 100%;
    margin: 0; padding: 0;
    appearance: none; background: transparent; cursor: ew-resize;
  }
  .seam__handle::-webkit-slider-thumb { appearance: none; inline-size: 44px; block-size: 100%; }
  .seam__handle::-moz-range-thumb { border: 0; inline-size: 44px; block-size: 100%; opacity: 0; }
  .seam__handle:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }

  /* The visible seam of the wipe, drawn on the stage rather than the input so
     it survives the browser's own slider styling. */
  .seam__stage::after {
    content: ""; position: absolute; inset-block: 0;
    inset-inline-start: var(--split, 50%);
    inline-size: 2px; background: oklch(1 0 0 / 0.9);
    pointer-events: none;
  }

  .seam__status {
    margin: var(--space-sm) 0 0; text-align: center; min-block-size: 1.5em;
    font-size: var(--type-size-sm); color: var(--text-muted);
  }

  /* ----------------------------------------------------------- play bar */

  /* The bar, the message and the panes are one object. Grouped so the controls
     read as belonging to the thing they drive rather than floating above it. */
  .play { display: grid; gap: var(--space-md); }

  .playbar {
    display: flex; flex-wrap: wrap; align-items: center; justify-content: center;
    gap: var(--space-md);
    margin: 0;
    padding-block-end: var(--space-md);
    /* Ties the controls to the panes below rather than leaving them adrift. */
    border-block-end: 1px solid var(--border);
  }

  .seg { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; }
  .seg__btn {
    padding: var(--space-2xs) var(--space-md);
    border: 0; background: transparent; color: var(--text-muted);
    font: inherit; font-size: var(--type-size-sm); cursor: pointer;
  }
  .seg__btn[aria-pressed="true"] { background: var(--text); color: var(--surface); }
  .seg__btn:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -2px; }

  /* A label wrapping a hidden input: the whole area is a drop target and a
     click target, and the input is what makes it work on a phone. */
  .drop {
    display: grid; justify-items: center; gap: var(--space-3xs);
    padding: var(--space-sm) var(--space-lg);
    border: 1px dashed var(--border-strong); border-radius: var(--radius-md);
    cursor: pointer; text-align: center;
  }
  .drop:hover, .drop[data-over] { border-color: var(--accent); }
  /* The input is visually hidden but focusable, so the ring has to appear on
     the label the user can actually see. */
  .drop:has(:focus-visible) { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
  .drop__cue { font-size: var(--type-size-sm); }
  .drop__note { font-size: var(--type-size-xs); color: var(--text-faint); }

  .playbar__msg {
    margin: 0 auto var(--space-sm); text-align: center;
    font-size: var(--type-size-sm); color: var(--text-muted);
  }
  .playbar__msg[hidden] { display: none; }
  .playbar__msg[data-tone="warn"] { color: var(--text); }

  /* ----------------------------------------------------------- try band */

  /*
   * Stacked by default; paired only once both halves can be operated at once.
   * A 3D pane squeezed beside a template on a phone is two things you cannot
   * use instead of one you can.
   *
   * align-items: start so the shorter column does not stretch — the template
   * is 585x559 and the body box is square, and stretching either distorts the
   * comparison the band exists to make.
   */
  .try { display: grid; gap: var(--space-lg); align-items: start; }

  /*
   * The slots reserve their own height, in the static HTML.
   *
   * Both panes reserve an aspect box *inside* themselves — but those panes are
   * built by JavaScript, so until it runs the slots are zero-height and
   * everything below the hero sits too high, then jumps. Measured: a single
   * 0.15 layout shift attributed to SECTION.feats, which is well past the 0.1
   * that counts as good.
   *
   * The reservation has to live where the markup is, not where the component
   * is. 585/559 is the template's aspect and both panes use it.
   */
  .try > * { aspect-ratio: 585 / 559; }
  /* Once filled, the child governs its own height — the panes carry a readout
     and a status line under the box, which the bare ratio would clip. */
  .try > *:has(*) { aspect-ratio: auto; }
  /*
   * Capped and centred while stacked. Uncapped, a 768px tablet gave each pane
   * 720px and the band became two full screens of scrolling to compare two
   * things that are supposed to be seen together. The cap is above the width
   * either column gets in the paired layout, so that one is untouched.
   */
  .try > * { max-inline-size: 34rem; margin-inline: auto; inline-size: 100%; }
  @media (width >= 60rem) {
    .try { grid-template-columns: repeat(2, minmax(0, 1fr)); }
    .try > * { max-inline-size: none; }
  }

  .bpane { display: grid; gap: var(--space-sm); }
  /* Reserved from first paint, and matched to the map's aspect so the two
     columns line up rather than one hanging below the other. */
  .bpane__stage {
    position: relative;
    aspect-ratio: 585 / 559;
    border: 1px solid var(--border); border-radius: var(--radius-lg);
    background: var(--surface-sunken);
    overflow: hidden;
  }
  .bpane__canvas { position: absolute; inset: 0; }

  .bpane__facade {
    position: absolute; inset: 0;
    display: grid; place-content: center; justify-items: center; gap: var(--space-3xs);
    border: 0; background: transparent; color: var(--text);
    font: inherit; cursor: pointer;
  }
  .bpane__facade:hover .bpane__cue { text-decoration: underline; }
  .bpane__facade:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -4px; }
  .bpane__facade[disabled] { cursor: default; opacity: var(--disabled-opacity); }
  .bpane__cue { font-weight: 600; }
  /* Says what pressing it costs. A button that quietly pulls 627 KB on a phone
     plan should admit as much before it is pressed, not after. */
  .bpane__cost { color: var(--text-faint); font-size: var(--type-size-xs); }

  /* Same height as the map's readout, so the two columns stay level whether or
     not this has anything to say. */
  .bpane__status {
    margin: 0; min-block-size: 2.6em; text-align: center;
    color: var(--text-muted); font-size: var(--type-size-sm);
    line-height: var(--type-leading-normal);
  }

  /* ---------------------------------------------------------- region map */

  .rmap { display: grid; gap: var(--space-sm); }
  .rmap__stage {
    inline-size: 100%;
    border: 1px solid var(--border); border-radius: var(--radius-lg);
    background: var(--surface-sunken);
    padding: var(--space-sm);
  }
  /* Aspect fixed by the viewBox, so the band never reflows as this loads. */
  .rmap__svg { display: block; inline-size: 100%; block-size: auto; }
  .rmap__svg:focus { outline: none; }

  .rmap__dead { fill: var(--surface); }

  /* The uploaded template, behind the faces. */
  .rmap__stage { position: relative; }
  .rmap__art {
    position: absolute; inset: var(--space-sm);
    inline-size: calc(100% - var(--space-sm) * 2);
    block-size: auto;
    /* It is pixel art; anything showing it should say so. */
    image-rendering: pixelated;
    /* Never a click target. Without this the artwork sits on top of the faces
       and swallows every selection — the map stops working the moment it has
       something to show, which is the moment it matters most. */
    pointer-events: none;
  }
  .rmap__art[hidden] { display: none; }

  /* The fill comes from the element, not from here: each face carries Roblox's
     own palette colour so the map reads like the template people know. */
  .rmap__face {
    fill-opacity: 0.55;
    stroke: var(--surface-sunken);
    stroke-width: 2;
    cursor: pointer;
    transition: fill-opacity var(--motion-fast) var(--motion-ease);
  }
  .rmap__face.is-hover { fill-opacity: 0.8; }
  .rmap__face.is-on { fill-opacity: 1; stroke: var(--text); }
  /* Only once something is picked — the default view must stay the honest
     picture rather than a puzzle with one piece lit. */
  .rmap__face.is-off { fill-opacity: 0.16; }
  /* The shapes are the control now, so focus has to be visible on them. */
  .rmap__face:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }

  /* The letter printed on Roblox's own template. Never a click target — the
     face beneath it is. */
  .rmap__tag {
    /* White, not the surface colour: the faces sit at 0.55 opacity over a dark
       page, so a dark letter on a dark red or green face is unreadable. */
    fill: oklch(1 0 0);
    font-size: 22px; font-weight: 700;
    text-anchor: middle; dominant-baseline: central;
    pointer-events: none;
    opacity: 0.6;
  }
  .rmap__face.is-on + .rmap__tag { opacity: 1; }
  .rmap__face.is-off + .rmap__tag { opacity: 0.22; }

  /* With artwork loaded the map stops being the picture and becomes the frame.
     No fills at all — the outfit is what you came to see — so selection is
     carried entirely by a glowing outline. */
  .rmap.has-art .rmap__dead { fill: transparent; }
  .rmap.has-art .rmap__face {
    fill-opacity: 0;
    stroke: oklch(1 0 0 / 0.3);
    stroke-width: 2;
  }
  .rmap.has-art .rmap__face.is-hover { stroke: oklch(1 0 0 / 0.65); }
  .rmap.has-art .rmap__face.is-on {
    /* The glow is the selection. currentColor carries the face's own palette
       colour, so it still matches the tint the body shows. */
    color: var(--accent);
    stroke: oklch(1 0 0 / 0.95);
    stroke-width: 4;
    filter: drop-shadow(0 0 6px currentColor) drop-shadow(0 0 14px currentColor);
  }
  /* Dimming the rest would dim their artwork with it. Only the outlines fade. */
  .rmap.has-art .rmap__face.is-off { stroke: oklch(1 0 0 / 0.12); }
  .rmap.has-art .rmap__tag { opacity: 0; }
  .rmap.has-art .rmap__face.is-on + .rmap__tag { opacity: 0.9; }

  .rmap__readout {
    margin: 0; min-block-size: 2.6em; text-align: center;
    color: var(--text-muted); font-size: var(--type-size-sm);
    line-height: var(--type-leading-normal);
  }

  .about-map { margin-block: var(--space-lg); }
  /* The map is a figure here, not a control panel: narrower than the prose so
     it reads as an illustration of the paragraph above it. */
  .about-map .rmap__stage { max-inline-size: 30rem; margin-inline: auto; }

  /* ---------------------------------------------------------- text pages */

  /* Prose measure. Long-form text at full container width is unreadable, and
     these are two pages people are meant to actually read rather than scroll
     past. */
  .prose { max-inline-size: 68ch; margin-inline: auto; padding: var(--space-xl) var(--space-lg); }
  .prose h2 { margin-block-start: var(--space-xl); }
  .prose ul { padding-inline-start: var(--space-lg); line-height: var(--type-leading-normal); }
  .prose li { margin-block-end: var(--space-2xs); }

  .prose-list { margin: 0; }
  .prose-list dt { margin-block-start: var(--space-md); font-weight: 600; }
  .prose-list dd {
    margin: var(--space-3xs) 0 0;
    color: var(--text-muted);
    line-height: var(--type-leading-normal);
  }

  /* The non-affiliation notice. Roblox's third-party terms require it to be
     stated; making it the first thing on the page is the honest reading of
     "expressly". */
  .prose-callout {
    margin-block: var(--space-lg);
    padding: var(--space-md);
    border-radius: var(--radius-md);
    border: 1px solid var(--border);
    background: var(--surface-sunken);
  }
  .prose-callout p { margin: 0; line-height: var(--type-leading-normal); }
  /* The non-affiliation notice is distinguished by position and weight, not by
     a coloured edge — visual-language rule 1 forbids an accent stripe as
     decoration, and its test caught this exact declaration. */
  .prose-callout--strong { background: var(--surface-raised); }

  .prose-meta {
    margin-block: 0 var(--space-lg);
    font-size: var(--type-size-sm); color: var(--text-muted);
  }

  /* Icon-led headings, so the page is scannable rather than read end to end —
     which is what people actually do with a policy. */
  .prose-head { display: flex; align-items: center; gap: var(--space-xs); }
  .prose-head__icon {
    inline-size: 18px; block-size: 18px; flex: none;
    stroke: currentColor; fill: none; color: var(--accent);
  }

  .footer-legal { display: flex; gap: var(--space-md); justify-content: center; }
  .footer-legal a { color: var(--text-muted); font-size: var(--type-size-sm); }

  /* ---------------------------------------------------- saved versions */

  /* Separated from the source report above by a rule rather than by space
     alone: two unlabelled blocks of small grey text in one dock read as one
     block, and these are about different things.

     --border, not --border-subtle: the latter is referenced ~20 times across
     this file and defined nowhere, so every declaration using it is dropped as
     invalid. Same note as the export dialog. */
  .versions {
    margin-block-start: var(--space-sm);
    padding-block-start: var(--space-sm);
    border-block-start: 1px solid var(--border);
  }
  .versions__title {
    margin: 0 0 var(--space-2xs);
    font-size: var(--type-size-sm); font-weight: 600; color: var(--text-muted);
    /* The dock already has a visible heading ("Source"); this names the second
       thing inside it, which was previously an unlabelled block of grey text
       that read as a continuation of the one above. */
    text-transform: none; letter-spacing: 0;
  }
  .versions__note {
    margin: var(--space-2xs) 0 0;
    font-size: var(--type-size-sm); color: var(--text-muted);
    line-height: var(--type-leading-normal);
  }
  .versions__note--quiet { font-size: var(--type-size-xs); }
  .versions__note[data-tone="error"] { color: var(--danger); }
  /* Refreshing keeps the rows up and dims them, rather than blanking to a
     loading line — a list that vanishes and returns several times a minute
     reads as work being lost and refound. */
  .versions[aria-busy="true"] .versions__body { opacity: 0.6; }
  .versions__list { margin: 0; padding: 0; list-style: none; }

  .versions__item {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    align-items: center;
    gap: var(--space-xs);
    padding-block: var(--space-2xs);
  }
  .versions__item + .versions__item { border-block-start: 1px solid var(--border); }

  .versions__text { display: grid; min-inline-size: 0; }
  .versions__when {
    font-size: var(--type-size-sm); color: var(--text);
    /* Long device names must not push the button out of the dock. */
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  .versions__item[data-current="true"] .versions__when { font-weight: 600; }
  .versions__detail {
    font-size: var(--type-size-xs); color: var(--text-muted);
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }

  .versions__restore {
    padding: var(--space-3xs) var(--space-xs);
    border: 1px solid var(--border); border-radius: var(--radius-sm);
    background: var(--surface); color: var(--text);
    font: inherit; font-size: var(--type-size-xs); cursor: pointer;
  }
  .versions__restore:hover:not(:disabled) { background: var(--surface-raised); }
  /* Still legible when disabled: this is how the open version is labelled, so
     fading it to invisible would delete the label. */
  .versions__restore:disabled { opacity: 0.45; cursor: default; }

  /* ------------------------------------------------------ colour picker */

  .swatch-btn { padding: 0; }
  .swatch {
    inline-size: 20px; block-size: 20px; border-radius: var(--radius-sm);
    background: var(--swatch, #0074BD);
    /* A light swatch on a light popover needs an edge, and a dark one needs to
       not look like a hole. An inset hairline does both without a border that
       would shift the box. */
    box-shadow: inset 0 0 0 1px rgb(0 0 0 / 0.25), inset 0 0 0 2px rgb(255 255 255 / 0.15);
  }

  /*
    The popover primitive. Anything that hangs off a button uses it.

    Visibility is driven by the data-open attribute, NOT by the hidden
    attribute, and that is the whole point of the class existing.

    hidden works through the UA rule [hidden] { display: none }. That is a bare
    attribute selector, so any component rule of the form
    .thing { display: grid } outranks it - the attribute flips, aria flips,
    every piece of state agrees the thing is closed, and it stays on screen. The
    colour picker shipped exactly that way and could not be dismissed at all.

    Here the same attribute drives the state and the display, so the two cannot
    disagree. Set data-open to show; remove it to hide. See src/app/popover.ts.

    (No backticks in this comment on purpose: the whole stylesheet is a template
    literal, and a stray one fails the generator with a SyntaxError pointing
    nowhere near the cause.)
  */
  .popover {
    display: none;
    position: fixed; z-index: var(--z-popover, 100);
    overflow: auto; overscroll-behavior: contain;
    padding: var(--space-sm);
    background: var(--surface-raised, var(--surface));
    border: 1px solid var(--panel-border);
    /* Shadow and radius come from .floating. A popover is one of the three
       things the visual language says may float, and the tokens test enforces
       that nothing else carries a shadow — see docs/decisions/visual-language.md. */
  }
  .popover[data-open] { display: grid; gap: var(--space-sm); }

  /*
    Popovers arrive rather than appear.

    display cannot be transitioned, so this is a keyframe on the newly shown
    element — the same reason the plan prompt uses one. Without it the panel
    blinked into existence at full size, which reads as a glitch rather than as
    a thing opening: there is no moment that connects the button you pressed to
    the panel that showed up.

    Fast, and from the anchor's side. --motion-fast is 120ms, the bottom of the
    range visual-language.md allows for a state change, because this one is in
    the way of what the user is trying to do next.
  */
  @keyframes popover-in {
    from { opacity: 0; transform: translateY(-4px) scale(0.985); }
    to { opacity: 1; transform: none; }
  }
  @media (prefers-reduced-motion: no-preference) {
    .popover[data-open] { animation: popover-in var(--motion-fast) var(--motion-ease); }
  }

  /* One line explaining whichever brush is configured. Sits under the sliders
     because it describes what they just did, not what they are about to. */
  /* Fill's two behaviours, as a pair rather than a dropdown: there are two of
     them, they are both short, and a menu would hide one of them behind a
     click for no reason. */
  .opts-modes { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-2xs); }
  /* Three modes stack rather than squeeze: "Whole part" does not fit a third of
     240px, and a truncated label on a mode switch is a guess. */
  .opts-modes[data-count="3"] { grid-template-columns: 1fr; }
  .opts-modes[hidden] { display: none; }
  .opts-mode {
    min-height: var(--control-height-md);
    padding: 0 var(--space-xs);
    border: 1px solid var(--control-border);
    border-radius: var(--radius-sm);
    background: none;
    color: var(--control-text);
    font: inherit;
    font-size: var(--type-size-xs);
    cursor: pointer;
    transition: background var(--motion-fast) var(--motion-ease),
                border-color var(--motion-fast) var(--motion-ease);
  }
  .opts-mode:hover { background: var(--control-bg-hover); }
  .opts-mode[data-selected="true"] {
    border-color: var(--accent);
    background: color-mix(in oklab, var(--surface-raised) 86%, var(--accent));
    font-weight: var(--type-weight-bold);
  }
  .opts-mode:focus-visible {
    outline: var(--focus-width) solid var(--focus-ring);
    outline-offset: var(--focus-offset);
  }

  .opts-hint {
    margin: 0;
    color: var(--text-muted);
    font-size: var(--type-size-xs);
    line-height: 1.45;
    text-wrap: pretty;
  }

  .opts {
    inline-size: min(240px, calc(100vw - 16px));
    gap: var(--space-sm);
  }
  .opts-title {
    margin: 0; font-size: var(--type-size-xs); font-weight: var(--type-weight-bold);
    color: var(--text-muted); letter-spacing: 0.02em;
  }
  /* A dab at the real size, because 40px means nothing until you see it. */
  .opts-preview {
    display: grid; place-items: center;
    block-size: 72px; border-radius: var(--radius-md);
    background: var(--surface-sunken);
  }
  .opts-preview i {
    display: block; border-radius: 50%;
    background: var(--swatch, var(--accent));
  }
  /* The eraser reads as an outline: a filled dab in the paint colour would say
     the opposite of what the tool does. */
  .opts-preview i[data-erasing] {
    background: none;
    box-shadow: inset 0 0 0 2px var(--text-muted);
  }
  .opts-row {
    display: grid; grid-template-columns: minmax(0, 1fr) auto;
    align-items: center; gap: var(--space-2xs);
    font-size: var(--type-size-sm);
  }
  .opts-row input[type="range"] { grid-column: 1 / -1; inline-size: 100%; }
  .opts-value { color: var(--text-muted); font-variant-numeric: tabular-nums; }
  /* Coarse pointers need a thumb you can actually catch. */
  @media (pointer: coarse) {
    .opts-row input[type="range"] { block-size: var(--control-height-lg); }
  }

  .cp {
    inline-size: min(260px, calc(100vw - 16px));
    max-block-size: min(560px, calc(100dvh - 16px));
  }

  .cp-square {
    position: relative; block-size: 140px; border-radius: var(--radius-md);
    /* Order matters: black overlay on top, white on the left, hue underneath. */
    background:
      linear-gradient(to top, #000, transparent),
      linear-gradient(to right, #fff, transparent),
      var(--hue, #f00);
    touch-action: none; cursor: crosshair;
  }
  .cp-hue {
    position: relative; block-size: 140px; inline-size: 24px;
    justify-self: start; border-radius: var(--radius-md);
    background: linear-gradient(to bottom,
      #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%);
    touch-action: none; cursor: ns-resize;
  }
  /* The square and hue strip sit side by side once there is room for them. */
  @media (width >= 380px) {
    .cp { grid-template-columns: minmax(0, 1fr) auto; }
    .cp-head, .cp-section, .cp-palette { grid-column: 1 / -1; }
  }

  .cp-square i, .cp-hue i {
    position: absolute; inline-size: 14px; block-size: 14px;
    margin: -7px 0 0 -7px; border-radius: 50%;
    box-shadow: inset 0 0 0 2px #fff, 0 0 0 1px rgb(0 0 0 / 0.5);
    pointer-events: none;
  }
  .cp-hue i { left: 50%; inline-size: 20px; block-size: 20px; margin: -10px 0 0 -10px; }
  .cp-square:focus-visible, .cp-hue:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

  .cp-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-xs); align-items: center; }
  .cp-hex {
    font-family: var(--type-mono); font-size: var(--type-size-sm);
    text-align: center; letter-spacing: 0.04em;
    block-size: var(--control-height-md);
    border: 1px solid var(--panel-border); border-radius: var(--radius-md);
    min-inline-size: 0;
  }
  .cp-name { font-size: var(--type-size-xs); color: var(--text-muted); text-align: end; }

  .cp-section h3 {
    margin: 0 0 var(--space-2xs);
    font-size: var(--type-size-xs); font-weight: var(--type-weight-bold);
    color: var(--text-muted); letter-spacing: 0.02em;
  }
  .cp-swatches { display: grid; grid-template-columns: repeat(8, 1fr); gap: var(--space-2xs); }
  .cp-swatch {
    aspect-ratio: 1; inline-size: 100%; border: 0; padding: 0; cursor: pointer;
    border-radius: var(--radius-sm);
    box-shadow: inset 0 0 0 1px rgb(0 0 0 / 0.25);
  }
  .cp-swatch:hover { transform: scale(1.12); }
  .cp-swatch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
  @media (prefers-reduced-motion: reduce) { .cp-swatch:hover { transform: none; } }

  /* Coarse pointers get fewer, larger swatches rather than the same grid with
     targets too small to hit. */
  @media (pointer: coarse) {
    .cp-swatches { grid-template-columns: repeat(6, 1fr); gap: var(--space-xs); }
  }

  /* ------------------------------------------------------------- the ladder */

  .too-narrow { display: none; place-items: center; text-align: center; padding: var(--space-2xl); }

  /* 1024–1280: properties becomes a drawer. */
  @media (width < 1280px) {
    .panels { grid-template-columns: minmax(0, 1fr); }
    .dock-properties { display: none; }
  }

  /* 768–1024: 3D collapses to a toggle, 2D takes the stage. */
  @media (width < 1024px) {
    .stage { grid-template-columns: minmax(0, 1fr); }
    .divider, .pane-3d { display: none; }
  }

  /* Below the editor floor — iPad portrait is the floor, phones are not. */
  @media (width < 768px) {
    .shell-body, .appbar .desktop-only { display: none; }
    .too-narrow { display: grid; }
  }

  @media (pointer: coarse) {
    .icon-btn { inline-size: var(--control-height-lg); block-size: var(--control-height-lg); }
  }
}

@layer components {
  .style-page { max-width: 860px; }
  .style-page .row { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; margin: 0 0 var(--space-md); }
  .style-page h2 { margin-top: var(--space-2xl); }
  .hint { color: var(--text-muted); font-size: var(--type-size-xs); margin: var(--space-sm) 0 0; }

  .ramps { display: grid; gap: var(--space-2xs); }
  .ramp { display: flex; align-items: center; gap: var(--space-sm); }
  .ramp-name { inline-size: 64px; font-size: var(--type-size-xs); color: var(--text-muted); }
  .ramp-cells { display: flex; flex: 1; border-radius: var(--radius-sm); overflow: hidden; }
  .ramp-cells i { flex: 1; block-size: 26px; }

  .icon-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: var(--space-sm); margin: var(--space-md) 0 0; }
  .icon-grid figure { margin: 0; text-align: center; }
  .icon-grid .ic {
    display: grid; place-items: center; block-size: 52px;
    border: 1px solid var(--panel-border); border-radius: var(--radius-md);
    background: var(--panel-bg); color: var(--text);
  }
  .icon-grid figcaption { font-size: 10px; color: var(--text-muted); margin-top: var(--space-3xs); word-break: break-word; }
  .icon-grid .own { color: var(--accent); font-weight: var(--type-weight-bold); }

  table.tokens { border-collapse: collapse; font-size: var(--type-size-sm); margin: var(--space-sm) 0 0; }
  table.tokens td { padding: var(--space-3xs) var(--space-sm) var(--space-3xs) 0; vertical-align: middle; }
  table.tokens tr + tr td { border-top: 1px solid var(--panel-border); }
}
