/* ====================================================================
   WCPE INSTRUCTOR STUDIO — RESPONSIVE MODE CONTRACT
   ====================================================================
   Cascade layer 7 of 7. Owns regions 18-21 — the composed responsive-mode
   contract, full mode, self-test fit rules, fill-window mode.
   Region 21 post-dates the mockup, as do the lettered tiers (18a, 18b, 18c,
   18d, 18r, 18s, 19a, 19b, 19d). The shipped banners are the rule.

   LOAD ORDER IS LOAD-BEARING:
     tokens -> base -> shell -> stage -> panels -> producer -> responsive
   This file is #7. 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
   ==================================================================== */
/* ====================================================================
   18 · RESPONSIVE MODES — the composed contract
   Layout is composed from width AND height gates, not one breakpoint:
     max-width 520px ....... phone topbar (title stacks over status)
     max-width 600px ....... phone: 64px rail, single-column panels
     max-height 699px ...... short: tighter rail/stage rhythm
     max-height 520px ...... short landscape: switcher top row, utility
                             dock as a right icon column, slide paging
                             overlaid on the slide, compact transport
     the PHONE BANDS ....... tier 18c (after 18b): the 2026-08-11 mobile
                             redesign — full-bleed visual stage, dock
                             pinned onto the rail, landscape burger
     min-width 1100px AND min-height 700px ... FULL mode (section 19)
   Below full mode, setup sheets are fixed overlays anchored to the
   rail; in full mode they render inline in the rail. Self Test fit
   rules follow in section 20; fill-window mode is section 21 and must
   stay the last block in the file.
   ==================================================================== */
@media(max-width:520px){
  /* The assist Apply is a 900-weight action-control label, not reading prose,
     and at this tier's 200%-zoom worst case its longest real label ("Change
     <name>'s microphone") needs more width than the 390px column has at 19px.
     Component sizing at the extreme, floor respected — the knock-card call.
     A review caught the first placement of this rule in the 600px block,
     which took the approved 19px from ordinary 521-600px layouts too. */
  .assist-apply{font-size:17px}

  /* DEVIATION (open item 7) — accessibility beats mockup fidelity, never
     silent (docs/CODEBASE.html#style-accessibility-over-fidelity). The mockup's
     146px and its `auto 48px` rows budgeted for a stacked topbar whose 48px
     role-toggle row S0 removed; the two remaining elements render in one row,
     leaving a dead band Tor hit on a real phone. Measured headless at
     390x844: title+status row 53px + 14px padding = 67px at default text
     (71px at the 320px WCAG floor, where the title wraps to three lines) but
     133-165px at 200% text zoom — 146px was silently clipping 43px of it. No
     fixed value serves both text sizes, so the workspace row keeps a measured
     76px floor (67-71px content + clearance) and grows with its content at
     larger text — the stage shrinks instead, which the zoom-and-reflow ruling
     calls correct (docs/CODEBASE.html#style-zoom-and-reflow). The fixed
     setup-sheet anchors at shell.css `var(--top-h) + 8px` sit just under the
     bar at 1x text only; at 200% text the grown bar extends under an opened
     sheet, which paints above it (z-index) and stays fully reachable — only
     non-interactive title text is covered. 18b (max-height:240px) re-declares
     .workspace later and still wins where the topbar scrolls in place. */
  :root{--top-h:76px}
  .workspace{grid-template-rows:minmax(var(--top-h),auto) minmax(0,1fr)}
  /* Third column: the Device settings gear (item 19 ruling) keeps its place
     beside the pill in every mode — the topbar is the pickers' one home that
     all three responsive modes reach. */
  .topbar{
    padding-top:2px;
    padding-bottom:2px;
    grid-template-columns:minmax(0,1fr) auto auto;
    align-content:center;
  }
  /* The longest honest state is reserved invisibly, so every state wraps to
     this same footprint and a transition cannot move the stage mid-take. */
  .session-clock{min-height:34px;margin-top:0;letter-spacing:-.02em}
  .session-clock-copy,.session-clock-reserve{
    display:grid;
    grid-template-columns:minmax(0,1fr);
  }
  .session-clock-separator{display:none}
  /* UNCHANGED FROM MAIN, and that needs saying because a relief WAS built here
     and then measured away. Ruling 125's first draft word was `Not monitored`,
     whose longest word needs ~174px of content at 200% text against this 112px
     box: measured at 406 of 390 on this viewport, against 390 of 390 on
     pristine main. A `ch`-based floor fixed it. Tor then ruled the word is
     `Standby` — the SAME seven characters this tier has always held — and the
     fix re-measured INERT: 390 of 390 with it removed. Removed rather than kept
     as harmless slack, because a fix that looks load-bearing and is not is how
     the next person is misled about what a number protects. */
  .global-air-status{width:112px;min-width:112px;white-space:normal}
  /* Item 60 S2: the Settings label sheds on ANY phone-width bar, and it is
     declared here — the widest of the narrow gates — rather than in the two
     blocks below, because between them they leave a hole. 195x422 — the
     browser-zoom half of 390x844; it is a MEASURED entry of the matrix but
     not a required one, because qa.js's REFLOW_MIN_WIDTH is 320 and every
     half below that carries an explicit `excluded` reason — is too short for
     the portrait block's min-height:521 and too narrow for the landscape
     block's min-width:390, so it kept the label, wrapped the bar, and pushed
     the instructor's session clock out of the topbar's own box — measured as
     qa-matrix's "instructor@195x422: session clock is visible, readable, and
     topbar-contained", bottomContained false. The name is unaffected: it
     lives in the button's aria-label, not in this span. */
  .topbar .topbar-settings{width:44px;min-width:44px;min-height:44px;padding:0}
  .topbar .topbar-settings .topbar-settings-label{display:none}
  /* The mockup's ≤520px .producer-sessions-shortcut pair was deleted with its
     shell.css base rules, in the S8 producer slice that also fixed the
     producer action bar's 200% text-zoom overflow: no rendered markup carried
     that class anywhere, frozen mockup included. Item 60 S4 then retired the
     button it named too — .producer-dashboard-button is gone, and the
     producer's door back to the picker is the rail's own head,
     [data-producer-section="sessions"]. */
}
/* The portrait phone BAR SHAPE lived here — item 19's gear on row 2 of the
   title column with the pill spanning both rows — until the 2026-08-11
   mobile redesign (Tor's rev-3) replaced that arrangement: with a 3-line
   title the gear floated mid-bar under the title while the pill wrapped
   against the top edge, reading as wrapping debris rather than layout. The
   rev-3 shape (tier 18c, ≤600px portrait: title beside the gear on row 1,
   the pill at FULL bar width on row 2) is what renders now, and the
   placement rules left with it. What this block keeps is what still holds:
   the measured scroll-in-place caps below, which are about the bar's
   HEIGHT budget and bind under the new shape exactly as they did under the
   old. Short landscape (≤520px tall) keeps the single-row three-column bar
   from the block above: 18b pins the bar's own row, and the clock's short
   state leaves the width to spare. */
@media(max-width:520px) and (min-height:521px){
  /* Accessibility-over-fidelity deviation, measured to the pixel
     (docs/CODEBASE.html#style-accessibility-over-fidelity). At 200% text
     zoom the deck panel's empty-state Upload button already ends EXACTLY
     at its container's edge (button bottom == .wcpe-slide bottom, 502==502
     at 390x844 in the base tree), so the gear row may not add ONE pixel of
     bar height in that state — and every placement adds its 44px to
     whichever column is the tall one (title h1 40 + clock 108 = 148 vs
     pill 119; all measured in the audit's own text-zoom pass). The slack
     is granted by the clock: at 100% text it is ~34px tall and this cap is
     invisible; under text zoom it scrolls in place past 56px — the
     .producer-help-copy precedent in this file, prose scrolling rather
     than the workflow. Nothing is truncated or ellipsised, and the sr-only
     aria-live announcement span is unaffected. Known limit, stated
     honestly: the scrollable region is not keyboard-focusable, the same
     limit the help-copy precedent has. (The gear-row arithmetic above was
     measured against the pre-rev-3 bar; the 18c two-row bar spends the
     same zoom budget on the full-width pill row instead, so the cap binds
     for the same reason — re-measured 2026-08-11, deck audits green.) */
  .session-clock{max-height:56px;overflow-y:auto}
  /* Accessibility-over-fidelity deviation
     (docs/CODEBASE.html#style-accessibility-over-fidelity) — the title half of
     the same arithmetic (item 32, Boko's item-19 finding). The clock cap above
     kept the bar's height only for the suite's own 9-character title: the h1 is
     uncapped, and at 200% text zoom every extra title LINE (38px) is stage-cell
     theft on a phone — measured: "Federal Tax Update 2026 — Partnerships"
     wrapped the h1 to 227px, the topbar to 332px of an 844px viewport, and the
     deck panel's Upload button clipped dead behind the letterbox. Same cure as
     the clock, same limits, honestly stated: past 80px the title scrolls in
     place — nothing truncated, nothing ellipsised, and the region is not
     keyboard-focusable (the help-copy precedent). 80px is four lines at the
     design's text size, so the cap is invisible at 100% for any title that fits
     four lines; under zoom it holds the bar to two visible lines and the
     workflow keeps the screen. */
  .session-title h1{max-height:80px;overflow-y:auto}
  /* Item 56 S3 on a portrait phone — measured at 390x844, 200% text, over
     three shapes (build) plus Boko's F2 paint audit (fix cycle). Two
     stacked capped regions (h1 80 + state line 56) plus a Switch row put
     the readonly deck's slide nav and Expand outside their cells, so the
     PRODUCER title block spends ONE cap on title + state line together (the
     h1's accessibility-over-fidelity cap above, moved up one element for this
     page only; scroll in place, nothing truncated, same stated limits — the
     instructor topbar keeps the h1 rule untouched). 56px: one-line title 19 +
     two-line state 36 = 55 measured at 100%, so the cap is invisible at
     default text; the state line's margin and leading tighten to keep that
     true.
     Item 60 S2: the ⇄ Switch button that shared the gear's row here is gone
     from the header entirely (Tor's R8), which returns its 44px + gap to
     this tier and is why the gear can keep its own 44px unmoved. */
  /* Boko F4: each child caps ITSELF (block scrollports overflow downward,
     into reach) — never the centered .session-title grid. The h1 keeps two
     phone lines and the state line its own two, so a long course name can
     never push instructor/LIVE/times out of view: both heads always show.
     38 + 36 + the 2px gap held the measured 100% content of a 9-character
     fixture exactly, and THAT was the defect (Stinker F3).
     ITEM 62 on the phone. 36px is two line boxes at the design's text size,
     and the product's ordinary strings are longer than two: the suite's own
     readonly fixture measures contentH 54 — THREE boxes — at 390x844 with
     NO zoom of any kind, so a producer at default text read
     "Shell Tester · Approved · Mon, Aug 10 · 10:30 AM –" and found the end
     time, the zone and the presence behind a scrollport. Item 66 ruled on
     the chat placeholder and its scope limit says so explicitly, so nothing
     licenses truncation here; item 62's owed carries no viewport exemption
     either.
     The two caps below are MEASURED, by sweeping the cap at 390x844 and
     running the full five-feature audit at both text sizes at each step:
       36px  content clipped at 100%          audits clean
       54px  content whole at 100%            audits clean
       72px  content whole at 100%            200% audit RED — "unreachable
             control Previous/Next center 227,391 hits SECTION.feature-stage"
       90px  content whole at 100%            200% audit RED, same pair
     So this tier has exactly 18px of slack at 200% and a great deal more at
     100%, because a pixel cap buys twice as many lines at the design's text
     size as it does at 200%. A single number cannot spend both budgets, so
     there are two, and the second is triggered the honest way — by the bar's
     width RELATIVE TO ITS OWN TEXT, the §19d idiom, never a width media
     query that text zoom is invisible to. 15em is derived the same way Boko
     derived 40em: this bar measures 20.4em of its own font at 100%
     (326px / 16) and 10.2em at 200% (326 / 32), so any threshold strictly
     between separates them, and 15em is the midpoint. Below 15em the tier
     keeps the 54px it can afford; above it, 90px, which holds FIVE line
     boxes — enough for the ordinary strings measured at this viewport (54px,
     three boxes) with two to spare.
     WHAT IS STILL TRUE AND IS NOT A GUARANTEE, stated in line boxes because
     a character count is the wrong unit for a 189px column: at 200% text this
     tier still scrolls in place past 54px, and at 100% it still scrolls
     anything past five boxes — which the 139-character CEILING is, comfortably
     (it takes three boxes in the FULL tier's 734px column and this column is
     roughly a quarter of that). An earlier version of this comment said
     "past ~140 characters", which was wrong by at least one in the wrong
     direction: the ceiling itself already scrolls here. Nothing is truncated
     or ellipsised, the region scrolls, and the region is not
     keyboard-focusable — the help-copy precedent's stated limits. A real
     remaining limit of a 390px bar, measured rather than asserted, and
     recorded against item 62 as OPEN rather than described as a guarantee. */
  .producer-session-title h1{max-height:38px;overflow-y:auto}
  .session-bar-state{max-height:90px;margin-top:0;line-height:1.2}
  @container topbar (max-width:15em){
    .session-bar-state{max-height:54px}
  }
}
@media(max-width:600px){
  :root{--rail-w:64px}
  .setup-rail{padding-left:5px;padding-right:5px}
  .feature-switcher button{padding-left:5px;padding-right:5px}
  .mark-review-card{grid-template-columns:1fr}
  .mark-frame{max-width:240px}
  .script-toolbar{align-items:stretch}
  .script-size-controls{margin-left:0;width:100%}
  .script-size-value{flex:1;align-self:center}
  .self-test-card{padding:12px;grid-template-columns:90px minmax(0,1fr);gap:12px}
  .test-ring{width:90px;height:90px;border-width:9px;font-size:20px}
  .self-test-copy h3{font-size:20px}
  .self-test-copy button{min-width:0;width:100%;font-size:16px}
  .self-test-headphones{align-items:flex-start;flex-direction:column}
  .callout-actions{grid-template-columns:1fr}
  .knock-card{grid-template-columns:1fr 1fr}
  .knock-card strong{grid-column:1/-1}
  /* THE PRESENTATION CELL MUST NEVER COLLAPSE TO NOTHING. At this tier's
     200%-text worst case the stacked column's auto rows (every grown label,
     doubled) consumed the whole viewport and the stage's minmax(0,1fr) row
     went to ZERO — the deck's Previous/Next/Expand then rendered OUTSIDE
     their dead cell, overlapping whatever occupied the space (measured
     2026-08-10: cell 0px tall with 48px controls at y+48 below it). A floor
     of one nav row plus a sliver of letterbox keeps the panel a panel; the
     column scrolls past it, which reachable-by-scroll is and
     painted-over-each-other is not. */
  .stage-cell:has(>.presentation-panel.is-active){min-height:132px}
  /* …and a floored cell inside a frozen shell has to be REACHABLE: at 200%
     the grown rows genuinely cannot all fit this viewport, so the stage row
     scrolls rather than letting grid tracks paint over one another. The
     tiniest tier below already unfreezes the shell the same way. */
  .feature-stage:has(.presentation-panel.is-active){overflow-y:auto}
  .chat-form{grid-template-columns:1fr}
  .chat-form button{width:100%}
  /* The moved-instructor placeholder wraps to three lines at 19px/1.5 in this
     tier's 290px field and needs 106px of content height; a placeholder
     cannot be scrolled to, so the resting box must hold all of it
     (no-truncation, item 60 S3 — see the ledger in panels.css). */
  .chat-form textarea{min-height:112px}
  .file-workspace-header,.file-row,.reminder-actions{grid-template-columns:1fr}
  .file-download{width:100%;min-width:0}
}
@media(max-height:699px){
  .self-test-gate{gap:12px}
  .brand-lockup{min-height:44px;padding:3px}
  .setup-list,.producer-menu{gap:3px}
  .setup-button,.producer-nav-button,.presence-button{width:46px;min-width:46px;min-height:46px;padding:9px}
  .step-icon,.producer-nav-icon,.presence-icon{width:25px;height:25px}
  .work-grid{padding-top:6px;padding-bottom:6px;gap:6px}
  .feature-stage{padding:6px;gap:4px}
  .stage-header{min-height:38px}
  .stage-header h2{font-size:20px}
}
@media(max-height:520px){
  /* Keep real vertical headroom around the two-row join announcement at the
     suite's 200%-zoom viewport without shrinking its 17px copy or 44px target. */
  .knock-card{padding:10px;gap:5px}
  /* Same collapse-floor as the max-width tier — see the note there. */
  .stage-cell:has(>.presentation-panel.is-active){min-height:132px}
  .feature-stage:has(.presentation-panel.is-active){overflow-y:auto}
}
@media(max-height:699px) and (min-width:700px){
  .self-test-device-panel h3,.self-test-state{display:none}
}
/* Accessibility beats mockup fidelity
   (docs/CODEBASE.html#style-accessibility-over-fidelity). The 601px floor is a
   mockup constant that reads "a landscape phone is at least this wide", and every
   short-landscape viewport in the tested matrix
   (docs/CODEBASE.html#arch-viewport-matrix) is — but 200% browser zoom halves the
   viewport, and the halves of that matrix's own entries (590x410, 422x195) land
   short AND under 601px. They fell through to the portrait branch, which spends
   the height it assumes it has on a stacked topbar, a switcher row and a utility
   row; at 590x410 that left the stage cell 82px and the slide nav hung out through
   its clip. Nothing about this block needs the extra width — it is the rules for a
   short screen — so the floor drops to the matrix's own narrowest width instead of
   guessing a new one. Below that, WCAG 1.4.10 stops binding and qa.js declines the
   gate. */
