/* ReelView — one stylesheet, no framework. Light app shell, dark video surfaces.
   Two scenes drove that split: uploading happens on a phone in Australian sun (forces light,
   high contrast); reviewing sixteen clips happens at a desk at night (forces dark around video,
   which is why every NLE is dark). Theme is per-surface, not global. */

/* Metric-matched fallback for Rethink Sans: the webfont loads via display=swap, and while it
   is in flight the system font draws the page. Segoe UI is ~6% narrower than Rethink at the
   same size, so text that fits one line in the brand font wraps to two lines in the fallback —
   the /new photo tips shift the whole page ~43px the moment the font lands (~1s after refresh
   on a slow link). Scaling the fallback to Rethink's advance width (measured 1.0606:1, 15px,
   see metrics note below) makes every line wrap identically, so the swap is invisible.
   The family name is distinct so the webfont (also named "Rethink Sans") wins the stack. */
@font-face {
  font-family: "Rethink Sans Fallback";
  src: local("Segoe UI"), local("system-ui"), local("Arial"), local("Helvetica Neue"), local("Liberation Sans"), local(sans-serif);
  size-adjust: 106.06%;
}

:root {
  color-scheme: light;

  --bg:        oklch(0.985 0.003 70);
  --surface:   oklch(1 0 0);
  --surface-2: oklch(0.965 0.004 70);
  --line:      oklch(0.90 0.005 70);
  /* 0.65, not the 0.82 this started at: --line-firm is the entire visible boundary of every
     quiet button, input and icon button, and at 0.82 it sat at 1.7:1 — under WCAG 1.4.11's 3:1
     for perceiving a control at all. --line stays soft: a row divider is decoration, not a
     control, and 1.4.11 does not reach it. */
  --line-firm: oklch(0.65 0.006 70);

  --ink:       oklch(0.24 0.008 70);
  --ink-2:     oklch(0.47 0.008 70);
  /* Both How-to reds moved together, Dean, 2026-09-05, and the pair is the point: this rest
     value only works because the hover went to pure red in the same change.

     The problem was the dark theme, where --bg is oklch(0.15 0.006 70). At the original 0.42
     the resting body measured 2.15:1 and read as mud; lifting it to 0.47 (#a0282a) got 2.66:1,
     better but still under the 3:1 WCAG 1.4.11 asks. Lifting it further on its own would have
     closed the gap to the old #d02525 hover until the two states stopped reading as different.

     Dean's fix was to raise the ceiling instead. Hover went to pure #ff0000 first, which is
     literally YouTube's own brand red, and that freed the rest state to come up to 0.500 --
     3.04:1, over the line. But pure red read as too bright on the page, so the hover settled
     at #db3834, oklch(0.590 0.200 27.0). The glare was chroma rather than lightness -- pure
     red's 0.258 is a long way past anything else in this palette -- so the walk down went
     0.258 -> 0.215 (#e63331) -> 0.200, with lightness barely moving at each step. It measures
     4.34:1 on dark --bg against the old 3.72, and the rest-to-hover lightness step is 0.090
     against the 0.134 the original pair had, which is the thing to watch if it goes lower:
     below this the hover stops announcing itself as a state change.

     Light theme was never the constraint and comes out fine: rest 7.09 -> 6.21, hover
     5.06 -> 4.34. Knocking the red back helped here rather than hurting -- pure red had taken
     light theme to 3.83, the worst number anywhere in this change. Note the two themes recede in
     different ways -- on light the resting red is quieter because it is less saturated than
     the hover, on dark because it is darker -- which is why one token can serve both here but
     would not survive being pushed much further in either direction. */
  --howto-red: oklch(0.500 0.150 24.8);  /* #a83635; the How-to icon at rest, #db3834 is its hover */
  --howto-play: oklch(0.80 0.004 70);    /* its play triangle at rest; #f0eeec is its hover */
  /* 0.54 clears 4.5:1 even on --surface-2 (hovered rows, the icon button). At the 0.62 it was,
     "Queued" and every hint missed AA at 0.875rem — too small for the large-text exemption. */
  --ink-3:     oklch(0.54 0.007 70);
  /* Placeholder text only, step-level quieter than the --ink-3 floor. Under 4.5:1 on purpose —
     Dean, 2026-08-05: "less prominent". A placeholder is a sample of what goes in the field, not
     the label; the label sits above it. Never use for real content. */
  --ink-4:     oklch(0.62 0.005 70);

  /* Gold, not purple. Purple-on-white is the AI-tool reflex and this product's credibility
     rests on restraint about what the AI did. Actions and selection only, never decoration.
     --accent is #fac800 exactly, the same gold every button's hover tint already used (see
     rgba(250,200,0,…) below) — Dean, 2026-08-03. --accent-in is a darker sibling built for the
     one job the literal hex can't do itself: #fac800 is only 1.5:1 on white, so it cannot be
     text; --accent-in measures 6.5:1. */
  --accent:    oklch(0.852 0.174 90.2);      /* = #fac800 */
  --accent-in: oklch(0.47 0.13 90.2);
  /* Text ON a filled --accent. Used to be near-white, because the old fill sat mid-lightness;
     #fac800 is L 0.85, and white text on it is 1.6:1. Dark ink instead — fixed, not var(--ink),
     because --accent itself does not move between themes (below) and what sits on it shouldn't
     either. 10.4:1 either theme. */
  --on-accent: oklch(0.24 0.008 70);
  /* The accent when it is TEXT rather than a fill — links, .status.act, the pack tag, the role
     badge. --accent-in serves that job on light, --accent-ink below overrides it on dark: a fill
     can stay the same bright gold in both themes, but text needs to go dark on a light ground and
     light on a dark one, so the two obligations split into two tokens the same way --line and
     --line-firm do. */
  --accent-ink: var(--accent-in);

  /* The ground behind an AGENCY's own artwork — a logo we did not draw and cannot recolour.
     It stays light in both themes, because the overwhelming majority of agency marks are dark
     ink on transparent and a dark plate would erase them. Same reasoning as the QR frame's #fff:
     when we do not control what sits on a surface, the surface does not get to move. The one
     exception is `.agency-logo` (spec HN): a flat plate cannot serve both a dark-ink mark and
     ReelView's own gold-on-transparent mark, so that one spot trades the flat plate for a
     theme-aware checkerboard (`--plate-check-a/b` below) instead of moving `--plate` itself. */
  --plate: var(--surface-2);

  /* Checkerboard for `.agency-logo` only (spec HN) — a transparency indicator, not a flat
     colour, behind a mark we did not draw. HN held this pair to WCAG 1.4.11 (non-text contrast):
     `-a` (white) and `-b` sat far apart on purpose so every mark type cleared 3:1 against at
     least one of them — gold read 1.6:1 on `-a` but 3.1:1 on `-b`. SC 1.4.11 carries an explicit
     logotype exception ("part of a logo or brand name has no minimum contrast requirement"), and
     an agency's own mark on /entity is exactly that — the bar HN held was self-imposed, not
     required. Dean asked for the pattern softer and supplied the standard editor transparency
     grid (white / #ccc) as the reference (spec HS, 2026-09-04); `-b` now trades the
     contrast-maximised anchor for that standard, and the per-mark guarantee above no longer
     holds — see the dark block's comment below for its mirror. */
  --plate-check-a: oklch(1 0 0);
  --plate-check-b: oklch(0.84 0.003 70);

  --ok:        oklch(0.48 0.09 150);
  --warn:      oklch(0.52 0.11 62);
  /* Destructive only — removing someone, and nothing else. Same lightness as --warn so it reads
     as part of this palette and still clears 4.5:1 on white. */
  --bad:       oklch(0.52 0.19 27);
  --skip:      #a0282a;
  --bad-bg:    oklch(0.96 0.02 27);

  --radius: 8px;
  --pad: 1rem;
  --ease: cubic-bezier(0.22, 1, 0.36, 1);

  /* Icon-swap motion, after the transitions.dev pattern of the same name: the outgoing icon
     scales down and blurs out while the incoming one does the reverse, both in one grid cell.
     Its `will-change` is deliberately not carried over — leaving that hint on an element that
     sits at rest on every page is the exact overuse an audit flags, and a blur on an 18px box
     needs no help. */
  --icon-swap-dur: 250ms;
  --icon-swap-blur: 2px;
  --icon-swap-start-scale: 0.25;
  --icon-swap-ease: ease-in-out;
}

/* --- dark ----------------------------------------------------------------
   ONE override block, keyed on an attribute the head script stamps before first paint. The OS
   preference is deliberately NOT queried here: the script resolves it and any stored choice into
   that same attribute, so these values live in exactly one place. A media query beside this block
   would be a second copy of the same list, and this stylesheet has already paid for that mistake
   once, with two tokens for one measure (see --measure). test_theme.py fails if one appears.
   With scripting off nothing is stamped and the page is light, which is what shipped before.

   The lightness ramp is not the light one flipped. --bg sits at 0.15 rather than mirroring 0.985,
   because the footage grounds are hard-coded at 0.20 and 0.22 (they are per-surface and do not
   move) — a page at 0.20 would swallow the review stage and the hero, and "footage sits on its
   own ground" would stop being visibly true. At 0.15 they still read as panels lifted off it.

   Every pair below was measured, not chosen. On --bg: ink 17.0, ink-2 9.8, ink-3 7.4; on the
   worst ground in the system (--surface-2, the hover fill) ink-3 still holds 6.2, well past the
   4.6 it manages on light. Dark grounds are cheap for contrast — the constraint that actually
   binds here is the accent, which is why --accent-ink exists. */
:root[data-theme=dark] {
  color-scheme: dark;

  --bg:        oklch(0.15 0.006 70);
  --surface:   oklch(0.19 0.006 70);
  --surface-2: oklch(0.24 0.007 70);
  --line:      oklch(0.31 0.006 70);
  /* 4.7:1 on --surface, against the 3:1 WCAG 1.4.11 asks of a control's boundary. Held well
     above the floor rather than at it: on a dark ground a hairline is the first thing lost to a
     phone's auto-brightness in the sun, which is the same scene that set 0.65 on light. */
  --line-firm: oklch(0.60 0.008 70);

  --ink:       oklch(0.95 0.004 70);
  --ink-2:     oklch(0.78 0.006 70);
  --ink-3:     oklch(0.70 0.006 70);
  /* Placeholder text only (pair of the light theme's --ink-4): quieter than the --ink-3 floor. */
  --ink-4:     oklch(0.62 0.006 70);

  /* --accent and --accent-in do NOT move. The gold is the one thing an agent recognises across
     both rooms, and --on-accent has the same job whatever is behind it — 10.4:1 either way,
     since it is now fixed rather than theme-linked (see the light block above). Hover still
     deepens rather than lifting, so "hover deepens the fill" stays literally true in both themes
     instead of becoming a per-theme special case.
     What cannot stay is --accent-in as TEXT: it is 2.9:1 on a 0.15 ground, under AA. */
  --accent-ink: oklch(0.72 0.13 90.2);        /* 7.9 on --bg, 6.6 on --surface-2 */

  /* Lifted to clear 4.5:1 on every ground, and kept at one lightness across the three so they
     still read as one family — the same rule the light set follows at 0.48/0.52/0.52. */
  --ok:        oklch(0.74 0.14 150);
  --warn:      oklch(0.78 0.13 62);
  --bad:       oklch(0.68 0.17 27);
  --skip:      oklch(0.68 0.17 27);
  --bad-bg:    oklch(0.28 0.06 27);

  --plate:     oklch(0.93 0.003 70);

  /* Dark theme's `.agency-logo` checkerboard (spec HN, softened spec HS 2026-09-04) -- see the
     light block's comment above for the contrast trade HN made and why the logotype exception
     means it no longer needs to hold. `-a` stays this theme's own identity tone (near-black,
     serving gold well: 11.2:1) -- Dean asked for "a darker one for dark theme" explicitly and
     that has not changed. `-b` is now theme-linked instead of a shared contrast anchor: each
     theme carries its own step from `-a` toward the standard editor grid, mirroring light's
     `0.55 → 0.84` with a `0.55 → 0.32` darkening here. */
  --plate-check-a: oklch(0.21 0.006 70);
  --plate-check-b: oklch(0.32 0.006 70);
}

* { box-sizing: border-box; }

html {
  /* Reserve the scrollbar gutter always. Without it a short page has no scrollbar and a long
     one does, so the viewport differs by its width and every centred element — wordmark, nav,
     footer — shifts a few pixels as you navigate. Cheaper and steadier than any JS. */
  scrollbar-gutter: stable;
}

/* `position: sticky` on the footer only keeps it visible while scrolling PAST it — on a page
   short enough to never scroll (a bare login form, most legal-page loads), sticky never engages
   and the footer just sits wherever the content ends, floating well above the viewport's true
   bottom edge. Dean, 2026-08-03. The flex column is what actually locks it to the bottom on a
   short page; sticky still does its own job of keeping it in view on a long, scrolling one — the
   two are complementary, not a replacement for each other. `min-height: 100dvh` overrides the
   `100vh` fallback where supported, so a phone's address bar collapsing does not yank the layout
   taller than the screen actually is. */
body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font: 400 16px/1.55 "Rethink Sans", "Rethink Sans Fallback", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
  -webkit-font-smoothing: antialiased;
  min-height: 100vh;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
}
main { flex: 1 0 auto; }

/* One measure for the FRAME, whatever the content does. The masthead, the workflow bar and the
   footer all use --frame, so the wordmark sits in the same place on every page — with two
   measures they moved 140px as you navigated between a narrow page and a wide one, which reads
   as the whole site shifting rather than the content changing. */
:root { --frame: 1180px; }

/* One width for the workflow pills and the masthead CTA. They sit one above the other in the
   same header, so a pill measured by its longest label and a button measured by its own text
   read as two unrelated controls stacked by accident. */
:root { --pill: 8.5rem; }

/* width: 100%, not left to `auto` — Dean, 2026-08-03. `<main class=wrap>` is now a flex item (the
   footer-lock fix, above), and a flex item's `auto` width sizes to its own content rather than
   filling the line the way a normal block box does — with a fit-content algorithm and nothing
   inside forcing 1180px, .wrap and everything in it (the account sidebar, in particular) quietly
   shrank to ~465px and `margin: auto` centred that smaller box instead of the real one, pulling
   the sidebar hundreds of pixels off the wordmark's left edge above it. Harmless everywhere .wrap
   is NOT a flex item (the footer's own `.wrap`, a normal block child of `.foot`): a block box
   already fills 100% of the line by default, so this changes nothing there. */
.wrap { width: 100%; max-width: 900px; margin: 0 auto; padding: 1.5rem 1.25rem 4rem; }

/* The job screens are a working surface, not a reading one: a scene row carries a grip, a
   thumbnail, a name, a shot description and three controls, and at 900px those crowd while the
   prose limits elsewhere (66ch) keep the rest of the site readable. Wider only where it earns
   it — the legal pages and the landing copy stay narrow. */
.wrap.wide { max-width: var(--frame); }

/* --- skip link -----------------------------------------------------------
   The masthead is sticky and carries the wordmark, the CTA, the balance, Account, Log out and
   the theme toggle; a job screen stacks five workflow stages and Cancel underneath. That is
   about nine stops before the first thing on the page, on every navigation, and the whole cost
   falls on people who move by keyboard or switch — which is why it stayed invisible until it
   was counted rather than looked at. WCAG 2.4.1.

   Parked off-screen rather than `display: none`, because a hidden element is not focusable and
   a skip link nobody can reach is worse than none: it satisfies an automated check while
   helping no one. No transition on `top` — this system animates no layout property (the render
   meter's waiver is scoped to its one line and must not be inherited), and an instant jump is
   the right behaviour for a control whose entire job is to be fast.

   `tabindex=-1` on <main> is what makes the jump actually move keyboard focus; without it some
   browsers scroll the fragment into view and leave focus behind in the masthead, so the next
   Tab returns to the nav the user just skipped. -1 keeps it out of the tab order itself. */
.skip {
  position: absolute; left: 0.75rem; top: -4rem; z-index: 30;
  padding: 0.55rem 1.1rem; border-radius: var(--radius);
  background: var(--surface); color: var(--ink); border: 1px solid var(--line-firm);
  text-decoration: none; font-size: 0.9375rem;
}
.skip:focus { top: 0.75rem; }
/* <main> is a focus TARGET, never a focus stop: it takes focus from the skip link so the next
   Tab lands on the page's own first control, and it must not draw a ring around the whole
   column when it does. Every real control keeps the global :focus-visible outline. */
#main:focus { outline: none; }

/* --- masthead ------------------------------------------------------------ */

/* Sticky: the plan and review screens scroll for sixteen scenes, and the balance and the way
   back should never be a scroll away. Translucent + blur so footage and photo grids read as
   passing under it rather than colliding with it. */
.top {
  position: sticky; top: 0; z-index: 20;
  background: color-mix(in oklch, var(--surface) 88%, transparent);
  -webkit-backdrop-filter: saturate(1.6) blur(10px);
  backdrop-filter: saturate(1.6) blur(10px);
  border-bottom: 1px solid var(--line);
}
/* Same measure as .steps-bar and .wrap, so the wordmark sits above the page's left edge and
   the nav above its right, instead of both being flung to the window edges. */
/* Grid, not space-between: the middle cell has to sit on the CENTRE of the frame, and with
   flex it would sit wherever the wordmark and the nav left it — drifting as the balance goes
   from "9 credits" to "12 credits". Equal 1fr shoulders pin it. With no CTA (a stranger) the
   middle column collapses to nothing and the result is what space-between drew before. */
.topline {
  max-width: var(--frame); margin: 0 auto;
  display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 1rem;
  padding: 0.5rem 1.25rem;
}
/* Columns named explicitly, not left to auto-placement. When there is no CTA — a stranger, a
   legal page — the element is absent rather than empty, and auto-placement drops the nav into
   the middle column and centres it. Pinning it to column 3 keeps the right edge the right edge. */
.topline .mark { grid-column: 1; justify-self: start; }
/* The home page's left slot: same grid cell as the wordmark, but a button (Gallery), because that
   screen carries no wordmark to anchor on. See home()'s `home_mark`. */
.topline .home-mark { grid-column: 1; justify-self: start; }
/* The home masthead runs shorter than every other page's (48px vs 56px, measured) — every other
   page's left slot is the wordmark image, 40px tall and taller than the 32px controls that
   govern height everywhere else in the bar; home's own left slot (Gallery) is one of those 32px
   controls, so nothing there reaches 40px to stretch the row. Extra vertical padding closes the
   gap without growing the Gallery button past the height every other masthead control shares.
   2026-08-04. */
.topline:has(.home-mark) { padding-top: 0.75rem; padding-bottom: 0.75rem; }
/* Louder text, same box. .btn carries a 44px tap target, which next to a 32px Log out made the
   masthead look like it had swallowed something — so the height is pinned to match the controls
   beside it and the emphasis comes from the type instead. The tap target comes back on mobile
   below, where the button has its own row and thumbs are the input. */
/* `a.cta`, not `.cta`: .btn.small is the same specificity and sits further down the file, so it
   quietly won the font-size and the button came out at 14px regular. */
.topline a.cta {
  grid-column: 2;
  min-height: 0; height: 2rem; width: max-content; padding: 0 0.85rem;
  /* width: max-content, not var(--pill): the CTA's label grew to "Create Walkthrough"
     (2026-08-10) and 8.5rem pill clipped/ran the text to two lines. Size to the label so it
     never wraps, whatever the copy says (Dean, 2026-08-10). */
  /* margin: 0 kills the inherited bottom margin. It is invisible but it is part of the grid
     item's box, so align-items centred button+margin and hung the button ~5px above the Log out
     beside it — while adding its whole height to the masthead. */
  margin: 0;
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 0.9375rem; font-weight: 600;
  /* The one action the masthead leads with — its border gets the gold accent so it reads as
     the conversion control in a row of quiet links (Dean, 2026-08-10). */
  border-color: var(--accent);
}
/* The signed-out masthead CTA is mobile-only (spec EY §2a) -- the desktop hero CTA is already
   above the fold there (measured top=664 on 1440x900), so a masthead copy of it would be
   redundant and would change a masthead nobody asked to change. Keyed on href, not a new class,
   so the signed-in Create Walkthrough button (href=/new) -- which DOES show on desktop, unchanged
   -- shares every other rule with zero risk of this one catching it too. */
@media (min-width: 641px) {
  .topline a.cta[href="/signup"] { display: none; }
}
.topline nav   { grid-column: 3; justify-self: end; }
/* The staff masthead's page links (Admin | Status | Usage) — column 2 is the same centred slot
   a.cta uses above; there is no CTA on the staff shell, so nothing else wants it. */
.topline nav.tabs { grid-column: 2; justify-self: center; }

/* --- workflow steps ------------------------------------------------------
   Where the job is, and what is behind it. Lives in the masthead because a job is one URL
   whose shape changes with its phase — without this the agent can see what the current screen
   wants but not what is left or what a click will cost.

   Three states, each carrying a non-colour signal as well: done has a tick and is a link,
   current is filled and marked aria-current, ahead is dimmed and is not a link at all. */
/* The bar spans the full window (it lives in the masthead), but its CONTENTS have to line up
   with the page: same max-width and auto margins as .wrap, or "centred" means centred on the
   viewport while the content sits in a 1180px column, and the escape pins to the window edge
   rather than the content edge. That mismatch is what made it look off.
   Grid `1fr auto 1fr`: the steps occupy the middle column, so they stay put as the escape
   label changes width — with flex the whole run would shift when Cancel became Leave. */
.steps-bar {
  border-top: 1px solid var(--line);
  max-width: var(--frame); margin: 0 auto; padding: 0.4rem 1.25rem 0.55rem;
  display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 0.5rem;
}
.steps-bar ol {
  grid-column: 2;
  display: flex; align-items: center; justify-content: center; gap: 0.3rem;
  list-style: none; margin: 0; padding: 0;
  overflow-x: auto; scrollbar-width: none;
}
.steps-bar ol::-webkit-scrollbar { display: none; }
/* Far left of the content column, directly under the wordmark — the bar shares .topline's frame
   and padding, so column 1 starts exactly where the mark does. Named explicitly rather than left
   to auto-placement: with no Cancel rendered, the steps would otherwise slide into its column.
   The compound selector is deliberate — `.top a` sets colour at 0-1-1 and would beat a lone
   `.step-back`, which is how the masthead buttons ended up with unreadable text once already. */
.step-back { grid-column: 1; justify-self: start; }
/* The same metrics as Cancel in the opposite shoulder, so the two ends of the bar are a matched
   pair rather than two different-sized controls. NOT `.btn.small`: that is (0,2,0) and sits
   later in this file, so it would win the tie on font-size and padding and quietly resize this
   — exactly how the masthead CTA came out 44px tall. `a.step-back.btn` is (0,2,1) and does not
   depend on source order.
   nowrap because at 400px the 1fr shoulder squeezes the label to its longest word, and
   "‹ Reels" over two lines makes the bar taller than the steps beside it. */
a.step-back.btn {
  min-height: 0; padding: 0.3rem 0.7rem; font-size: 0.8125rem; min-width: 69px;
  white-space: nowrap;
}
/* Far right of the content column, and always in column 3 whether or not it is rendered. */
.step-esc { grid-column: 3; justify-self: end; margin: 0; }
/* The uploader's Cancel is a GET form so it can be a real button like the plan header's; a
   block-level form would stretch across the grid cell, so un-box it — the button becomes the
   grid item, same as .team-actions form (Dean, 2026-08-16). */
.step-esc form { display: contents; }
/* Same metrics AND the same width as Log out directly above it: they sit in the same corner of
   two stacked bars, and "Cancel" being seven pixels narrower than "Log out" read as a wobble in
   the right edge rather than as two different buttons. min-width, not width, so a longer label
   still fits. */
.step-esc button { min-height: 0; padding: 0.3rem 0.7rem; font-size: 0.8125rem; min-width: 69px; }
/* The suspended Cancel during starting/curating (see _steps): the job page meta-refreshes every
   3s while the walkthrough is being built, and a live confirm-then-POST Cancel would race that
   reload and can land on a 404. So those phases render an inert <button disabled> with no form —
   same compact metrics as the live button above, dimmed by the global button[disabled] rule in
   this file, and above all stripped of the hover gold-fill that would promise a click that
   cannot happen. */
.step-esc button[disabled]:hover { background: transparent; cursor: default; }
/* The uploader's Cancel (no job yet, so there is nothing to confirm) — a real button, not a text
   link: it used to be quiet-link text and read as a lighter-weight action than the confirmed
   Cancel a job in progress gets, when both do the same thing. Same compact metrics as
   .step-esc button and .step-back.btn above, for the same reason: three controls in this bar's
   two shoulders, one size. Dean, 2026-08-03. */
a.step-esc.btn { min-height: 0; padding: 0.3rem 0.7rem; font-size: 0.8125rem; min-width: 69px; }
/* The status panel that replaces Cancel in the same shoulder once rendering starts (job_page
   passes it to _steps as `tiles`; see the comment there). Grid, not two stacked <p>s: a value and
   its label are two cells of the SAME row, and grid is what lets "14 of 16" and "10" share one
   right-aligned column while "scenes ready" and "changes left" share one left-aligned column
   below them — text-align on a block only ever aligns a line against its own edges, not against
   a sibling's. auto-flow: row with 2 explicit columns turns every pair of children into one row,
   so this grows from two rows to more without any markup change. Dean, 2026-08-03. */
.step-tiles {
  display: grid; grid-template-columns: auto auto; column-gap: 0.4rem; row-gap: 0;
  font-size: 0.8125rem; line-height: 1.25;
}
.step-tiles .val { text-align: right; color: var(--ink); font-variant-numeric: tabular-nums; white-space: nowrap; }
.step-tiles .val b { font-weight: 500; }
.step-tiles span:not(.val) { text-align: left; color: var(--ink-2); white-space: nowrap; }
/* Every step the same width, so the run does not reflow as labels change and the bar reads as
   five equal stages rather than five differently-important ones. 8.5rem fits the longest
   ("Review" plus its tick) with room to spare. */
/* The li is the flex ROW holding [connector][box], so the ::before below lands beside the box
   rather than inside it. Getting this wrong once put the chevron in the pill: with the box
   styling applied to the li itself, its ::before became a flex item within the pill's own row. */
/* flex: none, or the equal width above is a suggestion the browser overrules. The li is a flex
   item in a container that runs out of room on a phone, so the default flex-shrink:1 shrank every
   pill to its own longest word — 53/37/52/51/47px where the rule says one width — and the run
   read as five differently-important stages, which is the exact thing it exists not to do. */
.step {
  display: flex; align-items: center; flex: none;
  font-size: 0.8125rem; white-space: nowrap;
}
/* Square, not var(--radius) (Dean, 2026-08-03) — button/.btn and every input squared off earlier
   this project and the pills were the one control left rounded, reading as a different family
   from everything else on the page they sit directly above. */
/* `.step.done` as well as `.step.done a`: Photos is done AND unlinked once rendering starts
   (replan refuses after that), so it matched neither the anchor rule nor .now/.todo and rendered
   as bare 40px text while every other step was a 136px box. */
/* `.step.busy` joined this list on 2026-08-21, for the same reason `.step.done` had to: spec Z
   added it as a fourth state and styled it standalone, so it inherited no width, no padding and
   no border-width — 49.9px of bare text beside 136px boxes, with its own border-color painting a
   zero-width border. Adding a class to this family IS a change to this selector (spec AD). */
.step a, .step.now, .step.todo, .step.done, .step.busy {
  display: flex; align-items: center; justify-content: center; gap: 0.3rem;
  width: var(--pill); box-sizing: border-box;
  padding: 0.3rem 0.6rem; border: 1px solid transparent; border-radius: 0;
}
/* A LINKED done step is two boxes: the <li> matches .step.done and the <a> inside it matches
   .step a, so the li's own padding squeezed the anchor and the green border drew at 115px beside
   a 136px fill — finished steps read as smaller controls than the current one. Where there is an
   anchor, the li is only a wrapper and the anchor is the pill. */
/* border: 0 too, or the transparent 1px each side makes the wrapper 138px and nudges the
   connector beside it. */
.step.done:has(a) { padding: 0; width: auto; border: 0; }
/* An unlinked done step is history, not a control: the same green border as its linked siblings
   so the run still reads as one sequence, but quieter, since there is nothing to click.
   Quieter is the BORDER, not the text. `opacity: 0.75` on the whole box composited --ink-2 down
   to 3.74:1, and the agent pays for a stylistic dimming with the label they were reading. */
.step.done:not(:has(a)) {
  color: var(--ink-2);
  border-color: color-mix(in oklch, var(--ok) 45%, transparent);
}
.step a { color: var(--ink-2); text-decoration: none; }
/* The tick is absolutely positioned so it cannot shift the centred label: with it in flow, a
   done step's text sat off-centre against its neighbours by the width of the glyph. */
/* .step.done included: without it an unlinked done step (Photos, after rendering starts) had no
   positioned ancestor, so its absolutely-placed tick escaped to the far left of the whole bar. */
.step a, .step.now, .step.todo, .step.done, .step.busy { position: relative; }
.step-tick { position: absolute; left: 0.55rem; }

/* done — green border, the work is behind you and you may go back to it */
.step.done a { border-color: var(--ok); color: var(--ink); }
.step.done a:hover { background: color-mix(in oklch, var(--ok) 12%, transparent); }
.step-tick { color: var(--ok); font-size: 0.75rem; }
/* An empty tick span on a not-done step must not reserve space or draw a box. */
.step:not(.done) .step-tick { display: none; }

/* current — filled, the one place colour alone would have been ambiguous */
.step.now { background: var(--accent); color: var(--on-accent); font-weight: 500; }
/* A finished step the agent is standing on carries BOTH classes: the tick from `.done`, the fill
   from `.now` (spec AX §1). Without this rule `.step.done:not(:has(a))` wins on colour and paints
   `--ink-2` on the gold — measured by canvas readback at 1.27:1 in dark and 4.34:1 in light,
   against the 4.5:1 this 15px text needs. The light figure is the dangerous one: it looks fine in
   a screenshot and fails anyway.

   `:not()` and `:has()` take the specificity of their ARGUMENT rather than adding weight of their
   own, so `.step.done:not(:has(a))` is (0,2,1) — two classes plus the type selector `a` — and
   `.step.done.now` is (0,3,0). Three classes beat two-classes-plus-a-type, so this wins with no
   `!important` and without touching either rule it sits between. Confirmed against the engine
   rather than by hand: the same three-class rule placed BEFORE the one it overrides still won,
   which only higher specificity can do. */
.step.done.now { color: var(--on-accent); border-color: var(--accent); }
.step.now .step-tick { color: var(--on-accent); }

/* The machine is working and the agent is waiting. Same accent as `.now` so the eye still finds
   where it is in the arc — outlined rather than filled, because a FILLED step means "you can act
   here" (spec Z). Motion carries the rest; `aria-busy` carries it for anyone the motion does not
   reach. */
.step.busy {
  background: transparent;
  border-color: var(--accent);
  color: var(--ink);
  font-weight: 500;
}
@media (prefers-reduced-motion: no-preference) {
  .step.busy { animation: step-busy 1.6s ease-in-out infinite; }
}
@keyframes step-busy {
  50% { border-color: color-mix(in oklch, var(--accent) 45%, transparent); }
}

/* ahead — not reached, not clickable, and visibly so. It keeps a border because five equal
   boxes read as one sequence of stages; three boxes and two bits of floating text do not. The
   border is --line (decorative), not --line-firm, precisely because this is NOT a control.
   No opacity. --ink-3 is already the quiet floor DESIGN.md sets (5.05:1); the 0.55 that used to
   sit here composited it to 2.18:1 and failed WCAG 1.4.3 on every job screen. "Ahead" is carried
   by the --line border, the absent tick and the absent link — three signals that cost nothing to
   read. */
.step.todo { color: var(--ink-3); border-color: var(--line); }

/* The same chevron the scene rows use (\203A), between each pair of steps. It is a flex item in
   its own right rather than a ::before on the label — that was the earlier mistake: attached to
   a centred label it sat against the label's left edge and read as punctuation stuck to the
   word, instead of sitting midway between the two boxes.
   --accent, not --ink-3 (Dean, 2026-08-03): the arc these separators trace is the one thing on
   the page tying the five stages into a single sequence, and colouring it draws that thread
   without making any single chevron look clickable — it has no hover state and aria-hidden
   already tells a screen reader it carries no information of its own. */
.steps-bar ol { gap: 0.35rem; }
.step-sep {
  flex: none; align-self: center;
  color: var(--accent); font-size: 1.25rem; line-height: 1;
}
/* One row needs 959px. Measured, not guessed: two 69px shoulders, 40px of frame padding, 16px of
   grid gap and an ol of 5x136 + 4 chevrons + 8 gaps = 753. Below that the run does not fit beside
   the shoulders, and what it did instead was shrink — 53/37/52/51/47px at 320, inside a 122px
   scroller with `scrollbar-width: none`. What an agent actually saw on a phone was
   "Plan › Render ›" clipped mid-chevron: no sign the arc has five stages, and Photos — the one
   step you are allowed to go BACK to — scrolled out of sight with nothing to say so.

   So below 959 the stages get their own row, which is 291px even at 320 and enough for five real
   labels once the chevrons go. The chevrons go because they are the only thing here carrying no
   information: aria-hidden already, and five equal boxes in a row read as a sequence without them.

   Grid, not flex: `minmax(0, var(--pill))` makes the stages exactly equal at every width, capped
   at the desktop pill so a 900px window does not hand them 170px each, and nothing can shrink
   below its share or overflow the row. `auto-flow: column` rather than repeat(5) so the rule
   survives JOB_STEPS gaining or losing a stage.

   Row order is shoulders, then stages: Cancel stays directly under Log out in the same corner,
   which is the pairing the .step-esc rule above exists to preserve. DOM order is unchanged, so
   a keyboard user tabs Reels -> stages -> Cancel, one row back up at the end. */
@media (max-width: 959px) {
  .steps-bar { grid-template-columns: auto auto; justify-content: space-between; row-gap: 0.4rem; }
  .step-back { grid-area: 1 / 1; }
  .step-esc  { grid-area: 1 / 2; }
  .steps-bar ol {
    grid-area: 2 / 1 / 3 / 3;
    display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, var(--pill));
    justify-content: center; gap: 0.25rem; overflow: visible;
  }
  .step-sep { display: none; }
  .step a, .step.now, .step.todo, .step.done { width: 100%; }
}
@media (max-width: 640px) {
  .steps-bar { padding: 0.35rem 0.9rem 0.5rem; }
  .step { font-size: 0.75rem; }
  .step a, .step.now, .step.todo, .step.done { padding: 0.25rem 0.15rem; gap: 0.2rem; }
  /* In flow, not absolute. At 55px a pill has no margin for a glyph parked over its left padding,
     and the tick landed on the P of Photos. Equal columns mean the BOXES still line up, which is
     what the absolute positioning was protecting; a label sitting a few px off its own centre is
     not something anyone can see across five boxes. */
  .step-tick { position: static; font-size: 0.625rem; }
}
.top a { color: inherit; }
/* Every masthead button is quiet now — Create a Reel was the last one still missing the class,
   fixed 2026-08-03 — so the old `.top a.btn { color: var(--on-accent) }` rule this comment used
   to explain no longer matches anything and was removed with it: nothing in the masthead is a
   FILLED button any more for it to fix. (0,3,1) still beats plain `.top a`'s (0,1,1), which is
   what keeps this one dark rather than inherited. */
.top a.btn.quiet { color: var(--ink); }
/* The MARK plus live text, not the supplied lockup. The lockup is 2.9:1 and its mark takes 25%
   of that width, so at a masthead-height 20px the word "ReelView" got about 43px for eight
   characters — roughly 5px a glyph, less than half this system's 12px floor for anything a person
   is expected to read. The mark is square and survives any size; the word stays real text, which
   also keeps it selectable, translatable and sharp with no webfont.

   Sized by height so the frame stays put on every page (The Frame and the Content Rule). The
   staff console keeps its plain text wordmark on purpose: "ReelView staff" says which door you
   came in, and giving it the customer mark would make the two consoles look alike. */
.mark {
  font-weight: 500; letter-spacing: -0.01em; text-decoration: none;
  display: inline-flex; align-items: center; gap: 0.45rem;
}
/* The lockup, sized to the mark-to-word ratio the artwork itself sets rather than something CSS
   decides. `width: auto` keeps the aspect — a squashed wordmark is the one thing a logo must
   never be. Dean, 2026-08-02: two PNGs, not one SVG inverted by filter — same reasoning as the
   home banner's lockup (line ~562): this artwork is shaded, not flat vector, so `invert(1)`
   would flip it the wrong way rather than swap it. */
.mark img { display: block; width: auto; height: 40px; }
@media (max-width: 640px) { .mark img { height: 31px; } }
.mark .lockup-dark { display: none; }
:root[data-theme=dark] .mark .lockup-light { display: none; }
:root[data-theme=dark] .mark .lockup-dark { display: block; }
/* The share page's sign-off still uses the old flat-vector mark, so it keeps the invert trick:
   the artwork is `fill="currentColor"` and still cannot inherit, because an SVG loaded through an
   image element is a separate document — currentColor resolves against ITS root, which has no
   colour set and falls back to black. */
:root[data-theme=dark] .madeby img { filter: invert(1); }
/* `.topline nav`, NOT `.top nav`: the steps bar is also a <nav> inside .top, and at 0-1-1 this
   rule beat .steps-bar's own 0-1-0 display:grid — so the workflow silently laid itself out as a
   flex row, refusing to centre and dragging the escape link in behind it. */
.topline nav { display: flex; align-items: center; gap: 1rem; font-size: 0.875rem; color: var(--ink-2); }
/* 24px, not this system's own 44, and the number is the whole argument.
   These links were 20-22px tall, under WCAG 2.5.8's 24x24 minimum. On a wide window they scraped
   past on the spacing exception — one row, centres ~70px apart — but on a phone the nav wraps and
   the row below sits 6px away, so the exception stopped covering them and they were simply small.
   They grow to the AA floor rather than to 44 because the 44px rule was written for controls in
   the PAGE, and these are controls in a persistent band. Taking every one to 44 measured a
   158px masthead against 184, on a 760px phone that now also carries a sticky footer: 28% of the
   screen spent on chrome before any of the job is on it. The rule still governs everything an
   agent works in. It is suspended here, once, for the frame, and the price of suspending it is
   that these particular targets are AA and not AAA.
   The footer's negative-margin trick is not available here, because that row wraps and an
   expanded target would overlap the button on the line below — overlapping targets are a worse
   defect than small ones. Real height and real row-gap instead. It costs nothing on desktop,
   where the bar is sized by the CTA and the lockup, not by these. */
/* gap, because inline-flex is not free on text. The balance used to be `<b>12</b> credits`, and a
   flex container made the bold number and the word two separate items — which collapsed the
   whitespace between them and rendered "12credits". The measurements all passed; a screenshot is
   what caught it. 0.25em rather than a px so it tracks the font size as the bar shrinks. The word
   is gone now (see .cred-icon, below), replaced by the coin icon this gap still separates from
   the number — and it is inert on every other link in here, which carries a single text node. */
.topline nav a {
  min-height: 24px; display: inline-flex; align-items: center; gap: 0.25em;
}
/* The credit balance's icon: a single 16px art used in both themes (Dean, 2026-08-10 — the
   theme-swapped light/dark pair collapsed to one neutral coin). */
.cred-icon { width: 16px; height: 16px; display: block; }
/* The balance itself, bigger than the surrounding nav text — Dean, 2026-08-03. Title (1.125rem),
   DESIGN.md's own step. The old "number pop-in" animation (transitions.dev cred-pop-in) and its
   keyframes/stagger/reduced-motion machinery were removed on Dean's call, 2026-08-08. */
.cred-count {
  font-size: 1rem; font-weight: 400; display: inline-flex; align-items: baseline;
  color: var(--ink);  /* dark ink on light, near-white on dark — Dean, 2026-08-03 */
}
.cred-count .t-digit { display: inline-block; }
/* --- theme toggle --------------------------------------------------------
   A drawn moon and sun, cross-fading. This started as the word "Dark"/"Light", on the argument
   that the unicode sun and moon characters run the emoji-presentation lottery on Windows and
   Android and arrive as full-colour pictures in a masthead with no other colour in it. That
   argument was against the GLYPHS, not against icons: these are authored SVG on currentColor at
   one stroke weight, so they inherit the ink like everything else and cannot be recoloured by a
   font. Same reasoning the craft floor gives — icons are drawn, not borrowed from a character set.

   It also buys back masthead width, which is not incidental: the word cost 70px in a row that
   had to wrap on a phone; the icon costs 32.

   Which icon is showing is set in CSS off the same attribute that carries the theme, so it is
   right on the first paint of every page rather than after a script has walked the DOM. The
   accessible name comes from aria-label on the element and says both directions — the button has
   no text of its own now, so that label is the only name it has.

   Hidden until the attribute exists. The head script stamps it before the body does, so it is
   present whenever scripting is on — and when scripting is off there is no toggle to offer, and
   a button that cannot switch anything is a control that lies. Same argument as the drag grip
   on a touch device. */
.themetog { display: none; }
/* inline-grid, and both icons occupy the SAME cell — that is what lets one cross-fade into the
   other without the button changing width mid-animation or the masthead twitching. */
/* Square, and square-cornered — Dean, 2026-08-02. It is the only control in the product with a
   radius of 0 besides the account sidebar, and the reason is the same one that made the sidebar
   square: this is frame, not content. A 32x32 hard-edged box beside the 8px pills reads as part
   of the bar rather than as one more thing to press, which is right for a display preference
   sitting next to the actions an agent came to take.
   Height pinned as well as width, or the button is 32x20 and "square" is only half true. */
:root[data-theme] .themetog {
  display: inline-grid; place-items: center;
  width: 2rem; height: 2rem; min-width: 0; min-height: 0;
  padding: 0; line-height: 0; border-radius: 0;
}
.themetog .t-icon {
  grid-area: 1 / 1;
  width: 18px; height: 18px; display: block;
  transition: opacity var(--icon-swap-dur) var(--icon-swap-ease),
              filter var(--icon-swap-dur) var(--icon-swap-ease),
              transform var(--icon-swap-dur) var(--icon-swap-ease);
}
/* The icon shown is the theme the click GOES TO, which is what the word used to say: a moon on
   the light page, a sun on the dark one. The other is not hidden — it is scaled down and blurred
   out in the same cell, so the swap is one object turning into the other rather than two things
   taking turns. */
.themetog .icon-sun  { opacity: 0; filter: blur(var(--icon-swap-blur)); transform: scale(var(--icon-swap-start-scale)); }
.themetog .icon-moon { opacity: 1; filter: blur(0); transform: scale(1); }
:root[data-theme=dark] .themetog .icon-sun  { opacity: 1; filter: blur(0); transform: scale(1); }
:root[data-theme=dark] .themetog .icon-moon { opacity: 0; filter: blur(var(--icon-swap-blur)); transform: scale(var(--icon-swap-start-scale)); }
/* Someone who asked the OS to stop moving things still gets the right icon, instantly. */
@media (prefers-reduced-motion: reduce) { .themetog .t-icon { transition: none; } }

/* The masthead's buttons, matched to the theme toggle beside them: same 2rem height, same square
   corners, same quiet/outlined treatment — the right-hand end of the bar reads as one set of
   controls, not a pill next to a box. Dean, 2026-08-02.
   `a.btn`, not `a.btn.navcta` — Dean, 2026-08-03: Menu (added since) is a `.btn` with no `navcta`
   class, and `button.small`'s own height reduction (line ~1534) is scoped to real `<button>`s, so
   an `<a class=btn small>` here fell through to the base button's full 44px-track padding instead
   and rendered visibly taller than Log out beside it. Any masthead `.btn` gets this shape now, not
   just the ones named for it.
   Shape only. The `quiet` class (line 843) already supplies transparent background, ink text,
   the hover fill, and a firm border, and `.top a.btn.quiet` (line 401) already wins the colour
   cascade at (0,3,1), so nothing here touches colour — it only sets the height and corners the
   toggle also uses. `.home-mark` gets the same shape: it is Get Started's opposite number on the
   same bar. */
.topline nav a.btn, .topline .home-mark {
  height: 2rem; min-height: 0; padding: 0 0.75rem; border-radius: 0;
  display: inline-flex; align-items: center;
  font-size: 0.875rem; font-weight: 500;
}
/* The credit balance, pinned to the same 2rem so it reads as one of these controls too, rather
   than a shorter, unboxed thing floating beside them — Dean, 2026-08-03. */
.topline nav a.credits { height: 2rem; }

@media (max-width: 640px) {
  /* Wordmark and nav alone fill a 360px phone, so the CTA takes its own row rather than
     squeezing the balance off the screen. It is the action the agent came for — a full-width
     bar under the masthead is a better phone control than a centred one nobody can hit. */
  .topline { padding: 0.35rem 0.9rem; grid-template-columns: auto 1fr; }
  /* wrap, and it is load-bearing rather than defensive. A SIGNED-IN masthead carries the
     balance, Account, Log out and the theme toggle, and at 360px that run measured 235px beside
     a 118px lockup in a 345px viewport — 39px of horizontal scroll on the whole document, on the
     one screen size the product is actually used at. Without the toggle the same row measured
     zero, so this is the cost of adding it and it is paid here rather than by dropping the
     control on phones.
     Wrapping is what lets the flex row report a min-content width of its widest ITEM instead of
     the sum of all of them, so the grid column can be narrower than the run. The balance and
     Account take the first line, the two buttons the second, right-aligned; the bar grows 6px.
     A stranger's masthead has never needed this — it fits at 320. */
  .topline nav {
    gap: 0.7rem; font-size: 0.8125rem; grid-column: 2;
    flex-wrap: wrap; justify-content: flex-end; row-gap: 0.5rem;
  }
  /* The extra row-gap is what buys the taller targets below room to sit apart once the nav
     wraps. See the note on .topline nav a. */
  /* Its own row, full width, and back to a full tap target — this is a thumb, not a cursor. */
  .topline a.cta { grid-row: 2; grid-column: 1 / -1; width: auto; height: auto; min-height: 44px; }
  /* Staff shell only, and it never has a CTA to share row 2 with — same slot, same reasoning:
     a centred three-link row nobody can hit gives way to its own full-width row. Without this the
     unscoped desktop rule above still wins on specificity and fights the general `.topline nav`
     rule for column 2, and the two only avoid overlapping by grid auto-placement luck. */
  .topline nav.tabs { grid-row: 2; grid-column: 1 / -1; justify-self: start; }
}

/* --- landing page -------------------------------------------------------
   The only page a stranger sees. Same restraint as the app: no gradients, no drop shadows, one
   accent used only on actions. An agent reselling this under their own brand is not helped by us
   looking like an AI startup. */

/* The lockup above the hero, so the mark lands before the footage does. Centred. The lockup is
   two PNGs, not one inverted by filter: it is a photographed/rendered mark rather than flat
   vector, so `invert(1)` (the wordmark's own trick, line 430) would flip its shading the wrong
   way. Same show/hide-by-theme idea as the hero's wide/tall video swap. */
/* .wrap's own 1.5rem top padding is the standard gutter under the masthead everywhere else; the
   banner is the one page that starts flush against it, so its own small padding is the whole
   gap rather than a top-up on wrap's. */
.wrap:has(> .banner) { padding-top: 0; }
/* Off the weight rule (DESIGN.md caps emphasis at 500) on purpose: this is a temporary
   operational notice tied to config.DRY_RUN, not brand content, and it needs to be seen and
   gone, not tasteful. Dean, 2026-08-02. Size was ALSO off-ramp (a bespoke clamp) until Dean
   confirmed 2026-08-03 that part was never intentional — now the Display step, same as the
   landing headline. */
.construction {
  text-align: center; margin: 0; padding: 1rem 0 0; line-height: 1.1;
  font-size: clamp(1.75rem, 5vw, 2.6rem); font-weight: 700; letter-spacing: 0.02em;
  text-transform: uppercase; color: var(--warn);
}
/* `.wrap > .banner`, not `.banner`: `.wrap > section` above is (0,1,1) and was beating this
   rule's own (0,1,0), forcing a silent 44px margin-top onto the banner — invisible while it sat
   flush against the masthead (nothing above it to show a gap against), and exposed the moment
   `.construction` started sitting above it instead. Same bug, same fix, as `.wrap > .hero-video`
   further down. Dean, 2026-08-02. */
.wrap > .banner { text-align: center; margin-top: 0; padding: 0.5rem 0 0; }
.banner img { display: inline-block; max-width: 100%; height: auto; margin: 0 0 0.75rem; }
.banner .lockup-dark { display: none; }
:root[data-theme=dark] .banner .lockup-light { display: none; }
:root[data-theme=dark] .banner .lockup-dark { display: inline-block; }
.banner .lede { font-size: 1rem; max-width: none; white-space: nowrap; margin: 0 auto; }
@media (max-width: 560px) { .banner .lede { white-space: normal; } }

.hero { padding: 0.75rem 0 0; }
.hero h1 {
  font-size: clamp(1.75rem, 5vw, 2.6rem); line-height: 1.12; letter-spacing: -0.025em;
  margin-bottom: 0.75rem;
}
.lede { font-size: clamp(1rem, 2.2vw, 1.15rem); color: var(--ink-2); max-width: 65ch; margin: 0 0 1.5rem; }
.lede b { color: var(--ink); }
.cta { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center; margin: 0 0 0.6rem; }
.close .cta { justify-content: center; }
.hero-copy .cta { justify-content: center; align-items: flex-start; margin-bottom: 0; }
/* The hint sits on its own line under the CTA row, not in a column beside the primary button
   (Dean, 2026-08-31, reversing the column of 2026-08-08).

   Stacking the two made the wrapper as wide as the TEXT, not the button: the hint measures 209px
   against the button's 112px, so `justify-content: center` centred a 209px box and put the two
   buttons 24.3px right of the axis — exactly (209.2 - 111.9) / 4. It also pushed the row to 344px,
   which wrapped "See examples" onto its own line at 360px wide.

   On its own line the hint constrains nothing, and it still lands on the button's axis because
   both are centred in the same column. `max-width: none` because .hint's own --measure would
   otherwise centre the text inside a 90ch box rather than across the hero. Dean's call on the
   trade: the hint now reads as belonging to the whole row rather than to Get Started alone. */
.hero-copy .cta-note { text-align: center; max-width: none; margin: 0.3rem 0 0; }
.btn.small { padding: 0.35rem 0.7rem; font-size: 0.875rem; }

/* --- video hero ---------------------------------------------------------
   Footage behind the words, because the product is the footage. Breaks the .wrap margin to run
   full-bleed, then re-inflates its own gutter. The scrim is not decoration: white text over
   arbitrary listing footage is a contrast lottery, and a bright kitchen would take the headline
   to 1.5:1. A near-opaque left-weighted gradient keeps it past 4.5:1 whatever is playing.

   `.wrap > .hero-video`, not `.hero-video`: `.wrap > section` below is (0,1,1) and was beating
   this rule's own (0,1,0), silently cancelling the breakout margin — the section rendered at
   .wrap's own width, but the padding still assumed the full-viewport width it never got, so the
   box overflowed .wrap's right edge instead of bleeding evenly. `.wrap > .hero-video` is (0,2,0)
   and wins outright. */
.wrap > .hero-video {
  position: fixed; inset: 0; width: 100vw; height: 100vh;
  margin: 0; z-index: 0; min-height: 0; padding: 0;
  overflow: hidden; isolation: isolate;
  display: flex; align-items: center;
  background: oklch(0.22 0.006 70);
}
/* Variant A (Dean approved, 2026-08-08): the hero video is the page's fixed backdrop. The
   home content scrolls over it, so the pinned video stays visible as the sections pass —
   we raise the wrap above the fixed hero. Scoped to a wrap that actually contains a hero so
   no other page changes. `.top` (masthead) is sticky z20, so it already floats above both
   layers. Sections are left fully transparent, not the translucent-plate-plus-blur an earlier
   version of this comment described and never shipped: Dean's call (2026-08-1x, see The Fixed
   Backdrop Rule in DESIGN.md) is that the video stays visible at full strength behind every
   section, not just the hero — that's the pitch, not decoration. Legibility for the text that
   sits directly on it (nothing else here, since .wf-step/.pack/.pics/.device-* all carry their
   own --surface or dark-ground background already) is handled below with a text-shadow halo
   instead of a plate, so the fix protects reading without dimming a single pixel of footage. */
.wrap:has(> .hero-video) { position: relative; z-index: 1; padding-top: 0; }
.wrap:has(> .hero-video) > :not(.hero-video) { position: relative; z-index: 1; }
.wrap:has(> .hero-video) > section:not(.hero-video) {
  background: transparent;
}
/* The legibility halo above: best-effort, not a mathematical guarantee the way an opaque plate
   would be (a genuinely busy patch of video could still beat it at some scroll position), but a
   real improvement over raw, unprotected transparency — same category DESIGN.md already permits
   for the hero's own gradient scrim ("a gradient is permitted as a contrast device over footage
   and nowhere else"), extended to text-shadow as the equivalent device for the rest of the page.
   Uses var(--bg) so it is dark in dark theme and light in light theme with no separate rule —
   the token already carries that split. Scoped to the specific text that sits directly on a
   section (section-level h2's, the workflow intro/legend, the credit-list bullets, the cost
   notes) — never the card components, which don't need it and shouldn't get a redundant one. */
.wrap:has(> .hero-video) > section:not(.hero-video) > h2,
.wrap:has(> .hero-video) .workflow-inner h2,
.wrap:has(> .hero-video) .workflow-inner > .sub,
.wrap:has(> .hero-video) .wf-legend,
.wrap:has(> .hero-video) .credit-list li,
.wrap:has(> .hero-video) .cost-notes p {
  text-shadow: 0 0 6px color-mix(in oklch, var(--bg) 80%, transparent),
               0 0 14px color-mix(in oklch, var(--bg) 60%, transparent);
}
/* The "How it works" section — rebuilt as real HTML/CSS (2026-08-04) so the type is the app's
   own. It's a normal .wrap child now: the full-bleed breakout was removed (there's no band
   background to paint edge-to-edge anymore), so the five cards sit on the same 860px content
   column as the other sections like "What it costs". */
.workflow-inner { max-width: var(--frame); margin: 0 auto; }
/* The workflow section's own vertical margin is tightened (the default .wrap > section margin
   is 44px / 2.75rem): 16px above (halved on Dean's 2026-08-08 gap tighten, trimmed again to
   1rem on 2026-08-09), 24px below so the gap into the next section reads tighter. */
.wrap > section.workflow { margin: 0.5rem 0 1.5rem; }
/* `.wrap > section.transform` and its `> h2:first-child` companion lived here until 2026-08-26,
   when that section was replaced by the .inout band inside .hero-copy. Both were removed rather
   than left behind: they keyed on a class the page no longer emits, and a rule whose selector
   has outlived its markup is the kind of thing that reads as load-bearing to the next person. */
.workflow-inner h2 { margin: 0 0 0.5rem; }
.workflow-inner > .sub { margin: 0 0 1rem; max-width: 68ch; }
/* `.wrap > section > ol` applies a 90ch reading measure that squashes this card grid to ~496px;
   the cards are UI, not prose, so it gets the same escape `.wrap > section > p.full` does. */
.workflow-inner .workflow-steps { max-width: none; }
.workflow-steps {
  display: grid; grid-template-columns: repeat(5, 1fr); gap: 1rem;
  list-style: none; margin: 0; padding: 0;
}
.wf-step {
  display: flex; flex-direction: column; gap: 0.35rem;
  padding: 0.75rem; border: 1px solid oklch(0.852 0.174 90.2 / 0.5); border-radius: 0;
  background: var(--surface); position: relative;
}
/* Gold chevron between the cards, the same as the original workflow diagram. Doubled to 56px
   and centred on the gap's midpoint. The gap is 1rem (16px), so its midpoint is 8px left of the
   card's left edge; the '›' glyph at this size is ~15.4px wide, so the box's left edge lands at
   -8px minus half the glyph width, i.e. ≈ -15.7px. Hidden once the cards stack. */
.wf-step:not(:first-child)::before {
  content: "\203A";
  position: absolute; left: -0.98rem; top: 50%; transform: translateY(-50%);
  color: var(--accent); font-size: 3.5rem; line-height: 1;
}
.wf-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.4rem; }
/* The per-card step icons occupy the top-right 42px slot, the same size as the AI orbs, and
   align-items:center on .wf-top keeps each icon centred on the number circle's axis. Inlined,
   drawing with currentColor — they inherit the card's ink and theme automatically (dark ink on
   light, light on dark). */
.wf-top svg.wf-icon { display: block; width: 42px; height: 42px; flex: none; color: var(--ink); }
.wf-top canvas.orb { display: block; width: 42px; height: 42px; flex: none; }
/* The icon row stands taller than a lone number circle, so on those cards tuck the title up
   closer — the extra 42px height already gives the top row presence without the 0.4rem.
   `:has` is fine on a marketing page rendered for current evergreen browsers. */
.wf-step:has(.wf-top canvas.orb, .wf-top .wf-icon) .wf-top { margin-bottom: 0.15rem; }
.wf-num {
  width: 1.7rem; height: 1.7rem; border-radius: 50%;
  display: grid; place-content: center;
  border: 1px solid var(--accent); color: var(--ink);
  font-size: 0.8125rem; font-weight: 500;
}
.wf-step h3 { margin: 0; font-size: 0.875rem; font-weight: 500; }
.wf-step p { margin: 0; font-size: 0.75rem; color: var(--ink-2); }
.wf-tag {
  font-size: 0.6875rem; font-weight: 500; letter-spacing: 0.05em; text-transform: uppercase;
  color: var(--ink-2); background: var(--surface-2); border-radius: 999px; padding: 0.15rem 0.5rem;
  align-self: flex-start;
}
/* The "1 credit" pill on card 4 — the moment in the flow the walkthrough is actually paid
   for. Accent (#fac800) fill, dark ink on it (--on-accent) so the text holds AA against the
   gold; the card is a flex column, so margin-top:auto pins it to the bottom edge and
   align-self:flex-end to the card's right, exactly where the price sits on a pricing tile. */
.wf-credit {
  background: var(--accent); color: var(--on-accent);
  font-size: 0.6875rem; font-weight: 500; letter-spacing: 0.05em; text-transform: uppercase;
  border-radius: 999px; padding: 0.15rem 0.55rem;
  margin-top: auto; align-self: flex-end;
}
/* Card 4 (AI animates scenes) keeps the accent as its outline too — same 50% as the others, so no card
   jumps out with a full-strength border. */
.wf-paid { border-color: oklch(0.852 0.174 90.2 / 0.5); }
/* Footer row under the cards holds the credit note. Reverses the nowrap Dean asked for on
   2026-08-10 (that forced it onto one line even where it ran wider than the workflow column,
   overflowing the very cap meant to bound it) — Dean now wants it capped and wrapping like its
   sibling `.workflow-inner > .sub` above the cards, so it reads as the same width as everything
   else in the section, mobile included. */
.wf-foot {
  display: flex; margin-top: 1.25rem;
}
.wf-legend {
  max-width: 68ch; font-size: 0.8125rem; color: var(--ink-2); margin: 0;
}
@media (max-width: 959px) {
  .workflow-steps { grid-template-columns: repeat(2, 1fr); }
  .wf-step:not(:first-child)::before { display: none; }
}
@media (max-width: 560px) {
  .workflow-steps { grid-template-columns: 1fr; }
  section.workflow { padding-top: 2rem; padding-bottom: 2rem; }
}
.hero-bg {
  position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: cover; z-index: -2; border: 0;
}
.hero-video::after {          /* the scrim — light theme = light overlay */
  content: ""; position: absolute; inset: 0; z-index: -1;
  background: linear-gradient(100deg,
    oklch(0.97 0.003 70 / 0.92) 0%,
    oklch(0.97 0.003 70 / 0.85) 42%,
    oklch(0.97 0.003 70 / 0.55) 100%);
}
/* Flipped theme (dark) — the hero overlay goes DARK to match the page theme, and the hero
   text and buttons swap to light inks so they stay readable over it (Dean, 2026-08-08). The
   video itself carries the property colours; only the scrim and its foreground flip. */
:root[data-theme=dark] .hero-video::after {
  background: linear-gradient(100deg,
    oklch(0.18 0.006 70 / 0.94) 0%,
    oklch(0.18 0.006 70 / 0.86) 42%,
    oklch(0.18 0.006 70 / 0.52) 100%);
}
:root[data-theme=dark] .hero-video { background: oklch(0.22 0.006 70); }
.hero-copy .btn.on-dark {
  background: transparent; color: oklch(0.16 0.006 70);
  border-color: oklch(0.16 0.006 70 / 0.5);
}
.hero-copy .btn.on-dark:hover {
  background: rgba(250, 200, 0, 0.3); border-color: oklch(0.16 0.006 70 / 0.8);
}
/* The hero Get Started is the same conversion action as the bottom one — same gold accent
   border (Dean, 2026-08-08). The .hero-copy .btn.on-dark rules above set a dark-ink border
   for every hero button; this scoped rule restores the solid gold for the primary only, so
   See examples keeps its quiet outline. */
.hero-copy .btn.cta-accent, .hero-copy .btn.cta-accent:hover { border-color: var(--accent); }
:root[data-theme=dark] .hero-copy h1 { color: oklch(0.99 0.002 70); }
:root[data-theme=dark] .hero-copy .lede { color: oklch(0.90 0.004 70); }
:root[data-theme=dark] .hero-copy .lede b, :root[data-theme=dark] .hero-copy .hint { color: oklch(0.99 0.002 70); }
:root[data-theme=dark] .hero-copy .btn.on-dark {
  background: transparent; color: oklch(0.99 0.002 70);
  border-color: oklch(0.99 0.002 70 / 0.5);
}
:root[data-theme=dark] .hero-copy .btn.on-dark:hover {
  background: rgba(250, 200, 0, 0.25); border-color: oklch(0.99 0.002 70 / 0.8);
}
:root[data-theme=dark] .hero-copy .btn.cta-accent,
:root[data-theme=dark] .hero-copy .btn.cta-accent:hover { border-color: var(--accent); }
/* The hero Get Started fills 75% white on the LIGHT theme only so it reads as a button there;
   on the DARK theme it keeps the transparent on-dark outline (no chip). Ink: dark in light
   theme (on the chip), white on dark (on the transparent button over the scrim). The dark
   override below intentionally re-adds transparent bg so the chip never appears in dark theme.
   Dean, 2026-08-08. */
.hero-copy .btn.cta-accent { background: rgba(255, 255, 255, 0.75); color: oklch(0.16 0.006 70); }
:root[data-theme=dark] .hero-copy .btn.cta-accent { background: transparent; color: oklch(0.99 0.002 70); }
/* 42px fixed rather than the clamp(2rem, 8vh, 5rem) this replaced — Dean specified a pixel value,
   and the clamp's whole range was only 32-80px of travel above a decoration, not a headline
   (spec BC §4a, Dean 2026-08-23). */
.hero-copy { max-width: 52rem; margin: 0 auto; text-align: center; padding: 42px 0 4px; }
/* The copy moved out of the fixed hero section (Dean, 2026-08-08) so it scrolls with the
   page over the light video backdrop — the dark-text colours follow it (the dark-theme
   override restores light ink over the dark scrim, defined above). */
.hero-copy h1 { color: oklch(0.16 0.006 70); margin-bottom: 6px; }
.hero-copy .lede { color: oklch(0.30 0.006 70); margin-left: auto; margin-right: auto; }
.hero-copy .lede b, .hero-copy .hint { color: oklch(0.16 0.006 70); }
/* The eyebrow above the h1 only. `.lede` is shared — the home hero has a second one directly
   below the headline, and the body copy on /new and the landing sections use it too, so the
   shared rule would move the CTA block up by the same 12px (spec BC §4b, Dean 2026-08-23). */
.hero-copy .lede:has(+ h1) { margin-bottom: 6px; }
/* The body line above the CTA row only. `.lede` is shared, and the eyebrow above the headline is
   the other one in this same block -- it takes its own gap from the `:has(+ h1)` rule above
   (spec BF §6a, Dean 2026-08-23). */
.hero-copy .lede:has(+ .cta) { margin-bottom: 16px; }
.hero-copy .hint { opacity: 0.75; }
/* The no-background image above the hero headline (Dean, 2026-08-08). Now the 450px lockup,
   centred above the headline on the dark scrim, with the "Transform your listing photos…"
   lede directly beneath it (moved here from the banner, 2026-08-08). max-width:100% is the
   ONLY cap — it lets the lockup shrink on narrow screens, and it never exceeds the natural
   450px, so on desktop it still matches the banner lockup's size above (a fixed 22rem cap
   once rendered it 78% smaller, which is exactly what Dean saw). The copy column (40rem) is
   wider than 450 anyway, so nothing overflows. */
.hero-topimg { display: block; margin: 0 auto 1rem; max-width: 100%; height: auto; }
/* The hero lockup is theme-bound (Dean, 2026-08-08): the light theme gets the black wordmark
   on its light overlay, the flipped (dark) theme gets the white wordmark on its dark overlay.
   The filenames name the overlay they belong to, not the ink: home-nobg-LIGHT = black text
   (for the light overlay), home-nobg-DARK = white text (for the dark overlay). Same
   two-image pattern as the masthead .mark lockups. */
.hero-topimg.lockup-dark { display: none; }
:root[data-theme=dark] .hero-topimg.lockup-light { display: none; }
:root[data-theme=dark] .hero-topimg.lockup-dark { display: block; }
/* A quiet button on a dark ground: the light-surface border and ink would vanish. */
.btn.on-dark {
  background: transparent; color: oklch(0.99 0.002 70);
  border-color: oklch(0.99 0.002 70 / 0.5);
}
.btn.on-dark:hover { background: rgba(250, 200, 0, 0.25); border-color: oklch(0.99 0.002 70 / 0.8); }
/* The landing page's two Get Started buttons, and the plan screen's Start AI Render button —
   the primary conversion/spend action gets the accent outline, not every quiet button on the
   page (See examples, and the other _start_block states, stay neutral). `button.cta-accent`
   alongside `.btn.cta-accent`: Start AI Render is a bare <button> (already styled via the shared
   `button, .btn` rule), not an `<a class=btn>`, so the `.btn`-only selector would not have
   matched it. */
.btn.cta-accent, .btn.cta-accent:hover, button.cta-accent, button.cta-accent:hover {
  border-color: var(--accent);
}

/* Portrait hero on a phone, landscape everywhere else — the box is 313x506 at 360 wide, and a
   16:9 source centre-cropped into that shows 35% of its width.

   The hidden cut was never free, and this comment has been wrong about why twice. It claimed
   `preload=none` until 2026-08-22; the correction replaced that with "a display:none element is
   not rendered, so it is never fetched", which is also false. Chromium's media resource selection
   is decoupled from CSS box generation. Measured on the live page 2026-08-30 at 1280/768/390/375/
   320: BOTH files came back 206 with `Content-Range: bytes 0-N/N`, the entire file, whichever one
   the rule below had hidden — 2,611,437 + 2,131,373 bytes, about half of it discarded every load.

   `display: none` does suppress AUTOPLAY: the hidden element downloads, decodes a frame
   (`videoWidth` is set), and then sits at `paused`, `currentTime` 0. The cheap half of the old
   claim held up; the expensive half did not. What actually stops the download is `HERO_SRC_JS`
   in server.py, which ships both elements with `data-src` and promotes exactly one (spec EW §7).

   640px matches the masthead's own breakpoint rather than inventing a second idea of "phone" —
   including `.topline a.cta[href="/signup"]` at the `min-width: 641px` above (spec EY). It is
   also duplicated in HERO_SRC_JS now, the only copy of this number in JavaScript: move the phone
   breakpoint here and it MUST move there, or the element this rule shows and the element holding
   a src stop being the same element, and the hero is a blank box. */
.hero-tall { display: none; }
@media (max-width: 640px) {
  .hero-wide { display: none; }
  .hero-tall { display: block; }
}

/* Someone who has asked the OS to stop moving things means it — but they should still see the
   house. `display: none` on the video hides its POSTER too, which left a flat dark panel where
   this comment claimed a real room was showing; the panel had no image on it at all. The poster
   moves onto the section itself so the hero still opens on the property, and simply does not
   play. `--poster` is set inline per request by `home()` (spec FD §3a) to whichever scene the
   hero actually drew that day. This used to be a hardcoded url('/static/demo-poster.jpg') with a
   comment claiming "same file, so nothing extra is fetched" — true only while the hero was fixed,
   false for the nine days after it started rotating daily (2026-08-22), because demo-poster.jpg
   and scenes/qldhouse.jpg are the same 152,578 bytes and every other day's hero was a different
   house. The var's default keeps that same file as the fallback for the no-hero-picked case. */
@media (prefers-reduced-motion: reduce) {
  .hero-bg { display: none; }
  .hero-video {
    background: oklch(0.22 0.006 70) var(--poster, url('/static/demo-poster.jpg')) center/cover no-repeat;
  }
}

/* An inline <a> inside body copy is only as tall as its text -- the four /buy-bulk links measured
   72x18 CSS px at 390 wide, and now measure 144x28 and 163x31 (spec FE 6a). inline-block plus
   symmetric padding takes the box past 24 without moving the words: the padding grows the target,
   and the negative margin keeps the line box the height it already was so no surrounding copy
   reflows.

   This is a USABILITY improvement, not a conformance fix, and the comment said otherwise for
   about an hour on 2026-08-31. It claimed the links were "under WCAG 2.2 SC 2.5.8's 24x24 minimum
   and the only interactive elements on the site that failed it". The 72x18 was measured and
   correct; the conclusion was not. SC 2.5.8 carries five exceptions and Inline is one of them --
   "the target is in a sentence or its size is otherwise constrained by the line-height of
   non-target text" -- and W3C's Understanding document says outright that links inside a paragraph
   of text do not need to meet 24 by 24. All four are links in sentences. They conformed before
   this rule existed.

   Two things follow, and they are why this is written out rather than deleted. An automated
   sweep for boxes under 24x24 cannot read Inline, Spacing, Equivalent, User Agent Control or
   Essential, so it produces candidates, never failures -- three more links on /about turn up in
   such a sweep and all three pass under this same exception. And a false accessibility finding is
   the kind of claim a later audit cites back as established, so the correction has to live where
   the claim did. app.css:578 is the counter-example worth reading: it reasons about the SPACING
   exception by name for the masthead nav links, and identifies exactly where that exception stops
   covering them. That one is sound. This one was not. */
a.tap {
  display: inline-block;
  padding-block: 3px;
  margin-block: -3px;
}

/* --- gallery ------------------------------------------------------------ */
.gallery {
  display: grid; gap: 1.5rem;
  grid-template-columns: repeat(2, minmax(0, 1fr));
}
/* 1 column on phones — two :=160px cards are too tight for a caption. Dean, 2026-08-08. */
@media (max-width: 640px) {
  .gallery { grid-template-columns: 1fr; }
}
.gcard { margin: 0; }
.gcard video {
  width: 100%; aspect-ratio: 16 / 9; display: block;
  background: oklch(0.22 0.006 70); border-radius: var(--radius);
}
.gcard figcaption { display: grid; gap: 0.15rem; padding: 0.6rem 0.1rem 0; }
/* The card title is an <h2> (spec EZ §2 -- was a bare <b>, invisible to a crawler). h2's own
   rule is 1.125rem/500 (app.css's global h2), which would resize and re-weight every card title
   the moment the tag changed. Pinned back to <b>'s actual rendered appearance -- 1rem/700, not
   "the same as the surrounding body text" (1rem/400), which would silently de-bold every title. */
.gcard figcaption h2 { font-size: 1rem; font-weight: 700; margin: 0; }
.gcard .gnote { font-size: 0.875rem; color: var(--ink-3); }

/* Video sits on a dark ground, matching the review screens: the app is light because it is used
   in daylight, but footage always gets a dark surround so nothing competes with the picture. */
.stage.placeholder {
  display: grid; place-content: center; text-align: center; gap: 0.3rem;
  aspect-ratio: 16 / 9; background: oklch(0.22 0.006 70); color: oklch(0.99 0.002 70);
  border-radius: var(--radius);
}
.stage.placeholder .hint { color: oklch(0.75 0.006 70); }

/* --- "Images in. / Walkthrough out." (landing page only) -----------------------------------
   2026-08-26. Replaced BOTH the hero h1 and the "from listing photos, to ReelView" row below it,
   which carried the same sentence twice at two sizes. Photos in, the working orb, walkthrough
   out — one line. The orb takes the chevron's job; the chevrons stay on the "How it works"
   steps, which is a different claim (Dean: chevrons for steps, orb for before/after).

   An empty slot still gets .pic-ph, the app's own empty-photo tile — the same empty-state tone
   as everywhere else in the product, rather than a fabricated photo, since this is the one
   product whose whole pitch is not inventing things. */
.inout {
  /* /home's own device height, unchanged from the row this replaces: laptop frame 485x240,
     screen 380x213, phone 74x136 (measured). The band has to fit .wrap's 860px content column
     at that size, and quartet 266 + orb 104 + devices 504 leaves only ~-14px of slack — which is
     why the orb is centred in a flexible middle track rather than paying for a column of its
     own. See .inout-orb. */
  --device-h: clamp(140px, 20vw, 240px);
  /* Both re-measured for spec FF, 2026-09-01, when laptop-frame-music.png moved the cutout
     down 5 rows of the 475px artwork. These are not decoration: .inout-grid below is positioned
     off them so the 2x2 photo block and the laptop SCREEN start on the same line. Left at the
     old 0.03 the block sat 2.41px ABOVE the screen at 1440 -- measured, and exactly the 1.00%
     of --device-h that the cutout moved. The sheet did not anticipate this ripple. */
  --screen-h: calc(var(--device-h) * 0.8905);   /* the laptop cutout's height, from below */
  --screen-top: calc(var(--device-h) * 0.04);   /* and its inset from the frame's top */
  position: relative;   /* .inout-net and .inout-flyer are placed against the band (spec IX) */
  display: grid;
  /* The two blocks anchor to the column's edges and the middle track takes up the slack, so the
     band spans its full 860px and its edges line up with every other section's. An equal 1fr
     pair is the obvious alternative and is wrong here — the laptop+phone block is wider than the
     quartet, so equal columns push the phone 49px past the wrap's right edge (measured). */
  grid-template-columns: auto 1fr auto;
  grid-template-rows: auto auto;
  grid-template-areas:
    "inhead  .   outhead"
    "inmedia .   outmedia";
  align-items: start; justify-items: center;
  column-gap: 0;
  row-gap: clamp(0.2rem, 0.5vw, 0.4rem);
  margin: 1.5rem 0 18px;
}
/* The band lives inside .hero-copy, which is 52rem (832px) — 28px too narrow for it at
   --device-h: 240, which pushed the phone past the wrap's right edge (measured: 5px of
   overflow). Rather than shrink the laptop, the band steps back out to the wrap's own content
   box: .wrap is max-width 900px with 1.25rem of side padding, hence min(900px, 100vw) - 2.5rem.
   Scoped to .hero-copy so a .inout anywhere else keeps its container's width. */
.hero-copy .inout {
  width: calc(min(900px, 100vw) - 2.5rem);
  margin-left: 50%; transform: translateX(-50%);
}
/* 1.5rem/-0.015em is the h1 rule verbatim — this IS the page's headline now, so it is the h1's
   size, not a marketing-page-only one. `display: contents` on the h1 lifts its two spans into
   the grid so each lands in its own area, while the h1 itself still reads "Images in.
   Walkthrough out." exactly as the old one did. */
.inout-title { display: contents; }
.inout .h-in, .inout .h-out {
  margin: 0; font-weight: 500; letter-spacing: -0.015em;
  font-size: 1.5rem; line-height: 1.2; text-align: center; align-self: end;
}
.inout .h-in { grid-area: inhead; }
.inout .h-out { grid-area: outhead; }
/* 2x2, tight — one block of "everything you already have", not four separate thumbnails. Sized
   off the laptop SCREEN, not the laptop box: the cutout is 89.05% of --device-h, 4.00% down from
   the frame's top (.device-laptop.mocked video, below), so both numbers are read from there
   rather than eyeballed. Photos in and walkthrough out are then the same height and start on the
   same line, and that holds at any --device-h. Width follows from the height at 5:4 tiles, so
   the block cannot drift out of proportion. */
.inout-grid {
  grid-area: inmedia; justify-self: start;
  margin-top: var(--screen-top);
  height: var(--screen-h);
  width: calc((var(--screen-h) - 4px) * 1.25 + 4px);
  max-width: 100%;
  display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; gap: 4px;
}
.inout-grid img, .inout-grid .pic-ph {
  width: 100%; height: 100%; object-fit: cover; display: block; border-radius: 4px;
}
.inout-devices {
  grid-area: outmedia; justify-self: end;
  display: flex; align-items: flex-end; justify-content: flex-end;
}
/* The phone overlaps the laptop's right edge — one walkthrough on two screens, not two devices
   standing side by side. -0.32 puts it ~24px over the live screen rather than only over the
   bezel (Dean, 2026-08-26). KNOWN COST, chosen deliberately: the "AI-Animated from Real Photos"
   badge burned into the bottom-right of laptop.mp4 is clipped in half by the phone. That badge
   is a compliance disclosure — the .device-tablet.mocked video rule below exists for no other
   reason than to stop a crop eating it. -0.20 is the deepest overlap that leaves it whole (5px
   clear of the screen edge) if this is ever revisited; the other way out is a marketing cut of
   laptop.mp4 without the burned-in badge, since the disclosure's real job is on delivered
   videos, not on a shopfront loop. */
.inout-devices .device-phone.mocked {
  margin-left: calc(var(--device-h) * -0.32);
  position: relative; z-index: 1;
}
/* The connector (spec IX, 2026-09-14), replacing the 126px working orb that sat in the middle
   track from 2026-08-26 (its tuning notes are in the git history of this block). The middle track
   is left empty and still takes up the slack; the net is a canvas laid over the whole band, UNDER
   the photos and the devices, so the nodes that fall over the grid show through the three dimmed
   tiles and vanish under the lit one, and the tile in flight ends up hidden behind the laptop. */
.inout-net { position: absolute; inset: 0; z-index: 0; pointer-events: none; }
.inout-grid { position: relative; z-index: 1; }
.inout-devices { position: relative; z-index: 2; }
/* The headings too: stacked below 800px, "Walkthrough out." sits between the grid and the laptop,
   inside the fan, and a z-index:0 canvas would otherwise paint over its text. */
.inout .h-in, .inout .h-out { position: relative; z-index: 1; }
.inout-flyer {
  position: absolute; z-index: 1; object-fit: cover; border-radius: 4px; pointer-events: none;
  box-shadow: 0 12px 30px rgba(0, 0, 0, 0.55), 0 0 0 2px var(--accent);
  will-change: transform, opacity;
}
.inout-grid img { transition: opacity 0.5s ease, box-shadow 0.3s ease; }
.inout-grid.lit img { opacity: 0.55; }
.inout-grid.lit img.on { opacity: 1; box-shadow: 0 0 0 2px var(--accent); }
@media (prefers-reduced-motion: reduce) { .inout-grid img { transition: none; } }
/* Stacked below 800px: the orb still reads as in -> work -> out vertically, which is the one
   thing a rotated chevron would have needed a second asset for. */
@media (max-width: 800px) {
  .inout {
    --device-h: clamp(160px, 42vw, 240px);
    grid-template-columns: 1fr;
    grid-template-areas: "inhead" "inmedia" "outhead" "outmedia";
    row-gap: 1rem;
  }
  .inout .h-in, .inout .h-out { align-self: center; }
  .inout-grid { margin-top: 0; justify-self: center; }
  .inout-devices { justify-self: center; justify-content: center; }
}
.pic-ph {
  aspect-ratio: 16 / 9; border-radius: 4px; border: 1px solid var(--line);
  background: var(--surface-2); display: grid; place-content: center; color: var(--ink-3);
}
.pic-ph svg { width: 22px; height: 22px; }
.device-laptop {
  flex: 1 1 380px; max-width: 480px;
  background: oklch(0.22 0.006 70); border-radius: var(--radius); padding: 0.6rem;
}
.device-laptop video {
  width: 100%; aspect-ratio: 16 / 9; display: block; border-radius: var(--radius);
  background: oklch(0.15 0.006 70);
}
/* Portrait, sized between the laptop and the phone — the mockup itself is a portrait tablet as
   of 2026-08-04 (was landscape), which is also why its video is the vertical clip: the screen
   cutout is now the right shape for it, not a landscape box cropping a portrait source. */
.device-tablet {
  flex: 0 0 240px; background: oklch(0.22 0.006 70); border-radius: var(--radius); padding: 0.5rem;
}
.device-tablet video {
  width: 100%; aspect-ratio: 4 / 3; display: block; border-radius: var(--radius);
  background: oklch(0.15 0.006 70);
}
.device-phone {
  flex: 0 0 150px; background: oklch(0.22 0.006 70); border-radius: var(--radius); padding: 0.5rem;
}
.device-phone video {
  width: 100%; aspect-ratio: 9 / 16; display: block; border-radius: var(--radius);
  background: oklch(0.15 0.006 70);
}
/* --- photoreal device mockups (2026-08-04) --------------------------------------------------
   `.mocked` swaps the plain dark box for an AI-generated, chroma-keyed PNG (kie.ai
   nano-banana-pro): the studio background and the screen are both transparent in the source
   file, so .device-frame (normal flow, sets the box's size from its own aspect ratio) paints
   over an absolutely-positioned video sized to exactly the screen's cutout. `position:relative`
   on the frame img (not static) is required — an in-flow static element would still paint BELOW
   an absolutely-positioned sibling regardless of DOM order; giving it a position (with no offset,
   so it stays in flow) makes it a stacking participant, and tree order then puts it above the
   video that precedes it in the markup. The percentages are each PNG's own measured screen
   bounding box — see the mockup generation notes in DESIGN.md; a replacement PNG needs its own
   numbers re-measured, they are not proportional across devices.

   Heights follow real device screen sizes, not a shared box (corrected 2026-08-04: an equal
   height for all three was the wrong fix — it made the phone and tablet render as tall as the
   laptop, when a phone screen is genuinely about a third of a laptop screen's height). One fluid
   reference (`--device-h`, standing for the laptop's own height — a 14" 16:10 screen) and the
   other two are that reference times their real screen-height ratio to it: 11" 4:3 tablet
   (6.60in / 7.42in = 0.889), 6.1" ~19.5:9 phone (2.55in / 7.42in = 0.344 — bumped 50% to 0.516,
   2026-08-04, true scale read as too small to be legible). Scaling `--device-h` scales all three
   together, in the same proportion, from one number. */
.device-laptop.mocked, .device-tablet.mocked, .device-phone.mocked {
  position: relative; background: none; padding: 0; border-radius: 0;
  flex: 0 0 auto; width: auto; max-width: none;
}
.device-laptop.mocked { height: var(--device-h); }
.device-tablet.mocked { height: calc(var(--device-h) * 0.889); }
.device-phone.mocked { height: calc(var(--device-h) * 0.568); }
/* `align-items: flex-end` on the containing row (.inout-devices today, .device-row until
   2026-08-26) lines up the BOXES' bottom edges, not the SCREENS' — each mockup has a different
   base/bezel depth below its screen (the laptop's keyboard sliver is by far the deepest), so the
   video content itself sat at different heights even though the devices looked bottom-aligned.
   Nudges each screen's bottom up to match the laptop's own (the deepest gap, so only tablet/phone
   need a positive margin-bottom — pushing the laptop down instead would need a negative one and
   risk clipping against the row above). Each constant is (laptop's own screen-to-box-bottom gap,
   8.11%, minus that device's own gap) as a fraction of --device-h — not of the device's own
   height, since gaps were measured as a % of each device's OWN box, and everything here is scaled
   off the one shared laptop-height reference. 2026-08-04. */
.device-tablet.mocked {
  margin-bottom: calc(var(--device-h) * 0.0565);
  /* Phone-to-tablet spacing, from when the row carried three devices and a 2rem flex gap: this
     topped that gap up to an exact 60px (2026-08-04). No page renders the tablet today — the
     landing band is laptop + phone — so this is inert, kept with the rest of the tablet mockup
     rather than half-removed. */
  margin-left: calc(60px - 2rem);
}
.device-phone.mocked { margin-bottom: calc(var(--device-h) * 0.0614); }
.mocked .device-frame { position: relative; width: auto; height: 100%; display: block; }
.mocked video { position: absolute; aspect-ratio: auto; object-fit: cover; border-radius: 0; background: #000; }
/* Spec FF, 2026-09-01: Dean re-drew the laptop as laptop-frame-music.png and the screen
   cutout moved DOWN 5 rows of the 475px artwork. All four values are restated, not just
   `top`, so no stale number sits beside a freshly measured one.
   Measured by leak fraction rather than an alpha threshold: a row/column shows page
   background in proportion to (255 - alpha) / 255, and the screen is the CONTIGUOUS run
   bounded by opaque bezel -- without the contiguity the run escapes into the transparent
   margin around the laptop and returns the whole image, a stable WRONG answer.
   Vertically the run is rows 19-441 and is stable for any leak threshold from 6% to 75%;
   horizontally cols 104-855, stable from 11% to 80%. The two axes differ because the top
   transition row leaks 75% and the side transition columns only 11%, so the top edge has
   to be covered and the sides do not. */
.device-laptop.mocked video { top: 4.00%; left: 10.83%; width: 78.33%; height: 89.05%; }
/* object-position: bottom — the tablet's screen cutout (0.695 aspect) is wider than the 9:16
   clip (0.5625) it plays, so `cover` scales to match width and crops the excess height. Centered
   (the default) cropped it evenly off top AND bottom, which cut into the "AI-Animated from Real
   Photos" badge burned into the bottom-right corner of the footage — a compliance disclosure, not
   decoration (see "Built for what you are actually liable for" above), so losing it on one of
   three mockups isn't a cosmetic miss. Anchoring the crop to the bottom takes all the excess off
   the top (ceiling/fan) instead, which has nothing load-bearing in it. The phone plays the same
   clip but its cutout is narrower than 9:16, so it crops the sides, not the bottom, and never
   loses the badge — this rule is tablet-only, not a blanket default. 2026-08-04. */
.device-tablet.mocked video { top: 3.06%; left: 4.87%; width: 90.77%; height: 94.17%; object-position: bottom; }
.device-phone.mocked video { top: 4.29%; left: 8.53%; width: 81.47%; height: 91.90%; }

.wrap > section { margin: 2.75rem 0; }
.wrap > section > p, .wrap > section > ul, .wrap > section > ol { max-width: var(--measure); }
/* The "One credit, start to finish" list has two long items (the credits line and the
   AI-Animated disclosure) that run ~103 chars/line at the generic 90ch measure; a 72ch cap
   brings them to a readable length (impeccable-audit, 2026-08-08). Scoped so the other
   `.wrap > section ul`s (terms, etc.) keep their measure. */
.wrap > section > ul.credit-list { max-width: 72ch; }
/* Same escape as .sub.full / .acct-body.full: the pricing hint under "What it costs" is plain
   fact, not prose to keep at reading width, and the 90ch cap was wrapping it narrower than the
   pack tiles row directly above it. Dean, 2026-08-03. */
.wrap > section > p.full { max-width: none; }

/* Numbered because the order IS the promise — nothing costs anything until step 3. */
.steps { counter-reset: s; list-style: none; padding: 0; margin: 0; }
.steps li {
  counter-increment: s; position: relative; padding: 0 0 0 2.4rem; margin-bottom: 0.9rem;
}
.steps li::before {
  content: counter(s); position: absolute; left: 0; top: 0.05rem;
  width: 1.6rem; height: 1.6rem; border-radius: 50%;
  display: grid; place-content: center;
  background: transparent; border: 1px solid var(--accent);
  /* var(--ink), not literal white: this ring sits directly on the page background, which is
     light by default (no dark-mode media query, only the explicit toggle) — white text here
     would be invisible until someone switches themes. --ink already reads white in dark mode. */
  font-size: 0.8125rem; font-weight: 500; color: var(--ink);
}

/* Pack tiles are anchors on the landing page and forms on /buy — same look either way. */
a.pack { text-decoration: none; color: inherit; display: block; }
a.pack:hover { border-color: var(--accent); }

.wrap > section.close {
  padding-top: 0;
  /* Tightened 2.75rem -> 1.5rem on Dean's 2026-08-08 call: the collapse above the bottom CTA
     read as a full section-break before the closing button. Needs `.wrap > section.close` to
     out-specificity `.wrap > section` (0,1,1) — `.close` alone loses. */
  margin-top: 1.5rem;
}
.wrap > section.costs { margin-bottom: 1.5rem; }  /* same out-specificity reason */
/* /gallery positions the costs section under the cards at 2rem = 32px (Dean's 2026-08-08 call:
   44px default was too much, 22px too tight); scoped to the gallery's own structure, so /home's
   restored costs section keeps the normal rhythm. */
.gallery + section.costs { margin-top: 2rem; }
/* The bottom "Get Started" gets the same 75% white resting fill as the hero one, on the LIGHT
   theme only (Dean, 2026-08-08, matching the hero rule). Scoped to .close's button: the /jobs
   "Build Reel" is also .btn.cta-accent and must stay as-is. In the DARK theme it needs the
   transparent background AND white ink — the scoped light-rule color (oklch 0.16, dark ink)
   otherwise wins and the text vanishes on the dark page; the hero override sets both. */
.close .btn.cta-accent { background: rgba(255, 255, 255, 0.75); color: oklch(0.16 0.006 70); }
:root[data-theme=dark] .close .btn.cta-accent { background: transparent; color: oklch(0.99 0.002 70); }
/* The bottom Get Started's hover never fired: its resting fill (.close .btn.cta-accent, 0,3,0)
   out-specified the shared button:hover gold tint (0,2,0), so it sat on the white chip forever.
   This dedicated hover (0,4,0) restores the same gold-tint swap the hero gets — Dean, 2026-08-10. */
.close .btn.cta-accent:hover { background: rgba(250, 200, 0, 0.3); border-color: var(--accent); }
:root[data-theme=dark] .close .btn.cta-accent:hover { background: rgba(250, 200, 0, 0.25); border-color: var(--accent); }
/* The empty-state "Get Started" button and the payment receipt's "Create Walkthrough" button
   (#4/#5, 2026-08-10; the receipt's label changed in spec JB) carry the same conversion format as
   the hero/.close CTAs: 75% white chip on light, transparent on dark, gold border, gold-tint
   hover. `.gs` is the class name from when both read "Get Started"; it marks the ones that
   should match the hero, whatever they now say. */
.gs, .gs:hover { border-color: var(--accent); }
.gs { background: rgba(255, 255, 255, 0.75); color: oklch(0.16 0.006 70); }
.btn.gs:hover { background: rgba(250, 200, 0, 0.3); }
:root[data-theme=dark] .gs { background: transparent; color: oklch(0.99 0.002 70); border-color: var(--accent); }
:root[data-theme=dark] .btn.gs:hover { background: rgba(250, 200, 0, 0.25); border-color: var(--accent); }

/* --- drag to reorder -----------------------------------------------------
   Layered over the up/down arrows, which stay: they need no JS, work one-handed on a phone and
   are keyboard accessible. This is the faster path for someone at a desk moving a scene four
   places at once. */
/* One width for the grip and for the space the un-draggable rows leave in its place, so the
   thumbnails down the plan stay on a single line however the grip is styled later. */
/* Wide enough for ☰ at its rendered size. Too narrow and the glyph overflows its own column,
   pushing the thumbnails on draggable rows a couple of pixels right of the intro/outro rows —
   which is exactly the misalignment the shared column exists to prevent. */
:root { --grip-w: 1rem; }

.grip, .grip-gap {
  align-self: center; flex: 0 0 var(--grip-w);
  margin-left: -0.25rem; line-height: 1;
}
.grip {
  padding: 0.4rem 0;
  font-size: 1.05rem; line-height: 1;   /* ☰ is one glyph, so no letter-spacing correction */
  color: var(--ink-3); cursor: grab; user-select: none;
}
.grip:active { cursor: grabbing; }
/* The intro and outro are not scenes — they are text over the first and last clips, so they
   have no position to drag to. They still need the grip's column, or their thumbnails sit a
   grip-width left of every scene below them and the plan reads as two misaligned lists. */
.grip-gap { visibility: hidden; }

/* The grip used to hide on touch — native HTML5 DnD can't start a drag from touch, so a grip
   there was a control that lies and the arrows were the touch path. SortableJS's forceFallback
   (2026-08-05) brings touch drag, so the grip now works and stays visible on hover-less
   devices; the arrows remain as the keyboard/accessible path regardless. */

/* SortableJS drag states (the vendored library's classes; `.scene.saving` below is our own).
   `sortable-ghost` is the placeholder left at the drop position; `sortable-drag` styles the row
   while it is being dragged; `fallbackClass` (sortable-fallback) is the card CLONE that follows
   the cursor in forceFallback mode — the real card contents (thumb + name + note), flat, with an
   accent frame so it reads as "lifted" without a drop shadow (the flat system has none). */
.scene.sortable-drag {
  opacity: 0.4; background: var(--surface-2);
}
/* `sortable-chosen` lands on the row the moment the grip is picked up, before the card visibly
   lifts — a bare state class with no style reads as nothing happened. A second of urgency: the
   row darkens and edges up a fraction, so the grab -- which is instant -- is seen. */
.scene.sortable-chosen {
  background: var(--surface-2);
  transform: translateY(-1px);
}
.scene.sortable-ghost {
  opacity: 0.5; background: var(--surface-2);
  outline: 2px dashed var(--line-firm); outline-offset: -2px;
}
.scene.sortable-fallback {
  opacity: 0.95; background: var(--surface); color: var(--ink);
  /* Square corners + full-strength accent at 1px, on request (2026-08-05; was 2px @50%, then 50%).
     Scoped to .scene so it beats the later `.scene { border-bottom: ... }` row-divider rule — at
     equal specificity the later rule won and the ghost's bottom edge silently dropped back to the
     1px line. */
  border: 1px solid var(--accent); border-radius: 0;
  /* The clone carries the whole row (buttons included); hide the row's actions so the ghost is
     the card content, not a second control cluster floating over the page. */
}
.scene.sortable-fallback .actions, .scene.sortable-fallback .grip, .scene.sortable-fallback .status { display: none; }
.scene.saving { opacity: 0.6; pointer-events: none; }

/* Legal prose. The only pages here with real body copy, and they are the ones someone reads
   under pressure — a bullet list of what a credit does should not run edge to edge.

   `:not(.scenes)` and `:not(.sub)`: this was capping ul.scenes at 68ch, so the plan list sat at
   587px inside an 1180px page and every scene card was half width. A reading measure belongs on
   prose, not on a list of controls — the same over-broad-selector mistake as `.top nav`. */
/* One number for "as wide as prose may get". It was 68ch here and 66ch on the legal sections —
   two values for one idea, differing by less than a word.
   68 -> 90ch (Dean, 2026-08-03): 68ch measured 653px against this frame's actual font metrics —
   on the 900px page that is a 207px dead margin down the right of every paragraph, "text not
   using the page" on every prose-heavy screen there is (Terms, Privacy, Help). 90ch (~864px)
   exceeds the 900px page's 860px content box, so prose there now runs edge to edge same as any
   other block; on the 1180px job frame it still leaves real, deliberate margin rather than a
   full-bleed line nobody's eye could track. */
:root { --measure: 90ch; }

.wrap > ul:not(.scenes):not(.packs):not(.gallery) {
  padding-left: 1.2rem; margin: 0 0 1rem; max-width: var(--measure);
}
.wrap > ul:not(.scenes):not(.plan-tips) li { margin-bottom: 0.4rem; }
/* `.sub` was excluded here, and it is the longest single paragraph on most screens — 122ch on
   /new and 158ch on the plan board, measured. The rule capped everything except the text that
   most needed capping. `.cta` stays out: it is a centred action line, not a paragraph. */
.wrap > p:not(.cta) { max-width: var(--measure); }
/* A `> p` selector, deliberately: /help's prose sits one level deeper (inside `.help`, in
   server.py) specifically to fall outside this rule, so it can share the page's table's own
   width instead of the reading measure — Dean, 2026-08-03. Read the comment on that `<div>`
   before "fixing" this into a descendant selector. */

/* Footer. Still quiet — a legal and support obligation, not a navigation surface — but it is now
   PERSISTENT, a narrow band pinned to the bottom edge the way the masthead is pinned to the top.
   Dean, 2026-08-02. The old note here said it "sits below the fold's business"; that was written
   for a page you could reach the end of, and the plan and review boards scroll for sixteen scenes.
   The moment an agent wants Support is the moment something has gone wrong, and asking them to
   scroll past sixteen scenes of a job they are worried about to find it is the wrong answer.

   Sticky, not fixed, and the difference is load-bearing: sticky keeps the band in flow, so it
   reserves its own height at the end of the document and the last line of a page is never
   permanently buried under it. Fixed would overlay the final paragraph on every page forever.

   The same translucent-blur treatment as the masthead, for the same reason: the boards scroll
   photo grids and footage under both edges, and an opaque bar reads as a collision where a
   blurred one reads as a surface passing beneath. Two frames, one material. */
.foot {
  position: sticky; bottom: 0; z-index: 20;
  border-top: 1px solid var(--line); margin-top: 3rem;
  background: color-mix(in oklch, var(--surface) 88%, transparent);
  -webkit-backdrop-filter: saturate(1.6) blur(10px);
  backdrop-filter: saturate(1.6) blur(10px);
  font-size: 0.8125rem; color: var(--ink-2);
}
/* The footer is frame, not content: --frame regardless of which width the page chose, so its
   left edge matches the wordmark on every page rather than following the content in and out. */
/* Three explicit tracks, not a flex row — Dean, 2026-08-03, the same 1fr/auto/1fr split the
   masthead uses (line ~206): the legal line and the social icons are two independent, optional
   pieces of content, and the links between them stay on the row's true centre only if nothing
   here depends on which of those two happens to be present. nowrap is still what makes "narrow"
   true: wrapping was free when the band sat at the end of the document; on a persistent bar a
   second row is permanent chrome on every screen, and this product is used on a 760px-tall phone
   that already spends 138px on the masthead. */
.foot .wrap {
  max-width: var(--frame);
  display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
  flex-wrap: nowrap; align-items: center; gap: 0 1rem;
  padding: 0.15rem 1.25rem;
}
.foot-legal { justify-self: start; white-space: nowrap; }
.foot-links { justify-self: center; display: flex; align-items: center; gap: 1rem; white-space: nowrap; }
/* 44px targets in a band that stays about 40px tall. The negative margin is the same device the
   social icons already used and for the same reason: the TARGET grows, the visual box does not,
   so the bar keeps its quiet weight while a fingertip still lands. Without this the links were
   20px tall — the icons beside them had been given proper targets and the words next to them
   never were, which is the kind of inconsistency that only shows up when you measure both. */
.foot a {
  color: inherit; text-decoration: none;
  display: inline-flex; align-items: center; min-height: 24px; margin: 0;
}
.foot a:hover { color: var(--accent-ink); text-decoration: underline; }
/* justify-self: end, not margin-left: auto — Dean, 2026-08-03: auto margins centred the LINKS
   against wherever the icons happened to end, which is not the same thing as the row's own centre
   once a third, independent piece of content (the legal line) sits on the other side. */
.foot-social { justify-self: end; display: flex; gap: 0.3rem; align-items: center; }
/* 20px box, 36px touch target (Dean, 2026-08-18: 8px padding each side, was 6px). The icons
   are chrome an agent never needs to hit accurately, but a target smaller than a fingertip is
   a target that gets mis-tapped, not one that gets ignored — 36px still clears the 24px
   minimum while the social row reads tighter. */
.foot-social a { display: grid; place-items: center; min-width: 36px; min-height: 24px; margin: 0; }
/* Bare glyphs, no badge/circle — on request, 2026-08-04 (reversing the outlined-circle treatment
   from the same day). Each is a supplied PNG (static/icons/, solid black on transparent), painted
   with currentColor through its own alpha via a CSS mask rather than shown as a plain <img> —
   a flat black icon would be fine in light theme and invisible in dark, and this avoids needing a
   second dark-theme export of each file. mask-image itself is inline (per-icon file, set in
   server.py); everything shared about the technique lives here instead of repeating in every
   inline style attribute. */
.soc {
  width: 20px; height: 20px; display: block; background-color: currentColor;
  mask-size: contain; mask-repeat: no-repeat; mask-position: center;
  -webkit-mask-size: contain; -webkit-mask-repeat: no-repeat; -webkit-mask-position: center;
}
.foot-social a:hover .soc { background-color: var(--accent-ink); }
/* The icons used to take a full-width row of their own below 640. On a band that is always on
   screen that is a second permanent row for decoration, so they stay on the one line and the
   gap tightens instead. The legal line is the one column with somewhere to give: it drops out
   below 560px rather than fight the links and icons for a row that was already tight before it
   existed — full text remains one tap away on every page, via Terms and Privacy. */
@media (max-width: 640px) {
  .foot .wrap { padding: 0.15rem 0.9rem; gap: 0 0.75rem; }
  .foot-social { gap: 0.15rem; }
}
@media (max-width: 560px) {
  .foot .wrap { grid-template-columns: auto minmax(0, 1fr); }
  .foot-legal { display: none; }
  .foot-links { justify-self: start; }
}

/* Support form. The only multi-line input in the product, so it inherits the field styling and
   adds just what a textarea needs: room to write, and no horizontal resize that would let it
   escape its column. */
textarea { min-height: 8rem; resize: vertical; font: inherit; line-height: 1.55; }

/* --- job header ----------------------------------------------------------
   Title left, counters right when a page still keeps its counters here (the plan phase's "N
   scenes" tally; the render/review/done ones moved into the steps-bar's own shoulder — see
   .step-tiles). Run together in a sentence they read as prose to be parsed; stacked and
   right-aligned they are a status panel to be glanced at, which is what they are.

   The number is bold, never coloured: this stylesheet reserves the accent for actions, and a
   counter is not one. */
.jobhead {
  display: flex; align-items: flex-start; justify-content: space-between; gap: 1.5rem;
  margin-bottom: 0.35rem;
}
.jobhead h1 { margin-bottom: 0; }
/* spec HG, 2026-09-03: the h1 and the busy orb, grouped into ONE flex item so `.jobhead`'s own
   `justify-content: space-between` still treats the row as two things (title vs. the How-to
   link) rather than three, which would otherwise push the orb into the middle third whenever
   both it and the link are present (plan or done, mid-regen). Same gap the two used to get from
   `.jobhead`'s own `gap: 1.5rem` when they were its direct children. */
.jh-title { display: flex; align-items: flex-start; gap: 1.5rem; min-width: 0; }
/* The busy cycler (status text) rides in the jobhead, bottom-aligned with the 88px orb: .jh-lead
   is a column that stretches to the header's height (set by the orb) and space-between's the h1
   to the top and the cycler to the bottom, so the status line's bottom meets the orb's bottom on
   curate, render and build — no per-phase margins. (Dean, 2026-08-17) */
.jh-lead { align-self: stretch; display: flex; flex-direction: column; justify-content: space-between; flex: 1 1 auto; min-width: 0; }
/* The busy copy (cycler + the curate timer line) sits together below the h1 inside the jobhead.
   jh-copy groups them so the whole block rides as one unit under the title; the .sub timer loses
   the global 1.75rem bottom margin it would inherit, keeping the header tight. (Dean, 2026-08-17) */
.jh-copy { display: flex; flex-direction: column; }
.jh-copy .sub { margin: 0; }
.counters {
  flex: none; text-align: right;
  font-size: 0.875rem; color: var(--ink-2); line-height: 1.45;
}
.counters p { margin: 0; }
.counters b { color: var(--ink); font-weight: 500; font-variant-numeric: tabular-nums; }
/* The changes deadline qualifies the count above it rather than being a count of its own, so
   it sits in the label column at the muted weight (Dean, 2026-08-18). */
/* A skipped count is context, not progress — it should not read as loudly as the live one. */
.counters .quiet-count, .counters .quiet-count b { color: var(--ink-3); }
@media (max-width: 560px) {
  .jobhead { flex-direction: column; gap: 0.4rem; }
  /* Same stack the h1/orb pair fell into automatically before spec HG's `.jh-title` grouping
     existed -- explicit now because `.jobhead`'s own column rule above only reaches its direct
     children, and `.jh-title` is one of those, not h1 and the orb individually any more. */
  .jh-title { flex-direction: column; align-items: flex-start; gap: 0.4rem; }
  /* Kept right-anchored rather than falling to `.jobhead`'s own align-items: flex-start once
     stacked -- same reasoning as `.workflow-head .howto`'s narrow-width rule above. */
  .jobhead .howto { margin-left: auto; }
  .plan-lead { flex-direction: column; align-items: flex-start; gap: 0.4rem; }
  .counters { text-align: left; }
}

/* The How-to Guide link's title row for pages with no other flex title-row of their own to join
   (spec HG, 2026-09-03): /new, /jobs/{id}/share, /gallery. `.jobhead`/`.authhead` are the same
   idea already built for their own pages; this is that shape for everywhere else. Top-aligned
   with the heading (§3), never baseline -- the link's two-line label has no text baseline to
   share with the h1's. */
/* margin-bottom moved from the h1 (zeroed here) to this row, same 0.35rem the page-wide h1 rule
   already carries -- so the gap before whatever follows (a .sub paragraph, on all three pages
   this is used on) is unchanged from before this component existed, not merely "close enough". */
.workflow-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 0.35rem; }
.workflow-head h1 { margin: 0; }
/* `.howto`'s own margin-left:auto, not align-items on the container -- align-items would also
   push the h1 to the right edge once stacked, which is not wanted; a CROSS-axis auto margin on
   just the link keeps it right-anchored (matching its own right-aligned label) while the heading
   stays where headings always sit, at the left. */
@media (max-width: 560px) {
  .workflow-head { flex-direction: column; gap: 0.5rem; }
  .workflow-head .howto { margin-left: auto; }
}

/* Signup's heading row: Log in sits opposite it rather than buried in the hint line below the
   form, since it is the other thing a stranger landing on /signup might actually want. Login's
   own mirror of this (a Register button, labelled Sign up until 2026-08-11) moved down beside
   its submit button instead, 2026-08-10 — see _auth_form's and LOGIN_MODAL_HTML's own comments. */
/* align-items: flex-start, not baseline (spec HG, 2026-09-03) -- baseline aligned fine against a
   lone h1 (its only content before this sheet), but the How-to link's two-line label-plus-icon
   has no text baseline to share with an h1 the same way, and spec HG §3 wants every placement
   top-aligned with the heading, not with whatever the other item's own text sits on. Only
   producer of .authhead is /signup, which is the page gaining that link. */
.authhead { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
.authhead h1 { margin: 0; }
@media (max-width: 560px) {
  .authhead { flex-direction: column; gap: 0.5rem; }
  .authhead .howto { margin-left: auto; }
}
/* The modal's heading is an h2 (see LOGIN_MODAL_HTML's comment — one h1 per page, and the modal
   sits on top of a page that already has its own), given the h1 rule's exact weight back. Login
   had no bottom margin (fields follow immediately); the delete dialog's body paragraph wants a
   little air, so it gets its own spacing. */
.rv-modal h2 { font-size: 1.5rem; font-weight: 500; letter-spacing: -0.015em; margin: 0; }
#del-modal h2 { font-size: 1.25rem; margin-bottom: 0.75rem; }
/* The delete dialog's copy is centred — heading, the [job] [address] reference line and the
   warning together read as a single statement. The .cta action row stays where it is: it is a
   flex row (form.stack > p.cta), so text-align on the panel does not move the buttons (Dean,
   2026-08-10). */
#del-modal .modal-panel { text-align: center; }
#del-modal .modal-panel .cta { text-align: left; }
/* The purchase receipt (spec JB, Dean 2026-09-25): heading, note and button centred, in the
   pop-up and in the no-JS in-flow block alike. Keyed on its own class rather than on
   `.rv-modal .modal-panel`, which would also pull the login modal's heading and fields off the
   left edge. The h1 and .note are block-level, so their BOXES already span the container; this
   moves their TEXT, which is what a centring check has to measure. */
.receipt { text-align: center; }
/* The login modal's error shares its row with Log in and Sign up, and the buttons have to stay
   where they are whether or not there is a message (Dean, 2026-09-05). `.cta` wraps, so a plain
   inline span between them pushed Sign up onto a line of its own the moment an error appeared --
   at 320, 360 AND 412px, and at 900 too with the longer copy, because the panel is narrow at
   every viewport. Giving the message the leftover width instead means it wraps inside its own
   space rather than shoving the button along: measured at 320/360/412/900, both buttons hold the
   same x with a message showing and without one. `min-width: 0` is the half that matters -- a
   flex item's automatic minimum is its content, so without it the span refuses to shrink and
   nothing changes. */
#login-modal .cta .err { flex: 1 1 0; min-width: 0; }
/* The three copy lines — Job ID, address/title, "All assets…" — sit tighter together than the
   default 1em p margin: they read as one block under the heading, not three separate calls to
   attention (Dean, 2026-08-10). Only direct children; the .cta button row lives inside the
   form and is not affected. */
#del-modal .modal-panel > p { margin: 0.25rem 0; }
/* A little more air under the last copy line ("All assets…") so it clears the action row that
   follows (Dean, 2026-08-10). */
#del-modal .modal-panel > p:last-of-type { margin-bottom: 0.85rem; }
/* The DELETE button's border is #cc0000 in light, #a0282a in dark (Dean, 2026-08-10,
   2026-08-17), and hover drops to the darker #a0282a at the same opaqueness the base .quiet
   hover uses (25% wash — the button's own background stays transparent otherwise). */
#del-modal-go { border-color: #CC0000; }
#del-modal-go:hover { background: rgba(160, 40, 42, 0.25); border-color: #A0282A; }
/* The plan screen's Delete sits in the steps-bar shoulder, but it IS the same destructive
   action as the modal's — same red border, same hover, so the two deletes read as one
   control in two places (Dean, 2026-08-12). */
.step-esc .rv-del-open { border-color: #CC0000; }
.step-esc .rv-del-open:hover { background: rgba(160, 40, 42, 0.25); border-color: #A0282A; }
/* Dark theme: the light #cc0000 border floats against dark surfaces, so all three
   destructive controls drop to the darker #a0282a (Dean, 2026-08-17). */
[data-theme=dark] #del-modal-go,
[data-theme=dark] .step-esc .rv-del-open,
[data-theme=dark] .errtop .rv-del-open { border-color: #A0282A; }
/* Register sits far right of the Log in button's own row — see LOGIN_MODAL_HTML's comment on why
   this is an auto margin on the link rather than justify-content on .cta itself: the error
   message shares this row and must stay packed against the button it reports on. */
#login-modal .cta a { margin-left: auto; }
/* The standalone /login page mirrors the modal: Register shares the Log in button's row,
   pushed far right with the same auto margin (the error span stays packed on the button).
   Scoped to the auth forms so no other .cta row is affected. (Dean, 2026-08-11) */
form.stack > p.cta > a { margin-left: auto; }

/* --- login modal ---------------------------------------------------------------------------
   Native <dialog> + showModal(): free focus-trap, free Escape (routed through the same closing
   animation as every other close path via the `cancel` listener in LOGIN_MODAL_JS), free `inert`
   on the rest of the document, and top-layer stacking that needs no z-index — a modal dialog
   paints above the sticky masthead (z-index 20, .top) and the hero video's own stacking context
   (.wrap:has(> .hero-video), z-index 0/-1/-2) regardless of any number written here, because the
   top layer sits outside the normal stacking order entirely. Deliberately no z-index below.
   Flat Ground Rule: no box-shadow. The lift is tonal — a --surface panel on a dimmed backdrop,
   plus a 1px --accent outline (Dean, 2026-08-11) at the same weight the pack/pill/upload-drop
   accent borders already use elsewhere — a border, not a shadow, so the rule still holds. */
dialog.rv-modal {
  padding: 0; border: 1px solid var(--accent); max-width: 26rem; width: calc(100% - 2rem);
  border-radius: 0; background: var(--surface); color: var(--ink);
}
dialog.rv-modal .modal-panel { padding: var(--pad); position: relative; }
/* Dims toward the same fixed dark ground .stage/the hero video use (oklch 0.20), not --ink —
   --ink flips to near-white in dark theme and would tint the backdrop pale instead of dimming
   it. */
dialog.rv-modal::backdrop {
  background: color-mix(in oklch, oklch(0.20 0.006 70) 55%, transparent);
  -webkit-backdrop-filter: blur(4px);
  backdrop-filter: blur(4px);
}
/* Never animate a layout property — opacity/transform only. :not(.closing) makes the two
   animations mutually exclusive by selector rather than by source-order tie-break, so reordering
   these rules later cannot silently break the close animation. */
dialog.rv-modal[open]:not(.closing) { animation: modal-in 200ms var(--ease); }
dialog.rv-modal[open]:not(.closing)::backdrop { animation: modal-backdrop-in 200ms var(--ease); }
dialog.rv-modal.closing { animation: modal-out 160ms var(--ease) forwards; }
dialog.rv-modal.closing::backdrop { animation: modal-backdrop-out 160ms var(--ease) forwards; }
@keyframes modal-in { from { opacity: 0; transform: translateY(0.5rem) scale(0.98); } }
@keyframes modal-backdrop-in { from { opacity: 0; } }
@keyframes modal-out { to { opacity: 0; transform: translateY(0.5rem) scale(0.98); } }
@keyframes modal-backdrop-out { to { opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
  /* `!important` deliberately. `:not(.closing)` on the open rules above puts them at (0,3,1) --
     one class above this guard's (0,2,1) -- and a media query adds no specificity, so this block
     lost the cascade and modals animated open for someone who asked the OS for no animation. The
     close animation only ever worked because its selectors TIE and this block sits later, which
     is a source-order tie-break of exactly the kind the comment at :1594 warns against.
     `!important` makes the guard independent of both specificity and order, so a future animation
     rule cannot silently escape it (spec CC).
     The cost, stated because it is real: this cannot be overridden without another `!important`,
     including by a future reduce-SAFE animation -- a plain opacity fade with no transform is
     within the spirit of the guideline rather than against it, and its author will have to be
     explicit here too. Accepted deliberately: a guard that is too strong fails loudly, since the
     author's animation visibly does not run and they come and read this, while a guard that is
     too weak fails silently, which is the bug spec CC fixed and which survived in this file until
     a verification step tripped over it by accident (spec CF). */
  dialog.rv-modal[open], dialog.rv-modal.closing,
  dialog.rv-modal[open]::backdrop, dialog.rv-modal.closing::backdrop { animation: none !important; }
}
/* Same :has() idiom .wrap:has(> .hero-video) already uses — background scroll behind an open
   native dialog is not blocked by every engine on its own. */
html:has(dialog.rv-modal[open]) { overflow: hidden; }

h1 { font-size: 1.5rem; font-weight: 500; letter-spacing: -0.015em; margin: 0 0 0.35rem; }
h2 { font-size: 1.125rem; font-weight: 500; margin: 1.25rem 0 0.75rem; }
/* The heading that labels the finished walkthrough sits close to it, so it reads as the video's
   own label rather than as a section break — the 1.25rem above it is untouched and still does the
   separating (Dean, 2026-08-23). Keyed on the adjacency rather than a class on purpose: this is a
   rule about a heading that introduces a video, and if the figure ever stops following the
   heading, the rule should stop applying with it. */
h2:has(+ figure.stage) { margin-bottom: 6px; }
/* A subsection inside a page section — the Terms' "In plain terms" lead. One step down from
   h2, same quiet weight, so the summary reads as a preface rather than a numbered clause. */
h3 { font-size: 1rem; font-weight: 500; margin: 1.75rem 0 0.75rem; }
/* A heading with a leading icon — /help's "Reel Credits" so far, but the pattern is generic.
   flex, not inline vertical-align tuning: a 1:1 icon beside mixed-case text never quite sits
   right on the text baseline, and flex centring on the cap-height gets it right without guessing
   at a px offset. Dean, 2026-08-03. */
h2.icon { display: flex; align-items: center; gap: 0.4rem; }
.sub { color: var(--ink-2); margin: 0 0 1.75rem; }
/* The reach-a-human forms (/contact, /buy-bulk) sit closer to their sub-heading than other
   pages: the shared .sub margin is 1.75rem, but these forms want the tile right under the
   line, so they opt down to 1rem. Scoped, not a global change. */
.sub.contact-sub { margin: 0 0 1rem; max-width: var(--measure); }
/* Terms/Privacy prose had NO measure at all — not "90ch rendering wide", genuinely uncapped to
   the 900px card (spec DW). Same principle as .hint's own rule above: the measure follows the
   PROSE, not the position, because legal copy lives inside .paper where .wrap > p never reaches.
   Scoped to _legal()'s two callers via the semantic class, and `> p` deliberately: the provider
   table and the "In plain terms" h3 are not paragraphs and keep the card's width. NOT bare .sub
   or .paper — .jh-copy .sub (the job wait line), .wall .sub (admin) and _contact_page's .paper
   are live elsewhere and uncapped today by their own design. */
/* `li` too, not just `> p` — the sheet's own "not fully verified" flag was real: four list
   items carry 200-316 chars of legal prose each (measured live, DW gate 2). A list is prose
   here; the provider table is still neither. */
.legal-body > p, .legal-body li { max-width: var(--measure); }
/* Same escape as .acct-body.full: a direct-child .sub still inherits the reading measure from
   `.wrap > p:not(.cta)` below even on the 1180px board, which wraps a one-line intro to two for no
   reason once the sentence runs past 68ch. Opt-in per instance, not blanket on .wrap.wide, since
   most job-page .sub copy is meant to stay measure-width. Dean, 2026-08-03. */
.wrap > p.sub.full { max-width: none; }
/* The measure has to follow the prose, not the position. `.wrap > p` only reaches paragraphs that
   are direct children of the page, and almost none of this product's supporting copy is — it
   lives inside a form, a .note or a <details>. Measured on a 1180px board before this rule: hints
   at 134-138ch, .kv at 175-182ch. `max-width` only ever reduces, so it is inert in the many narrow
   containers a hint also appears in, and it does not apply to the inline `<span class=hint>`. */
.hint { font-size: 0.875rem; color: var(--ink-2); max-width: var(--measure); }
/* The /new upload form's closing note. The generic .hint measure (90ch) reads long here and caps
   the line ~70px inside the field column, so it wraps into two cramped lines. It is a form hint
   under a full-width button, not a reading paragraph — give it the same width as the fields so
   it uses the space it is describing (and reads as one easy line). */
.plan-note { max-width: none; }
/* Same reason as .plan-note above: a form lead-in, not a reading paragraph. It describes the
   fields below it, so it takes their width rather than the 90ch reading measure. */
.details-lead { max-width: none; }
/* On /new the Plan button and its disclosure line share one flex row; the line is longer
   than the space beside the button, so it must shrink and wrap its own text rather than
   dropping the row below (flex-wrap: wrap would move the whole span down). */
.actions .plan-note { flex: 1 1 0; min-width: 0; }
/* The /new Plan row: the button keeps its natural width; the long disclosure line takes the
   leftover space and wraps its own text. no-wrap so the button never drops below the note on
   a narrow screen (flex-wrap: wrap would put the span first and shove the button down). */
.actions.plan-row { flex-wrap: nowrap; }
.actions.plan-row #go { flex: 0 0 auto; }
/* The multi-line price note under the "What it costs" packs. Each line is a hint-styled paragraph
   (same muted tone and reading measure), but the block also wants a top gap from the cards and a
   little space between its own lines. */
.cost-notes { margin-top: 1.25rem; }
.cost-notes p { font-size: 0.875rem; color: var(--ink-2); max-width: var(--measure); margin: 0 0 0.4rem; }
.cost-notes p:last-child { margin-bottom: 0; }
.cost-notes a { color: var(--accent-ink); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }

/* --- controls ------------------------------------------------------------ */

/* Square corners, 0.875rem/500: the masthead's own button shape (Dean, 2026-08-02), now the
   site-wide default. Only the shape moved — min-height stays 44px here, unlike the masthead's
   2rem, because that height is a frame-only exemption from the 44px touch-target floor and
   these buttons sit in page content, not the persistent bar.

   inline-flex + centered, not inline-block: block layout top-aligns a single line of text and
   dumps every pixel of the gap between the text and min-height's forced 44px at the BOTTOM, so a
   short label sat visibly high in its own box. The masthead's own button never had this bug — it
   was already flex — which is what made the mismatch read as "inconsistent" rather than as one
   button simply being short. */
/* Dean, 2026-08-02: every button now carries the masthead's own quiet theme — transparent,
   ink text, --line-firm border, the same translucent gold hover — rather than the old filled
   ochre "primary" treatment. The .quiet rules below are unchanged and now redundant with this
   base rule (harmless to leave: other selectors, e.g. `.top a.btn.quiet`, still key off the
   class for cascade reasons elsewhere), but nothing new should rely on `.quiet` looking any
   different from a bare button — it no longer does. */
button, .btn {
  font: inherit; font-size: 0.875rem; font-weight: 500;
  padding: 0.55rem 1.1rem; border-radius: 0;
  border: 1px solid var(--line-firm); background: transparent; color: var(--ink);
  cursor: pointer; text-decoration: none;
  display: inline-flex; align-items: center; justify-content: center;
  transition: background 160ms var(--ease);
  min-height: 44px;                    /* field surfaces are used one-handed outdoors */
}
button:hover, .btn:hover { background: rgba(250, 200, 0, 0.25); }
button.quiet, .btn.quiet { background: transparent; color: var(--ink); border-color: var(--line-firm); }
button.quiet:hover, .btn.quiet:hover { background: rgba(250, 200, 0, 0.25); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Matches by exclusion, not by listing types: `input[type=text]` does NOT match `<input name=x>`
   with no type attribute, so every bare input on the upload form was rendering unstyled at the
   browser's default width. It went unnoticed because the two that sit in a grid got stretched by
   the grid — only the address field, which sits alone, showed the real size. */
/* min-width: 0 because `width: 100%` is not the whole story for a form control. Its automatic
   minimum size is its intrinsic one — a bare <input> is 20 characters wide, 275px here — and as a
   grid item that becomes the floor the track has to clear. Needed alongside the minmax(0, 1fr)
   tracks below; neither alone lets a field narrower than 275px exist. */
/* 40px and square, not the 44px/8px-radius pair above — Dean, 2026-08-03, matching the masthead's
   shape without adopting its 32px height: this is still a field surface used one-handed outdoors,
   so it does not drop all the way to the masthead's persistent-frame exemption, just partway.
   Vertical padding dropped from 0.55rem to 0.4rem alongside min-height: at 1rem/1.55 body text,
   0.55rem's own content box already stood 44px tall on its own — min-height cannot shrink an
   element below what its padding and line-height need, so the padding had to move too or the
   44px→40px change would have been a no-op. */
input:not([type=file]):not([type=checkbox]):not([type=radio]):not([type=submit]), select, textarea {
  font: inherit; width: 100%; min-width: 0; padding: 0.4rem 0.7rem; min-height: 40px;
  border: 1px solid var(--line-firm); border-radius: 0;
  background: var(--surface); color: var(--ink);
}
/* Native <select> keeps its internal appearance, whose intrinsic height can exceed the shared
   min-height until fonts settle — on a form that rides along the upload page it showed as a
   first-paint 51px collapsing to 40px (the Walkthrough details tile dropping ~11px on load).
   An explicit border-box height pins it at the same 40px the text inputs render, so nothing
   jumps. It is not meant to clip: box-sizing above, and selects don't grow like a textarea.
   (Dean, 2026-08-10, via comparing the Walkthrough-details and Contact-details tiles — the
   select is the only difference.)
   The native control also PAINTS ~4px shorter than its box in Windows Chromium, so beside a
   text input the dropdown looks inset with a gap under it (seen on the addr-line row). Stripping
   `appearance` paints it exactly like the inputs; the chevron comes back as a background image,
   themed per surface so it stays legible in both themes. */
select {
  height: 40px;
  appearance: none; -webkit-appearance: none; -moz-appearance: none;
  padding-right: 2rem;
  background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%235d5d5d' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 0.7rem center;
}
:root[data-theme=dark] select {
  background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23c8c8c8' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E");
}

/* LastPass injects an autofill icon wrapper into fields it recognises (the /new address block is
   prime bait), which lands ~1s after load and nudges the layout. data-lpignore on those inputs
   stops the injection at the source; this belt-and-braces rule hides any injected root that
   still appears, scoped to the upload form so the login modal keeps its autofill affordance. */
#upload div[data-lastpass-icon-root],
#upload div[data-lastpass-infield] {
  display: none !important;
  opacity: 0 !important;
  pointer-events: none !important;
}

/* Placeholder text sits below the --ink-3 floor on purpose (--ink-4) — a sample of what goes in
   the field, not the label; the label sits above. Note the plan screen's address block draws its
   field names from a placeholder, so that one surface reads quieter than elsewhere — Dean,
   2026-08-05, chose the quieter tier for all of them rather than exempting it. */
::placeholder { color: var(--ink-4); opacity: 1; }

/* --- job summary --------------------------------------------------------- */

.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.75rem; margin: 1.25rem 0 1.75rem; }
.stat { background: var(--surface-2); border-radius: var(--radius); padding: 0.9rem 1rem; }
.stat dt { font-size: 0.8125rem; color: var(--ink-2); margin: 0 0 0.2rem; }
.stat dd { font-size: 1.5rem; font-weight: 500; margin: 0; letter-spacing: -0.02em; }
.stat dd small { font-size: 0.875rem; font-weight: 400; color: var(--ink-3); letter-spacing: 0; }

/* --- scene rows ---------------------------------------------------------- */
/* Rows, not a card grid. Sixteen identical cards is noise; rows stay scannable at that
   count and collapse to mobile without reflowing into something else. */

.scenes { border-top: 1px solid var(--line); margin: 0; padding: 0; list-style: none; }
/* 10px each end so the thumbnail and the row's controls are not flush against the card edge.
   The inset lives on a variable because the hover surface has to reclaim it: shading that stops
   10px short of the card edge reads as a stripe rather than as "this row is live". */
:root { --card-inset: 10px; }
.scene { border-bottom: 1px solid var(--line); padding-left: var(--card-inset); padding-right: var(--card-inset); }

/* Collapsed row is the scannable list; everything scene-specific lives inside it.
   Native <details> rather than JS: keyboard and screen-reader behaviour for free. */
.scene summary {
  display: flex; align-items: center; gap: 0.9rem;
  padding: 0.8rem 0; cursor: pointer; list-style: none;
}
.scene summary::-webkit-details-marker { display: none; }
.scene summary::after {
  content: "\203A"; color: var(--ink-3); font-size: 1.25rem; line-height: 1;
  transition: transform 160ms var(--ease);
}
.scene details[open] summary::after { transform: rotate(90deg); }
/* Negative margin + matching padding: the summary stretches back out over the card's inset so
   the hover fill runs to both edges, while its contents stay exactly where they were. */
.scene summary {
  margin-left: calc(var(--card-inset) * -1); margin-right: calc(var(--card-inset) * -1);
  padding-left: var(--card-inset); padding-right: var(--card-inset);
}
.scene summary:hover { background: var(--surface-2); }
/* The grid `gap` is the ONLY vertical spacing in here. Three systems were stacking: the gap,
   .scenebar's own margin-bottom, and the browser's default 1em margins on every <p> — so the
   notes sat roughly three times further apart than the controls above them, which is what read
   as uneven. Children contribute no margins of their own. */
/* minmax(0, 1fr), not the implicit `auto` column. An auto track is sized to its widest child's
   min-content, and one of these children is the add-photos row, whose file input has a native
   275px intrinsic width that `min-width: 0` does not lower. The track grew to the input and the
   whole drawer sat 3px past a 320px screen. Same trap as the address grids. */
.scene-detail { padding: 0.25rem 0 1rem; display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.55rem; }
.scene-detail > * { margin-top: 0; margin-bottom: 0; }
/* Consecutive hint lines are one block of small print, not separate sections. */
.scene-detail > .hint + .hint { margin-top: -0.3rem; }
.stills { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.stills img { width: 108px; height: 61px; object-fit: cover; border-radius: 4px; border: 1px solid var(--line); }
/* The /jobs curating wait shows the photos back ten-across so the agent can see their whole
   upload at a glance (Dean, 2026-08-10). Scoped — the scene-detail strip above stays flex. */
.stills-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 0.4rem; align-items: start; }
.stills-grid img { width: 100%; height: auto; aspect-ratio: 16 / 9; object-fit: cover; }
/* 0.35rem, down from 0.8: the thumbnail is the tallest thing in the row, so trading padding for
   picture keeps the row height where it was while the image gets meaningfully bigger. */
.planrow { display: flex; align-items: center; gap: 0.9rem; padding: 0.35rem 0; }
/* A skipped scene's row is dimmed to say "not in the walkthrough" — but dim the
   row's CONTENT, not its actions: the Include button is the one control that undoes
   the skip, and an opacity on the <li> drags the button down with it into
   looking disabled. (Same shape as the no-strikethrough rule below.) */
.scene.off > .planrow > :not(.actions) { opacity: 0.5; }
.scene.off > .card-why { opacity: 0.5; }
/* No strikethrough on a skipped scene's name. The row is already dimmed, sits under a "Skipped"
   divider and carries an Include button — the rule added nothing but made the name harder to
   read, on the one control the agent needs in order to decide whether to put it back. */

/* Why we skipped it, indented to the row's text column rather than the card edge: it explains
   the scene above it, so it should start where that scene's name starts. Grip + gap + thumb +
   gap, in the same variables the row itself uses, so it cannot drift when those change. */
.card-why {
  margin: 0 0 0.7rem;
  padding-left: calc(var(--grip-w) + 0.9rem + 144px + 2px + 0.9rem);   /* +2px: thumb border */
  font-size: 0.875rem; color: var(--ink-2);
}
@media (max-width: 560px) { .card-why { padding-left: 0; } }
.planrow .actions button { padding: 0.5rem 0.7rem; min-width: 44px; }
/* Same 44px touch-target floor, extended to the review row's actions — a direct child of
   <summary> there (no .planrow wrapper), so the selector above never reaches it. Without this
   the review board's arrows/Keep/Skip come out smaller than the plan screen's own (Dean,
   2026-08-19: _scene_actions in server.py, splitting _scene_bar). */
.scene summary > .actions button { padding: 0.5rem 0.7rem; min-width: 44px; }
/* Keep and Skip read as one control with two states, not two buttons sized by their own
   letterforms — "Keep" needs 56.6px, "Skip" only 51.6px, a gap visible side by side (Dean,
   2026-08-19). Fixed width, not min-width: either label alone would clear the 44px floor above. */
.kd { width: 58px; }
button[disabled] { opacity: 0.35; cursor: default; }
/* wrap, because one of these rows holds a file input and a button whose label does not shrink.
   At 390px that is 244px + 115px in a 311px card: the row overflowed its note and took the whole
   plan screen 10px sideways. Wrapping drops the button under the input on a phone and changes
   nothing at any width where both fit. */
/* The ask field and its Send button only now — Keep/Skip/reorder moved out to the row's
   <summary> as .actions on 2026-08-19 (see _scene_ask/_scene_actions in server.py). The field
   takes whatever Send leaves, and the button never shrinks. */
.fb { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
.fb input { flex: 1 1 12rem; min-width: 0; }
.fb button { flex: 0 0 auto; }
/* Same width as the Add photos button above it (Dean, 2026-09-07). Add photos renders at
   ~111.6px at this file's own font-size/padding; "Import listing" is the longer label of the
   two (~120.6px unconstrained), and a button's default `white-space: normal` word-wrapped it
   onto two lines at this width -- the first attempt here missed that because scrollWidth vs
   clientWidth cannot detect a wrap (wrapping IS how the browser avoids that overflow). Measured
   with a Range around the text node instead: at 112px + nowrap, the glyphs run 83.4px against
   a 74.8px padding box, eating a few px of each side's padding, but stay inside the button's own
   border box -- no clipping, no overflow past the edge, no second line. */
#plan-listing-go { width: 112px; white-space: nowrap; }
.actions { display: flex; gap: 0.6rem; flex-wrap: wrap; align-items: center; }
/* The build tile's counters sit hard against the right edge, opposite the Build button.
   margin-left:auto rather than justify-content:space-between on .actions — the row is shared
   with tiles that hold several controls, and spreading those apart would push them to opposite
   ends of the note. When the row wraps on a narrow screen the auto margin collapses and the
   block simply falls under the button (Dean, 2026-08-18). */
.actions > .build-tiles { margin-left: auto; }
/* .step-tiles alone (line-height 1.25) still lines up the plan screen's own grid; this stays
   scoped to .build-tiles rather than touching the shared class. */
.step-tiles.build-tiles { line-height: 1.3; }
/* `.step-tiles`'s own `display: grid` (above) outranks the browser's built-in `[hidden] {
   display: none }` -- that rule is only user-agent origin, and author rules win regardless of
   specificity or source order. Server and POLL_JS both set `hidden` on a busy Build tile's
   counters expecting them gone; without this they rendered anyway, orb and counters both
   showing at once (Dean's screenshot, 2026-08-28). `[hidden]` compounded onto the class beats
   `.step-tiles` alone on specificity (0,2,0 vs 0,1,0), no `!important` needed. */
.build-tiles[hidden] { display: none; }
/* The Build tile: its button and its copy are one column, the counters the other, both starting
   at the top of the box. `.actions` centres its items, which is right for a row of buttons and
   wrong for a column that is taller than the thing beside it — centred, the button sat halfway
   down the counters' height with nothing under it (Dean, 2026-08-18). */
.build-row { align-items: flex-start; }
.build-lead { display: flex; flex-direction: column; gap: 0.8rem; min-width: 0; }
.build-lead .hint { margin: 0; }
.actions form { margin: 0; }
.build-row > .build-lead:only-child { flex: 1 1 auto; }
/* The Rebuild tile's busy orb, at the right end of the tile (Dean, 2026-08-24, spec CS §4) —
   but as the row's own right column, never inside the form: an orb-tall form row stacks the
   hint below it and grows the tile (148px at 88, 126px at 66); as a flex sibling of
   .build-lead the tile keeps its quiet 104px height, because the 66px orb is shorter than
   the lead column it sits beside (Dean, 2026-08-27). `margin-left:auto` does the pushing, so
   the lead column's :only-child growth above is not needed for it. The counters never share
   a row with an orb -- _cta_tile routes one only where there are no counters (spec DB §3.1). */
.build-row > canvas.orb { margin-left: auto; align-self: center; }
.actions .ok, .actions .err { margin: 0; }
/* "Coming Soon!" beside the signup button — a muted status tag, quieter than the button it
   rides beside (Dean, 2026-08-08). */
.coming { font-size: 0.8125rem; color: var(--ink-3); text-transform: none; letter-spacing: 0; }
/* The per-scene estimated length on the plan board, left of the reorder arrows and Skip — the
   clip's length is deterministic (pair=10s, single=5s, see _scene_seconds); shown where the
   keep/skip decision is made, not under the name. Styled like the Skip button's label (same
   weight, size and ink) so it reads as one of the row's controls rather than a mute caption. */
.scene-time {
  font-size: 0.875rem; color: var(--ink); font-weight: 500;
  font-variant-numeric: tabular-nums;
  /* Matches the Skip button's label (0.875rem / 500 / --ink) on request (2026-08-05), and sits
     1-2 arrow-button widths clear of the arrows (arrow buttons are ~44px, see
     `.planrow .actions button`) so it reads as a length, not one of the controls. */
  margin-right: 3.5rem;
}
/* Intro/outro cards have no reorder arrows, so without a placeholder their time chip would sit
   a cluster-width right of every scene's. Plan-screen only now (Dean, 2026-08-19, spec-E §2):
   the review board's card row gets its column from `.scenes.board` below instead, and applying
   both there would double up. Sized to exactly two 44px buttons + the 0.6rem gap between them. */
.ctl-gap { flex: 0 0 calc(2 * 44px + 0.6rem); }
/* The same chip on the boards after the plan, where its neighbour is a status pill (or, on the
   review board, the actions column below) rather than the reorder arrows — so the 3.5rem that
   clears those arrows is wrong here, and `none` keeps it from being squeezed out of a summary
   row that is already full (Dean, 2026-08-18). */
.scene-time.tight { flex: none; margin-right: 0.9rem; }
/* A skipped scene has no length to state, but the SPAN stays (see _time_chip) so its
   margin-right still reserves the gap to whatever trails it — visibility:hidden, not
   display:none, is what keeps that reservation live on an element with no text to size around
   (Dean, 2026-08-19). */
.scene-time.empty { visibility: hidden; }

/* One duration column on the review board (Dean, 2026-08-19, spec-E §2) — not a cross-board
   match with the plan screen. Spec C tried that and could not finish it: once an actions cluster
   trails a review row too, matching the plan screen's chip position needs more room than the row
   has (a negative margin was the only way to reach it, and a negative margin here does not just
   misalign, it pushes the chip's own text into the status pill — see that spec's own notes if
   this is ever revisited). The problem actually visible on screen was different anyway: the
   cards' chips and the scenes' chips sat ~174px apart on the SAME board, because a card row had
   only a status pill trailing its chip while a scene row also had a whole actions cluster.
   Fix: the chip moves INSIDE `.actions`, for both row shapes, pinned to the actions column's
   LEFT edge with `margin-right: auto` — the buttons then pack right inside a column fixed to one
   width, so a 1-button card row and a 4-button scene row put their chips in the identical place.
   `.scenes.board`-scoped throughout: `_card_row` renders on the PLAN screen too (job_page's
   `<ul>` carries the extra class, the plan screen's does not), and an unscoped rule here would
   drag the plan cards' chips out of the alignment `.ctl-gap` already holds with the plan scenes'.
   Chosen over sitting the chip beside "Ready" — see the tradeoff spec-E §2 records; moving it
   back there re-breaks the one thing this exists to fix. */
@media (min-width: 700px) {
  .scenes { --status-col: 145px; --actions-col: 285px; }
  .scenes .status { flex: 0 0 var(--status-col); justify-content: flex-end; }
  /* 285px, not the 225px Spec C measured — that number sized four BUTTONS alone (arrows, Keep,
     Skip); the chip joining them is a fifth item. Measured live: the widest row (chip + both
     arrows + Keep + Skip, `.kd` now widening Skip to match Keep) needs 274px to stay on one line
     before `flex-wrap: wrap` (`.actions`'s own base rule) splits it into two, which is the
     wrapped, two-line row this replaces. 285 clears that with a visible gap after the chip; a
     scene's own name never affects this width, only the fixed button set does, so this is not a
     number a longer label could invalidate. (Bumped from 275 when `.kd` shipped, 2026-08-19 —
     the old value cleared its own 269px minimum by 6px, this keeps the same margin over 274.) */
  /* Two selectors, not one, for three row shapes: a scene row's `.actions` is a direct child of
     `<summary>`; an ON card's sits one level deeper, inside `.planrow` (itself inside `<summary
     class=plansum>`) — spec-E's own reasoning for why `summary .actions` has to be a descendant
     match, not `summary > .actions`. An OFF card is a THIRD shape spec did not trace through: its
     `<li>` has no `<details>`/`<summary>` at all (`_card_row`'s early `if not on:` return is a
     bare `.planrow`), so `summary .actions` never reaches it — measured live: its `.actions` came
     out 71px wide, own-content-sized, Include sitting flush LEFT instead of packed right with
     everything else. `.planrow > .actions` closes that gap and, being a plain child selector,
     already covers the ON card's `.planrow` too — the overlap with the rule above is harmless,
     CSS only applies the wider rule once either way (Dean, 2026-08-19).
     Residual, deliberately not chased: an OFF card's row still lands ~20px right of every other
     row's, because it has no `<details>` at all and so no `::after` chevron claiming space at
     the end — measured, not a rounding error. Rare (most cards stay on) and already visually
     distinct (no expand arrow, dimmed by `.scene.off`), and closing it needs a real spacer
     standing in for a pseudo-element that only exists on an actual `<summary>` — more machinery
     than a once-in-a-while few-pixel gap on a skipped card earns. */
  .scenes.board summary > .actions, .scenes.board .planrow > .actions { flex: 0 0 var(--actions-col); }
  .scenes.board .actions > .scene-time { margin-right: auto; }
}
/* `.jh-lead`'s right edge minus a scene row's `.scene-time` right edge (the chip pinned above by
   `margin-right: auto`) -- 1058 - 889 = 169px (spec EL §4). Emergent, not designed: it is
   `.jobhead`'s 24px gap plus the 88px orb (the fixed 112px `.jh-lead` gives up to sit beside the
   orb) plus whatever the row's own actions cluster needs beyond the chip today. Add a control to
   a scene row and this drifts silently -- test_busy_head_pct.py's alignment check (item 6) is
   what actually keeps it honest; a number with no such test is a time bomb. */
:root { --chip-tail: 169px; }
/* Residual, deliberately not chased (spec U §1, 2026-08-20): between this rule's 700px floor and
   the mobile grid's 480px ceiling, `.actions` has neither a fixed column nor the chip's own
   auto-margin — it is a plain shrink-to-fit flex box, right-flush against the row by `.scene-main`
   absorbing the row's free space. A build board's card row (chip alone, 0 buttons after it) and a
   scene row (chip + Keep + Skip, 2 buttons after it) are different widths for that reason alone,
   so their chips — both packed flush-LEFT within their own differently-sized box — land at
   different x even though both boxes' RIGHT edges match exactly. Measured live at 481px on a
   stitching-phase job: 47px apart before §1 wrapped the card's chip in `.actions`, 117px apart
   after — §1's fix is still correct (it is what makes ≤480px and ≥700px both land exactly, see
   `_card_status_row`), it just has nothing to hold onto in this one narrow band, and widens what
   was already an imperfect gap there. Same shape of trade-off as the OFF-card residual just above:
   closing it needs this rule's mechanism (or an equivalent) extended down to a width where a
   285px-ish fixed column plainly will not fit without measuring a smaller one for that band
   specifically — more than a rarely-hit in-between window's few-pixel-turned-not-so-few gap earns
   on its own. Dean's call, 2026-08-20: ship it, come back if it turns out to matter. */
/* The plan tips are plain lines — list-style none so the marker dots don't render (Dean
   2026-08-05: the bullets were the complaint, not the copy). Margins mirror the pre-list
   layout, and max-width keeps the three lines to a conversational measure. */
.plan-tips { list-style: none; padding-left: 0; font-size: 0.9375rem; color: var(--ink-2); margin: 0; max-width: var(--measure); line-height: 1.45; }
.plan-tips li { margin: 0; }
.plan-tips b { color: var(--ink); font-weight: 600; }
/* The per-scene lines in the held/scene review warning ("Dining: The dining chairs and table look
   see-through") — one scene per line, name in the ink so it reads as the subject (Dean, 2026-08-10). */
.held-note { list-style: none; padding-left: 0; margin: 0.5rem 0 0; display: grid; gap: 0.3rem; max-width: var(--measure); }
.held-note li { font-size: 0.9375rem; line-height: 1.45; color: var(--ink-2); }
.held-note b { color: var(--ink); font-weight: 600; }
/* Listing-import exclusions (spec CZ §3a): a floor plan, map, headshot or brand card the import
   left out, named with its reason, one line each. `Include` is a plain small button rather than a
   settings page — the agent's own listing, their call, one click either way. */
.listing-excluded { list-style: none; padding-left: 0; margin: 0.5rem 0 0; display: grid; gap: 0.3rem; max-width: var(--measure); }
.listing-excluded li { font-size: 0.875rem; line-height: 1.4; color: var(--ink-2); display: flex; align-items: center; gap: 0.5rem; justify-content: space-between; }
/* The excluded bytes are already in the row (they power Include); the thumbnail shows what the
   sentence is about. 44px matches the Include button's height; span takes the slack so the
   three-child space-between doesn't strand the text mid-row. */
.listing-excluded li img { width: 44px; height: 44px; object-fit: cover; border-radius: 4px; border: 1px solid var(--line); flex: none; }
.listing-excluded li span { flex: 1; }
.listing-excluded b { color: var(--ink); font-weight: 600; }
/* On /help the Photos rules read as a checklist — disc markers there, while the /new upload
   tips keep the cleaner markerless list (Dean, 2026-08-10). */
.help .plan-tips { list-style: disc; padding-left: 1.5rem; }
.help .plan-tips li { margin: 0 0 0.4rem; }
.help .plan-tips li:last-child { margin-bottom: 0; }
/* Same disc treatment for the /new upload tips, scoped to the upload form #upload so the plan
   screen's own plan-tips stay markerless (Dean, 2026-08-10). */
#upload .plan-tips { list-style: disc; padding-left: 1.5rem; }
/* The /new drop zone's surrounding gaps: the sub line above sits 1rem clear (its default
   1.75rem is the other pages' spacing) (Dean, 2026-08-10). */
.sub-upload { margin-bottom: 1rem; }
/* /new's two lead lines sit 12px clear on both edges (Dean, 2026-08-24: "tighten the gap above
   and below ... to 12px"). The form is a 16px-gap grid and gap does NOT collapse with margins, so
   trimming a single row means a negative margin: 16 - 4 = 12. Replaces the +0.5rem this rule used
   to add, which was making the gap above the photos hint 24px (Dean's own 2026-08-10 call, now
   reversed -- see spec CU §3).

   The first line needs no trim: it is the form's first child, so nothing above it is a grid row.
   Its gap is the 12px bottom margin of the tile above, which is already the target -- it only
   needs the old +8px taken off. `:first-child` and not `.details-lead` because being first is the
   REASON, and a rule keyed on the reason survives the class being renamed (spec CU §4). */
#upload > p.hint { margin: -4px 0; }
#upload > p.hint:first-child { margin-top: 0; }
/* The empty picked-strip must not hold a grid row: `.picked { display: grid }` would otherwise
   beat the hidden attribute's display:none and leave a phantom second 16px gap between the drop
   zone and the hint below (the author display rule outranks the UA [hidden] rule), making the
   bottom gap 40px instead of the 24 both sides read as (Dean, 2026-08-10). */
#upload .picked[hidden] { display: none; }
/* Same defect as .picked above, same cause, 15 days later: `.listing-excluded { display: grid }`
   outranks the UA's [hidden] rule, so the empty exclusions list still holds a row plus its 8px
   margin-top -- 31px of bottom gutter against 13px at the top (spec DF §7). */
#listing-import .listing-excluded[hidden] { display: none; }
/* The approve line and the disclosure qualifying it are one statement in two sentences (spec DF
   §1/§6), pulled up out of .stack's 16px gap so they read as a pair. */
#upload > p.hint.tight-above { margin-top: -12px; }
/* Spec HV wrapped those same three notes in `.upload-notes` so the photo strip can be inserted
   above them. That made them GRANDCHILDREN of #upload, so all three `#upload > p.hint` rules above
   stopped matching and the gaps went 12/8/0px -> 16/0/0px: the first two notes ended up touching.
   Measured on production 2026-09-04, fixed on Dean's call the same day. The rules above stay keyed
   on `>` deliberately -- they still own the hints at the TOP of the form, and `:first-child` there
   means "first child of the form", which a descendant selector would wrongly extend to the first
   note inside this wrapper. So the wrapper restates the same geometry in its own terms: -4px trims
   the .stack gap above it to 12, an 8px internal gap replaces the -4px margin pairs, and
   tight-above cancels that gap to keep the approve line and its disclosure reading as one
   statement (spec DF §1/§6). */
#upload .upload-notes { display: grid; gap: 8px; margin-top: -4px; }
#upload .upload-notes > p.hint.tight-above { margin-top: -8px; }
/* Scene rows are the stay-in-place anchors for in-card edits (photo/rename/move redirect to
   #scene-<name>): the sticky masthead (~57px) would otherwise cover the row's top edge. 350px
   clears the masthead and parks the row ~½ an open card down the viewport (an open card measures
   ~418px, so ~209px of it hangs above the fold — you can see the card's header plus the first
   field or two, "where I was" rather than the header alone. 81px was jammed, 140px still tight,
   350px on request — Dean, 2026-08-05). */
li.scene[id] { scroll-margin-top: 350px; }
/* The plan board's title is followed by the tips AND the scene/est. counters. Bottom-aligned so
   the counters' last line sits on the tips' last baseline instead of hanging off the shorter
   bullets; margin mirrors what .plan-tips' own 1.5rem bottom used to be. 6px since 2026-08-18:
   the Walkthrough details tile below carries its own 6px top margin, and the two are 6px EACH
   (12px total) — the lead's spacing is PADDING so it cannot collapse with the tile's margin
   (Dean). */
.plan-lead { display: flex; align-items: flex-end; justify-content: space-between; gap: 1.5rem; margin: 0; padding-bottom: 0.375rem; }
.kv { font-size: 0.875rem; color: var(--ink-2); margin: 0; max-width: var(--measure); }
.kv b { font-weight: 500; color: var(--ink); }
/* Spec HC, 2026-09-02: `--measure` is right for .kv as prose (app.css:1783's 175-182ch
   measurement is why the class carries the cap at all) and wrong inside an expanded scene card's
   .scene-detail panel -- a detail block beside a photo strip, not a page-width column of prose.
   Scoped to the container, not a class swap: `.kv-row` already solves a DIFFERENT problem (a
   flex row needing its content wrapped in a <span>) and reusing it here would silently reflow
   these plain <p><b>…</b> …</p> lines into a flex row while looking like a width fix. No
   `.wrap > p.kv-row`-style second rule needed either -- .kv is capped directly on the class
   (see :1783's own reasoning: most of this product's supporting copy is nested, not a direct
   .wrap child), not via `.wrap > p:not(.cta)`, so there is nothing here for that combinator to
   re-win against. */
.scene-detail > .kv { max-width: none; }
@media (prefers-reduced-motion: reduce) { .scene summary::after { transition: none; } }
/* 144x81. The row thumbnail is how an agent tells "bedroom 2" from "bedroom 3" without opening
   anything, so it earns the space. The row's vertical padding shrinks as the thumb grows (see
   .planrow) so the row height barely moves — the picture takes the padding, not the page. */
.thumb {
  width: 144px; height: 81px; flex: none; border-radius: 4px;
  background: var(--surface-2) center/cover no-repeat; border: 1px solid var(--line);
}
/* A logo is artwork with its own margins, not a photo: contain it and give it a light ground
   so a dark mark stays legible. Cropping it to fill would be the one thing an agency notices. */
.thumb.logo { background-size: contain; background-color: var(--plate); }
.scene-main { flex: 1; min-width: 0; }
.scene-name { font-size: 0.9375rem; margin: 0; }
.scene-note { font-size: 0.875rem; color: var(--ink-2); margin: 0; }
/* The /mine note stacks the name, scenes and status under the title — tighten the inter-line
   <br> gap here only, not the shared .scene-note used on the plan board (Dean, 2026-08-08). */
ul.scenes.mine .rowsep .scene-note { line-height: 1.3; }
.scene-note.flag { color: var(--warn); }
/* The outro's beds/baths/cars/sqm, on the collapsed row so they read without opening the card —
   an icon-plus-number pair per stat, only the ones that have a value (see _card_row). */
.propstats { display: flex; flex-wrap: wrap; gap: 0.1rem 0.3rem; margin-top: 0.3rem; }
.propstat { display: inline-flex; align-items: center; gap: 0; font-size: 0.8125rem; color: var(--ink-2); }
.stat-icon { width: 14px; height: 14px; flex: none; }
.propstat .stat-file-icon { width: 1.5rem; height: 1.5rem; flex: none; }

/* Name and shot type share the top line, half each. The shot type is what gets scanned down
   the column before starting a render, and under a long room name it wrapped into the subject
   and stopped forming a column at all. Baseline-aligned so the two sizes sit on one line. */
/* Name then shot type, one line, the type sitting immediately after the name rather than in a
   50% column of its own — at 50% the two read as separate fields with a gulf between them, and
   on a wide job screen that gulf was most of the row. The name keeps whatever width it needs and
   the type follows it, ellipsised if the row is tight. */
.scene-head { display: flex; gap: 0.6rem; align-items: baseline; flex-wrap: wrap; }
.scene-head .scene-name { flex: 0 1 auto; min-width: 0; }
/* These open the scene AND focus the control they name, so they have to look like a way in
   rather than a caption. Underline on hover only — a permanently underlined row header would
   read as a link to another page, and it is not one. */
[data-opens] { cursor: pointer; text-underline-offset: 2px; }
.plansum:hover [data-opens]:hover { text-decoration: underline; }
.scene-kind {
  flex: 0 1 auto; min-width: 0; margin: 0;
  font-size: 0.875rem; color: var(--ink-2);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* A separator, so two greys on one line do not read as one run-on phrase. */
.scene-head .scene-kind::before { content: "· "; color: var(--ink-3); }
@media (max-width: 560px) {
  .scene-head { display: block; }
  .scene-head .scene-kind::before { content: none; }
}

/* Status is never colour alone — every state carries an icon glyph and a word.
   Red/green-only fails ~8% of male users. */
.status { display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.875rem; white-space: nowrap; color: var(--ink-2); }
.status::before { font-size: 0.9em; }
.status.done  { color: var(--ok); }
.status.done::before { content: "\2713"; }
.status.flag  { color: var(--warn); }
.status.flag::before { content: "\26A0"; }
/* Skipped is its own state, not Ready with a different word: a scene the agent took out of the
   walkthrough carries the cross and the red (Dean, 2026-08-18, #a0282a). The dark theme lifts
   the same hue to stay readable on the dark ground, exactly as --bad does.
   NOT `.skip` — that is the skip-to-content link above, `position: absolute; top: -4rem`, and a
   status sharing the name flew off the top-left corner of the page. */
.status.skipped  { color: var(--skip); }
.status.skipped::before { content: "\2715"; }
/* Busy no longer draws a CSS pulse — it hosts a canvas.orb instead (ORB_JS, below the poll
   script). flex: none keeps it a fixed size in a tight row; the animation loop itself, not CSS,
   is what respects prefers-reduced-motion, drawing one static frame instead of skipping the
   element. 28px is still the /help legend's own size (its canvas is 20x20 to begin with — a
   table-row icon, not a scene row) — only the scene board's orb grew. */
.status canvas.orb { display: block; width: 28px; height: 28px; flex: none; }
/* 28 -> 42px (Dean, 2026-08-03), scoped to the scene board: ORB_SIZE in ORB_JS moved with it
   (the canvas's own drawing-buffer resolution, not just its CSS box), but the /help legend's
   orb shares that same script and stays visually 28px on purpose, so the bump lives here rather
   than in the shared .status rule above. */
.scene .status canvas.orb { width: 42px; height: 42px; }
/* Job page's curating/rendering/stitching-wait copy row. The 88x88 thinking orb these phases
   share used to live at the end of this row — but the row itself sits at very different depths
   on the page (right under the h1 while curating, several blocks down once stats/review_note
   exist), so a copy-relative position could not stay aligned with the address title across all
   three. The orb lives in .jobhead now, beside the h1, and this row is copy-only. */
.curate-head { display: flex; align-items: flex-start; gap: 24px; }
.curate-copy { flex: 1 1 auto; min-width: 0; }
.curate-copy .sub { margin-bottom: 0.5rem; }
/* Text states swap — Transitions.dev (https://transitions.dev/detail.html?t=text-states-swap).
   A single-line state message that fades out, swaps text, then fades in on each tick.
   The container gets a min-height so the orb doesn't jump when copy height changes. */
.curate-status { display: flex; align-items: baseline; min-height: 1.6em; }
.state-text { display: inline-block; transition: opacity 0.25s ease, transform 0.25s ease; }
.state-text.state-out { opacity: 0; transform: translateY(-3px); }
.state-text.state-in { opacity: 1; transform: translateY(0); }
@media (prefers-reduced-motion: reduce) {
  .state-text { transition: none !important; }
  .state-text.state-out, .state-text.state-in { opacity: 1; transform: none; }
}
/* The stitching countdown (spec EB), now on the cycler's own row, right-aligned to the scene
   rows' chip column via --chip-tail (spec EL). `:not(:empty)` so it carries no margin/push before
   the first measurement arrives -- the span is server-rendered empty on purpose, and an empty
   element with `margin-left: auto` would still claim the flex row's free space and shove the
   cycler text left before there is anything to show on the right. Type inherited from the body
   (matching `.state-text`, which declares none) rather than the old muted `--ink-3` -- a value
   standing on its own row reads as a footnote no longer. */
.build-eta:not(:empty) {
  margin-left: auto; margin-right: var(--chip-tail); white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
@media (max-width: 560px) {
  /* No orb column and no scene-row chip column to align to below this width, so --chip-tail is
     meaningless here -- the percent just sits at the end of the cycler's own row (spec EL §8).
     Placed AFTER the rule above rather than in `.jobhead`'s own 560px block up near :1548: same
     specificity as `.build-eta:not(:empty)` above, so source order is what decides the tie, and
     an earlier declaration would silently lose to the later one regardless of viewport width. */
  .build-eta:not(:empty) { margin-right: 0; }
}
.curate-timer { flex: none; font-size: 1rem; font-variant-numeric: tabular-nums; color: var(--ink); white-space: nowrap; }
.kv-row { display: flex; align-items: baseline; gap: 1rem; max-width: none; }
.wrap > p.kv-row { max-width: none; }   /* .wrap > p:not(.cta) would cap it to --measure */
.curate-timer .timer-unit { color: var(--ink-3); margin-left: 0.15em; }
/* "2 min 40 sec": the units are painted inside the digit group now, so the minutes unit
   needs room before the seconds start (Dean, 2026-08-18). */
.curate-timer .timer-unit + .t-digit { margin-left: 0.35em; }
/* Number pop-in — Transitions.dev (https://transitions.dev/detail.html?t=number-pop-in).
   Digits pop in with a blur + stagger when the counter changes. Scoped tokens so the app's
   :root stays clean; the curate page carries the only usage. */
.curate-timer .t-digit-group { display: inline-flex; align-items: baseline; --digit-dur: 500ms; --digit-distance: 8px; --digit-stagger: 70ms; --digit-blur: 2px; --digit-ease: cubic-bezier(0.34, 1.45, 0.64, 1); --digit-dir-x: 0; --digit-dir-y: 1; }
.curate-timer .t-digit { display: inline-block; will-change: transform, opacity, filter; }
.curate-timer .t-digit-group.is-animating .t-digit { animation: t-digit-pop-in var(--digit-dur) var(--digit-ease) both; }
.curate-timer .t-digit-group.is-animating .t-digit[data-stagger="1"] { animation-delay: var(--digit-stagger); }
.curate-timer .t-digit-group.is-animating .t-digit[data-stagger="2"] { animation-delay: calc(var(--digit-stagger) * 2); }
@keyframes t-digit-pop-in {
  0%   { transform: translate(calc(var(--digit-distance) * var(--digit-dir-x)), calc(var(--digit-distance) * var(--digit-dir-y))); opacity: 0; filter: blur(var(--digit-blur)); }
  100% { transform: translate(0, 0); opacity: 1; filter: blur(0); }
}
@media (prefers-reduced-motion: reduce) {
  .curate-timer .t-digit { animation: none !important; }
}
/* Lives in .jobhead now, as a flex sibling of the h1 — .jobhead's own align-items: flex-start
   and justify-content: space-between top-align and right-pin it, no margin math required. */
.curate-orb { flex: none; width: 88px; height: 88px; }
/* Hourglass, not an open circle. Every state now carries its OWN silhouette — check, triangle,
   cross, hourglass, square, diamond — so the pill survives being read without colour, at 0.9em, by
   someone who cannot rely on hue. Three states used to share "\25CB" and two of those shared a
   colour too; nothing draws it any more (Dean, 2026-08-19). */
.status.wait { color: var(--ink-3); }
.status.wait::before { content: "\29D6"; }
/* A halted render, resumable — money is already spent on it. One step louder than .wait, which is
   a scene merely waiting its turn, but deliberately NOT --warn: that belongs to .flag, which means
   "this scene needs your judgement on quality". Different question, so it must not look the same. */
.status.stopped { color: var(--ink-2); }
.status.stopped::before { content: "\25A0"; }
/* Waiting on the agent. Accent is reserved for actions, and this is the one row that needs one. */
.status.act { color: var(--accent-ink); font-weight: 500; }
/* Not an arrow: an arrow points somewhere and this pill does not — it marks a scene holding a
   quote that nobody has answered yet (Dean, 2026-08-18). Open circle until 2026-08-19, now an
   open DIAMOND — the decision node in every flowchart anyone has read, which is exactly what this
   row is. Outline, not filled `\25C6`, so it sits at the same visual weight as `.wait`'s hourglass;
   the accent colour and the 500 weight are still what separate a wait the agent has to end from
   one they only have to outlast.

   NOT a white square `\25A1` here or on `.stopped`, however well an outline would suit them: an
   outline square is what a MISSING glyph draws as. `.wait`'s `\29D6` is the one character here
   outside the Geometric Shapes block, so if it ever tofus on a device, Queued renders as a box —
   and a box that also means something would turn a font failure into a confidently wrong status
   rather than an obvious bug (Dean, 2026-08-19). */
.status.act::before { content: "\25C7"; }

/* --- the working row -----------------------------------------------------
   A busy row is a LIST ROW, and it stays on the theme's own paper (spec EE, Dean 2026-08-26:
   "No dark slab.. that is the point of a theme").

   What stood here from 2026-08-03 to 2026-08-26 was `.scene.busy { background: oklch(0.20 ...) }`
   — the footage-dark ground, fixed in both themes exactly like `.stage`. That generalised a rule
   about VIDEO onto a surface that never shows any: DESIGN.md:259-264 lists where the fixed dark
   ground belongs — the review stage, the gallery, the hero, and a placeholder standing in for a
   video — and the scene row is not in it. DESIGN.md's scene-row section never mentioned the slab
   either; the only record of the decision was the comment that used to sit here.

   Three things it cost, none of which a contrast check could see:
     1. Every child that set its own colour had to be hand-listed to survive the dark ground, and
        that list failed twice by the same mechanism — a child added later that nobody added to it
        (specs DY, EC). The class of bug only exists because the row left the theme.
     2. It was invisible where it claimed to work. The slab was 0.20 and dark `--surface` is 0.19:
        same hue, same chroma, 0.01 apart. The slab only ever did anything on the LIGHT theme.
     3. It inverted the orb. ORB_JS picks its greys from the PAGE theme, not the row it sits on
        (server.py:5304), so on a light page the bright particles paint at grey 20-77 and the
        faint orbit rings at 184 — then composite onto a grey-22 slab. The signal vanished and the
        scaffolding became the brightest thing in the orb. Removing the slab fixed that with no
        change to ORB_JS.

   Nothing replaces it, deliberately. Busy is the one status with no glyph and no colour rule of
   its own (there is no `.status.busy` anywhere in this file) because the 42px `canvas.orb` has
   always been its glyph — so the row keeps the orb, the Rendering/Stitching pill and the gold
   `.prog i.busy` segment, and needs no tint or rail on top of them. If a busy row ever does need
   marking again, `.scene.editing` (below) is the themed precedent: `--surface-2` and an inset
   accent rail, both tokens, both following the theme. */
.bar { height: 3px; border-radius: 2px; background: var(--surface-2); overflow: hidden; margin-top: 0.4rem; }

/* --- job progress -----------------------------------------------------------------------------
   One segment per scene, coloured by that scene's real status. Deliberately NOT a percentage:
   kie reports state only — pending/success/fail, no completion figure — so a percentage would be
   invented. Segments say exactly what is known, and say it twice over: how many are filled is the
   overall progress, and which colours sit where is the per-scene detail. */
.prog { display: flex; gap: 3px; margin: 1rem 0; }
/* 2px, the same literal `.bar` uses and for the same reason DESIGN.md gives it: on something a
   few pixels tall the 8px system default draws a lozenge rather than a bar. (Was written as
   `var(--radius-hair, 2px)` — a token that does not exist, so the fallback was doing all the
   work and it only looked right by coincidence.) */
.prog i { flex: 1 1 0; height: 6px; border-radius: 2px; background: var(--surface-2);
          min-width: 3px; transition: background 300ms var(--ease); }
.prog i.done { background: var(--ok); }
.prog i.flag { background: var(--warn); }
.prog i.skipped { background: var(--skip); }
.prog i.act  { background: var(--accent-ink); }
/* The rendering segment is a steady gold — no pulse. The segment is not a meter filling,
   it is a status chip: the gold already says "working", and the scene row beneath it carries
   the orb. A pulse on a 6px chip just flickers. (Dean, 2026-08-12.) */
.prog i.busy { background: var(--accent); }

/* Curation has NO per-photo signal — it is one vision call over the whole set (curate.build_manifest),
   so there is nothing to divide into segments and a bar that filled would be lying. This one
   sweeps instead: it says "working", which is the only honest claim available. */
.prog.indeterminate { position: relative; height: 6px; background: var(--surface-2);
                      border-radius: 2px; overflow: hidden; display: block; }
.prog.indeterminate::after {
  content: ""; position: absolute; inset: 0 auto 0 0; width: 35%;
  background: var(--accent); border-radius: 2px; animation: sweep 1.6s var(--ease) infinite;
}
@keyframes sweep { 0% { left: -35%; } 100% { left: 100%; } }

/* Motion here is status, not decoration — but a sweeping bar is exactly what reduced-motion is
   for. Hold it still and let the words carry it; the copy beside it already says what is running. */
@media (prefers-reduced-motion: reduce) {
  .prog.indeterminate::after { animation: none; width: 100%; opacity: 0.35; }
}
/* Waived on THIS LINE only, never by rule or by file. A rule-wide or file-wide ignore would also
   swallow the next layout animation someone adds, which is the one thing this exception must not
   buy. Scoped here, a second one anywhere in the stylesheet still fails the pass.

   Do not name the flagged property in prose above a waived line: the detector matches the file by
   regex and does not skip comments, so writing it out raises a second finding on the sentence
   explaining the first. That is how this comment read on its first draft. */
.bar span { display: block; height: 100%; background: var(--accent); transition: width 400ms var(--ease); } /* impeccable-disable-line layout-transition: width IS this meter's value */
/* The meter still moves and the megabytes beside it still count up — only the easing goes. A
   progress bar that stops reporting progress is not an accessibility improvement. */
@media (prefers-reduced-motion: reduce) {
  .bar span { transition: none; }
  .drop { transition: none; }
}

/* --- video surfaces go dark --------------------------------------------- */

.stage {
  background: oklch(0.20 0.006 70); border-radius: 12px; padding: 0.75rem;
  color: oklch(0.95 0.003 70);
}
/* Every .stage figure used to carry its margin inline, which is precisely why they disagreed:
   the four nested ones said `margin:0` and the done page said `margin-bottom:0`, so the done
   page kept the UA default's 40px sides and sat 60px BEHIND the rows it should match. The base
   rule is load-bearing, not tidiness — drop the inline attributes without it and every figure
   inherits `1em 40px`, the exact opposite of the intent (Dean, 2026-08-19). */
figure.stage { margin: 0; }
/* A preview inside a row reaches back out over the card's inset, the same idiom `.scene summary`
   uses for its hover fill — scoped on the ROW, not on `.scene-detail`, because `_card_row`'s
   preview is a bare figure with no such wrapper and a child selector misses it. The done page's
   figure is the one .stage NOT inside a `.scene`, and it is the target: both land at 1116px. */
.scene figure.stage { margin-inline: calc(var(--card-inset) * -1); }
.stage video { width: 100%; display: block; border-radius: 6px; background: #000; }
.stage figcaption { font-size: 0.8125rem; color: oklch(0.72 0.005 70); padding: 0.6rem 0.25rem 0.15rem; }

/* --- notices ------------------------------------------------------------- */

/* Button first, caption beside it: the button is the action, and whatever sits next to it (the
   credit-cost line, on the paid state) reads at caption size rather than competing with it. Used
   to also hold a `Render: N scenes` / `Create: N title cards` breakdown (.billed) — dropped on
   request, 2026-08-04, along with that rule. */
.startbar { display: flex; align-items: center; gap: 1.1rem; flex-wrap: wrap; margin: 0 0 12px; }
/* 12 / 12 / 12 (spec AH, 2026-08-21): top drops from --pad's 16 to 12, the middle is already
   .startbar's own 12px margin, and the bottom is set 3px short — 9, not 12 — for the same reason
   as before: a tile ending in TEXT ends higher than its box does, the last line's half-leading and
   descender space adding back the ~3px a flat 12 would undershoot by. Only the tile whose last
   child is text needs the trim, which is what the :has() scope buys; every other .note already
   ends in an input with a solid border and reads even. Reaches /new and the plan screen's own
   startbar, both direct children of a .note — verified live rather than inferred. */
.note:has(> .startbar) { padding: 12px 12px 9px; }
.startbar form { margin: 0; }
/* The plan page's other hint rows (the .scene-detail blocks) zero their children's margins and
   let the 0.55rem grid gap carry the spacing; the start-bar hint should read the same, so zero
   it too and let the startbar's own bottom margin do the work (Dean, 2026-08-10). */
.startbar + .hint { margin: 0; }
/* The plan tile's photo-lock notice rides the Start row itself, sitting left-aligned after
   the credit line — the row's own 1.1rem gap is the separation (Dean, 2026-08-17). */
.startbar .lock-note { margin: 0; }
/* The uploader's status line (photo count, the no-photos warning) rides the same row, right
   of the Plan Walkthrough button (Dean, 2026-08-18). A <p> keeps its default 1em margins
   without this, which would shove it off the row's centre line. */
.startbar .hint { margin: 0; }

/* /new's CTA tile (spec DF §2): button and its two instruction lines on one row, replacing the
   plain .startbar. Not a reuse of .build-row -- that is flex-wrap: wrap with the text block
   claiming the full width, so both lines dropped below the button (measured, then replaced). */
.startrow { display: flex; align-items: center; gap: 1rem; flex-wrap: nowrap; }
.startrow > button { flex: none; }
.startrow > .start-say { flex: 1 1 0; min-width: 0; display: grid; gap: 0; }
.startrow > .start-say > p { margin: 0; text-align: left; }

/* /new's listing-import row: input and button share one row, both 40px (spec DF §3) -- a short
   button on a 40px row is the same defect as the 44px stats icons (v2026-08-24-023). */
.listing-row { display: flex; align-items: center; gap: 0.6rem; }
.listing-row > input  { flex: 1 1 auto; min-width: 0; }
.listing-row > button { flex: none; white-space: nowrap; height: 40px; }

@media (max-width: 560px) {
  .startrow { flex-wrap: wrap; }
  .startrow > .start-say { flex: 1 1 100%; }
  .listing-row { flex-wrap: wrap; }
  .listing-row > button { flex: 1 1 100%; }
}

/* The price, on its own line and at heading size. It is the single fact the agent is deciding
   on; the sentences under it are context and read as such. */
.cost {
  font-size: 1.125rem; font-weight: 500; letter-spacing: -0.01em;
  margin: 1.1rem 0 0.3rem;      /* collapses with the .hint below, so it sets that gap */
}
/* The render cost line's own coin, same 16px icon as the masthead balance. inline-flex for the
   same reason as .topline nav a: a bare gap on mixed text+img content is not free otherwise. */
.cost-credit { display: inline-flex; align-items: center; gap: 0.25em; }
/* The referral count line's own coin, third site sharing this need (Dean, 2026-09-07: the icon
   dropped to its own line). Without an inline-flex wrapper .cred-icon's own `display: block`
   forces a break after the bold count, exactly like .cost-credit would without one. */
.cred-line { display: inline-flex; align-items: center; gap: 0.25em; }

/* The tile's own rhythm. Its <p>s carried the browser's default 1em margins, so the two
   explanatory sentences sat as far apart as the price does from the button — three unrelated
   blocks rather than a price with its explanation under it. The gap between the sentences is
   now smaller than the gap that separates them from the price above. */
.note > .cost + .hint { margin: 0.15rem 0 0.5rem; }
.note > .hint + .hint { margin: 0; }
/* The agency-logo format notes on /entity read as one tight block — no vertical gap between
   the five hint lines. They are siblings inside the .note grid, whose gap does not touch
   this wrapper, so the gap the grid would have added is removed. */
.logo-notes { display: grid; gap: 0; }

/* --- account section -----------------------------------------------------
   My Reels, Profile, Agency, Team and Credits are one errand — your stuff, rather than the
   walkthrough in front of you — so they share a sidebar instead of adding five items to a
   masthead that was starting to read as a menu bar.

   The nav sits at the page's left edge, level with the wordmark above it: .wrap already places
   that edge, so the sidebar simply starts the grid there rather than being indented into the
   content column, which would make it look like part of the page instead of the frame. */
.acct { display: grid; grid-template-columns: 11rem 1fr; gap: 2.5rem; align-items: start; position: relative; }
/* The How-to Guide link's own shape (spec HG, 2026-09-03): a right-aligned two-line label beside
   the icon, both centred on a shared axis. Positioning (where the row sits on each of the
   thirteen pages) is a separate concern, handled by the context-specific rules below and by
   `.workflow-head .howto` further down — this rule is what stays the same everywhere. */
.howto { display: inline-flex; align-items: center; gap: 9px; text-decoration: none; }
.howto-label { font-size: 0.75rem; line-height: 1.2; text-align: right; white-space: nowrap; }
.howto svg { display: block; flex: none; }
/* At rest the link recedes; hovering brings it up to the page's own ink and full-strength red
   (Dean, 2026-09-05: "the hint colour normally, then on mouse-over the current colour"). It sits
   on thirteen pages beside real content, so resting quiet and paying attention when reached for
   is the behaviour that keeps it available without competing.

   `color: inherit` on hover, not `var(--ink)`: inherit is what this rule USED to be unconditionally,
   so hovering restores exactly today's colour wherever the link sits, including any context that
   sets its own. The at-rest colour is --ink-2, the same token `.hint` uses (:1852) -- the hint
   colour Dean named, taken from the token rather than restated, so the two cannot drift.

   :focus-visible matches :hover so a keyboard reaches the same state a mouse does.

   Both halves recolour through the classes the SVG carries, .yt-body and .yt-play, not positional
   selectors. The triangle dims too (Dean, 2026-09-05, after seeing the body alone): a full-strength
   #f0eeec wedge on a dimmed body read as the loudest thing left in a link that is meant to be
   resting. At 0.80 lightness it still clears the 0.42 body by a wide margin, so the play shape
   stays legible -- it is quieter, not lower-contrast. */
.howto { color: var(--ink-2); }
.howto .yt-body { fill: var(--howto-red); }
.howto .yt-play { fill: var(--howto-play); }
.howto, .howto .yt-body, .howto .yt-play { transition: color 120ms ease, fill 120ms ease; }
.howto:hover, .howto:focus-visible { color: inherit; }
.howto:hover .yt-body, .howto:focus-visible .yt-body { fill: #db3834; }
.howto:hover .yt-play, .howto:focus-visible .yt-play { fill: #f0eeec; }
/* §6, r4: the floating in-page player HOWTO_PLAYER_JS mounts on click. A pinned corner card, not
   a centred modal -- the page stays visible (and, unlike .rv-modal's dialog backdrop, interactive)
   behind it, per Dean's ruling that the video belongs to the page it sits on. Flat Ground Rule
   (see the .rv-modal comment above): border, not box-shadow, is the lift. z-index 40 clears the
   sticky masthead (20) and the tooltip bubble (30), the two highest explicit values in this file. */
.howto-player {
  position: fixed; right: 1rem; bottom: 1rem; z-index: 40;
  width: min(540px, calc(100vw - 2rem));
  background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
  overflow: hidden;
}
.howto-player-head {
  display: flex; align-items: center; justify-content: space-between;
  padding: 0.2rem 0.75rem; font-size: 0.75rem; font-weight: 600; color: var(--ink);
  border-bottom: 1px solid var(--line);
}
.howto-player-close {
  background: none; border: 0; padding: 0.15rem 0.4rem; font: inherit; font-size: 1.25rem;
  line-height: 1; color: var(--ink-2); cursor: pointer;
  min-height: 24px; min-width: 24px;   /* WCAG 2.5.8 AA floor, deliberately below the site-wide
                                          44px (2.5.5 AAA) -- Dean, 2026-09-04, spec HK §1 */
}
.howto-player-close:hover, .howto-player-close:focus-visible { color: var(--ink); }
.howto-player-body { aspect-ratio: 16 / 9; }
.howto-player-body iframe { display: block; width: 100%; height: 100%; border: 0; }
@media (max-width: 480px) {
  .howto-player { right: 0.5rem; bottom: 0.5rem; left: 0.5rem; width: auto; }
}
/* The How-to Guide link, top-right corner of the account CONTENT card, level with its title
   (spec HG, 2026-09-03 -- replaces the agency mark that lived here, `.acct-logo`, removed below
   with it). Anchored to .acct-body, which defines the card column, so the right edge lines up
   with the card's — not with the wider section frame the sidebar and body share.

   No /walkthroughs-specific offset: the removed `.acct-body.wt .acct-logo` rule existed because
   the agency mark is IDENTITY and Dean wanted it beside the person filter, not the heading
   (2026-08-12). The How-to link is wayfinding, not identity, and every other placement in this
   sheet sits at the heading — so this one does too, deliberately (spec HG's builder decided this
   explicitly rather than inheriting or silently dropping the old rule; see the handback).

   Scoped to `.acct-body` rather than a bare `.howto`: Group B's five workflow screens (spec HG
   §5) place the same component in a flex title row instead, where absolute positioning would
   pull it out of that row's layout entirely — see the `.workflow-head .howto` rule further down. */
.acct-body .howto { position: absolute; top: 0; right: 0; z-index: 1; }
/* Down one of its own rows (36px item + 0.1rem gap), which drops the first item off the h1's
   line and onto the content below it — the menu reads as a sibling of the page rather than of
   its heading. */
.acct-nav { display: grid; gap: 0.1rem; position: sticky; top: 5.5rem; margin-top: 2.375rem; }
.acct-nav a, .acct-nav button {
  padding: 0.4rem 0.6rem; border-radius: 0;
  color: var(--ink-2); text-decoration: none; font-size: 0.9375rem;
}
.acct-nav a:hover, .acct-nav button:hover { background: var(--surface-2); color: var(--ink); }
/* Log out is a form, not a link, and has to be: `/logout` is a POST because a GET one is followed
   by browser prefetch, link previews and any `<img src=/logout>` on another site. `display:
   contents` drops the form out of the grid so the BUTTON becomes the item and picks up the rule
   above — the same trick `.team-actions form` uses. Everything reset below is something the
   global `button` rule sets and a row in a list should not have: the border, the centred flex,
   the 1.1rem padding, and the 44px floor, which would make this one item taller than the six
   above it. It matches the list's 36px rather than the button guidance, because it now IS a menu
   item; the 44px rule is about standing controls. (Dean, 2026-09-05.) */
.acct-nav form { display: contents; }
.acct-nav button {
  border: 0; background: none; min-height: 0; width: 100%;
  font-weight: inherit; justify-content: flex-start; text-align: left;
}
/* The current page is filled, matching the workflow bar's "you are here" — one visual idea for
   one meaning, rather than a second convention for the same job. */
.acct-nav a[aria-current=page] {
  background: var(--surface-2); color: var(--ink); font-weight: 500;
  box-shadow: inset 2px 0 0 var(--accent);
}
/* The sidebar needs the page's full measure to sit under the wordmark, but the CONTENT beside
   it does not want 924px: three price tiles stretched to fill that are billboards, and a list
   of job rows becomes a thumbnail marooned from its own text. Capped at the reading width the
   rest of the site uses, left-aligned under the heading rather than centred in the gap. */
.acct-body { min-width: 0; max-width: 760px; position: relative; }
/* ...except a table, which is not prose and does not want a reading measure. The cap exists so
   three price tiles do not become billboards; six columns with an address in them need the
   frame. */
.acct-body.full { max-width: none; }
.acct-body h1 { margin-top: 0; }
/* #person is the anchor the team page's Edit links scroll to. The masthead is sticky (57px), so
   with no scroll-margin the heading lands flush beneath it and its 32px top margin is scrolled
   away — the Edit view read with a different top gutter to every other heading on the page.
   81px = 57px masthead + 24px standard gutter, matching the h1's air at the top of the page. */
#person { scroll-margin-top: 81px; }
/* Who you are on the left, your agency's logo on the right. The text is one block, not a stack
   of body-copy paragraphs — default <p> margins had the lines 16px apart and reading as
   unrelated. The name leads at full ink; the rest is supporting detail, which is what `p + p`
   colours. */
/* Three tracks, not two — the middle one for the Admin badge, same trick as the team row (line
   ~1319): two equal `minmax(0, 1fr)` sides put an `auto`-width middle column at the row's true
   centre regardless of what either side needs, and it collapses to nothing when the badge is
   absent. Dean, 2026-08-03. */
.whoami { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); gap: 1rem; align-items: stretch; }
.whoami p { margin: 0; }
.whoami p + p { margin-top: 0.2rem; color: var(--ink-2); font-size: 0.9375rem; }
/* align-self: start, not centre against the whole tile — Dean, 2026-08-03: the badge should read
   as sitting beside the name, the first line, not floating at the mid-height of four.

   align-self: start alone pins this box to the top of the GRID CELL, which is not where the
   name's glyphs are: the name's line box is taller than its cap-height, so the visible text sits
   some way below the cell's top edge while the badge, unstyled, sat flush with it — reading as
   floating high (spec IJ, 2026-09-07). Fix: give the badge a box exactly `1lh` tall (the line
   height this element inherits, which is the SAME inherited value the name's own `<p>` uses,
   since neither sets its own font-size/line-height) and centre the badge inside that box. Not a
   measured pixel offset — `1lh` tracks whatever the shared line-height actually is, so the two
   stay aligned if the body font ever changes, at this breakpoint or the mobile one below. */
.whoami-badge {
  grid-column: 2; align-self: start;
  height: 1lh; display: flex; align-items: center; justify-content: center;
}
/* The logo fills the height of the lines beside it — and must not set that height. Left in the
   flow, a tall logo makes the row as tall as ITSELF and the text stretches to match, which grew
   the tile from 104px to 176px for a square logo. Absolutely positioned, it takes no part in
   sizing the row: the column stretches to whatever the text needs and the image fits inside it.
   Contained, never cropped or squashed — same rule as the outro card. A square logo therefore
   fills the height; a wide lockup runs out of width first and sits shorter, which is right.
   grid-column: 3, explicit — the badge column is only in the DOM for an admin, and without a
   fixed slot auto-placement would drop this into column 2 for everyone else. justify-self: end
   keeps it flush with the tile's right edge now that its own track flexes instead of sitting
   exactly 11rem wide. */
.whoami-ag { position: relative; width: 11rem; grid-column: 3; justify-self: end; }
.whoami-logo {
  position: absolute; top: 0; right: 0; height: 100%; width: 100%;
  object-fit: contain; object-position: right center;
}
/* A face, not a mark — so `cover` and a circle, where a logo needs `contain` and its own shape.
   Cropping a portrait to fill the circle is right; cropping a logo would not be. Square, so it
   sizes off the row height and does not stretch to the 11rem column. */
.whoami-avatar {
  position: absolute; top: 0; right: 0; height: 100%; aspect-ratio: 1;
  object-fit: cover; border-radius: 50%; background: var(--surface-2);
}
@media (max-width: 560px) {
  /* Stacked, there is no column of text to match, so the logo goes back into the flow under a
     fixed cap. Absolute positioning here would collapse it to nothing — no row to fill. Centred,
     both of them — there is only one column now, and end/auto would just mean left. */
  .whoami { grid-template-columns: 1fr; gap: 0.75rem; }
  /* grid-column: 1, spec IK, 2026-09-07 -- the desktop rules above pin these to columns 2/3 on
     purpose (this query's own comment explains why), but neither was ever RESET here, so the
     grid kept generating implicit tracks 2/3 to satisfy that placement and never actually
     collapsed to the single 1fr track this block declares. Confirmed on both the admin (badge
     present) and non-admin (badge absent) case -- both fail identically without this. */
  .whoami-badge { grid-column: 1; justify-self: center; }
  .whoami-ag { grid-column: 1; position: static; width: auto; justify-self: center; }
  .whoami-logo { position: static; height: auto; width: auto; max-height: 2.75rem; }
  .whoami-avatar { position: static; height: 3.5rem; width: 3.5rem; }
}

@media (max-width: 720px) {
  /* Stacked, and the nav becomes a scrolling row: a 11rem column beside content on a phone
     leaves neither enough room. */
  .acct { grid-template-columns: 1fr; gap: 1.25rem; }
  /* The How-to link lives inside .acct-body (it aligns to the card's right edge), so on the
     stacked layout right-align it against the body instead of relying on grid justify-self --
     same reasoning the removed `.acct-logo` rule had, but `display: flex` rather than `block`:
     .howto is a label-plus-icon row, not a single image, and needs its own flex context to keep
     that row even once it stops being absolutely positioned. Its own narrow-width sizing (§HG
     gate 5) is unchanged from wide -- 12px/two-line label and a 42x31 icon read fine stacked. */
  .acct-body .howto { position: static; display: flex; margin-left: auto; }
  .acct-nav {
    position: static; grid-auto-flow: column; justify-content: start; gap: 0.25rem;
    overflow-x: auto; scrollbar-width: none; padding-bottom: 0.25rem;
    margin-top: 0;      /* stacked, there is no h1 beside it to step down from */
  }
  .acct-nav::-webkit-scrollbar { display: none; }
  /* The button too, not just the links: this is the third `.acct-nav a` selector and the one
     that only exists in this block, so extending the two above it for Log out (2026-09-05)
     left it wrapping to two lines here and nowhere else. */
  .acct-nav a, .acct-nav button { white-space: nowrap; }
  .acct-nav a[aria-current=page] { box-shadow: inset 0 -2px 0 var(--accent); }
}

/* --- agency ------------------------------------------------------------- */
/* Suburb, state and postcode on one line, sized to what each holds: a suburb name is long, a
   state is three letters, a postcode is four digits. Equal thirds would give the state five
   times the room it needs and the suburb less than it wants. */
/* minmax(0, 1fr), not 1fr, wherever a track holds a form control. `1fr` is minmax(auto, 1fr), and
   an <input>'s auto minimum is its intrinsic 20-character width — 275px — which `width: 100%`
   does not lower, because a percentage resolves to auto while the track is being sized. The track
   would grow to the input rather than the input shrinking to the track. Defensive rather than a
   fix for something seen: the 320px overflow measured here came from the file input, which is a
   worse case again (see input[type=file]). These tracks were one longer label away from it. */
.addr-line { display: grid; grid-template-columns: minmax(0, 1fr) 7rem 7rem; gap: 12px; }
.addr-line p { margin: 0; }
@media (max-width: 560px) { .addr-line { grid-template-columns: minmax(0, 1fr); } }
/* Street address and the agency's listing reference share a line above 560px — placement only,
   not a DOM move (see the comment in server.py on the property_id input): it still comes after
   suburb/state/postcode in source order, which is what the single-column fallback below 560px
   uses, and is what keeps this exact field from reading as the second line of the address there.
   The form uses the SAME three-column template as .addr-line (suburb/state/postcode), so the
   address field's 1fr column is exactly the suburb column — the two look the same width, and
   the Job ID input spans the two fixed 7rem columns so it fills out to the right edge like
   state+postcode do. Dean, 2026-08-12 (was a 1fr + 12rem split, which made address wider). */
/* .addr-form is also a .stack, whose later `gap: 1rem` would flatten this block's gap to
   1rem and create an oversized 16px gutter between the address and suburb rows — the
   .stack.addr-form selector (2 classes) out-ranks .stack, keeping a uniform 0.6rem gap so the
   address and suburb rows sit tight and match the rest of the form rhythm. */
.stack.addr-form { grid-template-columns: minmax(0, 1fr) 7rem 7rem; gap: 12px; }
.addr-form > input[name=address] { grid-column: 1; grid-row: 1; }
.addr-form > input[name=property_id] { grid-column: 2 / 4; grid-row: 1; }
.addr-form > .addr-line { grid-column: 1 / -1; grid-row: 2; }
.addr-form > p { grid-column: 1 / -1; grid-row: 3; }

/* /new's Walkthrough details, two lines (Dean, 2026-08-24):
     line 1  Address .................................. | Suburb
     line 2  State | Postcode | Project Code           | bed bath car m2

   ONE grid, not two nested rows, and that is the whole reason this works. Suburb has to line up
   with the stats block below it, and the stats block is `max-content` -- an intrinsic width nothing
   else can be told. Two sibling grids cannot agree on that number; one grid with a shared 4th
   column gets the alignment for free and keeps it if a fifth stat is ever added. Measured 834px
   wide: both lines break at x=427.

   `.newdetails` rather than editing `.addr-form`: that class is also the plan screen's address
   form (`/jobs/{job}/address`, server.py), which still wants its own three-column shape. Two
   classes out-rank the one-class rules below, so those stay as they are and keep serving it.

   The two narrow columns are 6rem = 96px, which is what "Postcode" actually needs -- measured at
   95px (70px of text + 11.2px padding each side + 2px border), so 6rem is the round token just
   over it. State takes the same width because its own content is shorter, so the pair reads as one
   unit. Project Code gets `minmax(0, 1fr)`: whatever is left, 199px here. The `minmax(0, ...)`
   matters -- a bare `<input>` has an intrinsic minimum of about 20 characters, and as a grid item
   that becomes the track's floor unless the minimum is explicitly zeroed. */
.stack.addr-form.newdetails { grid-template-columns: 6rem 6rem minmax(0, 1fr) max-content; }
/* `.stack` is in these three container selectors for specificity, not for meaning. The existing
   `@media (max-width: 560px)` block below sets `.stack.addr-form { grid-template-columns:
   minmax(0, 1fr) }` -- also two classes, so a plain `.addr-form.newdetails` TIED with it and lost
   on source order, because these rules are declared above that block. Between 461px and 560px that
   produced a `0px 209px 209px` template with Address collapsed to 24px and the stats block to
   zero. Three classes wins at any source position. Found by rendering 500px; every width either
   side of that band looked correct, which is exactly why the band needed rendering. */
/* Row 2 is 44px tall because the stats block's icons are 44px, while every control on the site is
   40px. Left to stretch, Postcode and Project Code grew to fill it and the <select> did not (it
   carries an explicit height, so it ignores `stretch`) -- three controls side by side at 44, 44
   and 40. Centring instead keeps all of them at 40 and lines up what actually reads as the row:
   the input BOXES, including the four number boxes inside the stats block, which are 40px too.
   Harmless on row 1, where the tallest item is already 40. */
.stack.addr-form.newdetails { align-items: center; }
/* Line 1 is its own row, not two cells of the shared template (Dean, 2026-08-24: "make suburb
   narrower"). While Address was pinned to columns 1-3 and Suburb to column 4, Suburb WAS the stats
   block's width -- 407px for a field that holds "Ashgrove" -- and narrowing it inside that column
   only moved the problem: `justify-self: start` leaves a ragged right edge, `end` opens a 227px
   hole mid-line. Neither is a layout. Giving line 1 its own two tracks lets Address absorb exactly
   what Suburb gives up, so both lines still run flush from 0 to the form's right edge; what changes
   is that line 1 no longer breaks at the same x as line 2, which is the thing being traded away on
   purpose. 12rem shows about 26 characters -- longer than "Surfers Paradise" with room spare. */
.addr-form.newdetails > .nd-top {
  grid-column: 1 / -1; grid-row: 1;
  display: grid; grid-template-columns: minmax(0, 1fr) 12rem; gap: 12px;
}
.addr-form.newdetails > select                  { grid-column: 1;     grid-row: 2; }
.addr-form.newdetails > input[name=postcode]    { grid-column: 2;     grid-row: 2; }
.addr-form.newdetails > input[name=property_id] { grid-column: 3;     grid-row: 2; }
.addr-form.newdetails > .statfields             { grid-column: 4;     grid-row: 2; }
/* The stats block is right-aligned everywhere else on the site (`.statfields` sets
   `justify-self: end`). Here its column is sized to it exactly, so alignment inside the cell is
   moot on desktop -- but it is NOT moot once the block spans the full width below, where `end`
   would shove four fields to the right of an empty row. */
.addr-form.newdetails > .statfields { justify-self: start; margin-left: 0; }
/* 40px, not the 44px these icons carry in the plan tile. Every control on this form is 40px, and a
   44px block on the same row made the row 44px, so `align-items: center` floated the 40px inputs
   2px low at BOTH ends. The tile's whole rhythm is 12px, and that overhang turned the gap above
   row 2 into 14px and the gutter beneath it into 15px -- read as a bottom gutter that was too big
   (Dean, 2026-08-24), which is exactly what it was, just not where it looked like it came from.
   Measured after: row gap back to 12px, gutters 13px top and bottom, stats block flush with the
   input tops. Scoped to .newdetails so the plan screen's details tile keeps its 44px icons. */
.addr-form.newdetails .statfields.tile-stats .statfile svg { width: 40px; height: 40px; }
/* `margin-left: 0` is the load-bearing half. `.statfields.tile-stats` sets `margin-left: auto` to
   push itself right in the plan tile, and an auto margin eats every spare pixel in a grid cell --
   it beats `justify-self` because it is consumed during layout, not alignment. On desktop the cell
   is `max-content`, exactly the block's width, so there was no slack and no symptom. It only
   appeared at <=720px where the block spans the full row: measured x248 in a 654px form, i.e.
   still hard right. Caught by rendering the breakpoint, not by reading the rule. */

/* Project Code's tooltip anchors to the nearest .t-tt-anchor, and that used to be the whole
   826px grid -- so `left: 0` put the bubble above the TILE's edge, 216px from the field it
   describes. The field gets its own anchor, which is also its grid cell (spec DF §7a). The old
   `input[name=property_id]` grid-column/row rules below are unmatched now that the input is no
   longer a direct grid child -- left as-is, deliberately: CSS Grid auto-placement puts .tt-cell
   into row 2's one remaining gap (select and postcode take columns 1-2, .statfields takes column
   4), which is the only slot left and is exactly where it needs to be. Measured, not assumed. */
.newdetails .tt-cell { position: relative; display: block; min-width: 0; }
.newdetails .tt-cell > input { width: 100%; }

/* Line 2 needs 96 + 96 + 407 for the fixed parts plus three 12px gaps = 635px before Project Code
   gets a pixel, and the form runs about 66px narrower than the viewport until it caps at 834px. So
   the two-line layout only holds above roughly 840px: measured at 760px it gave Project Code 60px,
   which is a field you cannot read a reference in. 860px leaves it 159px at the boundary and 199px
   once the form caps. Below that the two lines become four rows: address, suburb, then
   state/postcode/project code, then the stats. The first draft of this rule said 720px and was
   wrong -- found by rendering 760, not by reading the arithmetic. */
@media (max-width: 860px) {
  .stack.addr-form.newdetails { grid-template-columns: 6rem 6rem minmax(0, 1fr); }
  .addr-form.newdetails > .nd-top                 { grid-column: 1 / -1; grid-row: 1; }
  .addr-form.newdetails > select                  { grid-column: 1;      grid-row: 2; }
  .addr-form.newdetails > input[name=postcode]    { grid-column: 2;      grid-row: 2; }
  .addr-form.newdetails > input[name=property_id] { grid-column: 3;      grid-row: 2; }
  .addr-form.newdetails > .statfields             { grid-column: 1 / -1; grid-row: 3; }
}
/* One column. State and Postcode stop sharing a row because 2 x 6rem + a gap + the page's own
   padding is already past a 360px viewport. Every child is unpinned explicitly: the 560px block
   below unpins the one-class `.addr-form` rules, which cannot reach these two-class ones -- a
   selector that quietly stops applying is the defect this file keeps re-learning. */
@media (max-width: 460px) {
  .stack.addr-form.newdetails { grid-template-columns: minmax(0, 1fr); }
  /* Address beside a 12rem Suburb leaves Address 113px in a 317px form. One per line instead. */
  .addr-form.newdetails > .nd-top { grid-template-columns: minmax(0, 1fr); }
  .addr-form.newdetails > .nd-top,
  .addr-form.newdetails > select,
  .addr-form.newdetails > input[name=postcode],
  .addr-form.newdetails > input[name=property_id],
  .addr-form.newdetails > .statfields { grid-column: 1; grid-row: auto; }
}
@media (max-width: 560px) {
  .stack.addr-form { grid-template-columns: minmax(0, 1fr); }
  .addr-form > input[name=address],
  .addr-form > input[name=property_id],
  .addr-form > .addr-line,
  .addr-form > p { grid-column: auto; grid-row: auto; }
}
/* A checkbox in a two-up beside a labelled input: the input has a label line above it and is
   40px tall, so left alone the checkbox floats at the top of its cell and reads as belonging to
   nothing. Bottom-aligned and matched to the input's height, it sits level with the field it
   qualifies. The hint goes underneath rather than after the word, where it would push the box
   off the grid line on a narrow screen. */
.checkline {
  align-self: end; display: flex; align-items: center; min-height: 40px; margin: 0;
  /* `gap` here rather than only on the inner label, because the card toggles use `.checkline`
     AS the label rather than as a wrapper around one. Inert for the wrapper form, which has a
     single child. */
  gap: 0.5rem;
}
/* The card's three toggles. A column on a phone, a row once there is width for one — they are
   one decision each, so they read better together than stacked down a form. */
.ticks { display: flex; flex-wrap: wrap; gap: 0.25rem 1.5rem; margin: 0.25rem 0 0; }
.ticks .checkline { align-self: auto; min-height: 36px; }
.checkline label { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.checkline .hint { margin: 0; }
/* 1.1rem, not the 13px default: beside a 40px field the browser's box reads as an afterthought,
   and this one decides who can spend the agency's money.
   The card toggles and the outro stat checkboxes share a styled-checkbox pattern (transitions.dev
   "Checkbox check", 2026-08-06): the box fills accent, then the check draws on via
   stroke-dashoffset. The native input stays in the markup — its `show_*` value still posts, the
   browser still handles keyboard/Screen-reader, and `prefers-reduced-motion` kills the whole
   animation — but its stock glyph is hidden; the sibling `.check-glyph` svg is the visible box.
   The admin checkbox has no `.check-glyph` beside it, so it deliberately stays native. */
.checkline.glyph input[type=checkbox], .statcb input[type=checkbox] {
  position: absolute; width: 1px; height: 1px; min-height: 0; margin: 0; padding: 0;
  border: 0; appearance: none; opacity: 0; flex: none; pointer-events: none;
}
.check-glyph {
  display: inline-flex; align-items: center; justify-content: center;
  width: 1.1rem; height: 1.1rem; min-height: 0; flex: none; box-sizing: border-box;
  border: 1px solid var(--line-firm); background: var(--surface);
  --check-box: 150ms; --check-draw: 350ms; --check-ease: cubic-bezier(0.22, 1, 0.36, 1);
  transition: background var(--check-box) var(--check-ease),
              border-color var(--check-box) var(--check-ease);
}
.check-glyph path {
  fill: none; stroke: var(--on-accent); stroke-width: 1.8; stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 15; stroke-dashoffset: 15;   /* path length ~10.1; 15 clears it */
  transition: stroke-dashoffset 150ms var(--check-ease);
}
input[type=checkbox]:checked + .check-glyph { background: var(--accent); border-color: var(--line-firm); }
input[type=checkbox]:checked + .check-glyph path {
  stroke-dashoffset: 0; transition: stroke-dashoffset var(--check-draw) var(--check-ease);
}
/* The box fill (150ms) is a sibling transition; the stroke draws after it starts. Keyboard-only
   users get the same focus affordance the native box would have shown. */
input[type=checkbox]:focus-visible + .check-glyph { box-shadow: 0 0 0 2px var(--accent); outline: none; }
@media (prefers-reduced-motion: reduce) {
  .check-glyph, .check-glyph path { transition: none !important; }
}
/* A disabled tick (spec EQ) -- nothing for it to switch on, so it reads as unavailable rather
   than merely unchecked. Dimmed, not hidden: the row still shows the placeholder face beside it.
   The input itself is visually hidden and pointer-events:none (see .checkline.glyph above), so
   the cursor has to land on the label -- the element that actually receives the click. */
input[type=checkbox]:disabled + .check-glyph { opacity: 0.35; }
.checkline:has(input[type=checkbox]:disabled) { cursor: not-allowed; }
.agency-logo {
  max-width: 200px; max-height: 80px; object-fit: contain;
  background-color: var(--plate-check-a);
  background-image:
    linear-gradient(45deg, var(--plate-check-b) 25%, transparent 25%, transparent 75%, var(--plate-check-b) 75%),
    linear-gradient(45deg, var(--plate-check-b) 25%, transparent 25%, transparent 75%, var(--plate-check-b) 75%);
  background-size: 16px 16px;
  background-position: 0 0, 8px 8px;
  border: 1px solid var(--line); border-radius: var(--radius); padding: 0.5rem;
}
/* Only admins are badged. An unmarked row is an agent, which is the common case, and badging
   every row twice over would make the list harder to scan for the thing you are looking for —
   who can spend money. Aligned with the buttons because it is the same kind of fact: a property
   of the account, not a way to contact them. */
.role-badge {
  display: inline-flex; align-items: center; justify-content: center;
  align-self: center; font-size: 0.75rem; font-weight: 500;
  color: var(--accent-ink); border: 1px solid var(--accent); border-radius: 0;
  padding: 0.15rem 0.5rem; white-space: nowrap;
  /* A fixed height with flex centring, not line-height: wherever the badge sits its box is the
     same pixels. A direct grid child (the People rows) is blockified and measures taller than
     the inline version beside an account name; one explicit height kills that variance. The
     height is the account badge's natural inline box, so /account doesn't move. Dean,
     2026-08-12. */
  height: 1.33rem;
  box-sizing: border-box;
}
/* Two columns, not three (spec IO, 2026-09-08): the badge moved off its own track and into
   .row-end, beside Edit, at every width -- see .row-end below. Was
   `minmax(0, 1fr) auto minmax(0, 1fr)`, badge in the middle track; the ≤560px override that used
   to switch this to two columns for lack of room is gone too, because this IS that shape now,
   unconditionally, and there is no badge column left to lose. */
.planrow.team-row {
  display: grid; grid-template-columns: minmax(0, 1fr) auto;
  align-items: center; gap: 0.6rem;
}
.team-row .scene-main { grid-column: 1; }
.team-row .row-end    { grid-column: 2; justify-self: end; }
/* The photo, on the left of name/title/contact -- a nested flex wrapper around the existing
   block, the same place and the same way /walkthroughs' own thumbnail sits beside its label
   (`a.rowlink` below), not a grid track of its own. `.scene-text` is what stacks name/title over
   contact as before; `.scene-main` only lays the photo and that block side by side. Scoped to
   `:not(.mine)` for the same reason the ≤480px fix a few screens down is -- /walkthroughs has its
   own thumbnail mechanism through `.rowlink` and must not gain a second, competing one here. */
ul.scenes:not(.mine) .team-row .scene-main { display: flex; align-items: center; gap: 0.9rem; }
.team-row .scene-text { min-width: 0; }
.team-photo {
  width: 44px; height: 44px; border-radius: 50%; object-fit: cover; flex: none;
  border: 1px solid var(--line); background: var(--surface-2);
}
/* Badge and actions together, right-justified as one unit (spec IO) -- same DOM order as before
   (badge first) so `.role-badge`'s own appearance is untouched, just no longer a grid column. */
.row-end { display: flex; align-items: center; gap: 0.6rem; }
/* /mine rows: the row link is the anchor wrapping thumb + label (not the whole planrow now that
   Edit/View + Delete ride beside it). .rowlink restores the clickable span's flex layout so the
   thumb and text sit side by side as before, and the plain <a> underline is dropped. */
a.rowlink { display: flex; align-items: center; gap: 0.9rem; text-decoration: none; color: inherit; }
.rowlink .rowsep { flex: 1; min-width: 0; }
/* /mine has no role badge and no .row-end -- its own two direct children (the rowlink, and
   .team-actions) keep their own placement, untouched by the badge move above. */
ul.scenes.mine .planrow.team-row { grid-template-columns: minmax(0, 1fr) auto; }
ul.scenes.mine .team-row .scene-main   { grid-column: 1; }
ul.scenes.mine .team-row .team-actions { grid-column: 2; justify-self: end; }
.team-actions {
  display: grid; grid-template-columns: var(--rowbtn) var(--rowbtn);
  align-items: center; justify-items: center; gap: 0.5rem;
}
.team-actions form { display: contents; }   /* so the button, not the form, is the grid item */
/* Named columns, not auto-placement. A row with no Remove would otherwise slide Edit into the
   empty slot — the ragged edge the grid is here to fix. Same lesson as the masthead: an absent
   element does not hold its place. */
.team-actions a.rowbtn      { grid-column: 1; }
.team-actions button.rowbtn { grid-column: 2; }
/* The ≤560px two-row stacking trick (badge above actions, for lack of room beside a 2.4rem
   button pair) is gone (spec IO, 2026-09-08): badge and actions now sit together in one
   `.row-end`, side by side, at every width -- there is nothing left to stack. */
/* Square, and sized by the widest thing in one: "Edit" at 0.8125rem needs ~26px, so 2.4rem
   leaves a margin either side without the box growing into a standing button. */
:root { --rowbtn: 2.4rem; }
.rowbtn {
  width: var(--rowbtn); height: var(--rowbtn); min-height: 0; padding: 0;
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 0.8125rem;
}
/* `.planrow .actions button` sets a 44px min-width and its own padding at 0-2-1, which beat the
   rule above and left the cross 44px wide while Edit — an <a>, so untouched — sat at 38. Only
   the <button> was wrong, which is exactly the kind of difference that reads as a rendering
   glitch rather than a CSS one. */
.planrow .team-actions .rowbtn { min-width: var(--rowbtn); padding: 0; }
/* Red on the destructive one. --bad is a red at the same lightness as the other tokens, so it
   reads as this palette rather than a browser default; the border stays quiet until hover so a
   row of people is not a row of alarms. */
.rowbtn.danger { color: var(--bad); font-size: 0.9rem; }
.rowbtn.danger:hover { background: var(--bad-bg); border-color: var(--bad); }
/* The failed job's error tile: Delete pinned top-right, the message below it. gap for the
   killed-job Retry button that now sometimes sits beside it (spec AQ §1). */
.errtop { display: flex; align-items: center; justify-content: flex-end; gap: 0.5rem; margin: -0.25rem -0.25rem 0.25rem 0; }
/* Pushes Retry/Delete to the row's end without fighting justify-content — a plain sentence,
   not a button, so it needs its own margin reset rather than the row's flex sizing (spec DK). */
.errtop .errsorry { margin: 0 auto 0 0; }
.errtop .rv-del-open { border-color: #CC0000; }
.errtop .rv-del-open:hover { background: rgba(160, 40, 42, 0.25); border-color: #A0282A; }
/* The row you are editing, so the form below is obviously about this person and not the last
   one you clicked. */
.scene.editing { background: var(--surface-2); box-shadow: inset 2px 0 0 var(--accent); }
/* Marks your own row in the team list — you are the one person there who cannot be identified
   by name alone, because you already know who you are. Square corners (not the pill radius),
   to read as a field tag rather than a rounded chip — Dean, 2026-08-05. */
.you {
  font-size: 0.75rem; font-weight: 500; color: var(--ink-3);
  border: 1px solid var(--line-firm); border-radius: 0; padding: 0.05rem 0.4rem;
  margin-left: 0.3rem;
}

/* Two short promises about the money. A list, because they are two separate commitments and a
   sentence joining them reads as one hedge. Sized like .hint — this is supporting copy. */
.terms { margin: 0.6rem 0; padding-left: 1.1rem; color: var(--ink-2); font-size: 0.875rem; }
.terms li { margin: 0.15rem 0; }
/* Revenue-by-month chart. A dozen rects drawn server-side — no charting library, so no script
   tag and nothing to keep up to date. The viewBox scales it to whatever width the note gives it.
   Named `chart-bar` because `.bar` above is the uploader's progress meter (it was the scene
   meter too until spec AR §2 removed that one): an SVG <rect> inheriting its `height: 3px`
   renders as nothing at all. */
/* Sized by HEIGHT, not width. `width: 100%` scales a 270-unit viewBox by 3.3x on a desktop
   note, which blows the 11px labels up to 36px — the whole chart rendered as a billboard.
   Fixing the height pins the scale near 1:1 and lets the width follow the number of months. */
/* No `max-width: 100%`. It looks like a responsive safety net and is the opposite: it outranks the
   height on a narrow window and shrinks the whole drawing, and every label in an SVG is sized in
   viewBox units, so they shrink with it. The width may overflow; the scale may not. */
.chart { height: 170px; width: auto; overflow: visible; display: block; }
.chart-scroll { overflow-x: auto; }
.chart-bar { fill: var(--accent); }
/* These two sizes are viewBox USER UNITS, not pixels, and the rule above is what makes that safe:
   the scale is pinned, so they land above the readable floor at every viewport. Waived per line,
   never per value — a stray small size in ordinary CSS elsewhere must still fail the pass.
   (Do not write the numbers in this comment: the detector matches the file by regex and does not
   skip comments, so naming them here would re-raise exactly what these lines waive.) */
.chart-x, .chart-n { text-anchor: middle; fill: var(--ink-2); font-size: 11px; } /* impeccable-disable-line design-system-font-size: viewBox user unit, see The Fixed Scale Rule */
.chart-n { fill: var(--ink-3); font-size: 10px; } /* impeccable-disable-line design-system-font-size: viewBox user unit, see The Fixed Scale Rule */
.money { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
/* The authenticator setup key. Wide letter-spacing and a monospace face because this gets read
   off the screen and typed into a phone: 0/O and 1/l are the whole problem. `user-select: all`
   so one click takes the lot — a half-copied secret enrols an app that then never matches. */
.secret {
  display: inline-block; user-select: all; font-size: 1rem; letter-spacing: 0.08em;
  padding: 0.45rem 0.6rem; background: var(--surface-2); border-radius: var(--radius);
  word-break: break-all;
}
/* QR stays black on white in both themes — it is a scanning target, not decoration.
   The padding belongs to the WRAPPER, never to the <svg>. With `box-sizing: border-box` above,
   padding on the svg is subtracted from its width, and a symbol with no viewBox then clips
   rather than scales: the quiet zone and an edge of a finder pattern silently disappear and the
   code stops decoding while still looking like a QR. The viewBox in totp.qr_svg is the real
   guard; keeping padding off the element means the two cannot conspire again.
   `crispEdges` stops the browser antialiasing module boundaries into grey mush when the CSS
   size is not an exact multiple of the module count. */
.qr { margin: 0 0 1rem; text-align: center; }
.qr-frame { display: inline-block; background: #fff; padding: 0.5rem; border-radius: 4px; }
.qr svg { display: block; width: 280px; height: auto; max-width: 100%; }
.qr figcaption { margin-top: 0.4rem; }
.note { border: 1px solid var(--line); background: var(--surface); border-radius: var(--radius); padding: 12px; }
.note.warn { border-color: color-mix(in oklch, var(--warn) 35%, var(--line)); }
/* Every note tile measured 33px from its top border to its first line of text where 16px was
   intended: 1px border + 16px --pad + the first child's OWN 16px margin, which sits ON the
   padding instead of collapsing through it (padding prevents the collapse). 58 of the 61 tiles
   in the app carried it. One line fixes all of them, and replaces the two one-off patches that
   had been applied by hand — an inline margin-top:0 on the held-scenes note and a padding-top:0
   on the change-request tiles (Dean, 2026-08-18).
   Deliberately unqualified by element: a note can open with a <p>, a <div>, an <h2> or a <form>,
   and every one of them brought its own top margin to the same place.
   `.share-note > p:first-child` below is MORE specific (0,2,1 against this rule's 0,2,0) and so
   keeps the tighter rhythm it was given in v2026-08-10. */
.note > :first-child { margin-top: 0; }
/* The symmetric half of :first-child above. Without it a tile's last <p> adds its own 1em to
   the padding and every notice reads bottom-heavy — 16 above the text, 32 below (spec BA). */
.note > :last-child { margin-bottom: 0; }
.disclosure { font-size: 0.875rem; color: var(--ink-2); }

/* Share-page format downloads: each button stacks over its format name beneath its OWN video.
   `.dlpair` mirrors `.share-vids`' column split (256fr 81fr), so the 16:9 button sits centred
   under the wide video and the 9:16 under the portrait, exactly one column each — flex would
   left-pack them together instead of under their files. On a phone the pair falls to a single
   already-stacked column, matching the videos above. */
.dlpair { display: grid; grid-template-columns: 256fr 81fr; gap: 0.75rem; justify-items: center; }
@media (max-width: 560px) { .dlpair { grid-template-columns: 1fr; } }
.dl { display: inline-flex; flex-direction: column; align-items: center; gap: 0.3rem; }
.dl small { color: var(--ink-2); }

/* The share page shows both finished cuts side by side — the 16:9 master and the 9:16 social
   cut — rather than only the wide one. Two columns on a wide screen; the vertical falls below
   the wide on a phone, since two live videos side by side on 390px would be unreadably small.
   The column split is 256fr 81fr, not 16fr 9fr, because a 16:9 master and a 9:16 portrait
   only come out the same height when their column widths are in ratio 16/9 ÷ 9/16 = 256/81
   (v1 height = w1·9/16, v2 height = w2·16/9; equal ⟺ w1/w2 = 256/81). A 16:9 split would
   render the portrait far taller than the landscape.
   The videos also carry explicit aspect-ratios (gallery pattern, .gcard video) — the review
   rows are preload=none, so without them the 9:16 box would sit at 300x150 until first play
   and the two previews' heights would disagree (Dean, 2026-08-16). */
.share-vids video { width: 100%; display: block; aspect-ratio: 16 / 9; background: oklch(0.22 0.006 70); border-radius: 6px; }
/* The 9:16 cut alone — keyed on its URL tail, not :last-child: the ready screen renders the
   16:9 master SOLO when a job predates the vertical cut, and a positional :last-child rule
   would then squash that master into a portrait. The vertical preview/cut is always served
   from the /vertical route, which is the same pole on review rows and the ready stage. */
.share-vids video[src$="/vertical"] { aspect-ratio: 9 / 16; }
.share-vids { display: grid; grid-template-columns: 256fr 81fr; gap: 0.75rem; }
/* A job with no 9:16 cut (rendered before one existed) has only the 16:9 video — the twin
   grid would still reserve the 81fr column and leave a third of the card empty beside it
   (Dean, 2026-08-18). The `single` class collapses the grid to one column. */
.share-vids.single { grid-template-columns: 1fr; }
@media (max-width: 560px) { .share-vids { grid-template-columns: 1fr; } }
/* The share tile's own vertical rhythm: the disclosure line and the download row are its only
   neighbours, so neither needs the full default 16px provenance on a .note at its default
   padding — the tile read as overly deep top and bottom (measured ~33px each: 16px note padding + 16px p
   margin). Trim the adjacent margins so the tile sits tighter, without touching .note itself,
   which the rest of the app shares (Dean, 2026-08-10). */
.share-note { padding-top: 0.5rem; padding-bottom: 0.5rem; }
.share-note > p:first-child { margin: 0.25rem 0 0.5rem; }
.share-note .dlpair { margin: 0.5rem 0 0.25rem; }
.share-note .dlpair > .dl small { margin-bottom: 0; }

@media (max-width: 560px) {
  .wrap { padding: 1rem 1rem 3rem; }
  .thumb { width: 104px; height: 59px; }  /* proportional to the desktop bump, still 16:9 */
  .scene { gap: 0.7rem; }
}
/* Below 480 the row stops being a row. Thumbnail on the left spanning both lines, the name and
   its shot type on the first, the controls on the second.

   Squeezing the four parts onto one line instead was tried and measured to zero overflow, and it
   was still wrong: it left "Front of house" breaking after "Front", the three buttons stacked into
   a tower, and a row taller than this one. Passing the measurement is not the same as fixing the
   screen — the number said 0 and the result was unusable, which is only visible by looking.

   Stacking also gives the picture its size back. It was going to be cut to 88px to win 16px on one
   line; here it keeps the 104px the 560 rule sets, which is the whole argument DESIGN.md makes for
   it — big enough to tell "bedroom 2" from "bedroom 3" without opening anything.

   The grip goes at this width whatever the pointer is. A touch device already loses it to
   `@media (hover: none)`; a desktop window this narrow keeps it and was the last thing still
   overflowing. Dragging a row around 250px is not a workflow anyone has, and the up and down
   arrows are right there — the same argument the hover rule makes, with width as the reason
   instead of input. */
/* Spec AL — the same row split, raised to 877px viewport (= 862px client) and scoped to the
   review board.

   The defect it fixes is one band up from the one below: `@media (min-width: 700px)` gives
   `.scenes.board` a 145px status column and a 285px actions column, and `.scene-main` is
   `flex: 1; min-width: 0` — the row's only compressible track carrying all 430px of that loss.
   Measured on twelve rows with real AU listing names (longest "Covered Alfresco Entertaining",
   29 chars): the first name breaks at ~948px, and by 830px ten of twelve are wrapped four lines
   deep with the list 3328px tall against 1478px at 1265px. The column shrinks exactly 1:1 with
   the viewport, so this is not a tail case — it is every laptop window narrower than a maximised
   one.

   877px in the media query = 862px of CLIENT width, and the distinction is the whole point.
   A `max-width` media query matches the VIEWPORT, which includes the vertical scrollbar; the
   crossover below was measured in `document.documentElement.clientWidth`, which excludes it.
   This board always has a scrollbar at these widths, so the two spaces differ by exactly 15px
   (measured: innerWidth 877 -> clientWidth 862). Shipping the raw 862 here fired the grid at
   clientWidth 847 — 15px later than intended, leaving the top of the band in the layout the
   crossover says it should have left. Same defect as spec AG's `innerWidth` vs `clientWidth`
   bug in tooltip.js, one layer down: the arithmetic was right and the reference was wrong.
   **Any re-measure must state which space its number is in, or this comes back.**

   862px CLIENT width is measured, not chosen: it is where the grid stops costing more than it
   saves. The
   split-row grid gives every row the same fixed stack, so its list height is a constant 2417.7px
   at every width, while the flex row's grows as names wrap. At 863px flex is 2289.5px and the
   grid would cost +128.2px of extra scrolling; at 862px flex is 2549.8px and the grid saves
   132.1px. Above the crossover the grid buys tidy names for up to +939px of scroll on a
   twelve-row job and a 686px column holding a 180px name, which is why the ceiling is NOT at the
   ~948px onset of wrapping. Re-measure the crossover if the fixture changes — the widths at
   which lines increment are a property of the longest name in the job, not of this stylesheet.

   Second measurement, unreconciled and recorded rather than averaged: a SIX-row fixture with a
   27-character longest name put the crossover at clientWidth 820, not 862 — at clientWidth 847
   the grid cost +109px there where this fixture says it saves. Both were measured with both row
   types reverted and the override asserted, so neither is a broken probe; they are different
   jobs. 862 is kept because it came from the twelve-row fixture, which is closer to a real
   listing. Whoever re-measures should use a job of realistic length and say which it was.

   Accepted residual, (862, 948] client width: names wrap to two lines there. It costs no row growth at the
   top of the band and closing it would buy those two lines for ~840px of scroll.

   `.scenes.board`, not a bare `.planrow`: that selector is emitted by seven call sites across
   three surfaces — the plan board's own scene rows, the build board's card rows, and the
   team/people settings page's `planrow team-row`. An unscoped ceiling would have given the
   account settings page a row-split grid at 877px as a side effect of a review-board fix. The
   plan board measured 218px of name column and zero wrapping at 830px — its `.actions` is
   shrink-to-fit rather than a fixed 430px, so its own onset is ~135px lower and the grid would
   replace a healthy row with a taller one. It is a real but separate defect; do not widen this
   rule to cover it.

   The declarations are the ≤480 block's, deliberately duplicated rather than merged into it:
   that block stays byte-identical so every other surface keeps today's behaviour, and below
   480px both apply to the review board with identical values. The one rule that does NOT come
   up here is the duration-chip hide — see its own query below.

   The grip goes, as it does at ≤480, and here the reason is structural rather than the phone
   argument: the grid declares two columns and three areas, so a `.grip` with no `grid-area`
   auto-places into an implicit cell and rebuilds the tower the split exists to avoid. Cost, and
   it is real: drag-to-reorder is unavailable on the review board below 877px on a pointer
   device. The up/down arrows do the same job and are in the row. */
@media (max-width: 877px) {   /* = 862px client width; see above */
  .scenes.board .planrow {
    display: grid; align-items: start;
    grid-template-columns: auto minmax(0, 1fr);
    grid-template-areas: "thumb main" "thumb actions";
    column-gap: 0.6rem; row-gap: 0.4rem;
  }
  .scenes.board .planrow .thumb { grid-area: thumb; align-self: center; }
  .scenes.board .planrow .scene-main { grid-area: main; }
  .scenes.board .planrow .actions { grid-area: actions; flex-wrap: nowrap; }
  .scenes.board .grip, .scenes.board .grip-gap { display: none; }
  .scenes.board .planrow .ctl-gap { display: none; }
  .scenes.board .planrow .actions > .scene-time { margin-right: auto; }

  .scenes.board .scene summary:has(> .actions) {
    display: grid; align-items: start;
    grid-template-columns: auto minmax(0, 1fr);
    grid-template-areas: "thumb main" "thumb meta" "thumb actions";
    column-gap: 0.6rem; row-gap: 0.4rem;
  }
  .scenes.board .scene summary:has(> .actions) > .thumb { grid-area: thumb; align-self: center; }
  .scenes.board .scene summary:has(> .actions) > .scene-main { grid-area: main; }
  /* DEAD TODAY for the same reason the ≤480 copy is — spec-E §2 put the chip inside .actions, so
     this direct-child rule matches nothing. Carried up with the rest so the two blocks stay
     readable as one layout, and so whichever of them is edited next still documents where the
     chip is meant to live. */
  .scenes.board .scene summary:has(> .actions) > .scene-time { grid-area: meta; justify-self: start; margin-right: 0; }
  .scenes.board .scene summary:has(> .actions) > .status { grid-area: meta; justify-self: end; }
  .scenes.board .scene summary:has(> .actions) > .actions { grid-area: actions; flex-wrap: nowrap; justify-self: start; }
}

@media (max-width: 480px) {
  .planrow {
    display: grid; align-items: start;
    grid-template-columns: auto minmax(0, 1fr);
    grid-template-areas: "thumb main" "thumb actions";
    column-gap: 0.6rem; row-gap: 0.4rem;
  }
  .planrow .thumb { grid-area: thumb; align-self: center; }
  .planrow .scene-main { grid-area: main; }
  /* nowrap now that they have a line of their own: three 44px targets fit 240px, and wrapping
     them here would rebuild the tower this layout exists to avoid. */
  .planrow .actions { grid-area: actions; flex-wrap: nowrap; }
  .grip, .grip-gap { display: none; }
  /* Two fixed spacers that cannot give ground, in a row that can no longer afford them: at 375px
     .planrow .actions is 189.5px and its children need 246.8px, of which 153.6px is spacing — so
     Skip ended 11.4px past the viewport and the whole board scrolled sideways (66.4px at 320,
     26.4 at 360). Both are correct above this breakpoint — the 3.5rem clears the reorder arrows
     so the chip reads as a length rather than a control, and .ctl-gap stands in for exactly two
     44px arrows so a card's chip lines up with a scene's — and neither had a rule releasing them
     when the row runs out of width. The same shape as spec S one board over: a rule written
     against a wider row surviving into a width where it does not fit. `margin-right: auto` keeps
     the chip left and pushes the controls right without reserving anything, which is the device
     .scenes.board already uses at >=700px (Dean, 2026-08-21). */
  .planrow .ctl-gap { display: none; }
  .planrow .actions > .scene-time { margin-right: auto; }
  /* The team row has no `.thumb`-classed grid item (its own photo lives inside `.scene-main`'s
     nested flex, spec IO), and the shared areas above reserve a column for one anyway.
     `.planrow.team-row` wins grid-template-columns on specificity (0,2,0 over 0,1,0), so the
     tracks are the team row's own `minmax(0, 1fr) auto` — but nothing overrides
     grid-template-areas, so "thumb main" still applies and puts .scene-main in column 2, the
     `auto` one. Column 1, the flexible track, then stands empty. Measured at 412px on
     /entity/people: a 138px dead gutter down the left, name, title and contact shrink-wrapped
     against the right edge with a ragged left, every row starting at a different x (Dean,
     2026-09-05, reported from a phone).

     Simpler than it was (spec IO, 2026-09-08): badge and actions sit together in `.row-end` now,
     so there is no second row to span into any more -- just the two real columns, main and
     row-end, same shape as every other width.

     Scoped to `:not(.mine)` deliberately. /walkthroughs uses the same `planrow team-row` markup
     under `ul.scenes.mine` and DOES carry a thumbnail, so it needs the shared areas that this
     turns off; only the people page has the empty column. */
  ul.scenes:not(.mine) .planrow.team-row { grid-template-areas: none; }
  ul.scenes:not(.mine) .planrow.team-row .scene-main { grid-column: 1; }
  ul.scenes:not(.mine) .planrow.team-row .row-end    { grid-column: 2; justify-self: end; }

  /* The review row's summary carries two more children than .planrow (a time chip and a status
     pill, next to each other rather than nested in one wrapper div), so reorder/Keep/Skip
     landing on it (Dean, 2026-08-19, _scene_actions in server.py) doesn't fit the same two-area
     grid — measured at 375px: .scene-main forced to 0 width, the name illegible, arrows and
     Keep/Skip wrapping onto a line of their own above the thumbnail instead of below it.
     Same trick, one more row: thumb spans three lines instead of two, chip and status share the
     middle line (start/end within it, same as their existing flex roles at ≥700px), actions
     drops to its own line same as the plan row. `:has(> .actions)` scopes this to real scene
     rows only — the intro/outro bracket-card summary (_card_status_row) has the same chip+status
     shape with no actions at all, and is left exactly as it already was. */
  .scene summary:has(> .actions) {
    display: grid; align-items: start;
    grid-template-columns: auto minmax(0, 1fr);
    grid-template-areas: "thumb main" "thumb meta" "thumb actions";
    column-gap: 0.6rem; row-gap: 0.4rem;
  }
  .scene summary:has(> .actions) > .thumb { grid-area: thumb; align-self: center; }
  .scene summary:has(> .actions) > .scene-main { grid-area: main; }
  /* DEAD TODAY, deliberately kept: spec-E §2 moved the duration chip INSIDE .actions (see the
     comment at the .scene-time.tight rule above), so the chip is a GRANDCHILD of summary and
     this direct-child rule matches nothing on a scene row. It stays because it documents where
     the chip is meant to live, and it is the anchor for the real fix — moving the chip back out
     of .actions for the narrow case, which is a markup change under a width the server cannot
     see, so it needs a duplicated element or a JS move (spec S, 2026-08-20). */
  .scene summary:has(> .actions) > .scene-time { grid-area: meta; justify-self: start; margin-right: 0; }
  .scene summary:has(> .actions) > .status { grid-area: meta; justify-self: end; }
  .scene summary:has(> .actions) > .actions { grid-area: actions; flex-wrap: nowrap; justify-self: start; }
  /* And so, until that lands, the chip is hidden where it is actually breaking. Stranded inside
     .actions it made five controls — 34 + 44 + 44 + 58 + 58 plus four gaps — share a 209px
     track, which nowrap cannot relieve and min-width:auto cannot shrink below, so the whole PAGE
     scrolled sideways by 56px and Skip sat off-screen until the agent went looking for it. The
     meta row above keeps the status pill; the clip's length is on the scene's own detail panel
     one tap away. Chosen over wrapping the controls (Dean, 2026-08-20): wrapping also clears the
     overflow, but at ~36px per row — +252px of scroll on a seven-scene job — and the compact
     board is worth more on a phone than a number that is one tap away. The buttons are NOT the
     thing to shrink: their 44px is WCAG 2.5.8's touch target. */
}

/* Spec AL — the duration chip's own boundary, decoupled from the row split above.

   This rule used to live inside the ≤480 block, and raising that block's ceiling to 877px would
   have carried it along, hiding the chip across the whole 480–877 band on the strength of
   reasoning that only holds on a phone: five controls sharing a 209px track at 375px, the page
   scrolling sideways 56px with Skip off-screen, and "the compact board is worth more on a phone
   than a number that is one tap away" (Dean, 2026-08-20). None of that is true of an 800px
   laptop window with room for the chip.

   So the chip keeps its own, narrower query. In the split-row layout `.actions` is
   `justify-self: start` and nowrap, so the control cluster is a fixed 430.6px wide measured from
   the row's left edge at every viewport — the last control's right edge does not move. The page
   therefore starts scrolling sideways at exactly 430px client width and is clean at 431px.
   440px ships that with ~9px of margin.

   This is the one place the board is deliberately NOT byte-identical to today: between 441 and
   480px the chip is now VISIBLE where the old rule hid it. That band is exactly where the chip
   fits and the phone rule was hiding it anyway, and recovering it is the point of splitting the
   two boundaries rather than a side effect. At 440px and below, unchanged.

   440 also prices spec S, which nobody had been able to cost: the chip survives to ~431px, a
   hair above the narrowest phone this product targets, so moving it back out of `.actions` for
   the narrow case buys a band roughly 55px wide. Park it. */
@media (max-width: 440px) {
  .scene summary:has(> .actions) > .actions > .scene-time { display: none; }
}

.divider {
  font-size: 0.8125rem; color: var(--ink-3); padding: 1rem 0 0.4rem;
  border-bottom: 1px solid var(--line);
}

/* --- forms --------------------------------------------------------------- */

.stack { display: grid; gap: 1rem; }
.stack .note { display: grid; gap: 0.6rem; }
/* A .note inside a form.stack is spaced by the stack's own grid gap. One emitted straight into
   block flow — the job boards, the account pages — gets nothing and sits on whatever follows it.
   12px both edges, matching the tile's own padding and /new's existing 12px rhythm (spec BA). */
.note { margin: 12px 0; }
/* The stack already supplies it. Grid gap and margin ADD rather than collapse, so without this
   line all 73 stacked tiles silently space out to 28px. Deliberately the same DESCENDANT selector
   as the layout rule above, not a child selector — so a tile nested one level deeper (inside a
   <details>, a wrapper div) can't take the grid treatment while missing the cancel (spec BC §1). */
.stack .note { margin: 0; }
.lbl { font-weight: 500; font-size: 0.9375rem; margin: 0; }
.lbl .hint { font-weight: 400; }
.two { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 0.6rem; }
/* Mobile beside Profile link. Not `.two`: a ten-digit number and a URL are not the same size, and
   equal halves left whitespace on one and crowding on the other. `minmax(0, …)` on both so a long
   URL cannot push the row wider than its container. */
.third { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 0.6rem; }
.third p { margin: 0; }
@media (max-width: 480px) { .third { grid-template-columns: 1fr; } }
/* Signup's Email beside Password: same unequal-split idea as `.third`, but reversed -- the wide
   column comes FIRST here, since it's the email that needs the room. */
.emailpw { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: 0.6rem; }
.emailpw p { margin: 0; }
@media (max-width: 480px) { .emailpw { grid-template-columns: 1fr; } }
/* The intro/outro card element rows: each element is its own "shown on the card" checkbox
   (address, then per-field for Agent 1 / Mobile 1 / Agent 2 / Mobile 2, then the Agency logo)
   alongside the fields (Dean, 2026-08-05). The agent row is one line — ☐ Agent 1 [▾] ☐ Mobile 1
   ☐ Agent 2 [▾] ☐ Mobile 2 — each name beside its own number with its own toggle
   (2026-08-06). Agency + tag line share the next row with NO checkboxes (always-on once set),
   Agency sized to one agent-name column so the two office facts line up; the logo checkbox is
   its own left-aligned row under them. */
.cardline { display: flex; align-items: flex-start; gap: 0.6rem; min-width: 0; }
.cardline > .lbl { align-self: center; }
.cardline > .checkline { flex: 0 0 8.15rem; }   /* single checkbox rows: equal baseline */
.cardfields, .cardfields-pair { flex: 1; min-width: 0; display: grid; gap: 0.6rem;
              grid-template-columns: repeat(2, minmax(0, 1fr)); }
.cardfields-pair { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@media (min-width: 641px) {
  .cardfields-pair { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
/* The tile's contact row stays Contact 1 | Contact 2 over Mobile 1 | Mobile 2, not the general
   .cardfields-pair reflow — it was deliberately reverted to that order (v9). Below 641px this is
   still the plain 2-column base above, unchanged. At >=641px (spec FG, 2026-08-31) the tile is
   already a quarter grid everywhere else -- .tile-addr's four tracks below are two quarters
   (Suburb), one quarter split for State/Postcode, and one quarter for Project Code -- so leaving
   this row at 2 columns was the one thing in it still living on halves. No grid-template-columns
   override here any more: it falls through to the general .cardfields-pair rules above (2 cols
   below 641px, 4 above), and the explicit placement below keeps both pairs in columns 1-2 at
   >=641px, leaving 3-4 as real reserved tracks -- Dean: "that empty section will be used for the
   audio options." Do not fill it, centre the pairs into it, or widen them back. */
.cardfields-pair.agent-rows { row-gap: 12px; }
/* Below 641px the picker is a plain block again and spans the whole row (spec FI §3b). Without
   this it is a single item of the 2-column phone grid and renders at half width beside an empty
   cell -- measured 152.5px at 390. The reserved columns do not exist down here, so the controls
   go back to full width, one per line, exactly as they were before this sheet moved them. */
.cardfields-pair.agent-rows > .bedpicker { grid-column: 1 / -1; }
@media (min-width: 641px) {
  .cardfields-pair.agent-rows > .paircell:nth-child(1) { grid-column: 1; grid-row: 1; }
  .cardfields-pair.agent-rows > .paircell:nth-child(2) { grid-column: 2; grid-row: 1; }
  .cardfields-pair.agent-rows > .paircell:nth-child(3) { grid-column: 1; grid-row: 2; }
  .cardfields-pair.agent-rows > .paircell:nth-child(4) { grid-column: 2; grid-row: 2; }
  /* The audio controls take the columns FG reserved (spec FI §3a). `display: contents` on the two
     wrappers is what does it: the DOM keeps `.bedpicker > .bedselects`, so BED_JS's root query and
     everything under it are untouched, while the label, the genre select and `.bedtrack` become
     items of THIS grid and can be placed by column like the contact cells above. Placement by
     grid-column, not source order -- the same idiom FG used, deliberately not a second mechanism.
     Only >=641px: below it the wrappers are real boxes again and the controls stack full width,
     because columns 3-4 do not exist there. */
  .cardfields-pair.agent-rows > .bedpicker,
  .cardfields-pair.agent-rows > .bedpicker > .bedselects { display: contents; }
  .cardfields-pair.agent-rows > .bedpicker > .checkline { grid-column: 3 / span 2; grid-row: 1; }
  .cardfields-pair.agent-rows > .bedpicker > .bedselects > select[name=bed_bucket] {
    grid-column: 3; grid-row: 2;
  }
  .cardfields-pair.agent-rows > .bedpicker > .bedselects > .bedtrack { grid-column: 4; grid-row: 2; }
}
/* Card editors only: the four contact cells take half the viewport, two across — ☐ Contact 1
   | ☐ Contact 2 over ☐ Mobile 1 | ☐ Mobile 2. The tile never gets this — it keeps its own row
   above (Dean, 2026-08-17). **Selects nothing today** (spec FG, 2026-08-31): `agent-rows` is
   emitted once, at server.py's `_details_block`, inside a plain `<div class=note>` with no
   ancestor carrying both `scene` and `card` — checked against every current call site, not
   proven dead everywhere. Flagged, not removed: deleting it is a separate decision. */
.scene.card .cardfields-pair.agent-rows { flex: 0 0 50vw; grid-template-columns: repeat(2, minmax(0, 1fr)); row-gap: 12px; }
/* Card editors pull their stacked data rows tighter than the default 1rem .stack gap — the
   address, contacts, agency and tag lines read as one block instead of drifting apart. 4px,
   tightened from 12px so the card editor's field rows sit close together (Dean, 2026-08-17). */
.scene.card form.stack { gap: 4px; }
.cardfields select, .cardfields input,
.cardfields-pair select, .cardfields-pair input { min-width: 0; }
/* Each of the four agent-row cells: its own checkbox then the field, on the same line so the
   toggle names the thing it switches (the words are an aria-label; position does the visible
   labelling — Dean, 2026-08-06). */
.paircell { display: flex; align-items: center; gap: 0.4rem; min-width: 0; }
.paircell .checkline { min-height: 44px; align-self: auto; flex: none; }
/* The room-names toggle sits beside Save now, not in the contact grid (spec FG, 2026-08-31 --
   half-reverses 252224e's placement only, keeping that commit's glyph fix). `.checkline`'s own
   base is `align-self: end; min-height: 40px` (above, :2806) -- in `.actions` (flex,
   align-items: center) the label would bottom-align against Save, and 40px misses the 44px
   tap-target floor either way. `.ticks .checkline` (above, :2816) solves the alignment the same
   way, but its own min-height (36px) is even shorter, so it is not reused wholesale -- only
   `align-self: auto` is worth taking from it. */
#walkthrough-details .actions .checkline { align-self: auto; min-height: 44px; }
/* `.actions > .build-tiles`'s own margin-left: auto idiom (above, :1994), scoped to this tile:
   Save and the checkbox sit together on the left, Saved lands on the right where the eye isn't. */
#walkthrough-details .actions > .ok { margin-left: auto; }
/* spec GR, 2026-09-02: the suggestion line and the lifecycle hint share one `.actions` child
   instead of each claiming a grid cell of their own -- flex-column so they stack top to bottom
   regardless of breakpoint (below, this is one wrapped item in the flex row; above 641px it is
   one grid item spanning columns 3-4). Unscoped, not inside the media query: the stacking has
   to hold below 641px too, where `.actions` is still `display: flex` (base rule, :2003) and this
   is one more item in that row rather than a grid cell. `gap` is inert with one child -- when
   `_bed_suggestion_line()` returns "" this wrapper holds only the lifecycle hint, and renders
   identically to before this spec. */
#walkthrough-details .actions > .hints { display: flex; flex-direction: column; gap: 0.15rem; }
/* The room-names toggle starts where Contact 2 and Mobile 2 do (spec FI §3e, Dean's mockup) --
   column 2's left edge, not hard against Save. `.actions` is flex everywhere else, so this tile's
   row becomes the SAME four tracks as `.cardfields-pair` above it and the checkbox is placed by
   `grid-column: 2`. Not a percentage: column 2's left edge is `(W - 3g)/4 + g`, which is 25.2-25.5%
   depending on width, so any fixed number is wrong somewhere and goes stale the day the gap or the
   track count changes. Placement is exact by construction at every width.
   The `<p>` is full width and so is `.cardfields-pair` (its `.cardline` parent has one flex child),
   which is what makes the two grids share edges.
   DOM order is untouched: Save still precedes the checkbox in the source, so it still precedes it
   in tab order. That is FG's approved layout and must not be "fixed" with `order`, which would
   split visual order from focus order. */
@media (min-width: 641px) {
  #walkthrough-details .actions {
    display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));
    /* 12px, NOT `.actions`' own 0.6rem: inside this tile the grid above is overridden to 12px by
       `.details-block form.stack .cardfields-pair` (below, :3511+), and two grids only share
       column edges if they share the gap. At 0.6rem the tracks came out 203.30px against the
       pair's 201.50px and the checkbox landed at 384.39 against Contact 2's 385.00 -- aligned to
       the eye, wrong by construction, and drifting further the wider the panel. If that rule's
       12px ever changes, this changes with it. */
    gap: 12px; align-items: center;
  }
  #walkthrough-details .actions > button { grid-column: 1; justify-self: start; }
  #walkthrough-details .actions > .checkline { grid-column: 2; }
  /* spec GN, 2026-09-02: the picker's lifecycle hint moved here from inside .bedpicker (which
     could not place it in this row at all -- a separate grid, a separate DOM subtree). Columns
     3-4, same as this row's own button/.checkline placement above and the Genre select's own
     `grid-column: 3` in the .cardfields-pair grid this row mirrors (app.css:3370) -- so its left
     edge lines up with the Genre select's, not with Save's. Row 1 explicitly, so `.ok` below
     (also column 4) cannot silently share a cell with it depending on auto-placement order.
     spec GR, 2026-09-02: the suggestion line joined this element -- it is `.hints` now, not
     `.hint` (the base flex-column rule is above, unscoped, :3403+), and both lines inside it
     occupy this one cell together rather than each wanting a cell of their own. */
  #walkthrough-details .actions > .hints { grid-column: 3 / span 2; grid-row: 1; }
  /* Row 2, not implicit: before GN this cell was free whenever a save had just happened. The
     hints wrapper now always occupies column 4 in row 1, so `.ok` moves down rather than
     overlapping it on the one render where both are present at once (right after a save). */
  #walkthrough-details .actions > .ok { grid-column: 4; grid-row: 2; justify-self: end; margin-left: 0; }
}

/* Music bed picker (spec FH §3, Dean's 2026-08-31 "too busy" redesign) -- checkbox inline with
   two native selects + one play button, no pill ramp, no per-track rows. Unscoped: shared by
   /new and #walkthrough-details. */
.bedpicker { margin-top: 0.75rem; }
/* Two cells, not three: Genre, then Track-and-play as one unit (spec FI §3a). The play button
   used to be a third track (`1fr 1fr auto`), which is fine standalone and wrong inside the tile --
   there it has to ride with the track select in a single grid column so the pair lines up under
   Project Code. `.bedtrack` carries that pairing, so both layouts use the same markup. */
.bedpicker .bedselects {
  display: grid; grid-template-columns: 1fr 1fr; gap: 0.6rem; margin-top: 0.4rem;
}
.bedtrack { display: grid; grid-template-columns: 1fr auto; gap: 0.6rem; min-width: 0; }
/* 640, not 760 (spec FI §3b-i). This collapse used to fire at 760 while the tile's grid switches
   at 641, and between the two a select rendered 568-687px inside a box totalling 278-341px -- a
   control at roughly twice its container, because this rule sets the select's width regardless of
   where the parent grid places it. One breakpoint now, matching `.cardfields-pair`'s. */
@media (max-width: 640px) { .bedpicker .bedselects { grid-template-columns: 1fr; } }
.bedpicker select:disabled { opacity: 0.45; }
/* The hint beside "Include audio track" (spec FJ §5) sits inline with the label at desktop width,
   but `.checkline` is `display: flex` with the default nowrap (app.css:2806), so a hint that does
   not fit does not wrap to its own line -- it compresses and wraps its OWN text instead, growing
   the label and the whole grid row with it. Measured: the label holds one line down to 860px and
   grows from 40px to 49.6px below it. 900px is the existing breakpoint this file already uses
   elsewhere (not a new one), chosen one width conservative of the measured 860px boundary so the
   hint still has margin to spare at exactly 900. Below it the hint drops onto its own line under
   the checkbox -- the shape app.css:2804's own comment on `.checkline` already recommends for
   this class of problem, arrived at here from measurement rather than from that comment. */
@media (max-width: 900px) {
  /* `.bedleft > .checkline` (spec FW §2a): Save's new wrapper makes `.checkline` a GRANDCHILD of
     `.bedpicker` on the two review boards, so the direct-child rule above alone stops matching
     there -- reverting this fix on exactly the surface spec FW rebuilds. Safe to share this
     breakpoint unchanged: the stacking rule below already moves Save out of `.checkline`'s row by
     900px, so `.checkline` is back to the same width this 900px number was measured against.
     Scoped to `.bedleft`, not widened to a bare `.checkline` descendant selector: `.bedpicker`
     also renders inside `.paircell` and `.cardfields-pair.agent-rows` on the plan tile
     (app.css:3370, :3393), both of which carry their own `.checkline` rules a descendant
     selector would also reach. `.bedleft` exists nowhere else, so this cannot. */
  .bedpicker > .checkline, .bedleft > .checkline { flex-wrap: wrap; }
  .bedpicker > .checkline > .hint, .bedleft > .checkline > .hint { flex: 1 0 100%; }
}
/* Save shares `.checkline`'s row only while there is room for both (spec FW §2a): sitting beside
   Save takes ~80px off `.checkline`'s share of the two-col grid's column, so it hits its own
   min-content width -- the hint compressing its own text -- 130px earlier than the bare label
   above ever does. Measured with Save in place: the label held one line to 1030px, broke to
   49.6px at 1025px. Rather than chase a second breakpoint number for that band, stack Save
   above `.checkline` at the same width, so by the time the 900px rule needs to reach
   `.checkline` through the wrapper, `.checkline` already has the full column back and the two
   rules measure the same case. (Stacking this early also closes a narrower defect the 900px
   fix alone did not: between 641px and 675px -- just above the two-col grid's own 640px
   collapse -- `.checkline` shared with Save fell to ~205-220px, too narrow even for the
   checkbox row's own text, and grew a third line to 76.2px.) */
@media (max-width: 1030px) {
  .bedleft { flex-direction: column; align-items: stretch; gap: 0.4rem; }
}
/* Square, per Dean's mockup (spec FI §3c) -- it was a 50% circle. app.css:634 records that the
   product's one square-cornered control is square on request, so this is a second deliberate
   exception rather than a drifting radius. */
.bedplay {
  /* min-height matches height (spec FJ §3) -- every `button`/`.btn` gets `min-height: 44px`
     (app.css:1878, "field surfaces are used one-handed outdoors") and min-height wins over
     height, so without this the button rendered 40 wide x 44 tall. That 44 was also the ONLY
     44-tall thing in the tile's grid row 2, which set `grid-template-rows: 40px 44px` and put
     every 40px control in the row 2px out of line with it (`.paircell`'s `align-items: center`
     centred the 40px inputs in the 44px row; the selects, with an explicit height, sat at the
     row's top edge). One declaration fixes both the button's shape and the row's alignment --
     see §3d for the WCAG target-size trade this makes and why Dean approved it explicitly. */
  width: 40px; height: 40px; min-height: 40px; cursor: pointer;
  border: 1px solid var(--line-firm); background: var(--surface); color: var(--ink);
  display: inline-flex; align-items: center; justify-content: center; font-size: 0.8rem;
}
.bedplay:disabled { opacity: 0.45; cursor: default; }
.bedplay:not(:disabled):hover { border-color: var(--accent); }
/* The lifecycle hint under the selects (spec FL §3a, plan tile only) -- its own line, not inside
   the checkbox label like FJ's hint above: it describes the JOB's lifecycle ("can still be
   changed"), not what the checkbox does, so it must not inherit the label's click-toggles-the-
   checkbox behaviour that hint deliberately relies on. */
.bedpicker > .hint { display: block; margin-top: 0.5rem; }
/* The review board's own arrangement (spec FL §3c): checkbox+hint in one column, the three
   selects/button in the other, instead of the stacked layout /new and the plan tile use. A class
   on the existing wrapper, not a fork -- `.bedpicker`'s two visible children (the checkbox label,
   then `.bedselects`) auto-place into the two tracks in source order; `.hint` is not used here
   (spec FL §3c has no third line) and `<audio hidden>` never joins the grid at all. */
.bedpicker.two-col {
  display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 1rem;
  align-items: start;
}
@media (max-width: 640px) { .bedpicker.two-col { grid-template-columns: 1fr; } }
/* Save's new home (spec FW §2, option B of a rendered comparison Dean chose): a flex row holding
   Save and the checkbox label, Save leftmost, collapsing the form from two rows to one at 1280px
   (98px to ~46px, measured on the mock). `.checkline` grows to take what Save leaves rather than
   sizing to its own content, same reason `.bedselects`' children get explicit column tracks
   instead of shrinking under one shared gap. */
.bedleft { display: flex; align-items: center; gap: 0.75rem; min-width: 0; }
.bedleft > .checkline { flex: 1; min-width: 0; }
.bedreview { margin-top: 1.25rem; }
/* Read-only card data (the intro's info rows): looks like a filled field but is not one —
   muted ink, no border, 40px height sits on the checkbox row's baseline (Dean, 2026-08-16). */
.ro-data {
  display: inline-flex; align-items: center; min-height: 40px; min-width: 0;
  color: var(--ink-2); font-weight: 500; white-space: nowrap; overflow: hidden;
  text-overflow: ellipsis;
}
.paircell > .ro-data { flex: 1 1 auto; }
/* Intro text: its own row after the org logo, sized to end where the Address, Suburb line
   above it does — the intro has nothing else editable, so it reads as one quiet data card
   with a single text input (Dean, 2026-08-16). */
.paircell.intro-tag { flex: 0 0 calc(2 * (100% - 3 * 0.6rem) / 4 + 0.6rem); }
.paircell.intro-tag .checkline { min-height: 40px; }
.addrbox.ro-data { flex: 0 0 calc(2 * (100% - 3 * 0.6rem) / 4 + 0.6rem); }
/* The intro/outro's second address row (Suburb State Postcode) — the opening frame's own
   line 2, so the form shows exactly what the video will print. Carries its own checkbox now
   (show_locality) and renders the data with the same box as the street line above it, so the
   two rows read as one address unit (Dean, 2026-08-17). */
.sub-loc { margin-top: 0.25rem; }
.sub-loc .sub-line {
  flex: 0 0 calc(2 * (100% - 3 * 0.6rem) / 4 + 0.6rem); min-height: 0;
}
.paircell .checkline.bare { gap: 0; min-height: 40px; }
/* Agency sized exactly like the Agent 1 column: the bare checkbox + its field share ONE pair-row
   column (no + 2.8rem), so the agency field is the same width as the Agent 1 select — which also
   shares its column with a checkbox (Dean, 2026-08-06). Tag line takes the rest of the row. */
.cardline .agency-cell { flex: 0 0 calc((100% - 3 * 0.6rem) / 4); }
.cardline .tagline-cell { flex: 1; min-width: 0; }
.cardline .agency-cell .agency-field { flex: 1; min-width: 0; }
.cardlogo { margin: 0; display: flex; justify-content: flex-start; gap: 0.4rem; }
.cardlogo .checkline { align-self: auto; min-height: 36px; }
/* Address: the bare show-checkbox sits OUTSIDE the field like every other field, and the field
   itself is a real editable ADDRESS input (2026-08-07) — the read-only span it replaced was why
   the row misaligned, because a span sizes differently from the data inputs beside it. It spans
   the first TWO pair-grid columns (Agent 1 + Mobile 1) so its right edge lines up with the END
   of the Mobile 1 box; the hint fills the space to the right. Default input styling already
   gives it the surface background, ink text and 40px height every other field has.

   The paircell's -0.2rem right margin shrinks the checkbox-to-field gap to the 0.4rem the agent
   paircells use (0.6rem cardline gap minus the -0.2rem == 0.4rem), so the address row reads with
   the same internal rhythm as the Agent 1 / Mobile 1 row it lines up under (Dean, 2026-08-07);
   the addrbox width drops the same 0.175rem so its right edge still lands on Mobile 1's end. */
.addrline { align-items: center; }
.addrline .paircell { flex: none; margin-right: -0.2rem; }
.addrbox { flex: 0 0 calc(2 * (100% - 3 * 0.6rem) / 4 - 0.9rem);
           min-height: 40px; cursor: text; }
/* The details tile's address row has no leading checkbox, so the -0.9rem seat the card
   reserves for it is not needed there. Two pair-grid columns span 2 cells PLUS the
   inter-cell gap (12px), so without the +12px the field ends exactly one gap short
   of the Contact 1/Phone 1 pair it should line up under (Dean, 2026-08-16; the 2026-08-15
   version ended 0.6rem before Mobile 1's end). */
.details-block .addrbox { flex-basis: calc(2 * (100% - 3 * 12px) / 4 + 12px); }
/* The tile's own h2 title sits in the <summary> (clickable whole-row toggle); strip the
   heading's default page margins so the collapsed row stays one line tall, and keep it at
   the section-head size rather than the page-h2 size (Dean, 2026-08-15). */
.details-block summary h2 { margin: 0; font-size: 1rem; font-weight: 500; display: inline; }
.details-block summary .hint { margin-left: 0.5rem; }
.details-block summary .hint b { color: var(--ink); }
/* The tile's scene-detail carries the base 1rem bottom padding on top of the .note's own
   1rem — that stacked to 2rem below the Save button. Zero the inner one so the space from
   button to tile edge is the .note's 1rem (Dean, 2026-08-16). */
.details-block .scene-detail { padding-bottom: 0; }
/* The tile's address-parts row carries Project Code too, so it needs a 4th column sized to
   the Mobile 2 field above (one .cardfields-pair cell), and suburb gives every spare pixel —
   scoped: the plan's address block keeps the shared 3-col .addr-line (Dean, 2026-08-16). */
.details-block .addr-line.tile-addr {
  grid-template-columns: calc(2 * (100% - 3 * 12px) / 4 + 12px) minmax(0, 1fr) minmax(0, 1fr) calc((100% - 3 * 12px) / 4);
}
/* The whole tile is one 12px rhythm now (Dean, 2026-08-16): the form's row gap drops from the
   .stack 1rem, and cardline/cardfields-pair/tile-addr all sit at 12px — matching /new's 12px
   address form. Scoped: the intro/outro card editors keep their 0.6rem spacing. */
.details-block form.stack { gap: 12px; }
.scene.card .scene-detail form.stack { gap: 12px; }
.details-block .cardline { gap: 12px; }
.details-block form.stack .cardfields-pair { gap: 12px; }
/* This 12px is also the tile action row's column gap (spec FI §3e, above in the
   `min-width: 641px` block): the room-names checkbox aligns with Contact 2 only while the
   two grids agree. Change one and change the other. */
/* Details-tile property stats: the card row's stats carry a checkbox and a 54px icon; the tile's
   are value-only, so shrink the icon and pull the field tight against it. The row already sits
   inline with Organisation thanks to .cardline.surroundings (Dean, 2026-08-15). */
.statfields.tile-stats { gap: 3.5px; width: auto; margin-left: auto; justify-content: start; }
.statfields.tile-stats .statfile svg { width: 44px; height: 44px; }
.statfields.tile-stats .statfield { gap: 0; }
/* The tile's sqm inherits the outro card's wider stat-wide field (4.75rem, see .statfield
   input.stat-wide) so a five-digit area + commas still fits; the other three stay 3rem
   (Dean, 2026-08-16). */
.statfields.tile-stats .statfield input { width: 3rem; flex-basis: 3rem; height: 2.5rem; }
.statfields.tile-stats input.stat-wide { width: 4.75rem; flex-basis: 4.75rem; height: 2.5rem; text-align: right; }
/* The one card-form hint (what the checkboxes are for) lives beside the org logo now — see
   _card_row's org_row (Dean, 2026-08-16). The old .addrline .addr-hint rule died with the move. */

/* The logo + stats row: the Agency logo checkbox leads, then its IMAGE, and the four stats
   follow on the same line. The image's right edge lines up under the Agency field's right edge
   above (one pair-row column in), and its height matches a data input (40px), so the sign-off
   reads as one balanced line (Dean, 2026-08-06). */
.surroundings { align-items: center; flex-wrap: wrap; }
.surroundings .cardlogo { flex: 0 0 calc((100% - 3 * 0.6rem) / 4); min-width: 0; }
/* The logo preview rides beside the bare "Agency logo" checkbox on both cards (2026-08-06;
   label text removed, words keep living in the checkbox's aria-label/title on request). The
   image fills the box's HEIGHT (40px — the input height) edge to edge; the padding came off on
   2026-08-20 so the artwork matches the Align logo dropdown beside it rather than reading a
   third smaller. The dashed box hugs the logo's width — flex:none, not flex:1, so a wide or
   square logo never stretches the row; its own aspect decides the box's width, and
   object-fit:contain keeps the artwork undistorted (Dean, 2026-08-07). */
.logoprev {
  flex: none; height: 40px; width: auto;
  object-fit: contain; padding: 0; border: 1px dashed var(--line-firm); border-radius: 0;
}
.surroundings .logoprev { min-width: 0; }
.surroundings .statfields { width: auto; justify-self: start; justify-content: start; margin-left: auto; }
/* The Style dropdown drops its label and is sized to Agent 1's field (one pair-row column) with
   a bare cell, left-aligned under the agent row (Dean, 2026-08-06). */
.stylecell { flex: 0 0 calc((100% - 3 * 0.6rem) / 4); }
.stylecell select { width: 100%; }
/* The outro card's editor (Dean, 2026-08-18): what to show runs down column one, where it sits
   is column two, and columns three and four are the Text and Background pair the colour pickers
   and the alignment sit under. A real grid — the intro's stack sizes every cell with a calc()
   off the same notional four columns, which is this grid written the long way, and it cannot
   hold a header row. Cells that carry nothing are .gridgap spacers: grid auto-flow packs
   continuously, so a short row must be padded out or the next row starts beside it. */
/* TWO grids side by side (Dean, 2026-08-18), not one shared four-column grid. While they shared
   rows, the field rows could not be tightened without dragging the Text style stack in with
   them: the address, locality and contact rows were each pinned by the 40px control beside
   them, so shrinking the left cells saved nothing (measured 0px) and closing the row gap saved
   22px but compressed the stack. Split, each half keeps its own rhythm — and the field run
   drops from 226px to the 180px measured as the ceiling. */
/* The card editors' hint. `.hint`'s 90ch reading measure caps the line ~130px inside the form and
   wraps "above." onto a second line with the space sitting empty beside it — the same trap the
   /new upload note hit two rules above. This is a form hint over a grid, not a reading
   paragraph, so it takes the form's width (Dean, 2026-08-18). */
.cardhint { max-width: none; margin: 0 0 0.5rem; }
.outrogrid {
  /* Still FOUR tracks, with the fields taking one to three and the stack the fourth. `3fr 1fr`
     looks equivalent and is not: it splits the leftover after ONE gap, so the fields came out
     623px against the 627.6px that three columns plus their two internal gaps measure, and the
     tagline stopped matching the intro's by 3.2px. */
  display: grid; grid-template-columns: repeat(4, minmax(0, 1fr));
  /* `start`, not `end` (Dean, 2026-08-28: "the text/bg format block seems to have drifted down").
     Spec AM §1 chose `end` on 2026-08-21 and its reasoning is kept here rather than deleted,
     because it was correct for the card as it stood: `.outrostack` was the TALLER column on the
     outro (same stack either card, over a fields column with no address/locality/stats block),
     so bottom-alignment held both bottoms level and nothing floated.

     Spec EQ's contact-photo row invalidated the premise, not the reasoning. Measured on
     2026-08-28: `.outrofields` is now 332px against the stack's 291px on the outro (352 vs 291
     on the intro), so the fields column is the taller one on BOTH cards and `end` pushed the
     stack's top down by 41px on the outro and 61px on the intro to keep the bottoms level. That
     is the drift — the stack did not move relative to the row, it moved relative to the first
     thing the eye reads, which is the logo row it used to sit level with.

     `start` puts both columns' tops on the same line and leaves the slack at the bottom of the
     shorter one, which is the right way round now that the shorter one is always the stack. Row 2
     (the Save/Regenerate row below both, see .outrogrid > .actions) is unaffected either way —
     one item alone in its own row has nothing to align against. */
  gap: 0 0.6rem; align-items: start;
}
/* Three columns of the SAME measure the four-column grid had, so the tagline still spans two
   plus the gap and lands on the intro's width exactly. Zero row gap: the cells are 40px with
   their content centred, so they carry their own breathing room. */
.outrofields {
  grid-column: 1 / 4;
  display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 0 0.6rem; align-items: center;
}
/* The Regenerate button and the price under it each take the WHOLE row. They are two consecutive
   children of this grid, so the note was auto-placed into the next CELL — measured 282px right of
   the control it prices at 1280, and 159px right of it at 375, where it also wrapped to two
   lines. `1 / -1`, not the `1 / 3` first drafted: this grid is THREE tracks at 1280 and two at
   375, so a fixed end line is wrong at one width or the other, and `1 / 3` would have stopped the
   block two-thirds across the desktop row — a smaller version of the asymmetry this fixes
   (spec AA §2, Dean, 2026-08-21). */
.outrogrid > .actions,
.outrogrid > [data-card-note] { grid-column: 1 / -1; }
.outrogrid > [data-card-note] { margin: .35rem 0 0; }
/* Address down to Mobile at 2rem instead of the app's standard 40px control height (Dean,
   2026-08-18). Those five rows hold only read-back data and its checkbox, so nothing here is
   typed into and the shorter box costs no editing comfort.
   Scoped to .outrofields on purpose: `.ro-data` and `.checkline.bare` also dress the INTRO card
   and the Walkthrough details tile, and neither was asked to change.
   The other four rows are untouched by this and stay 40/44 on their own — Logo, Outro text and
   Style/Duration each hold a 40px select or input, and Save is the global 44px button, so the
   taller control in each still sets its row.
   32px keeps the checkbox above WCAG 2.5.8's 24px minimum target, though below the 44px
   comfortable-touch mark the rest of the app uses. Dean's call, made with that trade stated. */
.outrofields .paircell .ro-data,
.outrofields .paircell .checkline.bare,
.outrofields .statval { min-height: 2rem; }
/* The contact photo row (spec EQ) is the one exception to the 2rem rule directly above: it
   carries a face, not text, at the app's own 44px control height from the approved mock — the
   checkbox beside it stays 2rem like every other row's, and the row's own height follows
   whichever of the two is taller, which is the photo. */
.outrofields .contact-photo {
  width: 44px; height: 44px; border-radius: 50%; object-fit: cover;
  border: 1px solid var(--line); flex: none; background: var(--surface);
}
/* Breathing room under the org-logo row and under Style/Duration, so the logo reads apart from
   the text fields and Save reads apart from the controls it commits (Dean, 2026-08-18). A grid
   has one row gap for every row, so per-row space is a margin — carried by EVERY cell in the
   row, because align-items:center would otherwise lift the marked cell's content clear of its
   neighbours. */
.outrofields > .gap-after { margin-bottom: 0.5rem; }
/* 0.75rem where the break is doing more work: under the mobiles row, which divides what APPEARS
   on the card from how the card is built. Note the MEASURED gap is not always the margin — the
   logo row's cells are 2rem inside a 40px row, so align-items:center adds 4px of its own and
   0.5rem there reads as 12px, the same as 0.75rem reads here where the row has no slack. */
.outrofields > .gap-wide { margin-bottom: 0.75rem; }
/* Align logo takes the shared 6.075rem basis, same as Duration and the other labelled controls.
   It used to be exempted to fill its column, but that rule was written as `[name=logo_align]`
   and alignment is per card — the intro posts `intro_logo_align` (server.py:2586), so the
   exemption only ever reached the outro and the two cards drew different widths. The narrower
   measure is the one that was kept (Dean, 2026-08-20); the tagline field ends on it, see .span2
   below. */
/* The stack keeps the 0.35rem it read best at, now that nothing else depends on it. Bottom
   alignment against `.outrofields` is `.outrogrid`'s own `align-items: end` now (spec AM §1,
   2026-08-21, see its comment) — no `align-self` needed here specifically, since which of the
   two columns is taller flips between the intro and the outro (the outro's stack, same content
   either card, sits over a SHORTER fields column with no address/locality/stats block), and the
   parent's own rule already covers both directions. */
.outrostack { grid-column: 4; display: flex; flex-direction: column; gap: 0.35rem; min-width: 0; }
.outrostack > .ctlcell { width: 100%; }
/* Space above Background, so it reads as a new group rather than one more row of the Text style
   run above it. `:not(:first-child)` picks it without a class — Text style is the first child. */
.outrostack > .colhead:not(:first-child) { margin-top: 0.5rem; }
/* The two group headings. Styled as .lbl, the card-line title (Dean, 2026-08-18) — they head a
   stacked run of controls now rather than labelling a column, so they read as section titles
   like every other title in the form, not as muted column furniture. Right-aligned with the run
   they head, so heading and controls share the form's right edge. */
/* One step up the scale from .lbl's 0.9375rem (Dean, 2026-08-18) — these head a run of controls
   rather than labelling a single field, so they carry a little more weight than a card-line
   title does. */
.outrostack > .colhead {
  /* stretch, not center: these were grid cells needing vertical centring in a 40px row.
     In a column flex container align-self is the CROSS axis, so `center` shrink-wrapped
     them and pushed each heading ~70px right of the labels it heads. */
  font-weight: 500; font-size: 1rem; margin: 0; align-self: stretch; text-align: left;
}
/* A labelled control cell: the word, then the control at a fixed measure rather than the rest of
   the column (Dean, 2026-08-18) — a Left/Centre/Right dropdown and a colour swatch are both
   small choices, and at full width they read as the widest controls on the card. 6.075rem is the
   earlier half-width 4.05rem grown 50%, which also buys back what half width cost: the native
   select arrow reserves 32px of the box, and "Centre" needs 50.6px of what is left. */
/* .paircell.ctlcell, not .ctlcell: a labelled cell IS a paircell, and the .outrogrid .paircell
   rule below sets flex:1 at equal specificity and later in the file, so it would win. */
/* `flex: 0 1`, not `0 0` (Dean, 2026-08-20): grow stays off so the control never exceeds
   6.075rem where there is room, but shrink coming back lets it give ground instead of
   overflowing its cell — at `0 0` this measure alone overflowed its cell by 14px between a
   641px and ~810px viewport, a band the mobile breakpoint below does not reach.

   THE GENERAL RULE, because this shape has now bitten twice: inside a labelled .paircell the
   LABEL is a fixed anchor and the CONTROL must be able to give ground. `flex: 0 0` on a control
   is the bug; `flex: 0 0` on .ctllabel is the POINT. The grid's cells are minmax(0, 1fr) and
   never expand for content, so a control with a fixed basis and no shrink has nowhere to go —
   but the label's fixed 4.8rem is what makes every control in the grid start at the same
   offset. Do not "fix" the label to match: measured at 768px, giving .ctllabel shrink spreads
   the five label widths from 0 to 25.1px and the controls stop sharing an offset entirely.
   The second bite was the tagline's own calc below, which carried `0 0` because it is a
   different measure in a different rule and did not match a search for 6.075rem. */
.outrogrid .paircell.ctlcell > select,
.outrogrid .paircell.ctlcell > input { flex: 0 1 6.075rem; min-width: 0; }
/* "Default" is the face the card draws today, not one of the families on offer. The select
   itself carries the CHOSEN family so the closed control previews it — and so Safari, which
   styles a <select> but not its <option>s, still shows something — but that would otherwise
   cascade into this option and set the word "Default" in, say, Playfair Display, where it reads
   as one more family in the list. Pinned back to the page's own type. */
.outrogrid select[name=text_font] option[value=""] {
  font-family: "Rethink Sans", "Rethink Sans Fallback", sans-serif;
}
/* The Text style / Background run lives in the card's RIGHT QUARTER — column four alone,
   left-aligned there (Dean, 2026-08-18). The label keeps its fixed measure so the four controls
   start on one line. The logo's alignment and the duration are not in the run — they sit in
   column two beside the controls they belong with. */
.outrogrid .ctlcell.rstack { justify-content: flex-start; }
/* The stack takes the same 6.075rem as the fields (Dean, 2026-08-20). This rule is redundant
   with the shared basis above and is kept deliberately: `.rstack` is the only handle on these
   five controls, and the next person who wants the Text style run a different width needs
   somewhere to write it. */
.outrogrid .paircell.ctlcell.rstack > select,
.outrogrid .paircell.ctlcell.rstack > input { flex: 0 1 6.075rem; min-width: 0; }
/* Font is the one control in the run that is not a fixed measure. At the shared 6.075rem its
   text box is 52px and eight of the eleven faces clip — including "Default", which a fresh card
   shows. 10rem is the smallest width that fits "Playfair Display", the longest name. This rule
   must stay BELOW the .rstack rule above: both compute to (0,4,1) and the tie breaks on source
   order, which is why it sat inert here for months (Dean, 2026-08-20). */
.outrogrid .paircell.ctlcell > select[name=text_font] { flex-basis: 10rem; }
/* One fixed measure, so every labelled control starts at the same offset into its column. Sized
   to content instead, the Colour swatch and the Alignment dropdown stacked in the Text column
   began 21px apart and read as a mistake. The basis must EXCEED "Alignment" at this font size:
   flex-shrink is 0 and min-width defaults to auto, so a basis narrower than the word is ignored
   in favour of the content width and the misalignment comes straight back (it did, by 7.2px,
   when the text grew to data size). Text size matches the read-back data beside it (Dean,
   2026-08-18); only the muted ink separates a label from a value. */
.ctllabel { flex: 0 0 4.8rem; white-space: nowrap; color: var(--ink-3); }
.outrogrid .paircell { min-width: 0; }
.outrogrid .paircell > input, .outrogrid .paircell > select { flex: 1; min-width: 0; }
.outrogrid .span2 { grid-column: span 2; }
.outrogrid .span4 { grid-column: 1 / -1; }
/* The tagline stops where Align logo stops, not where its own two-column cell ends. 50% is of
   the .span2 cell (two tracks + a gap); the rest is the track gap, the 4.8rem label, the 0.4rem
   paircell gap and the 6.075rem control, less the 1.1rem check-glyph that precedes the field
   (Dean, 2026-08-20). */
.outrofields .paircell.span2 > input[name$=_text] {
  flex: 0 1 calc(50% + 10.075rem); min-width: 0;
}
/* Inside the grid the Style cell IS a column, so it drops the flex-basis calc that sized it
   against the old stacked row. */
.outrogrid .stylecell { flex: none; }
.outrogrid .stylecell select { width: 100%; }
/* Swatches, not fields: a native colour input renders its whole box as the chosen colour, so it
   gets the data-field's height and a hairline rather than a full border fighting the swatch. */
.outrogrid input[type=color] {
  height: 40px; min-height: 40px; padding: 2px; cursor: pointer;
  border: 1px solid var(--line); border-radius: 6px; background: none;
}
/* The stats: ONE checkbox for the block, then four icon + read-only value pairs. The values
   belong to the Walkthrough details tile, so this run displays them and the single checkbox
   decides whether they ride the card. Explicit five-track grid — the base rule's
   repeat(4, max-content) has no column for the leading checkbox and wrapped the sqm pair onto
   a second line. Left, not right-aligned: the grid gives the run its own row rather than the
   leftover strip it used to sit in. */
.outrogrid .statcell .statfields {
  width: auto; justify-self: start; justify-content: start; gap: 0.8rem;
  grid-template-columns: max-content repeat(4, max-content);
}
/* The stats run reads as one more data row, so its first icon starts where the address text and
   the contact names do (Dean, 2026-08-18). Its checkbox is the same 17.59px as theirs, but the
   run's 0.8rem grid gap sat 6.4px wider than .paircell's 0.4rem and pushed the whole set right.
   Pulling only the checkbox's trailing gap keeps the four icon+value pairs their own 0.8rem
   apart — a uniform grid gap cannot be different for one track. */
.outrogrid .statcell .statcb { align-self: center; margin-right: -0.4rem; }
/* 1.75rem in the EDITOR (Dean, 2026-08-18; 1rem was a step too far down from the shared 54px).
   Scoped to .outrogrid: the same .statfile markup draws the Walkthrough details tile's stats at
   44px and the collapsed card row keeps its own 1.5rem badges, and neither was asked to change. */
.outrogrid .statcell .statfile svg { width: 1.75rem; height: 1.75rem; }
/* The values size to their content, no width floor. A floor sized to the number field they
   replaced pushed the run to 486px, which does not fit the two columns the row now has — the
   Background colour shares that row. At content width the worst case (a 5-digit sqm) measures
   well inside two columns, and the icons keep their 1.5rem. */
.statval { min-height: 2.75rem; justify-content: flex-start; }
.outrogrid .logocell .logoprev { flex: none; }
@media (max-width: 640px) {
  /* On a phone the two halves stack instead of sitting side by side — a quarter of a 360px
     line is no width for a font name — and the fields drop to two columns. */
  .outrogrid { grid-template-columns: minmax(0, 1fr); }
  .outrofields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .outrogrid .span2 { grid-column: span 2; }
  /* The grid collapses to one column here, but the placements did not follow: .outrofields kept
     grid-column 1/4 and .outrostack kept 4, which on a one-column grid conjures three implicit
     tracks and squeezed the fields into 74px (the tagline field measured 50px). Stack them both
     in the single column instead (Dean, 2026-08-20). */
  .outrogrid > .outrofields { grid-column: 1; }
  .outrogrid > .outrostack  { grid-column: 1; }
  /* The 6.075rem measure is a DESKTOP alignment with the Align logo and Duration selects, which
     sit in a quarter-width track. On a phone the stack is a full-width row and there is nothing
     left to align to, so the controls go back to filling it (Dean, 2026-08-20). */
  .outrogrid .paircell.ctlcell.rstack > select,
  .outrogrid .paircell.ctlcell.rstack > input { flex: 1 1 auto; }
  /* Below 640px .outrofields is two columns, not three, so the .span2 cell is the full row and
     the desktop calc (pinned to the three-column dropdown position) does not hold — back to
     filling the cell (Dean, 2026-08-20). */
  .outrofields .paircell.span2 > input[name$=_text] { flex: 1; }
}
@media (max-width: 640px) {
  .cardline { flex-wrap: wrap; }
  .cardfields, .cardfields-pair { flex-basis: 100%; }
  .cardfields-pair { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .cardline .agency-cell, .cardline .tagline-cell { flex-basis: 100%; }
  .addrline { flex-wrap: wrap; }
  .addrbox { flex-basis: 100%; width: 100%; }
  .surroundings { flex-wrap: wrap; }
  .stylecell { flex-basis: 100%; }
  /* The details tile's address row sits inside the SAME cardline as the stats now, so the
     tile's own (higher-specificity) .addrbox calc must step aside on a phone too — and the
     stats take a full-width two-up row under the address instead of crowding beside it
     (Dean, 2026-08-16). */
  .details-block .addrbox { flex-basis: 100%; width: 100%; }
  .statfields.tile-stats { width: 100%; grid-template-columns: repeat(2, max-content); }
}
/* Property stats on the outro card's edit row: icon mark + square number field + checkbox.
   Beds/baths/cars are 2-digit, sqm's field is a wider rect to fit its bigger cap. */
/* Pack the four stat cells to their content so the row reads as one input group, with the
   same rhythm as the other data rows (the 4x1fr stretch put ~140px of dead space between
   each field — Dean, 2026-08-05). Gap matches .scene-detail's 0.55rem. The block is sized to
   one name-field column (the Agency field above, see .cardline) and pushed to the form's
   right edge, so the stat row lines up under the Tag line field instead of spanning the whole
   card; the columns right-align inside it (Dean, 2026-08-05). */
.statfields {
  display: grid; grid-template-columns: repeat(4, max-content); gap: 1.1rem;
  width: calc((100% - 0.6rem) / 2);   /* one .cardfields column = the Agency field's width */
  justify-self: end; justify-content: end;   /* right-aligned block, right-aligned columns */
}
@media (max-width: 640px) { .statfields { grid-template-columns: repeat(2, max-content); } }
@media (max-width: 400px) { .statfields { grid-template-columns: max-content; } }
/* Mobile: stack the whole row full-width. */
@media (max-width: 480px) { .statfields { width: 100%; justify-content: start; } }
/* Within a stat: checkbox, then the icon, then the number. Dean asked to keep these gaps the
   same as the other checkbox-and-data-field rows (0.4rem — the paircell gap) rather than the
   tight 0.1rem/0.15rem run (2026-08-07). The grid gap between sets stays 1.1rem. */
.statfield { display: flex; align-items: center; gap: 0.15rem; min-width: 0; }
.statcb { display: inline-flex; align-items: center; gap: 0.15rem; min-width: 0; }
.statfile { display: inline-flex; flex: none; }
.statfile svg { width: 54px; height: 54px; }
.statcb input { width: 1.1rem; height: 1.1rem; min-height: 0; margin: 0; flex: none; accent-color: var(--accent); }
/* Hide the native number-input spinners (up/down arrows): on some browsers they render ON TOP
   of the typed value in a narrow field, which is what the "incrementer is over the number"
   complaint was about. The values are typed/short (beds, baths, cars, sqm), so losing the
   arrows costs nothing — and the grid keeps a clean square/rect silhouette (Dean, 2026-08-05).
   `appearance: textfield` is the old-Edge/Firefox non-standard key; `appearance: none` covers
   the rest. */
.statfield input[type=number] {
  min-width: 0; width: 2.75rem; height: 2.75rem; flex: 0 0 2.75rem;   /* 44px square, the field target */
  text-align: center;                                                  /* single/two-digit counts centred (Dean, 2026-08-05) */
  -moz-appearance: textfield; appearance: none;
}
.statfield input[type=number]::-webkit-outer-spin-button,
.statfield input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
/* sqm keeps a wide rect, trimmed from 6.875rem to 4.75rem — that fits its 5-digit max (99999
   measures ~72px incl. padding/borders at this face/size) with a little room left over, instead
   of the ~24px of slack the 110px field carried. Its value is right-justified, the numeric
   convention (Dean, 2026-08-05). */
.statfield input.stat-wide { width: 4.75rem; height: auto; text-align: right; flex-basis: 4.75rem; }
/* In-field validation: beds/baths/cars carry their native min/max (0-99) on type=number; sqm is
   a comma-grouped text input whose own range (0-99,999) is enforced by the inline JS in
   _card_row via setCustomValidity, plus plan_card_edit clamps server-side. This makes the
   browser's live invalid state visible IN the field instead of only on submit — a typed 150 in
   Beds turns the field --bad immediately, so the mistake is caught before Save. An empty field
   is NOT :invalid (inputs aren't required), so a fresh field never sits red (Dean, 2026-08-06). */
.statfield input:invalid { border-color: var(--bad); box-shadow: none; }
.statfield input:invalid:focus { outline-color: var(--bad); }
.stack p { margin: 0; }
/* The intro/outro "Style" dropdown now lives inside .cardfields (the same 2-column grid the agent
   fields use) so it is automatically the width of an agent-name field — see the .cardfields rules
   above (Dean, 2026-08-05). The old full-card / 442px widths are gone with the old markup. */
/* max-width and min-width, because a file input is the one control here with a native intrinsic
   width it will not give up: "Choose File / No file chosen" measures 275px, and at 320px that is
   wider than the card it sits in. It grew its own .note to 309px inside a 273px form and took the
   document 5px sideways — the only thing on /new that overflowed a phone. */
input[type=file] { font: inherit; font-size: 0.9375rem; max-width: 100%; min-width: 0; }
code { background: var(--surface-2); padding: 0.05em 0.35em; border-radius: 4px; font-size: 0.9em; }
button.small { min-height: 0; padding: 0.3rem 0.7rem; font-size: 0.8125rem; }
.topline nav form { margin: 0; }
@media (max-width: 480px) { .two { grid-template-columns: 1fr; } }

/* --- field tooltips -------------------------------------------------------
   transitions.dev "tooltip open/close" (17-tooltip.md), pasted verbatim apart
   from three adaptations spec AB forces: theme tokens instead of the snippet's
   hardcoded #fff/#2f2f2f (--tt-bg/--tt-fg map to --surface/--ink, since this
   app has no --card token); side-aware left-edge positioning instead of a
   centred max-width clamp (the clamp never engages — every left-column tip
   overflows at every width because left:50% puts the tip's edge at
   triggerCentre-120, well inside the card editor's 72.5px/26px margins — the
   defect is position, not width); and a hover:none guard, since a hover
   tooltip cannot exist on a phone (aria-describedby still reaches a screen
   reader there; only the visual bubble is withheld).
   :hover/:focus-within is scoped to .t-tt-anchor, not the skill's default
   sibling selector, because 17 of the card editor's 25 tipped controls sit
   inside a <label> and a tooltip inside a <label> toggles the checkbox it
   sits in when clicked.
   Spec AC §2 adds a JS shift clamp (static/tooltip.js) on top: tt-right is
   still the right default at full width, but between ~700 and 1100px a
   control can sit far enough into a multi-column grid that NEITHER static
   anchor clears the viewport — measured 13 of the plan screen's tips
   clipped at 700px. --tt-shift is a translateX offset the script writes on
   pointerenter/focusin and clears on leave; with the script absent, --tt-
   shift is simply unset and the transform is the AB-shipped translate(0,0),
   so the pure-CSS pattern still works exactly as it did, clipped at the
   edges in that band — not a regression, today's behaviour without JS.
   Spec AC §6 squares both this bubble and the validation error message's
   corners (Dean: offered rounded-tooltip/square-error, chose both square).
   The literal 0 instead of var(--radius) mirrors .t-tt's existing pattern
   of a literal that happens to agree with the token — deliberate only
   because the value is zero; if a radius ever returns here it should
   return as var(--radius) on both bubbles, not a second literal. */
/* Registered so the browser knows it is a <length>, not an opaque string — without this,
   tooltip.js writing --tt-shift on an element that already has `transition: transform …`
   leaves the transition permanently stuck at the pre-shift value (measured: still wrong
   200ms later, an order of magnitude past the 50ms it should have settled in). Chromium only
   treats a custom property as a real interpolation target once it is typed. */
@property --tt-shift {
  syntax: '<length>';
  inherits: false;
  initial-value: 0px;
}
:root {
  --tt-in-dur: 150ms;
  /* 120ms, not the pattern's 50 (Dean, 2026-08-21: "the tooltips don't fade away"). Below about
     100ms a fade is not perceived as motion, it reads as a cut — so the exit was doing the work
     of no transition at all. The leave-fast-arrive-gently asymmetry is kept deliberately, just at
     1.25x against the 150ms entrance instead of 3x. */
  --tt-out-dur: 120ms;
  --tt-scale: 0.98;
  --tt-delay: 80ms;
  --tt-in-ease: ease-out;
  --tt-out-ease: ease-out;
  --tt-bg: var(--surface);
  --tt-fg: var(--ink);
}
/* The cell that already contains the control is the positioning context and the hover target.
   This used to be a wrapper span around the trigger, which made the control stop being a DIRECT
   CHILD of its cell and silently killed every child-combinator rule that sized or placed it
   (spec AG). A class rather than a bare `.paircell { position: relative }`, so a cell only
   becomes a containing block because a tooltip asked it to. */
.t-tt-anchor { position: relative; }
.t-tt {
  position: absolute;
  /* BELOW the field, not above (Dean, 2026-08-28). The origin flips with it — `0 0` is the
     bubble's top edge, the one now adjacent to the trigger, so the scale-in still grows OUT of
     the control rather than back toward where the bubble used to sit. tooltip.js writes this
     same origin when it shifts, and carries the matching `0`.
     z-index is load-bearing, not defensive: a bubble hanging DOWNWARD overlaps content that
     comes LATER in the document, which paints later and wins by default — the old upward bubble
     overlapped EARLIER content and needed no help. Measured on the outro card editor, screenshot
     differential over each bubble's own box: with z-index 30, 20-21% of the box is the bubble's
     border and glyphs; with it removed, 6.6-8.5%, the rest painted over by the controls beneath.
     Note that the bubbles are still partly visible without it — a check that only asks "does it
     draw at all" passes against the bug, which is why this was measured by area. 30 matches
     form[data-validate] .field-msg, the app's other below-the-field overlay, and .t-tt is not
     the reason .field-msg has it: both are downward overlays with the same problem. */
  top: calc(100% + 8px);
  left: 0;
  z-index: 30;
  transform: translate(var(--tt-shift, 0px), 0) scale(var(--tt-scale));
  transform-origin: 0 0;
  padding: 8px 12px;
  border-radius: 0;
  /* A real border, in the token and never the literal: --accent is declared once and is not
     redefined per theme, so the bubble carries the same gold as the primary button, the workflow
     bar's current step and the busy border, on light and dark alike. A hardcoded hex here would
     be one token and one literal agreeing until someone changes the token. Not --line
     (decoration) or --line-firm (the visible edge of a CONTROL, and carrying a WCAG 1.4.11
     history): a tooltip is an informational surface, so no contrast floor reaches it and the
     accent is a free choice (Dean, 2026-08-21). Note the width is `max-content`, an INTRINSIC
     keyword, so the global box-sizing:border-box does not fold this border into it: a sub-cap
     bubble grows 2px (measured 228 -> 230). One already at the 240px max-width is clamped by
     that cap and does not move, and spec AC §2's shift clamp reads offsetWidth at whatever it
     is, so it still clamps correctly. */
  /* 50% alpha (Dean, 2026-08-21): the solid gold read as a hard frame around an informational
     surface. color-mix keeps the TOKEN — `--accent` stays the single source, so the bubble still
     follows the gold if it ever moves, which a literal rgba() would quietly stop doing. Alpha,
     not a lighter opaque gold: the bubble sits over form controls and over page ground, and a
     fixed tint would only match one of them. The other five `1px solid var(--accent)` rules are
     BUTTONS and the modal — controls, which keep the firm edge; this is the only informational
     surface among them. Width is unaffected: still 1px, so the +2px `width: max-content` note
     above still holds and spec AC §2's offsetWidth clamp reads the same number. */
  border: 1px solid color-mix(in srgb, var(--accent) 50%, transparent);
  background: var(--tt-bg);
  color: var(--tt-fg);
  white-space: normal;
  width: max-content;
  max-width: min(15rem, calc(100vw - 2rem));
  text-align: left;
  font-size: 0.8125rem;
  line-height: 1.35;
  /* The transitions.dev pattern's hairline ring is gone with the border above: 6% black is
     barely visible on light and is black-on-dark on the dark theme, where it does nothing at
     all. It was the pattern's substitute for a border, and doubles up once there is one. */
  box-shadow:
    0 2px 6px 0 rgba(0, 0, 0, 0.05),
    0 4px 42px 0 rgba(0, 0, 0, 0.06);
  opacity: 0;
  pointer-events: none;
  transition:
    opacity   var(--tt-out-dur) var(--tt-out-ease),
    transform var(--tt-out-dur) var(--tt-out-ease);
}
.t-tt.tt-right { left: auto; right: 0; transform-origin: 100% 0; }
.t-tt-anchor:hover .t-tt,
.t-tt-anchor:focus-within .t-tt {
  opacity: 1;
  transform: translate(var(--tt-shift, 0px), 0) scale(1);
  transition-duration: var(--tt-in-dur);
  transition-timing-function: var(--tt-in-ease);
  transition-delay: var(--tt-delay);
}
@media (hover: none) { .t-tt { display: none; } }
@media (prefers-reduced-motion: reduce) {
  .t-tt { transition: none !important; }
}
/* An error outranks an explanation. `.invalid` is the app's own error signal
   (validation.js paint(), never set on load — only on blur, on input while
   already invalid, and on submit) so this is provably mutually exclusive
   with the visible .field-msg. `:invalid` alone would also match an
   untouched empty `required` field from first paint, hiding the tooltip
   at the moment it is most useful to avoid a collision that has not
   happened yet — this codebase has met that trap once already (see the
   in-field validation comment below: "inputs aren't required" is what
   dodges it there, a property of those fields, not a fix). `:user-invalid`
   covers what `.invalid` cannot: a required field's native validation
   blocks submission before the `submit` handler ever runs, so `paint()`
   never fires and `.invalid` never gets set, but the browser's own bubble
   is on screen regardless. */
.t-tt-trigger.invalid + .t-tt,
.t-tt-trigger:user-invalid + .t-tt { display: none; }

/* --- pricing ------------------------------------------------------------- */

.packs { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; }
.pack {
  background: var(--surface); border: 1px solid var(--line); border-radius: 0;
  padding: 1.1rem; display: grid; gap: 0.4rem; align-content: start; margin: 0;
}
.pack.best { border-color: var(--accent); }
.pack .tag {
  font-size: 0.75rem; color: var(--accent-ink); font-weight: 500;
  text-transform: none; margin: 0;
}
.pack .price { font-size: 1.75rem; font-weight: 500; letter-spacing: -0.02em; margin: 0; }
.pack .hint { margin: 0 0 0.4rem; }
/* Purchasable tile: price + Buy button on top, the per-credit "@ $xxx each" line centred
   beneath the button (Dean, 2026-08-06). The landing-page (link) shape uses the same rows so
   the two never drift.

   Alignment fix (Dean, 2026-08-07): the hint was centred on the TILE not on the BUTTON —
   "@ $179 each" sat under the middle of the card while "Buy 10 credits" sat right. Making
   `.pack` one shared grid and dissolving the two vectors with `display: contents` pins price
   to col 1 and both the button and the hint to col 2, so "row 1 button / row 2 hint" always
   share a centre line no matter how long the price or credit count reads.

   Scoped to a.pack/form.pack, not `.pack` alone: the agent's plain price tile (no button,
   `div.pack` on /buy for a non-admin) has `.price`/`.hint` as DIRECT children and would be
   re-flowed into two columns by the same rule.

   NO LONGER a two-shape discriminator (2026-08-24). This comment used to read "a/form are exactly
   the two shapes that carry `.pack-top`, so the scope is the discriminator". /buy's order tile is
   a THIRD: `div.pack.picked`, which DOES carry `.pack-top`. It matched neither branch, so its
   `.pack-top` stayed flex and shrink-wrapped to 121px, leaving its chip ~88px short of the edge
   the buttons reach — correct rules that silently stopped applying. The `.pack.picked` block
   below repeats these pins for it. Adding a fourth shape means extending that block too; what
   discriminates is carrying `.pack-top`, not being an `a` or a `form`. */
a.pack, form.pack { display: grid; grid-template-columns: 1fr auto; align-items: center; }
a.pack .pack-top, form.pack .pack-top,
a.pack .pack-bottom, form.pack .pack-bottom { display: contents; }
a.pack .pack-top .price, form.pack .pack-top .price { grid-column: 1; grid-row: 1; }
a.pack .pack-top .btn, form.pack .pack-top button { grid-column: 2; grid-row: 1; justify-self: center; }
a.pack .pack-bottom .hint, form.pack .pack-bottom .hint { grid-column: 2; grid-row: 2; justify-self: center; margin: 0; }

/* /buy's order row: the pack you have chosen and the two you have not, as three EQUAL tiles in
   one flat grid, each under its own heading (Dean, 2026-08-24). Flat rather than a half-width
   tile beside a nested `.packs`, because a nested grid divides what is left after its own gap
   and so can never match a sibling outside it. Measured: 245 / 245 / 245. */
.order-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
             gap: 0.75rem; align-items: stretch; }
/* Each heading is wrapped WITH its tile rather than sharing a row of headings. A flat
   labels-then-tiles grid renders identically at three columns, but collapses to three orphaned
   headings followed by three tiles at one column — the mobile bug this replaces. Pairing them
   makes the stack correct by construction instead of by a breakpoint restating the layout. */
.order-col { display: grid; grid-template-rows: auto 1fr; gap: 0.4rem; }
.order-col > .lbl { margin: 0; align-self: end; }
/* The third pack shape — see the note above. Same pins as a.pack/form.pack, so the chip lands
   exactly where the alternatives' buttons land: box 19px in from each tile's edge, text 31px. */
.pack.picked { border-color: var(--accent); display: grid;
               grid-template-columns: 1fr auto; align-items: center; }
.pack.picked .pack-top, .pack.picked .pack-bottom { display: contents; }
.pack.picked .pack-top .price { grid-column: 1; grid-row: 1; }
.pack.picked .pack-top .order-chip { grid-column: 2; grid-row: 1; justify-self: center; }
.pack.picked .pack-bottom .hint { grid-column: 2; grid-row: 2; justify-self: center; margin: 0; }
/* Borrows the Buy button's box — same padding, same border width but transparent — so the chip's
   TEXT sits where a button's text sits rather than flush to the tile edge. A marker, not a
   control: this pack is already chosen. */
.order-chip { font-size: 1.125rem; font-weight: 500; color: var(--accent-ink);
              padding: 0.35rem 0.7rem; border: 1px solid transparent; white-space: nowrap; }
@media (max-width: 640px) { .order-row { grid-template-columns: 1fr; } }
/* Matches the 560px stack a.pack/form.pack already use, so all three collapse alike. */
@media (max-width: 560px) {
  .pack.picked { grid-template-columns: 1fr; justify-items: center; text-align: center; }
  .pack.picked .pack-top .price,
  .pack.picked .pack-top .order-chip,
  .pack.picked .pack-bottom .hint { grid-column: 1; justify-self: center; }
  .pack.picked .pack-top .price { grid-row: 1; }
  .pack.picked .pack-top .order-chip { grid-row: 2; }
  .pack.picked .pack-bottom .hint { grid-row: 3; }
}
/* The promo row and the Pay button were touching exactly: #pay-card is display:block, and both
   #promo-msg and #pay carry no margin, so the empty promo feedback line contributes nothing. The
   8px goes on the BUTTON, not under the promo row — a promo message belongs close to the input
   it answers, and the button keeps its air whether one is showing or not. */
#pay { margin-top: 8px; }
/* The gap under the /buy lede was 28px, all of it `p.sub`'s own margin-bottom - `.order-row`
   contributes no margin-top. So this is a one-line change to that margin and nothing else, but it
   cannot be made on `p.sub`: that class sets the lede spacing on every page that has one, and /buy
   is the only page being asked to tighten. Hence the extra class rather than a global edit or an
   `:has(+ .order-row)` selector - the order row is not always the next sibling (a non-buyable
   account renders the noscript tiles instead), and a selector that quietly stops matching is the
   defect this codebase keeps re-learning. */
.buy-lede { margin-bottom: 1.25rem; }
/* Pay is the only button on the site that takes money, and it was wearing the shared quiet-button
   costume: 14px, transparent, 1px hairline. Three changes, and only one of them is what it looks
   like.

   THE MISSING SPACE. The markup has always read `Pay <span id=pay-amount>`, with a real space in
   it, and it has always rendered "Pay$249". `button, .btn` is `display: inline-flex`, so the text
   node and the span are two flex items and the whitespace between them is discarded — this is
   flex layout working correctly, not a typo upstream. `gap` is the fix that matches the cause;
   `&nbsp;` in the HTML would also work and would leave the next person hunting for why a literal
   space in the source does nothing. 0.3em tracks the font size, so the word space stays a word
   space if this ever gets bigger again.

   THE FILL IS NOT DECORATION. White text needs it. The button sits on #pay-card, which is
   `oklch(1 0 0)` — pure white — in the light theme, so white-on-transparent is white-on-white and
   the label disappears. `--on-accent` is the right fill because it is the one ink token that does
   NOT invert between themes (0.24 in both); `--ink` would flip to near-white in dark and fill the
   button with the thing the text is meant to be.

   THE BORDER IS LEFT ALONE. `.btn.cta-accent` has set `border-color: var(--accent)` all along, and
   it stays at the shared 1px. It went to 2px here briefly, on the argument that 1px spread over a
   603x46 perimeter reads weaker than the same 1px enclosing a 202x144 tile. True of a TRANSPARENT
   button, and obsolete the moment the fill above landed in the same change: a solid dark block at
   16.41:1 on a white card is what marks this as the primary action now, so the border is not
   carrying that weight any more. Reverted 2026-08-24 (Dean) because gold appears in exactly two
   places on /buy - the "Your order" tile and this button - and they are a matched pair, `this is
   your selection` and `this is how you confirm it`. Two different weights make the heavier one read
   as a different kind of line rather than a stronger version of the same one, and 2px was the only
   one on a page where every other border is 1px. */
#pay {
  font-size: 1rem;
  gap: 0.3em;
  background: var(--on-accent);
  color: oklch(0.99 0.002 70);
}
/* The shared `button:hover` fills with translucent gold, which over a dark fill turns muddy rather
   than brighter. Going to solid accent instead means the hover reads as the button lighting up,
   and the label flips to the dark ink the accent token exists to be read against. */
#pay:hover:not([disabled]) { background: var(--accent); color: var(--on-accent); }
/* "Payment" at Title (1.125rem/500), the h2 size already in the scale, with the air under it
   halved. The 16px was never the label's to give: `.lbl` carries margin:0 and the gap belongs to
   the email paragraph's own margin-top. Scoped to #pay-card so the order-row headings stay at
   0.9375rem and the two do not compete. */
#pay-card > .lbl { font-size: 1.125rem; }
#pay-card > .lbl + p { margin-top: 8px; }
/* On a phone the tiles are ~160px wide (the .packs auto-fit caps them at 180px), so the
   side-by-side price | button rows wrap awkwardly. Stack each tile into one centred column:
   price, then the buy button, then "@ $x each" — matching how Dean described the phone layout
   (2026-08-08). The grid-column/row pins above are overridden here so the grid becomes a
   single column and everything centres. */
@media (max-width: 560px) {
  a.pack, form.pack { grid-template-columns: 1fr; justify-items: center; text-align: center; }
  a.pack .pack-top .price, form.pack .pack-top .price { grid-column: 1; grid-row: 1; justify-self: center; }
  a.pack .pack-top .btn, form.pack .pack-top button { grid-column: 1; grid-row: 2; justify-self: center; }
  a.pack .pack-bottom .hint, form.pack .pack-bottom .hint { grid-column: 1; grid-row: 3; justify-self: center; }
}
.pack-top { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
.pack-bottom { display: flex; align-items: center; justify-content: center; gap: 0.5rem; }
.pack-bottom .hint { margin: 0; }
.err { color: var(--warn); margin: 0; }
.ok { color: var(--ok); margin: 0; }

/* Credit statement. Narrow on purpose — three columns, and the amount is the one the eye
   goes to, so it is the only one that is right-aligned and tabular. */
.ledger { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
.ledger th { text-align: left; font-weight: 500; color: var(--ink-2); font-size: 0.8125rem; }
.ledger th:last-child, .ledger td:last-child { text-align: right; font-variant-numeric: tabular-nums; }
/* Six columns need air between them. The outer edges stay flush, so the table lines up with the
   note's own padding instead of sitting indented inside its box. */
.ledger th, .ledger td { padding: 0.45rem 0.75rem 0.45rem 0; border-bottom: 1px solid var(--line); }
.ledger th:last-child, .ledger td:last-child { padding-right: 0; }
.ledger tr:last-child td { border-bottom: 0; }
.ledger td:first-child { color: var(--ink-2); white-space: nowrap; }
/* A job id is a reference, not prose: it must not wrap mid-token, and it earns less weight than
   the address beside it, which is what the agent actually recognises. */
.ledger .jobid { white-space: nowrap; font-size: 0.8125rem; }
/* The reference stays on one line; its "(no reference on file)" note does not. Measured
   2026-08-29: with the note inheriting `nowrap` the widest cell in the column was
   "20260810-090000-bbbbbb (no reference on file)" on a single line, which pinned Property ID at
   322px and left Address 78px — a nowrap minimum is a floor, and `width:100%` on the address is
   only a preference, so the floor wins. Dropping the note to its own line takes the column back
   to the width of an actual reference, which is what it is for. */
.ledger .jobid .hint { display: block; white-space: normal; }
/* The privacy policy's provider table is prose, not figures: the last column holds a reason,
   not a number, so it reads left-to-right like the rest of the page. The four real ledgers
   share the right-aligned tail, so this is a modifier rather than a change to the base. */
.ledger.prose th:last-child, .ledger.prose td:last-child { text-align: left; font-variant-numeric: normal; }
.ledger.prose td:first-child { white-space: normal; color: var(--ink); }
/* The base rule below pins th nowrap because the real ledgers' headings are single words.
   Privacy's provider table heads are phrases ("What it receives") — unwrapped they force
   the table past the viewport on a phone. */
.ledger.prose th { white-space: normal; }
.ledger th { white-space: nowrap; }     /* headings are single words; never break one */
/* Right-aligned and tabular, by class rather than by position: a count is read by comparing it
   down the column, and four tables share `.ledger` with the number in a different place in each.
   `td:last-child` already does this for whatever ends the row; this is for the ones that do not. */
.ledger .num { text-align: right; font-variant-numeric: tabular-nums; }

/* --- column widths, per table -------------------------------------------------------------
   These were written about the statement's columns and applied to every `.ledger`, which held
   only while it was the sole six-column table. It is not any more: the admin sales table also
   has six, and its fifth is Credits, not Address. */

/* The address is the only column whose length varies and the one an agent actually reads, so it
   takes the slack. Without this the browser hands the extra width to whichever column happens to
   hold the longest word — which was User.

   Column FOUR since 2026-08-29, when Address and Property ID swapped places. This selector is
   positional and the swap moved the thing it was written about, so it had to move too — a
   `width:100%` left on :nth-child(5) would have made the Property ID column greedy and squeezed
   the address it was written to protect. Property ID needs no rule of its own: its cell carries
   `.jobid`, which is already `white-space: nowrap`, so it sits at exactly its content width. */
.ledger.statement th:nth-child(4), .ledger.statement td:nth-child(4) { width: 100%; }
/* Date and User take exactly what they need. Giving all the slack to the address squeezed User
   to 71px and broke "Dean Sheppard" across two lines — a name is a unit. */
.ledger.statement td:first-child, .ledger.statement td:nth-child(3) { white-space: nowrap; }
/* Type carries a short phrase, so it wraps rather than forcing the table wide — but it needs a
   floor. `width:100%` on the address leaves every other column at its longest word, and on rows
   with no address to compete that crushed Type to 40px: "test / top- / up", three lines. */
.ledger.statement td:nth-child(2) { min-width: 10rem; }

/* Sales: the slack goes to Item, the last column before the figures. Date, Agency and the buyer
   then stay packed to the left where they are read together, and the empty space falls in front
   of the right-aligned numbers where a gap is expected.

   It was on Agency first, and that is wrong in a way worth naming: a wide cell does not centre
   its text, it strands it. The agency name stayed hard left in a ~380px cell and every column
   after it was pushed to the right edge, so each row read as two clumps with a canyon between
   them. The cells were left-aligned the whole time; the columns were not where the eye needed
   them. Slack belongs beside the numbers, never between two things being compared. */
.ledger.sales th:nth-child(4), .ledger.sales td:nth-child(4) { width: 100%; }
/* A name and a date are units — neither may break across two lines. Item is the one left free to
   wrap, because it holds the slack and is the only cell with room to give. */
.ledger.sales td:first-child, .ledger.sales td:nth-child(2), .ledger.sales td:nth-child(3) {
  white-space: nowrap;
}

/* Activity (spec IN, 2026-09-08 addendum + round 2): seven columns (Property ID dropped in round
   2, once Address carried the job link and nothing was left for it to do here), its own class --
   `.statement`'s positional width rules above are written for six columns and land on the wrong
   ones on a table shaped like this. `.statement`'s own comment already documents this exact drift
   happening once before, against `.sales`; this page reopened it by sharing the class rather than
   getting a fresh one at build time.

   Slack goes to `$`, not Address, the way `.sales` gives it to Item rather than to Agency: Agency,
   Date and User are packed with `nowrap`, same "must not wrap mid-word" reason `.sales` packs
   Date/Agency/buyer.

   Address got the same `nowrap` in the first round and that measured worse, not better: 92px on a
   real street address, wrapped across three lines, the whole row stretched to match -- the exact
   "too narrow" symptom relocated rather than removed, `nowrap` alone giving a column nothing to
   stop it shrinking when a sibling is flexed to 100%. Round 2's own complaint (the table now too
   WIDE for the frame) traces to the same rule the other way: an unbounded `nowrap` sizes to
   whatever the longest real address happens to be, no ceiling. Capped instead: `nowrap` stays (an
   address must not wrap mid-line) but gains `max-width` + `text-overflow: ellipsis` +
   `overflow: hidden`, so a long address truncates rather than growing the table. `.note`'s own
   scroll (below, `.note:has(.ledger)`) never engages once the table fits `.wrap.wide`'s frame
   again.

   32ch, not a round guess -- measured with Playwright against both a genuinely long real address
   and a normal ~34-char one. 22ch measured first and truncated the normal address too, which would
   have made the ellipsis the common case rather than the outlier it is meant to catch; 32ch keeps
   the normal one on one line, untruncated, and only the outlier gets the ellipsis. Property ID
   needs no rule of its own, `.jobid` already keeps it packed.

   Slack moved again (Dean, 2026-09-08, third look): `$` (nth-child(6)) is a short numeric column,
   and giving IT the width:100% left the genuinely variable-length column -- Type, "Purchase:
   Single walkthrough" to "Affiliate: Jane Doe referred Acme Realty" -- sitting at its bare
   min-width floor with a visible gap opening up next to a column that never needed the room.
   Type takes the slack now; `$` gets the same `nowrap` protection Agency/Date/User already have,
   so an unflexed numeric column isn't crushed.

   User and Type then swapped places (Dean, same day, fourth look) -- `swap_type_user` in
   server.py moves the two `<td>`s, so nth-child(3)/(4)'s ROLES flip here too: `nowrap` now packs
   User at (3), and the slack that was on Type at (3) moves with it to Type's new seat at (4). */
.ledger.activity th:nth-child(4), .ledger.activity td:nth-child(4) { width: 100%; }
.ledger.activity td:first-child, .ledger.activity td:nth-child(2), .ledger.activity td:nth-child(3),
.ledger.activity td:nth-child(6) {
  white-space: nowrap;
}
.ledger.activity td:nth-child(5) {
  max-width: 32ch; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

/* Six columns will not fit a phone. Scroll the table inside its own box rather than letting it
   push the page sideways. */
.note:has(.ledger) { overflow-x: auto; }
.ledger .plus { color: var(--ok); }
.ledger .minus { color: var(--ink-2); }
/* The filter sits above the table: just the control, whose first option says what it does. */
.filter { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.85rem; flex-wrap: wrap; }
.filter select { min-height: 0; padding: 0.3rem 1.8rem 0.3rem 0.5rem; font-size: 0.875rem; width: auto; }
.filter button { min-height: 0; padding: 0.3rem 0.7rem; font-size: 0.8125rem; }
/* The job page's Reassign-owner row reuses .filter so its dropdown matches the /walkthroughs
   filter's width. The row shrinks to its content (width: fit-content) so the Reassign button
   sits snug right of the dropdown, not flung to the page's edge by a full-width flex stretch.
   (Dean, 2026-08-10 — reassign on the job page.) */
.filter.rassign { width: fit-content; display: grid; gap: 0.5rem; }
.filter.rassign .lbl { white-space: nowrap; }
.filter.rassign button { margin-left: 0; justify-self: start; }
/* The reassign tile's scene-detail defaults to 1rem of bottom padding; drop it to 0 so the
   gap under the control equals the note's own (symmetric) padding, matching the top gap
   (Dean, 2026-08-10). */
.rassign-note .scene-detail { padding-bottom: 0; }
.rassign-note .filter { margin-bottom: 0; }

a.planrow { text-decoration: none; color: inherit; }
a.planrow:hover { background: var(--surface-2); }

a { color: var(--accent-ink); text-decoration-thickness: 1px; text-underline-offset: 2px; }
/* Hover pushes the link TOWARD THE PAGE'S INK, which is a direction rather than a value — so it
   deepens on light and brightens on dark from one rule, and can only ever gain contrast. It was
   `var(--accent)`, a lighter ochre, which reads as emphasis on a white ground and as a link
   fading into the background on a dark one: 3.5:1, under AA, on every link in the product. */
a:hover { color: color-mix(in oklch, var(--accent-ink) 70%, var(--ink)); }
.topline nav a, a.btn, a.planrow { text-decoration: none; }
.topline nav a:hover { color: var(--accent-ink); }

/* Five across, computed rather than a fixed width: a grid of equal columns fills the row
   whatever the container is, so the photo strip and the scene card it sits in share one right
   edge instead of the strip stopping short wherever a fixed tile width happened to run out.

   Five, not seven: this is the picker where the agent decides WHICH photo a shot is built from
   and, for a pair, which end it starts at — at 155px two angles of the same room are hard to
   tell apart. 220px (+42%) makes that judgement possible; a 15-photo scene simply takes three
   rows, which costs nothing but scroll.

   Below the breakpoint it falls back to as-many-as-fit, because five on a phone is a contact
   sheet nobody can tap. */
.pics { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.6rem; }
.pic { display: grid; gap: 0.3rem; justify-items: stretch; min-width: 0; }
/* aspect-ratio, not a fixed height: the tile width is now fluid, and a fixed height would crop
   harder as the column narrows. 16:9 matches the output the agent is choosing frames for. */
.pic img {
  width: 100%; aspect-ratio: 16 / 9; height: auto; object-fit: cover;
  border-radius: 4px; border: 1px solid var(--line);
}
@media (max-width: 900px) {
  .pics { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); }
}
/* --- the uploader's drop zone ------------------------------------------------------------
   A <label> wrapping its own file input, so the whole surface opens the picker with no script.
   Before this, the target was the leftmost ~90px of a 23px-tall control. --line-firm rather than
   --line because this IS a control and owes 3:1, dashed because a drop target that looks like a
   filled card reads as something already holding content. */
.drop {
  display: grid; justify-items: center; align-content: center; gap: 0.25rem;
  min-height: 120px; padding: 1.25rem 1rem; text-align: center; cursor: pointer;
  border: 1px dashed var(--line-firm); border-radius: var(--radius);
  background: var(--surface);
  transition: background 160ms var(--ease), border-color 160ms var(--ease);
}
.drop:hover, .drop:focus-within { border-color: var(--accent); background: var(--surface-2); }
/* Drag-over is the accent, the one place this screen uses it — the page is asking for exactly one
   thing and this is the moment it is about to get it. */
.drop.over {
  border-color: var(--accent); border-style: solid;
  background: color-mix(in oklch, var(--accent) 7%, var(--surface));
}
.drop-lead { font-size: 1.125rem; font-weight: 500; }
.drop-sub { font-size: 0.875rem; color: var(--ink-2); }
/* The two lines under the drop zone ("or tap to choose up to 40 images" / "JPEG, PNG or HEIC")
   stack rather than run together — each is its own block (Dean, 2026-08-12). */
.drop-sub span { display: block; }
/* The control shows itself by DEFAULT and is hidden only once the script that replaces it has
   actually run — `.js` is added by UPLOAD_JS, not printed by the server. The other way round, a
   script that throws on an old browser leaves an agent looking at a drop zone with no visible
   control and no "3 files selected" to tell them anything happened.
   Visually hidden, never `display:none`: it must stay keyboard-reachable and in the
   accessibility tree, because the label around it is what people see. */
.drop.js input[type=file] {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}
.drop:not(.js) input[type=file] { margin-top: 0.5rem; max-width: 100%; }

.picked { display: grid; grid-template-columns: repeat(6, 1fr); gap: 0.6rem; align-items: start; }
.pick { position: relative; margin: 0; display: grid; gap: 0.25rem; min-width: 0; }
.pick img, .pick-none {
  width: 100%; height: auto; aspect-ratio: auto; object-fit: contain;
  border-radius: 4px; border: 1px solid var(--line);
}
.pick-none { aspect-ratio: 16 / 9; }
/* A photo the browser cannot decode is still a photo the SERVER can read — an iPhone HEIC in
   Chrome. It gets a tile with no picture rather than a broken image, because refusing to show it
   would suggest it is not going to be uploaded. */
.pick-none { display: grid; place-content: center; background: var(--surface-2); color: var(--ink-3); }
.pick figcaption {
  font-size: 0.75rem; color: var(--ink-2);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* 28px, not 44: it sits ON a 112px tile and a 44px square would cover a quarter of the picture
   the agent is using to decide. Spaced from its neighbours by the grid gap, and every tile also
   has the strip's own remove reachable by keyboard. */
.pick-x {
  position: absolute; top: 0.25rem; right: 0.25rem;
  min-height: 0; width: 28px; height: 28px; padding: 0; line-height: 1;
  display: grid; place-content: center; font-size: 1rem;
  border-radius: 999px; border: 1px solid var(--line);
  background: color-mix(in oklch, var(--surface) 85%, transparent);
  backdrop-filter: blur(4px); color: var(--ink); cursor: pointer;
}
.pick-x:hover { background: var(--bad-bg); border-color: var(--bad); color: var(--bad); }
.warn-text { color: var(--warn); }

.pic.out img { opacity: 0.35; filter: grayscale(1); }
/* Staged states, spec IG. Added's tag is row-based -- `.pic` is already a single-column grid, so
   it is just its own row between the image and the Remove button, same gap as everything else in
   it. Removing's tag is the one exception (Dean, 2026-09-07): centred over the photo itself, same
   font-size/colour as the Undo button beside it, so `.pic-img` gives the image its own positioning
   context for it -- `.pic` itself stays the plain grid the Added case still relies on. */
/* display:block on the img here specifically (Dean, 2026-09-08): as a direct child of `.pic`'s
   grid, the img is auto-blockified by being a grid item and this never mattered; wrapped in
   `.pic-img`, a plain block div and no grid item itself, the img falls back to its default
   `display: inline` and picks up the classic inline-replaced-element baseline gap below it --
   measured at ~14.8px, exactly what pushed Undo out of line with the other tiles' buttons. */
.pic-img { position: relative; }
.pic-img img { display: block; }
.pic.out .pic-tag {
  position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  font-size: 0.8125rem; color: var(--ink);
}
.pic.new img { border-color: var(--accent); }
.pic-tag { font-size: 0.6875rem; font-weight: 600; color: var(--ink-2); text-align: center; }
.pic form { margin: 0; }
.pic button { width: 100%; }
/* Vertical padding stays 0 — the .planrow inside supplies it — but the horizontal inset must
   survive, or the hover fill loses the card edges it was just given. */
.plansum { padding-top: 0 !important; padding-bottom: 0 !important; }
.plansum .planrow { flex: 1; }

/* Editable-in-place room name: the field is the heading, not a form below it. */
/* Name and move control on one row, on the SAME five-column grid as the photo strip below: the
   name spans two tile columns, the move control the third. Aligning to the grid rather than to
   chosen widths is what makes the card read as one object, and it stays true if the tile count
   ever changes. */
.scenebar {
  display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.6rem;
  align-items: center;    /* no margin: .scene-detail's gap is the only vertical rhythm here */
}
.scenebar .namebar { grid-column: span 2; }
/* The dropdown needs two columns; the stated-fact box needs three, because it carries a full
   sentence plus its reason and at two columns the mirror one wrapped to a second line. The name
   field keeps its two either way, so the row still lines up with the photo grid below. */
.scenebar .moveform { grid-column: span 2; margin: 0; }
.scenebar .movefixed { grid-column: span 3; margin: 0; }
.scenebar .moveform select { width: 100%; }

/* Where a scene has no move to choose — a pair's path comes from its two photos, a mirrored
   room is pinned to the safest move — the same slot states the fact instead. Styled as a
   disabled field on purpose: it occupies the control's position, so it should look like a
   control that cannot be changed rather than like a stray sentence. */
/* Block, not grid: the reason is a continuation of the sentence before it, so it flows on the
   same line when there is room and wraps naturally when there is not — as a grid row it always
   broke, whatever the width. */
.movefixed {
  padding: 0.5rem 0.7rem; min-height: 40px;
  display: flex; align-items: center; flex-wrap: wrap; gap: 0 0.35rem;
  border: 1px solid var(--line); border-radius: 0;
  background: var(--surface-2); color: var(--ink-2); font-size: 0.875rem;
}
.movefixed span { font-size: 0.8125rem; color: var(--ink-3); }
/* Mid-swap: the slot is asking for something rather than stating a settled fact. */
.movefixed.warn {
  border-color: color-mix(in oklch, var(--warn) 45%, var(--line));
  background: color-mix(in oklch, var(--warn) 7%, var(--surface));
  color: var(--ink);
}

@media (max-width: 900px) {
  .scenebar { grid-template-columns: 1fr; }
  .scenebar .namebar, .scenebar .moveform, .scenebar .movefixed { grid-column: auto; }
}

.namebar { display: flex; align-items: center; gap: 0.4rem; }
.namefield {
  font: inherit; font-size: 1rem; font-weight: 500; flex: 1; min-height: 40px;
  border: 1px solid transparent; border-radius: 0;
  background: transparent; color: var(--ink); padding: 0.3rem 0.5rem;
}
.namefield:hover { border-color: var(--line); }
.namefield:focus { border-color: var(--line-firm); background: var(--surface); }
button.icon {
  min-height: 0; padding: 0.35rem 0.6rem; line-height: 1;
  background: transparent; color: var(--ink-2); border-color: var(--line-firm);
}
button.icon:hover { background: var(--surface-2); color: var(--ink); }

.arrows { display: flex; gap: 0.3rem; }
.arrows form { flex: 1; margin: 0; }
.arrows button { width: 100%; padding: 0.3rem 0; }

/* Intro and outro: pinned, skippable, never movable — they are clips of their own, not
   scenes with a position (Dean, 2026-08-17). */
/* Centres the placeholder glyph only. Deliberately NOT the `background` shorthand: this rule
   outranks .thumb, and the shorthand resets background-size to auto, which silently un-covered
   the card thumbnails the moment they started carrying a photo or a logo. */
.scene.card .thumb {
  display: flex; align-items: center; justify-content: center;
  color: var(--ink-3); font-size: 1.1rem;
}
.scene.card .scene-name { font-weight: 500; }

/* Share page sign-off. The one page a VENDOR opens, so it carries the lockup rather than the bare
   mark — a stranger needs the name, and there is room for it here where the masthead has none.
   The caption beside it takes --ink-2 and sits back rather than competing with the video above.
   The mark itself does NOT: it is `fill="currentColor"` inside an image element, which is a
   separate document, so it renders at that document's initial black and inherits nothing. (This
   comment used to claim the opposite. Dark mode proved it wrong — see the invert beside .mark.)
   Do not write that element's tag out in prose anywhere in this file: the design hook matches by
   regex and does not skip comments, so naming it raises a broken-image finding on the sentence. */
.madeby { display: flex; align-items: center; gap: 0.5rem; color: var(--ink-2); }
.madeby img { height: 18px; width: auto; display: block; }
/* Share page credit: the ReelView mark, right-aligned, closing the share body. In flow (not
   fixed) because /share still carries the site footer — a viewport-fixed logo would sit on top
   of the social icons in its bottom-right corner. */
.share-credit { display: flex; justify-content: flex-end; margin: 1.25rem 0 0; opacity: 0.6; }
.share-credit img { height: 18px; width: auto; display: block; }
:root[data-theme=dark] .share-credit img { filter: invert(1); }
:root[data-theme=dark] .share-credit { opacity: 0.5; }
/* In-field validation (static/validation.js). A floating message under an invalid field carries
   the error, plus a shake on the field; neither the field nor the message carries a red border
   (the message is red text on a 10%-transparent (90% opaque) --surface wash, sized to its own
   words via width: max-content, with a soft shadow — Dean, 2026-08-10). The message is a
   sibling of the field, absolutely positioned with its left/top set inline from the field's
   live geometry — the field is never wrapped (several fields are direct grid children targeted
   by `> input[name=…]`, and wrapping would sever those selectors), so nobody's layout is
   resettled. */
form[data-validate] .field-msg {
  position: absolute; top: calc(100% + 0.25rem);
  margin: 0; padding: 0.3rem 0.55rem;
  width: max-content;
  background: color-mix(in oklch, var(--surface) 90%, transparent); border: 1px solid var(--bad); border-radius: 0;
  color: var(--bad); font-size: 0.85rem; line-height: 1.3; z-index: 30;
  box-shadow: 0 0.35rem 0.9rem rgba(0, 0, 0, 0.12);
}
form[data-validate] .field-msg[hidden] { display: none; }

/* Error-state shake (reference: transitions.dev error-state-shake). When a field is marked
   invalid, a short horizontal shake makes the error felt without words — the user knows which
   field failed. transform-only, so it never touches layout. Per-segment easing:
   each keyframe stop carries its own timing function so every leg of the shake follows the
   product's --ease (cubic-bezier(.22 1 .36 1)) like the reference. Recompute the cumulative
   %-stops if any leg duration changes (80,60,80,60 → 280ms total: 28.57/57.14/78.57%). */
@keyframes field-shake {
  0%     { transform: translateX(0);                        animation-timing-function: var(--ease); }
  28.57% { transform: translateX(6px);                      animation-timing-function: var(--ease); }
  57.14% { transform: translateX(calc(6px * -1));           animation-timing-function: var(--ease); }
  78.57% { transform: translateX(4px);                      animation-timing-function: var(--ease); }
  100%   { transform: translateX(0); }
}
.field-shake { animation: field-shake 280ms linear; }
@media (prefers-reduced-motion: reduce) {
  .field-shake { animation: none !important; transform: none !important; }
}

/* --- ops wallboard (/admin/status) ---------------------------------------
   Dark in BOTH themes, deliberately, and dark for the whole page rather than just the tiles.
   DESIGN.md's rule is that theme is per-surface, not global — the same reason every review
   screen is dark around video. This board is glanced at from across a room and often left
   open on a second screen; a full white field at that size is a lamp. Two consequences worth
   stating: the tokens are overridden on `body`, not by setting data-theme, so the staff
   theme toggle is neither read nor changed by being here (it stays whatever the person
   picked, and the board ignores it); and because they are set on `body` they win over
   `:root[data-theme=...]` by inheritance rather than by specificity, so neither theme can
   leak a light surface back in. */
body:has(.wall) {
  color-scheme: dark;
  --bg:        oklch(0.15 0.006 70);
  --surface:   oklch(0.19 0.006 70);
  --surface-2: oklch(0.24 0.007 70);
  --line:      oklch(0.31 0.006 70);
  --line-firm: oklch(0.60 0.008 70);
  --ink:       oklch(0.95 0.004 70);
  --ink-2:     oklch(0.78 0.006 70);
  --ink-3:     oklch(0.70 0.006 70);
  --accent-ink: oklch(0.72 0.13 90.2);
  --ok:        oklch(0.74 0.14 150);
  --warn:      oklch(0.78 0.13 62);
  --bad:       oklch(0.68 0.17 27);
  --bad-bg:    oklch(0.28 0.06 27);
  background: var(--bg);
}
/* The one staff surface with no text column to protect: it is a grid of tiles, and every
   pixel of width buys another readable number. */
.wrap.bleed { max-width: none; padding: 0.9rem 0.9rem 1.4rem; }

.wall { display: grid; grid-template-columns: repeat(12, 1fr); gap: 14px;
        font-variant-numeric: tabular-nums; }
.wall .tile {
  background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
  padding: 18px 20px; position: relative; display: flex; flex-direction: column;
  /* min-height:0 so a tile holding an SVG that flexes can actually shrink inside the row —
     without it the chart sets a floor and drags the whole row taller. */
  min-height: 0; grid-column: span 3;
}
.wall .tile h2 { font-size: 0.95rem; font-weight: 600; color: var(--ink-2); margin: 0 0 12px; }
/* The entire point of the board. A tile states its own verdict, so a healthy board is quiet
   and a broken one is legible before a single number has been read. */
.wall .tile.alert { border-color: var(--bad); background: var(--bad-bg); }
.wall .tile.alert::after {
  content: "!"; position: absolute; right: -11px; bottom: -11px;
  width: 34px; height: 34px; border-radius: 50%;
  background: var(--bad); color: #fff; font-weight: 800; font-size: 1.25rem;
  display: grid; place-items: center;
  /* Ringed in the page background so the badge reads as sitting ON the tile edge rather than
     as a shape that happens to overlap the tile beside it. */
  box-shadow: 0 0 0 3px var(--bg);
}
.wall .big { font-size: 4.4rem; line-height: 0.92; font-weight: 800; letter-spacing: -0.03em; }
.wall .big.sm { font-size: 2.9rem; }
.wall .unit { font-size: 0.42em; font-weight: 700; }
.wall .cap { font-size: 1.05rem; color: var(--ink-2); margin-top: 6px; }
.wall .sub { font-size: 0.82rem; color: var(--ink-3); margin-top: 2px; }
.wall .split { display: flex; gap: 26px; align-items: flex-end; flex-wrap: wrap; }

.wall .meters { display: flex; flex-direction: column; gap: 13px; }
.wall .mrow { display: flex; justify-content: space-between; align-items: baseline;
              font-size: 0.92rem; margin-bottom: 5px; }
.wall .mrow b { font-size: 1.25rem; font-weight: 700; }
.wall .mrow span { color: var(--ink-3); }
.wall .track { height: 9px; border-radius: 5px; background: var(--surface-2); overflow: hidden; }
.wall .fill { height: 100%; border-radius: 5px; background: var(--ok); }
.wall .fill.w { background: var(--warn); }
.wall .fill.b { background: var(--bad); }

.wall .gauge { width: 150px; flex: none; }
.wall .spark { width: 100%; flex: 1; min-height: 90px; }
.wall .spark polyline { fill: none; stroke: var(--accent); stroke-width: 3;
                        stroke-linejoin: round; stroke-linecap: round;
                        /* preserveAspectRatio=none stretches the line's WIDTH with the tile,
                           which would thin the stroke to a hair on a wide screen. */
                        vector-effect: non-scaling-stroke; }

.wall table { width: 100%; border-collapse: collapse; font-size: 0.95rem; }
.wall th { text-align: left; font-weight: 500; color: var(--ink-3); font-size: 0.8rem;
           padding-bottom: 7px; border-bottom: 1px solid var(--line); }
.wall td { padding: 7px 0; border-bottom: 1px solid var(--line); }
.wall tr:last-child td { border-bottom: 0; }
.wall .n { text-align: right; font-weight: 700; }
.wall .mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.82rem;
              color: var(--ink-3); }

/* Tiles are sized in twelfths for a wide screen; below that the spans stop meaning anything
   and the inline grid-column has to be overridden, hence !important — the span is written on
   the element by `_tile`, and a wallboard on a laptop is still worth reading. */
@media (max-width: 1200px) { .wall .tile { grid-column: span 6 !important; } }
@media (max-width: 760px)  { .wall .tile { grid-column: span 12 !important; }
                             .wall .big { font-size: 3.4rem; } }

/* --- papered pages: a suburb behind the page, kept quiet ---------------------------------------
   /terms and /privacy carry a photographic background. It is fixed to the viewport, so it does
   not scroll with eight thousand pixels of legal text — the page slides over it.

   The reading column gets its OWN ground rather than sitting on the photo, and that is the whole
   design rather than a nicety. Measured: `--ink` against the raw image has a worst-case contrast
   ratio of 1.00 — somewhere in frame the text colour and the photo are the SAME colour, so a
   paragraph would vanish. Veiling the photo hard enough to fix that (0.80 scrim -> 7.74) leaves
   20% of a photograph, which is a waste of one. A solid panel removes the constraint instead of
   fighting it: text is on `--surface`, and the photo shows in the margins, where it can be seen
   without having to be legible under anything.

   A fixed ::before rather than `background-attachment: fixed` on the body: there is no <body>
   tag in PAGE (the shell is tag-soup, valid but with nothing to hook), and `attachment: fixed`
   is the property iOS Safari gets wrong. A fixed pseudo-element covers the viewport wherever its
   host sits in the document.

   TWO hooks, because the photo and the panel are two decisions and /gallery wanted only one of
   them (Dean, 2026-08-28: "ship b"). `.papered` is the picture alone; `.paper` is the picture
   plus the reading panel, and it carries `.papered`'s rules by sitting in the same selector
   lists below rather than by an HTML change at its five call sites.

   The gallery takes `.papered`: its content is three video players, which are their own dark
   ground and need no sheet under them, and a white panel over the photograph hides the thing the
   page exists to show. The paragraph above does NOT apply to it — that 1.00 worst case was
   measured against the RAW image, and what a caption actually sits on is the 0.2/0.1 composite
   over `--bg`, which never gets near it. It is not free either: see the `--ink-2` step below.

   `.papered` goes ON `.wrap` itself (page(papered=True)), never on a div wrapped around the body,
   and that is load-bearing rather than tidy. This stylesheet drives the page's whole vertical
   rhythm off DIRECT-child selectors — `.wrap > section`, `.wrap > section.costs`,
   `.wrap > section.close`, `.wrap > section > p`, `.wrap > p:not(.cta)`. One extra div between
   `.wrap` and the content stops every one of them matching, and the failure is silent: the page
   still renders, the credit packs just lose their section margins and the intro paragraph loses
   its reading measure. The first mock of this change wrapped a div and shipped exactly that. */
.paper { position: relative; background: var(--surface); border-radius: 14px;
         /* No shadow (Dean, 2026-08-25; DESIGN.md's Flat Ground Rule — depth is a change of
            tone, never a shadow; .paper was the file's one genuinely-elevation shadow). If the
            sheet reads weakly against the photographic background, the answer is a hairline or
            a tone step, not the shadow back. The 14px radius stays: off the scale, but squaring
            it is a visible 5-page change made for tidiness, not a reason. */
         padding: 1.75rem 1.75rem 2.25rem; }
/* The image lives INSIDE the min-width query, not behind a `display: none` override, and that
   is the difference between not painting it and not fetching it. Measured: with the url() in the
   base rule and `display: none` applied at 375px, Chrome requested legal-bg.jpg anyway. A
   declaration that never matches is the only reliable way to not download something. */
@media (min-width: 769px) {
  .paper::before, .papered::before {
    content: ""; position: fixed; inset: 0; z-index: -1;
    background: url("brand/paper-bg.jpg") center / cover no-repeat;
    opacity: 0.2;
  }
  /* /about's own picture (spec FA §5b), same geometry as paper-bg.jpg (1400x2100) so the shared
     opacity/reduced-motion rules below need no change. The override has to live in THIS query,
     not behind a `display: none` at narrower widths, for the same reason paper-bg.jpg does -- a
     base-rule url() with display:none still gets fetched on mobile.
     `animation: none` is its own override, not inherited: Dean, 2026-08-30, "the about bg
     doesn't need parallax" -- without it this would silently inherit legal-drift from the
     grouped selector below, since only the image is scoped to .paper-tonka. `.papered.paper-tonka`,
     not `.paper-tonka` alone: the @supports block below sets the animation on `.papered::before`
     at EQUAL specificity and comes later in source order, so a same-specificity override placed
     earlier in the file loses to it silently -- confirmed live (animationName stayed legal-drift
     until the specificity bump). Two classes beats one regardless of position.

     The host class moved `.paper` -> `.papered` on 2026-08-30 when About dropped the reading panel
     (Dean: same as gallery). If it moves again this selector must move with it, or the image
     silently reverts to paper-bg.jpg AND the parallax silently returns -- two regressions from one
     stale selector, neither of which throws. */
  .papered.paper-tonka::before { background-image: url("brand/tonka_sm-bg.jpg"); animation: none; }
  /* The picture pans DOWN as the page scrolls, and by the end you have seen all of it (Dean,
     2026-08-22).

     The direction follows the image, not a preference. `cover` crops on whichever axis the photo
     has to spare, and the axis is decided by comparing aspect ratios. The first background was
     16:9 in a viewport taller than 16:9, so it was cut left and right and only a sideways pan
     could reveal it. This one is 2:3 (1400x2086): at 1280x900 it scales to width and stands
     1907px tall, leaving ~1000px of vertical excess and nothing missing off the sides. Swap the
     picture for a landscape one and this keyframe must go back to `-x`, or it will animate an
     axis with no travel on it and appear to do nothing.

     Panning the BACKGROUND rather than translating the layer is what keeps `inset: 0` and costs
     no zoom: the image moves inside a box that stays exactly viewport-sized, so the crop is only
     ever the honest one `cover` imposes, and there is no layer that can walk off its own edge.

     `0%` to `100%` is top edge to bottom edge — a background-position percentage aligns that
     point of the image with the same point of the box.

     Known and accepted: on a window NARROWER in aspect than the photo (below ~0.67, a tall thin
     desktop window) `cover` crops horizontally instead and this animates an axis with no travel.
     The background is then simply still, which is what it was before any of this. */
  @supports (animation-timeline: scroll()) {
    @media (prefers-reduced-motion: no-preference) {
      .paper::before, .papered::before {
        animation: legal-drift linear both;
        animation-timeline: scroll(root block);
      }
    }
  }
}
/* 35%..65%, not 0%..100%: the full range moved ~1000px at a 1280x900 viewport and Dean
   read it as too much (2026-08-22). A third of the travel, and the cost is stated rather
   than hidden — the viewport covers ~47% of the image at any moment, so the full range
   showed all of it and this shows about 77%. Less movement is necessarily less picture. */
@keyframes legal-drift { from { background-position-y: 35%; } to { background-position-y: 65%; } }
/* Both values came down a step once the drift landed (Dean, 2026-08-22): 0.45/0.32 were judged
   on a STILL layer, and movement makes a background far easier to notice at the same opacity —
   the eye tracks motion before it tracks contrast. Dark came down again, 0.24 -> 0.16, on "can u
   make it darker" — and on a near-black ground opacity IS the darkening lever, since what shows
   through is `--bg` itself. Light is untouched at 0.34: it was never the one being judged, and
   the same move there would wash the photo out rather than darken it.

   Dark stays the lower of the two for the original reason: a daylight photo over a near-black
   ground turns its white roofs into bright patches beside a dim panel, so the same picture needs
   less of itself there than it does against a near-white one. */
:root[data-theme="dark"] .paper::before, :root[data-theme="dark"] .papered::before { opacity: 0.1; }
:root[data-theme="light"] .paper::before, :root[data-theme="light"] .papered::before { opacity: 0.2; }
/* A viewport-fixed layer moves relative to the page as you scroll — that IS the parallax, and it
   is the thing scroll-linked motion sensitivity reacts to. Pinning it to the document turns the
   effect off without removing the picture. */
@media (prefers-reduced-motion: reduce) {
  .paper::before, .papered::before { position: absolute; }
  /* Only here. `absolute` is the one state that needs a positioned host, and `.papered` IS
     `.wrap` — giving the page's main column a containing block in the normal path would change
     what every absolutely-positioned descendant resolves against, for no gain. `.paper` already
     carries `position: relative` for its own reasons and is unaffected either way. */
  .papered { position: relative; }
}

/* The price of no panel: on `.paper` the quiet greys sit on `--surface`, a known flat colour. On
   `.papered` they sit on the photo composite, whose darkest patch in LIGHT theme measures
   rgb(200,200,198) across the drift range — and `--ink-2` against that is 4.08:1, under AA for
   text this size. `--ink` and `--ink-3` both clear it; `--ink-2` is the only one that does not,
   which is why this is a token step and not a colour.

   Scoped to `.papered` and to light, because those are the two conditions that produce the
   failure: dark theme's worst case is 7.83:1 (the photo LIGHTENS a near-black ground, and
   `--ink-2` is bright there), and `.paper` has a solid sheet under it. Set on the container so
   every `var(--ink-2)` reader inside inherits it — .sub, .hint, .gnote and .cost-notes p are
   four separate rules today and the next one added would have been missed.

   0.42, not darker: it clears 4.5:1 on the worst patch while staying visibly quieter than `--ink`
   at 0.24, which is the hierarchy a caption depends on.

   `:not([data-theme=dark])` rather than a pair of rules that names dark's value too: THEME_HEAD
   stamps data-theme before first paint and there is no third state, so this is "light" — and a
   rule that restated `--ink-2: oklch(0.78 …)` for dark would be a second copy of a token, stale
   the day the real one moves. Not matching in dark is how dark keeps its own. */
:root:not([data-theme=dark]) .papered { --ink-2: oklch(0.42 0.008 70); }
/* Below the panel's own width the wrap fills the screen and there is no margin left to show a
   photograph in, so the picture is not merely hidden — it is never requested (see the min-width
   query above). The panel keeps its ground and loses its border, matching every other narrow
   screen in the app. */
@media (max-width: 768px) {
  .paper { background: none; border-radius: 0; padding: 0; box-shadow: none; }
}
