/* PTR Labs - the pieces that are specific to this site.
   Everything shared (brand field, glass, dock, drawer, hero, gate, panels)
   lives in ptr-core.css and is byte-identical with the AI Studio site. */

/* ============================================================================
   ICONS - THE SAME FAMILY, EXTENDED. NOT A SECOND ONE.
   ============================================================================
   ptr-core.css defines --ptr-icon-mic / -chat / -voice / -avatar / -book and
   applies them as `mask-image` on a span with `background: currentColor`. That
   sheet is BYTE-IDENTICAL with ptr-ai-studio-launch and must not be edited, so
   the glyphs Practice needs and that sheet does not carry are declared here.

   THIS IS AN EXTENSION, NOT A RIVAL SET, AND THE DIFFERENCE IS ENFORCEABLE.
   Every icon below is drawn to the SAME production rules as the five in
   ptr-core: a 24-unit box, `fill='none'`, `stroke='%23000'`, `stroke-width='2'`,
   round caps and round joins, inlined as a data URI and worn as a mask so it
   inherits currentColor. `tools/practice-ux-acceptance.mjs` parses every
   `--ptr-icon-*` declaration in BOTH sheets and fails if any one of them
   disagrees on any of those five properties - which is what "one family" means
   operationally, as opposed to a promise in a comment.

   There are NO EMOJI in the Practice UI. Emoji are a second family by
   definition: they are somebody else's artwork, they are coloured, they do not
   inherit currentColor, and they render differently on every platform. The
   markup they used to occupy (a speaker, a floppy disk, a refresh arrow) now
   carries `class="ptr-ico" data-ico="..."` instead.
   -------------------------------------------------------------------------- */
:root {
  /* No send glyph exists in the canonical set. A paper plane is the standard
     one, drawn in the same hand as the rest. */
  --ptr-icon-send: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M22 2 11 13'/%3E%3Cpath d='M22 2 15 22l-4-9-9-4 20-7z'/%3E%3C/svg%3E");
  --ptr-icon-back: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 12H5'/%3E%3Cpath d='M12 19l-7-7 7-7'/%3E%3C/svg%3E");
  --ptr-icon-speaker: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M11 5 6 9H2v6h4l5 4V5z'/%3E%3Cpath d='M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07'/%3E%3C/svg%3E");
  --ptr-icon-save: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z'/%3E%3Cpath d='M17 21v-8H7v8M7 3v5h8'/%3E%3C/svg%3E");
  --ptr-icon-retry: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M23 4v6h-6M1 20v-6h6'/%3E%3Cpath d='M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15'/%3E%3C/svg%3E");
  --ptr-icon-copy: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='9' y='9' width='13' height='13' rx='2'/%3E%3Cpath d='M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1'/%3E%3C/svg%3E");
  --ptr-icon-share: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8'/%3E%3Cpath d='M16 6l-4-4-4 4M12 2v13'/%3E%3C/svg%3E");
  --ptr-icon-thumb-up: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3z'/%3E%3Cpath d='M7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3'/%3E%3C/svg%3E");
  --ptr-icon-thumb-down: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3z'/%3E%3Cpath d='M17 2h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17'/%3E%3C/svg%3E");
  --ptr-icon-end-call: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67'/%3E%3Cpath d='M5.34 10.03a19.79 19.79 0 0 1-1.22-5.85A2 2 0 0 1 6.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L10.09 9.91'/%3E%3Cpath d='M23 1 1 23'/%3E%3C/svg%3E");
  --ptr-icon-lock: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='11' width='18' height='11' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 10 0v4'/%3E%3C/svg%3E");
  --ptr-icon-clock: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 6v6l4 2'/%3E%3C/svg%3E");
  --ptr-icon-award: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='8' r='7'/%3E%3Cpath d='M8.21 13.89 7 23l5-3 5 3-1.21-9.12'/%3E%3C/svg%3E");
}

/* ONE RULE, ONE ATTRIBUTE. `class="ptr-ico" data-ico="send"` is the whole
   contract, and it is the same mask/currentColor mechanism as `.lens-ico` in
   ptr-core.css.

   A BUTTON IS NEVER ITS OWN MASK, and `:not(button)` is what enforces that.
   The first version applied the mask to every `.ptr-ico`, buttons included, and
   `background: none` afterwards was not a fix: a mask CLIPS THE WHOLE ELEMENT,
   so the button's border, its glass fill, the send button's gradient AND the
   ::before glyph drawn inside it were all cut down to the silhouette of a 24px
   path stretched across a 46px box. On screen that is a couple of stray strokes
   where a control should be, which is exactly what the first screenshot pass
   showed. Elements that ARE the icon paint it as their own background;
   BUTTONS paint it into a ::before, so the chrome survives. */
.ptr-ico:not(button) {
  background: currentColor;
  -webkit-mask-position: center; mask-position: center;
  -webkit-mask-size: contain;    mask-size: contain;
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
}
button.ptr-ico { position: relative; }
button.ptr-ico::before {
  content: ""; position: absolute; inset: 0;
  margin: auto; width: 19px; height: 19px;
  background: currentColor;
  -webkit-mask-position: center; mask-position: center;
  -webkit-mask-size: contain;    mask-size: contain;
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
}
.ptr-ico-lead::before {
  content: ""; width: 15px; height: 15px; flex: none;
  background: currentColor;
  -webkit-mask-position: center; mask-position: center;
  -webkit-mask-size: contain;    mask-size: contain;
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
}
.ptr-ico-lead { display: inline-flex; align-items: center; gap: 7px; }

/* The map from `data-ico` to the variable. Canonical five first: chat, voice,
   avatar, book and mic come STRAIGHT out of ptr-core.css and are not
   re-declared anywhere in this file. */
.ptr-ico:not(button)[data-ico="chat"], button.ptr-ico[data-ico="chat"]::before, .ptr-ico-lead[data-ico="chat"]::before { -webkit-mask-image: var(--ptr-icon-chat); mask-image: var(--ptr-icon-chat); }
.ptr-ico:not(button)[data-ico="voice"], button.ptr-ico[data-ico="voice"]::before, .ptr-ico-lead[data-ico="voice"]::before { -webkit-mask-image: var(--ptr-icon-voice); mask-image: var(--ptr-icon-voice); }
.ptr-ico:not(button)[data-ico="avatar"], button.ptr-ico[data-ico="avatar"]::before, .ptr-ico-lead[data-ico="avatar"]::before { -webkit-mask-image: var(--ptr-icon-avatar); mask-image: var(--ptr-icon-avatar); }
.ptr-ico:not(button)[data-ico="book"], button.ptr-ico[data-ico="book"]::before, .ptr-ico-lead[data-ico="book"]::before { -webkit-mask-image: var(--ptr-icon-book); mask-image: var(--ptr-icon-book); }
.ptr-ico:not(button)[data-ico="mic"], button.ptr-ico[data-ico="mic"]::before, .ptr-ico-lead[data-ico="mic"]::before { -webkit-mask-image: var(--ptr-icon-mic); mask-image: var(--ptr-icon-mic); }
.ptr-ico:not(button)[data-ico="send"], button.ptr-ico[data-ico="send"]::before, .ptr-ico-lead[data-ico="send"]::before { -webkit-mask-image: var(--ptr-icon-send); mask-image: var(--ptr-icon-send); }
.ptr-ico:not(button)[data-ico="back"], button.ptr-ico[data-ico="back"]::before, .ptr-ico-lead[data-ico="back"]::before { -webkit-mask-image: var(--ptr-icon-back); mask-image: var(--ptr-icon-back); }
.ptr-ico:not(button)[data-ico="speaker"], button.ptr-ico[data-ico="speaker"]::before, .ptr-ico-lead[data-ico="speaker"]::before { -webkit-mask-image: var(--ptr-icon-speaker); mask-image: var(--ptr-icon-speaker); }
.ptr-ico:not(button)[data-ico="save"], button.ptr-ico[data-ico="save"]::before, .ptr-ico-lead[data-ico="save"]::before { -webkit-mask-image: var(--ptr-icon-save); mask-image: var(--ptr-icon-save); }
.ptr-ico:not(button)[data-ico="retry"], button.ptr-ico[data-ico="retry"]::before, .ptr-ico-lead[data-ico="retry"]::before { -webkit-mask-image: var(--ptr-icon-retry); mask-image: var(--ptr-icon-retry); }
.ptr-ico:not(button)[data-ico="copy"], button.ptr-ico[data-ico="copy"]::before, .ptr-ico-lead[data-ico="copy"]::before { -webkit-mask-image: var(--ptr-icon-copy); mask-image: var(--ptr-icon-copy); }
.ptr-ico:not(button)[data-ico="share"], button.ptr-ico[data-ico="share"]::before, .ptr-ico-lead[data-ico="share"]::before { -webkit-mask-image: var(--ptr-icon-share); mask-image: var(--ptr-icon-share); }
.ptr-ico:not(button)[data-ico="thumb-up"], button.ptr-ico[data-ico="thumb-up"]::before, .ptr-ico-lead[data-ico="thumb-up"]::before { -webkit-mask-image: var(--ptr-icon-thumb-up); mask-image: var(--ptr-icon-thumb-up); }
.ptr-ico:not(button)[data-ico="thumb-down"], button.ptr-ico[data-ico="thumb-down"]::before, .ptr-ico-lead[data-ico="thumb-down"]::before { -webkit-mask-image: var(--ptr-icon-thumb-down); mask-image: var(--ptr-icon-thumb-down); }
.ptr-ico:not(button)[data-ico="end-call"], button.ptr-ico[data-ico="end-call"]::before, .ptr-ico-lead[data-ico="end-call"]::before { -webkit-mask-image: var(--ptr-icon-end-call); mask-image: var(--ptr-icon-end-call); }
.ptr-ico:not(button)[data-ico="lock"], button.ptr-ico[data-ico="lock"]::before, .ptr-ico-lead[data-ico="lock"]::before { -webkit-mask-image: var(--ptr-icon-lock); mask-image: var(--ptr-icon-lock); }
.ptr-ico:not(button)[data-ico="clock"], button.ptr-ico[data-ico="clock"]::before, .ptr-ico-lead[data-ico="clock"]::before { -webkit-mask-image: var(--ptr-icon-clock); mask-image: var(--ptr-icon-clock); }
.ptr-ico:not(button)[data-ico="award"], button.ptr-ico[data-ico="award"]::before, .ptr-ico-lead[data-ico="award"]::before { -webkit-mask-image: var(--ptr-icon-award); mask-image: var(--ptr-icon-award); }

