/* bubbleup.gg/demo — the same surface as the landing page, scrolled.
 *
 * This file is an ADDENDUM to style.css, never a replacement: the palette, the
 * mono stack, the ground (two radial glows + hairline grid), the three-dot mark
 * and the chip all come from there unchanged, so the two pages cannot drift
 * apart by editing one of them. What is here is only what a document that
 * SCROLLS needs and a single fixed card did not. */

/* The landing is one viewport with a physics field in it and pins a card into
 * the corners, so it locks scrolling. This page is a page. */
html,
body {
  height: auto;
  min-height: 100%;
}
body {
  overflow: visible;
}

/* ---------- the page's furniture, sized by the room it has ----------
 *
 * FINDING 49 put a `--chrome` here — `clamp(10px, 1.2vh, 18px)` — and made
 * every piece of furniture on this page a multiple of it. FINDING 94 kept the
 * multiples and took the number: it is `--ui` in `style.css` now, shared with
 * the other four pages, because this page's `--chrome` and the landing's were
 * two different answers to one question and the BEFORE table in
 * `docs/superpowers/specs/2026-09-12-lane-site-scale-report.md` is what they
 * measured. `--ui`'s own comment carries finding 49's argument, including the
 * `1.2vh` term, which survives inside it as the height half of the slope so
 * that nothing on THIS page ever gets smaller than it is today.
 *
 * What is left here is only what is this page's alone: `--gap`, on `.wrap`
 * below, and the short-window rhythm at the foot of this file. (What USED to
 * be named here was the contact pill's stand-off from the framed app's
 * doorbell, finding 57 — retired by FINDING 101, which put the pill in flow, and
 * by FINDING 142, which put it in `#corner-br` on the field itself, where there
 * is no clearance left to keep. The `.head` block below is where that history is written down.) The
 * head's mark, wordmark, chip and cluster gap are `style.css`'s `.brand` /
 * `.chip` / `.head-end` rules, which are the same four objects the other three
 * document pages carry. */

/* `body::before` is the ground and stays `position: fixed` from style.css —
 * the grid holds still under the scroll, which is what an instrument panel
 * does. Content rides above it.
 *
 * With NO z-index, deliberately. `z-index: 2` used to be here and it did
 * nothing this page needs: the ground is `body::before` at z-index 0 and it is
 * EARLIER in tree order, so a positioned `.wrap` at `auto` already paints over
 * it. What the 2 did do was trap every pane inside a stacking context two
 * layers below the then-fixed contact pill (40), which is why the pane that
 * holds the Twitch player could not be lifted over it. The pill has left that
 * corner since (finding 101) and the per-pane lift went with it, but the
 * contact SHEET's backdrop is still at 45 and `body.cu-veil #stage` is still
 * lifted over it — so an outer stacking context here would still be a trap.
 * See "nothing may cover the player" below. */

/* The page's ONE width. Everything that has to line up — the head's rule, the
 * field's two edges, the foot's rule — is inside `.wrap`, so there
 * is a single number to reason about and they cannot come apart.
 *
 * It used to be a flat 1280px, which is a laptop's number pinned to a wall: on
 * a 2549px screen the two panes sat inside the middle half with the bubbles too
 * small to read and the whole bottom of the page empty. So the cap grows with
 * the viewport instead, between three bounds:
 *
 *   floor  1280px  — never NARROWER than it was. Below ~1391px this is what
 *                    wins, so every width from the 1200px breakpoint up to a
 *                    laptop is byte-for-byte the layout that was walked.
 *   track  92vw    — 4% of air down each side. Enough that the page reads as a
 *                    page and not a browser-chrome-to-chrome bleed, and well
 *                    clear of a classic 15px scrollbar at any of these widths.
 *   cap    2200px  — a 16:9 pane is 1071px here, which is a 602px-tall stream
 *                    and a bubble field the same size. Past that the two panes
 *                    stop being a pair you can hold in one look.
 *
 * The fourth term is a guard, not a shape: the panes get TALLER as they get
 * wider (both frames are 16:9), so on a short-and-wide window a full-width
 * layout would slide the bubbleup pane's bottom edge past what the viewport
 * holds. Width is therefore also capped at whatever 16:9 leaves room above the
 * bottom — 290px of top chrome at its largest, 52px for the row under the
 * panes, 22px of air, and the ratio run backwards through the gutter and the
 * panes' own borders (a pane is (W - 22) / 2 wide). On anything ~825px tall or
 * more it never binds.
 *
 * FINDING 101 CHANGED WHAT IT IS A GUARD AGAINST, and not one number in it. It
 * was derived when the contact pill was fixed 18px off the bottom right of the
 * viewport: those 52px were the PILL's box plus its inset, and the term existed
 * so the pill and the framed app could never share a rectangle. Nothing is
 * fixed over this page any more, so what the term now reserves is the OBS row
 * itself — and it does that slightly under strength: the row is as tall as its
 * tallest control, which is the `Add to OBS` button at 59.4px at 1920x1080
 * (measured 2026-09-13), not 52. THAT IS AFFORDABLE AND IT IS DELIBERATE. The
 * failure it used to prevent was a fixed pill painted over a live player;
 * the failure it can now produce is ~7px of scroll on a window short enough for
 * the term to bind at all (~825px tall and under). And the value is not this
 * file's to move: it has been `--head-cap` in style.css since finding 98 round
 * 2, so /pricing, /phone and /privacy stand their headers in a band of exactly
 * this width, and re-deriving it would move three other pages to buy this one
 * seven pixels. */
/* FINDING 142 ADDED THE OTHER AXIS. The width above is unchanged; what is new
 * is that this box is a COLUMN at least one viewport tall, so `#stage` can take
 * whatever the head, the foot and this padding leave instead of being told a
 * height. Kevin asked for a page that "can dynamically scale vertically way
 * lower, only needing to show the bottom bubbleup.gg watch-chat-converge
 * tagline thing"; letting the chrome measure itself is how that is spent
 * without a `100dvh - 331px` in this file that four clamps and a header's
 * line-height can quietly falsify. The 16:9 guard inside `--head-cap` is now
 * slack rather than binding — nothing on this page is two 16:9 panes any more —
 * and it stays exactly where it is, because three other pages stand their
 * headers in that band (see the note above). */
.wrap {
  position: relative;
  display: flex;
  flex-direction: column;
  min-height: 100dvh;
  /* The gutter between the panes, and the drop from the panes to the OBS row —
     one value, because the row reads as the next thing in the same stack only
     while those two agree. 14px up to the breakpoint, exactly as before. */
  --gap: clamp(14px, 0.9vw, 22px);
  /* FINDING 98 ROUND 2 moved this expression, unchanged, to `--head-cap` in
     style.css, because `/pricing`, `/phone` and `/privacy` now stand their
     HEADER in a band of exactly this width (their document stays in a 1100px
     reading column). The three bounds and the guard are still explained above —
     this is their page — and the other three pages read the result rather than
     restating it, so "the same width as /demo" cannot quietly stop being true. */
  max-width: var(--head-cap);
  margin: 0 auto;
  /* Horizontal padding stays 22px at every width: it is a term in the
     two-column threshold below (44px of it) and in the guard above, and a
     page inset by 4vw does not need a second inset that grows too. */
  padding: clamp(34px, 2.4vw, 52px) 22px clamp(70px, 4vw, 96px);
}

/* ---------- the head: the mark, home, and what this page is ---------- */
.head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
  padding-bottom: clamp(22px, 1.5vw, 30px);
  border-bottom: 1px solid var(--line);
}
.brand {
  display: inline-flex;
  align-items: center;
  color: var(--fg);
  text-decoration: none;
}
.brand:hover {
  color: #fff;
}

/* THE CONTACT PILL IS NOT FIXED ON THIS PAGE — see `.obs-side` below.
 *
 * What stood here was the pill's DOORBELL STAND-OFF: `bottom: calc(max(18px,
 * env(safe-area-inset-bottom)) + var(--ui) * 6.9)`, finding 57 (Kevin,
 * 2026-09-11), which lifted the fixed pill clear of the demo's own
 * `your Twitch username` box. That box is fixed inside the FRAMED APP
 * (`#doorbell`, web/index.html), so the two lived in different documents and
 * the clearance had to be a number written down in both places — 59px of
 * answering doorbell + 10 of air, kept as a multiple so it could only grow with
 * `--ui`.
 *
 * FINDING 101 (Kevin, walk 9, 2026-09-13) removes the whole question rather than
 * the number, and FINDING 142 removes the doorbell: the framed page is the
 * chrome-free view now and draws no `your Twitch username` box at all. The pill
 * is in `#corner-br`, which is a slot on the field with no arithmetic to keep in
 * step with anything inside the frame.
 * A doorbell row that changes height is no longer a change to anything here;
 * `web/index.html`'s `#doorbell` block still names this file, and this is the
 * paragraph it now finds. */

/* The head's right cluster: the two nav doors (`a.chip.to`, style.css), held
   together so `.head`'s `space-between` has two children to push apart rather
   than three to strand. Finding 98 emptied it of the chip that said what this
   page is and did NOT give this page the purple door — see demo.html.
   The gap is `style.css`'s (finding 94): 1.2 of `--ui`, which is the flat
   12px the other three pages carried at the old floor, growing with the
   furniture it separates; the wrap and the right-alignment are style.css's
   too (finding 98). */
.head-end {
  display: flex;
  align-items: center;
}

/* ---------- sections ---------- */
/* The one section on this page, and since finding 142 it is also the pass-
   through for the column's growth: it grows, `#stage` inside it grows, and the
   air above the field is still the air above the field. `min-height: 0` is the
   flex floor every column item needs to be allowed to be shorter than its
   content — without it a short window would refuse to shrink the stage below
   its natural size and the page would scroll for a reason nobody wrote down. */
.sec {
  padding-top: clamp(40px, 2.6vw, 56px);
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  min-height: 0;
}
/* The engraved micro-legend: the operator console's own device for naming what
 * a thing OWNS, at the size it uses. */
.legend {
  color: var(--fg-3);
  font-size: clamp(10px, 0.6vw, 13px);
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}
.h {
  margin: 9px 0 10px;
  font-size: clamp(19px, 2.6vw, 27px);
  font-weight: 700;
  letter-spacing: 0.01em;
}
.copy {
  max-width: 68ch;
  margin-bottom: 18px;
  color: var(--fg-2);
  font-size: clamp(13px, 0.7vw, 16px);
  line-height: 1.62;
}

