
/* ====================== tokens (design pack v1.1) ======================== */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; }
}

:root {
  --bg-base: #0A1420;
  --bg-surface-1: #101D2E;
  --bg-surface-2: #17293D;
  --bg-surface-3: #1F3348;
  --border-subtle: #1D2E42;
  --border-default: #2C4258;
  --text-primary: #EAF1F7;
  --text-secondary: #9FB0C0;
  --text-tertiary: #6C7E90;

  --brand-blue: #4A9BD8;
  --brand-blue-hover: #6BB0E3;
  --brand-blue-soft: #172E42;
  --brand-green: #5FB347;
  --brand-green-hover: #74C65C;
  --brand-green-soft: #16301A;
  /* Text painted ON a brand/danger fill (button hovers, filled chips). Theme-invariant:
     the fills stay saturated in both themes, so their text stays white in both. Section
     CSS must use this token, never a raw hex — selftest holds review.js to that. */
  --on-brand: #fff;
  --chain-gray: #8B96A3;
  --chain-gray-soft: #1B2836;
  --accent-yellow: #E8B92F;
  --accent-yellow-soft: #332809;
  --danger: #F0656A;
  --danger-soft: #3A1518;
  /* FB (owner, 2026-09-02): revision is the RED action, so reject needs a colour of its
     own or the two terminal-looking buttons read as the same decision. Violet is the
     only hue in this palette not already spoken for (green approve, red revision,
     amber pending, cyan stage) and it carries on both grounds. */
  --reject: #B07BD8;
  --reject-soft: #2C1B3D;
  --stage-cyan: #38B6B0;
  --stage-cyan-soft: #0F2E2C;

  --shadow-1: 0 1px 2px rgba(0,0,0,0.4);
  --shadow-2: 0 12px 32px rgba(0,0,0,0.5);
  --glow-blue: 0 0 0 1px rgba(74,155,216,0.25), 0 0 24px rgba(74,155,216,0.08);

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

  --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px;
  --space-5: 24px; --space-6: 32px; --space-7: 48px; --space-8: 64px;

  --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", "Segoe UI Variable", "Helvetica Neue", Arial, sans-serif;
  --font-mono: "JetBrains Mono", ui-monospace, "Cascadia Code", "SF Mono", Consolas, monospace;

  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --dur-micro: 120ms; --dur-standard: 180ms; --dur-overlay: 240ms;
}

:root[data-theme="light"] {
  --bg-base: #F4F7FA; --bg-surface-1: #FFFFFF; --bg-surface-2: #FFFFFF; --bg-surface-3: #FFFFFF;
  --border-subtle: #E4EAF0; --border-default: #D3DCE5;
  --text-primary: #10202E; --text-secondary: #4C5C6C; --text-tertiary: #7A8A9A;
  --brand-blue: #1E6FB4; --brand-blue-hover: #17588F; --brand-blue-soft: #E3F0FB;
  --brand-green: #3F9142; --brand-green-hover: #337638; --brand-green-soft: #E4F3E1;
  --chain-gray: #667585; --chain-gray-soft: #EBEEF2;
  --accent-yellow: #9A7209; --accent-yellow-soft: #FBF1D6;
  --danger: #C22B30; --danger-soft: #FBE6E6;
  --reject: #7A3E9E; --reject-soft: #F2E9F9;
  --stage-cyan: #1A7E82; --stage-cyan-soft: #E1F3F2;
  --shadow-1: 0 1px 2px rgba(16,32,46,0.06); --shadow-2: 0 8px 24px rgba(16,32,46,0.12);
  --glow-blue: 0 0 0 1px rgba(30,111,180,0.15);
}
/* Explicit dark override wins in both directions (pack "CSS token export"). */
:root[data-theme="dark"] {
  --bg-base: #0A1420; --bg-surface-1: #101D2E; --bg-surface-2: #17293D; --bg-surface-3: #1F3348;
  --border-subtle: #1D2E42; --border-default: #2C4258;
  --text-primary: #EAF1F7; --text-secondary: #9FB0C0; --text-tertiary: #6C7E90;
  --brand-blue: #4A9BD8; --brand-blue-hover: #6BB0E3; --brand-blue-soft: #172E42;
  --brand-green: #5FB347; --brand-green-hover: #74C65C; --brand-green-soft: #16301A;
  --chain-gray: #8B96A3; --chain-gray-soft: #1B2836;
  --accent-yellow: #E8B92F; --accent-yellow-soft: #332809;
  --danger: #F0656A; --danger-soft: #3A1518;
  --reject: #B07BD8; --reject-soft: #2C1B3D;
  --stage-cyan: #38B6B0; --stage-cyan-soft: #0F2E2C;
  --shadow-1: 0 1px 2px rgba(0,0,0,0.4); --shadow-2: 0 12px 32px rgba(0,0,0,0.5);
  --glow-blue: 0 0 0 1px rgba(74,155,216,0.25), 0 0 24px rgba(74,155,216,0.08);
}

