/* ==========================================
   Chat Mode — Voice Practice Overlay
   Desktop only. 和の感性 design system.
   Premium polish: 13-point upgrade.
   ========================================== */

/* ==========================================
   THE ROOM'S TYPE SCALE (2026-07-25) — FOUR VOICES, NOT SIXTEEN SIZES
   ==========================================
   The room had 67 font-size declarations across 16 values, and 52 of them sat
   between 8.5 and 13.5px in half-pixel steps. Half a pixel is under what the
   eye resolves at reading distance, so those eleven "sizes" told the learner
   nothing — they were variety, not hierarchy. That is the mechanical reason
   the room read detailed but never composed.

   Worse, the hierarchy ran BACKWARDS: the hover card's headword was 20px while
   Sensei's Japanese speech was 17px and his English 13.5px. The apparatus
   out-shouted the speech it exists to serve — the same inversion T1.4 already
   forbids on WIDTH ("apparatus can never out-measure the speech it serves"),
   just never enforced on size.

   Five voices now, each a step you can actually see:
     --rm-jp       the Japanese — speech (both speakers) and the card's echo of
                   the same word. The biggest thing in the room, because the
                   learner cannot read kanji yet and this is what he came for.
     --rm-en       the English — speech, both speakers. 15px matches the Voice
                   Portal's written Sensei (15.5px) inside half a pixel, i.e.
                   indistinguishably: his spoken voice and his written voice
                   are finally the same size.
     --rm-support  the card's supporting stack — reading, romaji, meaning,
                   word rows. One size, distinguished by ink and slant.
     --rm-control  every button, chip, tag, timer and hint. One size.
     --rm-caption  the micro-label voice — uppercase tracked eyebrows, section
                   labels, tiny badges, fold captions. The SAME SIZE as
                   --rm-control by decision (2026-07-28): 10px and below is
                   under the practical reading floor, so the portal's 9.5/10/
                   10.5 tier does not survive as a size. It gets its own name
                   anyway, so a caption can dress differently from a control
                   — case, tracking, ink — without a sixth size entering the
                   room. The S5 contrast pass therefore touches only COLOUR:
                   the size question was settled here.
   Plus --rm-ruby, which is furigana and NOTHING else (0.53 of the base, just
   over the conventional half).

   ⛔ GLYPH SIZES ARE A SEPARATE AXIS and are deliberately NOT folded in: the
   tray caret (16). An icon is sized by its target, not by the reading
   hierarchy. The ＋ and the ✕ left this axis entirely on 2026-07-28 — they are
   strokes now, in the letter key's 16px SVG family, sized by their own viewBox
   and answering to no font at all. The hold's ⏸/▶ followed in slice four: one
   stroke pair at 18, so the room's centre control no longer changes font AND
   size between its two states.

   ⛔ --rm-line IS A BASELINE PIN, not a preference. English and Japanese share
   one line box, so its height is set by the LARGER: Klee 19px at 32px is 1.68
   leading, inside the JLReq range for Japanese body text (1.5-1.75). Change a
   speech size and you must re-derive this, or a line with Japanese in it stops
   being the same height as one without and turns jump as text arrives.

   ⛔ THE THREE SCOPES ARE LOAD-BEARING (2026-07-28, when the portal chrome
   joined the scale). The portal is a designed steady state with the room
   CLOSED — Study plan and New words call closeRoomForNav → exitChatMode while
   the portal stays opaque (voice-portal.js) — so body.chat-mode is absent
   exactly when the portal is being read. The letter panel is a body-level
   node and sits outside the container too. A var() with no definition in
   scope does not fall back to the room's size; it falls back to the browser's
   16px, silently, on the surface nobody re-checks. Mirrors the tint seam at
   voice-portal.css:182. Pinned by tests/css/type_voice_test.js.

   ── ONE LIGHT (2026-07-28) ────────────────────────────────────────────────
   The three --rm-pop-* values below are the popover spec, and they live here
   for the same reason the sizes do: five floating surfaces across two files
   read them, under all three scopes.

   A popover is ONE object in the learner's head — paper lifted off the page —
   so it gets one light: T1.9's warm-sumi channel, already the .chat-pop
   values, now the whole family's. What it replaced was five private answers
   (two lit from a warm brown that exists nowhere else in the app, two borrowed
   a RESTING card's shadow for a surface that floats, and the radii ran
   8 · 8 · 10 · 10 · 12 with nothing telling them apart but their write dates).
   ⛔ RESTING SURFACES ARE NOT IN THIS FAMILY and must not read these: a docked
   placard, a card, the recovery sheet and the bar are furniture SITTING on the
   page — --shadow-rest is theirs. The distinction is float, not importance.
   Pinned by tests/css/popover_spec_test.js.

   ── THREE RUNGS OF INK (2026-08-06, owner ruling — B9) ────────────────────
   The --rm-ink-* values below are the ink ladder, and they live here for the
   third time for the same reason: two files read them, under all three scopes.

   What they replaced: 134 fractional alphas across the two files, an unbroken
   run of 21 distinct values from 0.16 to 0.95 with no gap wider than 0.03 —
   structurally the same disease as the sixteen font-sizes above, and cured the
   same way. Nothing in the room ever wrote ink as a colour alpha; every fade is
   the `opacity` property, which is why the ladder governs that property alone.

   ⛔ THERE IS NO FOURTH "WHISPER" RUNG, AND THAT IS THE RULING'S POINT.
   Transparency on text is not a free design axis here — it is an accessibility
   function of the paper behind it, and fading multiplies against whatever that
   paper is. Warm stone (--color-stone) on the portal's paper (--color-paper)
   measures 6.53:1 at full · 5.40:1 at 0.92 · 4.59:1 at 0.85 · 4.10:1 at 0.80 ·
   3.68:1 at 0.75 · 2.70:1 at 0.60. WCAG AA asks 4.5:1, and every size on the
   scale above (19/15/13/11/11/10px) is "normal text" — not one of them reaches
   the large-text exemption. A fourth rung below QUIET would have been an
   officially blessed way to ship text the reader cannot comfortably read.
   0.92 is the one number that clears AA for the room's quiet ink on its
   DARKEST paper, with headroom for the surfaces between; a rung that is only
   legal on some papers is not a rung.

   ⭐ SO WHERE DOES "QUIETER" COME FROM? THE INK'S COLOUR, AT FULL STRENGTH.
   The system already carries warm stone beside sumi. Stone at 1.0 reads quiet
   to the eye and keeps 6.53:1 in hand; black faded toward the paper reads quiet
   and keeps nothing. Every hierarchy in this room that used to be said in alpha
   is now said in hue — and a hover that brightens quiet ink lands on FULL,
   which is where --rm-ink-full earns its tenants rather than documenting an
   intention (see the --color-learner tombstone below for how that ends).

   MARK is a different standard and always was: non-text UI answers to WCAG
   1.4.11's 3:1, not 4.5:1, and 0.68 was measured for exactly that (3.41:1 on
   card-bright, 3.17:1 on paper). It was argued three times in this file before
   it had a name — at the room's corner keys, at .cu-dock-close and at .ctp-x — and it
   is pinned by tests/css/control_room_test.js and by CDP. ⛔ MARKS ONLY: an
   entry that puts TEXT on this rung is a law break, not an exception.
   Pinned by tests/css/ink_law_test.js.

   ⛔ AND #chatSaveFailed IS ON THIS LIST (B4, 2026-08-10) FOR A REASON THE
   OTHER THREE SCOPES ALREADY RECORD: it is a body-level node that OUTLIVES the
   room. `exitChatMode` removes `body.chat-mode` (js/chat-mode.js) eleven lines
   after firing the save, so on the close-the-room path the POST answers 0-8s
   later with the scope already gone — and every `var(--rm-…)` on the banner
   would silently fall back. That is worse than a raw number: it is a surface
   that wears the type scale on one path and its own fallbacks on another,
   invisible to every gate, decided by how the learner happened to leave. */
body.chat-mode,
#voicePortalOverlay,
.chat-thread-panel,
#chatSaveFailed {
    --rm-jp: 19px;
    --rm-en: 15px;
    --rm-support: 13px;
    --rm-control: 11px;
    --rm-caption: 11px;
    --rm-ruby: 10px;
    --rm-line: 32px;

    /* THE CAPTION'S DRESS (slice five, 2026-09-18). The uppercase micro-label
       family ran ten trackings and four weights across the two files, so peers
       on one screen read as two ranks. One tracking, one weight, the course
       page's shipped --e4-track. The size was settled above: --rm-caption. */
    --rm-track: 0.12em;
    --rm-track-weight: 600;
    /* The one argued second step: a surface head's caps English sits beside a
       24px display word, so it tracks wider than a section eyebrow. */
    --rm-track-display: 0.18em;

    /* The bar's ONE hand mass. Standby word, live hold and paused pill share
       one seat, so they share one size: the press changes the occupant's WORD,
       never the silhouette. They ran 44 and 64 against each other until here. */
    --hand-h: 64px;

    --rm-pop-shadow: 0 12px 32px -18px rgba(44, 44, 44, 0.25);  /* T1.9: the one warm-sumi channel */
    --rm-pop-line: rgba(44, 44, 44, 0.08);
    --rm-pop-radius: 12px;

    /* THE ROOM'S TWO RESTING EDGES, NAMED (slice five). Raw until now in this
       file while the portal already named them; its --vp-line / --vp-line-strong
       alias these now. Same channel as the light above, T1.9's rgba(44,44,44):
       a warm brown was measured and ruled out. 0.07 is the internal divider,
       0.12 a handed object's hairline (.cat-thumb). 0.08 (structure), 0.14
       (the ghost family) and 0.18 (controls) keep their own written jobs. */
    --rm-edge: rgba(44, 44, 44, 0.07);
    --rm-edge-strong: rgba(44, 44, 44, 0.12);

    /* The ink ladder (B9). Three rungs, and the header above says why there is
       no fourth. Content · supporting text · non-text marks. */
    --rm-ink-full: 1;
    --rm-ink-quiet: 0.92;
    --rm-ink-mark: 0.68;

    /* ⭐ SLICE ONE (owner pick 2026-09-17, candidate C staged on the live record):
       SENSEI'S ENGLISH IS WARM INK. Her speech wore --color-stone, the app's
       SECONDARY colour, so the main content of the room sat in the ink the
       family reserves for chrome and captions. The course page found the same
       thing in September (CT-4: "the neutral sumi was the one cold colour on a
       warm page") and set its English in the drawer's warm sumi, --slip-ink
       (tokens.css:106). One token, read by the one speech rule. Measured on
       the room's darkest paper (#E3DFD5): 9.0:1. ⛔ The learner's English is
       NOT on this token: R3.8 is an owner-picked lever that softens HIS
       scaffolding to faint-warm, and it stands (the rule below at "R3.8"). */
    --rm-speech-ink: var(--slip-ink, #3A3633);
}

/* ---- Custom Properties (driven by JS) ---- */
.chat-mode-container {
    --rms: 0;
    --breath-duration: 3.5s;
    /* ⛔ --color-learner DELETED 2026-07-27, and the deletion is the honest part.
       Its comment claimed "ONE decision for TWO surfaces (the user turn's
       hairline + the user-speaking ensō fill)". Only ONE ever consumed it: the
       ensō. There is no hairline rule in this file and there never was — the
       token was documenting an intention, and a test counting `>= 2` usages
       passed anyway because the ensō's stroke AND its echo both read it.
       The one-warm-light ruling retired the ensō's hue, which left the property
       with zero consumers. So the room marks no voice by COLOUR at all now: the
       lamp says a voice is in the room, the transcript says whose. If a
       learner-coloured surface is ever wanted back, define it where it renders
       and let the test count what actually paints. */
}

/* Dragging over text is the one place the OS gets to choose a colour in this
   product, and its answer is a flat system blue. The hall already refused it
   (css/home-hall.css) with the app's indigo at 0.18; the room and the letter
   panel say the same. ⛔ THE PANEL IS NAMED SEPARATELY on purpose — it is a
   body-level node, outside both overlays, and it is the one surface a learner
   really does select text on (Sensei's letter). */
#chatModeOverlay ::selection,
.chat-thread-panel ::selection { background: rgba(61, 90, 128, 0.18); }

/* ---- Overlay — Shoji entrance ---- */

.chat-mode-overlay {
    position: fixed;
    inset: 0;
    /* ⛔ NO --vp-rail-w BINDING (2026-07-27, owner ruling: "we do not need two
       screens"). This used to read `left: var(--vp-rail-w, 0px)` — the room was
       a SECOND body-level overlay holding itself in alignment with the portal's
       rail by copying the rail's own width. Two screens agreeing to look like
       one, and every disagreement between them was a bug: collapsing the rail
       slid the whole room 122px sideways, and the room's z-index meant nothing
       next to the hall's.
       The room is now SEATED inside the portal's stage (.vp-stage — see
       ensureShell/seatRoom in voice-portal.js) and the GRID does the aligning,
       so there is nothing left to keep in sync. `.in-portal` below is that seat;
       the fixed inset here is the room standing alone, which is still the whole
       truth off localhost, where the portal does not exist. */
    /* ⛔ THE ROOM NO LONGER YIELDS TO THE LETTER (S12, 2026-07-29). It insetted
       its right edge for the panel for as long as the panel sat BESIDE the
       talk; the owner's ruling made the panel a cover, and a covered room has
       nothing to move out of the way of. The panel reads this edge instead —
       see --ctp-left in openThreadPanel. */
    right: 0;
    /* The room's seat on the body-level ladder (enterChatMode lifts it out of
       #app; see the note there). It was 5000, which read as 5000 among #app's
       children and put it UNDER the 玄関 hall. The ladder, low to high:
         9500 hall · 9550 THE ROOM · 9580 journey composer · 9600 portal rail
         · 9601 rail handle · 9610 the letter panel · 9612 the page viewer
         · 9619 session-decision (recovery scrim + the rail's ⋯ menu)
         · 9620 the recovery/leave/delete cards
       Everything the room must not cover is above it and stays there.
       ⛔ A rung here is only real for a node that actually sits on the BODY:
       the portal overlay carries a transform, so anything inside it is capped
       at its 9600 whatever number it declares — that is how the ⋯ menu's old
       "9610" painted UNDER the letter panel, and why A6 re-parented the menu
       and the rail handle to document.body. The ENFORCED map is
       tests/voice/zindex_ladder_test.js, which also reads inline cssText in
       js/ (showSaveFailed's 10000 is invisible to a CSS-only grep). */
    z-index: 9550;
    /* T1.1 mat-paper-world: the room stands on the gallery's paper with the
       genkan's one light (home-hall.css's morning-window wash). Card material
       is henceforth reserved for OBJECTS handed to the learner (unpack,
       kintsugi, recovery/leave, settings); speech and labels are ink on paper. */
    background-color: var(--color-paper, #F0EDE8);
    /* ONE LIGHT (2026-07-25 composition pass): the hall's morning window,
       indoors. A wide warm pool centred on where the talk lives, cooling to
       the edges — the room reads as a lit space instead of a flat sheet with
       a column of text on it. Depth from light, never from new material
       (T1.1 holds: card material stays reserved for handed objects). */
    background-image: radial-gradient(118% 74% at 50% 42%,
        #FFFDF8 0%, #FAF7F0 32%, #F1EEE7 62%, #E9E5DC 86%, #E3DFD5 100%);
    display: flex;
    align-items: center;
    justify-content: center;
    opacity: 0;
    clip-path: inset(50% 50% 50% 50%);
    /* ⛔ ONE transition shorthand per rule. A second one REPLACES the first —
       it does not add to it — and the two insets above were declared in their
       own `transition: left…, right…` for as long as that was true, which made
       them dead: the rail's inset and the letter panel's both SNAPPED while
       the CSS claimed 0.34s. Anything new that transitions here joins THIS
       list. (Same mistake, same file family: voice-portal.css's
       `body.chat-mode` rule had killed the portal's own entrance fade.) */
    transition: clip-path 0.5s cubic-bezier(0.4, 0, 0.2, 1),
                opacity 0.45s cubic-bezier(0.4, 0, 0.2, 1),
                left 0.34s var(--ease-settle, ease),
                right 0.34s var(--ease-settle, ease);
}

/* ── THE ROOM SEATED IN SENSEI'S PLACE (2026-07-27) ─────────────────────────
   ONE SCREEN. The portal's shell is a two-column grid — rail | stage — and the
   room fills the stage. It is the same element and the same renderer either
   way; only its containing block changes, which is why a live session survives
   the move (a DOM reparent touches no socket and no audio node).
   `position: fixed` would still measure against the VIEWPORT here and put the
   room back over the rail, so the seat must say absolute. The seat fills the
   stage outright: since S12 the letter panel covers the room rather than
   insetting it, so there is no inset left to carry. */
.chat-mode-overlay.in-portal {
    position: absolute;
    inset: 0;
    /* inside the stage the room needs no ladder of its own — the portal already
       stands above the hall, and the letter panel is its own body-level layer */
    z-index: 1;
}

.chat-mode-overlay.visible {
    opacity: 1;
    clip-path: inset(0% 0% 0% 0%);
}

/* ---- The room as the PORTAL'S CONTENT: one place arrives once ----
   The shoji iris above, and the curtain-raise staged behind it (orb at 0.25s,
   caption at 0.45s, transcript at 0.6s), were written for a room that arrived
   ALONE over the gallery. There they are right and they stay.

   Inside the portal they lie. The rail is painted at frame one and the room
   then grows out of a point in the middle of it — measured on the live tree:
   rail full at 0ms, room fully open only at 608ms, orb not settled until
   930ms. So Sensei's place appeared to load in two halves, history first and
   the room second, which is exactly what it is NOT: one place.

   So when the room comes up as the portal's content it crosses on the same
   calm fade as the paper around it, and everything inside it comes with it
   rather than making its own entrance. */
body.voice-portal-open .chat-mode-overlay {
    clip-path: none;
    transition: opacity 0.4s var(--ease-settle, ease),
                left 0.34s var(--ease-settle, ease),
                right 0.34s var(--ease-settle, ease);
}
/* ⛔ .voice-viz-label is NOT in this list, and must never be added back. These
   rules cancel ENTRANCE fades for elements that are always visible; the status
   line's opacity is its message, and forcing it to 1 here would leave
   "Connecting…" burned onto the room for the whole session. */
body.voice-portal-open .voice-viz,
body.voice-portal-open .chat-transcript {
    animation: none;
    opacity: 1;
}

/* The grain. LOAD-BEARING, not decoration: a warm ramp this soft bands
   visibly on this palette (measured on the light above), and 1-2% noise is
   what kills the banding. It doubles as the room's ONE textured surface —
   design law #6, one surface is elegant, every surface is wallpaper. Paints
   under the container: both are positioned, source order decides. */
.chat-mode-overlay::before {
    content: '';
    position: absolute;
    inset: 0;
    pointer-events: none;
    opacity: 0.4;
    mix-blend-mode: multiply;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)' opacity='0.055'/%3E%3C/svg%3E");
}

/* ⭐ SLICE ONE (owner pick 2026-09-17, candidate C of three staged on the live
   record): AT REST THE LIGHT LANDS ON THE GREETING. The room's one light
   (the radial above, peak at 42% of the height) was composed for a LIVE talk,
   where the column of speech lives mid-stage. At rest that column is empty:
   the lamp sits at the ceiling, the greeting at the floor, and the pool's
   peak fell dead centre of the 700px band between them — the brightest
   paper in the room lit nothing, and the one word in the room was the least
   lit thing on it. This layer is the landing's own pool: tighter (70% × 46%),
   centred on the greeting (74%), opaque over the base light, and it FADES
   out on the settle curve the moment the landing leaves — a talk's first line
   arrives on the talk's light, never a snap. Paints below the grain (negative
   z-index inside the overlay's own stacking context) so the grain still
   kills the banding on it. Nothing moves; only the light. */
.chat-mode-overlay::after {
    content: '';
    position: absolute;
    inset: 0;
    z-index: -1;
    pointer-events: none;
    background-image: radial-gradient(70% 46% at 50% 74%,
        #FFFDF8 0%, #FAF7F0 34%, #F1EEE7 66%, #E9E5DC 88%, #E3DFD5 100%);
    opacity: 0;
    transition: opacity 0.6s var(--ease-settle, ease);
}
/* ON while the landing is seated and no turn has landed: the first line of a
   talk (or a reopened night's record) is what sends the pool away. Keyed on the
   presence of a turn rather than on state, because a reopened night keeps the
   landing head above its record. */
.chat-mode-overlay:has(.chat-mode-container.has-landing):not(:has(.chat-message))::after { opacity: 1; }

/* Depth separation — grid blurs behind during shoji entrance */
body.chat-mode .grid-container,
body.chat-mode .flashcard-container,
body.chat-mode .bottom-action-bar,
body.chat-mode .premium-progress-wrapper {
    filter: blur(3px);
    opacity: 0.6;
    transition: filter 0.5s ease, opacity 0.5s ease;
}

/* ---- Container ---- */

.chat-mode-container {
    position: relative;
    width: 100%;
    max-width: 640px;
    height: 100vh;
    display: flex;
    flex-direction: column;
    padding: 20px 32px 16px;
    box-sizing: border-box;
}

/* ---- Header ---- */

.chat-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0 0 4px;
    flex-shrink: 0;
    /* the clock hangs off this box instead of sitting in it — see below */
    position: relative;
}

/* ---- Session Timer (with heartbeat) ---- */

/* ⛔ THE CLOCK MAY NOT MOVE THE ROOM (2026-07-27, owner: "the orb slightly
   moves down when the session starts"). It did, by exactly 14px, and the orb
   was innocent: the header's only in-flow child is this clock, it is
   display:none while empty, and startSessionTimer() fills it. So the header
   grew from its bare padding to a full line box the instant a session opened
   and pushed the lamp — and everything under it — down a row. A layout shift
   is not a transition; nothing can smooth it, because the box it happens in
   changes size between two frames.
   The keys already left the flow for their own reason (they pin to the room's
   corner). The clock leaves it for this one: it hangs off the header, so the
   header is the same height in every state and the lamp has one seat. */
.chat-session-timer {
    position: absolute;
    top: 0;
    left: 2px;
    font-family: 'Inter', sans-serif;
    font-size: var(--rm-control);
    font-variant-numeric: tabular-nums;
    color: var(--color-stone);
    opacity: var(--rm-ink-quiet);
    letter-spacing: 0.03em;
    min-width: 3em;
}

.chat-session-timer:empty {
    display: none;
}

/* ⛔ NO BREATH ON THE CLOCK (slice four). It ran `timerHeartbeat 4s infinite`
   for the whole session — a pace that answered to nothing, in a room whose one
   motion language is the lamp's 7s→9s. B9 had re-based its stops onto the
   ladder (0.45→0.6 was under the AA floor for 11px digits); the rule above now
   carries that ink outright — stone at the quiet rung, 6.08:1 → 4.82:1 across
   the room's paper. ⛔ Never --color-faint here: 3.60:1 at the lamp's centre,
   2.75:1 at the paper's edge — it would re-open exactly what B9 closed. */

/* ---- Close Button ---- */

/* ── the room's keys, in the room's own corner (owner, 2026-07-27) ──────────
   The header lives inside .chat-mode-container, which is capped at the
   transcript's reading measure (640px — the 860px-at-rest this once named was
   deleted by the ONE MEASURE ruling below). A control at the far
   end of THAT box lands three quarters of the way across the room and reads as
   floating in the middle of the paper, not as a corner mark. So the keys leave
   the column and pin to the ROOM.
   ⛔ These are measured from the VIEWPORT, not from the overlay. The overlay
   carries a clip-path, which the spec says makes it the containing block for
   fixed descendants — it does not here, and the difference is invisible until
   something insets the room.
   ⛔ NOTHING TRACKS THE LETTER PANEL ANY MORE (S12, 2026-07-29). These keys and
   the dock rail carried a `calc(… + --ctp-w)` term and a `transition: right`
   to glide off an opening panel's edge. The panel COVERS now: it sits at 9610
   over the room's 9550 and simply hides them, which is the intended grammar —
   the panel's own fold mark and Escape are the ways back. A key that slid out
   from under a cover would be chrome floating beside a reading. */
.chat-room-keys {
    position: fixed;
    top: 18px;
    right: 22px;
    display: flex;
    align-items: center;
    /* 8, not 6: the 44px hit expanders below reach 5px past each key's paper,
       and at a 6px gap the ✕'s would cover a pixel of the letter key's own box.
       The ✕ is the way OUT of a live room — the worst mis-tap in the room. */
    gap: 8px;
    z-index: 2;
}

/* ── the top row: the way OUT (owner, 2026-07-27) ───────────────────────────
   One mark, one job: the 30px square, the hairline ring and the warm paper that
   arrive under the pointer. (The letter KEY sat beside it in the same dress
   until S10 retired the letter; the rail's keys and the panel's fold mark still
   wear this spec, pinned by tests/css/control_room_test.js.)
   ⛔ The 2026-07-26 ruling is intact: it banned a SINGLE control doing two
   things (hide the history / quit the place). */
.chat-close-btn {
    display: inline-grid;
    place-items: center;
    position: relative;
    width: 30px;
    height: 30px;
    padding: 0;
    box-sizing: border-box;
    color: var(--color-stone);
    background: rgba(253, 252, 249, 0);
    border: 1px solid rgba(44, 44, 44, 0);
    border-radius: 9px;
    cursor: pointer;
    line-height: 1;
    flex-shrink: 0;
    transition: color 0.2s, background 0.24s var(--ease-settle, ease),
                border-color 0.24s var(--ease-settle, ease),
                box-shadow 0.24s var(--ease-settle, ease), transform 0.12s;
    /* 0.68, not 0.35 (2026-07-25): stone at 0.35 on the room's paper measures
       1.73:1, and the only way out of a live session is a control — WCAG
       1.4.11 wants 3:1 for its boundary. 0.68 measures 3.20:1 and still reads
       as the quietest thing on the header. */
    opacity: var(--rm-ink-mark);
}

/* ---- the 44px touch floor (iPad is a primary device here) ----
   A 30px mark is a 30px mark; what has to reach 44 is the HIT. The expander is
   the house idiom — css/vocab-panel.css:621, "Expanded hit area (44pt) without
   visual bloat" — a pseudo-element with a negative inset, invisible, inheriting
   its host's pointer-events, costing the layout nothing.
   ⛔ The insets are ASYMMETRIC where a neighbour is close: an expander may
   overlap another expander in the dead space between two controls, never a
   neighbour's own visible box. Here 30 + 7×2 = 44 tall, 30 + 5×2 = 40 wide,
   and the two 5s meet in the middle of the cluster's 8px gap.
   Every expanded control below carries `position: relative` for it. */
.chat-close-btn::after {
    content: '';
    position: absolute;
    inset: -7px -5px;
}

/* the paper only arrives under the pointer: at rest both are bare marks, which
   is what keeps a header with two controls in it from reading as a toolbar */
.chat-close-btn:hover {
    color: var(--color-ink);
    opacity: 1;
    background: rgba(253, 252, 249, 0.97);
    border-color: rgba(44, 44, 44, 0.10);
    /* ⛔ NO box-shadow, and there never was one on screen: a --shadow-rest decl
       sat here for weeks and never painted — `.chat-header button:hover
       { box-shadow: none }` (:2135) outranks it (0,2,1) over (0,2,0). Found
       2026-07-28 when the rail's keys adopted this dress and the copied line
       DID paint there, lifting them off paper the room's own keys rest on.
       The flat hover is the true dress: cards rest. */
}

.chat-close-btn:active {
    transform: scale(0.94);
}

.chat-close-btn:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 2px;
}

