/* ====================================================================
   WCPE INSTRUCTOR STUDIO — FEATURE PANELS / SIDE RACK / COMPACT HOMES
   ====================================================================
   Cascade layer 5 of 7. Owns regions 10-11, 13-14 — text panels, self-test
   gate, side rack + compact homes, first-run callout — plus the S-series
   banners (S3..S11), which are additive slice sections with no mockup origin.

   LOAD ORDER IS LOAD-BEARING:
     tokens -> base -> shell -> stage -> panels -> producer -> responsive
   This file is #5. Nothing here may depend on a rule that loads later.
   The numbered region banners below are this file's own. The numbering is one
   flat sequence 1-22 shared across the seven cascade files and no two files
   repeat a number; it was inherited from the concept-6 "Studio Rail" mockup the
   cascade was first extracted from. That mockup is deleted and these banners
   are the source now. Region map: docs/CODEBASE.html#style-css-regions
   ==================================================================== */
/* ====================================================================
   10 · TEXT PANELS — SCRIPT / MARKS / CHAT / TRANSCRIPT
   Internal-scroll reading surfaces. Script: toolbar (listening state,
   Linked-to-slides toggle, help, A−/A+ sizing clamped 22-36px over a
   24px default) above the .script-reader, whose dashed-cyan cue rows
   mark page turns (.is-passed dimmed solid, .is-next glowing;
   .script-unlinked on the shell renders cues passive). Marks review
   cards, chat bubbles (yours: cyan, right-aligned), and the transcript
   editor all wrap at spaces and scroll vertically — truncation and
   mid-word breaks are banned file-wide.
   ==================================================================== */
.text-panel{
  align-content:start;
  gap:12px;
  padding:3px;
  overflow-y:auto;
  overscroll-behavior:contain;
}
.script-toolbar{
  display:flex;
  align-items:center;
  gap:8px;
  flex-wrap:wrap;
}
.script-help-action{
  min-height:44px;
  padding:8px 12px;
  display:flex;
  align-items:center;
  gap:8px;
  border-radius:var(--control-radius);
  font-size:15px;
  font-weight:900;
}
.script-help-action svg{width:20px;height:20px;fill:var(--cyan)}
.listening-state{
  min-height:44px;
  padding:7px 11px;
  display:flex;
  align-items:center;
  gap:8px;
  border:1px solid var(--cyan);
  border-radius:var(--control-radius);
  background:rgba(7,76,142,.38);
  font-size:15px;
  font-weight:900;
}
.listening-state svg{width:22px;height:22px;fill:var(--cyan)}
.script-size-controls{
  margin-left:auto;
  display:flex;
  align-items:center;
  gap:6px;
}
.script-size-controls button{
  min-width:48px;
  min-height:48px;
  padding:7px;
  border-radius:var(--control-radius);
  font-size:18px;
  font-weight:900;
}
.script-size-value{min-width:98px;text-align:center;font-size:15px;font-weight:900}
.script-reader{
  --script-size:24px;
  min-height:0;
  padding:14px;
  display:grid;
  align-content:start;
  gap:10px;
  overflow-y:auto;
  border:1px solid var(--line);
  border-radius:var(--card-radius);
  background:var(--void);
  font-size:var(--script-size);
  line-height:1.5;
}
.script-reader p{margin:0;padding:10px;border-left:4px solid transparent;overflow-wrap:normal;word-break:normal}
.script-link-toggle{
  min-height:44px;
  padding:8px 12px;
  display:flex;
  align-items:center;
  gap:7px;
  border-radius:var(--control-radius);
  font-size:15px;
  font-weight:900;
}
.script-link-toggle svg{width:20px;height:20px;fill:var(--cyan)}
.script-link-toggle.is-linked{border:2px solid var(--cyan);background:var(--blue);box-shadow:var(--glow)}
.script-cue{
  width:100%;
  min-height:44px;
  margin:2px 0;
  padding:8px 12px;
  display:flex;
  align-items:center;
  gap:8px;
  border:1px dashed var(--cyan);
  border-radius:var(--control-radius);
  background:rgba(34,211,238,.08);
  color:var(--cyan);
  font:900 15px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;
  letter-spacing:.04em;
  text-align:left;
}
.script-cue svg{width:18px;height:18px;fill:var(--cyan);flex:0 0 auto}
.script-cue.is-passed{border-style:solid;opacity:.62}
.script-cue.is-next{box-shadow:var(--glow)}
.app-shell.script-unlinked .script-cue{border-color:var(--line);background:transparent;color:var(--dim);box-shadow:none}
.app-shell.script-unlinked .script-cue svg{fill:var(--steel)}
.script-reader p.is-current{
  border-left-color:var(--cyan);
  background:rgba(34,211,238,.1);
  box-shadow:inset 0 0 20px rgba(34,211,238,.05);
}
.mark-review-header{display:flex;align-items:center;gap:9px;flex-wrap:wrap}
.mark-review-header p{margin:0;color:var(--dim);font-size:16px}
.export-button{
  margin-left:auto;
  min-height:48px;
  padding:8px 13px;
  display:flex;
  align-items:center;
  gap:8px;
  border:2px solid var(--azure);
  border-radius:var(--control-radius);
  background:var(--blue);
  box-shadow:var(--azure-glow);
  font-size:15px;
  font-weight:900;
}
.export-button svg{width:22px;height:22px;fill:currentColor}
.focused-marks{display:grid;gap:10px}
.mark-review-card{
  min-width:0;
  padding:12px;
  display:grid;
  grid-template-columns:minmax(150px,230px) minmax(0,1fr);
  gap:14px;
  border:1px solid var(--line);
  border-radius:var(--card-radius);
  background:var(--surface);
}
.mark-review-card.is-target{
  border-color:var(--cyan);
  box-shadow:0 0 26px -8px var(--cyan);
  animation:targetMark .8s ease-out 1;
}
@keyframes targetMark{50%{transform:scale(1.01)}}
.mark-frame{
  width:100%;
  aspect-ratio:16/9;
  display:grid;
  place-items:center;
  overflow:hidden; /* item-32 audit: decorative media clip — frame art only, never a control */
  border:1px solid var(--steel);
  border-radius:var(--control-radius);
  background:
    linear-gradient(135deg,rgba(34,211,238,.12),transparent),
    var(--void);
  color:var(--cyan);
  font-size:15px;
  font-weight:900;
  text-align:center;
}
.mark-detail{min-width:0;display:grid;align-content:start;gap:8px}
.mark-time{color:var(--cyan);font:900 15px/1 ui-monospace,SFMono-Regular,Menlo,monospace}
/* Long-token policy — server and user text must wrap rather than overflow
   (docs/CODEBASE.html#style-long-token-policy): mark captured-text and notes
   are user/server data and may hold a pasted URL or ID with no legal break
   point. anywhere breaks only those; ordinary words stay whole, so the
   no-mid-word-wrap floor still governs the prose around them. */
.mark-detail p{margin:0;font-size:19px;overflow-wrap:anywhere;word-break:normal}
.mark-detail label{display:grid;gap:6px;font-size:15px;font-weight:900}
.mark-detail textarea{
  width:100%;
  min-height:74px;
  padding:10px;
  resize:vertical;
  border-radius:var(--control-radius);
  font-size:19px;
  line-height:1.35;
  overflow-wrap:anywhere;
  word-break:normal;
}
/* Item 32, measured with a POPULATED panel at 410x590 @200% text: the frozen
   rows starved the message list to zero and the grown Send form overflowed
   the pinched cell straight under the transport — elementFromPoint over Send
   landed on Drop marker. The floor keeps a readable window of messages
   (min() guards cells shorter than the floor), and the panel itself scrolls
   once the form alone outmeasures the cell, so Send is one scroll away
   instead of painted under a neighbour. At the design's text size the floor
   and the scrollbar are both invisible — the natural layout fits. */
.chat-panel.is-active{
  grid-template-rows:minmax(min(5em,60%),1fr) auto;
  gap:10px;
  overflow-y:auto;
  overscroll-behavior:contain;
}
.chat-messages{
  min-height:0;
  padding:10px;
  display:grid;
  align-content:start;
  gap:9px;
  overflow-y:auto;
  border:1px solid var(--line);
  border-radius:var(--card-radius);
  background:var(--void);
}
.chat-message{
  max-width:min(86%,620px);
  padding:10px 12px;
  border:1px solid var(--line);
  border-radius:var(--card-radius);
  background:var(--raised);
  font-size:19px;
  /* Message bodies and the sender name in <b> are user data; a pasted
     link must wrap inside the bubble, never overflow it (long-token
     policy, docs/CODEBASE.html#style-long-token-policy). */
  overflow-wrap:anywhere;
  word-break:normal;
}
.chat-message.is-me{justify-self:end;border-color:var(--cyan);background:rgba(7,76,142,.55)}
.chat-message b{display:block;margin-bottom:4px;color:var(--cyan);font-size:15px}
.chat-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px}
.chat-form textarea{
  width:100%;
  /* ITEM 60 S3 — the design asked to "Raise the resting height to 72px",
     kept, but for a different reason than the design gives, and the
     measurement is recorded here rather than the assumption repeated.
     The design says a two-line placeholder "clips at 52px". Measured across
     the viewport matrix (docs/CODEBASE.html#arch-viewport-matrix) in both
     roles it does not: a textarea's intrinsic height is two rows, so this
     box already RESTED at 66px and min-height:52px never governed anywhere.
     What 72px buys is MARGIN, and the margin is real. Both placeholders need
     66px of content at the narrowest fields the product ships (341px at
     1100x700, 290px at 390x844). Against a 52px rule the box rests at 66px —
     a fit with exactly ZERO spare, where any font fallback or a slightly
     narrower field truncates, and a placeholder cannot be scrolled to. Against
     this rule it rests at 70px client, so the same sentences clear by 4px.
     The instructor's ORIGINAL 81-character sentence needed 89px and was
     truncated by 19px at both narrow tiers — a no-truncation violation
     (docs/CODEBASE.html#style-no-truncation) this slice's own move
     created. It was fixed by shortening the sentence (partials/
     panels-chat.php), not by growing this box, because the wording was ours
     and the floor is not negotiable.
     RE-MEASURED FOR THE 19px/1.5 DEFAULT (2026-08-10 harmonisation): the
     gated 327px field rested at EXACTLY its 77px of content — the zero-spare
     fit the paragraph above calls out — and the 290px phone field lost 29px
     of the placeholder outright. Same philosophy, new numbers: 84px rests the
     gated tier with 7px spare; the phone tier gets its own rule in
     responsive.css because three wrapped lines at 19px/1.5 need 106px there
     and growing EVERY viewport's chat box to phone-worst-case would spend
     desktop space on a phone problem. */
  min-height:84px;
  max-height:120px;
  padding:10px 12px;
  resize:vertical;
  border-radius:var(--control-radius);
  font-size:19px;
  overflow-wrap:anywhere;
  word-break:normal;
}
/* F2 (Oakshott) — the moved sentence must be READABLE, not merely present.
   Nothing in public/css declares a ::placeholder colour or a color-scheme, so
   Chrome paints its LIGHT-MODE placeholder grey onto this dark field: measured
   3.74:1 against --raised, below the 4.5:1 AA floor, where the rail paragraph
   this replaced was 11.78:1. Moving copy into a placeholder is only a fix if
   the copy survives the move, and for a 65+/low-vision reader 3.74:1 is a
   sentence they cannot read.
   --dim is the token the rail paragraph used, so the contrast goes back to
   what it was rather than to some new value. `opacity:1` is not decoration:
   Firefox applies a default placeholder opacity that would quietly undo the
   colour. Scoped to this field — every other placeholder in the product has
   the same UA problem and none of them is this slice's to fix (residual). */
.chat-form textarea::placeholder{color:var(--dim);opacity:1}

.chat-form button{
  min-width:108px;
  min-height:52px;
  padding:8px 14px;
  border:2px solid var(--azure);
  border-radius:var(--control-radius);
  background:var(--blue);
  font-size:19px;
  font-weight:900;
}
/* Item 32, measured with a POPULATED list at 410x590 @200% text: the intro
   sentence is an auto row, so once zoom grew it to ~90px of a 99px cell the
   SEGMENT LIST — the panel's whole point — kept 0px, and every edit pencil
   laid out under the transport (elementFromPoint hit Drop marker). The
   segments take an em floor (min() guards cells shorter than it) and the
   intro compresses and scrolls in place past its share — standing prose
   yields to the workflow, the 19a help-copy ruling. Identical at the
   design's text size: the intro fits its row and neither scrollbar exists. */
.transcript-panel.is-active{grid-template-rows:minmax(0,auto) minmax(min(5em,60%),1fr);gap:9px}
.transcript-panel.is-active>p{min-height:0;overflow-y:auto;overscroll-behavior:contain}
.transcript-panel p{margin:0;color:var(--dim);font-size:16px}
.transcript-editor{
  width:100%;
  height:100%;
  min-height:180px;
  padding:14px;
  resize:none;
  border-radius:var(--card-radius);
  background:var(--void);
  font-size:18px;
  line-height:1.55;
  overflow-y:auto;
  /* Transcript segments are recognizer output — long run-together tokens
     are routine and must wrap, not scroll the page (long-token policy,
     docs/CODEBASE.html#style-long-token-policy). */
  overflow-wrap:anywhere;
  word-break:normal;
}
/* ====================================================================
   11 · SELF TEST GATE
   The pre-recording device check (mirrors the studio.php device-check
   flow): live 16:9 preview one side, device selectors + mic level
   meter + headphones choice + the primary run button the other.
   Two columns from 700px width; stacked below. .is-passed turns the
   frame + fields cyan and reveals the results list. The producer role
   gets the compact passed-card with the check ring. Short-height rules
   at the end of this stylesheet keep the gate usable without
   compressing any control under 44px.
   ==================================================================== */
.self-test-panel{
  padding:6px;
  align-content:start;
  overflow-y:auto;
  overscroll-behavior:contain;
}
.self-test-gate{
  width:100%;
  min-height:auto;
  display:grid;
  grid-template-columns:minmax(0,1fr);
  grid-template-rows:max-content max-content;
  align-items:start;
  align-content:start;
  gap:22px;
}
.self-test-preview{
  position:relative;
  min-width:0;
  aspect-ratio:16/9;
  overflow:hidden; /* item-32 audit: decorative media clip — video letterbox; overlays inside scroll themselves */
  border:1px solid var(--line);
  border-radius:var(--card-radius);
  background:
    radial-gradient(circle at 50% 34%,var(--dim) 0 7%,transparent 7.5%),
    radial-gradient(ellipse at 50% 96%,var(--steel) 0 28%,transparent 28.5%),
    linear-gradient(145deg,var(--navy),var(--surface) 72%);
  border-color:var(--cyan);
  box-shadow:var(--glow),0 22px 60px rgba(0,0,0,.42);
}
.self-test-preview::after{
  content:"";
  position:absolute;
  inset:4%;
  border:1px solid rgba(34,211,238,.25);
  border-radius:var(--card-radius);
  pointer-events:none;
}
/* DEVIATION — not in the mockup, whose preview is the decorative gradient
   above. S2's features/selftest.js mounts a <video class="self-test-video">
   inside [data-self-test-preview] to show the real camera during the test.
   It fills the 16:9 box with object-fit:cover (the stage invariant —
   letterbox or cover, never stretched or distorted:
   docs/CODEBASE.html#arch-stage-invariant). Stacking needs no z-index:
   .self-test-preview is already the
   containing block (position:relative above), so the video paints over the
   parent's gradient background by ordinary paint order, the ::after frame
   paints above it because a pseudo-element follows all children in tree
   order at z-index auto, and the quality/status captions sit above both at
   their existing z-index:2. Giving the video its own z-index would lift it
   over the auto-level ::after frame — do not add one. border-radius:inherit
   only cleans up corner antialiasing; the parent's overflow:hidden is what
   actually clips. */
