/* demo-cinema.css — the public showcase cinematic chrome (spec 2026-07-23 B4).
 *
 * Token-based only (var(--…) from tokens.css) so it inherits light/dark
 * automatically — pinned by check_dark_mode.py --strict. The dashboard below
 * the cinematic is the REAL clinic UI (already token-based); these styles only
 * dress the intro + the two NEW mock scenes (PMS list, phone). */

.demo-intro {
  background: var(--bg-secondary);
  border: 1px solid var(--border-color);
  border-radius: var(--radius-lg);
  padding: var(--space-lg);
  margin-bottom: var(--space-lg);
}

.demo-intro-title {
  font-family: var(--font-display);
  font-size: var(--text-xl);
  color: var(--text-primary);
  margin: 0 0 var(--space-sm);
}

.demo-intro-text {
  color: var(--text-secondary);
  line-height: var(--leading-relaxed);
  /* max-width: var(--container-narrow); */
  margin: 0 0 var(--space-md);
}

.demo-intro-actions {
  display: flex;
  gap: var(--space-sm);
  /* Both CTAs stay on ONE row at every width (the short labels are sized for
     it). On a wide intro they read as a pair — primary then secondary — rather
     than two buttons stranded at opposite ends of the column. */
  flex-wrap: nowrap;
  justify-content: flex-start;
}

@media (max-width: 560px) {
  /* On a phone the row is barely wider than the buttons, so pushing them to
     the edges is what keeps them from reading as one blob. */
  .demo-intro-actions {
    justify-content: space-between;
  }
}

/* --- Cinematic stage (revealed by demo-cinema.js) ---
 *
 * THE STAGE IS SIZED BY A BUDGET, NOT BY ITS CONTENT. demo-cinema.js measures
 * what the device actually leaves (visual viewport − sticky header − transport)
 * and publishes it here as `--demo-body-budget`: the height a scene's CHROME
 * may occupy, caption and padding already paid for. Every scene below sizes
 * itself against that variable, so the whole tour — caption, scene, Back/Next —
 * fits one phone screen without scrolling. Unset (no JS, first paint) each
 * clamp falls back to its natural desktop size, so nothing depends on the
 * variable existing. */
.demo-cinema-stage {
  margin-bottom: var(--space-lg);
  /* The driver gives this box an exact height, capped by what the screen leaves.
     Where a scene refuses to shrink that far (the squeeze has a floor, so a
     handset stays a handset), the excess is CLIPPED here rather than pushing the
     transport off the bottom of a landscape phone. */
  overflow: hidden;
  /* The outgoing scene of a transition is lifted out of flow ON TOP of the
     incoming one (see .is-leaving), so the stage is its containing block. */
  position: relative;
  /* demo-cinema.js pins an exact height here for the whole tour, so the
     transport below never moves. A column flex box is what lets the one visible
     scene FILL that reservation instead of sitting at the top of it — otherwise
     the short scenes read as a caption stranded above 450px of nothing. */
  display: flex;
  flex-direction: column;
}

/* An author `display` beats the UA's `display:none` for [hidden], so the flex
   rules above would defeat the server-rendered `hidden` the JS toggles. */
.demo-cinema-stage[hidden] {
  display: none;
}

/* A scene on its way out during a transition: still painted, no longer
   affecting layout or accepting input. */
.demo-scene.is-leaving {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  pointer-events: none;
}

/* A scene revealed for its NARRATION ONLY, ahead of the choreography that will
   deliver its chrome (see crossfadeCaption). Absolute for the same reason
   .is-leaving is: it must not perturb the outgoing scene's layout, whose
   measured rects the FLIP in flight is glued to. Top-aligned puts its caption
   in exactly the slot the outgoing caption occupies, so the two lines cross in
   place instead of sliding past each other. */
.demo-scene.is-entering {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  pointer-events: none;
}

/* visibility, not display: the caption's reserved height is measured from the
   scene's own box, and collapsing the body would change it. */
.demo-scene.is-entering .demo-scene-body {
  visibility: hidden;
}

/* The outgoing narration, lifted over the incoming scene for the hand-off.

   An absolutely-positioned .is-entering scene paints above its in-flow sibling,
   so without this the new line would REPLACE the old one on the spot — a cut,
   not a dissolve. Lifting the caption alone (never the scene: .is-entering
   exists precisely so the outgoing scene's geometry stays untouched while a
   FLIP is measured against it) puts the old line back on top, where it can fade
   away and reveal the new one already at full strength underneath. */
.demo-scene.is-caption-lifted .demo-scene-caption {
  position: relative;
  z-index: 1;
}

/* Fixed-position layer the transition's flying pieces live in (clones of the
   appointment blocks, signal dots). Never interactive; torn down with the
   animation that created it. */
.demo-fly-layer {
  position: fixed;
  inset: 0;
  z-index: 60;
  pointer-events: none;
}

/* A cloned appointment block in flight. It carries NO look of its own: the
   whole point of the flight is that the visitor follows the same block they
   were just reading, so anything added here (a radius, a shadow, a ring)
   would read as the book restyling itself the instant it lifted off. The
   palette + type it needs to keep painting itself are re-established on this
   class in the .demo-pms scope below. */
.demo-fly-block {
  position: fixed;
  margin: 0;
  will-change: transform, opacity;
}

/* What travels the wire is a MESSAGE, so it looks like one: a little chat
   bubble with a tail and two lines of text, drawn entirely in CSS (no asset,
   no innerHTML on a moving element). The margins centre the bubble body on
   the wire; the tail is allowed to hang below it. */
/* Carries the phone's per-OS palette to the real SMS bubble while the driver
   flies it on the layer above (scene 2 → 3). It matches `.demo-phone[data-demo-os]`
   so the palette blocks below apply to it verbatim — and generates no box at
   all, so none of the phone's own chrome (bezel, size, shadow) comes with it.
   Custom properties still inherit through a display:contents element, which is
   the whole trick: the skin travels, nothing is duplicated in JS. */
.demo-phone.demo-fly-skin {
  display: contents;
}

/* The bubble in flight: a free box on the fly layer, clipping its own text
   (which the driver has pinned to the FINAL width, so it is uncovered rather
   than reflowed as the box opens). */
.demo-sms-bubble.is-flying {
  position: fixed;
  margin: 0;
  max-width: none;
  overflow: hidden;
  will-change: width, height, left, top;
}

/* Holds the bubble's box in the thread while it is away, so the phone does not
   change size mid-flight and the bubble has an exact place to come home to. */
.demo-sms-slot {
  flex: none;
}

.demo-fly-signal {
  position: fixed;
  width: 20px;
  height: 15px;
  margin: -9px 0 0 -10px;
  border-radius: 4px;
  background: var(--color-primary);
  box-shadow: 0 0 0 3px var(--color-primary-bg);
  will-change: transform, opacity;
}

/* The bubble's tail. */
.demo-fly-signal::before {
  content: "";
  position: absolute;
  left: 4px;
  top: 100%;
  border-left: 5px solid var(--color-primary);
  border-bottom: 4px solid transparent;
}

/* Two lines of text inside it — one box, two gradient stripes, so the whole
   glyph stays a single element the animation can move. */
.demo-fly-signal::after {
  content: "";
  position: absolute;
  inset: 4px 4px;
  background:
    linear-gradient(var(--text-on-primary), var(--text-on-primary)) top left /
      100% 2px no-repeat,
    linear-gradient(var(--text-on-primary), var(--text-on-primary)) bottom
      left / 60% 2px no-repeat;
  opacity: 0.9;
}

.demo-scene {
  padding: var(--space-md) 0;
  /* Fill the stage's reservation. A transition's outgoing scene is absolutely
     positioned (.is-leaving), so it steps out of the flex flow and the incoming
     scene takes the whole frame.

     The scene is NOT centred as a whole: that moved the caption on every step,
     because the chrome under it is a different height each time. Caption first
     and top-aligned (one fixed screen position for the narration), chrome
     centred in what is left — so a short scene still sits in the middle of the
     frame instead of stranded at the top of it. */
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  justify-content: flex-start;
}

