/* ============================================================
   Voice Portal — Sensei's place (the Tutor home around the room)
   The 床の間 (Tokonoma) shell + the journey map, ported from the
   picked demos (design-mockups/voice-portal/, PR #737 + premium
   pass) into the app as an overlay AROUND the live room:
     app < #voicePortalOverlay (9600) > the room, SEATED inside it
   (the 4900-under-5000 ladder above was true until S3R reseated the
   room into .vp-stage; the portal is the outer surface now — see the
   z-index ladder at the top of css/chat-mode.css, and note that a
   number read off a comment is how the dock rail spent a week behind
   the letter panel.)
   One house: the room's shoji slides open INSIDE this lobby and the
   lantern orb travels between them. Everything scoped to the
   overlay; the app's own chrome is opted out (cards rest).
   Desktop-only, localhost-gated (same gate as the room).
   ============================================================ */

/* The paper rises OVER whatever is up — the hall (z 9500) included — in one
   calm motion: opacity + a small settle, compositor-only (no blur of the app
   beneath, no reflow). Every geometric change (scroll lock, chrome hide, hall
   dismissal) waits under body.voice-portal-covered, added only once the paper
   is fully opaque, so nothing ever shifts in view. */
#voicePortalOverlay {
  position: fixed; inset: 0; z-index: 9600;
  display: none;
  background: var(--color-paper);
  color: var(--color-ink);
  font-family: var(--font-ui);
  -webkit-font-smoothing: antialiased;
  overflow-y: auto;
  scrollbar-width: thin;
  opacity: 0;
  transform: translateY(10px);
  transition: opacity 0.4s var(--ease-settle, ease),
              transform 0.45s var(--ease-settle, ease),
              filter 0.5s ease;
}
/* the room's 3px hairline, on every scroller in this file. ⛔ BOTH HALVES OR
   NEITHER: `::-webkit-scrollbar` is Chrome/Safari, `scrollbar-width` is what
   Firefox reads. The room styled its record to a hairline and the portal drew
   the platform's full-fat bar three inches away, on the same screen. */
#voicePortalOverlay::-webkit-scrollbar { width: 3px; }
#voicePortalOverlay::-webkit-scrollbar-track { background: transparent; }
#voicePortalOverlay::-webkit-scrollbar-thumb { background: rgba(44, 44, 44, 0.08); border-radius: 3px; }
#voicePortalOverlay.visible { opacity: 1; transform: translateY(0); }
/* ------------------------------------------------------------
   THE ROOM AS THE ENTRY (S2, owner model 2026-07-26)

   The room does NOT nest inside this overlay. It stays a fixed,
   viewport-anchored layer and the rail becomes a left INSET on
   it, so everything measured inside the room keeps meaning what
   it meant: the bounded transcript column, the dock rail, the
   bottom anchor, the clip-path box. Nesting would have cost all
   five, plus the overlay's transform makes it a containing block
   for the room's fixed children.

   So while the room is live the PAPER steps aside and the rail
   stays. (The portal cannot duck beneath the room: the chat
   overlay lives inside #app, whose stacking context sits at z 10,
   while the portal is a body-level layer — any portal opacity
   paints OVER the room. First live walk 2026-07-11.) Instead the
   overlay goes transparent and inert, and only the rail keeps
   its paint and its pointer events.
   ------------------------------------------------------------ */
/* 252, not 244 (owner 2026-09-17, first look at the merged rail): the marks that
   hang in the margin — the door seals 道/新, the ember, the ＋ — sat 5px from
   the window's edge, where a browser sidebar or a screenshot's crop takes them.
   The rail's left padding grows 18 → 26 so the margin column starts at x13 and
   the text edge at 36; the rail grows by the same 8 so every row keeps its
   measure (186px) and no title clips more than it did. */
body.voice-portal-open { --vp-rail-w: 252px; }
body.voice-portal-open.vp-rail-collapsed { --vp-rail-w: 0px; }

body.chat-mode #voicePortalOverlay {
  background: transparent; pointer-events: none; overflow: hidden;
  /* ⛔ the entrance transition above is REPEATED here on purpose. A second
     `transition` shorthand replaces the first rather than adding to it, and
     since S3R the room is up from the moment the portal opens — so this rule
     applies on the very first frame and used to leave the overlay with
     `transition-property: background` alone. The paper's own 0.4s rise was
     dead: the rail snapped into place at frame one while the room behind it
     took 0.6s to open, and Sensei's place looked like it loaded in two
     halves. Whatever this rule transitions, it carries opacity + transform. */
  transition: background 0.4s ease,
              opacity 0.4s var(--ease-settle, ease),
              transform 0.45s var(--ease-settle, ease);
}
/* ⛔ ONE RAMP CARRIES THE WHOLE PLACE (slice four, 2026-09-17). Since S3R the
   room is a CHILD of this overlay, and it ran its own 0.4s fade inside this
   one — nested opacity MULTIPLIES, so the first ~150ms of every arrival was
   thinner than either ramp alone (0.585 against 0.765 at 100ms) and the hall's
   painting read straight through the greeting. The paper is the only thing
   that ramps now; the room is up at full ink inside it, and everything in the
   room still arrives with the room. (Measured on the shots: a uniform 0.34
   cover, i.e. one crossfade, not a stuck state.) */
body.voice-portal-open .chat-mode-overlay { opacity: 1; }
/* …and the place underneath is CLEARED FIRST, so no frame ever holds two
   screens at once: hall and app out on 0.16s, the paper rising 0.14s later.
   The delay is a longhand on its own rule — both transition lists in this file
   are complete and a second shorthand would replace one — and it is scoped to
   the entrance, so LEAVING is never held for 140ms. It is seated BELOW the
   body.chat-mode rule above: that rule ties with it on specificity and repeats
   the whole shorthand, which would reset the delay to zero. */
body.voice-portal-opening #app,
body.voice-portal-opening .home-hall-root {
  opacity: 0; transition: opacity 0.16s var(--ease-settle, ease);
}
body.voice-portal-opening #voicePortalOverlay { transition-delay: 0.14s; }
body.chat-mode #voicePortalOverlay .vp-shell { background: transparent; }
body.chat-mode #voicePortalOverlay .vp-main { opacity: 0; pointer-events: none; transition: opacity 0.3s ease; }
body.chat-mode #voicePortalOverlay .vp-rail { pointer-events: auto; }
/* ⛔ LOAD-BEARING since the room moved inside (2026-07-27). The overlay above
   turns pointer-events OFF for itself and everything under it, which was
   harmless while the room was a separate body-level layer — it is now a CHILD,
   so without this the entire room would be click-through: the bar, the hold,
   the ＋, the transcript's reading doors, all of it dead. Any new element
   seated in the stage needs the same line. */
body.chat-mode #voicePortalOverlay .chat-mode-overlay { pointer-events: auto; }

/* ⛔ AND SO DOES EVERY NEW THING SEATED IN THE OVERLAY. The rule above is not a
   quirk of the rail — the overlay turns pointer-events OFF for itself and
   everything under it, so ANY element appended to it is born click-through
   (and el.click() in a CDP suite bypasses pointer-events entirely — only
   elementFromPoint catches it). If you append to #voicePortalOverlay, you add
   a line here. The two lines that stood here left with their subjects: the
   ⋯ menu seats on the BODY since A6 (the overlay's transform capped it under
   the body-level letter panel) and the Undo echo retired with set-aside. */

/* collapsed: the rail slides off the left edge and the room goes full bleed.
   ⛔ AND IT LEAVES THE TAB ORDER WITH IT. transform moves a thing without
   hiding it, so every button in a rail the learner had put away kept its seat
   in the walk: Tab from the room landed on controls nobody could see. The
   visibility flip is DELAYED by the slide's own 0.34s on the way out and
   instant on the way back, so the rail is never invisible while it is still
   travelling. ⛔ ONE complete transition list per rule — a second shorthand
   replaces the first rather than adding to it. */
body.vp-rail-collapsed #voicePortalOverlay .vp-rail {
  transform: translateX(-100%); visibility: hidden;
  transition: transform 0.34s var(--ease-settle, ease), visibility 0s 0.34s;
}

/* THE PANEL TOGGLE, second position. One control, one glyph, one corner: it
   lives in the rail's top row while the rail is out, and against the viewport's
   top-left once the rail has gone. It is NOT a way out of Sensei's place —
   leaving is the room's own ✕, top right. (Until 2026-07-26 this corner held a
   ✕ that closed the whole portal, so hiding the history quit the room instead.
   Hide and leave are different acts and may not share a glyph.) */
.vp-rail-handle { display: none; }
/* Same key, other seat — so the same spec (see .vp-rail-x below for the
   measurement and the reference block). Its hover never inked the mark, only
   the wash: half of one control wearing half a dress. */
body.voice-portal-open.vp-rail-collapsed .vp-rail-handle {
  display: flex; align-items: center; justify-content: center;
  position: fixed; left: 14px; top: 14px;
  width: 30px; height: 30px; z-index: 9601; pointer-events: auto;
  background: rgba(253, 252, 249, 0); border: 1px solid rgba(44, 44, 44, 0);
  border-radius: 9px; cursor: pointer; padding: 0;
  color: var(--color-stone); opacity: var(--rm-ink-mark);
  /* ⛔ IT ARRIVES WITH THE RAIL, not 340ms ahead of it. display:none→flex is not
     animatable and opacity was never in the transition list, so the handle
     painted at full strength on frame one — on top of a rail still three
     hundred milliseconds from leaving — and vanished at frame one on the way
     back, leaving 340ms with neither control on screen. display:none restarts
     the animation on every arrival, which is exactly the behaviour wanted here.
     ⛔ The base opacity stays 0.68 and the keyframe carries only `from`, so
     reduce lands the end state by construction. */
  animation: vpHandleIn 0.34s var(--ease-settle, ease);
  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;
}
@keyframes vpHandleIn { from { opacity: 0; } }
/* flat on hover, for the reason recorded at .vp-rail-x below.
   ⛔ SCOPED LIKE ITS BASE RULE, NOT LIKE THE OVERLAY (B10, 2026-08-10). These
   two read `#voicePortalOverlay .vp-rail-handle` until today and therefore
   matched NOTHING: A6 (92334f8496) moved the handle out to a direct child of
   <body> (js/voice-portal.js:1811) and renamed the selectors written as
   `#voicePortalOverlay > .vp-rail-handle`, but missed the two written as
   descendants a hundred lines earlier — so the handle has had no hover paint
   and no press at all since. ⛔ `body > .vp-rail-handle` is NOT enough for the
   hover: at (0,2,1) it loses to the base rule above, which is (0,3,1) and
   declares every property this one sets (background, border-color, color,
   opacity). Wearing the base rule's own scope puts this at (0,4,1), one class
   clear. The press has no opponent left — the portal roster's `transform:
   none` went with B10 — but it still has to MATCH. */
body.voice-portal-open.vp-rail-collapsed .vp-rail-handle:hover {
  color: var(--color-ink); opacity: 1;
  background: rgba(253, 252, 249, 0.97); border-color: rgba(44, 44, 44, 0.10);
}
body.voice-portal-open.vp-rail-collapsed .vp-rail-handle:active { transform: scale(0.94); }
/* 30 → 44×44 (touch floor). Nothing sits within 14px of this corner, so the
   expander is symmetric. */