/* (.chat-letter-key.open — the key standing down while its letter was out —
   and .chat-letter-key.is-writing — its seat as a promise mark while Sensei
   wrote — left with the letter, S10.) */

@media (prefers-reduced-motion: reduce) {
    .chat-close-btn { transition: color 0.2s, opacity 0.2s; }
    .chat-close-btn:active { transform: none; }
}

/* ---- THE ROOM'S MARK: the lantern (owner ruling, 2026-07-27) ----
   A LAMP, not a diagram. It answers one question — whose turn is it — and it
   answers with LIGHT: the glow's hue says who, its depth says how alive the
   room is. Loudness breathes ONLY the scale (law #747, carried over verbatim
   from the ensō it replaces); the aura is the echo and rises with the voice.

   ⛔ WHY THE ENSŌ LOST, since it was the better drawing. Its own 2026-07-25
   note names the risk exactly: "pale + thin + broken ring is a loading
   spinner's vocabulary". It answered that with ink DENSITY — real, and it
   works while a session is running. It cannot work at REST, where density is
   at its lowest by design: a still, pale, broken ring on warm paper is a
   spinner, and the owner read it as one on sight. A room that greets you must
   not look like a room that is loading. A lamp cannot be misread: lit, or not.

   ⛔ ONE MARK, ONE SEAT. The portal drew its own copy of this sphere on the
   landing, which is exactly why pressing Talk swapped one object for another.
   The room owns the mark; nothing else draws an orb, ever. */

.voice-viz {
    flex-shrink: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    /* ⛔ ONE SIZING RULE, AND IT IS THIS ONE. A second `.voice-viz { height }`
       lived 1500 lines below (a leftover from the ensō pass) and quietly won the
       cascade, so this rule said 116px while the room drew 78px — a file that
       disagrees with the screen is worse than no comment at all. The numbers
       below are what is actually on the paper, and the duplicate is deleted.
       The 92px lamp deliberately overhangs this 78px band into the header's
       empty strip: that is what keeps it near the top without opening a gap
       between the light and the words it sits over. */
    height: 78px;
    position: relative;
    margin: 6px 0 2px;
    opacity: 0;
    animation: vizFadeIn 0.6s ease 0.25s forwards;
}

@keyframes vizFadeIn {
    to { opacity: 1; }
}

/* WHO IS SPEAKING is a hue, set on the stage and inherited by both light
   layers — so a state rule changes ONE property and the whole lamp answers.
   Idle is a warm neutral on purpose: it must not claim a speaker. */
.voice-viz-orb {
    position: relative;
    display: grid;
    place-items: center;
    width: 92px;
    height: 92px;
    /* ⛔ ONE WARM LIGHT, AND IT IS THE BALL'S OWN (owner, 2026-07-27: "reduce
       the glow and make it non-blue, same colour as the ball"). The lamp had
       been changing HUE to say whose turn it was — indigo for Sensei, sage for
       you — and indigo at depth 0.5 put a blue halo round a cream sphere. It
       read as a notification, not as light. A lamp does not change colour; it
       changes how brightly it burns. So the hue is fixed to the ball's own
       warmth and every state moves --orb-lift alone. */
    --orb-hue: #d8cdb6;
    --orb-lift: 0.12;            /* how brightly it burns; states move this ONLY */
}