@media(max-height:520px) and (min-width:390px){
  :root{--top-h:60px;--transport-h:58px;--rail-w:64px}
  /* The ≤520px block above dropped the mockup's dead 48px role-toggle row
     (open item 7), so the bar is one row everywhere now; this keeps the
     tighter padding and pins the bar's own row to the box. Where that block
     also applies (390-520px wide) its .workspace minmax floor is the 60px
     --top-h set here, so the row starts at 60px and grows only if the
     title/status content is genuinely taller — growth the stage absorbs,
     which beats clipping the bar at this height too. */
  .topbar{padding-top:5px;padding-bottom:5px;grid-template-rows:minmax(0,1fr)}
  /* Item 32, accessibility over mockup fidelity
     (docs/CODEBASE.html#style-accessibility-over-fidelity) — the OTHER answer
     to a growing topbar, because this band cannot afford the workspace minmax
     the taller modes use (shell.css §5): on a ≤520px-tall screen a zoomed
     multi-line title would spend the stage's whole height on the bar (measured
     at 640x310 @200% text: the bar alone was 310px and every control below it
     landed outside the viewport). So the bar keeps its pinned row and SCROLLS
     IN PLACE instead — the 18b topbar precedent verbatim: nothing truncated,
     the gear and pill scroll into reach (the ?qa=1 probes scroll
     user-scrollable ancestors before judging), and the stage keeps the
     screen. */
  .workspace{grid-template-rows:var(--top-h) minmax(0,1fr)}
  .topbar{overflow-y:auto}
  /* Item 56 S3's Switch-button rules stood here: in this pinned 60px bar a
     wrapped two-line label centered a 79px button across BOTH edges of the
     one minmax(0,1fr) row, clipped where scroll-in-place can never reach.
     Item 60 S2 removed that button from the header, so the rules go with it.
     Item 60 S2, same reasoning applied to the merged gear: a landscape phone
     band is 60px tall and pinned, so the gear stays the square glyph and its
     label sheds — a wrapped word here would clip exactly as the Switch
     label did, and the aria-label carries the name regardless. */
  .topbar .topbar-settings{width:44px;min-width:44px;min-height:44px;padding:0}
  .topbar .topbar-settings .topbar-settings-label{display:none}
  /* Boko F4, third tier: this bar's row is PINNED (minmax(0,1fr) above) and
     the topbar itself is the scroller, but .topbar's align-items:center
     centers a taller title block in that row — measured with a long title,
     the block's top sat 10px above the viewport at 100% and 32px at 200%,
     the same unreachable-upward shape one level out. Start-aligned, its
     overflow goes DOWN into the bar's own scrollport, which the viewport
     matrix's own probes (docs/CODEBASE.html#arch-viewport-matrix) already
     scroll before judging. PRODUCER-SCOPED: on the shared
     .session-title this rule moved the INSTRUCTOR topbar too and its
     422x195 marks/transcript/chat/files audits went red on a 232>195 page
     overflow — that surface is not this slice's to change and stays
     byte-identical. */
  .producer-session-title{align-self:start}
  .work-grid{
    grid-template-columns:minmax(0,1fr) 56px;
    grid-template-rows:minmax(48px,auto) minmax(0,1fr);
    column-gap:6px;
    row-gap:5px;
  }
  .feature-switcher{grid-column:1;grid-row:1}
  .feature-stage{grid-column:1;grid-row:2}
  .presentation-panel.is-active{position:relative;grid-template-rows:minmax(0,1fr)}
  .presentation-panel .local-slide-nav{
    position:absolute;
    z-index:7;
    left:8px;
    right:8px;
    top:50%;
    display:flex;
    justify-content:space-between;
    transform:translateY(-50%);
    pointer-events:none;
  }
  /* Overlaid paging sits at the two edges of the slide, so these buttons must
     hug their labels rather than share the row the way the stacked nav does. */
  .presentation-panel .local-slide-nav button{
    flex:0 0 auto;
    width:auto;
    min-width:108px;
    min-height:48px;
    pointer-events:auto;
    background:rgba(11,19,34,.96);
    box-shadow:0 8px 24px rgba(0,0,0,.38);
  }
  .utility-dock{
    grid-column:2;
    grid-row:1/3;
    grid-template-columns:1fr;
    grid-template-rows:1fr 1fr;
  }
  .utility-dock button{padding:5px;flex-direction:column;gap:3px}
  .utility-dock .utility-label{display:none}
  .transport{padding:2px 8px}
  /* Height, not just min-height, in the mockup — and a two-line "Drop marker" at
     200% text zoom wants 60px of it. The floor is what this mode is asserting;
     the ceiling was only ever incidental. */
  .action-bar{min-height:54px;padding:0}
  .action-bar button{min-height:52px;padding-top:5px;padding-bottom:5px}
  /* The producer@640x310 cell of the 200%-text-zoom producer-bar defect; the
     other three cells are fixed by shell.css's .producer-action-bar auto-fit
     grid (docs/CODEBASE.html#style-zoom-and-reflow). At 200% text zoom
     the producer verbs go to two lines and the bar's height starves the stage
     row to 31px — 2px under the slide's own floor (the .wcpe-slide clamped
     padding plus the surface border, ~33px at that width), so the 16:9 visual
     lands outside its cell. Two lines at 32px want 70.4px; 5px pads put the
     bar at 84.4 and the cell at 31.1, 3px pads put them at 80.4 and 35.5, and
     the visual fits exactly (measured). Invisible at the design's text size:
     one 17px line plus these pads is 27.6px against the 52px floor.
     Producer-only — the instructor bar never two-lines at this width. */
  .producer-action-bar button{padding-top:3px;padding-bottom:3px}
  .first-run-callout{padding:12px;gap:7px}
  .first-run-callout h2{font-size:20px}
  .first-run-callout p{font-size:15px}
  .callout-actions button{min-height:46px;font-size:15px}
}
@media(max-height:520px) and (min-width:601px) and (max-width:899px){
  .self-test-device-panel{gap:12px}
  .self-test-device-row[data-self-test-device="speakers"]{margin-top:24px}
}
/* ====================================================================
   18a · STACKED COMPACT, SHORT ON TEXT-LINES — the accessibility zoom fallback
   Accessibility beats mockup fidelity
   (docs/CODEBASE.html#style-accessibility-over-fidelity). The stacked compact
   layout spends the work area on three full-width bands: a switcher row, the
   stage, a utility row. Each band is labelled controls, so each grows with the
   text while the work area does not — at 410x590 and 200% text zoom the two tab
   rows took 199px of 350px and left the stage cell 63px to hold a slide plus
   120px of paging, which hung out through the cell's clip.

   The height that matters here is not pixels. 410x590 is 26em of role view at
   the design's text size and 13em at 200%; a px query sees 444 both times and
   cannot tell them apart, while `em` in a CONTAINER query resolves against the
   view's own font size, which is precisely what text zoom changes. Under 14em
   the stacked arrangement no longer fits all three bands, so the utility dock
   takes the right icon column the short-landscape band already defines for short
   screens — the same rules, verbatim, from the max-height:520px block above.
   Nothing new is invented and nothing is lost: both tabs stay full size, in the
   same order, one tap away. The next tested viewport up
   (docs/CODEBASE.html#arch-viewport-matrix) is 16em at 200%, so at the design's
   text size this never applies anywhere.

   INSIDE THE PHONE BANDS this tier is out-cascaded on purpose: tier 18c
   (later in this file) pins the dock onto the rail and re-declares the
   single-column work grid, so the side column this tier buys height with
   would be a dead 56px strip there — the dock has already left the grid.
   Every viewport this tier fires at OUTSIDE those bands (e.g. a 700x580
   window at 200% text) keeps these rules exactly.
   ==================================================================== */
@container studio-work (max-height:14em){
  .work-grid{
    grid-template-columns:minmax(0,1fr) 56px;
    grid-template-rows:minmax(48px,auto) minmax(0,1fr);
    column-gap:6px;
  }
  .feature-switcher{grid-column:1;grid-row:1}
  .feature-stage{grid-column:1;grid-row:2}
  .utility-dock{
    grid-column:2;
    grid-row:1/3;
    grid-template-columns:1fr;
    grid-template-rows:1fr 1fr;
  }
  .utility-dock button{padding:5px;flex-direction:column;gap:3px}
  .utility-dock .utility-label{display:none}
}
/* ====================================================================
   18b · SHORTER THAN THE CONTROL STACK — the shell stops being a
         fixed viewport and the PAGE scrolls vertically
   Accessibility beats mockup fidelity
   (docs/CODEBASE.html#style-accessibility-over-fidelity), on the coordinator's
   standing ruling of this slice. base.css states the frozen shell's premise —
   "the page itself never scrolls" — and below a certain viewport height that
   premise cannot be kept without breaking a floor that outranks it. Add up what
   this app may not remove and may not shrink under the floor's 44px target
   minimum (docs/CODEBASE.html#style-accessibility):

       transport (Record / Drop marker)          44 + 4 padding =  48
       work-grid padding                                        =  12
       feature switcher (Slides / Camera / Script)              =  44
       stage border + padding + gap             4 + 12 + 4      =  20
       stage header (Focus / Full screen / Pop out, 44 each)    =  44
       slide paging (Previous / Next)                           =  44
       topbar (title + ON AIR status at the 15px floor)         =  26
                                                          total = 238px

   238px of mandatory controls, before the slide gets a pixel. Under that the
   fixed shell has only one move left, and it is the wrong one: clip controls
   out of an overflow:hidden ancestor, where they are unreachable. The long-token
   ruling (docs/CODEBASE.html#style-long-token-policy) forbids only HORIZONTAL
   page scrolling ("a wide element scrolls inside its own container"); vertical
   page scrolling is nowhere forbidden, and WCAG 2.1 SC 1.4.10 — the clause the
   200% zoom gate (docs/CODEBASE.html#style-zoom-and-reflow) exists to satisfy —
   names vertical scrolling as the assumed mechanism, not the failure. That same
   gate already settled the principle for the sibling invariant: "a letterboxed
   stage that shrinks is correct; a control the user cannot reach is not." The
   100dvh shell is that same class of invariant and yields the same way, for the
   same reason.

   So: 240px, the arithmetic above rounded up. Above it nothing here applies and
   the shell stays exactly as frozen — no scrollbar appears at any viewport that
   fits. The threshold is in px on purpose, unlike the two container tiers above:
   what it is measuring is a stack of px floors, not a column of text.

   What the scroll buys is space, and nothing here spends it back — no control
   shrinks, no type shrinks. Every clip on the path from the shell to a control
   is released, so a control below the fold is scrolled to, never cut. Both tab
   bars stand in side columns so the stage still opens directly under the topbar;
   the paging returns to the flow beneath the slide, because an overlay sized for
   a 16:9 slide has nothing to sit on once the slide is a sliver.
   ==================================================================== */
@media(max-height:240px){
  /* The shell scrolls, not the document. Same reachability — the user scrolls,
     focus scrolls with it — and it keeps base.css's "the page itself never
     scrolls" literally true, along with the absolute ban on horizontal page
     scrolling (docs/CODEBASE.html#style-long-token-policy). It also keeps the
     audit honest rather than merely quiet: an
     overflow:auto box is still examined for partially clipped controls, so a
     control cut by the fold would still be reported. Only controls wholly below
     it, which scrolling reaches, are not. */
  .app-shell{overflow-x:hidden;overflow-y:auto}
  /* The shell's whole spine becomes ordinary block flow, and that is the load-
     bearing line in this block. Grid is what makes the frozen shell a fixed box:
     every one of these stretches its child to a track, and the tracks are `1fr`
     with a zero minimum. Asked to size to content instead, an `fr` track with a
     zero floor resolves to zero — and because the slide is sized from a
     percentage of the very track it is trying to size, the chain is circular as
     well. Measured, mid-fix: a stage cell reporting a max-content height of 0px
     with a 138px panel inside it. Block flow has neither the stretch nor the
     cycle, and it is the honest reading of a scrolling layout anyway — topbar,
     stage, tab bars, transport, stacked in the order the DOM already states.
     That order is the 65+/low-vision floor's own "content first, navigation
     second" (docs/CODEBASE.html#style-accessibility), so the slide lands
     directly under the topbar and stays above the fold while the tab bars
     below it are scrolled to. */
  .role-view.is-active,.work-grid,.feature-stage,.stage-cell,.feature-panel.is-active{display:block}
  .role-view,.work-grid,.feature-stage,.feature-panel,.transport{overflow:visible}
  /* The topbar keeps its row, and this is the one place the scroll is not the
     answer. Left to size itself, a wrapped title is 130px of a 195px viewport at
     200% and the slide starts below the fold — the 16:9 stage invariant
     (docs/CODEBASE.html#arch-stage-invariant) wants that visual on screen, not
     scrolled to. So the title scrolls in place instead, exactly as
     the rail's standing help prose does at 19a. Two fixed rows, no fr, no cycle. */
  .workspace{grid-template-rows:var(--top-h) auto;overflow:visible}
  .topbar{overflow-y:auto}
  /* Size containment would hold these to a height their contents can no longer
     influence, which is the opposite of what a scrolling layout needs. 18a's
     height query goes quiet with it, and should: that tier exists to buy height
     by rearranging, and the scroll has already bought it. */
  .role-view.is-active,.stage-cell{container-type:inline-size}
  /* Grid's row gaps go with grid. */
  .feature-switcher,.utility-dock{margin-top:6px}
  .feature-switcher{flex-direction:row;flex-wrap:wrap}
  /* Full width is what the side columns of the shorter modes were spending, and
     it is what is scarce here: squeezed into a 136px column the stage heading had
     nowhere to go — "Presentation" is one word and 242px at 200% — so it shrank to
     a zero-width box and painted its text straight across the window-action
     buttons. Both tab bars keep their labels for the same reason: there is room
     for them now, so nothing in this mode is icon-only. */
  .utility-dock{
    display:grid;
    grid-template-columns:repeat(2,minmax(0,1fr));
    grid-template-rows:auto;
  }
  .utility-dock button{flex-direction:row;gap:6px;padding:7px}
  .utility-dock .utility-label{display:inline}
  .local-slide-nav{margin-top:7px}
  .presentation-panel .local-slide-nav{
    position:static;
    transform:none;
    background:transparent;
    box-shadow:none;
  }
  .presentation-panel .local-slide-nav button{
    flex:1 1 calc(50% - 7px);
    width:auto;
    min-width:auto;
    background:var(--raised);
    box-shadow:none;
  }
  /* The one thing the scroll cannot help: the stage invariant
     (docs/CODEBASE.html#arch-stage-invariant) requires the 16:9 visual to be
     inside the viewport, and a slide sized from the stage's width would run past
     the fold on a viewport this short. Driving it from the viewport instead keeps
     the letterbox exactly true — height is the definite side, so the ratio gives
     the width — and keeps it on screen with the rest of the stage scrolling to
     it. It shrinks, which is precisely what the 200% zoom gate
     (docs/CODEBASE.html#style-zoom-and-reflow) says is correct. */
  .stage-cell .visual-surface,.presentation-panel .visual-surface{
    width:auto;
    height:28dvh;
    max-height:none;
    margin-inline:auto;
  }
}
/* ====================================================================
   18c · THE PHONE BANDS — the mobile redesign (Tor's rev-3, 2026-08-11)
   ====================================================================
   Two composed gates, approved as a pair (design record:
   docs/MOBILE-REDESIGN.md; verified against the live app by the rev-3
   prototype captures):

     PORTRAIT PHONE ..... (max-width:600px) and (min-height:521px)
     LANDSCAPE PHONE .... (max-height:520px) and (min-height:241px)
                          and (min-width:390px) and (max-width:950px)

   The landscape gate is CAPPED at 950px by the approved design: a short
   DESKTOP window (a real 1300x500, say) keeps the shipped two-column
   short-landscape layout — the burger and the single-cell stage are for
   phones, where the switcher row's height is the scarce thing. Every
   short viewport in the tested matrix and its halves
   (docs/CODEBASE.html#arch-viewport-matrix: 844x390, 720x450, 640x310,
   590x410, 422x195) sits under the cap. The gate also FLOORS at 241px:
   below 240px tier 18b turns the shell into a scrolling page whose own
   contract says "nothing in this mode is icon-only", and hiding the
   switcher behind a burger there would delete the tabs that contract
   keeps whole — 422x195 therefore keeps 18b's stacked tabs untouched.

   ORDER IS LOAD-BEARING, twice:
   - AFTER 18a, because 18a re-arranges .work-grid into its two-column
     dock layout whenever the work area runs short on text-lines, which
     under text zoom fires INSIDE these bands (410x590 at 200% measures
     13em) — the single-column templates below must win that tie, and the
     dock has already left the grid (pinned onto the rail).
   - BEFORE 19/20/21: full mode's gates are disjoint, the Self Test fit
     rules (§20) out-rank these bands by specificity AND order — the Self
     Test panel keeps its shipped compact shape, its own redesign is a
     later slice — and fill-window mode (§21) keeps hiding the rail, the
     dock and the switcher wholesale.

   What the bands build, per the approved design:
   - FULL-BLEED STAGE for the visual features (Slides / Camera): panel
     chrome stripped, the 16:9 surface edge to edge; in portrait the
     [icon-only ⤢ strip + visual + Previous/Next] group centers in the
     stage's vertical middle. Non-visual features keep the framed panel.
   - The ⤢ Expand verb OUTSIDE the visual everywhere and ICON-ONLY —
     the accessible name lives in its constant aria-label, untouched.
   - Portrait topbar: title + gear on row 1, the air pill at FULL bar
     width on row 2, height reserved by the markup ghost
     (.global-air-reserve — see shell.css §5).
   - The Marks/Transcript utility dock pinned onto the rail's bottom as
     icon buttons with a corner count badge. Paint only — the buttons
     never move in the DOM, because ui/focus.js resolves them through
     closest('[data-feature-root]') and re-queries them within the role
     root; position:fixed is the whole relocation.
   - Landscape: single-cell work grid, the switcher restyled as a
     full-screen burger sheet (.rail-burger in the rails, .sheet-close in
     rack.php, ui/burger.js), the visual height-bound with 60px gutters
     reserved for the corner ⤢.
   ==================================================================== */