.self-test-video{
  position:absolute;
  inset:0;
  width:100%;
  height:100%;
  object-fit:cover;
  border-radius:inherit;
}
/* Walk item 7 (Tor's redesign): the gate's left grid child is now a column —
   the 16:9 preview with the live transcript DIRECTLY BELOW it, so the
   instructor watches their words appear under their own picture while the
   test records. The gate keeps its documented two-column shape (the Self
   Test gate entry in the component vocabulary,
   docs/CODEBASE.html#style-components): this wrapper is still one grid
   child. */
.self-test-stage-col{min-width:0;display:grid;gap:12px;align-content:start}
.self-test-captions{
  min-width:0;
  padding:12px 14px;
  border:1px solid var(--line);
  border-radius:var(--card-radius);
  background:var(--surface);
}
.self-test-captions-title{
  margin:0 0 8px;
  font:800 15px/1.3 ui-monospace,SFMono-Regular,Menlo,monospace;
  letter-spacing:.08em;
  text-transform:uppercase;
  color:var(--dim);
}
.self-test-caption-log{
  max-height:132px;
  overflow-y:auto;
  overscroll-behavior:contain;
  display:grid;
  gap:6px;
  font-size:19px;
  line-height:1.45;
}
.self-test-caption-log p{margin:0;overflow-wrap:anywhere}
/* The in-flight utterance: a visual echo only (aria-hidden — see the partial).
   Reduced opacity + italic mark it as unsettled; the same words re-render
   upright in the log the moment they settle, so no meaning is lost. */
.self-test-caption-interim{
  margin:6px 0 0;
  font-size:19px;
  font-style:italic;
  color:var(--dim);
  overflow-wrap:anywhere;
  min-height:1.45em;
}
.self-test-caption-note{margin:6px 0 0;font-size:16px;color:var(--dim);overflow-wrap:anywhere}
/* Playback review: each settled line with its stamp; the playhead's current
   line carries .is-current + aria-current — border, fill, AND the ▶ glyph,
   never color alone (docs/CODEBASE.html#style-glyph-needs-word). */
.self-test-review{
  margin:0;
  padding:0;
  list-style:none;
  display:grid;
  gap:6px;
  max-height:180px;
  /* A review viewport SHORTER than one row can never show a whole caption
     line: at the shrunken layouts this list flexed to 60px while a two-line
     row at the 19px/1.5 default measures 67px, so the current row was
     clipped no matter how the product scrolled (measured, 2026-08-10
     harmonisation; the pre-change fit was 60-vs-~61, marginal by luck).
     90px floors the viewport at tallest-row-plus-margin, so the highlight
     the selftest promises to keep visible is always WHOLLY visible. */
  min-height:90px;
  overflow-y:auto;
  overscroll-behavior:contain;
}
.self-test-review li{
  display:grid;
  grid-template-columns:56px minmax(0,1fr);
  gap:8px;
  padding:6px 8px;
  border-left:3px solid transparent;
  border-radius:4px;
  font-size:19px;
  line-height:1.45;
}
.self-test-review-stamp{font:800 15px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--dim)}
.self-test-review-text{overflow-wrap:anywhere}
.self-test-review li.is-current{
  border-left-color:var(--cyan);
  background:color-mix(in srgb,var(--blue) 30%,var(--surface));
}
.self-test-review li.is-current .self-test-review-stamp{color:var(--text)}
.self-test-review li.is-current .self-test-review-stamp::before{content:"▶ ";color:var(--cyan)}
.self-test-quality{
  position:absolute;
  z-index:2;
  left:14px;
  top:14px;
  max-width:calc(100% - 28px);
  padding:8px 11px;
  border:1px solid var(--line);
  border-radius:var(--control-radius);
  background:rgba(5,8,15,.84);
  font-size:15px;
  line-height:1.35;
  backdrop-filter:blur(10px);
}
.self-test-quality strong{display:block;color:var(--text);font-size:16px}
.self-test-preview-status{
  position:absolute;
  z-index:2;
  left:50%;
  bottom:14px;
  min-height:44px;
  padding:8px 12px;
  display:flex;
  align-items:center;
  gap:8px;
  transform:translateX(-50%);
  border:1px solid var(--cyan);
  border-radius:var(--control-radius);
  background:rgba(15,27,48,.9);
  font-size:15px;
  font-weight:900;
  white-space:nowrap;
}
.self-test-preview-status svg{width:22px;height:22px;fill:var(--cyan)}
/* In the played-back-sample state the <video> carries native controls
   along its bottom edge; the pill ("Recorded sample — press play") lifts
   clear of the scrubber instead of covering it. A base rule on purpose
   (slice 3): the collision exists at every size the sample plays, not
   only on phones. */
.self-test-preview:has(video[controls]) .self-test-preview-status{bottom:64px}
.self-test-device-panel{min-width:0;display:grid;align-content:center;gap:12px}
.self-test-device-panel h3{margin:0;text-align:center;font-size:21px}
.self-test-state{margin:0;color:var(--dim);font-size:16px;text-align:center;overflow-wrap:normal;word-break:normal}
.self-test-device-row{min-width:0;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px}
.self-test-field{
  min-width:0;
  min-height:48px;
  padding:0 11px;
  display:flex;
  align-items:center;
  gap:9px;
  border:1px solid var(--line);
  border-radius:var(--control-radius);
  background:var(--raised);
}
.self-test-field:focus-within{border-color:var(--cyan);box-shadow:var(--glow);outline:3px solid var(--cyan);outline-offset:2px}
.self-test-field svg{width:21px;height:21px;fill:var(--steel);flex:0 0 auto}
.self-test-field select{
  width:100%;
  min-width:0;
  min-height:46px;
  padding:0 25px 0 0;
  appearance:none;
  border:0;
  border-radius:0;
  background:linear-gradient(45deg,transparent 50%,var(--cyan) 50%) right 9px center/7px 7px no-repeat;
  font-size:15px;
  font-weight:800;
}
.self-test-field select:focus{outline:0}
.self-test-side{
  min-width:76px;
  min-height:48px;
  padding:8px 12px;
  border-radius:var(--control-radius);
  font-size:15px;
  font-weight:900;
}
.self-test-side:not(:disabled):hover{border-color:var(--cyan);box-shadow:var(--glow)}
.self-test-side:disabled{color:var(--good);opacity:1}
.self-test-level{
  height:7px;
  margin:1px 0;
  overflow:hidden; /* item-32 audit: decorative clip — the meter's fill bar */
  border:1px solid var(--line);
  border-radius:4px;
  background:var(--raised);
}
.self-test-level i{
  display:block;
  width:68%;
  height:100%;
  background:var(--cyan);
  box-shadow:0 0 9px var(--cyan);
  animation:selfTestLevel .9s ease-in-out infinite alternate;
}
@keyframes selfTestLevel{to{width:84%;opacity:.76}}
/* The microphone level meter's labeled row (slice 3): the meter's ONE
   home, directly under the preview in the stage column, shared by the
   in-studio panel and the pre-join wizard. The label is a visible word
   beside the glyph; the meter element keeps its own accessible name. */
.prejoin-meter-row{display:flex;align-items:center;gap:9px;color:var(--text);font-size:15px;font-weight:800;white-space:nowrap}
.prejoin-meter-row svg{width:19px;height:19px;fill:var(--cyan);flex:0 0 auto}
.prejoin-meter-row .self-test-level{flex:1 1 auto;margin:0}
/* The pre-join wizard's own chrome (responsive.css 20b) rests outside
   the states that show it. */
.prejoin-turnon,.prejoin-enter,.prejoin-rerun,.prejoin-review-head{display:none}
.self-test-headphones{display:flex;align-items:center;justify-content:space-between;gap:10px;color:var(--text);font-size:15px}
.self-test-headphones span{display:flex;align-items:center;gap:8px}
.self-test-headphones svg{width:21px;height:21px;fill:var(--steel)}
.self-test-pills{display:flex;gap:6px}
.self-test-pills button{min-width:58px;min-height:44px;padding:7px 11px;border-radius:var(--control-radius);font-size:15px;font-weight:900}
.self-test-pills button.is-selected{border-color:var(--cyan);background:var(--blue)}
/* DEVIATION — the mockup marks the chosen pill by border/background color
   alone; a colour must carry a glyph or a word
   (docs/CODEBASE.html#style-glyph-needs-word), so the selected pill also
   draws a check glyph (selftest.js mirrors the same state into aria-pressed
   for assistive technology). */
.self-test-pills button.is-selected::before{content:"✓";margin-right:5px;color:var(--cyan);font-weight:900}
.self-test-run{
  width:100%;
  min-height:52px;
  padding:9px 14px;
  display:flex;
  align-items:center;
  justify-content:center;
  gap:9px;
  border:2px solid var(--azure);
  border-radius:var(--control-radius);
  background:var(--blue);
  box-shadow:var(--azure-glow);
  font-size:19px;
  font-weight:900;
}
.self-test-run svg{width:23px;height:23px;fill:currentColor}
.self-test-run [data-test-count]{font:900 15px/1 ui-monospace,SFMono-Regular,Menlo,monospace}
.self-test-gate.is-passed .self-test-preview,.self-test-gate.is-passed .self-test-field{border-color:var(--good)}
@media(min-width:700px){
  .self-test-gate{grid-template-columns:minmax(0,1fr) minmax(260px,320px);grid-template-rows:max-content;align-items:start}
}
[data-role="producer"] .self-test-panel{place-items:center}
.self-test-card{
  width:min(100%,590px);
  padding:18px;
  display:grid;
  grid-template-columns:128px minmax(0,1fr);
  align-items:center;
  gap:20px;
  border:1px solid var(--line);
  border-radius:var(--card-radius);
  background:var(--surface);
}
.test-ring{
  width:128px;
  height:128px;
  display:grid;
  place-items:center;
  border:12px solid var(--steel);
  border-top-color:var(--cyan);
  border-radius:50%;
  font:900 24px/1 ui-monospace,SFMono-Regular,Menlo,monospace;
}
.self-test-copy h3{margin:0;font-size:24px}
.self-test-copy p{margin:7px 0 0;color:var(--dim);font-size:19px}
.self-test-copy button{
  min-width:220px;
  min-height:50px;
  margin-top:12px;
  padding:8px 14px;
  border:2px solid var(--azure);
  border-radius:var(--control-radius);
  background:var(--blue);
  box-shadow:var(--azure-glow);
  font-size:19px;
  font-weight:900;
}
.result-list{margin:12px 0 0;padding:0;display:grid;gap:6px;list-style:none;font-size:16px}
.result-list li::before{content:"✓";margin-right:8px;color:var(--good);font-weight:900}
/* Deviation from the mockup's always-✓ seed list: selftest.js stamps real
   results with data-result, and a failed/neutral line under a green check
   would lie — the glyph must never contradict the wording
   (docs/CODEBASE.html#style-glyph-needs-word). */
.result-list li[data-result="failed"]::before{content:"✕";color:var(--red)}
.result-list li[data-result="neutral"]::before{content:"–";color:var(--dim)}
/* warn (item 8's below-1080 camera flag): attention-class, so it borrows the
   attention red — but with the "!" glyph and the sentence itself carrying the
   meaning, never the color alone
   (docs/CODEBASE.html#style-glyph-needs-word). */
.result-list li[data-result="warn"]::before{content:"!";color:var(--red)}
/* ====================================================================
   13 · SIDE RACK + COMPACT HOMES (full mode only)
   The rack is the permanent home of every feature: Presentation and
   Camera locked-16:9 media panels (.rack-media, 54a — the CONTENT box
   keeps the ratio, the header is excluded from it), the collapsible
   Marks/Transcript panels, and the Teleprompter accordion at the very
   bottom. INVARIANT: homes are never removed or hidden when their
   feature is focused — focus promotes the feature to the stage; the
   home stays live and clickable (click anywhere on a media tile =
   Focus). Every panel carries ONE slim split header (item 54): a
   flex-1 .rack-focus button (aria-pressed = on stage, written by
   ui/focus.js) beside a 44x44 .rack-toggle chevron (aria-expanded,
   ui/rack.js); the teleprompter adds a 44x44 .rack-newtab between
   them. On media panels the expanded header OVERLAYS the tile's top
   edge at zero layout height; responsive.css tier 19b stacks it under
   text zoom. Compact mark rows are fixed-height with scroll-snap;
   inline editing lifts the row (.is-editing): tinted field, 2px cyan
   border + glow, explicit Save + Cancel.
   ==================================================================== */
.side-rack{display:none}
.compact-visual{
  position:relative;
  width:100%;
  aspect-ratio:16/9; /* 54a: the locked ratio lives on the CONTENT box, inside the panel body */
  overflow:hidden; /* item-32 audit: decorative media clip — 16:9 tile */
  background:var(--surface);
}
.rack-panel:hover,.rack-panel:focus-within{
  border-color:var(--cyan);
  box-shadow:var(--glow);
}
.compact-home-label{
  position:absolute;
  left:8px;
  right:8px;
  bottom:7px;
  min-height:38px;
  padding:7px 9px;
  display:flex;
  align-items:center;
  border-radius:var(--control-radius);
  background:rgba(5,8,15,.92);
  font-size:15px;
  font-weight:900;
  overflow-wrap:normal;
  word-break:normal;
}
.compact-slide-art{
  position:absolute;
  inset:0;
  padding:14px;
  display:flex;
  align-items:center;
  border-left:7px solid var(--cyan);
  background:var(--void);
  color:var(--cyan);
  font-size:17px/* designed compact-rail pin, NOT body-intent: the 2026-08-10 17->19 sweep misclassified it and made the recorded sibling-overlap hypothesis REAL (review-measured: 0px overlap at 17px, 14px at 19px with an ordinary 25-char deck name). Same exception family as the knock card. */;
  font-weight:900;
}
/* The deck MIRROR (Tor, 2026-08-17): the tile shows the presentation itself,
   never its file name. A second <img> painted from the SAME page-raster URL
   the stage renders (deck.js pageUrl — one fetch, the browser cache serves
   both). The art div stays behind it carrying the deck name as the
   load/error fallback; the image's opaque letterbox background keeps that
   text from peeking through while a real page is up. */