.vp-rail-handle::after { content: ''; position: absolute; inset: -7px; }
@media (prefers-reduced-motion: reduce) {
  /* the entrance lands its end state with no motion at all: what is underneath
     is cleared in the same frame, and the paper waits for nothing */
  body.voice-portal-opening #app,
  body.voice-portal-opening .home-hall-root { transition: none; }
  body.voice-portal-opening #voicePortalOverlay { transition-delay: 0s; }
  #voicePortalOverlay .vp-rail { transition: none; }
  /* the collapsed rule outranks the base one, so its own list has to be killed
     by name or the transform would still travel under reduce */
  body.vp-rail-collapsed #voicePortalOverlay .vp-rail { transition: none; }
  #voicePortalOverlay .vp-rail-x,
  body.voice-portal-open.vp-rail-collapsed .vp-rail-handle {
    transition: color 0.2s, opacity 0.2s; animation: none;
  }
  /* …at the scope its live twin now wears (B10). A reduce companion that does
     not match is a press that travels under reduce anyway. */
  body.voice-portal-open.vp-rail-collapsed .vp-rail-handle:active { transform: none; }
  /* ⛔ THE .vp-rail-x COMPANION IS NOT HERE ANY MORE (B10, 2026-08-10) — it was,
     and it never won. Its live press is `#voicePortalOverlay .vp-rail-x:active`
     at :1106, identical specificity (1,2,0) and ~930 lines LATER, and a media
     query adds nothing to specificity: at a tie the later rule takes it. So the
     hide key has been squashing under reduce for as long as the companion has
     existed, while its neighbour the handle held still. It now sits beside its
     own rule, where source order can only help it. Same move, same reason, for
     the + button beside :709. The handle's companion stays HERE because its
     live rule is at :162, above this block. */
}
/* once covered: lock the page scroll and step the app chrome out (all of this
   happens beneath opaque paper — invisible, so nothing can shake) */
body.voice-portal-covered { overflow: hidden; }
body.voice-portal-covered .ink-wash-nav,
body.voice-portal-covered .hero-top-bar,
body.voice-portal-covered .app-branding-topleft,
body.voice-portal-covered .intro-mode-indicator { display: none !important; }
/* (.grid-filter-bar / .grid-search-bar / .nav-tools-row dropped 2026-07-28 —
   the free-text filter chrome was retired with the lookup palette, #772/#773;
   nothing in js/ or index.html has emitted those classes since.) */

#voicePortalOverlay, #voicePortalOverlay * { box-sizing: border-box; }
#voicePortalOverlay [hidden] { display: none !important; }
/* 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 — same ink here. */
#voicePortalOverlay ::selection { background: rgba(61, 90, 128, 0.18); }

/* Japanese always Klee One; English always Inter. Never let them drift.
   ⛔ THE `#chatModeOverlay .ces-lobby …` HALVES ARE GONE (2026-07-28). #cesLobby
   is a real socket in index.html, but the only thing any JS writes to it is
   emptiness (chat-mode.js renderDoorCard: `seat.textContent = ''`); the landing
   seats in .chat-landing-head. A DESCENDANT of an always-empty node cannot
   match, so those halves were dressing nothing. */
/* ⛔ THE SIDE PANEL IS BODY-LEVEL, so it needs saying twice. Everything in it
   is portal-built (the arrivals tray since S10; the letter before it) but it
   lives OUTSIDE #voicePortalOverlay — so its Japanese fell back to the body's
   Noto Sans and a Latin serif. Both scopes, every time. */
#voicePortalOverlay .jp,
.chat-thread-panel .jp { font-family: var(--font-jp); }
#voicePortalOverlay .romaji,
.chat-thread-panel .romaji { font-family: var(--font-ui); font-style: italic; color: var(--color-romaji); }
/* (the record's and the plan's prose-Japanese ink — .vp-rec-sec / .jy-proof-*
   — left with the letter and the plan, S10) */

/* ---- native fidelity: opt out of the app's raised-button chrome (cards rest;
   the portal's own hover rules re-enable their gentle motion further down) ----

   ⛔ SCOPED TO THE PORTAL'S OWN FURNITURE, NEVER "everything under this ID"
   (2026-07-27). This read `#voicePortalOverlay button:hover { transform: none }`,
   which was true for as long as the room was a SEPARATE overlay. The moment the
   room was seated inside the portal (one screen, ensureShell/seatRoom) that
   blanket became a rule over every button in the room — and it carries an ID,
   so at (1,1,1) it outranks the room's own (0,3,0) hover rules and simply won.
   Two controls are centred BY transform, so killing their transform dropped
   them half their own size down and right, out from under the cursor:
     · #chatStartBtn (Talk to Sensei / Resume) — measured 73px × 22px
     · #chatPauseBtn (the hold)                — measured 32px × 32px
   Which then FLICKERED: the button moves off the pointer, :hover ends, it snaps
   back, :hover fires again. The owner caught it within a minute.
   chat-mode.css already warned about exactly this ("Every transform on a centred
   dock control must restate the centering") — the warning was right and the
   defence was specificity, which an ID walks straight through.
   The lesson is the general one: a rule scoped to a CONTAINER'S ID annexes
   whatever is later moved into that container. Scope to what you own.
   The portal owns the rail, the reference surfaces and the handle. The landing
   is portal content sitting in the room, so it is named in its seat there —
   ONE seat, `.chat-landing-head` (the `.ces-lobby` half went 2026-07-28: that
   socket is only ever emptied, so nothing inside it can match).

   ⛔ AND THE LIST HAS TO GROW WITH THE PORTAL — the exact mirror of the bug
   above (owner, 2026-07-27: "make the menu options not shake as I hover between
   them"). That one was scoped too WIDE and annexed the room's buttons; this was
   scoped too NARROW, and the row menu and the echo are appended to the OVERLAY
   rather than into .vp-rail or .vp-main — so they fell straight back under the
   global `button:hover` (components.css:385), which lifts 2px and drops a
   6px/20px card shadow. Measured: each item rose to y 371 from 373 and gained
   `0 6px 20px`, so crossing from Rename to Remove made both jump. They also wore
   a `0 4px 15px` at REST — a card shadow on a row inside a menu that already has
   one. ⭐ Both halves of this trap are the same question: WHICH ELEMENTS DOES
   THIS SELECTOR ACTUALLY REACH TODAY? Ask it again every time the portal grows
   a new surface, and add it here.

   ⛔ AND THE `transform: none` HALF IS GONE (B10, 2026-08-10) — the same
   deletion as the room's roster (css/chat-mode.css, see the long note there).
   PR #1063 removed the global hover lift these lines were written to fight, so
   the transform opt-out no longer had a rule to answer; what it still did was
   out-rank the portal's own designed `:active` squashes, because a pressed
   button is also a hovered one. `box-shadow: none` stays — components.css:395
   still ships the hover shadow. */
#voicePortalOverlay :is(.vp-rail, .vp-main) button,
body > .vp-menu button,
body > .vp-rail-handle,
#chatModeOverlay .chat-landing-head button { box-shadow: none; }
#voicePortalOverlay :is(.vp-rail, .vp-main) button:hover,
body > .vp-menu button:hover,
body > .vp-rail-handle:hover,
#chatModeOverlay .chat-landing-head button:hover { box-shadow: none; }

/* ── ONE FOCUS RING, AND IT IS THE ROOM'S (2026-07-28) ──────────────────────
   The room draws `outline: 2px solid var(--color-accent); outline-offset: 2px`
   on each of its controls. The portal drew nothing of its own and fell through
   to the app's global mark (css/base.css) — a 2px outline PLUS a 4px indigo
   box-shadow halo. So the keyboard indicator changed shape at the rail seam:
   tab across from a rail row into the room and the same act wore two marks.
   The room's is the one that stays, because it is the mark drawn beside the
   speech; the halo is app chrome from a different screen.
   base.css is deliberately untouched — it is app-wide and not S2's to re-rule.

   ⛔ SCOPED TO THE PORTAL'S OWN FURNITURE, exactly like the opt-out above, and
   for the recorded reason (:139-170). `#voicePortalOverlay` CONTAINS the room
   now, and an ID walks straight through the room's own (0,3,0) rules — which
   would flatten the two controls that declare a different offset on purpose
   (.chat-dock-hold at 3px, .cu-dock-close at 1px). Same list as the opt-out;
   it grows with the portal, never with a wildcard. */
#voicePortalOverlay :is(.vp-rail, .vp-main) :is(button, [role="button"], a):focus-visible,
body > .vp-menu :is(button, [role="button"], a):focus-visible,
body > .vp-rail-handle:focus-visible,
/* ⛔ AND THE PANEL, because S14 put real VERDICTS in it. The list above grows
   with the portal, and this is the growth: the arrivals tray's rows carry the
   same Activate / Skip / Count-as-a-pass hands the words surface carries, and
   in the panel they fell through to the app-wide mark (css/base.css) — a 2px
   outline PLUS a 4px indigo halo — so the keyboard indicator changed shape at
   the seam between the surface and the tray showing the same rows. The panel's
   own dismiss already declares this exact spec by hand (.ctp-x); this says it
   once for everything in there. Same values, so .ctp-x is unchanged either way. */
.chat-thread-panel :is(button, [role="button"], a):focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
  box-shadow: none;              /* the global 4px halo — the room draws no halo */
}
/* …but a card must not DIP when you reach it by keyboard: the line above is
   also what would strip its resting shadow. (1,3,1) beats the (1,3,0) above. */
#voicePortalOverlay :is(.vp-rail, .vp-main) :is(button, a).vp-card:focus-visible { box-shadow: var(--shadow-rest); }

/* The letter and the record render in the ROOM's side panel, not in this
   overlay (one room, one conversation surface — S3R). The portal still BUILDS
   them, so their ink has to reach where they are mounted; both token blocks
   name that seam explicitly. */
#voicePortalOverlay, .chat-thread-panel, #chatModeOverlay {
  /* portal-local semantic tints, all derived from the token palette.
     #chatModeOverlay is here because the LANDING renders inside the room
     (owner ruling 2026-07-26) and its shelf reads these — without them a
     pill's background resolves to nothing and paints white on white. */
  /* (--vp-solid deleted 2026-07-29, S9 — the last matcha in Sensei's place.
     S7 took --rel-similar out of the status vocabulary (it is the word-graph's
     relation hue, and a green "solid" chip made a machine state look like a
     semantic edge) and left it on loan to .jy-tl-dot until S9 rebuilt that
     timeline. S9 made the shelf earned end to end, so the dot went gold and
     this token lost its last consumer; W2 (2026-09-20) then took the dot off
     the shelf entirely — a colour every row wears tells no row apart. The
     eviction is total, and the colour law enforces it as such: any matcha in
     either file is red.) */
  /* (--vp-forming / --vp-growing deleted 2026-07-28, S7: the chips they dressed
     now grade in ink, so both tokens had zero consumers. design-mockups keeps
     its own copies in design-mockups/voice-portal/portal.css.) */
  /* (--vp-watching deleted 2026-09-20, W2: its one consumer was .jy-pchip.b-watch,
     which went off the page with the admin drawer that emitted it. A token with
     no consumer documents an intention rather than a decision — the exact thing
     --color-learner's tombstone in css/chat-mode.css was written about.) */
  /* the room's two named edges (css/chat-mode.css --rm-edge*), pixel-identical;
     the fallback holds in #chatModeOverlay, which no --rm scope covers once the room closes */
  --vp-line: var(--rm-edge, rgba(44, 44, 44, 0.07));
  --vp-line-strong: var(--rm-edge-strong, rgba(44, 44, 44, 0.12));
}