/* Markup that ships in every mode but participates only here: the rail
   burger and the switcher's "Close menu" row. Dormant by default — these
   two lines must stay ABOVE the landscape band that wakes them, because
   later-order is the only thing that turns them on. */
.rail-burger{display:none}
.feature-switcher .sheet-close{display:none}
/* The first-landing drawer's scrim (tier 18d below) is third in that set:
   studio.php ships it in every mode, only .rail-expanded on the bands
   paints it. */
.rail-scrim{display:none}
/* ---- 18c·P · PORTRAIT PHONE ---------------------------------------- */
@media(max-width:600px) and (min-height:521px){
  /* The topbar, two deliberate rows (Tor's rev-3). Row 1: the session
     title block beside the Settings gear trailing at the bar's right
     edge. Row 2: the air pill at FULL bar width. Nothing ever floats
     mid-bar — the shape this replaces is recorded where it stood (the
     ≤520px+≥521px block above). */
  .topbar{
    padding:6px 9px 8px;
    grid-template-columns:minmax(0,1fr) auto;
    grid-template-rows:auto auto;
    row-gap:8px;
    column-gap:7px;
    align-content:center;
  }
  /* Title content tops its row so its first line and the gear's box share
     the row's top edge — a 44px square cannot center on a 19px text line
     without leaving the bar; sharing the top edge is the honest
     alignment. A long title wraps inside its own minmax(0,1fr) column
     beside the gear's slot: never truncated, never under the gear. */
  .session-title{grid-row:1;grid-column:1;align-content:start}
  /* PLACEMENT only. The box itself is the ≤520px block's shed square
     (44px floor) grown to 48px wherever the §19d width-pressure
     container fires — which is every bar this narrow — and an `auto`
     column takes either without overhang, so this tier does not re-fight
     that width. */
  .topbar .topbar-settings{grid-row:1;grid-column:2;justify-self:end;align-self:start;margin-right:0}
  /* The pill spans the WHOLE bar on its own row. Width stability is free
     at full bleed — both roles' longest states fit one line — but the
     HEIGHT guarantee stays explicit, not lucky: the markup ghost
     (.global-air-reserve, the .session-clock-reserve idiom) stacks the
     widest per-role state under the live span in the same grid area, so
     Off air -> ON AIR cannot change the pill's box or move the stage
     mid-take. */
  .global-air-status{
    grid-row:2;
    grid-column:1/-1;
    justify-self:stretch;
    align-self:center;
    width:auto;
    white-space:normal;
    display:grid;
    align-items:center;
    justify-items:center;
  }
  .global-air-status>span{grid-area:1/1;min-width:0}
  .global-air-reserve{display:block}
  /* §19d's producer pill cap is the width-pressure relief for a pill
     SHARING its row with the title; on this bar the pill owns the whole
     row, so width is free and the cap only does harm — measured at the
     producer's 390x844 zoom pass, 198px wrapped the reserve ghost to
     five 30px lines and spent 165px of bar height on an empty span. The
     extra ancestor out-weighs 19d's two-class selector, which follows
     this block in source order. */
  .workspace .topbar .global-air-status{max-width:none}

  /* Marks/Transcript pinned onto the rail's bottom-left corner. The
     buttons stay in rack.php's DOM — ui/focus.js finds them via
     closest('[data-feature-root]') — so only their PAINT moves. The
     producer's workspace views and the Self Test focus still hide the
     dock outright (producer.css / §20, both out-ranking this on
     specificity), and the rail un-shortens below when they do. */
  .utility-dock{
    position:fixed;
    left:0;
    bottom:0;
    z-index:105; /* above .setup-rail's 100 */
    box-sizing:border-box;
    width:var(--rail-w);
    margin:0;
    padding:8px 5px 10px;
    display:flex;
    flex-direction:column;
    align-items:center;
    gap:6px;
    background:var(--raised);
    border-top:1px solid var(--line);
    border-right:1px solid rgba(34,211,238,.52);
  }
  .utility-dock button{position:relative;flex:0 0 auto;width:48px;min-width:48px;min-height:48px;padding:6px}
  .utility-dock button svg{width:26px;height:26px}
  .utility-dock .utility-label{display:none}
  /* The count badge sits INSIDE the button's corner — the collapsed
     rail's own corner-badge form (§19), not the approved shots' slight
     overhang: measured, an overhanging badge puts ~3px of its count text
     past the button's padding box, which the audit's content-overflow
     rule convicts (qa.js textOverflow — real text laid out past the
     control's edge). Inside the corner the badge overlaps the icon's
     shoulder instead, exactly as .rail-collapsed's status badges do. */
  .utility-dock .mark-count{position:absolute;top:1px;right:1px;min-width:24px;min-height:24px;padding:2px;font-size:15px;border:1px solid var(--cyan)}
  /* The rail's scrollport ENDS above the pinned dock rather than running
     under it. Measured (640x310, the 1280x620 browser-zoom half, where
     the icon rail genuinely overflows): with a full-height rail and only
     a padding-bottom reserve, the minimal user-scroll — the audit's own
     scrollOwnAncestors, and what a person's first flick does — brings a
     bottom row exactly to the rail's border-box edge, which is UNDER the
     opaque dock, so the "revealed" control still fails its hit test.
     Shortening the box is what makes one scroll land rows clear. 121px =
     dock border 1 + padding 8+10 + two 48px buttons + one 6px gap. */
  .setup-rail{height:calc(100% - 121px)}
  .setup-rail:has(~ .workspace .role-view.is-workspace-view),
  .setup-rail:has(~ .workspace [data-active-feature="self-test"]){height:auto}

  /* The work grid: two rows (switcher / stage) — the dock is out of
     flow. The single column is declared, not assumed: 18a re-adds its
     56px dock column whenever zoom shortens the work area, and that
     column would be a dead strip here. */
  .work-grid{
    padding:6px 0 8px;
    gap:8px;
    grid-template-columns:minmax(0,1fr);
    grid-template-rows:minmax(52px,auto) minmax(0,1fr);
  }
  .feature-switcher{margin:0 8px}
  /* Icon above label on every tab: "Camera" wraps at this width anyway,
     so the column form makes all three tabs match instead of only the
     long one. */
  .feature-switcher button{flex-direction:column;gap:3px;min-height:60px;padding-top:6px;padding-bottom:6px;font-size:17px}
  .feature-switcher button svg{width:24px;height:24px}
  /* Non-visual features (Script, Marks, Chat, Files, the producer's
     pickers) keep the framed panel — give it back the side breathing
     room the work grid no longer pads. */
  .feature-stage:not(:has(.visual-panel.is-active)){margin:0 8px}

  /* FULL-BLEED stage for Slides / Camera, CENTERED. The stage grid
     becomes two content-sized rows (strip, cell) and safe-center floats
     the group — [⤢ strip glued above the visual] + [visual] +
     [Previous/Next, slides only] — to the stage's vertical middle, both
     roles. `safe` falls back to top-aligned the moment the group would
     outgrow a short stage, and the STAGE is then the scroller.
     max-content rows, NOT auto, and the difference is the whole scroll
     story: stage.css ships .stage-cell{min-height:0} (and the strip rule
     zeroes the header's), which zeroes each item's min-content
     contribution, so an `auto` row only grows in the maximize-tracks
     step — which stops at the container edge and stops entirely under
     negative free space (measured on the prototype at 393x852: an auto
     cell row resolved 526px against 575px of content, the bottom 50px
     unreachable behind the clip; with that fixed the auto header row
     collapsed to 0). A max-content track takes its base size from the
     content contribution directly. */
  .feature-stage:has(.visual-panel.is-active){
    padding:0;
    gap:6px;
    grid-template-rows:max-content max-content;
    align-content:safe center;
    overflow-y:auto;
    border:0;
    border-radius:0;
    background:transparent;
    box-shadow:none;
  }
  /* Content-sizing the cell means releasing its size containment (size
     containment zeroes an auto height). Nothing in the full-bleed state
     needs the cell as a query container: the surface is width-driven
     (100% below) and slide type's cqw clamps resolve against the
     studio-work container, which is the same full-bleed width. One query
     DOES re-parent — panels.css §13's unnamed height query (the
     deckless/camera-empty escape) resolves against studio-work here
     instead of the cell — but it cannot flip: this band floors the
     viewport at 521px, the role-view never drops near that query's
     ~165px threshold, and the zoom release below restores cell
     containment exactly where small-cell states return (opencode NOTE 4).
     Non-visual panels keep the cell container untouched, and the zoom
     tier at the end of this block restores it. */
  .feature-stage:has(.visual-panel.is-active) .stage-cell{container-type:normal}
  /* The stage header renders as a slim right-aligned TOOLBAR STRIP above
     the visual, and the ⤢ Expand verb lives THERE — fully outside the
     visual's box (Tor's 2026-08-11 review) and icon-only (rev-3): base
     stage.css already sizes the 44px button and .window-actions'
     margin-left:auto hugs it to the right edge, so the strip needs no
     button rules at all. The knock alert surfaces in the same strip. */
  .feature-stage:has(.visual-panel.is-active) .stage-header{min-height:0;margin:0;padding:0 8px;gap:6px}
  /* Visually hidden, kept in the accessibility tree (base.css .sr-only
     inlined — a stylesheet cannot add the class): the active switcher
     tab already names the feature on screen. */
  .feature-stage:has(.visual-panel.is-active) .stage-header h2{
    position:absolute;
    width:1px;
    height:1px;
    padding:0;
    margin:-1px;
    overflow:hidden;
    clip-path:inset(50%);
    white-space:nowrap;
  }
  /* Start-alignment inside the content-sized cell is what keeps the
     strip GLUED to the visual while the whole group floats. */
  .visual-panel{place-items:start center}
  .presentation-panel.is-active{grid-template-rows:auto auto;align-content:start;gap:10px}
  .work-grid[data-role="producer"] .presentation-panel.is-active{grid-template-rows:auto;gap:10px}
  /* The join/knock card leaves its absolute center-of-stage overlay —
     dead center is exactly where the visual now sits — and flows as an
     ordinary row AFTER the visual inside the centered group, so it can
     never paint over the picture. Both cards: the producer's Join card
     and the instructor's Producer-joined announcement. */
  .feature-stage:has(.visual-panel.is-active) .knock-card{
    position:static;
    transform:none;
    justify-self:center;
    width:min(480px,calc(100% - 24px));
  }
  /* Edge to edge: 16:9 by its own aspect-ratio, radius and side borders
     removed, top/bottom borders kept. Overrides the cqh width forms
     (stage.css §9 / panels.css) — width is the driving side here. */
  .presentation-panel .visual-surface,
  .camera-panel .visual-surface{
    width:100%;
    height:auto;
    max-height:none;
    place-self:start center;
    border-radius:0;
    border-left:0;
    border-right:0;
  }
  /* INSTRUCTOR camera only: at full-bleed width the bottom-right readout
     sat beside the bottom-left Hide-preview toggle; the ⤢ owns the strip
     above, so the readout takes the surface's top-left. The producer
     camera has no toggle and its empty-state copy starts high, so its
     readout keeps the shipped bottom-right. */
  .work-grid[data-role="instructor"] .camera-panel .camera-readout{left:10px;top:10px;right:auto;bottom:auto}
  .local-slide-nav{width:100%;max-width:none;padding:0 8px;gap:8px}
  .local-slide-nav button{flex:1 1 40%;min-height:56px;font-size:17px}

  /* THE ZOOM RELEASE — short on text-lines, the letterbox wins. A
     width-driven surface cannot shrink, and this band's grown tabs and
     nav cannot un-grow, so under text zoom the full-bleed shapes above
     spend height the crammed work area no longer has: measured, the
     surface's bottom left the viewport at 410x590 @200% (13em of work
     area), and at the producer's 390x844 zoom pass (16.1em — its
     session bar spends what the instructor's does not, and the readonly
     review block owns most of the transport row) the readonly paging
     overflowed its own buttons. Chasing those cells one by one put the
     threshold between 16.1 and 16.3em — a 0.2em knife-edge no honest
     tier sits on — so the tier owns EVERY zoomed portrait cell instead:
     full-bleed centering is the 100%-text presentation, and under zoom
     the stage returns to the shipped cell-bound letterbox, which is the
     zoom gate's ruling in its own words — a letterboxed stage that
     shrinks is correct. 18a's instrument, re-derived the §62 way: the
     matrix's LARGEST zoomed portrait work area is 16.3em (instructor
     390x844 @200%, which must fire) and its SMALLEST 100% work area is
     ~24.5em (410x590, which must not); any threshold strictly between
     separates them, and 20em is the midpoint. Chrome stays stripped —
     that costs no height. */
  @container studio-work (max-height:20em){
    /* The tab row holds ONE line and scrolls sideways inside itself. This
       band's 17px tab labels compute to 34px under the zoom pass, whose
       min-content (three ~120px words) outgrows the ~330px row that the
       shipped 15px labels still (barely) fit — the row then wrapped to
       TWO tab lines, 159px of a 255px work area, and the chat panel
       measured 10px with its Send button painting over the transport.
       Sideways own-container scrolling is the sanctioned form for a wide
       element (the pinched-stage h2 directly above the cell does exactly
       this); every tab stays 44px+, whole, and one swipe away. */
    .feature-switcher{flex-wrap:nowrap;overflow-x:auto}
    .feature-switcher button{flex:0 0 auto;min-height:48px;padding-top:4px;padding-bottom:4px}
    .feature-stage:has(.visual-panel.is-active){grid-template-rows:auto minmax(0,1fr);align-content:stretch}
    .feature-stage:has(.visual-panel.is-active) .stage-cell{container-type:size}
    /* overflow-y:auto beside the restored rows, because the cell's 132px
       collapse floor holds a panel whose zoomed nav band alone measures
       129px — content 139 in a 132 box (measured with a real deck at
       410x590 @200% text; Next's bottom 7px clipped dead behind the
       panel's hidden overflow). The deckless variant already scrolls the
       panel (panels.css); with a deck the same answer applies: a region
       holding controls either grows or scrolls. */
    .presentation-panel.is-active{grid-template-rows:minmax(0,1fr) auto;overflow-y:auto}
    .work-grid[data-role="producer"] .presentation-panel.is-active{grid-template-rows:minmax(0,1fr)}
    @supports(width:1cqh){
      /* Both surfaces take the height-driven form (height capped by the
         OWN row via 100%, width from the ratio of the cell width) — the
         same correction panels.css carries for the camera panel's
         two-row grid: a cqh width here would size against the whole
         cell while the pickers row spends part of it, and max-height
         then clamps the box into a distorted ~1.92 letterbox (measured
         at 410x590 under the zoom pass). */
      .presentation-panel .visual-surface,
      .camera-panel .visual-surface{width:auto;height:min(100%,calc(100cqw * 9 / 16));max-height:100%}
    }
    /* The paging pair returns to its stage.css shape too: this band's
       grown 56px/17px buttons were measured against the roomy 100%-text
       stage, and inside the zoom-crammed cell they outgrew the nav's own
       scroll cap — the with-deck Next clipped 7px dead behind the panel
       at 410x590 @200% text. The shipped sizes are what that cap's
       arithmetic was derived for (48px targets and 30px computed text
       under zoom — both floors hold). */
    .local-slide-nav{max-width:max-content;padding:0;gap:7px}
    .local-slide-nav button{flex:1 1 calc(50% - 7px);min-height:48px;font-size:15px}
  }
}
/* ---- 18c·L · LANDSCAPE PHONE --------------------------------------- */
@media(max-height:520px) and (min-height:241px) and (min-width:390px) and (max-width:950px){
  /* The rail: brand yields to controls, and the arithmetic is the whole
     game on a 393px-tall rail, so every gap here is counted, not styled.
     Instructor rail at 393px: 4 top padding + 44 burger + 4 gap +
     (3 steps x 44 + 2 x 3 gaps = 138) + 4 gap + (44 chat + 3 + 44 files
     = 91) + 7 base bottom padding = 292 against the 295px box the dock
     reserve leaves — 3px of slack. Any new rail row re-opens this sum.
     The producer rail (11 rows) overflows and scrolls, as it does today;
     the pinned dock below is opaque and the shortened scrollport is what
     lets every row scroll fully clear of it. */
  .brand-lockup{display:none}
  .setup-button,.producer-nav-button,.presence-button{
    width:44px;
    min-width:44px;
    min-height:44px;
    padding:8px;
  }
  .setup-list,.producer-menu{gap:3px}
  .setup-rail{padding-top:4px;gap:4px;height:calc(100% - 98px)}
  /* Same un-shorten condition as portrait: when the dock is hidden
     (producer workspace views, Self Test focus) the rail takes its
     column back. */
  .setup-rail:has(~ .workspace .role-view.is-workspace-view),
  .setup-rail:has(~ .workspace [data-active-feature="self-test"]){height:auto}
  .producer-help{display:grid;gap:3px;justify-items:center}

  /* The burger at the top of the rail (rail-*.php ships it; ui/burger.js
     opens the sheet below). Cyan like the affordances it stands in for;
     44px floor exactly — rail arithmetic above. */
  .rail-burger{
    display:flex;
    flex:0 0 auto;
    align-items:center;
    justify-content:center;
    justify-self:center;
    width:44px;
    min-width:44px;
    min-height:44px;
    padding:7px;
    border:1px solid var(--cyan);
    border-radius:var(--control-radius);
    background:var(--raised);
    color:var(--cyan);
  }
  .rail-burger svg{width:24px;height:24px;fill:var(--cyan)}
  .rail-burger:hover,.rail-burger:focus-visible{box-shadow:var(--glow)}

  /* Marks/Transcript pinned onto the rail at the 44px floor — the
     portrait pin, tightened. 98px = dock border 1 + padding 2+4 + two
     44px buttons + one 3px gap (the .setup-rail reserve above). */
  .utility-dock{
    position:fixed;
    left:0;
    bottom:0;
    z-index:105;
    box-sizing:border-box;
    width:var(--rail-w);
    margin:0;
    padding:2px 5px 4px;
    display:flex;
    flex-direction:column;
    align-items:center;
    gap:3px;
    background:var(--raised);
    border-top:1px solid var(--line);
    border-right:1px solid rgba(34,211,238,.52);
  }
  .utility-dock button{position:relative;flex:0 0 auto;width:44px;min-width:44px;min-height:44px;padding:6px}
  .utility-dock button svg{width:24px;height:24px}
  .utility-dock .utility-label{display:none}
  /* Inside the corner, not overhanging — the portrait pin's measured
     content-overflow reasoning, verbatim. */
  .utility-dock .mark-count{position:absolute;top:1px;right:1px;min-width:24px;min-height:24px;padding:2px;font-size:15px;border:1px solid var(--cyan)}

  /* The stage owns the whole work area: the switcher row and the right
     dock column are both gone from the grid — the switcher's home is the
     burger sheet, the dock's is the rail pin. Single column DECLARED for
     the same 18a reconciliation as portrait. */
  .work-grid{
    padding:0;
    column-gap:0;
    row-gap:0;
    grid-template-columns:minmax(0,1fr);
    grid-template-rows:minmax(0,1fr);
  }
  .feature-switcher{display:none}
  .feature-stage{grid-column:1;grid-row:1}
  .feature-stage:not(:has(.visual-panel.is-active)){margin:4px 8px}
  .feature-stage:has(.visual-panel.is-active){
    padding:0;
    gap:0;
    border:0;
    border-radius:0;
    background:transparent;
    box-shadow:none;
  }
  .feature-stage:has(.visual-panel.is-active) .stage-header{min-height:0;margin:0;padding:0 8px;gap:6px}
  .feature-stage:has(.visual-panel.is-active) .stage-header h2{
    position:absolute;
    width:1px;
    height:1px;
    padding:0;
    margin:-1px;
    overflow:hidden;
    clip-path:inset(50%);
    white-space:nowrap;
  }
  /* ⤢ OUTSIDE the visual here too, icon-only: the stock 44px button
     anchors to the stage's top-right corner, which is the letterbox
     GUTTER beside the height-bound visual — and the gutter is
     GUARANTEED, not incidental: the surface formula below reserves 60px
     each side, so the visual can never grow under the button at any
     window size this band admits. stage.css's .window-actions already
     carries z-index:8, above the paging overlay's 7. */
  .feature-stage:has(.visual-panel.is-active) .window-actions{
    position:absolute;
    top:8px;
    right:8px;
  }
  /* The gutter reservation. The band's surfaces are height-driven
     (stage.css §9 / panels.css camera): width follows from aspect-ratio,
     so capping HEIGHT by the cell width LESS two reserved gutters keeps
     the 16:9 contract true while pinning ≥60px of letterbox each side —
     8px edge + the 44px ⤢ + 8px clearance. Measured at 852x393 the
     natural gutters are ~152px, so the cap is inert there; it exists to
     guarantee the band's wider windows. cqw resolves against .stage-cell,
     whose size containment this band KEEPS. */
  @supports(width:1cqh){
    .presentation-panel .visual-surface,
    .camera-panel .visual-surface{
      width:auto;
      height:min(100%,calc((100cqw - 120px) * 9 / 16));
      border-radius:0;
    }
  }
  /* With the stage full width the paging overlay's rail can span the
     whole panel: Previous and Next land on the letterbox gutters BESIDE
     the slide instead of over its words (stage.css's max-content cap is
     what kept the pair huddled mid-slide). At the band's narrower
     windows the 108px buttons still lap onto the slide's edge — the
     shipped overlay's own resting state, with the same opaque backing. */
  .presentation-panel .local-slide-nav{max-width:none}

  /* The burger sheet: the switcher ITSELF, restyled as a full-screen
     layer of large labeled buttons — same DOM nodes, so ui/focus.js's
     click handlers and is-active syncing work untouched; ui/burger.js
     only opens and closes. z-index 150: over the workspace chrome (rail
     100, dock 105, anchored sheets 120), under the toast (200) and every
     modal layer, so a confirmation or a refusal still outranks an open
     menu. Under 200% text zoom the labels wrap inside their buttons and
     the SHEET scrolls (safe center + overflow) — nothing truncated. */
  .app-shell.burger-open .role-view.is-active .feature-switcher{
    position:fixed;
    inset:0;
    z-index:150;
    margin:0;
    display:grid;
    /* max-content rows, the 18c·P centered-group finding one more time:
       auto rows resolved ~75px against 82px of zoomed two-line button
       content (measured at 852x393 under the zoom pass — 7px of label
       clipped inside the button), where a max-content track takes the
       contribution directly. Rows grow, the sheet scrolls, nothing is
       cut. */
    grid-auto-rows:max-content;
    align-content:safe center;
    justify-items:center;
    gap:12px;
    padding:20px;
    overflow-y:auto;
    background:rgba(5,8,15,.9);
  }
  .app-shell.burger-open .role-view.is-active .feature-switcher button{
    width:min(430px,86vw);
    min-height:58px;
    font-size:20px;
    background:var(--raised);
    border:1px solid var(--line);
  }
  .app-shell.burger-open .role-view.is-active .feature-switcher button svg{width:26px;height:26px}
  .app-shell.burger-open .role-view.is-active .feature-switcher button.is-active{
    border:2px solid var(--cyan);
    background:var(--blue);
    box-shadow:var(--glow);
  }
  .app-shell.burger-open .role-view.is-active .feature-switcher .sheet-close{
    display:flex;
    align-items:center;
    justify-content:center;
    gap:8px;
    color:var(--dim);
  }
  .app-shell.burger-open .role-view.is-active .feature-switcher .sheet-close svg{width:22px;height:22px;fill:currentColor}
}
/* ====================================================================
   18d · THE FIRST-LANDING DRAWER (mobile redesign slice 2 — ITEM A,
   Tor's rev-3, 2026-08-11; design record docs/MOBILE-REDESIGN.md)
   ====================================================================
   On an instructor's FIRST landing — selfTest 'pending', the durable
   server fact studio.php seeds from the session's self_tests row — the
   left rail opens EXPANDED so a 65+ instructor reads what each step IS
   before it collapses to icons. Phone treatment: a DRAWER over a scrim,
   not a push — at 393px a pushed 272px rail leaves a 121px stage, which
   is a layout nobody can use, so the rail overlays instead and the
   approved rev-3 layout waits intact behind the scrim. The drawer
   reuses the FULL-MODE rail idiom (§19: max(272px,17ch) column,
   .step-copy visible, 19px step names, 15px subtitles, statuses
   inline) so the phone's expanded menu and the desktop's expanded menu
   are the same design.

   State and lifecycle live in ui/rail.js (.rail-expanded on the shell —
   the .rail-collapsed idiom, the inverse sense). The GATES below are
   18c's two bands verbatim, including the 241px floor and the 950px
   cap, and ui/rail.js carries the same pair in its PHONE_BANDS matcher
   — kept in sync BY HAND, two-way comment both sides: a drift opens
   the drawer where this CSS is dormant, or strands this CSS unused.

   Z-ORDER, deliberate: scrim 170 / drawer 180 / pinned Minimize 185 —
   above the workspace chrome and the burger sheet's 150 (which it can
   never actually meet: the open drawer hides .rail-burger, an open
   sheet inerts the rail), BELOW the toast's 200 and the device
   banner's 240, because a first landing is exactly when the camera
   permission flow can raise that banner and it must stay perceivable
   and clickable over the drawer — the burger sheet's own ruling.
   ==================================================================== */