/* Takes the stage's remaining height so the caption above it stays put.

   Deliberately a BLOCK, not a flex box. Centring the chrome vertically in here
   looked marginally nicer and cost a horizontal overflow: the scene's chrome
   carries `margin: 0 auto`, auto cross-axis margins switch OFF `stretch`, and
   the item then sizes to fit-content with a MIN-CONTENT floor — the appointment
   book's nowrap titlebar forced a 404px-wide page inside a 390px phone (caught
   by the mobile flow audit). Block layout lets each scene's own max-width and
   auto margins do exactly what they already did before this wrapper existed. */
.demo-scene-body {
  flex: 1 1 auto;
  min-height: 0;
}

/* MUST follow the rule above. `hidden` hides via `display: none` in the UA
   stylesheet, so ANY author `display` on the same element silently defeats it —
   the moment .demo-scene became a flex box, all four scenes rendered at once
   (the stage measured 1427px: the sum of every scene) and the stepper appeared
   to do nothing. The driver hides scenes exclusively through the attribute, so
   it has to keep winning. */
.demo-scene[hidden] {
  display: none;
}

/* --- Tour transport (the visitor sets the pace) --- */
/* Two rows, one structure: the status head (where you are) and the nav pair
   (where you can go). They ride on one line while there is room and stack on a
   phone — the transport never re-orders itself, it only wraps. */
.demo-cinema-controls {
  display: flex;
  align-items: center;
  gap: var(--space-sm) var(--space-md);
  flex-wrap: wrap;
  padding: var(--space-sm) var(--space-md);
  margin-bottom: var(--space-lg);
  /* The transport is INSIDE the stage's height budget (demo-cinema.js measures
     it), so every row it grows is a row the scene above loses. Hence one row:
     Back, where-you-are, Next. */
  border: 1px solid var(--border-color);
  border-radius: var(--radius-lg);
  background: var(--bg-secondary);
}

/* Same footgun as the stage: the transport only exists once the tour starts. */
.demo-cinema-controls[hidden] {
  display: none;
}

/* Where you are, between the two places you can go. Centred and allowed to
   wrap, so the pair of buttons keeps the edges at every width. */
.demo-step-head {
  flex: 1 1 auto;
  display: flex;
  align-items: baseline;
  justify-content: center;
  gap: var(--space-xs) var(--space-sm);
  flex-wrap: wrap;
  min-width: 0;
}

.demo-step-status {
  display: flex;
  align-items: baseline;
  justify-content: center;
  gap: var(--space-sm);
  flex-wrap: wrap;
  margin: 0;
  min-width: 0;
}

/* The two buttons are the transport's fixed poles: Back at the start edge,
   Next at the end edge, whatever the status between them does. */
.demo-step-back,
.demo-step-next {
  flex: 0 0 auto;
}

.demo-step-counter {
  font-size: var(--text-xs);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-tertiary);
  white-space: nowrap;
}

/* The counter has two spellings and exactly one is rendered at each width. The
   other is CLIPPED, never `display: none` — both live inside the transport's
   aria-live status, and a display-none node is dropped from the accessibility
   tree, which would silence the step announcement at that width. */
.demo-step-counter.is-short {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
}

.demo-step-label {
  color: var(--text-primary);
  font-size: var(--text-sm);
}

/* Leaving is always available, but it is never a fat target next to "Next":
   a quiet link at the end of the status row, so the button row stays exactly
   the two controls a visitor reaches for. */
.demo-step-exit {
  flex: 0 0 auto;
  /* Quiet, but still a real target: the padding carries it over the WCAG 2.5.8
     24px floor without giving it the visual weight of a button (the audit
     caught it at 48x15). */
  padding: var(--space-xs) var(--space-sm);
  min-height: 24px;
  border: 0;
  background: none;
  color: var(--text-tertiary);
  font-size: var(--text-sm);
  cursor: pointer;
  white-space: nowrap;
}

.demo-step-exit:hover,
.demo-step-exit:focus-visible {
  color: var(--text-primary);
  text-decoration: underline;
}

/* Phone: the tour has to fit ONE screen, so the transport spends the least
   height it can — a single row, Back and Next under the thumbs at the edges,
   the counter and the exit stacked quietly between them.

   Two things are dropped from the RENDER here (never from the DOM: both sit
   inside the aria-live status, so both are still announced) — the scene label,
   which restates the caption the visitor is reading two lines above it, and the
   long spelling of the counter. On a phone a restatement costs a row the
   appointment book needs. */
@media (max-width: 560px) {
  .demo-cinema-controls {
    gap: var(--space-sm);
    padding: var(--space-sm);
  }

  .demo-step-head {
    flex-direction: column;
    align-items: center;
    gap: 2px;
  }

  /* Clipped, NOT `display: none` — a display-none node is dropped from the
     accessibility tree too, so a screen reader would be told the number and not
     the step. */
  .demo-step-label,
  .demo-step-counter.is-long {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
  }

  .demo-step-counter.is-short {
    position: static;
    width: auto;
    height: auto;
    margin: 0;
    overflow: visible;
    clip: auto;
    font-size: var(--text-sm);
    letter-spacing: 0;
    font-variant-numeric: tabular-nums;
  }

  /* Tap area comes from the 44px min-height, not from side padding — so the
     padding is the cheapest 32px available, and it buys the LAST step's longer
     label a place on the same row as Back on a 344px phone. Without it the bar
     reserves two rows for the whole tour (its height is pinned at its tallest),
     and the scene above pays 55px for a row shown on one step. */
  .demo-cinema-controls .btn {
    min-height: var(--touch-target-min);
    padding-inline: var(--space-sm);
    white-space: nowrap;
  }
}

/* --- Replay transport (demo/play.html footer) ---
   Player controls: one round play/pause button and a scrub bar that owns the
   rest of the width. Deliberately NOT `.btn`s in a row — the thing being
   controlled is a recording, so it wears the controls of one. */
.demo-replay-controls {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  margin-bottom: var(--space-sm);
}

.demo-replay-play {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* The one thing a visitor reaches for on a phone — sized to the tap-target
     floor rather than to the glyph. */
  width: var(--touch-target-min);
  height: var(--touch-target-min);
  padding: 0;
  border: 0;
  border-radius: var(--radius-full);
  background: var(--color-primary);
  color: var(--text-on-primary);
  cursor: pointer;
}

.demo-replay-play:hover {
  background: var(--color-primary-dark);
}

.demo-replay-icon {
  width: 22px;
  height: 22px;
  display: block;
}

/* One button, two glyphs: the state lives in `data-state` (set by the driver)
   so nothing here has to know how playback works. */
.demo-replay-play[data-state="playing"] .demo-replay-icon-play,
.demo-replay-play[data-state="paused"] .demo-replay-icon-pause {
  display: none;
}

.demo-replay-count {
  flex: 0 0 auto;
  font-size: var(--text-xs);
  font-variant-numeric: tabular-nums;
  color: var(--text-tertiary);
  white-space: nowrap;
}

/* The scrub bar. `appearance: none` + explicit track/thumb because the two
   engines disagree on every part name, and the native control ignores our
   tokens entirely. */
.demo-replay-scrub {
  flex: 1 1 auto;
  min-width: 0;
  height: var(--touch-target-min);
  margin: 0;
  appearance: none;
  background: none;
  cursor: pointer;
}

/* Elapsed fill. Only Firefox gives us a ::-moz-range-progress part, so the
   played portion is painted as a hard-stop gradient on the track instead — one
   technique both engines honour. `--scrub-pct` is set by the driver on each
   position change (custom properties inherit into these pseudo-elements). */