/* ⛔ THE LANTERN ORB IS RETIRED (2026-07-27, owner ruling). Its own heading
   said what was wrong with it: "chat-mode v18's lantern, lobby-sized" — the
   portal was carrying a COPY of a mark the room had already replaced with the
   ensō (law #747). Two orbs for one room, and pressing Talk swapped one for
   the other in the same seat, which is the loudest half of why the place read
   as two places. The room's ensō is the one mark now, at rest and in the talk;
   the landing draws no orb at all (lobbyNode, voice-portal.js).
   ⛔ AND THE RAIL'S SMALL SPHERE WENT THE SAME WAY (feel review 2026-09-16,
   slice two; owner pick 09-17). `.vp-orb-mini` was a 30px copy of the lamp
   parked in the rail head — pasted with the mockup port and never edited
   (its eleven values had drifted from the lamp's), no hover, no focus, and a
   mascot avatar in a header by CLAUDE.md's own NEVER list. The head is type
   now (.vp-brand-jp / .vp-brand-title, in the rail block): the lamp is the
   room's one sphere. The warm-brown inset shadow that only it was allowed to
   wear left with it (tests/css/popover_spec_test.js, stray inks). */

/* ------------------------------------------------------------
   TYPE SCALE
   ------------------------------------------------------------ */
/* (.vp-eyebrow left with its last emitters, the record and its strip, S10) */
/* (`.vp-h1` RETIRED 2026-07-31 with its last writer. Both reference surfaces
   open on their own name now — the words surface's 新しい言葉 (S8), on BOTH
   its branches (the plan desk's 道 head left with the plan, S10). Nothing
   else in the portal wants a 26px heading; the type law's DISPLAY entry for it
   left in the same commit.) */
.vp-h2 { font-family: var(--font-ui); font-size: var(--rm-en); font-weight: 600; letter-spacing: 0.01em; color: var(--color-ink); }
.vp-body { font-family: var(--font-ui); font-size: var(--rm-en); line-height: 1.6; color: var(--color-stone); }
.vp-small { font-family: var(--font-ui); font-size: var(--rm-support); line-height: 1.5; color: var(--color-stone); }
.vp-faint { color: var(--color-stone); opacity: var(--rm-ink-quiet); }

/* (.vp-anchor left with anchor(), its plan-only emitter, S10) */

/* ------------------------------------------------------------
   RESTING CARD + ACTIONS
   ------------------------------------------------------------ */
.vp-card { background: var(--color-card); border: 1px solid var(--vp-line); border-radius: var(--radius-lg); box-shadow: var(--shadow-rest); padding: 18px 20px; }
#voicePortalOverlay a.vp-card, #voicePortalOverlay button.vp-card {
  display: block; text-align: left; cursor: pointer; width: 100%;
  transition: border-color var(--duration) var(--ease-settle), transform var(--duration) var(--ease-settle);
}
#voicePortalOverlay a.vp-card:hover, #voicePortalOverlay button.vp-card:hover { border-color: var(--vp-line-strong);}

.vp-btn {
  font-family: var(--font-ui); font-size: var(--rm-support); font-weight: 600;
  border-radius: var(--radius-full); padding: 12px 30px; border: 1px solid transparent; cursor: pointer;
  transition: filter var(--duration) var(--ease-settle), transform 0.12s var(--ease-settle), background var(--duration), border-color var(--duration);
  display: inline-flex; align-items: center; gap: 9px;
}
/* (--primary and --ghost deleted 2026-07-28: both are mockup-era variants that
   the live portal never emits — the only construction of either is in
   design-mockups/voice-portal/portal.js, which loads its OWN portal.css.
   .vp-btn--quiet below is live; so is .vp-add-btn.ghost further down, which is
   a different class despite the word.) */
.vp-btn--quiet { background: transparent; color: var(--color-stone); border-color: transparent; padding: 8px 12px; font-weight: 500; }
/* the quiet second verdict inside a word row — one class, because five copies
   of `style="padding:6px 12px;font-size:12.5px"` were invisible to the type law
   and drifted from every token the surface uses (S8) */
.vp-btn--row { padding: 6px 12px; font-size: var(--rm-support); }
/* the panel joins the scope for the quiet halves of the same verdict pairs (S14
   arrivals tray: Skip · Not now · Keep as is) */
#voicePortalOverlay .vp-btn--quiet:hover,
.chat-thread-panel .vp-btn--quiet:hover { color: var(--color-ink); }
/* (.vp-btn .chev DELETED 2026-08-06, B9. No `.chev` has ever been written into a
   .vp-btn: all five live constructions pass literal class strings (Skip · Let it
   go x2 · Not now · Keep as is) and the token appears nowhere in either JS file.
   ⛔ IT WAS BEING KEPT ALIVE BY ITS OWN PIN — tests/css/type_voice_test.js
   allowlisted its 15px as a GLYPH, so the type law counted a surface that does
   not exist and the rule read as ruled-on rather than forgotten. The allowlist
   entry goes with it in the same commit, and the ink law now carries the
   emitter check that would have caught it years earlier.) */

/* weight 400 since the Muji cut (owner pick 2026-07-28) — the ask is content,
   not a heading; margin-inline centres the 46ch cap on the landing's one axis */
/* (.vp-focus-lead retired 2026-07-31 with the landing's aim line — the
   greeting ladder owns the landing now; the aim lives on the Study plan door) */

/* chips */
.vp-chip {
  display: inline-flex; align-items: center; gap: 6px;
  font-family: var(--font-ui); font-size: var(--rm-caption); letter-spacing: var(--rm-track); font-weight: var(--rm-track-weight); text-transform: uppercase;
  padding: 3px 9px 3px 8px; border-radius: var(--radius-full); border: 1px solid currentColor;
}
.vp-chip .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
/* HOW SOLIDLY YOU KNOW IT — graded neutrals, one wash (S7, owner pick B).
   Four hues said four unrelated things (matcha=relation, amber=earned,
   indigo=interactive) about ONE ordered scale. The scale is ordered, so ink
   WEIGHT carries it — over a single background so the row reads as one family.
   The border and .dot are currentColor, so they follow without a rule.
   ⛔ THREE QUARTERS OF THAT SCALE NEVER EXISTED (deleted 2026-08-06, B9's
   gate-hygiene pass). .solid, .growing and .watching are gone: this room has
   exactly ONE thing that has ever built a .vp-chip — the hard-coded literal
   'vp-chip forming' in takeChip(), js/voice-portal.js — and no composed
   'vp-chip ' + x anywhere, so the other three could not be reached even in
   principle. Two of them were PINNED by tests/css/colour_law_test.js, which is
   how a law came to defend a four-step scale on behalf of a single chip and
   count itself the more thorough for it. The pins go in the same commit.
   (If the scale is ever really wanted, it comes back with its emitter.) */
.vp-chip.forming { color: var(--color-stone); background: rgba(140,133,120,0.08); }

/* (.vp-won / .vp-won-seal / .vp-missed — the record's win verdict — left with
   the record, S10: no win is scored any more) */

/* nav */
/* ⛔ ONE TEXT EDGE (feel review 2026-09-16, slice two; owner pick 09-17). The
   rail's text used to start on five left edges — 28 (the headings), 30 (the
   rows, behind a 2px selection border), 41 (a row wearing the ember), 52
   (these labels, behind their seals) and 69 (the name, behind its sphere) —
   which is what made a label rail read as a chat app's sidebar. Every line
   starts at 28px now (18 rail padding + 10 row padding), and the marks that
   pushed text around hang in the MARGIN, seated absolutely: the two seals
   here, the ember on a row and on the words status line, the ＋ of the New
   session hand. Position, never padding — every row keeps its measure. */
.vp-nav-item {
  position: relative;
  display: inline-flex; align-items: center; gap: 9px;
  font-family: var(--font-ui); font-size: var(--rm-support); color: var(--color-stone);
  margin-left: -13px; padding: 8px 12px 8px 23px; border-radius: var(--radius); cursor: pointer; background: none; border: none;
  transition: color var(--duration), background var(--duration);
}
/* The seal is the JP half of a chrome duet (道 Study plan) — TEXT, so it sits
   on the text rung, never the mark rung — and at a size a 13-stroke kanji
   survives: 17px is the glyph axis, sized by its target like the 16px keys
   and 12px carets (at 13px on 0.68 it measured as a grey smudge). It hangs
   6px left of the axis at x5-22, INSIDE the row's own plate: the row reaches
   13px into the margin (margin-left/padding-left above) so its active wash
   and focus ring hold the seal instead of cutting through it. The active
   row's accent reaches the seal by inheritance. */
.vp-nav-item .seal {
  position: absolute; left: 0; top: 16px; transform: translateY(-50%);
  font-family: var(--font-jp); font-size: 17px; line-height: 1; opacity: var(--rm-ink-quiet);
}
#voicePortalOverlay .vp-nav-item:hover { color: var(--color-ink); }
.vp-nav-item.active { color: var(--color-accent); }
.vp-nav-item.active .seal { opacity: var(--rm-ink-full); }
/* the row grows a second line ONLY when it has something to say, so a quiet
   queue keeps the menu a plain list of four words */
/* position: relative — the containing block of the status line's ember (below).
   ⛔ NOT the status span itself: it clips its own overflow (nowrap + ellipsis),
   and an ember seated against it at left:-14px was clipped clean off. */
.vp-nav-body { position: relative; display: flex; flex-direction: column; gap: 2px; min-width: 0; text-align: left; }
.vp-nav-item.has-status { align-items: flex-start; padding-top: 7px; padding-bottom: 7px; }
.vp-nav-item.has-status .seal { top: 15px; }
.vp-nav-status {
  font-family: var(--font-ui); font-size: var(--rm-control); font-weight: 600; letter-spacing: 0.01em;
  color: var(--color-stone); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* ⛔ THE TONE CLASSES CARRY NO COLOUR (S7, owner pick B, 2026-07-28). Five
   states wore five hues — amber, indigo, matcha, dimmed stone — and between
   them they spent the whole palette on what the MACHINE is doing. The words
   already differ ("6 built to review" / "2 being built"); the ink does not.
   The classes STAY in the DOM: they are the JS writers' output and the ember's
   only hooks. Every tone now inherits the base .vp-nav-status stone.
   the ember — the ONE status colour: a small mark meaning "something waits for
   YOU" (built cards to review, refreshes to judge, candidates to activate).
   Text never wears the hue; as a graphic it clears WCAG 1.4.11 at 4.12:1
   worst-case on this paper. Everything the machine is merely doing stays ink. */
/* …seated in the MARGIN (slice two): against .vp-nav-body, x = 28 − 11 = 17, the
   same x as a row's ember; bottom-anchored on the status line it belongs to
   (the body's last child), centred on its 11px line box. Inline it pushed the
   text 11px off the axis. */
.vp-nav-status.ready::before, .vp-nav-status.refresh::before, .vp-nav-status.build::before {
  content: ''; position: absolute; left: -11px; bottom: 4px;
  width: 5px; height: 5px; border-radius: 50%;
  background: var(--color-amber-deep);
}
/* neutral, not amber: the status line beside it already carries the ember, and
   two warm marks on one nav row would say "waiting" twice. */
.vp-nav-badge {
  font-family: var(--font-ui); font-size: var(--rm-caption); font-weight: 700;
  min-width: 16px; height: 16px; padding: 0 4px; border-radius: var(--radius-full);
  background: rgba(44,44,44,0.07); color: var(--color-stone); display: inline-grid; place-items: center;
}

.vp-surface { animation: vpSurfaceIn 0.4s var(--ease-brush) both; }
@keyframes vpSurfaceIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }

