:root {
  --bg: #0a0c13;
  --panel: #121622;
  --panel-2: #171c2b;
  --line: #242c40;
  --text: #e7ebf5;
  --muted: #8d97b0;
  --accent: #7aa2ff;
  --ok: #56d6a8;
  --warn: #eeb65e;
  --violet: #a88cff;
  --off: #64708c;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);
  font: 15px/1.55 ui-sans-serif, -apple-system, "Segoe UI", Roboto, Inter, system-ui, sans-serif; }

.stage { display: grid; grid-template-columns: minmax(300px, 460px) minmax(340px, 1fr);
  gap: 20px; max-width: 1180px; margin: 0 auto; padding: 22px 16px; align-content: start; }
@media (max-width: 880px) { .stage { grid-template-columns: 1fr; } }

/* ---------- face ---------- */
.face-panel { background: var(--panel); border: 1px solid var(--line); border-radius: 18px;
  padding: 14px; display: flex; flex-direction: column; gap: 12px; }
.face-wrap { position: relative; border-radius: 14px; overflow: hidden; background: #0b0d15; }
#face { display: block; width: 100%; aspect-ratio: 640 / 796; max-width: 100%; }

.status { position: absolute; left: 12px; top: 12px; font-size: 11px; font-weight: 600;
  letter-spacing: .07em; text-transform: uppercase; padding: 5px 11px; border-radius: 999px;
  background: rgba(10,12,19,.72); border: 1px solid rgba(231,235,245,.2); color: #c3cadd;
  backdrop-filter: blur(6px); }
.status[data-state="listening"] { color: var(--ok); border-color: rgba(86,214,168,.5); }
.status[data-state="thinking"]  { color: var(--warn); border-color: rgba(238,182,94,.5); }
.status[data-state="speaking"]  { color: var(--violet); border-color: rgba(168,140,255,.5); }

.caption { position: absolute; left: 0; right: 0; bottom: 0; padding: 14px 16px 16px;
  background: linear-gradient(to top, rgba(6,8,14,.94) 38%, rgba(6,8,14,0));
  font-size: 15px; line-height: 1.45; min-height: 0; }
.caption:empty { display: none; }

.face-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; }
.badges { display: flex; flex-wrap: wrap; gap: 6px; }
.badge { font-size: 11px; padding: 4px 9px; border-radius: 999px; border: 1px solid var(--line);
  color: var(--muted); background: #0f1320; max-width: 190px; overflow: hidden;
  text-overflow: ellipsis; white-space: nowrap; }
.badge--ok { color: var(--ok); border-color: rgba(86,214,168,.35); }
.badge--off { color: var(--off); }

/* ---------- chat / script ---------- */
.chat-panel { background: var(--panel); border: 1px solid var(--line); border-radius: 18px;
  padding: 18px; display: flex; flex-direction: column; gap: 14px; min-height: 480px; }
.chat-head h1 { margin: 0; font-size: 19px; letter-spacing: -.01em; }
.chat-head .sub { margin: 4px 0 0; color: var(--muted); font-size: 13px; }