/* ---------- the stage: one field, and four corners on it ----------
 *
 * FINDING 142 (Kevin, 2026-09-17): *"eliminate the two encompassing boxes, make
 * that whole space the right one — which is effectively making the whole space
 * demo.bubbleup.gg."* So the two `.pane` boxes are gone and what is left is the
 * ROOM they stood in. `#stage` is the segment between the head's hairline and
 * the foot's, `#demoframe` fills it edge to edge, and every control this page
 * owns sits ON the field in one of four corner slots.
 *
 * ITS HEIGHT IS NOT A NUMBER IN THIS FILE — `.wrap` is a flex column one
 * viewport tall and this is the item that takes the slack (see `.wrap`). There
 * is ONE bound left and it is derived rather than chosen:
 *
 *   min — the floor a short window bottoms out on, and it is corner geometry
 *     rather than taste. The stream is 306px tall at the autoplay floor below,
 *     inset `--slot` + `--bar-h` from the top; `#corner-br` is ~90px of
 *     controls inset `--slot` from the bottom. Under the sum those two
 *     rectangles meet — and tree order puts the plate UNDER the player, which
 *     is a control nobody can press. Shorter than that the page scrolls, which
 *     is the trade the old layout already made at 743px and 811px (the
 *     short-window rhythm at the foot of this file).
 *
 * FINDING 144 (Kevin, walk 11, 2026-09-17, with a 2000x1600 screenshot):
 * *"what's up with the vertical spacing here?"* — and what was up was a
 * `max-height: 900px` sitting where the second bound used to be explained. On
 * his window the stage stopped at 900 and the foot stayed pinned to the bottom
 * of a `100dvh` column, so ~415px of nothing stood between the two bottom
 * plates and the foot's rule. The cap's argument ("past this a field stops
 * being one look") was a two-pane argument: it survived finding 142, which made
 * the field the whole room, without being re-asked. The room IS the look now,
 * so the cap is gone and the stage takes every pixel `.wrap`'s column has left
 * over. What remains between the stage and the foot is the foot's own
 * `margin-top` — the page's composed stand-off, unchanged — and nothing else.
 *
 * NO BOX, NO BAR, AND NO GROUND. `body.overlay { background: transparent }`
 * (web/index.html) is what makes the frame below composite over THIS page's own
 * glow and grid rather than over a second dark rectangle — which is what "the
 * whole space is demo.bubbleup.gg" actually looks like. */
#stage {
  /* ---- THE FOUR DECLARATIONS THIS ID ALREADY HAD, UNDONE ONE BY ONE ----
   *
   * `style.css` line 133 is `#stage { position: fixed; inset: 0; z-index: 1;
   * opacity: 0.9; }` — the LANDING page's physics field, which is a fixed
   * full-viewport canvas. This page loads that sheet too (it is an addendum,
   * never a replacement), and an id is an id: every one of those four reaches
   * the element below unless this rule says otherwise, and three of them are
   * silent damage rather than a visible one.
   *
   *   position  the whole layout. Left `fixed`, the four corner slots position
   *             against the WINDOW instead of the field.
   *   inset     harmless today only by luck — on a relatively positioned box a
   *             `top`/`left` of 0 shifts nothing — and a trap the day one of
   *             the four becomes non-zero. Reset outright.
   *   z-index   a stacking context at 1. The contact sheet's backdrop is 45 on
   *             `<body>`, so it would still cover the field, but the lift this
   *             file does under `body.cu-veil` (46) would be fighting a number
   *             from another sheet rather than setting one.
   *   opacity   **0.9 over the whole stage**, and the one that would have cost
   *             the product: Intersection Observer v2's visibility test — the
   *             test a Twitch embed's autoplay is decided by — fails an element
   *             whose ancestors are not fully opaque. A 90% field is also just
   *             a dimmer demo than the demo.
   *
   * Written here, next to the contract, because the next person to read this
   * block is the one who would otherwise re-derive it from a page that looks
   *90% right. (Lane `demo-pip-142` found it first, from the other side.) */
  position: relative;
  inset: auto;
  z-index: auto;
  opacity: 1;
  flex: 1 1 auto;
  /* What every slot keeps from the stage's edges. `--gap` again, so the corner
     furniture stands off by the same measure that separates everything else on
     this page. */
  --slot: var(--gap);
  /* The stream's width wherever it is parked. The 544px floor is the autoplay
     floor derived below and it is a FLOOR here too — a corner player Twitch
     refuses to start is the demo not happening (Kevin's demo-quality charter).
     29vw keeps the corner in proportion as the room grows (557px at 1920, 742px
     at 2560) and 760 is where a corner stops being one.

     `vw` AND NOT A PERCENTAGE of the stage, which is what this wanted to be: a
     custom property holding `32%` resolves against the WIDTH where it is used
     as a width and against the HEIGHT where it is used as a height, and the
     rule below needs this number in both. 29vw is 32% of the stage at every
     width where `--head-cap` is on its 92vw track, which is the band that
     matters, and the 760 cap covers the rest. */
  --pip-w: clamp(544px, 29vw, 760px);
  /* How tall a bottom plate is, near enough to reserve room for: `#corner-br`
     is the OBS button (2 × 1.2 of `--ui` of padding on a ~28px line) inside the
     slot's own 0.7 × `--ui` and a hairline. Measured against the running page,
     this reads 89.5 where the plate is 79.4 (1280) and 110 where it is 104.9
     (2560) — over on purpose, because what it buys is CLEARANCE and the cheap
     direction to be wrong in is "too much". */
  --plate-h: calc(var(--ui) * 4.6 + 30px);
  /* HOW TALL THE FRAMED APP'S OWN STRIP IS — FINDING 147.
   *
   * Kevin, walk 11, 2026-09-17: *"keep the bubbleup status/control bar up top …
   * fix it."* The field frames `https://demo.bubbleup.gg/` again (demo.html,
   * demo.js `DEMO_VIEW`), which is the app WITH its operator strip, and the
   * strip is `position: fixed; top: 0; left: 0; right: 0` inside that document.
   * So the two FREE slots — and therefore the parked stream, which is
   * `#corner-tr`'s rectangle — have to start below it or they cover the thing
   * he asked to keep.
   *
   * IT CANNOT BE READ AT RUNTIME. The strip is in a cross-origin document; the
   * app publishes its height to its own canvas as `--chrome-h` (web/index.html,
   * the ResizeObserver in main.ts) and posts nothing outward. So this is a
   * RESERVATION, measured off the real `web/dist` build IN A FRAME — the only
   * posture worth measuring, and the one that makes the number small
   * (2026-09-17, at `#stage`'s own width at each window):
   *
   *     field width   1166  1236  1281  1428  1722  2156
   *     strip height   37    37    37    37    37   42.5
   *
   * A FRAMED APP FOLDS ITS STRIP TO THE RAIL. `web/src/fold.ts`'s
   * `foldsByDefault` returns true for `framed || viewer` and this page's frame
   * is both, so the app comes up `chrome-folded` — verified in a frame, where
   * its own `--chrome-h` reads 37px: the wordmark, the fold key, link state and
   * throughput on one row, which is exactly the "status/control bar" Kevin
   * asked to keep. The 42.5 at 2156 is the app's `--ui-scale` growing on a wall.
   *
   * Measured UNFRAMED the same strip is 124–180px, because the three racks are
   * open and wrap. That is the number this file must NOT reserve, and it is
   * written here because measuring the wrong posture is the mistake available
   * to whoever reads this next. 48 is 42.5 plus the same ~13% cushion
   * `--plate-h` carries over its own plate. FLAT, because the number belongs to
   * the app's furniture rather than to this page's `--ui`, so a slope here
   * would be a coincidence dressed as a derivation.
   *
   * WHAT IT DOES NOT COVER, on purpose: a visitor who presses the app's own
   * fold key and opens the racks. That is a deliberate act inside the frame,
   * the app moves its own field down for it (`--chrome-h`), and reserving for
   * it would cost every visitor ~140px of field for a state almost nobody
   * enters. `docs/knobs.md` carries that as the row's open edge. */
  --bar-h: 48px;
  /* The floor, and it is the corner geometry rather than a taste: the framed
     app's strip, a 306px player at the autoplay floor, the three stand-offs
     between it, the plate and the two edges, and the plate. Under this the
     stream and `#corner-br` would share a rectangle — the player would win
     (tree order) and the OBS button would be under it. ~486px at a laptop's
     `--ui`, ~530 at a wall's. */
  min-height: calc(306px + var(--slot) * 3 + var(--plate-h) + var(--bar-h));
}

/* THE FIELD. The whole stage, no frame around it, and since finding 147 it is
   the APP — strip and all (`https://demo.bubbleup.gg/`, demo.html's note and
   `DEMO_VIEW` in demo.js). `Add to OBS` still hands out the chrome-free view,
   so the two are deliberately different strings now; `--bar-h` above is what
   keeps this page's own corner furniture off the app's strip. */
#demoframe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
  display: block;
}

/* ---------- the four slots ----------
 *
 * All four are `position: absolute` children of `#stage`, which is the only
 * positioned ancestor, so a slot's rectangle is readable off the stage and
 * nothing else has to agree about it. That is the contract lane `demo-pip-142`
 * builds the floating stream against (demo.html carries it in full):
 *
 *   tl / tr  EMPTY. Sized geometry, no paint, and `pointer-events: none` so a
 *            free slot never eats a click meant for the field. `#twitchframe`
 *            is parked on `tr` at rest and the PiP lane is what makes it move.
 *   bl / br  the controls, on a translucent plate. `data-occupied="1"` in the
 *            markup is how a reader tells the two apart from the two above.
 *
 * The plates are translucent and blurred rather than solid because the field
 * runs UNDER them: a bubble drifting behind a control should still read as a
 * bubble, and a solid panel in the corner would be the box finding 142 just
 * took away. */
.corner {
  position: absolute;
  display: flex;
  align-items: center;
  gap: var(--gap);
}
/* THE TWO EMPTY SLOTS AND THE STREAM ARE ONE RULE, because they are one
   rectangle: the stream is parked ON `#corner-tr` at rest, and a slot whose
   geometry were computed twice is a slot the PiP lane would snap a player into
   by a pixel or two.
 *
 * SIZED BY HEIGHT, not by width, and that is what keeps a short stage honest.
 * `height` takes the smaller of what `--pip-w` asks for and what is actually
 * free between the framed app's strip and the bottom plates; `width: auto` with
 * `aspect-ratio: 16 / 9` then follows it, so the corner gives up SIZE and never
 * its shape. The floor under both is `#stage`'s `min-height`: the room term
 * cannot come out below 306px, which is 544px of width, which is Twitch's
 * 400×300 with 16:9 applied.
 *
 * `--bar-h` IS THE NEW TERM IN BOTH (finding 147). The framed app is the app
 * again, strip and all, and the strip is pinned to the top of that document —
 * so the two free slots start one `--slot` below it rather than one `--slot`
 * below the stage. `#stage`'s block carries the measurement and the reason.
 *
 * `pip.js` READS BOTH OF THESE OFF THIS ELEMENT rather than being told them:
 * `#corner-tl`'s LEFT offset is the margin and its TOP offset less that margin
 * is the bar. Two insets that no longer agree is exactly the case its old
 * `Math.abs(top - left) < 1` guard refused, so that guard had to learn the
 * difference instead of falling back to a number. */
#corner-tl,
#corner-tr,
#twitchframe {
  position: absolute;
  top: calc(var(--slot) + var(--bar-h));
  width: auto;
  height: min(
    calc(var(--pip-w) * 9 / 16),
    calc(100% - var(--slot) * 3 - var(--plate-h) - var(--bar-h))
  );
  max-width: calc(100% - var(--slot) * 2);
  aspect-ratio: 16 / 9;
}
#corner-tl {
  left: var(--slot);
}
#corner-tr,
#twitchframe {
  right: var(--slot);
}
/* An empty slot has no paint and takes no click: it is geometry for the lane
   that reads it, and the field runs underneath. */
#corner-tl,
#corner-tr {
  pointer-events: none;
}
/* The two plates.
 *
 * ONE PADDING VALUE ON ALL FOUR SIDES — finding 152. It was `0.7 × --ui`
 * vertically and `0.9` horizontally, which is the proportion a BAR wants (a row
 * of controls reads wider than it is tall). Neither of these is a bar any more:
 * `#corner-bl` is a two-row plate and `#corner-br` hugs its row on every side,
 * and an inner margin that is smaller at the top and bottom than at the sides is
 * what makes a plate read as crowded above and below its contents. `0.9` is the
 * value kept, not `0.7` — the two plates share this rule, and shrinking the
 * horizontal padding would have moved `Add to OBS` in a lane that is not about
 * it. */