@media (max-width:600px) and (min-height:521px),
       (max-height:520px) and (min-height:241px) and (min-width:390px) and (max-width:950px){
  /* One width, defined once: drawer and its pinned Minimize must agree.
     ch resolves at each var() site — both carry the rail's inherited text
     size, so the two compute alike, and under 200% text zoom the column
     widens with its own labels (the §19 --rail-w reasoning, one tier
     down). The cap is calc(100vw - 44px), NOT a percentage: the reserved
     strip is a tap-target-sized scrim (the pointer dismissal stays a real
     target at every width), and the extra room is load-bearing under
     zoom — review-measured at 390x844 @200%, the earlier 86vw cap (335px)
     left the copy track 199px against the 209px the unbreakable word
     "PowerPoint" needs at 38px, and its glyphs crossed 0.64px into the
     status track; at 100vw-44px (346px) the track measures 210px and the
     word fits whole. */
  .app-shell.rail-expanded{--drawer-w:min(max(272px,17ch),calc(100vw - 44px))}
  .app-shell.rail-expanded .rail-scrim{
    display:block;
    position:fixed;
    inset:0;
    z-index:170;
    margin:0;
    padding:0;
    border:0;
    border-radius:0;
    background:rgba(5,8,15,.68);
  }
  /* The drawer. position:fixed lifts the rail out of the shell grid, so
     the workspace keeps its approved rev-3 shape behind the scrim (the
     64px rail column simply sits empty under it). The rail keeps base
     shell.css's overflow-y:auto: when the column outruns the viewport
     THE RAIL scrolls, and the pinned Minimize below never scrolls with
     it — the padding-bottom is that button's reserve (56px button + the
     breathing room), the utility-dock pin discipline. */
  .app-shell.rail-expanded .setup-rail{
    position:fixed;
    top:0;
    left:0;
    bottom:0;
    /* height:auto releases 18c's dock-driven shortening
       (height:calc(100% - dock)) so top/bottom govern: measured at
       390x844, the shortened calc left the fixed drawer 723px tall with
       a 121px dead band above the pinned Minimize. The dock paints
       under the scrim (z 105 vs 170) while the drawer is open, so the
       shortening buys nothing here. */
    height:auto;
    z-index:180;
    box-sizing:border-box;
    width:var(--drawer-w);
    padding:10px 10px 84px;
    display:grid;
    grid-template-rows:auto auto minmax(min-content,1fr) auto;
    gap:8px;
    border-right:2px solid var(--cyan);
    box-shadow:26px 0 60px -14px rgba(0,0,0,.85),12px 0 34px -20px var(--cyan);
    overflow-y:auto;
  }
  /* Landscape hides the brand for rail height; the drawer has room, and a
     first landing is exactly where the lockup earns its place. Explicit
     HEIGHT on the fan, width from aspect-ratio — measured on the
     prototype: with a percentage width the short-landscape rail's auto
     row ignores the fan's derived height and the lockup collapses to its
     min-height, bleeding the fan and wordmark over the SETUP eyebrow. A
     definite height sizes the row honestly in both bands; min-height 90
     covers the full content (12 pad + 54 fan + 4 gap + 18 wordmark) as
     the second guard. */
  .app-shell.rail-expanded .brand-lockup{display:grid;min-height:90px;padding:6px 12px;gap:4px}
  .app-shell.rail-expanded .brand-fan{width:auto;height:54px}
  .app-shell.rail-expanded .brand-lockup::after{display:block}
  /* The burger opens the OTHER menu (the feature switcher). Two open-menu
     affordances inside one open menu is a trap; it returns on collapse. */
  .app-shell.rail-expanded .rail-burger{display:none}
  /* "Setup" eyebrow — §19's rail-heading treatment, verbatim. */
  .app-shell.rail-expanded .rail-heading{
    display:block;
    padding:0 7px;
    color:var(--cyan);
    font:900 15px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;
    letter-spacing:.08em;
    text-transform:uppercase;
  }
  /* Labeled steps — the §19 full-mode idiom. */
  .app-shell.rail-expanded .setup-list{align-items:stretch;gap:8px}
  .app-shell.rail-expanded .setup-item{width:100%}
  .app-shell.rail-expanded .setup-button,
  .app-shell.rail-expanded .producer-nav-button,
  .app-shell.rail-expanded .presence-button{
    width:100%;
    min-width:0;
    min-height:54px;
    padding:9px 10px;
    grid-template-columns:27px minmax(0,1fr) 22px;
    place-items:center stretch;
    gap:7px;
    text-align:left;
  }
  /* §19's fourth track for the deck/script accordion chevron. */
  .app-shell.rail-expanded .setup-button{grid-template-columns:27px minmax(0,1fr) 22px 20px}
  .app-shell.rail-expanded .setup-button [data-accordion-glyph]{display:block;width:20px;height:20px;fill:var(--dim)}
  .app-shell.rail-expanded .step-copy,
  .app-shell.rail-expanded .producer-nav-copy{display:block;min-width:0}
  /* CHILD combinator on purpose (tighter than §19's descendant form): the
     Self Test row's .step-state holds three alternative state words that
     panels.css shows/hides from the button's state class; a descendant
     `span` rule here would out-cascade that mirror and stack all three
     words. `>` styles the wrapper and leaves the word-picking to
     panels.css.
     overflow-wrap:anywhere, deliberately NOT §19's `normal`: full mode's
     rail widens freely with its text, but this drawer is capped by the
     viewport, so when a viewport narrower than the tested matrix meets an
     unbreakable word at 200% zoom the honest degradation is an emergency
     break — every glyph stays visible — never glyphs painting into the
     neighbouring status track (the review-measured 0.64px collision the
     cap above already clears at the matrix cells). */
  .app-shell.rail-expanded .step-copy strong,
  .app-shell.rail-expanded .step-copy>span,
  .app-shell.rail-expanded .producer-nav-copy strong,
  .app-shell.rail-expanded .producer-nav-copy>span{
    display:block;
    overflow-wrap:anywhere;
    word-break:normal;
  }
  .app-shell.rail-expanded .step-copy strong,
  .app-shell.rail-expanded .producer-nav-copy strong{font-size:19px;line-height:1.1}
  .app-shell.rail-expanded .step-copy>span,
  .app-shell.rail-expanded .producer-nav-copy>span{margin-top:3px;color:var(--dim);font-size:15px;line-height:1.12}
  /* Status leaves the corner badge and sits inline at the row's end. */
  .app-shell.rail-expanded .status-icon{position:static;justify-self:end;border:0;background:transparent}
  .app-shell.rail-expanded .producer-help{width:100%;align-self:end;display:grid;gap:8px}
  /* The dismiss affordance: the rail's own Minimize-menu button (icon +
     words), pinned at the drawer's bottom edge so scrolling can never
     hide it — the same pin + padding-reserve discipline as the utility
     dock. 56px tall, 17px words: the 65+ next-action floor. The 17px
     rides on the SPAN, not the button, deliberately: both this button
     and the rail resolve width from the same var(--drawer-w), and ch in
     a custom property resolves against each USING element's own font —
     measured at 390x844 under the 200% text pass, a 17px button font
     made the pinned bar 321px under a 335px drawer (17ch of 34px
     against 17ch of 38px). font:inherit (base.css) keeps the button's
     ch context identical to the rail's, so the two widths cannot
     disagree at any zoom. */
  .app-shell.rail-expanded .rail-collapse{
    position:fixed;
    left:0;
    bottom:0;
    z-index:185;
    box-sizing:border-box;
    width:var(--drawer-w);
    min-height:56px;
    margin:0;
    padding:9px 12px;
    display:flex;
    align-items:center;
    justify-content:center;
    gap:9px;
    border:0;
    border-top:2px solid var(--cyan);
    border-right:2px solid var(--cyan);
    border-radius:0;
    background:var(--raised);
    color:var(--text);
    font-weight:900;
  }
  .app-shell.rail-expanded .rail-collapse span{font-size:17px}
  .app-shell.rail-expanded .rail-collapse svg{width:22px;height:22px;fill:var(--cyan);flex:0 0 auto}
  .app-shell.rail-expanded .rail-collapse:hover,
  .app-shell.rail-expanded .rail-collapse:focus-visible{box-shadow:var(--glow)}
}
/* ====================================================================
   18r · THE PRODUCER RAIL'S TWO SECTIONS — every mode (item 60 S4)
   ====================================================================
   Tor's R8, verbatim: "Left menu needs 2 categories for producer,
   'Producer' 'Instructor' sections." The wrappers exist so the DOM and
   the accessibility tree carry that grouping — they must NOT become
   layout boxes. .producer-menu is a flex column with its own gap
   (shell.css, and 8px in tier 19); a real box would swallow that gap
   for its children and, at the icon-rail tiers, strip the 4px between
   them and the centring shell.css sets. display:contents keeps every
   row a direct flex item of the one proven scroller at every tier,
   which is the same invariant rail-producer.php's grid note protects.

   The [hidden] rule IS belt-and-braces, and it is written down as such
   because the first draft of this comment claimed the opposite. The
   reasoning was that an author display:contents outranks the UA
   stylesheet's [hidden]{display:none} by origin. Measured instead of
   believed — deleting this one line in an outside copy and re-running
   producer-shell left it 103/0, every instructor row still reporting
   zero client rects — Chromium's [hidden] wins here anyway. The line
   stays because it states the intent at the point of the risk and does
   not depend on a UA detail, but it is not what makes the hiding work:
   ui/rail.js's `hidden` flag is.

   The section labels are full-mode only (below the gate the rail is a
   48-76px icon column with no room for a word), so they default to
   display:none here and tier 19 turns them on.
   ==================================================================== */