.demo-replay-scrub::-webkit-slider-runnable-track {
  height: 6px;
  border-radius: var(--radius-full);
  background: linear-gradient(
    to right,
    var(--color-primary) 0 var(--scrub-pct, 0%),
    var(--border-color) var(--scrub-pct, 0%) 100%
  );
}

.demo-replay-scrub::-moz-range-track {
  height: 6px;
  border-radius: var(--radius-full);
  background: linear-gradient(
    to right,
    var(--color-primary) 0 var(--scrub-pct, 0%),
    var(--border-color) var(--scrub-pct, 0%) 100%
  );
}

.demo-replay-scrub::-webkit-slider-thumb {
  appearance: none;
  width: 18px;
  height: 18px;
  margin-top: -6px; /* centre the thumb on the 6px track */
  border: 2px solid var(--bg-elevated);
  border-radius: var(--radius-full);
  background: var(--color-primary);
  box-shadow: var(--shadow-sm);
}

.demo-replay-scrub::-moz-range-thumb {
  width: 18px;
  height: 18px;
  border: 2px solid var(--bg-elevated);
  border-radius: var(--radius-full);
  background: var(--color-primary);
  box-shadow: var(--shadow-sm);
}

.demo-replay-scrub:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
}

/* The one narration line a scene carries, always its first child (issued by the
   ``tour_scene`` macro). Fully self-describing — weight and margins included —
   because this class has already been moved between a <p> and an <h3> once, and
   the UA defaults for the two differ enough that the scenes visibly disagreed
   with each other about caption size, weight and spacing. */
.demo-scene-caption {
  flex: 0 0 auto;
  margin: 0 0 var(--space-md);
  /* The caption is the tour's NARRATION — the one line telling the visitor what
     the scene below is showing them — so it carries body-copy weight and full
     contrast rather than the muted secondary it used to. */
  color: var(--text-primary);
  font-size: var(--text-xl);
  font-weight: var(--weight-bold);
  line-height: 1.4;
  text-align: center;
  /* Reserved height, so the chrome below starts at the same place on every step
     even though the captions are different lengths. demo-cinema.js measures the
     TALLEST caption at the current width and publishes it as --demo-caption-min
     (re-measured on resize), which keeps this honest as the copy changes; the
     two-line fallback covers the pre-pin paint and the no-JS render. Track the
     font-size token above — a fallback computed from the OLD size reserves too
     little and the chrome jumps on first paint. */
  min-height: var(--demo-caption-min, calc(2 * 1.4 * var(--text-xl)));
}

/* A screen that is SMALL IN EITHER DIRECTION spends less on the tour's own
   framing, so the scene keeps the height.

   The caption is narration, not a page title, and at 22px over a 360px column it
   took three lines and ~90px off the top of the tour before the visitor saw
   anything. Smaller type wraps less AND reserves less; the gap to the chrome and
   the scene's padding shrink with it. The min-height fallback tracks the
   font-size — one computed from the OLD token reserves too little and the chrome
   jumps on first paint.

   Keyed on max-HEIGHT too, and that is not symmetry for its own sake: a phone in
   LANDSCAPE (844x390) is wide, so it was served the desktop's generous framing
   out of the ~210px it has in total, and the appointment book was left one row
   with the hero booking outside it. The transport's own compaction stays
   width-keyed below, because a short WIDE bar has room for its label. */
@media (max-width: 560px), (max-height: 560px) {
  .demo-cinema-stage {
    margin-bottom: var(--space-sm);
  }

  .demo-scene {
    padding: 0 0 var(--space-xs);
  }

  .demo-scene-caption {
    font-size: var(--text-lg);
    line-height: 1.35;
    margin-bottom: var(--space-sm);
    min-height: var(--demo-caption-min, calc(2 * 1.35 * var(--text-lg)));
  }
}

/* The step number sits in the caption but is not part of the sentence, so it
   keeps its own (quieter) colour — loud enough to count by, not loud enough to
   compete with the words. */
.demo-scene-caption-step {
  color: var(--text-secondary);
}

.demo-scene-in {
  animation: demo-scene-fade 0.5s ease both;
}