.compact-slide-image{
  position:absolute;
  inset:0;
  /* Explicit, like .slide-page-image and .camera-video: a REPLACED element's
     auto width/height resolve to its intrinsic size even with all four
     insets set — without these the raster paints at raster size and clips
     (Codex review, 2026-08-17). */
  width:100%;
  height:100%;
  object-fit:contain;
  background:var(--void);
}
.compact-camera-art{
  position:absolute;
  inset:0;
  background:
    radial-gradient(circle at 50% 34%,var(--dim) 0 7%,transparent 7.5%),
    radial-gradient(ellipse at 50% 100%,var(--steel) 0 29%,transparent 29.5%),
    linear-gradient(145deg,var(--navy),var(--surface) 72%);
}
/* The live preview in the rack home (walk item 11 — it used to show the
   gradient art and nothing else until the feature was focused into the
   stage). Painted over the art, under the "Online" badge that follows it
   in the DOM, and transparent to the pointer so the whole tile keeps
   being the one click that focuses the camera. */
.camera-home-video{pointer-events:none}
.rack-panel{
  min-width:0;
  padding:0; /* item 54: the header row runs flush to the panel edge; list bodies pad themselves below */
  display:grid;
  grid-template-rows:auto;
  gap:8px;
  overflow:hidden; /* item-32 audit: kept — the full-mode min-content floor (responsive.css 19) stops this clip reaching the header, and the list inside is a scrollport */
  border:1px solid var(--line);
  border-radius:var(--card-radius);
  background:var(--surface);
}
.rack-panel.is-expanded{grid-template-rows:auto minmax(0,1fr)}
/* Item 54 split header: ONE slim row, two adjacent targets (three on the
   teleprompter). The aria problem decides the anatomy: aria-expanded
   (disclosure) and aria-pressed (on stage) cannot live on one button, so the
   heading wraps the flex-1 Focus button and the 44x44 chevron sits beside it
   behind a hairline divider. Both are permanently visible — no hover-only
   affordance — and the Focus button's content WRAPS at 200% text zoom
   instead of clipping. Focus rings are inset so the panel's overflow clip
   can never swallow them. */
/* min-width:0 — the header is a grid item, and its automatic minimum is its
   min-content width (the word "Teleprompter" at 200% zoom plus two 44px
   buttons is wider than the rack); zeroed so the track never overflows the
   panel. flex-wrap — the transport bar's answer, reused: when a zoomed
   one-word label plus the fixed 44px buttons genuinely cannot share a line,
   the row breaks and every target stays whole and full size; at the design
   text size nothing ever wraps. */
.rack-panel-header{display:flex;flex-wrap:wrap;align-items:stretch;min-height:44px;min-width:0}
.rack-panel-header h3{margin:0;flex:1 1 auto;min-width:0;display:flex;font-size:18px}
.rack-focus{
  position:relative; /* anchors the sr-only name parts to the button's own origin */
  flex:1 1 auto;
  min-width:0;
  min-height:44px;
  padding:7px 9px;
  display:flex;
  flex-wrap:wrap;
  align-items:center;
  gap:8px;
  border:0;
  border-radius:0;
  background:transparent;
  font-size:18px;
  font-weight:900;
  text-align:left;
}
/* The sr-only "Focus " prefix and " marks" suffix are absolutely positioned
   1px boxes; pinned to the button's top-left so their (clipped, invisible)
   laid-out text always sits inside the button's own box — their default
   static position drifts under text zoom, which the ?qa=1 content-overflow
   rule rightly measures. */
.rack-focus .sr-only{left:0;top:0;margin:0}
.rack-focus svg{width:22px;height:22px;fill:var(--cyan);flex:0 0 auto}
.rack-focus:hover,.rack-focus:focus-visible{
  border:0;
  background:color-mix(in srgb,var(--blue) 32%,transparent);
  box-shadow:none;
}
/* The segmented-control idiom from the component vocabulary
   (docs/CODEBASE.html#style-components): the focused feature's header target
   fills blue with the 3px cyan inset edge; on media tiles it reads as a lit
   title strip. ui/focus.js updateWindowActions is the one writer. */
.rack-focus[aria-pressed="true"]{
  background:var(--blue);
  box-shadow:inset 3px 0 0 var(--cyan);
}
.rack-focus[aria-pressed="true"] .mark-count{background:var(--navy)}
.rack-toggle,.rack-newtab{
  flex:0 0 44px;
  min-height:44px;
  display:grid;
  place-items:center;
  border:0;
  border-left:1px solid var(--line);
  border-radius:0;
  background:transparent;
}
.rack-toggle:hover,.rack-newtab:hover,.rack-toggle:focus-visible,.rack-newtab:focus-visible{
  border:0;
  border-left:1px solid var(--cyan);
  background:color-mix(in srgb,var(--blue) 32%,transparent);
  box-shadow:none;
}
.rack-focus:focus-visible,.rack-toggle:focus-visible,.rack-newtab:focus-visible{
  outline:3px solid var(--cyan);
  outline-offset:-3px;
}
.rack-newtab svg{width:19px;height:19px;fill:var(--cyan)}
.rack-glyph{width:19px;height:19px;fill:var(--cyan)}
/* 54a media panels. Block flow, not the panel grid: expanded, the header is
   absolutely overlaid on the tile (zero layout height — the 16:9 CONTENT box
   is the whole panel height); collapsed, the body is hidden and the very
   same header is the panel, a normal 46px row. */
.rack-media{display:block;position:relative}
.rack-media.is-expanded>.rack-panel-header{
  position:absolute;
  inset:0 0 auto 0;
  z-index:8; /* today's overlay layer — above the tile art and the home video */
  background:rgba(5,8,15,.92); /* the shipped chip idiom: ~13:1 for --text over a worst-case white frame */
  border-bottom:1px solid var(--line);
}
.rack-media .rack-panel-body{padding:0}
/* The slide tile's art starts below the 44px strip so its title text is
   never occluded; its bottom page-label chip is unchanged. */
.rack-media.is-expanded .compact-slide-art{padding-top:52px}
/* The camera tile owes the SAME clearance (Tor, 2026-08-17: the preview was
   cut off by the Camera strip). Padding cannot deliver it here — the home
   video is absolutely positioned inset:0 INSIDE the art, and padding on its
   containing block does not inset an abs-pos child — so the art itself
   starts below the strip and the video letterboxes inside what remains. */
.rack-media.is-expanded .compact-camera-art{top:52px}
/* The deck mirror clears the strip the same way — height shrinks WITH the
   offset (height:100% would push 52px past the tile into the clip). */
.rack-media.is-expanded .compact-slide-image{top:52px;height:calc(100% - 52px)}
.rack-panel-body{
  min-width:0;
  min-height:0;
  padding:0 9px 9px;
  display:grid;
  grid-template-rows:minmax(0,1fr);
  overflow:hidden; /* item-32 audit: layout clip backed by the mark list's own scrollport below */
}
.rack-panel-body[hidden]{display:none}
.live-prompter-preview{
  margin:0;
  min-height:44px;
  font-size:15px;
  line-height:1.5;
  color:var(--dim);
}
.compact-marks{min-height:0}
.mark-count{
  min-width:34px;
  min-height:34px;
  padding:5px;
  display:grid;
  place-items:center;
  border-radius:50%;
  background:var(--blue);
  color:var(--text);
  font-size:15px;
  font-weight:900;
}
.compact-mark-list{
  min-height:0;
  display:grid;
  align-content:start;
  gap:8px;
  /* Item 32 correction: this shipped overflow:hidden, with the JS row fit as
     the excuse — whole rows are hidden, so nothing real reaches the clip. But
     the fit is measured at the DESIGN text size and text zoom doubles the
     rows underneath it, so the fit's arithmetic goes stale and the clip cuts
     a live row's edit button (measured at 1440x900 @200% text). A scrollport
     behaves identically while the fit is right — the fit trims content to
     clientHeight, so there is nothing to scroll and no scrollbar — and turns
     honest exactly when the fit is wrong. The .is-editing scroll below is now
     the same declaration kept for its comment's sake. */
  overflow-y:auto;
  overscroll-behavior:contain;
  /* Documented deviation from the frozen mockup
     (docs/CODEBASE.html#style-accessibility-over-fidelity). Without this the
     implicit row tracks size to the ROW'S min-height (104px) and not to what
     the row contains, so anything taller than 104px spills out of its own
     track and is painted over by the next row. Measured at 1440x900 with an
     editor open: row content 219px, row box 104px, and elementFromPoint over
     the Save button returned .compact-mark-row — the control was not merely
     clipped, it was covered, which is why Tor could type a note mid-take and
     never reach Save. The same cap silently cut long captured words off every
     ordinary row, and features/marks.js's overflow fit measured the capped 104
     and believed them to fit. min-content sizes each track to its row, which
     is what the fit was always assuming. */
  grid-auto-rows:min-content;
}
.view-all-marks{
  min-height:48px;
  padding:8px 12px;
  display:flex;
  align-items:center;
  justify-content:center;
  gap:8px;
  border:1px solid var(--cyan);
  border-radius:var(--control-radius);
  background:var(--blue);
  font-size:15px;
  font-weight:900;
}
.view-all-marks[hidden]{display:none}
/* The second half of item 12. features/marks.js hides whole rows that do not
   fit, and an open editor row is taller than the whole list at the rack's
   real height — so every arrangement of "whole rows plus See-all" loses it.
   While an editor is open the list must be a real scrollport (item 32 made
   that the list's resting state above; this now restates it for the state
   that first proved it). align-content:start is inherited from the rule above
   and is load-bearing: a centred scrollport pushes a too-tall child's TOP
   above scroll origin where nothing can reach it (the S11 lesson) — check
   both edges, never only the bottom. */
.compact-mark-list.is-editing{overflow-y:auto;overscroll-behavior:contain}
.compact-mark-row{
  min-height:104px;
  padding:8px;
  display:grid;
  grid-template-columns:minmax(0,1fr) 48px;
  align-items:start;
  gap:9px;
  scroll-snap-align:start;
  scroll-snap-stop:always;
  border-left:4px solid var(--steel);
  background:var(--raised);
}
.compact-mark-copy{min-width:0;display:grid;align-content:start;gap:5px}
.compact-mark-time{
  color:var(--cyan);
  font:900 15px/1.1 ui-monospace,SFMono-Regular,Menlo,monospace;
  letter-spacing:.03em;
}
.compact-mark-row p{
  margin:0;
  font-size:15px;
  line-height:1.28;
  overflow-wrap:anywhere;
  word-break:normal;
}
.compact-mark-row button{
  justify-self:end;
  min-width:48px;
  min-height:44px;
  padding:7px;
  display:grid;
  place-items:center;
  border:1px solid var(--cyan);
  border-radius:var(--control-radius);
  background:var(--blue);
  font-size:15px;
  font-weight:900;
}
.compact-mark-row button svg{width:25px;height:25px;fill:currentColor}
.compact-mark-editor-shell{
  grid-column:1/-1;
  min-width:0;
  padding-top:3px;
  display:grid;
  gap:7px;
}
.compact-mark-editor-shell label{color:var(--text);font-size:15px;font-weight:900}
.compact-mark-editor-shell textarea{
  width:100%;
  min-height:92px;
  padding:10px 12px;
  resize:vertical;
  border:2px solid var(--cyan);
  border-radius:var(--control-radius);
  background:color-mix(in srgb,var(--blue) 30%,var(--surface));
  color:var(--text);
  box-shadow:var(--glow);
  font-size:19px;
  line-height:1.4;
  overflow-wrap:anywhere;
  word-break:normal;
}
.compact-mark-row.is-editing{
  border-left-color:var(--cyan);
  background:color-mix(in srgb,var(--blue) 16%,var(--raised));
  outline:2px solid var(--cyan);
  outline-offset:-2px;
}
/* Item 32, the .producer-action-bar em-floor precedent: "1fr auto" gave Save
   whatever Cancel left over, and at 200% text that was 48px for a 65px label
   — the word painted out of its own button (measured populated at 410x590
   and 1180x820). The em floor keeps the mockup's side-by-side pair at the
   design's text size and stacks the pair only once a column can no longer
   hold its own verb; min() guards editors narrower than one floor. */
.mark-editor-actions{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(7em,100%),1fr));gap:8px}
.mark-editor-actions button{
  min-height:48px;
  padding:8px 12px;
  display:flex;
  align-items:center;
  justify-content:center;
  gap:7px;
  border-radius:var(--control-radius);
  font-size:16px;
  font-weight:900;
}
.mark-editor-actions .mark-editor-save{border:2px solid var(--cyan);background:var(--blue)}
.compact-mark-row.is-editing{align-items:start}
.live-transcript-preview{
  min-height:0;
  padding:10px 12px;
  overflow-y:auto;
  border:1px solid var(--line);
  border-radius:var(--control-radius);
  background:var(--raised);
  color:var(--text);
  font-size:15px;
  line-height:1.4;
  overflow-wrap:anywhere;
  word-break:normal;
}
/* ====================================================================
   14 · FIRST-RUN SELF TEST CALLOUT
   The can't-miss action for a new instructor: a center-stage red-glow
   card with one dominant action (Run Self Test) and one quiet dismiss
   (Later). The 65+ rule in its purest form — the required action is
   the loudest thing on screen.
   ==================================================================== */
.first-run-callout{
  position:absolute;
  z-index:45;
  left:50%;
  top:50%;
  width:min(520px,calc(100% - 24px));
  max-height:calc(100% - 18px);
  padding:18px;
  display:grid;
  gap:10px;
  overflow-y:auto;
  transform:translate(-50%,-50%);
  border:3px solid var(--red);
  border-radius:var(--card-radius);
  background:rgba(11,19,34,.99);
  box-shadow:var(--shadow),0 0 42px -7px var(--red);
}
/* Accessibility beats fidelity to the frozen shape, and the deviation is
   commented at the site (docs/CODEBASE.html#style-accessibility-over-fidelity)
   — the callout stops swallowing the clicks it was never meant to take.
   partials/stage.php states the ruling in its own comment:
   this is deliberately NOT aria-modal, because "the rail, switcher and every
   control behind this callout stay interactive on purpose — 'Later' is a soft
   dismissal, not the only way past". A 520px card at z-index 45 in the middle
   of the stage was not honouring that: everything it physically covered was
   dead to the pointer while looking perfectly available. Measured twice, from
   two directions — Purvis found it over the marks editor's Save button at
   844x390 and 390x844, and journey-gaps' file download failed with
   "<aside class='first-run-callout'> intercepts pointer events" the moment the
   instructor Files panel got its real styling and its Download button landed
   under the card.
   The card's only interactive parts are its two actions, so only they take
   the pointer. Keyboard reach is untouched (nothing here changes focus order
   or tab stops), and the card still paints over the stage — it just no longer
   claims clicks meant for what it is sitting on top of. */