#corner-bl,
#corner-br {
  bottom: var(--slot);
  padding: calc(var(--ui) * 0.9);
  border: 1px solid var(--line);
  border-radius: 9px;
  background: rgba(10, 10, 13, 0.72);
  backdrop-filter: blur(7px);
  -webkit-backdrop-filter: blur(7px);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
/* `max-width: 50%` less its own stand-off is what keeps this plate from meeting
   the other in the middle at the narrowest desktop this layout serves (1201px,
   where the stage is 1157px wide); past it its own contents wrap, in the
   direction a form wraps. `#corner-br` used to share this line and no longer
   does — see finding 154 below. */
#corner-bl {
  left: var(--slot);
  max-width: calc(50% - var(--slot) * 1.5);
  flex-direction: column;
  align-items: flex-start;
  /* ZERO, and the stand-off is on the CHILD that needs one (finding 152). The
     plate's three children are the legend line, the form, and an answer that is
     EMPTY on every visit nobody has asked anything on — and a flex `gap` is
     charged between items whether or not one of them has any height, which is
     one of the two halves of the dead band under the form. The other was the
     answer's own reserved line; both are gone, and both are restored as margins
     the moment there is something to stand off. */
  gap: 0;
}
/* The page's own stand-off between the legend line and the form, where the plate
   used to use `0.5 × --ui`: everything else on this page that separates two
   things separates them by `--gap`, and a plate whose two rows sat closer than
   that read as one crowded block rather than as a label over a control. */
#corner-bl > .request {
  margin-top: var(--gap);
}
/* ---------- FINDING 154 — THE OBS PLATE NEVER WRAPS ITS ROW ----------
 *
 * Kevin, walk 11, 2026-09-17, with a screenshot of a tall-then-widened window:
 * *"the bottom right … not having a rightsized under-box"* — the plate had
 * wrapped `Add to OBS` above the `like what you see?` pill inside a wide, mostly
 * empty box.
 *
 * WHAT DID IT: this plate shared `#corner-bl`'s `max-width: calc(50% - …)`, and
 * `--ui` is `clamp(13px, max(0.68vw, 1.2vh), 28px)` — so a TALL window grows the
 * pair (1.2vh: 16.8px at 1400 of height) while a narrow one shrinks the plate.
 * Measured at 1201×1400: the row wants 614px and the cap allows 527, so
 * `.obs-side`'s `wrap-reverse` did what it was asked and the plate became
 * 557×172 holding two rows and a lot of nothing.
 *
 * THE RULE, and it is the ruling's: the row does not give way to the plate, the
 * plate gives way to the row. `width: max-content` is the plate hugging its
 * contents on every side; the cap below is the ONLY thing that can take width
 * away from it, and it is written so that it never can at a width where the row
 * would then have to wrap. `.obs-side` is `nowrap` now (below), so if the cap
 * ever does bind, what gives is the PILL's text — one long word becomes two
 * lines — and the two controls stay on one row.
 *
 * `--bl-reserve` is how much of the stage is kept for the plate at the other
 * end, derived rather than guessed. `#corner-bl` is a shrink-to-fit plate and
 * its widest child is the form row: a 21ch field (`12.6 + 1.8` of `--ui`, plus
 * two borders), the `0.6` gap, `Request` (`4.2 + 1.8`), and this rule's `1.8` of
 * padding — `22.8 × --ui + 6px`, which measures 310.9 at `--ui` 13 and 399.9 at
 * 16.8. Neither the legend line nor the answer can pass it (the answer is capped
 * at 30ch in its own type, which is `14.4 × --ui`). **24 + 8 is that with the
 * same ~13% cushion `--bar-h` and `--plate-h` carry over their own
 * measurements**, and the three `--slot`s are the two plates' stand-offs from
 * the field's edges and from each other. */
#corner-br {
  right: var(--slot);
  justify-content: flex-end;
  width: max-content;
  --bl-reserve: calc(var(--ui) * 24 + 8px);
  max-width: calc(100% - var(--slot) * 3 - var(--bl-reserve));
}
/* The pane bars' contents, with the bars taken away: what the stream is, and
   whether it is running. THREE elements where there were four — the station's
   copy ruling dropped the legend `bubbleup` with the box it labelled (Kevin:
   *"You don't need a box to indicate that it's bubble up, it says it right
   there"*), and `#livemark` keeps its place beside the channel. See demo.html. */
.legends {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: calc(var(--ui) * 0.5);
  /* FINDING 148 — the line has to be as wide as the plate before anything on it
     can be right-aligned. `#corner-bl` is a column with `align-items:
     flex-start`, so this `<p>` was only as wide as its three words; stretching
     it makes its right edge the plate's own padding edge, which is what
     `#livemark`'s `margin-left: auto` below then flies to. Nothing else on the
     line moves: "the stream" and `#channel` stay where they were. */
  align-self: stretch;
}
/* FINDING 148 (Kevin, walk 11, 2026-09-17): *"bottom left, the request box, it
   says 'the stream ohnepixel live' with ohne and live are colored blue. I want
   the word live to be red, and right-aligned."*
 *
 * RED, not cyan. `.ch` above is cyan because on this surface cyan is DATA — and
 * the channel name is data. `live` is not data, it is a state, and the state a
 * red means. `--red` is `style.css`'s palette entry, added with this finding:
 * the site had no red token at all, and the number in it is the one red the
 * site already draws (`main.js`'s `YOUTUBE_HUE`, `0xff4e45`).
 *
 * The id beats `.ch`'s class, so this is the whole override. An EMPTY
 * `#livemark` still draws `.ch:empty::before`'s em dash in `--fg-3`, which is
 * untouched: it is the not-live state and it is not a word. */
#livemark {
  margin-left: auto;
  color: var(--red);
}
/* ---------- phones and narrow windows: STACK, and no floating anything -------
 *
 * Kevin, 2026-09-17: *"for phones maybe you just stack, no pip. The idea of
 * maximizing for PC should not make mobile worse."* So under 1200px the stage
 * stops being a canvas with controls ON it and becomes four rows in flow: the
 * stream (the thing being read), the field, then the two plates' contents.
 * Nothing here is absolutely positioned, nothing is fixed, and the two empty
 * slots leave the page outright — there is no floating stream to snap into one.
 *
 * A GRID and not a column of flex items, for one reason: `#demoph` — the
 * `connecting to the demo` line — has to stand ON the field, and the field is
 * an IFRAME, which cannot hold a child. Two boxes in ONE grid cell is the only
 * way to overlap them without a positioned ancestor to measure against, and
 * `#fallback` takes the same cell because the card replaces the field at every
 * width.
 *
 * THE ORDER IS CSS'S. `#twitchframe` is LAST in the document — that is its
 * paint order over the plates, and the whole of why the desktop's controls can
 * never cover a running player — and `order: -1` puts it first here without
 * moving a node. Moving an iframe in the DOM reloads it, and a reloaded Twitch
 * embed comes up stopped.
 *
 * THE THRESHOLD IS STILL 1200px and it has a new reason. It used to be the
 * width at which the FRAMED APP's own control strip stacked (561px of frame,
 * twice, plus the gutter and the page's padding); the frame is chrome-free now
 * and has no strip to lay out. What 1200 marks today is where a 544px player
 * and two plates stop fitting inside one stage — and it is also the width every
 * measurement on this page has been taken either side of since the two-column
 * era, so keeping it is what makes the before/after tables comparable. */
@media (max-width: 1200px) {
  #stage {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas:
      "stream"
      "field"
      "bl"
      "br";
    gap: var(--gap);
    /* Back to a box of its own height: the column's growth is a desktop
       mechanism, and a phone page is taller than its viewport anyway. The
       `max-height: none` that stood here with these two went with finding 144's
       removal of the 900px cap — there is no cap left to undo. */
    flex: 0 0 auto;
    min-height: 0;
  }
  #corner-tl,
  #corner-tr {
    display: none;
  }
  #demoframe,
  #demoph,
  #fallback {
    grid-area: field;
  }
  #demoframe {
    position: relative;
    inset: auto;
    width: 100%;
    height: auto;
    /* The floor and the shape the walked phone layout had, in the units an
       iframe can carry. `aspect-ratio` rather than the old zero-height-plus-
       percentage-padding trick, which was a `.frame.app` DIV's answer to
       holding a box open around an absolutely positioned iframe: the iframe IS
       the box now, its width is stated (`100%`, definite), and a stated width
       is what keeps `min-height` from transferring back as a minimum WIDTH. */
    aspect-ratio: 16 / 9;
    min-height: 430px;
  }
  /* On the field rather than absolutely positioned over the whole stage. */
  #demoph {
    position: static;
  }
  /* FINDING 148's right-alignment is a PLATE thing and stops here. With the
     plate gone there is no right padding edge to be flush with, and a `live`
     flung to the far side of a phone's width would be the word losing the
     channel it belongs to. The RED stays — that is a state and not a layout. */
  .legends {
    align-self: auto;
  }
  #livemark {
    margin-left: 0;
  }
  /* The plates stop being plates: in flow there is nothing behind them to see
     through, and a bordered panel around two controls is the box this lane took
     away. The phone gets the bare controls it has today. */
  #corner-bl,
  #corner-br {
    position: static;
    max-width: none;
    padding: 0;
    border: 0;
    background: none;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
    box-shadow: none;
  }
  #corner-bl {
    grid-area: bl;
    /* The form takes the row's whole width here, as it did in the OBS row: its
       readout is `flex-basis: 100%` OF THE FORM, so a form shrunk to its
       controls is a sentence wrapped to two lines on a phone. On a desktop the
       slot is a plate and the shrink is the point. */
    align-items: stretch;
  }
  /* …and the readout's `30ch` cap goes with the plate that needed it (finding
     154): it exists so a sentence cannot decide how wide the desktop plate is,
     and there is no plate here — only a column that is already the row's width,
     where a capped line would leave two thirds of a phone empty beside it. */
  .request-said {
    max-width: 100%;
  }
  #corner-br {
    grid-area: br;
    justify-content: flex-end;
    /* …and finding 154's `width: max-content` goes with the plate that needed
       it. In one column there is nothing to hug and nothing to be rightsized
       against: a row that is 490px of max-content inside a 390px phone is a
       page that scrolls sideways, which is the one thing this layout must never
       do. The row wraps here for the same reason — see `.obs-side` below. */
    width: auto;
  }
  /* WRAPPING IS RIGHT ON A PHONE and wrong on the plate (finding 154). The
     desktop rule is `nowrap` because a wrapped pair makes the PLATE wide and
     empty; here there is no plate, the column is the width it is, and the pair
     has nowhere else to go. `wrap-reverse` so the button keeps the bottom of
     the stack — the same reason the desktop had it before the finding. */
  .obs-side {
    flex-wrap: wrap-reverse;
  }
  #twitchframe {
    grid-area: stream;
    order: -1;
    /* Out of the corner and back into the flow: the height the desktop rule
       computes off the stage is meaningless in a row, and 16:9 on a stated
       width is the whole shape here. */
    position: relative;
    top: auto;
    right: auto;
    width: 100%;
    height: auto;
    margin-inline: auto;
    /* Room under the stream for the sound control's strip (finding 155): in
       the stack the control hangs below the card just as it does at a top
       home, and the next row of the stack must start under it, not under the
       card. The control is `0.8 × --ui` of type at line-height 1.4 inside
       `0.25 × --ui` of padding top and bottom, plus its hairlines. */
    margin-bottom: calc(var(--ui) * 1.62 + 2px + var(--pip-hang));
    /* AND the stream is never taller than the room above the fixed pill.
     *
     * One column makes the player as wide as the page, and 16:9 turns that
     * width straight into height: at 1200px the frame is 1137 × 640 starting
     * at y=151, so its bottom lands at 791 — and the contact pill is fixed
     * 52px off the bottom of the viewport. On any window shorter than ~840px
     * the two met, the pill covered the player's bottom-right corner, and the
     * stream STOPPED. Measured: at 1180 × 813 the page never played at all
     * (zero media events); with the pill removed, the same page played.
     * Scrolling cannot save it either — the stream is the first thing in the
     * document, so scrolling only ever moves it UP, and the collision is
     * decided at scroll 0.
     *
     * So the stream is capped at whatever 16:9 fits above the pill: 151px of
     * head and pane bar, 52px of pill, and 22px of air = 225px. The floor is
     * there because on a phone in landscape (390px of height) the honest answer
     * would be a 300px video, and at that point the guard below is the better
     * trade — the stream keeps its size and the pill is what yields.
     *
     * The floor is a VARIABLE and not a literal because the block below raises
     * it, on the windows where a bigger one buys autoplay. Everything else
     * about this line — the 225 and the ratio — is the same number in both
     * cases, and two copies of it is how they come apart.
     *
     * FINDING 142 MOVED IT OFF `.panes` AND ONTO THE STREAM, and dropped one
     * term: the `+ 4px` was the pane's own borders (3px purple left, 1px
     * right), and there is no pane. Nothing else in it changed, and the 225 is
     * now SLACK rather than tight — the pane bar it counted is gone, so the
     * stream's top edge sits higher than the reserve assumes and the cap only
     * ever errs towards a stream that fits. The pill it was derived against
     * left this corner at finding 101; what the term reserves today is the room
     * under the stream for the field and the two rows of controls. */
    --player-floor: 420px;
    max-width: max(var(--player-floor), calc((100dvh - 225px) * 16 / 9));
  }
}

