/* Syzygy studio — one visual system for a focused creator workspace. */

/* Self-hosted fonts (fixes the missing-Inter bug — Inter was never actually loaded).
   Files ship from app/assets/fonts/ via a copy-dir in index.html (integrator-owned). */
@font-face {
  font-family: "Inter";
  src: url("/app/fonts/InterVariable.woff2") format("woff2-variations");
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "JetBrains Mono";
  src: url("/app/fonts/JetBrainsMono.woff2") format("woff2-variations");
  font-weight: 100 800;
  font-style: normal;
  font-display: swap;
}

/* The four faces the RENDER actually draws overlay text with. remotion/src/
   fonts.js registers the proprietary-sounding family names against these exact
   open-licensed files (TikTokDisplayMedium → Montserrat 500, ArialBrat → Arimo,
   TimesNewRoman → Tinos, InstrumentSerif), so registering the same file under
   the same family here makes the preview measure glyphs the way the export
   does. Without them the preview fell back to a system face ~8% narrower than
   Montserrat, so correctly-sized text still under-reported how much room it
   needs. Same files, copied from remotion/public/fonts (licences in
   RENDER-FONTS-LICENSES.md); ~68 KB total. */
@font-face {
  font-family: "TikTokDisplayMedium";
  src: url("/app/fonts/Montserrat-500.woff2") format("woff2");
  font-weight: 500;
  font-display: swap;
}
@font-face {
  font-family: "ArialBrat";
  src: url("/app/fonts/Arimo-400.woff2") format("woff2");
  font-weight: 400;
  font-display: swap;
}
@font-face {
  font-family: "TimesNewRoman";
  src: url("/app/fonts/Tinos-400.woff2") format("woff2");
  font-weight: 400;
  font-display: swap;
}
@font-face {
  font-family: "InstrumentSerif";
  src: url("/app/fonts/InstrumentSerif-400.woff2") format("woff2");
  font-weight: 400;
  font-display: swap;
}

/* ── Structure ─────────────────────────────────────────────────────────────
   Type, spacing, radii and layout metrics belong to the PRODUCT, not to a
   theme. They lived inside the dark block, so narrowing that selector left
   light with no fonts, no spacing scale and no sidebar width — the page
   collapsed. Split out so both themes inherit one skeleton and differ only in
   colour, which is what "design both themes" actually requires. */
:root {
  /* Safe-zone bands paint on the 9:16 stage, which is dark in both themes, so
     they belong with the product skeleton rather than with either palette.
     `--band-shorts` is violet on purpose: the three bands have to be told apart
     at a glance at micro size, and warm/cyan/violet do that without colliding
     with the brand accent and making "YouTube Shorts" read as "selected". */
  --band-hook: rgba(255, 180, 94, 0.9);
  --band-tiktok: rgba(54, 217, 207, 0.9);
  --band-shorts: rgba(167, 139, 250, 0.9);
  /* Opaque enough that the frame behind it no longer matters: at a=0.92 over
     pure white the composite is #1b1d21 and the pair still holds 7.93:1. */
  --stage-scrim: rgba(7, 9, 14, 0.92);
  --stage-warn: #e0a75a;

  --ease-field: cubic-bezier(0.45, 0.05, 0.55, 0.95);
  --shadow-sm: var(--elev-1);
  --shadow-md: var(--elev-3);
  --shadow-lg: var(--elev-4);
  /* ── INTERFACE TYPE ───────────────────────────────────────────────────────
     The system stack LEADS; Inter is the fallback, not the lead.

     HIG typography.md: "Don't embed system fonts in your app". Apple's licence
     does not permit serving SF Pro as a webfont either, so the only legitimate
     route to Apple's own type is the face already installed on the reader's
     machine: `-apple-system` / `BlinkMacSystemFont` resolve to San Francisco on
     macOS and iOS, `system-ui` and "Segoe UI" resolve to the native face
     elsewhere. That also buys what typography.md calls out as SF's advantage —
     "Both available as variable fonts … supporting dynamic optical sizes" —
     which a static webfont cannot give us at any size.

     Inter stays third in line, ahead of the generic families, because it is the
     one face we actually ship: it guarantees a known-good rendering on the
     platforms where `system-ui` resolves to something poor. Leading with Inter
     instead would override real SF on every Apple device, which is the exact
     opposite of the goal.

     Nine faces in app/assets/fonts/ are CONTENT faces drawn inside rendered
     videos (TikTokDisplayMedium, ArialBrat, …). They are not interface type and
     are untouched by this stack. */
  --system-sans: -apple-system, BlinkMacSystemFont, system-ui, "Segoe UI", Roboto,
    Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
  --display: var(--system-sans);
  --sans: var(--system-sans);
  /* SF Mono on Apple hardware via ui-monospace, then the platform's fixed face,
     then the bundled JetBrains Mono. All of these carry tabular figures, which
     the .stat-num / .num alignment rule below depends on. */
  --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
    "JetBrains Mono", monospace;
  --num: var(--mono);

  /* ── TYPE SCALE ───────────────────────────────────────────────────────────
     typography.md gives macOS its own built-in text styles — Body 13pt, Title 3
     15, Title 2 17, Title 1 22, Large Title 26, Caption 10 — and a hard floor:
     "macOS | 13pt default | 10pt minimum" (iOS: 17/11). The honest web
     translation of a 13pt macOS body is a 16px browser body, so the whole ladder
     is Apple's macOS ratios re-based on 16px, expressed in rem so a reader who
     raises their browser's default font size scales the entire interface —
     the closest the web gets to "Support Dynamic Type".

     --text-micro (10px) is Apple's macOS minimum and is for NON-INTERACTIVE
     labels only. Nothing you can click, type in or focus goes below
     --text-caption-2 (11px), which is Apple's iOS minimum. */
  --text-large-title: 2rem;      /* 32px — Large Title 26pt */
  --text-title-1: 1.6875rem;     /* 27px — Title 1 22pt */
  --text-title-2: 1.3125rem;     /* 21px — Title 2 17pt */
  --text-title-3: 1.125rem;      /* 18px — Title 3 15pt */
  --text-headline: 1rem;         /* 16px — Headline 13pt, emphasised */
  --text-body: 1rem;             /* 16px — Body 13pt */
  --text-callout: 0.9375rem;     /* 15px — Callout 12pt */
  --text-subhead: 0.875rem;      /* 14px — Subheadline 11pt */
  --text-footnote: 0.8125rem;    /* 13px — Footnote 10pt */
  --text-caption: 0.75rem;       /* 12px — Caption 1 */
  --text-caption-2: 0.6875rem;   /* 11px — floor for ANY interactive text */
  --text-micro: 0.625rem;        /* 10px — macOS floor; non-interactive only */
  --leading-tight: 1.2;
  --leading-body: 1.5;
  --radius-sm: 8px;
  --radius-md: 12px;
  --radius-lg: 16px;
  --sidebar-width: 304px;
  --header-height: 88px;
  --content-max: 1240px;
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-6: 24px;
  --space-8: 32px;
  --space-12: 48px;
  --space-16: 64px;

  /* ── MOTION ───────────────────────────────────────────────────────────────
     Four durations, three curves, and nothing else. Before this table existed
     the file carried 160ms, 150ms, 220ms, 240ms, 260ms and 360ms, each picked
     in isolation, so two controls a centimetre apart answered the hand at
     visibly different speeds and half the app entered while the other half
     blinked. The numbers below are not preferences; they are the four jobs
     motion does here, and every rule in this file now names one of them.

       --dur-press   90ms  the hand is ON the control. Anything slower reads as
                           lag rather than as feedback, so this is the floor and
                           the press state is the only thing that uses it.
       --dur-state  140ms  hover, focus ring, colour and elevation changes, and
                           anything LEAVING. Well inside the 180ms ceiling the
                           premium ledger sets for a control state (P-4).
       --dur-enter  220ms  something ARRIVING: a menu, a toast, a list item, a
                           screen. Enters are allowed to be slower than exits
                           because arriving is information and leaving is not.
       --dur-stage  320ms  reserved for the one big surface a screen is about —
                           the finished render. Exactly one thing at a time may
                           move this slowly, the same way exactly one thing at
                           a time may sit on elevation rung 5.

     Curves. `--ease` is the house curve and stays as it was — it is in a
     hundred declarations and it is good. `--ease-out` decelerates harder and is
     for ENTERS only: an element that arrives should be fastest at the moment it
     first appears and settle into place, which is what makes an interface feel
     answered rather than animated. `--ease-in` is its mirror and is for exits.

     `--stagger-step` is the gap between one list item's entrance and the next.
     34ms is deliberately below the ~50ms where a stagger stops reading as one
     movement and starts reading as items counting themselves in. */
  --ease: cubic-bezier(0.2, 0.8, 0.2, 1);
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-in: cubic-bezier(0.4, 0, 1, 1);
  --dur-press: 90ms;
  --dur-state: 140ms;
  --dur-enter: 220ms;
  --dur-stage: 320ms;
  --stagger-step: 34ms;
  --transition: var(--dur-state) var(--ease);
  /* The two composed patterns the rest of the file reaches for by name. */
  --transition-press: var(--dur-press) var(--ease-out);
  --enter: var(--dur-enter) var(--ease-out) both;

  /* Platform safe-area band hues. These identify a PLATFORM, not our brand, and
     they paint on the 9:16 stage, which is dark in both themes — so they belong
     with the product skeleton, not with either palette.
     `--band-shorts` is violet on purpose. It is not leftover v1 accent: the
     three bands have to be told apart at a glance at 8px, and warm/cyan/violet
     is the only triple here that survives that. Recolouring it green would
     collide with the brand accent and make "YouTube Shorts" read as "selected".
     Named so nobody has to guess that again. */
  --band-hook: rgba(255, 180, 94, 0.9);
  --band-tiktok: rgba(54, 217, 207, 0.9);
  --band-shorts: rgba(167, 139, 250, 0.9);

  /* Same reasoning, one rung up: `.stage-unsaved` is a warning that paints ON
     the stage, so it is light-on-dark in BOTH themes and must not follow the
     palette's `--warm`. Light's `--warm` is tuned for a white card; over the
     stage scrim it measured 3.86:1 on the empty stage and 1.98:1 once a bright
     frame was loaded under it — an "unsaved" warning you could not see on the
     screen it warns about. The old α=0.76 scrim was the other half: it let the
     frame lift the ground. This is the dark-theme warm, pinned, over a scrim
     opaque enough that the frame no longer matters — at α=0.92 over pure white
     the composite is #1b1d21 and the pair still holds 7.93:1. */
  --stage-scrim: rgba(7, 9, 14, 0.92);
  --stage-warn: #e0a75a;
}

/* Dark is the DEFAULT, carried by bare :root as well as its own attribute — so
   the first paint is correct before any script runs, and stays correct if one
   never does. The light attribute selector (0,2,0) outranks bare :root (0,1,0),
   so the toggle still wins in both directions regardless of source order.

   Dark — the premium default.
   This palette is shared with the live Rising Tides and ShipStream products:
   near-black #060606, cool slate surfaces, #FAFCFF ink, and the magenta→violet
   brand ramp. See docs/BRAND-SYSTEM.md for the measured reference and the
   accessibility-safe fill variants used by controls. */
:root,
:root[data-theme="dark"] {
  color-scheme: dark;
  --bg: #060606;
  --bg-soft: #090a0d;
  --sidebar: #060606;
  --surface: #0c0e13;
  --surface-2: #12151b;
  --surface-3: #1c212b;
  --surface-hover: #222833;
  --ink: #fafcff;
  --ink-soft: #d9dbe0;
  --muted: #b8b9bb;
  --muted-2: #a6a9b3;
  --rule: #2a2e37;
  --rule-strong: #3a4150;

  /* PRIMARY is the brand action. The live brand begins at #E100C3, but white
     on that exact stop is 4.21:1. Controls therefore start at its measured
     AA-safe companion #B000C8; decorative glows retain the exact brand stop. */
  --primary: #b000c8;
  --primary-hover: #9900bd;
  --primary-press: #8500d7;
  --primary-ink: #ffffff;

  /* ACCENT carries selection, focus, active state and quiet emphasis — the
     active nav link, the selected chip, the workspace kicker, `.card--float`,
     `::selection`, the One-Shot step numbers, the progress bar. Same family as
     primary on purpose. What marks MONEY is not the accent, it is
     `--accent-gradient` on `.btn--upsell`; nothing else in the file uses it.

     This is stated identically in the light block below. The two used to
     disagree — light claimed the accent "must never be the colour of an
     ordinary action" while both themes were painting every ordinary selection
     with it — and comments that contradict each other are exactly how the
     cross-product palette drift survived for months. If the doctrine changes, both
     blocks change together, and the code has to change with them. */
  --accent: #ee2bd1;
  --accent-strong: #f251df;
  --accent-soft: rgba(225, 0, 195, 0.14);
  --accent-fill: #b000c8;
  --accent-fill-hover: #9900bd;
  --accent-fill-press: #8500d7;
  --accent-ink: #ffffff;
  /* A softer label on the same fill still clears 4.5:1 at every interactive
     state (4.82:1 at the brightest stop). Guarded in theme_contrast.rs. */
  --accent-ink-soft: #fbe5ff;
  --accent-gradient: linear-gradient(96deg, #d500b8 0%, #b000c8 52%, #8500d7 100%);

  --cyan: #a77bfa;
  --warm: #e0a75a;
  --success: #1ed760;
  --danger: #f0736b;
  --stage-1: #1c212b;
  --stage-2: #060606;
  --clip-fill: #3a2f50;
  --glass-bg: rgba(12, 14, 19, 0.93); /* ≥0.93 keeps --muted-2 ≥4.5:1 even over pure-white media under the glass */
  --thumb-backing: #12151b;
  --mode-dry-bg: #221a09;
  --mode-live-bg: #04120a;

  /* Ambient field — the depth that makes a flat dark page read as premium.
     Kept low-alpha so it never competes with content. */
  --field-base: #060606;
  --field-glow-1: rgba(225, 0, 195, 0.1);
  --field-glow-2: rgba(133, 0, 215, 0.08);
  --field-ember: rgba(224, 167, 90, 0.035);

  /* Depth in dark comes from a light rim plus a deep shadow, not shadow alone —
     a shadow on a near-black ground is invisible. */
  --rim: inset 0 1px 0 rgba(255, 255, 255, 0.05);
  --rim-strong: inset 0 1px 0 rgba(225, 0, 195, 0.12);
  --ring: rgba(225, 0, 195, 0.95);
  /* ONE ladder, one shadow colour. Rungs 5 and the inset used to live in the
     structural :root block above and were tinted rgba(2, 3, 6, …) while rungs
     1–4 here were pure black — so a modal (rung 5) sat under a differently
     coloured shadow from every card beneath it, AND rung 5's near shadow was
     LIGHTER (0.5) than rung 4's (0.6), which is a ladder that goes down at the
     top. Both are dark-theme values, so they belong in the dark block; light
     defines its own. */
  --elev-0: none;
  --elev-1: 0 1px 2px rgba(0, 0, 0, 0.5);
  --elev-2: 0 2px 6px rgba(0, 0, 0, 0.5), 0 10px 28px rgba(0, 0, 0, 0.42);
  --elev-3: 0 6px 16px rgba(0, 0, 0, 0.55), 0 22px 56px rgba(0, 0, 0, 0.5);
  --elev-4: 0 10px 28px rgba(0, 0, 0, 0.6), 0 40px 96px rgba(0, 0, 0, 0.58);
  --elev-5: 0 16px 40px rgba(0, 0, 0, 0.64), 0 48px 120px rgba(0, 0, 0, 0.66);
  --elev-inset: inset 0 2px 24px rgba(0, 0, 0, 0.55);
}

/* Light — a first-class theme, applied by the toggle. */
:root[data-theme="light"] {
  color-scheme: light;
  /* Light is a first-class theme, not an inverted dark one.
     Vocabulary matches the same cross-product system: white page, near-black
     text, cool hairline rules, and the deep end of the brand ramp for actions. */
  --bg: #ffffff;
  --bg-soft: #fafafa;
  --sidebar: #ffffff;
  --surface: #ffffff;
  --surface-2: #fafafa;
  --surface-3: #f4f4f5;
  --surface-hover: #f4f4f5;
  --ink: #111111;
  --ink-soft: #3f3f46;
  /* Was #6b7280 — 4.83:1 on --surface, so every guard that measured the card
     said green. But `.badge`/`.pill` declare `background: var(--surface-3)`,
     and on #f4f4f5 the same token measured 4.40:1 at 10px. Same hue, darkened
     4%: 5.19:1 on --surface, 4.97:1 on --surface-2, 4.72:1 on --surface-3.
     The guard now reads all four grounds, not just the card. */
  --muted: #676d7b;
  --muted-2: #59606e;
  --rule: #ececec;
  --rule-strong: #d9d9d9;

  /* PRIMARY is the thing you click to move forward. Deep magenta retains the
     Rising Tides identity while keeping white labels above 4.5:1. */
  --primary: #9900bd;
  --primary-hover: #8500d7;
  --primary-press: #6f00a8;
  --primary-ink: #ffffff;

  /* ACCENT carries selection, focus, active state and quiet emphasis — the
     same job it does in dark, and the same job the code has always given it
     here. This comment used to read "reserved for paid / upsell surfaces … must
     never be the colour of an ordinary action", which was never true of the
     code in either theme: `--accent-fill` paints `::selection` and the active
     nav link, `--accent-strong` paints the workspace kicker and the render-job
     link. What is genuinely reserved for paid is `--accent-gradient` on
     `.btn--upsell` — that is the line that keeps "upgrade" meaning something,
     and it is the only one the file actually enforces.

     Same magenta/violet family as dark, seated deep enough to remain readable
     as text and icons on white and pale raised surfaces. */
  --accent: #9900bd;
  --accent-strong: #8500d7;
  --accent-soft: rgba(225, 0, 195, 0.1);
  --accent-fill: #9900bd;
  --accent-fill-hover: #8500d7;
  --accent-fill-press: #6f00a8;
  --accent-ink: #ffffff;
  --accent-ink-soft: #fbe5ff;
  --accent-gradient: linear-gradient(96deg, #d500b8 0%, #b000c8 52%, #8500d7 100%);

  --cyan: #6941c6;
  /* Status inks are measured on the chip they ship in, not on the card. Both
     of these failed on `.badge`/`.pill`'s own `--surface-3` ground and are
     darkened along their exact hue until they clear it:
       --warm  #a16207 → 4.48:1 on --surface-3; #9b5e07 → 4.78:1
       --success #15803d → 4.12:1 on its own 8% tint; #137337 → 4.84:1
     `--success` is measured on the tint because `.badge--success` repaints the
     ground as 8% of itself, which is a DARKER ground than --surface-3, so the
     plain-ground number was never the pair that shipped. */
  --warm: #9b5e07;
  --success: #137337;
  --danger: #b91c1c;
  --stage-1: #27272a;
  --stage-2: #18181b;
  --clip-fill: #71717a;
  --glass-bg: rgba(255, 255, 255, 0.93); /* ≥0.93 keeps --muted-2 ≥4.5:1 even over black media/stage under the glass (measured 4.65) */
  --thumb-backing: #f4f4f5;
  --mode-dry-bg: #fffbeb;
  --mode-live-bg: #f0fdf4;

  /* Field — light must stay ≤ 0.04 alpha (contrast hard cap, see spec §4.3).
     Hue follows dark's magenta + violet field plus a warm ember. */
  --field-base: #ffffff;
  --field-glow-1: rgba(225, 0, 195, 0.03);
  --field-glow-2: rgba(133, 0, 215, 0.025);
  --field-ember: rgba(224, 167, 90, 0.02);

  /* Shadows carry hierarchy in light where borders carry it in dark.
     ONE ladder, one shadow colour — the same rule dark states, now actually
     held here. Rungs 1–4 were neutral rgba(16, 16, 20, …) while rung 5 and the
     inset were still the blue-navy rgba(24, 31, 48, …) they were first drafted
     as for the violet product. So the one surface that floats above everything
     — the modal — cast a shadow of a different HUE from every card beneath it,
     and the stage's recess did too. Alphas are untouched (the ladder already
     climbs 0.05 → 0.06 → 0.07/0.09 → 0.09/0.12 → 0.12/0.18); only the tint
     moved onto the ladder's own colour. */
  --elev-0: none;
  --elev-1: 0 1px 2px rgba(16, 16, 20, 0.05);
  --elev-2: 0 1px 3px rgba(16, 16, 20, 0.06), 0 8px 24px rgba(16, 16, 20, 0.06);
  --elev-3: 0 4px 12px rgba(16, 16, 20, 0.07), 0 20px 48px rgba(16, 16, 20, 0.09);
  --elev-4: 0 8px 24px rgba(16, 16, 20, 0.09), 0 32px 80px rgba(16, 16, 20, 0.12);
  --elev-5: 0 16px 40px rgba(16, 16, 20, 0.12), 0 48px 120px rgba(16, 16, 20, 0.18);
  --elev-inset: inset 0 2px 24px rgba(16, 16, 20, 0.1);
  --rim: inset 0 1px 0 rgba(255, 255, 255, 0.85);
  --rim-strong: inset 0 1px 0 rgba(255, 255, 255, 1);
  --shadow-sm: var(--elev-1);
  --shadow-md: var(--elev-3);
  --shadow-lg: var(--elev-4);

  /* Deep brand magenta, raised to 0.9 alpha so it clears the 3:1 non-text
     floor on the page and both raised surfaces. Guarded in theme_contrast.rs. */
  --ring: rgba(153, 0, 189, 0.9);
}

* { box-sizing: border-box; }
html {
  min-width: 320px;
  -webkit-text-size-adjust: 100%;
  background: var(--field-base);
}
body {
  margin: 0;
  /* `dvh`, not `vh`. On a mobile browser `100vh` is the tallest the viewport
     ever gets — the height it has with the chrome collapsed — so a `100vh`
     floor on the body guarantees a screenful of empty scroll under every short
     page for as long as the toolbar is showing. `100dvh` is the height the
     glass actually has right now. */
  min-height: 100dvh;
  /* LOAD-BEARING. `body`'s own background paints at Appendix E step 3, AFTER
     the negative-`z-index` pseudo-elements below at step 2 — so any opaque
     colour here deletes the field. It is not a tidy-up; it is the switch. */
  background: transparent;
  color: var(--ink);
  font-family: var(--sans);
  font-size: var(--text-body);
  line-height: var(--leading-body);
  text-rendering: optimizeLegibility;
}

/* ── THE FIELD ──────────────────────────────────────────────────────────────
   Two inert pseudo-elements behind everything. Pure CSS, zero bytes, our green.
   Layer 1 is static and always on; layer 2 is a transform-only drift. The base
   colour lives on <html> so body::before can sit behind content but above the fill.

   This is the ONLY field. A second one used to live in the "Depth" section near
   the end of the file as a `body { background-image: … }` and, being later at
   equal specificity on a step that paints on top, it hid this one completely —
   for as long as both existed, `field-drift` never ran where anyone could see
   it. If the ambient background ever needs to change, it changes here.

   Both layers are `position: fixed`, which is why the glow belongs to the
   window rather than to the scroll position. `body::after`'s drift is frozen
   by hand under `prefers-reduced-motion: reduce` further down — `animation:
   none` plus a fixed `scale(1.02)`, so the composition is identical and only
   the motion stops. */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -2;
  pointer-events: none;
  background-color: var(--field-base);
  background-image:
    radial-gradient(circle at 18% 12%, var(--field-glow-1), transparent 48%),
    radial-gradient(circle at 84% 30%, var(--field-glow-2), transparent 52%),
    radial-gradient(circle at 62% 92%, var(--field-ember), transparent 46%);
  background-repeat: no-repeat;
}
body::after {
  content: "";
  position: fixed;
  inset: -20%;
  z-index: -1;
  pointer-events: none;
  opacity: 0.55;
  will-change: transform;
  background-image:
    radial-gradient(circle at 30% 70%, var(--field-glow-1), transparent 44%),
    radial-gradient(circle at 70% 20%, var(--field-glow-2), transparent 50%);
  background-repeat: no-repeat;
  animation: field-drift 48s var(--ease-field) infinite alternate;
}
@keyframes field-drift {
  from { transform: translate3d(-2.5%, -1.5%, 0) scale(1); }
  to { transform: translate3d(2.5%, 2%, 0) scale(1.06); }
}

button, input, select, textarea { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
a { color: inherit; }
img { max-width: 100%; }
::selection { background: var(--accent-fill); color: var(--accent-ink); }

/* ── App shell ──────────────────────────────────────────────────────────────
   The studio is height-bounded to the viewport and does not scroll as a
   document. Only #studio-workspace and #studio-nav scroll, each within its own
   grid row.

   This is the difference between a page and an application. With `min-height`
   the whole studio was one tall document: the chrome was `position: sticky`, so
   it drifted a few pixels on every wheel tick, the browser's own scrollbar ran
   the full height of the app, and there was no way to scroll a pane without
   moving the header. Every desktop tool this product sits beside — an editor, a
   library, a board — pins its chrome and scrolls its content. That is a layout
   property, not a decoration, and no amount of type and colour work substitutes
   for it.

   `dvh`, not `vh`: on mobile browsers `100vh` is the tallest the viewport ever
   gets, which puts the bottom of the shell under the URL bar. Below 820px the
   media query at the end of this file hands scrolling back to the document,
   where a phone expects it. */
#syzygy-app {
  height: 100dvh;
  overflow: hidden;
  display: grid;
  grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
  grid-template-rows: var(--header-height) minmax(0, 1fr);
  grid-template-areas:
    "masthead wordmark"
    "nav workspace";
}

#masthead {
  grid-area: masthead;
  z-index: 30;
  /* Exact, not min-: the shell's first grid row is --header-height and the
     mobile block's sticky offsets are calc()ed from it, so drift here mis-pins
     them. On desktop the shell row holds this in place; nothing is sticky. */
  height: var(--header-height);
  min-height: var(--header-height);
  display: grid;
  grid-template-columns: minmax(0, 1fr) 44px;
  align-content: center;
  gap: var(--space-1) var(--space-4);
  padding: var(--space-3) var(--space-4);
  background: color-mix(in srgb, var(--sidebar) 88%, transparent);
  backdrop-filter: blur(20px);
  border-right: 1px solid var(--rule);
  border-bottom: 1px solid var(--rule);
}
.brand-lockup {
  min-width: 0;
  display: flex;
  align-items: center;
  gap: var(--space-2);
  text-decoration: none;
}
#rt-logo {
  flex: 0 1 120px;
  width: 120px;
  min-width: 100px;
  max-height: 28px;
  height: auto;
  display: block;
  object-fit: contain;
}
.brand-product {
  min-width: 0;
  padding-left: var(--space-2);
  border-left: 1px solid var(--rule-strong);
  color: var(--ink);
  font: 750 var(--text-caption-2)/1 var(--display);
  letter-spacing: 0.12em;
  white-space: nowrap;
}
.brand-caption { grid-column: 1; }
#theme-toggle {
  grid-column: 2;
  grid-row: 1 / span 2;
  align-self: center;
  width: 44px;
  height: 44px;
  display: grid;
  place-items: center;
  border: 1px solid var(--rule);
  border-radius: var(--radius-sm);
  background: var(--surface);
  color: var(--ink-soft);
  cursor: pointer;
  transition: background var(--transition), color var(--transition), border-color var(--transition);
}
#theme-toggle:hover { background: var(--surface-hover); color: var(--ink); border-color: var(--rule-strong); }
#mobile-menu-toggle { display: none; }
:root[data-theme="light"] #rt-logo { filter: invert(1); }