.rail-section{display:contents}
.rail-section[hidden]{display:none}
.rail-section-label{display:none}
/* ====================================================================
   18s · THE DECK AND SCRIPT ACCORDIONS — every mode (item 60 S5)
   ====================================================================
   Tor's R8, verbatim: "Both Powerpoint and script should be accordians
   with checkmarks if the files are found.  If the files are found, it
   should be displayed as an option list."

   The OPTION LIST is a real <ul>: the panel now offers two or three
   ways forward rather than one upload prompt, and a list is what says
   "these are the choices" to a screen reader as well as to an eye. Its
   li's are grid cells, not markers — the option is the 44px control
   inside, and li[hidden] is how JS drops an option that does not apply
   in this state (readonly hides every write verb) without deleting the
   list semantics the way display:contents on the li would.

   .file-facts is the fact block: the filename and the page count, the
   two things the row's two-line subtitle cannot carry (the two-line rail
   rule, docs/CODEBASE.html#style-spacing-layout — explanatory prose moves
   INTO the content it explains). It has to out-specify
   shell.css's `.setup-sheet p` (0,1,1) to stop being dim body copy, so
   it is written as a descendant pair rather than a bare class.

   .lock-note is the ONE short read-only line the open panel keeps — the
   130px of sentence Tor's amendment 2 objected to, reduced to a line
   beside the actions it governs, with the lock glyph AND its words
   (docs/CODEBASE.html#style-glyph-needs-word: never a glyph alone).

   The chevron defaults to display:none because below the full-mode gate
   the rail is a 48-76px icon column with no third or fourth grid track
   to put it in; tier 19 turns it on and widens the button's grid to
   match. It is aria-hidden everywhere: aria-expanded on the button is
   the state a screen reader is told, and saying it twice is noise.
   ==================================================================== */
.setup-options{margin:0;padding:0;display:grid;gap:8px;list-style:none}
.setup-options>li{min-width:0;display:grid}
.setup-options>li[hidden]{display:none}
.setup-sheet .file-facts{
  margin:0;
  color:var(--text);
  font-size:16px;
  font-weight:800;
  line-height:1.3;
  overflow-wrap:anywhere;
}
.setup-sheet .lock-note{
  margin:0;
  display:flex;
  align-items:flex-start;
  gap:7px;
  color:var(--dim);
  font-size:15px;
  line-height:1.35;
}
.setup-sheet .lock-note svg{width:18px;height:18px;flex:0 0 auto;fill:var(--dim)}
[data-accordion-glyph]{display:none}
/* ====================================================================
   19 · FULL MODE (min 1100px wide AND 700px tall)
   Everything opens up: labeled 272px rail with step copy, inline setup
   sheets, the side rack appears (switcher + utility dock hide — the
   rack replaces them), transport centers under the stage with slide
   paging restored, key-cap hints show, and the user-controlled
   .rail-collapsed variant shrinks the rail to a 76px icon column
   (toggle always visible, sheets disabled, statuses become corner
   badges). After it: input-dependent affordances and reduced-motion,
   then the Self Test fit rules, then the fill-window override last.
   ==================================================================== */
@media(min-width:1100px) and (min-height:700px){
  /* Accessibility beats mockup fidelity
     (docs/CODEBASE.html#style-accessibility-over-fidelity). The rail is a column
     of labels, so measuring it only in pixels means its own labels wrap as soon as
     the text grows — at 200% text zoom "Minimize menu" and the sheet actions all
     went to two lines and the rail ran out of column. The ch floor keeps the
     mockup's exact 272px at the design's text size and lets the column widen
     with the text it has to carry, so the labels stay on one line instead. */
  :root{--rail-w:max(272px,17ch);--top-h:72px;--transport-h:82px}
  /* The rail is a size container so the two rules at the end of this block can
     ask the one question a media query cannot answer: how tall is this column
     measured in the text it is currently carrying. A px height query sees 820px
     whatever the text does, and `em` in a MEDIA query resolves against the
     initial font size, which text-only zoom also leaves alone — but `em` in a
     CONTAINER query resolves against this element's own font size, which is
     exactly what text zoom changes. 820px is ~48em of rail at the design's text
     size and ~24em at 200%; the composed responsive modes' own pairing of width
     AND height gates (docs/CODEBASE.html#style-spacing-layout) is the same idea,
     one axis further. */
  .setup-rail{
    padding:10px;
    grid-template-rows:auto auto minmax(0,1fr) auto auto;
    gap:8px;
    overflow:hidden; /* item-32 audit: full-mode layout clip — the LIST inside is the scroller (19a) */
    container-type:size;
    container-name:studio-rail;
  }
  .brand-lockup{min-height:72px;padding:6px 12px;gap:4px}
  .brand-fan{width:66%}
  .brand-lockup::after{display:block}
  .rail-heading{
    display:block;
    padding:0 7px;
    color:var(--cyan);
    font:900 15px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;
    letter-spacing:.08em;
    text-transform:uppercase;
  }
  /* ITEM 60 S4. The producer rail's standing .rail-heading is retired — it
     said "Producer" and would have sat directly above a section label saying
     "Producer" — so this rail is brand / scroller / help / collapse, FOUR
     rows against the instructor rail's five. Load-bearing, and the reason it
     is written as its own rule rather than by changing the shared one: an
     unexpected child count lands in an implicit grid row that this block's
     overflow:hidden then clips, which the item-53 build measured as
     unreachable controls at 200% text zoom. The instructor rail keeps
     "Setup" and its five rows untouched above. */
  .producer-rail{grid-template-rows:auto minmax(0,1fr) auto auto}
  /* The section eyebrow. Deliberately the .rail-heading treatment it
     replaces — same 15px mono, same cyan, same tracking — because these ARE
     that heading, one per section, and a second typographic voice in a
     240px column is exactly the clutter amendment 2 objected to. 15px is the
     docs/CODEBASE.html#style-accessibility floor for the 65+ reader, not a
     decorative small-caps label, so it is never shrunk to buy pixels. */
  .rail-section-label{
    display:block;
    padding:2px 7px 0;
    color:var(--cyan);
    font:900 15px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;
    letter-spacing:.08em;
    text-transform:uppercase;
  }
  .setup-list,.producer-menu{
    align-items:stretch;
    gap:8px;
    overflow-y:auto;
    overflow-x:hidden;
    scrollbar-color:var(--steel) transparent;
  }
  .setup-item{width:100%}
  .setup-button,.producer-nav-button,.presence-button{
    width:100%;
    min-width:0;
    min-height:54px;
    padding:9px 10px;
    grid-template-columns:27px minmax(0,1fr) 22px;
    place-items:center stretch;
    gap:7px;
    text-align:left;
    border:1px solid var(--line);
    border-radius:var(--control-radius);
    background:var(--raised);
  }
  .setup-button.is-attention{border:2px solid var(--red)}
  /* ITEM 60 S5. A FOURTH track for the accordion chevron. The shared rule
     above is three (icon / copy / status) and an unexpected fourth child
     would land in an implicit COLUMN whose width the copy pays for — the
     same class of accident rail-producer.php's grid note records for rows.
     Only .setup-button gains it: the nav buttons are not accordions. */
  .setup-button{grid-template-columns:27px minmax(0,1fr) 22px 20px}
  .setup-button [data-accordion-glyph]{display:block;width:20px;height:20px;fill:var(--dim)}
  .step-copy,.producer-nav-copy{min-width:0;display:block}
  .step-copy strong,.step-copy span,.producer-nav-copy strong,.producer-nav-copy span{
    display:block;
    overflow-wrap:normal;
    word-break:normal;
  }
  .step-copy strong,.producer-nav-copy strong{font-size:19px;line-height:1.1}
  .step-copy span,.producer-nav-copy span{margin-top:3px;color:var(--dim);font-size:15px;line-height:1.12}
  .status-icon{position:static;justify-self:end;border:0;background:transparent}
  /* ITEM 60 S5, Tor's R8: "Both Powerpoint and script should be accordians".
     `display:none` here — NOT the `display:grid` full mode pinned every sheet
     open with since the mockup — so shell.css's `.setup-item.is-open >
     .setup-sheet{display:grid}` (0,2,0 beats this 0,1,0) is what governs at
     THIS tier too, exactly as it already governs every compact mode and, until
     this slice, the 19a zoom fallback. That fallback's own copy of this line
     is deleted below as the duplicate it became.
     What it buys, measured (Plank, item 67): with a session chosen at 1440x900
     and 100% text the producer rail was 770px of content in a 666px window —
     and the whole 104px was these two pinned sheets. Closed, it is 666/666.
     It does NOT close the 200% case: 19a already collapsed the sheets there,
     so at 200% there was never a sheet for this line to shut, and the rail
     still overflows for reasons this slice cannot reach. That is item 67. */
  .setup-sheet{
    position:static;
    width:auto;
    max-height:none;
    padding:2px 10px 12px;
    display:none;
    gap:8px;
    overflow:visible;
    border:0;
    border-radius:0;
    background:transparent;
    box-shadow:none;
  }
  /* The header's Close button is the popover's affordance; inline, the step
     button itself is the toggle (its chevron says which way) and the h2 would
     repeat the row directly above it.
     `.setup-sheet>p` USED to hide here as well, and item 53 had to add a
     producer-only rule (producer.css) putting the paragraph back because for
     that rail the paragraph WAS the state. Neither survives S5: the state is
     the row's own subtitle now, and what is left inside the panel — the
     .file-facts block, the .lock-note, deck.js's upload progress line — are
     all things a person must be able to read in full mode. */
  .setup-sheet-header{display:none}
  .setup-control{gap:4px}
  .setup-control>span{display:none}
  .setup-select,.setup-action{min-height:44px;padding-top:7px;padding-bottom:7px}
  .presence-button{
    grid-template-columns:17px 27px minmax(0,1fr) 22px;
    border:2px solid var(--cyan);
    border-radius:var(--control-radius);
    background:rgba(7,76,142,.58);
  }
  .producer-help{width:100%;display:grid;gap:8px}
  .producer-help .presence-button{grid-template-columns:27px minmax(0,1fr)}
  .producer-help-copy{
    margin:0;
    padding:2px 9px 4px;
    display:block;
    color:var(--dim);
    font-size:15px;
    line-height:1.45;
    overflow-wrap:normal;
    word-break:normal;
  }
  /* ====================================================================
     19a · FULL MODE, SHORT ON TEXT-LINES — the accessibility zoom fallback
     Everything above is the mockup verbatim and stays that way at the
     design's text size: 36em is under 620px of rail, and full mode starts
     at 700px, so at 100% this block never applies at any viewport in the
     tested matrix (docs/CODEBASE.html#arch-viewport-matrix). It applies
     only once the text has grown enough that the
     rail can no longer hold what the mockup asks it to hold — measured,
     not guessed, and only two rules deep.
     ==================================================================== */
  @container studio-rail (max-height:36em){
    /* ITEM 60 S5. This block USED to open with `.setup-sheet{display:none}` —
       the accessibility zoom fallback, which from 200% text down collapsed
       the five inline sheets the mockup pinned open (631px of an 820px rail)
       back to shell.css's `.setup-item.is-open` disclosure. Tor's R8 makes
       that the shape at EVERY text size, so the line moved up into tier 19
       and the copy here would have been an exact duplicate. Deleted, not kept
       "for safety": two homes for one rule is how the pair drift apart.
       What did NOT move with it is the fact this fallback was measuring. At
       200% the rail still overflows — 192px with a session chosen, Script and
       Files unreachable (Plank, item 67) — and no sheet is left to close,
       because this query had already closed them. Item 67 is open and owns it;
       S5 closes the 100% case only. */
    /* Accessibility beats mockup fidelity
       (docs/CODEBASE.html#style-accessibility-over-fidelity). This paragraph sits
       in the rail's fixed bottom row, so every pixel it grows is a pixel taken
       from the setup steps above it — at 200% text zoom it tripled to 224px and
       pushed the steps back behind the clip even with the sheets closed. 90px is
       the height it already occupies at the design's text size, so the mockup's
       rendering is what is being held, not shrunk; past it the standing help
       prose scrolls in place rather than the instructor's workflow doing so.
       Neither truncated nor ellipsised — truncation is forbidden outright
       (docs/CODEBASE.html#style-no-truncation). */
    .producer-help-copy{max-height:90px;overflow-y:auto}
  }
  /* minmax, not the mockup's constant: the item-32 workspace ruling
     (shell.css §5) — at 200% text a long title outgrew the fixed 72px bar
     and painted over the stage heading. Identical at the design text size. */
  .workspace{grid-template-rows:minmax(var(--top-h),auto) minmax(0,1fr)}
  /* Two auto columns: the air chip, then the merged Settings hub (item 60
     S2, Tor's R8). The third auto was the item-56 Switch-session slot, which
     R8 moved to the left menu. The air chip's 198px floor below holds beside
     it — the title's 1fr is what flexes. */
  .topbar{padding:9px 16px;grid-template-columns:minmax(0,1fr) auto auto;gap:12px}
  /* Boko F5, the READABLE FLOOR: minmax(0,1fr) let the furniture take
     everything — measured at 200% text, the session name got 54px at
     1180x820 and ZERO at the 1100x700 gate minimum, rendering an ordinary
     course title as one character per line, or nothing at all. 260px is
     derived, not chosen. Re-derived for item 60 S2, which deleted the
     Switch button AND gave the gear a label: the narrowest gated FULL bar
     is 747px of content (1100 wide), and with the pressure relief below its
     furniture asks 198 (the chip's own floor, never less) + 48 (the gear,
     glyph-only under that relief) + 24 of gaps = 270, leaving 477 — so 260
     now fits with 217px to spare, where before it had 109. Producer-scoped:
     the instructor bar keeps minmax(0,1fr) exactly. */
  .producer-topbar{grid-template-columns:minmax(260px,1fr) auto auto}
  .session-title h1{font-size:24px}
  .global-air-status{min-width:198px;font-size:16px}
  .work-grid{display:contents}
  .role-view.is-active{
    padding:12px 14px 0;
    grid-template-columns:minmax(0,1fr) clamp(238px,24vw,320px);
    grid-template-rows:minmax(0,1fr) minmax(var(--transport-h),auto);
    column-gap:12px;
  }
  .feature-switcher,.utility-dock{display:none}
  .feature-stage{grid-column:1;grid-row:1;padding:10px;gap:8px}
  .stage-header{min-height:44px}
  .stage-header h2{font-size:23px}
  .self-test-gate{padding:16px;min-height:100%;grid-template-columns:minmax(0,1fr) minmax(300px,340px);grid-template-rows:minmax(0,1fr);align-items:center;align-content:stretch;gap:24px}
  .side-rack{
    grid-column:2;
    grid-row:1/3;
    padding-bottom:12px;
    min-width:0;
    min-height:0;
    display:flex;
    flex-direction:column;
    gap:8px;
    overflow:hidden; /* item-32 audit: at the design text size the tenants fit by the rack.php arithmetic and the min-content floors below; under zoom the 19b tier turns this column into the scroller */
  }
  .local-slide-nav{display:none}
  /* Item 32: an overflow:hidden flex item's AUTOMATIC minimum is zero, so a
     squeezed rack panel compresses straight through its own header — with
     the marks editor open at 1440x900 the transcript panel measured 35px and
     its Expand toggle clipped dead (at the design's text size, not zoom).
     min-content restores the floor flex would have given a visible box: a
     panel never shrinks below its header row (the expanded body row is
     minmax(0,1fr) and contributes nothing), so the toggle always renders
     whole. The EXPANDED marks panel deliberately overrides this right
     below — its list is the one region built to be squeezed, and its own
     scrollport (panels.css §13) is its floor. Collapsed it keeps this
     floor: item 57 measured an unconditionally-flexed collapsed marks
     panel absorbing the whole column slack (318px tall, header stretched
     to 316px at 1440x900) instead of the single slim header row. */
  .rack-panel{min-height:min-content}
  /* 54a: the media panels' 16:9 content is NOT flexible. Their min-content
     floor is already the ratio-derived height (aspect-ratio transfers the
     inline size), but flex:0 0 auto states the ruling outright: the column's
     squeeze never lands on a tile. */
  .side-rack>.rack-media{flex:0 0 auto}
  .compact-marks.is-expanded{min-height:0;flex:1 1 auto}
  .compact-mark-list{height:auto;max-height:none}
  /* ====================================================================
     19b · FULL MODE, RACK SHORT ON TEXT-LINES — the item-32 zoom fallback
     The rack's tenants were budgeted for the column at the design's text
     size (the arithmetic in partials/rack.php). Text zoom doubles the
     tenants while the column stands still, and a squeezed flex column has
     only bad moves: an overflow:hidden item's automatic minimum is ZERO,
     so the transcript panel compressed below its own header and clipped
     its Expand toggle, while the marks list's JS row fit — measured at
     100% — went stale and clipped a row's edit button (both measured at
     1440x900 @200% text). Same shape as the rail's 19a tier: an em
     container query against the work area, which text zoom shrinks in em
     while a px query sees nothing. Under 30em — full mode's floor is
     ~37em of role view at the design size, so like 19a this can never
     fire at 100% — the tenants keep their natural heights and the COLUMN
     scrolls with a real scrollbar: nothing squeezed, nothing painted
     over, every control one scroll away. The marks panel stops flexing
     here too (its fit was computed for a slack the column no longer
     has); its list keeps its own scrollport from panels.css §13.
     ==================================================================== */
  @container studio-work (max-height:30em){
    .side-rack{overflow-y:auto;overscroll-behavior:contain}
    .side-rack>*{flex:0 0 auto;min-height:min-content}
    /* Item 54 zoom tier: at 200% text the doubled overlay strip would eat
       the media (measured at 200% text with this fallback removed), so
       inside this same query the media headers render STACKED above their
       tiles: the panel grows taller, this column scrolls, nothing is
       occluded and the 16:9 content box is untouched. */
    .rack-media.is-expanded>.rack-panel-header{
      position:static;
      background:transparent;
      border-bottom:0;
    }
    .rack-media.is-expanded .compact-slide-art{padding-top:14px}
    /* Stacked headers occlude nothing, so the below-the-strip offsets
       (panels.css §54a) come back to the tile's own edge here. */
    .rack-media.is-expanded .compact-camera-art{top:0}
    .rack-media.is-expanded .compact-slide-image{top:0;height:100%}
    /* The EXPANDED marks panel stays the flexible tenant — but BOUNDED, not
       natural height: its LIST is the scrollport the inline editor depends
       on (item 12, and room-flow's reachability lane drives the editor
       through list scrolling), so the panel keeps a 10em window — chrome
       plus one open editor row at any text size — and yields everything
       past it to the column scroll. min() guards a rack shorter than the
       window. Collapsed (item 57) the panel falls back to this tier's
       `.side-rack>*` natural-height rule above: a single slim header row,
       never a forced 10em window. */
    .side-rack>.compact-marks.is-expanded{flex:0 1 auto;min-height:min(10em,100%)}
  }
  .transport{grid-column:1;grid-row:2;padding:8px 0;background:transparent}
  /* Accessibility beats mockup fidelity
     (docs/CODEBASE.html#style-accessibility-over-fidelity). Full mode is the only
     mode that pins these labels to one line, and it is the only mode where they
     overflowed: `white-space:nowrap` with `min-width:0` leaves the button's
     geometry intact while its text spills sideways over the next button's text,
     and the transport's own `overflow:hidden` hides the page overflow that would
     otherwise give it away. At 1440x900 and 200% text zoom "Previous slide" wanted
     252px of its 201px and Record — the loudest control in the product — 175px of
     161px, rendered over its neighbours and unreadable. Wrapping is the same
     answer .rack-panel-header already gives: the row breaks, the labels break,
     every verb stays whole and full size.

     SUPERSEDED IN PART (Tor, 2026-08-14): at 1440x900 the four LONG verbs
     wanted 819px of an 808px bar, so the flagship desktop size rendered TWO
     rows — a whole button-row of stage height lost to wrapping. The ruling:
     the verbs are one line, never wrapped, and the labels came down to fit
     (Previous / Record / Mark / Next in transport.php) instead of the row
     breaking. flex-wrap stays, deliberately, as the 200%-text-zoom emergency
     this comment's first paragraph exists for — at normal zoom the short
     labels hold one row at every width this tier serves. Record keeps the
     widest padding of the three, because it is still the loudest control on
     the screen. The clamp ceilings bind above ~1200px wide and change nothing
     below it. */
  .action-bar{
    max-width:930px;
    min-height:62px;
    display:flex;
    flex-wrap:wrap;
    align-items:center;
    justify-content:center;
    gap:7px;
  }
  .action-bar button{min-width:0;min-height:52px;padding:9px clamp(10px,1.3vw,16px);font-size:19px}
  .action-bar [data-action="previous"],.action-bar [data-action="next"]{display:flex}
  .action-bar .record-action{padding:9px clamp(16px,2vw,24px)}
  .action-bar .marker-action{padding:9px clamp(14px,1.8vw,22px)}
  .producer-action-bar button{padding:9px clamp(14px,1.8vw,28px)}
  .rail-collapse{
    display:flex;
    align-items:center;
    justify-content:center;
    gap:8px;
    min-height:48px;
    border:1px solid var(--line);
    border-radius:var(--control-radius);
    background:var(--raised);
    font-size:15px;
    font-weight:900;
  }
  .rail-collapse svg{width:20px;height:20px;fill:var(--cyan);flex:0 0 auto}
  .app-shell.rail-collapsed{--rail-w:76px}
  .app-shell.rail-collapsed .brand-lockup{min-height:52px;padding:6px}
  .app-shell.rail-collapsed .brand-lockup::after{display:none}
  .app-shell.rail-collapsed .brand-fan{width:46px}
  .app-shell.rail-collapsed .rail-heading{display:none}
  /* Item 60 S4: the collapsed rail is a 76px icon column — the same reason
     .rail-heading hides here applies to the section eyebrows. The rows keep
     their grouping in the accessibility tree (role=group + aria-label on the
     wrapper), so nothing is lost but the pixels. */
  .app-shell.rail-collapsed .rail-section-label{display:none}
  .app-shell.rail-collapsed .setup-button,.app-shell.rail-collapsed .producer-nav-button,.app-shell.rail-collapsed .presence-button{position:relative;grid-template-columns:1fr;place-items:center;min-height:54px;padding:8px 4px}
  .app-shell.rail-collapsed .step-copy,.app-shell.rail-collapsed .producer-nav-copy{display:none}
  .app-shell.rail-collapsed .status-icon{position:absolute;right:3px;top:3px}
  .app-shell.rail-collapsed .setup-sheet{display:none}
  /* Item 60 S5: the collapsed rail is a one-track icon column (the rule
     above sets grid-template-columns:1fr), so a chevron would stack UNDER
     the step icon rather than beside it. aria-expanded stays correct. */
  .app-shell.rail-collapsed [data-accordion-glyph]{display:none}
  .app-shell.rail-collapsed .producer-help-copy{display:none}
  .app-shell.rail-collapsed .presence-button{grid-template-columns:1fr}
  .app-shell.rail-collapsed .rail-collapse span{display:none}
  .app-shell.rail-collapsed .rail-collapse svg{transform:rotate(180deg)}
}
/* ====================================================================
   19d · TOPBAR WIDTH PRESSURE (item 56, Boko F5) — producer only
   ====================================================================
   Three findings running came from ONE shape: the topbar carries
   unbounded server data (the session name, the instructor, the times)
   beside furniture whose width is fixed by ITS OWN text — a Switch label,
   an "Off air · Standby" chip, two 44px icons. At 200% text the furniture
   doubles while the bar does not, so the name's column is what pays:
   measured 54px at the gated 1180x820 and 0px at the 1100x700 gate
   minimum, i.e. "B" where the session name belongs. Capping the name only
   moved the failure; this RELIEVES the pressure with forms this product
   already ships.

   The trigger is a container query, because text zoom is invisible to a
   width media query: 40em of the BAR'S OWN font is ~13.6x its text size,
   and every measured 100% tier sits above it (45.9-68.7em) while every
   200% tier sits below (22.9-32.9em). It fires on the real condition —
   the bar has run out of room for its words — at any viewport.

   Two reliefs, both already shipped elsewhere:
   1. The one LABELLED control in the bar sheds its label to a square glyph
      with its constant aria-label — the portrait tier's own form (and the
      rail's icon idiom below the gate), promoted to wherever the pressure
      exists. In item 56 that control was the Switch button and the relief
      freed the 241px difference between 285 and 44. Item 60 S2 deleted the
      Switch and merged the settings and user buttons into ONE labelled
      Settings hub, so the same relief now applies to the gear: it is the
      only word-bearing furniture left, and under pressure it returns to the
      48px square the product shipped before S2 gave it its label.
   2. The air chip wraps inside its own 198px FULL-tier floor instead of
      demanding 352px on one line — the chip's own <=520px form, which the
      component already carries. Never below the 118px base floor: 198 IS
      its FULL-tier minimum and it keeps it exactly.
   The state line's cap is no longer part of this block. It used to double
   from 80px here under the same trigger — a pixel constant calibrated
   against one fixture's string, which is item 62's whole defect. It is now
   stated in the line's own line boxes in shell.css, so it scales with the
   text at every tier and needs no width trigger at all. */