/* ---------- and never SMALLER than Twitch will autoplay ---------- */
/*
 * The cap above answers "how big may the stream be before the pill covers it".
 * This answers the other end, which the walk found: Twitch refuses to autoplay
 * an embed under 400×300 —
 *
 *   "Embedded video windows must be at least 400x300 pixels."
 *   "Minimum size requirements and visibility are necessary for autoplay to
 *    begin. The exception is mobile devices, on which video cannot be played
 *    without user interaction."   — dev.twitch.tv/docs/embed/video-and-clips
 *
 * — and it decides ONCE, at load, and never reconsiders (demo.js §when a player
 * may be built). Two columns never lands under it. ONE column did: measured on
 * this page, 800×500 gave a 489×275 player and 577×800 gave 529×298. A demo page
 * whose stream comes up stopped with a play button on it is the whole product
 * not happening.
 *
 * 16:9 makes HEIGHT the binding half — 400 wide is only 225 tall, so the real
 * requirement is 533.34px of frame width. It is set to **544px**, which is a
 * 544 × 306 player: 10px clear of the number, in the spirit of the corner
 * player below being 40px clear of the last size measured to stop. (It used to
 * read 537.34 + borders, because a `.pane` put 4px of its own around the frame
 * and the floor had to pay for them. Finding 142 took the pane away and left
 * the number alone: what was 3.75px of clearance is 10px now, and the SAME 544
 * is the floor the desktop's corner player is built on — `--pip-w` at the top
 * of this file, one number for "the smallest stream Twitch will start".)
 *
 * The two bounds on the query are what keep this from being a floor that costs
 * more than it buys, and both are derived rather than chosen:
 *
 *   min-width 600px — `.wrap` insets 22px a side, so a 600px window offers 556px
 *     of row, 12px over the floor, and 541px even behind a classic 15px
 *     scrollbar (a 541 × 304 player: still over). NARROWER than that the floor is
 *     unreachable — the stream is capped by the page, not by this — and raising
 *     it would only widen it for a player that still cannot autoplay. Under
 *     600px the old 420px stands.
 *
 *   min-height 455px — the frame's top edge was 151px at every one-column width
 *     when a pane bar stood above it, so a 303.75px player ended at 454.75. The
 *     bar is gone and the stream starts HIGHER than that now, which makes the
 *     cut conservative rather than wrong: at or above it the floored player is
 *     entirely in view at scroll 0 with room to spare; below it the window
 *     genuinely cannot show a 400×300 video and the honest answer is the
 *     smaller stream and a play button — the floor is for AUTOplay, and
 *     click-to-play on a 450px-tall window is a fallback, not a bug. It also
 *     leaves every landscape phone (390px, 430px of height) where it was, which
 *     is right twice over: Twitch does not autoplay on mobile at all by its own
 *     rule above.
 *
 * The band that moves is therefore 600–1200px wide by 455–528px tall, and only
 * that: above ~528.75px of height the cap's own formula already exceeds 544px
 * and this changes nothing.
 *
 * WHAT PAID for it, until finding 101, was the contact pill's clear rectangle
 * in that band: the pill was fixed 18px off the bottom right of the viewport,
 * the floored player reached down into it, and the guard below painted the pill
 * behind the video rather than letting the video go dark (96.66 × 34 of a
 * 207.66 × 34 pill, at 800×460, the worst of the band). **NOTHING PAYS FOR IT
 * NOW.** The pill is in flow under the field at these widths, so the floor
 * costs this page no pill space at any viewport and the band is a straight win.
 *
 * The min-width bound stays, and it is still the right bound: the same
 * measurement with it removed widened the stream to the whole page at 560×460
 * and produced a 512×288 player, STILL under Twitch's own 400×300 floor. A
 * floor that cannot be reached is a layout change bought for nothing, which is
 * what the bound refuses — the pill is no longer the reason, the player is. */
@media (max-width: 1200px) and (min-width: 600px) and (min-height: 455px) {
  #twitchframe {
    --player-floor: 544px;
  }
}

/* ---------- nothing may cover the player ---------- */
/*
 * A Twitch player stops the moment anything with paint sits over it, and it
 * never starts again by itself. Measured with Chrome's own media pipeline
 * events, and the mechanism is now named: it is Intersection Observer v2's
 * visibility test, not a pointer hit test. Both halves of that were checked on
 * this page, at 1180 × 813, over a real player —
 *
 *   overlay with `pointer-events: none`   → `kPause` (elementFromPoint said
 *                                            the IFRAME; it stopped anyway)
 *   overlay with `opacity: 0`             → nothing, it played on
 *
 * — so an occluder cannot be excused by taking itself out of the pointer
 * chain, only by having no paint at all. Which leaves exactly two ways to keep
 * a player alive: paint UNDER it, or do not share its rectangle. Both are
 * used, and no JavaScript "repair" is: re-pointing `src` is a reload, and a
 * reloaded embed comes up stopped.
 *
 * 1. The contact sheet's backdrop (`.cu-backdrop`, 45) is painted under the
 *    stage, so the chrome dims and the instrument keeps running.
 * 2. …and there is no second one any more. `.panes > .pane:first-child {
 *    position: relative; z-index: 41 }` stood here until finding 101, to paint
 *    the FIXED pill (40) under the pane that holds the player where a short,
 *    wide window made the two share a rectangle. The pill is in `#corner-br`
 *    now — static, and therefore on no layer at all — so it cannot reach the
 *    player at any viewport, and a stacking rule whose only occluder is gone is
 *    a comment that has stopped being true. The `--player-floor` band above no
 *    longer buys autoplay with pill space: it buys it with nothing.
 * 3. FINDING 142's own case, and it is answered by TREE ORDER rather than by a
 *    layer: on a desktop the two control plates sit ON the field, and the
 *    stream is parked in a corner over the same field. `#twitchframe` is the
 *    LAST child of `#stage` and every one of them is `position: absolute` with
 *    no z-index, so the player paints over both plates and nothing this page
 *    draws can ever be the occluder. `#stage`'s `min-height` is the
 *    other half — it is what stops the two from sharing a rectangle at all on a
 *    short window, so the overlap is a paint question and never a reach one.
 *
 * `cu-veil` and not `cu-open` throughout, because `cu-open` comes off 200ms
 * before the sheet and backdrop are hidden — the whole of a close transition —
 * and a field that drops back under a still-fading scrim is a stream that dies
 * on every close. See `veil()` in contact.js. `#stage` carries the whole lift,
 * exactly as `.panes` did; the player that escapes to the corner one breakpoint
 * down (below) is fixed INSIDE that stacking context and so still paints over
 * the scrim without a second layer under it. */
body.cu-veil #stage {
  position: relative;
  z-index: 46;
}

/* One column is where the sheet lands ON the stream: the player is the full
 * width of the page and the sheet is 390px of it (below 460px, all of it), so
 * stacking order cannot separate them — one of the two has to move, and it
 * cannot be the sheet, which is the thing being read.
 *
 * So the player steps out of its frame into the corner the sheet never reaches
 * and keeps playing there, at a size that fits above every shape the sheet
 * takes. Moving an iframe with CSS does not reload it — only moving it in the
 * DOM does — so this costs the stream nothing: same element, same media
 * pipeline, no `kPause`, no second `kWebMediaPlayerCreated`. The frame it left
 * keeps its box (it is `aspect-ratio` on an empty div), so nothing under it
 * moves, and the whole page is behind the scrim anyway.
 *
 * 340px wide, and that number is a floor with evidence under it rather than a
 * taste: a Twitch embed also stops when it is made SMALL, whatever is or is
 * not over it. Measured on this page, moving a PLAYING player and nothing else
 * — 250 × 141 → `kPause`, 280 × 158 → `kPause`, 300 × 169 → nothing, 400 × 225
 * → nothing. So the corner is 340 wide, 40px clear of the last size that
 * stopped, and it gives up width only to a viewport too narrow to hold it.
 *
 * The sheet is capped to match, and its DROP SHADOW comes off while it is up.
 * That shadow is the one thing in this whole round that had to be measured
 * three times to be believed: `0 22px 60px` reaches about 68px above the
 * sheet's own top edge as far as the visibility test is concerned — a blur
 * radius is drawn to roughly 1.5× its length — and a player 62px clear of the
 * sheet still stopped. Same page with `box-shadow: none` on the sheet: no
 * pause, at 606px, 700px and 800px alike. It is decoration that was never
 * visible anyway; it lifts a panel off a page, and this panel is sitting on a
 * 62%-black blurred scrim. The inset hairline stays.
 *
 * The cap is then 202px of corner, 30px of air, and 18px more because the
 * sheet's corner shape is bottom-inset by that. It only bites on short
 * windows, where the sheet was going to scroll inside itself regardless — it
 * has `overflow-y: auto` and always has. */