/* The body of the lamp. Scale is the VOICE and nothing else. */
.voice-viz-orb .vo-ball {
    position: relative;
    z-index: 2;
    width: 72px;
    height: 72px;
    border-radius: 50%;
    background: radial-gradient(circle at 38% 33%, #f6f2ea 0%, #e7dccb 38%, #cbbda3 100%);
    box-shadow: inset 0 2px 3px rgba(255, 252, 244, 0.65),
                inset 0 -5px 11px rgba(120, 105, 78, 0.15),
                0 10px 26px -12px rgba(120, 105, 78, 0.35);
    transform: scale(calc(1 + var(--rms) * 0.045));
    transition: transform 0.25s ease, opacity 0.5s ease, filter 0.9s ease;
}
/* the specular — where the light is coming from; it is what makes it a sphere
   and not a disc, and it is the whole reason this reads as an object */
.voice-viz-orb .vo-ball::before {
    content: '';
    position: absolute;
    top: 19%; left: 22%; width: 34%; height: 30%;
    border-radius: 50%;
    background: radial-gradient(circle, rgba(255, 253, 247, 0.9), rgba(255, 253, 247, 0) 70%);
}

/* The glow — the lamp's own light on the paper. Hue = whose turn. */
.voice-viz-orb .vo-glow {
    position: absolute;
    z-index: 1;
    width: 92px; height: 92px;
    border-radius: 50%;
    background: radial-gradient(circle at 42% 40%, var(--orb-hue), var(--orb-hue) 55%, transparent 78%);
    filter: blur(14px);
    opacity: var(--orb-lift);
    transform: scale(calc(1.18 + var(--rms) * 0.09));
    /* ⛔ THE TRANSITION MUST NOT OUTRUN THE ANIMATION. This was 0.6s, and since
       --orb-lift is animated (via @property) while opacity READS it, every
       keyframe move arrived through a 0.6s lag — the arrival bloom peaks at
       0.42 in 370ms and measured 0.132 on the glass. The room was drawing a
       smear of the animation, not the animation. 0.2s still ate half of it
       (0.219); 0.09s is short enough to track a keyframe within a frame or two,
       and still takes the edge off the states that set --orb-lift as a FLAT
       value (speaking, paused, error) instead of letting them snap. */
    transition: opacity 0.09s linear, background 1s ease, transform 0.3s ease;
}

/* The aura = the ensō's ECHO, in light: invisible until a voice is live, and
   its opacity IS the loudness. Nothing here animates on its own. */
.voice-viz-orb .vo-aura {
    position: absolute;
    z-index: 0;
    width: 92px; height: 92px;
    border-radius: 50%;
    background: radial-gradient(circle at 50% 46%, var(--orb-hue), transparent 68%);
    filter: blur(24px);
    opacity: 0;
    transform: scale(calc(2 + var(--rms) * 0.34));
    transition: opacity 0.18s ease, background 1s ease, transform 0.3s ease;
}

/* ── CONNECTING → CONNECTED: THE ONE THING THE LAMP HAS TO SAY ──────────────
   Owner, 2026-07-27: "it should show something like 'connection is being
   established' and then the glow kicks in when it is done."

   The room used to say it in words and the owner cut them for being loud and
   western ("it should just show the change in connection status, this is it").
   But cutting the label left nothing behind: connecting swelled 0.08→0.20,
   resting 0.12→0.21, connected 0.15→0.24 — three states inside four hundredths
   of an opacity, which is to say one state. Measured live, the glow read 0.196
   at rest and 0.181 connected. Nothing arrived, because nothing changed.

   So the light carries it, and it carries it the only way a lamp can: by how
   much of it there is. Connecting the lamp is turned DOWN — dim, desaturated,
   breathing shallowly, plainly not ready. Then the connection lands and it
   BLOOMS, up eight-fold in a third of a second, and settles into the session's
   breath. A learner never has to be told; he watches the room light up.
   ⛔ Light only. Not one of these states may move the lamp — see the header
   note on the 14px drop. Position is a promise; brightness is the message. */

/* Connecting — the lamp is turned down while the line is found. */
.chat-mode-container.state-connecting .voice-viz-orb {
    /* one-shot dim off whatever it was burning at, then a shallow wait-breath.
       Two animations on one property: the later-listed wins once it starts, so
       the delay hands off cleanly with no step. */
    animation: orbDim 0.6s ease-out forwards,
               orbSettle 2.8s ease-in-out 0.6s infinite;
}
@keyframes orbDim {
    to { --orb-lift: 0.05; }
}
@keyframes orbSettle {
    0%, 100% { --orb-lift: 0.05; }
    50%      { --orb-lift: 0.11; }
}
/* …and the body of the lamp goes with it, so "not yet" is unmistakable even
   at a glance. Both properties are already transitioned on .vo-ball. */
.chat-mode-container.state-connecting .vo-ball {
    opacity: 0.84;
    filter: saturate(0.88);
}
.chat-mode-container.state-connecting .vo-aura { opacity: 0; }

/* Ready — THE ARRIVAL. The glow kicks in: 0.05 → 0.42 in a quarter of a second,
   then settles. This is the whole announcement.
   ⛔ 0.6s, AND THAT NUMBER IS A HANDSHAKE. state-ready lasts exactly 600ms
   (chat-mode.js hands over to state-connected on a timer), so the animation
   must finish inside it: at 1.1s the breath took over mid-fall from 0.39 and
   yanked the light down to 0.17 in one step — a flicker at the exact moment
   the room is supposed to look calm and arrived. It now lands on 0.20 against
   the breath's 0.17 opening, a step too small to see. Change one and change
   the other. */
.chat-mode-container.state-ready .voice-viz-orb {
    animation: orbReady 0.6s ease-out forwards;
}
@keyframes orbReady {
    0%   { --orb-lift: 0.05; }
    45%  { --orb-lift: 0.42; }
    100% { --orb-lift: 0.20; }
}

/* Connected idle ("your turn") — the lamp breathes at the session's pace, and
   it burns clearly brighter than it did while it was still finding the line. */
.chat-mode-container.state-connected .voice-viz-orb {
    animation: orbBreath var(--breath-duration, 7s) ease-in-out infinite;
}
@keyframes orbBreath {
    0%, 100% { --orb-lift: 0.17; }
    50%      { --orb-lift: 0.26; }
}

/* THE QUIET ASK (slice four). Two minutes with no word from either side used
   to raise the card a dropped line wears. It is not an alarm: the lamp draws
   back a little and takes up the STANDBY breath — the one the room already
   uses for "waiting for you, and alive while it waits" — while the caption
   under it asks once. No new motion language, no new hue, and the first word
   from either side takes both away (noteActivity). Speaking outranks it: the
   two rules below own the lamp whenever a voice is in the room. */
.chat-mode-container.state-idle-ask .voice-viz-orb {
    --orb-lift: 0.13;
    animation: orbWaiting 9s ease-in-out infinite;
}

/* Speaking — no idle animation: the VOICE carries the life, through the ball's
   scale and the aura's depth. Same warm light for both turns; a voice in the
   room is a voice in the room, and the transcript already says whose it is. */
.chat-mode-container.state-speaking .voice-viz-orb,
.chat-mode-container.state-user-speaking .voice-viz-orb {
    animation: none;
    --orb-lift: 0.26;
}
.chat-mode-container.state-speaking .vo-aura,
.chat-mode-container.state-user-speaking .vo-aura { opacity: calc(var(--rms) * 0.22); }

/* PAUSED — a held breath. The light drawn back and nothing moving, because a
   lesson set down mid-sentence should look held, not idling. */
.chat-mode-container.state-paused .voice-viz-orb {
    animation: none;
    --orb-lift: 0.09;
}

/* STANDBY — waiting for you, and ALIVE while it waits.
   ⛔ This is the one state that parts company with paused, and it is deliberate.
   The ensō ruled them identical ("a room waiting to be started and a room
   holding mid-lesson are the same thing to look at"), which is true of INK: a
   stroke at rest is a stroke at rest. It is not true of a LAMP, and it is not
   true of a tutor. The landing is the first thing the learner sees and its job
   is to say someone is here — a still lamp says the room is empty, and the
   owner has now rejected that read twice (once as a portal rebuild that
   "dropped the breathing orb", once as the ensō's spinner).
   So standby breathes: slower and shallower than a live turn, because it is
   waiting rather than listening, but it breathes. Held is still; waiting is
   alive. */
.chat-mode-container.state-standby .voice-viz-orb {
    animation: orbWaiting 9s ease-in-out infinite;
}
@keyframes orbWaiting {
    0%, 100% { --orb-lift: 0.12; }
    50%      { --orb-lift: 0.21; }
}
.chat-mode-container.state-paused .vo-ball,
.chat-mode-container.state-standby .vo-ball {
    transform: scale(0.97);
    filter: saturate(0.92);
}
.chat-mode-container.state-paused .vo-aura,
.chat-mode-container.state-standby .vo-aura { opacity: 0; }

/* Error — the light pales to muted stone, NOT gold (the design system reserves
   kintsugi gold for RECOVERY; the recovery card carries it). */
.chat-mode-container.state-error .voice-viz-orb {
    animation: none;
    --orb-hue: var(--color-faint);
    --orb-lift: 0.14;
}
.chat-mode-container.state-error .vo-ball { filter: saturate(0.35); }
.chat-mode-container.state-error .vo-aura { opacity: 0; }

/* --orb-lift is animated through @property-less custom properties, which do NOT
   interpolate — the keyframes above therefore STEP rather than glide in engines
   without registration. Registering it makes the breath a real fade. */
@property --orb-lift {
    syntax: '<number>';
    inherits: true;
    initial-value: 0.12;
}

/* ---- Viz Label — ⛔ THE ROOM DOES NOT NARRATE ITSELF (owner, 2026-07-27) ----
   "all these messy text labels, too loud and western as well, need to go — it
   should just show the change in connection status, this is it."

   This line was the room saying out loud what its own mark was already saying:
   "Connecting…", then "Sensei is speaking", then "Your turn". Three English
   sentences appearing and disappearing under a lamp, in a room whose entire
   design language is that the LIGHT tells you. It also broke the branch's one
   promise — press play and nothing changes but the state — by making text
   arrive on the press.

   ⛔ THIS IS THE CONNECTION LINE. NOT A NARRATOR. (owner, 2026-07-27:
   "shall we add connection status so the user can see when the connection is
   being established and once it is established")

   Two rulings, one line, and they are not in conflict. The first cut this
   element for chattering: it narrated every turn — "Your turn", "Sensei is
   speaking", "Sensei is listening…" — which the lamp already says better than
   words can, and which read as loud and western over washi. So it was hidden
   outright. That threw out the honest half with the noisy half: a blocked
   microphone, a failed token, a shared tab with no audio in it, all went
   silent, and so did the one fact a learner genuinely cannot infer — whether
   the room has reached Sensei yet.

   So the element comes back with exactly one job: the state of the CONNECTION
   and of the DEVICE. Connecting, resuming, connected, ended, and every honest
   failure. Nothing about whose turn it is — that is the lamp's, permanently.
   Call sites were audited one by one; the turn cues are deleted at the source
   (setVizState), not hidden here, so nothing can leak back in through CSS.

   ⛔ ZERO HEIGHT, ALWAYS. The line paints out of a 0px box and is nudged clear
   of the lamp with a transform, so it can appear and vanish without moving one
   pixel of the room. The whole reason this pass exists is a 14px layout jump
   (see the header note); a status line that shoves the lamp to announce
   itself would be the same bug wearing a different hat.

   Visibility has ONE owner: setVizLabel adds .is-on for non-empty text and
   drops it for empty. No state class may show or hide this line — a rule like
   `.state-standby .voice-viz-label {opacity:0}` is a second owner, and two
   owners is how "Connecting…" ends up invisible on the one path that matters. */
.voice-viz-label {
    display: block;
    height: 0;
    overflow: visible;
    position: relative;
    z-index: 2;
    transform: translateY(14px);   /* clears the lamp's overhang; costs no layout */
    text-align: center;
    font-family: 'Inter', sans-serif;
    font-size: var(--rm-control);   /* the room's one size for a hint or caption */
    letter-spacing: 0.04em;
    color: var(--color-stone);
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.55s var(--ease-settle, ease);
}
.voice-viz-label.is-on {
    /* 0.92, not 0.5 (2026-07-28). Stone at 0.5 over the lamp's bright core
       (#FAF7F0 → #F1EEE7) measures 2.29:1 → 2.23:1 — half the 4.5:1 AA floor
       for 11px text, and this text carries "Microphone blocked" and the
       share-audio hints: the room's whole honest-failure channel, unreadable.
       0.92 measures 5.82:1 → 5.44:1, and it is not a new number — it is the
       one already ruled for the ♪ door on this same paper (:1659). */
    opacity: var(--rm-ink-quiet);
}

/* ⛔ NOT "once conversation starts, the label fades permanently" — that comment
   outlived its behaviour and misled a room review into redesigning around a
   captionless ring. The P1 pass deleted appendMessage's hideVizLabel() call on
   purpose: the caption PERSISTS as the turn cue ("Your turn" / "Sensei is
   speaking" / "Sensei is listening…") for the whole session, and it is also the
   room's error channel. Nothing in app code adds .hidden any more; only a
   screenshot harness ever did. Keep the class — resetTranscript still removes
   it — but never hide the cue. */
.voice-viz-label.hidden {
    opacity: 0 !important;
    animation: none;
}

/* Dead-mic line (1.5): pale-stone DEVICE diagnostic under the enso — stands
   only while a live session's mic reports muted/ended (or a sustained
   exact-zero flatline), gone the instant the track unmutes or energy
   returns. Quiet by design: it questions the input, never the learner. */
.voice-mic-silent {
    flex-shrink: 0;
    text-align: center;
    font-family: 'Inter', sans-serif;
    font-size: var(--rm-support);
    color: var(--color-stone);
    /* Same defect one size up, same answer: 0.8 measured 4.13:1 on the lamp's
       paper, under the floor for a line that says the device is not working. */
    opacity: var(--rm-ink-quiet);
    height: 16px;
    line-height: 16px;
    margin-bottom: 6px;
}
.voice-mic-silent[hidden] { display: none; }

/* ---- Transcript Area (with top edge fade) ---- */

.chat-transcript {
    flex: 1;
    overflow-y: auto;
    /* ⛔ STRUCTURAL, not cosmetic. `overflow-y: auto` alone computes overflow-x
       to auto as well, so ANY overlay poking past the column earns a 15px
       horizontal scrollbar — and a horizontal scrollbar eats 15px of this
       box's clientHeight, which the bottom-anchored record below absorbs as a
       15px jump under the learner's eyes. A session record never scrolls
       sideways; say so, and the whole class of bug is closed at the source.
       (The card's own left clamp keeps it from being clipped — this is the
       backstop for the frame where a card grows before the clamp re-runs.) */
    overflow-x: hidden;
    scrollbar-width: thin;   /* the Firefox half of the 3px hairline below */
    scroll-behavior: smooth;
    padding: 16px 0;
    display: flex;
    flex-direction: column;
    /* named because the bottom-anchor's spacer below has to cancel exactly
       one of these — a literal there would drift the day this changes */
    --chat-turn-gap: 6px;
    gap: var(--chat-turn-gap);
    min-height: 0;
    opacity: 0;
    animation: vizFadeIn 0.4s ease 0.6s forwards;
    /* Both edges dissolve now. The old 28px top fade was shorter than one
       32px line box, so Sensei's first line was cut in half rather than
       faded (worst on iPad, where the band is tighter); and the bottom had
       no fade at all, so the record collided with the bar's paper.
       ⛔ THE TOP FADE CLEARS THE LAMP (S6, 2026-07-28). The 92px lamp overhangs
       its 78px band ~5px into this box, and its glow reaches ~25px further —
       and the old 44px ramp left a scrolled line ~60% readable inside that
       light (measured at 28px in). The first 16px are now fully transparent
       (the ball's own overhang), then the ramp runs to 72px, so a line
       dissolves before it enters the lamp's field instead of passing, still
       readable, behind it. */
    -webkit-mask-image: linear-gradient(to bottom,
        transparent 16px, black 72px, black calc(100% - 26px), transparent 100%);
    mask-image: linear-gradient(to bottom,
        transparent 16px, black 72px, black calc(100% - 26px), transparent 100%);
    padding-bottom: 22px;
}

/* THE TALK GROWS FROM WHERE YOU SPEAK (landed 2026-07-25, third attempt).
   A young session used to hang from the ceiling with ~450px of nothing
   between the last line and the bar — the room's worst look, in its opening
   minutes. The record now rests ON the bar and grows upward, the way every
   chat the learner already uses behaves; the empty space moves above, where
   it reads as sky. Once the talk outgrows the port the auto margin resolves
   to 0 by itself and the scroller takes over — and scrollTranscript()'s
   120px stick-to-bottom means that handover is invisible.

   THE THREE TRAPS, all paid for once:
   ⛔ NEVER `justify-content: flex-end` here. Both Chrome and WebKit strand
      the START edge of an overflowing scroller, and THIS scroller is the
      session record (Copy transcript reads it).
   ⛔ Scope by :has() to a transcript that actually holds turns. The empty
      state carries `flex: 1` and would fight an unconditional spacer.
   ⛔ The pseudo is a flex ITEM, so it earns a `gap` of its own: without the
      negative bottom margin the record silently gained 6px of dead air
      (measured: scrollHeight 1728 -> 1734 on a 14-turn room).

   The 2026-07-25 reversal was right that a first-item spacer makes free
   space absorb every perturbation, and right to demand the overlay be made
   provably inert first. It named the wrong culprit twice, and both were
   worth chasing:
   · The 15px it measured on hover was NOT the placard — the identical 15px
     lands with no card on screen at all. It was the line's own reading
     attachment settling late in a staging that hand-writes textContent after
     the append (see the S3c note in verify_voice_transcript.py).
   · But a REAL placard perturbation was hiding underneath: expanding a word
     tray grew the card past the column, which bought a horizontal scrollbar,
     which ate 15px of clientHeight. Fixed at the source (overflow-x above)
     and at the card (clamp-last + a ResizeObserver, js/chat-mode.js).
   With both fixed, the overlay is inert: measured frame-by-frame on the real
   dev server — young room and scrolled overflowing room, hover / pin-dock /
   word-tray-expand, 1440 and 820 — it moves no message top, no scrollTop, no
   scrollHeight, no clientWidth, at any animation frame.
   `tests/desktop/verify_voice_transcript.py` S53 pins that as a law now,
   instead of a comment claiming it. */
/* ⛔ .chat-landing-head COUNTS AS RECORD (2026-07-27). The spacer is what makes
   a young session rest ON the bar instead of floating mid-room (S53). The
   landing is the record's first block now, so it must sit where a first block
   sits — otherwise the greeting hangs in the middle at rest and drops the
   moment Sensei's first line makes the spacer apply, which is the 149px half of
   the jump the owner reported. */
.chat-transcript:has(.chat-message, .chat-attach-turn, .chat-landing-head)::before {
    content: '';
    flex: 0 0 auto;
    margin-top: auto;
    margin-bottom: calc(-1 * var(--chat-turn-gap));
}

/* Subtle scrollbar */
.chat-transcript::-webkit-scrollbar {
    width: 3px;
}
.chat-transcript::-webkit-scrollbar-track {
    background: transparent;
}
.chat-transcript::-webkit-scrollbar-thumb {
    background: rgba(44, 44, 44, 0.08);
    border-radius: 3px;
}

.chat-empty-state {
    flex: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 6px;
    text-align: center;
    padding: 0 24px;
    color: var(--color-stone);
    font-family: 'Inter', sans-serif;
}
/* ── THE ROOM AT REST IS THE LANDING (owner ruling 2026-07-26) ──────────────
   S2 made the room the entry and the lobby went with it. It comes back here —
   not as a surface of its own, but seated in the room while it rests, so
   nothing has to move to start talking. The lobby's own CSS (.vp-lobby,
   .vp-welcome, voice-portal.css) is untouched and still the one definition;
   these rules only give it a seat. (.vp-shelf was named here too until
   2026-07-28 — the shelf is gone from the landing and its rule with it.) */
.chat-empty-state .ces-lobby { width: 100%; }
.chat-empty-state .ces-lobby:empty { display: none; }
/* ⛔ ONE SEAT, AND #cesLobby IS NOT IT (corrected 2026-07-28). This block used
   to say "TWO SEATS, ONE DRESS" and pair every landing rule with a
   `.chat-empty-state .ces-lobby …` half. The socket is real — #cesLobby is in
   index.html — but the ONLY thing any JS ever writes to it is emptiness
   (renderDoorCard: `seat.textContent = ''`); the landing is seated in
   `.chat-landing-head` inside the transcript. So no DESCENDANT of .ces-lobby
   can ever match, and those halves have been dressing an empty box.
   The two lines above STAY and are load-bearing: the :empty hide is what keeps
   the empty socket from taking up room in the door card.
   The rule below still needs its .chat-landing-head half for the original
   reason — .vp-lobby's own `min-height: calc(100vh - 150px)` (voice-portal.css)
   would come straight back and shove the whole talk off screen. */
.chat-landing-head .vp-lobby {
    width: 100%;
    min-height: 0;          /* it is not filling a viewport here — the room is */
    margin-inline: auto;
    padding: 0 8px;
    /* (text-align:left left this seat 2026-07-28 — the landing centres on the
       room's one axis now, and the alignment is the lobby's own dress:
       .vp-lobby, voice-portal.css. One alignment, every width, every seat.) */
    animation: none;        /* the room already has an entrance */
}
/* …but the RECORD's arrival is not an entrance. The seat's animation:none
   above out-ranks .vp-record-in (0,2,0 over 0,1,0), which silently killed the
   S5 one-crossfade law on the landing while the rail faded alone — the file
   claimed a fade the screen never drew. This twin re-arms exactly the record
   crossfade at seat specificity; base opacity stays 1 and the keyframe carries
   only `from`, so the reduce kill below lands the end state by construction. */
.chat-landing-head .vp-lobby.vp-record-in { animation: vpRecordIn 0.22s ease-out; }
/* (the ≤900px .vp-welcome single-column twin left with the orb — the orbless
   welcome IS one column at every width: .vp-welcome--noorb, voice-portal.css) */
/* Promoted, it is the record's first block and the talk's head. No rule, no
   seam, no card: it is not a new object, it is the same greeting one beat later.
   ⛔ The 24px side padding is LOAD-BEARING and is copied from .chat-empty-state
   deliberately. The landing is measured in its door-card seat, which carries
   that padding; a head without it re-lays the same text 24px left and 48px
   wider the instant it is promoted. Measured before this line: the greeting
   moved x 734→710, w 504→552 on the press. The two seats must agree to the
   pixel or "nothing moves" is not true. */
.chat-landing-head { flex: 0 0 auto; padding: 0 24px; }
/* …and the same argument vertically. room-open gives .chat-empty-state a 12px
   top padding, which at rest sits ABOVE the landing and after the promotion
   sits above the RESCUE TRIO instead — i.e. it crosses from one side of the
   greeting to the other, and the greeting rises 12px to pay for it (measured:
   the last 14px of the press's movement). The head takes that padding over, and
   the door card gives it up for exactly as long as a head is carrying it, so
   the total mass and the greeting's offset inside it are both unchanged. */
.chat-mode-container.room-open .chat-landing-head { padding-top: 12px; }
.chat-mode-container.room-open .chat-transcript:has(.chat-landing-head) .chat-empty-state {
    padding-top: 0;
}
/* the generic promise is the fallback for a room opened without the portal */
.chat-empty-state.has-aim .ces-line,
.chat-empty-state.has-aim .ces-sub { display: none; }
/* ⛔ NOT WHERE THE LANDING IS (owner 2026-07-26, amended 2026-07-27). The TRIO
   is ruled content and KEEPS its home — it just stops appearing where it says
   nothing: it is permission before panic (#936), for a stall that has not
   started, and at rest he is reading his history, not about to freeze. (Its
   partner here, the letter's ECHO, left with the letter, S10.)
   The TRIO keeps .state-standby: it is not a duplicate of anything, it is new
   content, and "Connecting…" is still the right beat to teach the three moves
   the learner may always play.

   ⛔ visibility, NOT display, for the trio. The room at rest centres its content
   as one mass, so a trio that arrives with `display:none → flex` grows that mass
   by its own 138px and lifts everything above it by half — measured: the whole
   greeting rose 69px on the press, which is the learner's own reading matter
   moving under his eyes at the one moment we promised it would not.
   `visibility: hidden` keeps the box and spends the space up front. It costs a
   band of blank paper below the greeting at rest, on a surface whose whole
   design is an empty room, and buys a press that moves NOTHING. */
/* ⛔ AND THE TRIO GOES WITH THEM (owner, 2026-07-27). It was authored for the
   "Connecting…" beat and it is real teaching — もう一度 (mou ichido, "again"),
   ゆっくりお願いします (yukkuri onegaishimasu, "slowly, please"), 英語で？
   (eigo de?, "in English?") — but it ARRIVED ON THE PRESS: a letterspaced
   English rubric ("YOU CAN ALWAYS SAY") plus three glossed lines materialising
   in a room that had just promised nothing would change. Loud, western, and
   the wrong moment. The press shows one thing: the lamp changing.
   ⛔ NOT DELETED — the markup and its dress stay whole, and the phrases are
   owed a calmer home (a rest-state fold, or Sensei simply teaching them). This
   is a retirement from the connect beat, not from the curriculum.
   (S12c: the show-a-page line travels with the trio for the same reason and
   under the same rule — where the landing speaks, this card says nothing, and
   there Sensei carries the teaching instead.) */
.chat-mode-container.has-landing .ces-rescue,
.chat-mode-container.has-landing .ces-page-hint { display: none; }

.chat-empty-state .ces-line {
    font-family: 'Klee One', 'Noto Sans JP', serif;
    font-size: var(--rm-en);
    color: var(--color-ink);
    opacity: var(--rm-ink-full);
}
.chat-empty-state .ces-sub {
    font-size: var(--rm-support);
    line-height: 1.5;
    max-width: 32ch;
    opacity: var(--rm-ink-quiet);
}
/* The way in for a page, said once and quietly — the sub's own recipe, its own
   class (S12c). It is the portal-less fallback's copy of what Sensei says. */
.chat-empty-state .ces-page-hint {
    margin-top: 14px;
    font-size: var(--rm-support);
    line-height: 1.5;
    max-width: 34ch;
    opacity: var(--rm-ink-quiet);
}
/* Rescue phrases (1.4) — the legal moves, taught at the door before the first
   stall. Quiet reference, never a lesson: JP in ink, romaji reach in stone.
   Since the one-bar build (#936) this is the trio's ONLY home: the door card
   folds when the talk starts, so there is no mid-session reminder — the named,
   owner-gated loss of that slice. */
.chat-empty-state .ces-rescue {
    margin-top: 18px;
    display: flex;
    flex-direction: column;
    gap: 3px;
}
.cr-lead {
    font-family: 'Inter', sans-serif;
    font-size: var(--rm-caption);
    letter-spacing: var(--rm-track);
    font-weight: var(--rm-track-weight);
    text-transform: uppercase;
    color: var(--color-stone);
    opacity: var(--rm-ink-quiet);
    margin-bottom: 2px;
}
.cr-row {
    font-size: var(--rm-support);
    line-height: 1.55;
}
.cr-jp {
    font-family: 'Klee One', 'Noto Sans JP', serif;
    color: var(--color-ink);
    opacity: var(--rm-ink-full);
}
.cr-gloss {
    font-family: 'Inter', sans-serif;
    font-size: var(--rm-control);
    color: var(--color-stone);
    opacity: var(--rm-ink-quiet);
    margin-left: 2px;
}

/* (Scope line retired — owner 2026-07-23: the session aim is Sensei's
   marching order, never learner-facing copy.) */

/* ---- Messages (variable rhythm) ---- */

.chat-message {
    display: flex;
    flex-direction: column;
    /* T1.2: anchor for absolutely-positioned per-line surfaces (the replay
       mark today, the T1.5R disclosure placard next) — migrated here from
       .agent.has-replay so every message can anchor an overlay. */
    position: relative;
}

/* Adjacency air — the cross-role rhythm is the transcript's main beat.
   BUBBLE-DRESS ruling (owner 2026-07-23, superseding the same-day breathing
   pass's indent-and-labels grammar): follow the established voice/chat
   convention (claude.ai, iMessage) instead of inventing one — the learner
   speaks from a soft right-aligned bubble, Sensei speaks in plain left
   text, and that per-turn asymmetry IS the speaker identity, continuous at
   any scroll position. The vertical rules stay retired (breathing), the
   role labels stop painting (sr-only below), and the air stays 20px.
   Ghost blocks sit on the 6px baseline gap (A2). */
.chat-message.agent + .chat-message.user:not(.is-pending),
.chat-message.user + .chat-message.agent {
    margin-top: 20px;
}

/* Agent message — plain left text, the convention's assistant side. No
   rule, no wash, no indent beyond breathing padding (T1.8's ink law);
   gold never dresses a speaker (repair-only). */
.chat-message.agent {
    padding: 6px 10px;
    animation: agentFadeIn 0.4s ease;
}

/* User message — the learner's bubble (bubble-dress ruling): a quiet ink
   wash (3% since 07-25), radius 12, right-aligned via margin-left auto so short lines
   hug the scroller's 14px lane (legacy fix #2) and long lines keep the
   measure cap. Text inside stays left-aligned — the bubble sits right, the
   words do not. */
.chat-message.user {
    padding: 4px 0;
    animation: userFadeIn 0.3s ease;
}
.chat-message.user .chat-message-text {
    width: fit-content;
    margin-left: auto;
    margin-right: 14px;
    padding: 3px 14px;
    /* 3%, not 5% (2026-07-25). R3.8's faint-warm ink HOLDS AA on bare paper
       (4.78:1); it was this wash underneath that pushed the learner's own
       words to 4.35:1, under the 4.5 floor for 12.5px. Lightening the wash
       clears the floor (4.55:1) and leaves the owner-picked ink lever alone.
       The bubble dress is unchanged in kind: wash + radius + right seat. */
    background: rgba(44, 44, 44, 0.03);
    /* slice five: 14 → the room's 12, one radius fewer. It borrows the
       popover's CORNER, never its light (no pop shadow, no pop hairline: a
       wash rests). The wash above is a measured AA floor and stays as it is. */
    border-radius: var(--rm-pop-radius);
}

/* ---- WHOSE VOICE (A5, 2026-08-01) ----------------------------------------
   Share-audio puts a textbook CD into the same stream the microphone feeds, so
   the transcription filed a 本間先生 monologue as the learner's own speech.
   The record now says which it was, and the room has to say it too — the tag
   would otherwise be a truth only a JSON file knows.

   A PLAYED line is material he BROUGHT, not language he produced. It keeps his
   side of the room (he did bring it) and loses the two things that mean "he
   said this": the ink wash of his bubble, and his name. In their place it
   wears the handed-object grammar the shown-page card already owns — one
   hairline on bright card paper, `.cat-thumb`'s exact border — because a
   recording and a photographed page are the same kind of thing.
   No new hue, no new size, no new mark family. */
/* THE CARD IS THE BLOCK (rec-card, 2026-08-26): the dress moved up from the
   text span so the caption and the words bind into ONE leaf — the caption
   used to float as a stray right-aligned line over a separate box, which is
   what made the surface read unfinished. The material is unchanged and the
   material is the argument: his speech is a WASH — flat ink on the paper, no
   edge, because a spoken line is not a thing. A recording is a thing he
   carried in, so it wears exactly what the page card wears (.cat-thumb: card
   paper, one hairline, one resting shadow — T1.1's "a handed object MAY wear
   card material"). ⛔ Rests, never floats. */
.chat-message.user.played {
    width: fit-content;
    max-width: calc(100% - 14px);
    margin-left: auto;
    margin-right: 14px;
    background: var(--color-card-bright, #FEFDFB);
    border: 1px solid var(--rm-edge-strong);
    border-radius: 8px;
    padding: 6px 13px;
    box-shadow: var(--shadow-subtle, 0 1px 2px rgba(44, 44, 44, 0.03));
}
.chat-message.user.played .chat-message-text {
    background: none;
    border: none;
    border-radius: 0;
    box-shadow: none;
    padding: 0;
    margin: 0;
    width: auto;
}
/* A change of KIND entering the talk earns the same air a change of SPEAKER
   does (the 20px cross-role rule above), so a recording never looks like the
   next thing he said. */
.chat-message.user.played { margin-top: 20px; }
.chat-message.user.played + .chat-message.user:not(.played) { margin-top: 20px; }
/* The only labels in the transcript that are NOT orientation. Every other
   speaker label is sr-only, read off the seat and the dress instead — but
   provenance cannot be read off a seat he still occupies, so these are spoken.
   ONE grammar for one fact: the caption says whose voice, and the dress says
   whether the words are his. (A hairline was tried first and measured
   invisible against the bubble's own edge — a mark nobody can see is not a
   quieter mark, it is a missing one.) */
.chat-message.user.played .chat-message-role,
.chat-message.user.over-played .chat-message-role {
    position: static;
    display: block;
    width: auto;
    height: auto;
    /* 6px to its object, the same gap the page card keeps between its status
       line and the page (.cat-side) — one rhythm for both brought things. */
    margin: 0 14px 6px auto;
    padding: 0;
    overflow: visible;
    clip: auto;
    text-align: right;
    font-family: 'Inter', sans-serif;
    font-size: var(--rm-control);
    line-height: 1.5;
    color: var(--color-stone);
    /* 0.92, not 0.75: stone × 0.75 at 11px measures 3.7-4.2:1 on this paper —
       under the 4.5:1 AA floor the file has already ruled on twice at this
       size (see the 0.5 and 0.8 notes above). The line that says whose voice a
       played turn was — the whole point of A5 — may not be the least legible
       text on the surface (A10). */
    opacity: var(--rm-ink-quiet);
}
/* He talked OVER the recording: the words ARE his, so the bubble is untouched
   and only the caption changes. Nothing is re-seated. */

/* THE EYEBROW (rec-card): on a played card the caption stops floating above
   the object and becomes its own top line — same voice, same ink, now part
   of the leaf. The ♪ in front is the Share-audio family's existing mark, so
   the door that created this turn and the line that names it wear one sign
   (alt-text empty: the caption already says it in words). It is also the
   card's STANDING control — the one re-fold door on an open card, so the
   word doors inside keep every other click. */
.chat-message.user.played .chat-message-role {
    margin: 0 0 4px 0;
    text-align: left;
    cursor: pointer;
}
.chat-message.user.played .chat-message-role::before {
    content: '♪ ' / '';
}
.chat-message.user.played .chat-message-role:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 2px;
}

/* AT REST A LONG RECORDING FOLDS (rec-card): ~3 lines in the support voice,
   reading as a RECORD entry beside the live speech — a 2-minute listening
   drill takes the space of a footnote, not a monologue (T1.4's deeper rule:
   apparatus never out-masses the speech it serves). Opened, it returns to
   full reading dress: once he chooses to read it, the Japanese is what he
   came for. Voices re-scoped through the CHARTERED tokens — no sixth size
   enters the room, and --rm-line is re-derived for the support size per the
   baseline-pin law (13px in a 20px box = 1.54 leading, inside JLReq range).
   Quiet said in HUE at full strength (B9): the local --color-ink hands the
   folded card's Japanese the stone the room already owns — never a new
   alpha. */
.chat-message.user.played.rec-folded {
    --rm-jp: var(--rm-support);
    --rm-en: var(--rm-support);
    --rm-line: 20px;
    --color-ink: var(--color-stone);
    cursor: pointer;
}
.chat-message.user.played.rec-folded .chat-message-text {
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 3;
    overflow: hidden;
}
/* The dissolve edge IS the door: one ::after does both — the last line fades
   into the card paper (the fade says "more"; no counter, no caption), and
   the overlay makes the folded card itself the click target, so a folded tap
   can never land on a word door underneath. Removed the moment the card
   opens. */
.chat-message.user.played.rec-folded::after {
    content: '';
    position: absolute;
    inset: 0;
    z-index: 1;
    cursor: pointer;
    border-radius: 8px;
    background: linear-gradient(to bottom,
        transparent 55%, var(--color-card-bright, #FEFDFB) 96%);
}
/* The door affordance is the page-card door's own: the hairline warms to the
   indigo hover tone — ink changes, geometry never moves. */
.chat-message.user.played.rec-folded:hover {
    border-color: rgba(61, 90, 128, 0.5);
}

@media (prefers-reduced-motion: reduce) {
    .chat-message.user.played,
    .chat-message.user.over-played { animation: none; }
}

@keyframes agentFadeIn {
    from {
        opacity: 0;
        transform: translateY(3px) translateX(-4px);
    }
    to {
        opacity: 1;
        transform: translateY(0) translateX(0);
    }
}

@keyframes userFadeIn {
    from {
        opacity: 0;
        transform: translateY(3px) translateX(4px);
    }
    to {
        opacity: 1;
        transform: translateY(0) translateX(0);
    }
}

/* ---- Message Text ---- */

/* T1.3 type-barbell-two-voices: one line, two voices. English speech rests
   in Inter stone at the base size; Japanese runs wear Klee at full ink
   INSIDE the same line (.jp-run/.jp-wide rules at the Tier 1 block below).
   Px line-heights pin the baseline grid so mixed sizes never shift turn
   heights.
   BOTH SPEAKERS NOW SHARE ONE SIZE AND ONE BOX (2026-07-25 type scale). The
   learner used to speak 1px smaller in a 27px box — a size difference nobody
   can see, doing the work of saying whose turn it is, which the bubble dress
   already says outright. In a room whose whole purpose is getting HIM to
   speak, his words are not the junior voice. (R3.8's ink lever is untouched:
   his English stays faint-warm by owner ruling. That is colour, not size.) */
.chat-message-text {
    font-family: 'Inter', 'Noto Sans JP', sans-serif;
    font-size: var(--rm-en);
    line-height: var(--rm-line);
    color: var(--rm-speech-ink);   /* slice one: Sensei's English is content, in warm ink */
    word-break: break-word;
    /* 禁則 kinsoku — a wrapped Japanese line never OPENS with a small kana or a
       mark that belongs to the character before it (ゃゅょっー、。」). At the
       default `line-break: auto` Chrome resolves small kana to ideographic and
       a break before ゃ is legal, so 「じゃあ」 wrapped as じ / ゃあ in the
       owner's 2026-08-01 screenshot. `line-break` is the ONLY property that
       carries this tailoring — word-break and overflow-wrap do not touch CJK.
       House precedent: css/study-walk.css. Costs no lines: strict changes
       where a break may fall, never how many there are. */
    line-break: strict;
}

.chat-message.user .chat-message-text {
    font-size: var(--rm-en);
    line-height: var(--rm-line);
}

/* Reading fetch failed: a pale tappable hairline stands where the reading
   would be — the helper may never fail silently over raw kanji. DOM-only
   (outside sessionTurns), so Copy transcript never carries it. Sits in the
   reading line's seat: if the annotation gutter (2.9) indents the
   .chat-reading/.chat-en/.chat-gloss decks, indent this with them. */
.chat-reading-retry {
    display: block;
    margin-top: 4px;
    padding: 0;
    background: none;
    border: none;
    font-family: 'Inter', sans-serif;
    font-size: var(--rm-control);
    line-height: 1.5;
    font-style: italic;
    color: var(--color-stone);
    opacity: var(--rm-ink-quiet);
    cursor: pointer;
    text-align: left;
}
.chat-reading-retry:hover,
.chat-reading-retry:focus-visible { opacity: var(--rm-ink-full); }

/* The unpack card: the full pack for one phrase — kana, romaji, meaning, and
   the corpus-known words inside. Paper on paper, one hairline; content is the
   decoration. */
/* T1.7 mat-object-frames-hug: the placard is exactly as big as what it
   says — fit-content, hard-capped at the deck measure (318px + 28px padding
   + border = 352px), min-width guarding the narrow diff case. Material:
   card-bright SOLID (its documented role — a translucent wash would sit on
   T1.1's paper), one hairline, the subtle shadow. */
.chat-unpack {
    margin-top: 8px;
    padding: 10px 14px 11px;
    width: fit-content;
    /* Both bounds yield to the column (--cu-available-width, set by
       anchorPlacard). A card wider than the column overflows the transcript
       horizontally, and since `overflow-y: auto` computes overflow-x to auto,
       the browser answers with a 15px horizontal scrollbar that eats 15px of
       clientHeight — which the bottom-anchored record then absorbs as a jump.
       The min-width yields too: on a narrow room, 240px of frame is not worth
       a scrollbar. */
    min-width: min(240px, var(--cu-available-width, 240px));
    max-width: min(352px, var(--cu-available-width, 352px));
    max-height: min(72vh, 560px, var(--cu-available-height, 560px));
    overflow-y: auto;
    overscroll-behavior: contain;
    scrollbar-width: thin;
    background: var(--color-card-bright, #FEFDFB);
    border: 1px solid var(--rm-pop-line);
    border-radius: var(--rm-pop-radius);
    /* ⚖️ AMENDED 2026-07-28 (the popover spec). This carried --shadow-rest, the
       house's RESTING shadow — chosen in the 2026-07-25 pass over an even
       fainter --shadow-subtle, which was the right call between those two and
       still the wrong family. The placard does not rest: it is an abspos
       overlay hanging beside a line of the record, i.e. the same object as the
       bar's slips and the row menu. It lights like them now.
       ⛔ .cu-docked overrides all of this back to --shadow-rest, and that is
       correct — docked, the card really is sitting in the rail. */
    box-shadow: var(--rm-pop-shadow);
    animation: unpackIn 0.22s ease;
}
.chat-unpack::-webkit-scrollbar {
    width: 3px;
}
.chat-unpack::-webkit-scrollbar-thumb {
    background: rgba(44, 44, 44, 0.08);
    border-radius: 3px;
}
@keyframes unpackIn {
    from { opacity: 0; transform: translateY(-2px); }
    to { opacity: 1; transform: none; }
}
.chat-unpack .cu-surface {
    font-family: 'Klee One', 'Noto Sans JP', sans-serif;
    font-size: var(--rm-jp);
    line-height: 1.6;
    color: var(--color-jp-text);   /* slice one: the house's Japanese ink */
}
/* (.cu-kana DELETED 2026-08-06, B9. Nothing has ever emitted it — the placard's
   fragment builder mints cu-surface / cu-romaji / cu-en, and no `cu-` class in
   this room is ever built by concatenation, so that check is exact rather than a
   guess. Found by the ink ladder's gate-hygiene pass: a dead rule only surfaces
   when something is finally forced to make a decision about its value.) */
.chat-unpack .cu-romaji {
    font-family: 'Inter', sans-serif;
    font-size: var(--rm-support);
    font-style: italic;
    color: var(--color-romaji);
}
.chat-unpack .cu-en {
    font-family: 'Inter', sans-serif;
    font-size: var(--rm-support);
    color: var(--color-ink);
    opacity: var(--rm-ink-quiet);
    margin-top: 3px;
}
.chat-unpack .cu-words {
    margin-top: 8px;
    padding-top: 6px;
    border-top: 1px solid var(--rm-edge);
    display: flex;
    flex-direction: column;
    gap: 1px;
}
.chat-unpack .cu-w {
    display: block;
    margin: 0 -6px;
    padding: 4px 6px;
    border-radius: 6px;
    align-items: baseline;
    font-size: var(--rm-support);
    line-height: 1.55;
}
.chat-unpack .cu-w-main,
.chat-unpack button.cu-w-summary {
    display: grid;
    grid-template-columns: minmax(64px, auto) minmax(76px, auto) minmax(0, 1fr) 16px;
    align-items: baseline;
    column-gap: 12px;
    min-width: 0;
}
.chat-unpack .cu-w-jp {
    font-family: 'Klee One', 'Noto Sans JP', sans-serif;
    font-size: var(--rm-support);
    color: var(--color-jp-text);   /* slice one: the house's Japanese ink */
}
.chat-unpack .cu-w-read {
    font-family: 'Inter', sans-serif;
    font-style: italic;
    color: var(--color-romaji);
}
.chat-unpack .cu-w-gloss {
    font-family: 'Inter', sans-serif;
    color: var(--color-stone);
    min-width: 0;
}
.chat-unpack .cu-w:not(.cu-w-actionable) .cu-w-gloss {
    grid-column: 3 / 5;
}
.chat-unpack .cu-w-actionable {
    transition: background 0.15s ease;
}
.chat-unpack .cu-w-actionable:hover,
.chat-unpack .cu-w-actionable:focus-within,
.chat-unpack .cu-w-actionable.actions-open {
    background: color-mix(in srgb, var(--color-accent) 4%, transparent);
}
.chat-unpack button.cu-w-summary,
.chat-unpack button.cu-w-summary:hover,
.chat-unpack button.cu-w-summary:active {
    width: 100%;
    margin: 0;
    padding: 0;
    border: 0;
    border-radius: 3px;
    background: none;
    box-shadow: none;
    transform: none;
    font: inherit;
    line-height: inherit;
    color: inherit;
    text-align: left;
    cursor: pointer;
}
.chat-unpack button.cu-w-summary:focus-visible {
    outline: 2px solid color-mix(in srgb, var(--color-accent) 72%, transparent);
    outline-offset: 2px;
}
.chat-unpack .cu-w-caret {
    align-self: center;
    justify-self: end;
    width: 16px;
    font-family: 'Inter', sans-serif;
    font-size: 16px;
    line-height: 1;
    text-align: center;
    color: var(--color-stone);
    opacity: var(--rm-ink-mark);
    transform-origin: center;
    transition: color 0.15s ease, opacity 0.15s ease, transform 0.15s ease;
}
.chat-unpack .cu-w-actionable:hover .cu-w-caret,
.chat-unpack button.cu-w-summary:focus-visible .cu-w-caret,
.chat-unpack .cu-w-actionable.actions-open .cu-w-caret {
    color: var(--color-accent);
    opacity: var(--rm-ink-full);
}
.chat-unpack .cu-w-actionable.actions-open .cu-w-caret {
    transform: rotate(90deg);
}
.chat-unpack .cu-word-actions {
    margin-top: 5px;
    padding: 8px 0 2px;
    border-top: 1px solid var(--rm-edge);
}
.chat-unpack .cu-word-actions[hidden] {
    display: none;
}
@media (hover: none), (pointer: coarse) {
    .chat-unpack button.cu-w-summary {
        min-height: 44px;
    }
    .chat-unpack button.cu-verb {
        min-height: 44px;
    }
    /* (the caret's coarse-pointer lift to 0.45 LEFT here 2026-08-06, B9. It
       existed because a touch device has no hover to reveal a 0.28 mark — but
       0.45 measures 1.89:1, still under WCAG 1.4.11's 3:1, so it was half a
       fix. The rest state is the mark rung now (3.41:1) on every pointer, which
       is what the lift was reaching for; keeping it would make the caret
       LIGHTER on iPad than on a mouse, backwards on a primary device.) */
}

/* ⛔ THE cu-keep ANATOMY IS DELETED (2026-07-28). Eleven selectors dressing a
   `.cu-keep` row inside the placard — a row NO JS has written since the R3.3
   ruling replaced it with the named .cu-verb rows. Its own comment admitted as
   much and then kept it anyway, "for the historical elevation screenshot
   harness". A screenshot harness is not a reason to dress a surface the product
   cannot render: nothing it shot can be re-shot, because nothing emits the
   markup. One of the eleven was NOT dead — the kept dot — and it was hiding in
   a shared declaration block with a dead sibling, which is exactly how a live
   rule gets deleted by the next person to read this. It now stands on its own
   beside the .jp-run rules it belongs to.
   (残す itself is alive and well; it is a JP-RUN door in the record now, not a
   row in the placard.) */

/* Per-line replay (2.3 → T1.5R): the resting "listen" mark and its 58px
   reserved padding DIED with the second ruling (kill row 21) — the
   listen → slower → stop ladder rides the hover placard of any door on a
   Sensei range whose audio is still buffered (.cu-listen in the Tier 1
   block). Replay mechanics (buffer, seal, evict, barge-in yield, own
   element — law #747) are untouched. */

/* The kintsugi repair seam (2.8) is RETIRED (owner, 2026-08-01). It underlined
   one word of Sensei's line in gold whenever a kana edit-distance heuristic
   guessed she had just recast the learner's slip. Two findings killed it,
   measured by replaying the live detector over all 46 captured sessions:
   it fired 322 times (1 in 4 eligible replies) and under 1 in 10 was a real
   recast — 寒い→寒いです, します→仕事, 130 on lines opening "Yes, that's
   correct". And because the seam was a BUTTON spliced into the middle of a
   run, it cut that clause's hover door into fragments 66% of the time, so
   the learner asking for the sentence's meaning got half a sentence
   translated alone, while the gold word's own card carried no English at
   all. Corrections keep their honest home: the letter's correction block,
   authored after the session by a Teacher that read the whole night. */

@media (prefers-reduced-motion: reduce) {
    .chat-unpack { animation: none; }
    .chat-message-text .jp-run[role="button"] { transition: none; }
}

/* ---- Role Label — sr-only ALWAYS (bubble-dress ruling, owner 2026-07-23):
   the speaker reads from the dress (learner bubble right / Sensei plain
   left), so no label paints. The labels stay in the DOM for assistive tech,
   and the orientation-moment emission rhythm (T1.10) keeps deciding which
   carry .role-hidden — a class distinction that no longer changes paint. */

.chat-message-role {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
}

/* T1.10 — suppressed labels are SR-ONLY (kept for assistive tech), never
   display:none: identity must survive for a screen reader even when the
   eye reads it from the hairline grammar. */
.chat-message-role.role-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

/* ---- Screen reader only ---- */

.sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

/* ---- Hide app chrome when voice overlay is active ---- */

body.chat-mode .hero-top-bar,
body.chat-mode .app-branding-topleft,
body.chat-mode .grid-filter-bar,
body.chat-mode .grid-search-bar,
body.chat-mode .nav-tools-row,
body.chat-mode .intro-mode-indicator {
    display: none !important;
}

/* ---- Desktop Only ---- */

@media (max-width: 768px) {
    .chat-mode-overlay,
    /* body-level, so the overlay's hide cannot reach them — every body-level
       room surface leaves with the room, or it stands over a vanished room as
       a locked door. A10 took the page viewer; A7 finishes the roster: the
       letter panel, the recovery family (the portal's delete ask rides the
       same .chat-recovery classes) and the save-failed banner (inline-styled,
       so this is its only stylesheet word — display wins because its cssText
       never sets one). */
    .cat-viewer,
    .chat-thread-panel,
    .chat-recovery,
    .chat-recovery-scrim,
    #chatSaveFailed {
        display: none !important;
    }
}

/* ⛔ NO "NARROWER DESKTOPS" OVERRIDE (deleted 2026-07-28). It read
   `@media (min-width: 769px) and (max-width: 1024px) { .chat-mode-container {
   max-width: 100%; padding: 20px 24px 16px } }` — written when the room was a
   floating overlay that needed to earn back its side air on a small laptop.
   It outlived that: it sits LATER in this file than the 640px cap, at the same
   specificity, so on every viewport in the band it simply won, and the room ran
   the full width of its stage against the owner's own ⛔ ruling below
   ("THE ROOM IS 640px WIDE. ALWAYS"). Measured on the iPad's 820×1180 with the
   rail put away: container 820px wide, i.e. the reading measure the whole room
   is composed around, gone. Deleting it is not a new decision — it is the 2026
   -07-27 ruling finally reaching the viewport where the owner reads. */

/* ---- Reduced Motion ---- */

@media (prefers-reduced-motion: reduce) {
    .chat-mode-overlay {
        transition: opacity 0.3s ease;
        clip-path: none;
    }
    .chat-mode-overlay.visible {
        clip-path: none;
    }
    /* the portal-content rule above outranks the one two lines up (it carries
       body.voice-portal-open), so reduced motion has to say it again there */
    body.voice-portal-open .chat-mode-overlay {
        transition: opacity 0.3s ease;
    }
    .chat-message,
    .chat-message.agent,
    .chat-message.user {
        animation: none;
    }
    .voice-viz {
        animation: none;
        opacity: 1;
    }
    /* A still lamp: state reads through the glow's hue and depth alone. The
       breath and the voice's scale both stop; the light does not, because it
       is what says whose turn it is and reduced motion is not reduced meaning.
       ⛔ The end state must LAND (reference_reduced_motion_must_land): killing
       the animation over an `--orb-lift: 0` base would leave an unlit lamp
       forever, which is the exact class of bug that once blanked mobile search. */
    .voice-viz-orb {
        animation: none !important;
        --orb-lift: 0.18;
    }
    /* ⛔ REDUCED MOTION IS NOT REDUCED MEANING. Killing the animations left one
       flat 0.18 for every state, so the one thing the lamp exists to say —
       finding the line vs. connected — stopped being said at all to the people
       who asked for less movement. The breath goes; the DIFFERENCE stays, as
       two held levels the glow's own transition crossfades between. */
    .chat-mode-container.state-connecting .voice-viz-orb { --orb-lift: 0.05; }
    .chat-mode-container.state-ready .voice-viz-orb,
    .chat-mode-container.state-connected .voice-viz-orb { --orb-lift: 0.24; }
    .voice-viz-orb .vo-ball {
        transform: none !important;
        transition: filter 0.4s ease;
    }
    .voice-viz-orb .vo-glow {
        transform: scale(1.18);
        transition: opacity 0.4s ease, background 0.4s ease;
    }
    .voice-viz-orb .vo-aura { display: none; }
    /* the status line keeps its meaning under reduced motion — only the
       crossfade goes. ⛔ Never a flat `opacity: .5` here: that pins it ON, and
       a permanent "Connecting…" is a lie the moment the room connects. */
    .voice-viz-label {
        transition: none;
    }
    .chat-transcript {
        animation: none;
        opacity: 1;
        scroll-behavior: auto;
    }
    /* the quiet ask lands its drawn-back light with no breath */
    .chat-mode-container.state-idle-ask .voice-viz-orb {
        animation: none;
        --orb-lift: 0.13;
    }
    body.chat-mode .grid-container,
    body.chat-mode .flashcard-container,
    body.chat-mode .bottom-action-bar,
    body.chat-mode .premium-progress-wrapper {
        filter: none;
        opacity: 1;
        transition: none;
    }
}

/* ==========================================
   Bar controls (share door + slip furniture)
   ========================================== */

/* (The 調 gear, its settings sheet, and the "?" rescue pop retired with the
   one-bar build — slice 4, the owner's razor: every control earns a bar seat
   or becomes a default. The rescue trio's ONE surface is the door card.) */

/* (The header pause pill died with the dock — the hold is the room's central
   control now: .chat-dock-hold, in the 一枚の盤 section at the end of this file.) */

/* Share audio — the labeled door for computer-played sound (listening exercises),
   re-homed from the header to the bar's left cluster. It KEEPS its words: Sensei's
   own prompt says "press Share audio", so the words must exist on screen. While
   the channel is open a small accent dot breathes in place of the glyph: a live
   tap into the machine's audio must never be a silent state. */
.chat-share-btn {
    background: none;
    border: none;
    font-family: 'Inter', system-ui, sans-serif;
    font-size: var(--rm-control);
    color: var(--color-stone);
    cursor: pointer;
    padding: 0 12px;
    line-height: 1;
    transition: color 0.2s, opacity 0.2s, background 0.2s, border-color 0.2s;
    flex-shrink: 0;
    /* 0.92, matching the chip beside it (2026-07-25). At 0.6 this measured
       ~2.8:1 on the bar's paper — a hard AA failure on 12.5px text, and this
       text is load-bearing INSTRUCTION: Sensei's own prompt tells the learner
       to "press Share audio" by name. The chip 40px away had already been
       measured and raised to 0.92 for exactly this reason; its sibling never
       got the pass. Same paper, same size, same answer. */
    opacity: var(--rm-ink-quiet);
    display: inline-flex;
    align-items: center;
    gap: 5px;
    position: relative;
}
.chat-share-btn[hidden] { display: none; }
/* 34 → 44 tall (the touch floor above); the width already clears it, and a
   sideways expander would eat into the cluster's 5px pill rhythm. */
.chat-share-btn::after {
    content: '';
    position: absolute;
    inset: -5px 0;
}
.chat-share-btn:hover { color: var(--color-accent); opacity: var(--rm-ink-full); }
.chat-share-btn:active { transform: scale(0.92); }
/* The one bar hand that had no ring of its own and fell through to the app's
   4px halo — the seam S2 closed everywhere else on this surface. */
.chat-share-btn:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 2px;
}
.chat-share-btn.sharing { color: var(--color-accent); opacity: var(--rm-ink-full); }
.chat-share-dot {
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: var(--color-accent);
    animation: chatSharePulse 2.4s ease-in-out infinite;
}
@keyframes chatSharePulse { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .chat-share-dot { animation: none; } }

/* The chips' paper slips (one-bar build) — small frosted-paper pops rising
   from the bar itself: children of #chatDock, anchored above its top edge,
   right-aligned with the picker cluster that opens them, so they track the
   bar on any viewport. Same [hidden] discipline as every room surface: the
   author `display` would beat the UA rule, so it's restored explicitly. */
.chat-pop {
    position: absolute;
    right: 0;
    bottom: calc(100% + 8px);
    z-index: 5001;
    box-sizing: border-box;
    background: rgba(250, 250, 247, 0.96);
    -webkit-backdrop-filter: blur(14px) saturate(1.04);
    backdrop-filter: blur(14px) saturate(1.04);
    border: 1px solid var(--rm-pop-line);
    border-radius: var(--rm-pop-radius);
    box-shadow: var(--rm-pop-shadow);   /* these three values ARE the spec — this slip is where they were set */
    padding: 14px 16px;
    display: flex;
    flex-direction: column;
    animation: chatPopRise 0.22s cubic-bezier(0.22, 1, 0.36, 1);
}
.chat-pop[hidden] { display: none; }

@keyframes chatPopRise {
    from { opacity: 0; transform: translateY(6px); }
    to { opacity: 1; transform: translateY(0); }
}

@media (prefers-reduced-motion: reduce) {
    .chat-pop { animation: none; }
}

/* Each slip names its subject in one word — the chip shows the VALUE ("leo",
   "0.85×"), the slip says what that value governs. */
.chat-pop-title {
    font-family: 'Inter', sans-serif;
    font-size: var(--rm-caption);
    letter-spacing: var(--rm-track);
    font-weight: var(--rm-track-weight);
    text-transform: uppercase;
    color: var(--color-stone);
    opacity: var(--rm-ink-quiet);
    padding: 0 9px;
    margin-bottom: 8px;
}

/* Voice slip — a plain list, the saved voice marked. The hint line beneath is
   the honest provider lock ("new voice starts your next session"), written by
   JS only while a session is live; :empty keeps its seat from costing height. */
.chat-voice-pop { min-width: 150px; padding: 12px 10px 10px; gap: 1px; }
.chat-voice-pop .cvp-rows { display: flex; flex-direction: column; gap: 1px; }
.cvp-row {
    display: flex;
    align-items: center;
    gap: 8px;
    border: none;
    background: none;
    font-family: 'Inter', sans-serif;
    font-size: var(--rm-support);
    color: var(--color-stone);
    text-align: left;
    padding: 7px 9px 7px 26px;
    border-radius: 7px;
    cursor: pointer;
    position: relative;
    transition: color 0.18s, background 0.18s;
}
.cvp-row:hover { color: var(--color-ink); background: rgba(44, 44, 44, 0.03); }
/* Polish · the voice sample key beside each name: quiet stone, ink on hover,
   the accent while it plays (and its mark turns to stop). It seats 2px after
   the NAME, not at the slip's right edge, so it reads as that voice's key and
   not as a disclosure chevron for the row: the row shrinks to its text. The
   mark is a stroke (index.html), so the key sets no type of its own. */
.cvp-line { display: flex; align-items: center; gap: 2px; }
.cvp-line .cvp-row { flex: 0 1 auto; padding-right: 0; }
.cvp-play {
    flex: 0 0 auto;
    display: inline-flex;
    align-items: center;
    border: none;
    background: none;
    color: var(--color-stone);
    opacity: var(--rm-ink-quiet);
    padding: 6px 8px;
    border-radius: 7px;
    cursor: pointer;
    transition: color 0.18s, background 0.18s;
}
.cvp-play:hover { color: var(--color-ink); background: rgba(44, 44, 44, 0.03); opacity: 1; }
.cvp-play.is-playing { color: var(--color-accent); opacity: 1; }
/* While a conversation is live or paused the key refuses the way the share
   door does before Sensei is there: dimmed, no pointer, no answer. */
.cvp-play:disabled {
    opacity: 0.34;
    cursor: default;
    pointer-events: none;
}
.cvp-row.current {
    color: var(--color-accent);
    background: rgba(61, 90, 128, 0.06);
    font-weight: 600;
}
.cvp-row.current::before {
    content: '\2713';
    position: absolute;
    left: 9px;
    font-size: var(--rm-control);
    font-weight: 400;
}
.chat-pop-hint {
    font-family: 'Inter', sans-serif;
    font-size: var(--rm-control);
    color: var(--color-stone);
    opacity: var(--rm-ink-quiet);
    line-height: 1.35;
    margin-top: 7px;
    padding: 0 9px;
}
.chat-pop-hint:empty { display: none; }

/* Tempo slip — Speed + Patience together (both are how the conversation
   breathes; both push live via session.update on release). */
.chat-tempo-pop { width: min(280px, calc(100vw - 96px)); gap: 18px; padding: 18px 20px; }
.chat-tempo-pop .chat-pop-title { padding: 0; margin-bottom: -8px; }

.vs-row {
    display: flex;
    flex-direction: column;
    gap: 7px;
}

.vs-label {
    font-family: 'Inter', sans-serif;
    font-size: var(--rm-support);
    color: var(--color-ink);
    letter-spacing: 0.01em;
}

.vs-label em {
    font-style: normal;
    color: var(--color-accent);
    font-variant-numeric: tabular-nums;
    margin-left: 4px;
}

.vs-row input[type="range"] {
    -webkit-appearance: none;
    appearance: none;
    width: 100%;
    height: 2px;
    border-radius: 2px;
    background: rgba(44, 44, 44, 0.12);
    cursor: pointer;
}

/* The thumb answers in INK, never in size. It used to swell 12% under the
   press — the one control in the room that grew when every other one squashes
   or holds — and its hover grew the halo 4 → 6px. Now the ring keeps its 4px
   and only deepens (0.08 → 0.14), and a press darkens the dot to the app's
   pressed indigo (#34507a, base.css's retry-button press). The keyboard ring
   is the room's own 2px accent outline, drawn once on the input rather than
   the browser's default on the thumb. The Firefox thumb wears every state the
   WebKit one does; the two pseudos cannot share one selector list (an unknown
   pseudo voids the whole rule), so each is spelled out. */
.vs-row input[type="range"]:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 3px;
}

.vs-row input[type="range"]::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    width: 13px;
    height: 13px;
    border-radius: 50%;
    background: var(--color-accent);
    box-shadow: 0 0 0 4px rgba(61, 90, 128, 0.08);
    outline: none;
    cursor: pointer;
    transition: background-color 0.12s, box-shadow 0.2s;
}

