/* Night is the default and the one the app was designed in. Day is a real second
   palette, not an inversion.

   THE ACCENT IS NOT THE REEL'S PAPAYA, AND THIS IS THE NOTE THAT SAYS SO.
   It used to be, and this comment used to claim they must stay equal "because it is the
   one colour that also ends up burned into the reels". That was never load-bearing: the
   banner colour is server-side, in brand.js (`headerColour`), and ffmpeg burns it from
   there — no stylesheet is involved in making a reel. So when the owner moved the app's
   accent to #FF3132 (2026-08-23) the reels kept their papaya, correctly and separately.
   The variable was renamed from --papaya at the same time, because a token named after a
   colour it no longer holds is a trap for whoever reads it next. */
:root {
  /* Native popups, scrollbars and form controls are drawn by the browser, and without this
     it draws them for a LIGHT page: a white dropdown panel over a dark app, which is where
     half the blue was coming from. */
  color-scheme: dark;
  --accent: #FF3132;
  /* White, by the owner's call (2026-08-23), and the same in both themes so a red button
     reads identically whichever palette you are in. Near-black measures better against
     this red — 5.17:1 against white's 3.66:1 — but every one of these is short, bold,
     12-13px text on a large filled target, and matching the brand's own red-and-white is
     the decision being made here. Noted rather than argued: if small red text on a light
     surface ever appears, it needs its own colour, not this token. */
  --on-accent: #fff;
  --bg: #101318;
  --panel: #171b21;
  --panel-2: #1e242c;
  --line: #2a323c;
  --text: #e7ecf2;
  --muted: #8b95a3;
  --good: #45b26b;
  /* The approve orange: the papaya already in this app's accent palette (ACCENTS in
     app.js). Fixed rather than themed on purpose — orange means "not done yet" and green
     means "done", and those meanings must not change when somebody picks a new accent. */
  --approve: #FC6200;
  --warn: #e0b341;
  --bad: #e2564d;
  --media-bg: #000;
  --brand-mark: #ffffff;
  --ink: #e7ecf2;             /* on dark the active pill inverts to near-white */
  --on-ink: #101317;
  --chip-mock-bg: #1d2b1f; --chip-mock-line: #2f5136; --chip-mock-text: #9edeb0;
  --chip-live-bg: #33191a; --chip-live-line: #6a2b2b; --chip-live-text: #ff9f96;
  --pulse: #f6dd9a;
  --shadow: rgba(0, 0, 0, .35);
  --glass: rgba(30, 36, 44, .62);
  --glass-line: rgba(255, 255, 255, .10);
  --scrim: rgba(6, 8, 11, .58);
  --bar-edge: rgba(255, 255, 255, .72);
}

* { box-sizing: border-box; }

body {
  margin: 0;
  height: 100vh;
  display: flex;
  flex-direction: column;
  background: var(--bg);
  color: var(--text);
  font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}

h1 { font-size: 20px; margin: 0; }
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin: 0 0 10px; }
h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin: 18px 0 8px; }

.muted { color: var(--muted); font-weight: 400; }

.topbar {
  display: flex; align-items: center; justify-content: space-between;
  padding: 10px 16px; background: none;
  margin-left: 0; transition: margin-left .16s ease;
  /* Held to the same line as everything else. The bar's height used to come from its
     tallest child, so hiding the brand when the menu opens collapsed it and dragged the
     theme button up with it — 13px, every single toggle. Doubling the mark made that
     jump three times worse, which is how it was noticed at all. Twice the line is a bar
     that centres on the line. */
  min-height: calc(var(--menu-btn-line) * 2);
  position: relative;
}
body.nav-open .topbar { margin-left: var(--rail-open); }
/* The brand lives in the menu; the top bar only carries it while the menu is hidden. */
/* The brand is no longer inside the top bar, so there is nothing here to hide. */
.brand { font-weight: 700; letter-spacing: .01em; margin-right: auto; }
/* THE mark. Singular, and deliberately a child of <body> rather than of the top bar or
   the menu, because both of those move: the bar slides right by --rail-open, and the
   menu animates its width from 0 while clipping its contents. It previously existed
   twice and swapped between them, which is why it kept moving however carefully each
   copy was positioned — the two never had to agree mid-animation, and one of them was
   being clipped by a container that was still growing.

   Above the rail so the sidebar opens behind it. Click-through, because it is the only
   thing now sitting over the menu head and it must not steal the toggle's corner. */
.app-brand { position: fixed; left: var(--brand-cx); top: var(--menu-btn-line);
  /* Just above the rail, and below every overlay. At 60 it floated over the workspace
     and sat in the way of the panel that had just opened. The mark belongs to the page,
     not on top of whatever is covering the page. */
  transform: translate(-50%, -50%); z-index: 41; pointer-events: none;
  /* The lockup is a background rather than an <img> because it has to answer the theme,
     and a stylesheet can swap a background but cannot swap a src. Both files are the
     same alpha map with the type recoloured, so the two states are the same artwork. */
  width: var(--brand-w); height: calc(var(--brand-w) * 204 / 371);
  background: url("/logo-base.png") center / contain no-repeat; }
/* The mark is two layers: the clapper and "one" are white and stay white, and the dot and
   "take" are painted with the accent through a mask cut from the red pixels of the
   original. That is why changing the accent does not need a second logo, or seven. */
.app-brand::after, .brand::after {
  content: ''; position: absolute; inset: 0;
  background: var(--accent);
  -webkit-mask: url("/logo-tint.png") center / contain no-repeat;
  mask: url("/logo-tint.png") center / contain no-repeat;
}

.chips { display: flex; gap: 6px; flex-wrap: wrap; }
.chip {
  font-size: 11px; padding: 3px 8px; border-radius: 999px;
  background: var(--panel-2); border: 1px solid var(--line); color: var(--muted);
}
.chip.mock { background: var(--chip-mock-bg); border-color: var(--chip-mock-line); color: var(--chip-mock-text); }
.chip.live { background: var(--chip-live-bg); border-color: var(--chip-live-line); color: var(--chip-live-text); }
.chip.bad { color: var(--bad); border-color: var(--chip-live-line); }

/* The rail sits outside the grid and overlays it when it expands, so opening the
   menu never reflows the work underneath. Pinning is the one case that does. */
.layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  /* Bound the row too. Without this the row auto-sizes to its content, the grid
     clips it, and anything below the canvas is simply never on screen. */
  grid-template-rows: minmax(0, 1fr);
  gap: 1px; background: var(--line);
  /* The composer is pinned to the bottom of the canvas, so the canvas must scroll
     inside the viewport rather than grow it. The height chain is explicit all the
     way down: body -> layout -> content -> panel -> canvas. */
  flex: 1;
  min-height: 0;
  overflow: hidden;
  margin-left: 0;
  transition: margin-left .16s ease;
}
body.nav-open .layout { margin-left: var(--rail-open); }
/* The ledger is a drawer behind the spend pill, not a permanent third column. */
.sidebar, .content, .ledger { background: var(--bg); padding: 14px; }
.content { display: flex; flex-direction: column; min-height: 0; }
/* #workspace sits between the content column and the panel. Without it in the
   flex chain the panel is unbounded and the composer lands below the fold. */
#workspace { display: flex; flex-direction: column; flex: 1; min-height: 0;
  /* The shelf is positioned against this, so it floats over the grid instead of
     taking a row in the column and pushing the reels up. */
  position: relative; }
.ledger { overflow-y: auto; }
.panel { flex: 1; min-height: 0; overflow-y: auto; }
/* The studio panel does not scroll: the canvas inside it does. Set by the renderer
   rather than by :has(), so the behaviour does not depend on selector support. */
.panel.studio { overflow: hidden; display: flex; flex-direction: column; }
.ledger { background: var(--panel); }

input, select, textarea, button {
  font: inherit; color: var(--text);
  background: var(--panel-2); border: 1px solid var(--line);
  border-radius: 6px; padding: 7px 9px;
  /* The bits of a form control the browser paints itself — the highlighted option in an
     open dropdown, a caret, a checkbox tick — default to the operating system's blue.
     Pointing them at the accent is what makes a themed app themed all the way down
     rather than only as far as our own CSS reaches. */
  accent-color: var(--accent);
}
textarea { resize: vertical; width: 100%; }
button { cursor: pointer; background: var(--accent); border-color: var(--accent); color: var(--on-accent); font-weight: 600; }
button:hover:not(:disabled) { filter: brightness(1.1); }
button:disabled { opacity: .4; cursor: not-allowed; }
button.ghost { background: var(--panel-2); border-color: var(--line); color: var(--text); font-weight: 500; }
button.danger:hover { border-color: var(--bad); color: var(--bad); }

/* A LINK THAT IS A BUTTON, and it has to be a link.
   The download on the last step is an <a download>, not a <button>: a button would need
   JavaScript to fetch the file and hand it to the browser, where an anchor with a filename is
   the browser's own job and gets the save dialog, the progress and the resume for free. It
   therefore misses everything above, which is all element selectors on `button`. */
a.btn { display: inline-block; text-decoration: none; cursor: pointer;
  font: inherit; font-weight: 600; border-radius: 6px; padding: 7px 9px;
  background: var(--accent); border: 1px solid var(--accent); color: var(--on-accent); }
a.btn:hover { filter: brightness(1.1); }

/**
 * `.spends` — this button calls a model, or rebuilds audio.
 *
 * ONE class rather than a decision at each of a dozen call sites, because the question
 * "does pressing this cost something or take real time" has a single answer per button
 * and should look the same everywhere. It beats `.ghost`: several of these were secondary
 * grey while doing the most expensive thing on their screen.
 *
 * WORKING = DIAGONAL STRIPES, not a pulse (owner, 2026-08-21). A pulse says "something is
 * happening somewhere"; stripes that travel say "this is running, and it has not stalled".
 * On a call that can take a minute — a render, a script, five voice takes — that
 * distinction is the whole message.
 *
 * The 28.284px repeat is 20 x sqrt(2): a 45-degree gradient with a 20px period repeats
 * horizontally at that distance, so scrolling the background by exactly one repeat loops
 * seamlessly. Any other number visibly jumps once a second.
 */
/**
 * THE FADED ACCENT, and it is not a new colour.
 *
 * `.spends.working` lays white stripes at 30% over the accent; the pale band that produces is
 * the "faded colour used in the animated buttons" the owner asked for (2026-09-01). Defined
 * here, beside the rule it comes from, so the two cannot drift apart — and derived with
 * color-mix from --accent so it follows every theme rather than being a second hex to update.
 */
:root { --accent-soft: color-mix(in srgb, #fff 30%, var(--accent)); }

button.spends,
button.ghost.spends {
  background-color: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
  font-weight: 600;
}
button.spends.working {
  background-image: repeating-linear-gradient(45deg,
    rgba(255, 255, 255, .30) 0 10px,
    rgba(255, 255, 255, 0) 10px 20px);
  background-size: 28.284px 28.284px;
  animation: spends-stripes .55s linear infinite;
  /* Disabled here means "already working", not "not for you", so it keeps its colour
     rather than fading to the usual 40%. */
  opacity: 1;
  cursor: progress;
}
@keyframes spends-stripes { to { background-position: 28.284px 0; } }
@media (prefers-reduced-motion: reduce) {
  /* The stripes stay, so the state is still legible; only the travel stops. */
  button.spends.working { animation: none; }
}

.empty { color: var(--muted); padding: 40px 0; text-align: center; }
.project-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; margin-bottom: 12px; }
/* Fixed with the title rather than scrolling with the thing it describes. */
.head-title { min-width: 0; }
.head-name { display: flex; align-items: center; gap: 8px; min-width: 0; }
/* Faint until you go looking for it: the title is read far more often than renamed. */
.title-edit { background: none; border: 0; padding: 4px; border-radius: 7px; color: var(--muted);
  display: grid; place-items: center; opacity: 0; transition: opacity .12s ease; }
.head-title:hover .title-edit, .title-edit:focus-visible { opacity: 1; }
.title-edit:hover { color: var(--text); background: var(--panel-2); filter: none; }
/* Sized to the heading it replaces, so the header does not jump when you start typing. */
.title-rename { font-size: 20px; font-weight: 700; padding: 2px 8px; min-width: 320px; max-width: 100%; }
.title-rename[hidden], .title-edit[hidden], #project-name[hidden] { display: none; }
.page-blurb { color: var(--muted); margin: 3px 0 0; font-size: 13px; }
.page-blurb:empty { display: none; }

.rail { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 14px; }
.rail button {
  background: var(--panel); border: 1px solid var(--line); color: var(--muted);
  font-weight: 500; font-size: 12px; padding: 6px 10px;
}
.rail button.active { color: var(--text); border-color: var(--accent); }
.rail button.approved { color: var(--good); }
.rail button.locked { opacity: .45; }

.panel { background: none; border: 0; border-radius: 0; padding: 16px 16px 0; }
.panel .blurb { color: var(--muted); margin: 0 0 14px; }

.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; }
.card { background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px; padding: 10px; }
.card h4 { margin: 0 0 8px; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
.card img { width: 100%; border-radius: 4px; display: block; background: var(--media-bg); }
.card .placeholder {
  aspect-ratio: 1 / 1; display: grid; place-items: center; color: var(--muted);
  border: 1px dashed var(--line); border-radius: 4px; font-size: 12px;
}
.card .row { display: flex; gap: 6px; margin-top: 8px; }
.card .opt { display: block; font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; margin-top: 8px; }
.card .opt select { width: 100%; margin-top: 3px; font-size: 12px; padding: 5px; text-transform: none; letter-spacing: 0; }
.card .lib select { flex: 1; font-size: 11px; padding: 5px; min-width: 0; }
.card .lib button { flex: 0 0 auto; }
label.upload {
  display: block; margin-top: 8px; text-align: center; cursor: pointer;
  background: var(--accent); color: var(--on-accent); font-weight: 600; border-radius: 6px; padding: 7px 9px; font-size: 12px;
}
label.upload:hover { filter: brightness(1.1); }
.card .row button { flex: 1; font-size: 12px; padding: 5px; }
.card.ok { border-color: var(--good); }

.beat .del { background: var(--panel-2); border-color: var(--line); color: var(--muted); }

.fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin-bottom: 14px; }
.field label { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-bottom: 4px; }
.field input, .field select { width: 100%; }

.actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--line); }
.note { color: var(--muted); font-size: 12px; }
/* THE STATE COLOURS COME AFTER .note, AND THAT ORDER IS THE WHOLE RULE.
   They used to sit three lines above it, same specificity (0,1,0), so .note won every
   time the two met — and they only ever meet on the elements that matter:
   `class="note ${lint.errors ? 'bad' : ''}"` on the script's error count, and the same
   shape on the two-strikes warning. Both are written to go red and both rendered grey.
   A warning that cannot show is worse than an ugly one. Anything added here must stay
   below .note, or it goes quiet again with nothing to see. */
.ok { color: var(--good); }
.warn { color: var(--warn); }
.bad { color: var(--bad); }

.lint { list-style: none; margin: 12px 0; padding: 0; display: grid; gap: 4px; }
.lint li { font-size: 12px; padding: 6px 10px; border-radius: 6px; background: var(--panel-2); border-left: 3px solid var(--warn); }
.lint li.error { border-left-color: var(--bad); }
.lint li b { color: var(--warn); margin-right: 6px; text-transform: uppercase; font-size: 11px; }
.lint li.error b { color: var(--bad); }
/* The one lint row that carries its own answer. On its own line so the button does not
   sit mid-sentence, and inline-block so it keeps the row's padding rather than stretching
   across it. */
.lint li button { display: inline-block; margin-top: 6px; }

table.vo { width: 100%; border-collapse: collapse; margin-top: 12px; }
table.vo td { border-bottom: 1px solid var(--line); padding: 6px 8px; vertical-align: middle; font-size: 13px; }
table.vo .role { color: var(--muted); font-size: 11px; letter-spacing: .06em; white-space: nowrap; }
table.vo .secs { color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
table.vo audio { height: 32px; width: 220px; }

.split { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; margin-top: 14px; align-items: start; }
.split img.preview, .split video.player { width: 100%; max-width: 300px; border-radius: 8px; background: var(--media-bg); display: block; }
.bar { height: 6px; background: var(--panel-2); border-radius: 3px; overflow: hidden; }
.bar i { display: block; height: 100%; background: var(--accent); }

.entries { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; max-height: 40vh; overflow: auto; }
.entries li { font-size: 11px; color: var(--muted); border-bottom: 1px solid var(--line); padding-bottom: 4px; }
.entries b { color: var(--text); }

.toast {
  position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%);
  background: var(--panel-2); border: 1px solid var(--bad); color: var(--text);
  padding: 10px 14px; border-radius: 8px; max-width: 60ch; z-index: 10;
}
.opt.inline { display: inline-flex; align-items: center; gap: 6px; margin: 0; }
.opt.inline select { width: auto; margin: 0; }

/* Working state: a slow generation must never look like a frozen app. */
@keyframes pulse-border {
  0%, 100% { border-color: var(--warn); box-shadow: 0 0 0 0 rgba(224, 179, 65, .40); }
  50%      { border-color: var(--pulse); box-shadow: 0 0 0 7px rgba(224, 179, 65, 0); }
}
/* Was a pulsing yellow border. The spinner says the same thing in the shared language,
   and two animations on one tile competed for the eye. The dim stays: it is what makes
   the spinner readable over a picture. */
.card.busy { border-color: var(--accent); }
.card.busy img, .card.busy .placeholder { opacity: .35; }
.card .working {
  margin: 8px 0 0; font-size: 11px; letter-spacing: .06em; text-transform: uppercase;
  color: var(--warn); text-align: center;
}
.card .working::after { content: ''; animation: working-dots 1.8s steps(4, end) infinite; }
@keyframes working-dots { 0% { content: ''; } 25% { content: '.'; } 50% { content: '..'; } 75% { content: '...'; } }

@media (prefers-reduced-motion: reduce) {
  .card.busy { animation: none; }
  .card .working::after { animation: none; content: '...'; }
}

/* Turnaround: extra angles of an approved asset. */
.turnaround { margin-top: 10px; border-top: 1px solid var(--line); padding-top: 8px; }
.turnaround h5 { margin: 0 0 6px; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); font-weight: 600; }
.turnaround h5 .note { text-transform: none; letter-spacing: 0; }
.views { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; }
.view { margin: 0; position: relative; border: 1px solid var(--line); border-radius: 4px; overflow: hidden; background: var(--panel); }
.view.ok { border-color: var(--good); }
.view.busy { border-color: var(--accent); }
.view.busy img, .view.busy .ph { opacity: .35; }
.view img, .view .ph { display: block; width: 100%; aspect-ratio: 1/1; object-fit: cover; background: var(--media-bg); }
.view figcaption { font-size: 9px; color: var(--muted); padding: 2px 3px; text-align: center; line-height: 1.2; }
.view-actions { display: flex; gap: 2px; padding: 0 2px 3px; }
.view-actions button { flex: 1; font-size: 11px; padding: 2px; line-height: 1; }
button.wide { width: 100%; margin-top: 6px; font-size: 12px; padding: 6px; }
.note.tight { margin: 8px 0 0; font-size: 11px; }
.lint li.good { border-left-color: var(--good); }
.lint li.good b { color: var(--good); }

/* Long jobs get the same yellow throb as the cards, with an elapsed counter. */
.busy-block {
  border: 1px solid var(--warn); border-radius: 8px; padding: 12px 14px; margin: 12px 0;
  background: var(--panel-2); animation: pulse-border 1.8s ease-in-out infinite;
}
.busy-title {
  display: flex; justify-content: space-between; align-items: baseline;
  font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--warn); font-weight: 600;
}
.busy-title::after { content: ''; animation: working-dots 1.8s steps(4, end) infinite; }
.busy-clock { font-variant-numeric: tabular-nums; color: var(--muted); letter-spacing: 0; }
.busy-bar { height: 4px; background: var(--panel); border-radius: 2px; overflow: hidden; margin-top: 10px; }
.busy-bar i { display: block; height: 100%; width: 35%; background: var(--warn); border-radius: 2px; animation: busy-slide 1.6s ease-in-out infinite; }
@keyframes busy-slide { 0% { margin-left: -35%; } 100% { margin-left: 100%; } }
table.vo.busy { opacity: .45; }
@media (prefers-reduced-motion: reduce) {
  .busy-block, .busy-bar i { animation: none; }
  .busy-title::after { content: '...'; animation: none; }
}