/* Preserve room for the brand at compact desktop widths without surrendering
   the theme control or shrinking either control below its touch target. */
@media (min-width: 821px) and (max-width: 1100px) {
  #syzygy-app { --sidebar-width: 264px; }
  .brand-product { display: none; }
}

#studio-nav {
  grid-area: nav;
  /* Fills its shell row and scrolls inside it. It used to be sized against the
     viewport (`100vh - header`) and pinned with `sticky`, which is the same
     arithmetic done twice — once here and once by the browser — and drifted
     whenever --header-height did. */
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  padding: var(--space-3) var(--space-3) var(--space-4);
  background: color-mix(in srgb, var(--sidebar) 88%, transparent);
  backdrop-filter: blur(20px);
  border-right: 1px solid var(--rule);
  scrollbar-width: thin;
  scrollbar-color: var(--rule-strong) transparent;
}
.nav-group-label {
  margin: 10px var(--space-3) 6px;
  color: var(--muted-2);
  font: 800 var(--text-micro)/1 var(--mono);
  letter-spacing: 0.17em;
  text-transform: uppercase;
}
.nav-group-label:first-child { margin-top: 2px; }
.nav-group-label--accent { color: var(--accent-strong); }
.nav-link {
  /* 36px keeps every group (through Brand & system) on-screen on ~900px-tall
     laptops without giving up a comfortable hit target. */
  min-height: 36px;
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: 6px var(--space-3);
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  color: var(--muted);
  text-decoration: none;
  font-size: var(--text-footnote);
  font-weight: 700;
  transition: color var(--transition), background var(--transition), border-color var(--transition), transform var(--transition);
}
.nav-link:hover { color: var(--ink); background: var(--surface); transform: translateX(2px); }
.nav-link.is-active {
  color: var(--accent-ink);
  background: var(--accent-fill);
  border-color: rgba(255, 255, 255, 0.16);
  box-shadow: var(--elev-2), var(--rim-strong);
}
.nav-mark {
  width: 24px;
  flex: 0 0 24px;
  text-align: left;
  color: var(--muted-2);
  font: 800 var(--text-micro)/1 var(--mono);
  letter-spacing: 0.08em;
}
.nav-link.is-active .nav-mark { color: var(--accent-ink-soft); }

/* One-Shot hero nav + quiet nav (classes set in main.rs — integrator-owned). */
.nav-link--hero {
  flex-direction: column;
  align-items: flex-start;
  gap: 5px;
  min-height: auto;
  padding: var(--space-3);
  margin-bottom: var(--space-2);
  background: var(--accent-fill);
  color: var(--accent-ink);
  border-color: rgba(255, 255, 255, 0.16);
  box-shadow: var(--elev-3), var(--rim-strong);
  font-size: var(--text-callout);
  font-weight: 800;
  letter-spacing: -0.2px;
}
.nav-link--hero:hover { background: var(--accent-fill-hover); color: var(--accent-ink); transform: translateX(2px); }
.nav-link--hero.is-active { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
.nav-link--hero .nav-link-sub {
  font: 600 var(--text-caption-2)/1.35 var(--sans);
  letter-spacing: 0;
  text-transform: none;
  color: var(--accent-ink-soft);
}
.nav-link--quiet { font-size: var(--text-caption); color: var(--muted); }
.nav-link--quiet.is-active { color: var(--accent-ink); }
/* Sign out pins to the sidebar's floor, past a hairline, off the main path. */
.nav-signout { margin-top: auto; padding-top: var(--space-2); border-top: 1px solid var(--rule); }
.nav-signout .nav-link { width: 100%; background: transparent; cursor: pointer; text-align: left; }
.nav-signout .nav-link:hover { background: var(--surface); }

#wordmark {
  grid-area: wordmark;
  min-width: 0;
  /* Same exact height as #masthead: both live in the shared header grid row,
     so either one growing past --header-height breaks every sticky offset. */
  height: var(--header-height);
  min-height: var(--header-height);
  overflow: hidden;
  display: flex;
  align-items: center;
  gap: var(--space-6);
  padding: var(--space-4) clamp(var(--space-6), 4vw, var(--space-12));
  border-bottom: 1px solid var(--rule);
  background: color-mix(in srgb, var(--bg) 88%, transparent);
}
.workspace-heading {
  min-width: 0;
  width: 100%;
  display: grid;
  grid-template-columns: minmax(240px, 0.8fr) minmax(320px, 1.2fr);
  grid-template-rows: auto auto;
  align-items: center;
  column-gap: var(--space-12);
}
.micro {
  color: var(--muted-2);
  font: 800 var(--text-micro)/1.3 var(--mono);
  letter-spacing: 0.15em;
  text-transform: uppercase;
}
.workspace-kicker { grid-column: 1; color: var(--accent-strong); }
#workspace-title {
  grid-column: 1;
  margin: var(--space-1) 0;
  color: var(--ink);
  font: 900 clamp(1.7rem, 3.2vw, 2.3rem)/1.02 var(--display);
  letter-spacing: -0.032em;
}
#definition { grid-column: 2; grid-row: 1 / span 2; max-width: 64ch; margin: 0; color: var(--muted); font-size: var(--text-footnote); }

#studio-workspace {
  grid-area: workspace;
  min-width: 0;
  /* The application's scroll container. `min-height: 0` is load-bearing: a grid
     item's default `min-height: auto` refuses to shrink below its content, so
     without it this pane grows to fit and pushes the scrollbar back onto the
     document — exactly the layout this shell exists to remove.
     `overscroll-behavior` stops a wheel gesture that reaches the end of this
     pane from continuing into the page behind it. */
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  /* Bottom gutter reserves the always-mounted #render-tray pill's footprint
     (fixed, bottom-right) so trailing content — including bottom-right mode
     banners — never underlaps it. The pill is not moved. */
  padding: var(--space-8) clamp(var(--space-6), 4vw, var(--space-12))
    calc(var(--space-16) + var(--space-12));
}
#studio-workspace > * { width: min(100%, var(--content-max)); margin-inline: auto; }

/* Shared type, controls, and surfaces */
.screen { width: min(100%, var(--content-max)); display: grid; gap: var(--space-6); margin: 0 auto; }
.screen > *, #studio-workspace > * > * { min-width: 0; }
.section-title { margin: var(--space-1) 0 0; color: var(--ink); font: 800 var(--text-caption)/1.3 var(--sans); letter-spacing: 0.09em; text-transform: uppercase; }
.section-head, .row, .row-actions, .row-main, .btn-row, .field-row, .chip-group, .bulk-bar, .bulk-actions, .inspector-head, .inspector-actions {
  display: flex;
  align-items: center;
}
.section-head, .inspector-head { justify-content: space-between; gap: var(--space-4); }
.row-main { min-width: 0; flex: 1; align-items: flex-start; flex-direction: column; gap: var(--space-1); }
.row-main strong, .row-main h1 { overflow-wrap: anywhere; }
/* Screen titles are real <h1>s now. Strip UA heading defaults so an h1 renders
   byte-identically to the <strong> it replaced: inherited size/line box, no
   margins, weight 700 (what strong's `bolder` resolved to on the 400 body).
   `#oneshot-intro h1` (id specificity) still wins its display-face rule. */
.screen h1 { margin: 0; font-size: inherit; line-height: inherit; font-weight: 700; }
/* Campaigns and Not-found already had a real heading — an <h2> with no rule of
   its own, i.e. pure UA metrics. Promoted to <h1> so every screen has exactly
   one; these pin the UA h2 box (1.5em, 0.83em block margins, bold) so nothing
   moves. Longhands on purpose, so the design-freeze font extractor never sees them. */
#campaigns-title, #notfound-heading { margin: 0.83em 0; font-size: 1.5em; font-weight: 700; }
.row-actions, .btn-row, .field-row, .chip-group, .bulk-bar, .bulk-actions, .inspector-actions { flex-wrap: wrap; gap: var(--space-2); }
.dim { color: var(--muted); font-size: var(--text-caption); }
.status { min-height: 1.2em; margin: 0; color: var(--warm); font-size: var(--text-caption); }
.error, .render-job-error { color: var(--danger); }

/* ── ELEVATION — the lift rule. Cards float on the field; the field NEVER shows
   through a card (opaque --surface). One rung per surface, no freelancing. */
.card, .panel, .stat, .cell, .template-card,
.editor-rail, .editor-inspector,
#page-agent, #generate-panel, #oneshot-intro {
  background: var(--surface);
  border: 1px solid var(--rule);
  box-shadow: var(--elev-2), var(--rim);
  transition: transform var(--transition), box-shadow var(--transition),
              border-color var(--transition), background var(--transition);
}
.card:hover, .stat:hover, .template-card:hover, .look-card:hover:not(:disabled) {
  transform: translateY(-2px);
  box-shadow: var(--elev-3), var(--rim-strong);
  border-color: var(--rule-strong);
}
.card--float {
  border-color: color-mix(in srgb, var(--accent) 32%, transparent);
  box-shadow: var(--elev-3), var(--rim-strong), 0 0 0 1px color-mix(in srgb, var(--accent) 12%, transparent);
}

/* In-flow furniture — rung 1. Present, not floating. */
.row, .bulk-bar, .editor-timeline {
  border: 1px solid var(--rule);
  background: var(--surface);
  box-shadow: var(--elev-1);
}
.card, .panel { padding: var(--space-6); border-radius: var(--radius-md); }
.card { display: flex; flex-direction: column; gap: var(--space-4); }
.panel { display: grid; gap: var(--space-4); }
.row { gap: var(--space-4); margin: 0; padding: var(--space-4); border-radius: var(--radius-sm); }
.row:hover { border-color: var(--rule-strong); background: var(--surface-2); }
.rows { display: grid; gap: var(--space-2); margin: 0; padding: 0; list-style: none; }
/* The row's title IS the door — an <a> wrapping the row's <strong>. It must not
   look like body copy that happens to be blue, and it must not carry the link
   underline across the whole row-main block. */
.row-open { color: inherit; text-decoration: none; }
.row-open:hover { text-decoration: underline; text-underline-offset: 2px; }
.row-open:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; border-radius: 4px; }
/* A hook is user content — the subject of its row, never uppercased (§3.2). */
.hook-text { color: var(--ink); font-weight: 700; overflow-wrap: anywhere; }
/* Reschedule opens INSIDE the row's action cluster, so it takes the whole width
   and reads as a tray rather than a control that wandered into the button row.
   `display` stays off this rule on purpose: send.rs toggles flex/none inline. */
.reschedule-editor {
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
  flex-basis: 100%;
  /* min-width:0 on both is load-bearing: .row is a non-wrapping flex box and
     .row-main can shrink to zero, so anything in .row-actions that refuses to
     narrow crushes the row's own title. The global `input { width: 100% }`
     already did exactly that here. */
  min-width: 0;
  margin-top: var(--space-2);
  padding: 10px 11px;
  border: 1px solid var(--rule);
  border-radius: var(--radius-sm);
  background: var(--bg-soft);
}
.reschedule-editor input { width: auto; min-width: 0; flex: 1 1 168px; }
.glass { backdrop-filter: blur(18px); background: var(--glass-bg); box-shadow: var(--elev-4); }

.btn, .btn-sm, .panel button, .oneshot-make-btn, #agent-run, #gen-run {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  border: 1px solid var(--rule-strong);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  color: var(--ink);
  text-decoration: none;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  cursor: pointer;
  transition: transform var(--transition), background var(--transition), color var(--transition), border-color var(--transition), box-shadow var(--transition);
}
.btn, .panel button, #agent-run, #gen-run { min-height: 40px; padding: var(--space-2) var(--space-4); font-size: var(--text-footnote); }
.btn-sm { min-height: 32px; padding: var(--space-2) var(--space-3); font-size: var(--text-caption-2); letter-spacing: 0.08em; text-transform: uppercase; }
.btn:hover, .btn-sm:hover, .panel button:hover, #agent-run:hover, #gen-run:hover { transform: translateY(-1px); background: var(--surface-hover); border-color: var(--rule-strong); }
/* The press state itself lives with the rest of the motion vocabulary at
   "P-4 · CONTROLS ANSWER THE HAND". All this rule has to do here is cancel the
   hover lift before that push-down is applied on top of it. */