@container topbar (max-width:40em){
  /* BOTH roles — the hub is shared, and so is the pressure (Stinker F4). */
  .topbar .topbar-settings{width:48px;min-width:48px;padding:0}
  .topbar .topbar-settings .topbar-settings-label{display:none}
  /* PRODUCER only, deliberately: this relief was derived for the producer
     bar's own furniture, and the instructor chip keeps its shipped
     behaviour byte-identical now that .topbar is a container for both. */
  /* 198px IS MAIN'S NUMBER AND IT STAYS. Same story as the phone tier above:
     ruling 125's first draft word `Not monitored` wrapped to a THIRD line
     inside 198px, took the topbar from 110 to 145 and 35px off the rail's
     scrollport, and needed 292px to come back to two lines — scanned, not
     calculated, because the arithmetic was wrong twice (258 and 272 both still
     measured three). Tor's `Standby` fits the shipped 198px in two lines
     exactly as it always did: re-measured at chip 198x90 and topbar 110, which
     are main's own figures. The wider cap is removed rather than left inert. */
  .producer-topbar .global-air-status{max-width:198px;white-space:normal}
}
/* ==== INPUT-DEPENDENT AFFORDANCES ====
   Key-cap hotkey hints exist only with a real keyboard + fine pointer.
   (The compact-tile hover-revealed window-actions this section once
   compensated for on touch are gone — item 54's rack headers are
   permanently visible on every input.)
   1300px, not 1100px (mirror review, 2026-08-17 — MEASURED, not argued):
   at 1180x820 the four keycaps pushed the transport verbs onto a second
   row (qa-matrix's new one-row check red at spread 59px), which is exactly
   the wrap 3ac71be's ruling forbids. The hints are decoration; the one-row
   verbs are the ruling — so below 1300px the quiet hints yield. The
   hotkeys themselves work at every width; only the caps hide. */
@media(min-width:1300px) and (hover:hover) and (pointer:fine){
  .hotkey-hint{display:inline-flex}
}
@media(prefers-reduced-motion:reduce){
  *,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}
}
/* ====================================================================
   20 · SELF TEST FIT RULES (compact heights)
   The gate stays usable down to short landscape: gaps tighten, device
   rows reflow into multi-column grids, and on the shortest screens the
   run button compresses its label to "Run" + countdown. No control
   ever drops under 44px, the preview keeps the 16:9 letterbox, and
   the passed state hides device rows on short screens so results fit
   without scrolling.
   ==================================================================== */
@media(max-height:699px) and (min-width:768px){
  .self-test-gate{grid-template-columns:minmax(0,1fr) minmax(272px,320px);grid-template-rows:minmax(0,1fr);align-items:center;gap:16px;min-height:100%}
}
/* Self Test is the setup task in compact modes. Give its gate the work area
   while the persistent rail and recording transport remain in place. */
@media(max-width:600px), (max-height:520px){
  .work-grid[data-active-feature="self-test"]{
    grid-template-columns:minmax(0,1fr);
    grid-template-rows:auto minmax(0,1fr);
  }
  .work-grid[data-active-feature="self-test"] .utility-dock{display:none}
  .work-grid[data-active-feature="self-test"] .feature-stage{grid-column:1;grid-row:2}
}
@media(max-height:699px){
  .self-test-device-panel{grid-template-columns:1fr 1fr;gap:10px;align-content:center}
  .self-test-device-panel h3,.self-test-state{display:none}
  .self-test-level,.self-test-headphones,.self-test-run{grid-column:1/-1}
  .self-test-field{min-height:44px}
  .self-test-side{min-height:44px}
}
@media(max-width:600px){
  .work-grid[data-active-feature="self-test"] .self-test-gate,
  .work-grid[data-active-feature="self-test"] .self-test-device-panel{gap:8px}
  .work-grid[data-active-feature="self-test"] .self-test-headphones{align-items:center;flex-direction:row}
}
@media(max-height:520px) and (min-width:601px){
  .work-grid[data-active-feature="self-test"] .self-test-device-row[data-self-test-device="speakers"]{margin-top:0}
}
/* ---- 20·C SELF TEST FULL-BLEED (mobile redesign slice 3, ITEM C —
        Tor's round-5 direction: "match camera/presentation"). The phone
        bands give the Self Test the visual panels' own treatment: chrome
        stripped, the preview edge-to-edge (portrait) / height-bound-large
        (landscape). Replaces the six-column short-landscape reflow that
        used to live here (it truncated the fake-device labels at 852px and
        compressed the run label to a bare "Run"); a full-width single
        column fits the real words. Scope: instructor only — the
        [data-role][data-active-feature] prefix also gives every rule here
        (0,4,0)+ specificity, which is what lets this block beat §20's
        (0,3,0) fit rules cleanly. Paint order needs no `order` rules any
        more: the partial's DOM is the paint order (heading → sentence →
        results → run → pickers), which is the tab-order fix this slice
        carries. ------------------------------------------------------- */
/* C·P PORTRAIT */
@media (max-width:600px) and (min-height:521px){
  /* Full-bleed stage: chrome stripped, rows content-sized, the group
     floats to the vertical middle; `safe` falls back to top-aligned and
     the stage scrolls when the stack outgrows it. */
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .feature-stage{
    margin:0;
    padding:0;
    gap:6px;
    grid-template-rows:max-content max-content;
    align-content:safe center;
    overflow-y:auto;
    border:0;
    border-radius:0;
    background:transparent;
    box-shadow:none;
  }
  /* BOTH rows are `max-content`, NOT `auto`, and the difference is the
     whole scroll story: stage.css ships .stage-cell{min-height:0} (and
     this block sets the header's), which zeroes each item's min-content
     contribution, so an `auto` row only grows in the maximize-tracks
     step — which stops at the container edge, and stops entirely once
     the tracks overflow. Measured at 393x852: `auto` cell row resolved
     to 526px against 575px of content with the stack's bottom 50px
     unreachable behind the cell's clip, and once the cell row was fixed
     the `auto` HEADER row collapsed to 0 (negative free space, zero
     contribution — the strip vanished). A `max-content` track takes its
     base size from the content contribution directly, so both rows are
     their true heights, the group centers when it fits, and the STAGE
     is the one scroller when it does not. Releasing the cell's size
     containment is what lets the rows content-size (the visual panels'
     documented trade); nothing in this state queries the cell — the
     preview is width-driven (100% below). */
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .stage-cell{container-type:normal}
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .stage-header{min-height:0;margin:0;padding:0 8px;gap:9px}
  /* The h2 STAYS VISIBLE on this panel (deliberate deviation from the
     visual panels' sr-only heading): Slides/Camera are named by their
     active switcher tab, but Self Test has NO tab — nothing else on the
     phone screen names where the instructor is. */
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .stage-header h2{font-size:20px}
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .self-test-panel{padding:0;overflow:visible}
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .self-test-gate{gap:14px}
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .self-test-stage-col{gap:10px}
  /* The preview, edge to edge — the Camera panel treatment verbatim:
     full width, 16:9 by its own aspect-ratio, radius and side borders
     gone, top/bottom borders kept. Overrides §20's cqh width cap (the
     rule that made it small). */
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .self-test-preview{
    width:100%;
    border-radius:0;
    border-left:0;
    border-right:0;
  }
  /* Chrome stripped means stripped: the decorative inner frame goes. */
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .self-test-preview::after{content:none}
  /* Below the full-bleed surface the stack breathes inside side padding
     standing in for the stripped panel chrome. */
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .prejoin-meter-row{margin:2px 10px 0}
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .self-test-captions{margin:0 10px}
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .self-test-device-panel{padding:0 10px 4px}
}
/* C·L LANDSCAPE — with the landscape band's own 241px floor (18c/18d
   carry it too): below 241px the 18b scrolling-page tier owns the layout,
   and this two-column band firing there would fight it (CodeRabbit
   round 5, seen at 422x195). */