/* The other half of the swap. The arrival has had an entrance since the portal
   was built; the departure was a hard cut in the same frame, so a surface
   change read as a flicker rather than a move. The outgoing node is lifted out
   of the flow and laid over the arriving one for 90ms.
   ⛔ NOTHING TRAVELS: the entrance carries the 6px rise, and a ghost that also
   moved would make two things travel in opposite directions over one 400ms.
   Opacity only, and fast — an exit that outlasts its arrival is a smear.
   ⛔ SOURCE ORDER IS LOAD-BEARING. Re-appending a node restarts its CSS
   animations, so the ghost would otherwise fade straight back IN under
   .vp-surface's rule; both are (0,1,0), so this must stay below it.
   ⛔ NO reduce-motion rule here on purpose — the ghost is never CREATED under
   reduce (voice-portal.js), which is the version of this that cannot strand
   anything invisible.
   ⛔ THE OFFSETS ARE .vp-main's OWN PADDING (40 / 44 / 44, declared below).
   An absolute child measures the PADDING box, so a bare `inset: 0` would seat
   the ghost 40px above and 44px left of the content it is replacing. Move that
   padding and move these with it. */
.vp-swap-ghost {
    position: absolute;
    top: 40px;
    left: 44px;
    right: 44px;
    pointer-events: none;
    animation: vpSurfaceOut 90ms ease-out both;
}
@keyframes vpSurfaceOut { from { opacity: 1; } to { opacity: 0; } }

.vp-section-title { display: flex; align-items: baseline; gap: 10px; margin: 0 0 4px; }
.vp-section-title .lead { font-family: var(--font-ui); font-size: var(--rm-support); font-weight: 600; color: var(--color-ink); }
.vp-section-title .sub { font-family: var(--font-ui); font-size: var(--rm-control); color: var(--color-stone); opacity: var(--rm-ink-quiet); }
/* the title's breathing dot — the room's one signal that a worker is at it right
   now. THE MOTION IS THE SIGNAL, not the hue (S7): "being made" is the machine
   working, and the machine never gets the ember. Stone, still breathing. */
.vp-section-title .d { width: 6px; height: 6px; border-radius: 50%; background: var(--color-stone); align-self: center; animation: vplWriting 2.4s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) { .vp-section-title .d { animation: none; opacity: 0.6; } }

/* (the record beneath a night — .vp-rec-strip / -facts / -head / -verdict /
   -sec / -fold, .vp-indeck, .vp-list — left with the letter it folded under,
   S10. The words surface's one "nothing waiting" card keeps its dress.) */
.vp-allcaught { background: var(--color-card-bright); border-color: var(--vp-line-strong); }

/* the surface's own name — the hall's .wall-section grammar. The romaji is
   FAINT, never the composer's amber: amber means "sound" everywhere else in
   the app, and this is a title, not something to say. */
.vp-words-head { display: flex; align-items: baseline; gap: 12px; margin: 0 0 6px; }
.vp-words-head .jp { font-family: var(--font-jp); font-size: 24px; font-weight: 600;
  color: var(--color-ink); line-height: 1; font-feature-settings: 'palt' 1; }
.vp-words-head .rom { font-family: var(--font-ui); font-style: italic;
  font-size: var(--rm-control); letter-spacing: 0.04em; color: var(--color-faint); }
.vp-words-head .en { font-family: var(--font-ui); font-size: var(--rm-caption);
  letter-spacing: var(--rm-track-display); font-weight: var(--rm-track-weight); text-transform: uppercase; color: var(--color-faint); }
.vp-words-head .rule { flex: 1; height: 1px; align-self: center; margin-left: 4px;
  background: linear-gradient(90deg, rgba(44,44,44,0.10) 0%, rgba(44,44,44,0.07) 55%, transparent 96%); }

/* ── 盆 THE ARRIVALS TRAY (S14) ────────────────────────────────────────────
   Portal-built, but it renders in the ROOM's side panel — the letter's own
   housing — so it lives with the other body-level guests and takes the same
   two things they take: its own MEASURE, and the fold mark's ground.
   ⛔ IT CARRIES ITS OWN MEASURE, and the number is not the letter's 620px. The
   panel is the room's whole width (~1196 at 1440 with the rail out), and a
   full-bleed tray left its rows hugging the far left of an empty page. But word
   rows need room — the S8 one-baseline law is that a long word (なければならない)
   must not wrap, and it is pinned at the 820 cut — so the measure steps UP from
   the letter's rather than down to it: 760px, centred like every other page in
   this panel. Centring is also what puts the tray clear of the ✕ (the floating
   fold mark sits at panel-x 29..64; a centred 760 starts near 218) — see the ⛔
   note on .ctp-body's left padding.
   The head keeps a little extra air above it so the tray reads as a page that
   begins, not as content that starts at the top edge of a scroller. */
.vp-tray { max-width: min(760px, 100%); margin-inline: auto; padding-bottom: 8px; }
/* the tray's own name, the .vp-words-head grammar one notch shorter: no EN line
   and no rule — the panel's title already said what this is, out loud, to a
   screen reader, and a third naming of the same surface is decoration. */
.vp-tray-head { display: flex; align-items: baseline; gap: 12px; margin: 14px 0 18px; }
.vp-tray-head .jp { font-family: var(--font-jp); font-size: 24px; font-weight: 600;
  color: var(--color-ink); line-height: 1; font-feature-settings: 'palt' 1; }
.vp-tray-head .rom { font-family: var(--font-ui); font-style: italic;
  font-size: var(--rm-control); letter-spacing: 0.04em; color: var(--color-faint); }
/* the news lane's remainder: a LINE, not a door. The tray is a glance over a held
   lesson and the New words page is the archive, so this says where the rest are
   rather than offering to take him there. No pointer, no hover, nothing to press
   — .vp-spoken-more next door is a real disclosure and wears cursor:pointer for
   it; this one must not borrow that look for something that does nothing. */
.vp-tray-more { font-family: var(--font-ui); font-size: var(--rm-support);
  color: var(--color-stone); opacity: var(--rm-ink-quiet); margin-top: 12px; }

/* WORD ROWS — ONE BASELINE (S8, owner pick B 2026-07-29).
   Two tracks: everything the row says, and — only where there is one — a
   right-hand fact about the word out loud. The old 104px face column beside a
   flex body broke every word longer than four characters over three lines and
   left the body half empty; the word now opens line 1 INSIDE the body, so a
   long word has the whole row.
   NO BORDERS. Space and the section titles carry the structure — a hairline
   per row turned a list of nine into a ledger of nine, and this surface is
   Sensei's, not an invoice. */
/* ⛔ THE GAP LIVES IN THE CELL, NOT BETWEEN THE TRACKS. A column-gap is spent
   whether or not the second track holds anything, so every row without a
   right-hand fact — most of the queue — gave up 24px of its own measure to an
   empty column. It moved into .vp-word-side's padding: side-less rows get the
   whole track, side rows read exactly as they did. */
.vp-word { display: grid; grid-template-columns: minmax(0, 1fr) max-content;
  column-gap: 0; align-items: start; padding: 15px 0; }
/* a ready row IS the door — the whole row taps through; its only affordance is
   the quiet look-line and a resting hover, never a chip or a filled button */
a.vp-word--tap { text-decoration: none; color: inherit; cursor: pointer;
  margin: 0 -14px; padding-left: 14px; padding-right: 14px; border-radius: var(--radius-lg);
  transition: background var(--duration) var(--ease-settle); }
/* the panel is named beside the overlay wherever a word row's INTERACTION lives:
   S14 shows these very rows in the arrivals tray, and the tray is body-level, so
   without it a door row there underlined its look-line (that rule is unscoped)
   and never washed — half a hover. Scoped to what we own, never the panel wholesale. */
#voicePortalOverlay a.vp-word--tap:hover,
.chat-thread-panel a.vp-word--tap:hover { background: var(--color-card-bright); }
/* the door line lives in the row's right column (S8) — no auto-margin: the cell seats it */
.vp-look { font-family: var(--font-ui); font-size: var(--rm-support); font-weight: 600;
  color: var(--color-accent); white-space: nowrap; opacity: var(--rm-ink-quiet); transition: opacity var(--duration); }
a.vp-word--tap:hover .vp-look { opacity: 1; text-decoration: underline; text-underline-offset: 3px; }
/* (.vp-pill + .vp-pill.refresh deleted 2026-07-28, S7 — the last two survivors
   of a set no live renderer emits (the word-row states are .vp-status-chip
   below). They were held only so shoot_elevation_t2's `.vp-pill.refresh` probe
   would not go silently empty; that probe moved to .vp-nav-status, the live
   successor, in the same commit.) */
/* (.vp-word-face deleted 2026-07-29, S8 — the 104px column is gone; line 1
   lives in the body now.) */