.btn:active, .btn-sm:active, .panel button:active { transform: translateY(0); }
.btn--primary, .btn.primary, button.primary, .btn-sm.accent, .oneshot-make-btn, #agent-run, #gen-run,
.panel button.btn--primary, .panel button.primary {
  border-color: transparent;
  /* The core action is PRIMARY, never the upsell accent. Using the paid colour
     for ordinary buttons is what made "upgrade" invisible and the whole app feel
     arbitrary — the reference reserves its gradient strictly for paid CTAs. */
  background: var(--primary);
  color: var(--primary-ink);
  box-shadow: var(--elev-2);
}
.btn--primary:hover, .btn.primary:hover, button.primary:hover, .btn-sm.accent:hover, .oneshot-make-btn:hover, #agent-run:hover, #gen-run:hover {
  background: var(--primary-hover);
  color: var(--primary-ink);
  box-shadow: var(--elev-3);
}
.btn--primary:active, .btn.primary:active, button.primary:active, .btn-sm.accent:active, .oneshot-make-btn:active, #agent-run:active, #gen-run:active {
  background: var(--primary-press);
  color: var(--primary-ink);
}
.btn--ghost { background: transparent; }
.btn--danger, .btn.danger, button.danger { border-color: color-mix(in srgb, var(--danger) 42%, transparent); background: color-mix(in srgb, var(--danger) 8%, transparent); color: var(--danger); }
.btn--danger:hover, .btn.danger:hover, button.danger:hover { background: color-mix(in srgb, var(--danger) 16%, transparent); border-color: var(--danger); }
button:disabled, .btn:disabled, .btn-sm:disabled {
  transform: none !important;
  box-shadow: none !important;
  opacity: 0.42;
  cursor: not-allowed;
  filter: saturate(0.55);
}
/* `summary` is in this list because it is focusable by default and therefore
   opts OUT of a ring it was never named in: `summary#editor-hook-style-head`
   drew the user agent's own `outline: auto 1px` instead of the 2px `--ring` at
   offset 2px that every other control here gets. Line ~1386 covers
   `.oneshot-stock > summary` only, which is why one disclosure looked right and
   the other did not. */
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, summary:focus-visible, [tabindex]:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
}

label, .field-label { color: var(--muted); font-size: var(--text-caption-2); font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; }
input, select, textarea, .panel input, .panel select, .panel textarea,
#agent-task, #gen-prompt, #gen-format, .rail-head select, .editor-inspector .field input,
.lyric-row input, .timing-row .clip-dur-input {
  width: 100%;
  min-height: 40px;
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--rule);
  border-radius: var(--radius-sm);
  background: var(--bg-soft);
  color: var(--ink);
  font-size: var(--text-footnote);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02);
}
textarea { min-height: 96px; resize: vertical; }
input::placeholder, textarea::placeholder { color: var(--muted-2); }
input:hover, select:hover, textarea:hover { border-color: var(--rule-strong); }
input[type="checkbox"], input[type="radio"] { width: 17px; min-height: 17px; accent-color: var(--accent); }
input[type="file"] { padding: var(--space-2); }
input[type="file"]::file-selector-button {
  margin-right: 10px;
  padding: 7px 10px;
  border: 0;
  border-radius: 7px;
  background: var(--surface-3);
  color: var(--ink-soft);
  font-weight: 700;
  cursor: pointer;
}
.field { display: flex; min-width: 0; flex-direction: column; gap: 6px; }
.field-row > .field, .form-grid > .field { flex: 1 1 180px; }
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--space-4); }
.field-toggle, .check-row, .bulk-all { display: inline-flex; align-items: center; gap: var(--space-2); }
/* Shared control-panel grammar. These classes are emitted by the style panels,
   FX/ADSR dialogs and crop controls; without rules, panel groups collapsed
   together, <hr> used a UA border, and both colour inputs and crop silhouettes
   had no intentional shape. */
.stack { min-width: 0; display: grid; gap: var(--space-3); }
.divider { width: 100%; height: 0; margin: var(--space-1) 0; border: 0; border-top: 1px solid var(--rule); }
.swatch {
  flex: 0 0 auto;
  display: inline-block;
  border: 1px solid var(--rule-strong);
  border-radius: 6px;
  background: var(--surface-3);
}
.swatch--dashed { border-style: dashed; }
input.swatch { width: 32px; height: 32px; min-height: 32px; padding: 2px; cursor: pointer; }

.badge, .pill {
  display: inline-flex;
  width: fit-content;
  align-items: center;
  gap: 5px;
  padding: 5px 9px;
  border: 1px solid var(--rule);
  border-radius: 999px;
  background: var(--surface-3);
  color: var(--muted);
  box-shadow: var(--elev-1);
  font: 800 var(--text-caption-2)/1.2 var(--mono);
  letter-spacing: 0.09em;
  text-transform: uppercase;
}
.badge--success, .pill.posted, .pill.status-posted, .pill.status-rendered { border-color: color-mix(in srgb, var(--success) 28%, transparent); background: color-mix(in srgb, var(--success) 8%, transparent); color: var(--success); }
.badge--danger, .pill.failed { border-color: color-mix(in srgb, var(--danger) 28%, transparent); background: color-mix(in srgb, var(--danger) 8%, transparent); color: var(--danger); }
.pill.status-scheduled { color: var(--warm); }
/* A simulated (dry-run) send is a normal state, not a fault: it borrows the same
   colour-only treatment as .status-scheduled — an existing token, no new
   surface or border — so it reads as distinct from a real publish (green)
   without borrowing the danger red. */
.pill.status-simulated { color: var(--cyan); }
.chips { display: flex; flex-wrap: wrap; gap: 7px; margin: 0; padding: 0; list-style: none; }
.chips button.pill { cursor: pointer; }
.chips button.pill.active { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-strong); }

/* Overview, dashboard, and content grids */
.launchpad, #dashboard, .screen[aria-label="Publishing"] { grid-template-columns: repeat(12, minmax(0, 1fr)); }
.launchpad > *, #dashboard > *, .screen[aria-label="Publishing"] > * { grid-column: 1 / -1; }
#launchpad-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
#launchpad-grid .card { height: 100%; }
#launchpad-grid .card .btn { width: 100%; margin-top: auto; }
#dashboard-aesthetics { grid-column: span 6; }

/* Dashboard orientation rail — Set up → Make → Ship, one primary next action
   per region. Reuses existing tokens; only the rail's own layout is new. */
#dashboard-orient { display: grid; gap: var(--space-3); }
.orient-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: var(--space-4); }
.orient-region {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  padding: var(--space-4);
  border: 1px solid var(--rule);
  border-radius: var(--radius-md);
  background: var(--surface-2);
}
.orient-region-head { display: flex; flex-direction: column; gap: var(--space-1); }
.orient-label { color: var(--accent-strong); }
.orient-cta { min-height: 44px; width: 100%; }
.orient-links { display: flex; flex-wrap: wrap; gap: var(--space-2); margin: 0; padding: 0; list-style: none; }
.orient-links a {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: var(--space-1) var(--space-2);
  border-radius: var(--radius-sm);
  color: var(--muted);
  font-size: 12px;
  font-weight: 700;
  text-decoration: none;
}
.orient-links a:hover { color: var(--ink); background: var(--surface-hover); }
.orient-links a:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
#dashboard-in-progress { color: var(--warm); font-weight: 700; }
#dashboard-attention { grid-column: span 6; }
.screen[aria-label="Publishing"] #send-accounts,
.screen[aria-label="Publishing"] #send-actions,
.screen[aria-label="Publishing"] #ai-caption-studio,
.screen[aria-label="Publishing"] #connect-accounts { grid-column: span 6; align-content: start; }

#create-draft {
  grid-template-columns: repeat(12, minmax(0, 1fr));
  align-items: end;
}
#create-draft > label:first-child,
#create-draft-hint,
#create-draft > .status { grid-column: 1 / -1; }
#draft-open-modal {
  grid-column: span 3;
  width: 100%;
  white-space: nowrap;
}
#draft-kind { grid-column: span 3; }
#draft-count { grid-column: span 2; }
#draft-preset { grid-column: span 4; }
#draft-run { grid-column: span 3; width: 100%; }

#media-upload {
  grid-template-columns: minmax(0, 1fr) auto auto;
  align-items: end;
}
#media-upload > label, #media-upload > .status { grid-column: 1 / -1; }
#media-upload-run, #media-phone-upload { white-space: nowrap; }

.stat-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(144px, 1fr)); gap: var(--space-4); }
.stat {
  min-height: 92px;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  gap: var(--space-2);
  padding: var(--space-4);
  border-radius: var(--radius-md);
  color: var(--ink);
  text-decoration: none;
}
.stat:hover { border-color: color-mix(in srgb, var(--accent) 35%, transparent); background: var(--surface-2); }
.stat-num { font: 800 var(--text-large-title)/1 var(--num); letter-spacing: -0.02em; font-variant-numeric: tabular-nums slashed-zero; }
.stat-label { color: var(--muted); font: 800 var(--text-micro)/1.2 var(--mono); letter-spacing: 0.11em; text-transform: uppercase; }
.stat--danger .stat-num { color: var(--danger); }
.grid, .card-grid, .template-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(224px, 1fr)); gap: var(--space-4); }
/* A .card that is a link and reads as one line: label left, arrow right.
   .card is a column flex box, so the row direction has to be said out loud. */
.media-oneshot-hint {
  flex-direction: row;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-4);
  color: var(--ink);
  text-decoration: none;
}
.media-oneshot-hint:hover { border-color: var(--rule-strong); }
#media-summary, #media-empty, #media-error {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto auto;
  align-items: center;
}
.cell {
  margin: 0;
  overflow: hidden;
  border-radius: var(--radius-md);
  box-shadow: var(--elev-2), var(--rim);
}
.cell:hover { border-color: var(--rule-strong); }
.cell img { width: 100%; aspect-ratio: 9 / 13; display: block; object-fit: cover; background: var(--thumb-backing); }
.cell figcaption { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px; color: var(--muted); font-size: var(--text-caption-2); overflow-wrap: anywhere; }
.cell.audio { min-height: 180px; display: grid; place-items: center; }
.audio-glyph { color: var(--cyan); font-size: 2.2rem; text-shadow: 0 0 28px color-mix(in srgb, var(--cyan) 32%, transparent); }
/* ── P-3 · WAITING LOOKS DESIGNED ──────────────────────────────────────────
   A skeleton is a promise about SHAPE. Its whole job is that when the content
   lands, nothing under it moves — so a skeleton that is not the size of the
   thing it stands in for is worse than no skeleton at all: it reserves the
   wrong space and then jolts the page when the real answer arrives.

   `background-size: 200% 100%` on a three-stop gradient is deliberate and is
   not a dead sweep waiting to be animated. At 200% only the left half of the
   ramp is on screen, so the tile reads as `--surface` easing up to `--surface-3`
   across its width — a soft internal highlight rather than a flat grey block.
   Doubling the size is what makes that ramp gentle instead of a hard band.

   WHY THESE PULSE AND THE BOOT SHELL DOES NOT.
   `app/index.html` says of its own rail placeholders: "No pulse: a skeleton
   that animates draws the eye to the wait." That is right THERE and wrong here,
   and the difference is what else is on the screen. The boot shell is the only
   thing painted — there is no content to be distinguished from, the wait is
   unavoidable and total, and a pulsing outline would be the single moving thing
   in an otherwise dead window, which is precisely "look how long this is
   taking". An in-app skeleton sits INSIDE a screen that has already arrived,
   beside live controls and real copy. Static grey next to live content does not
   read as "still loading", it reads as "this loaded broken". The breath is what
   carries the meaning, and `pulse` is deliberately 0.62→1 over 1.6s — slower
   and shallower than any other loop in the file — so it says "working" without
   competing with the content around it. The two decisions disagree on purpose
   and for the same reason: put motion where it is information. */
.skeleton { background: linear-gradient(90deg, var(--surface), var(--surface-3), var(--surface)); background-size: 200% 100%; }
/* Empty skeleton placeholders have no content to size them — give each the
   footprint of the card it stands in for so loading grids don't collapse.
   Every number below is the measured resting height of the real component, not
   a round guess: `.cell` is a 9:13 media tile, `.stat` already declares its own
   92px floor and needs nothing here, and the three added below were each
   collapsing to their padding alone. */
.cell.skeleton { aspect-ratio: 9 / 13; }
.template-card.skeleton { min-height: 200px; }
/* A `.row` is 16px padding, a title line, a meta line and an action cluster.
   Empty it was 33px tall, so a loading list of six rows occupied a third of the
   space the answer needed and the screen jumped when it came. */
.row.skeleton { min-height: 76px; }
/* `#formats-loading` — a whole panel standing in for a screen's worth of
   content. 24px padding on its own made a 49px bar that read as a broken
   divider rather than as a panel on its way. */
.panel.skeleton { min-height: 240px; }
/* Deliberately NOT added: `.look-card.skeleton`. The look gallery's loading
   state uses `.cell skeleton`, which already has the 9:13 shape, and a rule
   with no markup behind it is the same rot as markup with no rule — see the
   handoff note at the foot of this file. */

.board-toolbar { display: flex; flex-direction: column; gap: var(--space-3); }
.chip-group > .micro { min-width: 66px; }
.date-window { display: inline-flex; align-items: center; gap: var(--space-2); }
#date-window-label { min-width: 150px; text-align: center; color: var(--muted); }
.bulk-bar { padding: var(--space-3) 14px; border-radius: var(--radius-sm); }
.bulk-bar .status, .bulk-bar .micro { flex-basis: 100%; }
.bulk-check { flex: 0 0 17px; }
@media (hover: hover) {
  .row .row-actions { opacity: 0.55; transition: opacity var(--transition); }
  .row:hover .row-actions, .row:focus-within .row-actions { opacity: 1; }
}

.caption-text { user-select: all; color: var(--ink-soft); line-height: 1.55; }
.hashtag-line { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px var(--space-3); border: 1px solid var(--rule); border-radius: var(--radius-sm); background: var(--bg-soft); }

/* ── HONESTY SURFACES — beauty is not an excuse to lie. These read LOUDER after
   the redesign: opaque, on the page (never behind it), never dimmed or pulsed,
   and carried on size AND colour AND a bar so no single failure can erase them.

   Every colour here is now derived from the THEME's own --warm / --success, not
   from a frozen literal. It used to be `rgba(255, 180, 94, .55)` — the retired
   dark palette's amber — applied to both themes, so in light the banner's
   outline measured 1.34:1 against its own fill and 1.39:1 against the page:
   a DRY-RUN banner whose extent nobody could see. The fill itself carries only
   1.04:1 in light (#fffbeb on white), so the outline IS the surface's edge; it
   is drawn at full token strength rather than a fraction of a colour that no
   longer exists. Measured light 4.75 / 4.79:1, dark 8.07 / 7.20:1. */
.mode-banner {
  position: relative;
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  padding: var(--space-3) 14px;
  border: 1px solid var(--rule);
  border-radius: var(--radius-sm);
  box-shadow: var(--elev-1);
  font-size: var(--text-footnote);
  font-weight: 600;
}
.mode-banner.mode-dry, .mode-dry {
  border-color: color-mix(in srgb, var(--warm) 55%, transparent);
  border-left: 4px solid var(--warm);
  background: var(--mode-dry-bg);
  color: var(--warm);
}
.mode-banner.mode-live, .mode-live {
  border-color: color-mix(in srgb, var(--success) 52%, transparent);
  border-left: 4px solid var(--success);
  background: var(--mode-live-bg);
  color: var(--success);
}
.badge--warn {
  border-color: color-mix(in srgb, var(--warm) 55%, transparent);
  background: var(--mode-dry-bg);
  color: var(--warm);
  font-weight: 800;
}
.badge--warn::before {
  content: "";
  width: 6px; height: 6px; flex: 0 0 6px;
  border-radius: 50%;
  background: currentColor;
  box-shadow: 0 0 8px currentColor;
}
/* A caution note, as opposed to a caution chip. `fx_modal.rs` renders
   `<p class="micro warn">` for "this opacity will make the overlay invisible"
   and "this shadow offset puts the shadow outside the frame", and `.warn`
   matched nothing — so both rendered as ordinary `.micro`: 9px, `--muted-2`,
   indistinguishable from the help text above them. A warning nobody can see is
   the same defect as a DRY-RUN banner with an invisible boundary.

   Not colour alone. `.micro` is 9px and these sit directly under the control
   they are about, so the rule down the leading edge — the same mark
   `.mode-banner` already uses to say "state, not decoration" — is what
   separates "advice" from "this setting is currently broken" for anyone who
   cannot use the hue. `--warm` measures 5.25:1 on the card in light and
   8.28:1 in dark.

   A border rather than a `::before` glyph on purpose: both call sites render
   the `<p>` unconditionally and put an empty string in it when there is
   nothing to warn about, so generated content would leave a marker floating
   under a healthy control. An empty block is zero-height, so its border draws
   nothing — the treatment appears exactly when there is a warning to carry.

   Ordering matters and is not accidental: `.warn` and `.micro` are both one
   class, so this rule wins on the colour only because it is LATER in the file.
   Do not move it above `.micro`. */
.warn {
  border-inline-start: 3px solid var(--warm);
  padding-inline-start: var(--space-2);
  color: var(--warm);
  font-weight: 800;
}

.mode-banner, .badge--warn, .badge--danger, .warn, .status, .error, .render-job-error {
  opacity: 1 !important;
  animation: none !important;
  filter: none !important;
}
button:disabled .badge--warn,
.is-loading .mode-banner { opacity: 1 !important; }

/* One-Shot: guided, obvious, and prerequisite-aware. */
.oneshot { grid-template-columns: repeat(12, minmax(0, 1fr)); }
.oneshot > * { grid-column: 1 / -1; }
#oneshot-intro {
  padding: var(--space-6);
}
#oneshot-intro strong, #oneshot-intro h1 { font: 900 clamp(1.5rem, 3vw, 2.15rem)/1 var(--display); letter-spacing: -0.032em; }
#oneshot-step-song, #oneshot-step-photos { grid-column: span 6; }
#oneshot-step-look { grid-column: 1 / -1; }
#oneshot-step-make { grid-column: 1 / -1; padding: clamp(20px, 3vw, 28px); }
.oneshot-step-head { display: flex; align-items: center; gap: 10px; }
.oneshot-step-head strong { flex: 1; font-size: var(--text-callout); }
.oneshot-step-num {
  width: 28px;
  height: 28px;
  flex: 0 0 28px;
  display: grid;
  place-items: center;
  border: 1px solid color-mix(in srgb, var(--accent) 32%, transparent);
  border-radius: 9px;
  background: var(--accent-soft);
  color: var(--accent-strong);
  font: 700 var(--text-caption-2)/1 var(--mono);
}
.oneshot-controls { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 10px var(--space-3); }
.oneshot-controls input[type="file"], .oneshot-controls button { grid-column: 1 / -1; }
.oneshot-start { width: 100%; min-width: 0; }
.oneshot-start input { min-width: 120px; flex: 1 1 180px; }
.oneshot-lyrics-search { display: grid; grid-template-columns: minmax(110px, auto) minmax(0, 1fr); align-items: center; gap: var(--space-2) var(--space-3); }
.oneshot-lyrics-search .btn { grid-column: 2; justify-self: start; }
/* `.oneshot-controls` is a grid, so it overrides the browser's default
   details disclosure rule. Restore the closed state explicitly; optional stock
   search should not occupy the same visual tier as the required uploads. */