/* ============================== base ==================================== */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
  background: var(--bg-base);
  color: var(--text-primary);
  font-family: var(--font-ui);
  font-size: 15px;
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
}
h1, h2, h3, h4 { text-wrap: balance; font-weight: 650; letter-spacing: -0.01em; margin: 0; }
p { margin: 0; }
a { color: var(--brand-blue); text-decoration: none; }
a:hover { text-decoration: underline; }
/* "The codes are the content" - every system-generated string gets the mono voice. */
code, .mono, .sid, .tc-badge { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
:focus-visible { outline: 2px solid var(--brand-blue); outline-offset: 2px; }
.hide { display: none !important; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }

/* ====================== portal shell (pack section 10) ==================== */
/* Manager = dense left rail. Portal = lighter-touch TOP TABS, seat-gated. Both
   share the top-bar pattern: 28px brand mark left (anchor to section home),
   section title, right-aligned identity + bell. */
/* minmax(0, 1fr) is load-bearing, not decoration. With no explicit column the single
   implicit track is an auto track, which sizes to the MAX-CONTENT of its items - so
   once enough sections are registered the top bar's natural width (brand + every tab +
   identity) pushed the track past the viewport and the WHOLE DOCUMENT scrolled
   sideways, instead of the tab strip scrolling inside itself. Measured at 1280px with
   eleven tabs: track 1469px in a 1265px container. Clamping the track to the container
   is what finally lets .ptabs use its own overflow-x. Standing rule: the page body
   never scrolls horizontally. */
.pshell { display: grid; grid-template-columns: 216px minmax(0, 1fr); min-height: 100vh; }

/* ---------------------- support view banner (impersonation) ----------------
   DESIGN-PACK.html 402-403 / 990 / 1008, applied to agency -> client. Signal-yellow
   rather than danger-red because LOOKING is a permanent state to work INSIDE, not an
   error, and the diagonal stripe is the one place stripes appear in this whole system, so
   it can never be mistaken for a normal banner.

   .rw IS THE SECOND STATE, and it is the one exception to that colour reasoning (owner,
   2026-08-03: "team leads can write, everyone else can read"). Once a team lead has
   enabled editing, this is no longer somewhere you merely work inside: an Approve from
   here publishes the client's video to their live networks within about a minute and
   cannot be taken back. So the stripe goes danger-red. Nothing else about the bar
   changes - same fixed height, same single line, same offsets - so switching states
   moves not one pixel of the page behind it.

   FIXED, and a fixed HEIGHT, on purpose. It has to stay on screen while a long section
   scrolls - forgetting you are looking at somebody else's account is the failure this
   bar exists to prevent - and a fixed height is what lets the two offsets below be
   exact instead of approximate. white-space: nowrap + overflow-x means it can never
   wrap to a second line at any width and silently break those offsets; on a narrow
   phone the bar scrolls inside ITSELF, which is the same answer this sheet gives every
   other over-wide thing. Only the two rules under body[data-support] move anything, and
   they are inert for every client, who never has the attribute. */
:root { --support-h: 30px; }

/* QUICK-REVIEW FIT (feedback #91). Both are OVERWRITTEN at runtime by review.js's
   fitSwipeStage(), which measures the real geometry rather than assuming any of it.
   The values here are only what the single frame before that measurement uses, and they
   are deliberately pessimistic - a first paint that is slightly too small is invisible,
   a first paint that is too large is the bug this ticket was filed about. They live in
   this sheet because it is the one place the token gate reads definitions from. */
:root { --rv-sw-max: calc(100dvh - 280px); --rv-sw-barh: 84px; }
.psupport {
  position: fixed; top: 0; left: 0; right: 0; z-index: 90;
  height: var(--support-h);
  display: flex; align-items: center; justify-content: center; gap: var(--space-2);
  padding: 0 var(--space-3);
  white-space: nowrap; overflow-x: auto; overflow-y: hidden;
  background: repeating-linear-gradient(135deg, var(--accent-yellow), var(--accent-yellow) 10px, #B08A17 10px, #B08A17 20px);
  color: #1A1400; font-size: 12px; font-weight: 700; line-height: 1;
}
.psupport b { font-weight: 800; }
/* WHAT YIELDS WHEN THE BAR RUNS OUT OF ROOM. Exactly one thing: the sentence. It is
   the only flexible child (min-width: 0 is what lets a nowrap span shrink at all inside
   a flex row) and it truncates from the right, where the elaboration lives - the state
   word, "editing" or "read-only", is the first thing in it and is never the part that
   goes. Everything else is flex-shrink: 0, so the time left and both buttons keep their
   full size at every width. This replaces "the bar scrolls inside itself", which was
   true and also put Stop editing / Exit off-screen on a phone: a control you have to
   discover a horizontal scrollbar to reach is not a control. Height is untouched, so
   neither state moves a single pixel of the page below. */
.psupport .msg { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.psupport .w, .psupport .sep, .psupport .left, .psupport .x { flex-shrink: 0; }
.psupport .sep { opacity: 0.6; }
.psupport .left { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.psupport .x {
  font-family: var(--font-ui); font-size: 11px; font-weight: 700; line-height: 1;
  color: #1A1400; background: rgba(255,255,255,0.55); border: 1px solid rgba(26,20,0,0.35);
  border-radius: var(--radius-sm); padding: 4px 9px; cursor: pointer; flex-shrink: 0;
}
.psupport .x:hover { background: #FFFFFF; }
/* The one call to action in the bar. Solid rather than translucent so it reads as the
   button you press, not the third of three greys. */
.psupport .go { background: #1A1400; color: var(--accent-yellow); border-color: #1A1400; }
.psupport .go:hover { background: #000000; color: #FFFFFF; }
/* EDITING. Same geometry, danger-red stripe, light text - see the note above. */
.psupport.rw {
  background: repeating-linear-gradient(135deg, var(--danger), var(--danger) 10px, #8E1E22 10px, #8E1E22 20px);
  color: #FFF2F2;
}
.psupport.rw .x { color: #2A0508; background: rgba(255,255,255,0.82); border-color: rgba(0,0,0,0.35); }
.psupport.rw .x:hover { background: #FFFFFF; }
/* border-box is set globally above, so min-height: 100vh already accounts for this
   padding - the page does not gain 30px of scroll. */
body[data-support="1"] .pshell { padding-top: var(--support-h); }
body[data-support="1"] .pside { top: var(--support-h); height: calc(100vh - var(--support-h)); }

/* LEFT RAIL (owner, 2026-08-02: "can we move these section options to a panel on the
   left, like the admin side"). The design pack proposed top tabs for the portal and a
   left rail only for the Manager (DESIGN-PACK.html section 10); the owner's instruction
   supersedes that, and it is the better call here anyway — twelve sections never fit a
   top strip, which is why the strip had to scroll sideways. A rail lists all twelve at
   once, and it now matches the Manager so one person moving between the two surfaces
   reads the same shape. Everything else in the pack is unchanged. */
.pside {
  display: flex; flex-direction: column; min-height: 0;
  background: var(--bg-surface-1);
  border-right: 1px solid var(--border-subtle);
  position: sticky; top: 0; height: 100vh;
  min-width: 0;
}
/* The mark is fixed brand: never recoloured, never on busy imagery, clear space
   >= 1x its height. This is the REAL asset (/portal/app/mark.png), byte-identical to
   the Manager's, not a redrawn SVG. */
.pbrand {
  display: flex; align-items: center; gap: var(--space-2); flex-shrink: 0;
  color: var(--text-primary); padding: var(--space-4) var(--space-4) var(--space-3);
}
.pbrand:hover { text-decoration: none; }
.pbrand .mark { width: 32px; height: 32px; display: block; flex-shrink: 0; object-fit: contain; }
.pbrand-text { font-weight: 700; font-size: 14px; letter-spacing: -0.01em; line-height: 1.1; min-width: 0; }
.pbrand-text span { display: block; font-weight: 500; font-size: 9.5px; color: var(--text-tertiary); letter-spacing: 0.06em; text-transform: uppercase; margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* All twelve sections visible at once, stacked. The rail scrolls internally if a
   short viewport cannot hold them; the page body never scrolls sideways. */
.ptabs {
  flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 1px;
  overflow-y: auto; overflow-x: hidden; padding: 0 var(--space-2) var(--space-3);
  scrollbar-width: thin;
}
.ptabs::-webkit-scrollbar { width: 3px; }
.ptabs::-webkit-scrollbar-thumb { background: var(--border-default); border-radius: 3px; }
.ptab {
  /* The weight this tab takes when it is the active one, declared once and read
     by BOTH .ptab.active below and the .wlock reserve its label carries. On a
     phone .ptabs turns into a horizontally scrolling ROW of flex:0 0 auto tabs
     (see the 560px block), so a heavier active label really does push every tab
     to its right along - reserved, it cannot. */
  --wlock-off: 550; --wlock-w: 650;
  display: flex; align-items: center; gap: var(--space-2);
  padding: 8px var(--space-3); font-size: 13px; font-weight: var(--wlock-off);
  color: var(--text-secondary); background: none; border: 0; cursor: pointer;
  font-family: var(--font-ui); border-radius: var(--radius-sm);
  /* Reserved on the BASE state, recoloured on .active — selecting a section must not
     change the element's box (no layout shift). */
  border-left: 2px solid transparent; text-align: left; white-space: nowrap;
  overflow: hidden; text-overflow: ellipsis;
  transition: color var(--dur-micro) var(--ease-out), background var(--dur-micro) var(--ease-out);
}
.ptab:hover { color: var(--text-primary); background: var(--chain-gray-soft); text-decoration: none; }
.ptab.active { color: var(--brand-blue); background: var(--brand-blue-soft); border-left-color: var(--brand-blue); font-weight: var(--wlock-w); }
.ptab .tab-badge {
  margin-left: auto; background: var(--accent-yellow); color: var(--bg-base);
  font-family: var(--font-mono); font-size: 9.5px; font-weight: 700;
  padding: 1px 6px; border-radius: var(--radius-pill);
}
/* Pinned "Message Support" (FB#229, owner live: "pin it and make it look like that
   one channel button... green as well and when a message is in it turns red also
   with a number next to it" — the Manager sidebar's pinned Channels row). Sits
   OUTSIDE .ptabs so it never scrolls away with a long section list; kept the
   .ptab base look (padding, no underline, flex layout) plus a bordered-pill
   treatment matching the Manager's own reference button, not the flat look every
   other tab has, since the ask was specifically a colored BOX. Green at rest, red
   (.has-unread, toggled by pollMessages() in core-js.js from the same badge count
   it paints) when there is a real unread staff message waiting. */
.ptabs-pinned { flex-shrink: 0; padding: 0 var(--space-2); margin-bottom: var(--space-2); }
.ptab-pin {
  width: 100%; color: var(--brand-green); background: var(--brand-green-soft);
  border: 1px solid var(--brand-green); border-radius: var(--radius-sm);
}
.ptab-pin:hover { color: var(--brand-green-hover); background: var(--brand-green-soft); border-color: var(--brand-green-hover); }
.ptab-pin.active { color: var(--brand-green); background: var(--brand-green-soft); border-color: var(--brand-green); font-weight: var(--wlock-w); }
.ptab-pin.has-unread { color: var(--danger); background: var(--danger-soft); border-color: var(--danger); }
.ptab-pin.has-unread:hover, .ptab-pin.has-unread.active { color: var(--danger); background: var(--danger-soft); border-color: var(--danger); }
.ptab-pin .msg-badge { background: var(--danger); color: #fff; }
.ptab-pin .msg-badge.hidden { display: none; }

/* The relocated feedback pill (owner, 2026-08-28, live — see portal-app-core-js.js
   mountFeedback()). #fb-slot is a flex-shrink:0 row like .pident below it; #fb-btn
   itself is the SHARED component's own class (public/shared/ll-feedback.css), whose
   default is position:fixed bottom-right — overridden here, scoped to this slot only,
   so the Manager's own floating pill is untouched. Width:100% + justify-content:center
   makes it read as a rail row rather than a pill dropped into a box too big for it. */
#fb-slot { flex-shrink: 0; padding: 0 var(--space-2) var(--space-2); }
#fb-slot .fb-btn { position: static; width: 100%; justify-content: center; box-shadow: none; }
/* ALWAYS CLICKABLE (#521). While a window or the drawing overlay covers the page,
   keepFeedbackReachable() (portal-app-core-js.js) lifts this same node to <body> and pins it
   at the exact box it had in the rail - left/top/width/height are written from that
   measurement - above every overlay: modal backdrops are 100, the drawing overlay 400 and
   its toolbar 401. And it is never hidden: the shared component hides its pill while
   drawing, which in this portal left an empty hole in the rail and nothing to click. */
.fb-btn.fb-lifted { position: fixed; right: auto; bottom: auto; margin: 0; z-index: 405; justify-content: center; box-shadow: none; }
#fb-slot .fb-btn.hidden, .fb-btn.fb-lifted.hidden { display: inline-flex; }
@keyframes fbnudge { 0%, 100% { transform: translateX(-50%); } 25% { transform: translateX(calc(-50% - 6px)); } 75% { transform: translateX(calc(-50% + 6px)); } }
body .fb-bar.fb-nudge { animation: fbnudge 0.28s var(--ease-out); }
/* ONE ROW (#519, ML&LP at 1440x765: "what is wrong with the feedback annotations? its two
   rows now? should be one"). The shared toolbar is centred with left:50% +
   translateX(-50%), and a fixed box with left:50% and no width is shrink-to-fit against
   the HALF of the viewport to the right of that left edge - 720px on a 1440 screen. Ticket
   #458 gave it flex-wrap (so it could no longer overflow a phone) and #505 grew every
   control to a 44px hit box; together the row passed 720px and wrapped at every desktop
   size. max-content sizes it to its one-row width, the shared max-width (100vw - 24px)
   still caps it on a phone, where it wraps as #458 intended. The hint reserves the width
   of its longer sentence, so the controls do not slide when "Capturing the screen..."
   becomes "Draw on the screen, then press Enter". Canonical fix for the shared file is
   filed with the ticket; this rule is harmless once that lands. */
body .fb-bar { width: max-content; }
body .fb-bar .hint { display: inline-block; min-width: 34ch; }

/* Identity sits at the FOOT of the rail, as in the Manager. */
.pident {
  display: flex; align-items: center; gap: var(--space-2); flex-shrink: 0; position: relative;
  padding: var(--space-3) var(--space-3); border-top: 1px solid var(--border-subtle);
}
.pident-who { flex: 1; min-width: 0; }
.pident-who .nm { font-size: 12.5px; font-weight: 600; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pident-who .rl { font-size: 10.5px; color: var(--text-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pident .iconbtn {
  width: 30px; height: 30px; border-radius: var(--radius-sm); border: 1px solid transparent;
  background: transparent; color: var(--text-secondary); cursor: pointer;
  display: flex; align-items: center; justify-content: center; font-size: 14px; line-height: 1; flex-shrink: 0;
}
.pident .iconbtn:hover { background: var(--chain-gray-soft); color: var(--text-primary); }
.pident .iconbtn .bell-dot {
  position: absolute; top: 4px; right: 4px; width: 7px; height: 7px;
  border-radius: 50%; background: var(--accent-yellow);
}
.avatar {
  width: 30px; height: 30px; border-radius: 50%;
  background: var(--brand-blue-soft); color: var(--brand-blue);
  font-size: 10.5px; font-weight: 700;
  display: flex; align-items: center; justify-content: center; flex-shrink: 0;
  border: 1px solid var(--border-subtle); cursor: pointer;
}
/* Opens UPWARD out of the rail foot. */
.pmenu {
  position: absolute; bottom: 52px; left: var(--space-3); min-width: 232px; z-index: 60;
  background: var(--bg-surface-3); border: 1px solid var(--border-default);
  border-radius: var(--radius-md); box-shadow: var(--shadow-2); padding: var(--space-2);
}
.pmenu .who { padding: 6px 8px 8px; border-bottom: 1px solid var(--border-subtle); margin-bottom: var(--space-1); }
.pmenu .who .nm { font-size: 13px; font-weight: 650; }
.pmenu .who .em { font-size: 11.5px; color: var(--text-tertiary); word-break: break-all; }
.pmenu a, .pmenu button {
  display: flex; align-items: center; justify-content: space-between; gap: var(--space-2);
  width: 100%; text-align: left; padding: 7px 8px; font-size: 12.5px;
  color: var(--text-secondary); background: none; border: 0; border-radius: var(--radius-sm);
  cursor: pointer; font-family: var(--font-ui);
}
.pmenu a:hover, .pmenu button:hover { background: var(--chain-gray-soft); color: var(--text-primary); text-decoration: none; }

.pmain { overflow-y: auto; padding: var(--space-5); min-width: 0; }
.pwrap { max-width: 1180px; margin: 0 auto; }
/* THE BARE ROUTE (portal-app.js bareShellHtml, /portal/app/swipe). No .pshell grid,
   no sidebar column - this is the whole page, mobile-first by default rather than
   a desktop layout squeezed down at a breakpoint. */
/* SAFE AREA. This route is the one that declares viewport-fit=cover (portal-app.js
   bareShellHtml), which tells the browser to lay the page out UNDER the notch and under
   the home indicator — and it did so without ever padding for either, so on a notched
   phone the bottom of the quick-review card could sit beneath the indicator bar. env()
   resolves to 0 wherever there is no inset and on every browser that does not implement
   it, so this is inert everywhere else. max(), not a sum: the inset REPLACES the normal
   padding when it is the larger of the two, rather than stacking on top of it.
   fitSwipeStage() reads this back through getComputedStyle(sc).paddingBottom, so the
   measured stage height follows the inset automatically with no constant to keep. */
.pbare {
  min-height: 100vh; min-height: 100dvh; box-sizing: border-box;
  padding: var(--space-3);
  padding-top: max(var(--space-3), env(safe-area-inset-top));
  padding-bottom: max(var(--space-3), env(safe-area-inset-bottom));
  padding-left: max(var(--space-3), env(safe-area-inset-left));
  padding-right: max(var(--space-3), env(safe-area-inset-right));
}
.pbare .pview { min-height: 0; }
/* Space is RESERVED before any section mounts, so the first paint is the final
   geometry (standing rule: no layout shift on load). */
.pview { min-height: 62vh; }
.view-head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); margin-bottom: var(--space-4); flex-wrap: wrap; }
.view-head h2 { font-size: 20px; }
.view-head .sub { color: var(--text-tertiary); font-size: 12.5px; margin-top: 2px; max-width: 68ch; }
.skel { border-radius: var(--radius-sm); background: var(--chain-gray-soft); height: 14px; margin-bottom: var(--space-2); }
.skel.card-h { height: 96px; margin-bottom: var(--space-3); }
.skel.row-h { height: 42px; }

/* Narrow: the rail collapses to an icon-free compact column rather than disappearing,
   so a client on a tablet still reaches every section without a hamburger. */
@media (max-width: 900px) {
  .pshell { grid-template-columns: 168px minmax(0, 1fr); }
  .pbrand { padding: var(--space-3) var(--space-3) var(--space-2); }
  .pbrand-text { display: none; }
  .ptab { font-size: 12px; padding: 7px var(--space-2); }
  .pmain { padding: var(--space-4) var(--space-3); }
}

/* PHONE: the rail comes OUT OF THE COLUMN ENTIRELY.
   168px of a 361px screen left a 169px content pane, and every content-heavy section
   then scrolled sideways INSIDE .pmain — the document did not scroll horizontally, so
   the letter of "the page body never scrolls sideways" was met while the spirit was
   not. Measured at 361x800 before this rule: .pside 168, .pview 169, .pmain
   scrollWidth 608 in a 193px box; Team pushed 579 nodes past the edge, Branding 154.
   The rail becomes a top strip that scrolls inside itself — the same shape the
   pre-rail .ptopbar was verified at 361px with — and .pview gets the full width back.
   Verified after: .pview ~329px at 361px, 0 nodes past the edge on all 11 sections. */
@media (max-width: 560px) {
  .pshell { grid-template-columns: minmax(0, 1fr); }
  .pside {
    position: sticky; top: 0; height: auto; min-height: 0; z-index: 40;
    flex-direction: row; align-items: center; flex-wrap: wrap;
    border-right: 0; border-bottom: 1px solid var(--border-subtle);
    padding: 0 var(--space-2);
  }
  .pbrand { padding: var(--space-2) 0; }
  .ptabs {
    order: 3; flex: 1 0 100%; min-height: 0;
    flex-direction: row; gap: var(--space-1);
    overflow-x: auto; overflow-y: hidden;
    padding: 0 0 var(--space-2);
  }
  .ptab {
    flex: 0 0 auto; font-size: 12px; padding: 6px var(--space-2);
    border-left: 0; border-bottom: 2px solid transparent;
  }
  .ptab.active { border-left-color: transparent; border-bottom-color: var(--brand-blue); }
  /* Reserved on the base state above, so making a tab active still changes no box. */
  .pident { border-top: 0; padding: var(--space-2) 0 var(--space-2) var(--space-2); }
  .pident-who { display: none; }
  .pmenu { bottom: auto; top: 48px; left: auto; right: 0; }
  .pmain { padding: var(--space-3) var(--space-3); }
  /* The rail is a TOP STRIP down here, not a full-height column, so the support offset
     only moves where it sticks - it must not restore the 100vh height the rule above
     deliberately gave up. Without this the desktop rule wins on specificity and the
     strip grows to fill the screen. */
  body[data-support="1"] .pside { top: var(--support-h); height: auto; }
  /* On a phone the elaboration goes first, so the client name keeps as many of its
     characters as possible before the ellipsis takes them. Nothing load-bearing lives
     in .tail - state, client, time left and both buttons are all outside it. */
  .psupport { gap: 6px; padding: 0 var(--space-2); justify-content: flex-start; }
  .psupport .tail { display: none; }
  /* .pside is a wrapping row down here; #fb-slot has no more claim on row 1 than
     .pident does, so it is given its own explicit line, after the tabs strip and
     before identity, rather than left to fight .pbrand for space on row 1. */
  #fb-slot { order: 4; flex: 1 0 100%; padding: 0 var(--space-2) var(--space-2); }
}

/* =========================== cards & layout ============================= */
.card {
  background: var(--bg-surface-1);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-md);
  padding: var(--space-5);
  box-shadow: var(--shadow-1);
}
.card.tight { padding: var(--space-4); }
.grid { display: grid; gap: var(--space-4); }
/* minmax(0, 1fr) everywhere, never a bare 1fr: a 1fr track's implicit minimum is
   MIN-CONTENT, so one wide child (a table, a long mono id) sizes the track and escapes
   the page instead of scrolling inside its own .table-wrap. Same reasoning as .pshell. */
.g2 { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
.g3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.g4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.gauto { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
@media (max-width: 900px) { .g2, .g3, .g4 { grid-template-columns: minmax(0, 1fr); } }
.row { display: flex; align-items: center; gap: var(--space-3); }
.row.between { justify-content: space-between; }
.stack { display: flex; flex-direction: column; gap: var(--space-3); }
.muted { color: var(--text-secondary); font-size: 13px; }
.tiny { color: var(--text-tertiary); font-size: 11.5px; }
hr.sep { border: 0; border-top: 1px solid var(--border-subtle); margin: var(--space-4) 0; }

/* ============ status chips (exact pack mapping, 11 states) ============== */
.chip { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; font-weight: 650; padding: 3px 9px 3px 7px; border-radius: var(--radius-pill); white-space: nowrap; }
.chip .dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; background: currentColor; }
.chip.neutral, .chip.sequencing, .chip.pending { background: var(--chain-gray-soft); color: var(--text-secondary); }
.chip.open, .chip.info { background: var(--brand-blue-soft); color: var(--brand-blue); }
.chip.progress, .chip.in-progress, .chip.revision, .chip.warn { background: var(--accent-yellow-soft); color: var(--accent-yellow); }
.chip.qc, .chip.review { background: var(--stage-cyan-soft); color: var(--stage-cyan); }
.chip.approved, .chip.ok { background: var(--brand-green-soft); color: var(--brand-green); }
.chip.rejected, .chip.bad { background: var(--danger-soft); color: var(--danger); }
.chip.completed { background: var(--chain-gray-soft); color: var(--text-tertiary); }
.chip.hold { background: transparent; color: var(--text-tertiary); border: 1px dashed var(--border-default); }
.chip.deleted { background: transparent; color: var(--text-tertiary); text-decoration: line-through; opacity: 0.6; border: 1px solid var(--border-subtle); }

/* ============================== buttons ================================= */
.btn {
  font-family: var(--font-ui); font-size: 13px; font-weight: 600;
  padding: 7px 13px; border-radius: var(--radius-sm); border: 1px solid transparent;
  cursor: pointer; display: inline-flex; align-items: center; gap: 6px;
  background: transparent; color: var(--text-primary);
  transition: all var(--dur-micro) var(--ease-out);
}
.btn-primary { background: var(--brand-blue); color: #fff; }
.btn-primary:hover { background: var(--brand-blue-hover); }
.btn-secondary { background: var(--bg-surface-2); color: var(--text-primary); border-color: var(--border-default); }
.btn-secondary:hover { border-color: var(--brand-blue); }
.btn-ghost { color: var(--text-secondary); }
.btn-ghost:hover { background: var(--chain-gray-soft); color: var(--text-primary); }
.btn-danger { color: var(--danger); border-color: var(--danger); }
.btn-danger:hover { background: var(--danger-soft); }
/* Reject is rarer and more final than a revision, and since the owner asked for revision
   to be the red one it cannot also be red. Same outline grammar as .btn-danger so the
   decision bar still reads as one set of controls. */
.btn-reject { color: var(--reject); border-color: var(--reject); }
.btn-reject:hover { background: var(--reject-soft); }
/* Approve gets its own green - the single most-clicked action in the portal. */
.btn-approve { background: var(--brand-green); color: #fff; }
.btn-approve:hover { background: var(--brand-green-hover); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn .key { opacity: 0.6; font-weight: 400; font-family: var(--font-mono); font-size: 11px; }
.btn-row { display: flex; gap: var(--space-2); flex-wrap: wrap; align-items: center; }

/* =============================== forms ================================== */
.field { display: flex; flex-direction: column; gap: 5px; }
.field label { font-size: 12px; font-weight: 600; color: var(--text-secondary); }
input[type="text"], input[type="email"], input[type="password"], input[type="search"],
input[type="number"], input[type="date"], select, textarea {
  font-family: var(--font-ui); font-size: 13.5px; color: var(--text-primary);
  background: var(--bg-surface-2); border: 1px solid var(--border-default);
  border-radius: var(--radius-sm); padding: 9px 10px; width: 100%;
}
select { cursor: pointer; }
textarea { resize: vertical; min-height: 72px; line-height: 1.5; }
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--brand-blue); box-shadow: var(--glow-blue); }
input::placeholder, textarea::placeholder { color: var(--text-tertiary); }
.field .hint { font-size: 11px; color: var(--text-tertiary); }
.field .hint.err { color: var(--danger); }

/* =============================== tables ================================= */
.table-wrap { overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--bg-surface-1); }
table { border-collapse: collapse; width: 100%; font-size: 13px; min-width: 560px; }
th { text-align: left; font-size: 10.5px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-tertiary); padding: 9px 14px; background: var(--bg-surface-1); border-bottom: 1px solid var(--border-default); position: sticky; top: 0; z-index: 1; }
td { padding: 10px 14px; border-bottom: 1px solid var(--border-subtle); color: var(--text-secondary); vertical-align: middle; }
tr:last-child td { border-bottom: none; }
td.strong { color: var(--text-primary); font-weight: 600; }
td .mono { font-size: 12.5px; }
td.actions { text-align: right; white-space: nowrap; }

/* ====================== modals (native dialogs are BANNED) ============== */
.modal-backdrop {
  position: fixed; inset: 0; background: rgba(6,12,20,0.55); z-index: 100;
  display: flex; align-items: center; justify-content: center; padding: var(--space-4);
  animation: fadein var(--dur-overlay) var(--ease-out);
}
.modal {
  position: relative; background: var(--bg-surface-2); border: 1px solid var(--border-default);
  border-radius: var(--radius-lg); box-shadow: var(--shadow-2);
  width: 100%; --modal-max: 420px; max-width: var(--modal-max); padding: var(--space-5);
  max-height: 90vh; overflow-y: auto;
  animation: popin var(--dur-overlay) var(--ease-out);
}
.modal.wide { --modal-max: 860px; }
/* A WINDOW NEVER OPENS UNDER THE FEEDBACK BUTTON (#521 repair). While any window is open the
   button is lifted above it, pinned at its rail spot (keepFeedbackReachable, core-js), and the
   body carries fb-lifted-on plus that spot, measured: --fb-safe-left is the button's right
   edge + 8px, --fb-vw the width a window centres in. In the rail layout a window whose centred
   box would reach into that column is moved right JUST far enough to clear it - margin-left
   M = 2 x safe-left - vw + the window's own max width, floored at 0 (a window that already
   clears the rail is not moved at all) and capped at safe-left - 16px (no room to centre: it
   starts at safe-left and narrows, which a scroll box may do). The phone rule is in the 560px
   block below. The :root values are inert fallbacks - nothing reads them unless fb-lifted-on
   is on, and the shell sets the measured ones on <body> in the same step. */
:root { --fb-safe-left: 0px; --fb-safe-top: 0px; --fb-vw: 100vw; }
body.fb-lifted-on .modal {
  margin-left: clamp(0px, calc(2 * var(--fb-safe-left) - var(--fb-vw) + var(--modal-max)), calc(var(--fb-safe-left) - var(--space-4)));
}
.modal h4 { font-size: 15px; margin-bottom: var(--space-1); padding-right: 24px; }
.modal .desc { font-size: 12.5px; color: var(--text-secondary); margin-bottom: var(--space-4); }
.modal .actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-4); flex-wrap: wrap; }
/* Every popup carries an X (standing rule). */
.modal-x {
  position: absolute; top: 10px; right: 12px; background: transparent; border: none;
  color: var(--text-tertiary); font-size: 15px; cursor: pointer; padding: 4px 6px;
  border-radius: var(--radius-sm); line-height: 1;
}
.modal-x:hover { background: var(--chain-gray-soft); color: var(--text-primary); }
@keyframes fadein { from { opacity: 0; } to { opacity: 1; } }
@keyframes popin { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
@keyframes sheetup { from { transform: translateY(100%); } to { transform: none; } }

/* ================== ON A PHONE, A MODAL IS A BOTTOM SHEET ===============
   Owner-approved mobile packet: "bottom sheets, not centered modals, for every
   confirmation/edit prompt — full width, drag handle, thumb's reach." A 420px box
   floating in the middle of a 361px screen puts its buttons under the reading hand and
   its top edge behind the notch; the same content docked to the bottom puts the primary
   action exactly where the thumb already is.

   CSS ONLY, AND THAT IS THE POINT. ctx.modal() (portal-app-core-js.js) is shared by all
   twelve sections and by ctx.confirm(); rewriting it would put every section's popup on
   one ticket's risk. Not one line of it changes here — the same nodes, the same X, the
   same backdrop-click-to-dismiss, the same focus handling. Only where the box sits and
   how it enters is different, and only below 560px. Desktop is byte-for-byte untouched.

   The handle is a ::before on the box rather than a real node, so no JS builds it and
   no section has to know it exists. Drag-to-dismiss is NOT here: it needs pointer
   handlers on that shared component, and tap-outside plus the standing-rule X already
   close every sheet. Flagged rather than half-built. */
@media (max-width: 560px) {
  .modal-backdrop { align-items: flex-end; padding: 0; }
  .modal, .modal.wide {
    max-width: none; width: 100%;
    border-bottom-left-radius: 0; border-bottom-right-radius: 0;
    border-bottom: 0; border-left: 0; border-right: 0;
    /* Never taller than the screen minus the notch, and the last button clears the
       home indicator instead of hiding behind it. */
    max-height: 88dvh;
    padding: var(--space-5);
    padding-top: var(--space-5);
    padding-bottom: max(var(--space-5), env(safe-area-inset-bottom));
    animation: sheetup var(--dur-overlay) var(--ease-out);
  }
  .modal::before {
    content: ""; display: block; width: 36px; height: 4px; margin: -6px auto var(--space-3);
    border-radius: var(--radius-pill); background: var(--border-default);
  }
  /* Thumb zone: full-width stacked verbs, primary LAST so it sits lowest and closest.
     .actions is emitted in caller order with the primary already last (see modal()'s
     actions array), so reversing nothing is correct — only the axis changes. */
  .modal .actions { flex-direction: column-reverse; align-items: stretch; gap: var(--space-2); }
  .modal .actions .btn { width: 100%; min-height: 44px; }
  /* The X is a 15px glyph in a 4px pad — about 23px of target. The glyph does not
     change size; the box around it does. */
  .modal-x { min-width: 44px; min-height: 44px; top: 4px; right: 4px; }
  /* #521 repair, phone half. Down here the lifted feedback button sits in the top strip, so a
     window rises no higher than the line under it (--fb-safe-top: the button's bottom edge +
     8px, measured) - its X and title are never under the button - and it stays full width. */
  body.fb-lifted-on .modal-backdrop { padding-top: var(--fb-safe-top); }
  body.fb-lifted-on .modal { margin-left: 0; max-height: min(88dvh, calc(100dvh - var(--fb-safe-top))); }
  /* Toasts must not stack under the home indicator either. */
  .toast-host { bottom: max(var(--space-4), env(safe-area-inset-bottom)); left: var(--space-3); right: var(--space-3); }
  .toast { max-width: none; }
}

/* =============================== toasts ================================= */
.toast-host { position: fixed; bottom: var(--space-4); right: var(--space-4); z-index: 200; display: flex; flex-direction: column; gap: var(--space-2); }
.toast {
  display: flex; align-items: center; gap: var(--space-3);
  background: var(--bg-surface-3); border: 1px solid var(--border-default);
  border-left: 3px solid var(--brand-green); border-radius: var(--radius-sm);
  padding: 10px 14px; font-size: 13px; box-shadow: var(--shadow-2); max-width: 360px;
  animation: popin var(--dur-overlay) var(--ease-out);
}
.toast.warn { border-left-color: var(--accent-yellow); }
.toast.err { border-left-color: var(--danger); }
.toast .code { font-family: var(--font-mono); font-size: 11px; color: var(--text-tertiary); }

/* ==================== attention halo (caption gate, section 13) ========== */
/* Outline ring, NEVER a fill: this is the one deliberate break of the semantic
   colour rule - it reads as "unresolved gate", not "something went wrong".
   The ONLY red in Video Review. There was once a .halo-tag ("Captions owed")
   pinned inside the ring; it was removed on 2026-08-02 when the merge gave the
   card its two review tags, which say the same thing without a red fill. Do not
   reintroduce it, and do not give .halo a background. */
.halo { box-shadow: 0 0 0 2px var(--danger), 0 0 16px rgba(240,101,106,0.3); }
:root[data-theme="light"] .halo { box-shadow: 0 0 0 2px var(--danger), 0 0 14px rgba(194,43,48,0.35); }

/* =============================== toggle ================================= */
.toggle { width: 34px; height: 19px; border-radius: 999px; background: var(--border-default); position: relative; flex-shrink: 0; cursor: pointer; border: none; padding: 0; }
.toggle::after { content: ""; position: absolute; width: 15px; height: 15px; border-radius: 50%; background: #fff; top: 2px; left: 2px; transition: transform var(--dur-micro) var(--ease-out); }
.toggle.on { background: var(--brand-green); }
.toggle.on::after { transform: translateX(15px); }

/* ============ thumbnail frames - square, contain-fit (pack section 17) === */
/* EVERY grid/list context uses this identical square frame regardless of source
   orientation. contain, never cover: the platform hand-picks frame 0 as the
   poster and cover-cropping can cut off the subject it was chosen for. */
.frame-sq {
  aspect-ratio: 1 / 1; border-radius: var(--radius-sm); overflow: hidden;
  border: 1px solid var(--border-subtle); background: var(--chain-gray-soft);
  display: flex; align-items: center; justify-content: center; position: relative;
}
.frame-sq img, .frame-sq video { width: 100%; height: 100%; object-fit: contain; display: block; background: #0B1420; }
.frame-sq .frame-empty {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  height: 100%; width: 100%; gap: 2px; color: var(--text-tertiary); font-size: 11px; text-align: center; padding: var(--space-2);
}
.frame-sq .frame-play { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: rgba(255,255,255,0.85); font-size: 22px; text-shadow: 0 1px 6px rgba(0,0,0,0.6); pointer-events: none; }
/* (.ar-badge, the "9:16" corner label, was removed on 2026-08-02: a client never
   acts on an aspect ratio, and the square frame already tells them the shape.) */
.frame-cap { text-align: center; font-size: 11.5px; color: var(--text-tertiary); margin-top: var(--space-2); }

/* ============ THE ORIENTATION GRID - ONE implementation ================= */
/* "why is this not one of 2 orientations. vertical or horizontal" and "i want to see
   3 per page when we have 3 horizontals" (owner, 2026-08-14).

   TWO SHAPES, AND THE COUNT IS THE SHAPE. A vertical cut gets a 9/16 tile and five fit
   across a row; a horizontal cut gets a 16/9 tile and three fit across. Fifteen columns
   divides evenly by both, so one grid serves both without a mode switch: vertical spans
   3 of 15, horizontal spans 5 of 15.

   THIS BLOCK IS THE ONLY COPY. It used to exist twice - .rv-grid in review.js and a
   near-verbatim .lb-grid in library.js, each with its own third "we do not know" case -
   and the two had already drifted apart. Video Review and the Approved Library now both
   render .o-grid with .o-vertical / .o-horizontal cards, so a change lands once. Do not
   reintroduce a per-section copy of these rules.

   THERE IS NO THIRD CLASS. The previous pass carried an .o-sq neutral square for
   "unmeasured", which is what put a 3840x2160 poster in a tall letterboxed box on the
   owner's screen. Orientation is now resolved and STORED server-side before any of this
   is asked (portal-media.js orientOf / asset_orientation), so a card always carries one
   of exactly two classes. */
.o-grid { display: grid; grid-template-columns: repeat(15, minmax(0, 1fr)); gap: var(--space-4); }
.o-grid > .o-vertical { grid-column: span 3; }
.o-grid > .o-horizontal { grid-column: span 5; }
.o-vertical .frame-sq { aspect-ratio: 9 / 16; }
.o-horizontal .frame-sq { aspect-ratio: 16 / 9; }
/* Two steps down. The owner's 5-and-3 is the DESKTOP row; below that the tiles would be
   thumbnails, so the count drops while horizontal stays the wider of the two - the shape
   rule survives, only the row length changes. */
@media (max-width: 1100px) {
  .o-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); }
  .o-grid > .o-vertical { grid-column: span 2; }
  .o-grid > .o-horizontal { grid-column: span 3; }
}
@media (max-width: 700px) {
  .o-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .o-grid > .o-vertical { grid-column: span 1; }
  .o-grid > .o-horizontal { grid-column: span 2; }
}
/* THE GROUP HEADING above each grid. Video Review splits its queue into a horizontal
   group and a vertical one, each with its own heading and its own pager, because the
   owner asked for them separated. A client whose account only ever holds one shape sees
   one heading and one grid - the other group is not rendered at all, rather than
   rendered empty. */
.o-group { margin-bottom: var(--space-6); }
.o-group:last-child { margin-bottom: 0; }
/* THE BREAK ITSELF, and why it needed more than a margin (owner, 2026-08-14: "the
   vertical and horizontal should be separated in this view"). The first pass separated
   the groups in the DOM - two <section>s, two headings, two grids - and then asked a
   32px margin to carry the whole of "separated" visually. It could not. Cards inside a
   grid already sit 16px apart, so the gap between two groups was only twice the gap
   between two rows OF THE SAME group, and both grids ride the identical 15-column
   track, so tiles line up straight through the boundary. The result read as one
   continuous wall of videos with a faint label floating in it - which is exactly what
   the screen showed, and why the separation could be correct in the markup and still
   not be there for the person looking at it.
   So the boundary is now drawn, not implied: a rule between consecutive groups with
   real air on both sides of it (32px below the group above, 32px above the heading
   below). ONLY between siblings - :first-child gets no rule, so a single-orientation
   client is not given a stray line above their one and only group. */
.o-group + .o-group {
  border-top: 1px solid var(--border-default);
  padding-top: var(--space-6);
}
/* And the heading has to be legible enough to name the group it sits on. At 10.5px in
   --text-tertiary it was the smallest, lowest-contrast text on the page, which is the
   wrong weight for the one label that tells a client which shape they are looking at. */
.o-head {
  display: flex; align-items: baseline; gap: var(--space-2); margin-bottom: var(--space-3);
  font-size: 12px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
  color: var(--text-secondary);
}
.o-head .n { font-family: var(--font-mono); color: var(--text-secondary); letter-spacing: 0; }

/* ============ player stage - fixed HEIGHT, not ratio (pack section 17) === */
/* A vertical video pillarboxes, a horizontal one fills the width, and the
   controls dock to the STAGE so they never jump between a 9:16 and a 16:9. */
.stage {
  position: relative; height: 340px; overflow: hidden;
  background: var(--bg-surface-1); border: 1px solid var(--border-default);
  border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center;
}
.stage.landscape { height: 220px; }
.stage .fill { position: absolute; inset: 0; background: radial-gradient(120% 100% at 50% 0%, #16273B, #0A1420 72%); }
.stage video, .stage img.poster {
  position: relative; z-index: 1; height: 100%; width: auto; max-width: 100%;
  display: block; object-fit: contain;
}
.stage.landscape video, .stage.landscape img.poster { height: auto; width: 100%; max-height: 100%; }
.stage .stage-controls {
  position: absolute; left: 0; right: 0; bottom: 0; z-index: 3;
  padding: var(--space-3); background: linear-gradient(0deg, rgba(0,0,0,0.6), transparent);
}
.stage .scrub { height: 3px; background: rgba(255,255,255,0.22); border-radius: 2px; margin-bottom: 8px; cursor: pointer; }
.stage .scrub .f { width: 0; height: 100%; background: var(--brand-blue); border-radius: 2px; }
/* 11px is the floor (mobile packet: "no text below 11px"). This row is a timecode read
   at arm's length over moving video, which is the worst case for small type. */
.stage .stage-controls .row { display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 11px; color: rgba(255,255,255,0.85); }
.stage .playbtn {
  position: relative; z-index: 2; width: 46px; height: 46px; border-radius: 50%;
  background: rgba(255,255,255,0.14); border: 1px solid rgba(255,255,255,0.28);
  display: flex; align-items: center; justify-content: center; color: #fff;
  backdrop-filter: blur(3px); cursor: pointer;
}
@media (max-width: 700px) { .stage { height: 300px; } .stage.landscape { height: 190px; } }

/* ============ annotation tool rail (pack section 12) ==================== */
.toolrail { position: absolute; right: var(--space-2); top: var(--space-2); display: flex; flex-direction: column; gap: 6px; z-index: 4; }
.toolbtn {
  width: 30px; height: 30px; border-radius: var(--radius-sm);
  background: rgba(10,20,32,0.55); border: 1px solid rgba(255,255,255,0.18);
  color: rgba(255,255,255,0.85); display: flex; align-items: center; justify-content: center;
  font-size: 13px; backdrop-filter: blur(3px); cursor: pointer; padding: 0;
}
.toolbtn.active { background: var(--brand-blue); border-color: var(--brand-blue); color: #fff; }
/* TOUCH TARGET, NOT GLYPH SIZE. A 30px disc is comfortable with a mouse and a miss with
   a thumb; the packet's floor is a 44px real hit area even where the mark is smaller.
   Grown only on coarse pointers, so the desktop rail keeps the compact look the pack
   drew — the glyph is 13px in both cases, only the box around it changes. */
@media (pointer: coarse) {
  .toolbtn { width: 44px; height: 44px; }
  .toolrail { gap: var(--space-2); }
}
/* Marks are signal-yellow so an annotation never reads as an approval-green or
   reject-red action. */
.annot { position: absolute; z-index: 3; pointer-events: none; }
.annot .ring { width: 34px; height: 34px; border: 2px solid var(--accent-yellow); border-radius: 50%; box-shadow: 0 0 0 3px rgba(232,185,47,0.18); }
.annot .arrow { position: absolute; width: 46px; height: 2px; background: var(--accent-yellow); top: 44px; left: -4px; transform: rotate(24deg); transform-origin: left center; }
.annot .arrow::after { content: ""; position: absolute; right: -1px; top: -4px; border: 5px solid transparent; border-left-color: var(--accent-yellow); }
.annot-canvas { position: absolute; inset: 0; z-index: 3; }
.annot-canvas.armed { cursor: crosshair; pointer-events: auto; }

/* ============ comments rail (pack section 12) =========================== */
.comment { display: flex; gap: var(--space-2); padding: var(--space-2) 0; border-bottom: 1px solid var(--border-subtle); }
.comment:last-child { border-bottom: none; }
.tc-badge { font-size: 10.5px; color: var(--brand-blue); background: var(--brand-blue-soft); padding: 2px 6px; border-radius: 4px; height: fit-content; flex-shrink: 0; cursor: pointer; border: 0; }
.comment .txt { font-size: 12.5px; color: var(--text-secondary); line-height: 1.4; word-break: break-word; }
.comment .who { font-size: 11px; color: var(--text-tertiary); margin-top: 2px; }

/* ============ caption cards (pack section 13) =========================== */
/* UNIFORM TILES (owner, 2026-08-04: "uniform the tiles"). Every card is the same
   height regardless of how long that platform's caption is — a flex column so the
   foot (count + actions) always sits flush on the bottom edge, and a generous
   min-height on the body so a short X caption gets padding instead of a visibly
   shorter card next to a long Instagram one. Longer-than-the-fold text still grows
   the card a little (never clipped mid-sentence) — the fold/"more" toggle in
   preview() is what keeps the COMMON case uniform; this is the floor, not a cap. */
.cap-card { display: flex; flex-direction: column; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: var(--space-3); background: var(--bg-surface-1); position: relative; }
.cap-head { display: flex; justify-content: space-between; align-items: center; gap: var(--space-2); margin-bottom: var(--space-2); }
.cap-platform { font-size: 12px; font-weight: 700; display: flex; align-items: center; gap: 6px; }
.cap-shell { flex: 1; }
.cap-body { font-size: 12.5px; color: var(--text-secondary); line-height: 1.5; min-height: 118px; white-space: pre-wrap; word-break: break-word; }
.cap-body .tag { color: var(--brand-blue); }
.cap-foot { margin-top: auto; padding-top: var(--space-2); }

/* ============================== misc ==================================== */
.empty { text-align: center; color: var(--text-tertiary); padding: var(--space-7) var(--space-4); font-size: 13px; }
.empty .big { display: block; font-size: 14px; color: var(--text-secondary); margin-bottom: 4px; }
.spin { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--border-default); border-top-color: var(--brand-blue); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.notice { border: 1px solid var(--border-default); border-left: 3px solid var(--accent-yellow); background: var(--bg-surface-1); border-radius: var(--radius-sm); padding: 10px 14px; font-size: 12.5px; color: var(--text-secondary); }
.notice.err { border-left-color: var(--danger); }
.notice.info { border-left-color: var(--brand-blue); }
.notice .code { font-family: var(--font-mono); font-size: 11px; color: var(--text-tertiary); }
.copyline { display: flex; gap: var(--space-2); align-items: stretch; }
.copyline input { font-family: var(--font-mono); font-size: 12px; }

/* ===================== spacing utilities (CSP-forced) ==================== */
/* The portal's CSP is style-src 'self' with no 'unsafe-inline', so a style=""
   attribute is BLOCKED, not merely discouraged. Section modules build markup as
   strings, so they need a named class for the handful of one-off gaps that do not
   deserve a component. Deliberately tiny - this is not a utility framework. */
.mt-1 { margin-top: var(--space-1); }
.mt-2 { margin-top: var(--space-2); }
.mt-3 { margin-top: var(--space-3); }
.mt-4 { margin-top: var(--space-4); }
.mb-2 { margin-bottom: var(--space-2); }
.mb-3 { margin-bottom: var(--space-3); }
.mb-4 { margin-bottom: var(--space-4); }
.grow { flex: 1; min-width: 0; }
.nowrap { white-space: nowrap; }
.wrapline { word-break: break-word; }

/* ===================== computed gap panel (RETIRED from the page) ======== */
/* The gap[] shape is still COMPUTED on every request and still returned in each
   section's API payload - no measurement was deleted - but on 2026-08-02 the
   "What this section cannot show you" panels came off the client's screen: they
   were written in our vocabulary ("Unlocked by: the Post Log ledger"), and the
   same four panels appeared in calendar / links / metrics / community. The
   client-relevant fact inside each is now re-said in one plain line where the
   client is standing. The classes are gone with the panels; if a gap ever needs
   to be rendered again, render it as a .notice, not as a diagnostic list. */

/* ===================== platform tokens (shared) ========================= */
.pl-dots { display: inline-flex; gap: 3px; flex-wrap: wrap; }
.pl-dot { font-family: var(--font-mono); font-size: 9px; font-weight: 700; background: var(--chain-gray-soft); color: var(--text-secondary); border-radius: 3px; padding: 1px 4px; }
.pl-dot.off { opacity: 0.5; text-decoration: line-through; }

/* ================== THE WEIGHT LOCK (shared) ============================
   A BOLDER FACE IS A WIDER FACE. Every control in this portal whose ACTIVE
   state changes font-weight therefore changes its own BOX the moment it is
   selected, and a content-sized ROW of them changes total width with WHICH
   member is active. The owner met it on Video Review's
   Grid | Folders | Quick review switcher (2026-09-08, verbatim): "this
   switcher keeps moving when i jump from one view to another (should be
   anchored)". That group is right-aligned in the section head, so bolding
   "Quick review" - a wider word than "Grid" - walked the whole group, the
   "9 waiting on you" pill beside it included, sideways.

   THE FIX IS TO RESERVE THE BOLD WIDTH IN EVERY STATE, never to drop the
   weight change and never to bold everything: the weight difference IS the
   affordance, and the owner asked for the control to hold still, not to be
   restyled. So each label is wrapped in a .wlock span that carries its own
   text again in data-label, and the ::after re-lays that text at the ACTIVE
   weight as a zero-height block. A block child contributes its max-content
   width to an inline-block parent, so the span is exactly as wide when the
   label is light as when it is bold - while height:0 + overflow:hidden give
   it no height, visibility:hidden keeps it off the screen and out of the
   accessibility tree, and content:attr() text is never selectable.

   BOTH WEIGHTS ARE RESERVED, not just the heavier one, and that is not
   belt-and-braces: measurement says a heavier face is not always a wider one.
   Segoe UI Semibold renders "1080p" NARROWER than Regular does, so on the two
   resolution pickers - font-weight 400 at rest, 600 selected - reserving only
   the selected weight still left the resting button wider than the selected
   one, and the gate below caught it on the first run of this very fix. So
   ::before re-lays the label at the RESTING weight and ::after at the ACTIVE
   weight; the span takes the wider of the two in every state, whichever way
   round the font happens to draw them.

   --wlock-off and --wlock-w are declared ONCE per control, ON the control, and
   read by BOTH the reserves here and that control's own base and .on/.active
   rules - so the weights that are reserved and the weights that are rendered
   are literally the same two values and cannot drift apart. The 400/700
   fallbacks span the range this portal uses: a .wlock that somehow loses its
   declarations over-reserves rather than under-reserves.

   vertical-align: top IS LOAD-BEARING. A baseline-aligned inline-block whose
   last in-flow line box belongs to a zero-height ::after sits lower than the
   bare text it replaced, and every button grew 3-4px taller - measured, before
   this line existed. Top-aligning it puts the label back on the same pixels:
   with it, the segmented controls and the pagination strip photograph
   byte-identical to the unreserved rendering (5 of 1728 pixels one level of
   antialiasing apart on the switcher, nothing at all on the pager).

   ONE THING THE RESERVE CANNOT PRESERVE, said plainly: a box that is the same
   width in every state must be as wide as the WIDEST state it used to have.
   On the resolution pickers, where the selected face is the narrower one, that
   means the selected rung now takes the resting width - about 0.8px wider than
   before. There is no arrangement in which the group holds still and every
   button also keeps the width it had; holding still is what was asked for.

   scripts/weight-lock-check.mjs MEASURES this in a real browser - every
   control, every active state, the control box and every button box - and
   fails if any of them moves by a pixel. The failure is geometric, so the
   assertion is geometric; no amount of reading this stylesheet can see it. */
.wlock { display: inline-block; vertical-align: top; }
.wlock::before, .wlock::after {
  content: attr(data-label);
  display: block; height: 0; overflow: hidden; visibility: hidden;
  pointer-events: none;
}
.wlock::before { font-weight: var(--wlock-off, 400); }
.wlock::after { font-weight: var(--wlock-w, 700); }

/* ===================== segmented control (shared) ====================== */
.seg { display: inline-flex; border: 1px solid var(--border-default); border-radius: var(--radius-sm); overflow: hidden; }
.seg button { background: var(--bg-surface-1); border: 0; color: var(--text-secondary); font-family: var(--font-ui); font-size: 12px; font-weight: 600; padding: 6px 12px; cursor: pointer; }
.seg button.on { background: var(--brand-blue-soft); color: var(--brand-blue); }

/* =================== send feedback ====================================
   NOTHING HERE, ON PURPOSE. 2026-08-03: the portal's own rail button (.pfeed) and its
   own one-textarea modal (.fb-note) are DELETED, along with the runtime that drew them.
   The portal now mounts the same component the Manager mounts, and that component
   brings its own button and its own styles with it — public/shared/ll-feedback.css,
   linked on the shell. Do not re-add a .pfeed here: a second, differently-shaped door
   into one flow is exactly what the owner asked us to stop doing. */


*{box-sizing:border-box}
body{margin:0;min-height:100vh;background:var(--bg-base);color:var(--text-primary);
  font-family:var(--font-ui);font-size:15px;line-height:1.5;
  display:flex;align-items:center;justify-content:center;padding:var(--space-6)}
.wrap{width:100%;max-width:430px}
.brand{display:flex;align-items:center;gap:var(--space-2);justify-content:center;margin-bottom:var(--space-5);
  font-weight:700;font-size:19px;letter-spacing:-.2px;color:var(--text-primary)}
.brand .mark{width:28px;height:28px;flex-shrink:0;display:block}
.card{background:var(--bg-surface-1);border:1px solid var(--border-subtle);border-radius:var(--radius-lg);
  padding:26px 24px;box-shadow:var(--shadow-1)}
h1{font-size:20px;margin:0 0 6px;font-weight:650;letter-spacing:-.01em}
.sub{color:var(--text-secondary);font-size:13.5px;margin:0 0 var(--space-5)}
label{display:block;font-size:13px;font-weight:600;margin:var(--space-4) 0 5px;color:var(--text-secondary)}
input,select{width:100%;padding:11px 12px;border:1px solid var(--border-default);border-radius:var(--radius-sm);
  background:var(--bg-surface-2);color:var(--text-primary);font-size:15px;font-family:var(--font-ui)}
input:focus,select:focus{outline:2px solid var(--brand-blue);outline-offset:2px;border-color:var(--brand-blue)}
input[readonly]{color:var(--text-tertiary);background:var(--bg-surface-3)}
button{width:100%;margin-top:var(--space-5);padding:12px 14px;border:1px solid transparent;border-radius:var(--radius-sm);
  background:var(--brand-blue);color:#fff;font-size:15px;font-weight:600;cursor:pointer;
  font-family:var(--font-ui);transition:background var(--dur-micro) var(--ease-out)}
button:hover{background:var(--brand-blue-hover)}
button:focus-visible{outline:2px solid var(--brand-blue);outline-offset:2px}
button:disabled{opacity:.6;cursor:default}
button.ghost{background:transparent;color:var(--text-primary);border-color:var(--border-default);font-weight:600}
button.ghost:hover{background:var(--bg-surface-2)}
.links{margin-top:var(--space-4);text-align:center;font-size:13.5px}
a{color:var(--brand-blue);text-decoration:none}
a:hover{text-decoration:underline}
a:focus-visible{outline:2px solid var(--brand-blue);outline-offset:2px;border-radius:2px}
.msg{margin-top:var(--space-4);padding:11px 12px;border-radius:var(--radius-sm);font-size:13.5px;
  background:var(--danger-soft);color:var(--danger);word-break:break-word}
.msg.ok{background:var(--brand-green-soft);color:var(--brand-green)}
.msg.hide{display:none}
.hide{display:none}
.hint{color:var(--text-tertiary);font-size:12.5px;margin-top:6px}
.hint.err{color:var(--danger)}
.sect{border-top:1px solid var(--border-subtle);margin-top:22px;padding-top:var(--space-5)}
.sect h2{font-size:15px;margin:0 0 4px;font-weight:650}
.row{display:flex;align-items:center;justify-content:space-between;gap:var(--space-3)}
.pill{font-size:12px;font-weight:600;padding:3px 9px;border-radius:var(--radius-pill);
  background:var(--chain-gray-soft);color:var(--text-secondary)}
.pill.on{background:var(--brand-green-soft);color:var(--brand-green)}
.qr{display:flex;justify-content:center;margin:var(--space-4) 0 var(--space-2)}
.qr svg{border-radius:var(--radius-sm)}
.key{font-family:var(--font-mono);font-size:14px;letter-spacing:1px;word-break:break-all;text-align:center;
  background:var(--bg-surface-2);padding:10px;border-radius:var(--radius-sm);color:var(--text-primary)}
ul.codes{list-style:none;padding:var(--space-3);margin:var(--space-3) 0 0;background:var(--bg-surface-2);
  border-radius:var(--radius-sm);font-family:var(--font-mono);font-size:14px;columns:2;color:var(--text-primary)}
ul.codes li{padding:2px 0}
.foot{text-align:center;color:var(--text-tertiary);font-size:12px;margin-top:var(--space-4)}
/* Get Started (/portal/start) */
.qs{display:flex;flex-direction:column;gap:var(--space-5);margin-top:var(--space-4)}
.q{border-top:1px solid var(--border-subtle);padding-top:var(--space-4)}
.q:first-child{border-top:0;padding-top:0}
.q .qh{font-size:13.5px;font-weight:650;color:var(--text-primary)}
.q .qw{font-size:12.5px;color:var(--text-tertiary);margin-top:3px}
.q textarea{width:100%;margin-top:var(--space-2);min-height:96px;resize:vertical;padding:11px 12px;
  border:1px solid var(--border-default);border-radius:var(--radius-sm);background:var(--bg-surface-2);
  color:var(--text-primary);font-size:14.5px;font-family:var(--font-ui);line-height:1.5}
.q textarea:focus{outline:2px solid var(--brand-blue);outline-offset:2px;border-color:var(--brand-blue)}
.q .qs-state{font-size:11.5px;font-family:var(--font-mono);color:var(--text-tertiary);margin-top:5px;min-height:14px}
.gs-bar{display:flex;align-items:center;justify-content:space-between;gap:var(--space-3);flex-wrap:wrap;
  font-size:12.5px;color:var(--text-secondary)}
.gs-bar .n{font-family:var(--font-mono);color:var(--text-primary)}
/* The voice leg. The record control sits ABOVE the textarea because speaking is the
   primary way to answer this form and typing is the alternative, and the row keeps a
   fixed min-height so revealing the player and the "use this" button never moves the
   question below it. */
.q .qv{display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin-top:var(--space-3);min-height:42px}
.q .qv button{width:auto;margin-top:0;padding:9px 13px;font-size:13.5px}
.q .qv button.on{background:var(--danger);border-color:var(--danger)}
.q .qv button.on:hover{background:var(--danger)}
.q .qv audio{height:32px;flex:0 1 210px;max-width:100%}
.q .qv .qv-timer{font-family:var(--font-mono);font-size:12.5px;color:var(--text-secondary)}
.q .qv-state{font-size:12px;color:var(--text-secondary);margin-top:5px;min-height:15px}
.q .qv-state.err{color:var(--danger)}
.q .qv-or{font-size:12px;color:var(--text-tertiary);margin-top:var(--space-3)}