.vs-row input[type="range"]::-webkit-slider-thumb:hover {
    box-shadow: 0 0 0 4px rgba(61, 90, 128, 0.14);
}

.vs-row input[type="range"]::-webkit-slider-thumb:active {
    background: #34507a;
}

.vs-row input[type="range"]::-moz-range-thumb {
    width: 13px;
    height: 13px;
    border: none;
    border-radius: 50%;
    background: var(--color-accent);
    box-shadow: 0 0 0 4px rgba(61, 90, 128, 0.08);
    outline: none;
    cursor: pointer;
    transition: background-color 0.12s, box-shadow 0.2s;
}

.vs-row input[type="range"]::-moz-range-thumb:hover {
    box-shadow: 0 0 0 4px rgba(61, 90, 128, 0.14);
}

.vs-row input[type="range"]::-moz-range-thumb:active {
    background: #34507a;
}

/* The thumb's ink change lands at once under reduced motion. */
@media (prefers-reduced-motion: reduce) {
    .vs-row input[type="range"]::-webkit-slider-thumb { transition: none; }
    .vs-row input[type="range"]::-moz-range-thumb { transition: none; }
}

/* ==========================================
   End-of-session / recovery card
   ========================================== */

/* S2: these FOUR surfaces are appended to document.body, so they centre on the
   VIEWPORT, not on the room. With the session rail out, the end-of-session card
   would land half the rail's width left of the room it belongs to, and its scrim
   would dim the rail. They follow the same inset the room does.
   ⛔ EVERY NEW BODY-LEVEL SURFACE OWES THIS LIST A LINE (A10): the #1072 page
   viewer shipped without one, so a page opened 122px off the axis of the thumb
   he tapped, over the rail — the exact bug this block was written to end. */
body.voice-portal-open .chat-recovery { left: calc(50% + var(--vp-rail-w, 0px) / 2); }
body.voice-portal-open .chat-recovery-scrim { left: var(--vp-rail-w, 0px); }
body.voice-portal-open .cat-viewer { left: var(--vp-rail-w, 0px); }
/* ⛔ …EXCEPT THE DELETE ASK (A6), which is ABOUT a rail row, not about the
   room: the inset above left the rail — the very surface the ask came from —
   unveiled and pressable under a destructive confirm (caught by the suite's
   scrim probe, 2026-08-04: scrim x started at the rail's edge). Its veil
   covers the whole viewport and its card centres on the true middle. */