@media (max-width: 1200px) {
  body.cu-veil #twitchframe iframe {
    position: fixed;
    top: max(10px, env(safe-area-inset-top));
    left: max(10px, env(safe-area-inset-left));
    width: min(340px, calc(100vw - 16px));
    height: auto;
    aspect-ratio: 16 / 9;
    box-sizing: border-box;
    border: 1px solid var(--line);
    border-radius: 6px;
    box-shadow: 0 12px 34px rgba(8, 8, 11, 0.6);
  }
  body.cu-veil .cu-sheet {
    max-height: min(760px, calc(100dvh - 250px));
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
  }
}
/* `.pane` AND `.pane-bar` STOOD HERE and finding 142 deleted them: the two
 * bordered boxes with a legend bar each are exactly what Kevin asked to have
 * taken away, and the only thing either of them still carried that the page
 * needs — the legends' words — moved into `#corner-bl` (`.legends`, above).
 * The one box that survives is the STREAM's, because a video player is a thing
 * with an edge: `.frame` further down is its 16:9 box and its dark ground.
 *
 * The channel the relay named, shown where a panel legend would be — it is
 * also the visible proof the relay is working. Cyan, because on this surface
 * cyan is data. */
.ch {
  color: var(--cyan);
  letter-spacing: 0.1em;
}
.ch:empty::before {
  content: "—";
  color: var(--fg-3);
}
/* ---------- the two controls: put this in a scene, and ring the bell ------- */
/* A streamer's whole integration is a URL in a browser source, so the control
   is a copy button and nothing else — no dialog, no field to select from, no
   instructions to read. What it was NOT is findable: a 14px grey glyph in a
   pane bar is a thing you discover by accident. So it is a labelled button at
   the page's call-to-action scale, and finding 142 left it exactly where it had
   got to — Kevin, 2026-09-17: *"the like-what-you-see and the add-to-OBS, if
   you just kept them exactly where they were, bottom right"*. What changed is
   what it is bottom-right OF: `#corner-br`, a slot on the field, instead of the
   last row of a document.

   FINDING 101 (Kevin, walk 9, 2026-09-13) GAVE IT A NEIGHBOUR. The `like what
   you see?` pill was `position: fixed` at the bottom right of the viewport
   (`contact.css`) and this button was right-aligned at the bottom of the flow,
   so on a wide window they arrived in the same corner and the pill was drawn
   over the button. The pill belongs to this box now (`data-cu-pill` in
   demo.html, mounted by contact.js, un-fixed by contact.css), and the two are
   laid out by one set of rules instead of by two that cannot see each other.

   `align-items: center` is load-bearing. A flex row's default is `stretch`,
   which would pull the pill up to the OBS button's height — and the pill's
   height is read across all four pages by `site-scale.spec.ts` as one chrome
   scale. Centred, it is the same box here as it is on `/pricing`.

   `wrap-reverse` STOOD HERE and finding 154 took the wrapping away entirely.
   The argument was that where the two do not fit on one line the line that has
   to give is the pill's — `wrap-reverse` puts the FIRST line at the cross-end,
   so the button kept the bottom-right corner it is promised and the pill went
   above it. What that missed is that the plate around them is a shrink-to-fit
   box: a wrapped row does not save space, it makes the plate TALLER and leaves
   it as wide as the cap that caused the wrap, which is the wide, mostly empty
   box Kevin photographed. The pair is `nowrap` now and the PLATE is what gives
   (see `#corner-br` above); the last thing left to give, at a width where even
   that cannot, is the pill's own text, which wraps inside its pill.

   `.obs-row` STOOD HERE and finding 142 deleted it. It was the row that held
   this pair and the request form, and the two boxes existed because they wrapped
   in OPPOSITE directions (the form above the pair, the pill above the button)
   and one element cannot do both. The form is in `#corner-bl` now and the pair
   in `#corner-br`, which are two slots at opposite ends of the field: the
   "wraps above" the row was buying is what the slots are, so the outer box has
   nothing left to decide. `justify-content: flex-end` moves in here, where it
   is what keeps the pair at the slot's right on a wrapped line. */
.obs-side {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  flex-wrap: nowrap;
  gap: var(--gap);
}
/* …and the one thing that may give when the plate's cap binds: the pill's text.
   A flex item's floor is its own min-content by default, which for a pill
   holding one unbroken line is that line — so the pill would push the row past
   the plate instead of wrapping inside it. `min-width: 0` is what lets it
   shrink; the button is `flex: none` by its own `white-space: nowrap` and the
   pill is the only item on this row with words to break. */
.obs-side > .cu-pill {
  min-width: 0;
}
/* The BUTTON goes when the FIELD goes: `body.down` swaps in the fallback card,
   and there is nothing to add to a scene while the demo is not running.

   THE TWO SLOTS STAY, because the pill is in one of them and a visitor who
   finds the demo asleep is exactly the visitor most likely to want to say so.
   `.obs-wrap` is the button's own box (the button, its tooltip and its
   readout), so hiding that hides the control and everything that describes it,
   and leaves `#corner-br` holding the pill and `#corner-bl` holding — since
   finding 103 — the request form, both on the fallback card. The form in
   particular is meant to be there: a visitor who finds the demo asleep is also
   the one most likely to want it pointed somewhere, and the ask reaches Kevin
   whether the box is up or not. */
body.down .obs-wrap {
  display: none;
}
/* The button, its tooltip and its readout are three siblings, not nested: the
   button's accessible name is its label and only its label, and the other two
   attach to it by `aria-describedby` and by `role="status"`. */
.obs-wrap {
  position: relative;
  display: inline-flex;
}

.obs {
  position: relative;
  display: inline-flex;
  align-items: center;
  /* FINDING 94: this control is "the demo page's `.chip.go`" by the comment
     three lines down, so it is sized the way `.chip.go` is — off `--ui`, in the
     same whole tenths its own three clamps used as their floors (1.1 of gap,
     1.2/2 of padding, 1.2 of type, and a 2.0 glyph below). Four clamps of its
     own were four more ways for this button to disagree with the buy chip on
     `/pricing` and the play control on `/phone`, which is the finding. */
  gap: calc(var(--ui) * 1.1);
  padding: calc(var(--ui) * 1.2) calc(var(--ui) * 2);
  border: 1px solid var(--purple);
  border-radius: 999px;
  background: linear-gradient(180deg, rgba(35, 35, 44, 0.8), rgba(22, 22, 28, 0.8));
  color: var(--fg);
  font-family: var(--mono);
  font-size: calc(var(--ui) * 1.2);
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  cursor: pointer;
  /* The landing page's `.chip.go` signature: lifted off the ground, with a
     breath of purple around it. This is the demo page's `.chip.go`. */
  box-shadow:
    0 10px 30px rgba(8, 8, 11, 0.5),
    0 0 18px rgba(145, 71, 255, 0.18);
  transition:
    color 140ms ease-out,
    background 140ms ease-out;
}
.obs:hover {
  color: #fff;
  background: linear-gradient(180deg, rgba(145, 71, 255, 0.28), rgba(145, 71, 255, 0.14));
}
.obs:focus-visible {
  outline: 2px solid var(--purple);
  outline-offset: 3px;
}

/* The OBS Studio logo, inline: the ring and the three-bladed aperture inside
   it, two paths off the CC0 simple-icons `obsstudio` glyph with the curves
   untouched — the mark has to be the real one or it should not be there.
   Inline because this site fetches no images it does not ship. */
.obs-mark {
  flex: none;
  display: block;
  /* 2.0 of `--ui`; the viewBox does the rest. The old `clamp(20px, 1.1vw, 26px)`
     had its floor at 2x the old 10px chrome unit and its ceiling at 2.6x, so on
     the shared unit the glyph keeps the button's own proportion instead of
     drifting away from it as the button grows. */
  width: calc(var(--ui) * 2);
  height: auto;
}
/* The attention: the aperture breathing, slower than the header pill's running
   light so it reads as an invitation and not an alarm. */
.obs-ring {
  animation: obs-ring 3.4s ease-in-out infinite;
}
/* The halo, on a pseudo-element rather than the button's own box-shadow, so
   the animation is one composited opacity and not a repainted shadow.

   TWO shadows since 2026-09-01, and both of them stronger than what shipped
   (Kevin, on the live page: "I don't think i see the animation, or it's too
   subtle for me?"). The wide one is the glow — 22px/2px/0.35 became 34px/8px/
   0.5, which is what carries at arm's length; the tight one is new and hugs the
   rim, in the same lifted violet `::after` paints, so the peak reads as the
   control LIGHTING UP rather than as a soft cloud appearing behind it. Both are
   painted into this layer once and never animated: the whole motion is still the
   single composited opacity below. */
.obs::before {
  content: "";
  position: absolute;
  inset: -1px;
  border-radius: inherit;
  box-shadow:
    0 0 34px 8px rgba(145, 71, 255, 0.5),
    0 0 13px 2px rgba(220, 200, 255, 0.4);
  opacity: 0;
  pointer-events: none;
  animation: obs-halo 3.4s ease-in-out infinite;
}
/* The purple HIGHLIGHT itself, breathing (Kevin, walk 2026-08-31: "can you make
   the purple highlighting of the add to obs thing subtly animated?"). The
   button's own rim was the one purple on this control that never moved: the
   aperture breathes, the halo breathes, and the hairline between them sat
   still.

   A SECOND pseudo-element rather than an animation on `.obs`'s own
   `border-color` and `box-shadow`: both of those repaint the button on every
   frame, and the halo directly above already documents the alternative — one
   composited opacity over a layer that carries the stronger paint. The ring is
   laid over the button's own border rather than beside it (the containing block
   is the padding box, and `box-sizing: border-box` is this site's global), so
   what fades up and down is the SAME rim, brighter. Nothing is ANIMATED but
   opacity: no shadow to re-rasterise, no border-width keyframe, no second cadence
   — the whole motion is a colour lifting off `--purple` and settling back.

   `obs-halo`'s own keyframes and its own 3.4s clock, not a fourth cadence: the
   rim and the glow around it are the same purple, so brightening them together
   is one breath rather than two loops that happen to share a page.

   TWO PIXELS and a lighter violet since 2026-09-01, and the reason the width is
   PRE-PAINTED rather than animated is the same reason this is a pseudo-element
   at all: a `border-width` keyframe re-rasterises the ring on every frame, while
   a 2px ring that fades in costs exactly what the 1px one did. `inset: -2px`
   with a 2px border puts the ring's inner pixel back on the button's own border
   (the containing block is the padding box) and its outer pixel 1px beyond it,
   so the hairline does not MOVE — it grows outward into space the halo already
   occupies. Nothing reflows: the button's border box is unchanged and this layer
   is `pointer-events: none`.
   The colour goes #c3a2ff → #dcc8ff for the same reason the halo gained a
   second shadow: a hairline is a small target, and at 1px of #c3a2ff on this
   chassis the whole breath was a change nobody was reliably seeing. */
.obs::after {
  content: "";
  position: absolute;
  inset: -2px;
  border: 2px solid #dcc8ff;
  border-radius: inherit;
  opacity: 0;
  pointer-events: none;
  animation: obs-halo 3.4s ease-in-out infinite;
}
/* Found. Once the pointer or the focus ring is on it, it has done its job and
   settles — a control that keeps waving while you are using it is noise. */
.obs:hover .obs-ring,
.obs:focus-visible .obs-ring,
.obs:hover::before,
.obs:focus-visible::before,
.obs:hover::after,
.obs:focus-visible::after {
  animation: none;
}
.obs:hover .obs-ring,
.obs:focus-visible .obs-ring {
  opacity: 1;
}
.obs:hover::before,
.obs:focus-visible::before,
.obs:hover::after,
.obs:focus-visible::after {
  opacity: 1;
}
@keyframes obs-ring {
  0%,
  100% {
    opacity: 1;
  }
  50% {
    opacity: 0.4;
  }
}
/* The breath, with a DWELL at the top since 2026-09-01. A single 50% keyframe
   gives a peak that exists for one frame: the eye is handed a maximum it never
   has time to register, and "too subtle" is partly a statement about duration
   rather than about amplitude. 40%..60% is 680ms of the 3.4s cycle held at full
   — long enough to be a state the page is IN rather than an instant it passes
   through — with the rise and fall still 1.36s each, so nothing about the
   cadence reads faster or more urgent than what shipped.
   Written as two flat keyframes rather than as a longer `animation-duration`
   deliberately: the clock is shared with `obs-ring`, and stretching it would
   have slowed the aperture down to buy the rim its dwell. */