details.oneshot-stock:not([open]) > .oneshot-controls { display: none; }
.oneshot-songs, .oneshot-aesthetics { display: grid; gap: 7px; margin: 0; padding: 0; list-style: none; }
.oneshot-song, .oneshot-aesthetic { display: flex; align-items: center; gap: 10px; padding: 9px 11px; border: 1px solid var(--rule); border-radius: var(--radius-sm); background: var(--bg-soft); }
.oneshot-song-name, .oneshot-aesthetic-name { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.oneshot-make-btn { width: 100%; min-height: 58px; font-size: var(--text-callout); }
.action-readiness {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 10px var(--space-3);
  border: 1px solid var(--rule);
  border-radius: var(--radius-sm);
  background: var(--bg-soft);
  color: var(--muted);
  font-size: var(--text-caption);
}
.readiness-dot { width: 8px; height: 8px; flex: 0 0 8px; border-radius: 50%; background: var(--muted-2); box-shadow: 0 0 0 4px color-mix(in srgb, var(--muted-2) 12%, transparent); }
.action-readiness.is-ready { border-color: color-mix(in srgb, var(--success) 28%, transparent); color: var(--success); background: color-mix(in srgb, var(--success) 7%, transparent); }
.action-readiness.is-ready .readiness-dot { background: var(--success); box-shadow: 0 0 0 4px color-mix(in srgb, var(--success) 12%, transparent), 0 0 16px color-mix(in srgb, var(--success) 38%, transparent); }
.oneshot-progress, progress { width: 100%; height: 7px; overflow: hidden; border: 0; border-radius: 999px; background: var(--surface-3); accent-color: var(--accent); }
progress::-webkit-progress-bar { background: var(--surface-3); }
progress::-webkit-progress-value { background: linear-gradient(90deg, var(--accent), var(--cyan)); border-radius: inherit; }
/* ── P-8 · NUMBERS MOVE ─────────────────────────────────────────────────────
   A render's progress arrives as a poll: the server is asked every couple of
   seconds and answers 5, then 5, then 5, then 88, then 100. Written straight to
   the element that is a HARD CUT — the bar teleports two thirds of the way
   across the panel between two frames, which reads as a glitch rather than as
   work being done, and it is the most-watched pixel in the product because it
   is what a person stares at while they wait.

   THE OBVIOUS FIX DOES NOT WORK, AND IT WAS MEASURED RATHER THAN ASSUMED.
   `progress::-webkit-progress-value { transition: width … }` is the standard
   recipe for this and it is quoted everywhere. It was written here, shipped
   into a real Chrome against a real build, and instrumented: a `<progress>` was
   moved from value 5 to value 95 and its `transitionrun` / `transitionstart` /
   `transitionend` listeners — which do fire for pseudo-elements, and report
   which one via `event.pseudoElement` — recorded NOTHING. Not with the rule on
   the pseudo alone, and not with `appearance: none` on the host, which is the
   usual explanation offered when it fails. In the same page, in the same frame,
   a plain `<div>` with `transition: width 320ms` fired `transitionend` at
   0.32s exactly. The engine simply does not run author transitions on the width
   the UA computes for that pseudo-element any more.

   So the rule is NOT here. A declaration that provably does nothing is worse
   than an absent one: the next person reads it, believes the bar ticks, and
   stops looking. This is the honest state instead — the classes for a bar that
   CAN tick, and a named wiring job.

   WIRING JOB (P-8, open). `<progress>` cannot be animated, so the two callers
   need the ordinary two-element bar below, which is also the WAI-ARIA
   `progressbar` pattern and keeps everything a screen reader had:

     <div class="progress-track" role="progressbar"
          aria-valuenow="42" aria-valuemin="0" aria-valuemax="100"
          aria-label="Render progress">
       <div class="progress-fill" style="width: 42%"></div>
     </div>

   Indeterminate — which is the honest state for most of a Syzygy render, because
   the server parks at 5 until it is done — is the same track with no
   `aria-valuenow` and no `.progress-fill`, so nothing claims a percentage it
   does not have. That distinction already exists in the Rust
   (`render_tray.rs::progress_view`, `ProgressView::Indeterminate`) and must
   survive the swap.

     · `oneshot.rs:3727` and `:3739` — the determinate and indeterminate
       `<progress id="oneshot-progress" class="oneshot-progress">`.
     · `render_tray.rs:246` and `:250` — the same pair, per job.

   `--dur-stage`, not a new number. The ledger's ceiling is 600ms and the
   temptation is to spend all of it, but this file has four durations and
   inventing a fifth for one bar is the exact drift the motion table was written
   to stop. 320ms is already reserved for "the one surface this screen is
   about", and while a render is running the progress bar IS that surface. Eased
   OUT, so a jump from 5% to 88% leaves fast and settles, which is what makes it
   read as catching up rather than as sliding.

   `width` here is not a P-10 violation: P-10 is about hover and enter states
   moving a box under the cursor. This is a progress indicator whose entire
   purpose is to change width, it sits inside its own fixed-height track, and it
   shifts nothing around it. `app/motion-check.mjs` scopes its layout ban to
   `:hover` / `:active` selectors for exactly that reason.

   Reduced motion snaps: the wildcard in the reduce block collapses
   `transition-duration` for every element in the document, so a reader who
   asked for less motion gets the number immediately. */
.progress-track {
  width: 100%;
  height: 7px;
  overflow: hidden;
  border-radius: 999px;
  background: var(--surface-3);
}
.progress-fill {
  height: 100%;
  border-radius: inherit;
  background: linear-gradient(90deg, var(--accent), var(--cyan));
  transition: width var(--dur-stage) var(--ease-out);
}

/* The other half of P-8 — a number printed as TEXT (the quota counters, the
   render count, the line count) — cannot be tweened by CSS from an element that
   simply contains new text. It needs the value handed over as a number rather
   than as a string, which is a markup change and therefore not this file's to
   make. The class and its contract are defined here so the markup pass is a
   one-line edit rather than a design decision; it is listed with the other open
   wiring jobs at the foot of this file, and it is applied to nothing yet, so it
   costs nothing until it is.

   Contract:  <span class="num-tick" style="--tick: 42"></span>
   The element must be EMPTY — the digits are drawn by the counter, so a value
   in the markup would print twice. Registering `--tick` as an `<integer>` is
   what makes it animatable at all; an unregistered custom property is a string
   and jumps. `counter-reset` re-reads it every frame of the transition, so the
   digits count rather than cross-fade, and the integer syntax means only whole
   numbers are ever shown on the way. A browser without `@property` ignores the
   at-rule and lands on the final value with no transition, which is the correct
   degradation: the number is right, it just does not travel. */
@property --tick {
  syntax: "<integer>";
  inherits: false;
  initial-value: 0;
}
.num-tick {
  /* 480ms, inside the ledger's 600ms ceiling and deliberately slower than the
     bar. This is the one duration outside the table and the justification is
     legibility, not taste: at 320ms a three-digit counter renders about eight
     distinct values and reads as flicker, where half a second lets the eye
     follow the run. Named as its own token so the next person changes it here
     and once. */
  --tick-dur: 480ms;
  transition: --tick var(--tick-dur) var(--ease-out);
  counter-reset: tick var(--tick);
  font-variant-numeric: tabular-nums slashed-zero;
}
.num-tick::after { content: counter(tick); }
.oneshot-result { display: flex; flex-wrap: wrap; gap: 9px; }
/* The finished render, playable in the page. Takes the whole row so the
   download/signup controls wrap underneath it. Renders are 9:16, so it is
   capped by height rather than width or a tall phone frame fills the viewport. */
.oneshot-player { flex: 1 0 100%; width: 100%; max-height: 58dvh; border-radius: var(--radius-md); background: #000; object-fit: contain; }
/* Optional stock search, folded away until asked for. `display` is deliberately
   NOT set on the <details> itself: overriding it to flex/grid makes Chromium and
   WebKit paint the closed content, which would put a search box the user never
   opened in the middle of the photo step. */
.oneshot-stock {
  margin-top: var(--space-2);
  padding: var(--space-3);
  border: 1px solid var(--rule);
  border-radius: var(--radius-sm);
  background: var(--bg-soft);
}
.oneshot-stock > summary {
  cursor: pointer;
  color: var(--muted);
  font: 800 10px/1.2 var(--mono);
  letter-spacing: 0.09em;
  text-transform: uppercase;
}
.oneshot-stock > summary:hover { color: var(--ink); }
.oneshot-stock > summary:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; border-radius: 4px; }
.oneshot-stock[open] > summary { margin-bottom: var(--space-3); color: var(--ink); }
.oneshot-stock > *:not(summary) + * { margin-top: var(--space-3); }

/* Generate/agent panels */
#page-agent, #generate-panel { display: grid; gap: var(--space-3); padding: 18px; border-radius: var(--radius-md); }
#agent-log, #gen-log { display: grid; gap: var(--space-2); max-height: 45dvh; overflow: auto; margin: 0; padding: 0; list-style: none; }
#agent-log li, #gen-log li { padding: 10px var(--space-3); border: 1px solid var(--rule); border-radius: var(--radius-sm); background: var(--bg-soft); color: var(--muted); font-size: var(--text-caption); }

/* Templates and previews */
.template-card { overflow: hidden; display: flex; flex-direction: column; gap: 10px; padding: 13px; border-radius: var(--radius-md); }
.template-editor { display: grid; gap: var(--space-4); }
.template-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.template-meta, .template-descriptor { color: var(--muted); font-size: var(--text-caption-2); }
.template-thumb { width: 100%; aspect-ratio: 16 / 10; object-fit: cover; border-radius: var(--radius-sm); background: var(--thumb-backing); }
.tpl-stage { position: relative; width: min(259px, 100%); aspect-ratio: 9 / 16; margin: var(--space-1) auto; overflow: hidden; border: 1px solid var(--rule); border-radius: var(--radius-md); background: linear-gradient(160deg, var(--stage-1), var(--stage-2)); box-shadow: var(--elev-inset); }
.tpl-overlay { position: absolute; left: 5%; width: 90%; display: flex; align-items: center; justify-content: center; text-align: center; line-height: 1.15; font-weight: 700; pointer-events: none; }
.tpl-overlay span { max-width: 100%; overflow-wrap: anywhere; }
.repurpose-platforms, .repurpose-variants { display: grid; gap: var(--space-2); }
.repurpose-platform-row, .repurpose-variant, .repurpose-schedule { display: flex; flex-wrap: wrap; align-items: center; gap: 9px; padding: 10px 11px; border: 1px solid var(--rule); border-radius: var(--radius-sm); background: var(--bg-soft); }
.repurpose-platform-row > label, .repurpose-variant > label { flex: 1; }
.cut-structure, .effect-list, .overlay-list { display: grid; gap: var(--space-2); }
.effect-intensity, .overlay-timing { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--space-2); }
.overlay-row { align-items: stretch; flex-direction: column; }
.overlay-row-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); }
.overlay-type, .overlay-lottie-src { width: 100%; }

/* Modals, menus, tabs, and notifications */
.modal-overlay { position: fixed; inset: 0; z-index: 200; display: grid; place-items: center; padding: 18px; background: rgba(3, 5, 8, 0.72); backdrop-filter: blur(12px); }
.modal-card { width: min(100%, 520px); max-height: min(90dvh, 760px); overflow: auto; padding: 20px; border: 1px solid var(--rule-strong); border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--elev-5), var(--rim); }
.modal-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); margin-bottom: 14px; }
.modal-title { font-size: var(--text-headline); font-weight: 800; letter-spacing: -0.2px; }
.modal-body { display: grid; gap: 13px; }
/* PP-5. A dialog is `width: min(100%, 520px)`, which is a promise it cannot
   keep on its own: one unbreakable string inside it sets the content's
   min-content width and the card then scrolls sideways behind its own rounded
   corner. Measured on a 390px phone, the "Upload from your phone" dialog's
   card was 354px wide around 759px of content — the QR code, which is the only
   thing that dialog exists for, was cut off mid-pattern, the link ran off the
   edge and both sentences under it were clipped mid-word. Nothing about that
   is visible in the numbers: the DOCUMENT does not scroll, because the card
   already carries `overflow: auto` and absorbed it.
   `anywhere`, not `break-word`: a 64-character hex token has no break
   opportunity at all, so only `anywhere` may split it. The image cap is the
   same defect's other half — a QR is served at a fixed pixel size and has no
   reason to be larger than the card holding it. */
#phone-upload-link { overflow-wrap: anywhere; }
.modal-body img { max-width: 100%; height: auto; }

/* The OTHER dialog. `shared_block.rs` renders a bare
   `<div class="modal" role="dialog" aria-modal="true">` — clip animations —
   and until now `.modal` matched nothing in this file, so a dialog that
   announces itself as modal to a screen reader was, on screen, loose text
   inside the panel it interrupts. Same defect shape as a DRY-RUN banner with
   an invisible boundary: the promise is made in one channel and not kept in
   the other.

   It gets the modal surface — rung 5, the top of the ladder, because it IS a
   modal and the ladder is assigned by what a surface is, not by which
   component built it. What it does NOT get from here is position: the element
   is in normal flow at its call site, and lifting it into a fixed, scrimmed,
   centred overlay is a change to `shared_block.rs` (it would need a wrapper —
   a scrim painted by a descendant of a z-indexed dialog paints ON TOP of that
   dialog, which is the same painting-order trap this lane just removed from
   `body`). Reported, not done here. Until it is, this at least makes the
   dialog look like a dialog. */
.modal {
  display: grid;
  gap: 13px;
  width: min(100%, 520px);
  max-height: min(90dvh, 760px);
  overflow: auto;
  margin: var(--space-4) 0;
  padding: 20px;
  border: 1px solid var(--rule-strong);
  border-radius: var(--radius-lg);
  background: var(--surface);
  box-shadow: var(--elev-5), var(--rim);
}

#phone-upload-qr { width: min(280px, 100%); justify-self: center; border-radius: var(--radius-sm); background: #fff; padding: 10px; }
/* flex-shrink: 0 is load-bearing: inside a flex column (.editor-inspector)
   an overflow-x:auto strip otherwise collapses to its min-content height.
   Wrap, don't clip (same contract as .rail-clip): the editor's six inspector
   tabs need ~450px and the inspector track is at most 340px, so a single-line
   strip flush-cut "Overlays" and "Repurpose" at the panel edge with no visible
   scroll cue — two undiscoverable tabs. Wrapping to a second row keeps every
   tab reachable at any width; overflow-x stays as the last resort for a single
   tab wider than its container. */
.tabs { display: inline-flex; flex-wrap: wrap; flex-shrink: 0; width: fit-content; max-width: 100%; overflow-x: auto; gap: 3px; padding: var(--space-1); border: 1px solid var(--rule); border-radius: var(--radius-sm); background: var(--bg-soft); }
.tab { min-height: 34px; flex-shrink: 0; padding: 7px 11px; border: 0; border-radius: 7px; background: transparent; color: var(--muted); cursor: pointer; }
.tab--active { background: var(--surface-3); color: var(--ink); box-shadow: var(--elev-2), var(--rim); }
.dropdown { position: relative; display: inline-block; }
.dropdown-backdrop { position: fixed; inset: 0; z-index: 140; background: transparent; }
.dropdown-menu { position: absolute; top: calc(100% + 6px); right: 0; z-index: 150; min-width: 190px; display: grid; gap: 3px; padding: 6px; border: 1px solid var(--rule-strong); border-radius: var(--radius-sm); background: var(--surface); box-shadow: var(--elev-4), var(--rim); }
.dropdown-item { padding: 9px 11px; border: 0; border-radius: 7px; background: transparent; color: var(--ink-soft); text-align: left; cursor: pointer; }
.dropdown-item:hover { background: var(--surface-hover); color: var(--ink); }
/* The trigger must LOOK open while its menu is open — aria-expanded already
   carries that fact for assistive tech; this is the same fact for everyone else. */
.dropdown-trigger { gap: 6px; }
.dropdown-trigger[aria-expanded="true"] { background: var(--surface-hover); border-color: var(--rule-strong); color: var(--ink); }
.dropdown-caret { font-size: 9px; line-height: 1; transition: transform var(--transition); }
.dropdown-trigger[aria-expanded="true"] .dropdown-caret { transform: rotate(180deg); }
.toast-host { position: fixed; top: 18px; right: 18px; z-index: 300; width: min(360px, calc(100vw - 36px)); display: grid; gap: 9px; pointer-events: none; }
.toast { display: flex; align-items: center; gap: 10px; padding: var(--space-3) 13px; border: 1px solid var(--rule-strong); border-radius: var(--radius-sm); background: var(--glass-bg); backdrop-filter: blur(16px); box-shadow: var(--elev-4), var(--rim); pointer-events: auto; }
.toast-success { border-left: 3px solid var(--success); }
.toast-error { border-left: 3px solid var(--danger); }
.toast-text { flex: 1; font-size: var(--text-caption); }
.toast-success .toast-icon { color: var(--success); }
.toast-error .toast-icon { color: var(--danger); }
/* Dismiss is a .btn-sm, but it is punctuation, not an action: no border, no
   fill, no uppercase tracking — until you reach for it. */
.toast-close {
  flex: 0 0 auto;
  min-height: 28px;
  padding: var(--space-1) var(--space-2);
  border-color: transparent;
  background: transparent;
  color: var(--muted);
  letter-spacing: 0;
}
.toast-close:hover { background: var(--surface-hover); border-color: var(--rule); color: var(--ink); }
/* Entrance only, and it ENDS at fully opaque — a toast that carries an error
   must never be mid-fade when someone reads it. The reduced-motion block below
   collapses the duration; the end state is identical either way. The timing is
   set once by the motion system (`--enter`), further down. */
@keyframes toast-enter {
  from { opacity: 0; transform: translateY(-8px) scale(0.98); }
  to { opacity: 1; transform: none; }
}

/* Render activity */
/* Pill height (42px) + its bottom inset. Kept beside #render-tray so a change
   to the pill's size is made in the same place as the space reserved for it.

   ── THE THUMB BAND (PP-5) ─────────────────────────────────────────────────
   `--tray-inset` is the one place the studio says how far a viewport-anchored
   control must stay off the bottom of the glass, and it is a token rather than
   a number because two different rules used to answer that question with two
   different numbers (16px here, 12px in the phone block) and neither of them
   was right on a phone.

   On a desktop browser the bottom of the window is the bottom of the page and
   16px is simply a margin. On iOS Safari it is not: the VISUAL viewport is
   about 60px shorter than the layout viewport whenever the bottom bar is
   expanded, and the bar is drawn OVER the page rather than beside it. A pill
   at `bottom: 12px` is therefore a pill under the browser chrome — measured at
   390x844 the "Renders" pill's bottom edge sat at y=832 in an 844px viewport,
   on every screen of the funnel, in both themes. Not clipped, not hidden:
   present, visible in a screenshot, and untappable.

   So on a coarse pointer the inset becomes the height of that bar plus
   whatever the device says its own safe area is. `env(safe-area-inset-bottom)`
   alone cannot carry this: it reports 0 unless the document opts in with
   `viewport-fit=cover`, which lives in `app/index.html` and is not this file's
   to set (see the handoff note at the foot of the file). The 60px is therefore
   the load-bearing half and the `env()` is the correction on top of it for
   devices with a home indicator — additive, not a `max()`, because a home
   indicator sits BELOW the toolbar rather than instead of it.

   `--render-tray-clearance` is derived from the same token so the gutter
   `.inspector-dock` reserves for the pill can never drift from where the pill
   actually is. */
:root {
  --thumb-bar: 0px;
  --tray-inset: max(16px, env(safe-area-inset-bottom));
  --render-tray-clearance: calc(42px + var(--tray-inset));
}
#render-tray { position: fixed; right: max(16px, env(safe-area-inset-right)); bottom: var(--tray-inset); z-index: 120; width: min(390px, calc(100vw - 32px)); }
#render-tray-toggle { min-height: 42px; display: block; margin-left: auto; padding: 9px 13px; border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); border-radius: 999px; background: var(--glass-bg); backdrop-filter: blur(16px); color: var(--ink); font-size: var(--text-caption-2); font-weight: 720; cursor: pointer; box-shadow: var(--elev-3), var(--rim); }
#render-tray-panel { max-height: min(70dvh, 620px); overflow: auto; margin-top: var(--space-2); padding: 14px; border: 1px solid var(--rule-strong); border-radius: var(--radius-md); background: var(--glass-bg); backdrop-filter: blur(18px); box-shadow: var(--elev-4), var(--rim); }
.render-tray-head, .render-job-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
#render-tray-list { display: grid; gap: 9px; margin: var(--space-3) 0 0; padding: 0; list-style: none; }
.render-tray-empty, .render-job { padding: 11px; border: 1px solid var(--rule); border-radius: var(--radius-sm); background: var(--bg-soft); }
.render-job p, .render-tray-connection { margin: 6px 0 0; font-size: var(--text-caption-2); }
.render-job-meta, .render-job-message { color: var(--muted); }
.render-job-phase { padding-inline-start: var(--space-2); border-inline-start: 2px solid var(--accent); color: var(--ink-soft); }
.render-job-link { margin-top: var(--space-2); padding: 0; border: 0; background: transparent; color: var(--accent-strong); font-weight: 700; cursor: pointer; }
.render-job-again { display: inline-flex; margin-top: var(--space-2); color: var(--muted); font-size: 11px; font-weight: 700; text-underline-offset: 2px; }
.render-job-again:hover { color: var(--ink); }