/* Avatar bank: the shipped cast. Hero shot only; the other angles are references. */
.avatars { margin-top: 10px; border-top: 1px solid var(--line); padding-top: 8px; }
.avatars h5 { margin: 0 0 6px; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); font-weight: 600; }
.avatar { margin: 0; text-align: center; }
.avatar img, .avatar .ph { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 6px; background: var(--panel-2); border: 2px solid transparent; display: block; }
.avatar.ok img { border-color: var(--good); }
.avatar.incomplete img, .avatar.incomplete .ph { opacity: .35; }
.avatar figcaption { font-size: 10px; color: var(--muted); margin: 3px 0 2px; }
.avatar button { width: 100%; font-size: 10px; padding: 2px 0; }

/* The Actors library: fifty faces, so the tile is bigger than the in-reel picker's and
   the filter above it is the way anyone actually finds a person. Nothing is selectable. */
/* One row. Six rows of pills cost about a third of the panel before a single face
   appeared, which is the wrong trade in a library whose job is showing you faces. */
.actor-filters { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; margin-bottom: 14px; }
.actor-filter { font-size: 12px; padding: 5px 7px; max-width: 165px; }
/* A set filter has to be visible at a glance, or you forget why the grid is short. */
.actor-filter.on { border-color: var(--accent); color: var(--accent); }
.actor-count { margin: 0 0 0 4px; white-space: nowrap; }
.actor-count b { color: var(--text); }
.actor-filters .small { padding: 5px 9px; font-size: 12px; }

/**
 * The filter row stays put while the grid scrolls under it.
 *
 * Both libraries hold seventy-eight tiles, which is four or five screens: the controls
 * for narrowing that down were scrolling away exactly when the scrolling made you want
 * them. Sticky rather than a pinned flex column, because these panels differ in shape —
 * Actors carries a player overlay and a footer note, Locations does not — and sticky
 * does not care what else is in the box.
 *
 * The background is not optional. Without it the tiles slide visibly under the
 * dropdowns; the negative side margins and matching padding put that background across
 * the full width of the scrollport rather than leaving the tiles showing at the edges.
 */
.toolbox-body .actor-filters {
  /* -18px pulls the row up over the body's own top padding, so nothing shows in the
     strip above it as tiles pass underneath; the matching padding puts the space back
     inside the row. --panel, not --bg: the panel is what sits behind it. */
  /* -18px, not 0: measured. This engine pins the sticky box against the scrollport's
     CONTENT box, so top:0 parked the row 18px down — exactly the body's padding — and
     left a strip for tiles to show through. The offset cancels it, and the padding
     below puts the spacing back inside the row. */
  position: sticky; top: -26px; z-index: 5;
  background: var(--panel);
  margin: -26px -22px 10px; padding: 26px 22px 10px;
}

/* Format's copy of that row is NOT pinned, and cannot be.
   The rule above is written for a filter row that is the first thing in the panel: it
   pulls itself up by 26px to cover the body's padding. Mid-panel that offset lands on
   whatever sits above, which here is the row's own heading — the label disappeared under
   its own filters. Nothing on Format needs pinning anyway, since the strip it filters is
   directly beneath it and both are on screen together. */
.loc-pick .actor-filters{
  position: static; z-index: auto; background: none;
  margin: 0 0 10px; padding: 0;
}

/* The enlarge pill: on the tile, only while the pointer is on it. Bottom-left, clear of
   the caption, and small enough that it does not hide the thing it is offering to show. */
/* The square the photograph occupies, and the pill's positioning context. */
.loc-shot { position: relative; display: block; }
.loc-enlarge {
  /* Dead centre of the picture, not of the tile: the tile is the picture plus a caption,
     so centring on it would sit the pill low and off the image. */
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 2;
  padding: 2px 8px; border: 0; border-radius: 999px;
  background: rgba(8, 10, 14, .78); color: #fff; font-size: 10.5px; font-weight: 600;
  opacity: 0; transition: opacity .12s ease; cursor: pointer; }
.actor:hover .loc-enlarge { opacity: 1; }
.loc-enlarge:hover { background: var(--accent); color: var(--on-accent); filter: none; }

/* The enlarged photograph, over the grid. Sized to the file rather than to the screen:
   these are 512px squares and stretching one past its own resolution makes it worse. */
.loc-viewer { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center;
  background: var(--scrim); }
.loc-viewer-box { display: flex; flex-direction: column; gap: 10px; align-items: flex-start;
  background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 14px;
  box-shadow: 0 30px 90px var(--shadow); max-width: min(560px, 92vw); }
.loc-viewer-box img { width: min(512px, 84vw); height: auto; border-radius: 10px; display: block; }
.loc-viewer-meta { display: flex; flex-direction: column; gap: 2px; }
/* The angle strip under an enlarged prop. A pack has four or five views and this is the
   only place in the app they can be looked at, which is the whole reason for having them:
   is it the same object from every side. */
.shot-strip { display: flex; gap: 6px; flex-wrap: wrap; }
.shot { width: 54px; height: 54px; padding: 0; border-radius: 8px; overflow: hidden;
  border: 2px solid transparent; background: var(--panel-2); cursor: pointer; }
.shot img { width: 100%; height: 100%; object-fit: cover; display: block; }
.shot:hover { border-color: var(--line); }
.shot.on { border-color: var(--accent); }
.loc-viewer-meta .note { margin: 0; }

.actor-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); gap: 12px; }
/* The positioning context for the overlays that sit on a tile: the actor play button
   and the location enlarge pill. */
.actor { position: relative; margin: 0; text-align: center; }
.actor img, .actor-ph { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 8px;
  background: var(--panel-2); display: block; }
/* An ungenerated portrait reads as a deliberate gap, not a broken image. */
.actor-ph { border: 1px dashed var(--line); }
.actor figcaption { margin: 5px 0 0; line-height: 1.35; }
.actor figcaption b { display: block; font-size: 12px; color: var(--text); font-weight: 600; }
.actor.unbuilt figcaption b { color: var(--muted); font-weight: 500; }
/* The pack line is a second line under the name, not a continuation of it. */
.actor figcaption .note { display: block; font-size: 10.5px; }

/* An actor with a preview clip. The hit area covers the whole tile rather than being a
   small button in a corner: the picture IS the control, and the play mark only appears
   on hover so a grid of 78 is faces rather than furniture. */
.actor.playable { position: relative; }
.actor-hit {
  position: absolute; inset: 0; padding: 0; border: 0; border-radius: 8px;
  background: transparent; color: #fff; opacity: 0;
  display: flex; align-items: center; justify-content: center;
  transition: opacity .12s ease;
}
.actor-hit:hover, .actor-hit:focus-visible { opacity: 1; background: rgba(0, 0, 0, .42); }
.actor-hit svg { width: 22px; height: 22px; }