.first-run-callout{pointer-events:none}
.callout-actions button{pointer-events:auto}
.first-run-callout h2,.first-run-callout p{margin:0;overflow-wrap:normal;word-break:normal}
.first-run-callout h2{font-size:24px}
.first-run-callout p{color:var(--dim);font-size:19px}
.callout-actions{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px}
.callout-actions button{
  min-height:52px;
  padding:9px 14px;
  border-radius:var(--control-radius);
  font-size:19px;
  font-weight:900;
}
.callout-actions .run-test{border:2px solid var(--azure);background:var(--blue);box-shadow:var(--azure-glow)}
/* ====================================================================
   S3 · CAMERA PANEL — LIVE PREVIEW (additive; not in the mockup)
   features/camera.js mounts everything below inside the frozen
   .camera-surface: the live <video>, the camera-off empty state with
   its one loud Start action, the permission-denial line, and the
   hide-my-preview toggle (self-view hiding only — tracks and the
   recording are never touched). The Self Test camera row's Live
   button gains a real .is-live state here (a documented deviation,
   docs/CODEBASE.html#style-accessibility-over-fidelity; its site
   comments live in camera.js / panels-self-test.php). Floor holds
   throughout: text ≥15px, controls ≥17px, targets ≥44px. base.css's global
   [hidden]{display:none!important} is what off-states rely on — do
   not re-derive visibility with classes here.
   ==================================================================== */
/* contain, not cover: the preview is the 16:9 letterbox invariant made
   visible — a frame is letterboxed against the void, never distorted,
   never cropped into a lie about the recorded framing. */
.camera-video{
  position:absolute;
  inset:0;
  width:100%;
  height:100%;
  object-fit:contain;
  background:var(--void);
}
.camera-empty{
  position:absolute;
  inset:0;
  padding:14px;
  display:grid;
  place-content:safe center;
  justify-items:center;
  gap:12px;
  overflow-y:auto;
  text-align:center;
  background:rgba(5,8,15,.62);
}
.camera-empty-copy{
  margin:0;
  max-width:44ch;
  color:var(--text);
  font-size:19px;
  line-height:1.4;
  overflow-wrap:normal;
  word-break:normal;
}
.camera-start{
  /* S9 open-item-14 ruling applied symmetrically (measured: this control
     carried the identical defects as .deck-upload-cta — grid stretch
     pinned it to 52px while 200% text wrapped its label 70px tall at six
     tested-viewport cells (docs/CODEBASE.html#arch-viewport-matrix), and
     it clipped its surface at 844x390/640x310). Same three
     cures as the deck CTA below: the minimum yields to the overlay, the
     wrapped label stays compact, and the action leads the copy. */
  min-width:min(200px,100%);
  min-height:52px;
  padding:9px 16px;
  display:flex;
  align-items:center;
  justify-content:center;
  gap:8px;
  border:2px solid var(--azure);
  border-radius:var(--control-radius);
  background:var(--blue);
  box-shadow:var(--azure-glow);
  font-size:19px;
  font-weight:900;
  line-height:1.15;
}
.camera-empty .camera-start{align-self:start;order:-1}
/* The wording carries the failure; the red border is reinforcement, never
   the message (docs/CODEBASE.html#style-glyph-needs-word). Module-authored
   prose, so normal wrapping. */
.camera-denied{
  margin:0;
  max-width:52ch;
  padding:10px 12px;
  border:1px solid var(--red);
  border-radius:var(--control-radius);
  background:rgba(255,59,107,.12);
  color:var(--text);
  font-size:19px;
  line-height:1.4;
  overflow-wrap:normal;
  word-break:normal;
}
.camera-hidden-note{
  position:absolute;
  left:50%;
  top:50%;
  margin:0;
  max-width:calc(100% - 24px);
  /* S3 review (Hudson F2): at 200% text zoom in compact the note outgrew its
     overflow:hidden surface and clipped unreadable — the zoom gate's "no
     content lost" floor (docs/CODEBASE.html#style-zoom-and-reflow). Cap it
     to the surface and let the note itself scroll. */
  max-height:calc(100% - 18px);
  overflow-y:auto;
  padding:10px 14px;
  transform:translate(-50%,-50%);
  border:1px solid var(--line);
  border-radius:var(--control-radius);
  background:rgba(5,8,15,.84);
  color:var(--text);
  font-size:19px;
  line-height:1.4;
  text-align:center;
  overflow-wrap:normal;
  word-break:normal;
}
/* Bottom-left, opposite the bottom-right .camera-readout, mirroring the
   badge/readout corner language the mockup established. */
.camera-hide-toggle{
  position:absolute;
  left:10px;
  bottom:9px;
  min-height:44px;
  padding:8px 12px;
  display:flex;
  align-items:center;
  gap:7px;
  border:1px solid var(--line);
  border-radius:var(--control-radius);
  background:rgba(5,8,15,.88);
  color:var(--text);
  font-size:19px;
  font-weight:900;
}
.camera-hide-toggle:hover,.camera-hide-toggle:focus-visible{border-color:var(--cyan);box-shadow:var(--glow)}
/* The Live button while the preview stream is really live: devices.js
   mirrors the state to aria-pressed, and the ● glyph joins the word so
   the state never rides on color alone
   (docs/CODEBASE.html#style-glyph-needs-word). */
.self-test-side.is-live{border-color:var(--cyan);background:rgba(7,76,142,.38);box-shadow:var(--glow)}
.self-test-side.is-live::before{content:"●";margin-right:6px;color:var(--good)}
/* ====================================================================
   S3b · DEVICE PICKERS UNDER THE PREVIEW (walk item 19 — additive)
   The Camera and Microphone pickers left the setup rail and sit under
   the camera preview on the stage. No new component: they are the
   design system's own .setup-control/.setup-select, pinned the way
   .booking-form and .whitelist-add already pin them when those controls
   are reused outside the rail — 17px text, a 48px target, and the label
   kept VISIBLE (responsive.css hides .setup-control>span in FULL mode,
   which is right for a sheet sitting inline under the step row that
   names it and wrong for a bare dropdown under a picture).

   They were ALSO under the rack's compact camera home, which is what
   Tor ruled, and are not any more. The rack has 190px of slack at
   1440x900 and the marks editor needs all of it (see the note in
   partials/rack.php for the full arithmetic); two pickers there cost
   108px of it and left the editor unopenable, and at 200% text zoom the
   rack tile — 16:9, so its height comes from its WIDTH and not from the
   track it is given — overflowed its shrunken track and painted over
   both pickers, which then could not be clicked at all. One 16:9 tile
   in that column is what fits, and it is the tile that walk item 11 is
   about.
   ==================================================================== */
.device-pickers{min-width:0;display:grid;gap:6px}
.device-pickers .setup-control{gap:4px}
.device-pickers .setup-control>span{display:block;font-size:15px}
.device-pickers .setup-select{font-size:19px;min-height:48px}
/* The stage panel now has a second row, so its 16:9 surface must be
   driven from that row rather than from the whole cell — the identical
   correction .presentation-panel carries for its slide-nav row (stage.css
   §9): height capped by the row, width by the ratio, so the letterbox
   shrinks instead of overshooting and clipping. */
.camera-panel.is-active{grid-template-rows:minmax(0,1fr) auto;gap:8px}
@supports(width:1cqh){
  .camera-panel .visual-surface{width:auto;height:min(100%,calc(100cqw * 9 / 16))}
}
/* Wide enough to read a device name, never wider than the picture it
   belongs to. */
.camera-panel .device-pickers{width:min(100%,560px);justify-self:center}
/* ====================================================================
   S4 · PRESENTATION PANEL — DECK PAGE DISPLAY (additive; not in the mockup)
   features/deck.js mounts everything below inside the frozen
   [data-slide-canvas] .wcpe-slide: the rasterized page <img>, the
   no-deck empty state with its one loud Upload action (the S3
   .camera-empty precedent), the upload progress/failure line, and the
   page-load failure note. The deviation's site comments live in deck.js
   (docs/CODEBASE.html#style-accessibility-over-fidelity). New
   selectors only — no frozen rule is edited. The frozen .slide-page
   counter keeps its z-index:2, which already paints above the page
   image's auto stacking level; the module overlays take level 3 so
   the seed shape's z-index:1 nodes never bleed through their copy.
   Floor holds throughout: text ≥15px, controls ≥17px and ≥44px, and
   base.css's global [hidden]{display:none!important} is what the
   off-states rely on.
   ==================================================================== */
/* contain, not cover: the page letterboxes inside the 16:9 surface —
   the stage invariant (docs/CODEBASE.html#arch-stage-invariant) — full-bleed
   over the slide padding, never distorted, never cropped. */
.wcpe-slide .slide-page-image{
  position:absolute;
  inset:0;
  width:100%;
  height:100%;
  object-fit:contain;
  background:var(--void);
}
/* A ready deck replaces the seed slide shape (a documented deviation,
   docs/CODEBASE.html#style-accessibility-over-fidelity — see deck.js):
   the seed nodes hide rather than unmount, because the
   empty state needs them back if the deck is ever replaced mid-flight. */
.wcpe-slide.has-deck .slide-brand,
.wcpe-slide.has-deck h3,
.wcpe-slide.has-deck .slide-subtitle,
.wcpe-slide.has-deck .slide-bullets{display:none}
.deck-empty{
  position:absolute;
  z-index:3;
  inset:0;
  padding:14px;
  display:grid;
  place-content:safe center;
  justify-items:center;
  gap:12px;
  overflow-y:auto;
  text-align:center;
  background:rgba(5,8,15,.62);
}
.deck-empty-copy{
  margin:0;
  max-width:44ch;
  color:var(--text);
  font-size:19px;
  line-height:1.4;
  overflow-wrap:normal;
  word-break:normal;
}
.deck-upload-cta{
  /* Open item 14: the flat 200px minimum was itself the clip at 410x590
     (the halved 820x1180 browser-zoom cell) — a 200px button centred in a
     210px letterbox pokes through the surface's own clip on both sides.
     The minimum yields to the overlay's real width and the shipped 200px
     stands everywhere the overlay actually has 200px to give. */
  min-width:min(200px,100%);
  min-height:52px;
  padding:9px 16px;
  display:flex;
  align-items:center;
  justify-content:center;
  gap:8px;
  border:2px solid var(--azure);
  border-radius:var(--control-radius);
  background:var(--blue);
  box-shadow:var(--azure-glow);
  font-size:19px;
  font-weight:900;
  /* Open item 14: at 200% text in a compact cell the label wraps to two
     lines and every extra line-height pixel is paid inside a ~120px
     overlay window; 1.15 keeps the wrapped button inside that window.
     One line at the design size sits in the 52px minimum either way. */
  line-height:1.15;
}
/* Open item 14, three defects in one control, each accessibility over
   fidelity to the shipped S4 shape
   (docs/CODEBASE.html#style-accessibility-over-fidelity):
   · The sprite glyph shipped unsized, so it rendered at the SVG
     replaced-element default (300x150) and drove the button to ~400px —
     wider than the empty-state overlay on every compact viewport, clipped
     at both edges. Sized like every other glyph-in-a-control here
     (.export-button, .self-test-run).
   · The default grid-item stretch pinned the button's used height to its
     own 52px row even once 200% text wrapped the label to two lines — the
     glyphs painted 19px past both edges of the box. align-self:start lets
     the button grow with its label; at the design's text size the label
     is one line and 52px stands exactly as before.
   · order:-1 paints the action ABOVE the copy. At 200% text on a compact
     cell the overlay is a ~120px scroll window and the copy alone is
     330px, so the button laid out entirely below the window — invisible
     until scrolled, and hanging past the work area's clip. Action-first
     keeps the one required action inside the window at every size (the
     floor's obvious-next-action clause,
     docs/CODEBASE.html#style-accessibility). DOM order is
     unchanged (copy first for readers); the button is the only focusable
     thing in the overlay, so visual-vs-DOM order cannot desync tabbing. */
.deck-upload-cta svg{width:22px;height:22px;flex:0 0 auto;fill:currentColor}
.deck-empty .deck-upload-cta{align-self:start;order:-1}
/* A lower-third strip, not a centered card: during a failed upload it
   must coexist with the re-shown .deck-empty action above it. Capped to
   the surface with internal scroll (the S3 Hudson-F2 lesson) so a long
   server sentence at 200% text never clips unreadable. */
.deck-progress{
  position:absolute;
  z-index:3;
  left:50%;
  bottom:12px;
  margin:0;
  max-width:calc(100% - 24px);
  max-height:calc(100% - 18px);
  overflow-y:auto;
  padding:10px 14px;
  display:flex;
  align-items:flex-start;
  gap:8px;
  transform:translateX(-50%);
  border:1px solid var(--line);
  border-radius:var(--control-radius);
  background:rgba(5,8,15,.88);
  color:var(--text);
  font-size:19px;
  line-height:1.4;
  text-align:left;
  overflow-wrap:anywhere;
  word-break:normal;
}
/* The alert glyph joins the words only on failure — shape carries the
   state, and a percent line needs no glyph at all. */
.deck-progress .deck-progress-icon{display:none;width:20px;height:20px;flex:0 0 auto;margin-top:1px;fill:var(--red)}
.deck-progress.is-error{border-color:var(--red)}
.deck-progress.is-error .deck-progress-icon{display:block}
/* In-surface page-load failure: words over a broken-image glyph. */
.slide-page-error{
  position:absolute;
  z-index:3;
  left:50%;
  top:50%;
  margin:0;
  max-width:min(52ch,calc(100% - 24px));
  max-height:calc(100% - 18px);
  overflow-y:auto;
  padding:10px 14px;
  transform:translate(-50%,-50%);
  border:1px solid var(--red);
  border-radius:var(--control-radius);
  background:rgba(5,8,15,.88);
  color:var(--text);
  font-size:19px;
  line-height:1.4;
  text-align:center;
  overflow-wrap:normal;
  word-break:normal;
}
/* The no-deck reasons ui/support.js appends after Previous/Next must sit
   BELOW the frozen two-button row, keeping that row's shape intact above
   them. Open item 14 correction: this shipped as `grid-column:1 / -1`,
   which is a dead declaration on a flex container (.local-slide-nav is
   flex) — so each reason actually flowed INLINE between the two buttons,
   and at 200% text a wrapped reason pushed Next a full screen of nav
   below Previous, past every clip. flex-basis:100% is the flex spelling
   of the same declared intent — each reason takes a full row — and
   order:1 is what actually puts both rows AFTER the button row: the DOM
   interleaves button/reason/button/reason (each reason is appended to its
   own control), and flex wrapping is sequential, so without it a full-row
   reason still split the two buttons onto different lines. DOM order (and
   with it reading order) is untouched; the buttons are the only focusable
   things in the band, so tab order cannot desync. */