/* Clip and board inspectors */
.inspector { margin-bottom: var(--space-1); }
.clips, .lyric-list, .timing-list, .effect-list, .overlay-list { display: grid; gap: 7px; margin: 0; padding: 0; list-style: none; }
.clip-row, .lyric-row, .timing-row, .effect-row, .overlay-row { display: flex; align-items: center; gap: var(--space-2); padding: 9px 10px; border: 1px solid var(--rule); border-radius: var(--radius-sm); background: var(--bg-soft); }
.media-list { display: grid; gap: var(--space-2); margin: 0; padding: 0; list-style: none; }
.media-row { display: grid; gap: var(--space-2); padding: 10px; border: 1px solid var(--rule); border-radius: var(--radius-sm); background: var(--bg-soft); }
.media-row-head { display: grid; grid-template-columns: 42px minmax(0, 1fr) auto; align-items: center; gap: var(--space-2); min-width: 0; }
.media-thumb { width: 42px; height: 42px; display: grid; place-items: center; overflow: hidden; border: 1px solid var(--rule-strong); border-radius: 7px; background: var(--surface-3); color: var(--muted); object-fit: cover; font: 16px/1 var(--mono); }
.media-name { min-width: 0; overflow: hidden; color: var(--ink-soft); font-size: var(--text-caption); font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
.media-row .field { gap: 5px; }
.media-row .field input, .media-row .field select { min-height: 36px; }
.media-row .btn-row > *, #editor-save-media { min-height: 44px; }
#editor-media-global-head { margin-top: var(--space-2); padding-top: var(--space-3); border-top: 1px solid var(--rule); }
.clip-idx { min-width: 20px; color: var(--muted-2); font: 650 var(--text-micro) var(--mono); text-align: center; }
.clip-name { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-soft); font-size: var(--text-caption); }
.clip-ctl { display: flex; gap: var(--space-1); }
.clip-ctl .btn-sm { min-height: 27px; padding: var(--space-1) 7px; }
.trim, .num { display: inline-flex; align-items: center; gap: 5px; }
.num { color: var(--muted); font: var(--text-micro) var(--mono); }
.num input { width: 62px; min-height: 32px; padding: 5px 7px; }
.timing-row .clip-dur-input { width: 70px; flex: 0 0 70px; min-height: 34px; text-align: right; }
.timing-row { flex-wrap: wrap; }
.clip-transition { min-width: 0; flex: 1 1 100%; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding-inline-start: 28px; }
.clip-transition-type { min-width: 130px; flex: 1 1 150px; }
.clip-transition-dir, .clip-transition-mode { width: auto; min-width: 96px; flex: 1 1 110px; }
.clip-transition-dur { width: 76px; min-height: 34px; flex: 0 0 76px; text-align: right; }
.clip-transition-zoom { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.clip-transition-zoom input { flex: 0 0 17px; }
.effect-at { display: inline-flex; align-items: center; gap: 5px; margin-inline-start: auto; white-space: nowrap; }
.effect-at-input { width: 68px; min-height: 32px; padding: 5px 7px; text-align: right; }

/* Numbers align across the app — tabular figures, slashed zero. */
.stat-num, .num, .clip-idx, .nav-mark, .render-job-meta, #date-window-label,
.timing-row .clip-dur-input, .num input {
  font-family: var(--num);
  font-variant-numeric: tabular-nums slashed-zero;
  font-feature-settings: "tnum" 1, "zero" 1;
}

/* Editor: rail, stage, inspector, timeline. */
.editor { display: grid; gap: var(--space-3); }
.editor-rail, .editor-inspector { min-width: 0; display: flex; flex-direction: column; gap: 11px; padding: 13px; border-radius: var(--radius-md); }
/* The inspector's primary actions ride the bottom edge of its scroll area —
   negative margins cancel the panel padding so the dock spans edge to edge. */
.inspector-dock {
  position: sticky;
  bottom: -13px;
  z-index: 5;
  margin: auto -13px -13px;
  padding: 10px 13px 13px;
  border-top: 1px solid var(--rule);
  border-radius: 0 0 var(--radius-md) var(--radius-md);
  background: var(--surface);
}
.inspector-dock .status { margin-top: 6px; }
.rail-head { display: grid; gap: 6px; }
.rail-clips { display: grid; gap: 7px; margin: 0; padding: 0; list-style: none; }
/* Wrap, don't clip: name + badge + 3 reorder controls can't share one line in
   a 240px rail, so the control cluster drops to its own right-aligned row. */
.rail-clip { display: flex; flex-wrap: wrap; align-items: center; gap: 6px var(--space-2); padding: 9px; border: 1px solid var(--rule); border-radius: var(--radius-sm); background: var(--bg-soft); }
/* The name keeps enough width to stay readable; the control cluster wraps to
   its own right-aligned row instead of crushing the name or clipping. */
.rail-clip .clip-name { flex: 1; min-width: 11ch; }
.rail-clip .clip-ctl { margin-left: auto; }
.rail-empty {
  display: grid;
  gap: var(--space-2);
  padding: var(--space-4);
  border: 1px dashed var(--rule-strong);
  border-radius: var(--radius-sm);
  background: var(--bg-soft);
  color: var(--muted);
  font-size: var(--text-caption);
}
.rail-empty strong { color: var(--ink-soft); font-size: var(--text-caption); }
.rail-empty a { color: var(--accent-strong); font-weight: 700; }
.editor-group { padding-top: var(--space-3); border-top: 1px solid var(--rule); color: var(--muted-2); font: 800 var(--text-micro)/1.2 var(--mono); letter-spacing: 0.14em; text-transform: uppercase; }
.editor-stage { min-width: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; }
.stage-guide-control { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 7px; }
.stage-guide-control select { width: auto; min-height: 34px; padding: 6px 28px 6px 9px; }
.stage-9x16 { position: relative; width: min(100%, 350px); aspect-ratio: 9 / 16; overflow: hidden; display: grid; place-items: center; border: 1px solid var(--rule-strong); border-radius: var(--radius-lg); background: radial-gradient(circle at 50% 20%, var(--stage-1), var(--stage-2) 70%); box-shadow: var(--elev-inset), 0 0 0 1px rgba(0, 0, 0, 0.5); }
.stage-media, .stage-player { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.stage-player { z-index: 5; background: #000; }
.stage-safe-zone-overlay { position: absolute; inset: 0; z-index: 6; pointer-events: none; }
.stage-safe-band { position: absolute; left: 4%; right: 4%; border: 1px dashed rgba(255, 255, 255, 0.65); background: rgba(7, 9, 14, 0.12); color: #fff; font: var(--text-micro)/1 var(--mono); }
.stage-safe-band span { position: absolute; top: 2px; left: 3px; padding: 2px var(--space-1); border-radius: 4px; background: rgba(0, 0, 0, 0.62); }
.stage-safe-band--hook { top: 12%; height: 6%; border-color: var(--band-hook); }
.stage-safe-band--tiktok { top: 60%; height: 10%; border-color: var(--band-tiktok); }
.stage-safe-band--shorts { top: 65%; height: 10%; border-color: var(--band-shorts); }
.stage-hook, .stage-caption { position: absolute; left: 6%; right: 6%; z-index: 3; color: #fff; text-align: center; word-break: break-word; text-shadow: 0 2px 0 #000, 0 0 18px rgba(0, 0, 0, 0.7); }
.stage-hook { top: 12%; font: 900 clamp(16px, 4vw, 29px)/1.05 var(--display); letter-spacing: -0.5px; }
.stage-caption { bottom: 12%; font: 700 clamp(13px, 3.2vw, 21px)/1.15 var(--sans); }
/* The editor stage is a size query container so the hook/caption overlays can
   size their text in `cqw` — a fraction of the STAGE, matching the fraction of
   the 1080px render frame the same `font_size` occupies (see `overlay_css`).
   Viewport units cannot express that: they track the window, not the stage. */
#editor-preview { container-type: inline-size; }
.stage-empty {
  position: absolute;
  left: 8%;
  right: 8%;
  top: 50%;
  transform: translateY(-50%);
  /* Was rgba(255,255,255,0.62) — 3.9:1 over the stage gradient, and this block
     carries a LINK. Raised to 0.78 (6.0:1) and off --text-micro, because the
     link inside it was 10px: typography.md puts the interactive floor at
     11pt/11px. Placement, not the hook or caption sizing, so render fidelity is
     untouched. */
  color: rgba(255, 255, 255, 0.78);
  font-size: var(--text-caption-2);
  text-align: center;
}
/* Lives INSIDE the always-dark 9:16 stage, in both themes, so it cannot take a
   theme-relative muted: --muted-2 measured 2.36:1 against the stage in light and
   1.45:1 at increased contrast. Fixed light-on-dark instead — 8.1:1 on the
   lightest stage stop. Colour only; nothing about the stage's sizing moves. */
.stage-meta { position: absolute; left: 0; right: 0; bottom: 10px; color: rgba(255, 255, 255, 0.72); text-align: center; }
.stage-overlay-draggable { cursor: grab; touch-action: none; user-select: none; }
.stage-overlay--dragging { cursor: grabbing; outline: 2px solid var(--cyan); outline-offset: 2px; }
.stage-unsaved { position: absolute; top: 10px; left: 50%; z-index: 7; transform: translateX(-50%); padding: var(--space-1) var(--space-2); border-radius: 999px; background: var(--stage-scrim); color: var(--stage-warn); pointer-events: none; }
.editor-timeline { position: relative; padding: var(--space-3); border-radius: var(--radius-md); }
.timeline-head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); margin-bottom: var(--space-2); }
.timeline-track { position: relative; height: 58px; display: flex; gap: 3px; }
.timeline-clip { min-width: 6px; display: flex; align-items: flex-end; padding: 5px; overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.05); border-radius: 7px; background: linear-gradient(145deg, var(--clip-fill), color-mix(in srgb, var(--clip-fill) 45%, var(--stage-2))); }
.timeline-clip .clip-idx { color: rgba(255, 255, 255, 0.78); }
.timeline-beats { position: absolute; inset: 0; pointer-events: none; }
/* Taps captured but not yet confirmed. Same geometry as .timeline-beats so a
   pending cut lands exactly where the beat it was tapped against sits; the
   colour says "not committed yet". editor.rs supplies only `left`. */
.timeline-taps { position: absolute; inset: 0; pointer-events: none; }
.timeline-tap {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 2px;
  margin-left: -1px;
  background: var(--danger);
  pointer-events: none;
}
.timeline-beat { position: absolute; top: 0; bottom: 0; width: 1px; background: color-mix(in srgb, var(--warm) 70%, transparent); }
.timeline-cut-handle { position: absolute; top: -4px; bottom: -4px; width: 10px; margin-left: -5px; z-index: 4; border-radius: 4px; background: rgba(255, 255, 255, 0.22); cursor: col-resize; touch-action: none; }
.timeline-cut-handle--dragging { background: var(--cyan); box-shadow: 0 0 12px color-mix(in srgb, var(--cyan) 50%, transparent); }
/* Live tap-recording state. --danger, not a literal red: light theme's red is
   #b91c1c and the hardcoded #ff5c5c this replaces measured 3.4:1 on a white
   card — a "Recording" notice you could miss in the theme it was loudest in. */
.tap-recording {
  margin: 6px 0 0;
  color: var(--danger);
  font-size: 12px;
  font-weight: 700;
}
.tap-beat { display: grid; gap: var(--space-2); padding: var(--space-3); border: 1px solid var(--rule); border-radius: var(--radius-sm); background: var(--bg-soft); }
.tap-controls { display: flex; flex-wrap: wrap; gap: var(--space-2); }

/* Desktop editor: one workbench, not three floating cards (2026-07 layout
   council — codex/glm/fable synthesis). The middle track is sized BY the stage
   (auto), never an elastic 1fr, so the old side voids cannot exist; surplus
   width becomes balanced outer margins (justify-content: center). The stage
   width grows with viewport height (never below a usable 300px) and the shared
   row height is derived from it, so all three panels end on one bottom edge
   and inspector tab switches can never reflow the stage or the timeline.
   1280px, not 1050px: below that the post-sidebar workspace cannot hold three
   honest columns, so the single-column flow stays. The stage width is also
   capped by the available row width: a tall browser must not grow the preview
   until the inspector's primary actions fall off the right edge. */
@media (min-width: 1280px) {
  .editor {
    /* Keep the workbench row a real, bounded row. `height: 100%` on the
       inspector looks harmless, but its long scrollable contents then become
       the grid item's intrinsic height and stretch the otherwise tiny
       timeline into a giant empty panel. The row follows the viewport while
       retaining enough room for the 9:16 stage. */
    --editor-row-height: max(625px, calc(80vh - 40px));
    /* stage width ⇄ row height are one equation: keep them coupled. */
    --stage-w: clamp(
      300px,
      min(calc((80vh - 132px) * 9 / 16), calc(100% - 570px)),
      560px
    );
    /* Fixed middle track (stage + its 12px padding + 1px borders): an `auto`
       track lets the stage's percentage width resolve circularly to
       min-content and collapse. */
    grid-template-columns: minmax(220px, 256px) calc(var(--stage-w) + 26px) minmax(300px, 340px);
    grid-template-rows: var(--editor-row-height) auto;
    grid-template-areas:
      "rail stage inspector"
      "timeline timeline timeline";
    align-items: stretch;
    justify-content: center;
  }
  .editor-rail { grid-area: rail; height: var(--editor-row-height); min-height: 0; max-height: none; overflow: hidden; }
  .editor-rail .rail-clips {
    min-height: 0;
    flex: 1 1 auto;
    /* The list is a grid: without this, its rows stretch to split the rail's
       new full height between them and every clip card balloons. */
    align-content: start;
    overflow-y: auto;
    scrollbar-width: thin;
    scrollbar-color: var(--rule-strong) transparent;
  }
  /* Save clips anchors the rail's floor — the dead lower half becomes
     deliberate panel structure that rhymes with the inspector's dock. */
  #editor-save-clips { flex: 0 0 auto; margin-top: auto; }
  .editor-stage {
    grid-area: stage;
    height: var(--editor-row-height);
    min-height: 0;
    justify-content: flex-start;
    padding: var(--space-3);
    border: 1px solid var(--rule);
    border-radius: var(--radius-md);
    background: var(--surface);
    box-shadow: var(--elev-1), var(--rim);
  }
  .stage-9x16 { width: var(--stage-w); flex: 0 0 auto; }
  .editor-inspector { grid-area: inspector; height: var(--editor-row-height); min-height: 0; max-height: none; overflow: auto; }
  .editor-timeline { grid-area: timeline; align-self: start; }
}

/* ── PROTECTED SURFACES ──────────────────────────────────────────────────────
   These render what the user is MAKING. The field must not tint, blur, fade, or
   bleed into a single pixel of them. `isolation: isolate` walls off any ancestor
   blend/backdrop so a colour-critical preview reads true. */
.stage-9x16,
.stage-player,
.stage-media,
.tpl-stage,
.template-thumb,
.look-thumb,
.cell,
.cell img,
.timeline-clip,
#phone-upload-qr {
  isolation: isolate;
  backdrop-filter: none !important;
  mix-blend-mode: normal !important;
}
/* The opacity lock is for the surfaces that show the ARTEFACT. An author
   `!important` outranks a normal INLINE declaration — the one cascade position
   a component cannot argue with from its own markup — so listing the two
   catalogue thumbnails here silently killed their load fade
   (`templates.rs` and `oneshot.rs` both set `opacity: 0 → 1` inline). The
   image painted instantly at full opacity UNDERNEATH its own shimmer overlay,
   which reads as a design choice rather than a broken transition.
   `.template-thumb` and `.look-thumb` are gallery previews of looks on offer,
   not the video being made, and a self-terminating entrance that ends at 1
   misrepresents nothing. They keep the blend/backdrop guards, which are the
   ones that actually protect colour fidelity; they lose the opacity lock. */
.stage-9x16,
.stage-player,
.stage-media,
.tpl-stage,
.cell,
.cell img,
.timeline-clip,
#phone-upload-qr {
  opacity: 1 !important;
}
.template-thumb,
.look-thumb,
.cell img {
  background: var(--thumb-backing);
}

/* Accessibility and motion */
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }

/* Skip link — WCAG 2.4.1 Bypass Blocks.
   Measured in Chrome against a real server: on EVERY screen the masthead and
   the rail put exactly 18 tab stops between the start of the page and the
   first control of the actual content, and there was no way past them. Six
   routes, all 18. A creator on a dead trackpad paid that toll on every
   navigation.

   `position: fixed` and not `absolute`: `#syzygy-app` is a grid with named
   areas, and an in-flow child that names no area gets auto-placed into a track
   of its own. Out of flow, it takes no track and the shell is byte-identical
   when the link is not focused.

   Hidden by TRANSFORM, not `display: none` / `visibility: hidden` — those
   remove it from the tab order, which is the one thing it exists to be in.
   z-index sits below `.modal-overlay`'s 200 on purpose: a dialog traps focus,
   so this can never be focused over one, and if that ever broke, the link
   floating above a modal would be the worse failure. */
.skip-link {
  position: fixed;
  top: var(--space-3);
  left: var(--space-3);
  z-index: 150;
  padding: var(--space-2) var(--space-4);
  border: 1px solid var(--rule-strong);
  border-radius: var(--radius-sm);
  background: var(--surface);
  box-shadow: var(--elev-5);
  color: var(--ink);
  font-size: var(--text-footnote);
  font-weight: 700;
  text-decoration: none;
  /* Far enough up to clear its own shadow as well as its box. */
  transform: translateY(calc(-100% - var(--space-12)));
}
/* `:focus`, not `:focus-visible`. The only way to reach this link is the
   keyboard, and `:focus-visible` is a heuristic — a heuristic that decides
   wrong here leaves a link that has focus and cannot be seen. */
.skip-link:focus { transform: translateY(0); }

/* The skip target. `outline-offset` is NEGATIVE so the ring is drawn inside
   the pane: `#studio-workspace` is the app's scroll container and reaches the
   viewport edges, so an outset ring would be clipped on the right and read as
   no ring at all. */
#studio-workspace:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; }
/* ── THE MOTION SYSTEM ──────────────────────────────────────────────────────
   The durations and curves are declared once, up in the structural :root under
   "MOTION". This is where they are spent. Four patterns, and every moving thing
   in the studio is one of them:

     ENTER    `animation: <name> var(--enter)` — opacity, and at most an 8px
              rise or a 3% scale. Eased OUT, so the element is quickest at the
              instant it appears and settles rather than glides. `both` fill, so
              the element is never briefly visible in its pre-animation state.

     EXIT     `var(--dur-state) var(--ease-in)`. Faster than the enter, on
              purpose: waiting for something you have already dismissed is the
              single cheapest way to make software feel slow.

     HOVER    a LIFT. `transform: translateY(-1px)` on a control, -2px on a
              card, at `var(--dur-state)`. Transform and opacity only — see
              P-10 below — and never on something disabled.

     PRESS    a PUSH DOWN. The lift is cancelled and the surface takes a 1.5%
              scale at `var(--dur-press)`. It has to beat the hover transition
              or the press is swallowed by the lift still easing in.

   Focus is deliberately NOT in that list. The 2px `--ring` at `outline-offset:
   2px` was measured and argued for; it is a state, not a movement, and nothing
   here touches it.

   P-10 — NOTHING SHIFTS. No hover or enter state in this file may set width,
   height, margin, padding, border-width, font-size, gap or letter-spacing.
   A border that only exists on hover is written as an always-present
   transparent border whose COLOUR changes (`.create-format a`, `.nav-link`),
   or as an inset box-shadow (`.look-card.is-active`). `app/motion-check.mjs`
   parses this file and fails the build if a hover rule ever sets one of those
   properties again, so the rule survives the next person who does not read
   this paragraph.

   The one composite compromise, stated rather than hidden: the elevation group
   crossfades `box-shadow` on hover. Shadow is not a cheap property to animate,
   and on a large surface it would not be allowed — but in the LIGHT theme the
   shadow IS the lift (a 2px translate on a white card against a white page is
   very nearly invisible), so removing it would cost light theme its entire
   hover affordance to buy back a repaint on a card-sized box. It is confined to
   hover, on card-sized surfaces, and no new shadow transition was added.

   WHAT LOOPS. Three things, and they are all either a genuine progress
   indicator or the ambient field: `.anim-pulse` (a skeleton — the loop IS the
   statement that the region is still loading), `.anim-spin` (a spinner), and
   `field-drift` (48s, documented at "THE FIELD"). Nothing else in this
   stylesheet is allowed an infinite iteration count.

   REDUCED MOTION. Every rule below is switched off by the
   `prefers-reduced-motion: reduce` block further down — by name, not by
   trusting the wildcard, because the wildcard collapses animation-DURATION and
   leaves animation-DELAY alone, which would have left a staggered list item
   invisibly waiting out a 240ms delay it could no longer see the point of. */