body.voice-portal-open .chat-recovery.is-delete { left: 50%; }
body.voice-portal-open .chat-recovery-scrim.is-delete { left: 0; }
/* …and the page it fits must measure the veil it is centred in, not the window
   — a wide page otherwise paints out over the rail the veil no longer covers. */
body.voice-portal-open .cat-viewer .cat-panel-page {
    max-width: calc(100vw - var(--vp-rail-w, 0px) - 70px);
}
/* the save-failed bar centres on the room, not the window, when the rail is out.
   (B4 dropped the `!important` this carried: it was fighting the bar's own
   inline cssText, and the bar has a stylesheet now — (1,1,1) over (1,0,0) is
   all it ever needed.) */
body.voice-portal-open #chatSaveFailed { left: calc(50% + var(--vp-rail-w, 0px) / 2); }
/* ⛔ …AND THEY TRAVEL WITH THE COLUMN (slice four, 2026-09-17). The stage's
   grid track is animated now (.vp-shell, css/voice-portal.css), so a surface
   that reads --vp-rail-w raw arrives a third of a second before the room it
   belongs to unless it rides the same curve. Same duration, same easing, one
   gesture — the letter panel's own `left 0.34s` has had this partner in its
   comment since S12. The page cap rides its own property. A LINE HERE IS OWED
   BY EVERY NEW MEMBER OF THE LIST ABOVE. */
body.voice-portal-open .chat-recovery,
body.voice-portal-open .chat-recovery-scrim,
body.voice-portal-open .cat-viewer,
body.voice-portal-open #chatSaveFailed { transition: left 0.34s var(--ease-settle, ease); }
body.voice-portal-open .cat-viewer .cat-panel-page { transition: max-width 0.34s var(--ease-settle, ease); }
/* …and their reduce companions, at the scope their live rules wear: the
   `.chat-recovery-scrim { transition: none }` further down this file is one
   class weaker and could never win here. The fold lands, it does not travel. */
@media (prefers-reduced-motion: reduce) {
    body.voice-portal-open .chat-recovery,
    body.voice-portal-open .chat-recovery-scrim,
    body.voice-portal-open .cat-viewer,
    body.voice-portal-open #chatSaveFailed,
    body.voice-portal-open .cat-viewer .cat-panel-page { transition: none; }
}

.chat-recovery {
    position: fixed;
    left: 50%;
    top: 44%;
    transform: translate(-50%, -50%);
    /* ⛔ ABOVE EVERYTHING IT IS ABOUT (2026-07-27). This was 9000 — written when
       the room was z 5000 inside #app and 9000 genuinely cleared it. Two moves
       since then left it buried and NOBODY SAW, because a card that is painted
       under an opaque room is not a visible failure, it is a card that "did not
       appear": the room went body-level at 9550 (2026-07-26), and the portal at
       9600 now CONTAINS it. Measured with elementFromPoint at the card's own
       centre — the answer came back `chat-landing-head`, i.e. the room.
       That is every card this file raises: Leaving?, Session ended, Connection
       lost, Microphone blocked, and the mid-lesson switch ask. A decision ABOUT
       the session must outrank every surface the session is drawn on, so it
       sits one rung above the top of the room's own ladder
       (…9600 portal rail · 9601 handle · 9610 letter panel · 9612 page
       viewer · THIS).
       ⛔ Do not "fix" a future burial by nudging this number — check the whole
       ladder in that note, and remember z-index is local to its stacking
       context, so a container moving is what usually breaks it. */
    z-index: 9620;
    width: calc(100% - 48px);
    max-width: 340px;
    box-sizing: border-box;
    /* T1.1: token and fallback finally agree — the card is card material on
       the paper room (never paper-on-card, the audit's inverted figure/ground). */
    background: var(--color-card, #FAFAF7);
    border: 1px solid rgba(44, 44, 44, 0.08);
    border-radius: 12px;
    /* S15: furniture SITTING on the page wears --shadow-rest by the written
       ruling at the top of this file — the private third shadow this rule
       carried (0 18px 50px, claiming "token law") was exactly the drift that
       ruling forbids. The scrim already separates the card; the rest shadow
       is all the lift a resting sheet earns. */
    box-shadow: var(--shadow-rest, 0 1px 2px rgba(44, 44, 44, 0.03), 0 8px 28px rgba(44, 44, 44, 0.045));
    padding: 22px 24px 20px;
    text-align: center;
    font-family: 'Inter', system-ui, sans-serif;
    /* Own keyframes (the card used to borrow the settings wall's vsPanelIn,
       whose bare transform stomped the centering translate mid-entrance):
       these carry the -50%,-50% so the card rises in place. */
    animation: recoveryIn 0.32s cubic-bezier(0.22, 1, 0.36, 1);
}

@keyframes recoveryIn {
    from { opacity: 0; transform: translate(-50%, -50%) translateY(8px); }
    to { opacity: 1; transform: translate(-50%, -50%); }
}

@media (prefers-reduced-motion: reduce) {
    .chat-recovery { animation: none; }
}

/* A dropped session heals on reconnect — the kintsugi-gold hairline is the recovery cue. */
.chat-recovery.is-error { border-color: rgba(201, 169, 110, 0.55); }

/* (family/weight live in the one late rule — the Klee declaration that sat
   here lost the cascade to it for a year and read as a lie; S15 removed it) */
.chat-recovery-title {
    font-size: var(--rm-en);
    color: var(--color-ink);
    margin-bottom: 6px;
}

.chat-recovery-sub {
    font-size: var(--rm-support);
    color: var(--color-stone);
    line-height: 1.5;
    margin-bottom: 16px;
}
/* S8 · the receipt's one quiet line — the sub's ink, one step smaller. It sits
   UNDER THE DOORS now (slice four), the card's last line: the money is the
   footnote, not something standing between the letter promise and the way out.
   The -10px that tucked it against the sub went with the move; as the last
   child its own bottom margin would only pad the card's foot twice. The
   min-height holds the seat while the save is still answering (the figure
   lands IN the line, it does not grow the card under him). */
.chat-recovery-actions + .chat-recovery-cost { margin-top: 14px; }
.chat-recovery-cost {
    font-size: var(--rm-control);
    color: var(--color-stone);
    opacity: var(--rm-ink-quiet);
    line-height: 1.5;
    min-height: calc(var(--rm-control) * 1.5);
    margin-bottom: 0;
}

.chat-recovery-actions {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    justify-content: center;
}

.chat-recovery-btn {
    flex: 0 0 auto;
    border: 1px solid rgba(44, 44, 44, 0.18);
    background: transparent;   /* paper doors on a paper card — the #fff read as chrome */
    color: var(--color-ink);
    border-radius: 8px;
    min-height: 44px;   /* the honest touch floor — iPad is a primary device */
    padding: 10px 16px;
    font-size: var(--rm-support);
    font-family: inherit;
    cursor: pointer;
    transition: border-color 0.2s, background 0.2s, transform 0.1s;
}

.chat-recovery-btn:hover { border-color: var(--color-accent); }
.chat-recovery-btn:active { transform: scale(0.97); }

.chat-recovery-btn.primary {
    background: var(--color-accent);
    border-color: var(--color-accent);
    color: #fff;
}
.chat-recovery-btn.primary:hover { filter: brightness(1.06); }

/* ==========================================================================
   THE SAVE-FAILURE BAR (B4, 2026-08-10) — the last surface in this room that
   still looked like a different app.

   It was built entirely from inline strings in showSaveFailed(): four font
   sizes off the room's four-voice scale (14 / 13 / 12 / 12.5), gold spelled as
   a raw hex and painted on FOUR button borders (gold means "a repair", not
   "press me"), `background:#fff` on the doors — the exact value this file
   deleted from .chat-recovery-btn with the note "the #fff read as chrome" —
   31px targets against the room's 44px floor, and a private cast shadow in
   rgba(44,42,40), an ink that exists nowhere else in the product.

   ⛔ IT DOES NOT WEAR THE RECOVERY CARD'S SEAT, only its MATERIAL. The card is
   fixed at top:46%, and the event that raises this bar has, moments earlier,
   raised a card at that exact seat: sayGoodbye → persistSession (the POST) →
   settleFarewell, all one synchronous turn, with the answer landing seconds
   later. Adopting the seat as well as the dress would drop this notice on top
   of おつかれさまでした and hide the ending of the learner's own lesson behind
   a technical message. Measured at 1440×913: card bottom 570, this bar top
   ~643. They do not meet.

   ⛔ AND IT MAY ARRIVE WITH NO ROOM AT ALL. exitChatMode fires the same save,
   so the ✕ path answers over a closed room — which is why the seat below
   clears the bar only WHEN the bar is there, and why this id is in the type
   scale's scope list up at :121. */
#chatSaveFailed {
    position: fixed;
    left: 50%;
    bottom: 24px;
    transform: translateX(-50%);
    /* one rung over the card family, so a notice about the record is never
       painted under the card discussing the same night. Registered in
       tests/voice/zindex_ladder_test.js: …9619 scrim · 9620 card · 9621 this. */
    z-index: 9621;
    width: calc(100% - 48px);
    max-width: 420px;
    box-sizing: border-box;
    background: var(--color-card, #FAFAF7);
    /* THE ONE GOLD, AND IT IS THE CARD'S OWN HAIRLINE — the same 0.55 border
       .chat-recovery.is-error wears, for the same reason: kintsugi marks a
       repair. The four gold BUTTON borders it replaces were the defect. */
    border: 1px solid rgba(201, 169, 110, 0.55);
    border-radius: 12px;
    box-shadow: var(--shadow-rest, 0 1px 2px rgba(44, 44, 44, 0.03), 0 8px 28px rgba(44, 44, 44, 0.045));
    padding: 18px 20px 16px;
    font-family: 'Inter', system-ui, sans-serif;
    /* ⛔ ITS OWN KEYFRAMES, NOT THE CARD'S. recoveryIn bakes in
       translate(-50%,-50%) — the card's centring — and borrowing it would drag
       this bar half its own height up the screen mid-entrance. The card learned
       this the same way when it borrowed the settings wall's vsPanelIn. */
    animation: saveFailedIn 0.32s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes saveFailedIn {
    from { opacity: 0; transform: translateX(-50%) translateY(8px); }
    to { opacity: 1; transform: translateX(-50%); }
}
@media (prefers-reduced-motion: reduce) {
    #chatSaveFailed { animation: none; }
}
/* Clear the bar only while the bar is there. The dock is 88px on a 16px foot;
   16 more is the room's own breathing gap. On the closed-room path there is no
   dock to clear and the notice rests at the foot of the page. */
body.chat-mode #chatSaveFailed,
body.voice-portal-open #chatSaveFailed { bottom: 120px; }

/* The transcript's own quiet aside — "the reading helper is not answering", and
   its kin. B4 gave it a stylesheet: until today it had NONE, the only element in
   the room dressed entirely from one JS string, which is exactly why its raw
   12.5px (off the four-voice scale) and its 0.8 alpha survived every sweep the
   room has had. ⛔ 0.8 was not a taste: warm stone at 0.8 measures 4.10:1 on the
   room's paper, under AA, on the line a learner reads when something has gone
   wrong. --rm-ink-quiet is the measured floor. */
.chat-system-note {
    text-align: center;
    font-size: var(--rm-support);
    line-height: 1.5;
    color: var(--color-stone, #57534e);
    opacity: var(--rm-ink-quiet);
    margin: 10px auto;
    max-width: 80%;
}

.chat-fail-title {
    font-family: 'Inter', 'Noto Sans JP', sans-serif;
    font-weight: 600;
    font-size: var(--rm-en);
    color: var(--color-ink);
    margin-bottom: 4px;
}
.chat-fail-sub {
    font-size: var(--rm-support);
    color: var(--color-stone);
    opacity: var(--rm-ink-quiet);
    line-height: 1.5;
    margin-bottom: 14px;
}
.chat-fail-actions {
    display: flex;
    gap: 9px;
    align-items: center;
    flex-wrap: wrap;
}
/* the dismiss sits apart from the two doors that DO something */
.chat-fail-actions .is-dismiss { margin-left: auto; }
/* ⛔ THE RING HAS TO BE SAID HERE. `#chatSaveFailed button { box-shadow: none }`
   used to carry it by accident: at (1,0,1) it out-ranked base.css's app-wide
   `button:focus-visible { box-shadow: 0 0 0 4px … }` (0,1,1), so the banner
   showed the 2px outline alone — which is exactly the room's ONE FOCUS RING
   law (voice-portal.css). Deleting that roster entry, which B4 must, would
   have handed the 4px halo back and turned the one accessibility detail the
   banner had right into a regression. .chat-recovery-btn has no
   :focus-visible of its own to inherit, so it is stated. */
#chatSaveFailed .chat-recovery-btn:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 2px;
    box-shadow: none;
}

/* ==========================================
   Polish pass: recovery scrim/recap
   ========================================== */

/* Recovery scrim — focuses the end/reconnect decision without hiding the talk. */
.chat-recovery-scrim {
    position: fixed;
    inset: 0;
    z-index: 9619;   /* one under its card — see the ladder note on .chat-recovery */
    background: rgba(44, 44, 44, 0.14); /* token law (T1.9) */
    -webkit-backdrop-filter: blur(2px);
    backdrop-filter: blur(2px);
    animation: recoveryScrimIn 0.3s ease;
}
/* The leave card stands over a still-live session: the scrim must not swallow
   the second ✕ press (the same-trigger confirm) or block Pause/調.
   The ended cards' veil is hit-through too (R4, S15): the session is over and
   the record behind it is his to scroll and replay — a veil, not a wall. Only
   the failure cards (error / mic) keep the full stop. */
.chat-recovery-scrim.is-leave,
.chat-recovery-scrim.is-rest { pointer-events: none; }
/* The settle lifts the veil (S15): a farewell is not a decision. The remove is
   JS's at 260ms — this fade is decoration a reduced-motion room never sees. */
.chat-recovery-scrim.is-lift { opacity: 0; transition: opacity 0.24s ease; }
@keyframes recoveryScrimIn { from { opacity: 0; } to { opacity: 1; } }

/* (The recap stat line and the win echo's caption died with the S15 re-cut —
   owner: too many words, too western. The quote stands alone; the numbers
   live on the rail row.) */

/* The win echo: his longest mostly-Japanese line, quoted back — evidence, not
   praise. The reading rides beneath in the session's chosen script; helper
   down → the line stands alone, never a blocked card. */
.chat-recovery-win {
    margin-bottom: 14px;
    padding-top: 10px;
    border-top: 1px solid rgba(44, 44, 44, 0.08);
}

.chat-recovery-win-line {
    font-family: 'Klee One', 'Noto Sans JP', serif;
    font-size: var(--rm-en);
    line-height: 1.6;
    color: var(--color-jp-text);   /* slice one: the house's Japanese ink */
    overflow-wrap: break-word;
}

.chat-recovery-win-reading {
    margin-top: 2px;
    /* the seat is mounted with the card and filled when the helper answers —
       one line of its own type, held empty, so nothing grows under him */
    min-height: calc(var(--rm-support) * 1.5);
    font-family: 'Inter', sans-serif;
    font-size: var(--rm-support);
    line-height: 1.5;
    font-style: italic;
    color: var(--color-romaji);
    overflow-wrap: break-word;
}

/* Kana reading (hiragana mode): real Japanese text — JP type, never italic. */
.chat-recovery-win-reading.is-kana {
    font-style: normal;
    font-family: 'Klee One', 'Noto Sans JP', sans-serif;
    font-size: var(--rm-support);
    color: var(--color-stone);
}

/* The farewell (S15, owner pick A): the settled card's title becomes the one
   brush word — おつかれさまでした in the room's JP voice, romaji beneath.
   Both sizes are the room's own tokens; no new type voice. */
.chat-recovery.is-farewell .chat-recovery-title { margin-bottom: 10px; }
.chat-recovery.is-farewell .chat-recovery-title .jp {
    display: block;
    font-family: 'Klee One', 'Noto Sans JP', serif;
    font-size: var(--rm-jp);
    font-weight: 400;
    color: var(--color-jp-text);   /* slice one: the house's Japanese ink */
}
.chat-recovery.is-farewell .chat-recovery-title .rom {
    display: block;
    margin-top: 3px;
    font-family: 'Inter', sans-serif;
    font-size: var(--rm-control);
    font-style: italic;
    font-weight: 400;
    letter-spacing: 0.02em;
    color: var(--color-stone);
    opacity: var(--rm-ink-quiet);
}
/* the farewell's one quiet door takes the full row (no primary on a goodbye) */
.chat-recovery.is-farewell .chat-recovery-btn { flex: 1 0 100%; }

/* THE SETTLE (slice four). The ask becomes the ending in the same node, and
   reassigning className restarts nothing — so the whole swap used to land in
   one frame, growing a card that is centred by a translate and throwing both
   its edges. The height eases from the ask's to the ending's on recoveryIn's
   OWN curve (no second motion language in this room), and the new children soak
   in behind it, staggered in the order he reads them (the delays are written by
   stageSettle: title · win · sub+receipt · doors). 340ms of 間 is the difference
   between a dialog swapping strings and a bow. */
.chat-recovery.is-settling {
    overflow: hidden;
    transition: height 0.34s cubic-bezier(0.22, 1, 0.36, 1);
}
.chat-recovery.is-settling > * { opacity: 0; transition: opacity 0.22s var(--ease-settle); }
.chat-recovery.is-settled > * { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
    .chat-recovery-scrim { animation: none; transition: none; }
    /* ⛔ AND THE SETTLE LANDS ITS END STATE: no ease, no soak — but every child
       VISIBLE, which is the whole point the motion was making. (stageSettle
       returns early here too, so the inline height is never written.) */
    .chat-recovery.is-settling { transition: none; }
    .chat-recovery.is-settling > *,
    .chat-recovery.is-settled > * { opacity: 1; transition: none; }
}

/* ============================================================
   ELEVATION PASS — 2026-07-09 (陽だまり / Hidamari)
   A premium detail pass over the room: a warm dimensional
   "lantern" orb lit from within, deliberate header grouping, a
   real Pause/Resume control, a held-not-broken pause, a quieter
   reading helper, and gold retired to recovery-only. Layered on
   top (this file already uses append-and-override, see "Polish
   pass" above); later rules win the cascade.
   ============================================================ */

/* ---- Header: pure witness chrome (one-bar build) ----
   The timer holds the left; the ✕ holds the right. flex-end (not the base
   space-between) so the ✕ stays right even when the timer is empty
   (display:none) and can no longer push — with the timer visible, its
   margin-right:auto makes the two layouts identical. */
.chat-header { gap: 6px; padding-top: 2px; justify-content: flex-end; }
/* (the timer's margin pair retired with the flow — it hangs off the header now,
   see "THE CLOCK MAY NOT MOVE THE ROOM" above; .chat-room-keys places the ✕) */

/* (The pause pill's elevation dress died with the header pill — the paused
   fill grammar lives on the dock hold now, 文机 section below.) */

/* ---- Viz sizing: MOVED to the .voice-viz rule in the lantern section above.
   It sat here overriding that rule by 1500 lines of cascade, which made the
   main rule read as a lie. One rule, one place. ---- */

/* PAUSED — the record keeps its ink (2026-07-25). Dimming the whole
   transcript to 0.6 took the learner's own words to ~2.2:1, the worst
   contrast in the room, in the one state where he is most likely to read
   back what he just said. The pause already speaks three times over: the
   ensō stills and draws back, the hold widens into the indigo Resume pill,
   and the caption says so. Dim the chrome, never the record. */
.chat-mode-container.state-paused .chat-transcript {
    opacity: 1;
    transition: opacity 0.5s var(--ease-settle, ease);
}

/* ---- Turn-cue label ---- */

/* T0.7 — silence wears a ghost, not a turn: a breath mark while the learner
   might still speak (T0.6 sweeps it when the exchange moves on). No label,
   faint small ink. (The hairline exemption it used to carry died with the
   hairline itself — breathing ruling.) */
.chat-message.user.is-pending .chat-message-role { display: none; }
.chat-message.user.is-pending .chat-message-text {
    opacity: 0.35;
    font-size: var(--rm-en);
    background: none;   /* the ghost never wears the bubble — a breath mark, */
    padding: 0;         /* not a spoken turn (it keeps the right-side seat)  */
}

/* ---- Recovery card: no awkward 2+1 wrap; calmer paper buttons ----
   Primary takes its own full row; the quiet actions sit together beneath.
   S15: one control hairline (0.18) for every door — the quiet 0.14 made four
   hairline alphas in one card; two remain, each with a job (0.08 structure,
   0.18 controls). */
.chat-recovery { top: 46%; padding: 26px 28px 24px; }
.chat-recovery-actions { gap: 9px; }
.chat-recovery-btn.primary { flex: 1 0 100%; padding: 10px 16px; }
.chat-recovery-btn:not(.primary) {
    background: transparent;
    border-color: rgba(44, 44, 44, 0.18);
    color: var(--color-stone);
    font-size: var(--rm-support);
    padding: 10px 16px;
}
.chat-recovery-btn:not(.primary):hover {
    border-color: var(--color-accent);
    color: var(--color-ink);
}

/* ---- Native fidelity: opt out of the app's raised-button chrome ----
   components.css styles every <button> as a raised white card
   (box-shadow + a translateY(-2px) hover-lift). Left alone it leaks onto the
   room's own controls — floating shadows under the header ✕, the bar's hands
   and chips, the slips' rows, the recovery buttons — against the room's
   "cards rest, gentle hover" law. Opt them all out so they stay flat and
   hover in place.

   ⛔ THE LIFT HALF IS GONE, AND SO IS THIS ROSTER'S `transform: none` (B10,
   2026-08-10). PR #1063 deleted `transform: translateY(-2px)` from
   components.css's global `button:hover`; :395 there now ships a hover SHADOW
   and nothing else. So the transform opt-out had nothing left to opt out of —
   and it was not merely inert. A pressed button is also hovered, so this rule
   sat at (0,2,1) over eight designed `:active { transform: scale(…) }`
   squashes at (0,2,0) and cancelled every one of them: the header ✕ and letter
   key, ♪ Share audio, the letter panel's fold mark, the recovery cards' doors,
   the rail's hide key and handle, the + button in its portal scope — and,
   through a legacy panel wearing `.chat-header` (index.html:345), the
   word-info panel's ✕ in css/standard-mode.css. Designed feedback, silently
   swallowed for as long as nobody held a mouse button down and watched.
   ⭐ `box-shadow: none` STAYS on both halves — components.css still ships the
   shadow, and the rest roster below does not out-rank it for every selector
   here (`.chat-recovery-btn` is (0,1,0), under `button:hover`'s (0,1,1)). The
   two halves answer two different rules; only the dead one leaves.

   ⛔ AND THE LETTER PANEL IS ON THIS LIST (S12c, 2026-07-30). It is a
   BODY-LEVEL node — outside #chatModeOverlay and outside #voicePortalOverlay
   — so neither this list nor the portal's ever reached it, and it grew
   buttons at S12: two door lines, the fold mark, and now a head per section.
   Measured before the fix: a 620px-wide `0 4px 15px` card shadow painted
   under an 11px caption, and the pointer snapped the line up 2px
   (un-transitioned — the label's transition list is opacity alone). The
   panel's own hovers are ink: opacity, colour, a wash. Nothing moves. */
.chat-header button,
.chat-dock button,
.chat-thread-panel button,
.chat-recovery-btn,
.chat-pop button,
.chat-reading-retry { box-shadow: none; }
.chat-header button:hover,
.chat-dock button:hover,
.chat-thread-panel button:hover,
.chat-recovery-btn:hover,
.chat-pop button:hover,
.chat-reading-retry:hover { box-shadow: none; }
/* (⛔ `#chatSaveFailed button` left BOTH halves of this roster in B4. Its doors
   are .chat-recovery-btn now, so the entries above already cover them, and the
   id-scoped pair was doing one thing nobody had noticed: at (1,0,1) it out-ranked
   base.css's app-wide focus halo, which is the only reason the banner's ring was
   the room's 2px outline. That is now SAID, beside the bar's own dress.) */


/* English UI text stays on the UI font (Inter) — Klee One is for Japanese only.
   These titles were rendering their English in the JP font, reading non-native. */
.chat-recovery-title {
    font-family: 'Inter', 'Noto Sans JP', sans-serif;
    font-weight: 600;
}
.chat-empty-state .ces-line {
    font-family: 'Inter', 'Noto Sans JP', sans-serif;
    font-weight: 500;
}

/* ---- Reduced motion: a still stroke (the main sumi section handles the ink) ---- */
@media (prefers-reduced-motion: reduce) {
    .chat-mode-container.state-paused .chat-transcript { transition: none; }

    /* B10's revived presses, held still. The room already had three companions
       (the corner keys at :535, the fold mark at :3985, the rail pair in
       voice-portal.css) written when those squashes were believed to paint;
       every press this room paints owes one, or reduce is the single setting
       where the room is inconsistent with itself.
       ⛔ base.css:220 forces `transition-duration: 0.01ms !important` on
       everything under reduce, so an un-companioned squash does not ease here —
       it SNAPS, which is the jolt these blocks exist to prevent. */
    .chat-share-btn:active,
    .chat-recovery-btn:active { transform: none; }
    /* (the bar's two centred hands are companioned beside their own rules
       further down — at equal specificity the later rule wins, and theirs are
       ~1200 lines after this block, so a companion here would simply lose.) */
}

/* (The 2.9 annotation gutter died with the resting decks — T1.5R: nothing
   rests to indent; the placard is an overlay with its own interior gutter.) */

/* (The unscoped 36ch measure died in T1.4 — the scoped px measures live in
   the Tier 1 block below.) */

/* ============================================================
   TIER 1 — THE ROOM'S NEW BODY (2026-07-21 build)
   Later rules win the cascade (this file's append-and-override
   pattern); each block names its owning item.
   ============================================================ */

/* T1.3 — the Japanese voice inside the Inter line. .jp-run = the door law
   (the server's _JP_RUN mirrored; T1.5R arms these as the disclosure's
   doors); .jp-wide = the type law (JP punctuation + fullwidth forms never
   drop into Inter mid-sentence). The learner's Japanese is PROMOTED
   stone→ink — his JP is the substance. */
/* ⭐ SLICE ONE (owner pick 2026-09-17, staged side by side on the live record):
   the room's Japanese wears the HOUSE ink, --color-jp-text, the sepia the
   landing greeting and the course page already use for every Japanese word
   (47 uses in course-lesson.css, 0 here before this). It was --color-ink,
   the one neutral black on a warm page — the same cold seat the course page
   corrected in September. Measured on the room's darkest paper (#E3DFD5):
   7.6:1. Every Klee seat in the room takes it: speech runs, the hover
   placard's echo and word rows, the farewell brush word, the win line. */
.chat-message-text .jp-run,
.chat-message-text .jp-wide {
    font-family: 'Klee One', 'Noto Sans JP', sans-serif;
    font-size: var(--rm-jp);
    color: var(--color-jp-text);
}
.chat-message.user .chat-message-text .jp-run,
.chat-message.user .chat-message-text .jp-wide {
    font-size: var(--rm-jp);
}

/* THE REVEAL'S TOKEN (slice four, 2026-09-17). The audio-clocked reveal appends
   one word — or one glyph — at a time (js/chat-mode.js, revealTick), and each
   one soaks in over 140ms. Overlapping fades read as ink crossing the line;
   the per-character paint they replace read as a cursor. No resting opacity is
   declared here on purpose: the token's rest IS the line's own ink, so killing
   the animation below lands the end state. */
.chat-message-text .rv {
    animation: inkIn 140ms var(--ease-brush, ease-out) both;
}
@keyframes inkIn { from { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .chat-message-text .rv { animation: none; } }

/* T1.4 — three measures, not one. The measure serves the script the eye
   reads linearly: Klee CJK reads best near 18 full-width glyphs, so the JP
   measure is a COUNT, bound to the size (18 × --rm-jp 19px = 342px) and it
   re-derives itself when the size moves. It was a flat 330px, written for
   17px Klee on 2026-07-21 and left behind when the type scale raised the
   size (07-25): 17.4 glyphs by September. English is a WIDTH, not a count:
   Inter holds comfort to the family's 486px (argued at 13.5px, 32.4em at
   today's --rm-en 15px), so it stays a px value. Classification is per
   dominant script (.en-dominant, stamped by the wrapper + attachReading —
   per speaker BLOCK under T1.5M). --chat-measure-en is a review-time
   tunable (§7). The deck measure caps the helper stack so apparatus can
   never out-measure the speech it serves (T1.5R re-scopes it to the
   disclosure placard's interior). */
.chat-mode-container {
    --chat-measure-jp: calc(var(--rm-jp) * 18);
    --chat-measure-en: 486px;
    --chat-measure-deck: 318px;
}
.chat-message-text {
    max-width: var(--chat-measure-jp);
    /* no stub last line: about one wrapped turn in five ended on a 2-4 glyph
       tail (います。). Chrome-only, which is this room; costs no layout. */
    text-wrap: pretty;
}
.chat-message.en-dominant .chat-message-text { max-width: var(--chat-measure-en); }
/* (the deck measure lives on as the placard frame's 352px cap — T1.7:
   318px interior + 28px padding + 2px border; --chat-measure-deck stays
   the documented source of that arithmetic) */

/* T1.5R — the armed doors. NO resting affordance (D1): underline + indigo
   appear on hover/focus/pin only; immediate cursor:pointer is the one
   whisper (Yomitan precedent: zero resting marks). ≥25px hit zone via
   padding inside the --rm-line box, zero layout cost. Opts out of
   components.css's raised-button chrome (the retired chips' pattern). The
   Klee voice is restated here because font:inherit would pull Inter from
   the base line. Doors are role=button SPANS, never <button>: an atomic
   button box cannot fragment across line boxes, so a wrapped run would
   reflow at settle and could overflow the measure (review fix,
   wf_bbcd352c). */
.chat-message-text .jp-run[role="button"] {
    background: none;
    border: none;
    padding: 4px 2px;
    margin: 0 -2px;   /* inline box: vertical padding grows the hit area for free */
    font-family: 'Klee One', 'Noto Sans JP', sans-serif;
    font-size: var(--rm-jp);
    font-weight: inherit;
    color: var(--color-jp-text);   /* slice one: the house's Japanese ink */
    line-height: inherit;
    text-align: inherit;
    cursor: pointer;
    user-select: text;
    -webkit-user-select: text;
    box-shadow: none;
    border-radius: 0;
    transition: color 0.15s;
}
.chat-message.user .chat-message-text .jp-run[role="button"] { font-size: var(--rm-jp); }
.chat-message-text .jp-run[role="button"]:hover,
.chat-message-text .jp-run[role="button"]:active {
    background: none;
    transform: none;
    box-shadow: none;
}
.chat-message-text .jp-run[role="button"]:hover,
.chat-message-text .jp-run[role="button"]:focus-visible,
.chat-message-text .jp-run[role="button"].open {
    color: var(--color-accent);
    text-decoration: underline dotted currentColor;
    text-decoration-thickness: 1px;
    text-underline-offset: 5px;
}
.chat-message-text .jp-run[role="button"]:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 2px;
    border-radius: 3px;
}

/* The kept dot — the one paint of a kept phrase on a Sensei door. Written by
   deriveKeptDots (chat-mode.js), which marks every door in the record whose
   surface the learner has kept tonight. Declarations moved here VERBATIM from
   the deleted cu-keep block, where this selector shared a block with a dead
   twin. */
.chat-message-text .jp-run.kept::before {
    content: '';
    display: inline-block;
    width: 4px;
    height: 4px;
    border-radius: 50%;
    background: var(--color-ink);
    opacity: var(--rm-ink-mark);
    margin-right: 5px;
    vertical-align: 2px;
}

/* The all-EN audio door (B2): an agent RANGE with live audio and zero JP
   doors is a range-level door to a listen-only placard. Nothing at rest;
   cursor is the whisper. Keyboard-armed (role=button, tabindex) in JS.
   (T1.5M: the range is the .turn-range span inside the paragraph.) */
.turn-range.listen-door { cursor: pointer; }
.turn-range.listen-door:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 2px;
    border-radius: 3px;
}

/* T1.5M — range spans carry NO visual dress: sentence boundaries inside a
   paragraph are just text (the separator law); the speaker rule/hairline
   are block-level and span the whole paragraph. A pin asserts computed-
   style identity between range-interior and range-boundary text. */
.turn-range {
    font: inherit;
    color: inherit;
}

/* T1.5R — the placard is an ABSPOS OVERLAY beside its line: zero layout,
   zero scroll (REVERSES the in-flow ruling — hover frequency would reflow
   the record, the exact motion T3.1/T3.3 kill). Anchored below the hovered
   rect by JS; z sits above transcript text, below the header (6000) and
   the card scrim (8999). min-width makes the pending frame itself say
   "open" — no spinner, one fill commit. Frame material is T1.7's. */
.chat-unpack {
    position: absolute;
    z-index: 500;
    margin: 0;
    box-sizing: border-box;
    animation: unpackIn 0.24s var(--ease-settle, ease);
}

/* T1.6's rung lives INSIDE the placard: the translation rests one step below
   the reading.
   ⚖️ AMENDED by the 2026-07-25 type scale. The rung used to be 12.5px italic
   -> 12px upright at 0.78 ink. That is HALF A PIXEL doing a job two other cues
   were already doing outright. The whole supporting stack is now one size
   (--rm-support); upright-vs-italic and 0.78-vs-full ink carry the step, and
   they carry it visibly. Re-pinned in verify_voice_transcript S3c2. */
.chat-unpack .cu-en {
    font-size: var(--rm-support);
    opacity: var(--rm-ink-quiet);
}

/* The listen ladder row (kill row 21: the resting mark died; the ladder
   rides the placard of any door on a Sensei range with live audio).
   listen wears interactive indigo; the label is always the next action. */
/* Legacy fix #5b: the row holds TWO doors — listen (play/stop) and slower
   (0.8x play/stop). The slower door sits quieter until it is the one playing. */
.chat-unpack .cu-listen-row {
    margin-top: 6px;
    display: flex;
    align-items: center;
    gap: 14px;
}
.chat-unpack button.cu-listen.cu-listen-slow {
    opacity: var(--rm-ink-quiet);
}
.chat-unpack button.cu-listen.cu-listen-slow:hover,
.chat-unpack button.cu-listen.cu-listen-slow:focus-visible,
.chat-unpack button.cu-listen.cu-listen-slow.playing {
    opacity: 1;
}
.chat-unpack button.cu-listen {
    background: none;
    border: none;
    padding: 0;
    font-family: 'Inter', sans-serif;
    font-size: var(--rm-control);
    color: var(--color-accent);
    opacity: var(--rm-ink-quiet);
    cursor: pointer;
    user-select: none;
    -webkit-user-select: none;
    box-shadow: none;
    border-radius: 0;
    transition: opacity 0.15s;
}
.chat-unpack button.cu-listen:hover,
.chat-unpack button.cu-listen:active {
    background: none;
    transform: none;
    box-shadow: none;
    opacity: 1;
}
.chat-unpack button.cu-listen:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 2px;
    border-radius: 3px;
}
.chat-unpack button.cu-listen.playing {
    font-style: normal;
}