@keyframes obs-halo {
  0%,
  100% {
    opacity: 0;
  }
  40%,
  60% {
    opacity: 1;
  }
}

/* What it is, without hovering. */
.obs-label {
  white-space: nowrap;
}

/* What it DOES, on hover or focus — one line, and the only place the word
   "copies" appears. Above the button; the readout below it, so a press while
   the pointer is still there does not stack two chips on one spot. */
.obs-tip {
  position: absolute;
  right: 0;
  bottom: calc(100% + 9px);
  z-index: 3;
  width: max-content;
  max-width: min(300px, calc(100vw - 44px));
  padding: 7px 11px;
  border: 1px solid var(--line-hi);
  border-radius: 7px;
  background: linear-gradient(180deg, rgba(16, 16, 20, 0.97), rgba(10, 10, 13, 0.97));
  box-shadow: 0 12px 30px rgba(8, 8, 11, 0.6);
  color: var(--fg-2);
  font-size: 10px;
  letter-spacing: 0.06em;
  line-height: 1.45;
  opacity: 0;
  transform: translateY(3px);
  pointer-events: none;
  transition:
    opacity 140ms ease-out,
    transform 140ms ease-out;
}
.obs:hover ~ .obs-tip,
.obs:focus-visible ~ .obs-tip {
  opacity: 1;
  transform: none;
}
/* While the readout is up, the readout is the answer. */
.obs.said ~ .obs-tip,
.obs.said-bad ~ .obs-tip {
  opacity: 0;
}

/* Green, and only for as long as it is true — the same signal the header pill
   uses for "proven", not a toast that has to be dismissed. */
.obs.said {
  border-color: var(--green);
}
.obs.said-bad {
  border-color: var(--amber);
}
/* …and while it is saying something, the purple rim gets out of the way. The
   breathing highlight is drawn ON the border it is brightening, so left running
   it would paint over exactly the green (or amber) hairline that IS the answer
   — a press would report itself in a colour nobody could see. Declared after
   the `:hover` block above and at equal specificity, which is what lets it win
   for the one moment that matters: the press that raised the readout came from
   a pointer that is by definition still on the button. */
.obs.said::after,
.obs.said-bad::after {
  animation: none;
  opacity: 0;
}
.obs-said {
  position: absolute;
  right: 0;
  top: calc(100% + 8px);
  z-index: 3;
  padding: 3px 8px;
  border: 1px solid currentColor;
  border-radius: 999px;
  background: rgba(14, 14, 16, 0.95);
  color: var(--fg-3);
  font-size: 9px;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  white-space: nowrap;
  pointer-events: none;
}
.obs-said:empty {
  display: none;
}
.obs.said ~ .obs-said {
  color: var(--green);
}
.obs.said-bad ~ .obs-said {
  color: var(--amber);
}

@media (prefers-reduced-motion: reduce) {
  .obs-ring,
  .obs::before,
  .obs::after {
    animation: none;
  }
  .obs-ring {
    opacity: 1;
  }
  .obs::before {
    opacity: 1;
  }
  /* The settled end of the breath, which is where the other two already stop:
     the button wears its "found" look permanently rather than its dimmest one,
     so a reader who asked for no motion still gets the brightest reading of the
     control and not a duller page. `.obs.said::after` outranks this on
     specificity, so the green readout still wins here exactly as it does above
     — the suppression is a rule about the answer, not about the animation. */
  .obs::after {
    opacity: 1;
  }
  .obs,
  .obs-tip {
    transition: none;
  }
}

/* ---------- ask for a channel (finding 103) ------------------------------- */
/* A form, at the volume of a form.
 *
 * This row already has exactly one loud control: `Add to OBS` is the page's
 * `.chip.go`, it breathes purple, and it is the thing a streamer came here to
 * press. A second control at that volume would be two objects waving at
 * somebody who arrived to watch a stream, so this is the QUIET half — a well
 * and a key, in the shape this site already uses for a typeable box
 * (`contact.css`'s `.cu-input`: one hairline, a near-black fill, a purple rim
 * and a 3px wash of it on focus) and the shape the APP uses for exactly this
 * pair of controls (`#doorbell` / `.doorbellid` / `.doorbellring`,
 * web/index.html, where finding 57 is the record of what a page looks like when
 * nobody says what an input is). Square-ish corners, no glow at rest, and the
 * purple arrives only when a finger or the keyboard is on the key.
 *
 * Every number is a whole tenth of `--ui` (finding 94), so the control grows
 * from a laptop to a wall with the rest of the site's chrome — and it is
 * deliberately about two thirds the button's scale, because "small that fits
 * inline there" is what was asked for. */
.request {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  /* The containing block for the completion's overlay (finding 168, below).
     Nothing else here is positioned, so this changes no paint order. */
  position: relative;
  /* GROWS into the row's slack, and two things ride on that — both layout
     rather than taste. The form's left edge lands at the row's left with no
     rule about alignment anywhere; and `.request-said`, which is
     `flex-basis: 100%` of THIS box, gets the whole left half of the row to say
     a sentence on one line. Without the grow the longest of the six lines wraps
     to two, the row gains a line, and `Add to OBS` moves — because the row is
     `align-items: center` and the form would have become its tallest child. */
  flex: 1 1 auto;
  /* TWO GAPS, and only one of them is a gap (finding 152). Between the field and
     the key, `0.6 × --ui`, unchanged. Between the row and the answer under it,
     ZERO — the answer is empty on every visit that has not asked for anything,
     and a row gap is space the plate reserves for a line that is not there. The
     stand-off the sentence needs when it DOES arrive is its own margin, below. */
  column-gap: calc(var(--ui) * 0.6);
  row-gap: 0;
}
/* ONE declaration for the well and the key: they are a pair, and a pair that
   drifts by a pixel reads as two objects that happen to be adjacent.
   `appearance: none` is what actually takes the platform's white box and grey
   button away — this is the first `<input>` and the first `<button
   type="submit">` this page has ever carried, and a browser paints both in its
   own clothes until something says otherwise. */
.request input,
.request button {
  appearance: none;
  -webkit-appearance: none;
  margin: 0;
  padding: calc(var(--ui) * 0.6) calc(var(--ui) * 0.9);
  border: 1px solid var(--line);
  border-radius: calc(var(--ui) * 0.4);
  background: rgba(8, 8, 11, 0.6);
  color: var(--fg);
  font-family: var(--mono);
  font-size: var(--ui);
  line-height: 1.4;
  transition:
    color 140ms ease-out,
    border-color 140ms ease-out,
    background 140ms ease-out;
}
/* THE PLACEHOLDER, EXACTLY — and this is the one number in the brief the copy
   would not let it have. `a live Twitch channel` is 21 characters and the brief
   asks for a field "~14-16 characters wide"; a box that clipped its own prompt
   would be asking a question the visitor cannot read, which is the rule the
   app's doorbell states in the same words ("20ch is the placeholder, exactly").
   So the CONTENT box is 21ch and the declared width adds the padding and the
   two borders back, because `box-sizing: border-box` is this site's global.
   Fixed rather than fluid for the doorbell's other reason: a box that resizes
   while a login is typed moves the key beside it. */
.request input {
  width: calc(21ch + var(--ui) * 1.8 + 2px);
  min-width: 0;
}
/* The prompt, in the legends' grey — at `--fg` it would read as an answer
   already typed. `opacity: 1` because Firefox dims a placeholder on top of
   whatever colour it is given. */
.request input::placeholder {
  color: var(--fg-3);
  opacity: 1;
}
/* The caret's own disclosure, in `contact.css`'s numbers to the value: this
   site has one look for a field that has the cursor in it and this is it. */
.request input:focus {
  outline: none;
  border-color: var(--purple);
  box-shadow: 0 0 0 3px rgba(145, 71, 255, 0.16);
}
.request button {
  cursor: pointer;
  white-space: nowrap;
}
@media (hover: hover) {
  /* Pointer-only: a tap on a phone would otherwise leave the key lit until
     something else was touched. */
  .request button:hover:enabled {
    color: #fff;
    border-color: var(--purple);
    background: rgba(145, 71, 255, 0.16);
  }
}
/* The keyboard's half and the finger's. `:focus-visible` is a disclosure and
   gets the site's own ring — the one `.chip.go` and `Add to OBS` already wear —
   and `:active` is real and ends with the finger. */
.request button:focus-visible {
  outline: 2px solid var(--purple);
  outline-offset: 2px;
}
.request button:active:enabled {
  color: #fff;
  border-color: var(--purple);
  background: rgba(145, 71, 255, 0.16);
  transform: translateY(1px);
}
/* In the air. The word does not change and there is no spinner: the state lasts
   one round trip, and a control that renames itself mid-press is a control
   whose label nobody can rely on. */
.request button:disabled {
  color: var(--fg-2);
  opacity: 0.75;
  cursor: default;
}
/* The invisible Turnstile's mount. Empty until the FIRST submit renders a
   widget into it, and `:empty` is what keeps it out of the row's flex line —
   and out of its gap — until then. Once Cloudflare has put something in it, it
   is a box again: an `interaction-only` widget draws nothing on almost every
   visit, but on the rare one where it decides a person has to be asked, the
   asking has to be reachable. */
#request-turnstile {
  flex: none;
}
#request-turnstile:empty {
  display: none;
}
/* FINDING 168 — THE GHOST. Kevin, 2026-09-18: *"tab to autocomplete the
 * streamer username"*. `demo-request.js` lays this box exactly over the field
 * (left, top, width, height read off the field each time it is drawn) and puts
 * two spans in it: what was typed, INVISIBLE, and then the rest of the
 * completion, in the placeholder's grey. The invisible copy is what lands the
 * rest at the caret — the same glyphs in the same font end at the same pixel —
 * so every text metric here is `.request input`'s, line for line: the padding,
 * a border of the same WIDTH (transparent — the field draws the real one), the
 * font, the size, the line-height.
 *
 * ABSOLUTE, so it adds no width to the row. The plate's width is derived from
 * this row (findings 152 and 154), and an in-flow twin of the field would have
 * doubled it. `pointer-events: none` so the field under it still takes the
 * click; `aria-hidden` on the element itself, because the field's value is the
 * accessible truth and `aria-autocomplete="inline"` on the input is what says a
 * completion may be offered. */
.request-ghost {
  position: absolute;
  left: 0;
  top: 0;
  box-sizing: border-box;
  overflow: hidden;
  pointer-events: none;
  white-space: pre;
  padding: calc(var(--ui) * 0.6) calc(var(--ui) * 0.9);
  border: 1px solid transparent;
  border-radius: calc(var(--ui) * 0.4);
  font-family: var(--mono);
  font-size: var(--ui);
  line-height: 1.4;
  color: var(--fg-3);
}
.request-ghost[hidden] {
  display: none;
}
/* Present for its width, never for its ink. `visibility` rather than
   `color: transparent` because a selection highlight or a forced-colours mode
   could paint transparent text back in. */