@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
/* `to { transform: none }`, not `translateY(0)` / `scale(1)`. With `both` fill
   the last keyframe's value persists forever, and a persisted `translateY(0)`
   is still a transform: it makes the element a containing block for every
   `position: fixed` descendant it has, permanently, long after the 220ms are
   over. `toast-enter` below already ended at `none`; these three did not, so an
   overlay mounted inside anything carrying `.anim-fade-in-up` would have been
   positioned against that element instead of the viewport. Same visual result,
   no residue. */
@keyframes fade-in-up { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
@keyframes scale-in { from { opacity: 0; transform: scale(0.97); } to { opacity: 1; transform: none; } }
/* 0.62, not 0.55, and the house in/out curve rather than the UA's: a skeleton
   should read as "working", which is a breath, not a blink. */
@keyframes pulse { 0%, 100% { opacity: 0.62; } 50% { opacity: 1; } }
@keyframes spin { to { transform: rotate(360deg); } }

.anim-fade-in { animation: fade-in var(--enter); }
.anim-fade-in-up { animation: fade-in-up var(--enter); }
.anim-scale-in { animation: scale-in var(--enter); }
.anim-pulse { animation: pulse 1.6s var(--ease-field) infinite; }
.anim-spin { animation: spin 0.9s linear infinite; }
/* The one thing allowed --dur-stage: the finished render, arriving. */
.anim-arrive { animation: scale-in var(--dur-stage) var(--ease-out) both; }

/* ── P-2 · LISTS ARRIVE, THEY DO NOT BLINK ──────────────────────────────────
   A grid that fades in as one block tells you nothing about what is in it. A
   grid whose items arrive in sequence reads as content being laid down. But a
   stagger is a cost per item, and the media library holds 400 of them — 400
   items at 34ms is a thirteen-second light show, which is not premium, it is a
   fault. So the ladder is CAPPED: the first eight items step, and the ninth
   onward are simply there, already at rest, with no animation at all. Eight is
   roughly one screenful of tiles at desktop width, so the cap is invisible —
   by the time item nine would have moved, it is below the fold.

   No JavaScript, and no markup change for what already ships: the containers
   named here are the ones that ALREADY carry an enter class in app/src today
   (`<div class="grid anim-fade-in-up">`, `<ul class="rows anim-fade-in">`), so
   the container's own animation is handed down to its children instead. The
   bare `.stagger` alias exists for the next markup pass to put on a grid that
   has no enter class yet; it needs nothing else attached to it.

   `:is()` takes the specificity of its heaviest argument, so the compound below
   is (0,2,0) and reliably outranks the plain `.anim-fade-in-up` it overrides.
   Written out four times rather than hidden behind a preprocessor — this file
   has no build step and does not want one. */
:is(.stagger, :is(.grid, .card-grid, .template-grid, .rows, .stat-strip, .chips):is(.anim-fade-in, .anim-fade-in-up)) {
  animation: none;
}
:is(.stagger, :is(.grid, .card-grid, .template-grid, .rows, .stat-strip, .chips):is(.anim-fade-in, .anim-fade-in-up)) > * {
  animation: fade-in-up var(--enter);
}
/* The cap. Everything past the eighth child is present immediately. */
:is(.stagger, :is(.grid, .card-grid, .template-grid, .rows, .stat-strip, .chips):is(.anim-fade-in, .anim-fade-in-up)) > :nth-child(n + 9) {
  animation: none;
}
:is(.stagger, :is(.grid, .card-grid, .template-grid, .rows, .stat-strip, .chips):is(.anim-fade-in, .anim-fade-in-up)) > :nth-child(2) { animation-delay: calc(1 * var(--stagger-step)); }
:is(.stagger, :is(.grid, .card-grid, .template-grid, .rows, .stat-strip, .chips):is(.anim-fade-in, .anim-fade-in-up)) > :nth-child(3) { animation-delay: calc(2 * var(--stagger-step)); }
:is(.stagger, :is(.grid, .card-grid, .template-grid, .rows, .stat-strip, .chips):is(.anim-fade-in, .anim-fade-in-up)) > :nth-child(4) { animation-delay: calc(3 * var(--stagger-step)); }
:is(.stagger, :is(.grid, .card-grid, .template-grid, .rows, .stat-strip, .chips):is(.anim-fade-in, .anim-fade-in-up)) > :nth-child(5) { animation-delay: calc(4 * var(--stagger-step)); }
:is(.stagger, :is(.grid, .card-grid, .template-grid, .rows, .stat-strip, .chips):is(.anim-fade-in, .anim-fade-in-up)) > :nth-child(6) { animation-delay: calc(5 * var(--stagger-step)); }
:is(.stagger, :is(.grid, .card-grid, .template-grid, .rows, .stat-strip, .chips):is(.anim-fade-in, .anim-fade-in-up)) > :nth-child(7) { animation-delay: calc(6 * var(--stagger-step)); }
:is(.stagger, :is(.grid, .card-grid, .template-grid, .rows, .stat-strip, .chips):is(.anim-fade-in, .anim-fade-in-up)) > :nth-child(8) { animation-delay: calc(7 * var(--stagger-step)); }
/* A skeleton is not content arriving — it is a placeholder for content that has
   not arrived. It already breathes; giving it an entrance as well would animate
   the wait twice. */
:is(.stagger, :is(.grid, .card-grid, .template-grid, .rows, .stat-strip, .chips):is(.anim-fade-in, .anim-fade-in-up)) > .skeleton { animation: pulse 1.6s var(--ease-field) infinite; }

/* ── P-5 · SCREENS SETTLE ───────────────────────────────────────────────────
   The workspace region cross-fades on a route change; the rail and the masthead
   are outside `.screen` and never move, which is the whole point — chrome that
   flashes on navigation is the thing that makes a SPA feel like a website.
   Opacity only, no transform: a transform here would make every screen a
   containing block for the fixed overlays mounted inside it.

   THE DURATION CHANGED, and the previous comment here was wrong about why it
   could not matter. It read "screens that already carry `.anim-fade-in` in
   markup are unaffected — same keyframe, same duration, so the two rules
   agree." Same keyframe, yes. Not the same duration: `.anim-fade-in` is
   `var(--enter)` (220ms) and this rule was `var(--dur-state)` (140ms), both at
   specificity (0,1,0), and this one is declared later in the file — so it WON,
   and the eight screens that ask for `.anim-fade-in` in their markup
   (`dashboard`, `docs`, `formats`, `hooks`, `insights`, `media`, `plan`) were
   silently getting a different, faster entrance than the seven that do not ask
   for anything. Half the app entered at one speed and half at another, which is
   the exact fault P-1 exists to end.

   Resolved toward the motion table rather than toward the older rule, because
   the table names this case outright: "--dur-enter 220ms — something ARRIVING:
   a menu, a toast, a list item, A SCREEN." A screen is the largest thing that
   enters, so if any of the four durations is wrong for it, it is the one
   reserved for a hover. Both rules now compute to the identical animation and
   the markup class is genuinely redundant instead of accidentally overridden.

   VERIFIED, and the reason it fires at all is worth writing down: this app's
   router is a FULL PAGE LOAD (`current_screen()` in `app/src/main.rs` — "Full-
   page navigation is the router: read the path once per load"). So `.screen` is
   a brand-new element on every navigation and a mount animation is guaranteed
   to run; there is no reused node for the cross-fade to miss. The other half of
   the promise — that the chrome does not flash — is not this rule's doing and
   could not be: it is `#boot` in `app/index.html`, which paints the masthead
   and rail at the real geometry before WASM exists, so the chrome is continuous
   across a navigation while only the workspace fades. Nothing in this file
   should ever animate `#masthead` or the rail; that is what would break it. */
.screen { animation: fade-in var(--enter); }

/* ── P-4 · CONTROLS ANSWER THE HAND ─────────────────────────────────────────
   Hover lifts, press pushes down, focus keeps the ring it already had, and
   disabled does none of it. The four states have to be told apart INSTANTLY and
   from each other — a control where hover and press look the same is a control
   you cannot tell you have clicked.

   Everything here is transform, opacity, colour or shadow. Nothing moves a box.

   Press first, because it must outrank the hover transition it interrupts: the
   shorter `transition-duration` is what stops the push-down from being
   swallowed by a lift that is still easing in. */
.btn:active:not(:disabled),
.btn-sm:active:not(:disabled),
.panel button:active:not(:disabled),
#agent-run:active:not(:disabled),
#gen-run:active:not(:disabled),
.oneshot-make-btn:active:not(:disabled),
.tab:active:not(:disabled),
.dropdown-item:active,
.create-auto:active,
.nav-mode-btn:active,
.chips button.pill:active,
#theme-toggle:active,
#mobile-menu-toggle:active,
#render-tray-toggle:active,
.stat:active,
.template-card:active,
.media-oneshot-hint:active,
.look-card:active:not(:disabled) {
  transform: scale(0.985);
  transition-duration: var(--dur-press);
}
/* The rail's links already sit 2px right of rest on hover; the press has to
   keep that offset or the row jumps sideways under the finger. */
.nav-link:active,
.nav-link--hero:active {
  transform: translateX(2px) scale(0.99);
  transition-duration: var(--dur-press);
}

/* Controls that had no transition at all, so their hover was a hard cut while
   the button beside them eased. Same vocabulary, no new colour: the tab and the
   menu item borrow a 6% wash of the theme's own ink. */
.tab, .dropdown-item, .chips button.pill, .badge, .pill,
#render-tray-toggle, .oneshot-stock > summary, .row-open, .render-job-again,
.nav-client-select, .skip-link {
  transition: transform var(--transition), background var(--transition),
              color var(--transition), border-color var(--transition),
              box-shadow var(--transition);
}
.tab:hover:not(.tab--active) { background: color-mix(in srgb, var(--ink) 6%, transparent); color: var(--ink); }
.chips button.pill:hover:not(.active) { border-color: var(--rule-strong); color: var(--ink); }
#theme-toggle:hover, #render-tray-toggle:hover { transform: translateY(-1px); }
#theme-toggle { transition: transform var(--transition), background var(--transition), color var(--transition), border-color var(--transition); }

/* DISABLED reads as inert, and inert does not lift. The `!important` pair was
   already here for buttons; the rest of the interactive surface needs the same
   promise, including anything that says it is disabled to a screen reader
   without carrying the attribute. Opacity plus desaturation plus a cursor —
   three channels, so no single one of them has to carry it alone. */
.look-card:disabled,
[aria-disabled="true"],
input:disabled, select:disabled, textarea:disabled {
  transform: none !important;
  box-shadow: none !important;
  cursor: not-allowed;
}
input:disabled, select:disabled, textarea:disabled { opacity: 0.55; }
[aria-disabled="true"] { opacity: 0.42; filter: saturate(0.55); }
/* A skeleton is furniture standing in for content. It must not answer the hand
   at all — a placeholder that lifts under the cursor invites a click on nothing
   and, for the half second it lasts, is the least honest thing on the screen. */
.skeleton:hover, .skeleton:active { transform: none !important; box-shadow: var(--elev-1) !important; }

/* ── ENTRANCES ON SURFACES THAT ARRIVE ──────────────────────────────────────
   Each of these mounts into the DOM at the moment it becomes true, so a mount
   animation is the right hook and needs no class in the markup. */
/* The scrim, quick — it is context, not content. The card inside it carries
   `.anim-scale-in` from `ui::Modal`, so the two arrive together and the card
   is the thing you watch. */
.modal-overlay { animation: fade-in var(--dur-state) var(--ease-out) both; }
/* The other dialog — the in-flow one from `shared_block.rs`, which has no
   overlay to fade. It is a surface interrupting a panel, so it settles. */
.modal { animation: scale-in var(--enter); }
/* A menu grows from the corner it is anchored to. Without an origin, the 3%
   scale reads as the menu breathing rather than opening. */
.dropdown-menu { transform-origin: top right; }
.toast--enter { animation: toast-enter var(--enter); }
#render-tray-panel { animation: fade-in-up var(--enter); }
/* The finished video. The only surface in the studio that gets --dur-stage. */
.oneshot-player { animation: scale-in var(--dur-stage) var(--ease-out) both; }

/* Mobile turns the sidebar into a compact top shell and horizontal nav. */
@media (max-width: 820px) {
  /* A phone scrolls the document, and its browser chrome expects to be able to
     collapse on that scroll. Hand the scrolling back: the shell's height bound
     and the workspace's scroll container are both released here, so the top
     shell can go back to being `sticky` over one ordinary page. */
  #syzygy-app {
    --header-height: 72px;
    display: block;
    height: auto;
    min-height: 100dvh;
    overflow: visible;
  }
  #studio-workspace { overflow-y: visible; overscroll-behavior: auto; }
  #masthead {
    position: sticky;
    top: 0;
    z-index: 40;
    height: var(--header-height);
    min-height: var(--header-height);
    display: flex;
    align-items: center;
    gap: var(--space-2);
    padding: var(--space-3) var(--space-4);
    border-right: 0;
    background: color-mix(in srgb, var(--sidebar) 94%, transparent);
    backdrop-filter: blur(18px);
  }
  .brand-lockup { flex: 1 1 auto; }
  #rt-logo { flex-basis: 120px; width: 120px; min-width: 96px; }
  .brand-product { font-size: var(--text-caption-2); }
  .brand-caption { display: none; }
  #mobile-menu-toggle {
    min-width: 44px;
    min-height: 44px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-2);
    padding: var(--space-2) var(--space-3);
    border: 1px solid var(--rule);
    border-radius: var(--radius-sm);
    background: var(--surface);
    color: var(--ink);
    font-size: var(--text-caption-2);
    font-weight: 700;
  }
  #theme-toggle { grid-column: auto; grid-row: auto; flex: 0 0 44px; }
  .toast-host {
    top: calc(var(--header-height) + var(--space-3));
    right: var(--space-3);
    left: var(--space-3);
    width: auto;
  }
  /* Desktop pins #wordmark to --header-height with overflow:hidden so it stays
     in lockstep with #masthead in the shared header grid row. On a phone the
     description wraps to three or four lines and that clamp CLIPPED it
     mid-sentence ("...behind every edit." lost its last line). Here the
     wordmark is no longer sharing that row with the masthead, so height:auto
     is safe and min-height becomes a floor rather than a cap. */
  #wordmark { height: auto; min-height: 96px; overflow: visible; padding: var(--space-4); }
  .workspace-heading { display: block; }
  #workspace-title { font-size: var(--text-title-1); }
  #definition { font-size: var(--text-caption); }
  #studio-nav {
    position: fixed;
    top: calc(var(--header-height) + var(--space-2));
    right: var(--space-3);
    left: var(--space-3);
    z-index: 35;
    width: auto;
    height: auto;
    max-height: calc(100dvh - var(--header-height) - var(--space-6));
    overflow-y: auto;
    display: flex;
    padding: var(--space-4);
    border: 1px solid var(--rule-strong);
    border-radius: var(--radius-lg);
    background: var(--glass-bg);
    box-shadow: var(--elev-4), var(--rim);
    backdrop-filter: blur(20px);
    opacity: 0;
    visibility: hidden;
    transform: translateY(-8px) scale(0.985);
    transform-origin: top center;
    transition: opacity var(--transition), transform var(--transition), visibility var(--transition);
  }
  #studio-nav.is-open { opacity: 1; visibility: visible; transform: translateY(0) scale(1); }
  .nav-group-label { margin-top: var(--space-4); }
  .nav-group-label:first-child { margin-top: 0; }
  .nav-link { min-height: 44px; flex: 0 0 auto; gap: var(--space-2); padding: var(--space-2) var(--space-3); font-size: var(--text-caption); }
  .nav-mode-btn { min-height: 44px; display: flex; align-items: center; justify-content: center; }
  .nav-link:hover { transform: none; }
  .nav-mark { width: 24px; flex-basis: 24px; }
  #studio-workspace { padding: var(--space-6) var(--space-4) var(--space-16); }
  .screen { gap: var(--space-4); }
  .card, .panel { padding: var(--space-4); }
  .oneshot { display: grid; grid-template-columns: 1fr; }
  #oneshot-step-song, #oneshot-step-photos, #oneshot-step-look, #oneshot-step-make { grid-column: 1; }
  .launchpad, #dashboard, .screen[aria-label="Publishing"] { grid-template-columns: 1fr; }
  .launchpad > *, #dashboard > *, .screen[aria-label="Publishing"] > *,
  #dashboard-aesthetics, #dashboard-attention,
  .screen[aria-label="Publishing"] #send-accounts,
  .screen[aria-label="Publishing"] #send-actions,
  .screen[aria-label="Publishing"] #ai-caption-studio,
  .screen[aria-label="Publishing"] #connect-accounts { grid-column: 1; }
  #launchpad-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  #create-draft { grid-template-columns: 1fr 1fr; }
  #create-draft > label:first-child, #create-draft-hint, #create-draft > .status,
  #draft-preset, #draft-run, #draft-open-modal { grid-column: 1 / -1; }
  #draft-kind, #draft-count { grid-column: span 1; }
  #media-upload { grid-template-columns: 1fr 1fr; }
  /* PP-5. `minmax(0, 1fr) auto auto` is a desktop row: a sentence, then two or
     three buttons beside it. At 390px the three auto tracks divide 358px
     between five items and the last button got 34px — "Upload from phone
     (QR)" rendered as a 34x96 column of single characters, which is not a
     control, it is a column of letters. One track per item on a phone. */
  #media-summary, #media-empty, #media-error { grid-template-columns: minmax(0, 1fr); }
  #media-upload > label, #media-upload > input, #media-upload > .status { grid-column: 1 / -1; }
  .grid, .card-grid, .template-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); }
  .row { align-items: flex-start; flex-wrap: wrap; }
  .row-actions { width: 100%; }
  .row-actions > * { flex: 1 1 auto; }
  .field-row { align-items: stretch; flex-direction: column; }
  .btn-row > * { flex: 1 1 130px; }
  .editor-rail, .editor-inspector { padding: var(--space-3); }
  .stage-9x16 { width: min(82vw, 330px); }
  /* `bottom` is NOT set here any more. It was `max(12px, env(…))`, which is
     12px on every iPhone in existence because `env()` reports 0 without
     `viewport-fit=cover` — so this rule, whose whole purpose was the phone,
     parked the pill 12px into the 60px the browser bar covers. The inset now
     comes from `--tray-inset`, raised for coarse pointers further down, and
     this rule keeps only what is genuinely about WIDTH. */
  #render-tray { right: 12px; width: calc(100vw - 24px); }
  /* The render pill is position:fixed at z-index 120; the editor's action bar is
     position:sticky at z-index 5, so the pill floats OVER it. On a phone that
     put "Renders (0)" directly on top of the primary Confirm button — the one
     control the whole screen exists to reach. #studio-workspace already reserves
     a bottom gutter, but that cannot help a sticky element pinned to the
     viewport. Reserve the pill's footprint inside the dock instead, so it
     overlays padding rather than a control. */
  .inspector-dock { padding-bottom: calc(13px + var(--render-tray-clearance)); }
}

@media (max-width: 520px) {
  .brand-product { display: none; }
  #mobile-menu-toggle > span:last-child { display: none; }
  #mobile-menu-toggle { width: 44px; padding: 0; }
}

@media (max-width: 480px) {
  #wordmark { min-height: 84px; }
  #workspace-title { font-size: var(--text-title-2); }
  /* 8px was below Apple's 10pt macOS floor even for a non-interactive label. */
  .workspace-kicker { font-size: var(--text-micro); }
  .grid, .card-grid, .template-grid { grid-template-columns: 1fr; }
  #launchpad-grid, #create-draft, #media-upload { grid-template-columns: 1fr; }
  #draft-kind, #draft-count, #draft-preset, #draft-run,
  #media-upload > label, #media-upload > input, #media-upload > button, #media-upload > .status { grid-column: 1; }
  .cell figcaption { font-size: var(--text-micro); }
  .oneshot-controls { grid-template-columns: 1fr; }
  .oneshot-controls label, .oneshot-controls select { grid-column: 1; }
  .date-window { width: 100%; }
  #date-window-label { flex: 1; min-width: 0; }
}

/* accessibility.md gives one hard number for hit targets: nothing interactive
   smaller than 44x44 CSS px. On a mouse pointer that would cost the sidebar its
   density — the 36px nav row exists so every group fits a 900px-tall laptop —
   so the floor is raised where the finger actually is, and only there. A coarse
   pointer is a touchscreen; `min-height` alone is enough because every one of
   these controls is already full-width or wider than 44px. */