.actor-player {
  position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center;
  background: rgba(0, 0, 0, .72); padding: 24px;
}
.actor-player-box { display: flex; flex-direction: column; align-items: center; gap: 8px; }
/* 9:16, so height is the constraint on every screen this will ever open on. */
.actor-player video { height: min(72vh, 620px); width: auto; border-radius: 10px; display: block; background: #000; }
.actor-player .note { margin: 0; }

/* ------------------------------------------------------------- main nav */
:root { --rail-w: 56px; --rail-open: 214px;
  /* The line the menu control sits on, measured from the top of the window.
     There are TWO controls — #nav-show in the top bar when the menu is hidden, and
     #nav-toggle in the menu head when it is open — and they swap places as you use it.
     They were 16px apart, so the icon visibly jumped every time. This is where the
     top bar already centres its own contents (the theme button sits here), so the
     menu head is brought to it rather than the other way round. */
  --menu-btn-line: 40px;
  /* Where the mark sits, in BOTH states. It used to live inside two different layouts —
     the top bar's flex row when the sidebar is shut, the menu head when it is open — so
     it landed at cx 79 when closed and cx 107 when open and hopped 28px across and 4px
     down on every toggle. Pinning both copies to the rail's own centre line means the
     sidebar opens and closes behind a mark that does not move. */
  --brand-cx: calc(var(--rail-open) / 2);
  /* Bounded by the BUTTONS, not by the rail. The lockup is horizontal now, and the two
     menu states put a button on opposite flanks of it: open, the hide button occupies
     178..205; closed, the show button occupies 16..43. The mark is centred on 107 and is
     required not to move between the states, so its half-width is capped by the nearer of
     the two — min(107-43, 178-107) = 64 — less a margin. 116 wide lands it at 49..165:
     6px clear of the show button, 13px clear of the hide button.
     Measured in both states, not guessed: at 176 the wordmark ran under the hide button,
     and at 130 the clapper touched the show button.

     Since 2026-08-21 the artwork is no longer a wide lockup, so width buys HEIGHT and
     height is the tighter budget: the mark is centred on the 40px menu line inside an
     80px head, so anything over ~72px tall runs out of the bar. The current 1.8:1 gives
     62px at 112 wide, which clears both ends of that and still leaves the horizontal
     margins the note above measured. */
  --brand-w: 112px; }

.mainnav {
  position: fixed; left: 0; top: 0; bottom: 0; z-index: 40;
  width: 0;
  background: var(--panel); border-right: 1px solid var(--line);
  display: flex; flex-direction: column;
  overflow: hidden; transition: width .16s ease;
}
/* Hover expands over the content; pinned keeps it open and the layout moves. */
body.nav-open .mainnav { width: var(--rail-open); }

.nav-items { list-style: none; margin: 10px 0 0; padding: 0 8px; flex: 1; min-height: 0; overflow-y: auto; }
.nav-items li { margin: 0; }
.nav-items button{
  width: 100%; display: flex; align-items: center; gap: 13px;
  background: none; border: 0; border-radius: 8px;
  color: var(--muted); padding: 9px 10px 9px 14px; cursor: pointer;
  font: inherit; font-size: 13.5px; font-weight: 500; text-align: left; white-space: nowrap;
}
.nav-items button:hover:not(:disabled){ background: var(--panel-2); color: var(--text); }
.nav-items button.active { color: var(--text); background: var(--panel-2); font-weight: 600; }
.nav-items button:disabled { opacity: .35; cursor: not-allowed; }

/* Project cards on the Projects screen. */

/* Spend pill: the one number that should never be more than a glance away. */
.nav-global { list-style: none; margin: 0; padding: 6px 8px; border-top: 1px solid var(--line); }
.nav-global li { margin: 0; }
.nav-global-item { width: 100%; text-align: left; background: none; border: 0; color: var(--text);
  font: inherit; font-size: 13px; padding: 7px 10px 7px 14px; border-radius: 8px; }
.nav-global-item:hover { background: var(--panel-2); }
.nav-global-item.active { background: var(--panel-2); font-weight: 600; }

.nav-foot { border-top: 1px solid var(--line); }
.nav-spend {
  width: 100%; display: flex; align-items: center; gap: 13px;
  background: none; border: 0; border-radius: 8px;
  color: var(--text); padding: 10px 10px 10px 14px; cursor: pointer;
  font: inherit; font-size: 13px; text-align: left; white-space: nowrap;
}

/* ------------------------------------------------- studio: canvas + composer */
/* The canvas scrolls; the composer is pinned to the bottom of it, so the thing you
   are about to do never scrolls out of reach. */
/* Content dissolves into the composer rather than being sliced by the scroll edge.
   Arcads never needs this because their canvas is empty; ours holds the reel. */
.canvas { max-height: 34vh; overflow-y: auto; padding: 4px 4px 20px; min-height: 0;
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 34px), transparent 100%);
  mask-image: linear-gradient(to bottom, #000 calc(100% - 34px), transparent 100%); }
.canvas-empty { padding: 40px 8px; max-width: 60ch; }
.canvas-empty p { margin: 0 0 8px; }

/* The empty canvas is normally a paragraph, so it is capped at a readable measure and
   sits where reading starts. A slate is not prose: it stands for the picture, and the
   picture belongs in the middle of the step. */
.canvas-empty.canvas-centred { max-width: none; display: grid; place-items: center; }

/* ── The render slate ──────────────────────────────────────────────────────────
   A reel-shaped hole where the reel will be. The Render step showed a line of grey
   text before anything existed, which named what was missing and showed nothing of
   the shape it arrives in.
   Sized off the panel's own height rather than a fixed pixel count, so it fills the
   space the finished video will fill and does not push the step into scrolling —
   the same rule the storyboard follows. */
.reel-slate { height: min(52vh, 520px); aspect-ratio: 9 / 16; margin: 0 auto;
  display: grid; place-items: center; padding: 24px; text-align: center;
  border: 1px dashed var(--line); border-radius: 14px; background: var(--panel-2); }
.reel-slate p { color: var(--muted); }

/* The spinner. A ring with one papaya arc turning in it: the arc says which way time is
   going, where a pulsing dot only says "busy". Sized to sit in the middle of the slate
   rather than fill it, because the slate is the subject and this is a state on it. */
/* ONE SPINNER, wherever a picture is being made.
   The render slate uses it as a real element; anything carrying `busy` gets it drawn on
   top without markup, which is why the three places that already used that class — a
   storyboard frame being retaken, an actor angle, an asset card — all pick it up at once.
   Size rides on --spin so a 34px tile and a 56px slate share one definition. */
.reel-spin, .busy::after, .clip-tile.busy .clip-tile-shot::after {
  width: var(--spin, 56px); height: var(--spin, 56px);
  border-radius: 50%; border: var(--spin-w, 4px) solid var(--line);
  border-top-color: var(--accent); animation: reel-spin 900ms linear infinite; }
.busy { position: relative; }
.busy::after, .clip-tile.busy .clip-tile-shot::after {
  content: ''; position: absolute; inset: 0; margin: auto;
  --spin: 34px; --spin-w: 3px; z-index: 2; }
/* A CLIP OR ANCHOR BEING MADE GETS THE BIG RING, ON THE PICTURE (owner, 2026-09-02: one
   spinner, "which will be the larger one", and the same for every clip that is processing).
   Drawn on the SHOT rather than on the tile: `.busy::after` centres itself in the whole tile,
   which includes the caption and the two buttons, so it sat visibly low — that offset is what
   made the doubled rings read as two different things rather than one drawn twice. */
.clip-tile.busy::after { content: none; }
.clip-tile.busy .clip-tile-shot::after { --spin: 56px; --spin-w: 4px; }
@keyframes reel-spin { to { transform: rotate(360deg); } }
/* The ring stays, so the state is still legible; only the turning stops. */
@media (prefers-reduced-motion: reduce) {
  .reel-spin, .busy::after, .clip-tile.busy .clip-tile-shot::after { animation: none; } }
/* While it turns, the dashes give way to a solid edge: a dashed box reads as "empty and
   waiting for you", and this one is neither. */
.reel-slate.working { border-style: solid; border-color: var(--accent); }

/* APPROVED: the themed accent, at double weight.
   It has to be told apart from `working` directly above, which is the SAME colour at 1px
   while the draft is being made — one means "being drawn", the other "you have signed
   this off", and a viewer glancing at the step should not have to work out which. The
   second pixel is a box-shadow rather than a thicker border, so approving an anchor does
   not nudge the 9:16 slate and the pair beside each other stay aligned.
   Deliberately NOT applied to the filmstrip's approved frames: there the accent outline
   already means "the frame you are looking at" (.strip-item.centred), and one colour
   cannot say two things in the same row. Those keep their green tick. */

/* The finished reel, in the slate's own footprint.
   Same height rule as .reel-slate so pressing Render swaps the contents of the middle of
   the screen rather than moving the subject somewhere else and shrinking it. */
.reel-done { display: grid; justify-items: center; gap: 10px; }

/* NOTE 2026-08-27: the anchor pair used to live here as `.anchor .reel-slate` — two
   height-sized holes centred in the step. Anchors now use `.clip-tile` in the clips filmstrip
   so they match the clips in size and scroller, and those rules went with the markup. The base
   `.reel-slate` rules above are left in place but are no longer referenced by any markup. */

.reel-player { height: min(52vh, 520px); aspect-ratio: 9 / 16; width: auto; max-width: 100%;
  border-radius: 14px; background: var(--media-bg); display: block; }
.reel-done .note { margin: 0; text-align: center; }

.cg { margin-bottom: 26px; }
.cg h3 { margin: 0 0 11px; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); font-weight: 600; }
.cg-body { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-start; }
.cg-body video.player { width: 260px; max-width: 260px; border-radius: 8px; background: var(--media-bg); }
.tile { margin: 0; width: 112px; }
.tile img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 8px; background: var(--media-bg); border: 2px solid transparent; display: block; }
.tile.ok img { border-color: var(--good); }
.tile.busy img { border-color: var(--warn); animation: pulse 1.6s ease-in-out infinite; }
.tile figcaption { font-size: 11px; color: var(--muted); margin-top: 5px; display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
button.mini { font-size: 10px; padding: 2px 7px; border-radius: 5px; }

/* The tool shelf: everything you press sits in front of the reels, low and centred,
   and the grid scrolls underneath it. pointer-events are handed back to the parts
   that are actually there, so the empty space either side stays clickable grid. */
.toolshelf { position: absolute; left: 0; right: 0; bottom: 16px; z-index: 5;
  display: flex; flex-direction: column; align-items: center; gap: 10px;
  padding: 0 16px; pointer-events: none; }
.toolshelf > * { pointer-events: auto; }
/* The tool box: one large panel over the workspace, with the page dimmed behind it. */
/* 50, not 40. The sidebar is also 40, so while these shared a level there was no value
   that could sit above the rail and below the workspace — which is exactly where the
   brand has to live. Still under the lightbox and the player at 60 and the dialog at 90. */
.toolmodal { position: fixed; inset: 0; z-index: 50; display: grid; place-items: center; padding: 22px; }
.toolmodal[hidden] { display: none; }
.toolmodal-back { position: absolute; inset: 0; background: var(--scrim); }
.toolbox { position: relative; display: flex; flex-direction: column;
  /* Close to the full workspace: the storyboard and the cast strip both want room,
     and the margin is only there to keep it reading as a box over the reels. */
  width: min(1560px, 100%); height: min(1040px, 100%);
  background: var(--panel); border: 1px solid var(--line); border-radius: 22px;
  box-shadow: 0 30px 90px var(--shadow); overflow: hidden; }
.toolbox-head { display: flex; flex-direction: column; gap: 6px;
  padding: 20px 22px 14px; border-bottom: 1px solid var(--line); }
.toolbox-head-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.toolbox-head h2 { font-size: 19px; text-transform: none; letter-spacing: 0; color: var(--text); margin: 0; }
/* Full width, deliberately. The 78ch cap is the right call for body copy a reader has
   to track line to line; this is a short summary read once at the top of a workspace,
   and capping it left the explanation huddled against the left edge of a 1560px box. */
.toolbox-head .note { margin: 0; max-width: none; line-height: 1.55; }
.live-note { color: var(--warn); }
.toolbox-acts { display: flex; align-items: center; gap: 8px; flex: none; }
/* Unsaved settings are worth pointing at, since the X throws them away. */
#tool-save.pending::after { content: ' •'; }
.iconbtn { background: var(--panel-2); border: 1px solid var(--line); color: var(--muted);
  font-weight: 500; padding: 7px 11px; border-radius: 9px; }
.iconbtn:hover { color: var(--text); }
.toolbox-body { flex: 1; min-height: 0; overflow-y: auto; padding: 18px 22px 22px; }
/* The bottom bookend to the head's rule: sign-off is pinned here, everything between the
   two rules scrolls, and the button that moves the run on is never below the fold. */
.toolbox-foot { flex: none; padding: 14px 22px; border-top: 1px solid var(--line); background: var(--panel); }
.toolbox-foot:empty { display: none; }
/* No rule of its own inside the pinned foot: the foot already draws one, edge to edge,
   and the sign-off's is inset by the panel's padding — so they read as two lines a
   centimetre apart, the lower one stopping short of both sides. The padding stays, so
   removing the line does not move the button. Kept for a sign-off outside the foot,
   where its line is the only separator. */
.toolbox-foot .signoff { margin: 0; border-top: 0; }

/**
 * A PINNED HEAD: everything above the lines stays put, and only the lines move.
 *
 * On the script step the top of the workspace is reference material you write against —
 * who is cast, what the format is, how long the reel should be — and it was scrolling
 * away the moment you reached the fourth beat, which is exactly when you want to see it.
 *
 * The body stops scrolling and hands the job to the beats list, so there is one vertical
 * scroller instead of two nested ones. The sign-off comes along for the ride: it sits
 * after the lines in the flex column, so Approve stays on screen instead of being
 * something you scroll to find.
 *
 * Applied by class rather than to .toolbox-body itself, because the other steps have
 * their own shapes and a workspace-wide freeze would break them.
 */
.toolbox-body.pinned { overflow: hidden; display: flex; flex-direction: column; }
.toolbox-body.pinned > .canvas { flex: none; }
.toolbox-body.pinned > .composer { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.toolbox-body.pinned .composer-body { flex: 1; min-height: 0; display: flex; flex-direction: column; }

/**
 * A FILLED BODY: the storyboard's picture takes the room that is left.
 *
 * The canvas is normally a scrolling strip capped at 34vh with a fade at its foot, which
 * is right for a grid of cards you page through and wrong for a single picture that is
 * the whole point of the step — the frame would be boxed into a third of the panel and
 * faded out along its bottom edge.
 *
 * So on this one step the canvas becomes the flexible link in the chain instead: no cap,
 * no scroll, no mask, and the carousel inside it takes whatever height is left once the
 * band, the line and the buttons have had theirs. Nothing on the step scrolls vertically,
 * which is the entire point of the layout.
 */
.toolbox-body.filled { overflow: hidden; display: flex; flex-direction: column; }
.toolbox-body.filled > .canvas { flex: 1; min-height: 0; max-height: none; overflow: visible;
  display: flex; flex-direction: column; padding-bottom: 0;
  -webkit-mask-image: none; mask-image: none; }
.toolbox-body.filled > .composer { flex: none; }
/* The one thing that scrolls. max-height goes: the height comes from what is left over
   after the pinned head, not from a fraction of the viewport. */
.toolbox-body.pinned .beats { flex: 1; min-height: 0; max-height: none; }
/* Inside the box the composer is not its own floating card: the box is the card. */
.toolbox-body .composer { background: none; border: 0; border-radius: 0; padding: 0; box-shadow: none; max-width: none; }
.toolbox-body .canvas { max-height: none; overflow: visible; -webkit-mask-image: none; mask-image: none; padding: 0 0 8px; }
.toolbox-body .canvas-empty { padding: 2px 0 12px; }
.toolbox-body .signoff { margin-top: 18px; }
/* The tool section. The workflow reads left to right, so it is a row and not a
   wrapped cloud: the order is information, and wrapping hides it. */
/* Liquid glass: translucent and blurred, so the reels are visible moving underneath
   without the labels ever fighting them. A rounded rectangle, not a pill. */
.toolbar { display: flex; justify-content: center; gap: 6px; flex-wrap: wrap;
  background: var(--glass); border: 1px solid var(--bar-edge); border-radius: 20px;
  padding: 7px; box-shadow: 0 14px 40px var(--shadow);
  backdrop-filter: blur(20px) saturate(180%); -webkit-backdrop-filter: blur(20px) saturate(180%); }
.toolbar .tool { border-color: transparent; background: none; box-shadow: none; }
.toolbar .tool.on { background: var(--ink); border-color: var(--ink); }
/* Papaya on hover: the brand colour, and the same one that ends up burned into the
   reels, so the bar points at the house colour rather than a neutral grey. */
.toolbar .tool:hover:not(:disabled):not(.on) { background: var(--accent); color: var(--on-accent); }
/* Full-contrast labels: white on night, near-black on day. These are the workflow,
   read at a glance from across the desk, not secondary text. */
.tool { background: var(--panel); border: 1px solid var(--line); color: var(--text);
  border-radius: 999px; padding: 7px 15px; font-size: 13px; font-weight: 500; box-shadow: 0 1px 2px var(--shadow); }
.tool.on { background: var(--ink); border-color: var(--ink); color: var(--on-ink); }
.tool.done:not(.on) { color: var(--good); }
/* A locked tool still has to be readable: it is telling you what comes next. */
.tool:disabled { opacity: .55; cursor: not-allowed; box-shadow: none; }

/* Choosing the kind of reel: the first step, so it gets cards and not a dropdown. */
/* ONE ROW, SCROLLED SIDEWAYS.
   Ten formats stacked 5x2 cost two card-heights at the top of step one and pushed the
   brief below the fold, so the thing the reel is actually built from sat where you had
   to scroll for it. A single row keeps the choice to one band of height: you scroll
   through formats, which is a small job, instead of scrolling past them to reach the
   work. Scroll snapping makes it read as a carousel rather than a runaway strip. */
/* A horizontal rail: a single row that scrolls sideways, with an arrow that appears on
   hover only while there is more to the right. Shared by the format cards and the actor
   strips, because the second one wanting exactly this was the moment to stop it being
   the format's private trick. The rail catches the hover rather than the track, since
   the arrow sits over the track's edge and hovering the track alone would lose it. */
.hrail { position: relative; }
.hrail-track { display: flex; gap: 10px; overflow-x: auto; overflow-y: hidden;
  scroll-snap-type: x proximity; padding-bottom: 10px;
  scrollbar-width: thin; scrollbar-color: var(--line) transparent; }
.hrail-track::-webkit-scrollbar { height: 8px; }
.hrail-track::-webkit-scrollbar-thumb { background: var(--line); border-radius: 99px; }
.hrail-track::-webkit-scrollbar-thumb:hover { background: var(--muted); }
.hrail-track::-webkit-scrollbar-track { background: transparent; }
.hrail-next {
  position: absolute; top: 0; right: 0; bottom: 10px; width: 46px;
  display: flex; align-items: center; justify-content: flex-end; padding-right: 6px;
  border: 0; border-radius: 0 12px 12px 0; color: var(--accent); cursor: pointer;
  /* A fade rather than a plain button, so the row reads as continuing underneath it
     instead of being covered by a panel. */
  background: linear-gradient(to right, transparent, var(--panel-2) 78%);
  opacity: 0; pointer-events: none; transition: opacity .14s ease; }
/* Both conditions: the pointer is over the rail, AND there is something to scroll to.
   An arrow at the end of the row points at nothing. */
/* has-more says there is somewhere to go; hint-on says the pointer is on THIS box and
   not on one wrapped around it. Set in JS, because CSS :hover is true for every nesting
   level at once and cannot tell an inner rail from the panel holding it. */
.hrail.has-more.hint-on .hrail-next { opacity: 1; pointer-events: auto; }
.hrail-next:hover { color: var(--text); }

/* The vertical twin, injected into the scroller rather than wrapped around it.
   Zero height and sticky to the bottom edge: it adds nothing to the layout, which is
   what makes it safe to put inside boxes like .toolbox-body that other things size
   themselves against. */
.vhint { position: sticky; bottom: 0; left: 0; height: 0; overflow: visible;
  pointer-events: none; z-index: 4; order: 999; flex: none; grid-column: 1 / -1; }
/* Centred, not tucked into the right corner. The corner is where per-row controls live
   — the delete cross on the last visible beat sits exactly there — and an overlay on
   top of a button is a click that goes to the wrong place. */
.vhint > button {
  position: absolute; left: 50%; transform: translateX(-50%); bottom: 0;
  width: 56px; height: 30px;
  display: flex; align-items: flex-end; justify-content: center; padding: 0 0 2px;
  border: 0; border-radius: 12px 12px 0 0; color: var(--accent); cursor: pointer;
  background: linear-gradient(to bottom, transparent, var(--panel-2) 78%);
  opacity: 0; pointer-events: none; transition: opacity .14s ease; }
/* Same pair of conditions as the horizontal arrow: somewhere to scroll, and the
   pointer on this box rather than on something nested inside it. */
.has-below.hint-on > .vhint > button { opacity: 1; pointer-events: auto; }
.vhint > button:hover { color: var(--text); }
.cast-track { gap: 8px; padding-bottom: 8px; }
/* Small enough that a dozen faces fit across, which is the whole reason the strip
   scrolls rather than wrapping into a wall. Fixed width and no shrink, or flex would
   squeeze the bank into slivers and there would be nothing to scroll. */
.cast-tile { flex: 0 0 72px; scroll-snap-align: start; display: flex; flex-direction: column;
  gap: 4px; padding: 0; background: none; border: 0; cursor: pointer; }
.cast-tile img, .cast-tile .ph { width: 72px; height: 72px; border-radius: 9px; object-fit: cover;
  background: var(--panel-2); border: 2px solid transparent; display: block; }
.cast-tile:hover img { border-color: var(--line); }
.cast-tile.on img { border-color: var(--accent); }
.cast-name { font-size: 10.5px; color: var(--muted); font-weight: 500;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cast-tile.on .cast-name { color: var(--text); }

/* The location strip on Format. Same rail, same tile, one difference: 116px instead of
   72px, because a face is one subject filling the frame and a room is not — a shop at
   72px is a smudge, and the whole reason for a picture here is that a name could not
   tell you which coffee shop was the bright one. */
/* Both rows on Format are labelled, in the same voice as the role headings on Script,
   so one rule serves all three and they cannot drift apart. The note beside the label
   says what is currently picked, which is the thing you scroll away from and then want
   to check without scrolling back. */
.rail-block { margin: 0 0 14px; }
/* White, unlike every other section label in the app. These three name the decisions
   the step is asking for, and grey read as commentary sitting above the controls rather
   than as the question being asked. The note beside them keeps its own grey: what is
   currently picked is an answer, not part of the label. */
.rail-head{ color: var(--text); }
.rail-head { margin: 0 0 6px; font-size: 11.5px; text-transform: uppercase;
  letter-spacing: .05em; font-weight: 600; }
.rail-head .note { text-transform: none; letter-spacing: 0; margin-left: 8px; font-weight: 400; }
.loc-pick .actor-filters { display: flex; gap: 8px; margin-bottom: 10px; }
.loc-tile { flex: 0 0 116px; }
.loc-tile img, .loc-tile .ph { width: 116px; height: 116px; }
/* The column is the scroller's item now, so the fixed width moves here and the card
   fills it. The example below inherits that width, which is the point: an example
   narrower than the thing it illustrates reads as a thumbnail rather than as the format. */
.fmt-col { flex: 0 0 262px; scroll-snap-align: start; display: flex; flex-direction: column; gap: 8px; }
.fmt-col .fmt { flex: 1 1 auto; }
.fmt-eg { margin: 0; display: flex; flex-direction: column; gap: 4px; }
/* 9:16 at the card's width. Tall on purpose: it is a vertical reel and cropping it to
   fit a row would misrepresent the one thing the example exists to show.

   `object-fit: cover` so the PICTURE fills the box and the rounded corners land on the
   picture rather than on a letterbox bar. It crops nothing here, because the box is the
   same 9:16 the reels are — cover and contain are the same picture when the two ratios
   agree. It earns its place on the placeholder cards, whose poster is an actor's portrait
   rather than a frame of a reel, and on any future clip that is not exactly 9:16: those
   fill the corner instead of sitting in bars, which is the whole point of the radius. */
.fmt-eg video { width: 100%; aspect-ratio: 9 / 16; border-radius: 12px; background: #000;
  border: 1px solid var(--line); display: block; object-fit: cover; }
.fmt-eg figcaption { font-size: 11px; }
.fmt {
  flex: 0 0 262px; scroll-snap-align: start;
  display: flex; flex-direction: column; gap: 4px; text-align: left; padding: 14px;
  background: var(--panel); border: 1px solid var(--line); border-radius: 12px; color: var(--text); font-weight: 500; }
.fmt.on { border-color: var(--accent); }
.fmt.soon { opacity: .55; }
.fmt:disabled { cursor: not-allowed; }
/* Sits in the flow where a built card puts `people`, so both cards are the same three lines.
   It used to be `margin-top: auto; padding-top: 6px` — pinned to the bottom as a FOURTH line,
   which made every unbuilt card taller than its neighbours and squeezed the example under it. */
/* `line-height` matched to `.note`'s 18px, not left to fall out of the 11px font. Without it
   this line is 16.5px against the 18px it replaces, every unbuilt card is 1px shorter than its
   neighbours, and their examples end up 2px taller — invisible, and still not lined up. */
.fmt-soon { font-size: 11px; line-height: 18px; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; }
/* A capture format is a different pipeline, not a queue position, so it does not
   share the "waiting its turn" grey. */
.fmt-soon.capture { color: var(--warn); }
/* The brief follows the format row immediately. The default h3 gap was sized for
   headings separating blocks of text; here it sat between a row of cards and the work,
   holding the brief lower than it needs to be. */
.fmt-grid + h3 { margin-top: 6px; }
.field.wide { display: flex; flex-direction: column; }
.field.wide textarea { flex: 1; min-height: 190px; resize: vertical; }
.hint { display: block; font-size: 11.5px; color: var(--muted); margin-top: 5px; max-width: 62ch; }

/* The reels grid: the workspace at rest, behind the tool section. */
.grid-panel { overflow-y: auto; }
.first-run { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; padding: 30px 0; max-width: 460px; }
.first-run p { margin: 0; }
.reel-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px;
  /* Clears the floating shelf so the last row is never trapped under it. */
  padding-bottom: 96px; }
.reel-card { position: relative; background: var(--panel); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
.reel-card.on { border-color: var(--accent); }
/* Carried: this IS the tile you grabbed, under the pointer, off the surface and
   swinging. There is no second copy, because nothing is being snapshotted. */
.reel-card.carried {
  position: fixed; z-index: 60;
  border-color: var(--accent);
  box-shadow: 0 26px 54px var(--shadow);
  animation: reel-swing .9s ease-in-out infinite alternate;
}
body.carrying { cursor: grabbing; user-select: none; }
/* The hole it will drop into, which moves with the pointer. */
.reel-gap { border: 1px dashed var(--line); border-radius: 12px; background: var(--panel-2); opacity: .5; }
@keyframes reel-swing {
  from { transform: scale(1.04) rotate(-2.4deg); }
  to { transform: scale(1.04) rotate(2.4deg); }
}
/* Anyone who would rather the interface held still. */
@media (prefers-reduced-motion: reduce) {
  .reel-card.carried { animation: none; transform: scale(1.02); }
}
.reel-card .grip { position: absolute; top: 6px; left: 6px; z-index: 3; padding: 2px;
  border-radius: 5px; background: var(--panel); opacity: 0; }
.reel-card:hover .grip { opacity: .85; }
/* The top LEFT belongs to the grip and nothing else may sit there. The delete button
   was added at the same coordinate with a higher z-index, so a 28px bin covered a 14px
   handle completely and the cards could no longer be dragged at all: the feature was
   not removed, it was buried. The grip also outranks its neighbours now, so the next
   thing pinned to a corner cannot silently take the drag handle with it. */
/* The card is the frame: 9:16, the shape of the thing it will become, with the name
   over the picture rather than in a strip that would break the ratio. */
.reel-card { aspect-ratio: 9 / 16; }
/* The media fills the card and the caption sits over it. The media is a sibling of the
   open button rather than inside it, so a video can carry its own controls: a <video>
   nested in a <button> is neither valid nor clickable. */
.reel-media { position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: cover; display: block; background: var(--media-bg); border: 0; }
/* THE WHOLE CARD IS THE BUTTON, not just the caption strip along the bottom.
   A reel is picked by clicking the picture of it — that is what a grid of them is for —
   and only the name was hittable, so a click on the artwork did nothing at all. inset:0
   spreads the button over the card and the caption is pushed to the foot of it, which
   looks identical and behaves the way it reads. The controls on the card all carry a
   z-index, so they stay above this and keep their own clicks. */
/* An empty layer across the whole card: it exists to be clicked, and everything visible
   sits above it. */
.reel-open { position: absolute; inset: 0; background: none; border: 0; padding: 0;
  border-radius: 0; }

/* The foot stacks what used to be positioned separately: transport first, then the name
   and folder. One column, so the order is the DOM's and it survives a name that wraps.
   The gradient moved here from the caption so it covers both. */
.reel-foot { position: absolute; left: 0; right: 0; bottom: 0; z-index: 2;
  display: flex; flex-direction: column; align-items: flex-start; gap: 8px;
  padding: 34px 11px 10px;
  background: linear-gradient(to top, rgba(8, 10, 14, .88) 55%, rgba(8, 10, 14, 0));
  /* The FOOT is transparent to clicks, not just the caption inside it. Making only the
     caption pass through was not enough: the click then landed on the foot's own padding
     and was swallowed there, so the name stopped opening the reel. The transport takes
     its events back explicitly, being the only part that acts. */
  pointer-events: none; }
/* Our own transport rather than the browser's: the native bar is chunky, and it has
   no way to start the clip again from the top, which is the control you want most
   when you are judging an eight second take. */
.reel-controls { display: flex; gap: 6px; pointer-events: auto; }
.reel-btn { display: grid; place-items: center; width: 28px; height: 28px; padding: 0;
  border-radius: 50%; border: 1px solid rgba(255, 255, 255, .28); color: #fff;
  background: rgba(8, 10, 14, .62); backdrop-filter: blur(6px); }
.reel-btn:hover:not(:disabled) { background: var(--accent); border-color: var(--accent); color: var(--on-accent); filter: none; }
/* The 42px that used to hold the caption clear of the buttons is gone: the foot stacks
   them now, so nothing has to be reserved. */

/* The favourite marks the card itself, so it stays visible once set and only fades
   in on hover while it is off. */
.reel-fav { position: absolute; top: 6px; right: 6px; z-index: 2; display: grid; place-items: center;
  width: 28px; height: 28px; padding: 0; border-radius: 50%; border: 0;
  background: rgba(8, 10, 14, .55); color: #fff; opacity: 0; transition: opacity .12s ease; }
.reel-card:hover .reel-fav { opacity: 1; }
.reel-fav.on { opacity: 1; color: var(--accent); }
.reel-fav:hover { background: rgba(8, 10, 14, .8); filter: none; }
/* Bottom right, opposite the name, in the same round chrome as the delete and
   favourite buttons at the top. Above the caption in z-order because the caption is
   itself a full-width button: without this the open-the-reel click would win. */
.reel-edit { position: absolute; bottom: 10px; right: 8px; z-index: 3; display: grid; place-items: center;
  width: 28px; height: 28px; padding: 0; border-radius: 50%; border: 0;
  background: rgba(8, 10, 14, .55); color: #fff; opacity: 0; transition: opacity .12s ease; }
.reel-card:hover .reel-edit { opacity: 1; }
.reel-edit:hover { background: rgba(8, 10, 14, .8); color: var(--accent); filter: none; }
/* A reel with no storyboard yet has no picture to show. Say so quietly rather than
   showing a black rectangle, which reads as a broken thumbnail. */
.reel-blank { background: var(--panel-2); display: grid; place-items: center; }
.reel-blank::after { content: 'no frames yet'; font-size: 11px; color: var(--muted); font-weight: 400; }
/* A scrim, because the caption sits on whatever the frame happens to be. */
/* pointer-events: none so a click on the name falls through to .reel-open beneath and
   still opens the reel. Nothing here is interactive; the buttons above it keep theirs. */
.reel-cap { display: flex; flex-direction: column; gap: 1px; text-align: left;
  color: #fff; font-weight: 500; pointer-events: none; }
.reel-name { font-size: 13px; }
.reel-cap .note { font-size: 11px; color: rgba(255, 255, 255, .72); }
.composer-body.locked { padding: 10px 0 4px; }
.composer-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.composer-foot { display: flex; align-items: center; gap: 14px; margin-top: 12px; }
.beats { display: flex; flex-direction: column; gap: 9px; max-height: 52vh; overflow-y: auto; padding-right: 4px; }
/* A beat is a block, not a row.
   The row put the role, the line and a delete cross in three columns: six of them fitted
   on screen and none of them said what the line was FOR. The plan's label and hint were
   shown once on a landing screen and thrown away exactly when they were needed, which is
   while writing the line they describe. Now each beat carries its own heading. */
.beat { display: block; background: var(--panel-2); border: 1px solid var(--line);
  border-radius: 10px; padding: 10px 12px 12px; }
.beat-head { display: flex; align-items: center; gap: 8px; margin-bottom: 2px; }
.beat-no { display: grid; place-items: center; flex: none; width: 19px; height: 19px; border-radius: 50%;
  background: var(--panel); color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
.beat-label { font-size: 11.5px; text-transform: uppercase; letter-spacing: .05em; color: var(--text);
  font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The speaker sits at the far end of the heading, pushed there so the labels down the
   left edge stay in one column and the eye can run down them. Stated, not chosen: it is
   the format's own structure rather than a per-line decision. */
/* Reads as the label it replaced until you go near it: a role is a fact most of the
   time, and only occasionally a decision. The border appears on hover so the row is not
   carrying a button's worth of chrome on every line. */
.beat-role { margin-left: auto; flex: none; font-size: 11px; letter-spacing: .05em;
  color: var(--muted); text-transform: uppercase;
  background: none; border: 1px solid transparent; border-radius: 999px;
  padding: 2px 8px; font-weight: 500; }
button.beat-role { cursor: pointer; }
button.beat-role:hover { border-color: var(--line); color: var(--text); background: var(--panel-2); }
button.beat-role:focus-visible { border-color: var(--accent); color: var(--text); }
.beat select { margin-left: auto; width: auto; flex: none; padding: 3px 8px; font-size: 11.5px; }
.beat .del { flex: none; }
.beat-hint { margin: 0 0 7px; font-size: 11.5px; color: var(--muted); line-height: 1.45; }
.beat-tag { flex: none; font-size: 10px; text-transform: uppercase; letter-spacing: .06em;
  color: var(--warn); border: 1px solid currentColor; border-radius: 99px; padding: 1px 6px; }
/* A silent beat takes a camera direction rather than a line, so it is marked as a
   different kind of thing rather than looking like dialogue nobody has written yet. */
.beat.silent { border-style: dashed; }
/* The offer and the button travel together at the right-hand end, the sentence reading
   straight into the thing it is selling. Pushed apart across the full width, the two
   halves of one pitch sat at opposite ends of the workspace with the eye asked to
   connect them. The button's own right edge still lines up with Save for later. */
.script-source { display: flex; align-items: center; justify-content: flex-end; gap: 14px; }
/* The heading takes the left of that row and pushes the offer and the button right,
   which is where they already were. */
.script-source .rail-head { margin: 0 auto 0 0; }
.script-source .note { margin: 0; }
/* The inset that puts this button's right edge under Save's, measured at render time
   because the two live in different containers and Save is not the last thing in its. */
.write-script { flex: none; margin-right: var(--acts-inset, 0px); }
/* Throbs while the model is writing. It is the only thing on the step that takes time
   you can feel — several seconds of an LLM composing — and a button that merely greys
   out reads as broken rather than busy. */
/* The throb pulse lived here. Replaced by the travelling stripes on `.spends.working`. */

/* Empty on purpose, and holding its ground. The line that lived here was removed, but
   an empty paragraph collapses to nothing and everything below would climb by the height
   of a line — so it keeps the line box it always had. */
/* The outline offered before anything is written. */
.script-start { max-width: 68ch; }
.plan { margin: 10px 0 14px; padding-left: 20px; display: grid; gap: 5px; font-size: 13px; }
.plan li { color: var(--text); }
.plan .note { font-weight: 400; }
.beat textarea { min-height: 46px; resize: vertical; line-height: 1.45; }
.slot h4 { margin: 0 0 6px; font-size: 12px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
.signoff { display: flex; align-items: center; gap: 12px; margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line); }
.lint.tight { margin: 7px 0 0; }

/* Text sidebar: no icons. A hamburger in the top bar shows and hides it, because a
   text list cannot shrink to a strip of glyphs and still be readable. */
.nav-global { list-style: none; margin: 0; padding: 6px 8px; border-top: 1px solid var(--line); }
.nav-global li { margin: 0; }
.nav-global-item { width: 100%; text-align: left; background: none; border: 0; color: var(--text);
  font: inherit; font-size: 13px; padding: 7px 10px 7px 14px; border-radius: 8px; }
.nav-global-item:hover { background: var(--panel-2); }
.nav-global-item.active { background: var(--panel-2); font-weight: 600; }

.nav-foot { border-top: 1px solid var(--line); padding: 6px 8px 8px; }
.nav-spend { justify-content: space-between; font-size: 12.5px; }
.nav-foot-k { color: var(--muted); font-weight: 500; }
.mainnav { transition: width .16s ease; }
.nav-head { display: flex; align-items: center; justify-content: space-between;
  font-size: 11px; text-transform: uppercase; letter-spacing: .07em; color: var(--muted);
  font-weight: 600; padding: 12px 10px 5px 14px; }
/* NO font-size or line-height here, and none in .nav-folder-head below: `.nav-items
   button` (0,1,1) outranks every single-class rule in this nav and sets 13.5px. Both
   declared values were dead on arrival — the same fault as the .nav-reel font-size
   deleted earlier today, and the reason this comment exists rather than a fix. */
.nav-add { background: none; border: 0; color: var(--muted); padding: 0 4px; cursor: pointer; }
.nav-add:hover { color: var(--text); }
.nav-none { font-size: 12px; color: var(--muted); padding: 3px 0 3px 14px; opacity: .7; }
.nav-folder-head { width: 100%; display: flex; align-items: center; gap: 8px; background: none; border: 0;
  font: inherit; font-weight: 500; text-align: left;
  padding: 7px 10px 7px 6px; border-radius: 8px; cursor: pointer; }
.nav-folder-head:hover { background: var(--panel-2); }
.nav-folder-head .note { margin-left: auto; }
/* The grip replaces the caret in the same slot: it is the drag handle, and it keeps
   the names aligned whether a folder is open or shut. Faint until the row is hovered
   so a rail of folders does not read as a rail of dots. */
.grip { display: flex; align-items: center; padding: 0 2px 0 6px; color: var(--muted);
  opacity: .3; cursor: grab; transition: opacity .12s ease; }
.grip svg { fill: currentColor; }
.nav-folder-row:hover .grip { opacity: .85; }
.grip:active { cursor: grabbing; }

.nav-folder.dragging { opacity: .4; }
/* The drop line is drawn on the row being pointed at, so it tracks the pointer
   without inserting a placeholder and reflowing the list mid-drag. */
.nav-folder.drop-above .nav-folder-row { box-shadow: inset 0 2px 0 var(--accent); }
.nav-folder.drop-below .nav-folder-row { box-shadow: inset 0 -2px 0 var(--accent); }
.nav-sub { list-style: none; margin: 0; padding: 0 0 4px; }
/* Named things in the menu are read, not skimmed past, so they take the full text
   colour like the folders above them rather than sitting at muted grey. */
/* NO font-size HERE. There was one — 13px, meaning a reel should sit half a pixel under
   its folder — and it never applied: `.nav-items button, .nav-pin` (0,1,1) outranks
   `.nav-reel` (0,1,0) and sets 13.5px for every button in the nav. The rule had been
   dead for its whole life and the sidebar looked deliberate either way, which is why
   nobody caught it. Removed rather than made to win: 0.5px is below the size difference
   that reads as hierarchy, and the indent plus the folder's own weight already do that
   work. Reels and folders are 13.5px/500 and the stylesheet now says so. */
.nav-reel { width: 100%; text-align: left; background: none; border: 0; color: var(--text);
  font: inherit; padding: 6px 10px 6px 30px; border-radius: 8px; cursor: pointer;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-items > li > .nav-reel { padding-left: 14px; }
.nav-reel:hover { background: var(--panel-2); color: var(--text); }
.nav-reel.active { background: var(--panel-2); color: var(--text); font-weight: 600; }
.head-actions { display: flex; align-items: center; gap: 8px; }
#project-folder { font-size: 12px; padding: 5px 8px; }

/* Folder row: name expands, actions appear on hover so the list stays quiet. */
.nav-folder-row { display: flex; align-items: center; width: 100%; border-radius: 8px; }
.nav-folder-row:hover { background: var(--panel-2); }
.nav-folder-row .nav-folder-head { flex: 1 1 0%; min-width: 0; width: auto; }
/* A reel row is a folder row with a reel's name in it, indented to sit under its
   folder, so it inherits the hover icons and the inline rename field. */
.nav-reel-row .nav-reel { flex: 1 1 0%; min-width: 0; width: auto; }
.nav-reel-row.renaming { padding-left: 30px; }
/* Papaya on the folder name alone, not the whole row: the reel count beside it is a
   detail and would compete for the same attention. The rail is folders and reels in one
   list at similar indents, so colour is what separates a container from its contents. */
.fname { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--accent); }
.nav-folder-row .nav-folder-head:hover { background: none; }
/* Scoped to the row so it outranks `.nav-items button`, which sets a full-width
   flex row for the nav links and would otherwise size and reveal these. */
.nav-folder-row .nav-folder-act { width: auto; flex: none; padding: 4px 4px; display: none; }
.nav-folder-row:hover .nav-folder-act { display: flex; }
/* Hidden with display, not opacity: an invisible button still holds its space, and
   three of them steal enough width to truncate the folder name even at rest. */
.nav-folder-act { background: none; border: 0; color: var(--muted); cursor: pointer;
  border-radius: 6px; font-size: 14px; line-height: 1; align-items: center; }
.nav-folder-act:hover { color: var(--text); background: var(--panel); }
.nav-folder-act.danger:hover { color: var(--bad); }
.nav-folder-act:last-child { margin-right: 6px; }

/* The sidebar's own toggle, top right of the menu, as in the reference. */
/* The mark sits in the MIDDLE of the menu head, with the toggle taken out of the
   flow to get there. A space-between row cannot centre anything: the brand would sit
   at whatever position the toggle's width left it, which is centred-ish and visibly
   not centred. Absolute-positioning the button lets the brand centre against the rail
   itself, and it stays pinned to the top-right corner where it was. */
.nav-top { position: relative; display: flex; align-items: center; justify-content: center;
  /* The head is empty of the brand now, so its height is stated rather than inherited
     from whatever it happens to contain. Twice the line plus a little, which keeps the
     divider where it has always sat under the mark. */
  min-height: calc(var(--menu-btn-line) * 2 + 4px);
  padding: 0 10px; border-bottom: 1px solid var(--line); }
/* .brand carries margin-right:auto so the TOP BAR can push the theme button to the
   right. Inside the menu head that same rule pinned the mark hard left and quietly
   beat justify-content:center, so it is cancelled here and only here. */
.nav-top .brand { min-width: 0; margin: 0; }
/* Centred ON the line rather than offset from it, so the button's own height can
   change without moving where it sits. */
.nav-top .panel-btn { position: absolute; right: 8px; top: var(--menu-btn-line);
  transform: translateY(-50%); }
.panel-btn { background: none; border: 0; color: var(--muted); cursor: pointer;
  padding: 5px; border-radius: 7px; display: flex; align-items: center; }
.panel-btn:hover { background: var(--panel-2); color: var(--text); }
/* The top-bar one only exists while the menu is hidden. */
body.nav-open #nav-show { display: none; }
#nav-show { margin-right: 6px; }

/* Brand: the ONE TAKE lockup the owner supplied on 2026-08-20, replacing the Slate One
   mark that replaced the original clapperboard before it.

   It arrives as a raster on a flat navy ground, so it is keyed and trimmed by
   tools/build_logo.mjs into two PNGs that share one alpha map: white type for night,
   near-black for day. The keying unmixes each edge pixel rather than deleting the
   background colour, which is why the mark composites cleanly onto a panel and not just
   onto the canvas — the failure the Slate One note below used to describe.

   The clapper's stripes are HOLES, as they are in the source, so they take the colour of
   whatever is behind them. That makes the mark surface-independent by construction.

   Sarpanch Black stays in public/fonts/ and keeps its @font-face. The wordmark is drawn
   into the lockup now, so the face is unused, but it is the one the owner chose and it is
   one line away if the words are ever set live again. */
@font-face {
  font-family: 'Sarpanch';
  src: url('/fonts/Sarpanch-Black.ttf') format('truetype');
  font-weight: 900;
  font-display: swap;
}
/* The gallery's copy, in a normal flow row rather than pinned to the rail. */
.brand { position: relative; width: 112px; height: calc(112px * 204 / 371); flex: none;
  background: url("/logo-base.png") left center / contain no-repeat; }
.brand::after {
  -webkit-mask-position: left center;
  mask-position: left center;
}

/* Theme toggle, top right of the page. Icon only: the moon while in night, the sun
   while in day, so it reads as where you are rather than as a label. */
/* On the diagonal from the top-right corner: the dot's centre sits the same distance from
   the top edge as from the right one, so the corner, the dot and 45 degrees all agree.
   MEASURED: the bar put the centre 40px down and 25px in, so it needed 15px more on the
   right. Both distances are fixed by the bar's height and padding, so this holds at any
   window width. */
.brand-dots {
  position: relative; margin-left: auto; margin-right: 15px;
  flex: none; width: 17px; height: 17px;
}
.brand-dot, .fan-dot {
  position: absolute; inset: 0; border-radius: 50%; background: var(--c, #FF3132);
  cursor: pointer;
}
/* The static dot wears whatever the app is wearing — that IS how you can tell, so nothing
   needs a ring drawn round it. */
.brand-dot { background: var(--accent); }
/* Each fanned dot carries its own colour, ordered by HUE clockwise from the red one:
   papaya, yellow, turquoise, blue, purple, pink, and round to red again. Four of the six
   are the brand's own palette. The order is the point — the same six shuffled would be
   six colours, and in this order it is one sweep. */
/* The six that travel. They start underneath the original, so the fan reads as coming OUT
   of it, and each carries its own stop and its own delay — the delay is what makes them
   leave one after another clockwise rather than all at once. */
.fan-dot {
  opacity: 0; transform: translate(0, 0) scale(.4);
  transition: transform .5s cubic-bezier(.22, .9, .25, 1), opacity .22s ease;
  transition-delay: var(--d), var(--d);
}
/* Hovering a fanned dot still counts: they are children, so the ancestor stays hovered
   even where they sit outside its 17px box. Without that the ring would collapse the
   moment the pointer followed it. */
.brand-dots:hover .fan-dot { opacity: 1; transform: translate(var(--x), var(--y)) scale(1); }
@media (prefers-reduced-motion: reduce) {
  .fan-dot { transition-duration: .01ms; transition-delay: 0ms; }
}
.nav-folder-row.renaming { display: block; padding: 3px 8px 3px 14px; }
.folder-rename { display: block; width: 100%; box-sizing: border-box;
  background: var(--panel-2); border: 1px solid var(--accent); border-radius: 6px;
  color: var(--text); font: inherit; font-size: 13.5px; font-weight: 500;
  padding: 4px 8px; outline: none; }

/* In-page dialog. Replaces every browser prompt/confirm, so questions look like the
   app, can be styled, and can be tested in a screenshot. */
.dialog-back[hidden] { display: none; }
.dialog-back { position: fixed; inset: 0; z-index: 90; background: rgba(8, 10, 14, .55);
  display: flex; align-items: center; justify-content: center; padding: 20px; }
.dialog { width: 100%; max-width: 420px; background: var(--panel); border: 1px solid var(--line);
  border-radius: 16px; padding: 20px 22px; box-shadow: 0 18px 50px var(--shadow); }
.dialog h3 { margin: 0 0 6px; font-size: 15px; text-transform: none; letter-spacing: 0; color: var(--text); font-weight: 600; }
.dialog .note { margin: 0 0 14px; line-height: 1.5; }
.dialog input { width: 100%; margin-bottom: 14px; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
.dialog-actions button { min-width: 84px; }
.dialog-actions button.danger { background: var(--bad); border-color: var(--bad); color: #fff; }
.dialog-actions button:disabled { opacity: .45; cursor: not-allowed; }

/* The bin sits opposite the star, top left, so a mis-click on "favourite" can never
   land on "delete". Hidden until hover like the star, and it only turns red on its own
   hover: a permanently red button on every card reads as an alarm. */
/* Beside the favourite, not on the grip. Actions cluster top right, the drag handle
   owns top left: 6px gutter + the 28px favourite + a 6px gap puts this at 40px. */
.reel-del { position: absolute; top: 6px; right: 40px; z-index: 2; display: grid; place-items: center;
  width: 28px; height: 28px; padding: 0; border-radius: 50%; border: 0;
  background: rgba(8, 10, 14, .55); color: #fff; opacity: 0; transition: opacity .12s ease; }
.reel-card:hover .reel-del { opacity: 1; }
.reel-del:hover { background: #b3261e; color: #fff; filter: none; }

/* The storyboard at a size you can judge. Cheap thumbnails are where defects hide, and
   a defect missed here becomes one in a clip costing a hundred times more. */
/* This has to come first, and every other overlay here already has one. A class rule
   setting `display` beats the browser's default [hidden] { display: none }, so without
   it the close code runs, the attribute is set, and the frame stays on screen. */
.lightbox[hidden] { display: none; }
/* A ROW, not a centred stack. It was `display: grid; place-items: center`, which is right
   for one picture and puts the two step arrows above and below it the moment there are
   three children — the arrows ended up top and bottom of the screen, reading as unrelated
   controls rather than as previous and next. */
.lightbox { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center;
  justify-content: center; gap: 14px; padding: 16px;
  background: rgba(6, 7, 10, .88); backdrop-filter: blur(6px); cursor: zoom-out; }
.lightbox-fig { margin: 0; display: flex; flex-direction: column; align-items: center; gap: 10px;
  /* min-height:0 so the caption cannot push the picture past the viewport. */
  min-height: 0; }
.lightbox img { max-width: min(88vw, 620px); max-height: 84vh; border-radius: 12px;
  box-shadow: 0 24px 80px rgba(0, 0, 0, .6); }
#lightbox-cap { color: #dfe5ee; font-size: 13px; font-variant-numeric: tabular-nums;
  /* The caption is not a dismiss target by accident: it sits inside the overlay, so a
     click on it closes like the backdrop, which is correct and worth not fighting. */
  cursor: zoom-out; }
/* Big enough to hit without aiming, quiet enough not to compete with the picture. */
.lightbox-step { flex: none; width: 44px; height: 44px; border-radius: 50%; border: 0;
  background: rgba(255, 255, 255, .10); color: #fff; font-size: 22px; line-height: 1;
  cursor: pointer; transition: background .12s ease, opacity .12s ease; }
.lightbox-step:hover:not(:disabled) { background: rgba(255, 255, 255, .22); }
/* Dimmed in place rather than hidden: an arrow that vanishes at the end shifts the
   picture sideways just as you reach for it. */
.lightbox-step:disabled { opacity: .25; cursor: default; }
/* An approved line reads as done at a glance; recorded-but-unheard does not. */
.voice-line.ok { border-color: #2ea44f; }

/* The alternative openings. The first line decides whether the rest gets watched, so
   the choice is offered rather than buried behind a rewrite. */

/* ------------------------------------------------------------------ assets */

/* Two thirds props, one third brand. The divider is a real line rather than a gap: the
   left side is stock and disposable, the right is yours and capped at eight, and the
   split is the only thing on the page that says so. */
/* ONE TILE SIZE FOR THE WHOLE SCREEN. Both grids read `--tile`, so a prop and a brand mark
   are the same square by construction rather than by two numbers that happen to agree
   today. Scoped here, so the Actors and Locations libraries keep their own stretch-to-fit
   grids. */
/* --tile is the MINIMUM width of a props tile, not its fixed width, and what it really
   decides is the column COUNT: 112 gives eight across a 995px column, where a ninth would
   need 1104. 116 was tried and dropped the row to seven. The brand grids read the same
   variable, so they follow the props rather than having to be re-measured.

   The tracks then STRETCH to fill the column (owner, 2026-08-26). Fixed 112px tiles left
   980px of tiles in a 995px column — 15px of slack on the right — and the actions in the
   pinned row above sit at the column's edge, so "Upload an asset" overhung the thumbnails
   by exactly that slack. Stretching removes it at every window width, which a padding
   value could not: the slack is (column + gap) mod (tile + gap) and moves as the panel
   resizes. `justify-content` went with it, having nothing left to distribute. */
.assets-split { --tile: 112px; display: grid; grid-template-columns: 2fr 1fr; gap: 22px;
  align-items: start; margin-bottom: 6px; }
.assets-props .actor-grid { grid-template-columns: repeat(auto-fill, minmax(var(--tile), 1fr)); }
.assets-brand { border-left: 1px solid var(--line); padding-left: 22px; }
.assets-props .actor-filters { flex-wrap: wrap; align-items: center; }
/* The label sits in the row, so it needs the row's baseline rather than a heading's
   bottom margin. */
.assets-props .actor-filters .rail-head { margin: 0 4px 0 0; }
/* The upload sits at the end of the filter row, so it is beside the count rather than
   below fifty tiles. */
/* The two actions at the end of the filter row. Both papaya, both the same box: a button
   and a file-input label are different elements with different default display and
   padding, so the only way they line up is to be described once, here, rather than each
   inheriting a rule written for something else. */
.asset-act { display: inline-flex; align-items: center; justify-content: center;
  height: 30px; padding: 0 13px; margin: 0; border: 0; border-radius: 6px;
  background: var(--accent); color: var(--on-accent);
  font-size: 12px; font-weight: 600; line-height: 1; cursor: pointer; }
.asset-act:hover { filter: brightness(1.1); }
/* First of the pair sits against the right-hand end of the row; the second follows it. */
.asset-make { margin-left: auto; }

/* A photograph is cropped to its square; an uploaded logo or cut-out is letterboxed,
   because cropping a mark eats the mark. */
.actor img.fit-contain { object-fit: contain; padding: 8px; background: var(--panel-2); }

/* The cross on a tile you own. Top-right, on hover only, same manners as the enlarge
   pill: a grid of fifty should be objects, not fifty delete buttons. */
.tile-del {
  position: absolute; top: 5px; right: 5px; z-index: 2;
  width: 20px; height: 20px; padding: 0; border: 0; border-radius: 999px;
  background: rgba(8, 10, 14, .78); color: #fff; font-size: 11px; line-height: 1;
  display: flex; align-items: center; justify-content: center;
  opacity: 0; transition: opacity .12s ease; cursor: pointer; }
.actor:hover .tile-del, .mark-slot:hover .tile-del { opacity: 1; }
.tile-del:hover { background: var(--bad); }

/* Six slots, three across and two rows. Always six: the empty ones are the information,
   and the grid is the limit made visible. Same tile, same gap and same row rhythm as the
   props beside it — `align-items: start` because a grid row otherwise stretches its items
   to the tallest, which is what dropped the second row of marks well below where the
   second row of props sits. */
/* The second heading needs air above it, or the two grids run together and read as one
   long column of slots. */
.brand-assets-head { margin-top: 22px; }
.mark-grid { display: grid; grid-template-columns: repeat(3, var(--tile));
  justify-content: start; align-items: start; gap: 12px; margin-top: 10px; }
/* A label is inline by default, so an empty slot sat on a text baseline while a filled
   figure did not. Both are blocks. An empty BRAND ASSET slot is a button, which arrives
   painted papaya by the app's default button style — it has to be stripped back to
   nothing, or six invitations to compose read as six primary actions. */
.mark-slot { display: block; cursor: pointer; }
button.mark-slot { background: none; border: 0; padding: 0; box-shadow: none;
  color: inherit; font: inherit; text-align: center; width: 100%; }
button.mark-slot:hover { background: none; }
/* THE SQUARE IS THE CONTAINER, NOT THE PICTURE. A mark is usually a logo carrying its own
   background, and giving the image the border and the padding left its own square corners
   showing inside a rounded box — visible in refs/Brand.png. The container now carries the
   round corner and clips whatever is inside it, exactly as a prop tile does. */
.mark-slot .loc-shot { display: block; aspect-ratio: 1; overflow: hidden;
  border-radius: 8px; background: var(--panel-2); border: 1px solid var(--line); }
/* FILLED, like the props (owner, 2026-08-19). It was `contain`, so a mark sat in a small
   box inside the tile while every prop beside it filled one. A tall or wide mark is
   cropped by this — the enlarged view is where it is seen whole, and that is what the
   Enlarge pill is for. */
.mark-slot img { width: 100%; height: 100%; object-fit: cover; padding: 0;
  background: none; border: 0; border-radius: 0; }
/* A FILLED slot draws no border: it was inset by that 1px, so a mark measured 110 in a
   112 box while every prop beside it measured 112. The outline belongs to the empty
   slots, which have nothing else to show. */
.mark-slot:not(.mark-empty) .loc-shot { border: 0; }

/* The empty slot is the same square, drawn as an outline.
   `mark-empty`, NOT `empty`: there is a general-purpose `.empty` in this stylesheet that
   pads 40px top and bottom, and borrowing it made every empty slot 215px tall against a
   filled one's 137 — which is what pushed the second row of marks down the panel. A
   modifier on a component should not share a name with a utility. */
.mark-slot.mark-empty .loc-shot { display: flex; align-items: center; justify-content: center;
  border-style: dashed; transition: border-color .12s ease; }
.mark-slot.mark-empty:hover .loc-shot { border-color: var(--accent); }
.mark-slot.mark-empty figcaption b { font-weight: 500; color: var(--muted); }
.mark-plus { font-size: 22px; font-weight: 300; color: var(--muted); line-height: 1; }
.mark-slot.mark-empty:hover .mark-plus { color: var(--accent); }

/* The generate box: a small form over the library, not a page of its own. The backdrop
   is what closes it, so the box itself stops the click. */
.make-back { position: absolute; inset: 0; z-index: 20; display: flex; align-items: flex-start;
  justify-content: center; padding-top: 12vh; background: rgba(8, 10, 14, .55);
  backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); }
.make-box { width: min(520px, 92%); display: flex; flex-direction: column; gap: 10px;
  background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
  padding: 18px 20px 16px; box-shadow: 0 24px 60px var(--shadow); }
.make-box h4 { margin: 0; font-size: 15px; }
.make-box .note { margin: 0; }
.make-box textarea { width: 100%; resize: vertical; }
.make-acts { display: flex; justify-content: flex-end; gap: 8px; }

/* The brand-asset composer. Wider than the generate box because it holds two rows of
   things to choose from, and the object row scrolls: forty-two props will not fit in a
   dialog and should not try. */
.compose-box { width: min(620px, 94%); }
.compose-pickers { display: flex; flex-direction: column; gap: 10px; }
.pick-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 6px; }
.pick-scroll { flex-wrap: nowrap; overflow-x: auto; padding-bottom: 6px;
  scrollbar-width: thin; scrollbar-color: var(--line) transparent; }
/* Inside a rail the track owns its own top margin, and the chevron's fade has to sit
   against the dialog rather than the panel behind it. */
.compose-box .hrail-track { margin-top: 6px; }
.compose-box .hrail-next { background: linear-gradient(to right, transparent, var(--panel) 78%); }
.pick { flex: 0 0 56px; width: 56px; height: 56px; padding: 0; border-radius: 8px;
  overflow: hidden; border: 2px solid transparent; background: var(--panel-2); cursor: pointer; }
.pick img { width: 100%; height: 100%; object-fit: contain; display: block; }
.pick:hover { border-color: var(--line); }
.pick.on { border-color: var(--accent); }
/* The attempt on screen, at the size you can actually judge a logo at. */
.compose-shot { width: min(420px, 100%); aspect-ratio: 1; object-fit: contain;
  border-radius: 10px; background: var(--panel-2); align-self: center; }
.compose-box .field { display: flex; flex-direction: column; gap: 4px; }
.compose-box .field span { font-size: 12px; color: var(--muted); }

/* Naming a picture: the picture beside the two fields, so the thing being described is in
   front of you while you describe it. */
.name-row { display: flex; gap: 14px; align-items: flex-start; }
.name-shot { width: 132px; height: 132px; object-fit: cover; border-radius: 10px;
  background: var(--panel-2); border: 1px solid var(--line); flex: none; }
.name-fields { display: flex; flex-direction: column; gap: 8px; flex: 1; min-width: 0; }
.name-fields .field { display: flex; flex-direction: column; gap: 4px; }
.name-fields .field span { font-size: 12px; color: var(--muted); }
/* The rename pencil, opposite the delete cross and with the same manners. */
.tile-edit {
  position: absolute; top: 5px; left: 5px; z-index: 2;
  width: 20px; height: 20px; padding: 0; border: 0; border-radius: 999px;
  background: rgba(8, 10, 14, .78); color: #fff; font-size: 11px; line-height: 1;
  display: flex; align-items: center; justify-content: center;
  opacity: 0; transition: opacity .12s ease; cursor: pointer; }
.actor:hover .tile-edit { opacity: 1; }
.tile-edit:hover { background: var(--accent); color: var(--on-accent); }

/* The sound step: one bar, then the read as a list of lines. A line is a row rather than
   a card, because the only questions here are who says it, what it says and how long it
   took — and the controls appear when the pointer is on the row that owns them. */
/**
 * SOUND, TOP TO BOTTOM, IS THREE PASSES: record it, read down the words, listen back.
 *
 * Every section was a bare .composer-row with no vertical margin, so all three ran
 * together as one undifferentiated block and nothing said where one job ended and the
 * next began. Each now gets air proportional to how separate it is: the lines sit close
 * under the record bar because they are its result, and the read is set off by a rule
 * because it is a different action rather than more of the same list.
 */
.sound-bar { align-items: baseline; margin-bottom: 20px; }
.voice-lines { display: flex; flex-direction: column; gap: 3px; }
/* 8px vertical, not 6: the rows carry three sizes of type now — the role, the line and
   the duration — and 6 let the descenders of one row sit close to the caps of the next. */
.voice-line { display: flex; align-items: baseline; gap: 12px; padding: 8px 10px;
  border-radius: 8px; }
.voice-line:hover { background: var(--panel-2); }
.voice-line .role { font-size: 11px; text-transform: uppercase; letter-spacing: .05em;
  color: var(--muted); font-weight: 600; min-width: 92px; }
.voice-line .voice-text { margin: 0; flex: 1; min-width: 0; }
.voice-line .voice-secs { min-width: 74px; text-align: right; }
.voice-line-acts { display: flex; gap: 4px; opacity: 0; transition: opacity .12s ease; }
.voice-line:hover .voice-line-acts { opacity: 1; }

/* Below the script now, and separated from it: you have finished reading the words by
   the time you reach this, and the rule says so. */
.sound-listen { align-items: center; gap: 14px; margin-top: 24px; padding-top: 18px;
  border-top: 1px solid var(--line); }
/* The whole-read player. Wide, because scrubbing a thirty-second conversation with a
   50px slider is not listening to it. */
.read-player { flex: 1; min-width: 280px; max-width: 560px; height: 34px; }
/* The trim toggle reads as ON when the take is tightened, so a glance down the column
   says which lines still carry slack. */
.voice-line-acts .mini.on { color: var(--accent); }

/* A card that goes nowhere. Everything the interactive one has except the affordances:
   no pointer, no hover lift, no focus ring, because there is nothing to press. */
.seat.static { cursor: default; }

/* The confirmation cards on the Actors step, above the grid. Each carries the portrait
   of whoever is in it: the whole reason for picking from faces rather than a list is
   that you recognise a person, so the card shows the person. */
/* The sticky filter row pulls itself up by the canvas's own top padding (margin-top:
   -26px) so it sits flush with the panel head. That is right in both LIBRARIES, where
   it is the first thing in the canvas. On the Actors and Locations steps it comes after
   the selection card, and the same pull-up climbed 8px into that card and painted its
   bottom border out with the row's own opaque background — which read as a card missing
   an edge rather than as a row sitting on top of it. Cancelled whenever the row is not
   first; the sticky offset is separate and still works. */
.canvas > .actor-filters:not(:first-child) { margin-top: 0; }

.seats { display: flex; gap: 14px; flex-wrap: wrap; margin-bottom: 18px; }
/* Spacing, reworked: the first pass ran 9px down the left and 14px down the right, so
   the thumbnail sat hard against one edge while the text had room at the other, and
   11px of air around a 44px picture read as cramped however symmetrical it measured.
   Even padding all round, a bigger gap between picture and words, and a taller card so
   the two columns have similar optical weight rather than similar pixel heights. */
.seat { display: flex; align-items: center; gap: 15px; padding: 14px 20px 14px 14px; cursor: pointer;
  background: var(--panel-2); border: 2px solid var(--line); border-radius: 14px;
  color: var(--text); text-align: left; min-width: 250px; }
.seat:hover { border-color: var(--muted); }
/* Ringed when it is the seat the next click fills. Filled seats are ringed too, so the
   active-but-empty one is told apart by its dashed face rather than by colour alone. */
.seat.active { border-color: var(--accent); }
/* The default answer on a step that cannot be left undecided. Yellow rather than accent, so it
   reads as a suggestion and not as a selection already made -- the accent ring means chosen. */
/* A SUGGESTION IS NOT A WARNING. Owner, 2026-09-05, after adding a prop and taking it out again:
   "the border turned yellow instead of the theme colour". It was `--warn` (#e0b341), the same
   amber as a lint warning, a warning box and the busy bar - so removing a prop looked like
   something had gone wrong, when the card is only pointing at the other answer.

   `--accent-soft` was tried first, on the reasoning that the solid accent means "chosen" and a
   suggestion should not claim to be the choice. The owner's eye settled it: "it's a lighter colour
   than the card above it", and a border that is nearly the theme colour reads as a faded version
   of it rather than as a different state. One accent, used plainly. */
.seat.suggested { border-color: var(--accent); }
.seat.suggested:hover { border-color: var(--accent); }
.seat-face { flex: none; width: 52px; height: 52px; border-radius: 10px; overflow: hidden;
  background: var(--panel); display: grid; place-items: center; }
.seat-face img { width: 100%; height: 100%; object-fit: cover; display: block; }
.seat-ph { width: 100%; height: 100%; border: 1px dashed var(--line); border-radius: 10px; }
.seat-text { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.seat-role { font-size: 10.5px; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); }
.seat-who { font-size: 13.5px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.seat:not(.filled) .seat-who { color: var(--muted); font-weight: 400; }
.seats-hint { margin: -8px 0 14px; }

/* The grid is the library's, with two things added: a hit area that casts, and a ring
   on whoever is already in the seat. */
.actor-cast { position: absolute; inset: 0; border: 0; background: none; cursor: pointer;
  border-radius: 8px; }
.actor-cast:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.actor.cast-here img { outline: 3px solid var(--accent); outline-offset: -3px; }
/* .72, not .38. Seventy-two of the seventy-eight have no angle pack, and at .38 on a
   dark ground they read as empty tiles rather than as people — which is the opposite of
   a library you browse by face, and the owner asked for this to look like that library.
   Enough to say "not this one, not yet" and no more. */
.actor.cast-elsewhere img, .actor.uncastable img { opacity: .72; }
.actor.cast-elsewhere figcaption .note, .actor.uncastable figcaption .note {
  display: block; font-size: 10px; color: var(--muted); }
/* The play button must stay clickable over the cast layer. */
.actor-hit { z-index: 2; }

/* Select and Close, side by side under the clip. Select is a plain button, which is
   already papaya, so the choice is the orange thing on screen and dismissing is not. */
.player-actions { display: flex; align-items: center; gap: 10px; }

/* The "nothing in shot" card. A tick in the thumbnail's place, because the card's whole
   point is that there is no picture: an empty frame says the answer better than an icon
   borrowed from somewhere else would. */
.none-ph { display: grid; place-items: center; color: var(--accent); font-size: 22px; font-weight: 700; }

/* Script: the brief on the left, the lines on the right, half each. The lines used to
   have the whole width, which made a two-sentence beat sprawl across a screen and put
   the thing they are written FROM on another step entirely.

   THE SCROLL CHAIN. This step pins its head and lets one thing scroll underneath, which
   works by a flex chain running toolbox-body > composer > composer-body > .beats, every
   link carrying min-height:0. Dropping this grid in the middle broke that chain: .beats
   stopped being a child of .composer-body, so it kept its old max-height: 52vh and
   scrolled inside a column that was itself the full height of the panel — two scrollbars
   arguing, and the lines running past the bottom of the box. The grid and both its
   columns are now links in the same chain. */
.toolbox-body.pinned .composer-body > .script-split { flex: 1; min-height: 0; }
.script-split { display: grid; grid-template-columns: 1fr 1fr; gap: 24px;
  align-items: stretch; min-height: 0; }
/* The left column is now two things: the brief on top, the render check under it. Half
   each, so the checker is on screen while the lines are being written rather than
   somewhere you have to go and look. */
/* 32px, not the 18px that separates related blocks elsewhere on these steps. The checker
   is not part of the brief above it — it is watching the column on the RIGHT — and at the
   standard gap it read as a second paragraph of the same box. The extra space is the only
   thing saying they are two different features, so it is deliberately more than a nudge. */
.script-side { display: flex; flex-direction: column; gap: 32px; min-height: 0; }
.script-side > .script-topic { flex: 1 1 50%; min-height: 0; }
/* One line and a note: it takes its own height and leaves the halves above and below it to
   split what is left. Without flex:none it would join that split and steal a third of the
   column to hold a single input. */
.script-side > .script-cta { flex: none; display: flex; flex-direction: column; gap: 6px; }
.script-cta .rail-head { margin: 0; }
/* Shouted, because the value IS shouted: what is stored is "Comment FREE below" and the
   caption burns it in capitals. A box that displayed "free" would be showing something the
   reel does not hold. Tracking is wide enough that a short all-caps word still reads as a
   word rather than an initialism. */
.cta-word { width: 100%; text-transform: uppercase; letter-spacing: 0.08em;
  font-weight: 600; }
.script-side > .script-check { flex: 1 1 50%; min-height: 0; display: flex;
  flex-direction: column; gap: 6px; }
.script-check .rail-head { margin: 0; }
.check-body { flex: 1; min-height: 0; overflow-y: auto; display: flex;
  flex-direction: column; gap: 10px; }

/* A risk reads as a risk: the themed border, the same one that means "this is the thing
   in hand" everywhere else on the step. It goes when the line is fixed. */
.check-item { border-left: 3px solid var(--accent); background: var(--panel-2);
  border-radius: 8px; padding: 9px 12px; }
.check-why { margin: 0; font-size: 12.5px; line-height: 1.45; }
.check-why b { color: var(--accent); margin-right: 4px; }
/* The suggestion is quoted, not asserted: it is the owner's own words with one full stop
   moved, and it should look like something to read before accepting. */
.check-fix { margin: 7px 0 8px; font-size: 12.5px; line-height: 1.45; color: var(--text);
  border-left: 2px solid var(--line); padding-left: 9px; font-style: italic; }
.check-clean { color: var(--muted); }

.script-topic { display: flex; flex-direction: column; gap: 6px; min-height: 0; }
.script-topic .rail-head { margin: 0; }
/* The brief fills its column rather than standing at a fixed height: this is the one box
   on the step meant to be written in at length, and the space beside a scrolling list is
   there whether it is used or not. It still drags.

   min-height is 120, not 420. A floor is a promise the flex chain cannot break, and this
   one was taller than the whole panel on a short window: the box held at 420, the step
   grew past its container, and .toolbox-body clips rather than scrolls — so the bottom of
   the brief and the sign-off under it were not merely off-screen, they were unreachable.
   Measured at a 520px panel: 105px of the step was being cut off. Low floor, flex:1, and
   the box fills the room it has and gives it back when there is none. */
.topic-box { width: 100%; resize: vertical; flex: 1; min-height: 120px; line-height: 1.5;
  /* Its own scrollbar, so a long brief scrolls INSIDE the box rather than making the box
     taller than the page. Without this the shrink above just moves the overflow. */
  overflow-y: auto; }
/* The right column is the scroll chain's last link but one: its head stays, its beats
   scroll. */
/* The cast cards are a fixed band above the split, and the split takes what is left.
   Without flex:none the row shrinks with the page and the portraits squash. */
.toolbox-body.pinned .composer-body > .seats { flex: none; }
.script-lines { min-width: 0; display: flex; flex-direction: column; min-height: 0; }
.script-lines > .script-source { flex: none; }
.script-lines > .beats { flex: 1; min-height: 0; max-height: none; }
.script-lines > .composer-foot { flex: none; }
@media (max-width: 900px) {
  .script-split { grid-template-columns: 1fr; }
  /* Stacked, nothing can fill a shared height, so the beats go back to their own. */
  .script-lines > .beats { max-height: 52vh; }
  .topic-box { min-height: 160px; flex: none; }
}

/* THE PRODUCT LINK ROW (step 4, Product showcase only). Sits above the library it feeds, so a
   person arriving with a link in the clipboard meets the field before the 57 objects that are not
   their product. Its own block rather than a variant of the filter bar: the filter bar narrows what
   is already there, this brings something new in. */
/* ⚠️ 40px, not 14: the filter row below it is `.pinhead`, which is sticky and pulls itself UP by
   26px to sit under the panel's own header. A normal gap here is eaten by that pull and the row
   lands on top of this block's last line — which is the result note, the one line that has to be
   readable after a fetch. 40 - 26 leaves the 14 it looks like. */
.product-link { display: flex; flex-direction: column; gap: 6px; margin: 0 0 40px; }
.product-link .rail-head { margin: 0; }
.product-link-row { display: flex; gap: 8px; align-items: center; }
/* Takes the room: a product url is long and the middle of it — the part that says which product —
   is what a person checks before pressing. A short box shows the domain and hides the answer. */
.product-link-input { flex: 1; min-width: 0; }
.product-link-row button { flex: none; }
/* The result line is a note, not a toast: it says which of the page's own tags answered, and that
   is worth staying on screen while the picture beneath it is looked at. */
.product-link-note { color: var(--accent); }

/* THE LAST FAILURE, under the step that failed. Bordered like a `.check-item` because it is the
   same kind of object — a thing to read and act on — but in the warning colour rather than the
   accent, since the accent means "this is the thing in hand" everywhere else and this is the thing
   that stopped. It sits under the empty reel, where the owner asked for it, and it stays until the
   step succeeds. */
/* FULL WIDTH, and above the button rather than under it (owner, 2026-09-10). Above, because it is
   the thing to read BEFORE deciding whether to press again — under the button it was a footnote to
   an action already taken. Full width because it is the only card on the step that carries a
   sentence worth reading twice, and a measure that suits body copy made it look like a caption
   beside the empty reel it belongs to. */
.step-failure { border-left: 3px solid var(--warn); background: var(--panel-2);
  border-radius: 8px; padding: 11px 14px; margin: 0 0 14px; }
.step-failure-head { margin: 0 0 4px; font-size: 12.5px; font-weight: 600; }
.step-failure-why { margin: 0; font-size: 12.5px; line-height: 1.45; color: var(--muted); }

/* Play the reel, above the frames. */
.play-strip { display: flex; align-items: center; gap: 12px; margin: 0 0 12px; }
/* THE FRAME IN THE MIDDLE IS RINGED, ALWAYS.
   It used to be ringed only while the reel played, on the argument that the middle frame
   is obviously the one being worked on so a permanent ring says nothing. That was wrong
   in the place it matters: Fix and Approve sit BELOW the strip, and nothing tied the
   button you are about to press to the picture it acts on. Owner's call, and the ring is
   the cheapest way to say "this one".
   An outline rather than a border: a border takes layout space and would nudge the strip
   sideways every time the ring moved, so the pictures jump as you scroll. Outline is
   drawn outside the box, costs no layout, and follows the image's own corner radius. */
.strip-item.centred img,
.strip-item.centred .strip-empty { outline: 3px solid var(--accent); outline-offset: 3px; }

/* ── The storyboard, as a carousel ──────────────────────────────────────────────
   One clip on screen at the size it will be judged at, and nothing on the step
   scrolls vertically: the picture takes whatever height is left after the band, the
   line and the buttons, and gives it back when the window is short. */
/* Everything above the picture is height the picture does not get, and on a step whose
   whole job is looking at one image that trade is worth making explicitly. Play and the
   props note are both one short line, so they share a row. */
.storyboard-topline { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px;
  margin-bottom: 10px; }
.storyboard-topline .play-strip { margin: 0; }
.storyboard-topline .clips-head { margin: 0; }
.clips-head { margin: 0 0 10px; }

/* align-items: STRETCH, not center. Centring leaves the strip free to size itself to its
   content, so the pictures set the row's height instead of the row bounding them. */
.carousel { display: flex; align-items: stretch; justify-content: center; gap: 10px;
  flex: 1; min-height: 0; }

/* ── The clip scroller ────────────────────────────────────────────────────────
   Players, not thumbnails, filling the width. `justify-content: flex-start` on the
   track rather than the centring the frames use: a strip of watchable clips is read
   left to right from the beginning, where a filmstrip of stills is read from whichever
   one is in the middle. No spacers for the same reason — nothing needs to reach the
   centre. */
/* THE TILE FITS THE STRIP, and shrinks rather than being cut off.
   Measured 2026-08-30 at a 929px viewport: the tile was 519px tall in a 479px strip and
   `overflow-y: hidden` took 40px off the bottom — which is exactly where Approve and
   Re-render live, so the controls for the thing on screen were unreachable. It was never
   noticed because the band above only existed on the clips branch and those screenshots
   were taken on a taller window.
   `stretch` + a flexible shot lets the picture take whatever height is left after the
   caption and the buttons, and the 9:16 ratio then sets its width — capped at the old
   clamp so a tall window does not blow the tiles up beyond the size they were. */
.clips-carousel .filmstrip-track { align-items: stretch; gap: 18px; padding: 4px 2px 8px;
  /* `safe` centre: one or two tiles sit in the middle, and a full strip still starts at the
     left edge where it can be scrolled from. Without it a single anchor hung off to one side
     in a strip built for six clips. */
  justify-content: safe center; }

/* A tile with nothing in it yet — an anchor before it is drafted, or while it is. Same box as
   a clip so the step does not change size as it fills up. */
.clip-tile-shot.empty { display: grid; place-items: center; border-style: dashed; }
.clip-tile-shot.empty .note { margin: 0; padding: 0 12px; text-align: center; }
/* The cap rose from 260px on 2026-09-01, when the actor/location band came off the storyboard
   and gave the strip about 110px more height. The tile is sized by the height available and the
   9:16 ratio sets its width, so the cap is the only thing that was stopping it using the space:
   at ~530px of usable height a 9:16 shot wants ~300px across. */
/* THE WIDTH IS SET HERE, not inherited from whatever happens to be inside the tile.
   Measured 2026-09-02 from the owner's screenshot of an undrafted anchor: a filled tile came
   out 304px across and an empty one 140px, in the same strip. 304 is not a number this
   stylesheet chose -- it is `<video>`'s DEFAULT INTRINSIC WIDTH of 300px plus two 2px borders.
   The tile was `width: auto`, so it shrank to fit its contents, and for a real clip the
   contents happened to be a video with an opinion about its own size. An empty tile contains
   a line of small text and nothing else, so it collapsed to that.
   Which means the ratio below was never doing the sizing: at 611px of shot height a 9:16 box
   wants 344px, and every tile on screen was 304 instead. Declaring the width makes the empty
   and filled tiles identical and stops the layout depending on a browser default. */
.clip-tile { flex: none; width: clamp(190px, 26vw, 340px); max-width: 100%;
  display: flex; flex-direction: column; gap: 8px; min-height: 0; }
.clip-tile-shot { position: relative; aspect-ratio: 9 / 16; border-radius: 12px; overflow: hidden;
  /* Takes the height left over, then the ratio gives the width. min-height:0 or a flex item
     refuses to shrink below its content and the overflow comes straight back. */
  flex: 1 1 auto; min-height: 0; width: auto; max-width: 100%; margin: 0 auto;
  background: var(--panel-2); border: 2px solid var(--line);
  display: grid; place-items: center; }
.clip-tile.ok .clip-tile-shot { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.clip-tile-shot video { width: 100%; height: 100%; object-fit: cover; display: block; }
/* WHILE IT RE-RENDERS: one spinner, a solid edge, and the old clip still visible.
   Three house rules, all of which the first version broke.
   - "ONE SPINNER, wherever a picture is being made... anything carrying `busy` gets it
     drawn on top WITHOUT MARKUP." The tile carried `busy` AND emitted its own .reel-spin,
     so it drew two rings of different sizes, one inside the other.
   - "While it turns, the dashes give way to a solid edge: a dashed box reads as 'empty and
     waiting for you', and this one is neither." It went dashed, which is the opposite.
   - The filmstrip keeps the old picture underneath, dimmed, "because it is still the best
     guess at what is coming". The clip was replaced by a hole instead.
   Pointer events go too: a clip being replaced should not be scrubbable. */
.clip-tile.busy .clip-tile-shot { border-style: solid; border-color: var(--accent); }

/* A clip whose script has moved on. Dimmed rather than hidden: it is still the last thing that
   was rendered here, and seeing it is how you know what changed. */
.clip-tile.stale .clip-tile-shot { opacity: .38; border-style: dashed; }
.clip-tile.stale .clip-tile-cap .note { color: var(--warn, #d8a24a); }
.clip-tile.busy video { opacity: .35; pointer-events: none; }
/* CENTRED UNDER THE PICTURE, not against the left edge of the tile (owner, 2026-09-10).
   The tile is a fixed-width column and the shot inside it is sized by its 9:16 ratio from
   whatever height is left, then centred with `margin: 0 auto` — so on a tall panel the shot is
   narrower than the tile and a flex-start caption sits out on its own, pointing at nothing. It is
   a label for the picture, so it lines up with the picture. */
.clip-tile-cap { margin: 0; display: flex; align-items: baseline; justify-content: center;
  gap: 8px; font-size: 13px; text-align: center; }
.clip-tile-cap .note { font-size: 11px; }
.clip-tile-acts { display: flex; gap: 8px; }
.clip-tile-acts .mini { flex: 1 1 0; }

/* APPROVE IS A STATE, NOT AN ACTION, so it is coloured by where the clip has got to
   rather than by the accent every other button uses: orange while it still wants a
   decision, green once it has one. Keyed off the tile's own `ok` class rather than the
   button's data-approved, which holds the NEXT state and reads backwards. */
/* Owner, 2026-09-01: Approve takes the faded theme colour. It sits beside a `.spends` button
   in full accent, and the softer one is the free action — which is the distinction that
   matters on a step where the other button costs money. The green below is untouched: that
   says "already signed off", which is a different thing from "this is the safe button". */
.clip-tile-acts [data-clip-approve],
.clip-tile-acts [data-anchor-approve] {
  background: var(--accent-soft); border-color: var(--accent-soft); color: var(--on-accent); }
/* BOTH BUTTONS, and the anchor one was missing (owner, 2026-09-02: "I still want the button to
   go green if a clip is approved").
   The faded-accent rule above lists clip AND anchor; this one listed only clip, so an approved
   anchor kept the yellow. Proved on one tile by swapping nothing but the attribute: same tile,
   same `ok`, clip green and anchor `color(srgb 1 0.882 0.3)`.
   The two branches emit different attribute names for the same button, so a selector written
   for one silently covers half the app — the same shape as the doubled spinner fixed today. */
.clip-tile.ok .clip-tile-acts [data-clip-approve],
.clip-tile.ok .clip-tile-acts [data-anchor-approve] {
  background: var(--good); border-color: var(--good); color: #fff; }

/* AN APPROVED CLIP HIDES ITS RE-RENDER, so a paid button cannot be hit by accident on a
   clip that is already settled. Unapprove and it comes back.
   `visibility: hidden`, NOT `display: none` — the button keeps its space, so Approve stays
   exactly where it was and does not stretch across the tile the moment you sign it off. A
   control that moves under the pointer is how the wrong thing gets clicked, which is the
   fault this is here to prevent. visibility also takes it out of the tab order and stops it
   being a pointer target, so it cannot be reached by keyboard or by clicking the gap. */
.clip-tile.ok .clip-tile-acts [data-retake] { visibility: hidden; }

/* ── The filmstrip ─────────────────────────────────────────────────────────────
   Every frame on screen, scrolled left and right, the middle one in focus. The fade
   is applied per item by the scroll handler rather than by a mask over the strip: a
   mask would fade whatever happens to sit under it, while this fades each PICTURE by
   how far it is from the middle, which is the thing being said. */
.filmstrip { flex: 1; min-width: 0; min-height: 0; overflow-x: auto; overflow-y: hidden;
  scroll-snap-type: x mandatory; scrollbar-width: none; }
.filmstrip::-webkit-scrollbar { display: none; }
.filmstrip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 12px; }
.filmstrip-track { display: flex; align-items: center; gap: 14px; height: 100%; }
/* Sized by the script to half a viewport minus half a frame, so the first and last
   pictures can both reach the middle. flex:none or they would be squeezed away. */
.strip-spacer { flex: none; display: block; height: 1px; }

/* The vertical padding is what the RING lives in. The outline sits 3px outside the image
   and the strip clips at its own edge (overflow-y has to be hidden while overflow-x
   scrolls), so an image at the full height of the strip had its ring sliced off top and
   bottom — the sides showed and the ends did not, which reads as a rendering fault rather
   than a design. box-sizing is border-box, so this simply makes the picture 16px shorter
   and gives the outline 8px of room inside the clip. */
.strip-item { flex: none; height: 100%; margin: 0; padding: 8px 0; border: 0; background: none;
  position: relative; scroll-snap-align: center; cursor: pointer;
  /* Opacity and transform are written by the scroll handler every frame, so they are
     NOT transitioned — a transition would fight the scroll and lag behind the finger.
     Only the things the handler does not touch animate. */
  transition: filter .12s ease; }
/* A clip in the strip behaves exactly as a frame does: same size, same corner, same
   fade. The video carries no controls — it is a moving thumbnail, and the button around
   it is the control. */
.strip-item video { height: 100%; width: auto; display: block; border-radius: 12px;
  object-fit: cover; background: var(--panel-2); pointer-events: none; }
.strip-item.centred video { outline: 3px solid var(--accent); outline-offset: 3px; }
/* A gate flagged this one. Sits where the approved tick sits on a frame, and uses --bad
   rather than the accent, which in this row already means "the one you are looking at". */
.strip-flag { position: absolute; top: 16px; right: 8px; width: 22px; height: 22px;
  border-radius: 50%; display: grid; place-items: center; font-size: 12px; font-weight: 700;
  background: var(--bad); color: #fff; }

/* The clip under the strip: the player big enough to judge, its lines and controls beside
   it rather than under it, so the video keeps the height it needs. */
.clip-watch { display: flex; gap: 18px; align-items: flex-start; margin-top: 10px; }
.clip-player { height: min(38vh, 380px); aspect-ratio: 9 / 16; border-radius: 12px;
  background: #000; flex: none; }
.clip-side { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 10px; }

.strip-item img { height: 100%; width: auto; display: block; border-radius: 12px;
  object-fit: contain; box-shadow: 0 18px 50px rgba(0, 0, 0, .45); user-select: none; }
.strip-item.centred { cursor: zoom-in; }
/* Grayscale said "something is happening to this one" without saying what, and read as a
   disabled tile rather than a working one. */
.strip-item.busy img, .strip-item.busy .strip-empty { opacity: .35; }
.strip-empty { display: grid; place-items: center; align-content: center; gap: 6px;
  height: 100%; aspect-ratio: 9 / 16; border: 1px dashed var(--line); border-radius: 12px;
  color: var(--muted); text-align: center; padding: 16px; }
/* An approved frame says so on the picture, so scanning the strip answers "how far have I
   got" without reading the dots. */
/* Offset by the padding above, so it still sits just inside the picture's corner. */
.strip-tick { position: absolute; top: 16px; right: 8px; width: 22px; height: 22px;
  border-radius: 50%; display: grid; place-items: center; font-size: 12px;
  background: var(--good); color: #fff; }

.carousel-step { flex: none; align-self: center; width: 44px; height: 44px; border-radius: 50%; border: 0;
  background: var(--panel-2); color: var(--text); font-size: 22px; line-height: 1; cursor: pointer;
  transition: background .12s ease, opacity .12s ease; }
.carousel-step:hover:not(:disabled) { background: var(--accent); color: var(--on-accent); }
.carousel-step:disabled { opacity: .25; cursor: default; }

.carousel-below { flex: none; display: flex; flex-direction: column; gap: 8px;
  padding-top: 12px; margin-top: 12px; border-top: 1px solid var(--line); }
.clip-where { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.clip-where .note { font-variant-numeric: tabular-nums; }

/* A dot per clip: the size of the set and your place in it, without counting, and any
   clip one click away rather than N presses. Filled = approved, so how much of the
   storyboard is signed off is readable at a glance. */
.clip-dots { display: flex; gap: 6px; margin-left: auto; }
.clip-dot { width: 9px; height: 9px; padding: 0; border-radius: 50%; cursor: pointer;
  border: 1px solid var(--muted); background: none; transition: transform .12s ease; }
.clip-dot.done { background: var(--good); border-color: var(--good); }
.clip-dot.on { border-color: var(--accent); background: var(--accent); transform: scale(1.35); }

.clip-lines { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; font-size: 14px; }
.clip-lines li { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 10px; align-items: baseline; }
.clip-role { font-size: 10px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
/* A silent beat is a direction to the camera, not a line, so it does not look like speech. */
.clip-direction { font-style: italic; color: var(--muted); }

.clipfoot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; }
.clip-props { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.clip-label { font-size: 10px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
.clip-prop { display: inline-flex; align-items: center; gap: 6px; font-size: 12px;
  padding: 3px 6px 3px 3px; border: 1px solid var(--line); border-radius: 999px; background: var(--panel); }
.clip-prop img { width: 20px; height: 20px; border-radius: 50%; object-fit: cover; display: block; }
.clip-prop-x { border: 0; background: none; color: var(--muted); cursor: pointer; padding: 0 2px; font-size: 11px; }
.clip-prop-x:hover { color: var(--bad); }
.clip-prop-add { font-size: 12px; padding: 3px 6px; }
.clip-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }

@media (max-width: 720px) {
  .clip-lines li { grid-template-columns: minmax(0, 1fr); gap: 2px; }
}

/* ------------------------------------------------------------- captions */
/* The overlay sits ON the video, so the stage is the positioning context and the
   video keeps its own aspect. Percentages here are percentages of the frame, which
   is what makes the preview mean the same thing as the burn. */
/* The stage must be EXACTLY the video's box, because the overlay is measured against the
   stage and the caption is placed as a percentage of it. When the stage stretched to its
   column and the video kept its intrinsic width, every overlay element sat off to the
   right of the picture — visibly, and by a different amount at every window size. Fixing
   the stage to the reel's aspect and making the video fill it keeps the two identical. */
/* Sized by HEIGHT, with the width following from the aspect. Sizing by width made a
   9:16 reel 807px tall in a modal that is not, so the picture ran off the bottom and the
   thing being edited was the part you could not see. */
.cap-stage {
  /* inline-block, NOT block. On a block, `width: auto` means "fill the container", so the
     width came from the grid column and the height followed from the aspect — a 9:16 reel
     807px tall in a modal that is not. Shrink-to-fit is what makes the height the input
     and the width the consequence. */
  /* `width: fit-content`, not `width: auto` + inline-block: something in the modal forces
     display:block on this element, so the display switch never took and `auto` meant
     "fill the column". fit-content shrink-wraps whatever the display is, which makes the
     height the input and the width the consequence. */
  position: relative; line-height: 0;
  height: min(62vh, 620px); aspect-ratio: 9 / 16; width: fit-content;
  margin: 0 auto; max-width: 100%;
}
/* `video.player`, not `.player`: the panel is wrapped by group(), and `.cg-body
   video.player { width: 260px }` outranks a two-class selector. The video then kept 260px
   inside a 454px stage and every overlay percentage was measured against the stage rather
   than the picture. Same specificity, later in the file, so this wins. */
/* The waiting state, in the reel's own box. `.cap-stage` gives it the height, the 9:16 and
   the centring; this only supplies what the player supplies for itself — something to look
   at. `line-height` is reset because .cap-stage sets it to 0 for the video. */
.cap-stage.waiting { display: grid; place-items: center; line-height: normal;
  border: 1px solid var(--accent); border-radius: 14px; background: var(--panel-2); }
.cap-stage video.player { display: block; width: 100%; max-width: none; height: 100%; max-height: none; object-fit: contain; }
.cap-overlay { position: absolute; inset: 0; pointer-events: none; line-height: 1.15; }
/* The caption alone takes pointer events, so the video's own controls still work
   everywhere else on the frame. */
/* THE REEL IS THE EDGE OF THE WORLD, for the caption.
   A caption that spills is painted on the page behind the video, which is not something a
   burn could ever reproduce: the burn draws into a 1080x1920 canvas and has nowhere else
   to go. The chunker in captionAt is what stops a line NEEDING to spill; this stops it
   happening anyway when a setting is pushed somewhere the chunker cannot save it, such as
   a very large font at 95% down the frame.

   Its own layer rather than `overflow: hidden` on .cap-overlay, which was the first fix
   and the wrong one: the banner's nine drag handles and its hover outline sit ON its
   border, so clipping the whole overlay would have quietly cut them off whenever a banner
   was dragged near the edge — trading a visible caption bug for an invisible banner one. */
.cap-clip { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.cap-line, .cap-banner { pointer-events: auto; cursor: grab; touch-action: none; }
.cap-line:hover, .cap-banner:hover { outline: 1px dashed rgba(255,255,255,.45); outline-offset: 6px; }
/* `user-select` while dragging: the drag no longer cancels the press up front, because that
   is what stopped a caption word from being clicked at all, so the browser is free to start
   selecting text under a moving pointer until we say otherwise. */
.cap-line.dragging, .cap-banner.dragging { cursor: grabbing; outline: 1px solid rgba(255,255,255,.7);
  outline-offset: 6px; user-select: none; }
.cap-line {
  position: absolute; left: 50%; transform: translate(-50%, -50%);
  width: 86%; text-align: center; font-weight: 700;
  padding: 0.12em 0.4em; border-radius: 0.12em;
  paint-order: stroke fill; white-space: normal; word-break: break-word;
}
.cap-banner {
  position: absolute; transform: translate(-50%, -50%);
  text-align: center; font-weight: 700; padding: 0.35em 0.6em;
  box-sizing: border-box; opacity: 0;
}
/* A banner's settings. No longer a <details>: the chips at the top decide which banners
   exist, so a second thing to open and close was one control too many for the same fact. */
.cap-group { padding: 0.25rem 0 0; }
.cap-group + .cap-group { border-top: 1px solid var(--line); margin-top: 14px; padding-top: 14px; }
/* A button, because it does something — takes the reel to this banner's moment — but it
   still has to read as a heading, so it keeps the label's shape and gains only the
   affordance: a pointer, and a lift on hover. */
.cap-group-title {
  display: block; margin: 0 0 10px; padding: 0; background: none; border: 0;
  font-family: inherit; font-size: 11px; font-weight: 700; text-transform: uppercase;
  letter-spacing: .08em; color: var(--accent); text-align: left; cursor: pointer;
  transition: opacity .14s ease;
}
.cap-group-title:hover { opacity: .72; }
.cap-group-title:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.field.wide { grid-column: 1 / -1; }

/* NOT called .split: there is already a `.split video.player { max-width: 300px }` rule
   above, and naming this layout `split` silently capped the preview video's width, so the
   overlay was measured against a stage the picture did not fill and every caption sat off
   to the right of it. A layout class that collides with a component class is a bug that
   only shows up inside the component.
   Captions: settings on the left, the reel on the right and staying put while you
   scroll the settings — the reel is the thing being edited, not the outcome. */
/* The captions workspace FITS. It is a settings column beside a reel, and a reel that
   scrolls off the top while you adjust the thing you are looking at is the one layout
   this step cannot afford. The body does not scroll; the settings column takes whatever
   height is left and scrolls inside itself if a tab needs more. */
.toolbox-body.cap-split {
  /* The settings column is sized to the CONTROLS, not given half the panel. It used to
     take 1fr — over a thousand pixels for controls that need five hundred — and the reel
     was pushed against the right edge with the dead half sitting between them. Now the
     reel gets everything the tools do not, and centres in it. */
  display: grid; grid-template-columns: minmax(0, 520px) minmax(240px, 1fr);
  grid-template-rows: auto minmax(0, 1fr);
  gap: 0 1.6rem; align-items: stretch; overflow: hidden;
}
/* A column, so the burn-in button can be pushed to the bottom of the workspace rather
   than floating directly under whichever field happens to be last. */
.toolbox-body.cap-split .cap-config {
  min-height: 0; overflow-y: auto; padding-right: 6px;
  display: flex; flex-direction: column;
}
/* PINNED, not just last. margin-top:auto puts it at the bottom when the settings fit;
   sticky keeps it there when they do not — with both banners switched on the column is
   half as tall again as the panel, and the burn-in button was simply below the fold.
   A button you have to go looking for is a button that looks missing. */
.toolbox-body.cap-split .cap-config .composer-row {
  margin-top: auto; position: sticky; bottom: 0; z-index: 3;
  padding: 10px 0 2px; background: var(--panel);
}
/* Above the button, not under it: the hint marks that there is more to scroll, and the
   two share the bottom edge now. */
.toolbox-body.cap-split .cap-config > .vhint > button { bottom: 48px; }
.toolbox-body.cap-split .cap-canvas { min-height: 0; height: 100%; text-align: center; }
/* The height has to be PASSED DOWN, or `height: 100%` on the reel resolves against a
   wrapper that has none and falls back to sizing from its width — which is how a 9:16
   reel became 807px tall inside a 615px column. group() wraps the panel in .cg > .cg-body,
   and both were auto. */
.toolbox-body.cap-split .cap-canvas .cg { height: 100%; min-height: 0; display: flex; flex-direction: column; }
/* THE REEL GETS ITS OWN ROW, AND THE NOTE GOES UNDER IT.
   `.cg-body` is a wrapping FLEX ROW by default, which is right for a grid of tiles and
   wrong here: the "Burned cut: open · 31.97s" note sat BESIDE the reel as a row item, so
   the pair centred together and the reel itself sat 77px left of centre — measured, and
   exactly (144px note + 10px gap) / 2. The waiting state had a different note of a
   different width, so the reel moved when the merge landed.

   The slot is what makes the height work without a circular constraint. `.cap-stage` sizes
   from its HEIGHT and derives width from the 9:16 ratio, so it cannot also be the flex item
   that height is being resolved for: giving it `flex: 1; height: auto` froze the renderer
   outright. The slot takes the flex height, has no aspect ratio of its own, and the stage
   fills 100% of it. */
/* Any panel whose subject is the reel: the reel on its own row, whatever sits under it
   underneath rather than beside. Scoped by a class the panel declares rather than by which
   workspace it happens to be in, because the Download step wants the identical layout and was
   getting the default wrapping row — its player sat left with the warning line alongside. */
/* See renderToolbox: equal air above and below the reel. The 8px under .canvas and the extra
   4px of body padding exist to give a last row of controls clearance; a reel has no last row,
   and the asymmetry reads as the reel sitting high in the panel. */
.toolbox-body.reel-body { padding-bottom: 18px; }
.toolbox-body.reel-body .canvas { padding-bottom: 0; }
/* `.cg` carries a bottom margin so stacked groups breathe. A reel panel is never stacked —
   it is one full-height object — so that margin is just 26px of dead air under the reel, and
   it was the real reason the reel sat high: 18px above it and 44 below. */
.reel-panel { margin-bottom: 0; }
.reel-panel .cg-body { flex-direction: column; align-items: stretch; text-align: center; }
/* The height chain, for a reel panel in a container that HAS a height to give. `.cap-stage`
   sizes from its height, so every wrapper between it and the panel has to pass one down or it
   falls back to the 62vh default — which is what left the Download reel smaller than the
   Captions one. Written against `.filled` rather than a workspace name so the next panel to
   show a reel inherits it. */
.toolbox-body.filled .reel-panel { height: 100%; min-height: 0; display: flex; flex-direction: column; }
.toolbox-body.filled .reel-panel .cg-body { flex: 1; min-height: 0; }
.toolbox-body.filled .reel-panel .cap-stage-slot { flex: 1; }
.toolbox-body.filled .reel-panel .cap-stage { height: 100%; max-height: 100%; }
.toolbox-body.cap-split .cap-canvas .cg-body { flex: 1; min-height: 0; }
.cap-stage-slot { display: flex; justify-content: center; min-height: 0; }
.toolbox-body.cap-split .cap-stage-slot { flex: 1; }

/* Sized from the room the panel actually has, not from the viewport: the pinned footer
   and the head are already taken out of it by the grid. */
.toolbox-body.cap-split .cap-stage { height: 100%; max-height: 100%; }
.toolbox-body.cap-split .cap-config { min-width: 0; }
.toolbox-body.cap-split .cap-config .fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
/* A field that gets its own ROW but stays ONE COLUMN wide.
   The owner is moving these into a single left-hand column one at a time. Each one spans
   the grid so the field below it drops to a new row, and is then capped back to a column
   so every control still lines up: one column is (100% - gap) / 2, the gap is 10px, hence
   `calc(50% - 5px)` of the row it spans.
   The cap is on the FIELD, not the control, so it holds for a select, a colour well and a
   slider alike — the slider's value readout sits inside the field, so capping the control
   would have measured the track only and left the numbers hanging past the column. */
/* A stacked field takes its own row. It used to be capped at half the row as well, back
   when the panel was two columns and a full-width dropdown looked absurd; the panel is one
   column now and the column itself is the width, so the cap would just halve everything. */
.toolbox-body.cap-split .cap-config .fields .field.stack { grid-column: 1 / -1; }
@media (max-width: 900px) {
  .toolbox-body.cap-split { grid-template-columns: 1fr; }
  .toolbox-body.cap-split .cap-canvas { position: static; order: -1; }
}

/* Selection handles on the banner. Preview only — they are DOM, so nothing about them
   can reach the burned file. */
/* Hidden until the banner is selected. `display: none` rather than opacity, so an unselected
   banner has no invisible 9px targets sitting on its edges waiting to resize it. */
.cap-handle {
  display: none;
  position: absolute; width: 9px; height: 9px; box-sizing: border-box;
  background: #fff; border: 1px solid rgba(0,0,0,.55); border-radius: 2px;
  pointer-events: auto; touch-action: none;
}
.cap-banner.selected .cap-handle { display: block; }
/* Selected reads as selected even where the banner's own fill is white. */
.cap-banner.selected { outline: 1px solid rgba(255,255,255,.7); outline-offset: 6px; }
.cap-handle-nw { left: -5px; top: -5px; cursor: nwse-resize; }
.cap-handle-n  { left: 50%; top: -5px; margin-left: -5px; cursor: ns-resize; }
.cap-handle-ne { right: -5px; top: -5px; cursor: nesw-resize; }
.cap-handle-e  { right: -5px; top: 50%; margin-top: -5px; cursor: ew-resize; }
.cap-handle-se { right: -5px; bottom: -5px; cursor: nwse-resize; }
.cap-handle-s  { left: 50%; bottom: -5px; margin-left: -5px; cursor: ns-resize; }
.cap-handle-sw { left: -5px; bottom: -5px; cursor: nesw-resize; }
.cap-handle-w  { left: -5px; top: 50%; margin-top: -5px; cursor: ew-resize; }
/* The roundness handle: yellow, circular, sitting inside the top-left corner so it reads
   as belonging to the corner it changes. */
/* On the top border, centred on it, and moved only left and right — `left` is set from
   the current radius in paintOverlay, so the dot sits exactly where the curve ends. */
.cap-handle-radius {
  top: -6px; width: 11px; height: 11px; margin-left: -5px; border-radius: 50%;
  background: #FFD400; border: 1px solid rgba(0,0,0,.5); cursor: ew-resize;
}
.cap-banner { display: flex; align-items: center; justify-content: center; }

/* The banner's text is its own child so the handles and the caret can share the box. */
.cap-banner-text { pointer-events: auto; outline: none; white-space: pre-wrap; }
.cap-banner-text[contenteditable] { cursor: text; box-shadow: inset 0 0 0 1px rgba(255,255,255,.6); }

/* Captions / Banners tabs. Curved on the top corners only, sitting on the pane they
   open, so the selected one reads as continuous with its own controls. */
/* The strip spans both columns so its rule runs from the tabs across to the reel. The
   active tab is outlined in the accent and its underline is cut away, so the tab and the
   controls below it read as one surface. */
/* Hairlines: 0.5px, which a retina screen draws as one device pixel instead of two. */
.cap-tabs {
  grid-column: 1 / -1; display: flex; gap: 4px; align-items: flex-end;
  border-bottom: 0.5px solid var(--accent); margin-bottom: 14px;
}
.cap-tab {
  appearance: none; cursor: pointer; font: inherit; font-weight: 600;
  padding: 7px 18px; border-radius: 10px 10px 0 0; margin-bottom: -0.5px;
  border: 0.5px solid transparent; background: transparent; color: var(--muted, #999);
}
.cap-tab:hover { color: var(--text, #eee); }
.cap-tab.on {
  position: relative;
  color: var(--text, #eee);
  border-color: var(--accent);
  border-bottom-color: transparent;
}
/* The rule belongs to the strip, so it runs behind the active tab too and reads as a line
   under its label. Masked with the panel's own colour rather than by splitting the rule in
   two, so the tab sits open onto the controls below it at any tab width. */
.cap-tab.on::after {
  content: ''; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px;
  background: var(--panel);
}
.cap-pane { padding-top: 0; }
/* The switch and the follow chips share a row, so the spacing below belongs to the row
   rather than to the switch — a margin on the label would push it off centre. */
.cap-top { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-bottom: 12px; }
.cap-switch { margin-bottom: 0; }

/* Follow chips: bordered in the theme, filled with it once chosen, like a button.
   The radio itself is transparent rather than display:none — hiding it outright would
   take it out of the tab order and off the keyboard, and it is still the control. */
.chips { display: flex; gap: 8px; }
.chip { position: relative; display: inline-flex; }
/* Specific enough to beat the switch rule below, which matches any checkbox in this
   panel and would lay the input out as a 34x19 track: invisible at opacity 0, but still
   taking its space and padding the chip out. The captions chips never showed this because
   they are radios, and the switch rule only claims checkboxes. */
.chips .chip input[type="checkbox"],
.chip input { position: absolute; inset: 0; opacity: 0; margin: 0; pointer-events: none;
  width: auto; height: auto; background: none; border: 0; }
.chip span {
  padding: 6px 12px; border-radius: 6px; font-size: 12.5px; cursor: pointer;
  border: 1px solid var(--accent); color: var(--text); background: transparent;
  transition: background .14s ease, color .14s ease;
}
.chip:hover span { background: color-mix(in srgb, var(--accent) 14%, transparent); }
.chip input:checked + span {
  background: var(--accent); color: var(--on-accent); font-weight: 600;
}
.chip input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }

/* On/off switches in the Captions workspace.
   Scoped to this workspace rather than every checkbox in the app: a switch says "this is
   on or off, now", which is what these are, while a tick box says "include this" — and
   the app is full of the second kind. */
.cap-pane input[type="checkbox"],
.cap-group input[type="checkbox"] {
  appearance: none; -webkit-appearance: none;
  position: relative; flex: none; margin: 0;
  width: 34px; height: 19px; border-radius: 999px; box-sizing: border-box;
  /* The same accent hairline as the tabs, kept in BOTH states: off, the track is a dark
     grey on a dark panel and the control was easy to miss entirely. */
  background: var(--line); border: 0.5px solid var(--accent);
  cursor: pointer; transition: background .16s ease;
}
.cap-pane input[type="checkbox"]::after,
.cap-group input[type="checkbox"]::after {
  content: ''; position: absolute; top: 1.5px; left: 1.5px; box-sizing: border-box;
  width: 15px; height: 15px; border-radius: 50%;
  background: var(--panel); border: 0.5px solid var(--accent);
  box-shadow: 0 1px 2px rgba(0,0,0,.45);
  transition: transform .16s ease;
}
.cap-pane input[type="checkbox"]:checked,
.cap-group input[type="checkbox"]:checked {
  background: var(--accent); border-color: var(--accent);
}
.cap-pane input[type="checkbox"]:checked::after,
.cap-group input[type="checkbox"]:checked::after { transform: translateX(16px); background: #fff; }
.cap-pane input[type="checkbox"]:focus-visible,
.cap-group input[type="checkbox"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* The switch is the control, so its label should read as one line with it. */
.cap-switch { display: inline-flex; align-items: center; gap: 9px; font-weight: 600; }

/* A frozen workspace: it fits the panel and nothing in it scrolls vertically. The height
   has to be handed down every level — a break anywhere and the child sizes itself from
   its own content again, which is what made the format examples 486px tall in a panel
   that had 380 to give them. */
.toolbox-body.frozen { overflow: hidden; display: flex; flex-direction: column; }
.toolbox-body.frozen > .composer,
.toolbox-body.frozen .composer-body,
.toolbox-body.frozen .rail-block { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.toolbox-body.frozen .hrail { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.toolbox-body.frozen .hrail-track { flex: 1; min-height: 0; align-items: stretch; }
.toolbox-body.frozen .fmt-col { display: flex; flex-direction: column; min-height: 0; }
/* The card keeps its own height; only the example gives way. Without this the card
   absorbed the spare room and grew from 176px to 361px, squeezing the thing it is meant
   to be illustrating down to less than half. */
.toolbox-body.frozen .fmt-col > .fmt { flex: none; }
/* The figure is a column too: the video takes what is left AFTER its caption, or the
   caption is pushed off the bottom edge by the thing it describes. */
.toolbox-body.frozen .fmt-eg {
  flex: 1; min-height: 0; margin-bottom: 0;
  display: flex; flex-direction: column;
}
/* ⚠️ THE ASPECT RATIO SURVIVES HERE, AND IT DID NOT BEFORE.
   This read `flex: 1; object-fit: contain`, which let the flex line stretch the element's
   HEIGHT to fill the column while the width stayed at the card's 262px. Measured in the
   modal: a box of 262x496, a ratio of 0.528 against a reel's 0.5625 — so every clip was
   letterboxed, and `border-radius: 12px` was faithfully rounding the black bar instead of
   the reel. The corners have been there since the row was built; nothing could see them.

   So the video keeps its own 9:16 and gives way by SHRINKING (`max-height`) rather than by
   stretching, and `margin: auto 0` centres it in whatever room the column has left. The
   original intent — the example yields, the card above it does not — is unchanged. */
/* WIDTH drives, HEIGHT follows, and `max-height` is the safety rather than the ruler.
   Whichever axis drives, the OTHER one has to be free, or the clamp on it beats `aspect-ratio`
   and the box stops being a reel. Both mistakes have now been measured here: driving from the
   width while the cards were tall gave 262x440 (0.595, clamped by max-height), and driving from
   the height once the cards were short gave 262x532 (0.492, clamped by max-width). With the
   cards at 95px there is room for the full 262x466, so the width drives and nothing clamps. If a
   short window ever does bind `max-height`, `object-fit: cover` means the clip crops rather than
   letterboxes, so the corners keep rounding picture either way. */
.toolbox-body.frozen .fmt-eg video {
  flex: 0 1 auto; min-height: 0;
  width: 100%; height: auto; aspect-ratio: 9 / 16; max-height: 100%;
  object-fit: cover;
}
.toolbox-body.frozen .fmt-eg figcaption { flex: none; }

/* A pinned head inside a scrolling workspace: the selection cards and the filters stay
   put while the library scrolls under them.
   The -26px is the same measured offset the filter row used on its own — this engine
   pins a sticky box against the scrollport's CONTENT box, so top:0 parks it 18px down and
   leaves a strip for tiles to show through. The negative margin cancels the body's
   padding and the matching padding puts the spacing back inside the block. */
.toolbox-body .pinhead {
  position: sticky; top: -26px; z-index: 6;
  background: var(--panel);
  margin: -26px -22px 10px; padding: 26px 22px 10px;
}
/* The filter row inside a pinned head must NOT pin itself as well: it would pull itself
   up over the cards above it and cover them. */
.toolbox-body .pinhead .actor-filters {
  position: static; margin: 0; padding: 0; background: none;
}

/* The remove cross on a chosen-asset card: same manners as the one on a library tile —
   top right, on hover only. The card itself is no longer clickable, so this is the only
   thing on it that does anything, and it says what it does. */
.seat { position: relative; }
.seat .tile-del { width: 22px; height: 22px; font-size: 12px; top: 6px; right: 6px; }
.seat:hover .tile-del { opacity: 1; }
.seat .tile-del:hover { background: var(--accent); color: var(--on-accent); }

/* The three microphone cards under the topic box, in a row with room to breathe. */
.mic-pick { margin-top: 18px; }
.mic-row { display: flex; gap: 14px; align-items: stretch; flex-wrap: wrap; }
.mic-card {
  /* relative so the garment's remove cross has something to sit in */
  position: relative;
  flex: 1 1 0; min-width: 150px;
  display: flex; align-items: center; gap: 12px; text-align: left;
  padding: 12px 14px; border-radius: 14px;
  background: var(--panel-2); border: 2px solid var(--line); color: var(--text);
  font-weight: 500; cursor: pointer;
}
/* A chosen garment is a div, not a button: it is already chosen, and its only control is
   the cross. Default the cursor back, or it advertises a click that does nothing. */
div.mic-card { cursor: default; }

/* THE CROSS THAT TAKES A GARMENT OFF.
   Always visible, unlike .tile-del in the library, and for the opposite reason: that one
   hides because a grid of fifty should be objects rather than fifty delete buttons. There
   is one of these per person. A card whose only way out appears on hover reads as a card
   with no way out. Room is made for it in the padding so a long description cannot run
   underneath it. */
.mic-card:has(.mic-del) { padding-right: 40px; }
.mic-del {
  position: absolute; top: 10px; right: 10px; z-index: 2;
  width: 22px; height: 22px; padding: 0; border: 0; border-radius: 999px;
  background: rgba(8, 10, 14, .78); color: #fff; font-size: 12px; line-height: 1;
  display: flex; align-items: center; justify-content: center; cursor: pointer;
  transition: background .12s ease;
}
.mic-del:hover { background: var(--bad); }
.mic-card:hover { border-color: var(--muted); }
.mic-card.on { border-color: var(--accent); }
.mic-shot {
  flex: none; width: 46px; height: 46px; border-radius: 10px; overflow: hidden;
  background: var(--panel); display: flex; align-items: center; justify-content: center;
}
.mic-shot img { width: 100%; height: 100%; object-fit: cover; }
.mic-plus { font-size: 22px; color: var(--muted); line-height: 1; }
.mic-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.mic-text .note { font-weight: 400; }

/* The third card, while its brand picker is open. A dotted edge rather than the accent,
   which means "this one is in the reel" everywhere else in the row and would be a lie on a
   card you have only just opened. */
.mic-card.open { border-style: dashed; border-color: var(--muted); }

/* Only there when a branded mic is in the reel, so the row does not carry a control for
   something that has not happened. Matches the garment's clear, one row lower. */
.mic-clear { margin: 10px 0 0; }
.mic-clear .linkish { margin-left: 4px; }

/* The brand assets, opened underneath the row on this same step. */
.mic-brand-pick {
  margin-top: 10px; padding: 12px 14px; border-radius: 14px;
  background: var(--panel); border: 1px solid var(--line);
}
.mic-brand-pick .note.tight { margin: 0 0 10px; }
/* auto-fill, so one asset does not stretch to the full width of the column and read as a
   banner. Tiles keep their size and the row fills from the left. */
.mic-brand-grid {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); gap: 10px;
}
.mic-brand-tile {
  display: flex; flex-direction: column; gap: 6px; padding: 6px;
  border-radius: 10px; background: var(--panel-2);
  border: 2px solid transparent; color: var(--text); cursor: pointer;
  font-size: 12px; text-align: center;
}
.mic-brand-tile:hover { border-color: var(--muted); }
.mic-brand-tile.on { border-color: var(--accent); }
.mic-brand-tile img {
  width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 6px; display: block;
  background: var(--panel);
}
/* The label wraps rather than being clipped: "Brand mic" and "Candle" are how you tell
   which object this is, so a truncated one defeats the picker. */
.mic-brand-tile span { line-height: 1.25; word-break: break-word; }

/* A slider with its value beside it. The number is the point of the control — a slider
   alone says "bigger" and never says how big — so it sits on the right, in a fixed-width
   column so the track does not shift as the digits change. */
.slider { display: flex; align-items: center; gap: 10px; }
/* Drawn by us, not by the browser.
   With the native control the unfilled half of the track stays a pale grey whatever the
   theme is — there is no CSS border to change, it is Chrome's own rendering, and
   accent-color only reaches the FILLED part. So the track and the thumb are ours: the
   filled length comes from --fill, set beside the readout as the thumb moves. */
.slider input[type="range"] {
  flex: 1; min-width: 0; margin: 0; padding: 0; border: 0;
  appearance: none; -webkit-appearance: none; background: none; height: 18px; cursor: pointer;
}
.slider input[type="range"]::-webkit-slider-runnable-track {
  height: 6px; border-radius: 999px;
  background:
    linear-gradient(var(--accent), var(--accent)) left center / var(--fill, 0%) 100% no-repeat,
    var(--line);
}
.slider input[type="range"]::-moz-range-track {
  height: 6px; border-radius: 999px;
  background:
    linear-gradient(var(--accent), var(--accent)) left center / var(--fill, 0%) 100% no-repeat,
    var(--line);
}
.slider input[type="range"]::-webkit-slider-thumb {
  appearance: none; -webkit-appearance: none;
  width: 15px; height: 15px; border-radius: 50%; margin-top: -4.5px;
  background: var(--accent); border: 2px solid var(--panel);
  box-shadow: 0 1px 3px rgba(0, 0, 0, .5);
}
.slider input[type="range"]::-moz-range-thumb {
  width: 15px; height: 15px; border-radius: 50%;
  background: var(--accent); border: 2px solid var(--panel);
  box-shadow: 0 1px 3px rgba(0, 0, 0, .5);
}
.slider input[type="range"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
/* The colour slider: the track IS the choice, so it shows the spectrum rather than a
   filled length. The thumb wears the colour under it, and carries a pale ring so that
   white at one end of the ramp and black at the other are both still visible. */
.slider.colour input[type="range"]::-webkit-slider-runnable-track { background: var(--ramp); }
.slider.colour input[type="range"]::-moz-range-track { background: var(--ramp); }
.slider.colour input[type="range"]::-webkit-slider-thumb {
  background: var(--thumb); border-color: var(--panel);
  box-shadow: 0 0 0 1px rgba(255, 255, 255, .55), 0 1px 3px rgba(0, 0, 0, .5);
}
.slider.colour input[type="range"]::-moz-range-thumb {
  background: var(--thumb); border-color: var(--panel);
  box-shadow: 0 0 0 1px rgba(255, 255, 255, .55), 0 1px 3px rgba(0, 0, 0, .5);
}
/* The readout is TYPED INTO, not just read. It stays quiet until you go near it: a box
   drawn around every value would turn a row of sliders into a form.

   SCOPED TO .slider ON PURPOSE. This used to be an <output>, and as a bare `.slider-val`
   it was fine. Making it an <input> pulled it under `.field input { width: 100% }` far
   above, which outranks a single class — so the readout took the whole row and the range
   beside it, with min-width:0, was squeezed to nothing. Changing an element's TAG can
   move it under selectors that never matched it before. */
.slider .slider-val {
  flex: none; width: 62px; text-align: right; box-sizing: border-box;
  font-variant-numeric: tabular-nums; color: var(--muted); font-size: 13px;
  background: transparent; border: 1px solid transparent; border-radius: 4px;
  padding: 2px 5px; cursor: text;
}
.slider .slider-val:hover { border-color: var(--line); }
.slider .slider-val:focus {
  border-color: var(--accent); color: var(--text);
  background: var(--panel-2); outline: none;
}
/* A hex is seven characters, wider than the numbers the readout was sized for. */
.slider .slider-val.hex { width: 84px; }

/* Who is wearing the garment. Chips, the same control as the banner tabs, because it is
   the same question: none, one, or both, and not exclusive. */
/* One block per person. The gap is bigger than the gap BETWEEN cards on purpose: the
   rows have to read as two separate people, not one long strip of clothing. */
.garment-person { margin-top: 12px; }
.garment-person + .garment-person { margin-top: 18px; }
.garment-who { display: block; margin-bottom: 6px; }
/* The options sit under the row that opened them, indented and dimmer, so it is obvious
   which person they are about: an unindented second row of shirts under two identical
   rows is exactly how you dress the wrong one. */
.garment-options { margin-top: 8px; padding-left: 14px; border-left: 2px solid var(--line, #333); }
.linkish {
  background: none; border: 0; padding: 0 2px; color: var(--muted);
  font-size: 12px; text-decoration: underline; cursor: pointer;
}
.linkish:hover { color: var(--text); }

/* The Assets STEP borrows the library's split: the grid on the left, and on the right the
   one column that says what this reel is carrying — its mic, its clothing, its props. */
.step-assets .assets-props .pinhead { padding-top: 0; }
.step-assets .assets-brand .mic-pick { margin-bottom: 18px; }
/* Stacked, not side by side: this column is a third of the width and three mic cards in
   a row here would each be too narrow to read. .mic-row is flex, so it is the direction
   that changes — an earlier draft of this line set grid-template-columns on it, which is
   a property flex does not have and would have done nothing at all. */
.step-assets .assets-brand .mic-row { flex-direction: column; gap: 8px; }
.step-assets .assets-brand .seats { flex-direction: column; }
/* What each prop is doing. A label and a line, not a paragraph: the sentence that
   reaches the render is short by design. */
/* THE PURPOSE BELONGS TO THE CARD, not to a list under the column.
   It used to render as a separate block of labelled inputs below every seat, so with
   three props in shot you read the names twice — once on the cards, once as labels — and
   had to match them up by eye. Attached, the card IS the label. */
.seat-item { display: flex; flex-direction: column; gap: 0; }
.seat-item .seat { border-bottom-left-radius: 0; border-bottom-right-radius: 0; border-bottom: 0; }
.prop-purpose {
  width: 100%; padding: 9px 14px;
  border: 2px solid var(--accent); border-top: 1px solid var(--line);
  border-radius: 0 0 14px 14px;
  background: var(--panel); color: var(--text); font: inherit; font-size: 13px;
}
.prop-purpose::placeholder { color: var(--muted); }
.prop-purpose:focus { outline: none; border-color: var(--accent); background: var(--panel-2); }

/* A card that is a sentence, not a control: it says where to click, and the clicking
   happens in the grid. Dashed because that is what an unfilled slot looks like everywhere
   else here, and the cursor goes back to default so it never advertises a press. */
/* Dashed, but it still lights on hover like every other card in this column. The hover
   was suppressed here on the reasoning that a card you cannot press should not react —
   owner's call to put it back (2026-08-26), and the consistency wins: a single card that
   stays dead under the pointer reads as broken, not as informational. Inherited from
   .seat:hover rather than restated, so it cannot drift from the cards it matches. */
.seat-hint { border-style: dashed; }
/* ARMED: the card stays lit once clicked, instead of dropping back the moment the pointer
   leaves for the grid. The whole point of this state is that you look somewhere else while
   it is on, so a border that only survives hover would go out exactly when it is needed. */
.seat-hint.armed { border-color: var(--muted); }

/* Every tile is a target, said in the same dashed language as the card that armed it.
   An OUTLINE, not a border: outlines are drawn outside the box and shift nothing, so
   fifty-six of them appear without the grid reflowing under the pointer.
   `:not(.cast-here)` leaves the ones already in the reel wearing their accent ring — they
   are not targets in the same sense, and overwriting it would lose the only mark that
   says what is already in shot. */
.actor-grid.arming .actor:not(.cast-here) img,
.actor-grid.arming .actor:not(.cast-here) .actor-ph {
  outline: 2px dashed var(--muted); outline-offset: -2px;
}
/* ARMED MEANS THE WHOLE TILE ADDS, so the Enlarge pill stands down while it is on.
   That pill sits dead centre of every picture at z-index 2, which is the first place a
   pointer lands — so ringing fifty-six tiles as targets while the middle of each one
   opened a viewer instead would make the ring a lie exactly where it is most believed.
   Enlarge is untouched when the grid is not armed. */
.actor-grid.arming .loc-enlarge { display: none; }
/* The plus matches the other plus signs in this column (.mic-plus on the mic and garment
   cards): muted, regular weight. Scoped to the hint rather than changed on .none-ph,
   because that class is shared with the tick on "Nothing in shot", where the accent is
   what says "this is the choice in force" — recolouring it at source would take the one
   colour that carries the tick's meaning. */
.seat-hint .none-ph { color: var(--muted); font-weight: 400; }


/* A caption word on the reel is editable in place: double-click it, the same gesture the
   banner label uses. The hint only appears on hover so a reel being watched is not covered in
   editing chrome — the same rule the banner handles follow. */
.cap-w { cursor: text; border-radius: 4px; }
.cap-line:hover .cap-w:hover { background: rgba(255,255,255,.14); }
.cap-w[contenteditable] { outline: 2px solid var(--accent); outline-offset: 2px;
  background: rgba(0,0,0,.35); cursor: text; }

/* A clip the spend guard has stopped. Yellow rather than the accent: it is not an action you
   were looking for, it is an explanation of why the button you wanted is not there. */
.clip-tile-acts .warn-btn { background: transparent; border-color: var(--warn); color: var(--warn); }

/* ---------------------------------------------------------------- new-reel tile + folder filter
   The tile is a `.reel-card` so it takes the grid's 9:16 and sits in the flow with the reels
   rather than beside them: it should read as the next frame, the empty one, not as a control
   bolted to the grid. Dashed and quiet so a grid of real reels still reads as reels. */
.reel-card.reel-add {
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px;
  border-style: dashed; background: transparent; color: var(--muted);
  cursor: pointer; font: inherit; text-align: center; padding: 12px;
}
.reel-card.reel-add:hover { border-color: var(--accent); color: var(--fg); background: var(--panel); }
.reel-add-plus { font-size: 38px; line-height: 1; font-weight: 300; }
.reel-add-label { font-size: 13px; max-width: 90%; }


/* The selected folder is marked in the menu as well as in the grid: with the workspace
   filtered, the menu is the only thing that says WHICH folder it is filtered to. */
.nav-folder-head.picked { color: var(--fg); }
.nav-folder-head.picked .fname { text-decoration: underline; text-underline-offset: 3px; }

/* The unfiled row has no drag handle: it is not a real folder, it has no stored position, and it
   is always rendered last. It still needs the handle's WIDTH or its name would sit left of every
   folder above it, so the span stays as a spacer.

   ⚠️ A CLASS ON THE SPACER, NOT A DESCENDANT SELECTOR ON THE ROW. This was
   `.nav-folder[data-folder-item="__unfiled"] .grip`, which matches `.grip` at ANY depth — so it
   also hid the grip of every REEL inside the row. `visibility: hidden` removes an element from
   hit-testing as well as from view, so those reels could not be picked up at all: the owner
   moved one into "No folder" and then could not move it back out. Reported as "any reels inside
   of it can't be dragged", and it was one CSS selector reaching further than it was aimed. */
.grip-spacer { visibility: hidden; }

/* A folder lit up as a destination for a reel being dragged onto it. Not an insertion line:
   dropping on a folder chooses WHERE it goes, not what it sits next to. */
.drop-into { outline: 1px dashed var(--accent); outline-offset: 2px; border-radius: 8px;
  background: color-mix(in srgb, var(--accent) 12%, transparent); }

/* The tip sits between the Approve button and the right edge, centred in what is left, so it
   reads as the bar's content rather than as a label hanging off the button. `flex: 1` gives it
   the leftover width; `min-width: 0` lets it shrink instead of pushing the button off. */
.signoff-tip {
  flex: 1; min-width: 0; text-align: center;
  color: var(--text); font-size: 13px; line-height: 1.45;
  padding: 0 12px;
  opacity: 1; transition: opacity .26s ease;
}
.signoff-tip.fading { opacity: 0; }
/* Reduced motion: no cross-fade, the text simply changes. */
@media (prefers-reduced-motion: reduce) {
  .signoff-tip { transition: none; }
  .signoff-tip.fading { opacity: 1; }
}

/* The label carries the accent so the eye finds it, and the tip itself stays plain white:
   the advice is the message, "TIP:" is only the signpost. */
.tip-label { color: var(--accent); font-weight: 700; letter-spacing: .04em; margin-right: 4px; }

/* The burn tip replaces a paragraph, so it needs the paragraph's width and centring rather
   than the sign-off bar's flex behaviour. */
.burn-tip { margin: 10px 0 0; text-align: center; }
.burn-tip .signoff-tip { display: inline; flex: none; padding: 0; }

/* Laptop or mobile, on the Locations step of a Digital product showcase: two seats where the
   "Where it happens" seat normally sits, so the choice is made in the place it governs. */
.device-pick { display: flex; gap: 8px; margin: 0 0 10px; }
.device-pick .device-card { flex: 1 1 0; text-align: left; }
.seat.device-seat { cursor: pointer; font: inherit; color: inherit; text-align: left; }
.seat.device-seat:hover { border-color: currentColor; }
.seat.device-seat[aria-pressed="true"] { border-color: currentColor; }

/* ------------------------------------------------------------------ sign-in

   THE FIRST SCREEN OF THE PRODUCT, and the only one drawn before any data exists.

   It covers everything at z-index 200 — above the lightbox and the dialog — because it is
   not a panel over the app, it is what stands in place of the app. The app shell behind it
   is left in the DOM rather than torn down: an unauthenticated boot never fetches anything,
   so there is nothing behind it to leak, and keeping it means signing in does not have to
   rebuild the page from nothing. */
.signin-back {
  position: fixed; inset: 0; z-index: 200;
  background: var(--bg);
  display: flex; align-items: center; justify-content: center;
  padding: 24px;
  overflow: auto;
}
/* ⚠️ THE SAME TRAP THE LIGHTBOX HIT, and this file already carried the note about it: a class
   setting `display` beats the browser's own [hidden] { display: none }, so `el.hidden = true`
   silently does nothing. Missing it here meant the sign-in screen covered the app permanently
   with authentication switched OFF — a total local outage from one line of CSS. */
.signin-back[hidden] { display: none; }
.signin {
  width: 100%; max-width: 340px;
  display: flex; flex-direction: column; gap: 14px;
}
/* The same two-layer lockup as the top bar, so the accent the customer chose is the accent
   that greets them. Static here rather than fixed: this one belongs to the card. */
.signin-mark {
  position: relative; width: 132px; height: calc(132px * 204 / 371);
  background: url("/logo-base.png") center / contain no-repeat;
  margin-bottom: 4px;
}
.signin-mark::after {
  content: ''; position: absolute; inset: 0; background: var(--accent);
  -webkit-mask: url("/logo-tint.png") center / contain no-repeat;
  mask: url("/logo-tint.png") center / contain no-repeat;
}
.signin h1 { font-size: 21px; margin: 0; letter-spacing: -0.01em; }
.signin .note { margin: -8px 0 2px; }

.signin-social { display: flex; flex-direction: column; gap: 8px; }
.signin-social button {
  display: flex; align-items: center; justify-content: center; gap: 9px;
  width: 100%; padding: 10px 12px; font-size: 13px; font-weight: 600;
  background: var(--panel-2); color: var(--text);
  border: 1px solid var(--line); border-radius: 9px; cursor: pointer;
}
.signin-social button:hover { background: var(--panel); border-color: var(--muted); }
.signin-social svg { width: 17px; height: 17px; flex: none; }

/* A rule with the word sitting in it, rather than two rules and a floating label: one
   element, so the line and the word cannot drift apart at different widths. */
.signin-or {
  display: flex; align-items: center; gap: 10px;
  color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .08em;
}
.signin-or::before, .signin-or::after {
  content: ''; flex: 1; height: 1px; background: var(--line);
}

.signin form { display: flex; flex-direction: column; gap: 10px; }
.signin-field { display: flex; flex-direction: column; gap: 5px; }
.signin-field span { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; }
.signin-field input {
  padding: 10px 11px; font-size: 14px; font-family: inherit;
  background: var(--panel); color: var(--text);
  border: 1px solid var(--line); border-radius: 9px;
}
.signin-field input:focus { outline: 2px solid var(--accent); outline-offset: 1px; border-color: transparent; }
.signin button[type="submit"] {
  margin-top: 2px; padding: 11px 12px; font-size: 13px; font-weight: 700;
  background: var(--accent); color: var(--on-accent);
  border: 0; border-radius: 9px; cursor: pointer;
}
.signin button[type="submit"][disabled] { opacity: .55; cursor: default; }

/* Refusals are red and confirmations are green, and both are the same shape, because the
   customer reads the colour before the words. */
.signin-error, .signin-said {
  margin: 0; font-size: 12px; line-height: 1.45;
  padding: 8px 10px; border-radius: 8px;
}
.signin-error { color: #ff9f96; background: var(--chip-live-bg); border: 1px solid var(--chip-live-line); }
.signin-said { color: #9edeb0; background: var(--chip-mock-bg); border: 1px solid var(--chip-mock-line); }

.signin-feet { display: flex; justify-content: space-between; gap: 12px; }
.linky {
  background: none; border: 0; padding: 0; cursor: pointer;
  color: var(--muted); font-size: 12px; font-family: inherit; text-decoration: underline;
  text-underline-offset: 3px;
}
.linky:hover { color: var(--text); }

/* Who is signed in, at the foot of the menu. Two lines, small, and out of the way: it is
   reference rather than a control, apart from the one word that is. */
.nav-account { display: flex; flex-direction: column; gap: 2px; padding: 8px 2px 2px; }
.nav-account[hidden] { display: none; }  /* see .signin-back[hidden] */
.nav-account-who {
  font-size: 11px; color: var(--muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.nav-account .linky { font-size: 11px; align-self: flex-start; }

/**
 * PRICED SPEND BUTTONS ARE ONE WIDTH.
 *
 * Owner, 2026-09-11: *"make the pricing buttons throughout the app a standard width, so when they
 * get pressed and get the render animation their size doesn't increase or shrink."*
 *
 * ⚠️ THE JITTER IS CAUSED BY THE LABEL, NOT THE ANIMATION. These buttons swap their text when they
 * start working — "Render all clips · $3.20" becomes "Rendering…", "Draft 2 anchors · $0.80"
 * becomes "Drafting…" — and a button sized by its content therefore shrinks by half under the
 * pointer at the exact moment the stripes start travelling. The movement reads as the app
 * flinching, which is the opposite of what the stripes are there to say.
 *
 * A `min-width` rather than a fixed `width`: the longest label wins if one ever exceeds this, so a
 * future price or a longer verb can never be clipped. `justify-content: center` keeps the shorter
 * working label in the middle of the reserved space instead of ragged against the left edge.
 *
 * ⚠️ ONLY `.priced`. The class is on the buttons that display a price, which are the primary
 * actions on a step. The other `.spends` buttons — "Fix…", the strip's ↻, "Add to the library" —
 * sit inline beside other controls, and 15rem of reserved space around a three-letter label would
 * look broken rather than steady.
 */
button.spends.priced {
  min-width: 15rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* A label that changes length must not also change height: two lines would move everything
     below the button, which is a worse flinch than the one this rule removes. */
  white-space: nowrap;
}