@media (max-height:520px) and (min-height:241px) and (min-width:390px) and (max-width:950px){
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .feature-stage{
    margin:0;
    padding:0 0 4px;
    gap:4px;
    border:0;
    border-radius:0;
    background:transparent;
    box-shadow:none;
  }
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .stage-header{min-height:0;margin:0;padding:2px 8px 0;gap:9px;flex-wrap:nowrap}
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .stage-header h2{font-size:20px}
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .self-test-panel{padding:0}
  /* Two columns, shares FLIPPED: content-sized preview column, controls
     take the rest (the shipped band gave the preview the SMALLER share —
     that smallness is the complaint this item answers). Rows pin to the
     cell height; `safe` alignment keeps the top reachable when a column
     overflows and scrolls. */
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .self-test-gate{
    padding:0 10px;
    grid-template-columns:minmax(0,auto) minmax(264px,1fr);
    grid-template-rows:minmax(0,1fr);
    align-items:safe center;
    align-content:stretch;
    gap:0 12px;
    min-height:100%;
  }
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .self-test-stage-col{
    position:relative; /* the landscape caption band anchors to this box */
    min-width:0;
    align-self:safe center;
    gap:0;
  }
  /* HEIGHT-BOUND preview, the Camera panel's landscape treatment: width
     follows the cell height through the 16:9 contract (cqh/cqw resolve
     against .stage-cell, whose size containment this band KEEPS), with
     24px of height slack so the glow is never clipped at the cell edge.
     NO percentages here, deliberately: the preview column is auto-sized,
     and a percentage width inside min() resolves to zero during that
     track's intrinsic sizing — measured, min(100%, …) collapsed the
     whole column. The cqw term is the same guard in definite units: it
     reserves 300px (the device column's 264px floor + gaps + padding)
     so the preview can never squeeze the controls out at the band's
     wider windows. */
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .self-test-preview{
    width:min(calc(100cqh * 16 / 9 - 24px),calc(100cqw - 300px));
    margin:0 auto;
    border-radius:0;
  }
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .self-test-preview::after{content:none}
  /* The meter row rides directly under the height-bound preview at the
     preview's own width — picture and sound in the same glance. */
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .prejoin-meter-row{
    width:min(calc(100cqh * 16 / 9 - 24px),calc(100cqw - 300px));
    margin:4px auto 0;
  }
  /* The device column: a single clean stack, real words, scrolls under
     its own scrollbar when it must. */
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .self-test-device-panel{
    grid-template-columns:minmax(0,1fr);
    gap:8px;
    align-content:safe center;
    min-height:0;
    max-height:100%;
    overflow-y:auto;
    padding:2px 2px 4px;
  }
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .self-test-device-row{grid-column:auto;grid-template-columns:minmax(0,1fr) auto;gap:8px}
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .self-test-side{width:auto;min-width:76px}
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .self-test-headphones{grid-column:auto}
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .self-test-run{grid-column:auto;min-width:0;padding:9px 12px;gap:8px}
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .self-test-run [data-self-test-run-label]{display:inline}
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .self-test-run::after{content:none}
  /* "YOUR WORDS, LIVE" as a SUBTITLE BAND over the preview's lower edge
     while the test runs — landscape has no height to stack it below a
     height-bound preview, and subtitles-over-picture is the one overlay
     every instructor already reads fluently. */
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .self-test-captions{
    position:absolute;
    left:0;
    right:0;
    bottom:0;
    z-index:3;
    margin:0;
    padding:6px 12px 8px;
    border:0;
    border-top:1px solid rgba(34,211,238,.45);
    border-radius:0;
    background:rgba(5,8,15,.85);
    backdrop-filter:blur(8px);
  }
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .self-test-captions-title{margin:0 0 3px;font-size:15px}
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .self-test-caption-log{max-height:58px;font-size:19px;line-height:1.35}
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .self-test-caption-interim{margin:2px 0 0;min-height:0}
  /* The bottom-center status pill yields to the band while it is live
     (both sit on the same edge; the band replaces it) … */
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .self-test-stage-col:not(:has(video[controls])):has(.self-test-captions:not([hidden])) .self-test-preview-status{display:none}
  /* … and once the run ends and the preview becomes the SAMPLE PLAYER
     (presentSample flips the video to native controls — the one signal
     that holds whether or not any words were transcribed; keying on the
     review list fails on a wordless run, where the list stays [hidden]
     and only the note shows), the captions leave the picture: back to a
     static card below the preview, clear of the player's controls; the
     column scrolls to it. */
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .self-test-stage-col:has(video[controls]) .self-test-captions{
    position:static;
    margin:8px 0 0;
    padding:10px 12px;
    border:1px solid var(--line);
    border-radius:var(--card-radius);
    background:var(--surface);
    backdrop-filter:none;
  }
  .work-grid[data-role="instructor"][data-active-feature="self-test"] .self-test-review{max-height:132px;min-height:90px}
}
@supports(width:1cqh){
  .self-test-preview{width:min(100%,calc((100cqh - 56px) * 16 / 9))}
  @media(max-width:600px){
    .work-grid[data-active-feature="self-test"] .self-test-preview{width:min(100%,calc((100cqh - 360px) * 16 / 9))}
  }
}
@media(max-height:699px){
  /* .prejoin-meter-row, not the bare meter: the bar lives inside its
     labeled row now (slice 3), and hiding the bar alone would leave an
     orphaned "Microphone level" label pointing at nothing. */
  .self-test-gate.is-passed .self-test-device-row,
  .self-test-gate.is-passed .prejoin-meter-row,
  .self-test-gate.is-passed .self-test-headphones{display:none}
}
/* ====================================================================
   20b · THE PRE-JOIN WIZARD (.app-shell.prejoin) — mobile redesign
   slice 3, ITEM E (design record: docs/MOBILE-REDESIGN.md).

   A view state of the one studio page: an identified LIVE first landing
   (selfTest 'pending', the durable server fact) boots as
   `.app-shell.prejoin pj-step1` and renders the same panels-self-test.php
   markup as a three-step wizard — ① Devices · ② Test · ③ Review —
   before any studio furniture. ui/prejoin.js owns the pj-step1/2/3 shell
   classes and the gate's st-off/st-live halves; "Enter the studio" drops
   the class and opens the slice-2 first-landing drawer.

   STRUCTURE FIRST, UNQUERIED (Tor's point 7): the furniture-hiding and
   state-visibility rules below carry NO media query, only the .prejoin
   scope — item D's media-query hide leaked the transport on >950px short
   windows, and absence must be structural so no viewport can un-hide it.
   Geometry follows in four blocks: shared (all widths), desktop/tablet
   (min-width:601px ∧ min-height:521px — never prototyped, designed
   conservatively as a centered ~720px column), portrait phone (the 18c
   gate), and short windows (max-height:520px ∧ min-width:390px —
   deliberately WITHOUT 18c's 950px cap: that cap exists because a wide
   desktop window can afford the switcher row, but the wizard has no
   switcher row at any size, and an uncapped gate is what keeps a
   1300×500 window on the two-column wizard instead of an overflowing
   column).

   This region sits between §20 and §21 on purpose: it must out-order
   §20's fit rules and the 20·C blocks (most selectors here also carry
   (0,4,0)+ specificity via the .app-shell.prejoin prefix), and §21 must
   stay the file's last block.
   ==================================================================== */

/* Wizard chrome sleeps until its state shows it. (.prejoin-meter-row is
   NOT here: the meter's labeled row is the in-studio panel's home too.) */
.prejoin-brand,.prejoin-steps{display:none}

/* ---- 20b·1 STRUCTURAL (no media query) ----------------------------- */
/* The step never shows studio furniture, at ANY viewport. */
.app-shell.prejoin .setup-rail,
.app-shell.prejoin .rail-scrim,
.app-shell.prejoin .rail-burger,
.app-shell.prejoin .transport,
.app-shell.prejoin .feature-switcher,
.app-shell.prejoin .utility-dock,
.app-shell.prejoin .topbar-settings,
.app-shell.prejoin .global-air-status,
.app-shell.prejoin .first-run-callout,
.app-shell.prejoin .stage-header{display:none}
/* Clean video, structurally: nothing paints on the preview or the
   playback in any prejoin state (Tor's point 5). The chips' facts move:
   resolution lives in the ✓ rows, status words live in the state line. */
.app-shell.prejoin .self-test-quality,
.app-shell.prejoin .self-test-preview-status{display:none}
.app-shell.prejoin .self-test-preview::after{content:none}
/* No headphones question on the step (Tor's point 3), no side Live/Test
   buttons (each would start a device outside the wizard's one flow —
   both stay on the in-studio re-run panel). */
.app-shell.prejoin .self-test-headphones{display:none}
.app-shell.prejoin .self-test-side{display:none}
/* The captions live in the review modal only; during the run the growing
   log pushed the countdown below the 852x393 fold (Tor's point 9). */
.app-shell.prejoin .self-test-captions{display:none}
/* The meter never fake-shimmers on the step: only a real driver's
   inline width fills it (selftest.js during the run, the live-monitor
   driver in step 1). */
.app-shell.prejoin .self-test-level i{animation:none;width:0}
/* stage.css's .feature-panel.is-active focus animation (fill:both)
   leaves an IDENTITY TRANSFORM on the panel — measured: matrix(1,0,0,
   1,0,0) — which makes the panel the containing block for the step-3
   fixed modal card, whose box then dies inside the panel's zero-height
   overflow clip (the first capture was a black screen). The step never
   needs a focus transition — it IS the page. */
.app-shell.prejoin .feature-panel.is-active{animation:none}
/* Shell skeleton: one column (rail gone), workspace a flex column of
   [masthead][step indicator][step body]; the transport's floored track
   (stage.css §6 minmax(var(--transport-h),auto)) is released by the
   single-row role-view — MEASURED (item D): display:none alone left a
   76px reserve pushing the step's bottom rows under the stage clip. */
.app-shell.prejoin{grid-template-columns:minmax(0,1fr)}
.app-shell.prejoin .workspace{grid-column:1;display:flex;flex-direction:column;min-height:0}
.app-shell.prejoin .role-view.is-active{flex:1 1 auto;min-height:0;display:grid;grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr)}
/* §20 seats the self-test stage in grid-row:2 under an auto switcher
   row; with the tabs gone that is an IMPLICIT row the stage-cell's size
   containment zeroes (measured, item D: an 8px stage). One explicit
   row, stage seated in it. */
.app-shell.prejoin .work-grid{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr)}
.app-shell.prejoin .work-grid[data-active-feature="self-test"] .feature-stage{grid-column:1;grid-row:1}

/* THE ≤699px DEVICE-PANEL SPLIT NEVER APPLIES TO THE WIZARD. §20's
   compact-height reflow (max-height:699px, above) predates the pre-join
   step: it halves .self-test-device-panel into 1fr 1fr and re-spans only
   ITS OWN children (level, headphones, run) back to full width, so the
   wizard's state sentence and turn-on button — absent from that span
   list — rendered SIDE BY SIDE on any phone whose real browser viewport
   is under 700px tall. The test matrix never crossed that gate in
   portrait: its phone cells are 844-932px of LAYOUT viewport, but a real
   iPhone's Safari gives the page ~660px after browser chrome — measured
   on the emulated iPhone 14 descriptor (390x664): sentence 170px and
   button 184px in one row, Tor's 2026-08-12 report verbatim. The wizard
   is a single stack in every state by design (one obvious action per
   state), so the step re-declares the single column unqueried; the
   (0,3,0) .prejoin-scoped selector outweighs §20's (0,1,0) media rule at
   every size, and the wizard's own landscape blocks keep their later,
   more specific geometry. */
.app-shell.prejoin .self-test-device-panel{grid-template-columns:minmax(0,1fr)}
.app-shell.prejoin .self-test-device-panel>*{grid-column:1}
/* ---- 20b·2 STATE VISIBILITY (unqueried — the wizard's display logic
        is size-independent; only geometry lives in the size blocks) --- */
/* STEP 1 · OFF: placeholder, sentence, ONE button. Nothing else. */
.app-shell.prejoin .self-test-gate.st-off .self-test-device-row,
.app-shell.prejoin .self-test-gate.st-off .self-test-run,
.app-shell.prejoin .self-test-gate.st-off .prejoin-meter-row{display:none}
.app-shell.prejoin .self-test-gate.st-off .self-test-preview{border-color:var(--steel);box-shadow:0 22px 60px rgba(0,0,0,.42)}
.app-shell.prejoin .self-test-gate.st-off .prejoin-turnon{display:flex}
/* STEP 1 (both halves): a fresh step 1 never shows a previous run's
   result rows — the verdict's home is the step-3 modal. */
.app-shell.prejoin.pj-step1 [data-test-results]{display:none}
/* STEP 1 · LIVE: preview + labeled meter + pickers + Record. Said as
   explicit shows scoped to pj-step1 because §20's short-screen
   is-passed rules otherwise hide the rows and the meter the moment a
   passed instructor chooses "Re-run with different devices". */
.app-shell.prejoin.pj-step1 .self-test-gate.st-live .prejoin-meter-row{display:flex}
.app-shell.prejoin.pj-step1 .self-test-gate.st-live .self-test-device-row{display:grid}
/* STEP 2: watching + speaking only — pickers rest (device changes are
   step 1's job; the run refuses mid-run swaps anyway), heading rests,
   meter stays (selftest.js drives it for real during the run). */
.app-shell.prejoin.pj-step2 .self-test-device-row,
.app-shell.prejoin.pj-step2 .self-test-device-panel h3,
.app-shell.prejoin.pj-step2 [data-test-results]{display:none}
.app-shell.prejoin.pj-step2 .prejoin-meter-row{display:flex}
/* STEP 3: the modal owns the screen; the step controls rest. */
.app-shell.prejoin.pj-step3 .self-test-device-row,
.app-shell.prejoin.pj-step3 .prejoin-meter-row,
.app-shell.prejoin.pj-step3 .self-test-run,
.app-shell.prejoin.pj-step3 .self-test-device-panel h3{display:none}
.app-shell.prejoin.pj-step3 .prejoin-review-head{display:grid}
.app-shell.prejoin.pj-step3 .prejoin-enter,
.app-shell.prejoin.pj-step3 .prejoin-rerun{display:flex}
.app-shell.prejoin.pj-step3 .self-test-captions:not([hidden]){display:block}

/* ---- 20b·3 GEOMETRY · shared (every width) ------------------------- */
/* MASTHEAD (Tor's point 6): small fan beside a 17px session name,
   15px clock line — flex-wrap means the clock rides the same line
   when it fits (landscape) and drops to line 2 when it does not
   (portrait): at most two tight lines, one bar. */
.app-shell.prejoin .topbar{
  display:flex;
  flex-wrap:wrap;
  align-items:center;
  gap:2px 12px;
  height:auto;
  min-height:0;
  padding:8px 14px 6px;
}
.app-shell.prejoin .prejoin-brand{display:block}
.app-shell.prejoin .prejoin-brand .brand-lockup{min-height:0;padding:0;border:0;border-radius:0;background:transparent}
.app-shell.prejoin .prejoin-brand .brand-fan{width:58px;display:block}
.app-shell.prejoin .session-title{
  /* basis 0, not auto: with the clock un-stacked the block's
     max-content exceeds the row remainder and auto-basis wraps the
     whole block under the fan (measured — a three-row masthead);
     basis 0 grows into the space BESIDE the fan and the title/clock
     wrap inside it. */
  flex:1 1 0;
  min-width:0;
  display:flex;
  flex-wrap:wrap;
  align-items:baseline;
  gap:1px 12px;
}
.app-shell.prejoin .session-title h1{margin:0;font-size:17px;line-height:1.25}
.app-shell.prejoin .session-clock{font-size:15px;min-height:0;max-height:none;margin-top:0;overflow:visible}
/* The clock reads as ONE line ("Ends 3:02 PM · Start allowed") at the
   design size — it wraps below the title as a unit, never mid-phrase.
   The app's phone tier stacks the two phrases and hides the separator
   (18c); the masthead has the width, so the single line returns here.
   nowrap rides the PHRASE spans, not the container: at 200% text zoom a
   nowrap container clipped 107px of the sentence (measured at 393×852),
   while phrase-level nowrap lets the second phrase drop whole to the
   next line — wrapped, never truncated. */
.app-shell.prejoin .session-clock-copy{display:block;white-space:normal}
.app-shell.prejoin .session-clock-copy > span{display:inline-block;white-space:nowrap}
.app-shell.prejoin .session-clock .session-clock-separator{display:inline-block;padding-inline:5px}
/* THE WIZARD INDICATOR (Tor's point 1): three labeled segments, the
   current one filled and glowing, done ones checked. 15px words. */
.app-shell.prejoin .prejoin-steps{
  display:grid;
  grid-template-columns:repeat(3,minmax(0,1fr));
  gap:6px;
  margin:0;
  padding:2px 14px 8px;
  list-style:none;
}
/* No nowrap and flex-wrap on purpose: a 1fr segment is ~118px on a
   393px phone, and at 200% text zoom "Devices" plus its number wants
   ~128px — nowrap clipped the word (measured). Wrapping drops the word
   under its number instead; the segment grows past its 36px floor and
   nothing truncates. */
.app-shell.prejoin .prejoin-steps li{
  display:flex;
  flex-wrap:wrap;
  align-items:center;
  justify-content:center;
  gap:2px 7px;
  min-height:36px;
  padding:2px 4px;
  border:1px solid var(--line);
  border-radius:var(--control-radius);
  background:var(--surface);
  color:var(--dim);
  font-size:15px;
  font-weight:800;
  text-align:center;
}
.app-shell.prejoin .prejoin-steps .pj-num{
  display:grid;
  place-items:center;
  /* em, not px (opencode MINOR 1): the ring must grow WITH its digit under
     the product's text zoom — a px ring stayed 22px while the 15px digit
     doubled to 30px and its ink sat 11px past the ring bottom. 1.5em ≈ 22px
     at the 15px base and scales lockstep at 200%. */
  width:1.5em;
  height:1.5em;
  border:1px solid var(--steel);
  border-radius:50%;
  font:800 15px/1 ui-monospace,SFMono-Regular,Menlo,monospace;
}
.app-shell.prejoin .prejoin-steps li.is-current{
  border-color:var(--cyan);
  background:var(--blue);
  color:var(--text);
  box-shadow:var(--glow);
}
.app-shell.prejoin .prejoin-steps li.is-current .pj-num{border-color:var(--cyan);background:var(--cyan);color:var(--void)}
.app-shell.prejoin .prejoin-steps li.is-done .pj-num{font-size:0;border-color:var(--good)}
.app-shell.prejoin .prejoin-steps li.is-done .pj-num::before{content:"✓";font-size:15px;color:var(--good)}
/* THE LOUDNESS METER ROW (Tor's point 8): directly under the preview
   in the stage column, the real [data-self-test-level] bar grown to
   14px so motion reads at arm's length. Driven only by real audio
   (see 20b·1's shimmer kill). */
.app-shell.prejoin .work-grid[data-active-feature="self-test"] .prejoin-meter-row .self-test-level{
  height:14px;
  border-radius:7px;
}
.app-shell.prejoin .work-grid[data-active-feature="self-test"] .prejoin-meter-row .self-test-level i{border-radius:6px}
/* The state sentence is reading matter on every step (65+): 17px,
   --text, never hidden while its step needs it. (Beats §20's
   short-screen display:none by specificity.) */
.app-shell.prejoin .work-grid[data-active-feature="self-test"] .self-test-state{
  display:block;
  color:var(--text);
  font-size:17px;
  line-height:1.4;
}
/* STEP 1 OFF · the one action (65+ next-action treatment). */
.app-shell.prejoin .work-grid[data-active-feature="self-test"] .prejoin-turnon{
  align-items:center;
  justify-content:center;
  gap:10px;
  min-height:58px;
  padding:10px 16px;
  border:2px solid var(--azure);
  border-radius:var(--control-radius);
  background:var(--blue);
  color:var(--text);
  font-size:20px;
  font-weight:900;
  box-shadow:var(--azure-glow);
  animation:pjTurnonPulse 1.7s ease-in-out infinite alternate;
}
.app-shell.prejoin .work-grid[data-active-feature="self-test"] .prejoin-turnon svg{width:24px;height:24px;fill:currentColor;flex:0 0 auto}
/* STEP 1 LIVE · "Record a short test" is the bottom action after the
   pickers (pick, then record — the Zoom pre-join order). The partial's
   DOM keeps the run button above the pickers (the in-studio paint
   order); this `order` is the wizard's one deliberate tab-vs-paint
   trade, flagged in the partial's comment. */
.app-shell.prejoin .work-grid[data-active-feature="self-test"] .self-test-gate.st-live .self-test-run{
  order:5;
  min-height:56px;
  font-size:19px;
}
/* STEP 2 · the countdown card OUTSIDE the video (Tor's skeleton): the
   disabled run button restyled as a status row — recording dot,
   "Recording your test", the module's own ticking [data-test-count]. */