.request-ghost .typed {
  visibility: hidden;
}
/* The answer, on a line of its own under the pair.
 *
 * ITS HEIGHT IS NO LONGER RESERVED — finding 152, Kevin: *"the bottom left
 * request box spacing is a bit weird"*. What it was reserving was one `1.1 ×
 * --ui` line plus the form's own row gap — 23px of nothing under the form row,
 * on every visit, which is the dead band at the bottom of the plate he
 * photographed.
 *
 * THE RESERVATION'S ARGUMENT DIED WITH FINDING 142 and was left standing here:
 * *"Nothing in this row may move when the line arrives: `Add to OBS` is centred
 * in the same row"*. There is no such row. The two controls are in two corner
 * slots at opposite ends of the field now, and this one is anchored by its
 * BOTTOM edge (`#corner-bl { bottom: var(--slot) }`) — so a plate that grows
 * grows UPWARD, into the field, and moves nothing at all.
 *
 * STILL deliberately NOT `:empty { display: none }` — it is `role="status"`, and
 * a live region that is out of the accessibility tree until its text lands is a
 * live region a screen reader is entitled to miss. Empty, it is a rendered,
 * zero-height box in the tree, which costs the layout nothing and keeps the
 * announcement.
 *
 * IT IS A CHILD OF THE PLATE AND NOT OF THE FORM (finding 154; `demo.html`
 * carries the move and the reason). As the form's last item on a line of its own
 * it was ADDED to the form's intrinsic width — a wrapped flex container's
 * max-content width is the sum of its items' contributions — so the longest
 * sentence this page can say decided how wide the plate could get: 728px at
 * 1920, against a 312px resting plate. In the plate's own column the widest
 * child wins instead of the sum, and that is the form row.
 *
 * `max-width: 30ch` is the belt to that: a cap in the element's own type, so the
 * sentence wraps under the form rather than reaching past it at any `--ui`. The
 * plate grows UPWARD as it wraps, which is the direction it has. */
.request-said {
  max-width: 30ch;
  color: var(--fg-3);
  font-size: calc(var(--ui) * 0.8);
  line-height: 1.35;
  letter-spacing: 0.04em;
}
/* The stand-off, on the sentence rather than on the container that would have
   applied it to an empty one. */
.request-said:not(:empty) {
  margin-top: calc(var(--ui) * 0.6);
}
/* The row's own signal vocabulary, the one the OBS readout already speaks:
   green for a thing that happened, amber for a refusal. The FIELD is left
   alone on purpose — four of the six lines are about the world rather than
   about what was typed (`not_live`, `too_small`, `rate_limited`, and the one
   that covers our own side), so an amber rim on the box would be pointing at
   the wrong thing. `demo-request.js` sets the class and never a colour. */
.request-said.ok {
  color: var(--green);
}
.request-said.bad {
  color: var(--amber);
}
/* The label is the field's accessible NAME and nothing else. The placeholder
   already says what to type, and a visible label saying nearly the same three
   words would be the page asking twice. Clipped rather than `display: none`,
   which would take it out of the accessibility tree and leave the field
   unnamed — which is the whole reason it exists. */
.request label {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  white-space: nowrap;
  clip-path: inset(50%);
}
@media (prefers-reduced-motion: reduce) {
  .request input,
  .request button {
    transition: none;
  }
}

/* THE STREAM'S BOX, and since finding 142 the only `.frame` left on this page:
 * 16:9 because a Twitch player is, and a dark ground because a player that has
 * not painted yet should be a dark rectangle rather than a hole. Its POSITION
 * is decided two places up — parked on `#corner-tr` on a desktop, a row of the
 * stack on a phone — and never here, so the two readings of where the stream is
 * cannot disagree. */
.frame {
  position: relative;
  aspect-ratio: 16 / 9;
  background: var(--ink);
}
.frame iframe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
  display: block;
}
/* ---------- THE STREAM'S SOUND (finding 151) ----------
 *
 * Kevin, walk 11, 2026-09-17: *"Can you add an unmute button for the stream
 * audio somewhere also."*
 *
 * OF THE CARD, because the card is where the stream is, and OURS, because
 * Twitch's own controls are behind the full-card drag shield finding 145 put
 * over the player. `demo.js` builds it, owns the two words on it and rebuilds
 * the player when it is pressed; this is its box.
 *
 * HANGING OFF THE CARD, NEVER ON THE PICTURE — finding 155. It was drawn at the
 * player's bottom-left corner, and a control drawn on the player is paint over
 * the player, which is exactly what Twitch's visibility test calls occlusion:
 * the stream refuses to autoplay under it and pauses if it appears later
 * (`pip.css`, the note on `.pip-grip`, has the measurements). So it sits on a
 * strip just outside the card's box — `--pip-hang` below the card's bottom
 * edge — and the strip flips to ABOVE the card at the two bottom homes and
 * steps aside for the resize grip, both in `pip.css`, keyed on the corner
 * `pip.js` already writes. `--pip-hang` is 4px and not 1: the strip is measured
 * from the card's padding box and the player reaches 1px past that on every
 * side — it is the card's BORDER-box since finding 157 (`pip.css`, the player's
 * rule; before that it reached 1px past the content box on the right and
 * bottom only) — and a box 1px off the player was measured to occlude while 2px
 * clears. 4 from the padding box is 3 clear of the player.
 *
 * A GLYPH, NOT A WORD — finding 158 (Kevin, walk 11: *"i also hate the mute
 * button just saying 'mute' like that I want a glyph"*). The control carries an
 * inline SVG speaker (`demo.js` draws it): slashed while the stream is muted,
 * with two arcs beside it while it has sound. The WORD is still there as the
 * accessible name — `aria-label` `unmute` / `mute`, the action, as before —
 * and nowhere else. The box is a flex box around the glyph so nothing here
 * depends on a text baseline, and the glyph is as tall as the line of type it
 * replaces (0.8 × --ui at line-height 1.4 = 1.12 × --ui), so the strip is
 * exactly as tall as it was: 23.1px at --ui 13.
 *
 * THE PLATE'S OWN CLOTHES at two thirds the scale — the same translucent ground,
 * hairline and blur the two corner plates wear, because this is the same kind of
 * thing: a control of this page's, standing beside something else's picture. */
#twitchframe {
  --pip-hang: 4px;
}
.pip-sound {
  position: absolute;
  left: 0;
  top: calc(100% + var(--pip-hang));
  /* ABOVE the drag shield (`z-index: 2`) and level with the resize grip, which
     it never overlaps. Below the stage's own gesture shield (4), which is only
     ever up while a pointer is down — a press cannot land on this one then. */
  z-index: 3;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  appearance: none;
  -webkit-appearance: none;
  margin: 0;
  padding: calc(var(--ui) * 0.25) calc(var(--ui) * 0.5);
  border: 1px solid var(--line);
  border-radius: calc(var(--ui) * 0.4);
  background: rgba(10, 10, 13, 0.72);
  backdrop-filter: blur(7px);
  -webkit-backdrop-filter: blur(7px);
  color: var(--fg-2);
  font-family: var(--mono);
  font-size: calc(var(--ui) * 0.8);
  letter-spacing: 0.04em;
  line-height: 1.4;
  cursor: pointer;
  transition:
    color 140ms ease-out,
    border-color 140ms ease-out;
}
/* The glyph: `currentColor` all the way down, so the hover and focus rules
   below recolour it exactly as they recoloured the word. `display: block` so
   an inline SVG's descender gap cannot add a pixel to the strip. */
.pip-sound > svg {
  display: block;
  width: calc(var(--ui) * 1.12);
  height: calc(var(--ui) * 1.12);
}
@media (hover: hover) {
  .pip-sound:hover {
    color: var(--fg);
    border-color: var(--purple);
  }
}
.pip-sound:focus-visible {
  outline: 2px solid var(--purple);
  outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
  .pip-sound {
    transition: none;
  }
}

/* The demo host's own page is not shown until it has SPOKEN — the relay's
   first message. Not decoration: a cross-origin iframe that 404s or cannot
   resolve paints the browser's own error document, which is a full-bleed WHITE
   rectangle in the middle of a dark page, and it would sit there for the whole
   liveness timeout before the fallback card replaced it. The iframe still
   loads underneath; only its paint is held. */
#demoframe {
  opacity: 0;
  transition: opacity 260ms ease-out;
}
body.up #demoframe {
  opacity: 1;
}
body.up #demoph {
  display: none;
}

/* What stands in a frame until the thing behind it is ready. */
.ph {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  text-align: center;
  color: var(--fg-3);
  font-size: clamp(11px, 0.62vw, 14px);
  letter-spacing: 0.09em;
  text-transform: uppercase;
}

/* `.frame.app` STOOD HERE — the second frame, the one holding the APP, with a
   430px floor under a 16:9 box and a 560px portrait one at phone width. The
   floor existed because the framed page carried the operator STRIP: the box had
   to be deep enough for however many rows the strip wrapped to, or the VIEW
   rack's own sliders fell off the bottom edge.

   Finding 142 took the wrapper away on a desktop — the field IS the stage now,
   and the stage's own bounds are at the top of this file — and kept both
   numbers where they still mean something, on `#demoframe` inside the stacking
   block above. They mean something DIFFERENT there: the frame is chrome-free,
   so what 430 and 560 now buy is a field deep enough to read bubbles in on a
   narrow page, which is the shape the walked phone layout had. Portrait below
   700px for the same reason it always was — a phone is taller than it is wide,
   and a 16:9 letterbox on one is a strip of field with nothing in it. */

/* Portrait at phone width. The ratio is the old `padding-bottom: 133.333%`
   written as the ratio it always was (3:4 = 133.33% of the width), now that the
   box is an iframe with a stated width rather than a div holding one. */
@media (max-width: 700px) {
  #demoframe {
    aspect-ratio: 3 / 4;
    min-height: 560px;
  }
}

/* FINDING 98 DELETED THE RULE THAT STOOD HERE. It was `body.up .chip .dot {
   background: var(--green) }` — the header's `live demo` chip lighting green
   once the relay had spoken. That chip is gone (Fable's ruling: this page IS
   the demo, so it does not need a chip saying so), and the only `.chip .dot`
   left in this header is the `Beta` badge, whose dot is amber on all four
   document pages by the same ruling. Leaving the rule would have turned Beta
   green on one page out of four — a state chip changing colour for a reason
   that has nothing to do with the state it names. The live/down signal itself
   is not lost: `#livemark` in the bubbleup pane's bar says `live` in words
   (demo.js `markUp`), and `site-demo.spec.ts` asserts it. */