.vp-word-l1 { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.vp-word-jp { font-family: var(--font-jp); font-size: 20px; line-height: 1.25;
  color: var(--color-jp-text); font-feature-settings: 'palt' 1; }
.vp-word-reading { font-family: var(--font-ui); font-size: var(--rm-control); color: var(--color-stone); opacity: var(--rm-ink-quiet); }
.vp-word-romaji { font-family: var(--font-ui); font-style: italic; font-size: var(--rm-control); color: var(--color-romaji); }
/* the frequency BAND, in the walk's voice: a word, never a number (S8) */
.vp-word-band { font-style: italic; }
.vp-word-body { min-width: 0; }
/* the right-hand cell — one quiet phrase and a day, stone at two weights.
   ⛔ NO GOLD ON THIS AXIS: forty-five earned rows would be wallpaper, and the
   ember only means anything while it is scarce. */
.vp-word-side { text-align: right; padding-top: 3px; padding-left: 24px; }
.vp-word-state { font-family: var(--font-ui); font-size: var(--rm-control);
  color: var(--color-stone); opacity: var(--rm-ink-quiet); white-space: nowrap; }
.vp-word-date { font-family: var(--font-ui); font-size: var(--rm-control);
  color: var(--color-stone); opacity: var(--rm-ink-quiet); margin-top: 2px; }
.vp-spoken-more { font-family: var(--font-ui); font-size: var(--rm-control);
  color: var(--color-stone); opacity: var(--rm-ink-quiet); padding: 10px 0 0; cursor: pointer; }
#voicePortalOverlay .vp-spoken-more:hover { opacity: var(--rm-ink-full); }
.vp-word-gloss { font-family: var(--font-ui); font-size: var(--rm-support); font-weight: 600; color: var(--color-ink); margin-top: 4px; max-width: 52ch; }
.vp-word-why { font-family: var(--font-ui); font-size: var(--rm-support); line-height: 1.55; color: var(--color-stone); margin-top: 5px; max-width: 56ch; }
/* Why a build died — and now only where THIS row's reason differs from the one
   its run prints above the section (W1, 2026-09-20), so the line marks an
   exception rather than repeating a machine sentence down a column. Reads as a
   note pinned to the row, not an alarm: the design has no red, and a billing
   block is information the owner acts on calmly. The left rule is the only
   weight it carries — and it is a stone hairline, not gold (S7 earned-only
   law: failure never wears the win colour).
   ⛔ STONE, NOT INK. It sat at --color-ink, one rung ABOVE the teacher's own
   sentence to the learner (.vp-word-why, stone) sitting directly on top of it:
   the machine outranked the teacher on the teacher's own row. Quieter is a
   colour, not a fade — the hue steps back, the quiet rung carries it. */
.vp-word-failwhy {
    font-family: var(--font-ui); font-size: var(--rm-support); line-height: 1.55;
    color: var(--color-stone); opacity: var(--rm-ink-quiet);
    margin-top: 6px; max-width: 56ch;
    padding: 5px 0 5px 10px; border-left: 2px solid var(--vp-line-strong);
}
.vp-word-meta { display: flex; align-items: center; gap: 10px; margin-top: 10px; flex-wrap: wrap; }
.vp-status-chip { font-family: var(--font-ui); font-size: var(--rm-caption); letter-spacing: var(--rm-track); font-weight: var(--rm-track-weight); text-transform: uppercase; padding: 3px 9px; border-radius: var(--radius-full); }
/* ⛔ A CHIP WITH NOTHING TO SAY SEATS NOTHING. A failed row's chip stands
   empty at rest since its 'didn’t finish' word left (W1 — the section title
   says it), and it fills only when a decision comes back unsettled or unsaved.
   Without this it would spend 18px of its own padding plus the meta row's gap
   saying nothing, which is the zero-is-a-claim failure in a chip's clothes. */
.vp-status-chip:empty { display: none; }
/* THE CYCLE IS ONE WASH, WEIGHT GRADES IT (S7, owner pick B). These five are
   rungs on one ladder, and hue-coding them made pending→live read as five
   unrelated facts. One stone wash under all of them; ink weight says how far
   along (stone early, ink once it is worth a look). */
.vp-status-chip.pending  { color: var(--color-stone); background: rgba(140,133,120,0.1); }
.vp-status-chip.approved { color: var(--color-stone); background: rgba(140,133,120,0.1); }
.vp-status-chip.ready    { color: var(--color-ink); background: rgba(140,133,120,0.1); }
/* (.vp-status-chip.blessed + .live deleted 2026-07-29, S8 — the cycle's last
   two rungs, the owner's yes and the card on the wall, had one emitter between
   them (historyChip) and it had no caller anywhere. Both rungs are still SAID
   on this surface: 'Publishing soon' and 'On your wall' are section titles,
   which is where the state has lived since the rows went bare.
   .vp-status-chip.building followed in the A7 sweep, 2026-08-05: zero
   emitters anywhere — the "being made" breath its comment claimed to dress
   actually lives on the section-title dots (.vp-srow-state .d went with the
   rail's state words, 2026-09-17). .approved
   and .ready STAY: live emitters 'queued' / 'counted' / 'back in review'.) */
.vp-add-btn {
  font-family: var(--font-ui); font-size: var(--rm-support); font-weight: 600; color: #fff; background: var(--color-accent);
  border: none; border-radius: var(--radius-full); padding: 7px 16px; cursor: pointer;
  transition: filter var(--duration), transform 0.12s;
}
/* …and the same second scope: the tray's verdict hands are these hands (S14) */
#voicePortalOverlay .vp-add-btn:hover,
.chat-thread-panel .vp-add-btn:hover { filter: brightness(1.06); }
#voicePortalOverlay .vp-add-btn:active,
.chat-thread-panel .vp-add-btn:active { transform: scale(0.97); }
/* …held still under reduce, and seated HERE rather than in the block up at
   :167: a companion and its press are the same specificity, so only a LATER
   rule wins the tie (a media query adds nothing). B10. */
@media (prefers-reduced-motion: reduce) {
  #voicePortalOverlay .vp-add-btn:active,
  .chat-thread-panel .vp-add-btn:active { transform: none; }
}
.vp-add-btn[disabled] { background: transparent; color: var(--color-accent); border: 1px solid rgba(61,90,128,0.3); cursor: default; }
/* ghost = a look back at a settled wave, not a call to act */
.vp-add-btn.ghost { background: transparent; color: var(--color-accent); border: 1px solid rgba(61,90,128,0.3); }
/* (.vp-note, .vp-whisper, .vp-whisper-label and the three .vp-shelf-arc rules
   deleted 2026-07-28 — the shelf's own furniture, and the shelf went with the
   landing redesign. Only design-mockups/voice-portal/portal.js still builds
   them, against its own stylesheet.) */

/* (LETTERS — the sheet's own voices, --vpl-hand / -shu / -body / -lh / -jp /
   -kept / -meta — left with the letter, S10.) */
/* ------------------------------------------------------------
   THE SESSION RAIL — the history beside the room. A session is
   the entity here; its letter is one of its facets, so the rail
   lists sessions and never letters.
   ------------------------------------------------------------ */
/* ⛔ .vp-rail-cta (the blue New session hand) RETIRED 2026-07-26, owner call.
   The room's own bar carries the one hand that starts a talk; a second accent
   button in the corner competed with it and did something else besides (load
   an empty room — you still had to press the bar). The Sensei mark above the
   nav is the way back to an empty room, and it wears the label now. */

.vp-srail { display: flex; flex-direction: column; min-height: 0; flex: 1; margin-top: 18px; }
.vp-srail-head {
  display: flex; align-items: baseline; gap: 7px; padding: 0 10px 7px;
  font-family: var(--font-ui); 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);
}
/* ⛔ 1, NOT 0.7. It nests inside the head, so its own 0.7 rode the head's and
   the number measured 2.27:1 — the least legible text in the rail was the one
   fact the rail exists to state. At 1 it simply wears the head's 0.92. */
.vp-srail-count { font-weight: 500; letter-spacing: 0; opacity: 1; }
/* ⛔ THE LEFT BLEED IS THE MARGIN COLUMN (slice two, review fix 2026-09-17). The
   list bleeds 13px left of the rail's padding so a row's plate can start at
   x5 and HOLD its own hanging mark (the ember at x17-22, the ＋ at x10-22; the
   nav seals sit outside this box, on the same x5 edge). With the old 4px bleed
   the plate began at x18 and its edge bisected every mark. This is also the
   list's clip edge (overflow on one axis clips the other): nothing may sit
   left of x5. The right bleed stays 4px so the scrollbar keeps its seat. */
.vp-srail-list { overflow-y: auto; scrollbar-width: thin; min-height: 0; margin: 0 -4px 0 -13px; padding: 0 4px 4px 13px; }
.vp-srail-list::-webkit-scrollbar { width: 3px; }
.vp-srail-list::-webkit-scrollbar-track { background: transparent; }
.vp-srail-list::-webkit-scrollbar-thumb { background: rgba(44, 44, 44, 0.08); border-radius: 3px; }
/* ⛔ .vp-srail-empty and .vp-srail-aside RETIRED 2026-07-27 (owner). The empty
   state captioned an empty list ("Your sessions will collect here.") and the
   fold put the room's own quarantine bookkeeping on screen as "▸ 4 set aside",
   which reads as four broken sessions rather than four non-sessions. Both gone;
   the archive door below replaces the fold's job of keeping the rail short. */
/* ⛔ `flex: none` SINCE B3, and it is load-bearing. This door moved OUT of
   `.vp-srail-list` (it was inside the `overflow-y: auto` box, so the one way to
   the archive was itself something you scrolled to find) and became a flex child
   of `.vp-srail` beside the list. Flex children shrink by default, and the list
   above it is the one thing here that is allowed to: without this the door gets
   squeezed thinner than its own padding on a short rail while the scrolling list
   keeps its height. */
.vp-srail-more {
  display: block; width: 100%; text-align: left; background: none; border: none;
  flex: none;
  font-family: var(--font-ui); font-size: var(--rm-control); color: var(--color-stone);
  padding: 10px; margin-top: 2px; cursor: pointer; opacity: var(--rm-ink-quiet);
  transition: color var(--duration), opacity var(--duration);
}
/* the way out of a kept session: the rail's own ink, never a second blue hand */
.vp-srail-new {
  position: relative;
  display: flex; align-items: center; gap: 9px; width: calc(100% + 13px); text-align: left;
  background: none; border: none; cursor: pointer;
  font-family: var(--font-ui); font-size: var(--rm-support); color: var(--color-stone);
  padding: 8px 10px 8px 23px; margin: 0 0 2px -13px; border-radius: var(--radius);
  transition: color var(--duration), background var(--duration);
}
/* the ＋ hangs in the margin at x10-22, inside the hand's own plate (one text
   edge, see the nav block) */
.vp-srail-new .p { position: absolute; left: 5px; top: 50%; transform: translateY(-50%); font-size: 12px; opacity: var(--rm-ink-mark); line-height: 1; }
#voicePortalOverlay .vp-srail-new:hover { color: var(--color-ink); background: rgba(44,44,44,0.035); }
#voicePortalOverlay .vp-srail-new:hover .p { opacity: var(--rm-ink-full); }
/* (the '.on' landing dress retired with B11: where you already are, the hand
   now stands DOWN instead of dressing up — the accent never read as "here") */
#voicePortalOverlay .vp-srail-more:hover { opacity: 1; color: var(--color-ink); }

/* THE DAY, SAID ONCE (B3). Deliberately NOT the rail's own head repeated twenty
   times: that head is the one LABEL in this column and is uppercase, tracked and
   600 for exactly that reason. A day is a DIVIDER, so it separates by rhythm —
   air above it, its sittings tight beneath — never by weight, and never by a
   rule line the room would have to justify as a new mark family. Its ink is the
   supporting-TEXT rung (0.92 stone, 5.40:1 on this paper); the 0.68 rung is
   closed to it by law, marks only. Aligned to the row's own text, because a
   heading indented differently from what it heads reads as a second column. */
.vp-sday {
  padding: 0 10px 3px;
  margin-top: 13px;
  font-family: var(--font-ui); font-size: var(--rm-caption);
  color: var(--color-stone); opacity: var(--rm-ink-quiet);
}
/* the first day opens the list, so it has nothing above to be separated from */
.vp-sday:first-child { margin-top: 1px; }
/* the day speaks the room's date (slice two): 9月14日（月） in the brush face
   at the Japanese floor, the English beside it at the heading's own size —
   the landing's dateline shape, one line. The heading's rung covers both. */
.vp-sday .jp { font-family: var(--font-jp); font-size: var(--rm-support); color: var(--color-jp-text); }
/* THE MONTH, under View all only (slice five): a section above its days, told
   apart by AIR and by the rail's one label family (the caps English), never by
   a rule line. Its Japanese half is the day heading's own dress. */
.vp-smonth {
  padding: 0 10px 2px;
  margin-top: 24px;
  font-family: var(--font-ui); font-size: var(--rm-caption);
  font-weight: var(--rm-track-weight); letter-spacing: var(--rm-track); text-transform: uppercase;
  color: var(--color-stone); opacity: var(--rm-ink-quiet);
}
.vp-smonth:first-child { margin-top: 1px; }
.vp-smonth + .vp-sday { margin-top: 6px; }
.vp-smonth .jp { font-family: var(--font-jp); font-size: var(--rm-support); color: var(--color-jp-text); letter-spacing: 0; font-weight: 400; text-transform: none; }

/* (position: relative is declared once, at the ⋯ block — `#voicePortalOverlay
   .vp-srow` — and the bar and the ember below seat against it.) The row reaches
   13px into the margin column (see .vp-srail-list): its plate starts at x5 and
   holds its own marks, the text stays on the 28px edge. */
.vp-srow {
  display: block; width: calc(100% + 13px); margin-left: -13px; text-align: left; background: none; border: none;
  border-radius: 0 5px 5px 0;
  padding: 8px 10px 8px 23px; cursor: pointer;
}
.vp-srow:hover { background: var(--color-card-bright); }
.vp-srow.on { background: var(--color-card-bright); }
/* the selection bar is DRAWN, not boxed: as a 2px border it put every row's text
   2px right of the headings' (30 vs 28) — the rail's only 30px line (slice two).
   x14-16: where the rail's padding used to end, one step in from the plate. */
.vp-srow.on::before { content: ''; position: absolute; left: 9px; top: 9px; bottom: 9px; width: 2px; background: var(--color-accent); }
/* (`.vp-srow.aside { opacity: 0.5 }` RETIRED 2026-07-31. The class is still
   written on the row, but railSessions filters set-aside nights out before it
   renders any of them, so this rule could never paint. It is recorded at the
   filter, not here: bring those rows back and they will need a dress again.) */