.app-shell.prejoin.pj-step2 .work-grid[data-active-feature="self-test"] .self-test-run{
  pointer-events:none;
  opacity:1;
  min-height:56px;
  border:1px solid var(--line);
  background:var(--raised);
  box-shadow:none;
  color:var(--text);
  font-size:18px;
  font-weight:800;
}
.app-shell.prejoin.pj-step2 .work-grid[data-active-feature="self-test"] .self-test-run svg{display:none}
.app-shell.prejoin.pj-step2 .work-grid[data-active-feature="self-test"] .self-test-run::before{
  content:"●";
  color:var(--red);
  font-size:16px;
  animation:pjBlink 1.2s steps(2,start) infinite;
}
.app-shell.prejoin.pj-step2 .work-grid[data-active-feature="self-test"] .self-test-run [data-test-count]{font-size:24px;color:var(--cyan)}
.app-shell.prejoin.pj-step2 .work-grid[data-active-feature="self-test"] .self-test-state{font-size:18px;font-weight:800}
/* STEP 3 · THE REVIEW MODAL (Tor's point 2). Scrim over the dimmed
   step (the indicator behind shows step 3 too), centered card. The
   card IS the .self-test-gate — same DOM, painted as a dialog;
   ui/prejoin.js adds the dialog semantics, the focus containment and
   the Escape route while it is up. */
.app-shell.prejoin.pj-step3::before{
  content:"";
  position:fixed;
  inset:0;
  z-index:55;
  background:rgba(5,8,15,.74);
}
/* The modal card is position:fixed and must reach the VIEWPORT: both
   size containers on its ancestor chain impose layout containment,
   which makes them the fixed card's containing block AND a stacking
   context the shell's scrim ::before paints over (measured: the card
   rendered dimmed under the scrim in landscape, and clipped to
   nothing in portrait). Released for the modal state only — nothing
   behind the scrim needs container queries while the card is up. */
.app-shell.prejoin.pj-step3 .role-view.is-active,
.app-shell.prejoin.pj-step3 .work-grid[data-active-feature="self-test"] .stage-cell{container-type:normal}
.app-shell.prejoin.pj-step3 .work-grid[data-active-feature="self-test"] .self-test-gate{
  position:fixed;
  z-index:60;
  left:50%;
  top:50%;
  transform:translate(-50%,-50%);
  width:min(560px,calc(100vw - 20px));
  min-height:0; /* the short-window gate's min-height:100% would pin the card to the viewport edges */
  max-height:calc(100vh - 16px);
  overflow-y:auto; /* honesty valve; every captured state fits with 0 overflow */
  margin:0;
  padding:14px;
  display:grid;
  grid-template-columns:minmax(0,1fr);
  grid-template-rows:none;
  align-items:start;
  align-content:start;
  gap:12px;
  border:1px solid var(--cyan);
  border-radius:var(--major-radius);
  background:var(--surface);
  box-shadow:0 0 0 1px rgba(34,211,238,.22),0 30px 90px rgba(0,0,0,.6);
}
.app-shell.prejoin.pj-step3 .prejoin-review-head{gap:2px}
.app-shell.prejoin.pj-step3 .prejoin-review-head .pj-eyebrow{
  margin:0;
  font:800 15px/1.3 ui-monospace,SFMono-Regular,Menlo,monospace;
  letter-spacing:.08em;
  color:var(--dim);
  text-transform:uppercase;
}
.app-shell.prejoin.pj-step3 .prejoin-review-head .pj-title{margin:0;font-size:20px;font-weight:900;color:var(--text)}
/* The player: a card element again inside the modal (full-bleed was
   the step's shape, not the dialog's), native controls at its bottom
   edge, NOTHING painted on it. */
.app-shell.prejoin.pj-step3 .work-grid[data-active-feature="self-test"] .self-test-stage-col{position:static;align-self:auto;gap:10px}
.app-shell.prejoin.pj-step3 .work-grid[data-active-feature="self-test"] .self-test-preview{
  width:100%;
  margin:0;
  border:1px solid var(--line);
  border-radius:var(--card-radius);
  box-shadow:none;
}
.app-shell.prejoin.pj-step3 .work-grid[data-active-feature="self-test"] .self-test-gate.is-passed .self-test-preview{border-color:var(--good)}
/* The played-back words: a compact static card under the player
   (never over its controls). Scrolls internally past ~4 lines — the
   one variable-length surface in the modal, the one deliberate
   exception to the step's no-scroll rule. */
.app-shell.prejoin.pj-step3 .work-grid[data-active-feature="self-test"] .self-test-captions{
  position:static;
  margin:0;
  padding:8px 10px;
  border:1px solid var(--line);
  border-radius:var(--card-radius);
  background:var(--raised);
  backdrop-filter:none;
}
.app-shell.prejoin.pj-step3 .work-grid[data-active-feature="self-test"] .self-test-captions-title{margin:0 0 4px;font-size:15px}
.app-shell.prejoin.pj-step3 .work-grid[data-active-feature="self-test"] .self-test-review{max-height:96px;min-height:0;margin:0}
.app-shell.prejoin.pj-step3 .work-grid[data-active-feature="self-test"] .self-test-caption-note{margin:2px 0 0;font-size:15px}
/* Verdict column: sentence → ✓ rows → primary → secondary. */
.app-shell.prejoin.pj-step3 .work-grid[data-active-feature="self-test"] .self-test-device-panel{
  display:grid;
  grid-template-columns:minmax(0,1fr);
  align-content:start;
  gap:10px;
  padding:0;
  margin:0;
  max-height:none;
  overflow:visible;
}
.app-shell.prejoin.pj-step3 .work-grid[data-active-feature="self-test"] .self-test-state{font-size:16px;text-align:left}
.app-shell.prejoin.pj-step3 .work-grid[data-active-feature="self-test"] [data-test-results]{display:grid;grid-template-columns:minmax(0,1fr);gap:6px;margin:0}
.app-shell.prejoin.pj-step3 .work-grid[data-active-feature="self-test"] [data-test-results] li{font-size:15px;line-height:1.35}
/* The two actions. PASSED: enter loud, re-run quiet. */
.app-shell.prejoin.pj-step3 .prejoin-enter,
.app-shell.prejoin.pj-step3 .prejoin-rerun{
  align-items:center;
  justify-content:center;
  gap:10px;
  width:100%;
  border-radius:var(--control-radius);
  font-weight:900;
}
.app-shell.prejoin.pj-step3 .prejoin-enter svg{width:22px;height:22px;fill:currentColor;flex:0 0 auto}
.app-shell.prejoin.pj-step3 .prejoin-rerun svg{width:20px;height:20px;fill:currentColor;flex:0 0 auto}
.app-shell.prejoin.pj-step3 .self-test-gate.is-passed .prejoin-enter{
  order:1;
  min-height:56px;
  padding:10px 16px;
  border:2px solid var(--cyan);
  background:var(--blue);
  color:var(--text);
  font-size:18px; /* one line at the portrait card width */
  animation:pjEnterPulse 1.7s ease-in-out infinite alternate;
}
.app-shell.prejoin.pj-step3 .self-test-gate.is-passed .prejoin-rerun{
  order:2;
  min-height:48px;
  padding:8px 14px;
  border:1px solid var(--line);
  background:transparent;
  color:var(--dim);
  font-size:17px;
}
.app-shell.prejoin.pj-step3 .self-test-gate.is-passed .prejoin-rerun:hover,
.app-shell.prejoin.pj-step3 .self-test-gate.is-passed .prejoin-rerun:focus-visible{border-color:var(--cyan);box-shadow:var(--glow);color:var(--text)}
/* FAILED (and interrupted): the fix comes first — re-run loud, enter
   quiet. "Enter the studio anyway" stays, because a dead microphone
   must never lock an instructor out of their own studio. */
.app-shell.prejoin.pj-step3 .self-test-gate:not(.is-passed) .prejoin-rerun{
  order:1;
  min-height:56px;
  padding:10px 16px;
  border:2px solid var(--azure);
  background:var(--blue);
  color:var(--text);
  font-size:19px;
  box-shadow:var(--azure-glow);
}
.app-shell.prejoin.pj-step3 .self-test-gate:not(.is-passed) .prejoin-enter{
  order:2;
  min-height:48px;
  padding:8px 14px;
  border:1px solid var(--line);
  background:transparent;
  color:var(--dim);
  font-size:17px;
}

/* ---- 20b·4 GEOMETRY · desktop and tablet (never prototyped; the
        conservative centered column, measured before shipping) -------- */
@media (min-width:601px) and (min-height:521px){
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .feature-stage{
    margin:0;
    padding:0;
    grid-template-rows:minmax(0,1fr);
    border:0;
    border-radius:0;
    background:transparent;
    box-shadow:none;
  }
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .self-test-panel{padding:0 16px}
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .self-test-gate{
    width:min(720px,100%);
    margin-inline:auto;
    grid-template-columns:minmax(0,1fr);
    grid-template-rows:none;
    align-items:start;
    align-content:safe center;
    min-height:100%;
    gap:14px;
  }
  /* Height-capped preview: the stage-cell keeps its size containment
     here, so 100cqh is the step body's real height and the reserve
     leaves room for [heading + sentence + meter + three pickers +
     Record] below the picture without scrolling on ordinary desktop
     heights. 420px, measured: 380px left the live step's column 19px
     past its scrollport at 1280×800. */
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .self-test-preview{
    width:min(100%,calc((100cqh - 420px) * 16 / 9));
    margin-inline:auto;
  }
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .prejoin-meter-row{
    width:min(100%,calc((100cqh - 420px) * 16 / 9));
    margin:2px auto 0;
  }
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .self-test-device-panel{gap:12px}
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .self-test-device-row{grid-template-columns:minmax(0,1fr)}
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .self-test-field{min-height:54px}
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .self-test-field select{min-height:52px;font-size:17px}
}

/* ---- 20b·5 GEOMETRY · portrait phone (the 18c gate; ITEM C's portrait
        full-bleed stage carries the preview edge-to-edge and its
        max-content rows apply unchanged — the stage is the one scroller
        if a state ever outgrows it; none of the captured states do) --- */
@media (max-width:600px) and (min-height:521px){
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .self-test-gate{gap:12px}
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .self-test-device-panel{padding:0 12px 10px;gap:12px}
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .self-test-device-panel h3{font-size:20px}
  /* Roomy pickers — the reason the step exists (Tor, item D verbatim). */
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .self-test-device-row{grid-template-columns:minmax(0,1fr)}
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .self-test-field{min-height:54px}
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .self-test-field select{min-height:52px;font-size:17px}
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .prejoin-meter-row{margin:2px 12px 0}
}

/* ---- 20b·6 GEOMETRY · short windows (the landscape phones AND every
        wider short window — see the region banner for why the 950px cap
        is deliberately absent here). Self-contained: 20·C's landscape
        block stops at 950px, so everything the wizard needs in this
        band is restated under the .prejoin scope.
        DELIBERATE EXCEPTION to the landscape band's 241px floor
        (CodeRabbit round 5 asked for the floor or the reason): these
        rules are scoped to .prejoin, a first landing that owns the
        WHOLE screen — there is no studio furniture for 18b's
        scrolling-page tier to arbitrate with, and a sub-241px window
        still needs the wizard laid out rather than dormant. The wizard
        scrolls within its one 1fr stage row below that height; 18b's
        contract governs the studio, not this surface. ------------------ */
@media (max-height:520px) and (min-width:390px){
  /* One definite 1fr row for the stage (item D's measured finding: the
     hidden header's auto row + the cell's size containment otherwise
     zero the step to a blank screen in this band). */
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .feature-stage{
    margin:0;
    padding:0 0 4px;
    gap:4px;
    grid-template-rows:minmax(0,1fr);
    border:0;
    border-radius:0;
    background:transparent;
    box-shadow:none;
  }
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .self-test-panel{padding:0}
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .self-test-gate{
    padding:0 10px;
    grid-template-columns:minmax(0,auto) minmax(264px,1fr);
    grid-template-rows:minmax(0,1fr);
    align-items:safe center;
    align-content:stretch;
    gap:0 12px;
    min-height:100%;
    width:auto;
    margin:0;
  }
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .self-test-stage-col{
    min-width:0;
    align-self:safe center;
    gap:0;
  }
  /* Height-bound preview with the meter row's height carved out of the
     cqh term so [preview + meter] together fit the cell; the cqw term
     reserves 360px for the device column (item D's measured finding:
     ITEM C's 300px reserve clipped "Fake Default Audio Input" at 17px
     — the step has no rail, so the 64px go to the words). */
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .self-test-preview{
    width:min(calc((100cqh - 46px) * 16 / 9),calc(100cqw - 360px));
    margin:0 auto;
    border-radius:0;
  }
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .prejoin-meter-row{
    width:min(calc((100cqh - 46px) * 16 / 9),calc(100cqw - 360px));
    margin:6px auto 0;
  }
  /* The device column: single stack, fields at the 44px floor+ so
     [sentence + 3 pickers + Record] fits 393px of height with zero
     scrolling (measured by the capture probe). h3 stays hidden in this
     band (§20 already hides it): the indicator segment + the sentence
     name the step in 393px of height. */
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .self-test-device-panel{
    grid-template-columns:minmax(0,1fr);
    gap:9px;
    align-content:safe center;
    min-height:0;
    max-height:100%;
    overflow-y:auto;
    padding:2px 4px 6px;
  }
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .self-test-device-row{grid-template-columns:minmax(0,1fr);gap:8px}
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .self-test-field{min-height:48px}
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .self-test-field select{min-height:46px;font-size:16px}
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .self-test-state{font-size:16px}
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .self-test-gate.st-live .self-test-run{min-height:52px;font-size:18px}
  /* THE MODAL, landscape: two columns — player + words left, verdict +
     actions right, head across the top. */
  .app-shell.prejoin.pj-step3 .work-grid[data-active-feature="self-test"] .self-test-gate{
    width:min(832px,calc(100vw - 20px));
    grid-template-columns:minmax(0,42fr) minmax(0,58fr);
    gap:8px 14px;
    padding:12px;
  }
  /* Height is the scarce axis: the head is one line, eyebrow beside
     the title. */
  .app-shell.prejoin.pj-step3 .prejoin-review-head{grid-column:1/-1;display:flex;align-items:baseline;gap:12px}
  .app-shell.prejoin.pj-step3 .work-grid[data-active-feature="self-test"] .self-test-stage-col{grid-column:1}
  .app-shell.prejoin.pj-step3 .work-grid[data-active-feature="self-test"] .self-test-device-panel{grid-column:2;gap:8px}
  .app-shell.prejoin.pj-step3 .work-grid[data-active-feature="self-test"] .self-test-review{max-height:56px}
  .app-shell.prejoin.pj-step3 .self-test-gate.is-passed .prejoin-enter{min-height:52px}
  .app-shell.prejoin.pj-step3 .self-test-gate.is-passed .prejoin-rerun{min-height:44px}
  .app-shell.prejoin.pj-step3 .self-test-gate:not(.is-passed) .prejoin-rerun{min-height:52px}
  .app-shell.prejoin.pj-step3 .self-test-gate:not(.is-passed) .prejoin-enter{min-height:44px}
}

/* ---- 20b·7 MOTION --------------------------------------------------- */
@keyframes pjTurnonPulse{
  from{box-shadow:0 0 12px -6px var(--azure)}
  to{box-shadow:0 0 26px -2px var(--azure),0 0 0 1px var(--azure)}
}
@keyframes pjEnterPulse{
  from{box-shadow:0 0 12px -6px var(--cyan)}
  to{box-shadow:0 0 26px -2px var(--cyan),0 0 0 1px var(--cyan)}
}
@keyframes pjBlink{50%{opacity:.25}}
@media (prefers-reduced-motion:reduce){
  .app-shell.prejoin .work-grid[data-active-feature="self-test"] .prejoin-turnon{animation:none;box-shadow:0 0 22px -4px var(--azure)}
  .app-shell.prejoin.pj-step3 .self-test-gate.is-passed .prejoin-enter{animation:none;box-shadow:0 0 22px -4px var(--cyan)}
  .app-shell.prejoin.pj-step2 .work-grid[data-active-feature="self-test"] .self-test-run::before{animation:none}
}

/* ====================================================================
   21 · FILL-WINDOW MODE (.app-shell.stage-viewport) — LAST BLOCK, ALWAYS
   Must trail every responsive block so it wins in all modes.

   The mockup states this invariant in its own comment but then places
   the block ahead of section 20, so the verbatim extraction inherited
   the contradiction. Section 20's
   .work-grid[data-active-feature="self-test"] .feature-stage ties this
   block's .app-shell.stage-viewport .feature-stage on specificity, so
   whichever is written later wins. Ordered the mockup's way, entering
   fill-window mode during Self Test hides the rail, topbar and
   transport (those rules have no rival) without ever handing the stage
   the freed space — chrome gone, stage still half size. Keep this
   block last; nothing may be appended below it.
   ==================================================================== */
.app-shell.stage-viewport{grid-template-columns:minmax(0,1fr)}
.app-shell.stage-viewport .setup-rail,
.app-shell.stage-viewport .topbar,
.app-shell.stage-viewport .transport,
.app-shell.stage-viewport .side-rack,
.app-shell.stage-viewport .feature-switcher,
.app-shell.stage-viewport .utility-dock,
.app-shell.stage-viewport .local-slide-nav{display:none}
.app-shell.stage-viewport .workspace{grid-template-rows:minmax(0,1fr)}
.app-shell.stage-viewport .role-view.is-active{display:grid;grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr);padding:0;column-gap:0}
.app-shell.stage-viewport .work-grid{display:contents}
.app-shell.stage-viewport .feature-stage{grid-column:1;grid-row:1;border-width:0;border-radius:0}