/* -------------------------------------------------- THE ROUTE INTRO ------- */
/* THE ONE BLOCK OF PROSE A CRAWLER CAN READ, AND THE ONE A DEEP LINK NEEDS.
   Every URL on this site now serves a real committed file with its own <title>
   and canonical, but `.explore` - the panels, the tiles, every blurb - is
   `display: none` until `body.unlocked`, and nothing that indexes a page passes
   an entry gate. Without this section the visible body of /play/ar-business-card
   and of / would be the same wordmark and the same three words, which is the
   duplicate content the per-route files exist to stop.

   IT LIVES IN THIS SHEET AND NOT IN ptr-core.css. That file is byte-identical
   with ptr-ai-studio-launch by contract, and the AI Studio site has no routes;
   putting a labs-only rule there would break the invariant for nothing. labs.css
   loads after it, so the `body.unlocked` hide below wins on specificity ties.

   NOT SHOWN TO A VISITOR, IN ANY STATE. OWNER, 2026-08-28, pointing at the
   entry screen and quoting this block's own copy: "This text is not suppose to
   show". It used to be hidden only under `body.unlocked`, so a logged-out
   visitor - which is everyone who arrives - got the full block above the gate.

   THIS REVERSES A DELIBERATE DECISION AND THE CONFLICT IS REAL, so it is
   written down rather than buried. The paragraph above this one used to argue
   that showing it "is what keeps this honest rather than cloaked", and that
   argument was sound on its own terms: on 2026-08-28 commit ac064b7 hid this
   block, 6db666b manually reverted that hide, and c529bc3 then added
   tools/route-intro-runtime.mjs - a rendered-Chrome gate asserting the block IS
   visible on every route at two widths. It was protecting a genuine property:
   every URL on this site having real, on-screen, indexable prose instead of a
   body that is the same wordmark and the same three words. The owner has now
   called the visible block a regression. The owner wins, and the SEO cost is
   stated in the PR rather than papered over: these routes now carry their
   per-route signal in <title>, <meta name="description">, the canonical link,
   the og tags and the sitemap - all still per-route and still enforced by
   tools/static-shell-check.mjs - and NOT in visible body copy.

   `display: none` ON THE CONTAINER, DELIBERATELY NOT THE CLIP CLOAK ac064b7
   USED (position:absolute, 1x1px, clip-path: inset(50%)). Both remove the block
   from the screen; they are not equivalent to a search engine. The clip pattern
   is the textbook signature of text served to a crawler and withheld from a
   visitor, which is what "cloaking" means and what gets penalised. `display:
   none` makes the same claim honestly: this content is not rendered. The
   anti-clip check in tools/static-shell-check.mjs is therefore KEPT and still
   fires - `display: none` is the one sanctioned way to do this, and the cloak
   is still forbidden.

   THE COPY STAYS IN THE DOM rather than being deleted. Removing it would strip
   the page's only h1 - static-shell-check asserts there is exactly one and that
   it is this block's - and flatten every route into the same body.
   tools/route-intro-runtime.mjs now measures exactly that: present, correct for
   its route, and not visible. */
.route-intro { max-width: 60ch; margin: 0 auto; display: none; gap: 8px; }
.route-intro h1 {
  margin: 0;
  font-size: clamp(17px, 2.6vw, 23px);
  font-weight: 600; letter-spacing: -.01em; line-height: 1.2;
  color: #fff;
}
.route-intro p { margin: 0; font-size: 14px; line-height: 1.55; color: var(--ptr-ink-2); }
/* The capability line, on the four experience routes. Same treatment as the
   `.eyebrow` a tile carries, because it is the same sentence. */
.route-intro .route-eyebrow {
  font-size: 11px; letter-spacing: .16em; text-transform: uppercase;
  color: var(--ptr-cyan);
}
.route-intro .micro { color: var(--ptr-ink-3); }
/* NO `body.unlocked .route-intro` RULE LIVES HERE ANY MORE, and its absence is
   load-bearing rather than an omission: the base rule above is now the whole
   story, so there is exactly ONE place that decides whether this block is on
   screen. That narrower rule is precisely what produced the reported
   regression - it hid the block for the small number of visitors who were
   inside and showed it to everyone else. */

/* THE ROTATING HERO LINE IS FOR HOME, AND ONLY HOME.
   `.typed` cycles "Try the conversation before it matters." / "Interact with
   something unexpected." / "Turn yourself into the experience." every 3.7
   seconds. On `/` that is the site introducing its three modes and it is the
   right thing to show. On /play/the-pissing-game it is a generic line rotating
   above a specific heading that already says what this page is, and it is a
   third sentence on a screen whose real job is the entry gate.
   MEASURED, NOT PREFERRED: with the line kept, that route's gate submit button
   sat 70px below the fold at 360x640 - a visitor arriving from a search result
   on the smallest common phone would have seen no way in without scrolling.
   Dropping it, together with not repeating the provenance note, brings the
   whole gate screen back inside the viewport at that size.
   `data-route` is written into every generated file by tools/build-routes.mjs
   and kept current by the router, so this needs no JavaScript of its own. */
body[data-route]:not([data-route="home"]) .typed { display: none; }

/* THE PHONE. These four rules are now INERT and are kept deliberately, which
   is a claim worth defending rather than a leftover.
   They sized the intro down on a phone, back when it was the one block on the
   gate screen that grew and so the one that had to give the room back. The
   block is `display: none` at every width now, so none of this paints.
   KEPT because the copy is still in the DOM and the hide is a decision that
   could be revisited: if the block is ever shown again the phone sizing is
   still correct, and re-deriving it would mean re-measuring the 360x640 gate
   screen from scratch. Deleting four inert declarations buys nothing and
   throws away a measurement.
   THE `display` PROPERTY IS DELIBERATELY ABSENT FROM ALL FOUR. A `display`
   here would be a second place that decides whether this block is on screen,
   and a media query that quietly re-showed it on phones is exactly the kind of
   split rule that produced the reported regression in the first place. */
@media (max-width: 700px) {
  .route-intro { gap: 5px; }
  .route-intro h1 { font-size: 17px; line-height: 1.18; }
  .route-intro p { font-size: 13px; line-height: 1.45; }
  .route-intro .route-eyebrow { font-size: 10px; letter-spacing: .14em; }
}

/* ---------------------------------------------------------------- PLAY ---- */
/* ONE EXPERIENCE, ONE PAGE. This used to be a bounded carousel: every stage
   side by side on a horizontal track, a tab row paginating it, and swiping to
   move between them. The owner cut that on 2026-08-28 and the reasoning holds
   up - a carousel is a way of showing somebody several things at once, and the
   launcher already does that job better, with four tiles and nothing mounted.
   Once a visitor has CHOSEN, showing them the edge of three other experiences
   is not a feature; it is three ways to leave the one they asked for.
   So the container is a plain block, the inactive stages are not rendered at
   all, and the whole horizontally-scrolling track is gone - along with the
   swipe watcher, the smooth-scroll animation and the latch that stopped that
   animation being read as a swipe. */
#playStages {
  display: flex;
  flex-direction: column;
  min-height: 0;
  overflow: hidden;
}
/* `min-width: 0` is not optional: a flex item defaults to its content's minimum
   width, so an iframe or a long line inside a stage would push the panel wider
   than the viewport and make the DOCUMENT scroll sideways. */
.play-stage {
  min-width: 0;
  display: flex; flex-direction: column; min-height: 0;
}
/* SIZED FROM THE REMAINDER, NOT FROM THE CONTENT. .panel-body is a flex column
   (ptr-core.css), so an unsized container is a flex item at its content height
   and the embed frame's 16:10 height - derived from a 1500px wide column -
   pushed the body into a scrollbar. `min-height: 0` is on EVERY step between
   .panel-body and the frame because the chain stops shrinking at the first
   ancestor that does not carry it. The frame itself already has
   `flex: 0 1 auto; min-height: 0` from .surface. */
#playStages { flex: 1 1 0; min-height: 0; }

/* --- an experience owns the panel, at EVERY width ------------------------- */
/* These five rules were a phone-only block. They are the definition of "its own
   page", so they are no longer keyed to a breakpoint. */
[data-play-state="experience"] .panel[data-panel="play"] > .panel-head,
.panel[data-panel="play"][data-play-state="experience"] > .panel-head { display: none; }
/* HIDING THE HEAD MUST ALSO HAND ITS ROW BACK. .panel.active is a two-row grid
   (auto head, minmax(0,1fr) body). With the head display:none the body is the
   only in-flow item and auto-places into ROW ONE - the `auto` row - so the
   whole flex chain below it (panel-body 50px -> #playStages 0 -> stage-body 0
   -> .surface 2px) content-collapses and a desktop deep link like
   /play/360-immersive-experience renders an empty page over the backdrop.
   Measured 2026-08-30 on the live chain. One row, full height, only while an
   experience owns the panel. */
[data-play-state="experience"] .panel[data-panel="play"],
.panel[data-panel="play"][data-play-state="experience"] { grid-template-rows: minmax(0, 1fr); }
/* ONE experience, not one plus three parked off the edge. Leaving the others in
   the layout is what let a visitor swipe sideways out of the thing they chose. */
[data-play-state="experience"] .play-stage:not(.active) { display: none; }
[data-play-state="experience"] .play-stage {
  flex: 1 1 auto; height: 100%; overflow: hidden;
}
[data-play-state="experience"] #playBack { display: grid; flex: none; margin-bottom: 8px; }
.play-stage .stage-body {
  flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column;
}
/* The wall mounts through two class-less wrappers of its own. Matched by the
   surface they hold rather than by name, so .handoff and .status-row - neither
   of which holds one - keep their own layout. */
.play-stage .stage-body div:has(.surface) {
  display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0;
}
.embed-frame { aspect-ratio: 16 / 10; }
@media (max-width: 850px) { .embed-frame { aspect-ratio: 3 / 4; } }
@media (max-height: 780px) { .embed-frame { aspect-ratio: 16 / 9; } }

.handoff {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 18px;
  align-items: center;
}
.handoff-qr {
  width: 168px; height: 168px;
  padding: 12px;
  border-radius: 18px;
  background: #fff;
  display: grid; place-items: center;
}
.handoff-qr canvas { width: 100%; height: 100%; image-rendering: pixelated; }
.handoff-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 14px; }
.handoff-url {
  margin: 10px 0 0;
  font-size: 13px;
  color: var(--ptr-ink-3);
  word-break: break-all;
}
@media (max-width: 620px) {
  .handoff { grid-template-columns: 1fr; }
  .handoff-qr { justify-self: start; }
}

.needs {
  display: flex; gap: 6px; flex-wrap: wrap;
  margin: 0 0 12px;
  padding: 0; list-style: none;
}
.needs li, .needs > span {
  font-size: 11px; letter-spacing: .08em; text-transform: uppercase;
  padding: 5px 9px; border-radius: 99px;
  border: 1px solid rgba(255,255,255,.14);
  color: rgba(255,255,255,.6);
}
/* A <ul> is not valid inside a <button>, and the phone launcher tile IS a
   button, so its chips are spans. Same look, same source data, one rule. */
span.needs { display: flex; gap: 6px; flex-wrap: wrap; }

/* ------------------------------------------------------------ PRACTICE ---- */
/* ONE STEP OWNS THE PANEL, AT EVERY WIDTH.
   The previous layout was a permanent two-column grid - tiles on the left,
   conversation on the right - and the desktop auto-started the first scenario
   into it. That put a composer, a transcript-retention toggle, a restart
   button and a debrief button on screen for a visitor who had chosen nothing,
   which is the exact defect this flow exists to remove. So the grid is now ONE
   column and the step decides what is in it. A desktop is not a reason to show
   somebody step three while they are on step one; it is a reason to lay step
   one out generously, which the card grid below does. */
.practice-layout { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; align-items: stretch; }
/* Step 1 spreads into the width it has rather than staying a 300px rail. */
.scenario-list {
  display: grid; gap: 10px;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  align-content: start;
}
.scenario {
  display: grid; grid-template-columns: 34px 1fr;
  text-align: left; gap: 0 10px;
  border: 1px solid var(--pr-hair);
  border-radius: 18px;
  /* ONE MEASURED PLATE. The smoked base carries the text; the two tints are
     the cyan/violet optical-glass reading and are the ONLY things above it.
     Every value here is a token from the contrast contract at the foot of this
     sheet, so tools/practice-contrast-check.mjs is measuring what renders. */
  background:
    radial-gradient(130% 100% at 18% 0%, var(--pr-tint-cyan), transparent 60%),
    radial-gradient(140% 110% at 82% 100%, var(--pr-tint-violet), transparent 64%),
    var(--pr-tile);
  -webkit-backdrop-filter: blur(20px);
          backdrop-filter: blur(20px);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.08), 0 10px 26px rgba(0,0,0,.3);
  color: #fff; padding: 14px; cursor: pointer;
  transition: border-color .2s ease, box-shadow .2s ease;
}
/* HOVER AND SELECTED LIGHT THE EDGE, THEY DO NOT REPAINT THE PLATE. A state
   that raises the tile's own background also raises the floor every word on it
   has to beat, so the tile would read WORSE the moment you pointed at it. The
   plate is one measured surface (see the contrast contract at the foot of this
   sheet) and the state lives in the border and the glow. */