@media (pointer: coarse) {
  /* The selector lists here mirror the base rules deliberately. A short
     `input, select` would lose to `.panel input` and `.stage-guide-control
     select` on specificity and silently do nothing — measured: 12 of 41
     controls still under 44px on One-Shot before this was widened. */
  .btn, .btn-sm, .panel button, .oneshot-make-btn, #agent-run, #gen-run,
  .nav-link, .tab, .dropdown-item, .nav-mode-btn, .create-auto, .brand-lockup,
  #render-tray-toggle, #mobile-menu-toggle, #theme-toggle,
  input, select, textarea, .panel input, .panel select, .panel textarea,
  #agent-task, #gen-prompt, #gen-format, .rail-head select,
  .editor-inspector .field input, .lyric-row input,
  .timing-row .clip-dur-input, .stage-guide-control select,
  .num input, .nav-client-select {
    min-height: 44px;
  }
  .clip-ctl .btn-sm { min-height: 44px; min-width: 44px; }
  input[type="checkbox"], input[type="radio"] { width: 24px; min-height: 24px; }

  /* ── PP-5 · the four boxes a finger could not hit ────────────────────────
     Every one of these was MEASURED with getBoundingClientRect at 390x844 and
     at 360x844 by `app/phone-funnel-check.mjs`; none of them was visible as a
     defect in a screenshot, which is the whole reason the harness exists. The
     `min-height` list above did not reach them because none of them is a
     `.btn`, an `input` or a `select`.

     A <summary> is the most quietly broken of the four. It looks like a
     control — it has a box, a border and a pointer cursor — but the box a
     finger has to land on is the SUMMARY, not the <details> around it, and the
     summary is only as tall as its one line of 10px mono: 12px on the
     One-Shot stock-search disclosure, 24px on the editor's hook-style row.
     Pressing the generous-looking padding above or below the text does
     nothing at all, which reads as "the app ignored me" rather than as "you
     missed". `align-content` centres the text inside the taller box without
     touching `display` — setting `display: flex` here would silently delete
     the ▶ marker, which is the only thing telling anyone the row opens. */
  summary { min-height: 44px; align-content: center; }
  /* The colour swatch is an <input type=color>: 32px square, and the square IS
     the target — there is no label beside it to press instead. */
  input.swatch { width: 44px; height: 44px; min-height: 44px; }
  /* The toast dismiss is 44px tall and 26px wide: tall enough to look right,
     narrow enough that the ✕ is a coin-toss. It is punctuation rather than an
     action and stays visually quiet — only its hit box grows. */
  .toast-close { min-width: 44px; }
  /* Same shape, one rung more serious: the dialog dismiss is the only way out
     of a modal for someone who is not using a keyboard, and it measured
     35x44. */
  #modal-close { min-width: 44px; }

  /* The browser's own bottom bar. See --tray-inset above for why this is 60px
     of literal plus an additive env(), and not the other way round. */
  :root {
    --thumb-bar: 60px;
    --tray-inset: calc(env(safe-area-inset-bottom, 0px) + var(--thumb-bar));
  }
}

/* ── PP-5 · A HOVER STATE MUST NOT SURVIVE THE FINGER ───────────────────────
   The motion system's HOVER pattern is a lift, and a lift is the right answer
   to a pointer that can leave. A finger cannot: on a touchscreen the browser
   synthesises a hover on tap and then has nothing to pair it with, so the
   control stays 1px up, or 2px across, for the rest of the session — a button
   that looks permanently half-pressed and a nav row that has quietly slid out
   of alignment with the five below it.

   Every selector in this file that moves on `:hover` is answered here, and
   `app/motion-check.mjs` fails the build if a new one is added without being
   added here too. `transform: none` and nothing else: the colour and elevation
   halves of those same hover rules are harmless when they stick, and removing
   them would cost a tap its only acknowledgement. */
@media (hover: none) {
  .nav-link:hover,
  .nav-link--hero:hover,
  .btn:hover, .btn-sm:hover, .panel button:hover, #agent-run:hover, #gen-run:hover,
  #theme-toggle:hover, #render-tray-toggle:hover,
  .card:hover, .stat:hover, .template-card:hover, .look-card:hover:not(:disabled) {
    transform: none;
  }
}

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

  /* DELAY, which the line above does not touch. This is not pedantry: the P-2
     stagger drives its ladder with `animation-delay`, and a 0.01ms animation
     that has been told to wait 238ms first is an element that sits invisible
     for a quarter second and then appears — a WORSE experience than the motion
     it was meant to replace, and one that only a reader who asked for less
     motion would ever have had. Delay is part of motion; it goes too. */
  *, *::before, *::after { animation-delay: 0ms !important; transition-delay: 0ms !important; }
  :root { --stagger-step: 0ms; }

  /* Named, not inferred. The wildcard collapses the duration of every
     animation in the document, which is a blunt instrument that happens to
     work; these are the rules this motion system added, switched off
     deliberately so that a future change to the wildcard cannot quietly
     un-switch them. Each one is pinned at its RESTING state — full opacity, no
     transform — which is exactly where every keyframe above ends, so a reader
     with reduced motion sees the same composition as everyone else, arrived at
     without the journey. */
  .anim-fade-in, .anim-fade-in-up, .anim-scale-in, .anim-arrive,
  .screen, .modal, .modal-overlay, .toast--enter, #render-tray-panel,
  /* Both halves of the result. `.oneshot-player` is the single hero; the strip
     of three is the same surface arriving three times, so it is switched off
     by the same promise and lands at rest, complete. */
  .oneshot-player, .oneshot-result-slot,
  :is(.stagger, :is(.grid, .card-grid, .template-grid, .rows, .stat-strip, .chips):is(.anim-fade-in, .anim-fade-in-up)) > * {
    animation: none !important;
    opacity: 1 !important;
    transform: none !important;
  }
  /* The skeletons keep their meaning rather than their motion: pinned opaque,
     not pinned mid-pulse. `.skeleton` is named alongside `.anim-pulse` because
     the stagger block hands a pulse to any skeleton inside an entering grid,
     and that rule has to be reachable by this one. */
  .anim-pulse, .skeleton { animation: none !important; opacity: 1 !important; }
}

/* Explicit, deliberate freeze of the field drift — a statement, not a side effect.
   The composition stays identical; only the motion stops. */
@media (prefers-reduced-motion: reduce) {
  body::after {
    animation: none !important;
    transform: translate3d(0, 0, 0) scale(1.02);
  }
  #field-video { display: none !important; }
}

/* One-Shot look gallery — picking a look is a visual choice, not a dropdown. */
.oneshot-looks-more { display: grid; gap: var(--space-3); }
.oneshot-looks-more > summary {
  min-height: 44px;
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: 9px 11px;
  border: 1px solid var(--rule);
  border-radius: var(--radius-sm);
  background: var(--bg-soft);
  color: var(--ink-soft);
  font-size: 12px;
  font-weight: 750;
  cursor: pointer;
  list-style: none;
}
.oneshot-looks-more > summary::-webkit-details-marker { display: none; }
.oneshot-looks-more > summary::before {
  content: "+";
  width: 18px;
  flex: 0 0 18px;
  color: var(--accent-strong);
  font: 800 16px/1 var(--mono);
  text-align: center;
}
.oneshot-looks-more[open] > summary::before { content: "−"; }
.oneshot-looks-more > summary:hover { border-color: var(--rule-strong); background: var(--surface-hover); }
.look-card {
  display: flex;
  flex-direction: column;
  gap: 6px;
  align-items: flex-start;
  text-align: left;
  cursor: pointer;
  padding: var(--space-3);
  transition: transform var(--transition), border-color var(--transition),
              box-shadow var(--transition);
}
.look-card:hover:not(:disabled) { transform: translateY(-2px); }
/* The active look is marked by an inset ring, not by a border that appears —
   an inset shadow paints inside the box the tile already occupies, so the grid
   does not reflow the instant a look is chosen (P-10). */
.look-card.is-active {
  border-color: var(--accent);
  box-shadow: 0 0 0 2px var(--accent) inset;
}
.look-card:disabled { opacity: 0.55; cursor: default; }
.look-thumb {
  width: min(100%, 160px);
  aspect-ratio: 9 / 16;
  height: auto;
  max-height: none;
  margin-inline: auto;
  object-fit: contain;
  border-radius: var(--radius-sm);
  background: var(--thumb-backing);
}


/* One-Shot look gallery: when a look's still 404s or fails to render, a
   labelled tile stands in for the <img>, so it has to take the same box
   `.look-thumb` gives the image — a <span> is inline by default, which would
   drop the aspect-ratio and max-height entirely.
   NOT `var(--line)`: this stylesheet's hairline token is `--rule` / --rule-strong;
   an undefined custom property inside `border` makes the WHOLE shorthand
   invalid at computed-value time, so the tile would have shipped with no
   border at all. Dashed --rule-strong is the same idiom `.rail-empty` uses for
   a placeholder box. Text neutralised for the same reason `.thumb-none` does:
   the tile lives inside a <button>, which styles its label. */
.look-thumb--missing {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 6px;
  padding: var(--space-2);
  border: 1px dashed var(--rule-strong);
  color: var(--muted-2);
  text-align: center;
  text-transform: none;
  letter-spacing: normal;
  font-weight: 400;
}

/* ── Paid surfaces ─────────────────────────────────────────────────────────
   The ONLY place the gradient is allowed. If this ever appears on an ordinary
   action, the signal is gone and every button reads as an advert. */
.btn--upsell {
  border-color: transparent;
  background: var(--accent-gradient);
  /* NOT a hardcoded #ffffff. A white label measured 2.31:1 over the dark
     theme's gradient — the paid CTA was the least readable control in the
     product, in the theme that ships by default. The label is the accent
     family's own ink, so it flips with the fill: near-black on dark's bright
     ramp (worst 5.20:1), white on light's deep one (worst 5.26:1).
     `theme_contrast.rs` samples the whole ramp, not just the stops. */
  color: var(--accent-ink);
  box-shadow: var(--elev-2);
}
.btn--upsell:hover { filter: brightness(1.06); box-shadow: var(--elev-3); }

/* ── Mode pill ─────────────────────────────────────────────────────────────
   One-Shot is the only path that goes from nothing to a finished video, and it
   was item one of sixteen in a flat list. As a persistent mode it reads as what
   it is: the other way to use the product, not another section of it. */
.nav-mode {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 2px;
  margin: 0 var(--space-3) var(--space-4);
  padding: 3px;
  border: 1px solid var(--rule);
  border-radius: 999px;
  background: var(--surface-2);
}
.nav-mode-btn {
  padding: 7px 10px;
  border-radius: 999px;
  color: var(--muted);
  font: 650 var(--text-caption)/1 var(--sans);
  letter-spacing: 0.01em;
  text-align: center;
  text-decoration: none;
  transition: background var(--transition), color var(--transition);
}
.nav-mode-btn:hover { color: var(--ink); }
.nav-mode-btn.is-on {
  background: var(--primary);
  color: var(--primary-ink);
  box-shadow: var(--elev-1);
}
.nav-mode-btn:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
}

/* Account and reference material sit below the lifecycle, separated by space
   rather than another heading — they are not something you DO to a video. */
.nav-foot {
  display: grid;
  gap: 2px;
  margin-top: var(--space-6);
  padding-top: var(--space-3);
  border-top: 1px solid var(--rule);
}

/* ── Client switcher ───────────────────────────────────────────────────────
   Sits directly under the mode pill so "which client am I working on" is
   answered before any question about what to do. Quiet by default — it is
   orientation, not an action — but always present, because acting on the wrong
   artist is the most expensive mistake this product allows. */
.nav-client {
  display: grid;
  gap: var(--space-1);
  margin: 0 var(--space-3) var(--space-4);
}
.nav-client-label {
  color: var(--muted);
  font: 650 var(--text-micro)/1 var(--mono);
  letter-spacing: 0.12em;
  text-transform: uppercase;
}
.nav-client-select {
  width: 100%;
  padding: var(--space-2) 10px;
  border: 1px solid var(--rule);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  color: var(--ink);
  font: 650 var(--text-footnote)/1.2 var(--sans);
  cursor: pointer;
}
/* The reserved slot: exactly the switcher's box, and nothing else. It exists for
   the ~75ms between the rail painting and `/api/v1/aesthetics` answering, on
   browsers where the switcher was showing last visit. Its height is not stated
   here on purpose — it holds a real disabled `<select>`, so the space it reserves
   is the space the switcher will take, by construction rather than by arithmetic.
   Hidden rather than blank-styled: a skeleton shimmer would advertise a control
   that may turn out not to exist. */
.nav-client--reserved { visibility: hidden; }
.nav-client-select:hover { border-color: var(--rule-strong); }
.nav-client-select:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 1px;
}

/* ── Create modal ──────────────────────────────────────────────────────────
   Every choice offers a NAMED default rather than a hidden fallback, so a draft
   can be made having decided nothing and the user can still see what will
   happen. "Let Syzygy pick" is a real button, not the absence of a click. */
.create-modal { display: grid; gap: var(--space-4); }
.create-choice { display: grid; gap: 6px; }
.create-choice-label {
  color: var(--muted);
  font: 650 var(--text-micro)/1 var(--mono);
  letter-spacing: 0.12em;
  text-transform: uppercase;
}
.create-choice-body { display: flex; gap: 6px; flex-wrap: wrap; }
.create-auto {
  padding: 7px var(--space-3);
  border: 1px solid var(--rule);
  border-radius: 999px;
  background: var(--surface-2);
  color: var(--muted);
  font: 650 var(--text-caption)/1 var(--sans);
  cursor: pointer;
  transition: background var(--transition), color var(--transition), border-color var(--transition);
}
.create-auto:hover { color: var(--ink); border-color: var(--rule-strong); }
.create-auto.is-on {
  background: var(--primary);
  border-color: transparent;
  color: var(--primary-ink);
}
.create-auto:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.create-modal-foot {
  display: flex;
  justify-content: flex-end;
  gap: var(--space-2);
  margin-top: var(--space-2);
  padding-top: var(--space-3);
  border-top: 1px solid var(--rule);
}

/* ── Needs ─────────────────────────────────────────────────────────────────
   What a format requires, checked against what the profile holds, shown BEFORE
   a render is spent. Deliberately quiet: it is a readiness check, not an error,
   and dressing an unmet requirement in red would make an ordinary first-run
   state look like a fault. */
.create-needs { display: grid; gap: 6px; }
.create-need-list { display: grid; gap: var(--space-1); margin: 0; padding: 0; list-style: none; }
.create-need {
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  color: var(--muted);
  font-size: var(--text-subhead);
}
.create-need-mark {
  width: 1em;
  color: var(--rule-strong);
  font-variant-numeric: tabular-nums;
}
.create-need.is-ok { color: var(--ink-soft); }
.create-need.is-ok .create-need-mark { color: var(--success); }

/* ── Pool thinness (F4b) ───────────────────────────────────────────────────
   Whether a batch of N can actually look like N different posts, answered
   before the button. Same quiet register as Needs — a thin pool is a thing to
   fix in a minute, not a fault — so it warms (--warm) rather than alarms, and
   an ample pool takes the semantic success green (--success). Both tokens are redefined
   per theme, so light and dark are handled by the variables, not by a second
   rule. Never red: nothing here is broken. */
.create-pool {
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  margin: 0;
  color: var(--muted);
  font-size: 0.9rem;
}
.create-pool-mark {
  width: 1em;
  flex: 0 0 1em;
  font-variant-numeric: tabular-nums;
  font-weight: 800;
}
.create-pool.is-thin { color: var(--ink-soft); }
.create-pool.is-thin .create-pool-mark { color: var(--warm); }
.create-pool.is-ample { color: var(--ink-soft); }
.create-pool.is-ample .create-pool-mark { color: var(--success); }
.create-pool.is-checking, .create-pool.is-unknown { color: var(--muted); }
.create-pool a {
  flex: 0 0 auto;
  color: var(--accent);
  font-weight: 700;
  white-space: nowrap;
}
.create-pool a:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }

/* ── Depth ─────────────────────────────────────────────────────────────────
   What makes a dark UI read as premium rather than merely unlit: a ground that
   is not one flat fill, and surfaces that catch a little light at the top edge.

   THE GROUND ITSELF IS NOT DECLARED HERE. There used to be a second, complete
   field implementation at this spot — a `body { background-image: … }` with its
   own three radials and `background-attachment: fixed` — sitting several
   hundred lines below the pseudo-element field at "THE FIELD". Two independent
   fields in one stylesheet, and the later one won every time: per CSS 2.1
   Appendix E, `body`'s own background paints at step 3 (in-flow block-level
   descendant) while `body::before`/`body::after` are negative-`z-index`
   descendants painting at step 2, so the opaque `--field-base` on `body`
   covered both pseudo-elements. `@keyframes field-drift` had therefore never
   run visibly, and `body { background: transparent }` up at "THE FIELD" — put
   there precisely so the pseudos could show — was doing nothing.

   The pseudo-element implementation is the one the rest of the file is written
   around: `html` carries `--field-base` for it, the reduced-motion block names
   `body::after` by hand, `--ease-field` exists only for its animation, and the
   design spec's §1 is that code. So that one stays and this one is gone. Its
   one real idea — the glow belongs to the WINDOW, not to the scroll position —
   is already how the survivor works: both layers are `position: fixed`, which
   is `background-attachment: fixed` by another route. Do not re-add a `body`
   background here; it will silently delete the field again. */

/* Cards sit on that ground with a top rim, which is how a dark surface shows
   its edge — a drop shadow alone is invisible against near-black.

   `.modal-card` is deliberately NOT in this list. It was, and since this rule
   is later in the file at equal specificity it beat the `--elev-5` the modal
   declares for itself — so a modal rendered under exactly the shadow of the
   cards behind it, and the top rung of the ladder was used by nothing at all.
   Dropping it from the group is the whole fix: `.modal-card` already carries
   `var(--elev-5), var(--rim)`, so it loses no rim by leaving. Rung 5 means
   "exactly one thing at a time"; a shared selector cannot express that. */
.card,
.panel {
  box-shadow: var(--elev-2), var(--rim);
}

/* The rail carries a hairline and a whisper of the accent so it separates from
   the ground without a hard border. */
.studio-nav,
#studio-nav {
  background-image: linear-gradient(180deg, var(--accent-soft) 0%, transparent 220px);
}

/* Paid surfaces keep the shared Rising Tides magenta→violet gradient. */
.btn--upsell { background: var(--accent-gradient); }

/* ── Proven formats in the create modal ────────────────────────────────────
   Each row states what the format asks for before anything is spent. Quiet by
   default — this is reference, not a call to action; the primary path is still
   the quick draft above it. */
.create-format-list { display: grid; gap: 2px; margin: 0; padding: 0; list-style: none; }
.create-format a {
  display: grid;
  gap: 2px;
  padding: var(--space-2) 10px;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  color: var(--ink-soft);
  text-decoration: none;
  transition: background var(--transition), border-color var(--transition);
}
.create-format a:hover {
  border-color: var(--rule);
  background: var(--surface-2);
  color: var(--ink);
}
.create-format a:focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; }
/* The look tile's timing line. A still cannot show cut count, length or
   transition, and those are what actually separate two looks — so the tile
   says them. Quiet, monospaced, and tabular so the numbers line up down the
   grid rather than dancing tile to tile. */
.look-timing {
  color: var(--muted);
  font: 500 var(--text-caption-2)/1.3 var(--mono);
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.02em;
}

.create-format .dim { font-size: var(--text-caption); }

/* ── Media tiles with no thumbnail ─────────────────────────────────────────
   The library reports `thumb: null` when a row has none, so the client says so
   instead of requesting a URL that 404s into the browser's broken-image icon.
   Same box as `.cell img` (so grids keep their rhythm and backing token) and
   the same quiet centred-glyph treatment `.cell.audio` already established.
   Used by the Media library grid and the scene picker alike. */