.local-slide-nav .support-reason{flex-basis:100%;order:1}
/* ====================================================================
   S9 · EMPTY-SURFACE ESCAPE — the pinched-band action surface
   A documented accessibility-over-fidelity deviation
   (docs/CODEBASE.html#style-accessibility-over-fidelity), implementing
   the S9 orchestrator ruling:
   "The §3 16:9 letterbox invariant protects rendered visual content — a
   slide or camera image. The deck-empty state is an ACTION SURFACE, not
   content: it has no aspect to preserve, and §10 already rules a control
   the user cannot reach is never correct. Therefore, when NO deck is
   ready, the empty surface may abandon the 16:9 clip and lay out at
   content height inside the (scrollable) stage cell. With a real deck
   present, 16:9 holds everywhere, unchanged."
   (The ruling is quoted verbatim; the two rules it names are now
   docs/CODEBASE.html#arch-stage-invariant and
   docs/CODEBASE.html#style-zoom-and-reflow.)

   Why banded and not unconditional: everywhere the letterbox has room
   (FULL mode, tall compact) the frozen seed-slide empty state renders
   whole and passes every measurement — abandoning it there would spend
   mockup fidelity buying nothing. The escape fires only where the
   letterbox is provably too small to hold its own mandatory action (a
   ≥44px CTA in a ≤27px padded window): the shell-scroll band (≤240px)
   and a work-area tier, below.

   Item 32 correction — the tier now asks the STAGE CELL, not the role
   view. It shipped as `studio-work (max-height:15em)`, and that measured
   the wrong box: the role view stays tall while a grown TOPBAR (a long
   session title at 200% text zoom — Boko's item-19 finding) starves the
   cell, so at 390x844 the cell fell to 213px, the escape sat quiet at
   role-view 661px, and the Upload CTA clipped dead behind the letterbox.
   The cell is the box the surface actually lives in, and it is already a
   size container (stage.css §8), so the unnamed query below resolves
   against it. The threshold is the measured fail
   condition: the surface gets cell − nav band − gap, the nav band caps at
   2.4em + 45px (stage.css §9) and the wrapped CTA box wants ~3em + 18px,
   so the letterbox cannot hold its action once the cell is under about
   5.4em + 63px — 247px in the audit's text-zoom world, where the
   measured pass/fail boundary sits between 251px (passes with 6px of
   slack) and 213px (fails). At the design's text size the threshold is
   155px, which the tested matrix's smallest cell (640x310, ~120px — the
   same window the old 15em tier was sized to catch) is under and every
   other matrix cell (docs/CODEBASE.html#arch-viewport-matrix) is
   comfortably over, so nothing changes at 100% except that the escape
   can no longer be starved from above. The camera-empty surface shares
   the trigger; its lone CTA needs less than the deck's CTA-plus-nav, so
   the shared threshold fires a band early for it — mockup art traded in
   an already-pinched cell, judged cheaper than a second diverging tier.
   Like the old tier, this one only ever fires under text zoom or on
   windows smaller than the tested matrix (and 640x310).

   Mechanism: display:contents removes the surface's box (so nothing
   16:9-shaped exists to protect — the audit's ratio/containment rules
   skip a box that is not rendered) and the empty overlay becomes a
   normal grid/block child of the panel: full width, content height,
   scrolling inside itself past the cell rather than truncating
   (own-container scrolling, docs/CODEBASE.html#style-no-truncation).
   The seed-slide chrome (brand line, title/subtitle/bullet shells, the
   page counter, the decorative ring) states facts about a slide that
   does not exist; it is PART of the 16:9 look the ruling lets go, and
   returns untouched the moment the band relaxes or a deck lands.
   Applied symmetrically to the camera-off surface (measured carrying
   the same defect family); its off-state is keyed with :has() on the
   visible .camera-empty — first :has() in the codebase, inside the
   browser-support gate (docs/CODEBASE.html#arch-browser-support-gate;
   Chrome 105+/Safari 15.4+/current Firefox).
   Instructor-scoped: the producer's seed slide has no in-surface action
   and measures green everywhere; it keeps the frozen look.
   ==================================================================== */
@container (max-height:calc(5.4em + 63px)){
  /* Item 32: in the escaped state the PANEL is the scrollport. The grid rows
     the panel uses at full size (minmax(0,1fr) auto) exist to letterbox a
     surface that no longer exists here, and in a cell this small they starve
     the overlay to 0 and overflow the nav band through the panel's clip by
     exactly the row gap (measured at 410x590 @200% text: overlay 12px, Next
     clipped dead at the panel edge). Content-sized rows plus an honest
     panel scrollbar make every control one scroll away — own-container
     scrolling (docs/CODEBASE.html#style-no-truncation), on the panel that
     owns the content. The ≤240px band is
     excluded by construction (the cell is an inline-size container there and
     a height query on it is false), which is right: there the SHELL scrolls,
     and a nested scroller would re-clip what 18b already made reachable. */
  [data-role="instructor"] .feature-panel:has(>.wcpe-slide:not(.has-deck)),
  [data-role="instructor"] .feature-panel:has(>.camera-surface>.camera-empty:not([hidden])){
    grid-template-rows:auto auto;
    overflow-y:auto;
  }
  /* Short landscape floats the paging over the slide (responsive.css §18) —
     an overlay sized for a 16:9 picture, floating over an escaped ACTION
     surface, lands squarely on the Upload CTA (measured at 640x310 @200%
     text: elementFromPoint over the CTA hit the paging button). With no
     slide there is nothing to overlay; the paging returns to the flow, the
     18b answer to the same collision. */
  [data-role="instructor"] .feature-panel:has(>.wcpe-slide:not(.has-deck)) .local-slide-nav{
    position:static;
    transform:none;
    justify-content:center;
  }
  [data-role="instructor"] :is(.wcpe-slide:not(.has-deck),.camera-surface:has(>.camera-empty:not([hidden]))){display:contents}
  [data-role="instructor"] .wcpe-slide:not(.has-deck)::after{content:none}
  [data-role="instructor"] :is(.wcpe-slide:not(.has-deck),.camera-surface:has(>.camera-empty:not([hidden])))
    :is(.slide-brand,[data-slide-title],.slide-subtitle,.slide-bullets,.slide-page,.camera-readout){display:none}
  [data-role="instructor"] :is(.wcpe-slide:not(.has-deck) .deck-empty,.camera-surface:has(>.camera-empty:not([hidden])) .camera-empty){
    position:static;
    grid-row:1;
    place-self:stretch;
    min-height:0;
    /* 14px of overlay padding is a third of a 63px cell; the pinched
       state spends it on the CTA instead (640x310: 2+14+52 overshot the
       cell, 2+4+52 fits with room). */
    padding:4px 10px;
    border:2px solid var(--steel);
    border-radius:var(--card-radius);
  }
  /* Same trade at the control: the 52px PREFERRED height cannot fit a
     61px cell beside any chrome at all; 44px is the hit-target floor the
     accessibility rules actually set
     (docs/CODEBASE.html#style-accessibility), and the slim padding keeps
     a zoomed one-line label whole. */
  [data-role="instructor"] :is(.wcpe-slide:not(.has-deck) .deck-upload-cta,.camera-surface:has(>.camera-empty:not([hidden])) .camera-start){
    min-height:44px;
    padding-top:2px;
    padding-bottom:2px;
  }
}
@media(max-height:240px){
  [data-role="instructor"] :is(.wcpe-slide:not(.has-deck),.camera-surface:has(>.camera-empty:not([hidden]))){display:contents}
  [data-role="instructor"] .wcpe-slide:not(.has-deck)::after{content:none}
  [data-role="instructor"] :is(.wcpe-slide:not(.has-deck),.camera-surface:has(>.camera-empty:not([hidden])))
    :is(.slide-brand,[data-slide-title],.slide-subtitle,.slide-bullets,.slide-page,.camera-readout){display:none}
  [data-role="instructor"] :is(.wcpe-slide:not(.has-deck) .deck-empty,.camera-surface:has(>.camera-empty:not([hidden])) .camera-empty){
    position:static;
    grid-row:1;
    place-self:stretch;
    min-height:0;
    /* 14px of overlay padding is a third of a 63px cell; the pinched
       state spends it on the CTA instead (640x310: 2+14+52 overshot the
       cell, 2+4+52 fits with room). */
    padding:4px 10px;
    border:2px solid var(--steel);
    border-radius:var(--card-radius);
  }
  /* Same trade at the control: the 52px PREFERRED height cannot fit a
     61px cell beside any chrome at all; 44px is the hit-target floor the
     accessibility rules actually set
     (docs/CODEBASE.html#style-accessibility), and the slim padding keeps
     a zoomed one-line label whole. */
  [data-role="instructor"] :is(.wcpe-slide:not(.has-deck) .deck-upload-cta,.camera-surface:has(>.camera-empty:not([hidden])) .camera-start){
    min-height:44px;
    padding-top:2px;
    padding-bottom:2px;
  }
}
/* ====================================================================
   S5 · SCRIPT PANEL — IN-APP EDITOR + SHEET NAME LINE
   (additive; not in the mockup)
   A documented addition beyond the frozen mockup
   (docs/CODEBASE.html#style-accessibility-over-fidelity) — site comments
   live in panels-script.php, rail-instructor.php and features/script.js.
   New selectors only; no frozen rule is edited. Everything composes the
   frozen vocabulary:
   tokens.css variables, .setup-action buttons (shell.css), the
   text-panel grid, and base.css's global focus-visible ring. Floor
   holds: editor text 18px on a plain sans (the reader's own family,
   never the textarea's monospace default), every control ≥44px, and
   the filename line carries .user-text so a 90-character upload name
   wraps instead of scrolling the rail sheet sideways.
   ==================================================================== */
.script-editor{
  min-height:0;
  display:grid;
  align-content:start;
  gap:10px;
}
.script-editor-label{font-size:15px;font-weight:900}
.script-editor-text{
  width:100%;
  min-height:min(46vh,420px);
  padding:14px;
  border:1px solid var(--line);
  border-radius:var(--card-radius);
  background:var(--void);
  color:var(--text);
  font:inherit;
  font-size:18px;
  line-height:1.5;
  resize:vertical;
}
/* width:auto beats shell.css's width:100% so Save (the loud primary)
   and Cancel sit side by side and wrap as a pair when narrow. */
.script-editor-actions{display:flex;gap:10px;flex-wrap:wrap}
.script-editor-actions .setup-action{width:auto;min-width:170px}
/* The loaded script's filename under the sheet copy — which file is on
   the prompter is a fact the sheet's fixed sentence cannot carry. */
.script-sheet-name{color:var(--text);font-size:15px;font-weight:800}
/* Long-token ruling, docs/CODEBASE.html#style-long-token-policy
   (S5 fix, Jeeves finding 1): script text is USER data,
   so its paragraphs take overflow-wrap:anywhere — breaking only tokens with no
   legal break point (a pasted URL), leaving prose intact. Overrides the frozen
   .script-reader p rule above (equal specificity; later in cascade), which was
   authored against seed prose. Wrapping is always preferred to hiding. */
.script-reader p{overflow-wrap:anywhere}
/* ====================================================================
   S5b · THE FILES PANEL — SHARED BY BOTH ROLES (moved from producer.css)
   These rules were written when only the producer had a file browser.
   Journey gap G3 gave the instructor the same panel (partials/
   panels-files.php reads $role and serves both) — but src/surfaces/
   studio.php does not load producer.css, so on the instructor's own
   Files panel NONE of this applied: no scrollport (the panel kept
   .feature-panel's overflow:hidden), no row cards, and a Download
   control that rendered as a bare inline-block 177px tall and could
   not fit its own panel at 844x390. Measured on pristine HEAD as well
   as here, so it is inherited, not introduced.
   Moved rather than duplicated: one home per fact. panels.css loads
   for every role and BEFORE producer.css, so any producer-only
   override there still wins.
   ==================================================================== */
.workspace-panel{
  align-content:start;
  gap:12px;
  padding:3px;
  overflow-y:auto;
}
.file-workspace-header{
  display:grid;
  grid-template-columns:minmax(0,1fr) auto;
  align-items:center;
  gap:12px;
}
.file-workspace-header p{margin:0;color:var(--dim);font-size:16px;line-height:1.4;overflow-wrap:normal;word-break:normal}
.file-groups{display:grid;gap:12px}
.file-group{display:grid;gap:7px}
.file-group h3{
  margin:0;
  color:var(--cyan);
  font:900 16px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;
  overflow-wrap:normal;
  word-break:normal;
}
/* Long-token policy (docs/CODEBASE.html#style-long-token-policy): file
   rows render server data —
   a 90-character upload name has no legal break point, and truncation
   is forbidden. overflow-wrap:anywhere breaks only such strings and
   leaves ordinary words whole, so the no-mid-word-wrap floor still
   holds for prose. word-break stays normal: it would break everything.
   Set on the row so any name/meta text added later inherits it. */
.file-row{
  min-width:0;
  overflow-wrap:anywhere;
  padding:11px 12px;
  display:grid;
  grid-template-columns:minmax(0,1fr) auto;
  align-items:center;
  gap:10px;
  border:1px solid var(--line);
  border-radius:var(--card-radius);
  background:var(--surface);
}
.file-detail{min-width:0;display:grid;gap:4px}
.file-name{color:var(--text);font-size:19px;font-weight:900;line-height:1.2;overflow-wrap:anywhere;word-break:normal}
/* The file name IS the view affordance (item 35, widened to both roles by
   Tor's 2026-08-17 ruling: media plays in a new tab instead of only
   downloading) — a link-styled button with the pop-out glyph saying where it
   leads. Moved here from producer.css so the instructor's rows, which load
   panels.css but not producer.css, wear the same control. */
.files-view{
  display:inline-flex;align-items:center;gap:8px;
  min-height:44px;padding:4px 8px;margin:-4px -8px;
  border:0;background:none;cursor:pointer;text-align:left;
  color:var(--text);font-size:19px;font-weight:900;
  text-decoration:underline;text-decoration-color:var(--azure);text-underline-offset:3px;
  border-radius:var(--control-radius);
  overflow-wrap:anywhere;word-break:normal;
}
.files-view:hover,.files-view:focus-visible{color:var(--cyan);text-decoration-color:var(--cyan)}
.files-view-name{min-width:0}
.files-view-glyph{width:16px;height:16px;fill:var(--azure);flex:0 0 auto}
.file-meta{color:var(--dim);font-size:15px;line-height:1.25;overflow-wrap:anywhere;word-break:normal}
.file-download{
  min-width:124px;
  min-height:48px;
  padding:8px 12px;
  display:flex;
  align-items:center;
  justify-content:center;
  gap:7px;
  border:1px solid var(--azure);
  border-radius:var(--control-radius);
  background:var(--blue);
  font-size:15px;
  font-weight:900;
}
.file-download svg{width:22px;height:22px;fill:currentColor;flex:0 0 auto}
.workspace-panel>p{margin:0;color:var(--dim);font-size:19px}
/* ====================================================================
   S6 · TRANSCRIPT PANEL — PER-SEGMENT LIST + INLINE CORRECTION
   (additive; replaces the frozen bulk textarea's role, not its rule)
   A documented deviation from the frozen shape
   (docs/CODEBASE.html#style-accessibility-over-fidelity) — transcript_edit
   corrects exactly ONE segment and no bulk save op exists, so the row list
   is the editing surface; site comment in panels-transcript.php, row builder in
   core/dom.js (transcriptSegment). Rows and the inline editor reuse
   the mockup's own .compact-mark-row / .compact-mark-editor-shell
   vocabulary above, which already holds the ≥15px/≥44px floor (17px
   editor field, 44–48px targets), so this section adds only the
   panel-internal scroller and the notice/empty lines. New selectors
   only — no frozen rule is edited.
   ==================================================================== */
