    /* ──────────────────────────────────────────────────────────────────────
       Loryn — AI overlay layer. Everything below is the Loryn module's UI
       (orb, chat drawer, dedicated tab view, lock screen, whisper). It is
       additive — no existing journal selector is overridden. Removing the
       loryn.js script tag and these rules takes Loryn out of the build with
       no impact on the journal itself.
       ──────────────────────────────────────────────────────────────────── */

    /* ── Orb — Loryn's persistent presence in the hero ───────────────────
       Solid mystical sphere with one elegant orbital ring + one faint
       secondary ring, soft atmospheric halo, slow continuous motion.
       Lives inside a flex titlebar with the LORYN wordmark. */
    .hero-titlebar {
      display: inline-flex;
      align-items: center;
      gap: 14px;
    }
    .loryn-orb-btn {
      width: 42px;
      height: 42px;
      padding: 0;
      background: transparent;
      border: none;
      cursor: pointer;
      transition: transform 0.22s ease, filter 0.22s ease;
      display: inline-block;
      flex: 0 0 auto;
      vertical-align: middle;
    }
    .loryn-orb-btn:hover { transform: scale(1.1); filter: brightness(1.12); }
    .loryn-orb-svg { width: 100%; height: 100%; display: block; overflow: visible; }

    /* Rings rotate very slowly. Main ring goes one way, sub ring the other,
       so the orb feels alive without ever appearing busy. */
    .orb-ring-main {
      transform-origin: 50% 50%;
      transform-box: fill-box;
      animation: orbRingMainSpin 22s linear infinite;
    }
    /* Rotation starts at 0deg so when the spin animation kicks in after
       the emerge handoff, there's no instant rotation jump. The ellipses
       inside the groups have their own baked-in transform="rotate(N)"
       SVG attributes that establish the initial tilt; the group's CSS
       rotation simply adds on top. */
    @keyframes orbRingMainSpin {
      from { transform: rotate(0deg);   }
      to   { transform: rotate(360deg); }
    }
    .orb-ring-sub {
      transform-origin: 50% 50%;
      transform-box: fill-box;
      animation: orbRingSubSpin 38s linear infinite reverse;
    }
    @keyframes orbRingSubSpin {
      from { transform: rotate(0deg);   }
      to   { transform: rotate(360deg); }
    }

    /* Mist body — the entire body group very subtly breathes (scale + brightness)
       so the cloud feels alive without ever appearing to "pulse". Wisps inside
       the body drift independently on long cycles so the internal structure
       is never static and never the same twice. */
    .orb-mist {
      transform-origin: 50% 50%;
      transform-box: fill-box;
      animation: orbMistBreathe 9s ease-in-out infinite;
      /* Smooth the keyframe swap when entering/leaving thinking state so
         the mist morphs rather than jinking between breath cadences. */
      transition: transform 240ms ease, filter 240ms ease;
    }
    /* Breath keyframes now START AND END at the orb's resting state
       (scale 1, brightness 1) — which is also the END state of the
       emerge animation. That means the handoff from emerge → breath has
       zero visible discontinuity. The breath simply continues from
       wherever emerge left it. */
    @keyframes orbMistBreathe {
      0%, 100% { transform: scale(1);    filter: brightness(1); }
      50%      { transform: scale(0.96); filter: brightness(0.93); }
    }
    .orb-mist-core,
    .orb-mist-glow,
    .orb-mist-wisp1,
    .orb-mist-wisp2 {
      transform-origin: 50% 50%;
      transform-box: fill-box;
    }
    /* Each wisp drifts on its own slow cycle — different durations, opposite
       directions, slight translate offsets. The eye reads this as living
       vapor curling inside the cloud. */
    .orb-mist-wisp1 { animation: orbWispDrift1 14s ease-in-out infinite; }
    .orb-mist-wisp2 { animation: orbWispDrift2 18s ease-in-out infinite reverse; }
    .orb-mist-glow  { animation: orbGlowBreathe 7.5s ease-in-out infinite; }
    @keyframes orbWispDrift1 {
      0%, 100% { transform: translate(-2px, 1px) scale(1); }
      50%      { transform: translate(2px, -2px) scale(1.08); }
    }
    @keyframes orbWispDrift2 {
      0%, 100% { transform: translate(2px, -2px) scale(0.95); }
      50%      { transform: translate(-3px, 2px) scale(1.05); }
    }
    @keyframes orbGlowBreathe {
      0%, 100% { opacity: 0.8;  transform: scale(0.94); }
      50%      { opacity: 1.05; transform: scale(1.06); }
    }

    /* Halo + outer aura breathe slightly out of phase with the body so the
       whole orb never appears to "throb" mechanically. */
    .orb-halo,
    .orb-outer {
      transform-origin: 50% 50%;
      transform-box: fill-box;
    }
    .orb-halo  { animation: orbHaloBreathe  8.5s ease-in-out infinite; }
    .orb-outer { animation: orbOuterBreathe 11s  ease-in-out infinite; }
    /* Halo and outer aura breath — same alignment as mist. Start state
       matches emerge's final state (opacity 1, scale 1) so the handoff
       from emerge to breath is invisible. Cycle dips down to the trough
       at 50% and returns. */
    @keyframes orbHaloBreathe {
      0%, 100% { opacity: 1;    transform: scale(1); }
      50%      { opacity: 0.78; transform: scale(0.94); }
    }
    @keyframes orbOuterBreathe {
      0%, 100% { opacity: 1;   transform: scale(1); }
      50%      { opacity: 0.6; transform: scale(0.96); }
    }

    /* State overrides. Listening = ring opens up. Speaking = whole orb
       pulses gently. Thinking = wisps slow + condense (mist concentrating
       inward as she's concentrating). */
    .state-listening .orb-ring-main ellipse,
    .state-listening .orb-ring-sub  ellipse {
      stroke-opacity: 0.85;
      transition: stroke-opacity 0.4s ease;
    }
    /* Speaking pulse lives on the .atrium-orb CONTAINER so scale and
       brightness ride the SAME keyframe/curve/duration — no decoupled
       double-throb between the SVG and the container's brightness. */
    .state-speaking .atrium-orb { animation: orbSpeakPulse 0.5s ease-in-out infinite; }
    @keyframes orbSpeakPulse {
      0%, 100% { transform: scale(var(--orb-base-scale, 1));                 filter: brightness(1); }
      50%      { transform: scale(calc(var(--orb-base-scale, 1) * 1.05));    filter: brightness(1.08); }
    }
    .state-thinking .orb-ring-main { animation-duration: 36s; }
    .state-thinking .orb-ring-sub  { animation-duration: 52s; }
    .state-thinking .orb-mist      { animation: orbMistThink 1.4s ease-in-out infinite; }
    /* Shares orbMistBreathe's resting state (scale 1, brightness 1) at
       0%/100% so the swap from standby breath → thinking has no jink; only
       the midpoint amplitude is more dramatic to signal concentration. */
    @keyframes orbMistThink {
      0%, 100% { transform: scale(1);    filter: brightness(1); }
      50%      { transform: scale(1.08); filter: brightness(1.15); }
    }

    /* Chamber-orb thinking — handled by the SVG's own internal breath
       animations. The extra pseudo-element halos on .atrium-orb that
       used to live here added paint layers under the SVG and held a
       visible 'THINKING' label during chamber entry. Removed. */

    /* Transient phase-1 greeting beneath the big orb during chamber
       entry. Sits inside .atrium-stage as an absolute-positioned text
       element directly below the orb-zone — close enough to feel
       paired with the orb, not anchored to the convo column. Fades
       in via .visible, fades out via .fading-out, then JS removes
       the element. Pointer-events: none so it never blocks anything. */
    .atrium-orb-greeting {
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      color: rgba(232, 240, 255, 0.92);
      font-size: 22px;
      font-weight: 300;
      letter-spacing: 0.018em;
      text-align: center;
      opacity: 0;
      /* 2200ms LINEAR fade-in — constant-rate ramp so the text climbs
         from invisible to visible at the same pace throughout, instead
         of front-loading the visible change in the first 300ms (which
         ease-out curves do, defeating the "gentle" feel even at long
         durations). Fade-out stays a snappier ease-out for the handoff
         to phase 2. */
      transition: opacity 2200ms linear;
      pointer-events: none;
      z-index: 3;
      white-space: nowrap;
    }
    .atrium-orb-greeting.visible {
      opacity: 1;
    }
    .atrium-orb-greeting.fading-out {
      opacity: 0;
      transition: opacity 700ms cubic-bezier(0.32, 0.7, 0.32, 1);
    }

    /* ── Chat drawer — slide-in panel from the right edge. ──────────────── */
    .loryn-drawer {
      position: fixed;
      inset: 0;
      z-index: 180;
      pointer-events: none;
      visibility: hidden;
      transition: visibility 0s linear 0.42s;
    }
    .loryn-drawer.open {
      pointer-events: auto;
      visibility: visible;
      transition: visibility 0s linear 0s;
    }
    .loryn-drawer-backdrop {
      position: absolute;
      inset: 0;
      background: rgba(0, 0, 0, 0.55);
      opacity: 0;
      transition: opacity 0.32s ease;
      backdrop-filter: blur(2px);
      -webkit-backdrop-filter: blur(2px);
    }
    .loryn-drawer.open .loryn-drawer-backdrop { opacity: 1; }
    .loryn-drawer-panel {
      position: absolute;
      top: 0;
      right: 0;
      bottom: 0;
      width: 380px;
      max-width: 92vw;
      background: var(--vv-void-far);
      border-left: 1px solid rgba(255,255,255,0.06);
      display: flex;
      flex-direction: column;
      transform: translateX(100%);
      transition: transform 0.42s cubic-bezier(0.22, 1, 0.36, 1);
      box-shadow: -20px 0 60px rgba(0,0,0,0.55);
    }
    .loryn-drawer.open .loryn-drawer-panel { transform: translateX(0); }
    .loryn-drawer-header {
      display: flex;
      align-items: center;
      gap: 12px;
      padding: 18px 18px 14px;
      border-bottom: 1px solid rgba(255,255,255,0.06);
    }
    .loryn-orb-mini { width: 26px; height: 26px; flex: 0 0 auto; }
    .loryn-drawer-title {
      flex: 1;
      font-size: 14px;
      font-weight: 600;
      letter-spacing: 0.06em;
      color: var(--accent);
      /* Multi-layer soft bloom instead of a tight 12px α0.4 — the original
         was a 264×21 strip with blur < text height, producing a long
         rounded-rect halo. Three layers spread the falloff so the glow
         reads as diffuse haze around the wordmark, not an outlined box.
         Outer radii kept moderate (max 52px) since the drawer is a
         narrow side panel — bloom shouldn't bleed past its container. */
      text-shadow:
        0 0 14px rgba(122,184,245,0.15),
        0 0 30px rgba(122,184,245,0.09),
        0 0 52px rgba(122,184,245,0.04);
    }
    .loryn-drawer-close {
      background: transparent;
      border: none;
      color: var(--muted);
      font-size: 22px;
      line-height: 1;
      cursor: pointer;
      padding: 4px 8px;
      transition: color 0.18s ease;
    }
    .loryn-drawer-close:hover { color: var(--accent); }
    .loryn-drawer-body {
      flex: 1;
      overflow-y: auto;
      padding: 16px 18px;
      display: flex;
      flex-direction: column;
      gap: 14px;
    }
    .loryn-empty {
      flex: 1;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      gap: 18px;
      padding: 40px 8px;
      text-align: center;
    }
    .loryn-empty-orb { width: 70px; height: 70px; }
    .loryn-empty-title {
      font-size: 14px;
      color: var(--text);
      letter-spacing: 0.02em;
    }
    .loryn-empty-hint {
      font-size: 11px;
      color: var(--muted);
      max-width: 240px;
      line-height: 1.55;
    }
    .loryn-msg { display: flex; }
    .loryn-msg-user { justify-content: flex-end; }
    .loryn-msg-bubble {
      max-width: 78%;
      background: #141414;
      border: 1px solid #1f1f1f;
      padding: 9px 12px;
      border-radius: 14px 14px 4px 14px;
      font-size: 13px;
      line-height: 1.5;
      color: var(--text);
    }
    .loryn-msg-loryn .loryn-msg-body {
      max-width: 88%;
      padding: 4px 0 4px 12px;
      border-left: 2px solid var(--accent);
      font-size: 13px;
      line-height: 1.55;
      color: #cdd2d8;
    }
    .loryn-drawer-input {
      display: flex;
      align-items: center;
      gap: 8px;
      padding: 12px 14px 8px;
      border-top: 1px solid #161616;
      background: #050505;
    }
    /* AI disclosure — mirrors ChatGPT/Claude/Gemini's quiet micro-copy.
       Sits below the input, always visible while the drawer is open. */
    .loryn-ai-disclosure {
      margin: 0;
      padding: 0 18px max(10px, calc(env(safe-area-inset-bottom) + 4px));
      background: #050505;
      font-family: var(--vv-font-display, system-ui);
      font-size: 10.5px;
      line-height: 1.45;
      color: rgba(255,255,255,0.34);
      text-align: center;
      letter-spacing: 0.005em;
    }
    .loryn-mic-btn, .loryn-send-btn {
      width: 34px;
      height: 34px;
      background: transparent;
      border: 1px solid #1f1f1f;
      border-radius: 50%;
      color: var(--muted);
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      transition: color 0.18s ease, border-color 0.18s ease, background 0.18s ease;
      font-size: 14px;
      padding: 0;
      flex: 0 0 auto;
    }
    .loryn-mic-btn:hover, .loryn-send-btn:hover {
      color: var(--accent); border-color: var(--accent);
    }
    .loryn-send-btn { font-size: 16px; }
    .loryn-mic-btn svg { width: 14px; height: 14px; }
    #lorynInput {
      flex: 1;
      background: #0d0d0d;
      border: 1px solid #1f1f1f;
      border-radius: 17px;
      padding: 8px 14px;
      color: var(--text);
      font-size: 13px;
      font-family: inherit;
      outline: none;
      transition: border-color 0.18s ease, background 0.18s ease;
    }
    #lorynInput:focus { border-color: var(--accent); background: #111; }

    /* ── Atrium — Loryn's command center ─────────────────────────────────
       When body.in-atrium is set, the standard journal chrome (hero,
       tab nav, news strip, etc.) hides and the Atrium becomes a full-
       bleed experience. The Atrium has its own focal layout: large
       central orb up top, exit chip in the corner, a four-card grid of
       Memory / Watch list / Briefing / Voice arrayed below. */
    body.in-atrium .hero,
    body.in-atrium .tab-nav,
    body.in-atrium .news-strip,
    body.in-atrium .view#journalView,
    body.in-atrium .view#accountsView,
    body.in-atrium .view#goalsView,
    body.in-atrium .view#visualsView { display: none !important; }
    body.in-atrium {
      background: #000;
      overflow: hidden;
    }
    /* Reserved custom property updated by JS (visualViewport API) — the
       VISIBLE viewport height (layout viewport minus iOS keyboard). The
       Atrium stage sets height: var(--chamber-h) so the natural flex
       layout (orb top, convo middle, input bottom) refits the visible
       area whenever the keyboard opens or closes. No padding hacks
       needed: the input ends up at the visible bottom and the orb at
       the visible top because that's what the flex column dictates. */
    :root { --chamber-h: 100vh; }
    /* In the Atrium, the .app shouldn't have any padding — the chamber
       is full-bleed. The previous safe-area padding (20–42px top/bottom)
       was pushing .atrium-stage's `min-height: 100vh` below the visible
       viewport on mobile, so the input bar at the bottom of the stage
       fell below the fold and had to be scrolled to. The individual
       Atrium elements (back arrow, input line) each handle their own
       safe-area-inset positioning, so removing .app's padding doesn't
       lose any inset awareness. */
    body.in-atrium .app {
      padding: 0;
      max-width: none;
    }
    body.in-atrium #lorynView { display: block; }

    /* Atrium stage — full-bleed chamber. The orb is the focal subject;
       conversation flows beneath; the horizon line input is anchored to
       the bottom of the viewport. No max-width capping the experience —
       the chamber occupies the entire screen. */
    /* The Atrium stage is PINNED to the viewport with position: fixed.
       That removes it from the body's flow entirely — iOS can't scroll
       what isn't part of the document scroll tree, so the chamber
       stays anchored even when the keyboard opens. The height is the
       JS-managed --chamber-h, which shrinks to visualViewport.height
       when the keyboard appears. */
    body.in-atrium .atrium-stage {
      position: fixed;
      /* Stage tracks the VISIBLE viewport position — when iOS shifts
         visualViewport.offsetTop on keyboard open, --chamber-top moves
         the stage with it so we're always rendering inside what the
         user can see. */
      top: var(--chamber-top, 0px);
      left: 0;
      right: 0;
      height: var(--chamber-h, 100vh);
      display: flex;
      flex-direction: column;
      padding: 0;
      z-index: 1;
    }
    .atrium-stage {
      position: relative;
      height: var(--chamber-h, 100vh);
      display: flex;
      flex-direction: column;
      padding: 0;
    }
    /* Chamber depth — the room has WEIGHT. Two stacked atmospheric
       layers create density and pressure rather than a flat black
       background. Both are pseudo-elements on the stage so they
       compose naturally beneath wisps + orb + content. Together
       with the existing .atrium-ambient wisps, they form a four-
       layer atmospheric stack at progressively "closer" depths.
       Nothing reads as a discrete effect; what Jake should feel is
       gravity and air. */
    /* RETIRED 2026-05-30 — the .void-backdrop primitive in
       visual-vocab.css replaces this layer with a properly-dithered,
       multi-stop, depth-grammar version. Kept selector reserved to
       0-out any inherited rule. */
    body.in-atrium .atrium-stage::before {
      content: none;
    }
    /* Chamber breath includes its own fade-in (0% → 8%) so the layer
       materializes from black instead of popping in at opacity 0.45.
       Cycle then continues 0.45 → 0.9 → 0.45 as before. */
    @keyframes atriumChamberBreath {
      0%   { opacity: 0; }
      18%  { opacity: 0.45; }
      50%  { opacity: 0.9; }
      100% { opacity: 0.45; }
    }
    /* Layer 2 (closer): atmospheric haze — large diffuse blobs that
       drift VERY slowly across the room. The previous version had
       filter: blur(40px) on this full-viewport pseudo-element, which
       was painted at the moment body.in-atrium fired — that paint
       cost was THE freeze frame at chamber open (40px blur on a
       100%×120% surface with 3 stacked radial gradients = hundreds of
       ms of paint work on a populated page). Removed the blur — the
       radial gradients are already soft falloffs, so the haze still
       reads diffuse without the expensive convolution filter. */
    /* RETIRED 2026-05-30 — handled by .void-backdrop primitive. */
    body.in-atrium .atrium-stage::after {
      content: none;
    }
    /* Haze includes its own fade-in (0% → 6%) so it materializes
       rather than popping in at opacity 0.7. */
    @keyframes atriumHazeDrift {
      0%   { transform: translate(0, 0);     opacity: 0; }
      6%   { transform: translate(0, 0);     opacity: 0.7; }
      50%  { transform: translate(-4%, 2%);  opacity: 1; }
      100% { transform: translate(0, 0);     opacity: 0.7; }
    }

    /* Slim exit chip — single thin glyph in the corner. Designed to be
       noticed by intent, not as a primary affordance. */
    /* Back arrow — position:fixed pins it to the VIEWPORT top-left
       regardless of any parent's padding or scroll. Without fixed,
       absolute positioning was relative to .atrium-stage which sits
       inside .app's safe-area padding — so the arrow ended up offset
       on web. Now it's truly in the corner on every screen, with
       safe-area-inset so it doesn't tuck under the iOS notch. */
    .atrium-exit {
      position: fixed;
      top:  max(10px, calc(env(safe-area-inset-top)  + 6px));
      left: max(12px, calc(env(safe-area-inset-left) + 6px));
      width: 28px;
      height: 28px;
      background: transparent;
      border: none;
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 0;
      opacity: 0.35;
      color: var(--accent);
      filter: drop-shadow(0 0 4px rgba(122,184,245,0.32));
      transition: opacity 0.5s ease, transform 0.5s ease;
      z-index: 6;
    }
    .atrium-exit:hover {
      opacity: 1;
      transform: translateX(-3px);
    }
    .atrium-exit:active { opacity: 0.7; transform: translateX(-3px) scale(0.9); }
    .atrium-exit:focus-visible { outline: none; opacity: 1; box-shadow: 0 0 0 1px rgba(122,184,245,0.5); border-radius: 6px; }
    .atrium-exit svg {
      width: 18px;
      height: 18px;
      display: block;
    }

    /* Ambient mist field — three large soft radial-gradient wisps drift
       across the chamber on long cycles. Below the orb in z order,
       barely visible. Sets atmosphere. */
    .atrium-ambient {
      position: absolute;
      inset: 0;
      pointer-events: none;
      overflow: hidden;
      z-index: 0;
    }
    /* Wisps — radial-gradient blobs that drift across the chamber.
       Pre-AI version had NO filter:blur — the radial gradients are
       already soft falloffs (transparent at 70%), no convolution
       filter needed. The blur(54-62px) versions were added later
       and were painted for the first time at body.in-atrium fire,
       which is hundreds of ms of paint work over three viewport-
       sized elements simultaneously. Removed the blurs; the gradients
       still read as soft diffuse haze. */
    .atrium-wisp {
      position: absolute;
      border-radius: 50%;
      opacity: 0;
    }
    .atrium-wisp-1 {
      width: 50vw; height: 50vw;
      top: 18%; left: -10%;
      background: radial-gradient(circle, rgba(122,184,245,0.16) 0%, transparent 70%);
      animation: atriumWisp1 32s ease-in-out infinite;
    }
    .atrium-wisp-2 {
      width: 38vw; height: 38vw;
      top: 60%; right: -8%;
      background: radial-gradient(circle, rgba(170,210,255,0.12) 0%, transparent 70%);
      animation: atriumWisp2 40s ease-in-out infinite reverse;
    }
    .atrium-wisp-3 {
      width: 30vw; height: 30vw;
      bottom: -10%; left: 35%;
      background: radial-gradient(circle, rgba(122,184,245,0.1) 0%, transparent 70%);
      animation: atriumWisp3 48s ease-in-out infinite;
    }
    /* Two "deep" wisps — farther layer. Larger but more diffuse, much
       slower cycles (parallax back). They give the chamber its sense
       of depth without competing with the near wisps for visibility. */
    .atrium-wisp-deep {
      /* 90px blur on a 64vw element re-rasterizes a huge surface every drift
         frame — a real cost on entry. 58px keeps the diffuse depth glow at far
         lower paint cost. */
      filter: blur(58px) !important;
      opacity: 0.62 !important;
    }
    .atrium-wisp-4 {
      width: 64vw; height: 64vw;
      top: -8%; right: -22%;
      background: radial-gradient(circle, rgba(122,184,245,0.06) 0%, transparent 65%);
      animation: atriumWisp4 78s ease-in-out infinite;
    }
    .atrium-wisp-5 {
      width: 58vw; height: 58vw;
      bottom: -18%; left: -18%;
      background: radial-gradient(circle, rgba(170,210,255,0.05) 0%, transparent 65%);
      animation: atriumWisp5 92s ease-in-out infinite reverse;
    }
    @keyframes atriumWisp4 {
      0%, 100% { transform: translate(0, 0) scale(1); }
      50%      { transform: translate(-3vw, 4vh) scale(1.05); }
    }
    @keyframes atriumWisp5 {
      0%, 100% { transform: translate(0, 0) scale(1); }
      50%      { transform: translate(4vw, -3vh) scale(1.08); }
    }

    /* Ambient flecks — tiny luminous specks scattered in the deep. Not
       stars; more like dust catching distant light. Each fleck has its
       own slow position+opacity cycle (offset random delays) so the
       chamber subtly twinkles without any single fleck calling
       attention to itself. */
    .atrium-fleck {
      position: absolute;
      width: 1.5px;
      height: 1.5px;
      border-radius: 50%;
      background: rgba(170, 210, 255, 0.7);
      box-shadow: 0 0 4px 1px rgba(122, 184, 245, 0.35);
      opacity: 0;
      animation: atriumFleckTwinkle 14s ease-in-out infinite;
    }
    .atrium-fleck-1 { top: 14%; left: 22%; animation-delay: -0.4s; animation-duration: 11s; }
    .atrium-fleck-2 { top: 31%; left: 78%; animation-delay: -3.7s; animation-duration: 17s; }
    .atrium-fleck-3 { top: 56%; left: 11%; animation-delay: -6.1s; animation-duration: 13s; }
    .atrium-fleck-4 { top: 72%; left: 84%; animation-delay: -1.9s; animation-duration: 15s; }
    .atrium-fleck-5 { top: 22%; left: 52%; animation-delay: -8.3s; animation-duration: 19s; }
    .atrium-fleck-6 { top: 84%; left: 41%; animation-delay: -5.1s; animation-duration: 12s; }
    .atrium-fleck-7 { top: 45%; left: 92%; animation-delay: -2.6s; animation-duration: 16s; }
    @keyframes atriumFleckTwinkle {
      0%, 100% { opacity: 0;    transform: scale(0.6); }
      30%      { opacity: 0.85; transform: scale(1); }
      55%      { opacity: 0.55; transform: scale(0.85); }
      75%      { opacity: 0.92; transform: scale(1.05); }
    }

    /* Asymmetric corner emphasis — the four corners get a touch more
       saturation than the side-edges. Sits ABOVE the existing layered
       vignette via a high-z stacking order, but with negative
       interference (multiply) so it can't brighten anything. */
    body.in-atrium .atrium-stage > .atrium-ambient::after {
      /* Already set by Pass 1 — keep. */
    }
    body.in-atrium .atrium-wisp-1 { animation-name: atriumWisp1, atriumWispFadeIn; animation-fill-mode: both; }
    body.in-atrium .atrium-wisp-2 { animation-name: atriumWisp2, atriumWispFadeIn; animation-fill-mode: both; }
    body.in-atrium .atrium-wisp-3 { animation-name: atriumWisp3, atriumWispFadeIn; animation-fill-mode: both; }
    /* Wisps respond to chamber tension — at idle they drift freely; as
       tension climbs, they dim and contract. The room's atmosphere
       gathers itself when Loryn is thinking. */
    body.in-atrium .atrium-wisp {
      opacity: calc(1 - var(--chamber-tension, 0.2) * 0.45);
      transition: opacity 1600ms cubic-bezier(0.32, 0.7, 0.32, 1);
    }
    body.in-atrium .atrium-wisp {
      animation-duration: var(--wd, 36s), 2400ms;
      animation-delay: 0s, 400ms;
      animation-timing-function: ease-in-out, ease-out;
      animation-iteration-count: infinite, 1;
    }
    @keyframes atriumWispFadeIn { from { opacity: 0; } to { opacity: 1; } }
    @keyframes atriumWisp1 {
      0%, 100% { transform: translate(0, 0) scale(1); }
      50%      { transform: translate(12vw, 6vh) scale(1.1); }
    }
    @keyframes atriumWisp2 {
      0%, 100% { transform: translate(0, 0) scale(1); }
      50%      { transform: translate(-10vw, -4vh) scale(1.15); }
    }
    @keyframes atriumWisp3 {
      0%, 100% { transform: translate(0, 0) scale(1); }
      50%      { transform: translate(8vw, -8vh) scale(1.08); }
    }

    /* Orb zone — focal element. Sized + padded so it lands at the same
       spot the FLIP morph targets (120px from top on desktop, 70px on
       mobile, both matching enterAtrium's targetY constants). */
    .atrium-orb-zone {
      display: flex;
      justify-content: center;
      align-items: center;
      /* Top padding includes safe-area-inset-top so the orb never gets
         clipped by the iPhone Dynamic Island / notch. The padding
         compresses when conversation is active so messages get the
         freed space (see body.atrium-active rule below). */
      padding: max(120px, calc(env(safe-area-inset-top) + 100px)) 0 40px;
      position: relative;
      z-index: 2;
      transition:
        padding 2600ms cubic-bezier(0.32, 0.7, 0.32, 1);
    }
    .atrium-orb {
      width: 200px;
      height: 200px;
      /* Match the pre-AI baseline: NO transform on the base rule.
         A transform on every .atrium-orb (even scale(1)) establishes
         a stacking context + composite layer permanently, which
         interferes with the chamber-entry assembly animations and was
         the source of the entry freeze. The active-shrink transform
         only applies on body.atrium-active (rule below) — that's the
         single moment the orb needs GPU compositing, not all the time.
         --orb-base-scale stays declared so thinking-pulse keyframes
         can compose against it without overwriting the active-shrink. */
      --orb-base-scale: 1;
      transform-origin: center center;
      transition: transform 2600ms cubic-bezier(0.32, 0.7, 0.32, 1);
    }
    /* Orb "steps aside" once a conversation begins — she shrinks to a
       smaller presence so the messages + modules become the dominant
       visual surface. Returns to full size when the user backs out of
       Command and re-enters with no current-session messages. The
       transition is one continuous ease across width/height/padding so
       it reads as a single graceful motion. */
    body.atrium-active .atrium-orb-zone {
      /* Tighter top padding (28px → 8px) so the small orb sits closer to
         the chamber's top edge — removes the awkward empty band between
         the chamber top and the orb that Jake repeatedly called out.
         Bottom padding stays minimal so the convo follows directly. */
      padding: max(8px, calc(env(safe-area-inset-top) + 4px)) 0 6px;
    }
    body.atrium-active .atrium-orb {
      /* 80/200 = 0.4 — the transform is APPLIED here (not on base)
         so the orb has no transform/stacking-context during chamber
         entry. The transition on .atrium-orb catches the property
         appearing and smoothly animates from identity → scale(0.4). */
      --orb-base-scale: 0.4;
      transform: scale(var(--orb-base-scale));
    }
    /* Desktop: original drop-shadow filter on the SVG. Smooth on Chrome
       and exactly the diffuse glow we want. */
    /* drop-shadow REMOVED from the SVG itself. With internal opacity
       animations on multiple SVG layers, the browser was re-evaluating
       the drop-shadow on the entire SVG every frame — that was the
       30-67ms stutters during orb assembly. Setting filter:none here
       leaves only the simple drop-shadow declared higher up the file. */
    .atrium-orb .loryn-orb-svg {
      filter: none;
    }
    /* Touch devices (iPhone/iPad): iOS WebKit rasterizes a filtered SVG
       into a rectangular GPU buffer that paints as a visible square halo
       on pure black. Swap to a box-shadow on a circular pseudo-element
       which respects border-radius and produces the same soft falloff
       without the rectangle. Scoped to touch only so desktop animation
       performance is untouched. */
    @media (hover: none) and (pointer: coarse) {
      .atrium-orb {
        position: relative;
      }
      .atrium-orb .loryn-orb-svg {
        filter: none;
      }
      .atrium-orb::before {
        content: '';
        position: absolute;
        top: 50%;
        left: 50%;
        width: 52%;
        height: 52%;
        transform: translate(-50%, -50%);
        border-radius: 50%;
        box-shadow: 0 0 56px 4px rgba(122,184,245,0.32);
        pointer-events: none;
      }
    }

    /* Container-level fade for the chamber orb. The parent .orb-mist
       opacity:0 doesn't reach its children when the group has an SVG
       filter applied (Chrome rasterizes the filter region with full
       child opacity, then group opacity multiplies — but the bright
       mist-glow center still leaks through as a tiny visible pixel).
       Gating the entire .atrium-orb container with visibility:hidden
       + opacity:0, then flipping visibility at the moment the opacity
       fade starts, is the only reliable way to hold EVERY part of the
       orb invisible until the fade begins. The orb then materializes
       as ONE piece via a slow linear opacity ramp — feels like the
       whole orb coming into being together, no parts popping early.
       Inner layered emerge animations still run beneath this fade,
       so once the container is partially visible the assembly cadence
       is preserved (outer → halo → mist) but always gated by the
       container's opacity. */
    .atrium-orb {
      visibility: hidden;
      opacity: 0;
      transition: transform 280ms cubic-bezier(0.32, 0.7, 0.32, 1),
                  filter   280ms cubic-bezier(0.32, 0.7, 0.32, 1);
    }
    /* Hit area only registers on the visible SVG pixels — clicking the
       transparent corners of the 200px bounding box no longer triggers
       voice mode. visiblePainted is the SVG-native rule for "only where
       paint exists, not the surrounding transparent square." Click
       still bubbles to .atrium-orb's listener, hover still fires. */
    .atrium-orb .loryn-orb-svg {
      pointer-events: visiblePainted;
      cursor: pointer;
    }
    /* Talking animation — CSS keyframe pulse on a ~520ms cycle when
       Loryn is speaking (body.loryn-talking). For LISTENING, the orb
       pulses with the user's mic amplitude via --voice-amp on :root.
       Same visual shape both ways: scale + brightness ride together,
       outer halo ring rides slightly stronger so the outer blue ring
       breathes with whoever's speaking. */
    :root { --voice-amp: 0; }

    /* SPEAKING: fixed keyframe pulse (audio output can't be safely
       waveform-analyzed without breaking playback). */
    body.loryn-talking .atrium-orb,
    body.loryn-talking .loryn-voice-orb {
      animation: lorynTalkingPulse 520ms ease-in-out infinite;
    }
    @keyframes lorynTalkingPulse {
      0%, 100% {
        transform: scale(var(--orb-base-scale, 1));
        filter: brightness(1);
      }
      50% {
        transform: scale(calc(var(--orb-base-scale, 1) * 1.06));
        filter: brightness(1.22);
      }
    }
    body.loryn-talking .atrium-orb .orb-outer,
    body.loryn-talking .atrium-orb .orb-halo {
      animation: lorynTalkingRing 620ms ease-in-out infinite;
      transform-origin: center center;
      transform-box: fill-box;
    }
    @keyframes lorynTalkingRing {
      0%, 100% { transform: scale(1);     opacity: 0.7; }
      50%      { transform: scale(1.08);  opacity: 1; }
    }

    /* LISTENING (ambient): gentle keyframe pulse ALWAYS runs during
       listening, regardless of whether the user is currently speaking
       — the user-speaking pulse layers ON TOP via a different
       selector. This guarantees the orb is always visibly animating
       while voice mode is active, so even if SR's sound-detection
       events never fire (Atlas / some Chromium builds), the user
       always sees the orb breathing. */
    body.in-atrium.loryn-mic-listening .atrium-orb {
      animation:
        orbContainerReveal 0s linear 400ms forwards,
        orbEmergeOpacity   2800ms linear 400ms forwards,
        lorynListeningPulse 770ms ease-in-out infinite;
    }
    /* LISTENING (user actively speaking): the user-speaking pulse
       REPLACES the ambient pulse (faster cadence, larger amplitude)
       to signal "I hear you." Both selectors include the reveal
       chain so the orb stays visible. */
    body.in-atrium.loryn-mic-listening.loryn-user-speaking .atrium-orb {
      animation:
        orbContainerReveal 0s linear 400ms forwards,
        orbEmergeOpacity   2800ms linear 400ms forwards,
        lorynUserPulse     460ms cubic-bezier(0.45, 0, 0.55, 1) infinite;
    }
    body.loryn-mic-listening.loryn-user-speaking .loryn-voice-orb {
      animation: lorynUserPulse 460ms cubic-bezier(0.45, 0, 0.55, 1) infinite;
    }
    @keyframes lorynUserPulse {
      0%, 100% {
        transform: scale(var(--orb-base-scale, 1));
        filter: brightness(1.05);
      }
      50% {
        transform: scale(calc(var(--orb-base-scale, 1) * 1.14));
        filter: brightness(1.35);
      }
    }
    @keyframes lorynListeningPulse {
      0%, 100% {
        transform: scale(var(--orb-base-scale, 1));
        filter: brightness(0.95);
      }
      50% {
        transform: scale(calc(var(--orb-base-scale, 1) * 1.04));
        filter: brightness(1.12);
      }
    }
    body.loryn-mic-listening .atrium-orb .orb-outer,
    body.loryn-mic-listening .atrium-orb .orb-halo,
    body.loryn-mic-listening .loryn-voice-orb .orb-outer,
    body.loryn-mic-listening .loryn-voice-orb .orb-halo {
      animation: lorynListeningRing 900ms ease-in-out infinite;
      transform-origin: center center;
      transform-box: fill-box;
    }
    @keyframes lorynListeningRing {
      0%, 100% { transform: scale(1);     opacity: 0.7; }
      50%      { transform: scale(1.06);  opacity: 1; }
    }
    /* User-speaking ring — kept as a subtle outer-ring breath that
       rides --voice-amp alongside the orb's body scale. The previous
       fixed 380ms keyframe was replaced because it competed with the
       live amplitude transform on the orb body. */
    body.loryn-user-speaking .atrium-orb .orb-outer,
    body.loryn-user-speaking .atrium-orb .orb-halo,
    body.loryn-user-speaking .loryn-voice-orb .orb-outer,
    body.loryn-user-speaking .loryn-voice-orb .orb-halo {
      transform: scale(calc(1 + var(--voice-amp, 0) * 0.28));
      opacity: calc(0.7 + var(--voice-amp, 0) * 0.4);
      transform-origin: center center;
      transform-box: fill-box;
      transition: transform 50ms linear, opacity 70ms linear;
    }

    /* Chamber-resident voice mode — small "LISTENING / SPEAKING"
       indicator beneath the (already-small) orb, plus a × close
       chip in the top-right corner. Doesn't take over the chamber;
       just signals the mode.
       ──────────────────────────────────────────────────────────
       POSITION FIX (2026-05-28). The previous rule used
       `bottom: -8px` of the .atrium-orb-zone box. That box is
       sized for the un-shrunk 200px orb, but body.atrium-active
       scales the orb to 80px via `transform: scale(0.4)`. The
       transform doesn't shrink the box — it only scales the visual.
       So `bottom: -8px` landed ~60px BELOW the visible orb edge
       instead of 8px below it, and the label floated into the
       prose area (Jake's "speaking:thinking should go directly
       under orb" screenshot).
       Fix: anchor `top` from the centre instead of `bottom` from
       the edge. The visible orb is centered in the 200px box, so
       its visible bottom is at center + 40px (half of post-scale
       80px). We want the label 8-12px below that:
         top: calc(50% + 40px + 10px)  →  top: calc(50% + 50px)
       This keeps the label visually attached to the orb regardless
       of when the scale transform runs. */
    /* Voice does NOT blow the orb up in the middle of the screen (Jake: "voice
       just puts the orb straight in the middle instead of moving to the top so
       loryn can actually bring modules"). Voice is the WORKING posture like chat
       — chamber-working docks the orb at the top; presence comes from her
       speaking bloom, not from centering + scaling. So leave the zone transform
       neutral and let the dock stand. */
    body.in-voice-mode .atrium-stage .atrium-orb-zone {
      transform: none;
      transition: transform 440ms cubic-bezier(0.32, 0.7, 0.32, 1);
    }
    /* The status sits a CONSISTENT, small gap below the orb (anchored to the
       orb-zone's bottom edge, not its center) so the orb → status → words cluster
       reads as one tidy unit instead of a label floating in space / hugging the
       words. The orb is always docked in voice (enterVoiceMode), so 100% = just
       under the orb. (Jake: "awkwardly spaced with the orb and the status.") */
    body.in-voice-mode .atrium-orb-zone::after {
      content: var(--voice-status, 'listening');
      position: absolute;
      left: 50%;
      top: calc(100% + 13px);
      transform: translateX(-50%);
      font-size: 9px;
      letter-spacing: 0.42em;
      text-transform: uppercase;
      color: rgba(122, 184, 245, 0.7);
      pointer-events: none;
      z-index: 6;
      animation: lorynVoiceListenPulse 1.4s ease-in-out infinite;
    }
    /* Give her words clean air beneath the status so the two don't collide
       (the status used to land ~9px above the verdict — cramped). */
    body.in-voice-mode:not(.chamber-in-room) .cc-hero {
      padding-top: 46px !important;
    }
    body.in-voice-mode:not(.chamber-in-room) [data-cc="hero-verdict"] {
      font-size: 16px;
      line-height: 1.5;
    }
    /* CHAMBER-IN-ROOM voice mode: the orb shrinks to 48px and the orb-zone
       collapses to ~60px tall, so the top:calc(50%+50px) anchor escapes BELOW
       the zone and lands smack on the verdict line (cc-hero-verdict, which is
       where Loryn's spoken words mirror to in voice mode). Re-anchor the
       indicator to sit just below the orb-zone edge in a dedicated slot, and
       bump cc-hero padding-top so the verdict has breathing room beneath the
       indicator instead of being jammed against the orb. (Jake, 2026-06-04
       handoff item #1.) */
    body.in-voice-mode.chamber-in-room .atrium-orb-zone::after {
      top: auto;
      bottom: -22px;
    }
    body.in-voice-mode.chamber-in-room .cc-hero {
      padding-top: 40px !important;
    }
    body.in-voice-mode.loryn-talking .atrium-orb-zone::after {
      content: 'speaking';
      animation: none;
      opacity: 0.95;
    }
    body.loryn-thinking.in-voice-mode .atrium-orb-zone::after {
      content: 'thinking';
      animation: lorynVoiceListenPulse 1.6s ease-in-out infinite;
    }
    /* On a voice error (JS sets data-voice-error), freeze the pulse and
       hold the label fully opaque so the failure reads as stuck/urgent,
       not as a transient 'still waiting' breath. */
    body.in-voice-mode[data-voice-error] .atrium-orb-zone::after {
      animation: none;
      opacity: 1;
    }
    @keyframes lorynVoiceListenPulse {
      0%, 100% { opacity: 0.55; }
      50%      { opacity: 1; }
    }
    /* Journal-proposal card — Loryn's request to write to objectives or
       trading rules. Rendered inline with her message, just above any
       next_actions. Yes / No buttons; "Yes" commits to localStorage +
       Supabase via the journal's setRules / setPlanner. */
    /* MINIMAL confirm card — no filled box (that read clunky in the narrow
       workspace pane). A quiet top hairline, a small label, the line, and two
       compact pills. Sleek, cyan accent. (Jake, 2026-06-03) */
    .atrium-proposal {
      max-width: 540px;
      width: 100%;
      margin: 16px auto 0;
      padding: 14px 0 2px;
      border: none;
      background: none;
      border-top: 1px solid rgba(255, 255, 255, 0.07);
      display: flex;
      flex-direction: column;
      gap: 9px;
      text-align: left;
      align-self: center;
    }
    .atrium-proposal-label {
      font-size: 9px;
      letter-spacing: 0.22em;
      text-transform: uppercase;
      color: rgba(170, 205, 255, 0.5);
    }
    .atrium-proposal-text {
      font-size: 13.5px;
      color: rgba(232, 240, 255, 0.92);
      line-height: 1.5;
      font-style: normal;
    }
    .atrium-proposal-actions {
      display: flex;
      gap: 9px;
      margin-top: 2px;
    }
    .atrium-proposal-yes,
    .atrium-proposal-no {
      flex: 0 0 auto;
      padding: 7px 16px;
      background: transparent;
      border: 1px solid rgba(255, 255, 255, 0.12);
      color: rgba(232, 240, 255, 0.6);
      font-family: inherit;
      font-size: 12px;
      letter-spacing: 0.03em;
      cursor: pointer;
      border-radius: 7px;
      transition: background 200ms ease, border-color 200ms ease, color 200ms ease, transform 120ms ease;
    }
    .atrium-proposal-yes {
      border-color: rgba(122, 184, 245, 0.4);
      color: rgba(207, 227, 255, 0.95);
    }
    .atrium-proposal-yes:hover {
      background: rgba(122, 184, 245, 0.12);
      border-color: rgba(122, 184, 245, 0.6);
      color: #fff;
    }
    .atrium-proposal-no:hover {
      border-color: rgba(255, 255, 255, 0.28);
      color: rgba(232, 240, 255, 0.85);
    }
    .atrium-proposal-yes:active, .atrium-proposal-no:active { transform: scale(0.97); }
    .atrium-proposal-status {
      font-size: 11.5px;
      letter-spacing: 0.03em;
      color: rgba(170, 205, 255, 0.6);
      padding: 2px 0;
    }
    .atrium-proposal.is-accepted .atrium-proposal-text { color: rgba(170, 210, 255, 0.92); }
    .atrium-proposal.is-declined .atrium-proposal-text { opacity: 0.55; }
    /* SMOOTH SPAWN (Jake: "decision cards spawn/despawn instantly"). The card
       rises from the void like the rest of the chamber vocabulary instead of
       popping into place; the confirmation status fades in when resolved; and a
       tagged card eases out instead of vanishing. */
    @keyframes ccProposalRise {
      from { opacity: 0; transform: translateY(10px); }
      to   { opacity: 1; transform: translateY(0); }
    }
    .atrium-proposal {
      animation: ccProposalRise 460ms cubic-bezier(0.32, 0.5, 0.32, 1) both;
    }
    @keyframes ccProposalStatusIn {
      from { opacity: 0; transform: translateY(3px); }
      to   { opacity: 1; transform: translateY(0); }
    }
    .atrium-proposal-status {
      animation: ccProposalStatusIn 360ms cubic-bezier(0.32, 0.5, 0.32, 1) both;
    }
    @keyframes ccProposalFall {
      from { opacity: 1; transform: translateY(0); max-height: 220px; }
      to   { opacity: 0; transform: translateY(-6px); max-height: 0; margin-top: 0; padding-top: 0; }
    }
    .atrium-proposal.is-leaving {
      animation: ccProposalFall 380ms cubic-bezier(0.4, 0, 0.7, 1) forwards;
      overflow: hidden;
      pointer-events: none;
    }
    @media (prefers-reduced-motion: reduce) {
      .atrium-proposal, .atrium-proposal-status, .atrium-proposal.is-leaving { animation: none; }
    }

    /* Voice exit chip — sits in the chamber's top-right, mirrors the
       existing back arrow's footprint so the UI stays balanced. */
    .chamber-voice-exit {
      position: fixed;
      top: max(18px, env(safe-area-inset-top));
      right: max(18px, env(safe-area-inset-right));
      width: 28px;
      height: 28px;
      display: none;
      align-items: center;
      justify-content: center;
      background: transparent;
      border: none;
      color: rgba(232, 240, 255, 0.55);
      cursor: pointer;
      z-index: 10;
      font-size: 20px;
      line-height: 1;
      padding: 0;
      transition: color 200ms ease;
    }
    .chamber-voice-exit:hover {
      color: rgba(232, 240, 255, 0.95);
    }
    /* Show the exit chip whenever ANY voice-related body class is on.
       Without this, a half-stuck state where in-voice-mode got cleared
       but the mic was still hot left the chip hidden and Jake with no
       way out (task #49). The chip's click handler is a forceful
       reset so it always recovers regardless of state. */
    body.in-voice-mode .chamber-voice-exit,
    body.loryn-mic-listening .chamber-voice-exit,
    body.loryn-talking .chamber-voice-exit {
      display: flex;
    }
    /* Subtle hover magnify + brightness lift so the cursor's affordance
       is unmistakable — Jake hovers near the orb and sees it respond
       before he commits to the click. Composes with active-shrink via
       the --orb-base-scale variable so it works at both sizes. The
       body.in-atrium prefix raises specificity above the active-shrink
       rule (body.atrium-active .atrium-orb) which would otherwise win
       and prevent the hover magnify on the small orb. */
    body.in-atrium .atrium-orb:hover {
      transform: scale(calc(var(--orb-base-scale, 1) * 1.13)) !important;
      filter: brightness(1.18) !important;
      cursor: pointer;
    }
    body.in-atrium .atrium-orb {
      animation:
        orbContainerReveal 0s linear 400ms forwards,
        orbEmergeOpacity 2800ms linear 400ms forwards;
    }
    @keyframes orbContainerReveal {
      to { visibility: visible; }
    }

    /* Conversation thread. Generous typography, breathing room. User
       messages render as muted small caps (your words, quiet); Loryn's
       render larger in a softer color with a luminous dot at the start
       (her words, prominent). No bubbles anywhere — this isn't a chat
       app. The thread spans the full width of the chamber so the room
       reads as wide and open, not centered in a column. */
    .atrium-convo {
      flex: 1;
      width: 100%;
      margin: 0;
      /* Bottom padding 220px reserves clear breathing room above the
         input bar — the offers' last line must NEVER visually touch
         the horizon. Top padding 24px pulls content close to the
         (small, active-state) orb above so the response sits as a
         visual unit beneath it instead of floating mid-chamber. */
      padding: 24px 40px 220px;
      overflow: visible;
      display: flex;
      flex-direction: column;
      justify-content: flex-start;
      align-items: center;
      gap: 22px;
      position: relative;
      z-index: 2;
      /* The lone-greeting / voice states float the content lower via a LARGER
         padding-top (not justify-content:center — see below). Transition it so
         when the first exchange starts the content EASES up to its resting top
         instead of snapping toward the orb. This is the "message flying up to
         the orb" fix. */
      transition: padding-top 640ms cubic-bezier(0.32, 0.5, 0.32, 1);
    }
    /* While only the greeting is in the convo, float it LOWER so it sits near
       the middle of the chamber instead of jammed under the orb — but do it
       with padding-top, NOT justify-content:center. Center-justify was the bug:
       (1) it shoves every earlier message UP toward the orb each time a new one
       is appended (the "flying up" in voice mode), and (2) the center→flex-start
       flip the instant a real exchange starts SNAPPED the greeting up to the orb.
       Top-anchored + a transitionable padding-top means content never jumps:
       the lone greeting floats low, then EASES up smoothly when the first
       exchange begins, and new messages append below without disturbing the
       ones above. (Jake: "message flying up to the orb", incl. voice mode.) */
    /* LONE GREETING — center it so it sits where Phase-1 ("Good afternoon, sir.")
       sat (dead-center, under the orb). Jumping it up to 28vh made the
       Phase-1→Phase-2 handoff feel disconnected (Jake). Safe to center: in the
       greeting-only state there is exactly ONE message, so center-justify can't
       shove anything upward — and the leaving-clone fly-up is gone (removed in
       loryn.js). The instant a real exchange starts, loryn-greeting-only clears
       and the convo returns to its top-anchored flex-start. */
    body.loryn-greeting-only .atrium-convo {
      justify-content: center;
      padding-top: 0;
    }
    /* VOICE mode stacks multiple turns — stay TOP-anchored so a new turn never
       shoves earlier ones up toward the orb (that was the fly-up). */
    body.in-voice-mode .atrium-convo {
      justify-content: flex-start;
      padding-top: 22vh;
    }
    /* Messages run wide. Smaller text — your words quiet small-caps,
       Loryn's words lighter and more typographic. Both spill almost the
       full width of the chamber. */
    /* ── Next actions — drilldown affordances ────────────────────────
       Quiet pill buttons that appear BENEATH a composition when Loryn
       wants to offer "I have more, ask if you want it" without dumping
       content. Tapping fires the action's intent as a synthetic user
       prompt — the chamber loops back through the normal send flow.
       Visually: low-hierarchy outlined pills, dimmer than primary
       chrome, with accent on hover. NEVER dominant; always a quiet
       offer the user can ignore. */
    /* Next actions — Loryn's offers, presented as quiet text affordances,
       NOT pill buttons. Each option is one tappable line with a thin
       accent dot prefix. No box, no border, no fill. Lines reveal one
       at a time via veil-recede with a 180ms stagger. Hover lifts the
       dot to full brightness and text toward white; click springs the
       chosen offer and recedes the others into chamber atmosphere. */
    .atrium-next-actions {
      width: 100%;
      display: flex;
      /* Horizontal layout — offers sit side-by-side instead of stacked.
         Three offers across uses the chamber's width and saves ~70px of
         vertical space (each stacked offer was ~35px tall). flex-wrap
         allows long-label cases to fall to a second line gracefully. */
      flex-direction: row;
      flex-wrap: wrap;
      justify-content: center;
      gap: 28px;
      margin: 28px auto 0;
      padding: 0;
      max-width: 1100px;
    }
    .atrium-next-action {
      /* In horizontal layout each offer is sized to its content with a
         sensible cap, so long labels don't dominate. */
      flex: 0 1 auto;
      max-width: 360px;
    }
    .atrium-next-action {
      border: none;
      background: transparent;
      color: rgba(232, 240, 255, 0.62);
      padding: 11px 0 11px 24px;
      font-size: 13.5px;
      letter-spacing: 0.022em;
      font-weight: 300;
      font-family: inherit;
      cursor: pointer;
      text-align: left;
      position: relative;
      transition:
        color 320ms cubic-bezier(0.32, 0.7, 0.32, 1),
        transform 480ms cubic-bezier(0.32, 0.7, 0.32, 1),
        opacity 1100ms cubic-bezier(0.32, 0.7, 0.32, 1),
        filter  1100ms cubic-bezier(0.32, 0.7, 0.32, 1);
      -webkit-tap-highlight-color: transparent;
      animation: lorynOfferReveal 760ms cubic-bezier(0.32, 0.7, 0.32, 1) both;
      animation-delay: calc(180ms * var(--offer-idx, 0) + 280ms);
    }
    @keyframes lorynOfferReveal {
      0%   { opacity: 0; filter: blur(6px); transform: translateY(2px); }
      100% { opacity: 1; filter: blur(0);   transform: translateY(0); }
    }
    /* Chevron prefix instead of a bare dot — the › glyph reads as
       "follow this," signaling tappable. Default subtle, hover advances
       slightly to the right + brightens. Combined with the hover
       underline below, offers now read clearly as interactive. */
    .atrium-next-action::before {
      content: '›';
      position: absolute;
      left: 4px;
      top: 50%;
      transform: translateY(-52%);
      color: rgba(122, 184, 245, 0.65);
      font-size: 15px;
      line-height: 1;
      font-weight: 400;
      transition:
        color 220ms cubic-bezier(0.32, 0.7, 0.32, 1),
        transform 280ms cubic-bezier(0.32, 0.7, 0.32, 1);
      pointer-events: none;
    }
    /* Hover underline — grows from left, telegraphs "click me" without
       turning the offer into a button. Quiet, deliberate. */
    .atrium-next-action::after {
      content: '';
      position: absolute;
      left: 18px;
      right: 6px;
      bottom: -2px;
      height: 1px;
      background: rgba(122, 184, 245, 0.45);
      transform: scaleX(0);
      transform-origin: left center;
      transition: transform 320ms cubic-bezier(0.32, 0.7, 0.32, 1);
      pointer-events: none;
    }
    .atrium-next-action:hover {
      color: rgba(232, 240, 255, 0.96);
    }
    .atrium-next-action:hover::before {
      color: rgba(170, 210, 255, 0.95);
      transform: translateY(-52%) translateX(2px);
    }
    .atrium-next-action:hover::after {
      transform: scaleX(1);
    }
    /* When an offer is chosen, springs the dot and text, then the
       OTHER offers recede into chamber atmosphere. Same primitive as
       her receded prior responses — they don't disappear, they sink. */
    .atrium-next-action.is-chosen {
      animation: lorynOfferChosen 320ms cubic-bezier(0.34, 1.56, 0.64, 1);
      color: rgba(255, 255, 255, 1);
    }
    .atrium-next-action.is-chosen::before {
      color: rgba(255, 255, 255, 1);
      transform: translateY(-52%) translateX(3px);
      text-shadow: 0 0 8px rgba(122, 184, 245, 0.7);
    }
    @keyframes lorynOfferChosen {
      0%   { transform: scale(1); }
      32%  { transform: scale(1.022); }
      100% { transform: scale(1); }
    }
    .atrium-next-actions.has-chosen .atrium-next-action:not(.is-chosen) {
      /* Cancel the reveal animation so the recede transition has the
         visual stage to itself. Without this, the animation's
         forwards-fill keeps opacity at 1. */
      animation: none;
      opacity: 0;
      filter: blur(4px);
      pointer-events: none;
    }

    /* ── Memory crumb — the compressed trace of the PRIOR response ────
       When a new response arrives, the previous one collapses to a
       single-line semantic crumb instead of a blurred paragraph. The
       crumb sits above the active manifestation in low hierarchy: small
       all-caps type, dimmed accent. Signals continuity ("the chamber
       remembers what just happened") without occupying real visual
       space or competing with the active thought. NO blurred paragraph,
       NO module remnants — just a short trace. */
    .atrium-crumb {
      align-self: center;
      text-align: center;
      max-width: 540px;
      margin: 0 auto 14px;
      padding: 4px 14px;
      font-size: 9.5px;
      letter-spacing: 0.32em;
      text-transform: uppercase;
      color: rgba(232, 240, 255, 0.32);
      font-weight: 400;
      pointer-events: none;
      background: transparent;
      border: none;
      animation: atriumCrumbSettle 1400ms cubic-bezier(0.32, 0.7, 0.32, 1) backwards;
    }
    @keyframes atriumCrumbSettle {
      0%   { opacity: 0;    filter: blur(3px); letter-spacing: 0.28em; }
      100% { opacity: 0.5;  filter: blur(0);   letter-spacing: 0.32em; }
    }

    .atrium-msg { line-height: 1.7; }
    /* User input doesn't render as a "message bubble." It enters as a
       brief TRANSMISSION TAG — small uppercase glyph at the top of the
       chamber, settles into its quiet form, then quietly dissolves back
       into the room after ~8 seconds. Jake retains context for a beat
       but nothing about it reads as "your message in a chat log." */
    .atrium-msg-user {
      align-self: flex-end;
      color: #6e747f;
      font-size: 10px;
      letter-spacing: 0.3em;
      text-transform: uppercase;
      font-weight: 400;
      max-width: 90%;
      text-align: right;
      animation:
        atriumUserEcho 540ms cubic-bezier(0.22, 1, 0.36, 1) backwards,
        atriumUserDissolve 1400ms cubic-bezier(0.4, 0, 0.6, 1) 7800ms forwards;
    }
    @keyframes atriumUserEcho {
      0%   { opacity: 0; transform: translateY(-22px); color: var(--accent); text-shadow: 0 0 14px rgba(122,184,245,0.6); }
      60%  { opacity: 1; transform: translateY(0);     color: var(--accent); text-shadow: 0 0 8px rgba(122,184,245,0.35); }
      100% { opacity: 1; transform: translateY(0);     color: #6e747f;       text-shadow: none; }
    }
    @keyframes atriumUserDissolve {
      0%   { opacity: 1; filter: blur(0); letter-spacing: 0.3em; }
      100% { opacity: 0; filter: blur(2px); letter-spacing: 0.5em; }
    }
    /* MANIFESTATION FIELD — Loryn's primary surface. Centered. Not a
       message in a chat column; an artifact that BECOMES legible in
       the center of her chamber.

       The text itself doesn't fade/slide/scale in. Instead, a haze
       veil (::before pseudo) initially covers the text and recedes.
       The viewer reads this as: "the text was already in the room;
       my eye just learned to see it." NEVER as "the text appeared."

       Old responses keep their position but recede in hierarchy
       (.atrium-msg-loryn.receded) — they dim, blur slightly, and
       sink behind atmospheric haze. Continuity without chat-log. */
    .atrium-msg-loryn {
      align-self: center;
      text-align: center;
      position: relative;
      font-size: 16px;
      color: rgba(232, 240, 255, 0.96);
      font-weight: 400;
      letter-spacing: 0.012em;
      line-height: 1.7;
      /* Outer container is wide so strategy surfaces (bar charts, splits,
         deep canvases) can use the chamber's horizontal real estate. Prose
         INSIDE the message still caps narrow via .strategy-prose for
         readability — it's only the surrounding layout that benefits from
         the extra width. Previously locked at 760px which left ~1100px of
         chamber unused on every response. */
      max-width: 1200px;
      width: 100%;
      margin: 0 auto;
      padding: 0 40px;
      font-feature-settings: "tnum" 1, "kern" 1, "liga" 1;
      transition:
        opacity 1400ms cubic-bezier(0.32, 0.7, 0.32, 1),
        filter  1800ms cubic-bezier(0.32, 0.7, 0.32, 1),
        transform 1600ms cubic-bezier(0.32, 0.7, 0.32, 1);
    }
    /* Accent bar killed — Jake judged it as vestigial UI chrome. The
       centered manifestation field IS the signifier of her speaking;
       a static blue line next to the text reads as a stale widget. */

    /* Token treatments inside her prose. JS wraps semantic tokens with
       these classes so we can express vocal stress visually:
         .loryn-sir     — the word "sir" (full opacity, slight letterspace)
         .loryn-stress  — numbers, weekdays, symbols, account aliases
                          (weight bump, tabular figures, single-shimmer)
         .loryn-emph    — em-dash flanked emphasis (subtle italic-ish track)
       Tokens fall back to plain prose if JS doesn't tag them. */
    .atrium-msg-loryn .loryn-sir {
      color: rgba(255, 255, 255, 1);
      letter-spacing: 0.038em;
      font-weight: 400;
    }
    .atrium-msg-loryn .loryn-stress {
      font-weight: 500;
      font-feature-settings: "tnum" 1, "ss01" 1;
      letter-spacing: 0.002em;
      position: relative;
    }
    /* Single-trigger shimmer — runs once when the parent's veil clears,
       so it lands as the text becomes legible. The animation reaches
       full glow at ~38% then decays. No infinite loop: the shimmer is
       a glint, not a spotlight. Staggered slightly by position-in-prose
       via the --stress-stagger custom property (set by JS at mount). */
    .atrium-msg-loryn .loryn-stress.shimmer-once {
      animation: none;
    }
    .atrium-msg-loryn.veil-clear .loryn-stress.shimmer-once {
      animation: lorynStressShimmer 920ms cubic-bezier(0.32, 0.7, 0.32, 1) forwards;
      /* Pushed back so the shimmer lands AFTER the arrival pulse settles.
         Approach 1900ms + arrival 400ms = 2300ms baseline. */
      animation-delay: calc(2280ms + var(--stress-stagger, 0ms));
    }
    @keyframes lorynStressShimmer {
      0%   { text-shadow: 0 0 0 rgba(122, 184, 245, 0); color: inherit; }
      38%  { text-shadow: 0 0 14px rgba(122, 184, 245, 0.55),
                          0 0 4px rgba(170, 210, 255, 0.85);
             color: rgba(255, 255, 255, 1); }
      100% { text-shadow: 0 0 0 rgba(122, 184, 245, 0); color: inherit; }
    }
    .atrium-msg-loryn .loryn-emph {
      font-style: normal;
      letter-spacing: 0.028em;
      color: rgba(232, 240, 255, 1);
    }
    /* Depth emergence — brightness climbs from near-black, letter-spacing
       tightens from loose to settled, opacity fades up. All on the text
       element itself. */
    /* Letter-spacing transition removed — was causing each letter to
       slide as the spacing tightened, which read as "the words trying
       to find their place before they settle." Letters now sit in
       their final positions from frame zero; only opacity + brightness
       animate, so the text emerges in place without jitter. */
    .atrium-msg-loryn:not(.veil-clear) .atrium-msg-text {
      opacity: 0;
      filter: brightness(0.04);
      letter-spacing: 0.012em;
    }
    .atrium-msg-loryn.veil-clear .atrium-msg-text {
      opacity: 1;
      filter: brightness(1);
      letter-spacing: 0.012em;
    }
    .atrium-msg-text {
      transition:
        opacity 1400ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
        filter  1400ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
      will-change: opacity, filter;
    }

    /* ARRIVAL PULSE — brief brightness overshoot at the end of approach. */
    .atrium-msg-loryn.atrium-arriving {
      animation: lorynArrivalPulse 400ms cubic-bezier(0.32, 0.7, 0.32, 1) forwards;
    }
    @keyframes lorynArrivalPulse {
      0%   { filter: brightness(1); }
      38%  { filter: brightness(1.13); }
      100% { filter: brightness(1); }
    }
    /* Legacy ::before vignette is gone — the text reveal is the
       animation now. */
    /* Old response recedes — dimmer, slightly blurred, smaller. NOT
       removed: persistence is what makes the chamber feel continuous
       instead of stateless. Stacks ABOVE the active manifestation
       (it drifts backward, the new one takes the focal point). */
    .atrium-msg-loryn.receded {
      opacity: 0.28;
      filter: blur(1.2px);
      transform: scale(0.94);
      font-size: 13.5px;
      color: rgba(232, 240, 255, 0.45);
      transition:
        opacity 2400ms cubic-bezier(0.32, 0.7, 0.32, 1),
        filter  2400ms cubic-bezier(0.32, 0.7, 0.32, 1),
        transform 2400ms cubic-bezier(0.32, 0.7, 0.32, 1);
    }
    .atrium-msg-loryn.receded::before {
      /* Receded responses don't need a veil — they're already pulled
         back into the chamber atmosphere by their own hierarchy. */
      display: none;
    }

    /* Transmission imprint — a low-hierarchy operational trace of Jake's
       last submitted input. Sits just above the horizon line. Now
       PERSISTENT until Loryn's reply begins streaming — Jake should
       never sit in an empty chamber wondering if his message landed.
       The entry animation runs once on mount; the dissolve runs only
       when `.dismissing` is added by streamIntoAtrium (or 30s failsafe). */
    .atrium-input-trace {
      position: absolute;
      bottom: calc(100% + 6px);
      left: 0;
      right: 0;
      margin: 0 auto;
      max-width: 80%;
      text-align: center;
      font-size: 9.5px;
      letter-spacing: 0.28em;
      text-transform: uppercase;
      color: #5d6473;
      pointer-events: none;
      opacity: 0;
      filter: blur(2px);
      animation: atriumInputTraceIn 540ms cubic-bezier(0.32, 0.5, 0.32, 1) forwards;
    }
    @keyframes atriumInputTraceIn {
      0%   { opacity: 0;    filter: blur(2px); letter-spacing: 0.22em; }
      100% { opacity: 0.82; filter: blur(0);   letter-spacing: 0.28em; }
    }
    .atrium-input-trace.dismissing {
      animation: atriumInputTraceOut 1400ms cubic-bezier(0.4, 0, 0.6, 1) forwards;
    }
    @keyframes atriumInputTraceOut {
      0%   { opacity: 0.82; filter: blur(0);   letter-spacing: 0.28em; }
      100% { opacity: 0;    filter: blur(2px); letter-spacing: 0.36em; }
    }
    /* The blue speaker-dot used to live here as a second ::before on
       .atrium-msg-loryn. Two ::before declarations on the same element
       merge — but they shared `background` and `position` properties,
       which meant the dot's solid blue clobbered the veil's radial
       gradient. Result: the dot rendered, the veil never did. Removing
       it restores the veil-recedes pattern and gets the distracting
       marker out of the chamber. */
    .atrium-msg-text { margin-bottom: 4px; }

    /* ── Annotations — Loryn's voice as a placed surface ────────────────
       Annotations are prose surfaces that live in a zone alongside
       modules, not narrator text in the center. They drop card chrome
       entirely (no background, no border, no padding) so they read as
       LORYN ANNOTATING the workspace, not another module. Roles map
       to different visual treatments — section_label is a small-caps
       header above modules, side_note is quiet italic, etc. */
    /* Annotation chamber-surface — strip all card chrome regardless of
       which zone the annotation sits in. Higher specificity than the
       per-zone surface overrides (e.g. memory-shelf bg) by including
       the chamber-workstation parent in the selector. */
    .chamber-workstation .chamber-surface.chamber-surface-annotation,
    .chamber-zone .chamber-surface.chamber-surface-annotation {
      background: transparent !important;
      border: none !important;
      box-shadow: none !important;
      backdrop-filter: none !important;
      padding: 0 !important;
      color: inherit;
      max-width: none;
    }
    .loryn-annotation {
      line-height: 1.55;
      max-width: 100%;
    }
    .loryn-annotation-thesis {
      font-size: 14px;
      color: rgba(230, 235, 243, 0.92);
      text-align: center;
    }
    .loryn-annotation-section_label {
      font-size: 10px;
      letter-spacing: 0.32em;
      text-transform: uppercase;
      color: rgba(185, 195, 210, 0.68);
      text-align: center;
      margin-bottom: 4px;
    }
    .loryn-annotation-annotation {
      font-size: 12px;
      color: rgba(216, 221, 230, 0.78);
      font-style: italic;
      text-align: left;
    }
    .loryn-annotation-side_note {
      font-size: 11.5px;
      /* Pull into palette: slate text token at 62% alpha matches the
         other faint helper text in the chamber. */
      color: rgba(138, 150, 163, 0.78);
      font-style: italic;
      letter-spacing: 0.02em;
    }
    .loryn-annotation-background_note {
      font-size: 10.5px;
      color: rgba(146, 156, 170, 0.50);
      letter-spacing: 0.04em;
    }

    /* Phrase reveal — Loryn's voice no longer types like a chatbot.
       Each phrase chunk arrives as a span starting in a "resolving"
       state (opacity 0, slight blur, tiny y-offset) and transitions
       to legible. The streamIntoAtrium pacing staggers phrase starts
       so the whole reply reads as one composed thought arriving from
       haze, not character-by-character keystrokes. */
    /* Per Jake 2026-05-23: the old phrase reveal still read as a CSS
       fade. The thought is supposed to be ALREADY PRESENT but veiled,
       with haze thinning around it as it becomes legible. So the
       resolving state is NOT opacity 0 — the text is faintly visible
       through a luminous veil, then contrast settles. Three things
       resolve together:
         1. text color from a dim cool grey → full warm white
         2. blur from 5px → 0 (haze thinning, not fade-in)
         3. text-shadow glow from soft halo → none (luminance focusing)
       No translate, no slide. The effect is clarity arriving, not
       motion. */
    .atrium-phrase {
      display: inline;
      color: rgba(230, 235, 243, 0.96);
      filter: brightness(1);
      letter-spacing: 0;
      transition:
        color  1400ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
        filter 1400ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
      will-change: color, filter;
    }
    .atrium-phrase.resolving {
      color: rgba(230, 235, 243, 0);
      filter: brightness(0.04);
      /* letter-spacing held at 0 — letters keep their final positions
         while emerging, avoiding the "settling into place" jitter. */
    }

    /* MODULE DECK — retrieved intelligence stabilizing in the chamber.
       The implementation order is inverted from how UIs usually do it:
       the CONTENT resolves first (data, numbers, glyphs become legible
       in the chamber atmosphere), then the FRAME coalesces around the
       content. Reads as "the intelligence assembled the information
       first; the chamber shell formed around it afterward."

       Default chrome (background, border, backdrop-filter) starts at
       zero, the frame-resolve animation brings them in after the
       content has finished settling. */
    .atrium-inline-card {
      margin: 16px auto 4px;
      max-width: 540px;
      width: 100%;
      /* HOLOGRAM BOXES ARE DEAD. The inline-card used to animate from
         transparent → dark gradient + border via atriumFrameCoalesce.
         That's the rectangle Jake kept catching in live responses
         (her staging directive renders through this path, not the
         state-container path). Stripped entirely. The card is just
         a layout anchor now — typography on chamber air. */
      background: transparent;
      border: none;
      border-radius: 0;
      padding: 6px 0;
      box-shadow: none;
      backdrop-filter: none;
      -webkit-backdrop-filter: none;
      position: relative;
      animation: none;
    }
    @keyframes atriumFrameCoalesce {
      to { background: transparent; border-color: transparent; }
    }
    /* Aggressive boxless override — covers inline-cards rendered via
       the live staging path (inside .atrium-composition inside
       .atrium-msg-loryn), not just the state-container surfaces. */
    body.in-atrium .atrium-inline-card,
    body.in-atrium .atrium-composition .atrium-inline-card,
    body.in-atrium .atrium-msg-loryn .atrium-inline-card {
      background: transparent !important;
      border: none !important;
      box-shadow: none !important;
      backdrop-filter: none !important;
      -webkit-backdrop-filter: none !important;
      animation: none !important;
    }
    /* Card content resolves from chamber haze BEFORE the frame
       coalesces. Heavy initial blur, full opacity quickly (the
       intelligence is already in the room — it's clarifying, not
       arriving), settling to legibility over ~900ms. */
    .atrium-inline-card > * {
      animation: atriumContentClarify 900ms cubic-bezier(0.32, 0.7, 0.32, 1) 120ms backwards;
    }
    @keyframes atriumContentClarify {
      0%   { opacity: 0;   filter: blur(8px) brightness(1.35); }
      30%  { opacity: 0.7; filter: blur(4px) brightness(1.15); }
      100% { opacity: 1;   filter: blur(0)   brightness(1); }
    }
    .atrium-inline-card .loryn-memory li,
    .atrium-inline-card .loryn-watch li { font-size: 13px; }