.scenario:hover { border-color: rgba(128,237,239,.34); box-shadow: 0 0 26px rgba(65,187,245,.16); }
.scenario.active { border-color: rgba(128,237,239,.52); box-shadow: 0 0 30px rgba(65,187,245,.22); }
.scenario .num { grid-row: 1 / 3; color: var(--pr-ink-quiet); font-variant-numeric: tabular-nums; }
.scenario strong { font-weight: 600; line-height: 1.3; }
.scenario small { color: var(--pr-ink-quiet); margin-top: 3px; }

/* THE PROGRESSION, ON THE TILE. Three pips per scenario, one per difficulty,
   each carrying whether it is open yet. Rendered rather than hidden: a level
   you cannot see is not something you can work towards, and the lock glyph is
   the same monochrome mask as every other icon here. */
.scenario-levels { grid-column: 2; display: flex; gap: 5px; margin-top: 8px; align-items: center; }
.scenario-levels .lvl {
  width: 22px; height: 20px;
  display: grid; place-content: center;
  border-radius: 7px;
  font-size: 11px; font-variant-numeric: tabular-nums;
  border: 1px solid rgba(255,255,255,.2);
  color: var(--pr-ink-pip);
}
.scenario-levels .lvl[data-unlocked="true"] {
  border-color: rgba(128,237,239,.4); color: var(--ptr-cyan);
  background: rgba(128,237,239,.08);
}
.scenario-levels .lvl[data-done="true"] {
  background: linear-gradient(120deg, #80EDEF, #41BBF5); color: #050507; border-color: transparent;
}
.scenario-levels .lock { width: 12px; height: 12px; opacity: .55; }

/* ---------------------------------------------------- step 2: choose a mode -- */
.mode-card[hidden] { display: none; }
.mode-choices {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 12px; margin-top: 14px;
}
.mode-choice {
  display: grid; gap: 4px; justify-items: start; text-align: left;
  padding: 20px 18px;
  border: 1px solid var(--pr-hair);
  border-radius: 20px;
  /* Same material as the tile, one step darker, because this one sits INSIDE
     a card and therefore already has the card's smoke underneath it. */
  background:
    radial-gradient(130% 100% at 20% 0%, var(--pr-tint-cyan), transparent 62%),
    var(--pr-inset);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.09);
  color: #fff; cursor: pointer;
  transition: border-color .2s ease, box-shadow .2s ease;
}
.mode-choice:hover { border-color: rgba(128,237,239,.4); box-shadow: 0 0 30px rgba(65,187,245,.18); }
.mode-choice strong { font-size: 19px; font-weight: 600; }
.mode-choice small { color: var(--ptr-ink-3); line-height: 1.4; }
.mode-choice-ico { width: 26px; height: 26px; margin-bottom: 8px; color: var(--ptr-cyan); }