/* The segment list scrolls INSIDE the panel: .transcript-panel.is-active
   hands its minmax(0,1fr) row to this wrapper, and min-height:0 +
   overflow-y keep a thousand-line transcript off the page scroll (a tall
   element scrolls in its own container, never the page:
   docs/CODEBASE.html#style-no-truncation). */
.transcript-scroll{
  min-height:0;
  display:grid;
  align-content:start;
  gap:9px;
  overflow-y:auto;
}
.transcript-segments{display:grid;align-content:start;gap:8px}
/* Scoped under .transcript-panel: the frozen `.transcript-panel p` rule
   (16px, dim) outweighs a lone class and would silently re-shrink these
   below the 17px notice floor — the exact cross-stylesheet trap S1
   recorded for copy rendered inside this panel. */
.transcript-panel .transcript-notice,
.transcript-panel .transcript-empty{
  margin:0;
  padding:10px 12px;
  border:1px solid var(--line);
  border-radius:var(--control-radius);
  background:var(--raised);
  color:var(--text);
  font-size:19px;
  line-height:1.4;
  /* Notices carry server sentences and engine names — user/server data
     wraps, never scrolls the panel sideways
     (docs/CODEBASE.html#style-long-token-policy). */
  overflow-wrap:anywhere;
  word-break:normal;
}
/* The degradation/limit notice is a named loss, not decoration: the
   steel edge + weight read as status, while the state itself is carried
   by the words — never by color alone
   (docs/CODEBASE.html#style-glyph-needs-word). */
.transcript-panel .transcript-notice{
  border-left:4px solid var(--steel);
  font-weight:700;
}
/* ====================================================================
   §S7 · MARKS + CHAT + PRESENCE/KNOCK — additive slice styles
   ==================================================================== */
/* Shared join card: a passive instructor announcement or the producer's
   immediate join action. studio.php deliberately does not load producer.css. */
.knock-card{
  /* The card's own vertical rhythm, pinned with its sizes: its children used
     to inherit 1.35 from the old page default, and inheriting the new 1.5
     grew the two-row announcement past the 200%-zoom headroom the room-flow
     check demands. Component, not reading surface — same call as the span
     pin below. */
  line-height:1.35;
  position:absolute;
  z-index:60;
  left:50%;
  top:50%;
  width:min(480px,calc(100% - 24px));
  padding:15px;
  display:grid;
  grid-template-columns:minmax(0,1fr) auto;
  align-items:center;
  gap:7px;
  transform:translate(-50%,-50%);
  border:2px solid var(--cyan);
  border-radius:var(--card-radius);
  background:rgba(11,19,34,.97);
  box-shadow:var(--shadow),var(--glow);
}
/* Names the producer who joined — server data, so it wraps
   (docs/CODEBASE.html#style-long-token-policy). */
.knock-card strong{font-size:16px;overflow-wrap:anywhere;word-break:normal}
/* Pinned at the card's own designed size, like its strong (16px) and button
   (15px) siblings — this span was the card's ONLY inheriting text, so the
   2026-08-10 move to a 19px/1.5 page default grew the two-row announcement
   until its 200%-zoom headroom fell to 1.5px against the >=8px the room-flow
   check demands. A compact status overlay is a component, not a reading
   surface; it keeps component sizing (>=15px floor holds), and the pinned-size
   harmonisation slice will revisit all such pins together. */
.knock-card > span{grid-column:1;grid-row:2;overflow-wrap:anywhere;font-size:17px/* deliberate exception to the 2026-08-10 17->19 harmonisation: this pin exists for the 200%-zoom headroom invariant, reviewed and shipped days earlier */}
.knock-card button{
  grid-column:2;
  grid-row:1 / span 2;
  min-height:44px;
  padding:7px 10px;
  border-radius:var(--control-radius);
  font-size:15px;
  font-weight:900;
  white-space:nowrap;
}
/* The producer join card has one text row rather than the announcement's two. */
.knock-card.knock-request > button{grid-row:1}
/* In-flight state is carried in words; dimming is reinforcement only. */
.knock-card button:disabled{opacity:.55;box-shadow:none}
/* The mark editors' Save/Cancel/Delete glyphs were never sized anywhere
   (base.css only sets display:block) — invisible until S7 first rendered
   these buttons with real data. */
.mark-editor-actions button svg{width:22px;height:22px;fill:currentColor}
/* Delete affordance on the stage review card (a documented addition,
   docs/CODEBASE.html#style-accessibility-over-fidelity — see core/dom.js
   markReviewCard). Quiet by design: the wording carries
   the meaning; it spans the actions grid's full width on its own row. */
.mark-editor-actions .mark-delete{
  grid-column:1/-1;
  border:1px solid var(--line);
  background:var(--raised);
}
/* The explicit second step is the loud one, mirroring .retake-action's
   red treatment: a destructive confirm is never styled like Save. */
.mark-editor-actions .mark-delete-confirm-go{
  border:2px solid var(--red);
  background:rgba(255,59,107,.14);
}
/* ====================================================================
   §S8 · PRODUCER WORKSPACE PANELS — additive slice styles
   ==================================================================== */
/* Long-token ruling (docs/CODEBASE.html#style-long-token-policy) — the S5
   script-reader precedent, again: dom.js's
   fileGroup builder puts .user-text on the group heading (the folder name is
   server data), but producer.css's frozen `.file-group h3` says
   overflow-wrap:normal at 0,1,1 and outranks the 0,1,0 utility. Matching the
   frozen shape plus the class wins on specificity from either load position.
   (.workspace-card h3/p and .file-name/.file-meta need no override here:
   producer.css already carries their long-token anywhere flips.) */
.file-group h3.user-text{overflow-wrap:anywhere}
/* The S8 wirers render short notice/empty lines directly into the producer
   list hosts (a bare p, or files.js's .file-empty-note). Today they
   only inherit the body's default (19px since the 2026-08-10 ruling) —
   nothing pins it — so any later panel-scoped
   p rule would silently re-shrink them below the notice floor: the exact
   .transcript-panel 16px trap §S6 closed above. Pin the floor the same way,
   scoped to direct children so .workspace-card content is untouched. These
   lines carry server sentences verbatim (files.js surfaces the gate message),
   so they take the long-token anywhere treatment too
   (docs/CODEBASE.html#style-long-token-policy). (.session-list>p left this
   list with the class it targeted: no rendered markup carries .session-list —
   verified by grep over partials/, src/ and public/js — item 32 cleanup of
   the item-5 builder's flag.) */
.outbox-list>p,
.file-groups>p{
  margin:0;
  font-size:19px;
  line-height:1.4;
  overflow-wrap:anywhere;
  word-break:normal;
}
/* ====================================================================
   §S10 · PUBLIC BOOKING — SLOT GRID + REQUEST FLOW — additive slice styles
   ==================================================================== */
/* The centered card's grid gap belongs to .script-help-card (shell.css §7);
   the form and the confirmation inside it need their own rhythm for a stack
   of 48px controls, and both hide via the [hidden] attribute, which base.css
   makes win over any display set here. */
/* The booking page is the one page that scrolls: its whole UI is the fixed
   centered card, and .script-help-card is the scroll container. .app-shell's
   overflow:hidden exists for the rail/stage grid pages and clips NOTHING real
   here — but at 200% zoom it convicts any control straddling the fold as
   unreachable (?qa=1's clipped-button walk), when scrolling the card in fact
   reveals it. Making the shell non-clipping states the truth: nothing on this
   page is ever lost to the shell's edge. */
.app-shell.booking-page{overflow:visible}
.booking-form,.booking-done{display:grid;gap:12px}
.booking-form p{margin:0;color:var(--dim);font-size:19px}
/* The card borrows .setup-control/.setup-select/.setup-action from the rail
   sheets, but this is NOT the rail: the rail's FULL-mode rules hide the control
   labels (responsive.css §19) and pin the reused inputs/actions to 15px
   (shell.css §4). A public form for 65+ instructors must keep every field label
   visible and hold the 19px control size (Ruling 7's page default, above the
   floor in docs/CODEBASE.html#style-accessibility). Scoped a level deeper than
   the rail rules so it wins in every responsive band regardless of stylesheet
   order. */
.booking-form .setup-control>span{display:block;font-size:19px}
.booking-form .setup-select,.booking-form .setup-action{font-size:19px;min-height:48px}
/* The whitelist identity line ("Booking as …"): server data — the producer's
   name for the instructor plus the canonical email — so it carries user-text
   (overflow-wrap:anywhere via base.css) and reads at the body floor. It is
   the one fact the form no longer lets anyone edit, so it renders as the
   form's loudest copy, not as a control. */
p.booking-identity{color:var(--text);font-weight:700}
p.booking-identity strong{font-weight:900}
.booking-done h3{margin:0;font-size:20px}
.booking-done p{color:var(--text);font-size:19px;line-height:1.5}
/* Zoom-safe responsive grid — the .producer-action-bar em-floor precedent
   (shell.css §15): the column floor is measured in the text it carries, so
   200% text zoom widens the columns instead of squeezing the time ranges
   through each other, and min(...,100%) guards a card narrower than one
   floor. A range label may wrap to a second line; the buttons grow — no
   fixed height ever clips it. */
.booking-slots{
  display:grid;
  grid-template-columns:repeat(auto-fill,minmax(min(9em,100%),1fr));
  gap:9px;
}
.booking-slot{
  min-height:48px;
  padding:9px 12px;
  display:flex;
  flex-direction:column;
  align-items:center;
  justify-content:center;
  gap:3px;
  border-radius:var(--control-radius);
  font-size:19px;
  font-weight:800;
  text-align:center;
}
/* Selection is shape + glyph + state, never color alone
   (docs/CODEBASE.html#style-glyph-needs-word): the
   self-test-pills precedent — cyan edge, blue fill, and a ✓ drawn into the
   time line; booking.js mirrors the same state into aria-pressed. */
.booking-slot.is-selected{border-color:var(--cyan);background:var(--blue);box-shadow:var(--glow)}
.booking-slot.is-selected .booking-slot-time::before{content:"✓ ";color:var(--cyan);font-weight:900}
/* A taken slot stays on screen — the word "Taken" plus the disabled state
   carry the meaning; the dimming is reinforcement only
   (docs/CODEBASE.html#style-glyph-needs-word). */
.booking-slot.is-busy{opacity:.55}
.booking-slot-state{font-size:15px;font-weight:900;letter-spacing:.04em}
/* The grid's honest sentences (the pick-a-date hint, the loading line, the
   all-taken day) span the full grid row so they read as copy, not as a slot. */
.booking-slots-note{
  grid-column:1/-1;
  margin:0;
  padding:10px 12px;
  border:1px solid var(--line);
  border-radius:var(--control-radius);
  background:var(--raised);
  color:var(--text);
  font-size:19px;
  line-height:1.4;
}
/* Renders server json_err sentences verbatim — the error-message boundary
   (docs/CODEBASE.html#arch-error-message-boundary) — so it is user/server
   data: overflow-wrap:anywhere breaks only tokens with no legal break point
   rather than scrolling the card sideways
   (docs/CODEBASE.html#style-long-token-policy). p.booking-error outweighs the
   .booking-form p intro rule above at equal specificity by cascade order.
   Empty is the resting state — no reserved gap until there is something to
   say (role=alert announces the sentence the moment it lands). */
p.booking-error{
  padding:10px 12px;
  border:1px solid var(--line);
  border-left:4px solid var(--red);
  border-radius:var(--control-radius);
  background:rgba(255,59,107,.12);
  color:var(--text);
  font-size:19px;
  line-height:1.4;
  overflow-wrap:anywhere;
  word-break:normal;
}
p.booking-error:empty{display:none}

/* ---------------------------------------------------------------------------
   §S11 — the instructor identify gate (features/identify.js)
   The front door for a personal ?t= link before identify has run. Sized to the
   65+ floor: 17px+ copy, a 20px field, a full-width 52px action. Full-bleed
   backdrop, because unlike the pop-out this one genuinely blocks the page and
   says so with aria-modal + inert.
--------------------------------------------------------------------------- */
/* margin:auto on the card, never align-items:center on the scroll parent —
   the same ruling as shell.css's .support-gate, and for the same reason: auto
   margins centre the card while it fits and COLLAPSE when it overflows, so a
   tall card's top stays scroll-reachable. Centring a scrollport's flex child
   pushes its top above the scroll origin, where no scroll can reach it; at
   844x390 under 200% browser zoom that put the title at -48px with scrollTop
   already 0 — the instructor could not read the only entry form on the page. */