/* the two reference doors while Sensei is talking: they say no, and look it.
   0.38 SURVIVES THE S7 CONTRAST SWEEP — RULED 2026-07-28 with the owner's pick:
   this is a refusal state, deliberately sub-floor, and WCAG exempts disabled
   controls (1.4.3). Raising it would make a door that does nothing look open. */
.vp-nav-item.waiting { opacity: 0.38; cursor: default; }
#voicePortalOverlay .vp-nav-item.waiting:hover { background: none; }
.vp-srow-when {
  display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
  font-family: var(--font-ui); font-size: var(--rm-control); color: var(--color-stone);
}
/* (.vp-srow-dot — the unread letter's ember on its carrier row — left with the
   letter, S10. The ember family keeps its one member that still means
   "something waits for YOU": the New words door's own mark.) */
/* ⛔ `.vp-srow-fold` RETIRED 2026-08-07 (B3, owner). It dressed "N of M that
   day" on a folded row, and the day HEADING above now owns the word "day" — the
   marker meant "the sittings that share one letter", a different grouping that
   disagrees with the calendar day on 14 of 14 multi-sitting days. Deleted WITH
   its emitter in the same commit: a rule the ink law ruled on whose emitter is
   gone is exactly the dead rule that law's gate-hygiene arm was written to
   catch, and it would have sat here looking governed. The fold is still dressed
   where it is still said — the session head and the letter's label, neither of
   which sits under a heading. */
/* THE RECORD SITS BACK (slice two): eight two-line titles at full sumi were
   the darkest mass on the arrival screen — 48.8% of its figure ink, measured —
   against two lines of room. Stone COLOUR at full opacity (the ink law's own
   "quieter = colour, never a fade"), ≈7:1 on the rail's paper; only the night
   he has open is at full ink, so full ink starts to mean where-you-are. */
.vp-srow-title {
  font-family: var(--font-ui); font-size: var(--rm-support); color: var(--color-stone); margin-top: 2px;
  line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  overflow: hidden;
}
/* the night he has open is at full ink — and the title line is a NAME again,
   never a machine state, so the old :not(.vp-srow-state) carve-out went with
   the state words (2026-09-17) */
.vp-srow.on .vp-srow-title { font-weight: 500; color: var(--color-ink); }
.vp-srow-title .jp { font-size: var(--rm-support); }
/* (.vp-srow-untitled DELETED 2026-09-17 with its emitter: a night the namer has
   not answered for yet renders NO title node at all. 'Untitled' was never true,
   only early, and dressing it made the absence look like a state.) */
/* how much of the open night was HIS — one quiet line under the night's own
   name, in the fold marker's voice (S9). 0.92 is the room's ruled stone for
   text this size on this paper. */
.vp-srow-turns { font-family: var(--font-ui); font-size: var(--rm-caption); color: var(--color-stone); opacity: var(--rm-ink-quiet); margin-top: 2px; }
/* THE WITNESS ROW ONLY (.vp-srow.writing is the synthetic 'Just now ·
   Delivering your conversation…' placeholder, the one sign a stranded draft is
   still being delivered). A real night's row wears no state: .vp-srow-state left with the
   state words on 2026-09-17, and the `gap` went with the breathing dot it
   spaced. */
.vp-srow.writing .vp-srow-title {
  display: flex; align-items: center; margin-top: 3px;
  font-family: var(--font-ui); font-size: var(--rm-control); font-style: italic; color: var(--color-stone);
}
/* ⛔ FAILURE IS NOT ABSENCE, and it wears its own line. The record could not be
   read, so the rail says that instead of rendering a learner with no history.
   Quiet, but it has to be READ: 0.92 stone is the room's own ruled number for
   text this size on this paper (css/chat-mode.css, twice, both citing 4.5:1). */
.vp-srail-fail {
  padding: 2px 10px 12px;
  font-family: var(--font-ui); font-size: var(--rm-support);
  color: var(--color-stone); opacity: var(--rm-ink-quiet);
}
/* the archive door's own quiet voice — the RECIPE, not the selector. Extending
   .vp-srail-more would give the one door in the rail a second identity. */
.vp-srail-retry {
  display: block; text-align: left; background: none; border: none;
  font-family: var(--font-ui); font-size: var(--rm-control); color: var(--color-stone);
  padding: 8px 0 0; margin: 0; cursor: pointer; opacity: var(--rm-ink-quiet);
  text-decoration: underline; text-underline-offset: 3px;
  transition: color var(--duration), opacity var(--duration);
}
#voicePortalOverlay .vp-srail-retry:hover { opacity: 1; color: var(--color-ink); }
/* a line the record could not read is not a line nobody wrote */
.vp-srail-warn {
  padding: 0 10px 7px;
  font-family: var(--font-ui); font-size: var(--rm-caption);
  color: var(--color-stone); opacity: var(--rm-ink-quiet);
}
/* The Claude lane's park (voice review 09-15, S3): one quiet line under the New
   words head while a build, a fresh take or a publish of his waits on Claude's
   usage limit. Same voice as the warn line above — a fact about the machine,
   said once where it bears, never a state on a row. */
.vp-lane-note {
  margin: 0 0 14px;
  font-family: var(--font-ui); font-size: var(--rm-caption);
  color: var(--color-stone); opacity: var(--rm-ink-quiet);
}
/* ⛔ THE RECORD LANDS ON ONE CROSSFADE. The rail's rows and the room's landing
   both wait for the same read, so they arrive together rather than each popping
   in when its own fetch happened to answer. The base opacity is UNTOUCHED and
   the keyframe carries only the `from` — so killing the animation lands the end
   state by construction (reference_reduced_motion_must_land, and the reduce
   block at the foot of this file). It sits below .vp-surface deliberately: the
   landing wears both classes and this is the one that wins on its first paint.
   ⛔ In the SEAT the landing needs a twin: `.chat-landing-head .vp-lobby`
   (chat-mode.css) kills `animation` at (0,2,0) to suppress the surface
   entrance, which silently out-ranked this rule too — the landing popped while
   the rail faded. The seat re-arms exactly this crossfade at seat specificity
   (S6, 2026-07-28); change one and you must change both. */
.vp-record-in { animation: vpRecordIn 0.22s ease-out; }
@keyframes vpRecordIn { from { opacity: 0; } }
.vp-srow.writing { cursor: default; }
/* (the rail's breathing dot — `.vp-srow-state .d, .vp-srow.writing .d`, stone
   since S7 — DELETED 2026-09-17 with its emitters: no row in the rail emits a
   `.d` any more. The state words left the rows, and the witness row never wore
   one. The breath itself lives on: .vp-section-title .d still runs vplWriting,
   which is why the keyframes and their reduced-motion landing stay.) */
@keyframes vplWriting { 0%, 100% { opacity: 0.3; } 50% { opacity: 0.9; } }

/* ------------------------------------------------------------
   THE SESSION PAGE is GONE (S3R, 2026-07-26)

   It was a SECOND conversation renderer: the portal drew a past
   session's turns in its own markup (.vps-conv/.vps-msg) with its
   own bar, and Resume then tore that down and opened the real
   room — empty. Two renderers of one thing is how a resume ends
   up looking like a fresh start.

   There is now ONE conversation surface: the room's #chatTranscript,
   painted by the room's own appendMessage. A session is a THREAD
   loaded into it. Its dress lives in css/chat-mode.css beside the
   live talk it has to match, because it IS that talk, stopped.
   ------------------------------------------------------------ */

/* (THE LETTER SHEET — the app's one written artifact, in Sensei's hand — and
   the record and notebook pages that rode with it left with the letter, S10.) */

/* ------------------------------------------------------------
   THE 床の間 SHELL (rail + main)
   ------------------------------------------------------------ */
/* ⛔ ONE SCREEN, TWO COLUMNS (2026-07-27, owner ruling: "we do not need two
   screens"). This grid is now the ONLY thing that aligns the rail and the room.
   The room used to be a second body-level overlay copying --vp-rail-w into its
   own `left` to stay beside the rail; it is seated in .vp-stage below instead,
   so the second track holds it and nothing has to be kept in sync by hand. */
/* ⛔ AND THE COLUMN IS THE ONE THING THAT MOVES (slice four, 2026-09-17). The
   rail glided 0.34s while this track snapped, so one press was three motions:
   the rail travelling, the room's 640px axis teleporting 122px under it, and an
   open letter gliding after a room that had already arrived. The track now
   carries the rail's exact curve and duration, so the room grows into the space
   the rail is leaving. Keep the two numbers identical to .vp-rail's above —
   they are one gesture — and see setRailCollapsed for what the letter and the
   dock wall are told, since a transition starts at the from-value and a rect
   read at the press is the box the fold started from. */
.vp-shell { min-height: 100%; background: var(--color-paper); display: grid; grid-template-columns: var(--vp-rail-w, 244px) minmax(0, 1fr); transition: grid-template-columns 0.34s var(--ease-settle, ease); }
/* The stage is the second column and the room's containing block — it is what
   `position: absolute` in .chat-mode-overlay.in-portal measures against, which
   is the whole mechanism. It holds BOTH the room and the reference surfaces
   (.vp-main); which one is showing is the room's own business, not the grid's. */
.vp-stage { position: relative; min-width: 0; }
.vp-rail {
  position: sticky; top: 0; align-self: start; height: 100vh; width: 252px;
  display: flex; flex-direction: column; padding: 26px 18px 22px 26px;   /* 26 left: the margin column (see --vp-rail-w) */
  border-right: 1px solid var(--vp-line);
  background: linear-gradient(180deg, var(--color-card) 0%, var(--color-paper) 100%);
  /* visibility is listed with no delay here so the rail becomes reachable the
     instant it starts coming back — the delayed half lives on the collapsed
     rule above (one complete list per rule, never a second shorthand) */
  transition: transform 0.34s var(--ease-settle, ease), visibility 0s;
  /* one figure width for the whole column: Inter's digits are proportional,
     so 11:11 and 00:00 differed by ~10px and the clocks never lined up */
  font-variant-numeric: tabular-nums;
}
.vp-rail-top { display: flex; align-items: flex-start; }
.vp-rail-brand { display: flex; align-items: baseline; gap: 8px; background: none; border: none; cursor: pointer; text-align: left; padding: 4px 10px 18px; flex: 1; }
/* the head is type (slice two; the sphere's retirement is recorded with the
   lantern's, above): 先生 in the brush face at the room's Japanese size — the
   first Japanese word in the column, in the face the greeting already wears —
   and SENSEI beside it in the rail's own label dress, the SESSIONS head's
   (one caps family in the rail, not a second tracking). */
.vp-brand-jp { font-family: var(--font-jp); font-size: var(--rm-jp); line-height: 1.2; color: var(--color-jp-text); }
.vp-brand-title { font-family: var(--font-ui); 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); }
/* ONE CORNER-KEY SPEC, AND IT IS THE ROOM'S MEASURED ONE (2026-07-28). This
   key and the handle below wore 32px/r8/0.42-stone with an ink-wash hover; the
   room's ✕ and letter key wore 30px/r9/0.68-stone and paper arriving under the
   pointer. Same act, same surface, two dresses — and the rail's was the one
   never measured: stone at 0.42 sits under WCAG 1.4.11's 3:1 for a control's
   own boundary, which is exactly why the room's was raised to 0.68 (3.20:1,
   recorded at css/chat-mode.css:381). The room's is the spec now.
   Reference block: css/chat-mode.css `.chat-close-btn`.
   Pinned byte-for-byte against it by tests/css/control_room_test.js. */