/* ---------- the first frame: the live half, or nothing yet ---------- */
/* FINDING 124. Kevin, 2026-09-15: *"when i hit the demo endpoint today it
   flashed the 2 panes then gave me this [the down card] for a few seconds
   before giving me the regular countdown screen"*.

   The live half is this document's DEFAULT — markup, no class, painted as soon
   as it is parsed — and `body.down` (below) is what replaces it with the card.
   Since finding 115 that class arrives on the Worker's answer at ~370 ms rather
   than on demo.js's twelve-second timeout, which is twelve seconds better and
   still one paint too late: the visitor sees the product, loses it, and is then
   told why. A flash is worse than a wait because it reads as something
   breaking.

   So it is held for the third of a second in which the page does not yet know,
   and `display: none` is the way it is held rather than `visibility` or an
   opacity. It is the same declaration `body.down` makes below, for the same
   reason it makes it: a zero-width box is what stops demo.js building a Twitch
   player into something that is not being drawn, and a player built into a
   hidden box comes up STOPPED and does not reconsider (the autoplay note in
   demo.js). A `visibility: hidden` frame would have a box, and would have
   bought this flash at the price of the stream.

   WHAT IS HELD IS THE PRODUCT AND ONLY THE PRODUCT (finding 142): the field,
   its placeholder and the stream. `#stage` itself is NOT hidden — it is the
   flex item the page's height is made of, and taking it away for 600 ms would
   trade a flash of the panes for a page that jumps — and the corner slots are
   not hidden either, because this page's own controls are not what flashed.
   They are where they will be, from the first paint to the last.

   THE CLASS IS ONE-WAY: the markup adds it, script only ever removes it, and
   both demo.js and demo-wake.js are entitled to — see `FIRST_FRAME_HOLD_MS`.
   Two removers cannot disagree about a class neither of them can put back, and
   the ceiling lives in demo.js so that a demo-wake.js which never loads cannot
   leave a visitor looking at an empty page. */
body.settling #demoframe,
body.settling #demoph,
body.settling #twitchframe {
  display: none;
}

/* ---------- the live half's two states ---------- */
/* Default (waiting) shows the field: the demo host's own iframe is loading and
   is the best possible progress indicator for itself. Only a timeout swaps in
   the card — see demo.js for why a timeout is the signal and not `onerror`. */
.fallback {
  display: none;
}
body.down #demoframe,
body.down #demoph,
body.down #twitchframe {
  display: none;
}
/* The card takes the field's place — the whole segment on a desktop, the
   field's row in the stack — rather than sitting in a corner of it. The two
   plates stay on top of it (tree order, again), which is what keeps the request
   form and the pill reachable while the demo is asleep. */
body.down .fallback {
  display: block;
  position: absolute;
  inset: 0;
  overflow: auto;
  border: 1px solid var(--line);
  border-left: 3px solid var(--amber);
  border-radius: 3px 9px 9px 3px;
  background: linear-gradient(180deg, rgba(16, 16, 20, 0.82), rgba(10, 10, 13, 0.82));
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
  padding: 26px 28px;
}
@media (max-width: 1200px) {
  /* In the stack it is a card again, in its own row, as tall as it needs. */
  body.down .fallback {
    position: static;
    overflow: visible;
  }
}
.fallback .h {
  margin-top: 10px;
}
.fallback .copy {
  margin-bottom: 0;
}

/* ---------- the card's third state: waking ---------- */
/* `body.waking` is demo-wake.js's, and it is the ONE thing that file asks of
   this sheet. While a wake is in flight the card carries the wake line and its
   countdown instead of the retry line, because the two say contradictory things
   at the same time — "Waking the demo" over a 14 and "retry 3 in 41s" are the
   same wait described by two mechanisms, and the visitor should be told about
   the one that is actually going to end it.

   The card's heading goes the same way: `the demo isn't up right now` over
   `Waking the demo` is the same contradiction in two lines (finding 70), and
   it comes back by itself the moment `waking` comes off — a wake that fails
   lands on the card that shipped before, heading and all.

   Written as a hide rather than as a swap so the failure mode is right: with no
   Worker attached nothing ever adds `waking`, `#wakemark` stays the empty
   element it is in markup, and this rule never applies. The card is exactly the
   card that shipped before. */
body.waking #retrymark,
body.waking .fallback .h {
  display: none;
}

/* THE COUNTDOWN. The one big number on a page that otherwise has none.
 *
 * It is sized like a readout rather than like a heading — bigger than the `.h`
 * above it, because for twenty seconds it is the only thing on this card that
 * is going to change, and a visitor deciding whether to stay is reading it and
 * nothing else. `--fg` and not `--amber`: amber on this page is the STATE
 * light (the chip's dot, this card's left rule) and a number is data, so it
 * takes the page's brightest neutral and leaves the alarm colour alone.
 *
 * `:empty` is what makes this element cost nothing when there is no number —
 * no reserved line, no gap above the retry line — so the card with the
 * countdown and the card without it are both properly composed rather than one
 * being the other with a hole in it.
 *
 * NOT ANIMATED, and there is deliberately no `prefers-reduced-motion` rule
 * anywhere near it: the number changing IS the information, not a decoration of
 * it, and a reader who asked for less motion still needs to know how much
 * longer. What that reader is spared is everything this could have been and is
 * not — no ring, no sweep, no pulse, no transition on the digit.
 *
 * `tabular-nums` is belt-and-braces on a page whose font is `--mono` already,
 * and it is the one thing standing between 20 -> 19 and a number that shifts
 * sideways once a second if the stack ever falls through to a proportional
 * face. */
#wakecount {
  margin: 4px 0 16px;
  color: var(--fg);
  font-size: clamp(40px, 4.6vw, 64px);
  font-weight: 700;
  line-height: 1;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
}
#wakecount:empty {
  display: none;
}

/* The Turnstile box. Zero-sized until Cloudflare decides a visitor needs an
   interaction, at which point the widget's own iframe gives it a size — so an
   invisible solve leaves no gap in the card and a challenged one is not
   clipped. `#wake-turnstile:empty` is the un-rendered state; demo-wake.js only
   renders into it after the demo has been found down. */
#wake-turnstile:empty {
  display: none;
}
#wake-turnstile {
  margin-top: 14px;
}

/* ---------- foot ---------- */
.foot {
  display: flex;
  align-items: center;
  gap: 14px;
  flex-wrap: wrap;
  margin-top: clamp(52px, 3vw, 72px);
  padding-top: clamp(20px, 1.3vw, 28px);
  border-top: 1px solid var(--line);
  color: var(--fg-3);
  font-size: clamp(10px, 0.55vw, 12px);
  letter-spacing: 0.14em;
  text-transform: uppercase;
}
.foot a {
  color: var(--fg-2);
  text-decoration: none;
}
.foot a:hover {
  color: var(--fg);
}

/* ---------- S5: the vertical rhythm on a SHORT window ----------------------
 *
 * WHAT FINDING 142 DID TO THIS BLOCK: nothing to its numbers, and everything to
 * what they buy. The page's height is no longer the sum of its parts — `#stage`
 * takes whatever the chrome leaves (see `.wrap`) — so trimming the rhythm on a
 * short window no longer decides whether the page SCROLLS. It decides how much
 * of the window is FIELD: every pixel this block takes off the padding is a
 * pixel the stage grows by, down to its own `min-height` floor, and below that floor
 * the page scrolls exactly as the paragraphs below describe. The measurements
 * are kept because they are the record of where the numbers came from.
 *
 * FINDING (walk 9, lane `studio-site-small`): bubbleup.gg/demo scrolls on a
 * short desktop window. Measured on the running page, 1920x825: the document
 * is 923px against 825 of viewport — 98px over — and 1280x800 is 816 against
 * 800. Nothing is wrong with the panes; what is over is PADDING.
 *
 * WHY IT IS PADDING AND NOT WIDTH. Every vertical measure on this page is a
 * `clamp(floor, Nvw, cap)`, so the air between things is sized off how WIDE
 * the window is. That is the right slope on a normal window and exactly the
 * wrong one on a short-and-wide one: at 1920x825 the page spends 301px of its
 * 923 on vertical padding — 46 above the head, 50 above the panes, 58 + 25
 * around the foot, 77 below it — every one of those numbers inflated by a
 * viewport WIDTH the extra height did not come with. The `--head-cap` guard in
 * `style.css` is the width answer and it is not this file's to move (three
 * other pages stand their headers in that band); the height answer is here.
 *
 * SO: under 923px of viewport height the rhythm stops taking its slope from
 * the width and becomes a multiple of `--ui`, the page's own furniture unit —
 * about half the multiple a tall window gets, and never less than one `--ui`,
 * which is the smallest gap anything on this surface is allowed to have. At
 * 1920x825 that is 923 -> 811px; at 1280x800, 816 -> 743.
 *
 * WHAT IS DELIBERATELY NOT IN HERE:
 *   - `#stage`'s `min-height` (and `#demoframe`'s 430/560 floors in the
 *     stack). Twitch refuses to autoplay an embed under 400x300 and decides
 *     once, at load, so shrinking the field to buy scrollbar-free would trade a
 *     running demo for a tidy one. Kevin's demo-quality charter settles that
 *     trade the other way.
 *   - the corner slots' own `--slot` inset. It is the clearance that keeps the
 *     controls off the field's edges; it is composition, not air.
 *   - `.head`'s own padding. The head's rhythm is shared with the header on
 *     three other pages by eye, and halving it here would be a shape change to
 *     buy 8px this block does not need.
 *
 * WHERE IT STOPS WORKING, BY DESIGN. With the rhythm at its short-window values
 * the page cannot get below the stage's floor plus that rhythm — which since
 * finding 142 is ~655px at 1280 wide and ~683px at 1920, against 743 and 811
 * before it. Shorter than that and /demo scrolls, which is the correct answer:
 * a scrollbar on a 600px-tall window costs a flick of the wheel, and the
 * alternative costs the demo. `e2e/tests/site-demo.spec.ts` asserts the heights
 * that must fit — 1920x825, 1920x900 and 1280x800 — and pins the desktop
 * rectangle so a query that leaked upward would fail rather than quietly
 * re-lay-out the page.
 *
 * SCOPE. `demo.css` is linked from `site/demo.html` and from nowhere else, so
 * a bare selector here is already `/demo`-only — that is how every rule above
 * scopes, and adding a body class to say it again would be markup for nothing.
 *
 * THE BREAKPOINT IS 923px AND IT IS MEASURED, NOT ROUND. 923 is exactly how
 * tall the untouched two-pane page was at 1920 wide — the widest that layout
 * got before `--head-cap`'s 2200px cap took over — so it was the last viewport
 * height at which the full-slope rhythm still fit, and the first at which it
 * did not. Setting the query one pixel lower would have left a band that still
 * scrolled: 860 was tried first and left 861-922 short by up to 23px, which is
 * 1920x900, the commonest inner height a 1080p screen actually gives a browser.
 *
 * It is KEPT at 923 after finding 142, and the reason is no longer "the page
 * fits" — the page fits by construction now. It is that a short window is
 * exactly where the field is starved, so it is where the padding should give
 * way first: above 923 the rhythm is the composed one and the stage has room to
 * spare; under it every unit this block takes back goes to the field.
 *
 * The other open band is WIDTH: past ~1920 wide `--head-cap`'s min(92vw, …)
 * keeps growing, so the head's band and the stage's width grow with it
 * (docs/knobs.md, short-window rhythm); changing that moves style.css and three
 * pages, which is Kevin's call. */
@media (max-height: 923px) {
  .wrap {
    /* 34-52px of vw slope -> 2 units; 70-96px -> 3. The bottom stays the
       deepest of the two because the foot needs a floor to stand on, but on a
       short window it does not need to be nearly twice the top. */
    padding-top: calc(var(--ui) * 2);
    padding-bottom: calc(var(--ui) * 3);
  }
  .sec {
    /* 40-56px -> 2 units. The field is the thing; the air above it is what
       says a new section started, and one head's worth of it is enough. */
    padding-top: calc(var(--ui) * 2);
  }
  .foot {
    /* 52-72px -> 2.5 units of stand-off, 20-28px -> 1.5 above the rule. The
       ratio between the two is kept: the foot still hangs further from the
       page than its own rule hangs from its words. */
    margin-top: calc(var(--ui) * 2.5);
    padding-top: calc(var(--ui) * 1.5);
  }
}