/* ============ R3.1 — the ladder leads with the family's audio glyph ========
   One speaker-with-waves app-wide (grid/word-panel/walk convention); the word
   beside it is the next action at caption dose. Upright — italic is reserved
   for romaji (the resting mark's italic was a law breach). */
.chat-unpack button.cu-listen {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    font-style: normal;
}
.chat-unpack button.cu-listen svg {
    width: 14px;
    height: 14px;
    display: block;
    flex: none;
}

/* ========= R3.5v2 — the placard headword wears its reading as furigana ====
   Owner-ruled 2026-07-22: gallery-card anatomy — kana above the kanji, romaji
   below, meaning below; no separate kana row, no section labels. */
/* Same specificity as the base .chat-unpack .cu-surface rule above and later
   in the file, so source order still hands it the line-height. (The
   :not(.is-kintsugi) guard left with the retired repair seam — there is no
   longer a second kind of card to exclude.) */
.chat-unpack .cu-surface {
    font-size: var(--rm-jp);
    line-height: 1.55;
}
.chat-unpack .cu-surface rt {
    font-family: 'Klee One', 'Noto Sans JP', sans-serif;
    font-size: var(--rm-ruby);
    color: var(--color-stone);
    opacity: var(--rm-ink-quiet);
    letter-spacing: 0.05em;
}
.chat-unpack .cu-romaji { margin-top: 1px; }
.chat-unpack .cu-en { margin-top: 3px; }
/* the ladder as a placard-level row: section rhythm without a label (no
   border when it is the placard's only row — the listen-door case) */
.chat-unpack .cu-listen-row:not(:first-child) {
    margin-top: 10px;
    padding-top: 9px;
    border-top: 1px solid var(--rm-edge);
}

/* ========== R3.4 — word-owned state seats + explicit run fallback =========
   Every servable word in the run wears the wall's own changeable pair —
   Save 残す (amber, the browse claim) / Got it 覚える (moss) — with
   stateLabels captions (R3.4, owner 2026-07-22, the one-law day;
   supersedes R3.3's one-shot Save + all-known absence; .active is a press
   RECEIPT only since #924/#926). Each un-carded word gets its own named
   ACTIVATE 育てる seat (stone/ink neutrality — NEVER gold), full anatomy
   (target/dot/cap/jp); a hitless run names the run fallback. Sibling seat
   rows stack under ONE shared hairline (the first row keeps it). The PAIR
   wears the room dress — see below. */