.vp-rail-x {
  display: flex; align-items: center; justify-content: center; position: relative;
  background: rgba(253, 252, 249, 0); border: 1px solid rgba(44, 44, 44, 0);
  color: var(--color-stone); cursor: pointer;
  width: 30px; height: 30px; padding: 0; line-height: 1; border-radius: 9px;
  opacity: var(--rm-ink-mark);
  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;
}
/* ⛔ NO box-shadow, and that IS the parity (measured 2026-07-28). The room's
   reference block declares one on hover — and it never paints: `.chat-header
   button:hover { box-shadow: none }` (chat-mode.css:2135) outranks it at
   (0,2,1) vs (0,2,0), so the room's keys hover flat. Copying the declaration
   here would have given the rail a lift the room does not have — the portal's
   own opt-out (:188-193) is a class weaker than the room's and would have let
   --shadow-rest's 8px/28px layer through. Same paper, same law: cards rest. */
#voicePortalOverlay .vp-rail-x:hover {
  color: var(--color-ink); opacity: 1;
  background: rgba(253, 252, 249, 0.97); border-color: rgba(44, 44, 44, 0.10);
}
#voicePortalOverlay .vp-rail-x:active { transform: scale(0.94); }
/* …and its reduce companion, moved down from :167 where it lost the tie to the
   line above (same specificity, ~930 lines earlier). B10. */
@media (prefers-reduced-motion: reduce) {
  #voicePortalOverlay .vp-rail-x:active { transform: none; }
  /* the fold lands its end state instantly — and this companion lives BELOW its
     live rule on purpose: at :167 it would tie with .vp-shell above and lose */
  .vp-shell { transition: none; }
}
/* 30 → 44 tall, 44 wide (the touch floor; the same expander idiom as the room's
   keys, css/chat-mode.css). The left inset is 0: .vp-rail-brand is a button
   flush against it, and .vp-rail-top carries no gap between them. */
.vp-rail-x::after { content: ''; position: absolute; inset: -7px -7px -7px 0; }
.vp-nav { display: flex; flex-direction: column; gap: 1px; }
.vp-nav .vp-nav-item { width: calc(100% + 13px); justify-content: flex-start; }   /* + the 13px it reaches into the margin */
.vp-nav .vp-nav-item.active { background: rgba(61,90,128,0.07); }
/* S8 · the receipt's month line — the rail's LAST line since slice two (the
   nameplate foot it sat above left, see renderRail): it keeps the foot's dress
   and the hairline that closed the column. */
.vp-rail-month { margin-top: auto; padding: 12px 10px 0; font-family: var(--font-ui); font-size: var(--rm-control); color: var(--color-stone); opacity: var(--rm-ink-quiet); border-top: 1px solid var(--vp-line); }
/* position: relative — the surface-swap ghost's containing block (.vp-swap-ghost
   above reads THIS padding for its offsets; the two move together).
   ⛔ margin-inline: auto IS LOAD-BEARING (B6, 2026-08-06). The room is seated
   absolute in .vp-stage, so it fills the cell and its centre IS the stage's;
   this measure is capped at 960 and, left to itself, sat flush against the
   rail. Measured on merged master: the landing centred at 842 and .vp-main at
   724 (1440), 1082 vs 724 (1920) — .vp-main reads left:244 width:960 at BOTH
   widths, so it never moved and the defect GREW with the window: a 118px
   sideways lurch on a laptop, 358px on a large display, every time Study plan
   was pressed. That is the ONE AXIS law below broken by the rule it is
   written under. 5.5px of residual offset on a surface is the overlay's own
   scrollbar, which the room (overflow: hidden under body.chat-mode) has not
   got. */
.vp-main { position: relative; padding: 40px 44px 90px; max-width: 960px; margin-inline: auto; }

/* lobby */
/* one column since the shelf went (owner, 2026-07-27) — the second track was
   the shelf's, and an empty 292px track pulls the greeting off the room's axis */
/* ONE AXIS (owner pick, 2026-07-28 — S6): the landing centres on the column
   the lamp and the bar already share — lamp → words → bar, like a hanging
   scroll. The band of paper above the greeting is STRUCTURAL (the greeting
   rests on the bar by the 0-move press law; the lamp keeps its one seat at
   the top), so the shared axis is what makes the band read as sky instead of
   a missing panel. One alignment at every width: the old ≤1080px query that
   centred ONLY the narrow widths — against the seat's own left — is deleted,
   not repaired; it was the iPad's two-products bug. */
.vp-lobby { display: grid; grid-template-columns: minmax(0, 1fr); gap: 44px; align-items: center; min-height: calc(100vh - 150px); align-content: center; text-align: center; }
.vp-welcome { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 26px; align-items: center; padding: 8px 4px; }
/* The landing has no orb of its own since 2026-07-27 (the room's ensō is the
   one mark — see lobbyNode in voice-portal.js). With the orb cell gone the
   two-column track would size the greeting to `auto` and shrink-wrap it, so the
   orbless welcome says plainly that it is one column. */
.vp-welcome--noorb { grid-template-columns: minmax(0, 1fr); }
.vp-welcome-body { display: flex; flex-direction: column; gap: 16px; align-items: center; }
/* The greeting is the landing's one brush word (the Muji cut, owner pick
   2026-07-28): おかえり at a restrained display size in full ink — a warm
   word, not a headline. The English sits beside it a step back; the block
   itself no longer fades as a whole.
   ⛔ THE PAPER HERE IS NOT --color-paper (B5, measured 2026-08-06). The
   landing renders inside .chat-landing-head, i.e. in the room's transcript,
   so it sits on the room's ONE LIGHT gradient — screenshot-sampled #F6F3EC
   under the greeting and #F1EEE7 under the dateline at 1440x1050. Every ratio
   quoted below was measured on that paper, not computed on the token. The
   greeting is bottom-anchored, so its paper darkens as the window grows
   taller; the floor is the gradient's own last stop #E3DFD5, where stone at
   QUIET still measures 4.82:1. The rung holds everywhere between. */
.vp-greet { font-size: var(--rm-support); color: var(--color-stone); }
/* ⭐ SLICE ONE (owner pick 2026-09-17, staged 24 / 28 / 30 side by side on the
   live record): the one brush word steps 24 → 30px. At 24 it was the smallest
   display word in the family (the hall's 全部 measures ~50px, the course's word
   head 70px) and the least lit thing on the landing; the room's own bar
   out-shouted it. A FIXED step inside the room's ruled 640px measure, never a
   viewport unit (chat-mode.css:4321 records what a vw-sized landing does).
   palt joins the two display heads that already have it. */
.vp-greet .jp { font-family: var(--font-jp); font-size: 30px; line-height: 1.2; color: var(--color-jp-text); font-feature-settings: 'palt' 1; }
/* B5: 0.75 measured 3.79:1 on the paper above — under AA for 13px, and the
   English IS the greeting for a learner who cannot read the Japanese. QUIET
   takes it to 5.71:1 and keeps it a step behind the brush word, which is said
   in a different ink at 9.59:1 rather than in a stronger alpha. */
.vp-greet .en { opacity: var(--rm-ink-quiet); }
/* furigana on the kanji-bearing greetings (夜更かし / 久しぶり / 暑い / 寒い):
   the learner cannot read kanji, and the owner's "japanese and english" cut
   ROMAJI, not the reading. ruby-position over the character.
   ⭐ THE READING WEARS THE JAPANESE INK (owner ruling 2026-08-06, B5 — the one
   taste call in the item, put to him rendered at the real 24px). A reading
   belongs to the word it sits over, so it takes that word's own colour: this
   ruby and the placard's (chat-mode.css, --color-stone) now agree on STRENGTH
   and differ in HUE for a reason someone can say out loud, instead of
   disagreeing on both by accident.
   ⛔ 0.62 measured 3.38:1 on this paper — under AA, on ~10px kana, i.e. the
   one thing here a beginner cannot read without. QUIET measures 7.60:1, still
   plainly the smaller voice beside the 24px greeting's 9.59:1. (The 2.81:1
   this site was carried at in the ink law's registry was wrong: it read the
   ruby as stone, when it inherits --color-jp-text from the rule above.)
   ⛔ NO 0.05em TRACKING, and it was measured rather than argued. B5 filed it
   as part of copying the placard's dress; rendered and diffed at 1x, 2x and 3x
   over all four furigana greetings, it moves ZERO pixels at 1x and 3x and
   jitters antialiasing at 2x — ruby-align distributes the annotation across
   its base and eats it. A declaration that paints nothing is a dead rule.
   ⛔ THE 0.42em RATIO IS NOT INK'S BUSINESS: settled law, pinned on the GLYPH
   axis of tests/css/type_voice_test.js — a reading is drawn relative to the
   character it rides, never at a px size. */
.vp-greet .jp ruby { ruby-position: over; }
.vp-greet .jp rt { font-size: 0.42em; opacity: var(--rm-ink-quiet); font-weight: 400; }
/* today, in fact — under the greeting and well below it in voice: the caption
   size the room already owns. Same JP-leads-English grammar as the greeting
   above it, so the landing speaks one pattern.
   ⛔ ITS OLD 0.7 CARRIED THE ARGUMENT THAT KILLED IT: "the ruled quiet number
   (0.92 is for text that must be READ; a dateline is glanced)". A date with a
   CLOCK in it is read — it is the one thing on this screen that answers a
   question — and 0.7 measured 3.29:1 at 11px. There is no glanced-text rung
   (B9: below QUIET nothing on this paper clears 4.5:1, so quieter means a
   COLOUR change, never a fade), and this line is already quieter than the
   greeting by size and by stone. QUIET = 5.47:1. */
.vp-dateline { margin-top: 10px; font-size: var(--rm-caption); color: var(--color-stone); opacity: var(--rm-ink-quiet); }
/* …and the date's Japanese at the family's floor for Japanese: it inherited
   the 11px caption size, the same px as the Inter beside it, the only Klee
   line in this file under 13px. The hall steps its own date up the same way
   (.gb-date [lang=ja]); the greeting above already leads its English 1.85×. */
.vp-dateline .jp { font-family: var(--font-jp); font-size: var(--rm-support); color: var(--color-jp-text); }
/* The way back (S1, 2026-09-15): inside the reconnect window the landing
   offers the lesson that just stopped. One plain door in the room's own
   paper-button dress (.chat-recovery-btn's measure and border — a door, not
   the bar's one accent hand, which still starts a talk), and beneath it the
   one fact that makes the door make sense: how long ago. As quiet as the
   dateline (same caption voice, same QUIET rung — 5.47:1 on this paper), so
   the greeting stays the landing's one brush word. */
.vp-pickup { margin-top: 22px; display: flex; flex-direction: column; align-items: center; gap: 8px; }
.vp-pickup-btn {
  border: 1px solid rgba(44, 44, 44, 0.18);
  background: transparent;
  color: var(--color-ink);
  border-radius: 8px;
  min-height: 44px;
  padding: 10px 18px;
  font-size: var(--rm-support);
  font-family: var(--font-ui);
  cursor: pointer;
  transition: border-color 0.2s, transform 0.1s;
}
.vp-pickup-btn:hover { border-color: var(--color-accent); }
.vp-pickup-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.vp-pickup-btn:active { transform: scale(0.97); }
.vp-pickup-when { font-size: var(--rm-caption); color: var(--color-stone); opacity: var(--rm-ink-quiet); }
@media (prefers-reduced-motion: reduce) {
  .vp-pickup-btn { transition: border-color 0.2s; }
  .vp-pickup-btn:active { transform: none; }
}
/* (.vp-cta and .vp-shelf deleted 2026-07-28 — the landing's old call-to-action
   column and the shelf beside it; neither class is built anywhere in js/.) */