.thumb-none {
  width: 100%;
  aspect-ratio: 9 / 13;
  display: grid;
  place-content: center;
  justify-items: center;
  gap: var(--space-2);
  padding: var(--space-2);
  background: var(--thumb-backing);
  color: var(--muted-2);
  text-align: center;
  /* The tile stands where an <img> stood, inside an <a> in the library grid
     and inside a .btn in the scene picker. Both ancestors style TEXT (link
     underline; the button's uppercase + tracking + weight), and an <img> was
     immune to all of it. Neutralise it so one placeholder reads identically in
     both grids instead of turning into an underlined link or a button label. */
  text-transform: none;
  letter-spacing: normal;
  font-weight: 400;
}
/* A text-decoration set on an ancestor is PAINTED ACROSS its in-flow block
   descendants and cannot be switched off from inside them, so the library
   grid's click-through link underlined the placeholder's label. The link wraps
   a media tile, never running text — an <img> was simply immune to it. */
.cell a { text-decoration: none; }
.thumb-none-glyph { font-size: 2.2rem; line-height: 1; }
.thumb-none-label { font-size: 11px; }

/* ── CREATION MARKUP HANDOFF (lane L6) ─────────────────────────────────────
   The One-Shot rules now replace the static inline styles L6 left behind.
   The template-thumbnail rules below them remain the CSS half of the handoff:
   templates.rs has not adopted those class names yet.

   Whoever next opens templates.rs: add the class, delete the inline style on
   the line named, and that half of this block stops being inert. If these names are NOT
   the ones that land, delete this block rather than leaving it — a rule with
   no markup is the same rot as the markup-with-no-rule this sweep just fixed,
   only pointing the other way. */
/* The two One-Shot section-control rows. */
.oneshot-inline-controls { display: inline-flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
/* The One-Shot "Pick a look" header row. */
.oneshot-looks-head { margin-top: var(--space-4); }
/* templates.rs:651 — the 9:16 thumb frame. Its own comment says the inline
   style exists "without touching styles.css"; this is that permission.
   `style="position:relative;overflow:hidden;aspect-ratio:9/16;width:min(270px,100%);margin:0 auto;"` */
.template-thumb-frame { position: relative; overflow: hidden; aspect-ratio: 9 / 16; width: min(270px, 100%); margin: 0 auto; }
/* templates.rs:662 — the "no preview" tile inside that frame. */
.template-thumb-missing {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 6px;
  padding: var(--space-2);
  text-align: center;
}
/* templates.rs:709 — the shimmer layer. */
.template-thumb-skeleton { position: absolute; inset: 0; pointer-events: none; }
/* templates.rs:713 and :717 — the "preparing preview" badge over it. */
.template-thumb-pending { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: var(--space-2); pointer-events: none; }
.template-thumb-pending .pill { background: var(--surface); color: var(--ink); box-shadow: var(--shadow-sm); }


/* ══ SECOND PASS ═════════════════════════════════════════════════════════════
   Owner P built the motion vocabulary. This pass spends it, and closes the
   maker screen's outstanding class handoff. Everything below composes from
   tokens that already existed — the hard freeze on colour and type is intact,
   and `scripts/design-freeze.sh` is the proof, not the promise.

   Two kinds of rule live here and they are labelled, because the difference
   matters to whoever reads this next:
     • LANDED   — markup in `app/src` already carries the class.
     • HANDOFF  — the class is defined and the markup does not carry it yet.
                  Each one names the exact element. If a handoff name is not the
                  one that lands, DELETE the rule rather than leave it: a rule
                  with no markup rots exactly the way markup with no rule does.
   ════════════════════════════════════════════════════════════════════════════ */

/* ── THE DROP TARGET ────────────────────────────────────────────────────────
   LANDED — `oneshot.rs:2505`, `<div class="rail-empty oneshot-drop" id="oneshot-drop">`.

   This is the first thing a new account sees and the only way into the product,
   so it is sized like a destination rather than like a form field. `.rail-empty`
   underneath it supplies the dashed placeholder idiom, the soft ground and the
   muted text; this adds the three things a DROP target needs that a generic
   empty panel does not: somewhere obvious to aim, room to aim at, and an armed
   state.

   The border is 2px and it is ALWAYS THERE. That is P-10, and it is the whole
   reason the armed state below can only touch `border-style` and
   `border-color`: a 1px dashed rule that becomes a 2px solid one on drag-over
   would move every pixel of the panel's contents by one, under a pointer that
   is holding a file, at the exact moment the person needs the target to hold
   still. Width is fixed; only the paint changes.

   `min-height` is a clamp rather than a fixed number because this panel is the
   full width of the step card on a desktop and the full width of the phone on a
   phone, and a 220px box is generous in the first case and most of the screen
   in the second. */
.oneshot-drop {
  min-height: clamp(168px, 24dvh, 232px);
  display: grid;
  place-content: center;
  justify-items: center;
  gap: var(--space-3);
  padding: var(--space-8) var(--space-6);
  border-width: 2px;
  border-style: dashed;
  text-align: center;
  /* --dur-press, not --dur-state. Everything else in the motion table that
     answers a pointer is a hover, and a hover can afford 140ms because the
     pointer is going to stay there. A drag is a person holding a file over a
     box asking "is this the right box" — the answer has to be at the floor of
     what registers as feedback rather than as lag. */
  transition: border-color var(--transition-press),
              background var(--transition-press),
              box-shadow var(--transition-press),
              color var(--transition-press);
}
/* The headline is the instruction, so it gets to be a sentence rather than a
   caption. `.rail-empty strong` sets --text-caption for a 240px editor rail;
   this panel is up to 1240px wide and the same size there reads as fine print
   on a poster. Existing scale step, one rung up. */
.oneshot-drop strong { font-size: var(--text-title-3); }
/* Measure. The supporting line is two sentences of explanation and at full
   card width it sets as one 130-character line, which nobody reads. */
.oneshot-drop .dim { max-width: 52ch; }
/* The browse fallback is the keyboard and screen-reader path into the same
   action, so it is centred with the rest rather than stretched across the
   panel as a full-width control would be. */
.oneshot-drop input[type="file"] { width: auto; max-width: 100%; justify-self: center; }

/* ── THE DROP TARGET, ARMED ─────────────────────────────────────────────────
   HANDOFF — `oneshot.rs:2506`, the `style=move || …` closure on `#oneshot-drop`
   that returns `"border-style: solid; text-align: center;"`. Replace it with
   `class:is-over=move || drag_over.get()` and the inline style goes entirely
   (the base rule above already owns `text-align`).

   Unmistakable, and three channels deep on purpose. A dashed border going solid
   is a real signal but it is a SUBTLE one, and it is the only one the inline
   style could carry, because a colour in a Rust string is a colour the freeze
   script cannot see. From the stylesheet the accent token is available, so the
   armed state also takes the accent border, an 8% wash of the accent as ground,
   and a 1px inset ring that reads as the box lighting up. Inset, not outset:
   an outset ring paints outside the border box and would push against the card
   edge; an inset one is drawn inside space the panel already occupies, so
   nothing moves. Same idiom as `.look-card.is-active`.

   Note there is no transform and no scale. The target must not move toward the
   cursor while a file is over it — the drop coordinates are the point. */
.oneshot-drop.is-over {
  border-style: solid;
  border-color: var(--accent);
  background: color-mix(in srgb, var(--accent) 8%, transparent);
  box-shadow: 0 0 0 1px var(--accent) inset;
  color: var(--ink-soft);
}
.oneshot-drop.is-over strong { color: var(--ink); }

/* ── THE SONG ROW'S TILE ────────────────────────────────────────────────────
   LANDED — `oneshot.rs:2699`, `<span class="thumb-none oneshot-song-thumb">`,
   which still carries `style="width: 34px; flex: 0 0 34px;"`. That inline pair
   can go now.

   `.thumb-none` is a grid TILE: `width: 100%`, a 9:13 aspect and a 2.2rem
   glyph, all correct in the media library and all wrong in a 40px-tall list
   row, where 9:13 at full row height would make an audio placeholder taller
   than the row that contains it. Squared off, pinned at 34px in both axes, and
   the glyph handed the row's own callout size instead of the tile's display
   size — an existing step on the type scale, so the freeze is untouched and the
   glyph is simply the size of the thing it sits beside. */
.oneshot-song-thumb {
  width: 34px;
  height: 34px;
  flex: 0 0 34px;
  aspect-ratio: auto;
  gap: 0;
  padding: 0;
  border-radius: var(--radius-sm);
  font-size: var(--text-callout);
  line-height: 1;
}

/* ── THE CONTACT SHEET ──────────────────────────────────────────────────────
   LANDED — `oneshot.rs:2780`, `<div class="grid" id="oneshot-photo-thumbs">`,
   which still carries the inline `grid-template-columns` / `gap` pair. Add
   `oneshot-thumb-grid` to the class list and the inline style goes.

   `.grid` is built for 224px catalogue cards. These are proof tiles — "here is
   what landed" — and at catalogue width six of them push the look picker two
   screens down, which is the fault the inline style was patching. 84px is
   large enough to recognise your own photograph and small enough that a dozen
   of them are one glance rather than one scroll. Declared after `.grid`, same
   specificity, so source order does the overriding; that is why this block
   lives at the foot of the file and not beside the other One-Shot rules. */
.oneshot-thumb-grid { grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: var(--space-2); }

/* ── THE HERO PLAYER ────────────────────────────────────────────────────────
   HANDOFF — `oneshot.rs:3876`, `<video id="oneshot-player" class="oneshot-player">`
   with a five-property inline `style`. Add `oneshot-player--hero` and delete it.

   The bug this shape fixes is recorded in that element's own comment and is
   worth keeping: a `<video>` with no height keeps the HTML default 150px box,
   `width: 100%` stretches that into a letterbox slot, and `object-fit: contain`
   then draws a 1080×1920 render about 90px tall with black down both sides.
   The frame has to be told it is portrait.

   `height: min(62dvh, 78vw)` and `width: auto` — height-led, because the render
   is 9:16 and height is the axis that runs out first. 62dvh leaves the
   result's three actions above the fold on a laptop, which is the whole point
   of FV8; 78vw is the phone bound, where 62dvh of a tall viewport would be
   wider than the screen and the browser would silently shrink it anyway.
   `dvh`, not `vh`, throughout: `vh` is the tallest the viewport EVER gets, so
   on a phone with the browser bar showing a `vh`-sized player is taller than
   the glass and its bottom edge — the scrubber — sits behind the toolbar.
   `max-height: none` because the base `.oneshot-player` caps at 58dvh for the old letterbox
   shape and that cap would now fight the aspect ratio. `flex: 0 0 auto`
   because the base rule says `flex: 1 0 100%` to claim its own row, and a
   flex-basis of 100% on an element that is deliberately narrower than its row
   is a contradiction the browser resolves against us. */
.oneshot-player--hero {
  aspect-ratio: 9 / 16;
  height: min(62dvh, 78vw);
  width: auto;
  max-width: 100%;
  max-height: none;
  flex: 0 0 auto;
  margin: 0 auto;
}

/* ── THE RESULT'S ACTIONS ───────────────────────────────────────────────────
   LANDED — `oneshot.rs:3907`, `<div class="oneshot-result-actions"
   id="oneshot-result-actions">`, which still carries the whole layout inline.

   `.oneshot-result` is a wrapping flex row and the player above takes a row to
   itself, so this claims its own row too (`flex: 1 0 100%`) and centres its
   contents under the video rather than ranging them left against a frame that
   is centred. 9px matches `.oneshot-result`'s own gap, which is what makes the
   actions read as belonging to the player rather than as a separate toolbar. */
.oneshot-result-actions {
  flex: 1 0 100%;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 9px;
}

/* ── P-6 · THE RESULT IS THEATRE ────────────────────────────────────────────
   HANDOFF — the three-up result strip being built in `#oneshot-result-block`.
   Expected markup:

     <div class="oneshot-result-strip">
       <figure class="oneshot-result-slot">
         <video class="oneshot-player oneshot-player--hero" …></video>
         <figcaption class="oneshot-result-caption">
           <strong class="oneshot-result-look">Neon Nights</strong>
           <span class="oneshot-result-hook">"when the beat drops at 0:14"</span>
         </figcaption>
       </figure>
       … up to three, each appended as its own render finishes …
     </div>

   ONE PRESS RETURNS THREE VIDEOS (`fcdd69e`), and they finish at different
   times — measured at 32.9s for the first and 59.0s for all three. So this is
   not a grid that appears; it is a strip that fills in, and each slot has to be
   able to arrive on its own, twenty-six seconds after its neighbour, without
   the two that are already there moving.

   `repeat(auto-fit, minmax(180px, 320px))` and NOT `minmax(180px, 1fr)`, which
   is the obvious spelling and is wrong here. `auto-fit` collapses the empty
   repetitions, so with a single video the one surviving track takes the whole
   `1fr` and a 9:16 player fills a 1240px column — a portrait video rendered
   two thousand pixels tall. Capping the track at 320px means one arrival is a
   phone-sized frame, centred, and three are a row; that is the "does not
   collapse when only one has arrived" requirement, and it is also the case
   that happens FIRST and therefore the one a person actually sees.

   `justify-content: center` centres the tracks themselves, so a single slot
   sits under the middle of the card rather than hard left, and two slots are a
   pair rather than a row with a hole in it. */
.oneshot-result-strip {
  flex: 1 0 100%;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(180px, 320px));
  justify-content: center;
  align-items: start;
  gap: var(--space-4);
}
/* Each slot ARRIVES. This is the P-6 row and the only other place in the studio
   allowed `--dur-stage`, for the same reason `.oneshot-player` has it: this IS
   the finished render, it is just that there are now three of them. No stagger
   ladder — the P-2 cap exists to stop a 400-item library counting itself in,
   and these three are separated by tens of SECONDS of real render time, so a
   34ms offset between them would be invisible and dishonest. Each one settles
   when its own file is ready, which is the truthful sequencing. */
.oneshot-result-slot {
  min-width: 0;
  display: grid;
  gap: var(--space-2);
  margin: 0;
  animation: scale-in var(--dur-stage) var(--ease-out) both;
}
/* Inside a capped track the hero's viewport-led height would overflow it, so
   the player fills the track and takes its height from the aspect ratio. */
.oneshot-result-slot .oneshot-player,
.oneshot-result-slot video {
  width: 100%;
  height: auto;
  max-height: none;
  aspect-ratio: 9 / 16;
  flex: 0 0 auto;
  margin: 0;
}
/* The label. Three videos that differ only in look and hook are three videos
   nobody can tell apart, so the strip says which is which — that is what makes
   it a choice rather than a pile. Quiet: the video is the thing being looked
   at, and a caption competing with it would be the tail wagging the dog. */
.oneshot-result-caption { display: grid; gap: var(--space-1); text-align: center; }
.oneshot-result-look { color: var(--ink-soft); font-size: var(--text-caption); }
/* The hook is a quotation of what is ON the video, so it is set as one — the
   line clamp is there because a hook can run to 120 characters and three
   captions of different heights would leave the strip's players at three
   different baselines. */
.oneshot-result-hook {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
  color: var(--muted);
  font-size: var(--text-caption-2);
}
/* A slot whose render has not landed yet holds the shape so the two that have
   do not shuffle sideways when the third arrives. Content-shaped, per P-3:
   this is the only skeleton in the studio that is 9:16, because it is the only
   one standing in for a video. */
.oneshot-result-slot.is-pending { aspect-ratio: 9 / 16; align-self: stretch; }

/* ── P-9 · EMPTY STATES INVITE ──────────────────────────────────────────────
   AUDIT, first, because the row is written as if nothing existed and that is
   not what is there:

     `.rail-empty` is already a real empty state — dashed placeholder box, soft
     ground, a `strong` headline, muted body copy and an accent link — and every
     one of its nine uses in `editor.rs` carries a sentence rather than a word.
     It is not apologising; it is just cramped. Its `gap: var(--space-2)` runs
     the headline into the body, and its padding is a 240px-rail padding used in
     places that are no longer a 240px rail.

     `.render-tray-empty` is the one that genuinely under-delivers. It shares a
     rule with `.render-job` — the same 11px box — so "nothing rendering" looks
     exactly like a render, and it is ranged left in a panel where every sibling
     is a card. It is a statement with no shape.

     `.stage-empty` is correct as it is and is deliberately left alone: it
     paints INSIDE the always-dark 9:16 stage, its colour is pinned
     light-on-dark for a measured contrast reason, and it already carries a
     link.

     The `"None"` strings the grep finds are all `<option>` labels in a select
     (`editor.rs`, `style_panel.rs`, `shared_block.rs`) — "no filter", "no
     transition". Those are values, not empty regions, and renaming them would
     make the control lie. The threshold is met: no empty REGION says only
     "None".

   So this is rhythm and shape, not a rewrite. */
/* Space to breathe, and a headline that separates from its explanation. The
   two-step gap (headline → body at --space-2, block → action at --space-3) is
   the same rhythm `.oneshot-step-head` uses, so an empty state reads as part of
   the same system rather than as a notice pasted into it. */
.rail-empty {
  gap: var(--space-3);
  padding: var(--space-6) var(--space-4);
  justify-items: start;
  line-height: 1.5;
}
.rail-empty strong + * { margin-top: calc(var(--space-2) - var(--space-3)); }
/* The link is the ACTION, and an action a person has to find inside a sentence
   is an action most people do not take. It keeps the accent it already had and
   gains an underline offset and a hit area, so it reads as somewhere to go. */
.rail-empty a {
  text-underline-offset: 3px;
  transition: color var(--transition);
}
.rail-empty a:hover { color: var(--accent); }
/* HANDOFF — `render_tray.rs:604`, `<li class="render-tray-empty">{label}</li>`.
   Nothing to change in the markup; this is the CSS half catching up. Centred
   and given the height of one job card, so an empty tray reads as a tray with
   nothing in it rather than as a job that failed to draw. */
.render-tray-empty {
  min-height: 68px;
  display: grid;
  place-content: center;
  gap: var(--space-2);
  border-style: dashed;
  color: var(--muted);
  text-align: center;
}

/* ── OPEN WIRING JOBS FROM THE SECOND PASS ──────────────────────────────────
   Every class below is defined above and is NOT yet on the element it was
   written for. This list is the whole of the debt; when it is empty, delete it.
   If one of these names turns out not to be the name that lands, delete the
   RULE too rather than leaving it — a rule with no markup rots exactly the way
   markup with no rule does, only pointing the other way.

     .oneshot-drop.is-over      → `#oneshot-drop` (`oneshot.rs:2506`). Replace the
                                  `style=move || …` closure with
                                  `class:is-over=move || drag_over.get()`. The
                                  inline `border-style: solid` and
                                  `text-align: center` both become dead.
     .oneshot-player--hero      → `#oneshot-player` (`oneshot.rs:3876`). Add to the
                                  class list, delete the five-property inline style.
     .oneshot-thumb-grid        → `#oneshot-photo-thumbs` (`oneshot.rs:2780`). Add to
                                  the class list beside `grid`, delete the inline
                                  `grid-template-columns` / `gap`.
     .oneshot-result-actions    → already on `#oneshot-result-actions`
                                  (`oneshot.rs:3907`); only the inline style needs
                                  deleting.
     .oneshot-song-thumb        → already on the tile (`oneshot.rs:2699`); only the
                                  inline `width` / `flex` pair needs deleting.
     .oneshot-result-strip      → the three-up result block being built in
     .oneshot-result-slot          `#oneshot-result-block`. Markup shape is written
     .oneshot-result-caption       out in full at the P-6 block above.
     .oneshot-result-look
     .oneshot-result-hook
     .oneshot-result-slot.is-pending
     .progress-track            → `oneshot.rs:3727` and `:3739`, `render_tray.rs:246`
     .progress-fill                and `:250`. `<progress>` cannot be animated in
                                  Chromium — measured, see the P-8 block — so P-8's
                                  bar half stays OPEN until these land. Keep the
                                  determinate / indeterminate distinction
                                  (`ProgressView`); an indeterminate bar must not
                                  claim a percentage.
     .num-tick                  → any counter that changes on screen: the plan/quota
                                  numbers (`plan.rs`), the render count, the hook
                                  line count. Contract at the P-8 block. This is the
                                  other half of P-8 and it is also OPEN.

   And one job that needs no new class, only markup — P-3, `hooks.rs:717`. The
   Hooks screen's loading state is a single `.row` containing the words
   "Loading hooks…". Every other screen in the studio shows content-shaped
   skeletons here (`media.rs`, `templates.rs`, `dashboard.rs`, `plan.rs`,
   `scene_picker.rs` all use `<… class="… skeleton anim-pulse">`). Six
   `<li class="row skeleton anim-pulse">` would match them, and `.row.skeleton`
   now has the 76px shape waiting for it. */