.modes, .seg { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.seg { gap: 0; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
.seg-btn { font: inherit; font-size: 13px; font-weight: 600; padding: 8px 13px; cursor: pointer;
  border: 1px solid var(--line); background: var(--panel-2); color: var(--muted); border-radius: 10px; }
.seg .seg-btn { border: 0; border-radius: 0; }
.seg-btn.is-on { background: #1e2a4d; color: var(--text); border-color: #2c3d6b; }
.seg-btn:hover:not(.is-on) { color: var(--text); }

.run-head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }
.run-title { font-weight: 600; font-size: 14px; }
.run-count { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }

.beats { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px;
  flex: 1; max-height: 40vh; overflow-y: auto; }
.beat { border-left: 2px solid var(--line); padding: 6px 0 6px 11px; opacity: .45; }
.beat.is-done { opacity: .55; border-left-color: var(--off); }
.beat.is-now { opacity: 1; border-left-color: var(--violet); }
.beat q { display: block; color: var(--muted); font-size: 13px; font-style: italic; quotes: none; }
.beat p { margin: 3px 0 0; font-size: 13.5px; }
.beat .flag { display: inline-block; margin-top: 4px; font-size: 11px; color: var(--warn); }

.transcript { flex: 1; min-height: 180px; max-height: 40vh; overflow-y: auto; background: #0d1120;
  border: 1px solid var(--line); border-radius: 12px; padding: 12px; display: flex;
  flex-direction: column; gap: 10px; }
.line { display: grid; grid-template-columns: 62px 1fr; gap: 10px; align-items: start; }
.line .who { color: var(--muted); font-size: 12px; padding-top: 2px; }
.line--you .who { color: var(--accent); }
.line--companion .who { color: var(--violet); }
.line--system { grid-template-columns: 1fr; color: var(--warn); font-size: 13px; }
.line[data-partial="1"] .what { opacity: .55; font-style: italic; }
.what { white-space: pre-wrap; word-break: break-word; }

/* ---------- controls ---------- */
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.row--sub { justify-content: space-between; margin-top: auto; }
.input { flex: 1 1 160px; min-width: 0; padding: 10px 12px; border-radius: 10px;
  border: 1px solid var(--line); background: #0d1120; color: var(--text); font: inherit; }
.btn { border: 1px solid var(--line); background: var(--panel-2); color: var(--text);
  padding: 10px 14px; border-radius: 10px; font: inherit; font-weight: 600; cursor: pointer; }
.btn:hover:not(:disabled) { background: #1d2437; }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn--go { background: #1e2a4d; border-color: #2c3d6b; }
.btn--ghost { background: transparent; }
.btn--mic { display: inline-flex; align-items: center; gap: 8px; }
.btn--mic .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--off); }
.btn--mic.is-live .dot { background: var(--ok); animation: pulse 1.3s infinite; }
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: .3 } }
@media (prefers-reduced-motion: reduce) { .btn--mic.is-live .dot { animation: none } }
kbd { font: inherit; font-size: 11px; padding: 1px 5px; border-radius: 4px;
  border: 1px solid var(--line); background: #0d1120; color: var(--muted); }
.rate { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; }
.rate input { width: 110px; accent-color: var(--accent); }
.rate output { min-width: 42px; text-align: right; font-variant-numeric: tabular-nums; }
.input:focus-visible, .btn:focus-visible, .seg-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.keys { display: none; }

/* ---------- presenter view: everything else gets out of the way ---------- */
body.presenting { background: #05070c; overflow: hidden; }
body.presenting .stage { display: block; max-width: none; margin: 0; padding: 0; height: 100%; }
body.presenting .chat-panel { display: none; }
body.presenting .face-panel { border: 0; border-radius: 0; padding: 0; height: 100%;
  background: #05070c; gap: 0; }
body.presenting .face-controls { display: none; }
/* The portrait keeps its own aspect and the caption sits BELOW it, so nothing
   ever covers her mouth, which is the one thing the room came to watch. */
body.presenting .face-wrap { border-radius: 0; height: 100%; background: #05070c;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: clamp(12px, 2.4vh, 30px); padding: clamp(10px, 2vh, 26px) 0; }
body.presenting #face { flex: 0 0 auto; height: min(56vh, 70vw); width: auto;
  max-width: 92vw; aspect-ratio: 640 / 796; }
body.presenting .status { top: clamp(10px, 2vh, 24px); left: 50%; transform: translateX(-50%); }
body.presenting .caption { position: static; flex: 0 1 auto; background: none; padding: 0;
  font-size: clamp(20px, 2.1vw, 34px); line-height: 1.32; text-align: center;
  text-wrap: balance; width: min(92vw, 46ch); max-height: 32vh; overflow: hidden; }
body.presenting .keys { display: flex; gap: 16px; position: fixed; right: 16px; bottom: 14px;
  font-size: 11px; color: #3c4560; }
body.presenting .keys kbd { border-color: #222a3d; background: #0a0f1a; color: #5a668a; }

/* A landscape phone or a short projector: shrink the face, keep the words. */
@media (max-height: 620px) {
  body.presenting #face { height: min(48vh, 52vw); }
  body.presenting .caption { font-size: clamp(16px, 1.9vw, 24px); width: min(94vw, 52ch); }
}

/* --------------------------------------------------------------------------
   The 3D face. It sits in the same slot as the canvas and only one of the two
   is ever visible, so every layout rule that framed the canvas keeps working.
   The background is ours, not the renderer's: the WebGL canvas is transparent,
   which means a soft studio wash costs nothing to draw.
   -------------------------------------------------------------------------- */
.face-3d {
  position: relative;
  width: 100%;
  aspect-ratio: 640 / 796;
  border-radius: inherit;
  overflow: hidden;
  background:
    radial-gradient(120% 90% at 50% 18%, #3c4658 0%, #222836 55%, #171b24 100%);
}
.face-3d[hidden] { display: none; }

/* The bright look (`?face=human&look=bright`) sits on plain white, or on the
   one solid colour &bg=<hex> names. Rolando, 24 Sept: "the background should
   just be white or a solid color". A painted room replaced it for an hour
   ("meta muse avatar is imersed in the background not just flat") and went
   when he saw it: "i didnt like the back ground". Redefining the tokens moves
   every surface that paints the room, including the landscape stand behind
   her, and the face-3d rule below is the only one that did not already read
   them.

   `room-light` is that light room, and the Simli stand has it too (main.js):
   25 Sept, "make the background white to match the avatar background". There
   her backdrop's own shade, read off her picture, replaces the white on the
   body once she arrives. The live face's frame is inset from the top and
   stops above the controls, so unlike the rig it does not cover the screen,
   and the stand's wrapper, which does, is painted here as well or the bands
   above and below her stay the dark page. Not the body: `body.presenting`
   repaints it later in this file on every stand, so on the stand it did
   nothing, and on the operator page it turned the page white round panels
   drawn for the dark one. */
body.room-light {
  --room-tint: #ffffff;
  --room-bg: none;
}
body.room-light.is-stand .face-wrap {
  background-color: var(--room-tint);
  background-image: var(--room-bg);
}
body.look-bright .face-wrap .face-3d {
  background-color: var(--room-tint);
  background-image: var(--room-bg);
  /* The presenting rule's inset shadow seats her in a dark room. In a light
     one it reads as a dirty frame round the glass. */
  box-shadow: none;
}
/* Everything below was coloured for a dark room and vanishes in a light one. */
/* The mark is knocked out to white letters. Inverting and turning the hue back
   round gives dark letters and keeps the red red. Not on a dark &bg=, where the
   mark's own white letters are the ones that read (main.js sets room-dark). */
body.room-light:not(.room-dark) .brandmark { filter: invert(1) hue-rotate(180deg) saturate(1.8) brightness(0.85) drop-shadow(0 1px 2px rgba(255, 255, 255, 0.7)); }
body.room-light .kiosk-type-toggle { color: #3b4663; }
body.room-light .kiosk-type-toggle:active { color: #1c2336; }
body.room-light .kiosk-hint { color: #8a4b00; }
body.room-light .kiosk-voice { color: #4a5570; }
/* In portrait these lines sit over her black top; in landscape, beside her
   on white. Dark text on a small frosted pill reads on both, where a
   colour picked for either one vanishes on the other. */
body.room-light .kiosk-type-toggle:not(:empty),
body.room-light .kiosk-hint:not(:empty),
body.room-light .kiosk-voice:not(:empty),
body.room-light.is-stand .kiosk-heard:not(:empty) {
  background: rgba(255, 255, 255, 0.82);
  border-radius: 999px;
  padding-inline: 12px;
  box-decoration-break: clone;
  -webkit-box-decoration-break: clone;
}
/* Two classes on the body, so it outranks the stand's own light-on-dark rule
   further down without an !important. */
body.room-light.is-stand .kiosk-heard { color: #1c2336; }
/* The stand-by's word and breath were light on a dark room. Not on a dark
   &bg=, where the light ones are the ones that read. */
body.room-light:not(.room-dark) .face-live .live-standby__word { color: rgba(28, 35, 54, 0.55); text-shadow: none; }
body.room-light:not(.room-dark) .face-live .live-standby__glow { background: radial-gradient(circle, rgba(120, 104, 88, 0.16), transparent 68%); }
#face[hidden] { display: none; }
.face-3d canvas { display: block; width: 100% !important; height: 100% !important; }

body.presenting .face-3d {
  flex: 0 0 auto;
  height: min(56vh, 70vw);
  width: auto;
  max-width: 92vw;
  aspect-ratio: 640 / 796;
}

/* ==========================================================================
   Booth mode: a tablet in a stranger's hands, mirrored onto a projector.

   Two readers at once. One is holding the thing at arm's length; the other is
   across a hall looking at a wall. Type sized in vmin lands somewhere both can
   read, because it scales with the screen rather than with a guess about how
   far away the screen is.
   ========================================================================== */

/* Nothing here should feel like a web page: no text selection on a drag, no
   grey flash under a fingertip, no rubber-band scroll behind the demo. */
body.booth,
body.booth .face-wrap,
body.booth .booth-bar button {
  -webkit-user-select: none;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
  -webkit-touch-callout: none;
}
body.booth { overscroll-behavior: none; touch-action: manipulation; }
body.booth .face-wrap { cursor: pointer; touch-action: pan-y; }
body.booth .keys { display: none; }

/* The bar sits over the bottom of the screen and is the only thing to press.
   56px is the smallest target a standing adult hits reliably; these are more. */
.booth-bar {
  position: fixed;
  left: 50%;
  bottom: max(18px, env(safe-area-inset-bottom, 0px));
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 8px;
  border-radius: 999px;
  background: rgba(12, 16, 26, 0.72);
  border: 1px solid #1d2435;
  backdrop-filter: blur(10px);
  z-index: 40;
}
.booth-bar[hidden] { display: none; }
.booth-bar button {
  min-width: 72px;
  min-height: 60px;
  padding: 0 22px;
  border: 0;
  border-radius: 999px;
  background: #182033;
  color: #cdd6ea;
  font: inherit;
  font-size: 20px;
  line-height: 1;
}
.booth-bar button[data-act="back"],
.booth-bar button[data-act="next"] { font-size: 34px; padding-bottom: 4px; }
.booth-bar button:active { background: #24314f; }

/* Portrait: face on top, words beneath. The tablet's natural way up. */
body.presenting.is-portrait .face-wrap { flex-direction: column; }
body.presenting.is-portrait #face,
body.presenting.is-portrait .face-3d { height: min(52vh, 88vw); }
body.presenting.is-portrait .caption {
  font-size: clamp(19px, 3.4vmin, 40px);
  width: min(92vw, 30ch);
  max-height: 34vh;
}

/* Landscape: the face would eat the height, so put the words beside it. */
body.presenting.is-landscape .face-wrap {
  flex-direction: row;
  gap: clamp(18px, 4vw, 60px);
  padding: clamp(10px, 3vh, 34px) clamp(16px, 4vw, 64px);
}
body.presenting.is-landscape #face,
body.presenting.is-landscape .face-3d {
  height: min(82vh, 46vw);
  width: auto;
  max-width: 46vw;
}
body.presenting.is-landscape .caption {
  font-size: clamp(18px, 2.6vmin, 38px);
  width: min(46vw, 34ch);
  max-height: 76vh;
  text-align: left;
}

/* Leave room for the control bar so it never sits on top of the words. */
body.booth.presenting .caption { padding-bottom: clamp(56px, 11vh, 96px); }

/* A tablet held in portrait is tall and narrow; the face should not shrink to
   fit a caption that could simply wrap more. */
@media (orientation: portrait) and (min-width: 700px) {
  body.presenting.is-portrait #face,
  body.presenting.is-portrait .face-3d { height: min(56vh, 76vw); }
}

/* In presenter view the face should not read as a photograph pinned to a wall.
   Drop the panel's own wash, put a soft one on the whole screen, and let her
   sit in it with no visible edge. Matters most on a projector, where a hard
   rectangle of slightly-lighter grey is the first thing the room notices. */
body.presenting {
  background:
    radial-gradient(120% 80% at 50% 26%, #1b2231 0%, #0b0e16 58%, #05070c 100%);
}
body.presenting .face-panel,
body.presenting .face-wrap { background: transparent; }
/* The 3D face keeps its backdrop. Making it transparent was a mistake: her
   clothes are dark, the stage is dark, and she came out looking like a head
   floating in a void. A person needs something to stand in front of. */
body.presenting .face-3d {
  background:
    radial-gradient(75% 55% at 50% 96%, rgba(0,0,0,.45) 0%, rgba(0,0,0,0) 60%),
    radial-gradient(110% 80% at 50% 22%, #4a5468 0%, #2b3244 52%, #1a1f2c 100%);
  box-shadow: inset 0 0 120px rgba(0,0,0,.55);
}

/* And give her more of the screen. A booth visitor is looking at a face, not
   at a layout. */
body.presenting.is-landscape #face,
body.presenting.is-landscape .face-3d { height: min(88vh, 56vw); max-width: 56vw; }
body.presenting.is-landscape .caption { width: min(40vw, 32ch); }

/* ==========================================================================
   Kiosk mode. She waits to be asked, and the one thing to press is enormous,
   because it is pressed by strangers who are standing up, holding a coffee,
   and have never seen this before.
   ========================================================================== */
.kiosk-ui {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
  padding: 18px 16px max(18px, env(safe-area-inset-bottom, 0px));
  background: linear-gradient(to top, rgba(6,8,14,.95) 55%, rgba(6,8,14,0));
  z-index: 30;
}
.kiosk-ui[hidden] { display: none; }

.kiosk-scope {
  margin: 0;
  max-width: 44ch;
  text-align: center;
  color: var(--muted);
  font-size: clamp(12px, 1.6vmin, 17px);
  line-height: 1.4;
  text-wrap: balance;
}

.kiosk-talk {
  font: inherit;
  font-weight: 600;
  font-size: clamp(17px, 2.4vmin, 26px);
  min-height: 68px;
  padding: 0 clamp(26px, 5vw, 56px);
  border: 1px solid #2c3d6b;
  border-radius: 999px;
  background: #1e2a4d;
  color: var(--text);
  cursor: pointer;
}
.kiosk-talk:active { background: #27356090; }
.kiosk-talk.is-live {
  background: #16402f;
  border-color: rgba(86,214,168,.55);
  color: var(--ok);
  animation: kiosk-pulse 1.4s ease-in-out infinite;
}
@keyframes kiosk-pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(86,214,168,.35) } 50% { box-shadow: 0 0 0 14px rgba(86,214,168,0) } }
@media (prefers-reduced-motion: reduce) { .kiosk-talk.is-live { animation: none } }
/* The few seconds between the question ending and the answer starting. The
   button has to look like it is doing something and like it cannot be pressed,
   because it can't: a tap here used to open a second recording and lose one of
   the two questions. Dimmed rather than greyed out, so it still reads from the
   back of a stand. */
.kiosk-talk.is-working {
  background: #23305a;
  border-color: #3a4c85;
  color: rgba(233,238,248,.72);
  cursor: progress;
  animation: kiosk-working 1.6s ease-in-out infinite;
}
.kiosk-talk:disabled { cursor: progress; }
@keyframes kiosk-working { 0%,100% { opacity: .72 } 50% { opacity: 1 } }
@media (prefers-reduced-motion: reduce) { .kiosk-talk.is-working { animation: none } }

/* ---------------------------------------------------------------------------
   THE TYPED ROUTE.

   Offered beside the Talk button all day, not only when something breaks. Half
   the machines a stranger walks up with have a muted microphone, a blocked
   one, or a browser that never asked, and the booth used to answer all three
   with a dead end.

   Quiet by design: this is the second choice at a stand, and it should read as
   an option rather than compete with the one enormous button above it.
   --------------------------------------------------------------------------- */
.kiosk-type-toggle {
  border: 0;
  background: none;
  color: #9fb0d0;
  font: inherit;
  font-size: clamp(13px, 1.7vmin, 17px);
  text-decoration: underline;
  text-underline-offset: 3px;
  padding: 6px 10px;
  border-radius: 8px;
  cursor: pointer;
}
.kiosk-type-toggle:active { color: #e8ecf6; }

.kiosk-type {
  display: flex;
  gap: 8px;
  align-items: center;
  width: 100%;
  max-width: 34rem;
  min-width: 0;
}
.kiosk-type[hidden] { display: none; }
.kiosk-type__input {
  flex: 1 1 auto;
  min-width: 0;
  /* 16px or larger, always. Anything under it and iOS Safari zooms the whole
     page the moment the field takes focus, which on a booth leaves her
     half off the screen and the visitor with no way back. */
  font: inherit;
  font-size: max(16px, 1rem);
  padding: 12px 14px;
  border-radius: 12px;
  border: 1px solid #2b3450;
  background: rgba(10, 13, 21, 0.86);
  color: #e8ecf6;
}
.kiosk-type__input::placeholder { color: #7d8aa6; }
.kiosk-type__send {
  flex: 0 0 auto;
  border: 0;
  border-radius: 12px;
  background: #2c60c4;
  color: #fff;
  font: inherit;
  font-size: max(16px, 1rem);
  padding: 12px 20px;
  cursor: pointer;
}
.kiosk-type__send:active { background: #23509f; }

/* On the stand everything is read at arm's length and pressed by somebody
   standing up, so both targets grow with the glass. */
body.is-stand .kiosk-type-toggle { font-size: clamp(15px, 2.1vmin, 21px); padding: 10px 14px; }
body.is-stand .kiosk-type__input,
body.is-stand .kiosk-type__send {
  font-size: max(16px, clamp(16px, 2.2vmin, 22px));
  padding: 16px 20px;
  min-height: 56px;
}

/* The line she could not get out, offered back. Hidden all day; when it is up
   it is the only thing to press besides the button above it. */
.kiosk-replay {
  border: 1px solid #3a4a60;
  border-radius: 999px;
  background: #1b2537;
  color: #dfe7f6;
  font: inherit;
  font-size: clamp(14px, 1.9vmin, 18px);
  padding: 10px 20px;
  cursor: pointer;
}
.kiosk-replay[hidden] { display: none; }
.kiosk-replay:active { background: #26334c; }
body.is-stand .kiosk-replay { font-size: clamp(16px, 2.2vmin, 22px); padding: 14px 26px; min-height: 52px; }

.kiosk-hint { margin: 0; min-height: 1.2em; color: var(--warn); font-size: clamp(12px, 1.6vmin, 16px); }
.kiosk-hint:empty { display: none; }

/* The question, printed back in the visitor's own words. Quieter than her
   caption on purpose: it is a receipt, not the answer. */
.kiosk-heard {
  margin: 0;
  max-width: 40ch;
  text-align: center;
  color: var(--muted);
  font-style: italic;
  font-size: clamp(13px, 1.8vmin, 18px);
  line-height: 1.35;
  text-wrap: balance;
}
.kiosk-heard:empty { display: none; }

/* Only ever filled in when she is NOT on her own voice, so its presence is
   the message. Small, dim, and out of the way of everything a visitor reads. */
.kiosk-voice {
  margin: 0;
  color: var(--muted);
  opacity: .65;
  font-size: clamp(11px, 1.3vmin, 13px);
  letter-spacing: .02em;
}
.kiosk-voice:empty { display: none; }

/* In presenter view the caption has to get out of the way of the controls. */
body.is-kiosk.presenting .caption { padding-bottom: clamp(140px, 22vh, 210px); }
body.is-kiosk.presenting .booth-bar { display: none; }
/* Tapping her face advances the scripted run. In kiosk mode there is no run to
   advance, and a stray tap must not do anything at all. */
body.is-kiosk .face-wrap { cursor: default; }

/* Her, filmed. Same slot as the other three faces; only one is ever visible.
   The live face shares every one of these rules rather than getting a parallel
   block, because the two are the same thing on screen — a video filling the
   face slot — and this file has already been bitten twice by a second block
   that looked equivalent and lost a specificity race. Adding a selector cannot
   lose one. */
.face-film,
.face-live {
  position: relative;
  width: 100%;
  aspect-ratio: 640 / 796;
  border-radius: inherit;
  overflow: hidden;
  background: #11141c;
}
/* EXCEPT HERS, WHICH IS NEVER BLACK. Rolando, 2026-09-20: "the frozen/cropped
   fallback is visually identifiable because a solid black rectangle appears
   behind the avatar ... treat any visible black media rectangle as a failure."
   The room below is the only background this element ever has; the shorthand
   above would otherwise be what shows through in the instant before the first
   paint, and in every letterbox `contain` leaves around her. The room's own
   gradient goes on top of this further down; the tint is repeated here so
   there is no point in the cascade at which this element is black. */
.face-live { background: var(--room-tint); }
.face-film[hidden], .face-live[hidden] { display: none; }

/* THE ROOM BEHIND HER.
 *
 * LiveAvatar's studio avatars render against flat green so the background is
 * the caller's to choose, and live.js keys the green out on the GPU, so what
 * shows through here is whatever this paints. A photograph would be the
 * obvious thing and is deliberately not used: a stock office at the wrong
 * angle, lit differently from the avatar, reads as a cut-out immediately.
 *
 * So this is a room rather than a picture of one. A soft warm key high on the
 * left where a window would be, a cool fill opposite, and a vignette to seat
 * her in it. It carries the brandmark's own warmth, holds at any size because
 * nothing in it has edges, and costs no bytes. Swap in a real photograph by
 * replacing the background-image below with a url() and nothing else changes.
 */
/* Written once, as a token, because it is now painted in three places: her own
   frame, the stand-by that holds that frame before she arrives, and in
   landscape the whole screen behind her. Three copies of a four-layer gradient
   is three chances for one of them to drift. Swap in a real photograph by
   replacing --room-bg and nothing else changes, exactly as before. */
:root {
  --room-tint: #2b3040;
  --room-bg:
    radial-gradient(120% 90% at 22% 8%, rgba(255, 236, 214, 0.34), transparent 58%),
    radial-gradient(100% 80% at 88% 24%, rgba(120, 150, 190, 0.26), transparent 62%),
    radial-gradient(140% 120% at 50% 118%, rgba(0, 0, 0, 0.55), transparent 70%),
    linear-gradient(168deg, #394054 0%, #2a2f3e 52%, #1d212c 100%);
}
.face-live {
  background-color: var(--room-tint);
  background-image: var(--room-bg);
}
.face-film video,
.face-live video,
.face-live canvas { display: block; width: 100%; height: 100%; object-fit: cover; }
body.presenting .face-film,
body.presenting .face-live {
  flex: 0 0 auto; height: min(56vh, 70vw); width: auto; max-width: 92vw; aspect-ratio: 640 / 796;
}
body.presenting.is-landscape .face-film,
body.presenting.is-landscape .face-live { height: min(88vh, 56vw); max-width: 56vw; }
body.presenting.is-portrait .face-film,
body.presenting.is-portrait .face-live { height: min(52vh, 88vw); }

/* ---------------------------------------------------------------------------
   Stand mode: the tablet on the stand, with nothing on it but her.

   Reached with ?kiosk=1. `presenting` already strips the operator panel and
   fills the screen, so this adds only what a booth needs on top: one tap to
   begin, a face that fills the display, and a button big enough to hit while
   standing up and talking to somebody.
   --------------------------------------------------------------------------- */

.stand-veil {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 14px;
  border: 0;
  background: #05070c;
  color: #e8edf7;
  font: inherit;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.stand-veil[hidden] { display: none; }
.stand-veil__title {
  font-size: clamp(28px, 6vw, 56px);
  font-weight: 600;
  letter-spacing: -0.01em;
}
.stand-veil__sub {
  font-size: clamp(15px, 2.4vw, 22px);
  color: #8e9bb8;
  max-width: 26ch;
  text-align: center;
  line-height: 1.5;
}

/* Nothing but her: no mode buttons, no operator chrome, no scrollbars. */
body.is-stand { overflow: hidden; touch-action: manipulation; }
/* Production. Nothing on the glass but her, the caption, the mark and the one
   button. The face switcher and the mode tabs are operator tooling and belong
   on ?stage=1, not in front of a visitor. */
body.is-stand .chat-panel,
body.is-stand .modes,
body.is-stand .keys,
body.is-stand .beats,
body.is-stand .face-controls,
body.is-stand .badges,
body.is-stand .mode-line,
body.is-stand .status { display: none; }

/* The button is the fallback for every way hearing can fail, so it is large
   and it is always reachable with a thumb. */
body.is-stand .kiosk-talk {
  min-height: 72px;
  font-size: clamp(18px, 2.6vw, 26px);
  padding: 0 32px;
  border-radius: 999px;
}
body.is-stand .kiosk-talk.is-live {
  /* A visitor has to be able to tell she is hearing them from arm's length. */
  animation: stand-listening 1.6s ease-in-out infinite;
}
@keyframes stand-listening {
  0%, 100% { box-shadow: 0 0 0 0 rgba(96, 165, 250, 0.55); }
  50% { box-shadow: 0 0 0 18px rgba(96, 165, 250, 0); }
}
@media (prefers-reduced-motion: reduce) {
  body.is-stand .kiosk-talk.is-live { animation: none; outline: 3px solid #60a5fa; }
}

body.is-stand .kiosk-hint {
  font-size: clamp(14px, 2vw, 19px);
  max-width: 34ch;
  text-align: center;
}

/* Read at arm's length, over a hall. Brighter than the operator page's
   version because on the stand this line is doing real work. */
body.is-stand .kiosk-heard {
  font-size: clamp(15px, 2.1vw, 21px);
  max-width: 34ch;
  color: var(--ink, #e8ecf6);
  opacity: .92;
}
/* The scope paragraph is operator-facing small print, and on a short landscape
   tablet it sits exactly where the caption lands, so the two print on top of
   each other. On the stand she says what she does out loud in her opening
   line, and the hint under the button says how to ask, so the paragraph is
   duplicating both. It stays on the ordinary kiosk page. */
body.is-stand .kiosk-scope { display: none; }

/* Her, edge to edge, on the stand.

   `presenting` sizes her to about half the height because a stage audience is
   also reading the caption. A booth visitor is looking at a face, so in stand
   mode she fills the glass and everything else floats on top of her.

   The filmed clips come back from HeyGen in landscape whatever aspect ratio we
   ask for, and the tablet is portrait, so the crop is the whole game: anchored
   above centre, because that is where a seated person's face is and the bottom
   of the frame is desk. */
/* The orientation rules above are body.presenting.is-portrait .face-film and
   friends, which outrank a plain body.is-stand .face-film and would keep her
   letterboxed. Matching through .face-wrap ties the specificity and being
   later in the file wins it. */
body.is-stand .face-wrap .face-film,
body.is-stand .face-wrap .face-live,
body.is-stand .face-wrap .face-3d,
body.is-stand .face-wrap #face:not([hidden]) {
  position: fixed;
  inset: 0;
  width: 100vw;
  height: 100dvh;
  max-width: none;
  max-height: none;
  aspect-ratio: auto;
  border-radius: 0;
  z-index: 0;
}
body.is-stand .face-wrap .face-film video,
body.is-stand .face-wrap .face-live video,
body.is-stand .face-wrap .face-live canvas {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 50% 28%;
}

/* Everything else floats over her rather than pushing her into a letterbox. */
/* body.presenting.is-landscape .face-wrap sets a padding for the stage layout
   and outranks a plain body.is-stand rule, so this padding was discarded in
   landscape. Nothing visible moved, because everything inside is fixed, which
   is exactly why it went unnoticed. Tie the specificity. */
body.is-stand.is-kiosk .face-wrap,
body.is-stand.presenting .face-wrap { display: block; height: 100dvh; padding: 0; }
body.is-stand .kiosk-ui {
  position: fixed;
  left: 0;
  right: 0;
  bottom: max(20px, env(safe-area-inset-bottom));
  z-index: 20;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  /* Horizontal only, so this cannot move `--kiosk-h` and cannot move the
     caption or the handover square that both measure off it. A notched phone
     on its side puts the cut-out over the left or right edge, and the one
     button a visitor has to hit was sitting under it. */
  padding: 0 max(16px, env(safe-area-inset-right)) 0 max(16px, env(safe-area-inset-left));
  background: none;
}
body.is-stand .caption {
  position: fixed;
  left: 50%;
  transform: translateX(-50%);
  /* Clear of the controls, measured rather than guessed. `--kiosk-h` is
     published by kiosk.js as the distance from the BOTTOM OF THE SCREEN to the
     top of the controls, so adding a gap to it lands the caption just above
     them whatever they currently contain. Published as the stack's own height
     first, which read six pixels short because the stack does not sit flush
     with the screen edge. The fallback is the old fixed number, for the moment
     before the first measurement. See the note in kiosk.js. */
  bottom: calc(var(--kiosk-h, 150px) + clamp(12px, 2dvh, 26px));
  z-index: 15;
  width: min(92vw, 40ch);
  text-align: center;
  /* Readable over whatever is behind her: a window, a bright office wall. */
  background: rgba(5, 7, 12, 0.72);
  border-radius: 14px;
  padding: 12px 18px;
  backdrop-filter: blur(6px);
  /* The box is anchored at the bottom and grows upward, so a long answer
     climbs straight over her mouth, which is the one thing a visitor is
     watching. Cap it and let the overflow scroll: she is saying the words out
     loud anyway, and the caption is the aid, not the answer. */
  max-height: 32dvh;
  overflow-y: auto;
  overscroll-behavior: contain;
}
/* body.is-kiosk.presenting .caption and body.booth.presenting .caption both add
   a padding-bottom of up to 210px to hold the caption clear of the booth
   controls, and both outrank a plain body.is-stand .caption. In stand mode the
   caption is fixed and already sits above those controls, so that padding is
   just an empty slab of box hanging under her chin. Two classes to tie the
   specificity, and later in the file to win it. */
body.is-stand.is-kiosk .caption,
body.is-stand.presenting .caption {
  padding: 12px 18px;
  /* Landscape presenting mode left-aligns the caption for a stage, and outranks
     the stand rule the same way the padding did. On the stand the box is a
     centred card under a centred face, so it centres. */
  text-align: center;
  /* Same collision, three more declarations. body.presenting.is-portrait
     .caption and its landscape twin also set font-size, width and max-height,
     and all three beat the plain stand rule, so the stand block above was
     writing values that never rendered. Measured in a browser at 800x1280 and
     1280x800 before and after.

     max-height is the one that matters: landscape presenting caps the caption
     at 76vh, so a long answer covered three quarters of the tablet and her
     face with it, which is the exact thing the stand rule caps at 32dvh to
     prevent. dvh rather than vh, because a tablet browser's toolbar moves. */
  font-size: clamp(18px, 2.6vw, 30px);
  width: min(92vw, 40ch);
  max-height: 32dvh;
}

body.is-stand .caption:empty { display: none; }
/* Her words are off the glass at the stand; main.js says when they come back
   (keepCaption). Visibility rather than display, so the box keeps its place
   and its text: nothing measured off it moves, and the checks that read what
   she said still can. */
body.is-stand:not(.captions-on) .caption:not(.is-kept) { visibility: hidden; }


/* ---------- the mark ---------- */

/* Top right, because the status pill owns the top left and the caption and the
   ask button own the bottom. She is centred horizontally and cropped to sit
   above centre, so both top corners are background in either orientation.

   The badge is white and the artwork is black and red on white, so the two
   meet with no seam and the wordmark keeps its own colours. Over the dark page
   that white block is the thing that makes it readable at all; a knocked-out
   white version would read as a smudge on a projector. */
.brandmark {
  position: absolute;
  top: 14px;
  right: 16px;
  z-index: 25;
  pointer-events: none;
  line-height: 0;
  /* It has to stay legible over a bright frame as well as a dark one, and a
     box around it would put us back where we started. A shadow does the same
     job and stays out of the way. */
  filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.75));
  opacity: 0.92;
}
.brandmark img {
  display: block;
  /* Small. It is a mark on her picture, not a header: the video is the thing
     filling the space and this sits in the corner of it. The height follows
     the artwork's own 1096x208. */
  width: clamp(96px, 11vw, 160px);
  height: auto;
}

/* Presenting on a stage: same corner, because the back row is reading it off a
   projector rather than holding the tablet. */
body.presenting .brandmark { position: fixed; top: 16px; right: 16px; }

/* On the stand she is fixed and full bleed, so the mark has to leave the
   document flow with her or it pins itself to a box that is no longer there.

   Stand mode is always presenting too, and the rule above ties this one's
   specificity, so it has to come after it or the safe-area insets never apply
   and the mark sits under the bezel on a tablet with rounded corners. */
body.is-stand .brandmark {
  position: fixed;
  top: max(14px, env(safe-area-inset-top));
  right: max(14px, env(safe-area-inset-right));
}
body.is-stand .brandmark img { width: clamp(110px, 15vw, 190px); }
body.is-stand.is-landscape .brandmark img { width: clamp(110px, 10vw, 180px); }

/* ---------------------------------------------------------------------------
   The handover code.

   Sits over her, bottom right, above the caption rather than across it: at a
   stand the caption is what somebody reads while she talks and the code is what
   they reach for afterwards, so neither may cover the other. White card on
   purpose — a QR inverted on a dark background is read by roughly half of the
   phones that try, and the half that fail look like the feature is broken.
   --------------------------------------------------------------------------- */
.offer {
  position: absolute;
  right: clamp(0.75rem, 2vw, 1.5rem);
  bottom: clamp(5.5rem, 14vh, 9rem);
  z-index: 6;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.4rem;
  padding: 0.9rem 0.9rem 0.75rem;
  border-radius: 0.9rem;
  background: #fff;
  color: #111;
  box-shadow: 0 1.5rem 3rem rgba(0, 0, 0, 0.45);
  max-width: min(42vw, 22rem);
}
.offer[hidden] { display: none; }
.offer-card { width: clamp(9rem, 22vw, 15rem); line-height: 0; }
.offer-card svg { width: 100%; height: auto; display: block; }
.offer-caption {
  margin: 0;
  font-weight: 700;
  font-size: clamp(0.85rem, 1.5vw, 1.05rem);
  text-align: center;
  line-height: 1.25;
}
.offer-note {
  margin: 0;
  font-size: clamp(0.7rem, 1.1vw, 0.85rem);
  text-align: center;
  opacity: 0.75;
  line-height: 1.3;
}

/* In portrait, on the tablet, she is full bleed and the caption sits low, so
   the code moves to the middle of the screen where a phone can reach it
   without somebody leaning across the stand. */
@media (orientation: portrait) {
  .offer {
    right: 50%;
    transform: translateX(50%);
    bottom: clamp(7rem, 20vh, 12rem);
    max-width: min(70vw, 20rem);
  }
  .offer-card { width: clamp(10rem, 44vw, 16rem); }
}

/* On the stand the code is anchored to the caption's own ceiling rather than to
   a guess. The stand caption is pinned at --kiosk-h plus a gap and grows
   UPWARD to a cap of 32dvh, so the highest it can ever climb is that sum: one
   more gap above it and a long answer can never print into the code, whatever
   she has just been asked. That is the whole reason this is arithmetic and not
   a round number. It also keeps the code in one fixed spot between visitors,
   which matters more than it sounds: a code that moves is a code somebody is
   still hunting for after it has gone.

   The first version of this sat bottom-right at a fixed 14vh and printed
   straight across the caption at both tablet orientations. Measured at 1280x800
   and 800x1280 with the caption pushed to its cap. */
body.is-stand .offer {
  /* Anchored above the caption's ceiling, and then capped so the top edge
     cannot leave the screen. --kiosk-h is not a constant: the stack grows the
     moment the visitor's question is echoed back and the "one moment" line
     appears, which is precisely when a code is on its way, and at 1280x800
     that lifted the card clean off the top of the glass. A code nobody can
     scan is worse than a code slightly closer to the caption, so when the two
     cannot both be had, the screen wins. --offer-h is published by offer.js
     when it draws; the fallback is a typical card. */
  bottom: min(
    calc(var(--kiosk-h, 150px) + clamp(12px, 2dvh, 26px) + 32dvh + clamp(10px, 1.5dvh, 20px)),
    calc(100dvh - var(--offer-h, 340px) - clamp(10px, 2dvh, 24px))
  );
}
/* Landscape is the tight one. Eight hundred pixels of screen, a caption that
   may take 256 of them and the charity's mark across the top right corner,
   which leaves a band roughly 340 pixels tall for the code to live in. Pulling
   it in from the right edge clears the mark, and clearing the mark is what buys
   the height back for the code itself: hard against the corner it had to shrink
   to 188 pixels, which is over the line a phone camera needs but not by
   anything I would want to bet a stand on. */
body.is-stand.is-landscape .offer {
  right: clamp(0.75rem, 13vw, 10.5rem);
  max-width: min(32vw, 18rem);
  padding: 0.75rem 0.75rem 0.65rem;
}
body.is-stand.is-landscape .offer-card { width: clamp(9rem, 18vw, 14.5rem); }

/* Portrait is the tablet's natural way up and she is full bleed in it, so the
   band above the caption's ceiling IS her face: centred, the card sat straight
   over her eyes, which is the one thing this build says repeatedly must not be
   covered. Everything on this screen costs some of her, so the code takes the
   margin rather than the middle — the same trade the landscape rule makes to
   clear the mark. Her mouth is lower still and stays clear either way. */
body.is-stand.is-portrait .offer {
  right: clamp(0.75rem, 2vw, 1.25rem);
  transform: none;
  /* Every number here is measured against one rule: the whole white box, edge
     of the shadow to edge of the shadow, stays inside the outer third of the
     glass. At 800 wide that third begins at 533 and the box was landing at
     515, so eighteen pixels of it sat in the middle column. Nobody would have
     called that covering her face and it is still the wrong side of the line
     the rule exists to hold. The widths are in vw rather than rem for the same
     reason the third is: they have to shrink together, or the rule holds at
     one tablet size and quietly stops holding at the next. 26vw of card plus
     0.9rem of padding each side plus 2vw of inset comes to 28vw and a bit,
     against a third of 33.3vw. The caption wraps to one more line inside the
     narrower box, which costs height, and height in portrait is the one thing
     there is plenty of. */
  max-width: min(30vw, 15rem);
}
body.is-stand.is-portrait .offer-card { width: clamp(9rem, 26vw, 13rem); }

/* ---------------------------------------------------------------------------
   The same code on the two screens that are not the stand.

   None of the arithmetic above reaches either of them. It is all written off
   --kiosk-h and dvh, and --kiosk-h is a stand thing that is simply absent here
   while the glass is not the box: on both of these she lives inside a panel
   that is a fraction of the window. So the card, sized at 42vw of the WINDOW,
   came out 330 pixels wide inside a 430 pixel panel and printed straight
   through a long answer. Measured at 1280x900 on ?stage=1: caption 226px tall,
   code across 100px of it.

   The page he drives from. The panel is narrow and there is no sideways to
   give, so this is the stand's trade again: the card is sized off its own
   container rather than the window, and the caption yields the height the card
   is actually using. Percentages here resolve against .face-wrap, whose height
   is definite because the canvas carries an aspect-ratio. */
body:not(.is-stand):not(.presenting) .offer {
  max-width: min(46%, 15rem);
  padding: 0.75rem 0.75rem 0.65rem;
}
body:not(.is-stand):not(.presenting) .offer-card { width: 100%; }
body:not(.is-stand):not(.presenting) .caption {
  max-height: calc(100% - var(--offer-h, 0px) - clamp(5.5rem, 14vh, 9rem) - 0.75rem);
  overflow: auto;
}

/* The page he presents from, which is two layouts and not one, and neither of
   them is the one I first assumed. Landscape puts her on the left and the
   words in a column beside her; portrait puts her on top and the words
   underneath. The card was pinned to the window's bottom right corner in both,
   which in landscape is the middle of the column of words.

   Both rules below only bite while a code is actually on screen, which is what
   :has() is doing here. A presenter view that quietly rearranged itself for a
   card that is not there would be a worse bug than the one being fixed, and
   this screen spends almost all of its life with no card on it.

   Scoped away from the stand deliberately. Stand mode sets .presenting too, and
   the rules further up are the ones that belong to it.

   LANDSCAPE: the words already have their own column, so the card takes the
   bottom of that column and the words take the top of it. The cap is arithmetic
   off where the card's top edge actually lands — 100vh less its 14vh anchor
   less its own measured height — and not a round number, because the card's
   height depends on how long the resource's name is. */
body.presenting.is-landscape:not(.is-stand) .face-wrap:has(#offer:not([hidden])) .caption {
  align-self: start;
  max-height: min(76vh, calc(86vh - var(--offer-h, 0px) - 2.7rem));
}

/* PORTRAIT: the words are centred underneath her and there is no column to
   share, so they give width instead and stop before the card rather than
   running under it.

   The card has to come off the middle first. There is an old rule up the file
   that centres it in any portrait window, which is right for a phone looking at
   this site and wrong here: a centred card and left-aligned words in the same
   band is an overlap whatever width the words take. Pinned right, the two sit
   side by side with room between them. */
body.presenting.is-portrait:not(.is-stand) .offer {
  right: clamp(16px, 4vw, 40px);
  transform: none;
}
body.presenting.is-portrait:not(.is-stand) .face-wrap:has(#offer:not([hidden])) .caption {
  align-self: start;
  margin-left: clamp(16px, 5vw, 48px);
  width: min(92vw, 30ch, calc(100vw - var(--offer-w, 0px) - clamp(48px, 12vw, 112px)));
}

/* When a code is up, the caption yields rather than being printed over.
   Both boxes are pinned to the same bottom edge and both grow upward, and on a
   short screen with the controls at their tallest the two of them together want
   more room than the glass has. Something has to give, and it is the caption:
   she is saying those words out loud at the same moment, while a code that is
   half covered is a code nobody can scan. It still scrolls, and it never drops
   below about three lines. --offer-h is only set while a code is actually on
   screen, so with nothing showing the caption is exactly as tall as it was. */
/* Two classes on the body, to tie the specificity of the pair of rules above
   that already set this, and later in the file to win it — the same fault this
   stylesheet has hit before, where a stand rule quietly wrote a value that
   never rendered. */
body.is-stand.is-kiosk .caption,
body.is-stand.presenting .caption {
  max-height: max(5.5rem, min(32dvh, calc(100dvh - var(--kiosk-h, 150px) - var(--offer-h, 0px) - 4.5rem)));
}

/* ---------------------------------------------------------------------------
   THE LIVE FACE: HER WHOLE HEAD, AND HER PORTRAIT BEFORE THE STREAM
   ---------------------------------------------------------------------------

   `object-fit: cover` with `object-position: 50% 28%` is tuned for the filmed
   bake, whose actor was framed for it. Applied to the live face it cropped the
   top of her head off on a laptop, which Rolando spotted in about four seconds.

   Cover exists so a video fills a box it does not match. The live face does not
   need that: the room behind her is ours and drawn, so the frame can sit inside
   it whole and the gap is not a letterbox, it is the room. Contain therefore
   cannot cut her, at any window shape, on any device, which is worth more than
   the few pixels of fill it gives up.

   Last in the file and matched through `.face-wrap` so it ties the specificity
   of the stand rule it overrides and wins on order, which is the failure this
   stylesheet has had before. */
.face-live canvas,
.face-live video,
.face-live img,
body.is-stand .face-wrap .face-live canvas,
body.is-stand .face-wrap .face-live video,
body.is-stand .face-wrap .face-live img {
  /* SIZE THEM BEFORE YOU CENTRE THEM, AND THIS IS THE BUG ROLANDO SAW.
   *
   * `img` is new here and it is the whole of "she is off to the left". The
   * portrait below is `position: absolute; inset: 0` and nothing else. On a
   * canvas or a video that is enough, because neither has an intrinsic size
   * worth honouring. An `<img>` does: with `left: 0`, `right: 0` and
   * `width: auto` the box is over-constrained, CSS solves it by keeping the
   * image's own pixel width and throwing `right` away, and she renders at the
   * webp's natural size pinned to the LEFT EDGE. On a 390px phone that is most
   * of her off the side of the screen.
   *
   * Nobody caught it because the portrait is only ever meant to be up for the
   * fifteen seconds before the stream paints over it. Once the 1080p refusal
   * stopped the stream from ever painting, those fifteen seconds became the
   * whole conference and the still was the entire product.
   *
   * So: fill the box, then fit inside it. */
  width: 100%;
  height: 100%;
  object-fit: contain;
  object-position: 50% 42%;
}

/* ---------------------------------------------------------------------------
   THE STAND-BY, holding the frame until there is actually a person in it.

   This was her own still portrait until 20 September, when Rolando timed a
   cold load on the live site and found three pictures inside six seconds: a
   stale photograph, then several seconds of nothing, then her. "Never expose
   the obsolete poster after the gate ... keep the media area in a clean
   branded/neutral loading state until the first correct live frame is actually
   ready."

   So it is the same room she is about to stand in, painted from the same
   token, with a slow breath of warm light across it and one word. It covers
   her frame exactly, which is what makes the arrival a fade and never a
   reflow: the canvas has been sitting in this box the whole time.

   `is-gone` is added in live.js on the first frame that actually has her in
   it, and taken off again whenever the session ends, fails, or stands down.
   --------------------------------------------------------------------------- */
.face-live .live-standby {
  position: absolute;
  inset: 0;
  /* Above the canvas, which is in normal flow. Being absolute is enough for
     that on its own; the z-index is here so a future sibling cannot quietly
     win the stack and put a half-keyed warm-up frame back on the glass. */
  z-index: 2;
  display: grid;
  place-items: center;
  background-color: var(--room-tint);
  background-image: var(--room-bg);
  opacity: 1;
  transition: opacity 320ms ease-out;
}
.face-live .live-standby.is-gone {
  opacity: 0;
  pointer-events: none;
}
/* AND WHATEVER IS UNDERNEATH IS NOT MERELY COVERED, IT IS TURNED OFF.
   
   The stand-by is opaque, so hiding a frozen face is already handled for the
   eye. This is about the two ways a black rectangle can still appear: a
   <video> element with no current frame paints black rather than nothing, and
   the stand-by FADES, so for 320ms of any transition whatever is behind it is
   partly visible. `visibility` flips instantly and takes both cases out.
   live.js puts this on at the same moment it shows the stand-by and takes it
   off at the same moment it reveals her. */
.face-live .live-video.is-blank { visibility: hidden; }
/* The breath. Nothing spins and nothing counts: a stand is a room full of
   people glancing over, and a progress indicator invites them to watch a
   number instead of waiting for a person. This just says the screen is alive.
   Both children share the one grid cell so the word sits inside the light. */
.face-live .live-standby__glow,
.face-live .live-standby__word { grid-area: 1 / 1; }
.face-live .live-standby__glow {
  width: min(46%, 240px);
  aspect-ratio: 1;
  border-radius: 50%;
  background: radial-gradient(circle, rgba(255, 236, 214, 0.22), transparent 68%);
  animation: standby-breath 3.6s ease-in-out infinite;
}
.face-live .live-standby__word {
  align-self: center;
  justify-self: center;
  font-size: clamp(0.85rem, 1.6vw, 1.05rem);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: rgba(231, 235, 245, 0.62);
  text-shadow: 0 1px 6px rgba(0, 0, 0, 0.6);
}
@keyframes standby-breath {
  0%, 100% { opacity: 0.35; transform: scale(0.94); }
  50% { opacity: 0.85; transform: scale(1.06); }
}
/* A tablet left on a stand all day runs this for hours, and somebody in the
   room may have asked their device not to do that. */
@media (prefers-reduced-motion: reduce) {
  .face-live .live-standby__glow { animation: none; opacity: 0.6; }
}

/* ---------------------------------------------------------------------------
   A COMPOSED FRAME, RATHER THAN HER PRESSED AGAINST THE BEZEL.

   Rolando, 2026-09-20: "not professionally framed ... the controls cover too
   much of her torso."

   Measured before touching anything, in `test/framing.mjs`, with a stand-in
   carrying her stream's own 9:16 shape:

     tablet portrait 768x1024   frame 576x1024 at y=0   0px headroom   20% covered
     tablet landscape 1024x768  frame 432x768  at y=0   0px headroom   24% covered
     desktop 1440x900           frame 506x900  at y=0   0px headroom   24% covered

   Zero headroom on every screen taller than the source, because the frame was
   `position: fixed; inset: 0` and `contain` then fills the full height. Her
   head touches the top edge, and the control stack, which is fixed to the
   bottom of the screen, lands across the bottom quarter of the frame, which is
   her chest. That is the whole complaint and it is arithmetic, not taste.

   WHAT THIS DOES NOT FIX, SO NOBODY GOES LOOKING. Her upper arms are outside
   the rendered frame. LiveAvatar's session API takes `quality` and `encoding`
   and nothing else: no crop, no zoom, no framing. Arms that were never in the
   picture cannot be recovered here. That is an avatar swap, one Pages variable
   (`LIVEAVATAR_AVATAR_ID`), and it is the other half of the answer.

   The composition: inset the frame from the top by a real headroom, and stop
   it at the top of the controls rather than at the floor. `--kiosk-h` is
   already published by kiosk.js as the distance from the BOTTOM OF THE SCREEN
   to the top of that stack, measured continuously, so using it here means her
   frame ends exactly where the controls begin, at whatever height they happen
   to be with a wrapped hint and an open text field. Nothing is guessed and
   nothing needs re-tuning when the stack grows.
   --------------------------------------------------------------------------- */
body.is-stand .face-wrap .face-live {
  /* Not `inset: 0`. Top: breathing room over her head, and the notch on a
     phone. Bottom: the controls' own measured ceiling, plus a gap so the
     frame does not touch them. */
  top: calc(env(safe-area-inset-top, 0px) + clamp(14px, 3.5dvh, 48px));
  bottom: calc(var(--kiosk-h, 150px) + clamp(8px, 1.5dvh, 18px));
  left: 0;
  right: 0;
  width: auto;
  height: auto;
}

/* ---------------------------------------------------------------------------
   LANDSCAPE: GIVE HER THE HEIGHT, AND PUT EVERYTHING ELSE BESIDE HER.

   Rolando, 2026-09-20, after the composition above shipped: "the visible
   avatar screen is still too narrow and crops her shoulders and upper arms ...
   widen the avatar stage/frame."

   Half of that is not available and it is worth saying again here, where
   somebody will come looking. HER CROP IS NOT OURS. LiveAvatar renders chest
   up; their own creation guide says sources must be framed from the chest up
   and lists full body as not supported, and the session API takes `quality`
   and `encoding` and nothing else. `object-fit: contain` already shows one
   hundred percent of what arrives and clips nothing, so no rule in this file
   can put an arm back in the picture. What a wider frame buys is SCALE: she
   arrives bigger, which is what "too narrow" actually describes on a screen
   where she was 305 pixels of a 1024-pixel tablet.

   And the arithmetic of that is fixed. A 9:16 source under `contain` is always
   width = height x 0.5625, so the ONLY way to widen her without stretching her
   is to give her more height. In landscape there was none to give: the frame
   stopped at the top of the control stack, which is fixed to the bottom of the
   screen, so a third of the glass went to a row of buttons and the two side
   columns were empty room.

   So landscape becomes two columns. She gets the full height of the screen and
   the controls, the caption and the code move into the column beside her that
   was doing nothing. Measured, with a stand-in carrying her 9:16 shape:

     tablet landscape 1024x768   305 -> 429 wide   (+41%)
     desktop 1440x900            358 -> 501 wide   (+40%)

   PORTRAIT IS DELIBERATELY UNTOUCHED. There is no empty column there to move
   anything into, so the only height left to give her is the band the controls
   sit in, and taking it back is the exact complaint this answers.
   --------------------------------------------------------------------------- */
body.is-stand.is-landscape {
  /* The column that holds everything that is not her. Wide enough for the Talk
     button, a wrapped hint and the typed-question field at a readable size;
     capped so a very wide monitor does not spend half the glass on it. */
  --stage-aside: clamp(20rem, 48vw, 44rem);
}

/* The room has to reach the whole screen now, or the column beside her is a
   different colour and the seam is the first thing anybody sees. Painted on
   the wrapper, from the same token, and taken off her own frame so the two
   cannot drift. `.is-kiosk` and `.presenting` are both here for the same
   reason they are everywhere else in this file: `body.presenting.is-landscape
   .face-wrap` outranks a plain stand rule. */
body.is-stand.is-landscape.is-kiosk .face-wrap,
body.is-stand.is-landscape.presenting .face-wrap {
  background-color: var(--room-tint);
  background-image: var(--room-bg);
}
body.is-stand.is-landscape .face-wrap .face-live {
  background: none;
  /* Her headroom is unchanged. The bottom is now a margin rather than the
     control stack's measured ceiling, because the controls are no longer
     under her. This is the whole widening: about 120 more pixels of height,
     which `contain` spends on width. */
  bottom: clamp(10px, 2dvh, 24px);
  left: 0;
  right: var(--stage-aside);
}

/* The controls, out from under her and into the column. `--kiosk-h` keeps
   being published exactly as before, so the caption and the code below still
   measure off the real stack; what changed is only where that stack sits. Her
   frame no longer reads `--kiosk-h` at all in landscape, which also means a
   hint that wraps to three lines can no longer resize her. */
body.is-stand.is-landscape .kiosk-ui {
  left: auto;
  right: 0;
  width: var(--stage-aside);
}

/* The dialogue shares the wider column and can use all the height above the
   controls. The old 29vw / 32dvh box needed scrolling even for her greeting. */
body.is-stand.is-landscape.is-kiosk .caption,
body.is-stand.is-landscape.presenting .caption {
  left: auto;
  right: max(12px, env(safe-area-inset-right));
  transform: none;
  width: calc(var(--stage-aside) - max(12px, env(safe-area-inset-right)) - 12px);
  text-align: left;
  font-size: clamp(18px, 2.05vw, 26px);
  line-height: 1.4;
  max-height: max(6rem, calc(100dvh - var(--kiosk-h, 150px) - 5.5rem));
  overflow-wrap: anywhere;
}

/* And the code, which was inset from the right edge by 13vw for one reason:
   to get out from under the brandmark in the corner. In two columns it can sit
   in its own column instead, which is both tidier and further from her face
   than it has ever been. It clears the mark by starting below it rather than
   beside it, so the inset goes back to an ordinary margin and the card keeps
   the width it was measured at. The bottom arithmetic is untouched: it is
   still anchored above the caption's ceiling and still capped so the top edge
   cannot leave the screen. */
body.is-stand.is-landscape .offer {
  right: max(12px, env(safe-area-inset-right));
  max-width: calc(var(--stage-aside) - max(12px, env(safe-area-inset-right)) - 12px);
  /* AND THE CEILING HAS TO KNOW THE MARK IS THERE. The shared rule caps the
     card's top at ten to twenty-four pixels off the glass, which was fine
     while the card was inset 13vw and sat to the LEFT of the mark. In its own
     column it is directly under it, and at 1024x768 the two met: caught by
     test/handover.mjs, which is the whole reason that check measures against
     everything a visitor reads rather than against the caption alone.

     The mark is 110 to 180 pixels of artwork whose own ratio is 1096x208, so
     it is 21 to 34 pixels tall, sitting at its own top inset. 2.6rem past that
     inset clears the tallest of those with room to spare. */
  bottom: min(
    calc(var(--kiosk-h, 150px) + clamp(12px, 2dvh, 26px) + 32dvh + clamp(10px, 1.5dvh, 20px)),
    calc(100dvh - var(--offer-h, 340px) - max(14px, env(safe-area-inset-top)) - 2.6rem)
  );
}

/* AND WHEN THE CODE IS UP, THE CAPTION IS WHAT GIVES WAY.
   
   In the old arrangement the code sat out over her room, inset from the right
   to clear the mark, and the caption ran along the bottom of the whole screen,
   so the two only met at the extremes. Sharing one column they are directly
   above and below each other, and a long answer walks straight into the card:
   caught by test/handover.mjs at 1280x800, one pixel of overlap.

   Something has to yield and it is not the code. A caption is an aid to
   somebody listening to her say the same words; a code that is covered, or
   that has been pushed off the top of the glass to avoid covering, is a
   feature that silently does not work. So while a code is on screen the
   caption takes whatever is left between the controls and the bottom of the
   card, which at 1024x768 is about 195 pixels, and scrolls inside it.

   `:has` rather than a class, because offer.js already toggles `hidden` on the
   element and the sheet already reads it that way for the two screens that are
   not the stand. The numbers are the same terms the card's own ceiling uses,
   so the two cannot drift: change one and change the other. */
body.is-stand.is-landscape.is-kiosk .face-wrap:has(#offer:not([hidden])) .caption,
body.is-stand.is-landscape.presenting .face-wrap:has(#offer:not([hidden])) .caption {
  max-height: calc(
    100dvh - var(--offer-h, 340px) - max(14px, env(safe-area-inset-top)) - 2.6rem -
    var(--kiosk-h, 150px) - clamp(12px, 2dvh, 26px) - clamp(10px, 1.5dvh, 18px)
  );
}

/* THE LIP-SYNC TUNER, `?face=simli&tune=1` only (lipsync-tuner.js).

   Rolando, 25 September: "the words dont macth to her lips". A small card
   that must never cover her mouth, since her mouth is the whole point. In
   portrait it sits along the bottom, over the Ask button (not needed while
   tuning; Hide gives it back), below her picture on a phone, an 8-inch tablet
   and a 10-inch one alike. A first cut sat top left and covered her face on
   an iPhone and on 600-pixel-wide tablets. In landscape, the controls'
   column, beside her rather than over her. Buttons big enough to press at
   arm's length. Under the tap-to-begin veil (60), so the first tap still
   wakes her voice; over everything else. Light on light, because the Simli
   page is the white room. */
.lipsync-tuner {
  position: fixed;
  left: 50%;
  bottom: max(10px, env(safe-area-inset-bottom));
  transform: translateX(-50%);
  z-index: 50;
  width: min(440px, calc(100vw - 20px));
  max-height: 44dvh;
  overflow-y: auto;
  box-sizing: border-box;
  padding: 10px 12px;
  border-radius: 14px;
  background: rgba(255, 255, 255, 0.96);
  color: #1c2336;
  box-shadow: 0 6px 24px rgba(28, 35, 54, 0.18);
  font: 15px/1.3 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.lipsync-tuner__title { margin: 0 0 2px; font-size: 16px; font-weight: 700; }
.lipsync-tuner__number { margin: 0 0 4px; font-weight: 600; }
.lipsync-tuner__how { margin: 0 0 8px; color: #4a5570; font-size: 13px; }
.lipsync-tuner__button {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  width: 100%;
  min-height: 46px;
  padding: 6px 8px;
  border: 1px solid rgba(28, 35, 54, 0.22);
  border-radius: 10px;
  background: #fff;
  color: inherit;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  touch-action: manipulation;
}
.lipsync-tuner__button:active { background: #eef1f7; }
.lipsync-tuner__button:disabled { opacity: 0.45; cursor: default; }
.lipsync-tuner__hint { font-size: 12px; font-weight: 400; color: #4a5570; }
.lipsync-tuner__play { background: #1c2336; color: #fff; border-color: #1c2336; }
.lipsync-tuner__play:active { background: #33405f; }
/* Play and the two buttons on one row in portrait, which is what keeps the
   card low enough to stay under her chin on a 768-wide tablet. */
.lipsync-tuner__controls { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-bottom: 6px; }
.lipsync-tuner__source,
.lipsync-tuner__details { margin: 0 0 2px; font-size: 12px; color: #4a5570; }
.lipsync-tuner__details:empty { display: none; }
.lipsync-tuner__fault { margin: 0 0 2px; font-size: 13px; color: #a11d1d; }
.lipsync-tuner__fault:empty { display: none; }
.lipsync-tuner__warn { margin: 0 0 2px; font-size: 13px; color: #8a4b00; }
.lipsync-tuner__warn:empty { display: none; }
.lipsync-tuner__said { margin: 0 0 2px; font-size: 13px; color: #8a4b00; }
.lipsync-tuner__said:empty { display: none; }
.lipsync-tuner__foot { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 6px; }
.lipsync-tuner__foot:has(.lipsync-tuner__name) { grid-template-columns: 1.6fr 1fr 1fr; }
.lipsync-tuner__foot .lipsync-tuner__button { min-height: 38px; font-weight: 500; }
/* In portrait the buttons say what they do; the paragraph is for landscape,
   where the column beside her has the room. */
body:not(.is-landscape) .lipsync-tuner__how { display: none; }
body.is-landscape .lipsync-tuner {
  left: auto;
  bottom: auto;
  right: max(10px, env(safe-area-inset-right));
  top: max(56px, env(safe-area-inset-top));
  transform: none;
  width: min(300px, calc(100vw - 20px));
  max-height: calc(100dvh - max(56px, env(safe-area-inset-top)) - 10px);
}
body.is-landscape .lipsync-tuner__controls { grid-template-columns: 1fr 1fr; }
body.is-landscape .lipsync-tuner__play { grid-column: 1 / -1; }

/* ---------------------------------------------------------------------------
   `?say=nooks`: four ways of saying his name, for Rolando to pick by ear.

   25 September 2026, "its prounced Nooks, like Books but with a N." The panel
   is for him, not for a visitor, so it is not keyed on `body.is-stand` and it
   takes no room from anything she needs: top left, where the mark is not,
   clear of the controls at the bottom. The same dark glass as the booth bar,
   so it reads over her dark room and the white Simli one alike. Under the
   veil (60) so it cannot be pressed before the tap that unlocks audio, and
   over everything else (the bar is 40) so nothing she draws covers a button.
   Two buttons a row keeps it short enough for a landscape tablet.
   --------------------------------------------------------------------------- */
.say-audition {
  position: fixed;
  top: max(14px, env(safe-area-inset-top));
  left: max(14px, env(safe-area-inset-left));
  z-index: 50;
  width: min(19rem, calc(100vw - 28px));
  max-height: calc(100dvh - 28px);
  overflow-y: auto;
  box-sizing: border-box;
  padding: 12px 14px;
  border-radius: 16px;
  background: rgba(12, 16, 26, 0.84);
  border: 1px solid #1d2435;
  backdrop-filter: blur(10px);
  color: #e8edf7;
  font-size: clamp(13px, 1.8vmin, 16px);
  line-height: 1.35;
  -webkit-user-select: none;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
}
.say-audition__title {
  margin: 0 0 4px;
  font-size: clamp(16px, 2.4vmin, 20px);
  font-weight: 600;
}
.say-audition__sub {
  margin: 0 0 10px;
  color: #8e9bb8;
}
.say-audition__tries {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 8px;
}
.say-audition__try {
  display: flex;
  gap: 10px;
  align-items: center;
  min-height: 56px;
  padding: 6px 12px;
  border: 0;
  border-radius: 14px;
  background: #182033;
  color: #cdd6ea;
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.say-audition__try:active { background: #24314f; }
.say-audition__try.is-on {
  background: #24314f;
  outline: 2px solid #60a5fa;
  outline-offset: 1px;
}
.say-audition__n {
  font-size: clamp(24px, 3.6vmin, 30px);
  font-weight: 600;
  line-height: 1;
  color: #e8edf7;
}
.say-audition__words { display: flex; flex-direction: column; min-width: 0; }
.say-audition__spelling { font-size: clamp(15px, 2.1vmin, 18px); }
.say-audition__note {
  font-size: 12px;
  color: #8e9bb8;
}
.say-audition__status {
  margin: 10px 0 0;
  color: #cdd6ea;
}
.say-audition__status:empty { display: none; }