/* ------------------------------------------------------------
   THE STUDY PLAN (vpj-*, jy-*) — RETIRED (S10, owner 2026-10-06)
   The scroll and its stones, the desk, the chapter card, the timeline, the
   measures drawer, the legend, the ribbon and the .jy-admin dress left with
   the plan and the letter. He drives his own study from the books he brings.
   ------------------------------------------------------------ */

/* ⛔ .vp-orb-fly RETIRED 2026-07-27 with the lantern it flew. It animated the
   landing's orb into the room's orb across the seam — a seam that stopped
   existing when the room BECAME the landing (S3R), leaving the rule with zero
   JS references for a month. Its removal is part of the same ruling: the portal
   does not draw room objects, so it has no orb to fly. */

/* ------------------------------------------------------------
   REDUCED MOTION + DESKTOP-ONLY
   ------------------------------------------------------------ */
@media (prefers-reduced-motion: reduce) {
  .vp-surface { animation: none; }
  .vp-record-in { animation: none; }
  #voicePortalOverlay { transition: none; transform: none; }
}
@media (max-width: 768px) {
  #voicePortalOverlay { display: none !important; }
  /* the portal's own body-level furniture goes with it (A7): the ⋯ menu and
     the folded rail's handle are body children, out of the overlay's reach —
     and the body classes gating the handle survive a live resize below 768 */
  body > .vp-menu,
  body > .vp-rail-handle { display: none !important; }
}

/* ── ⋯ AND ITS MENU: what he may do to a night of his own (2026-07-27) ──────
   Owner, holding up Claude's own sidebar: "perhaps we need something similar."
   Same shape, same reason — the row says what the night WAS; what you may DO to
   it is one mark away instead of printed on every row.

   It replaced two words (Rename · Set aside) spelled out inline on the OPEN
   row, and it is better on the point that matters: it reaches EVERY night in
   the list, not only the one already open, so naming a session no longer costs
   you the one you were reading.

   ⛔ THE ONE THING NOT COPIED IS HOVER-ONLY REVEAL. Claude's ⋯ appears on
   hover. Hover does not exist on the iPad — a primary device here since
   2026-07-16 — so on touch that mark would simply never appear and the whole
   feature would be invisible to half the product. It fades in on pointer
   devices and stands permanently visible on touch: one honest branch, same
   design. (The repo already draws this line for entity-peek, which stands down
   entirely without hover:hover + pointer:fine.) */
#voicePortalOverlay .vp-srow { position: relative; }
#voicePortalOverlay .vp-srow-more {
  position: absolute;
  top: 6px;
  right: 4px;
  width: 24px;
  height: 22px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--color-stone);
  background: none;
  border: 0;
  border-radius: 5px;
  padding: 0;
  cursor: pointer;
  opacity: 0;                     /* pointer devices only — see the media query */
  transition: opacity 0.18s ease, background 0.18s ease;
}
/* ⛔ NO font-size / line-height HERE ANY MORE (2026-07-28). The mark is a 16px
   stroke SVG, sized by its own viewBox — it answers to no font, which is the
   whole reason it left the type allowlist. A font-size on this rule would say
   nothing and would be the first thing a reader tried to tune. */
/* The 44px touch floor — the house idiom (css/vocab-panel.css:621), invisible,
   inheriting its host's pointer-events, costing the layout nothing.
   The mark is 24×22 at top:6 right:4 inside the row's own box, so:
   6 + 22 + 16 = 44 tall, 16 + 24 + 4 = 44 wide — the expander reaches DOWN and
   LEFT into the row it belongs to and stops exactly at that row's own top and
   right edges. ⛔ Never past the top: the row ABOVE is a full-width click
   target and the law protects a neighbour's visible box. Down-left is the
   ⋯'s own host, and a mis-press there now costs a dismissible menu rather
   than a session opening under him. */
.vp-srow-more::after {
  content: '';
  position: absolute;
  inset: -6px -4px -16px -16px;
}
#voicePortalOverlay .vp-srow:hover .vp-srow-more,
#voicePortalOverlay .vp-srow.on .vp-srow-more,
#voicePortalOverlay .vp-srow:focus-within .vp-srow-more { opacity: var(--rm-ink-mark); }
#voicePortalOverlay .vp-srow-more:hover,
#voicePortalOverlay .vp-srow-more:focus-visible,
#voicePortalOverlay .vp-srow[data-menu-open] .vp-srow-more {
  opacity: 1;
  background: rgba(44, 44, 44, 0.06);
}
/* ⛔ TOUCH: there is no hover to reveal it with, so it is simply there.
   This reaches the word rows' mark too — it wears .vp-srow-more as well. */
@media (hover: none), (pointer: coarse) {
  #voicePortalOverlay .vp-srow-more { opacity: var(--rm-ink-mark); }
}

/* THE SAME MARK, ON A WORD ROW (W1, 2026-09-20). A failed build's repair work
   — Try again, Let it go — used to stand as two buttons on every row, the
   filled indigo one the loudest thing in the room; it is the MACHINE's work,
   so it went behind the ⋯ the rail already taught. Only the SEAT differs: the
   rail's mark is absolute in its row's corner, a word's rides the end of its
   meta line, where the buttons stood. Ink, reveal and the 44px expander are
   the rules above, shared.
   ⛔ position: relative, not static — the ::after expander below is absolute
   and must stay pinned to the mark; on a static button it would seat itself
   against whatever ancestor happened to be positioned. */
#voicePortalOverlay .vp-word-more {
  position: relative;
  top: auto;
  right: auto;
  margin-left: auto;
  /* ⛔ AND IT DOES NOT WAIT FOR A HOVER. The rail hides its mark at rest because
     every one of forty-eight rows carries one and a column of ⋯ would be
     wallpaper — and because a rail row is itself a door, so the row is never
     mute. Here it is the reverse: only a stuck row wears the mark (three or
     four at most), the row is NOT a door, and the rail's ember has already
     said "something waits for you" and sent him to this surface. A mark that
     is the one thing to do, on the one row that needs doing, earns its place
     standing. Hover still lifts it to full ink — ink, never geometry. */
  opacity: var(--rm-ink-mark);
}
/* the 44px floor again, cut to stay INSIDE the row it belongs to: .vp-word has
   15px of its own padding below the meta line, so 11px down clears the row
   beneath it (the law the rail's mark states for its top edge, read for a mark
   that sits at the BOTTOM of its host). 22 + 11 + 11 = 44; 24 + 20 = 44. */
#voicePortalOverlay .vp-word-more::after { inset: -11px 0 -11px -20px; }
#voicePortalOverlay .vp-word:hover .vp-word-more,
#voicePortalOverlay .vp-word:focus-within .vp-word-more { opacity: var(--rm-ink-mark); }
#voicePortalOverlay .vp-word-more:hover,
#voicePortalOverlay .vp-word-more:focus-visible,
#voicePortalOverlay .vp-word[data-menu-open] .vp-word-more {
  opacity: 1;
  background: rgba(44, 44, 44, 0.06);
}

/* The menu is seated on the BODY (A6) — not in the row, and not in the
   overlay either. The row would clip it (the rail scrolls); the overlay is the
   quieter trap: its entrance transform makes it a stacking context AND the
   containing block for fixed children, so the 9610 this rule used to declare
   was LOCAL — capped at the overlay's own 9600 — and the body-level letter
   panel (a real 9610) painted over the very menu that now carries Delete.
   9619 = the session-decision rung (chat-mode.css's recovery family): a menu
   whose item can end a session's record belongs with the cards that decide
   about sessions, one under the recovery card itself. Registered in
   tests/voice/zindex_ladder_test.js. */
body > .vp-menu {
  position: fixed;
  z-index: 9619;
  min-width: 168px;
  padding: 5px;
  border-radius: var(--rm-pop-radius);
  background: var(--color-card, #FAFAF7);
  border: 1px solid var(--rm-pop-line);
  box-shadow: var(--rm-pop-shadow);   /* the popover spec, chat-mode.css head */
  animation: vpMenuIn 0.14s ease both;
  font-family: var(--font-ui);
}
body > .vp-menu .vp-menu-item {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  padding: 7px 10px;
  border: 0;
  border-radius: 7px;
  background: none;
  font-family: var(--font-ui);
  font-size: var(--rm-support);
  color: var(--color-ink);
  text-align: left;
  cursor: pointer;
  transition: background 0.14s ease;
}
body > .vp-menu .vp-menu-item:hover,
body > .vp-menu .vp-menu-item:focus-visible {
  background: rgba(44, 44, 44, 0.05);
}
body > .vp-menu .vp-menu-label { flex: 1; }
/* ⛔ THE RING IS NOT SUPPRESSED HERE, AND IT NEVER WAS. This rule carried an
   `outline: none` whose comment explained that it removed nothing — the app's
   global mark was a `box-shadow: 0 0 0 4px`, not an outline, so the line was a
   fix for the wrong property left standing beside a note saying so. It is gone
   (2026-07-28), and now it would actually do damage: the portal draws the
   ROOM's 2px outline (the focus-ring block near the top of this file carries a
   body > .vp-menu line since the A6 re-parent), and this rule would blank it
   on the one item the keyboard path lands on.
   WHO SEES A RING: a keyboard open moves focus to the first item
   (voice-portal.js, untouched) and that item wears it; a mouse open moves no
   focus, so nothing is marked. That is the whole behaviour, unchanged.
   ⭐ Read the computed style before "fixing" a ring: outline and box-shadow
   look identical and are not the same property. */
/* ⛔ STILL NOT RED, and the word IS "Delete" now. The act got its confirm card
   (Delete → confirm → gone, owner 2026-08-04) — the gate lives in the ask, not
   in a hue. This palette spends red on nothing, and a new destructive colour
   would need its own owner ruling. The item reads as the quieter of the two. */
body > .vp-menu .vp-menu-item.danger { color: var(--color-stone); }
/* ⛔ IT FADES, IT DOES NOT TRAVEL (owner, 2026-07-27: "make the menu not
   shake"). This slid 3px down as it appeared — measured 414 → 417 across the
   open — which puts the item you are aiming at somewhere else by the time you
   arrive. A popover opens where it will stay. */
@keyframes vpMenuIn {
  from { opacity: 0; }
  to   { opacity: 1; }
}
/* …and the same ruling on the way OUT. It used to be taken off the page in one
   frame, which reads as a flinch beside a menu that arrived by fading. 0.08s is
   under the sub-100ms band this file already uses for a state cross-fade — long
   enough to be a departure, short enough that the next press never waits for
   it. ⛔ OPACITY ONLY: a menu that slides away is still a menu that moved, and
   the owner's ruling covers both directions. This `animation` REPLACES vpMenuIn
   (later declaration, higher specificity), so there is no fill-mode fight.
   The JS side kills its pointer-events and takes the node out at 90ms. */
@keyframes vpMenuOut {
  to { opacity: 0; }
}
body > .vp-menu.vp-menu-out { animation: vpMenuOut 0.08s ease both; }
/* ⛔ the END STATE must land (reference_reduced_motion_must_land) */
@media (prefers-reduced-motion: reduce) {
  body > .vp-menu { animation: none; opacity: 1; transform: none; }
  body > .vp-menu.vp-menu-out { animation: none; opacity: 0; }
}

/* the name being typed sits exactly where the name is READ, so committing it
   moves nothing and there is no second surface to keep in agreement */
#voicePortalOverlay .vp-srow-rename {
  font-family: var(--font-ui);
  font-size: var(--rm-support);
  font-weight: 500;
  line-height: 1.35;
  color: var(--color-ink);
  width: 100%;
  background: transparent;
  border: 0;
  border-bottom: 1px solid rgba(61, 90, 128, 0.45);
  padding: 0 0 2px;
  outline: none;
}