.identify-gate{
  position:fixed;inset:0;z-index:120;
  display:grid;
  padding:24px;overflow-y:auto;
  background:rgba(3,10,20,.86);
}
.identify-card{
  width:min(560px,100%);
  margin:auto;
  background:var(--panel,#0f2036);
  border:2px solid var(--accent,#2f81f7);
  border-radius:14px;
  padding:28px;
  box-shadow:0 18px 60px rgba(0,0,0,.55);
}
.identify-title{font-size:26px;line-height:1.2;margin:0 0 10px}
.identify-blurb{font-size:19px;line-height:1.5;margin:0 0 20px;overflow-wrap:anywhere}
.identify-label{display:block;font-size:19px;font-weight:600;margin:0 0 8px}
.identify-field{
  display:block;width:100%;min-height:52px;
  font-size:20px;line-height:1.3;
  padding:10px 14px;
  border-radius:10px;
  border:2px solid var(--line,#2b4a6f);
  background:var(--field,#0a1729);color:inherit;
}
.identify-field:focus-visible{outline:3px solid var(--accent,#2f81f7);outline-offset:2px}
.identify-error{
  font-size:19px;line-height:1.5;margin:12px 0 0;
  color:var(--danger,#ff6b6b);overflow-wrap:anywhere;
}
.identify-error:empty{display:none}
.identify-submit{
  display:block;width:100%;min-height:52px;margin-top:20px;
  font-size:19px;font-weight:700;
  border-radius:10px;border:2px solid transparent;
  background:var(--accent,#2f81f7);color:#fff;cursor:pointer;
}
.identify-submit:disabled{opacity:.6;cursor:default}
.identify-submit:focus-visible{outline:3px solid #fff;outline-offset:2px}

/* ---------------------------------------------------------------------------
   G1 — the cancel path on the identify gate (features/identify.js)
   A quiet secondary region under the primary verb, then a two-step confirm.
   Every control here is held to the same 65+ floor as the gate above it: 17px+
   copy, 52px hit targets, focus rings that survive the dark backdrop. The
   destructive verb is danger-bordered AND says the destructive word, so the
   difference between the two buttons is never colour alone
   (docs/CODEBASE.html#style-glyph-needs-word).
--------------------------------------------------------------------------- */
.identify-cancel{
  margin-top:22px;
  padding-top:18px;
  border-top:1px solid var(--line,#2b4a6f);
  display:grid;
  gap:12px;
}
.identify-cancel-start{
  width:100%;min-height:52px;
  font-size:19px;font-weight:600;
  border-radius:10px;border:2px solid var(--line,#2b4a6f);
  background:transparent;color:inherit;cursor:pointer;
}
.identify-cancel-start:hover{border-color:var(--danger,#ff6b6b)}
/* Server data never lands in this region, but the copy is long prose that must
   wrap rather than clip at 200% zoom — wrapping always beats hiding
   (docs/CODEBASE.html#style-long-token-policy). */
.identify-cancel-copy{
  margin:0;
  font-size:19px;line-height:1.5;
  overflow-wrap:anywhere;
}
.identify-cancel-confirm,.identify-cancel-keep{
  width:100%;min-height:52px;
  font-size:19px;font-weight:700;
  border-radius:10px;cursor:pointer;
}
.identify-cancel-confirm{
  border:2px solid var(--danger,#ff6b6b);
  background:transparent;color:var(--danger,#ff6b6b);
}
/* The SAFE verb is the loud one: it is where focus lands and what an anxious
   instructor should be able to hit without reading twice. */
.identify-cancel-keep{
  border:2px solid transparent;
  background:var(--accent,#2f81f7);color:#fff;
}
.identify-cancel-start:focus-visible,
.identify-cancel-confirm:focus-visible,
.identify-cancel-keep:focus-visible{outline:3px solid #fff;outline-offset:2px}
/* The post-cancellation card offers a link, not a form action, so the shared
   primary styling needs an anchor's centring added back. */
a.identify-submit{
  display:flex;align-items:center;justify-content:center;
  text-decoration:none;text-align:center;
}
/* F1 (Purvis): the re-entry card's TERMINAL state — a refusal no retry can fix
   (revoked, window not open, window passed). The server's own sentence carries
   it, marked by a danger rule and bold weight rather than by colour alone
   (docs/CODEBASE.html#style-glyph-needs-word), and the primary action below
   it is the way OUT rather than a button that could never work. Server prose
   wraps, never clips (docs/CODEBASE.html#style-long-token-policy). */
.identify-terminal{
  margin:0 0 16px;
  padding:12px 14px;
  border-left:4px solid var(--danger,#ff6b6b);
  border-radius:8px;
  background:rgba(255,107,107,.1);
  font-weight:700;
  overflow-wrap:anywhere;
}

/* ---------------------------------------------------------------------------
   Item 36 (Tor, 2026-07-29) — the read-only studio
   The server ruled the session over; these are the two client faces of that
   ruling. The transport's verbs were never rendered (partials/transport.php),
   so this styles the sentence that stands in their place, and the shell's
   data-access hook REMOVES (display:none — out of the a11y tree and tab
   order, not merely faded) the controls whose verbs the server now refuses:
   deck/script upload, the in-app script editor entry, and the self-test step,
   none of which have a meaning after the session has ended. Everything holds
   the 65+ floor: 17px+ copy, 44px+ targets, no colour-only signalling.
--------------------------------------------------------------------------- */
.transport-readonly{
  min-width:0;
  display:flex;flex-wrap:wrap;align-items:center;justify-content:center;
  gap:10px 16px;
  padding:6px 4px;
}
.transport-readonly-copy{
  margin:0;
  font-size:19px;line-height:1.5;
  color:var(--dim);
  overflow-wrap:anywhere;
}
/* A real destination styled as the transport's one offered action: hairline
   rest border, cyan glow on hover/focus (docs/CODEBASE.html#style-glow),
   control radius. */
.transport-readonly-book{
  display:inline-flex;align-items:center;justify-content:center;
  min-height:44px;padding:8px 18px;
  border:1px solid var(--line);
  border-radius:var(--control-radius);
  background:var(--raised);
  color:var(--text);
  font-size:19px;font-weight:700;
  text-decoration:none;
}
.transport-readonly-book:hover,
.transport-readonly-book:focus-visible{
  border-color:var(--cyan);
  box-shadow:0 0 0 1px var(--cyan),0 0 18px -6px var(--cyan);
  outline:none;
}
.app-shell[data-access="readonly"] [data-deck-upload],
.app-shell[data-access="readonly"] [data-script-upload],
.app-shell[data-access="readonly"] [data-script-edit],
.app-shell[data-access="readonly"] [data-setup-item="self-test"]{display:none}
/* F1 (Stiffy): the first-run Self Test callout is rendered by stage.php for
   every non-producer, and only features/selftest.js — never wired on a
   readonly boot — can bind or dismiss it. Left visible it is an
   undismissable dialog telling a reviewer to check their camera "before the
   lesson begins". display:none takes it out of the render, the a11y tree and
   the tab order together. */
.app-shell[data-access="readonly"] [data-first-run-callout]{display:none}
/* F3 (Stiffy, interim — the clean fix is item 31's refusal codes plus
   readonly-aware rendering in marks.js/transcript.js, not owned here): the
   mark and transcript EDIT affordances dead-end into a 401 that arms the
   re-entry card on a page the instructor is already signed into. Remove the
   pencils and the editor action rows; the note text stays readable, and the
   review-card textarea loses its pointer so it stops inviting an edit whose
   Save no longer exists. The server refusal remains the boundary. */
.app-shell[data-access="readonly"] [data-edit-mark],
.app-shell[data-access="readonly"] [data-edit-segment],
.app-shell[data-access="readonly"] .mark-editor-actions{display:none}
.app-shell[data-access="readonly"] [data-mark-note]{pointer-events:none}
/* F4 (Stiffy) used to hide .producer-help-copy here, because the rail still
   promised "your producer can join … to help you record" while chat_send
   refuses post-session. ITEM 60 S3 deleted that paragraph from both rails
   under Tor's amendment 2, so the rule had nothing left to match. The promise
   is still kept out of a read-only page: partials/panels-chat.php refuses to
   put the instructor's sentence in the placeholder when $access is readonly.
   The chat button and the conversation stay readable either way. */

/* The session-over card's quiet second offer (features/identify.js
   showSessionOver): a real 52px link under the primary verb, the
   identify-cancel-start shape without the danger hover — booking again is an
   invitation, not a warning. */
.identify-book-link{
  display:flex;align-items:center;justify-content:center;
  width:100%;min-height:52px;margin-top:12px;
  font-size:19px;font-weight:600;
  border-radius:10px;border:2px solid var(--line,#2b4a6f);
  color:inherit;text-decoration:none;text-align:center;
}
.identify-book-link:hover{border-color:var(--accent,#2f81f7)}
.identify-book-link:focus-visible{outline:3px solid #fff;outline-offset:2px}

/* ====================================================================
   ITEM 56 · SESSION PICKER (Candidate A) — the HAPPENING NOW band,
   the default tier's single-row records, and the see-more door
   The sessions TABLE keeps its item-5 rules in producer.css (which
   loads AFTER this file on the producer page); every selector here is
   NEW, so nothing there is overridden. The band is the callout idiom
   (the callout in the component vocabulary,
   docs/CODEBASE.html#style-components) — red edge for a live room, cyan
   for an imminent one, and the state is always a word in the shared
   .session-status pill, never color alone
   (docs/CODEBASE.html#style-glyph-needs-word). All targets ≥44px
   (verbs 48), all text ≥15px.
   ==================================================================== */
/* The .workspace-panel grid squashes its auto rows when the panel's height
   is definite (the item-48 measurement: a scroll-container row compresses
   to the leftover space, and a specified min-height replaces an item's
   automatic minimum). That was harmless while the table wrap was the last
   visible tenant; with the see-more door BELOW it, a squashed table row
   overlaps everything after it (measured: the wrap held 984px inside a
   500px row and painted across the door). Scoped to this panel: every row
   is a real max-content row, and the panel itself (overflow-y:auto) stays
   the one vertical scroller. */
[data-feature="sessions"].workspace-panel{grid-auto-rows:max-content}
.sessions-now-band{display:grid;gap:10px}
.sessions-now-heading,
.sessions-tier-label{
  margin:0;
  color:var(--dim);
  font:900 15px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;
  letter-spacing:.14em;
  text-transform:uppercase;
}
.sessions-now-cards{display:grid;gap:10px}
.now-card{
  display:grid;
  grid-template-columns:minmax(0,1fr) auto;
  align-items:center;
  gap:12px;
  padding:12px 14px;
  border:2px solid var(--cyan);
  border-radius:var(--card-radius);
  background:var(--surface);
  box-shadow:0 0 18px -8px var(--cyan);
}
.now-card.is-live{border-color:var(--red);box-shadow:0 0 18px -8px var(--red)}
/* Server data (title, instructor) wraps, never truncates
   (docs/CODEBASE.html#style-long-token-policy,
   docs/CODEBASE.html#style-no-truncation). */
.now-card-facts{min-width:0;display:grid;gap:4px}
.now-card-title{margin:0;font-size:18px;line-height:1.2;overflow-wrap:anywhere;word-break:normal}
.now-card-meta{margin:0;color:var(--dim);font-size:15px;line-height:1.3;overflow-wrap:anywhere;word-break:normal}
.now-card-starts-note{display:block;margin-top:2px}
.now-card-verb{
  min-height:48px;
  padding:8px 16px;
  border:1px solid var(--azure);
  border-radius:var(--control-radius);
  background:var(--blue);
  font-size:19px;
  font-weight:900;
}
/* The selected session's one visual home follows the record wherever it
   renders (the producer.css [aria-current] rules cover the table tiers). */
.now-card[aria-current="true"]{background:rgba(7,76,142,.22)}
/* Default-tier records: one row per appointment, verbs inline in the Open
   column — the primary verb keeps the .session-actions button look, the
   Files verb is deliberately quiet beside it. */
.session-row td{border-top:1px solid var(--line)}
.session-open-verbs{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
.session-open-verb{
  min-height:48px;
  padding:8px 14px;
  border:1px solid var(--azure);
  border-radius:var(--control-radius);
  background:var(--blue);
  font-size:19px;
  font-weight:900;
  white-space:nowrap;
}
.session-files-verb{
  min-height:48px;
  min-width:44px;
  padding:8px 14px;
  border:1px solid var(--line);
  border-radius:var(--control-radius);
  background:var(--surface);
  color:var(--text);
  font-size:15px;
  font-weight:900;
}
.session-files-verb:hover,.session-files-verb:focus-visible{border-color:var(--cyan)}
.session-row[aria-current="true"] td{background:rgba(7,76,142,.22)}
.session-row[aria-current="true"] td:first-child{box-shadow:inset 3px 0 0 var(--cyan)}
/* The full-width quiet door to the full list (aria-expanded mirrors it). */
.sessions-see-more{
  width:100%;
  min-height:52px;
  padding:10px 16px;
  border:1px solid var(--line);
  border-radius:var(--control-radius);
  background:var(--raised);
  color:var(--text);
  font-size:19px;
  font-weight:900;
  cursor:pointer;
  text-align:center;
}
.sessions-see-more:hover,.sessions-see-more:focus-visible{
  border-color:var(--cyan);
  box-shadow:var(--glow);
}
/* Compact widths, the narrow tier the item-56 spec called for: the band
   cards become one-column stacks; the verb keeps its full target under
   the facts. */
@media (max-width:700px){
  .now-card{grid-template-columns:minmax(0,1fr)}
  .now-card-verb{justify-self:start}
}

/* ====================================================================
   ITEM 60 S3 · THE DENSITY VOCABULARY (Tor's R8 amendment 2)
   "Our left menu looks really cramped so we need to remove extra text
   and if it's necessary add it to the content. Also keep menu messaging
   to a max of 2 lines (title, subtitle) … or a combo. … We could also
   just use a lock icon for readonly instead of long messaging."

   Three small forms replace the rail's sentences, and every one of them
   is a COMBO — shape/colour PLUS a word — because
   docs/CODEBASE.html#style-glyph-needs-word and
   WCAG 1.4.1 forbid status carried by colour or a glyph alone, and our
   readers are 65+ and low-vision. Tor offered the bare dot and the bare
   lock; the combo is the one of his options that clears the floor, and
   it is exactly as short.

   These live in panels.css rather than shell.css because shell.css is a
   frozen-mockup file and another builder holds responsive.css this
   slice; panels.css loads on BOTH surfaces (studio.php and producer.php
   list it), which is what the rails need.
   ==================================================================== */

/* The presence / selection state token — features/presence.js writes it into
   the Session row's sub-label, and rail-producer.php ships the same shape as
   its boot state so the row never changes height when the first poll lands.
   One line by construction: a 10px dot, a 6px gap and one word all fit the
   rail's sub-label column, which is what makes the two-line rule hold. */
/* Two classes deep on purpose: responsive.css's tier 19 sets
   `.producer-nav-copy span{display:block;margin-top:3px}` (0,1,1) on every
   sub-label descendant, and it loads after this file — an unqualified
   `.state-token` (0,1,0) would lose and stack the dot ABOVE the word, which
   is both ugly and a second line box in a rail bound to two.
   The sub-label is already a block, so the token is `flex`, not `inline-flex`:
   an inline-flex child sits on a baseline, and measured, that descender space
   made the Session row 61px against every other rail row's 58px. */
.producer-nav-copy .state-token{display:flex;align-items:center;gap:6px;min-width:0;margin-top:0}
.state-token{display:inline-flex;align-items:center;gap:6px;min-width:0}
.state-token .state-dot{
  flex:0 0 auto;
  /* display/margin restated for the same reason as the token above: the tier-19
     sub-label rule reaches every descendant span. */
  display:block;
  margin:0;
  width:11px;
  height:11px;
  border:2px solid var(--dim);
  border-radius:50%;
  background:transparent;
}
/* Filled ring for present, hollow for absent: the SHAPE differs, so the state
   survives a greyscale render and a colour-blind reader. */
.state-token.is-online .state-dot{border-color:var(--cyan);background:var(--cyan)}

/* The read-only lock in the rail's standing label. rail-instructor.php prints
   it from $access; features/presence.js reveals the producer's from
   store.session.access.
   RULING 75 (Tor, 2026-08-03): "The Lock icon is plenty of feedback" — the
   visible word that used to sit beside the glyph is gone, so the box is now
   square around a 16px glyph rather than a pill around glyph+word. The word
   is NOT gone: role="img" + aria-label on the wrapper keeps it in the
   accessibility tree, which is the only channel it ever had for a
   screen-reader user (the <svg> is aria-hidden). */
.rail-lock{
  display:inline-flex;
  align-items:center;
  justify-content:center;
  gap:5px;
  margin-left:8px;
  padding:3px;
  border:2px solid var(--steel);
  border-radius:var(--control-radius);
  color:var(--dim);
  font-size:15px;
  font-weight:900;
  letter-spacing:0;
  text-transform:none;
  vertical-align:middle;
}
.rail-lock svg{width:16px;height:16px;fill:var(--dim);flex:0 0 auto}
.rail-lock[hidden]{display:none}

/* The setup step's state, in words. The density design asked for a sub-label
   the sighted low-vision instructor can READ, because today the step's state
   lives only in a colour, a glyph and the accessible name. It is mirrored
   from the button's own state class instead of being written by a second
   module, so it
   can never drift out of step with the glyph beside it: ui/rail.js's
   setRailStatus sets the class, and the class picks the word. */
.setup-button .step-state>span{display:none;margin-top:0}
.setup-button.is-attention .step-state>[data-step-state="attention"],
.setup-button.is-good .step-state>[data-step-state="good"],
.setup-button.is-optional .step-state>[data-step-state="optional"]{display:inline}

/* ====================================================================
   ITEM 60 S9 · STUDIO ASSIST — the instructor's devices, changed from
   the producer's stage (docs/CODEBASE.html#style-studio-assist)

   THIS SECTION IS WHERE THE SLICE'S HEADLINE RISK IS WON OR LOST.
   Nobody had ever measured what a REAL device name does to this
   product's text. "Logitech BRIO 4K Stream Edition (046d:086b)" is 43
   characters; "MacBook Pro Microphone" and "Studio Display Speakers"
   are the ordinary case. This project has been burned before by
   fixtures whose strings were short enough to hide the defect — a
   79-character course title once rendered as a column of single
   letters while every containment check passed it.

   So the rules below are written for the LONG name first and the short
   one incidentally:
     · NOTHING has a fixed width, a max-width in ch, or an ellipsis.
       docs/CODEBASE.html#style-no-truncation forbids truncation
       outright, and there is no text-overflow declaration anywhere in
       this section.
     · every box that can receive a hardware name carries min-width:0,
       so it may shrink inside its grid/flex parent instead of forcing
       an overflow an ancestor then clips (the item-32 defect shape).
     · the name itself carries .user-text (base.css:
       overflow-wrap:anywhere; word-break:normal) — the long-token
       policy, docs/CODEBASE.html#style-long-token-policy. A hardware
       name has no legal break point, so it breaks anywhere rather than
       truncating, while ordinary prose beside it still wraps at spaces.
     · the choice list is a RADIO GROUP, not a <select>. A native select
       ellipsises its closed value and draws its options in an OS popup
       that no measurement in this repo can see, so its truncation is
       unprovable. Every option here is a real, wrapping, measurable
       >=48px label.

   It lives in panels.css rather than producer.css because this is a
   stage-panel vocabulary and panels.css loads on both surfaces before
   producer.css, so a producer-only override there would still win if
   one is ever needed.
   ==================================================================== */
.assist-panel{align-content:start}
/* Content first, then the frame line, then the controls — the floor's
   semantic DOM order (docs/CODEBASE.html#style-accessibility). The heading
   is the PERSON, never "Devices". */
.assist-heading{
  margin:0;
  min-width:0;
  color:var(--text);
  font-size:22px;
  font-weight:900;
  line-height:1.25;
}
/* The standing frame line (docs/CODEBASE.html#style-studio-assist, rule 3
   of "whose devices") — the one place the mental model is stated. 19px (the page default), not
   meta-sized: it is the sentence that stops a producer thinking they are
   changing their own camera, and it must not read as fine print. */
.assist-frame{
  margin:0;
  min-width:0;
  padding:12px 14px;
  border:1px solid var(--line);
  border-left:4px solid var(--azure);
  border-radius:var(--card-radius);
  background:var(--surface);
  color:var(--text);
  font-size:19px;
  line-height:1.45;
}
.assist-frame strong{color:var(--cyan);font-weight:900}
/* The state sentence for every state that cannot work. A sentence, never
   an empty control — so it is sized like content, not like a hint. */
.assist-state{
  margin:0;
  min-width:0;
  color:var(--dim);
  font-size:19px;
  line-height:1.45;
}
/* ITEM 65's own error channel. It sits in the panel's flow, above the
   rows, so a door refusal about somebody else's hardware is beside the
   control it refused rather than in a toast that has already faded. */
.assist-error{
  margin:0;
  min-width:0;
  padding:11px 13px;
  border:1px solid var(--red);
  border-radius:var(--card-radius);
  background:rgba(255,59,107,.12);
  color:var(--text);
  font-size:19px;
  line-height:1.45;
}
.assist-error[hidden]{display:none}
.assist-reason{display:grid;gap:6px;min-width:0}
.assist-reason[hidden]{display:none}
.assist-reason-label{color:var(--text);font-size:19px;font-weight:900;line-height:1.3}
.assist-reason-input{
  min-width:0;
  min-height:48px;
  padding:10px 12px;
  border:1px solid var(--line);
  border-radius:var(--control-radius);
  background:var(--void);
  color:var(--text);
  font-size:19px;
}
.assist-reason-input:focus-visible{border-color:var(--cyan);box-shadow:var(--glow);outline:none}
.assist-reason-help{margin:0;color:var(--dim);font-size:15px;line-height:1.35}
.assist-rows{display:grid;gap:14px;min-width:0}
.assist-rows[hidden]{display:none}
/* ONE COLUMN, always. A two-column row would be the 320px rack's mistake
   at a different width: the hardware name gets the whole measure, at
   every viewport and at 200% text. */
.assist-row{
  display:grid;
  gap:9px;
  min-width:0;
  padding:14px;
  border:1px solid var(--line);
  border-radius:var(--card-radius);
  background:var(--surface);
}
.assist-row[hidden]{display:none}
.assist-row-title{
  margin:0;
  min-width:0;
  color:var(--cyan);
  font-size:18px;
  font-weight:900;
  line-height:1.25;
}
/* "Alex is using / <name>" — the label and the name are two boxes so the
   name may wrap onto its own lines without dragging the label with it,
   and neither can push the other out of the row. */
.assist-row-current{
  margin:0;
  min-width:0;
  display:grid;
  gap:2px;
  color:var(--dim);
  font-size:15px;
  line-height:1.4;
}
.assist-device-name{
  min-width:0;
  color:var(--text);
  font-size:19px;
  font-weight:900;
  line-height:1.35;
}
/* The reason a row is locked (a rolling take, a browser with no
   setSinkId, an empty list). Disabled WITH the reason, never hidden. */
.assist-row-note{
  margin:0;
  min-width:0;
  padding:9px 11px;
  border:1px solid var(--steel);
  border-radius:var(--control-radius);
  background:var(--raised);
  color:var(--text);
  font-size:15px;
  line-height:1.4;
}
.assist-row-note[hidden]{display:none}
/* A real fieldset/legend: the group's name is in the accessibility tree
   rather than implied by proximity, and `disabled` on the fieldset takes
   every radio inside it out of the tab order at once. */
.assist-choices{
  margin:0;
  min-width:0;
  padding:0;
  border:0;
  display:grid;
  gap:7px;
}
.assist-legend{
  padding:0;
  min-width:0;
  color:var(--text);
  font-size:19px;
  font-weight:900;
  line-height:1.3;
}
.assist-choice-list{display:grid;gap:7px;min-width:0}
/* The option row. A name that wraps to four lines simply makes the row
   taller, which is the correct answer and the one truncation forbids. */
/* position:relative here is DEFENSIVE, NOT LOAD-BEARING, and the correction is
   recorded because the first version of this comment claimed the opposite.

   The story I told myself: the product's QA audit convicted an escaped radio
   at 200% text ("unreachable control INPUT center 881,266 hits H2"), so this
   rule was the fix. That was a MISATTRIBUTION. The real cause of that
   violation was the imminent-session reminder card overlaying the stage —
   somebody else's overlay, which the suite now dismisses before measuring.
   Sabotaged (this declaration removed) the suites came back GREEN, so item
   68(b) applies: I widened the probe rather than concluding the code was dead,
   and producer-shell now asserts directly that every hidden radio's rect sits
   inside its own row, at both text sizes. It still holds without this line.

   The reason is worth knowing: an absolutely-positioned box with top/left/
   right/bottom all `auto` renders at its STATIC POSITION — where flow would
   have put it — so the containing block governs nothing until an offset
   exists. This declaration is kept because it makes the containing block the
   row rather than `.feature-stage`, which is what the next person to add an
   offset will assume; it is not what holds the layout up today. */
.assist-choice{min-width:0;display:block;position:relative}
.assist-choice-input{
  position:absolute;
  width:1px;
  height:1px;
  margin:0;
  padding:0;
  opacity:0;
  pointer-events:none;
}
.assist-choice-label{
  display:grid;
  grid-template-columns:22px minmax(0,1fr);
  align-items:start;
  gap:10px;
  min-width:0;
  min-height:48px;
  padding:12px 13px;
  border:1px solid var(--line);
  border-radius:var(--control-radius);
  background:var(--void);
  color:var(--text);
  font-size:19px;
  line-height:1.35;
  cursor:pointer;
}
/* The radio mark is DRAWN, so it scales with the text rather than
   staying a 13px OS dot beside 34px type at 200%. */
.assist-choice-label::before{
  content:"";
  margin-top:2px;
  width:20px;
  height:20px;
  border:2px solid var(--steel);
  border-radius:50%;
  background:transparent;
}
.assist-choice-input:checked+.assist-choice-label{border-color:var(--azure);background:var(--navy)}
.assist-choice-input:checked+.assist-choice-label::before{
  border-color:var(--cyan);
  box-shadow:inset 0 0 0 4px var(--cyan);
}
.assist-choice-input:focus-visible+.assist-choice-label{border-color:var(--cyan);box-shadow:var(--glow)}
.assist-choice-input:disabled+.assist-choice-label{cursor:default;opacity:.72}
/* "in use now" is a WORD, not a highlight — a low-vision or colour-blind
   producer must be able to tell which device is live with the colour
   removed (docs/CODEBASE.html#style-glyph-needs-word / WCAG 1.4.1).

   grid-column:2 IS THE WHOLE AFFORDANCE, and its absence shipped. The
   label is a two-column grid (22px mark, then the text) with THREE grid
   items on the live option: the drawn mark, the device name, and this.
   Auto-placement put this one at row 2 COLUMN 1 — the 22px mark column —
   so the only non-colour signal of which device is live rendered as a
   22px-wide vertical stack of three single cyan words, at 1440x900 at
   100% text, the primary desktop case. The accessibility affordance
   became the least legible text on the surface, which defeats the exact
   thing it was added for. Barmy II found it BY LOOKING AT IT: it passed
   every containment check in the suite, including this slice's own
   four-fact truncation test, because that test measured only
   .assist-device-name and the only assertion about this string was a
   textContent match, which is blind to layout by construction. */
.assist-choice-now{
  grid-column:2;
  min-width:0;
  color:var(--cyan);
  font-size:15px;
  font-weight:900;
  letter-spacing:.04em;
}
.assist-choice-label>span{min-width:0}
.assist-row-actions{display:flex;min-width:0}
/* The button's text is "Change Alex's camera" — it carries the
   possessive, so it must be allowed to wrap rather than clip. No fixed
   width, no white-space:nowrap. */
.assist-apply{
  display:inline-flex;
  align-items:center;
  justify-content:flex-start;
  gap:9px;
  min-width:0;
  min-height:48px;
  padding:11px 16px;
  border:1px solid var(--azure);
  border-radius:var(--control-radius);
  background:var(--blue);
  color:var(--text);
  font-size:19px;
  font-weight:900;
  line-height:1.3;
  text-align:left;
}
.assist-apply svg{width:22px;height:22px;fill:currentColor;flex:0 0 auto}
.assist-apply:disabled{border-color:var(--steel);background:var(--raised);color:var(--dim)}
.assist-apply:not(:disabled):hover,
.assist-apply:not(:disabled):focus-visible{border-color:var(--cyan);box-shadow:var(--glow);outline:none}
/* The round trip's sentence, under the control it describes. */
.assist-row-result{
  margin:0;
  min-width:0;
  padding:10px 12px;
  border:1px solid var(--steel);
  border-left:4px solid var(--steel);
  border-radius:var(--control-radius);
  background:var(--raised);
  color:var(--text);
  font-size:16px;
  line-height:1.4;
}
.assist-row-result[hidden]{display:none}
.assist-row-result.is-good{border-left-color:var(--cyan)}
.assist-row-result.is-bad{border-left-color:var(--red)}
.assist-row.is-pending{border-color:var(--azure)}
/* The rail row's state token. responsive.css tier 19 sets
   `.producer-nav-copy span{display:block;margin-top:3px}` on every
   sub-label descendant and loads after this file, so the word inside
   the token must be restated here or it stacks BELOW the dot and makes
   the row three lines — which is what amendment 2's two-line rule
   forbids. Specificity (0,3,1) beats tier 19's (0,1,1). It WRAPS rather
   than truncating if it ever must: the no-truncation floor
   (docs/CODEBASE.html#style-no-truncation) is absolute
   and the two-line rule is a density preference, so the density loses
   if they ever collide. */
.producer-nav-copy .state-token>[data-assist-rail-word]{
  display:inline;
  margin:0;
  min-width:0;
  overflow-wrap:break-word;
}

/* The producer chat gate's reason sentence — ruling 124(b) + item 47.
   A dimmed control that does not say why has been rejected twice on this
   project, so the sentence sits INSIDE the panel, immediately above the
   controls it explains, at 16px (above the 15px floor for a 65+ reader).
   It is not a toast on purpose: item 65's rule is that a sentence must be
   legible from the state the person is actually in, and the chat panel is
   that state. The disabled controls stay legible rather than ghosted —
   below 4.5:1 a dimmed control reads as absent to the audience this
   product is built for. */
.chat-gate-note{
  margin:0 0 10px;
  padding:10px 12px;
  border:2px solid var(--steel);
  border-radius:var(--control-radius);
  background:var(--surface);
  color:var(--text);
  font-size:16px;
  line-height:1.4;
}
.chat-gate-note[hidden]{display:none}
.chat-form textarea:disabled,
.chat-form button:disabled{
  cursor:not-allowed;
  opacity:.75;
  background:var(--surface);
}