.chat-unpack .cu-verb-row {
    margin-top: 12px;
    padding-top: 10px;
    border-top: 1px solid var(--rm-edge);
    display: flex;
    gap: 8px;
}
.chat-unpack button.cu-verb {
    flex: 0 1 auto;   /* room dress: seats hug their words, never span the card */
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    padding: 6px 12px;
    border-radius: 7px;
    border: 1px solid rgba(44, 44, 44, 0.14);
    background: transparent;
    font-family: 'Inter', sans-serif;
    font-size: var(--rm-caption);
    letter-spacing: var(--rm-track);
    font-weight: var(--rm-track-weight);
    text-transform: uppercase;
    white-space: nowrap;
    color: var(--color-stone, #57534e);
    cursor: pointer;
    box-shadow: none;
    transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.chat-unpack button.cu-verb:hover,
.chat-unpack button.cu-verb:active {
    transform: none;
    box-shadow: none;
}
.chat-unpack .cu-verb .dot {
    width: 7px;
    height: 7px;
    border-radius: 50%;
    background: currentColor;
    opacity: var(--rm-ink-mark);
    flex: none;
}
.chat-unpack .cu-verb .jp {
    font-family: 'Klee One', 'Noto Sans JP', sans-serif;
    font-size: var(--rm-control);
    font-weight: 400;
    text-transform: none;
    letter-spacing: 0.02em;
}
.chat-unpack button.cu-verb.save:hover,
.chat-unpack button.cu-verb.save:focus-visible {
    border-color: var(--color-amber, #A07850);
    color: var(--color-amber-deep, #8A6E35);
    background: color-mix(in srgb, var(--color-amber, #A07850) 10%, transparent);
}
.chat-unpack button.cu-verb.activate:hover,
.chat-unpack button.cu-verb.activate:focus-visible {
    border-color: rgba(44, 44, 44, 0.30);
    color: var(--color-ink, #2C2C2C);
    background: rgba(44, 44, 44, 0.04);
}
.chat-unpack button.cu-verb:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 2px;
}
.chat-unpack button.cu-verb.done,
.chat-unpack button.cu-verb.done:hover {
    border-color: rgba(44, 44, 44, 0.30);
    color: var(--color-ink, #2C2C2C);
    background: transparent;
    cursor: default;
}
.chat-unpack button.cu-verb.done .dot { opacity: var(--rm-ink-full); }

/* R3.4 pair rows — stacked siblings share the first row's hairline */
.chat-unpack .cu-verb-row + .cu-verb-row {
    margin-top: 10px;
    padding-top: 0;
    border-top: none;
}
/* Word-owned actions are progressive disclosure, not a second floor. The
   word row already names their target; only the native action/state remains
   when the tray unfolds. */
.chat-unpack .cu-word-actions .cu-verb-row {
    margin: 0;
    padding: 0;
    border-top: 0;
}
.chat-unpack .cu-word-actions .cu-activate-target,
.chat-unpack .cu-word-actions .cu-verb-word {
    display: none;
}
.chat-unpack .cu-word-actions .cu-pair-shoulder {
    justify-content: flex-end;
}
/* Activate is word-grain whenever the helper resolved words. Its visible
   shoulder names exactly what the button will send to Sensei; the run-grain
   fallback follows the same anatomy, so scope is never encoded by position. */
.chat-unpack .cu-verb-row.cu-activate-row { display: block; }
.chat-unpack .cu-activate-target {
    margin-bottom: 6px;
    font-family: 'Klee One', 'Noto Sans JP', sans-serif;
    font-size: var(--rm-support);
    line-height: 1.3;
    color: var(--color-jp-text);   /* slice one: the house's Japanese ink */
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
/* ---- ROOM DRESS (slice 3, owner latitude 2026-07-23) — AMENDS the one-
   dress law (#921) for narrow housings: inside the room's ~350px card the
   pair keeps its grammar (two equal quiet frames, StateLabels caps,
   amber/moss hover + press-receipt fills) at caption scale. The dot and
   the JP verb glyphs shed — display only, they stay in the DOM — and the
   word name + state tag rise to a SHOULDER line above the chips, so no
   cap ever wraps and no tag ever clips. Laws untouched: receipt (.active
   = press only), tag = sole state display, undo, caps from StateLabels. */
.chat-unpack .cu-verb-row.cu-pair { display: block; }
.chat-unpack .cu-pair-shoulder {
    display: flex;
    align-items: baseline;
    gap: 8px;
    min-width: 0;
}
/* 2+ pairs: each unit names its word (Klee, ink) on the shoulder line */
.chat-unpack .cu-verb-word {
    font-family: 'Klee One', 'Noto Sans JP', sans-serif;
    font-size: var(--rm-support);
    line-height: 1.3;
    color: var(--color-jp-text);   /* slice one: the house's Japanese ink */
    flex: 0 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.chat-unpack .cu-pair-chips {
    margin-top: 6px;
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 6px;
}
.chat-unpack .cu-pair button.cu-verb {
    padding: 7px 8px;
    font-size: var(--rm-control);
    border-radius: 6px;
}
.chat-unpack .cu-pair .cu-verb .dot,
.chat-unpack .cu-pair .cu-verb .jp { display: none; }
.chat-unpack button.cu-verb.known:hover,
.chat-unpack button.cu-verb.known:focus-visible {
    border-color: var(--color-moss, #6B7F5C);
    color: var(--color-moss, #6B7F5C);
    background: color-mix(in srgb, var(--color-moss, #6B7F5C) 8%, transparent);
}
/* receipt law (#924/#926): .active lights only as a PRESS receipt — the
   pair rests neutral on every render/reconcile; the tag holds the state */
.chat-unpack button.cu-verb.save.active {
    border-color: color-mix(in srgb, var(--color-amber, #A07850) 55%, transparent);
    background: color-mix(in srgb, var(--color-amber, #A07850) 10%, transparent);
    color: var(--color-amber-deep, #8A6E35);
}
.chat-unpack button.cu-verb.known.active {
    border-color: color-mix(in srgb, var(--color-moss, #6B7F5C) 55%, transparent);
    background: color-mix(in srgb, var(--color-moss, #6B7F5C) 8%, transparent);
    color: var(--color-moss, #6B7F5C);
}
.chat-unpack button.cu-verb.active .dot { opacity: var(--rm-ink-full); }
/* a same-state press answers with the wall's one quiet breath (claims law;
   keyframes verb-affirm-breath live in grid-mode.css — always co-loaded) */
.chat-unpack button.cu-verb.verb-affirm {
    animation: verb-affirm-breath 0.55s ease-out;
}
@media (prefers-reduced-motion: reduce) {
    .chat-unpack button.cu-verb.verb-affirm { animation: none; }  /* wall parity (grid-mode ~:2001) */
}
/* the unit's state TAG — the receipt. The app's one status vocabulary (New /
   Learning / Resting / Coming back) on the shoulder line; it FLIPS on a
   real write, which is the whole feedback for the one press whose captions
   read the same on both sides (Coming back → Resting). */
.chat-unpack .cu-verb-state {
    flex: none;
    font-family: 'Inter', sans-serif;
    font-size: var(--rm-caption);
    letter-spacing: var(--rm-track);
    font-weight: var(--rm-track-weight);
    text-transform: uppercase;
    color: var(--color-stone, #57534e);
    opacity: var(--rm-ink-quiet);
}
.chat-unpack .cu-verb-state[data-state="saved"] { color: var(--color-amber-deep, #8A6E35); }
.chat-unpack .cu-verb-state[data-state="known"] { color: var(--color-moss, #6B7F5C); }
/* undo — one quiet text link beside the tag it undoes, gone after a breath
   (opted out of the global button shadow/lift like every room control) */
.chat-unpack .cu-verb-undo,
.chat-unpack .cu-verb-undo:hover,
.chat-unpack .cu-verb-undo:active {
    box-shadow: none;
    transform: none;
}
.chat-unpack .cu-verb-undo {
    flex: none;
    border: none;
    background: none;
    padding: 2px 4px;
    font-family: 'Inter', sans-serif;
    font-size: var(--rm-control);
    color: var(--color-accent, #3D5A80);
    text-decoration: underline;
    text-underline-offset: 2px;
    cursor: pointer;
}

/* ============== R3.5v2 — the pinned panel docks to the wall ===============
   PIN moves the pop-up to the alcove in the right wall margin (mockup-
   ratified seat: 336px, right 32, top aligned to the conversation's first
   band). Legacy fix #4 (owner 2026-07-22): the alcove is a RAIL — a
   scrollable column of standing cards (cap in JS), each with its own ✕.
   Hover placards never wear this; below ~1100px the rail folds (no wall
   to dock into — dockPlacard gates it). */
.cu-dock-rail {
    position: fixed;
    /* This rail carried a `calc(32px + --ctp-w)` term for as long as the letter
       panel insetted the room: a bare `right: 32px` put every docked card 100%
       behind an open panel, invisible AND inert. S12 (2026-07-29) made the panel
       a COVER, so the wall is simply covered while a letter is out — whole,
       where it was, the moment the letter folds. The room's corner keys made
       the same move; see their rule for the ruling. */
    right: 32px;
    top: 178px;   /* fixture-derived (1440x1000): the conversation's first band;
                     re-measure if header/scope geometry ever changes — and the
                     calc below repeats this 178: the two move together */
    width: 336px;
    /* THE FLOOR IS THE BAR'S TOP, MEASURED, NEVER SPELLED (2026-08-27; the
       veil-hem precedent, and js/chat-mode.js:railHasWall's own "measured,
       never guessed"). This was a spelled viewport constant — a rail bottom
       at 100vh−42 while the bar's top sits at 100vh−104, so with three pinned
       cards the rail's overflow clip sliced the oldest card mid-sentence
       62px INSIDE the bar's band; and the page viewer's veil hems at the
       bar's top −10px, so up to 72px of that sliced tail stood BRIGHT below
       it. reseatDockRail measures the real bar and writes --rail-floor-clear
       = band + 16px — 6px above the hem, so a docked card is fully veiled by
       construction. The 42px fallback is the old constant: an unmeasurable
       bar degrades to yesterday's geometry, never worse. */
    max-height: calc(100vh - 178px - var(--rail-floor-clear, 42px));
    overflow-y: auto;
    scrollbar-width: thin;   /* the Firefox half of the 3px hairline below */
    display: flex;
    flex-direction: column;
    gap: 10px;
}
/* ⛔ STOOD DOWN, NOT DESTROYED (2026-07-31). Narrowing the window past the wall
   used to delete every pinned card; it hides the rail instead. `visibility`,
   never `display`: the cards keep their box, their doors and their ladders, and
   the rule the portal's own collapsed rail wrote applies here too — a hidden
   thing leaves the focus order, so nothing in a rail the window has put away can
   still be Tabbed to. It comes back whole with the wall. */
.cu-dock-rail.no-wall { visibility: hidden; pointer-events: none; }
.cu-dock-rail::-webkit-scrollbar {
    width: 3px;
}
.cu-dock-rail::-webkit-scrollbar-thumb {
    background: rgba(44, 44, 44, 0.08);
    border-radius: 3px;
}
.chat-unpack.cu-docked {
    position: relative;   /* in-rail flow; each card anchors its OWN ✕ */
    flex-shrink: 0;
    width: 100%;
    max-width: 336px;
    max-height: none;
    overflow: visible;
    padding: 16px 34px 14px 18px;   /* right air holds the card's own ✕ */
    border: 1px solid var(--color-divider, #D8D2C8);
    box-shadow: var(--shadow-rest, 0 1px 2px rgba(44, 44, 44, 0.03));
    animation: unpackIn 0.24s var(--ease-settle, ease);
}
/* The card's dismiss: same INK as the panel's corner key, a smaller seat.
   ⛔ 0.68, not 0.45 (2026-07-28, S4b). This ✕ predates the 07-25 contrast pass
   by three days and was simply missed by it — stone at 0.45 on the card's own
   #FEFDFB measures 2.12:1, under WCAG 1.4.11's 3:1, and it was the only way to
   put a docked card away. 0.68 is the room's measured value and reads 3.41:1
   here. It is deliberately NOT a 30px corner key: a 336px card in the wall
   margin would wear the room's own mark as furniture. Same ink, 28px seat, a
   12px stroke — the fold ▸ makes the same call at the same size. */
.cu-dock-close {
    position: absolute;
    top: 6px;
    right: 3px;   /* 3 + 28 + 3 = 34 = the card's right air column, exactly */
    display: inline-grid;
    place-items: center;
    width: 28px;
    height: 28px;
    padding: 0;
    box-sizing: border-box;
    background: none;
    border: none;
    box-shadow: none;
    line-height: 1;
    color: var(--color-stone);
    opacity: var(--rm-ink-mark);
    cursor: pointer;
    transition: color 0.2s, opacity 0.15s;
}
/* 6 + 28 + 10 = 44 tall; 3 + 28 + 3 = 34 wide — one axis clears the floor,
   which is what the floor asks.
   ⛔ Asymmetric, and DOWN rather than out: the card's padding-right: 34px is
   the air column this ✕ was given, so the expander fills exactly that column's
   top 44px and stops on its inner edge. Reaching further left would put an
   invisible hit over the content column, where `.jp-run` doors are real
   buttons — and the law protects a neighbour's VISIBLE box. Top and right land
   on the card's own edges; nothing escapes the card. */
.cu-dock-close::after {
    content: '';
    position: absolute;
    inset: -6px -3px -10px -3px;
}
/* ⛔ These three guards defeat the generic card-button hover (lift, paper,
   shadow) — a mark in the corner of a resting card must not become a button. */
.cu-dock-close:hover,
.cu-dock-close:active {
    background: none;
    transform: none;
    box-shadow: none;
    color: var(--color-ink);
    opacity: 1;
}
/* ⛔ UNCHANGED, and the 1px offset is deliberate: this ring sits 3px inside the
   card's own edge, so the room's 2px offset would draw it half over the border.
   voice-portal.css's focus-ring block names this control BY NAME as one of the
   two that declare a different offset on purpose. Do not normalise it. */
.cu-dock-close:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 1px;
    border-radius: 3px;
}

/* ============ R3.8 — the learner's English steps to faint-warm ===========
   Owner-picked lever (one lever, not both): his English scaffolding softens
   stone → --color-faint-warm; his JAPANESE keeps its promoted ink (.jp-run
   sets its own color — the barbell is untouched). Sensei unchanged. */
.chat-message.user .chat-message-text {
    color: var(--color-faint-warm, #6E6860);
}

/* ============================================================
   一枚の盤 THE BAR — slice 4 (one-bar build, evolving the Stage-1
   文机 desk). The room's ONE control surface: a paper bar at the
   bottom edge, the claude.ai composer grammar in the room's own
   material. ⚖️ NAMED AMENDMENT to T1.1 ("card material is for
   objects handed to the learner; a control is furniture"): this
   surface alone wears paper — fill, hairline, radius — and its
   shadow stays in the RESTING tier (0.03–0.06), never the raised
   register; permanent furniture rests. Center = the hold — the
   same true-suspend pause. Live: a quiet ⏸ inside a hairline ring
   that breathes ink-density at the session's own pace
   (--breath-duration, the enso's grammar — never loudness).
   Paused: the fill grammar the pill already spoke — indigo, ▶ —
   widened into a pill with the Resume word INSIDE the control.
   Left cluster = the bring-into-the-room doors (＋ page, ♪ Share
   audio). Right cluster = the two inline pickers (voice, tempo).
   Clusters grow AWAY from their anchored edge and the hold is
   absolutely centered, so a control appearing mid-session never
   moves another; the bar holds its height through every state,
   so the room never jumps.
   ============================================================ */

.chat-dock {
    position: relative;
    flex-shrink: 0;
    min-height: 88px;
    box-sizing: border-box;
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 12px 16px;
    /* ONE OBJECT (2026-07-25): capped and centred instead of spanning the
       whole column. At the column's full 576px the absolutely-centred hold
       sat in ~100px of dead air on either side and the bar read as five
       scattered marks, not one surface. The cluster laws are UNTOUCHED —
       edge-anchored hands, hold on true centre — so a control arriving
       mid-session still never shifts a neighbour; only the stage narrows.
       ⚖️ The bar's own T1.1 amendment stands: it wears paper, its shadow
       stays in the resting tier (--shadow-rest is the house's resting
       two-layer, still 1px/2px at the contact edge). */
    width: 100%;
    max-width: 496px;
    margin: 6px auto 0;
    background: rgba(253, 252, 249, 0.97);
    border: 1px solid rgba(44, 44, 44, 0.10);
    border-radius: 16px;
    box-shadow: var(--shadow-rest, 0 1px 2px rgba(44, 44, 44, 0.04));
}

/* The bar's two hands-clusters. Flex order does the anchoring: the left
   cluster packs left and grows rightward, the right cluster packs right and
   grows leftward — an arriving control extends into the bar's open middle,
   never shifting a neighbor toward the edges. */
.chat-dock-side {
    display: flex;
    align-items: center;
    gap: 5px;
    z-index: 2;
}

/* ONE SHAPE for every hand on the bar (2026-07-25): a ghost pill. Three
   different silhouettes — a bare glyph, a text run, two text chips — read as
   a scatter; one silhouette reads as a rhythm. Indigo stays interactive-only:
   the resting wash is neutral ink, indigo speaks on hover/open. NOT card
   chrome — no shadow, no lift (the room's global opt-out still applies). */
.chat-share-btn,
.chat-chip {
    height: 34px;
    border-radius: 17px;
    border: 1px solid transparent;
    background: rgba(44, 44, 44, 0.045);
}
.chat-share-btn:hover,
.chat-chip:hover,
.chat-chip[aria-expanded="true"] {
    background: rgba(61, 90, 128, 0.10);
    border-color: rgba(61, 90, 128, 0.32);
}
/* ⛔ WAITING, NOT MISSING (2026-07-27). The share door holds its seat from the
   moment the room opens instead of appearing when the socket does, so the bar
   has ONE shape all session. It still cannot act before Sensei is there, and
   the room does not pretend otherwise: dimmed, no indigo answer on hover, no
   pointer. This must out-order the hover pair above, so it stays below it —
   it is the same specificity plus :disabled. */
.chat-share-btn:disabled {
    opacity: 0.34;
    cursor: default;
}
.chat-share-btn:disabled:hover {
    background: rgba(44, 44, 44, 0.045);
    border-color: transparent;
    color: inherit;
    opacity: 0.34;
}

/* ---- The inline pickers (claude.ai composer grammar): quiet text chips —
   the saved value + a small caret; indigo speaks on hover/open. ---- */
.chat-chip {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    height: 34px;   /* the bar's one pill height (shared rule above) */
    cursor: pointer;
    font-family: 'Inter', system-ui, sans-serif;
    font-size: var(--rm-control);
    font-variant-numeric: tabular-nums;
    color: var(--color-stone);
    /* The bar's only readout of what the session is SET to — it reads a step
       stronger than the quiet doors beside it (0.75 measured ~3.9:1 on the
       bar's paper, under the 4.5:1 floor for text this size). */
    opacity: 1;   /* the pill carries the readout now; no fade on the value */
    padding: 0 11px;
    transition: color 0.2s, background 0.2s, border-color 0.2s;
    position: relative;
}
/* 34 → 44 tall (touch floor); the padding already carries the width. */
.chat-chip::after {
    content: '';
    position: absolute;
    inset: -5px 0;
}
.chat-chip:hover,
.chat-chip[aria-expanded="true"] {
    color: var(--color-accent);
    background: rgba(61, 90, 128, 0.06);
    opacity: 1;
}
.chat-chip:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 2px;
}
.chat-chip .chip-caret {
    font-size: var(--rm-control);
    opacity: var(--rm-ink-mark);
    transform: translateY(1px);
}

/* ---- The hold. Ink on the bar's paper — a hairline indigo circle, the
   glyph resting at its center. Absolutely centered on the bar (the clusters'
   masses differ; the room's heartbeat must not drift off-axis). Every
   transform on it COMPOSES with the centering translate — a bare scale would
   throw it off-center. ---- */
.chat-dock-hold {
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    width: var(--hand-h);
    height: var(--hand-h);
    border-radius: 50%;
    /* The room's central control needs a visible edge: at 0.32 alpha this
       hairline measured 1.64:1 on the bar's paper, roughly half the 3:1 WCAG
       1.4.11 asks of a control's boundary (hover only reached 2.26:1). 0.7
       measures 3.36:1, and the soft fill gives the target a body so the glyph
       stops being the only thing announcing a button is there. */
    background: rgba(61, 90, 128, 0.09);
    border: 1px solid rgba(61, 90, 128, 0.7);
    color: var(--color-ink);
    cursor: pointer;
    padding: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 1;
    /* transform is listed because this control PRESSES (B10). It was the one
       squash in the room that already painted, and it snapped: the room's other
       hands ease, so the difference read as the hold being a cruder control. */
    transition: background 0.2s, border-color 0.2s, color 0.2s, transform 0.12s;
}
.chat-dock-hold[hidden] { display: none; }
/* The selector carries .chat-dock deliberately: this control is centred BY a
   transform, so every transform on it must restate the centering — a bare
   scale() would throw it off-axis by half its own size.
   ⛔ THE HOVER'S RESTATEMENT IS GONE (B10, 2026-08-10) and this comment is the
   receipt. It read "the room's lift opt-out (.chat-dock button:hover
   { transform: none }) outranks a bare .chat-dock-hold:hover and would STRIP
   the centering" — true when it was written, and the defence was correct. That
   opt-out no longer sets a transform, so the restatement had become a no-op
   standing behind a comment describing a rule that does not exist, which is
   how a future session re-derives a defence against nothing. The base rule
   above carries the centering; hover simply leaves it alone now.
   The PRESS keeps its restatement, because a press really does compose. */
.chat-dock .chat-dock-hold:hover {
    background: rgba(61, 90, 128, 0.15);
    border-color: rgba(61, 90, 128, 0.85);
}
.chat-dock .chat-dock-hold:active { transform: translate(-50%, -50%) scale(0.96); }
.chat-dock-hold:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 3px;
}

/* The glyph is a stroke now (slice four): an 18px mark in the room's 16-box
   family, so no font resolves it and no size changes between states. flex kills
   the inline box's descender gap, which is what kept the mark off the circle's
   true centre. */
.chat-dock-hold .cdh-glyph {
    display: flex;
    opacity: var(--rm-ink-full);
}
.chat-dock-hold:hover .cdh-glyph { opacity: 1; }

/* The breathing ring — a second faint pressing of the circle, swelling and
   receding in ink density at the session's breath pace (opacity only: the
   enso's law #747 grammar; scale belongs to the voice, which a control
   never wears). */
.chat-dock-hold::before {
    content: '';
    position: absolute;
    inset: -7px;
    border-radius: 50%;
    border: 1px solid rgba(61, 90, 128, 0.4);
    opacity: 0.2;
    pointer-events: none;
    animation: dockHoldBreath var(--breath-duration, 3.5s) ease-in-out infinite;
}
@keyframes dockHoldBreath {
    0%, 100% { opacity: 0.16; }
    50% { opacity: 0.5; }
}

/* Paused — the held breath fills and WIDENS: the pill's own paused grammar
   (indigo = interactive, the one obvious way back in), the Resume word moving
   INSIDE the control (the old under-desk cue would clip the bar's paper).
   One target, one utterance. The ring stills and follows the pill's edge. */
.chat-mode-container.state-paused .chat-dock-hold {
    width: auto;
    min-width: var(--hand-h);
    padding: 0 26px;
    border-radius: calc(var(--hand-h) / 2);
    background: var(--color-accent);
    border-color: var(--color-accent);
    color: #fff;
}
.chat-mode-container.state-paused .chat-dock-hold:hover {
    background: var(--color-accent);
    filter: brightness(1.06);
}
.chat-mode-container.state-paused .chat-dock-hold .cdh-glyph { opacity: 1; }
.chat-mode-container.state-paused .chat-dock-hold::before {
    animation: none;
    opacity: 0.35;
    /* The halo follows the pill: inset −7 with a 1px hairline, so the pill's
       own radius + 6 keeps the two edges concentric. */
    border-radius: calc(var(--hand-h) / 2 + 6px);
}

/* The Resume cue — inside the pill, only while the room holds its breath
   (the viz label above says "tap Resume"; this is where). display:none when
   live so the circle stays a pure glyph. */
.chat-dock-hold .cdh-cue {
    display: none;
    font-family: 'Inter', sans-serif;
    font-size: var(--rm-support);
    letter-spacing: 0.04em;
    color: #fff;
    margin-left: 9px;
    white-space: nowrap;
}
.chat-mode-container.state-paused .chat-dock-hold .cdh-cue { display: inline; }

/* (The 調 gear retired with the one-bar build — its two surviving settings
   became the tempo chip; the rest fell to defaults.) */

/* The other occupant ARRIVING (slice four): the hold takes the seat the standby
   hand is fading out of, over the same 0.16s and with the same grammar — ink
   only. display:none → flex restarts this, and that restart IS the swap. */
.chat-dock-hold { animation: dockHandIn 0.16s var(--ease-settle); }
@keyframes dockHandIn {
    from { opacity: 0; }
}

/* Reduced motion: a still control — the ring rests at its mid ink, and the
   arrival is a cut that lands the hand at full ink (its base opacity). */
@media (prefers-reduced-motion: reduce) {
    .chat-dock-hold::before { animation: none; opacity: 0.3; }
    .chat-dock-hold { animation: none; }
}

/* ============================================================
   SHOW SENSEI A PAGE — Stage 2 (2026-07-22 build, addendum A3-A5)
   The learner lays a workbook page on the desk (⌘V or a drop) and
   it appears in the transcript as HIS turn — a small thumbnail
   card wearing his margin-note grammar, with the honest reading
   state beside it (a vision reader reads the page TO Sensei; the
   room never pretends he sees pixels). Tap the page → the exact
   reading Sensei was given, on the same placard paper as every
   other disclosure. "Look closer" appends the deep read; nothing
   is ever rewritten.
   (The bar's ＋ was a third door onto this same funnel and left at
   S12c, 2026-07-30 — a seat on the ONE bar for something two doors
   already did. Its dress went with it; the cue below did not.)
   ============================================================ */

/* ---- Drop cue: one quiet dashed hairline inside the room while a file is
   over it — indigo (interactive), never a wash. ---- */
.chat-mode-container.attach-drop::after {
    content: '';
    position: absolute;
    inset: 10px;
    border: 1.5px dashed rgba(61, 90, 128, 0.5);
    border-radius: 12px;
    pointer-events: none;
    z-index: 60;
}

/* ---- The page turn: HIS margin note, holding a small object. Not a
   .chat-message on purpose (the paragraph-merge law must never join speech
   into a card) but dressed in the same learner grammar — fit-content block
   in the scroller's lane (its sage hairline retired with the speech block's,
   breathing ruling 2026-07-22 evening). ---- */
.chat-attach-turn {
    position: relative;
    margin-top: 14px;
    margin-left: auto;    /* bubble-dress ruling: the learner's side is the right */
    margin-right: 14px;   /* legacy fix #2: the scroller's own lane */
    width: fit-content;
    display: flex;
    align-items: flex-end;
    gap: 12px;
    padding: 4px 0;
    animation: userFadeIn 0.3s ease;
}

/* The page itself — a handed object, so it MAY wear card material (T1.1). */
.chat-attach-turn .cat-thumb {
    display: block;
    width: 132px;
    padding: 0;
    border: 1px solid var(--rm-edge-strong);
    border-radius: 8px;
    background: var(--color-card-bright, #FEFDFB);
    box-shadow: var(--shadow-subtle, 0 1px 2px rgba(44, 44, 44, 0.03));
    overflow: hidden;
    cursor: pointer;
    transition: border-color 0.2s;
}
.chat-attach-turn .cat-thumb:hover,
.chat-attach-turn .cat-thumb[aria-expanded="true"] {
    border-color: rgba(61, 90, 128, 0.5);
    transform: none;
    box-shadow: var(--shadow-subtle, 0 1px 2px rgba(44, 44, 44, 0.03));
}
.chat-attach-turn .cat-thumb:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 2px;
}
.chat-attach-turn .cat-thumb img {
    display: block;
    width: 100%;
    max-height: 176px;
    object-fit: cover;
    object-position: top;
}

/* The state beside the page — one honest line; reading states breathe ink. */
.chat-attach-turn .cat-side {
    display: flex;
    flex-direction: column;
    gap: 6px;
    padding-bottom: 2px;
    max-width: 190px;
}
.chat-attach-turn .cat-status {
    font-family: 'Inter', sans-serif;
    font-size: var(--rm-control);
    line-height: 1.5;
    color: var(--color-stone);
    /* same ink as the played caption, for the same reason: provenance at 11px
       sits on the AA floor's wrong side at 0.75 (A10) */
    opacity: var(--rm-ink-quiet);
}
.chat-attach-turn[data-state="reading"] .cat-status,
.chat-attach-turn[data-state="closer"] .cat-status {
    animation: attachReadingPulse 2.4s ease-in-out infinite;
}
/* ⛔ RE-BASED ONTO THE LADDER 2026-08-06 (B9). The rule above argues in its own
   comment that provenance at 11px "sits on the AA floor's wrong side at 0.75"
   and sets the quiet rung — and then this infinite pulse painted the same line
   at 0.4→0.85 (1.93:1→4.59:1) for as long as the page was being read. The file
   was overruling itself, in the one place the argument had already been won. */
@keyframes attachReadingPulse {
    0%, 100% { opacity: var(--rm-ink-quiet); }
    50% { opacity: var(--rm-ink-full); }
}

/* Look closer — quiet chrome in the cu-verb family; appears once a reading
   exists, leaves after the deep read lands (one per page). */
.chat-attach-turn button.cat-closer {
    align-self: flex-start;
    padding: 5px 10px;
    border-radius: 7px;
    border: 1px solid rgba(44, 44, 44, 0.14);
    background: transparent;
    font-family: 'Inter', sans-serif;
    font-size: var(--rm-control);
    font-weight: 600;
    letter-spacing: 0.05em;
    color: var(--color-stone, #57534e);
    cursor: pointer;
    box-shadow: none;
    transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.chat-attach-turn button.cat-closer[hidden] { display: none; }
.chat-attach-turn button.cat-closer:hover,
.chat-attach-turn button.cat-closer:focus-visible {
    transform: none;
    box-shadow: none;
    border-color: rgba(61, 90, 128, 0.5);
    color: var(--color-accent);
    background: rgba(61, 90, 128, 0.05);
}
.chat-attach-turn button.cat-closer:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 2px;
}
.chat-attach-turn button.cat-closer:disabled {
    opacity: 0.5;
    cursor: default;
}

/* ---- The page, opened (owner report 2026-08-04). The expanded view was an
   in-column popover, and THE ROOM IS 640px WIDE by law — so a photographed
   workbook page rendered at ~520px, its tail silently cropped behind a 3px
   scrollbar, colliding with the talk's text at both edges: "expand it to see
   it myself" got a third of the screen. The tap now opens the page as THE
   MOMENT (#826's wall law: thumbs on the wall, full art where it is the
   moment): whole page, fit to the window, never cropped, over the recovery
   scrim's veil — the talk stays faintly visible, so it never reads as leaving
   the room. Any tap (the page included) or Esc puts it away — but the veil
   covers the RECORD, never the HANDS (owner, 2026-08-04): it stops above the
   dock, so the hold stays a live press mid-page. Own state machine
   beside the hover placard (removeUnpack must never sweep it). ---- */
.cat-viewer {
    position: fixed;
    inset: 0;
    /* THE HEM (2026-08-04): the bar keeps its hands under a standing page —
       pausing is most needed exactly while reading one, and the room already
       holds this law for cards (the recovery scrim keeps the hold reachable
       by design). The clearance is measured off the real dock by
       renderAttachPanel, never spelled here — every room state (live, paused,
       standby over a record) keeps its own bar height honest. The bar CANNOT
       be z-lifted over the veil instead: it lives in the room's stacking
       context (9550) and z-index is local, so the veil's hem is the only
       honest door. */
    bottom: var(--cat-hands-clear, 0px);
    /* over the letter (9610), under a decision about the session (9619/9620)
       — see the ladder note on .chat-recovery before touching this */
    z-index: 9612;
    display: flex;
    align-items: center;
    justify-content: center;
    background: rgba(44, 44, 44, 0.14); /* the recovery scrim's veil (T1.9) */
    -webkit-backdrop-filter: blur(2px);
    backdrop-filter: blur(2px);
    cursor: pointer;
    animation: recoveryScrimIn 0.3s ease;
}
.cat-viewer:focus { outline: none; }   /* focused on open so Esc lands; a ring around a whole-room veil is noise */
/* The mat: the page on card paper, wearing the one popover spec — a floating
   surface lights like the placard and the slips wherever it floats. */
.cat-viewer .cat-frame {
    padding: 10px;
    background: var(--color-card-bright, #FEFDFB);
    border: 1px solid var(--rm-pop-line);
    border-radius: var(--rm-pop-radius);
    box-shadow: var(--rm-pop-shadow);
}
/* Fit to the window, both axes, never upscaled past the photograph itself —
   the whole page is visible, or the door is not honest. */
.cat-viewer .cat-panel-page {
    display: block;
    max-width: calc(100vw - 70px);
    /* the vertical fit is cut from the VEIL, not the window — the hem above
       the dock shortened it, and a page sized to the window would paint out
       over the bar the hem just uncovered */
    max-height: calc(100vh - var(--cat-hands-clear, 0px) - 70px);
    width: auto;
    height: auto;
    border-radius: 4px;
}
/* A night older than the attachment store, or a page since removed: the card
   keeps its place and says so, rather than standing a broken frame in his
   record. */
.chat-attach-turn[data-no-image] .cat-thumb {
    display: none;
}

.cat-panel .cat-panel-title {
    font-family: 'Inter', sans-serif;
    font-size: var(--rm-caption);
    text-transform: uppercase;
    letter-spacing: var(--rm-track);
    font-weight: var(--rm-track-weight);
    color: var(--color-stone);
    opacity: var(--rm-ink-quiet);
    padding: 6px 4px;
}

/* Reduced motion: pages arrive and read still. */
@media (prefers-reduced-motion: reduce) {
    .chat-attach-turn { animation: none; }
    .chat-attach-turn[data-state="reading"] .cat-status,
    .chat-attach-turn[data-state="closer"] .cat-status { animation: none; }
    .cat-viewer { animation: none; }
}


/* ============================================================
   THE OPENING ROOM (2026-07-25 composition pass)
   Two shapes, one room. Before a word is said there is genuinely
   nothing to show, so the mark owns the field: the status line
   pins to the top, the bar to the bottom, and the ensō + the
   opening words settle as ONE mass in the middle of the light.
   The turn caption rides with it (it is the room's live state,
   never hidden). The moment a turn lands, setRoomOpen(false)
   drops the class and the talk takes the room.
   ============================================================ */
.chat-mode-container.room-open {
    justify-content: center;
}
.chat-mode-container.room-open .chat-header {
    position: absolute;
    top: 20px;
    left: 32px;
    right: 32px;
    margin: 0;
}
.chat-mode-container.room-open .chat-dock {
    position: absolute;
    bottom: 16px;
    left: 50%;
    transform: translateX(-50%);
}
.chat-mode-container.room-open .chat-transcript {
    flex: 0 1 auto;
    overflow: visible;
}
/* ⛔ room-open no longer resizes the lamp. It cannot: the landing is seated in
   the transcript from the moment the room opens, so room-open is never on while
   there is a landing, and a second size for the mark could only ever reintroduce
   the snap this branch spent a day removing. ONE box, always. */
.chat-mode-container.room-open .chat-empty-state {
    padding-top: 12px;
}
.chat-mode-container.room-open .chat-empty-state .ces-line {
    font-size: var(--rm-en);
}

/* ============================================================
   REDUCED MOTION — the late word (must stay LAST in this file)
   ⛔ The block at the top of this file sets `.chat-unpack {
   animation: none }`, but the T1.5R overlay rule and the docked-
   card rule both re-declare `animation` at the SAME specificity
   further down, and a media query adds none — so last-declared
   won and every hover placard and pinned card still animated for
   someone who asked for stillness. Anything that re-declares an
   animation below here must add its own kill in this block.
   ============================================================ */
@media (prefers-reduced-motion: reduce) {
    .chat-unpack,
    .chat-unpack.cu-docked,
    .chat-mode-overlay::before,
    .chat-mode-overlay::after,
    /* the landing's record crossfade (S6 re-arm above) — base opacity is 1,
       so killing the animation lands the end state by construction */
    .chat-landing-head .vp-lobby.vp-record-in {
        animation: none;
    }
}

/* ============================================================
   THE THREAD — a stopped session is this room, on standby
   (S3R, owner model 2026-07-26)

   There is ONE conversation surface in this app: #chatTranscript,
   painted by appendMessage. A past session is not a different
   screen that shows a copy of a talk — it is this room with that
   talk loaded and no live connection. So everything below is
   dress for a STATE of the room, never a second room:

     · standby       the bar's one hand, and nothing else changes
     · thread head   whose night this is, at the top of its scroll
     · picked-up     the hairline where the old talk ends
     · fold          what Sensei wrote afterwards, on a tap

   The only difference a learner can see between a new session and
   an old one is the WORD on the bar's hand.
   ============================================================ */

/* ---- the standby hand: the hold's centre seat, the hold's MASS, the paused
   pill's shape. It stood 44px tall against the hold's 64 in the same seat, so
   pressing it made the room's one control jump 20px — the only thing that may
   change between these states is the WORD. One --hand-h now feeds all three. */
.chat-dock-start {
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    min-width: var(--hand-h);
    padding: 0 26px;
    height: var(--hand-h);
    border-radius: calc(var(--hand-h) / 2);
    background: var(--color-accent);
    border: 1px solid var(--color-accent);
    color: #fff;
    font-family: var(--font-ui);
    /* 13.5 → --rm-support (2026-07-28, S4b). This standby word and the paused
       pill's cue (.cdh-cue) share the bar's ONE hand mass — see --hand-h above
       — and .cdh-cue already reads --rm-support. One seat, one size; the press
       changes the occupant's WORD, never its size. */
    font-size: var(--rm-support);
    font-weight: 600;
    letter-spacing: 0.01em;
    cursor: pointer;
    z-index: 1;
    /* transform listed for the press (B10) — see the hold's note above; these
       two hands share a seat and must not ease differently. Opacity carries
       the SWAP of occupants (slice four): ink only, no movement, no scale. */
    transition: filter 0.2s, transform 0.12s, opacity 0.16s var(--ease-settle);
}
.chat-dock-start[hidden] { display: none; }
/* Waiting on the tutor: the same word, the same mass, no longer a lever. A
   state change is INK — the hand must not resize, move or re-letter while the
   room reaches Sensei, or the press it answered becomes a second event. */
.chat-dock-start.is-waiting { pointer-events: none; filter: saturate(0.82) brightness(0.99); }
/* …and the leave is ink too: it goes out under the arriving hold and only then
   steps out of the layout (paintStandbyHand hides it 160ms later). */
.chat-dock-start.is-leaving { opacity: 0; pointer-events: none; }
/* Every transform on a centred dock control must restate the centering, or it
   drops half its own size down-right.
   ⛔ The hover's restatement went with B10 (2026-08-10) — the lift opt-out it
   answered (`.chat-dock button:hover { transform: none }`) no longer sets a
   transform, so it guarded nothing. The base rule holds the centering; only
   the PRESS composes with it. Same reasoning, longer, on the hold above. */
.chat-dock .chat-dock-start:hover {
    filter: brightness(1.08);
}
.chat-dock .chat-dock-start:active { transform: translate(-50%, -50%) scale(0.98); }

/* The bar's two centred hands, held still under reduce. Seated HERE rather than
   in the room's shared reduce block ~1200 lines up: both live presses are
   (0,3,0) and so is any companion, so at equal specificity only a LATER rule
   wins. ⛔ And the value is not `none` — these controls are positioned BY a
   transform, so `none` would drop each one half its own size down and right
   instead of stilling it (the strip bug both comments above record). Restate
   the centering; drop only the scale. */
@media (prefers-reduced-motion: reduce) {
    .chat-dock .chat-dock-hold:active,
    .chat-dock .chat-dock-start:active { transform: translate(-50%, -50%); }
    /* The occupants' swap is a CUT here, and it lands: the leaving hand is
       hidden outright (paintStandbyHand asks the same question in JS) and the
       arriving one is simply there, at full ink. */
    .chat-dock-start { transition: filter 0.2s, transform 0.12s; }
}

/* (No facts line on the bar. It said what the rail row and the thread head
   already say, and at any real length it ran straight under the hand — the bar
   is the session's CONTROLS, and a date is not one. Owner, 2026-07-26.) */

/* ---- the thread's head: whose night this is ---- */
/* ⛔ NO auto side margins on anything mounted in the transcript. It is a COLUMN
   flex container, so left/right margins are the CROSS axis: `margin: 0 auto`
   cancels align-items:stretch and the box shrink-wraps its text, landing
   somewhere near the middle instead of on the talk's own left edge (measured
   96px wide at x913 against messages at x674). The column is already exactly
   the room's measure — stretch into it, and pick up the agent line's 10px
   inset so a head, a seam and Sensei's first word share one edge. */
/* ⛔ padding-top 56px PAYS THE TOP FADE. .chat-transcript pads 16px and masks
   transparent 16px → black 72px, so at scroll-top a head at the box's top drew
   the conversation's name at ~0.3 of the ink of the talk under it. 16 + 56 = 72
   puts the title's line box on the mask's opaque stop; move one, move both. */
.chat-thread-head {
    margin: 0 0 26px;
    padding: 56px 10px 14px;
    border-bottom: 1px solid var(--rm-edge);
}
/* The head's three lines join the scale (2026-07-28, S4b): 17 → --rm-en,
   12/12 → --rm-control. A title is English chrome, not a display size, and it
   keeps its rank the room's way — weight 600 and the rule under it — not by
   out-sizing everything else in the column. The facts line and the note are a
   metadata line and an aside: the control voice, distinguished by slant and
   ink. Three sizes became two, and neither is a new number. */
.chat-thread-title {
    font-family: var(--font-ui);
    font-size: var(--rm-en);
    font-weight: 600;
    line-height: 1.4;
    color: var(--color-ink);
}
.chat-thread-facts {
    font-family: var(--font-ui);
    font-size: var(--rm-control);
    color: var(--color-stone);
    margin-top: 5px;
}
/* TWO TENANTS, ONE DRESS. .chat-thread-state (2026-09-17; the letter's line
   until S10 renamed it) says what the review is doing — the rail's rows carry
   NAMES, so this is the one line that says it. It is an aside about the night
   exactly as the fold note is, so it takes the note's own voice rather than a
   second set of numbers: no new size, no new hue, nothing to drift. */
.chat-thread-note,
.chat-thread-state {
    font-family: var(--font-ui);
    font-size: var(--rm-control);
    font-style: italic;
    color: var(--color-stone);
    opacity: var(--rm-ink-quiet);
    margin-top: 6px;
}

/* (Rename / Set aside were dressed HERE first, under the head's facts, and the
   seat measured wrong: the head is the top of the transcript and the room opens
   at the END, so on a real 40-minute night they sat 9,853px above the viewport.
   They live on the rail's OPEN row now — css/voice-portal.css, .vp-srow-hands.
   ⛔ A control inside scrolled content is not available just because it renders.) */

/* ---- the seam: where the kept talk ends and tonight begins ---- */
.chat-thread-seam {
    display: flex;
    align-items: center;
    gap: 14px;
    margin: 26px 0 22px;
    padding: 0 10px;
    font-family: var(--font-ui);
    /* an uppercase tracked eyebrow — the caption voice. Byte-same size
       (--rm-caption is 11px by decision); only the name changes. */
    font-size: var(--rm-caption);
    letter-spacing: var(--rm-track);
    font-weight: var(--rm-track-weight);
    text-transform: uppercase;
    color: var(--color-stone);
    opacity: var(--rm-ink-quiet);
}
.chat-thread-seam::before,
.chat-thread-seam::after {
    content: '';
    flex: 1;
    height: 1px;
    background: rgba(44, 44, 44, 0.09);
}

/* (The letter key and the card before it — the conclusion, on a tap — left
   with the letter, S10, and so did the writing mark that held its seat while
   Sensei wrote. The panel they opened stays: the arrivals tray rides it.) */

/* A4: failure is not absence, in the room's own transcript. The line stands
   where the turns would have (the await line's own voice; 0.92 stone is the
   rail's ruled contrast for text this size); the retry wears the rail's
   underlined hand. */
.chat-thread-fail {
    margin: 26px 10px 0;
    font-family: var(--font-ui);
    font-size: var(--rm-support);
    color: var(--color-stone);
    opacity: var(--rm-ink-quiet);
}
.chat-thread-retry {
    display: block;
    background: none; border: none; padding: 8px 0 0; margin: 0;
    font-family: var(--font-ui); font-size: var(--rm-control);
    color: var(--color-stone); opacity: var(--rm-ink-quiet); cursor: pointer;
    text-decoration: underline; text-underline-offset: 3px;
    transition: color var(--duration), opacity var(--duration);
}
.chat-thread-retry:hover { opacity: 1; color: var(--color-ink); }

/* ---- the letter OVER the talk (S12, owner 2026-07-29) ---- */
/* "Make the side panel cover the voice room." The letter is a reading, and a
   356px column squeezed beside the talk was never one: the sheet's own 58ch
   measure could not engage, so its paper was stripped to make the sliver fit.
   The panel now spans the ROOM's own extent — its left edge is the room's left
   edge (the rail keeps its column), its right is the viewport's — and the
   letter sat at its designed measure on its real paper. (S10 retired the
   letter; the arrivals tray is the panel's tenant now.)
   ⛔ --ctp-w IS GONE, and with it the inset it drove. Nothing moves for the
   panel any more: not the room, not the corner keys, not the dock rail. A
   surface that covers has nothing to push. */
.chat-thread-panel {
    position: fixed;
    top: 0;
    /* measured, never assumed: openThreadPanel writes the room overlay's own
       left edge here (rail out = 244, rail folded = 0) and keeps it fresh
       while the panel is up. The fallback is the rail-less truth. */
    left: var(--ctp-left, 0px);
    right: 0;
    bottom: 0;
    width: auto;
    z-index: 9610;
    display: flex;
    flex-direction: column;
    /* ⭐ SLICE ONE (owner pick 2026-09-17): the panel wears the room's PAPER,
       not the sheet's own card-bright. Sampled on the live record, panel
       ground and sheet interior resolved to the same #FEFDFB — zero
       separation, the app's one written artefact reading as a hairline box
       ruled onto the page. On paper the bright sheet lies on a desk: sixteen
       levels of separation, exactly the figure/ground tokens.css claims for
       card-bright. */
    background: var(--color-paper, #F0EDE8);
    border-left: 1px solid rgba(44, 44, 44, 0.10);
    box-shadow: -18px 0 44px -34px rgba(44, 44, 44, 0.45);   /* the app's sumi; it read (44,42,40) */
    transform: translateX(100%);
    /* ⛔ ONE transition shorthand per rule — a second REPLACES the first (the
       law at the top of this file). `left` joins the list because the rail can
       fold mid-read: the room's left edge animates, and the panel's must ride
       the same curve or a gap opens on the seam for a third of a second. */
    transition: transform 0.34s var(--ease-settle, cubic-bezier(0.22, 1, 0.36, 1)),
                left 0.34s var(--ease-settle, cubic-bezier(0.22, 1, 0.36, 1));
}
.chat-thread-panel.open { transform: translateX(0); }
/* THE REFUSAL, WHERE THE HAND ACTED (S10, A11). A page shown to a room that
   cannot take it is answered by the room's own connection line — except while
   the letter is open, because this panel covers the room's whole extent over
   opaque paper and that line would be refused behind it. So the panel says it,
   for the 3.5s the room's own flash lasts. One quiet line on its own paper wash:
   stone ink, the support size, no gold and no accent — a refusal is not an
   event, and the colour law reserves warmth for what is earned. */
.chat-thread-panel[data-refuse]::after {
    content: attr(data-refuse);
    position: absolute;
    left: 50%;
    bottom: 28px;
    transform: translateX(-50%);
    max-width: min(90%, 34em);
    padding: 8px 14px;
    border-radius: 10px;
    background: var(--color-card-bright, #FDFCF9);
    border: 1px solid rgba(44, 44, 44, 0.08);
    box-shadow: var(--rm-pop-shadow);
    font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
    font-size: var(--rm-support);
    line-height: 1.4;
    color: var(--color-text-secondary, #57534e);
    text-align: center;
    pointer-events: none;
}
/* ⛔ THE PANEL CARRIES ITS OWN DISMISS, ON THE EDGE FACING THE ROOM
   (owner, 2026-07-27: "Sensei's letter and close buttons get pushed outside the
   panel — it should mirror the left panel buttons").
   The rail is the model and it was already right: its hide-control sits INSIDE
   the rail, at the rail's INNER edge, against the seam with the room. The letter
   panel did the opposite — its ✕ sat at its far right, while the room's letter
   KEY sat outside the panel's left edge, shoved along by the inset it had just
   caused. Two marks stranded on a seam, reading as chrome floating mid-screen
   rather than as either surface's corner.
   So the ✕ moves to the panel's LEFT edge (`order`, not DOM order — the title
   still comes first for a screen reader) and the room's letter key stands down
   while the panel is up. One panel, one dismiss, on the seam. Symmetric with the
   rail across the room:   [rail |x]   room   [x| letter]                      */
/* ⛔ THE SEAM DISSOLVES (S12 phase 2, owner pick V2 2026-07-29: "yes please").
   The head above was a BAR — a title, 16px of padding, a hairline — laid across
   the top of a letter that already opens with its own dateline. Two heads on
   one sheet, and the upper one said nothing the lower one did not; worse, it
   was the first thing the eye met on a surface whose whole point is that it is
   handwritten. A letter does not arrive with a title bar.
   So the bar goes and the head stays, out of flow and bare: the title keeps its
   meaning for a screen reader and stops painting, and the mark floats at the
   page's top-left. It lands on the rail key's OWN line — y26, 30×30, 19px in
   from the seam, mirroring the rail's own inset across the room — so the two
   marks are one object seen twice, and the cover reads as the rail's twin.
   (The mark itself is untouched: dress, ring, expander, FOLD_MARK.) */
.ctp-head {
    position: absolute;
    top: 0;
    left: 0;
    z-index: 5;   /* over the page it floats on — nothing else is up here */
    display: flex;
    align-items: center;
    padding: 0;
    border-bottom: none;
}
/* The node and its sequenced id survive: the dialog is still named by it, and
   a screen reader still hears what this panel is. It simply stops painting —
   the visible title of a letter is the letter's own dateline, which is the
   other half of the name (js/chat-mode.js, openThreadPanel). The sr-only
   declarations are the app's, restated here rather than borrowed by class, so
   the head's own rule carries its own truth. */
.ctp-title {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}
/* ⛔ THE PANEL'S DISMISS IS A CORNER KEY IN DRESS, AND A FOLD MARK IN GLYPH
   (owner, 2026-07-28: "two cross close buttons do not look that premium —
   look at how we manage the left side panel"). The seat, ink, hit and ring
   below are the corner-key spec; the MARK inside is not the ✕ — with the
   panel out, the seam would hold two identical crosses meaning two different
   acts (leave the room / fold the letter). It is the rail's panel outline,
   mirrored (js/chat-mode.js FOLD_MARK): divider on the right third, the pane
   that folds. The ✕ is the room's alone again.
   ⛔ THE PANEL'S DISMISS WEARS THE MEASURED DRESS
   (2026-07-28, S4b). It arrived at 14px of U+2715 in stone at 0.45 — 2.12:1 on
   this bright paper, under WCAG 1.4.11's 3:1 for a control's own boundary, on a
   ~24×26px hit. Two panels away, the key that OPENS it had been hand-measured
   to 0.68 three days earlier; the pass simply never crossed the seam. It is the
   same act on the same surface, so it is the same object: 30/r9/0.68 read from
   `.chat-close-btn` above, 3.41:1 here (brighter ground buys
   more contrast for the same ink), and pinned against that rule by
   tests/css/control_room_test.js so the two cannot drift again.
   ⚠️ ONE deviation, deliberate: the hover ground is the ink WASH, not the
   room's arriving paper. The room's keys rest on the talk's warm paper, so
   rgba(253,252,249,0.97) reads as paper arriving under the pointer; the panel's
   ground already IS that bright paper, and the same declaration would paint
   nothing at all. Same gesture, the only value that can carry it here. */
.ctp-x {
    display: inline-grid;
    place-items: center;
    /* out of the head's flow and onto the rail key's own line: y26, and 19px in
       from the seam — the rail's hide key sits exactly 19px in from the other
       side of it (css/voice-portal.css .vp-rail, 244 − 1 border − 18 padding).
       Still the containing block for the expander below; absolute positions it
       just as relative did. */
    position: absolute;
    top: 26px;
    left: 19px;
    width: 30px;
    height: 30px;
    padding: 0;
    box-sizing: border-box;
    border-radius: 9px;
    background: none;
    border: none;
    line-height: 1;
    flex-shrink: 0;
    color: var(--color-stone);
    opacity: var(--rm-ink-mark);
    cursor: pointer;
    transition: color 0.2s, opacity 0.2s, background 0.24s var(--ease-settle, ease),
                transform 0.12s;
}
/* 30 + 7×2 = 44 tall, 30 + 5×2 = 40 wide — the room keys' own arithmetic.
   ⛔ Asymmetric, and it now reaches into nothing at all: with the head's bar
   gone the mark's only neighbour in any direction is the page it floats over,
   whose content the left margin below holds clear of it. (It was written when
   the RIGHT 5px reached into the head's 10px gap and stopped 5px short of the
   title's box; the title has no box now. The insets stay as measured — the hit
   is the room keys' 44×40 and that is the number, not the clearance.) */
.ctp-x::after {
    content: '';
    position: absolute;
    inset: -7px -5px;
}
.chat-thread-panel .ctp-x:hover {
    color: var(--color-ink);
    opacity: 1;
    background: rgba(44, 44, 44, 0.05);
}
.ctp-x:active { transform: scale(0.94); }
/* the room's ring spec, stated — not the app's global 4px halo, which a
   `look that depends on a heuristic` is not a look */
.ctp-x:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 2px;
}
.ctp-body {
    flex: 1;
    min-height: 0;
    overflow-y: auto;
    overflow-x: hidden;
    scrollbar-width: thin;
    /* the room's whole width is the panel's now, so the body stops being a
       column and becomes a PAGE: air at the top, a deep tail so the last line
       of a letter is never the last pixel of the scroller */
    padding: 44px 48px 80px;
}
/* ⛔ THE FLOATING MARK KEEPS ITS OWN GROUND (S12 phase 2). Out of the head's
   flow the mark has no column reserved for it, and the page's 48px of air is
   not enough of one: at the owner's 820 with the rail out the sheet centres
   into a 480px page and its left edge lands at x292, one pixel inside the
   mark's box. The letter would slide under the control that folds it. A wider
   left margin at narrow widths is the whole fix — at 1440 the page is roomy
   enough that the sheet never comes near it. */
@media (max-width: 900px) {
    .ctp-body { padding-left: 82px; }
}
.ctp-body::-webkit-scrollbar {
    width: 3px;
}
.ctp-body::-webkit-scrollbar-track {
    background: transparent;
}
.ctp-body::-webkit-scrollbar-thumb {
    background: rgba(44, 44, 44, 0.08);
    border-radius: 3px;
}
/* (The letter's measure — .ctp-body :is(.vpl-sheet, .vpl-record, .vpl-nb) — and
   the two doors under it, the brief and the Teacher's notes (.ctp-door,
   .ctp-notes-label), left with the letter, S10. The tray carries its own
   measure: css/voice-portal.css .vp-tray.) */
@media (prefers-reduced-motion: reduce) {
    /* the END STATE still lands — only the travel is dropped */
    .chat-thread-panel { transition: none; }
    /* the panel's dismiss keeps its ink change and loses the press (the corner
       keys' own treatment, applied here because this key now wears their dress) */
    .ctp-x { transition: color 0.2s, opacity 0.2s; }
    .ctp-x:active { transform: none; }
    /* (.chat-room-keys / .cu-dock-rail were the third and fourth members of
       this pair, back when they glided the panel's whole 440px on a
       `transition: right`. S12 made the panel a cover; they no longer move for
       it, so there is no travel here to drop.) */
}

/* ---- standby: no live chrome, and the door card only when there is no thread ----
   (the standby label rule is GONE: setStandby writes '' and setVizLabel drops
   .is-on, so the line is silent at rest for the one reason that is true — there
   is nothing connecting. A second owner here once meant a state class could
   hide a live "Connecting…"; see the connection-line note above.) */

/* ── ONE MEASURE FOR THE WHOLE SESSION (2026-07-27, owner ruling) ───────────
   ⛔ THE ROOM IS 640px WIDE. ALWAYS. Nothing about a session may change it.

   There were two width overrides here — `.state-standby.has-landing` set the
   container to 860px and re-pinned the bar at 496px inside it. Both are DELETED,
   not re-qualified. What they bought was one wide beat at rest, and what they
   cost was the room snapping 860px → 640px at the exact moment the learner
   pressed the one hand that starts the talk: every line he was reading re-wrapped
   and slid ~110px right, on the press that was supposed to change nothing. That
   was the fourth of the five things firing at once on that press.

   The 860px existed for ONE reason, stated in the rule it replaced: "the orb
   takes 132px off the front of the line, so at 640px the greeting collapses
   toward one word per line." The landing no longer carries an orb (lobbyNode,
   voice-portal.js — see the one-mark note below), so the reason is gone with it.
   What is left is a greeting, an eyebrow, one lead capped at 46ch (~460px) and
   one anchor. That reads at 640px with room to spare.

   So the transcript's own reading column is now the room's only measure, from
   the greeting through "Connecting…" to the last turn of the night — including
   a resumed thread, which used to get 860px purely because .state-standby
   happened to match while it sat there waiting for Resume.

   ⛔ The BAR keeps its pinned 496px geometry (verify_voice_transcript) — which
   is simply what it measures inside a 640px column, so it needs no rule at all
   now. That is the point: one width means nothing has to be re-pinned to it. */
/* ⛔ ONE MARK, AND IT IS THE ROOM'S (2026-07-27, owner ruling). This used to
   read `.state-standby.has-landing .voice-viz { display: none }` — the room hid
   its OWN ensō while the landing was up, because the landing carried a second
   orb: .vp-orb in voice-portal.css, which that file still labels "chat-mode
   v18's lantern". A fossil of a mark this room retired. Pressing Talk therefore
   swapped one object for a different object in the same seat, which is the
   loudest half of "it feels like we go from one room into another".
   The landing no longer draws an orb (lobbyNode, voice-portal.js), so the ensō
   simply stays where it always was — still ink at rest (state-standby, ~line
   438), alive the moment there is a turn to show. Nothing to hide, nothing to
   swap, one mark from the greeting to the last word of the night. */