@keyframes demo-scene-fade {
  from {
    opacity: 0;
    transform: translateY(8px);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

/* ====================================================================== */
/* Scene 1: the Example PMS appointment book.                             */
/*                                                                        */
/* VENDOR-FAMILY SKIN, same posture as the phone below: a GP practice      */
/* lives in a Windows desktop appointment book all day, so this scene      */
/* earns its recognition from THAT visual language (window chrome, a time  */
/* gutter, per-practitioner columns, pastel status blocks) rather than     */
/* from our design system. It is reminiscent, never a copy — no vendor     */
/* wordmark, no exact palette, and the "Example PMS" badge stays on it.    */
/* Colour literals are confined to the two palette blocks below (marked    */
/* dark-mode-ok); every rule underneath reads var(--pms-*), so the mock    */
/* still follows the page's light/dark instead of burning a white slab     */
/* into a dark page.                                                       */
/* ====================================================================== */

/* The palette AND the desktop-app type scale, declared for the book and for a
   block that has been lifted out of it onto the fly layer (.demo-fly-block
   lives under <body>, outside .demo-pms, so it inherits none of this). One
   declaration, two scopes — a flying block paints itself in exactly the
   colours and at exactly the size it had in the book. */
.demo-pms,
.demo-fly-block {
  --pms-chrome: #eceff4; /* dark-mode-ok */
  --pms-chrome-edge: #b6bfcd; /* dark-mode-ok */
  --pms-menubar: #f6f8fb; /* dark-mode-ok */
  --pms-head: #dbe3f1; /* dark-mode-ok */
  --pms-gutter: #eef1f7; /* dark-mode-ok */
  --pms-cell: #ffffff; /* dark-mode-ok */
  --pms-grid: #c3cddd; /* dark-mode-ok */
  --pms-text: #1f2733; /* dark-mode-ok */
  --pms-text-dim: #556172; /* dark-mode-ok */
  --pms-note: #4b5768; /* dark-mode-ok */
  --pms-unavailable: #9aa3ae; /* dark-mode-ok */
  --pms-booked: #cfe0f5; /* dark-mode-ok */
  --pms-new: #fbeda0; /* dark-mode-ok */
  --pms-hero-ring: #1a4fa0; /* dark-mode-ok */

  color: var(--pms-text);
  /* A desktop app's UI font, not our display face — part of the recognition. */
  font-family: "Segoe UI", Tahoma, "Helvetica Neue", Arial, sans-serif;
  font-size: 11px;
  line-height: 1.25;
  text-align: left;
}

[data-theme="dark"] .demo-pms,
[data-theme="dark"] .demo-fly-block {
  --pms-chrome: #262b34; /* dark-mode-ok */
  --pms-chrome-edge: #3b4453; /* dark-mode-ok */
  --pms-menubar: #1e222a; /* dark-mode-ok */
  --pms-head: #2c3648; /* dark-mode-ok */
  --pms-gutter: #222831; /* dark-mode-ok */
  --pms-cell: #1a1e26; /* dark-mode-ok */
  --pms-grid: #39414f; /* dark-mode-ok */
  --pms-text: #e8ecf3; /* dark-mode-ok */
  --pms-text-dim: #8c99ab; /* dark-mode-ok */
  --pms-note: #b3bece; /* dark-mode-ok */
  --pms-unavailable: #4a515c; /* dark-mode-ok */
  --pms-booked: #26405c; /* dark-mode-ok */
  --pms-new: #5f5320; /* dark-mode-ok */
  --pms-hero-ring: #6ba6e1; /* dark-mode-ok */
}

.demo-pms {
  max-width: 660px;
  margin: 0 auto;
  border: 1px solid var(--pms-chrome-edge);
  border-radius: 6px;
  background: var(--pms-chrome);
  box-shadow: var(--shadow-lg);
  overflow: hidden;
  /* A window shows as much of the day as the screen has room for — the one
     scene that genuinely cannot shrink (12 quarter-hour rows at a legible size
     is 420px on its own) gives up ROWS rather than legibility. The column
     layout hands the leftover height to the book below, so the title bar, the
     badge and the legend (the three things that make it read as software)
     always survive, and the hero booking is the third row — above any cut a
     phone can make. */
  display: flex;
  flex-direction: column;
  max-height: var(--demo-body-budget, none);
}

/* --- Window chrome --- */
/* The window's own chrome never gives height — only the book does (it is the
   one part with rows to spare). Without this, a hard squeeze distributes the
   shrink across every flex child and the title bar, menu strip and legend get
   quietly crushed with it. */
.demo-pms-titlebar,
.demo-pms-menubar,
.demo-pms-legend {
  flex: 0 0 auto;
}

.demo-pms-titlebar {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 5px 8px;
  background: var(--pms-chrome);
  border-bottom: 1px solid var(--pms-chrome-edge);
}

.demo-pms-title {
  font-weight: 600;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.demo-pms-badge {
  flex: 0 0 auto;
  font-size: 9px;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-on-primary);
  background: var(--color-primary);
  border-radius: var(--radius-full);
  padding: 2px 7px;
}

.demo-pms-window {
  margin-left: auto;
  display: inline-flex;
  gap: 5px;
}

.demo-pms-window-btn {
  width: 9px;
  height: 9px;
  border-radius: 2px;
  border: 1px solid var(--pms-chrome-edge);
  background: var(--pms-menubar);
}

.demo-pms-menubar {
  display: flex;
  gap: 14px;
  padding: 4px 10px;
  background: var(--pms-menubar);
  border-bottom: 1px solid var(--pms-chrome-edge);
  color: var(--pms-note);
}

/* --- The book --- */
.demo-pms-book {
  display: grid;
  grid-template-columns: 56px repeat(3, minmax(0, 1fr));
  background: var(--pms-grid);
  gap: 1px;
  padding: 1px;
  flex: 1 1 auto;
  min-height: 0;
  overflow: hidden;
}

/* Only when the book is actually cut (demo-cinema.js sets the class after it
   applies the budget): the last visible rows fade out, so the cut reads as a
   day that continues below the fold rather than as a mock that ran out. */
/* Either kind of cut wears the fade; WHICH edges fade is one variable below.

   A mask's colour is not a colour: only its ALPHA is read, so the opaque stop
   has no theme to follow (it means "keep these pixels", in both themes). Hence
   the literal + marker rather than a token, whose alpha would then decide how
   solid the book is.

   Prefixed twin for iOS Safari < 15.4, squarely inside the audience this whole
   change is for. Without it those phones simply get a hard cut — the layout is
   identical either way. */
.demo-pms-book.is-clipped,
.demo-pms-book.is-scrolled {
  --book-fade: linear-gradient(to bottom, #000 calc(100% - 26px), transparent); /* dark-mode-ok */
  -webkit-mask-image: var(--book-fade);
  mask-image: var(--book-fade);
}

/* The book has also been scrolled (to keep the hero booking inside the cut, see
   keepHeroBookingVisible), so the day continues off BOTH edges and both say so.
   One gradient with four stops, overriding the VARIABLE the rule above reads:
   `mask-image` does not compose, so a second declaration adding a top fade would
   simply replace the bottom one and the deeper cut would become the abrupt end. */
.demo-pms-book.is-scrolled {
  --book-fade: linear-gradient(to bottom, transparent, #000 18px, #000 calc(100% - 26px), transparent); /* dark-mode-ok */
}

.demo-pms-col {
  display: flex;
  flex-direction: column;
  gap: 1px;
  min-width: 0;
}

.demo-pms-colhead {
  display: flex;
  align-items: baseline;
  gap: 5px;
  padding: 4px 6px;
  background: var(--pms-head);
  font-weight: 600;
  white-space: nowrap;
  overflow: hidden;
}

.demo-pms-doctor {
  overflow: hidden;
  text-overflow: ellipsis;
}

.demo-pms-coldate {
  font-weight: 400;
  color: var(--pms-note);
  font-size: 10px;
}

/* Every slot is the same height, in every column — that lock-step is what
   makes a book read as a book, and it keeps the columns aligned without any
   row arithmetic in the template. */
.demo-pms-slot,
.demo-pms-cell,
.demo-pms-appt {
  /* Tall enough for a block's second line (the bracketed reason a real book
     shows under the name) — at a single line's height those clipped, which is
     the one thing that reads as "mock" rather than as software. */
  height: 34px;
  padding: 3px 6px;
  background: var(--pms-cell);
  overflow: hidden;
}

.demo-pms-slot {
  background: var(--pms-gutter);
  color: var(--pms-note);
  font-variant-numeric: tabular-nums;
  text-align: right;
  font-size: 10px;
}

.demo-pms-appt-line {
  display: flex;
  gap: 6px;
  min-width: 0;
}

.demo-pms-appt-time {
  font-variant-numeric: tabular-nums;
}

.demo-pms-appt-name,
.demo-pms-appt-note {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.demo-pms-appt-note {
  display: block;
  font-size: 10px;
  color: var(--pms-note);
}

/* --- Status colours (the legend's vocabulary) ---
   A book showing TOMORROW can only carry pre-arrival states, so there are
   exactly three: booked, unavailable, and the hero's new booking. */
.demo-pms-appt.is-booked,
.demo-pms-legend-chip.is-booked {
  background: var(--pms-booked);
}

.demo-pms-appt.is-new,
.demo-pms-legend-chip.is-new {
  background: var(--pms-new);
}

.demo-pms-appt.is-unavailable,
.demo-pms-legend-chip.is-unavailable {
  background: var(--pms-unavailable);
}

/* --- Transition state: the book hands its bookings to Audri ---
   The choreography lives in CSS and the JS only decides WHEN.

   NB the bookings themselves are never restyled for the transition (no
   highlight ring, no recolour): they leave looking exactly as they did, and
   the ONLY thing that changes is what is around them. */

/* Everything that is CHROME rather than a booking recedes, so what flies out
   is unmistakably the day's appointments. */
.demo-pms.is-chrome-out .demo-pms-titlebar,
.demo-pms.is-chrome-out .demo-pms-menubar,
.demo-pms.is-chrome-out .demo-pms-legend,
.demo-pms.is-chrome-out .demo-pms-colhead,
.demo-pms.is-chrome-out .demo-pms-slot,
.demo-pms.is-chrome-out .demo-pms-cell,
.demo-pms.is-chrome-out .demo-pms-appt.is-unavailable {
  opacity: 0;
  transition: opacity 0.45s ease;
}

.demo-pms.is-chrome-out {
  background: transparent;
  border-color: transparent;
  box-shadow: none;
  transition:
    background-color 0.45s ease,
    border-color 0.45s ease,
    box-shadow 0.45s ease;
}

.demo-pms.is-chrome-out .demo-pms-book {
  background: transparent;
  transition: background-color 0.45s ease;
}

/* The blocks themselves are handed to the fly layer, so the originals go
   invisible in place (never display:none — that would collapse the grid the
   clones were measured against). */
.demo-pms.is-handed-off .demo-pms-appt {
  visibility: hidden;
}

/* The one row the story is about. */
.demo-pms-appt.is-hero {
  outline: 2px solid var(--pms-hero-ring);
  outline-offset: -2px;
  font-weight: 600;
}

/* --- Legend --- */
.demo-pms-legend {
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
  padding: 6px 8px;
  background: var(--pms-chrome);
  border-top: 1px solid var(--pms-chrome-edge);
}

.demo-pms-legend-label {
  color: var(--pms-note);
}

.demo-pms-legend-chip {
  padding: 2px 8px;
  border: 1px solid var(--pms-grid);
  font-size: 10px;
}

/* A phone can't hold three practitioners; drop the last column rather than
   squeeze every name into an ellipsis. */
@media (max-width: 560px) {
  .demo-pms-book {
    grid-template-columns: 48px repeat(2, minmax(0, 1fr));
  }

  .demo-pms-col:last-child {
    display: none;
  }
}

/* ====================================================================== */
/* Scene 2: Audri fans out to every patient.                              */
/* ====================================================================== */

/* Scene 2 is the only scene SHORTER than the frame (a diagram, where the others
   are a full appointment book or a handset), so it is the only one that has to
   say where it sits in the leftover height: centred, not stranded at the top of
   200px of nothing.

   Scoped to this scene on purpose. `.demo-scene-body` is a BLOCK for everyone
   else because the appointment book carries `margin: 0 auto`, and auto
   cross-axis margins switch off `stretch` — the book then sizes to fit-content
   with a min-content floor and its nowrap titlebar forces a horizontal overflow
   (caught once by the mobile flow audit). `.demo-fanout` has no auto margin, so
   it stretches normally and the trap does not apply here. */
.demo-scene[data-demo-scene="audri"] .demo-scene-body {
  display: flex;
  flex-direction: column;
  justify-content: center;
}

.demo-fanout {
  flex: 0 1 auto;
}

.demo-fanout-stage {
  position: relative;
  max-width: 560px;
  /* Tall enough that the outer phones (at 18%/82%, half a phone tall each) sit
     fully inside the box rather than bleeding past its edges. Between those
     bounds it takes the budget the stage was given, so the diagram uses the
     same frame every other scene does instead of leaving a hole under itself. */
  --fan-stage-h: clamp(
    180px,
    calc(var(--demo-body-budget, 238px) - var(--space-sm)),
    380px
  );
  height: var(--fan-stage-h);
  margin: 0 auto var(--space-sm);

  /* The three objects' sizes, declared once: the phone's offset below is
     derived from them, so a resize (see the narrow breakpoint) re-balances the
     stage on its own. The phone's HEIGHT tracks the stage's, because the phones
     sit 32 percentage points apart: a fixed 66px phone in a shortened stage
     overlaps its neighbour into one blob. */
  --fan-mark-size: 66px;
  --fan-phone-w: 38px;
  --fan-phone-h: min(66px, calc(var(--fan-stage-h) * 0.28));
}

.demo-fanout-wires {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

.demo-wire {
  fill: none;
  stroke: var(--color-primary);
  stroke-width: 2;
  stroke-linecap: round;
  opacity: 0.55;
}

.demo-fanout-mark,
.demo-fanout-phone {
  position: absolute;
  transform: translate(-50%, -50%);
}

/* Mark + phones sit on the SAME percentages as the wire endpoints. */
.demo-fanout-mark {
  left: 12%;
  top: 50%;
  width: var(--fan-mark-size);
  height: var(--fan-mark-size);
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  background: var(--bg-elevated);
  border: 1px solid var(--border-color);
  box-shadow: var(--shadow-md);
}

.demo-fanout-mark-img {
  width: 40px;
  height: 40px;
}

/* 88% mirrors the mark's 12%, so the wire anchors are symmetric about the
   stage. The mark is the WIDER object, though, so anchoring alone leaves a
   fatter gutter on the right; nudging the phones out by half that difference
   makes the two visual margins equal at any width (and at the narrow
   breakpoint, where the mark shrinks, the nudge shrinks with it). The wire
   then stops just inside the phone's left half — under the phone, which
   paints over it, so it still reads as landing ON it. */
.demo-fanout-phone {
  left: calc(88% + (var(--fan-mark-size) - var(--fan-phone-w)) / 2);
  width: var(--fan-phone-w);
  height: var(--fan-phone-h);
  border-radius: 8px;
  background: var(--bg-elevated);
  border: 1px solid var(--border-strong);
  box-shadow: var(--shadow-sm);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-start;
  gap: 5px;
  padding-top: 12px;
}

/* These three MUST match the wire endpoints in _demo_fanout.html: each phone
   sits ON the end of the line drawn to it, evenly split about the axis
   (18/50/82 — 32 points between neighbours, and the same 32 from the hero to
   each outer phone).

   Keyed by an EXPLICIT class, never :nth-of-type: the stage's mark is a div
   as well, so type counting matched the phones one slot late — the hero took
   the bottom wire and the third phone, matching no rule at all, fell back to
   its static position, leaving the top wire ending in empty space. */
.demo-fanout-phone.is-upper {
  top: 18%;
}

.demo-fanout-phone.is-axis {
  top: 50%;
}

.demo-fanout-phone.is-lower {
  top: 82%;
}

.demo-fanout-phone-line {
  width: 20px;
  height: 6px;
  border-radius: var(--radius-full);
  background: var(--color-primary-bg);
}

/* The message that just landed. SOLID, where the line above it is a tint, so a
   phone that has been reached reads differently from an empty one at a glance
   — the whole point of the scene.

   It belongs to the scene's STEADY state (visible with no classes on the
   stage), like everything else in this partial: land here by pressing Back, or
   with reduced motion, and all three phones simply hold a message. The rules
   below only choreograph its REVEAL. */
.demo-fanout-phone-line.is-message {
  background: var(--color-primary);
}

.demo-fanout-phone[data-demo-mini-hero] {
  border-color: var(--color-primary);
}

/* Pre-arrival state, set by the driver before it animates in: the wires are
   undrawn and the phones absent, so only the mark is there to receive the
   bookings. Removing the class plays the reveal.

   The wires are "drawn" with a left-to-right CLIP, not a dash offset: the
   viewBox is deliberately non-uniform (percentage endpoints that track the
   mark and phones at any width), and a dash pattern in that space renders as
   broken segments of unequal length rather than as one advancing line. */
.demo-fanout.is-arriving .demo-fanout-wires {
  clip-path: inset(0 100% 0 0);
}

.demo-fanout.is-arriving .demo-fanout-phone {
  opacity: 0;
}

.demo-fanout.is-connected .demo-fanout-wires {
  clip-path: inset(0 0 0 0);
  transition: clip-path 0.65s ease-out;
}

.demo-fanout.is-connected .demo-fanout-phone {
  opacity: 1;
  transition: opacity 0.45s ease 0.35s;
}

/* No message until its signal actually arrives — through the phones' fade-in
   AND the whole flight of the dots. Without the `is-connected` half, every
   phone would show its message the moment it appeared and the dots would be
   delivering something that had already been delivered. */
.demo-fanout.is-arriving .demo-fanout-phone-line.is-message,
.demo-fanout.is-connected .demo-fanout-phone-line.is-message {
  opacity: 0;
  transform: scale(0.4);
}

/* Delivery. The driver adds .is-received to a phone the instant ITS dot lands
   (staggered, so the three arrive in turn); resetSceneState() takes the class
   off again, which is what hands the steady state back to the rules above. */
.demo-fanout-phone.is-received .demo-fanout-phone-line.is-message {
  opacity: 1;
  transform: none;
  animation: demo-msg-pop 0.42s cubic-bezier(0.2, 1.4, 0.4, 1) both;
}

/* The phone reacts too — a buzz in the hand. Every keyframe has to restate the
   translate(-50%, -50%) that POSITIONS the phone: transform is one property, so
   a keyframe that only rotates would drop the centring and fling the phone off
   its wire for the length of the animation. */
.demo-fanout-phone.is-received {
  animation: demo-phone-buzz 0.5s ease-out;
}

@keyframes demo-msg-pop {
  0% {
    opacity: 0;
    transform: translateY(-7px) scale(0.4);
  }

  55% {
    opacity: 1;
    transform: translateY(1px) scale(1.25);
  }

  78% {
    transform: translateY(0) scale(0.93);
  }

  100% {
    opacity: 1;
    transform: none;
  }
}

@keyframes demo-phone-buzz {
  0% {
    transform: translate(-50%, -50%) rotate(0);
    box-shadow: var(--shadow-sm);
  }

  30% {
    transform: translate(-50%, -50%) rotate(-4deg);
    box-shadow:
      var(--shadow-sm),
      0 0 0 6px var(--color-primary-bg);
  }

  60% {
    transform: translate(-50%, -50%) rotate(3deg);
  }

  82% {
    transform: translate(-50%, -50%) rotate(-1.5deg);
  }

  100% {
    transform: translate(-50%, -50%) rotate(0);
    box-shadow: var(--shadow-sm);
  }
}

/* The mark takes the delivery. */
.demo-fanout-mark.is-pulsing {
  animation: demo-mark-pulse 0.5s ease-out 2;
}

@keyframes demo-mark-pulse {
  0% {
    box-shadow: var(--shadow-md);
  }

  50% {
    box-shadow:
      var(--shadow-md),
      0 0 0 10px var(--color-primary-bg);
  }

  100% {
    box-shadow: var(--shadow-md);
  }
}

@media (max-width: 560px) {
  .demo-fanout-stage {
    /* Height is not reduced HERE (it follows the budget above instead): the
       tour's frame is pinned to its tallest scene, so shortening the fan-out by
       breakpoint would only leave more empty reservation. */
    --fan-mark-size: 54px;
  }

  .demo-fanout-mark-img {
    width: 30px;
    height: 30px;
  }
}

/* ====================================================================== */
/* Scene 3: the patient's phone receiving the real SMS.                    */
/*                                                                        */
/* VENDOR SKIN — deliberately NOT design tokens. The point of this scene   */
/* is that the prospect recognises their own Messages app, so the palette  */
/* below is Apple's / Google's, not Audri's: re-skinning it with our brand */
/* tokens would destroy the only thing it communicates. Every literal is   */
/* confined to the four palette blocks that follow (light/dark × iOS/      */
/* Android) and marked dark-mode-ok; ALL chrome rules underneath read      */
/* var(--phone-*), so nothing downstream ever hardcodes a colour and the   */
/* mock still follows the page's light/dark like the real app follows the  */
/* phone's. A third skin is a palette block, not a template fork. */
/* ====================================================================== */

/* iOS Messages — light */
.demo-phone {
  --phone-screen-bg: #ffffff; /* dark-mode-ok */
  --phone-bar-bg: #f7f7f7cc; /* dark-mode-ok */
  --phone-hairline: #d1d1d6; /* dark-mode-ok */
  --phone-text: #000000; /* dark-mode-ok */
  --phone-muted: #8e8e93; /* dark-mode-ok */
  --phone-bubble-bg: #e9e9eb; /* dark-mode-ok */
  --phone-bubble-text: #000000; /* dark-mode-ok */
  --phone-link: #007aff; /* dark-mode-ok */
  --phone-accent: #007aff; /* dark-mode-ok */
  /* iOS's own avatar grey is lighter, but white initials on it measure 1.7:1 —
     the visual audit is right to flag it, and the darker end of the same grey
     ramp reads identically at this size. */
  --phone-avatar-bg: #8e8e93; /* dark-mode-ok */
  --phone-avatar-text: #ffffff; /* dark-mode-ok */
  --phone-field-bg: #ffffff; /* dark-mode-ok */
  --phone-field-border: #d1d1d6; /* dark-mode-ok */
  --phone-home-bar: #00000059; /* dark-mode-ok */
  --phone-bezel: #1c1c1e; /* dark-mode-ok */
}

/* iOS Messages — dark */
[data-theme="dark"] .demo-phone {
  --phone-screen-bg: #000000; /* dark-mode-ok */
  --phone-bar-bg: #1c1c1ecc; /* dark-mode-ok */
  --phone-hairline: #38383a; /* dark-mode-ok */
  --phone-text: #ffffff; /* dark-mode-ok */
  --phone-muted: #8e8e93; /* dark-mode-ok */
  --phone-bubble-bg: #3a3a3c; /* dark-mode-ok */
  --phone-bubble-text: #ffffff; /* dark-mode-ok */
  --phone-link: #0a84ff; /* dark-mode-ok */
  --phone-accent: #0a84ff; /* dark-mode-ok */
  --phone-avatar-bg: #545458; /* dark-mode-ok */
  --phone-avatar-text: #ffffff; /* dark-mode-ok */
  --phone-field-bg: #1c1c1e; /* dark-mode-ok */
  --phone-field-border: #38383a; /* dark-mode-ok */
  --phone-home-bar: #ffffff8c; /* dark-mode-ok */
  --phone-bezel: #000000; /* dark-mode-ok */
}

/* Google Messages (Material 3) — light */
.demo-phone[data-demo-os="android"] {
  --phone-screen-bg: #ffffff; /* dark-mode-ok */
  --phone-bar-bg: #ffffff; /* dark-mode-ok */
  --phone-hairline: #e3e3e3; /* dark-mode-ok */
  --phone-text: #1f1f1f; /* dark-mode-ok */
  --phone-muted: #5f6368; /* dark-mode-ok */
  --phone-bubble-bg: #e8eaed; /* dark-mode-ok */
  --phone-bubble-text: #1f1f1f; /* dark-mode-ok */
  --phone-link: #0b57d0; /* dark-mode-ok */
  --phone-accent: #0b57d0; /* dark-mode-ok */
  --phone-avatar-bg: #0b57d0; /* dark-mode-ok */
  --phone-avatar-text: #ffffff; /* dark-mode-ok */
  --phone-field-bg: #f1f3f4; /* dark-mode-ok */
  --phone-field-border: #f1f3f4; /* dark-mode-ok */
  --phone-home-bar: #1f1f1f66; /* dark-mode-ok */
  --phone-bezel: #202124; /* dark-mode-ok */
}

/* Google Messages (Material 3) — dark */
[data-theme="dark"] .demo-phone[data-demo-os="android"] {
  --phone-screen-bg: #131314; /* dark-mode-ok */
  --phone-bar-bg: #131314; /* dark-mode-ok */
  --phone-hairline: #2d2f31; /* dark-mode-ok */
  --phone-text: #e3e3e3; /* dark-mode-ok */
  --phone-muted: #9aa0a6; /* dark-mode-ok */
  --phone-bubble-bg: #303134; /* dark-mode-ok */
  --phone-bubble-text: #e3e3e3; /* dark-mode-ok */
  --phone-link: #a8c7fa; /* dark-mode-ok */
  --phone-accent: #a8c7fa; /* dark-mode-ok */
  --phone-avatar-bg: #a8c7fa; /* dark-mode-ok */
  --phone-avatar-text: #062e6f; /* dark-mode-ok */
  --phone-field-bg: #1f2023; /* dark-mode-ok */
  --phone-field-border: #1f2023; /* dark-mode-ok */
  --phone-home-bar: #e3e3e380; /* dark-mode-ok */
  --phone-bezel: #000000; /* dark-mode-ok */
}

/* --- Device ---
   ONE handset, declared once. Scenes 3 and 4 are the SAME phone in the
   visitor's hands — the message arrives on it, then the intake runs on it — so
   a step change must not resize it. It used to: the SMS phone was 320px wide
   and as tall as its content (354px), the live one 360×~536, and stepping
   between them swapped one device for a bigger one. The screen is therefore a
   FIXED box both scenes fill, not a box each scene sizes. */
.demo-phone {
  --phone-w: 360px;
  /* The handset is the scene that CAN shrink gracefully — a thread and a chat
     both just show less at once — so it takes the stage's budget and gives the
     appointment book its rows back. `--phone-screen-max` is the natural size
     (the desktop look); the clamp only bites when the device has less. The 16px
     is the bezel this element's own padding adds around the screen. */
  --phone-screen-max: 520px;
  /* NB `--phone-bezel` (the four palette blocks above) is the bezel's COLOUR.
     This is its WIDTH, and it needs its own name: reusing that one made the
     handset's height a calc() over a hex literal under the dark palette, whose
     higher specificity wins — a garbage length that silently collapsed both
     handset scenes to a 305px screen, and cost the light theme its black bezel
     for good measure (pinned by `test_every_declared_property_keeps_one_kind`). */
  --phone-bezel-w: 8px;
  --phone-screen-h: clamp(
    280px,
    calc(var(--demo-body-budget, 9999px) - 2 * var(--phone-bezel-w)),
    var(--phone-screen-max)
  );
  width: var(--phone-w);
  max-width: 92%;
  margin: 0 auto;
  padding: var(--phone-bezel-w);
  border-radius: 46px;
  background: var(--phone-bezel);
  box-shadow: var(--shadow-lg);
  /* The bezel is the device, not a card: keep our own border off it. */
  font-size: 14px;
  line-height: 1.4;
}

.demo-phone[data-demo-os="android"] {
  border-radius: 34px;
  /* One owner: the screen's clamp above subtracts whatever this is, so the two
     phones fill the same budget rather than one of them being 4px short. */
  --phone-bezel-w: 6px;
}

/* A handset on its way out while the next step's identical handset dissolves in
   underneath it (see swapPhoneScreen). It keeps its LAYOUT — the screen must
   stay exactly where it is — but paints no frame: the device is drawn once, by
   the incoming phone, so the bezel cannot double and the semi-transparent
   shadow cannot stack into a dark ring around it. */
.demo-phone.is-frameless {
  background: none;
  box-shadow: none;
}

/* The same rule one level in, and it takes BOTH of these to be right.

   The status bar is DEVICE chrome (one partial, both phone scenes), so the
   incoming handset already paints it pixel for pixel underneath. Painting the
   outgoing copy on top of it at partial alpha thickens every glyph edge — text
   antialiasing is semi-transparent, so the clock and icons appear to bolden and
   un-bolden across the step. Hence `visibility: hidden` (not `display`, so
   nothing below it moves).

   But hiding it alone leaves the outgoing SCREEN's own opaque background
   covering the incoming status bar, which washes the whole band pale for the
   length of the dissolve. So the outgoing screen drops its background too and
   paints only its CONTENT: the app underneath shows through, the device and its
   status bar are painted exactly once, and the dissolve is confined to the one
   thing that actually differs between the two steps. */
.demo-phone.is-frameless .demo-phone-screen {
  background: none;
}

.demo-phone.is-frameless .demo-phone-statusbar {
  visibility: hidden;
}

.demo-phone-screen {
  display: flex;
  flex-direction: column;
  height: var(--phone-screen-h);
  border-radius: 38px;
  overflow: hidden;
  background: var(--phone-screen-bg);
  color: var(--phone-text);
}

/* --- The live-intake handset (scene 4) ---
   Same device as the SMS phone, same size: it just fills the screen with the
   conversation instead of a thread, streamed by the replay player into the REAL
   chat bubbles (intake.css). The transcript is the only thing that scrolls, and
   it scrolls inside the handset, which is what a phone does. */
.demo-phone-chat {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  min-height: 0;
  background: var(--phone-screen-bg);
}

.demo-phone-messages {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  /* Bubbles come from cross-surface.css; this is only the phone-sized gutter. */
  padding: 12px;
  gap: 10px;
}

/* The transport sits on the handset's own chrome, not the page's. */
.demo-phone-chat .demo-replay-controls {
  flex: 0 0 auto;
  margin: 0;
  padding: 10px 12px calc(10px + env(safe-area-inset-bottom, 0px));
  border-top: 1px solid var(--phone-hairline);
}

@media (max-width: 560px) {
  /* One number, still: a shorter screen on a narrow viewport — for BOTH scenes,
     which is the point of the variable. The clamp above may take it lower on a
     short one; it never goes above this. */
  .demo-phone {
    --phone-screen-max: 470px;
  }
}

/* A handset short enough that its own chrome starts eating the message.
 *
 * Set by demo-cinema.js (the one place that knows the budget) when the screen
 * clamps below the height the SMS needs. Everything trimmed here is DEVICE
 * decoration — bar paddings, the gesture pill — never the thread: the whole
 * point of the scene is the real reminder body, and a phone showing three
 * quarters of it is a worse mock than a phone with a tighter status bar. ~45px,
 * which is the difference between the message fitting and not. */
.demo-phone.is-compact .demo-phone-statusbar {
  padding: 5px 18px 3px;
}

.demo-phone.is-compact .demo-phone-appbar {
  padding-top: 3px;
  padding-bottom: 5px;
}

.demo-phone[data-demo-os="ios"].is-compact .demo-phone-back {
  top: 10px;
}

.demo-phone.is-compact .demo-phone-thread {
  padding: 8px 12px 6px;
}

.demo-phone.is-compact .demo-phone-composer {
  padding: 5px 12px 4px;
}

.demo-phone.is-compact .demo-phone-home {
  margin: 3px auto 4px;
}

.demo-phone[data-demo-os="android"] .demo-phone-screen {
  border-radius: 28px;
}

/* --- Status bar --- */
.demo-phone-statusbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 10px 20px 6px;
  font-size: 13px;
  font-weight: 600;
  color: var(--phone-text);
}

.demo-phone-clock {
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.01em;
}

/* Clock and icons take EQUAL shares of the leftover space (basis 0, same grow),
   which is what actually CENTRES the island between them. `space-between` alone
   only parks the middle item wherever two unequal side items leave it — "9:41"
   is 25px and the three status glyphs are 68px, so the island sat 21px left of
   centre on every phone in the tour. */
.demo-phone-clock {
  flex: 1 1 0;
  min-width: 0;
}

.demo-phone-status-icons {
  flex: 1 1 0;
  min-width: 0;
  justify-content: flex-end;
}

/* iOS: the Dynamic Island sits between clock and icons. Android: a
   hole-punch camera dot, centred the same way. */
.demo-phone-camera {
  flex: 0 0 auto;
  width: 78px;
  height: 22px;
  border-radius: 999px;
  background: var(--phone-bezel);
}

[data-theme="dark"] .demo-phone-camera {
  /* On a black screen the island reads only as a subtle outline. */
  box-shadow: inset 0 0 0 1px var(--phone-hairline);
}

.demo-phone[data-demo-os="android"] .demo-phone-camera {
  width: 10px;
  height: 10px;
  border-radius: 50%;
}

.demo-phone-status-icons {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  color: var(--phone-text);
}

.demo-phone-icon {
  display: block;
  fill: currentColor;
}

.demo-phone-icon-signal {
  width: 17px;
  height: 11px;
}

.demo-phone-icon-wifi {
  width: 16px;
  height: 12px;
}

.demo-phone-icon-battery {
  width: 25px;
  height: 12px;
}

/* --- App bar --- */
.demo-phone-appbar {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 6px 14px 10px;
  background: var(--phone-bar-bg);
  border-bottom: 0.5px solid var(--phone-hairline);
}

.demo-phone-back {
  display: inline-flex;
  color: var(--phone-accent);
}

.demo-phone-back .demo-phone-icon {
  width: 11px;
  height: 18px;
  fill: none;
}

.demo-phone-avatar {
  flex: 0 0 auto;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: var(--phone-avatar-bg);
  color: var(--phone-avatar-text);
  font-size: 14px;
  font-weight: 600;
}

.demo-phone-contact {
  display: inline-flex;
  align-items: center;
  gap: 3px;
  min-width: 0;
  color: var(--phone-text);
}

.demo-phone-contact-name {
  font-size: 13px;
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.demo-phone-icon-chevron {
  width: 7px;
  height: 12px;
  fill: none;
  color: var(--phone-muted);
}

.demo-phone-appbar-actions {
  margin-left: auto;
  display: inline-flex;
  align-items: center;
  gap: 14px;
  color: var(--phone-accent);
}

.demo-phone-appbar-actions .demo-phone-icon {
  width: 19px;
  height: 19px;
}

/* iOS stacks avatar + name vertically and shows no call/video icons in the
   Messages header; Android puts the name beside the avatar with call + video
   actions and a plain back arrow. */
.demo-phone[data-demo-os="ios"] .demo-phone-appbar {
  flex-direction: column;
  gap: 3px;
  position: relative;
  padding-bottom: 8px;
}

.demo-phone[data-demo-os="ios"] .demo-phone-back {
  position: absolute;
  left: 14px;
  top: 16px;
}

.demo-phone[data-demo-os="ios"] .demo-phone-avatar {
  width: 38px;
  height: 38px;
  font-size: 16px;
}

.demo-phone[data-demo-os="ios"] .demo-phone-contact-name {
  font-size: 12px;
  font-weight: 500;
}

.demo-phone[data-demo-os="ios"] .demo-phone-appbar-actions,
.demo-phone[data-demo-os="ios"] .demo-phone-icon-back-android,
.demo-phone[data-demo-os="android"] .demo-phone-icon-back-ios,
.demo-phone[data-demo-os="android"] .demo-phone-icon-chevron,
.demo-phone[data-demo-os="android"] .demo-phone-thread-kind {
  display: none;
}

.demo-phone[data-demo-os="android"] .demo-phone-back .demo-phone-icon {
  width: 20px;
  height: 20px;
  color: var(--phone-text);
}

/* --- Thread ---
   Fills whatever the fixed screen leaves between the app bar and the composer,
   so the composer sits at the bottom of the handset like a real one rather than
   riding up under a one-message conversation. */
.demo-phone-thread {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  padding: 14px 14px 10px;
  background: var(--phone-screen-bg);
}

.demo-phone-thread-time {
  margin: 0 0 8px;
  text-align: center;
  font-size: 11px;
  color: var(--phone-muted);
}

.demo-phone-thread-kind {
  font-weight: 600;
  margin-right: 4px;
}

.demo-sms-bubble {
  max-width: 88%;
  /* The stage centres its captions; a message bubble never centres its text. */
  text-align: left;
  padding: 9px 13px;
  border-radius: 19px;
  border-bottom-left-radius: 5px;
  background: var(--phone-bubble-bg);
  color: var(--phone-bubble-text);
  font-size: 14px;
  line-height: 1.35;
  word-break: break-word;
}

/* pre-wrap belongs to the BODY span, not the bubble: on the bubble it also
   preserved the template's own indentation, which rendered as a phantom
   first-line indent inside the message. */
.demo-sms-body {
  white-space: pre-wrap;
}

.demo-phone[data-demo-os="android"] .demo-sms-bubble {
  border-radius: 20px;
  border-bottom-left-radius: 5px;
}

.demo-sms-link {
  color: var(--phone-link);
  text-decoration: underline;
}

/* --- Composer --- */
.demo-phone-composer {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px 6px;
  color: var(--phone-muted);
}

.demo-phone-composer-icon .demo-phone-icon {
  width: 18px;
  height: 18px;
}

.demo-phone-composer-field {
  flex: 1 1 auto;
  min-width: 0;
  padding: 7px 12px;
  border-radius: 999px;
  background: var(--phone-field-bg);
  border: 1px solid var(--phone-field-border);
  color: var(--phone-muted);
  font-size: 13px;
}

/* The placeholder is the loudest tell of which app you are looking at, so it
   lives in the skin rather than in the markup. */
.demo-phone[data-demo-os="ios"] .demo-phone-composer-field::after {
  content: "Text Message";
}

.demo-phone[data-demo-os="android"] .demo-phone-composer-field::after {
  content: "Text message";
}

/* --- Home / gesture bar --- */
.demo-phone-home {
  height: 5px;
  width: 36%;
  margin: 6px auto 8px;
  border-radius: 999px;
  background: var(--phone-home-bar);
}

.demo-phone[data-demo-os="android"] .demo-phone-home {
  width: 30%;
  height: 4px;
}

/* --- Replay bubble transitions (scene 5 / /play) --- */
@media (prefers-reduced-motion: reduce) {
  .demo-scene-in {
    animation: none;
    transition: none;
    opacity: 1;
    transform: none;
  }
}

/* --- Finishing the tour: point at the intake on the real dashboard ---
   The last step scrolls this row into view and flashes it, so the visitor sees
   WHERE the intake they just watched lives in the product. Two pulses, then
   back to the row's own styling — no `forwards` fill, because the row underneath
   may carry its own highlight (.clin-appt-next). */
.demo-row-flash {
  border-radius: var(--radius-sm);
  /* Three shorter pulses rather than two long fades: on a wide desktop row the
     eye has a lot of page to scan, and a slow tint-and-fade reads as the page
     settling rather than as "look here". */
  animation: demo-row-flash 1.05s ease-out 3;
}

@keyframes demo-row-flash {
  0% {
    background-color: transparent;
    box-shadow: 0 0 0 0 transparent;
  }

  30% {
    /* A solid primary RING (not just the faint --color-primary-bg tint) plus a
     wider soft halo: visible against both themes and against a row that may
     already carry .clin-appt-next's own highlight. */
    background-color: color-mix(in srgb, var(--color-primary) 18%, transparent);
    box-shadow:
      0 0 0 3px var(--color-primary),
      0 0 0 10px color-mix(in srgb, var(--color-primary) 30%, transparent);
  }

  100% {
    background-color: transparent;
    box-shadow: 0 0 0 0 transparent;
  }
}

@media (prefers-reduced-motion: reduce) {
  /* A steady ring instead of a pulse. The driver removes the class on a timer,
     so this does not become permanent. */
  .demo-row-flash {
    animation: none;
    box-shadow: 0 0 0 2px var(--color-primary);
  }
}

/* The "click here" pointer the tour leaves on the row it wants opened.

   The flash fades after a couple of seconds — long enough to say "look here",
   too short to say "now do something". This stays until the visitor actually
   clicks (see finishTour), because a pointer that disappears before it is acted
   on is worse than no pointer.

   Positioned by the DRIVER adding .demo-row-point to the row, never by markup:
   the appointment row is the real clinic macro, shared with the live dashboard,
   and a demo affordance has no business in it. */
.demo-row-point {
  position: relative;
}

.demo-click-here {
  position: absolute;
  z-index: 1;
  /* Straddles the row's top edge rather than sitting inside it: the row's
     top-left is the appointment time, and a pill placed in there covered it at
     some widths. */
  top: -0.75em;
  left: var(--space-md);
  padding: 0.1rem 0.55rem;
  border-radius: var(--radius-full);
  background: var(--color-primary);
  color: var(--text-on-primary);
  font-size: var(--text-xs);
  font-weight: 700;
  white-space: nowrap;
  box-shadow: var(--shadow-sm);
  pointer-events: none;
  animation: demo-point-bob 1.4s ease-in-out infinite;
}

@keyframes demo-point-bob {
  0%,
  100% {
    transform: translateY(0);
  }

  50% {
    transform: translateY(-2px);
  }
}

@media (prefers-reduced-motion: reduce) {
  .demo-click-here {
    animation: none;
  }
}

/* --- Replay page chrome (demo/play.html) ---
   The replay borrows the real chat shell, but its footer is a disclaimer
   rather than a composer, so it uses `.chat-input-area` — a class the demo
   introduced and never styled. Unstyled, it collapsed to a bare block: both
   disclaimers rendered hard against the viewport edge while every message
   bubble sat inside `.chat-messages`' 20px padding. Mirrors
   `.chat-input-container` so the replay's footer sits where the composer
   does on the real chat page. */
.chat-input-area {
  flex: 0 0 auto;
  padding: 12px;
  padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
  background: var(--bg-primary);
  border-top: 1px solid var(--border-color);
}

/* Both replay disclaimers (the footer note, and the voice-modality note that
   sits above the transcript). Small, muted, and inset to the transcript's
   gutter so they read as chrome rather than as a message. */
.chat-shell .intake-disclaimer {
  margin: 0;
  font-size: var(--text-xs);
  line-height: 1.45;
  color: var(--text-secondary);
}

#demo-voice-note {
  flex: 0 0 auto;
  padding: 10px 20px 0;
}