.difficulty { display: flex; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
.difficulty .diff {
  display: inline-flex; align-items: center; gap: 7px;
  padding: 9px 14px; border-radius: 99px;
  border: 1px solid var(--pr-hair);
  background: var(--pr-inset);
  color: var(--pr-ink-body); font-size: 13px; cursor: pointer;
}
.difficulty .diff[aria-pressed="true"] {
  background: linear-gradient(120deg, #80EDEF, #41BBF5); color: #050507; border-color: transparent;
}
.difficulty .diff:disabled { opacity: .42; cursor: not-allowed; }
.difficulty .diff .lock { width: 13px; height: 13px; }

.roleplay-head { display: flex; justify-content: space-between; gap: 10px; align-items: start; }
.roleplay-head h3 { font-size: 24px; margin: 6px 0 2px; letter-spacing: -.02em; }
.roleplay-head .micro { margin: 0; }
.roleplay-actions { display: flex; gap: 8px; margin-top: 12px; }
.debrief-block { margin: 8px 0 0; }
.debrief-block h5 { margin: 10px 0 4px; font-size: 13px; letter-spacing: .1em; text-transform: uppercase; color: var(--pr-ink-label); }
.debrief-line {
  margin: 10px 0 0; padding: 10px 12px;
  border-left: 2px solid var(--ptr-cyan);
  background: rgba(128,237,239,.06);
  border-radius: 0 12px 12px 0;
}

/* ============================================================================
   THE FLOW, AS ONE TABLE OF RULES.
   ============================================================================
   `data-practice-state` on the panel is the only switch, and every card in the
   stage carries `hidden` in the shipped HTML so that a page which never runs
   labs.js shows step one and nothing else. The rules below UN-hide exactly one
   card per state.

   `[hidden]` is honoured explicitly on every card because an author `display`
   beats the UA sheet's `[hidden] { display: none }` - without these the cards
   would all be on screen from the moment the page loads.

   THE INVARIANT THE PROBE ASSERTS: in `scenario-select` the stage is not
   rendered at all, so there is no composer, no send button, no retention
   toggle, no restart, no avatar controls, no results and no share on screen
   before a scenario has been chosen. At any width.
   -------------------------------------------------------------------------- */
.practice-stage { display: flex; flex-direction: column; min-height: 0; }
.practice-stage > .card { flex: 1 1 auto; min-height: 0; }
.mode-card[hidden], .roleplay[hidden], .live-card[hidden], .debrief-card[hidden] { display: none; }

[data-practice-state="scenario-select"] .practice-stage { display: none; }
/* And in every later step the tiles are GONE, not scrolled past. That is the
   difference between a state machine and a long page with anchors, and it is
   now the rule on a 1920px desktop as well as on a phone. */
[data-practice-state="mode-select"] .scenario-list,
[data-practice-state="roleplay"] .scenario-list,
[data-practice-state="live-call"] .scenario-list,
[data-practice-state="debrief"] .scenario-list { display: none; }

.roleplay { display: flex; flex-direction: column; min-height: 0; }
.debrief-card { display: flex; flex-direction: column; }
.live-card { display: flex; flex-direction: column; }
/* The prose is the part that gives, and it gives INSIDE the card. The decisions
   that follow a result stay on screen no matter how long the review runs, which
   is the whole reason this is a bounded scroll and not a page. */
.debrief-card .results-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
.debrief-card .roleplay-actions { flex: none; flex-wrap: wrap; }

/* BACK EXISTS IN EVERY STEP THAT HAS ONE BEHIND IT. It used to be a phone-only
   affordance because the desktop kept the tiles on screen next to the stage;
   now that one step owns the panel at every width, the desktop needs the way
   back just as much as the phone does. */
.back-btn { display: grid; }
#playBack[hidden] { display: none; }

/* The character's body for this run. These rules describe the STAND-IN: a
   tinted tile carrying the character's initial, which is what is on screen from
   the instant a run starts and what stays there if anything about the 3D path
   fails. The live VRM is layered over it by the block further down. The two are
   the same size on purpose, so landing the models was a swap rather than a
   re-layout. */
.role-avatar {
  flex: none;
  width: 42px; height: 42px;
  border-radius: 14px;
  display: grid; place-content: center;
  font-weight: 600; font-size: 17px;
  color: rgba(255,255,255,.82);
  border: 1px solid rgba(255,255,255,.14);
  background: rgba(255,255,255,.05);
}
.role-avatar[data-vrm-tone="a"] { background: rgba(128,237,239,.10); border-color: rgba(128,237,239,.22); }
.role-avatar[data-vrm-tone="b"] { background: rgba(65,187,245,.10); border-color: rgba(65,187,245,.22); }
.role-avatar:empty { display: none; }

/* THE LIVE VRM SITS ON TOP OF THE STAND-IN, IT DOES NOT REPLACE IT.
   `applyRunAvatar()` writes the character's initial into this element's
   textContent on every run, which removes every child - the canvas included -
   so the canvas is re-parented by the renderer after that write and overlays
   what is already there. Absolute positioning is what makes that a layer rather
   than a second box, and it means the geometry of this element is untouched:
   the stand-in was deliberately sized as the real avatar so that landing the
   models would be a swap, not a re-layout, and it still is. */
.role-avatar { position: relative; overflow: hidden; }
.role-avatar > canvas {
  position: absolute; inset: 0;
  display: block;
  width: 100%; height: 100%;
  border-radius: inherit;
  /* A canvas is not text and must never eat a tap meant for the header. */
  pointer-events: none;
}
/* Once a model is actually mounted the initial underneath would show through
   the transparent parts of the render. `color: transparent` hides the glyph
   without emptying the element, which matters because `:empty` above is what
   hides this box when there is no character at all. If the model fails or is
   torn down, the attribute goes back to `placeholder` and the initial returns
   by itself. */
.role-avatar[data-vrm-source="model"] { color: transparent; }
.roleplay-head { align-items: center; }
.roleplay-head > div:not(.roleplay-tools) { min-width: 0; }

/* ----------------------------------------------------------- PLAY: launcher -- */
/* THE LAUNCHER IS THE ENTRY ON EVERY SCREEN, NOT ONLY A PHONE.
   It used to be phone-only, because three cards side by side made the desktop
   carousel its own chooser. Four experiences - two of which want a camera or a
   GPU context - changed that: defaulting into one to show somebody a menu is
   how you end up with a framed game running behind a list. So the tiles are the
   select state everywhere, and the carousel is what happens after a choice.
   2x2 on a desktop, one column on a phone (the breakpoint block below). */
.play-launcher {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  grid-auto-rows: minmax(0, 1fr);
  gap: 12px;
  flex: 1 1 auto; min-height: 0;
}
[data-play-state="experience"] .play-launcher { display: none; }
/* Not `display: none` on the track: a hidden iframe is still a mounted iframe,
   and the point of the launcher is that nothing is mounted yet. */
[data-play-state="select"] #playStages { display: none; }
.play-tile {
  display: grid; gap: 2px;
  text-align: left;
  border: 1px solid rgba(255,255,255,.09);
  border-radius: 18px;
  background: rgba(255,255,255,.035);
  color: #fff; padding: 14px; cursor: pointer;
  min-height: 0; overflow: hidden;
  align-content: center;
  transition: background .2s ease, border-color .2s ease;
}
.play-tile:hover { background: rgba(255,255,255,.06); }
/* The looping thumb takes the tile's LEFTOVER height, not an aspect ratio.
   The launcher rows are fixed (minmax(0,1fr) on a 2x2 grid), so an
   aspect-sized thumb overflowed the tile and the copy painted over the
   picture. With the tile's own grid giving the thumb the one stretchy row
   and the copy the auto rows, the video covers whatever height is left and
   the text always sits under it, at every tile size. */
.play-tile.has-thumb {
  grid-template-rows: minmax(0, 1fr) auto auto auto auto;
  align-content: stretch;
}
.play-tile-thumb {
  display: block; width: 100%; height: 100%; min-height: 0;
  border-radius: 12px; overflow: hidden; margin-bottom: 8px;
  background: rgba(255,255,255,.04);
}
.play-tile-thumb video {
  width: 100%; height: 100%; object-fit: cover; display: block;
}
.play-tile strong { font-weight: 600; line-height: 1.25; font-size: 17px; }
.play-tile small { color: rgba(255,255,255,.5); line-height: 1.35; }
.play-tile .needs { margin: 6px 0 0; }

/* -------------------------------------------------------------- CREATE ---- */
/* The stage tries to FIT before it scrolls. `align-items: start` let both cards
   grow to their content and pushed the panel body into a scrollbar on a 900px
   desktop; stretching them to the available row and letting the inner surface
   carry the overflow keeps the 16:9 feel the brief asks for. */
.create-layout {
  display: grid; grid-template-columns: 340px minmax(0, 1fr);
  gap: 14px; align-items: stretch;
  min-height: 0; height: 100%;
}
.create-layout > * { min-height: 0; }
@media (max-width: 950px) { .create-layout { grid-template-columns: 1fr; height: auto; } }
/* THE DESKTOP STAGE FITS INSTEAD OF SCROLLING. `height: 100%` above resolves
   against #createHost, which is an unstyled div the module mounts into, so it
   became `auto` and the stage grew to its own content. Sizing from what is left
   needs the whole chain: the host takes the remainder of the flex column that
   is .panel-body, the layout takes the remainder of the host, and the cards are
   flex columns so the .surface inside them - which already carries
   `flex: 0 1 auto; min-height: 0` - is allowed to give. Scoped to the layout's
   own 951px breakpoint: below it the stage stacks and is content-sized, which
   is what the phone rules further down already assume. */
@media (min-width: 951px) {
  #createHost { display: flex; flex-direction: column; flex: 1 1 0; min-height: 0; }
  .create-layout { flex: 1 1 0; height: auto; }
  .create-layout > .card { display: flex; flex-direction: column; }
  .create-layout > .card > div:has(> .surface) {
    display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0;
  }
}

/* Same reasoning for PRACTICE: the roleplay card owns the stage and the message
   list is the part that scrolls, so the composer stays pinned in view instead of
   sliding off the bottom of a long conversation. */
.practice-layout { min-height: 0; height: 100%; }
.practice-layout > * { min-height: 0; }
/* The scenario column scrolls itself rather than stretching the stage, so
   adding a scenario later cannot push the composer off the screen. */
@media (min-width: 951px) { .scenario-list { overflow-y: auto; align-content: start; } }
/* A SHORT DESKTOP IS A SHORT SCREEN. Measured on the desktop sweep: the column
   is bounded but its content is not, so `overflow-y: auto` above turned into a
   real scrollbar and the fifth tile sat below the fold - 80px past it at
   1280x720 and 42px at 1366x768, and 0 at 1440x900 and 1920x1080, which is why
   this is keyed off HEIGHT and leaves the tall viewports exactly as they are.
   `minmax(0, 1fr)` rows divide the column by construction, the same rule the
   phone block uses; 780px is the height threshold .embed-frame already uses. */
@media (min-width: 951px) and (max-height: 780px) {
  .scenario-list { grid-auto-rows: minmax(0, 1fr); }
  .scenario { align-content: center; overflow: hidden; }
  .scenario small {
    display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 1;
    line-clamp: 1; overflow: hidden;
  }
}
/* NO `display` HERE. `.practice-layout .roleplay` and `.roleplay[hidden]` have
   the SAME specificity (0,2,0) and this rule is later in the sheet, so putting
   `display: flex` back on this selector would silently defeat the `hidden`
   attribute and put the conversation on screen during scenario-select. The
   direction and the zero minimum are all this rule is for; the `display` lives
   on the bare `.roleplay` rule in the flow table above. */
.practice-layout .roleplay { flex-direction: column; min-height: 0; }
.practice-layout .messages { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
/* THE CONVERSATION STARTS AT THE TOP, NOT THE BOTTOM.
   ptr-core.css carries `.messages > :first-child { margin-top: auto }`, the
   usual messenger trick that pins a short thread to the BOTTOM of its pane so
   new lines rise into view. In a messenger that reads right. Here it does not:
   the practice pane is tall and a rehearsal opens with ONE line from the
   character, so the visitor met a screenful of empty space with a single
   sentence stranded at the floor of it, directly under the scenario title.
   Reported from the live site.
   OVERRIDDEN HERE RATHER THAN CHANGED IN ptr-core.css on purpose. That file is
   byte-identical with ptr-ai-studio-launch by standing invariant, so editing it
   means a mirrored change in the other repo; and `.messages` is used by exactly
   ONE element in this site (#roleMessages, the practice transcript) and by
   nothing at all in AI Studio, so a core edit would be all invariant cost for
   no shared benefit. Scoping it to `.practice-layout` keeps the blast radius to
   the pane that actually has the problem.
   The pane still scrolls normally once the thread outgrows it - this only
   decides where a SHORT thread sits. */
.practice-layout .messages > :first-child { margin-top: 0; }
/* 851-950px is a small laptop or a tablet: wide enough for the two-column
   layout to be worth keeping, narrow enough that letting the stage size to its
   content is kinder than pinning it. A PHONE is handled below and must NOT get
   `height: auto`, because that is exactly the rule that lets a state grow past
   the screen and take the document with it. */
@media (min-width: 851px) and (max-width: 950px) { .practice-layout { height: auto; } }

/* The roleplay's own controls: voice output, transcript retention, restart.
   Both toggles are off by default and say so through aria-pressed, so the
   state is available to a screen reader and not only to the eye. */
.roleplay-tools { display: flex; gap: 6px; align-items: center; flex: none; }
.icon-btn.on {
  color: #050507;
  background: linear-gradient(120deg, #80EDEF, #41BBF5);
  border-color: transparent;
}
.icon-btn.danger { color: #fff; background: rgba(240,14,249,.16); border-color: rgba(240,14,249,.4); }

/* ------------------------------------------------- chat: message actions ---- */
/* A CONVERSATION, NOT A FORM. The composer's third control is the paper plane
   and nothing else; the row of things you can DO with a message appears under
   the message, icon-only, and every one of them carries an accessible name and
   a tooltip. Labels are omitted only where the glyph is unambiguous, which is
   the whole set here: copy, listen, retry, share, thumbs up, thumbs down.

   `white-space: pre-wrap` from ptr-core lives on `.msg`, and a `.msg` that now
   contains element children would render the markup's own indentation as blank
   lines. So the text gets its own box and keeps the pre-wrap; the message box
   itself goes back to normal wrapping. */
.msg { white-space: normal; }
.msg-text { white-space: pre-wrap; display: block; }
.msg-actions {
  display: flex; gap: 2px; margin-top: 7px;
  opacity: .74; transition: opacity .18s ease;
}
.msg:hover .msg-actions,
.msg:focus-within .msg-actions { opacity: 1; }
.msg-act {
  width: 28px; height: 28px; flex: none;
  display: grid; place-content: center;
  border: 0; border-radius: 9px;
  background: none; color: inherit; cursor: pointer;
  padding: 0;
}
.msg-act::before { width: 15px; height: 15px; }
.msg-act:hover { background: rgba(255,255,255,.1); }
.msg-act[aria-pressed="true"] { color: var(--ptr-cyan); }
.msg-act:disabled { opacity: .35; cursor: not-allowed; }
/* Coarse pointers have no hover, so a row that only appears on hover would be
   a row that never appears. */
@media (hover: none) { .msg-actions { opacity: 1; } }

/* The send button is an icon button that lives in the composer's third track,
   so it matches the mic beside it rather than the primary buttons elsewhere. */
.send-btn {
  width: 46px; height: 46px;
  display: grid; place-content: center;
  border: 1px solid transparent; border-radius: 14px;
  background: linear-gradient(120deg, #80EDEF, #41BBF5);
  color: #050507; cursor: pointer;
}
.send-btn::before { width: 20px; height: 20px; }
.send-btn:disabled { opacity: .38; cursor: not-allowed; }

/* ============================================================================
   LIVE CALL
   ============================================================================
   THE AVATAR IS THE SURFACE. A desktop reads as a video call - one large frame
   with the participant in it and a control bar along the bottom - and a phone
   reads as a full-bleed call, which is the same hierarchy with the frame taken
   out to the edges. Neither is a copy of anybody's visual design: the glass,
   the radii, the type and the accent are the same ones the rest of this site
   already uses. What is being borrowed is the SPATIAL rule that a call surface
   puts the person first and the controls second.

   The avatar is never a 42px chip here. `#liveAvatar` is the whole stage, and
   `public/vrm-stage.js` - the same renderer the chat head mounts - sizes itself
   to its host through a ResizeObserver, so it gets real room for idle motion,
   blinking and the speaking state without a second renderer existing.
   -------------------------------------------------------------------------- */
/* THE HEAD READS LEFT TO RIGHT. `.roleplay-head` is `space-between`, which is
   right for the chat head - back, avatar, title, tools - and wrong for the two
   cards that carry only a back button and a title block: with two children,
   space-between pushed the title to the far right of a 1440px card and left a
   metre of nothing in the middle. */
.mode-card .roleplay-head, .debrief-card .roleplay-head { justify-content: flex-start; }
.mode-card .roleplay-head > div, .debrief-card .roleplay-head > div { flex: 1 1 auto; min-width: 0; }

/* THE SHARE CARD BESIDE THE REVIEW, NOT UNDERNEATH IT. Stacked, a 4:5 card sits
   below three criterion scores and two lists, which on a desktop means the
   thing a person might actually want to share is the one part of the results
   they never see. There is a whole column of unused width next to it. */
@media (min-width: 851px) {
  .results-body {
    grid-template-columns: minmax(0, 1fr) 300px;
    gap: 16px; align-items: start;
  }
  .result-scores, .debrief-card #roleDebrief { grid-column: 1; }
  .result-card { grid-column: 2; grid-row: 1 / span 2; margin-top: 0; }
}

.live-card { padding: 0; overflow: hidden; }
/* THE STAND-IN, CENTRED AND LARGE. `#liveAvatar` carries the character's
   initial as its textContent, exactly as the chat chip does, and without this
   it rendered as a stray letter in the top-left corner of the call surface -
   because the element is `position: absolute; inset: 0` and the glyph had
   nothing telling it where to sit. It goes transparent the moment a real model
   is on the GPU, by the same attribute the chat chip uses, so the letter is
   never on screen underneath a face. */
.live-avatar {
  display: grid; place-content: center;
  font-size: 64px; font-weight: 600;
  color: rgba(255,255,255,.14);
}
.live-avatar[data-vrm-source="model"] { color: transparent; }
.live-stage {
  position: relative;
  flex: 1 1 auto; min-height: 0;
  display: flex;
  border-radius: inherit;
  background:
    radial-gradient(120% 90% at 50% 0%, rgba(65,187,245,.14), transparent 62%),
    rgba(6,8,14,.72);
}
.live-avatar { position: absolute; inset: 0; }
.live-avatar > canvas { display: block; width: 100%; height: 100%; pointer-events: none; }
.live-hud {
  position: relative; z-index: 1;
  flex: 1 1 auto;
  display: flex; flex-direction: column; justify-content: space-between;
  padding: 14px;
  pointer-events: none;
}
.live-hud button, .live-hud .live-chip, .live-hud .live-timer { pointer-events: auto; }
.live-hud-top { display: flex; justify-content: space-between; align-items: center; gap: 10px; }
.live-hud-bottom { display: grid; justify-items: center; gap: 12px; }
.live-chip {
  font-size: 11px; letter-spacing: .12em; text-transform: uppercase;
  padding: 6px 11px; border-radius: 99px;
  border: 1px solid rgba(255,255,255,.16);
  background: rgba(6,8,14,.6);
  color: var(--pr-ink-quiet);
}
.live-chip[data-live-chip="live"] { border-color: rgba(128,237,239,.5); color: var(--ptr-cyan); }
.live-chip[data-live-chip="blocked"] { border-color: rgba(240,14,249,.45); color: #F7A8FA; }
.live-timer {
  font-variant-numeric: tabular-nums;
  font-size: 14px; letter-spacing: .03em;
  padding: 6px 11px; border-radius: 99px;
  border: 1px solid rgba(255,255,255,.16);
  background: rgba(6,8,14,.6);
  color: var(--pr-ink-body);
}
.live-status {
  margin: 0; max-width: 460px; text-align: center;
  font-size: 14px; line-height: 1.5;
  color: var(--pr-ink-body);
  background: rgba(6,8,14,.72);
  border: 1px solid rgba(255,255,255,.12);
  border-radius: 16px; padding: 12px 14px;
  pointer-events: auto;
}
.live-status:empty { display: none; }
.live-controls { display: flex; gap: 10px; align-items: center; }
.live-card > .micro { flex: none; margin: 0; padding: 10px 16px 14px; }
/* Hidden rather than merely disabled while there is no call: an End button on a
   surface that has never connected is a control for something that is not
   happening. */
.live-stage[data-live-state="idle"] #liveEnd,
.live-stage[data-live-state="blocked"] #liveEnd { display: none; }
.live-stage[data-live-state="live"] #liveToChat { display: none; }

/* ============================================================================
   RESULTS
   ============================================================================ */
.results-body { display: grid; gap: 12px; align-content: start; }
.result-scores { display: grid; gap: 10px; }
.result-overall {
  display: flex; align-items: baseline; gap: 12px;
  padding: 14px 16px; border-radius: 18px;
  border: 1px solid rgba(128,237,239,.24);
  background: rgba(128,237,239,.07);
}
.result-overall b { font-size: 34px; line-height: 1; font-variant-numeric: tabular-nums; }
.result-overall span { color: var(--ptr-ink-2); font-size: 13px; }
.criterion { display: grid; gap: 5px; }
.criterion-head { display: flex; justify-content: space-between; gap: 10px; font-size: 14px; }
.criterion-head b { font-variant-numeric: tabular-nums; flex: none; color: var(--ptr-cyan); }
.criterion-bar { height: 5px; border-radius: 99px; background: rgba(255,255,255,.1); overflow: hidden; }
.criterion-bar i { display: block; height: 100%; background: linear-gradient(90deg, #80EDEF, #41BBF5); }
/* EVIDENCE IS THE POINT OF A CRITERION SCORE. A number with no quote behind it
   is an opinion; the quote is what makes it reviewable, so it is not the part
   that gets dropped on a small screen. */
.criterion-evidence { font-size: 12.5px; line-height: 1.45; color: var(--ptr-ink-3); }

/* THE SHAREABLE CARD IS PORTRAIT ON PURPOSE. Every surface a result actually
   gets shared to is portrait, and a landscape card arrives there letterboxed
   into a strip. 4:5 is the widest portrait ratio that survives both a feed and
   a story crop. */
.result-card {
  margin: 4px 0 0;
  aspect-ratio: 4 / 5;
  max-width: 300px;
  display: grid; align-content: center; justify-items: center; gap: 2px;
  padding: 22px 18px;
  border-radius: 24px;
  border: 1px solid rgba(128,237,239,.34);
  background:
    radial-gradient(120% 80% at 50% 0%, rgba(65,187,245,.22), transparent 60%),
    var(--pr-inset);
  text-align: center;
}
.result-card-head {
  display: flex; align-items: center; gap: 8px;
  font-size: 12px; letter-spacing: .18em; color: var(--pr-ink-label);
  margin-bottom: 10px;
}
.result-card-head b { font-weight: 700; color: #fff; }
.result-card-ico { width: 26px; height: 26px; color: var(--ptr-cyan); margin-bottom: 4px; }
.result-card-score { font-size: 58px; font-weight: 700; line-height: 1; font-variant-numeric: tabular-nums; }
.result-card-of { font-size: 12px; letter-spacing: .1em; text-transform: uppercase; color: var(--ptr-ink-3); }
.result-card-scenario { margin: 12px 0 0; font-size: 15px; line-height: 1.3; }
.result-card-level { font-size: 12px; letter-spacing: .1em; text-transform: uppercase; color: var(--ptr-cyan); margin-top: 5px; }
.result-card-foot { margin-top: 14px; font-size: 11px; letter-spacing: .06em; color: var(--pr-ink-foot); }
#debriefNext[hidden] { display: none; }


/* ============================================================================
   THE PHONE. ONE STATE AT A TIME.
   ============================================================================
   PTR Labs on a phone is an app, not a document. Every rule below exists to
   keep exactly one primary thing on screen and to keep it inside one dynamic
   viewport, and none of them do it by shrinking the product: there is no
   `zoom`, no `transform: scale()`, no viewport-meta change and no global font
   reduction anywhere in this file. What changes is WHAT IS RENDERED, not how
   big it is drawn.

   850px is the site's established mobile ceiling and is duplicated in labs.js
   as PHONE_MQ. The two must agree; see the note there for what happens if they
   ever stop agreeing.

   THE ONE INTENTIONAL EXCEPTION is the message history during an active
   roleplay. A conversation is inherently scrollable and pretending otherwise
   would mean throwing away the visitor's own words. It is allowed to scroll
   INSIDE itself, and only while the tiles are gone and the composer is pinned
   in view. Nothing else in the primary flow scrolls to reach the next step.
   -------------------------------------------------------------------------- */
@media (max-width: 850px) {
  /* THE PANEL BODY KEEPS ITS SAFETY NET. `overflow-y: hidden` was the first
     version of this rule and it was the wrong instinct: it does not make a
     state fit, it makes a state that does not fit CLIP, silently, with the
     control you were meant to press behind the edge. It also blinded the
     acceptance probe, which looks for elements that scroll - an element that
     clips is not one of them. ptr-core.css leaves `auto` here deliberately as a
     last resort, and leaving it means an overflow is both survivable for the
     visitor and visible to the check that is supposed to catch it.
     What makes the states fit is the sizing below, not a clip. */
  .practice-layout, .create-layout { flex: 1 1 auto; min-height: 0; }
  /* `1fr` IS NOT `minmax(0, 1fr)`. A grid track's default minimum is its
     content's minimum, so the roleplay card was allowed to be as wide as its
     widest unbreakable child - the composer's text input, which carries a
     browser default intrinsic width - and pushed the panel 4px sideways. The
     input gets an explicit zero minimum for the same reason: it sits in a `1fr`
     track of its own inside .composer. Measured, not guessed: the probe named
     .panel-body overflowing by exactly 4px. */
  .practice-layout { grid-template-columns: minmax(0, 1fr); }
  .composer input { min-width: 0; }

  /* --- practice: scenario-select ----------------------------------------- */
  /* Five tiles divide the height they are given. `minmax(0, 1fr)` rather than a
     fixed row height is what makes this survive a 360x640 phone and a 430x932
     one with the same rule: the tiles get shorter, the list never scrolls, and
     nothing has to know how many scenarios there are. */
  /* Sized from what is LEFT, not from its own content: with the panel body a
     flex column, `flex: 1 1 auto` plus a zero minimum is what stops five tiles
     asking for more height than the screen has. `minmax(0, 1fr)` rows then
     divide whatever that turns out to be, which is why this survives 360x640
     and 430x932 with one rule and no knowledge of how many scenarios exist. */
  .scenario-list {
    flex: 1 1 auto; min-height: 0;
    grid-auto-rows: minmax(0, 1fr);
    gap: 7px;
  }
  .scenario {
    padding: 10px 12px;
    align-content: center;
    overflow: hidden;
    grid-template-columns: 26px 1fr;
  }
  .scenario strong { font-size: 15px; }
  /* The character line is the second half of a tile's meaning, so it is clamped
     rather than dropped. One line on a short phone, two where there is room. */
  .scenario small {
    display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
    line-clamp: 2; overflow: hidden; font-size: 12px; line-height: 1.3;
  }

  /* --- practice: roleplay ------------------------------------------------- */
  /* The tiles are GONE, not merely scrolled past, in every step after the
     first. That rule is no longer phone-only and lives in the flow table above;
     what is left here is the sizing. */
  .practice-stage { flex: 1 1 auto; min-height: 0; }
  /* ptr-core pins .messages to 300px on a phone. That is a sensible default for
     a card on a scrolling page and the wrong one for a flex child that is
     supposed to absorb whatever height is left. Overridden here, in the
     site-specific sheet, because ptr-core.css is byte-shared with AI Studio. */
  .practice-layout .messages {
    height: auto; flex: 1 1 auto; min-height: 0; overflow-y: auto;
  }
  /* The composer never leaves. It is the only thing on the screen the visitor
     is being asked to use. */
  .practice-stage .composer { flex: none; }
  .practice-stage .roleplay-actions { flex: none; margin-top: 8px; }
  #roleTurnCount { margin-top: 7px; }
  /* One disclaimer per state, and it is the last line rather than a paragraph
     competing with the conversation for rows. */
  .practice-stage > .card > .micro:last-child { font-size: 11px; margin-top: 7px; }
  .back-btn { display: grid; }
  .roleplay-head { gap: 8px; }
  /* The panel head two rows up already says PRACTICE. Repeating the section
     name inside the card cost two lines and squeezed the scenario title into
     three; the title is the thing that tells you which conversation you are in. */
  #roleEyebrow { display: none; }
  /* THE TITLE GETS ITS OWN ROW. Back, avatar, title and three tool buttons on
     one 360px line left the title about 90px wide, and "Push back on an
     unrealistic deadline" truncated to "Push back on an unrealisti...". Which
     conversation you are in is not the part to abbreviate. Wrapping the head and
     ordering the text block last costs one row and buys the whole title. */
  .roleplay-head { flex-wrap: wrap; }
  .roleplay-head > div:not(.roleplay-tools):not(.role-avatar) { order: 3; flex: 1 0 100%; }
  .roleplay-tools { margin-left: auto; }
  .roleplay-head h3 {
    font-size: 17px; margin: 0;
    display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
    line-clamp: 2; overflow: hidden;
  }
  .roleplay-head .micro { display: none; }
  .icon-btn { width: 38px; height: 38px; }

  /* --- practice: results ---------------------------------------------------
     WHICH CARD IS ON SCREEN IS DECIDED BY THE `hidden` ATTRIBUTE, NOT BY A
     PHONE RULE. The previous version of this block carried
     `[data-practice-state="debrief"] .practice-stage > .debrief-card
     { display: flex }`, which at specificity (0,3,0) OUTRANKS
     `.debrief-card[hidden]` at (0,2,0) - so a card that JavaScript had hidden
     would have been forced back on screen on a phone and only on a phone. One
     switch, and it is the attribute. */
  .debrief-card .roleplay-actions { display: flex; gap: 8px; margin-top: 10px; }
  .debrief-card .roleplay-actions button { flex: 1 1 auto; }
  /* The card is the thing worth sharing, so it keeps its proportions and gives
     up width instead. Centred because at this size it is the page. */
  .result-card { max-width: 220px; justify-self: center; }
  .result-overall b { font-size: 28px; }

  /* --- practice: mode-select ---------------------------------------------- */
  /* Two choices, one under the other, each big enough to be the primary thing
     it is. Anything more than two would be a menu. */
  .mode-choices { grid-template-columns: 1fr; gap: 10px; }
  .mode-choice { padding: 16px; }
  .mode-choice strong { font-size: 17px; }
  #modeEyebrow { display: none; }
  .mode-card .roleplay-head h3 { font-size: 18px; }

  /* --- practice: live call -------------------------------------------------
     FULL BLEED. The card's own padding and the panel's gutters are what make a
     call surface look like a widget on a page instead of a call. The avatar
     takes the screen and the controls float on it. */
  .live-card { padding: 0; }
  .live-stage { border-radius: 20px; }
  .live-hud { padding: 11px; }
  .live-status { font-size: 13px; padding: 10px 12px; }
  .live-card > .micro { padding: 8px 12px 11px; font-size: 11px; }

  /* --- play --------------------------------------------------------------- */
  /* ONE COLUMN ON A PHONE. Four tiles side by side on a 360px screen is four
     unreadable tiles; four stacked rows is a list you can actually choose from,
     and the rows are elastic so a short phone gets shorter rows rather than a
     scroll. */
  /* One column, and the rows are AUTO. The desktop's minmax(0,1fr) rows made
     every phone tile 165px tall while the 16:9 thumb wanted 187px, and a grid
     item taller than its track paints straight over the rows below it - the
     measured overlap. Phones scroll the panel, so tiles just take their
     height. */
  /* flex:none is the load-bearing part: flexed to the panel remainder the
     launcher is a DEFINITE 686px grid, auto rows compress to min-content
     (an aspect-ratio span's min-content height is ~0), and the thumbs then
     paint at their real 187px straight over the rows below - measured. A
     content-sized launcher gives true content rows and the panel-body
     scrolls, which it already does everywhere else on phones. */
  .play-launcher { grid-template-columns: 1fr; grid-auto-rows: auto; gap: 9px; flex: none; min-height: auto; }
  .play-tile.has-thumb { grid-template-rows: none; align-content: start; }
  .play-tile-thumb { height: auto; aspect-ratio: 16 / 9; }
  /* `height: 100%` on the container was 100% of the PANEL BODY, and the Back
     button had already eaten 46px of that body. The card therefore hung 46px
     past the bottom of the screen and took the provenance note with it - inside
     the card, outside the viewport, which is the version of "off screen" that a
     document-scroll check cannot see. Sizing from the REMAINDER is the fix. */
  [data-play-state="experience"] #playStages { flex: 1 1 auto; height: auto; min-height: 0; }
  /* THE EXPERIENCE OWNS THE VIEWPORT. The launcher tile the visitor just tapped
     carried the blurb and the chips; repeating them here pushed the actual
     experience below the fold and made the stage scroll to reach it. What is
     kept is what is still true and still needed: what this is, where it is
     running from, and the way out to full screen. */
  [data-play-state="experience"] .play-stage .stage-copy p,
  [data-play-state="experience"] .play-stage > .needs { display: none; }
  [data-play-state="experience"] .play-stage .stage-copy h3 { font-size: 18px; margin: 2px 0 6px; }
  [data-play-state="experience"] .play-stage .status-row { flex: none; }
  [data-play-state="experience"] .play-stage .stage-body {
    flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column;
  }
  /* An aspect ratio is how you size a frame on a page. On a state that owns the
     screen the frame takes what is left, which is the whole point. */
  [data-play-state="experience"] .play-stage .embed-frame {
    aspect-ratio: auto; flex: 1 1 auto; min-height: 0; width: 100%;
  }
  [data-play-state="experience"] .play-stage > .micro:last-child { font-size: 10px; margin-top: 6px; flex: none; }
  .play-tile { padding: 12px 13px; }
  .play-tile strong { font-size: 16px; }
  .play-tile small {
    display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
    line-clamp: 2; overflow: hidden; font-size: 12px;
  }
}

/* A SHORT PHONE HAS FEWER ROWS, NOT SMALLER ONES. An iPhone SE is 667 tall and
   a 360x640 Android is shorter still. The tiles are already elastic, so what
   gives here is the copy that is nice to have rather than the copy that tells
   you what you are choosing. */
@media (max-width: 850px) and (max-height: 700px) {
  .scenario small { -webkit-line-clamp: 1; line-clamp: 1; }
  .play-tile small { -webkit-line-clamp: 1; line-clamp: 1; }
  .play-tile .needs { display: none; }
  .scenario { padding: 8px 11px; }
  /* The "this is practice, not advice" line SHRINKS on a short phone. It does
     not disappear: it is the sentence that stops a roleplay being mistaken for
     HR, legal or medical guidance, and a screen small enough to hide it on is
     not a reason to stop saying it. */
  .practice-stage > .card > .micro:last-child { font-size: 10px; line-height: 1.35; margin-top: 5px; }
  .roleplay-head h3 { font-size: 16px; }
}

/* A 360px head row carries a back button, an avatar, a title and three tools.
   The touch targets stay at the 38px that is already the site's floor; what
   gives is the gap between them. */
@media (max-width: 380px) {
  .roleplay-head { gap: 6px; }
  .role-avatar { width: 38px; height: 38px; border-radius: 12px; font-size: 15px; }
  .roleplay-tools { gap: 4px; }
}

/* ------------------------------------------------------- listening strip ----
   IS IT LISTENING? The recording state was a border colour and a breathing
   glyph on a 50px button, and the product owner reported there was no way to
   tell the microphone was working at all. Three signals, because each answers a
   different question a person actually has:

     the word      am I recording          - a label, not a colour
     the countdown when does it stop       - the 30s cap made visible
     the meter     is it HEARING anything  - RMS of the captured audio

   The meter is the load-bearing one. It is driven by the frames `createMic`
   just captured, so it cannot animate while the capture is silent. A decorative
   pulse would have looked identical whether the microphone worked or not, which
   is the exact failure being fixed. `.quiet` is set when nothing has arrived
   for a couple of seconds, so a dead input SAYS it is dead instead of sitting
   there looking busy.

   Colours come from the existing tokens; nothing new is introduced. */
.mic-live {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 0 0 8px;
  padding: 7px 12px;
  border: 1px solid rgba(240,14,249,.42);
  border-radius: 12px;
  background: rgba(240,14,249,.07);
  font-size: 13px;
  line-height: 1;
}
.mic-live[hidden] { display: none; }
.mic-live-dot {
  width: 9px; height: 9px; border-radius: 50%;
  background: #F00EF9; flex: none;
  animation: lensBreath 1s ease-in-out infinite;
}
.mic-live-label { color: #F7A8FA; font-weight: 600; flex: 1 1 auto; }
.mic-live-time { color: var(--pr-ink-time); font-variant-numeric: tabular-nums; flex: none; }
.mic-meter { display: flex; align-items: flex-end; gap: 3px; height: 16px; flex: none; }
.mic-meter i {
  display: block; width: 3px; height: 4px; border-radius: 2px;
  background: rgba(247,168,250,.28);
  transition: height .06s linear, background-color .06s linear;
}
.mic-meter i.on { background: #F00EF9; }
.mic-meter i:nth-child(1).on { height: 5px; }
.mic-meter i:nth-child(2).on { height: 8px; }
.mic-meter i:nth-child(3).on { height: 11px; }
.mic-meter i:nth-child(4).on { height: 14px; }
.mic-meter i:nth-child(5).on { height: 16px; }
/* NOTHING IS ARRIVING, AND IT SAYS SO. */
.mic-live.quiet { border-color: rgba(154,160,180,.42); background: rgba(154,160,180,.08); }
.mic-live.quiet .mic-live-dot { background: #9aa0b4; animation: none; }
.mic-live.quiet .mic-live-label { color: var(--pr-ink-time); font-weight: 500; }

/* THE SAME STRIP IN THE LIVE CALL HUD, WHERE WHAT IS BEHIND IT IS A MOVING
   RENDER RATHER THAN A FLAT CARD. Chat's translucent fill is legible over a
   card; over an animated character it would not be, and legibility is the one
   thing this strip cannot trade away - it is the only evidence a live-call
   visitor has that a microphone is open. So it takes the SAME opaque base
   `.live-status` already uses on this surface, in the quiet state too, and
   changes nothing else: the meter, the dot and the countdown are byte for byte
   chat's. `margin: 0` because .live-hud-bottom owns the spacing here (a grid
   gap), not the strip. */
.live-mic-live {
  margin: 0;
  max-width: 100%;
  background: rgba(6,8,14,.72);
  border-color: rgba(240,14,249,.5);
}
.live-mic-live.quiet { background: rgba(6,8,14,.72); border-color: rgba(154,160,180,.5); }

/* A person who has asked for less motion still needs to know it is recording,
   so the dot stops moving and the meter keeps working - the meter is
   information, not decoration. */
@media (prefers-reduced-motion: reduce) {
  .mic-live-dot { animation: none; }
  .mic-meter i { transition: none; }
}

/* ============================================================================
   PRACTICE: THE CONTRAST CONTRACT
   ============================================================================
   THE PROBLEM WAS CONTRAST, NOT THE VISUAL LANGUAGE. Practice was drawing very
   faint ink (secondary text at 48% white, tertiary at 44%) onto surfaces that
   were LIGHTENING washes - .card is a white .05 to .02 gradient, the scenario
   tile was white .035 - laid over an animated field whose cyan and violet
   lobes move underneath. A lightening wash raises the floor the text has to
   beat, and it raises it by a DIFFERENT amount every second as the lobes
   drift. That is why the same words were readable in one moment and gone the
   next.

   THE FIX IS THE MATERIAL, NOT THE INK ALONE. Practice keeps optical glass and
   keeps the field visible through it; what changes is that the smoke now goes
   UNDER the tint instead of the tint being the whole surface. A smoked base of
   rgba(8,9,14,.55-.66) plus a cyan/violet tint plus a stronger blur still
   reads as glass - the backdrop shows through at 34% to 45% - but it gives the
   text a floor that does not move.

   EVERYTHING HERE IS MEASURED, AND THE MEASUREMENT IS ENFORCEABLE.
   `tools/practice-contrast-check.mjs` parses the token block below out of THIS
   FILE, composites each ink over the surface stack it is actually painted on,
   composites that stack over a modelled worst-case backdrop, and fails if any
   WCAG 2.1 contrast ratio falls under the floor for that role. It also fails
   if a faint colour literal is smuggled back into a Practice rule instead of
   going through a token. So a future change cannot quietly darken this again:
   it has to move a number the tool reads.

   SCOPE. Every override is under [data-panel="practice"]. --ptr-ink-2 and
   --ptr-ink-3 are re-declared LOCALLY on the panel, so PLAY, CREATE, the gate
   and the drawer keep the global values, and public/ptr-core.css - which is
   byte-identical with the AI Studio site - is untouched.
   -------------------------------------------------------------------------- */

/* pr-contrast-tokens:start */
[data-panel="practice"] {
  /* --- INK. Every text colour in Practice comes from one of these. --------- */
  /* LOCAL, NOT GLOBAL. :root still says .64 / .44 for the rest of the site. */
  --ptr-ink-2:      rgba(255,255,255,.78);  /* secondary prose */
  --ptr-ink-3:      rgba(255,255,255,.66);  /* .micro, criterion evidence, mode blurbs */
  --pr-ink-body:    rgba(255,255,255,.92);  /* debrief prose, difficulty chips, live HUD */
  --pr-ink-time:    rgba(255,255,255,.8);   /* the recording countdown */
  --pr-ink-label:   rgba(255,255,255,.76);  /* eyebrows and section headings */
  --pr-ink-quiet:   rgba(255,255,255,.72);  /* scenario blurb and scenario number */
  --pr-ink-pip:     rgba(255,255,255,.7);   /* a level pip that is still locked */
  --pr-ink-place:   rgba(255,255,255,.68);  /* composer placeholder */
  --pr-ink-foot:    rgba(255,255,255,.62);  /* the domain on the share card */

  /* --- MATERIAL. Smoke first, tint on top, backdrop still coming through. -- */
  --pr-glass:       rgba(8,9,14,.66);       /* a card */
  --pr-tile:        rgba(8,9,14,.58);       /* a scenario tile, straight on the field */
  --pr-inset:       rgba(8,9,14,.55);       /* something inside a card */
  --pr-bubble-ai:   rgba(8,9,14,.46);       /* the character speaking */
  --pr-bubble-user: rgba(8,9,14,.42);       /* you speaking */
  --pr-tint-cyan:   rgba(128,237,239,.1);
  --pr-tint-violet: rgba(109,87,255,.14);
  --pr-tint-user:   rgba(128,237,239,.13);
  --pr-hair:        rgba(255,255,255,.16);
}
/* pr-contrast-tokens:end */

/* THE CARD. Was a white .05 to .02 wash on blur(18px), which is the surface
   that made every .micro line on it marginal. Now smoke under two tints, and
   the blur goes up because a stronger blur is what stops a moving lobe from
   being legible AS a lobe through the glass. */
[data-panel="practice"] .card {
  border: 1px solid var(--pr-hair);
  background:
    radial-gradient(130% 100% at 14% 0%, var(--pr-tint-cyan), transparent 58%),
    radial-gradient(140% 110% at 86% 100%, var(--pr-tint-violet), transparent 62%),
    var(--pr-glass);
  -webkit-backdrop-filter: blur(26px);
          backdrop-filter: blur(26px);
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,.1),
    0 18px 44px rgba(0,0,0,.42);
}

/* THE BUBBLES. Both were lightening washes - white .065 and cyan .09 - so a
   long reply sat on a brighter plate than the card around it. Both are smoked
   now; the user bubble keeps its cyan identity as a TINT over the smoke rather
   than as the whole surface, and both borders come up so the shape of the turn
   is still legible when the text inside it is short. */
[data-panel="practice"] .msg.ai {
  background: var(--pr-bubble-ai);
  border: 1px solid rgba(255,255,255,.14);
}
[data-panel="practice"] .msg.user {
  background:
    linear-gradient(180deg, var(--pr-tint-user), transparent 78%),
    var(--pr-bubble-user);
  border: 1px solid rgba(128,237,239,.34);
}
/* An error keeps the magenta reading, on the same smoke as every other turn.
   This rule follows .msg.ai deliberately: an error message carries BOTH
   classes and the two selectors weigh the same. */
[data-panel="practice"] .msg.err {
  background:
    linear-gradient(180deg, rgba(240,14,249,.16), transparent 80%),
    var(--pr-bubble-ai);
  border: 1px solid rgba(240,14,249,.42);
}

/* THE COMPOSER. The field you type into was white .04, ie. LIGHTER than the
   card, and its placeholder was whatever the user agent picks for an input
   with no colour-scheme - which on this dark card is close to black. The
   placeholder is instruction, not decoration ("Say what you would actually
   say..."), so it gets a measured value like everything else. */
[data-panel="practice"] .composer input,
[data-panel="practice"] .composer textarea {
  background: var(--pr-inset);
  border: 1px solid var(--pr-hair);
}
[data-panel="practice"] .composer input::placeholder,
[data-panel="practice"] .composer textarea::placeholder {
  color: var(--pr-ink-place);
  opacity: 1; /* Firefox dims a placeholder by default; the value above is the value. */
}
/* AFTER the surface rule above, and one step heavier, or the focus ring loses
   to it: `.composer input:focus` and `[data-panel] .composer input` weigh the
   same and the later one would win. */
[data-panel="practice"] .composer input:focus {
  border-color: rgba(128,237,239,.6);
  box-shadow: 0 0 0 3px rgba(65,187,245,.16);
}

/* The debrief prose block and the eyebrows that label every step. */
[data-panel="practice"] .notice {
  border: 1px solid var(--pr-hair);
  background: var(--pr-inset);
  color: var(--pr-ink-body);
}
[data-panel="practice"] .eyebrow { color: var(--pr-ink-label); }

/* A person who has asked for less transparency gets the smoke without the
   window: same ink, same layout, no field showing through. */
@media (prefers-reduced-transparency: reduce) {
  [data-panel="practice"] {
    --pr-glass:       rgba(8,9,14,.94);
    --pr-tile:        rgba(8,9,14,.94);
    --pr-inset:       rgba(8,9,14,.9);
    --pr-bubble-ai:   rgba(8,9,14,.85);
    --pr-bubble-user: rgba(8,9,14,.85);
  }
}
/* ================================================ character selection ======
   THE VRM IS THE SELL, AND IT IS A PICTURE.

   Five tall character columns on desktop, compact rows on a phone. Every image
   here is a static .webp built by tools/render-vrm-previews.mjs - fourteen
   files, 168 KB for the whole set, against 800-1150 KB for a SINGLE .vrm. No
   canvas, no WebGL and no Three/VRM runtime exists on this screen; the first
   live renderer is mounted on the mode screen after a click.

   The two stacked <img> layers are what make a change a crossfade instead of a
   flash of empty space while the next file decodes. Only the layer with `.on`
   is visible; the carousel swaps which one that is. */

[data-panel="practice"] .scenario-list {
  /* Five across on a normal desktop without horizontal scrolling. `auto-fit`
     with a min width would drop to four and wrap the fifth onto its own row,
     which reads as an afterthought rather than a set. */
  grid-template-columns: repeat(5, minmax(0, 1fr));
  gap: 14px;
}
[data-panel="practice"] .scenario {
  grid-template-columns: 1fr;
  grid-template-rows: auto auto;
  padding: 0 0 14px;
  overflow: hidden;
  align-content: start;
}
[data-panel="practice"] .scenario-figure {
  position: relative;
  display: block;
  width: 100%;
  /* The renders are 480x900. Matching the ratio means no letterbox band and no
     crop of the feet, which is the whole point of grounding them. */
  aspect-ratio: 480 / 900;
  /* A faint plinth so the character stands on something rather than floating
     in a rectangle, and so a transparent PNG never reads as a hole. */
  background:
    radial-gradient(58% 26% at 50% 97%, rgba(128,237,239,.16), rgba(128,237,239,0) 70%),
    linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,0) 55%);
}
/* THE ANIMATED ROOM BEHIND EACH CHARACTER (operator 2026-08-30). A 17x2
   sprite sheet (34 portrait frames cut from the scenario's environment loop),
   walked by background-position steps - the same geometry the robot sprite on
   the main site documents: x travels 106.25% in 17 steps, y travels 200% in
   2, x period exactly half the y period. 4s and 8s match the source loop's
   real-time speed. Dimmed so the character stays the subject; the plinth
   gradient above it still reads. First child of the figure, so the absolute
   avatar imgs paint over it by DOM order. */
[data-panel="practice"] .scenario-scene {
  position: absolute; inset: 0;
  background-repeat: no-repeat;
  background-size: 1700% 200%;
  background-position: 0% 0%;
  animation: prSceneX 4s steps(17) infinite, prSceneY 8s steps(2) infinite;
  opacity: .45;
}
@keyframes prSceneX { from { background-position-x: 0%; } to { background-position-x: 106.25%; } }
@keyframes prSceneY { from { background-position-y: 0%; } to { background-position-y: 200%; } }
[data-panel="practice"] .scenario-figure img {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: contain;
  object-position: 50% 100%;
  opacity: 0;
  transition: opacity .55s ease;
  pointer-events: none;
}
[data-panel="practice"] .scenario-figure img.on { opacity: 1; }
/* THE CHARACTERS THEMSELVES ARE 17x2 IDLE SPRITE SHEETS now (34 frames of
   sway/breath from tools/render-vrm-sprites.mjs; frame 0 == the old still).
   Same crossfade contract as the img layers they replace: two stacked layers,
   .on is visible, the carousel swaps which. Cell aspect matches the figure
   for both kinds (240x450 in the 480/900 figure, 160x160 in the phone
   square), so background frames land exactly where object-fit put the still.
   X travels 106.25% in 17 steps, y 200% in 2, x period half of y - the same
   measured geometry the main site's robot sprite documents. */
[data-panel="practice"] .avatar-sprite {
  position: absolute; inset: 0;
  background-repeat: no-repeat;
  background-size: 1700% 200%;
  background-position: 0% 0%;
  animation: prIdleX 2.267s steps(17) infinite, prIdleY 4.533s steps(2) infinite;
  opacity: 0;
  transition: opacity .55s ease;
  pointer-events: none;
}
[data-panel="practice"] .avatar-sprite.on { opacity: 1; }
@keyframes prIdleX { from { background-position-x: 0%; } to { background-position-x: 106.25%; } }
@keyframes prIdleY { from { background-position-y: 0%; } to { background-position-y: 200%; } }
[data-panel="practice"] .scenario-copy {
  display: grid;
  grid-template-columns: 30px 1fr;
  gap: 0 10px;
  padding: 0 14px;
  text-align: left;
}
[data-panel="practice"] .scenario-copy strong { grid-column: 2; }
[data-panel="practice"] .scenario-copy small { grid-column: 2; }
[data-panel="practice"] .scenario-copy .num { grid-row: 1 / 3; }
[data-panel="practice"] .scenario:hover .scenario-figure img.on,
[data-panel="practice"] .scenario:hover .scenario-figure .avatar-sprite.on { transform: translateY(-2px); }
[data-panel="practice"] .scenario-figure img { transition: opacity .55s ease, transform .35s ease; }

/* A person who asked for less motion gets one character and no crossfade,
   and the room behind them holds its first frame. */
@media (prefers-reduced-motion: reduce) {
  [data-panel="practice"] .scenario-figure img { transition: none; }
  [data-panel="practice"] .scenario:hover .scenario-figure img.on { transform: none; }
  [data-panel="practice"] .scenario-scene { animation: none; }
  [data-panel="practice"] .avatar-sprite { animation: none; transition: none; }
  [data-panel="practice"] .scenario:hover .scenario-figure .avatar-sprite.on { transform: none; }
}

/* SCENE BACKDROPS on the mode, chat and live cards (operator 2026-08-30):
   the scenario's environment loop behind a legibility scrim. Injected and
   paused by syncSceneBg in labs.js, always as the card's FIRST child; every
   later sibling is lifted above it so no control can end up under the video. */
#modeCard, #roleplayCard, #liveCard { position: relative; }
.scene-bg {
  position: absolute; inset: 0;
  border-radius: inherit; overflow: hidden;
  pointer-events: none;
}
.scene-bg[hidden] { display: none; }
.scene-bg video { width: 100%; height: 100%; object-fit: cover; display: block; }
.scene-bg-scrim {
  position: absolute; inset: 0;
  background: linear-gradient(180deg, rgba(5,5,7,.58), rgba(5,5,7,.74));
}
/* The chat card carries a whole transcript, so it earns a heavier scrim. */
#roleplayCard .scene-bg-scrim { background: linear-gradient(180deg, rgba(5,5,7,.72), rgba(5,5,7,.85)); }
#modeCard > :not(.scene-bg), #roleplayCard > :not(.scene-bg), #liveCard > :not(.scene-bg) { position: relative; z-index: 1; }

/* Shorter laptops: compact the TYPE, never drop the character. A 768px-tall
   screen still has to show five whole people. */
@media (min-width: 851px) and (max-height: 820px) {
  [data-panel="practice"] .scenario-list { gap: 10px; }
  [data-panel="practice"] .scenario-copy { padding: 0 10px; grid-template-columns: 24px 1fr; }
  [data-panel="practice"] .scenario-copy strong { font-size: 15px; }
  [data-panel="practice"] .scenario-copy small { font-size: 12px; }
  [data-panel="practice"] .scenario-levels { margin-top: 6px; }
}
@media (min-width: 851px) and (max-height: 700px) {
  [data-panel="practice"] .scenario-figure { aspect-ratio: 480 / 760; }
  [data-panel="practice"] .scenario-copy small { display: none; }
}

/* ---------------------------------------------------- the mode screen ---- */
[data-panel="practice"] .mode-card {
  display: grid;
  grid-template-columns: minmax(260px, 38%) 1fr;
  gap: 22px;
  align-items: stretch;
}
[data-panel="practice"] .mode-card[hidden] { display: none; }
[data-panel="practice"] .mode-stage {
  position: relative;
  min-height: 380px;
  border-radius: 18px;
  overflow: hidden;
  background:
    radial-gradient(60% 24% at 50% 96%, rgba(128,237,239,.18), rgba(128,237,239,0) 70%),
    linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,.01));
}
[data-panel="practice"] .mode-avatar {
  position: absolute; inset: 0;
  /* THE POSTER IS THE SAME PICTURE THE CARD WAS SHOWING. It is painted from
     --poster the instant the card opens, so the column is never an empty box
     while the model streams in, and stays a real character if WebGL is
     unavailable. The canvas draws over the top once it is ready. */
  background-image: var(--poster, none);
  background-repeat: no-repeat;
  background-position: 50% 100%;
  background-size: contain;
}
[data-panel="practice"] .mode-avatar > canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
/* THE POSTER GOES AWAY THE MOMENT THE MODEL IS ON THE GPU.
   The canvas is transparent, so leaving the still image underneath draws the
   character TWICE - once flat and once live, at slightly different scales,
   which reads as a ghost or a double exposure. The poster exists to cover the
   load, and its job ends when the load does. `data-vrm-source` is set to
   'model' by the stage only after a body has actually mounted, so if the model
   never arrives the picture stays. Same mechanism the live-call surface
   already uses to drop its stand-in glyph. */
[data-panel="practice"] .mode-avatar[data-vrm-source="model"] { background-image: none; }
[data-panel="practice"] .mode-brief { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
[data-panel="practice"] .mode-brief .mode-choices { margin: 0; }
[data-panel="practice"] .mode-disclaimer { margin-top: auto; }

/* ------------------------------------------------------------ phones ----- */
@media (max-width: 850px) {
  /* COMPACT ROWS, NOT TALL CARDS. The desktop composition on a phone would be
     one character per screen and a lot of scrolling to compare five. */
  [data-panel="practice"] .scenario-list { grid-template-columns: 1fr; gap: 8px; }
  [data-panel="practice"] .scenario {
    grid-template-columns: 56px 1fr;
    grid-template-rows: auto;
    align-items: center;
    gap: 0 12px;
    padding: 10px 12px;
  }
  [data-panel="practice"] .scenario-figure {
    width: 56px; height: 56px;
    aspect-ratio: 1;
    border-radius: 14px;
    background: rgba(255,255,255,.05);
    flex: none;
  }
  /* THE ROWS ARE HEIGHT-CONSTRAINED ON PURPOSE, so the five fit a phone without
     the document scrolling - which mobile-state-acceptance asserts. Adding a
     portrait to a row that was previously sized by text alone overflowed it by
     12 to 35px, measured. The portrait is the point of this change, so the COPY
     gives way rather than the character: one line of description instead of
     two. The title is never clamped - it is what the row is FOR. */
  [data-panel="practice"] .scenario small {
    -webkit-line-clamp: 1;
    line-clamp: 1;
  }
  /* THE TITLE CLAMPS TOO, AT TWO LINES. Measured at 360x640, where the list
     gives each row 90px: the longest scenario title runs to three lines and
     took the card 35px past its box. Two lines with an ellipsis is a truncation
     a reader can see and understand; a third line hidden behind overflow:hidden
     is text that silently is not there. */
  [data-panel="practice"] .scenario-copy strong {
    display: -webkit-box;
    -webkit-line-clamp: 2;
    line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
  }
  [data-panel="practice"] .scenario-figure img { object-position: 50% 18%; }
  [data-panel="practice"] .scenario-copy { padding: 0; grid-template-columns: 26px 1fr; }

  /* One column, and NO live stage: phones keep the static portrait and meet the
     live character at the roleplay screen, exactly as before. */
  [data-panel="practice"] .mode-card { grid-template-columns: 1fr; gap: 14px; }
  [data-panel="practice"] .mode-stage { min-height: 132px; }
  [data-panel="practice"] .mode-avatar { background-position: 50% 30%; }
}

/* THE SMALLEST PHONES STILL IN USE, where the list gives each row 90px.
   Measured at 360x640: with a 48px portrait the copy STILL ran 12px past the
   row, so shrinking the character was paying the wrong thing - it made the
   picture worse and did not fix the overflow. The character line goes instead.
   The title and the difficulty pips are what the row is for; the character
   description is repeated in full on the very next screen. Portrait stays at
   the 56px the design asks for. */
@media (max-width: 380px) {
  [data-panel="practice"] .scenario-copy small { display: none; }
  [data-panel="practice"] .scenario-levels { margin-top: 4px; }
}

/* ============================================ the roleplay, on desktop =====
   THE CHARACTER YOU ARE TALKING TO SHOULD BE IN THE ROOM.

   The mode screen presents a full-body character and then, the moment the
   conversation started, that character shrank to a 42px chip in a corner while
   the rest of the card sat empty. The person you are practising a hard
   conversation with became a thumbnail exactly when they started talking.

   THE AVATAR IS POSITIONED, NOT RE-PARENTED, AND THAT IS DELIBERATE.
   `#roleAvatar` lives inside `.roleplay-head`, so a grid column assigned to it
   does nothing - a grandchild cannot be placed in its grandparent's grid.
   Moving it in the markup would work on desktop and would rearrange the phone
   header, where it is a chip beside the title and where 503 mobile assertions
   describe the current arrangement. Taking it out of flow with absolute
   positioning gives it the left column on desktop and leaves the DOM, and
   therefore the phone, exactly as it was.

   The renderer is the SAME one already mounted. Nothing extra is created; the
   existing character is simply given room, so the lip-sync and idle motion
   that were always there become visible at a size where they read.

   DESKTOP ONLY. Below 851px the composer and the last few turns are the whole
   job and a tall character would push them off screen. */
@media (min-width: 851px) {
  [data-panel="practice"] .roleplay {
    position: relative;
    /* The gutter the character stands in. Everything else keeps its own flow. */
    padding-left: calc(34% + 22px);
  }
  [data-panel="practice"] .roleplay .role-avatar {
    position: absolute;
    left: 0;
    top: 0;
    bottom: 0;
    width: 34%;
    height: auto;
    min-height: 0;
    border-radius: 18px;
    font-size: 46px;
    background:
      radial-gradient(60% 22% at 50% 96%, rgba(128,237,239,.16), rgba(128,237,239,0) 70%),
      linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,.01));
  }
  /* The head no longer reserves space for a chip that is no longer in its row. */
  [data-panel="practice"] .roleplay-head { align-items: center; }
}

/* ============================================================================
   SKYBOX - the 360 environments experience (public/experiences/skybox.js)
   ============================================================================
   APPENDED BLOCK, SELF-CONTAINED ON PURPOSE. Every rule below is scoped to a
   .skybox-* class that exists nowhere else in this stylesheet, so nothing here
   can reach an existing component, and nothing above needed editing to make
   room for it. The experience takes .surface, .card, .glass-btn, .micro, the
   .eyebrow ramp and .state-overlay from ptr-core.css as they already are - the
   only things defined here are the parts that genuinely do not exist yet: a
   drag surface, a flat fallback image, a scene rail and a non-blocking busy
   chip.

   NOTHING HERE RENDERS ATTRIBUTION. Both providers are CC0 and the owner ruled
   on 2026-08-27 that no credit line appears in the interface. There is
   deliberately no .skybox-credit rule to reach for.
   ============================================================================ */

.skybox-wrap { display: flex; flex-direction: column; gap: 12px; min-height: 0; }

/* Fullscreen takes the WHOLE experience, not just the picture: the rail, the
   scene name and the exit button all have to come with it, or the only way
   back out is a keyboard the visitor may not have. */
.skybox-wrap:fullscreen {
  background: #050507;
  padding: 16px;
  justify-content: center;
}
.skybox-wrap:-webkit-full-screen {
  background: #050507;
  padding: 16px;
  justify-content: center;
}

/* A DRAG SURFACE IS NOT A TAP TARGET.
   ptr-core.css gives every .surface canvas `touch-action: manipulation`, which
   is right for the game (drop double-tap zoom, keep panning). Here panning IS
   the interaction, so the browser must not claim the gesture at all: with
   anything other than `none`, a one-finger drag scrolls the panel and the
   pointermove stream stops halfway through the swipe. The `> canvas` selector
   outranks the core `.surface canvas` rule on specificity. */
.skybox-surface > canvas {
  touch-action: none;
  cursor: grab;
  outline-offset: -3px;
}
.skybox-surface > canvas.dragging { cursor: grabbing; }
/* With motion control on, the sensor owns the camera and dragging does
   nothing, so the grab cursor would be a lie. */
.skybox-surface > canvas.gyro { cursor: default; }
/* ptr-core sets `display: block` on `.surface > canvas`, which beats the UA's
   `[hidden] { display: none }`. Without this the no-WebGL fallback would paint
   a black canvas straight over the image it fell back to. */
.skybox-surface > canvas[hidden] { display: none; }

/* The honest degraded view when there is no WebGL context: a real photo of the
   real environment, with the rail still switching it. Same posture as
   backdrop.js ("no context, no problem"), never a blank card. */
.skybox-fallback {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  border: 0;
}
.skybox-fallback[hidden] { display: none; }

/* THE SCENE RAIL, PINNED TO THE BOTTOM OF THE STAGE.
   Inside the surface rather than under it, because the phone is the default
   case: at the bottom edge of the picture it is inside thumb reach at any
   stage height, and it cannot be pushed off screen by whatever the panel does
   with the rows below.

   z-index 5 puts it ABOVE .state-overlay (z-index 3) on purpose. When a single
   environment fails to load the error state must not take the switcher with
   it - the visitor's fastest way out of a broken scene is picking another
   one. The overlay's own backdrop-filter still frames it. */
.skybox-rail {
  position: absolute; z-index: 5;
  left: 0; right: 0; bottom: 0;
  display: flex; gap: 8px;
  padding: 26px 12px 10px;
  overflow-x: auto; overflow-y: hidden;
  scroll-snap-type: x proximity;
  overscroll-behavior-x: contain;
  background: linear-gradient(180deg, rgba(5,5,7,0), rgba(5,5,7,.72) 42%, rgba(5,5,7,.86));
  /* A system scrollbar under a row of thumbnails is grey chrome across the
     bottom of a photograph. The rail scrolls by drag, snap and arrow key. */
  scrollbar-width: none;
  -ms-overflow-style: none;
}
.skybox-rail::-webkit-scrollbar { display: none; }

.skybox-thumb {
  flex: 0 0 auto;
  scroll-snap-align: center;
  width: 78px; height: 44px;
  padding: 0; overflow: hidden;
  border: 1px solid rgba(255,255,255,.16);
  border-radius: 10px;
  background: rgba(255,255,255,.04);
  color: rgba(255,255,255,.62);
  font-size: 10px; line-height: 1.2;
  cursor: pointer;
  opacity: .62;
  transition: opacity .2s ease, border-color .2s ease, transform .2s ease;
}
.skybox-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.skybox-thumb:hover { opacity: .9; }
/* The current scene is marked by BRIGHTNESS AND A BORDER, not by colour alone:
   the thumbnails are photographs, so a hue change on one edge is not reliably
   visible against whatever image happens to be under it. */
.skybox-thumb[aria-current="true"] {
  opacity: 1;
  border-color: var(--ptr-cyan);
  box-shadow: 0 0 0 1px rgba(128,237,239,.5), 0 6px 18px rgba(0,0,0,.45);
}

/* The quiet busy state for a scene change that happens while a scene is
   already on screen. It is a chip, not an overlay, because the whole point of
   preloading and crossfading is that the view NEVER blanks between
   environments - covering it with a spinner would throw that away. */
.skybox-busy {
  position: absolute; z-index: 5;
  top: 12px; left: 12px;
  display: flex; align-items: center; gap: 8px;
  padding: 7px 12px 7px 9px;
  border-radius: 99px;
  border: 1px solid rgba(255,255,255,.14);
  background: rgba(5,5,7,.72);
  -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
  color: rgba(255,255,255,.82);
  font-size: 12px;
  pointer-events: none;
}
.skybox-busy[hidden] { display: none; }
/* .spinner is a block-sized rule and this one is a <span>. */
.skybox-busy .spinner { width: 15px; height: 15px; border-width: 2px; display: block; flex: none; }

.skybox-controls { display: flex; gap: 8px; flex-wrap: wrap; }
.skybox-controls .glass-btn { padding: 8px 12px; font-size: 13px; }
.skybox-controls .glass-btn[aria-pressed="true"] {
  border-color: rgba(128,237,239,.55);
  color: #fff;
}

@media (prefers-reduced-motion: reduce) {
  /* The rail still snaps, it just stops animating on the way. */
  .skybox-rail { scroll-behavior: auto; }
  .skybox-thumb { transition: none; }
}

@media (max-width: 850px) {
  /* Smaller chips and a tighter strip: the phone stage is short, and the rail
     sits on top of the picture, so every pixel it gives back is picture. */
  .skybox-rail { gap: 6px; padding: 20px 10px 8px; }
  .skybox-thumb { width: 62px; height: 35px; border-radius: 9px; }
  .skybox-busy { top: 8px; left: 8px; font-size: 11px; padding: 6px 10px 6px 8px; }
  .skybox-controls .glass-btn { padding: 7px 10px; font-size: 12px; }
}
