/**
 * Droplets' member-page stylesheet.
 *
 * LOAD-BEARING LINK ORDER: linked AFTER `/join-view.css`. Two `#app.activity-plate` rules in
 * the 768 block tie join-view.css on specificity with no `!important` and win on source order
 * alone; linked first the desktop card silently keeps the phone's padding and clips the CTA.
 * `stylesheet-integrity.test.js` asserts it. Design notes: docs/design/stylesheet.md
 */

  :root {
    /* `light dark` so the UA draws the caret, the selection highlight and form
       internals in the scheme the page is actually in — index.html declared
       nothing, so it got light chrome under a dark palette. */
    color-scheme: light dark;
    --forest: var(--color-button-accent-default);
    --forest-tap: var(--color-button-accent-tap);
    --green: var(--color-accent-alt-default);
    --tap-firm: var(--color-button-fill-tap);
    --danger: var(--color-error-default);

    --font-heading: var(--pi-font-heading);
    --font-body: var(--pi-font-body);
    --font-mono: var(--pi-font-mono);
    --track-heading: var(--pi-track-heading);
    --track-tight: var(--pi-track-tight);
    --track-body: var(--pi-track-body);
    --lh-heading: var(--pi-lh-heading);
    --lh-para: var(--pi-lh-para);
    --lh-copy: var(--pi-lh-copy);
    --lh-single: var(--pi-lh-single);

    /* ---- Pi .colorset values, light --------------------------------------- */
    --bg: #FAF3EA; --surface: #FCFAF7; --field: #FFFFFF; --tile: #FBF6EE;
/* `#message/background` #F3EADE. A LITERAL, not `var(--color-message-background)`: the
       generated mirror's dark value differs. DECISIONS.md D-AE; `stylesheet-integrity` pins it. */
    --tan: #F3EADE;
/* `join-view.css` reads this with a `url(...)` fallback. That fallback was load-bearing on
       `guest.html`, which declared no `--watermark`; on today's one host this file supplies it,
       and `join-view.css` keeps the fallback by policy. `browser-plate-edge` asserts all three reads. */
    --watermark: url("/img/pi-watermark.svg");
/* THE SAME MARK UNCROPPED, for the one surface that draws it whole. `pi-watermark.svg` is the
       PHONE's window onto it — viewBox `0 0 394 676` over geometry that runs `-378.15 … 631.95`, so
       two thirds of the mark is outside the file's own viewport and no `background-size` can bring
       it back. This variant is the SAME BYTES with the root tag re-pointed at the geometry's own
       box, plus `preserveAspectRatio="none"` because the frame squashes it (1010.1x775.6 drawn from
       artwork that is 1010.1x852). Declared beside `--watermark` at all three sites rather than
       switched in the rule that spends it, so dark is right by construction. */
    --watermark-wide: url("/img/pi-watermark-wide.svg");
    --mint: #DBEDE2; --mint-br: #BEDBCB; --ink: #3E3A35; --muted: #6B6258;
    --faint: #897F73; --border: #EBE3D5; --hairline: #D9D2C5; --dash: #CFC3B0;
    --edge-stroke: #FFFFFF;
    --skeleton: #C9BCA8; --tap: #F5EFE5;
    --av-bg: #F5EADC; --av-you-bg: #E5DCEF;
    --av-you-fg: #4A2E6F; --seat-held: #A79B8A;
    /* ONE DECLARATION, LIGHT AND DARK: `.dp-spot` and the header's waiting seats are the same
       designed fill (`#button/disabled-fill`), so the empty seat defers to the token the DS
       Avatar's `[data-state="pending"]` binds instead of restating it. The dark blocks used to
       carry #3E3A35 here — `--av-bg`'s dark, not this colour's — which is the divergence
       `miniapp-tokens.css` corrects. */
    --seat-free: var(--miniapp-color-avatar-pending-fill);
    --shadow: rgba(14,14,12,.16);
    --shadow-soft: rgba(94,68,15,.11);
/* `Button.disabled_fill`. THE DARK FILL DIVERGES FROM THE FRAMES AND IS LEFT ALONE: their
       #1F3B2E would push the dark disabled label from 1.67:1 to 1.29:1. Reported, not changed. */
    --btn-off: #B7C0B3;
    --bg-fade: rgba(250,243,234,0); --hair-bubble: rgba(14,59,38,0.1);
/* THE PAGE, AS OPPOSED TO THE CARD — spent by the `[data-presentation="fullscreen"]` rule
       at the foot of this palette, which is where the reasoning lives. Equal to `--bg` here on
       purpose: `#base/background` and this app's light page colour are the same #FAF3EA, so the
       rule is inert in light and stays honest rather than leaning on a `var()` fallback that
       would drift the day light's `--bg` moved. (doc: bg-page) */
    --bg-page: #FAF3EA; --bg-page-fade: rgba(250,243,234,0);
/* THE INK FOR A LABEL ON A SEAT TINT. This list is SCRAPED, not trusted
       (`test/unit/page-claims.test.js`), and it has been wrong twice. (doc: on-accent)

       WHAT READS IT NOW: nothing. PI-9852 took every letter on a seat tint to the design
       system Avatar's themed `#base/contrast`; dropping the declaration is its own change.

       `--on-accent-dim` HAS NO READER LEFT; kept declared because the untracked
       `docs/handoff/r6-feel-shots/verify-feel.mjs` names it. Untracked, not a guard: nothing
       in the repo would go red if this line went. (doc: on-accent) */
    --on-accent: #FBF6EE; --on-accent-dim: #B9D4C4;
    --mark-plate: #FAF3EA;
    --paper: var(--bg); --accent: var(--green); --ink-deep: var(--forest);
    --ink-deep-tap: var(--forest-tap); --av-fg: var(--forest);
  }
/* ---- dark ----------------------------------------------------------------
     Two blocks deliberately: the media query is the OS fallback, `[data-theme]` is Pi's own
     setting and outranks it. `stylesheet-integrity.test.js`'s "app.css's two dark blocks"
     compares the two declaration SETS, so a token edited in one and not the other fails here.
     THIS LINE USED TO NAME `verify-feel.mjs` INSTEAD, and that was a claim of coverage that
     did not exist: `.gitignore` excludes every app's `docs/` subtree, so that script is in no
     clone, and no npm script or workflow runs it. (doc: dark) */
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
      /* --forest, --forest-tap, --green, --tap-firm and --danger are absent
         from both dark blocks on purpose: they alias design-system tokens in
         :root, and those already carry their own dark values. */
      --bg: #242322; --surface: #1A1918; --field: #2D2B29; --tile: #2D2B29;
      --tan: #35322E;
      --watermark: url("/img/pi-watermark-dark.svg");
      --watermark-wide: url("/img/pi-watermark-wide-dark.svg");
      --mint: #21322A; --mint-br: #2E4A3A; --ink: #FCFAF7; --muted: #B4AFA7;
      --faint: #8F877D; --border: #595249; --hairline: #595249; --dash: #595249; --edge-stroke: #47423B;
      --skeleton: #47423B; --tap: #2D2B29;
      --av-bg: #3E3A35; --av-you-bg: #3A3050;
      --av-you-fg: #CBBBE4; --seat-held: #504A42;
      --shadow: rgba(0,0,0,.55);
      --shadow-soft: rgba(0,0,0,.45);
      --btn-off: #3A4A40;
      --bg-fade: rgba(36,35,34,0); --hair-bubble: #595249;
      --bg-page: #131212; --bg-page-fade: rgba(19,18,18,0);
/* 0.17, HALF WHAT SHIPPED, and the first number on this edge off a real device: Pi's rim
         peaks 58-63 over a 23 fill where .34 renders 105 over 27. A COLOUR, not an opacity -- the
         hue is ground-independent and the alpha is not. (doc: disc-rim) */
      --disc-rim: rgba(255,255,255,.17);
/* A LITERAL IN DARK, NOT `--field`, because the disc has since been photographed BESIDE
         PI'S OWN GLASS IN ONE FRAME rather than against a top-nav export: Pi's chevron reads
         29,29,28 on this page's 36,35,34 -- BELOW it -- where `--field` at 82% composites to
         43,41,40, ABOVE it. #1D1D1C at the same 82% composites to 30,30,29. (doc: disc-plate) */
      --disc-plate: #1D1D1C;
/* The head disc's edge is NOT a uniform rim in dark. ITS OWN TOKEN, so that retiring it here
         does not move `--disc-rim`, which the three iOS platters and delete mode's pill still
         spend through `--ios-glass-rim`. (doc: disc-spec) */
      --disc-head-rim: transparent;
/* THE SPECULAR that replaces it. `from 90deg` -- NOT 0 -- is what lands the .17 lobes on
         br/tl and the two page-colour stops on bl/tr, which is the phase measured off Pi's
         control; unrotated the lit arcs sit on the wrong diagonal. Masked to the outer .75px by
         the rule that spends it. (doc: disc-spec) */
      --disc-spec: conic-gradient(from 90deg, rgba(255,255,255,.15) 0deg, rgba(255,255,255,.17) 45deg, rgba(255,255,255,.15) 90deg, rgba(255,255,255,.02) 135deg, rgba(255,255,255,.15) 180deg, rgba(255,255,255,.17) 225deg, rgba(255,255,255,.15) 270deg, rgba(255,255,255,.02) 315deg, rgba(255,255,255,.15) 360deg);
    }
  }
  :root[data-theme="dark"] {
    /* Same five omissions as the media-query block above. */
    --bg: #242322; --surface: #1A1918; --field: #2D2B29; --tile: #2D2B29;
    --tan: #35322E;
    --watermark: url("/img/pi-watermark-dark.svg");
    --watermark-wide: url("/img/pi-watermark-wide-dark.svg");
    --mint: #21322A; --mint-br: #2E4A3A; --ink: #FCFAF7; --muted: #B4AFA7;
    --faint: #8F877D; --border: #595249; --hairline: #595249; --dash: #595249; --edge-stroke: #47423B;
    --skeleton: #47423B; --tap: #2D2B29;
    --av-bg: #3E3A35; --av-you-bg: #3A3050;
    --av-you-fg: #CBBBE4; --seat-held: #504A42;
    --shadow: rgba(0,0,0,.55);
    --shadow-soft: rgba(0,0,0,.45);
    --btn-off: #3A4A40;
    --bg-fade: rgba(36,35,34,0); --hair-bubble: #595249;
    --bg-page: #131212; --bg-page-fade: rgba(19,18,18,0);
    --disc-rim: rgba(255,255,255,.17);
    --disc-plate: #1D1D1C;
    --disc-head-rim: transparent;
    --disc-spec: conic-gradient(from 90deg, rgba(255,255,255,.15) 0deg, rgba(255,255,255,.17) 45deg, rgba(255,255,255,.15) 90deg, rgba(255,255,255,.02) 135deg, rgba(255,255,255,.15) 180deg, rgba(255,255,255,.17) 225deg, rgba(255,255,255,.15) 270deg, rgba(255,255,255,.02) 315deg, rgba(255,255,255,.15) 360deg);
  }
/* ---- THE PAGE COLOUR, WHEN PI WEB GAVE THIS PAGE THE WHOLE PANEL ---------
     `--bg` above is the CARD value, and it is right for a card: dark `#card/default` is
     #242322 in both design systems (plaza `primitives.ts`, `pi-design/tokens.css`). It is
     the wrong colour for a PAGE, and dark is where the two part company — light's
     `#base/background` and `#card/default` are near enough that nobody could see it.

     WHAT IT LOOKED LIKE. Pi Web draws its own surfaces from `#base/background`, #131212 in
     dark: the sidebar's neighbour, the band under its group-chat composer, the lot. This
     page painted #242322 across the panel between them, so the seam showed wherever Pi's
     own surface met this one — reported against the composer band, which is the one place
     they touch on a straight edge. The frames say the panel is Pi's page: `11048:49775`
     binds the 1160-wide `Page` beside the 280 sidebar to `#base/background`, and
     `11048:49777` is the node.

     WHY `data-presentation` IS THE RIGHT GATE, and it is the whole of the risk here.
     `presentation.js` seeds that attribute from `pi_presentation` before the first paint,
     and `applyPresentation` re-states it on `init`. Only PI WEB ever sets it: iOS and
     Android build their own URLs and append no such parameter, and neither puts
     `presentation` in its `init` reply either — argued at length in `presentation.js`. So
     a native host keeps #242322, which is what its own container draws, and every value
     in the dark block measured against this page's 36,35,34 keeps its premise (the iOS
     head disc's `--disc-plate` most of all, which is composited against it by hand and is
     `[data-platform="ios"]`-gated).

     AND ONLY `fullscreen`. Pi Web's other presentation is the 705 card — its own frame and
     `pi.presentSheet`'s route sheet — where this page IS a card and #242322 is the answer.
     That arm is `data-presentation="card"` and this rule does not touch it.

     NOT IN THE TWO DARK BLOCKS, deliberately: they are palette lists that
     `stylesheet-integrity.test.js` compares set-against-set, and a conditional override
     belongs to neither. One rule, no media query, because the tokens it spends carry the
     scheme — `--bg-page` is declared in all three palettes, which is also what keeps this
     inert in light. `--bg-page-fade` follows for the two `linear-gradient(to bottom,
     var(--bg-fade), var(--bg) 12px)` masks: a fade whose transparent end still names the
     old page colour hazes grey through a premultiplied interpolation. (doc: bg-page) */
  :root[data-presentation="fullscreen"] { --bg: var(--bg-page); --bg-fade: var(--bg-page-fade); }
  * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
/* An icon is the size it says it is (F2-17). The reset's `max-width: 100%` on a grid item
     resolves in WebKit against a track still being sized: a 20px mark came out 8px and painted
     3.67px of ink -- present, hit-testable and INVISIBLE. Chromium is fine, so no browser test
     could see it. (doc: icon-size) */
  .ic { max-width: none; }
  html { background: var(--bg); }
  body {
    margin: 0; background: var(--bg); color: var(--ink);
    font-family: var(--font-body); font-weight: 500;
    letter-spacing: var(--track-body); line-height: var(--lh-copy);
/* `var(--safe-top)`, not `env()` inline: body's padding, `.head-pinned`'s offset and the cap
       are ONE mechanism and must resolve the same number. The bottom inset is read again on
       `.bottom-cta`, which is fixed and inherits none of this -- those two are NOT one. */
    padding: var(--safe-top, 0px) 0 env(safe-area-inset-bottom);
    -webkit-user-select: none; user-select: none; -webkit-touch-callout: none;
  }
/* Cap the status-bar strip: body's padding IS the strip and padding SCROLLS, while
     `.head-pinned` sticks below it, so at some offset the strip holds what passes under it.
     Fixed, and on `body` rather than the pinned row, so it MOVES NOTHING. (doc: safe-top-cap) */
  body::before {
    content: ""; position: fixed; top: 0; left: 0; right: 0;
    height: var(--safe-top, 0px);
    background: var(--bg); z-index: 11; pointer-events: none;
  }
  input, textarea, .link-url {
    -webkit-user-select: text; user-select: text; -webkit-touch-callout: default;
  }
/* TWO gutters, and this is the one statement of it (F2-12): content at 24, the BAR at 16
     where Pi floats its own controls. `.head` takes the difference back. (doc: gutters) */
  :root { --gutter: 24px; --bar-gutter: 16px; }
  :root { --dp-preview-measure: 345px; }
/* `position: relative` and NOT a transform, as the desktop rule says: `.thread-foot` anchors to
     this foot under Pi's keyboard, and a transform would take the `fixed` bands with it. */
  #app { max-width: 460px; margin: 0 auto; padding: 20px var(--gutter) 40px; position: relative; }
  .serif { font-family: var(--font-heading); font-weight: 400; }
  .eyebrow {
    font-family: var(--font-body); font-weight: 500;
    font-size: 10px; letter-spacing: 0; line-height: var(--lh-single);
    text-transform: uppercase; color: var(--green);
  }
/* `--color-text-accent`, not `--forest` -- `--forest` IS a button FILL, spent as heading ink
     across this surface while the guest surface next door used the right token. Canonical note:
     four other rules cite it. (doc: heading-ink) */
  h1.title {
    font-family: var(--font-heading); font-size: 24px; font-weight: 400;
    letter-spacing: var(--track-heading); color: var(--color-text-accent); margin: 0;
    line-height: 1.1;
  }
  .sub {
    font-size: 14px; color: var(--muted); letter-spacing: var(--track-body);
    line-height: var(--lh-copy); margin: 0;
  }
  :root { --pi-chrome-top: 0px; --pi-chrome-lead: 0px; --pi-chrome-trail: 0px; }
/* Pi's group-chat composer, drawn NATIVELY OVER this web view.
     READ AS ITS TWO PARTS, NEVER AS THE TOTAL: the payload's `bottom` is composer PLUS keyboard,
     and reading it paid the keyboard TWICE once Pi stopped resizing the web view.
     `--kb` AND `--pi-host-kb` ARE TWO MEASUREMENTS OF ONE KEYBOARD, so `max` and never `+`;
     they differ by the home indicator (302 host vs 336 page on an 852pt phone). (doc: foot-room) */
  :root {
    --pi-composer-h: 0px;
    --pi-host-kb: 0px;
    --pi-foot-room: calc(var(--pi-composer-h, 0px) + max(var(--kb, 0px), var(--pi-host-kb, 0px)));
  }
/* THE GLASS ENDS WHERE PI'S CHROME BEGINS. All four layers are `position: fixed` over the LAYOUT
     viewport and Pi draws its composer natively over the bottom of it, so each put its foot -- and
     every control on it -- under a band the page cannot see: 96pt with the composer alone, 432 with
     Pi's field focused. `--kb` is 0 exactly then, so it cannot see any of it. ONE TERM for all
     four, the same one the two bands lift by — three spend it here and the sheet's scrim spends
     it through the hook below. Every wrapper takes `top/left/right` longhands: a later `inset`
     shorthand would re-declare this at equal specificity and win. (doc: foot-room) */
  .menu-wrap, .pi-dialog-scrim, .press-wrap { bottom: var(--pi-foot-room, 0px); }
  /* The sheet's scrim computes its own `bottom` from `--pi-sheet-foot` and the card its
     plate from `--pi-sheet-surface` (pi-design `css/sheet.css`); both facts are still ours.
     `--bg` and not a literal, so the fullscreen rule above keeps re-pointing the plate with
     the page. */
  .sheet-wrap { --pi-sheet-foot: var(--pi-foot-room, 0px); --pi-sheet-surface: var(--bg); }
  :root { --safe-top: env(safe-area-inset-top, 0px); }
  :root { --dt: 1; -webkit-text-size-adjust: calc(var(--dt) * 100%); }
  .head {
    display: flex; align-items: center; gap: 8px; margin-bottom: 18px;
    /* Back out to `TopNavigation`'s own 16, so the page's controls and the
       host's floating pair sit on one line. See `--bar-gutter`. */
    margin-left: calc(var(--bar-gutter) - var(--gutter));
    margin-right: calc(var(--bar-gutter) - var(--gutter));
    padding-left: max(0px, calc(var(--pi-chrome-lead) - var(--bar-gutter)));
    padding-right: max(0px, calc(var(--pi-chrome-trail) - var(--bar-gutter)));
  }
  .head .grow { flex: 1; }
/* THE SETUP HEADER'S TRAILING CLOSE HAS NO RULE OF ITS OWN, AND THAT IS THE FIX (PI-9779).
     IF YOU EVER RE-ADD ONE, WRITE IT `.head .step-close` (0,2,0): a bare `.step-close` is
     (0,1,0), ties `.icon-btn` and LOSES on source order -- which is how the old `display: none`
     never applied while passing every desktop assertion. (doc: step-close) */
/* ---- delete mode's header row (PI-9709 P2a) -----------------------------
     `1fr auto 1fr` RESERVES THE EXIT'S OWN WIDTH WITHOUT MEASURING IT. Absolutely centred, the
     count was drawn UNDER the word Cancel from AX1 up -- measured. (doc: delete-header) */
/* NO `position: relative` HERE, AND THAT IS A TRAP THIS ROW HAS FALLEN INTO ONCE:
     `.head-pinned` is already `sticky`, i.e. positioned, and `.head[data-delete-mode]` (0,2,0)
     would beat it (0,1,0) and quietly UNSTICK the header in delete mode. */
  .head[data-delete-mode] {
    display: grid; grid-template-columns: 1fr auto 1fr;
  }
  .head[data-delete-mode] .grow {
    grid-column: 2; min-width: 0; pointer-events: none;
  }
  .head[data-delete-mode] .thread-title {
    font-family: var(--font-body); font-size: 18px; font-weight: 500;
    line-height: var(--lh-copy); color: var(--ink); text-align: center;
  }
  .head[data-delete-mode] #pick-done {
    grid-column: 3; margin-left: auto; color: var(--green);
  }
/* ---- the iOS arm: Cancel is a GLASS PILL (PI-9852 item 7) ---------------
     THE HEIGHT IS 40 AND NOT THE FRAME'S 44, DELIBERATELY: 44 makes this header 3.1px taller on
     the way into the mode, moving a reader mid-thread. Two e2e cases pin it. (doc: delete-cancel) */
  [data-platform="ios"] .head[data-delete-mode] #pick-done {
    padding: 0 16px; border-radius: 999px;
    /* THE TOKENS, NOT A FOURTH COPY OF THE RECIPE (PI-9835 §4.1): this plate said it was reused
       rather than re-derived and then drifted anyway, 30 against the menus' 40. The rim comes
       with it -- Pi's own control is the same `glassEffect` call as the head discs one row up --
       and the lift does not. (doc: ios-glass) */
    background: var(--ios-glass-fill);
    -webkit-backdrop-filter: var(--ios-glass-blur);
    backdrop-filter: var(--ios-glass-blur);
    box-shadow: var(--ios-glass-rim);
  }
/* Restated because the rule above is (1,3,0) and `.text-btn:active` is (0,2,0): without it
     the pill is the one control on the row that does not darken, and the press cases read the
     TRANSITION rather than the fill, so nothing would have said so. */
  [data-platform="ios"] .head[data-delete-mode] #pick-done:active,
  [data-platform="ios"] .head[data-delete-mode] #pick-done.pressed {
    background: var(--tap-firm);
  }
  .head-hosted { min-height: var(--pi-chrome-top); }
  .head-pinned.head-hosted {
    min-height: var(--pi-chrome-top); padding-top: 0; margin-bottom: 16px;
  }
  /* The painted band below the row travels with the margin it covers. */
  .head-pinned.head-hosted::after { height: 16px; }
/* Every wait is ONE box in ONE place, and `margin: 0` is the whole of the defect it replaced:
     a margin on a `fixed; inset: 0` box put the dots 33px off centre, stepped them 33px mid-wait
     as the inset resolved, and jumped 144px at the handover. (doc: boot-wait) */
  #boot-wait,
  #app > .loading { position: fixed; inset: 0; margin: 0; }
/* A CUSTOM PROPERTY BECAUSE A SECOND FILE DOES ARITHMETIC WITH IT: `join-view.css` states the
     preview's wash edge as a length from `#app`'s top and this row is what lies between. Written
     as a literal in both places they drift -- which is D-43. (doc: head-disc) */
  :root { --head-disc: 40px; }
  .head .icon-btn { width: var(--head-disc); height: var(--head-disc); }
/* `:not(.flat)` rather than deleting `.flat` from the list: a bare `.head .icon-btn` still
     MATCHES a flat button and would keep the `backdrop-filter` -- a blurred circle with no fill,
     i.e. the reported defect with the colour taken out. DECISIONS.md D-3. (doc: head-glass) */
  .head .icon-btn:not(.flat) {
    background: color-mix(in srgb, var(--surface) 82%, transparent);
    backdrop-filter: var(--glass-blur);
    -webkit-backdrop-filter: var(--glass-blur);
  }
/* ---- AND ON iOS THE BARE ONES ARE GLASS TOO ------------------------------
     THE DIAMETER IS 48 HERE, WHICH REVERSES WHAT THIS COMMENT USED TO SAY: the 40 it cited is
     `RoundIconButtonStyle`, the NON-glass button's style. `TopNavigation.glassCircle` carries a
     literal 48, and the reference measures 48.14pt. Costs the row 8px on iOS. (doc: ios-head) */
  [data-platform="ios"] { --head-disc: 48px; }
  /* ---- AND IT RIDES PI'S LINE, WHICH THE DIAMETER ALONE DID NOT BUY ---------
     Pi's own disc sits 12..60 below the safe area (`browser-chrome-band`'s 12 + 48 + 6); this
     row starts every head at `#app`'s 20, so the two were 8pt apart once Pi's glass filled its
     slot. A NEGATIVE BLOCK MARGIN and not a smaller `padding-top`: `-8 / +8` leaves the margin
     box 48, so `.head` stays 68 tall and `join-view.css` goes on subtracting what it subtracts
     (D-43). `min(8px, ...)` and not a bare 8: the lift is only correct when there is a band to
     lift into. (doc: ios-head) */
  [data-platform="ios"] .head .icon-btn {
    --head-lift: min(8px, var(--pi-chrome-top));
    margin-top: calc(-1 * var(--head-lift));
    margin-bottom: var(--head-lift);
  }
  /* It names `:not(.flat)` too, which it did not used to: `--disc-plate` is themed, so without
     that dark would draw the bare disc on `--field` beside a filled one still on `--surface` --
     two materials on one row. Pi's own three controls are real Liquid Glass as of apple-pudding
     59f15ad65, so this row's disc is now a match rather than an approximation in isolation --
     and below iOS 26 Pi's stay bare while this arm does not follow them down. (doc: ios-head) */
  /* AND THE POPUPS' ✕ IS THE SAME CONTROL, so it wears the same material: `openSheet`'s head
     emits the ••• button's exact class list and only its ANCESTOR differs, so the four rules
     below take a `.sheet-wrap .sheet-head` twin rather than a second copy of the recipe. The
     twins land at the same specificity as the `.head` arms they join — (0,4,0), and (0,5,0) for
     the pressed fill, which the plate would otherwise swallow. Reported off staging: "the glass
     effect should be applied to all the X buttons of the popup UI". NOT the DIAMETER and not the
     `--head-lift` margins: those line the disc up with Pi's own floating control, which no sheet
     head is beside, and `.sheet-head .icon-btn`'s 40 is what `browser-frame-heads` measures.
     The HOSTED heads (`hostedSheetHead`, `setupSheetHead`) are NOT `.sheet-wrap` and are left
     bare — a separate question, raised in `evidence/63.md`. */
  [data-platform="ios"] .head .icon-btn.flat,
  [data-platform="ios"] .head .icon-btn:not(.flat),
  [data-platform="ios"] .sheet-wrap .sheet-head .icon-btn {
    background: color-mix(in srgb, var(--disc-plate) 82%, transparent);
    backdrop-filter: var(--glass-blur);
    -webkit-backdrop-filter: var(--glass-blur);
  }
  /* ---- AND THE PLATE HAS A LIT EDGE -----------------------------------------
     Pi's rim is BRIGHTER in G and B than the interior, which is what says specular highlight
     rather than antialiasing. Two overlay ASSETS were built and both under-deliver against this
     one line, so none ships. `.5px`, not `1px`, which reads as a border. The light arm has since
     been photographed beside Pi's own glass in one frame and moves nothing. IN DARK THIS RULE
     PAINTS NOTHING: `--disc-head-rim` is `transparent` there and the specular below is the
     whole edge -- the shape was wrong in dark, not only the alpha. (doc: disc-rim) */
  [data-platform="ios"] .head .icon-btn.flat,
  [data-platform="ios"] .head .icon-btn:not(.flat),
  [data-platform="ios"] .sheet-wrap .sheet-head .icon-btn {
    box-shadow: inset 0 0 0 .5px var(--disc-head-rim);
  }
  /* ---- AND IN DARK THE EDGE IS A SPECULAR, NOT A RING (PI-9835) --------------
     Photographed in ONE FRAME against Pi's real Liquid Glass back chevron, Pi's dark edge is
     TWO LIT LOBES on one diagonal with the other falling to page colour, 2.27px wide at half
     maximum; the uniform inset above draws an even circle 1.3px wide -- the wrong shape AND 41%
     under on width. `::before` AND NOT A SECOND BOX-SHADOW because a conic cannot be a shadow.
     `@supports` BECAUSE THE MASK IS THE RING: unmasked, the conic would fill the whole disc,
     which is worse than no specular -- so an engine that cannot mask draws no edge on the dark
     disc at all, a dead branch on `[data-platform="ios"]` rather than a live gap. Light paints
     none of this: `--disc-spec` is `none` at `:root`. (doc: disc-spec) */
  @supports ((-webkit-mask: radial-gradient(#000, #000)) or (mask: radial-gradient(#000, #000))) {
    [data-platform="ios"] .head .icon-btn.flat::before,
    [data-platform="ios"] .head .icon-btn:not(.flat)::before,
    [data-platform="ios"] .sheet-wrap .sheet-head .icon-btn::before {
      content: ""; position: absolute; inset: 0; border-radius: 50%;
      pointer-events: none; background: var(--disc-spec);
      -webkit-mask: radial-gradient(closest-side, #0000 calc(100% - .75px), #000 calc(100% - .75px));
      mask: radial-gradient(closest-side, #0000 calc(100% - .75px), #000 calc(100% - .75px));
    }
  }
  [data-platform="ios"] .head .icon-btn.flat:active,
  [data-platform="ios"] .head .icon-btn.flat.pressed,
  [data-platform="ios"] .sheet-wrap .sheet-head .icon-btn:active,
  [data-platform="ios"] .sheet-wrap .sheet-head .icon-btn.pressed { background: var(--tap-firm); }
  :root {
    --glass-tint: color-mix(in srgb, var(--bg) 68%, transparent);
    /* The radius on its own, because the plated head below spends it WITHOUT
       the saturation — one 18 rather than two. */
    --glass-blur-radius: blur(18px);
    --glass-blur: saturate(150%) var(--glass-blur-radius);
    --disc-rim: #FFFFFF;
    --disc-plate: var(--surface);
    /* SEPARATELY NAMEABLE FROM `--disc-rim`, which in light it simply IS -- this arm does not
       move. A shared token would have taken the three iOS platters' rim and delete mode's pill
       with it, and those are not this edge. And no specular in light: Pi's light rim measures 255
       against a 244 fill, a genuinely uniform opaque white. (doc: disc-spec) */
    --disc-head-rim: var(--disc-rim);
    --disc-spec: none;
    /* ---- THE iOS PLATE, DECLARED ONCE (PI-9835 §2.2, §4.1) -----------------
       A mini app is a WKWebView: no `pi.*` method presents a `UIMenu` and no CSS reaches
       `glassEffect`, so the material can only be approximated -- and the rule is that it is
       approximated ONCE. Six surfaces were citing "the same plate the iOS menus wear", three at
       `blur(40px)` and three at `blur(30px)`. The RIM was the half being invented: `2561:10794`
       is `Liquid Glass - Regular - Medium`, Shadow / Fill / Glass Effect with no stroke node, so
       a `1px solid var(--border)` on an iOS platter is a Material hairline on an iPhone.
       `test/unit/ios-glass-material.test.js` keeps the who-spends-what list true.
       (doc: ios-glass) */
    --ios-glass-fill: color-mix(in srgb, var(--field) 92%, transparent);
    /* 40 is `2561:10794`'s own backdrop-blur; the 0.92 beside it is that frame's 0.6 declined on
       a measurement. Nothing may set a different radius per surface. (doc: ios-plate) */
    --ios-glass-blur: blur(40px);
    /* The material's edge, not a drawn line: half a pixel catches light, a whole one is a border. */
    --ios-glass-rim: inset 0 0 0 .5px var(--disc-rim);
    /* A popover's own elevation, and NOT part of the material: `#pick-done` takes none of it and
       the two alerts have their own. A surface's lift is the surface's; its plate is not. */
    --ios-glass-lift: 0 6px 20px var(--shadow-soft), 0 1px 2px var(--shadow-soft);
    --confetti-z: 30;
  }
  /* A translucent plate with nothing blurred behind it is just a washed-out one,
     so an engine without the filter keeps the opaque token. ONE block, where
     there used to be four identical ones beside four copies of the recipe. */
  @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
    :root {
      --glass-tint: var(--bg); --glass-blur: none;
      --ios-glass-fill: var(--field); --ios-glass-blur: none;
    }
  }
  .head-pinned {
    position: sticky;
    top: var(--safe-top, 0px);
    /* Over the content (`.rx-row` is 1) and under everything that covers the page:
       `.bottom-cta` 20, the confetti 30, `.sheet-wrap`/`.menu-wrap` 40,
       `.press-wrap` 45, `.pi-dialog-scrim` 50, `.toast` 60. */
    z-index: 10;
/* Eat `#app`'s padding-top into the painted box and give it back, so the band reaches the
       status bar. LONGHANDS: the shorthand would zero the `--pi-chrome-lead` padding above, which
       is what keeps the avatars out from under Pi's chevron. */
    margin-top: -20px;
    padding-top: 20px;
    background: var(--glass-tint);
    backdrop-filter: var(--glass-blur);
    -webkit-backdrop-filter: var(--glass-blur);
  }
  .head-pinned::after {
    content: ""; position: absolute; top: 100%; left: 0; right: 0; height: 18px;
    background: var(--glass-tint);
    backdrop-filter: var(--glass-blur);
    -webkit-backdrop-filter: var(--glass-blur);
  }
  .head-pinned[data-delete-mode]::after {
    border-bottom: 1px solid var(--color-divider-stroke);
  }
/* The iOS thread head is SOLID: this row carries avatars, a wrapping title AND a subtitle, and
     any ramp under that much text let message rows read straight through it (PI-10358; Figma
     16046:74272 `I16046:74581` adds a visible "Solid Background" layer for group chats). Still no
     backdrop-filter -- the two stacked slabs it replaced stepped an avatar 27/255. (doc: thread-fade)
     `.head-thread` is the thread screen NAMING ITSELF: on iPhone Safari `stepHeadRow` draws an
     app-owned pinned head too, so exclusions alone took the gallery and the share error with it. */
  [data-platform="ios"] #app > .head-pinned.head-thread:not([data-delete-mode]) {
    background: var(--bg);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }
  [data-platform="ios"] #app > .head-pinned.head-thread:not([data-delete-mode])::after {
    /* The row's own box is the whole band; the tail keeps its 18px height (delete mode's divider
       hangs on it) and paints nothing, so the band does not extend past the header's content. */
    background: none;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }
/* ---- the same row over the activity plate (PI-9491 D8) ------------------
     The glass is a slab of the PAGE's colour, which on a plated screen is not what is under it.
     WHY EVERY BROWSER CAPTURE MISSED IT: the plate was scored at x=4, inside `#app`'s padding
     and outside this row's border box. `>`, and the properties rather than the two variables,
     because re-pointing `--glass-blur` would reach any button nested here. (doc: plate-glass) */
  @supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
    #app.activity-plate > .head-pinned,
    #app.activity-plate > .head-pinned::after {
      background: transparent;
      backdrop-filter: var(--glass-blur-radius);
      -webkit-backdrop-filter: var(--glass-blur-radius);
    }
/* ITS OWN RULE RATHER THAN A FOURTH SELECTOR ON THE GROUP ABOVE: an invalid selector
       invalidates the WHOLE group it is in, so an engine without `:has()` would lose the pinned
       row's glass along with this. Glass and not `transparent` -- plated screens do scroll. */
    body:has(#app.activity-plate)::before {
      background: transparent;
      backdrop-filter: var(--glass-blur-radius);
      -webkit-backdrop-filter: var(--glass-blur-radius);
    }
  }
  .grow { flex: 1; min-width: 0; }
  .tight > * { min-width: 0; }
  .accent { color: var(--forest); }
/* THE DISC, THE PILL, THE TEXT BUTTON, THE FIELD AND THE CHIP ARE `pi-design`'s NOW
     (`css/components.css`'s `[data-size="cta"]`, `[data-size="disc"]` and
     `[data-variant="app"]` arms, plus `css/controls.css`). Every emitter carries the package
     class beside its own, so the rules below that qualify one of them still select. What
     stays here is what the package cannot know: where the head's disc sits, its material, and
     every screen-scoped override. */
/* AND THIS ONE, WHICH HAS TO STAY AT (0,2,0). As a package compound it would be (0,3,0) and
     outrank `.icon-btn:active` below, so a bare disc stopped darkening under a finger while
     drawing correctly in every screenshot -- `platform-idioms` caught it. */
  .icon-btn.flat { background: transparent; }
/* THE FLAT SURFACES ARE THE PACKAGE'S NOW. The card and the empty state are
     `[data-variant="app"]` arms of `.pi-card`/`.pi-empty` (pi-design `css/components.css`);
     the notice, the banner and the badge are `css/surfaces.css`; the link preview is
     `css/preview.css` and the roster row `css/person-row.css`. What is left in this file is
     what the package cannot know: where a surface sits on a screen, and this app's own
     content rules ON one. (doc: card, notice, badge, banner) */
  .prompt {
    font-family: var(--font-heading); font-size: 18px; font-weight: 400;
    letter-spacing: var(--track-body); line-height: var(--lh-heading);
  }
  .row {
    display: flex; align-items: center; justify-content: space-between;
    gap: 12px; background: var(--surface); border: 1px solid var(--border);
    border-radius: 14px; padding: 14px 16px; margin-bottom: 10px;
  }
/* NO `.row.on`: nothing under `public/` has ever rendered `class="row"`, and the pair was
     being cited as live evidence in `--on-accent`'s comment. `.row` itself is deliberately NOT
     deleted -- it is a box model, not a contrast claim, with its own blast radius. */
  .row .name { font-size: 16px; font-weight: 500; line-height: var(--lh-single); }
  .row .hint {
    font-size: 12px; color: var(--muted); line-height: var(--lh-single);
    margin-top: 2px;
  }
  .tick {
    width: 20px; height: 20px; margin: 0 6px; border-radius: 50%;
    border: 1.5px solid var(--muted);
    flex: 0 0 auto; display: grid; place-items: center;
  }
  .tick.on { background: var(--green); border-color: var(--green); color: #fff; font-size: 12px; }
/* The two STATES stay: `.pi-chip[data-variant="app"]` is the box, and these are this app's
     own two inks on it. Both are (0,2,0) and tie the package arm, so this file's later link
     order is what decides them. */
  .chip.on { background: var(--tap-firm); color: var(--ink); }
  .chip.spent { color: var(--dash); }
/* THE FREE SEAT'S FILL, AND NOTHING ELSE — the box, the face, both sizes and the seat ink
     are `.pi-avatar`'s now (pi-design `css/avatar.css`) and every `.av` emitter carries the
     class. This pair has no upstream equal, and (0,1,0) is load-bearing: `[data-ink="seat"]`
     is (0,2,0) and must still win a seat that is taken. */
  .av { background: var(--av-bg); color: var(--av-fg); }
  .locked {
    position: relative; background: var(--surface);
    border: 1px solid var(--color-divider-stroke);
    border-radius: 10px; padding: 16px; overflow: hidden; margin-left: 32px;
    min-height: 139px;
  }
  .locked .blur { filter: blur(7px); opacity: .45; }
  .locked .badge-ghost {
    width: 48px; height: 25px; border-radius: 48px; background: var(--dash); margin-bottom: 14px;
  }
  .locked .bar { height: 11px; border-radius: 6px; background: var(--skeleton); margin-bottom: 9px; }
  .locked .bar.s { width: 58%; }
  .locked .over {
    position: absolute; inset: 0; display: flex; flex-direction: column; gap: 8px;
    align-items: center; justify-content: center; text-align: center; padding: 0 20px;
    font-size: 12px; line-height: var(--lh-copy); color: var(--color-text-accent);
  }
  .locked .over > span { max-width: 200px; }
  .locked .over .lock-mark { display: inline-flex; color: var(--muted); }
  .countdown { margin: -4px 0 12px 44px; font-size: 12px; color: var(--muted); }
/* THE SHEET IS `.pi-sheet*` NOW (pi-design `css/sheet.css`) — the scrim, the card, both
     `data-variant`s, the head, the handle, the title, the two motion pairs and the 768px
     centred modal. What is left in this file is what the package cannot know: the host's foot
     band (the hook below), and this app's own content rules ON a sheet. (doc: sheet-tall,
     action-sheet) */
  .sheet-tall:has(#save) { display: flex; flex-direction: column; }
  .sheet-tall:has(#save) > * { flex: 0 0 auto; }
  .sheet-tall:has(#save) > .btn { margin-top: auto; }

  .sheet-actions .delete-actions {
    display: flex; flex-direction: column; align-items: center; gap: 12px;
  }
  /* The panel takes focus so the Tab trap has somewhere to start; it is not a
     control and must not draw a ring. `openDeleteChoice` moves focus onto the
     real buttons from there, which keep theirs. */
  .sheet-actions .delete-actions:focus { outline: none; }
  /* THIS PAGE'S GUTTER, NOT THE COMPONENT'S: back out from `.pi-sheet`'s 24 of padding to the
     bar's 16, the same expression `.head` uses on a screen. The package draws the head; where
     the head's controls line up with Pi's own is this app's layout. */
  .sheet-head {
    margin-left: calc(var(--bar-gutter) - var(--gutter));
    margin-right: calc(var(--bar-gutter) - var(--gutter));
  }
  .sheet-head [data-close][aria-label="Close"] { margin-left: auto; }
  .sheet-head .icon-btn { width: 40px; height: 40px; }
  /* THE HEAD STAYS WHILE THE CARD SCROLLS. `.pi-sheet` is the scroller (pi-design
     `css/sheet.css`), so an overflowing sheet took the grab handle and the ✕ up with the
     content -- measured on Group members at 390x844 with 16 rows, the ✕ from y=311.4 to
     y=-221.6 on one wheel, i.e. off the card entirely. Reported off staging.
     STICKY AND NOT A SCROLLING BODY: the card has no body element to give `overflow-y`, and
     adding one moves the customize sheet's `margin-top: auto` Save and the four desktop rules
     that position `.sheet-title` absolutely off `.sheet` -- a bigger change than the bug.
     THE CARD'S TOP PADDING IS THE HEAD'S OWN BORDER, so the head's box reaches the card's top
     edge and there is nothing left to line up. #287 covered that 16px strip with a spread-less
     `box-shadow: 0 -16px 0` instead: paint over content that is still THERE -- hit testing at
     the strip's middle returns a `.person-row`, and one rounded edge shows a 1px line of it
     while scrolling, which is what Wei saw on iOS. A TRANSPARENT BORDER AND NOT PADDING: the
     grab is absolute against the head's PADDING box, which a border carries down with the row
     and padding would not. Row 63's glass head reuses this box — a `backdrop-filter` can only
     blur the strip the head's own box covers. Numbers: `evidence/70.md`.
     `z-index`, because a positioned descendant of the content would otherwise paint over the
     head it scrolls under — the same rung `.head-pinned` takes on a screen. */
  .sheet-wrap > .sheet:has(> .sheet-head) { padding-top: 0; }
  .sheet-wrap > .sheet > .sheet-head {
    position: sticky;
    top: 0;
    z-index: 1;
    border-top: 16px solid transparent;
    background: var(--pi-sheet-surface);
  }
/* THE OUTER CORNER AND THE INNER SCROLL, MEMBERS ONLY. `.pi-sheet` is normally both the
     rounded-corner clip (`border-radius` + `overflow-y: auto`) AND the element the reader
     scrolls, in one box -- scrolling it forces the browser to re-rasterize that corner clip on
     every frame along with the content. Real WebKit can momentarily lose the clip mid-scroll
     and let a hairline of whatever sits BEHIND THE WHOLE MODAL (the thread page, not the member
     list) show through the rounded corner -- reported again on a TestFlight/staging build as
     the thread header's own group avatar bleeding through Group members' top-left corner while
     scrolling the roster. `browser-sheet-swipe.test.js`'s geometry checks (this rule's sibling
     above) read a SETTLED `getBoundingClientRect()`, which cannot see a live rasterization
     glitch, so nothing there could have caught it and nothing there regresses: the head still
     sits flush with the card's top edge at rest, this only changes WHICH element scrolls.
     Splitting the two jobs across two boxes fixes it structurally rather than papering over a
     frame: `.sheet` keeps the rounding but never scrolls, so its clip is computed once and never
     touched by a scroll event again; `.sheet-scroll` does the scrolling and owns no corner of
     its own to lose. `min-height: 0` is the standard flex fix: without it a column flex item's
     minimum height is its own content, so the list would grow the 65dvh card instead of
     scrolling inside it. `:has(> .sheet-scroll)` AND NOT A BARE `.sheet-members`: `openSheet`
     only builds `.sheet-scroll` for a members sheet opened below 768px, and a members sheet
     opened AT that width keeps the flat structure the desktop block three rules down still
     expects -- a bare selector here zeroed that card's padding too and moved its ✕ 24px
     (measured x=368 against the frame's 344, `browser-desktop-layout.test.js`). */
  .sheet-wrap > .sheet.sheet-members:has(> .sheet-scroll) {
    display: flex;
    flex-direction: column;
    overflow: hidden;
    padding: 0;
  }
  /* `overscroll-behavior: contain` OF ITS OWN, and not inherited from `.sheet, .menu` (below,
     line ~1894): that rule holds the limits of a box that scrolls, and for a members sheet
     `.sheet` no longer does -- `.sheet-scroll` is what has the overflow now, so it is what
     needs the containment or a second wheel past either end chains to the thread behind the
     modal, the exact defect `browser-sheet-scroll-contain` already exists to catch (codex,
     gpt-5.6-sol). */
  .sheet-wrap > .sheet.sheet-members > .sheet-scroll {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    overscroll-behavior: contain;
    padding: 16px 24px 26px;
  }
  .sheet-wrap > .sheet.sheet-members > .sheet-scroll:has(> .sheet-head) { padding-top: 0; }
  .sheet-wrap > .sheet.sheet-members > .sheet-scroll > .sheet-head {
    position: sticky;
    top: 0;
    z-index: 1;
    border-top: 16px solid transparent;
    background: var(--pi-sheet-surface);
  }

  .sheet-tall .sheet-title,
  .sheet:has(#answer) .sheet-title { font-size: 32px; }
/* 36 AND NOT THE 24 THE INSPECTOR SHOWS: the frame's rows carry their own `py: 12` and ours
     declare `padding: 0`, so that 12 arrives folded into this step. `browser-frame-heads`
     asserts the two together. A class and not `:has()`, which would silently keep the bug. */
  .sheet-members .sheet-title { margin-bottom: 36px; }
  .sheet .title-row { margin: 6px 0 16px; }
  .title-row .sheet-title { margin: 0; }
  /* The answer sheet is the only sheet in the app that draws a countdown, and since PI-9772
     only its HOSTED presentation does — `answerSheetBody`'s `clock` option has the reasoning.
     The rule stays because that presentation is still built. */
  .sheet .countdown { margin-left: 44px; }
  .sheet .sub { margin: -6px 0 16px; }
  /* That pull-up is tuned against `.sheet-title`'s 16px bottom: every OTHER
     `.sub` in a sheet sits directly under the title, and -6 against 16 is the
     10px gap the design draws there. Nine sheets share it, so it stays theirs. */
  /* AND THE ANSWER SHEET IS THE ONE THAT DOES NOT. `6302:124741` draws 24 between the title
     and the waiting line, not 10 — the four-row column is 24 / 24 / 8 / 24 ink to ink.
     #123 deleted this rule on the stated grounds that "PI-9772 removes the helper outright,
     so it has no subject left"; PI-9780 put the helper back on mobile web, so the rule has a
     subject again. Without it the line falls through to -6 against the title's 16 and collapses
     to 10 — 14 short. `margin-bottom: 0` because `.prompt-row`'s own 24 draws the next gap. */
  .sheet .sub.answer-sub { margin: 24px 0 0; }

/* ---- the answer sheet's column, which is now two columns ---------------------
     AND IT IS NOT ANSWER-SHEET-ONLY: an earlier draft said so and was wrong. Setup's answer step
     mounts the same `.prompt-row` inside `.sheet.sheet-hosted` at `?in=sheet`, where
     `.sheet .prompt-row` (0,2,0) beats `.ask-row` (0,1,0). (doc: answer-column) */
  .sheet .prompt-row { margin: 24px 0; }
  .sheet .prompt-row:has(+ .countdown) { margin-bottom: 8px; }
  .sheet .prompt-row + .countdown { margin: 0 0 24px 44px; }

  .sheet:has(#enable) .sheet-head { height: 0; align-items: flex-start; }
  .sheet:has(#enable) .sheet-title { margin-top: 20px; max-width: 306px; }
  .sheet:has(#enable) .sub { font-size: 18px; letter-spacing: var(--track-tight); }

  /* The ••• menu: a popover under the header, dismissed by tapping past it. Longhands, not
     `inset` -- see the shared rule. The origin is unmoved, which `anchorMenu`'s viewport
     coordinates depend on; only the floor moves, and it reads that floor off this box. */
  .menu-wrap { position: fixed; top: 0; left: 0; right: 0; z-index: 40; }
/* THE PLATTER, ITS THREE PLATFORM SHAPES AND ITS MOTION ARE PI-DESIGN'S NOW
     (`css/components.css`, `.pi-menu[data-platform]`). The base was the **WEB** arm and is
     spelled as one there: `detectPlatform` answers `ios`, `android` or NOTHING, and `openMenu`
     writes what it answers — or `web` — onto the platter. Only the iOS PLATE is still here,
     below, because the material is this app's and is declared once. (doc: menus) */
/* Listed here rather than restated beside `.press-menu` so the two menus cannot drift again,
     which is exactly what happened once: every iOS rule named `.menu`, so the other popup kept a
     literal Material elevation on the surface iOS users reach by gesture. */
  [data-platform="ios"] .menu,
  [data-platform="ios"] .press-menu,
  [data-platform="ios"] .press-bar {
/* 0.92 IS THE MEASUREMENT, not a failure to read the frame's 0.6: scored per row against a
       real press at 18.4, this is 8.4 and 0.6 is 42.6, with the message text legible through the
       Mute row. THE COLOUR-DODGE LAYER CANNOT BE BUILT HERE -- as a layer it isolates, and folded
       into the filter WEBKIT SILENTLY DROPS IT, which `@supports` cannot see. The recipe is spent
       from the tokens now, never re-derived. (doc: ios-plate) */
    background: var(--ios-glass-fill);
    -webkit-backdrop-filter: var(--ios-glass-blur);
    backdrop-filter: var(--ios-glass-blur);
    box-shadow: var(--ios-glass-lift);
  }
  /* ---- and the RIM is the material's, not a drawn hairline (PI-9835 §2.2) ----
     THE TWO MENU PLATTERS ONLY: `.press-bar` is `ReactionEmojiBar`, a different component whose
     own frame specifies a stroke. `border: 0` AND NOT A TRANSPARENT ONE -- the 1px was paying for
     geometry as well as ink, and gone, the frame's own 24px icon slot arrives. The lift is
     restated through the same token because this rule outranks the one above on source order
     alone and a `box-shadow` replaces rather than adds. (doc: ios-glass) */
  [data-platform="ios"] .menu,
  [data-platform="ios"] .press-menu {
    border: 0;
    box-shadow: var(--ios-glass-rim), var(--ios-glass-lift);
  }
/* MULTILINE ONLY, so it stays an element rule: `.pi-field[data-variant="app"]` declares
     neither, and a one-line input must not be draggable. */
  textarea { resize: vertical; line-height: var(--lh-copy); }
  .btn.ghost {
    background: transparent; color: var(--faint); font-size: 14px;
    letter-spacing: var(--track-tight); padding: 10px; min-height: auto;
  }
  .link {
    display: block; width: 100%; background: none; border: none;
    font-family: inherit; font-size: 14px; font-weight: 500;
    letter-spacing: var(--track-tight); line-height: var(--lh-single);
    color: var(--faint); padding: 10px;
  }
  #answer { display: block; }
  #count { margin: 5px 0 14px; }
/* AT THE PACKAGE'S DEPTH, AND THAT IS THE WHOLE OF THIS EDIT: the invalid ring used to be
     `input.bad` (0,1,1), which the field arm's `border` shorthand (0,2,0) now outranks -- so
     a renamed-too-long field would have kept its ordinary border with nothing to say so. */
  .pi-field[data-variant="app"].bad { border-color: var(--danger); }

  .rename-actions {
    display: flex; justify-content: flex-end; align-items: center; gap: 8px;
    margin-top: 16px;
  }
  .rename-actions .btn {
    flex: 0 0 96px; width: 96px; min-height: 40px; margin-top: 0;
    border-radius: 10px; padding: 8px 16px;
    font-size: 16px; line-height: 1.1; letter-spacing: var(--track-body);
  }
  .btn.tertiary { background: none; color: var(--green); }
  .btn.tertiary:active, .btn.tertiary.pressed { background: var(--tap); }
  /* The title against the frame's 24 top gutter: `.sheet`'s own padding-top is
     16 and `head: false` leaves the heading first in the box, so 8.
     BODY/M AND NOT `.sheet-title`'s Grenette 24: `6121:111283` letters this dialog's title in
     the body face at all breakpoints, which is what QA filed against Web and Android. Scoped to
     the rename sheet alone — every other sheet is still headed in Grenette. The iOS arm below is
     (0,3,0) and redeclares every one of these properties, so its system face is untouched. */
  .sheet-rename .sheet-title {
    font-family: var(--font-body); font-size: 18px; font-weight: 500;
    line-height: var(--lh-copy); letter-spacing: var(--track-tight);
    margin: 8px 0 16px;
  }

/* ---- THE ANDROID/WEB CONTAINER IS A CENTRED CARD, NOT A BOTTOM SHEET -------
     THE FLAG COMES DOWN. The `rename, ios-alert` section of `stylesheet.md` recorded this as
     "the CONTAINER stays this app's bottom sheet ... Flagged for design, not silently redrawn":
     `6121:111283` is the shared Pi dialog, i.e. a CENTRED 345 card, and the build kept
     `openSheet`'s bottom sheet pending a decision. QA filed it from staging on both surfaces, so
     the decision is made and this is it.

     NOT ONE NUMBER HERE IS TYPED. `6121:111283` is the frame `pi-design` already implements as
     `.pi-dialog[data-width="input"]`, and every value below is that component's own token --
     345, `--pi-radius-dialog`, `--pi-space-6`, `--color-modal-stroke`, `--shadow-modal-shadow`.
     Re-deriving them here is how one dialog becomes two dialogs a release later; the stylesheet
     is linked AFTER `pi-design.css` (see the link-order note in the desktop block), so the
     tokens resolve. The CONTAINER is still `.sheet-wrap`, and that is load-bearing rather than
     lazy: it carries the scrim, the `--kb` offset, `holdBackground`, `dismiss`'s paired
     animations and `startThreadPoll`'s `covered()`. Wearing the dialog's SKIN is the change;
     becoming `.pi-dialog` would be a rewrite of all five.

     `:not([data-platform="ios"])` AND THAT GUARD IS THE WHOLE SAFETY OF THIS BLOCK. These rules
     are (0,5,0) and the iOS alert below is (0,4,0), so on any property both declare these would
     WIN -- silently replacing a tuned system alert with a product dialog on the one platform
     that was already right. The guard, not source order, is what stops it: order cannot settle
     a specificity difference. `:root` rather than `html` for the same reason the (0,4,0) note
     gives -- the class count is the point.

     AND THE MEDIA QUERY IS GONE -- PI-10174. It used to read `max-width: 767.98px`, and its own
     comment gave the reason: the desktop block "already centres this dialog at 400 in a window
     that has room", so an ungated block "would quietly narrow every desktop reader's card to
     345". That reasoning treated 400 as a DESIGN NUMBER. It is not one. 400 is
     `pi-design`'s GENERIC `.pi-sheet-scrim > .pi-sheet` desktop modal -- what every bottom sheet
     in this app becomes above 768 -- and no rename frame anywhere says it. `14514:139915` is a
     1440x1024 DESKTOP frame and the dialog inside it is `14514:140468 Android/Web_Rename`, 345
     wide, the same component the phone arm draws. So the query was not separating two arms; it
     was letting the desktop reader fall through to a sheet default that happened to be nearby,
     and QA filed the result from Chrome at 1366. One frame, one card, every width.

     `browser-rename-shape.test.js` pinned the 400 and is flipped with this. Its own comment
     conceded the case was guarding "the query itself, unwitnessed" rather than a frame.

     AND THE PLATE IS NOT DECLARED HERE, WHICH IS A DECISION AND NOT AN OMISSION. QA filed this
     card from dark, where it plates `#131212` on a page the scrim has taken to `#090808` -- a
     1.07:1 step, a card that reads as a card only because the FIELD inside it has a colour. The
     obvious fix is `--color-modal-background`, and it is WRONG: read off the node,
     `6121:111283` and `14514:140468` both fill with `#base/background`, i.e.
     `--color-background` -- #FAF3EA light, #131212 DARK. The plate QA photographed is the plate
     the frame draws. `pi-design`'s own `.pi-dialog` carries the warning in as many words
     ("the token whose NAME fits is not the token the design uses", `components.css:965`), and
     `--pi-sheet-surface: var(--bg)` (line 256) already lands on it under Pi Web's fullscreen
     presentation, line 179. What the card was missing is the STROKE and the SHADOW -- which the
     frame draws at #3E3A35 in dark and which is the whole of what separates that card from that
     page -- and both were gated behind the media query above. So the un-gating IS the fix, and a
     fill declaration would have been a second, invisible-in-light regression on top of it.

     AND THE CORNER IS 20, WHICH THE FIGMA EXPORT DISAGREES WITH AND LOSES -- SETTLED, PI-10174.
     Three dialog nodes export `rounded-[16px]`: `6121:111283`, `14514:140468` and
     `6589:31651` (the compact Delete modal). This block spends `--pi-radius-dialog`, which is
     20. The tie is broken by the platform that IMPLEMENTS the section rather than by this
     stylesheet guessing: Android's `PiModal` names itself "the canonical home for the
     'Standardize modals in Android' Figma section", cites this same file key, and pins
     `DialogCornerRadius = 20.dp` (`core/ui/.../theme/Shapes.kt:40`). The same composable
     independently confirms the two decisions above it -- `#base/background` fill, 1px
     `#modal/stroke`, `Shadow3`, and `ModalMaxWidth = 345dp`. Two shipping implementations
     against one export; the export is stale. NO CHANGE, and this paragraph exists so the
     next reader who diffs the node against the build stops here instead of "fixing" it.
     (doc: rename) */
/* `safe center` FOR THE REASON THE DESKTOP SIBLING DECLARES IT on this same box: a plain
     `center` on a flex item taller than its container pushes the item's TOP out, where no
     scroller can bring it back. `max-height: 100%` plus `.sheet`'s own `overflow-y: auto`
     should cap the card before that — but THIS is the box that shrinks hardest, because with
     the keyboard up the glass is `innerHeight - --pi-foot-room`, which is exactly what KB-9
     drives. Declared rather than reasoned away: the desktop arm's author thought it worth
     stating for the same element, and a silent omission here reads as an oversight. */
  :root:not([data-platform="ios"]) .sheet-wrap:has(.sheet-rename) {
    align-items: center; align-items: safe center;
    justify-content: center;
    box-sizing: border-box; padding: var(--pi-space-6, 24px);
  }
  :root:not([data-platform="ios"]) .sheet-wrap > .sheet.sheet-rename {
    width: 345px; max-width: 100%;
    max-height: 100%;
    border-radius: var(--pi-radius-dialog, 20px);
    border: 1px solid var(--color-modal-stroke);
    box-shadow: var(--shadow-modal-shadow);
    padding: var(--pi-space-6, 24px);
  }
/* A card that is not attached to the bottom edge does not slide up from it -- the same argument
     the iOS arm makes for borrowing `menu-in-ios`. `dialog-in` is the PI DIALOG's own curve,
     already in this file for `.pi-dialog`, and this card is the Pi dialog: borrowing it beats
     inventing a fourth. Both directions are listed in the reduced-motion block, at the equal
     specificity that block relies on winning by ORDER -- it sits later. Above 768 this replaces
     `pi-sheet-up`, which is the sheet rising from an edge this card is not attached to either. */
  :root:not([data-platform="ios"]) .sheet-wrap > .sheet.sheet-rename {
    animation: dialog-in .2s cubic-bezier(.32,.72,0,1) both;
  }
  :root:not([data-platform="ios"]) .sheet-wrap.closing > .sheet.sheet-rename {
    animation: dialog-in-exit .16s ease-in reverse both;
  }

/* SPECIFICITY IS DELIBERATE: `.sheet-wrap > .sheet` in the desktop block is (0,2,0) and sits
     LATER, so a plain `[data-platform="ios"] .sheet-rename` loses on an iPad in a wide window --
     the one place both arms are live at once. (0,4,0) settles it. */
  [data-platform="ios"] .sheet-wrap:has(.sheet-rename) {
    align-items: center; justify-content: center;
    box-sizing: border-box; padding: 24px;
  }
  [data-platform="ios"] .sheet-wrap > .sheet.sheet-rename {
    width: 300px; max-width: 100%;
    display: flex; flex-direction: column; gap: 10px;
    padding: 14px; border-radius: 34px;
    /* The tokens, for the reason the block declaring them gives: this rule's own
       comment already said the plate was "reused rather than re-derived", and it
       had still drifted a 10px blur radius away from the menus it names. The rim
       is NOT taken — that is §5.1's call on this surface, and it is one token
       spend when somebody makes it. The lift stays this surface's own. */
    background: var(--ios-glass-fill);
    -webkit-backdrop-filter: var(--ios-glass-blur);
    backdrop-filter: var(--ios-glass-blur);
    box-shadow: 0 8px 40px var(--shadow-soft);
  }
  [data-platform="ios"] .sheet-wrap > .sheet.sheet-rename {
    animation: menu-in-ios .22s cubic-bezier(.32,.72,0,1) both;
  }
  [data-platform="ios"] .sheet-wrap.closing > .sheet.sheet-rename {
    animation: menu-ios-exit .14s ease-in reverse both;
  }
/* ---- THE ALERT'S FACE, and why it stopped being this app's ---------------
     This used to say the face is this app's, because DM Sans ships Medium alone. THAT
     SUBSTITUTION IS THE DEFECT THE DESIGNER FILED: measured against Pi's own SwiftUI alert, box,
     corner, field and pills already agree to within a point -- only the LETTERING does not.
     The system face is used INSIDE THE ALERT ONLY. (doc: ios-alert) */
  [data-platform="ios"] .sheet-wrap > .sheet.sheet-rename,
  [data-platform="ios"] .sheet-wrap > .sheet.sheet-actions,
  [data-platform="ios"] .pi-dialog-scrim > .pi-dialog {
    --alert-face: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif;
  }
  [data-platform="ios"] .sheet-rename .sheet-title {
    font-family: var(--alert-face); font-weight: 600;
    font-size: 17px; line-height: 22px; letter-spacing: -0.43px;
    text-align: center; color: var(--ink);
    margin: 0; padding: 8px;
  }
  /* `fills/secondary` — a translucent system grey, so it is correct on both
     schemes without a second value, which is why iOS uses it. */
  [data-platform="ios"] .sheet-rename #name {
    height: 52px; padding: 0 16px; border: none; border-radius: 26px;
    background: rgba(120, 120, 128, .16); color: var(--ink);
    /* The field's own text is `Body/17` Regular in `11048:55190` and in the
       native alert beside it — a form field is not a heading, so it takes the
       400 of the sentence and not the 600 of the title. */
    font-family: var(--alert-face); font-weight: 400;
    font-size: 17px; line-height: 20px; letter-spacing: -0.43px;
  }
  /* The invalid border still has to read with no border to colour, so it becomes
     the ring the platform draws for the same state. */
  [data-platform="ios"] .sheet-rename #name.bad { box-shadow: inset 0 0 0 1px var(--danger); }
  /* 19 is the frame's `pb` on `Text Field(s)`, which the column's own 10 gap
     sits on top of. */
  [data-platform="ios"] .rename-actions { gap: 16px; margin-top: 19px; }
  [data-platform="ios"] .rename-actions .btn {
    flex: 1 1 0; width: auto; min-height: 48px; border-radius: 100px;
    padding: 13px 16px; font-size: 17px; line-height: 22px; letter-spacing: -0.43px;
    /* An alert's actions are Regular; only the title is heavier. `.btn` sets
       500 at (0,1,0), so this (0,3,0) rule is what decides it. */
    font-family: var(--alert-face); font-weight: 400;
  }
  [data-platform="ios"] .rename-actions .btn.tertiary {
    background: rgba(120, 120, 128, .16); color: var(--ink);
  }
  /* `accents/blue` #08f, and it is NOT Pi green on purpose: this alert is drawn
     as the system's, not as the product's, and softening it to `--forest` would
     be overruling the design. */
  [data-platform="ios"] .rename-actions .btn#save:not([disabled]) {
    background: #08f; color: #fff;
  }
  [data-platform="ios"] .rename-actions .btn#save:not([disabled]):active,
  [data-platform="ios"] .rename-actions .btn#save:not([disabled]).pressed {
    background: #0070d6;
  }
/* THE WAIT IS NOT BROKEN BY THIS: `.btn[disabled] .dots i` paints the discs in
     `--color-text-accent` and NOT `currentColor`, so the label colour here cannot reach them. */
  [data-platform="ios"] .rename-actions .btn#save[disabled] {
    background: rgba(120, 120, 128, .16); color: rgba(0, 136, 255, .4);
  }

/* ---- the CONFIRMATIONS: `.pi-dialog` is an alert on iOS too ---------------
     `openConfirm` IS FOUR DIALOGS, so the split goes on `.pi-dialog` and not a Leave-only class.
     AND THE ONE THING THE FRAME GETS WRONG: it hides Cancel on a destructive confirm whose only
     other exits are the scrim and Escape -- not reproduced, and `browser-confirm-shape` goes red
     if it ever is. `components.css` reaches (0,4,0), so the action rules below meet it or beat it. */
  [data-platform="ios"] .pi-dialog-scrim > .pi-dialog {
    /* 300x200 with 14 padding and a 10 column gap (`11048:55173`). `--pi-space-*`
       is the Pi dialog's rhythm and this is not the Pi dialog. */
    width: 300px;
    gap: 10px;
    padding: 14px;
    border: 0;
    border-radius: 34px; /* `sheet/iphone/top-radius` */
    /* The tokens, same reason as the rename alert beside it — and the rim is
       likewise §5.4's, not this item's. */
    background: var(--ios-glass-fill);
    -webkit-backdrop-filter: var(--ios-glass-blur);
    backdrop-filter: var(--ios-glass-blur);
    box-shadow: 0 8px 40px var(--shadow-soft);
  }
  [data-platform="ios"] .pi-dialog .pi-dialog__title { padding: 8px 8px 0; }
  [data-platform="ios"] .pi-dialog .pi-dialog__body  { padding: 0 8px 8px; }
  [data-platform="ios"] .pi-dialog .pi-dialog__title,
  [data-platform="ios"] .pi-dialog .pi-dialog__body {
    font-family: var(--alert-face);
    font-size: 17px; line-height: 22px; letter-spacing: -0.43px;
    text-align: left; color: var(--ink);
  }
  [data-platform="ios"] .pi-dialog .pi-dialog__title { font-weight: 600; }
  [data-platform="ios"] .pi-dialog .pi-dialog__body  { font-weight: 400; }
  /* `11048:55183`: the row is the card's full width and the actions divide it. */
  [data-platform="ios"] .pi-dialog .pi-dialog__actions {
    gap: 16px;
    justify-content: stretch;
  }
  [data-platform="ios"] .pi-dialog .pi-dialog__actions .pi-btn {
    flex: 1 1 0;
    width: auto;
    min-width: 0;
    height: 48px;
    padding: 13px 16px;
  }
/* ---- ONE SET OF ACTIONS FOR BOTH iOS POPUPS -------------------------------
     `.delete-actions` is delete mode's confirm, and it was the last popup on this arm still
     in Pi's clothes -- a filled red pill and a green Cancel in DM Sans. Only the FACE and the
     variant colours are shared: `9178:77623` keeps it a bottom sheet on every arm and
     `browser-delete-mode-confirm` pins its 345/24/26 card, so the box does not move.
     `:is()` takes its most specific arm, so these are (0,3,0) base, (0,4,0) variant, (0,5,0)
     hover/active. `components.css` reaches (0,4,0), NOT (0,3,0) -- `components.css:200`
     `.pi-btn[data-variant="negative"][data-size="medium"]:active` sets `color` -- so the
     variant and hover/active rules meet it or beat it, and the base rule ties that file's
     (0,3,0) block and wins on source order (`index.html` loads pi-design at :37, app.css at :44).
     600 CORRECTS the 400 this block shipped (PI-9852 item 8/11 — QA read the labels against
     the system alert). The body sentence stays 400: `11048:55177` draws it so, and the
     title/body pair is what stops the alert reading flat. */
  [data-platform="ios"] :is(.pi-dialog__actions, .delete-actions) .pi-btn {
    border: 0;
    border-radius: 100px;
    font-family: var(--alert-face); font-weight: 600;
    font-size: 17px; line-height: 22px; letter-spacing: -0.43px;
  }
  [data-platform="ios"] :is(.pi-dialog__actions, .delete-actions) .pi-btn[data-variant="tertiary"],
  [data-platform="ios"] :is(.pi-dialog__actions, .delete-actions) .pi-btn[data-variant="negative"],
  [data-platform="ios"] :is(.pi-dialog__actions, .delete-actions) .pi-btn[data-variant="tertiary"]:hover,
  [data-platform="ios"] :is(.pi-dialog__actions, .delete-actions) .pi-btn[data-variant="negative"]:hover {
    background: rgba(120, 120, 128, .16);
  }
  [data-platform="ios"] :is(.pi-dialog__actions, .delete-actions) .pi-btn[data-variant="tertiary"],
  [data-platform="ios"] :is(.pi-dialog__actions, .delete-actions) .pi-btn[data-variant="tertiary"]:hover {
    color: var(--ink);
  }
  [data-platform="ios"] :is(.pi-dialog__actions, .delete-actions) .pi-btn[data-variant="negative"],
  [data-platform="ios"] :is(.pi-dialog__actions, .delete-actions) .pi-btn[data-variant="negative"]:hover {
    color: #ff383c;
  }
  [data-platform="ios"] :is(.pi-dialog__actions, .delete-actions) .pi-btn[data-variant="tertiary"]:active,
  [data-platform="ios"] :is(.pi-dialog__actions, .delete-actions) .pi-btn[data-variant="negative"]:active {
    background: rgba(120, 120, 128, .32);
  }
  [data-platform="ios"] :is(.pi-dialog__actions, .delete-actions) .pi-btn[data-variant="negative"]:active {
    color: #ff383c;
  }
  [data-platform="ios"] :is(.pi-dialog__actions, .delete-actions) .pi-btn[data-variant="primary"],
  [data-platform="ios"] :is(.pi-dialog__actions, .delete-actions) .pi-btn[data-variant="primary"]:hover {
    background: #08f; color: #fff;
  }
  [data-platform="ios"] :is(.pi-dialog__actions, .delete-actions) .pi-btn[data-variant="primary"]:active {
    background: #0070d6; color: #fff;
  }
  /* The card takes the plate the two alerts wear; the rows are a COLUMN here, so each
     one spans it -- `large-tertiary` sizes Cancel to its text, which is Pi's idiom. */
  [data-platform="ios"] .sheet-wrap > .sheet.sheet-actions {
    border-radius: 34px; /* `sheet/iphone/top-radius`, as both alerts */
    background: var(--ios-glass-fill);
    -webkit-backdrop-filter: var(--ios-glass-blur);
    backdrop-filter: var(--ios-glass-blur);
    box-shadow: 0 8px 40px var(--shadow-soft);
  }
  [data-platform="ios"] .delete-actions .pi-btn { width: 100%; }
/* THE PILL IS `.pi-toast` NOW (pi-design `css/toast.css`), and ONLY its distance from the
     top of the window is left here: the safe area and the height of Pi's chrome are host facts
     the package cannot know, so it exposes one hook for them. Both insets named, in this
     order. Bound on `.toast` and not `:root` so it resolves on the pill itself, which is
     what the deleted rule did. */
  .toast { --pi-toast-top: calc(var(--safe-top, 0px) + var(--pi-chrome-top, 0px) + 12px); }
/* PI-9940, `12602:62915`: `Error text` sits at the centre of what is left once Pi's bar and the
     `BottomCTA` are paid. `#app` is the column, as it is for the setup band, `has-cta` is the
     band's reservation, and the bar takes the place of the 20 the way `.banner` clears it. The
     desktop card keeps its own height, below. 32 inset: the gutter and 8. */
  #app:has(> .load-failed) {
    min-height: calc(100dvh - var(--safe-top, 0px) - env(safe-area-inset-bottom, 0px));
    padding-top: var(--pi-chrome-top, 0px);
    display: flex; flex-direction: column;
  }
/* `.empty.load-failed` AND NOT `.load-failed`: the package's `[data-variant="app"]` arm is
     (0,2,0) and this was (0,1,0), so the arm's `display: block` and its 28px inset would take
     this screen's column apart. The tie is broken by app.css being the later stylesheet. */
  .empty.load-failed {
    flex: 1 1 auto; padding: 0 8px;
    display: flex; flex-direction: column; justify-content: center;
  }
  /* Body/M, as the `#enable` sheet's `.sub` is. */
  .load-failed .sub { font-size: 18px; letter-spacing: var(--track-tight); }
  #app > .banner:first-child { margin-top: var(--pi-chrome-top); }
/* The RE-TRIGGER, not a second entry: a swapped screen re-runs the package's own
     `pi-banner-in` by re-declaring `animation` at (1,2,0). */
  #app.swap-fwd > .banner,
  #app.swap-back > .banner { animation: pi-banner-in .2s cubic-bezier(.32,.72,0,1) both; }

  /* ---- screen 13: the day's stream ---- */
  /* One line, cut with an ellipsis: the title is every member's name joined, so
     its length is other people's to decide and the header's job is to survive
     it. Without this a single unbreakable name widened the whole document. */
  .thread-title {
    font-family: var(--font-heading); font-size: 18px; font-weight: 400;
    letter-spacing: var(--track-body); line-height: var(--lh-heading);
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  .thread-since {
    font-size: 14px; color: var(--muted); line-height: 1.1;
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  /* 24px, the design's `Combined Conversation` gap. The chips carry their own
     margin for the setup preview that also draws them, so it is cancelled here
     rather than added to the gap. */
  .stream { display: flex; flex-direction: column; gap: 24px; margin-bottom: 12px; }
  .head + .stream { margin-top: 44px; }
  .stream > .notice { margin: 0; }
/* FOUR values, not three: the three-value shorthand copies its SECOND value into the left, so
     `margin: -16px 0 0` silently set `margin-left: 0` and cancelled `.countdown`'s 44. Nothing
     declared that 0; the shorthand did. 44 is the mark's 36 plus the row's 8 gap. */
  .stream > .countdown { margin: -16px 0 0 44px; }
  .prompt-row { display: flex; gap: 8px; align-items: flex-start; }
  .prompt-row .pi-mark {
    width: 36px; height: 36px; margin-top: 0;
    background-color: var(--mark-plate);
  }
/* THE ARTWORK, not a letter in the heading face -- a photographic texture inside a letterform,
     which no font or flat token can spell, so it is lifted rather than traced. 66.7% x 55.6% and
     not a pixel pair, because this rule is worn at both 32 and 36. Figma contradicts itself on
     the shape and the split is real: the rounded square belongs to `.dp-pi`. (doc: pi-mark) */
  .pi-mark {
    flex: 0 0 auto; width: 32px; height: 32px; border-radius: 50%;
    background: var(--bg) url("/img/pi-app-icon.png") center / 66.7% 55.6% no-repeat;
    margin-top: 2px;
  }
/* THE SAME MARK, AS A CONTROL. Everywhere Pi's face is not tappable this matches nothing, and
     that is the point. A TAG selector paired with the class, so the three rules that resize this
     mark still win. `background` is deliberately absent -- the shorthand is where the artwork
     lives. `border`/`padding` are not cosmetic: the UA's outset border bevels the art. */
  button.pi-mark {
    appearance: none; -webkit-appearance: none;
    padding: 0; border: 0; font: inherit; color: inherit; cursor: pointer;
    transition: opacity .2s ease;
  }
  button.pi-mark:active { opacity: .72; transition: none; }
  button.pi-mark:focus-visible { outline: 2px solid var(--forest); outline-offset: 2px; }
/* NOT `flex: 1`, which is `flex-basis: 0`: the bubble took every pixel of the row, 581 in the
     705 card. 330 is measured off two frames, and at 393 the row has only 301 to give, so the
     phone frames are untouched by the cap. */
  .prompt-bubble {
    flex: 0 1 auto; max-width: 330px;
    background: var(--tan); border-radius: 10px; padding: 6px 12px;
  }
  .av-stack { position: relative; flex: none; width: 40px; height: 40px; }
  .av-stack .av { position: absolute; top: 0; left: 0; border: 2px solid var(--paper); }
  .av-stack .av + .av { top: 12px; left: 12px; }
/* The fade is the WHOLE of the 12, and `12px` not `12%`, because a percentage stop is
     relative to a box that grows with the platform's text size. The bottom 20 is CITED and an
     uncited 32 does not break it. `, 0px` in the `env()` because this is inside a SHORTHAND: an
     engine that drops it would take the fade strip and the gutter with it. (doc: pinned-cta) */
  .bottom-cta {
    position: fixed; left: 0; right: 0;
    bottom: var(--pi-foot-room, 0px); z-index: 20;
    padding: 12px var(--gutter) calc(20px + env(safe-area-inset-bottom, 0px));
    background: linear-gradient(to bottom, var(--bg-fade), var(--bg) 12px);
  }
/* 353 AND NOT 428 — PI-10174, inviter 4: "Share button is supposed to have max
     width of 353px as per the defined component". Measured on F10 before this, the
     Share CTA computed 428 wide against `11048:49720`'s 353.

     IT IS THE COMPONENT'S OWN CAP, not a number off a frame: pi-design pins
     `.pi-btn[data-size="large"] { max-width: 22.0625rem }` — 353px — and
     `data-size="cta"` is the one size that sets `width: 100%` with no cap of its
     own, so this band was the only thing deciding it and it had picked 428. The
     rem spelling is the package's, so the cap tracks a root-size change the way
     the rest of the control does.

     THE BAND CANNOT INHERIT IT. `.bottom-cta` is `position: fixed; left: 0;
     right: 0`, so it spans the viewport and `#app { max-width: 460px }` never
     reaches it — which is how a CTA in a 460 column came out 428 rather than
     being clipped to the column.

     `display: flex`, not `block`, so `.btn` has ONE display value here. No visible
     change for a `<button>` -- measured -- but `<a class="btn">` under `block`
     lays its label 10px high. */
  .bottom-cta .btn { max-width: 22.0625rem; margin: 0 auto; display: flex; }
  .bottom-cta.thread-cta { padding-bottom: calc(32px + env(safe-area-inset-bottom, 0px)); }
/* PINNED, NOT IN THE FLOW -- the reversal of what this rule used to say. The geometry comes
     from `.thread-foot` beside it, which is what gets this band `--kb` and its measured height.
     `pointer-events` is split: the thread scrolls UNDER a transparent strip that would otherwise
     eat every long-press passing beneath. */
  .invite-wait {
    padding: 0 var(--gutter) calc(32px + env(safe-area-inset-bottom, 0px));
    text-align: center;
    pointer-events: none;
  }
  .invite-wait .pi-btn { margin: 0 auto 12px; pointer-events: auto; }
  .invite-wait .sub + .sub { margin-top: 2px; }
/* `#app.has-cta` and not `.has-cta`: `#app` sets `padding` as a SHORTHAND and an id beats a
     class, so the bare class did nothing at all, on every screen that set it, since it was
     written. `--cta-h` is measured because the bar grows with the platform's text size. */
/* THE LOWEST-PRIORITY CLAIM ON `#app`'s BOTTOM PADDING: the two rules below add the same term
     to their own reservation and, at equal specificity, WIN BY BEING LATER IN THIS FILE. Moving
     either above this one silently halves the reservation on a thread. */
  #app.has-host-composer {
    padding-bottom: var(--pi-foot-room, 0px);
  }
  #app.has-cta {
    padding-bottom: calc(var(--cta-h, 104px) + var(--pi-foot-room, 0px));
  }

/* ---- the thread's floating foot (PI-9518) --------------------------------
     A second band must be a second VARIANT on this class, never a second class:
     `readPinnedHeights` measures the FIRST `.thread-foot`, so two bands up at once reserve the
     wrong height and the newest answer goes behind the other. (doc: thread-foot) */
/* THE WARNING THIS COMMENT USED TO CARRY CAME TRUE -- it ended if Pi ever stops resizing,
     this pays the keyboard twice, and Pi stopped for chat containers exactly. Unrepresentable
     now: the term below reads the parts. See `--pi-foot-room`. */
  .thread-foot {
    position: fixed; left: 0; right: 0;
    bottom: var(--pi-foot-room, 0px); z-index: 20;
/* IT FITS, OR IT SCROLLS — not the R2/R3 rule below, which is the band in the wrong PLACE. A
     pinned foot plus a content height means it grows UPWARDS off the screen; begins at AX3.
     THE CAP IS THE STRIP, NOT `100dvh`: both ends are Pi's, and a fixed box inherits neither.
     `hidden auto` + `contain` as `.menu-wrap`'s platter and `.chip-row` do; `pointer-events: none`
     STAYS, so a finger scrolls this only on `.delete-band`. (doc: foot-fits) */
    max-height: calc(
      100dvh - var(--pi-foot-room, 0px) - var(--safe-top, 0px) - var(--pi-chrome-top, 0px)
    );
    overflow: hidden auto;
    overscroll-behavior: contain;
/* The scrim `.bottom-cta` and `.delete-band` already draw, stated once on the class all three
     variants share — it MOVED here from `.delete-band`, which is why that rule no longer has it.
     Belt to `off-foot`'s braces: a band that lands on a row anyway then covers it rather than
     being unreadable through it. (doc: foot-off-foot) */
    background: linear-gradient(to bottom, var(--bg-fade), var(--bg) 12px);
  }
/* ---- THE BAND DRAWS WHERE IT WAS RESERVED FOR, AND NOWHERE ELSE (R2/R3) -------------------
     The rule above pins the band to the viewport's foot and its reservation is at the DOCUMENT's,
     so off the foot it is over a message. `pointer-events: none` does NOT cover this — the Share
     pill and the chips are `auto` inside it. Not keyed on the keyboard: measured 88.6px with
     none up. `markFootReach` (app.js) re-reads that distance; nothing scrolls the reader back to the
     foot, because following an inset Pi oscillates is what made the keyboard flicker. `visibility`, NEVER `display: none`: `readPinnedHeights` measures this band to publish
     `--thread-foot-h`, WHICH IS THE RESERVATION, so display-hidden measures 0 and the document
     shortens by its own height as it hides. NOT `.delete-band` — a MODE'S controls, and a reader
     scrolls the thread to pick messages to delete. NOT `.chip-strip` EITHER, since PI-9985: the
     frames draw it as floating buttons over the transcript (`10917:196898` is absolute over
     `10917:196460`), so it stays on the glass and the thread passes under it. What kept that
     honest here is done instead by the two rules below — no scrim, and `pointer-events` on the
     chips rather than on their scroller. (doc: foot-off-foot) */
  #app.off-foot .thread-foot:not(.delete-band):not(.chip-strip) {
    visibility: hidden; opacity: 0; pointer-events: none;
  }
/* UNDER PI'S KEYBOARD THE BAND IS ABSOLUTE, AT `#app`'s FOOT. Pi does not resize the web view for
     the keyboard, so `fixed` pins to a layout viewport whose foot is under it, and WebKit pans the
     VISUAL viewport inside that: at the layout max scroll a fixed band sits `scrollY` above its foot
     (measured [33,191] for a foot at 469) with `#app`'s padding showing in the gap. Absolute at the
     foot the band IS its reservation, as the desktop rule reads, and no pan can move it off. Only the
     pinning changes: at the foot the box is where `fixed` put it, so the cap still fits it to the
     strip and `off-foot` still takes it off the glass away from the foot, keyboard up or down.
     `.delete-band` stays pinned: a reader scrolls the thread under it. (doc: foot-room) */
  #app.has-host-keyboard .thread-foot:not(.delete-band) {
    position: absolute;
/* Less the indicator `body` pads for below `#app`, so the band's bottom is `--pi-foot-room` above
       the DOCUMENT's foot — the glass position `fixed` gives it above the viewport's, to the pixel
       (measured 469 both ways), so the keyboard's coming and going never moves its ink. */
    bottom: calc(var(--pi-foot-room, 0px) - env(safe-area-inset-bottom, 0px));
  }
/* THE FOURTH VARIANT ON `.thread-foot`, not a second class. BOTH STATES ARE IN THE MARKUP AND
     `data-picked` PICKS ONE, so a radio tap is an attribute flip and nothing repaints -- the
     reader's scroll offset is never read or restored. `display: none` and not visibility, so the
     hidden half leaves the tab order and the accessibility tree too. */
  .delete-band {
    padding: 12px var(--gutter) calc(32px + env(safe-area-inset-bottom, 0px));
  }
/* 6 BETWEEN MARK AND WORD, AND IT USED TO DRAW 13: `.btn svg`'s own `margin-right: 5px` was
     adding to this gap. The margin is zeroed rather than the gap shrunk, so the number here is
     the number a reader sees. 18px, not `.btn`'s app-wide 16, SCOPED TO THIS BAND on purpose --
     if 16 is wrong here it is wrong on eight other screens. Raised, not smuggled. */
/* 353, THE SAME COMPONENT CAP `.bottom-cta .btn` TAKES ABOVE, and for the same reason: this
     band is a `.thread-foot` variant, so it is `position: fixed; left: 0; right: 0` and `#app`'s
     460 column never reaches it either. The 428 here was the second copy of the number inviter 4
     removed from the other band. */
  .delete-band .btn {
    max-width: 22.0625rem; margin: 6px auto 0; display: flex;
    align-items: center; justify-content: center; gap: 6px;
    font-size: 18px;
  }
  .delete-band .btn svg { flex: 0 0 auto; margin-right: 0; }
  .delete-band .btn[data-variant="destructive"] {
    background: var(--color-error-default);
    color: var(--color-base-contrast);
  }
/* This rule's `background` outranks `.btn[disabled]`'s, so the dead CTA stood in full #d42600.
     The frame composites the WHOLE control at 0.3 -- pill and label both sample as that mix -- so
     `opacity`, not a second colour, and it follows the scheme for free. */
  .delete-band .btn[data-variant="destructive"][disabled] {
    background: var(--color-error-default);
    opacity: 0.3;
  }
/* THE HEIGHT INVARIANT MOVED HERE AND DID NOT GO AWAY: the taller of a 40px `.text-btn` and a
     two-line sentence is the floor. Without it `--thread-foot-h` re-publishes on a deselect, the
     document shortens, and the browser's clamp scrolls a reader at the foot of a long thread --
     a jump, in a state whose whole point is that the screen does not move. */
  .delete-band .delete-tail {
    min-height: 44px; margin-top: 8px;
    display: grid; place-items: center; text-align: center;
  }
  .delete-band .delete-tail .delete-empty { margin: 0; }
  .delete-band .delete-clear { color: var(--green); }
  .delete-band[data-picked="0"] .delete-clear { display: none; }
  .delete-band:not([data-picked="0"]) .delete-empty { display: none; }
/* PI-9518 C4 -- the suggestion strip, and the first horizontal scroller here. NOT `.chips`,
     whose wrap would put two rows over the newest answer.
     THREE BOXES, AND `safe` IS NOT ONE OF THEM -- PI-9787. iOS APPLIES `safe center` AS PLAIN
     `center`: the chips came out sliced at both ends and the leading slice was unreachable. AND
     NO TEST COULD HAVE CAUGHT IT -- Playwright's WebKit implements `safe` correctly. The centring
     is auto margins on a `max-content` track instead. (doc: chip-strip) */
/* 8 ON EVERY ARM, and it used to be 8 over Pi's composer and 32 off bare glass (PI-9985 QA,
     web STG). `has-host-composer` is NOT "a composer stands beneath": Pi Web gives the composer's
     resting band up out of the frame's own box and reports `bottomChrome: 0` on purpose
     (`[appId].tsx`, "THE OVERFLOW ONLY … at rest this is 0"), so on web the strip took the
     32 and QA measured 32 where the frame says 8. Both frames that draw this strip now end
     Suggestions 8 above `BottomArea` -- `10917:196459` at 855/863 and `9528:123207`, which is
     where the 32 was read from before it was redrawn (its `9528:123215` no longer exists).
     THE INDICATOR IS STILL PAID: `bottomChrome` is the band ABOVE the safe area
     (`MiniAppContainerView.swift:448-452`), so Pi's band stops at the indicator and the page
     owns the strip below it -- an arm without `env()` puts the chips 26pt behind Pi's composer
     on device (#239), and no headless engine can see it. */
  .chip-strip {
    padding: 0 0 calc(8px + env(safe-area-inset-bottom, 0px));
    pointer-events: none;
    transition: opacity .18s ease;
/* NO SCRIM ON THIS ONE BAND, undoing `.thread-foot`'s: the frames paint nothing behind
       `Suggestions`, and the transcript is meant to read through (`10917:196935` has no fill).
       The other two variants keep the gradient — they are bands, these are buttons. */
    background: none;
/* AND IT CHAINS, undoing `.thread-foot`'s `contain`. MEASURED floating: a vertical wheel over the
       chips moved the page 0px. Blink picks a scroll container without consulting
       `pointer-events`, finds this one, and `contain` stops it handing on a scroll it cannot
       perform. The cap above stays; only the refusal to chain goes. */
    overscroll-behavior: auto;
  }
/* PI-9985 -- the strip leaves the glass while Pi's field is in use. `visibility`, NEVER
     `display: none`: `--thread-foot-h` is measured off this band, so a display-hidden one
     measures 0 and the document shortens under the reader (the `off-foot` note above).
     `has-host-keyboard` is the signal on BOTH mobile hosts -- Chrome's WebView resizes for the
     keyboard, but Pi's Android host still sends `bottomKeyboard` over the same seam (KB-9).
     The `0s .18s` holds the box until the fade lands; the way back takes `.chip-strip`'s own
     transition and fades in from the first frame.
     PI-10192 ADDS THE WEB ARM AS A SECOND SELECTOR, NOT A SECOND RULE: Pi Web draws its composer
     outside the iframe, so its focus moves no inset and arrives as `composerActive` on the same
     payload -- a different signal for the same state, and one fade, so the two can never drift. */
  #app.has-host-keyboard .chip-strip,
  #app.has-host-composer-active .chip-strip {
    visibility: hidden; opacity: 0;
    transition: opacity .18s ease, visibility 0s .18s;
  }
/* THE SCROLLER, AND NO LONGER THE BOX THAT ANSWERS A HIT TEST — `pointer-events` moved to the
     chips with PI-9985's float. Full width and `auto`, this row ate every tap beside a chip, and
     the thread now genuinely runs under it, so a message there has to stay touchable. Blink
     still scrolls it from a drag on a chip; iOS WebKit does not — see the rule below. */
  .chip-row {
    overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none;
  }
/* PI-10434 -- iOS WebKit only hands a finger drag to an overflow scroller that is itself
     hit-testable; a drag on an `auto` chip inside a `none` row moved it 0px on device. The row is
     the chips' own height, so this gives back only the gutters and the 8px gap, and only on iOS. */
  [data-platform="ios"] .chip-row { pointer-events: auto; }
  .chip-row::-webkit-scrollbar { display: none; }
  .chip-track {
    --chip-gap: 8px;
    display: flex; flex-wrap: nowrap; gap: var(--chip-gap);
    width: max-content;
    padding-inline: var(--bar-gutter);
/* These two margins ARE the centring, and the whole reason there is no alignment keyword on
       this rule: see the `safe` block above. Over-constrained, CSS 2.1 10.3.3 treats them as zero,
       so the track pins to the leading edge and the overflow stays reachable. */
    margin-inline: auto;
  }
  .chip-track .chip {
    flex: 0 0 auto; white-space: nowrap; cursor: pointer;
    appearance: none; -webkit-appearance: none;
    font-family: inherit; font-weight: inherit; text-align: left;
    /* The only `auto` in the band now — the strip and its scroller are both `none`. */
    pointer-events: auto;
  }
/* Room UNDER the strip, and it STAYS now that the strip floats (PI-9985): the chips stand over
     the thread mid-scroll, and this is what lets a reader scroll the newest answer clear of them
     at the end. MEASURED without it: `landAtFoot()` leaves 16px of that answer behind the chips on
     every arrival, i.e. PI-9259. The 68 is only a fallback. */
  #app.has-thread-foot {
    padding-bottom: calc(var(--thread-foot-h, 68px) + var(--pi-foot-room, 0px));
  }

  .msg-row { display: flex; gap: 8px; align-items: flex-start; }
  .msg-row.mine { justify-content: flex-end; }
/* ---- delete mode's selection control (PI-9527) --------------------------
     `margin-right: auto` is the whole of left/right alignment is preserved: an auto margin takes
     free space BEFORE `justify-content` distributes any.
     `align-self: center` is the whole of the radio is centred on the message. CROSS AXIS ONLY --
     an auto margin on the cross axis makes a flex item ignore `align-self` entirely, and nothing
     on `.msg-pick` may declare one without re-reading this.
     THE 20px WIDTH IS MEASURED: `.msg-hold` is `max-width: 88%` and does not shrink when a child
     is added, so this plus the row's 8px gap has 4.6px of headroom at 320. (doc: msg-pick) */
  .msg-pick {
    flex: 0 0 auto; position: relative; align-self: center;
    appearance: none; -webkit-appearance: none;
    margin: 0; padding: 0; border: 0; background: none; color: inherit; font: inherit;
    display: grid; place-items: center; cursor: pointer;
  }
  .msg-row.mine .msg-pick { margin-right: auto; }
  .msg-pick::before {
    content: ""; position: absolute; top: -11px; bottom: -11px; left: -8px; right: -4px;
  }
  .msg-col { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; }
  .msg-who {
    font-size: 12px; color: var(--muted); line-height: var(--lh-copy);
    letter-spacing: var(--track-body);
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  /* Relative, and the only thing that is: the reaction chip is positioned
     against the BUBBLE, so it stays in the corner whatever the text does. */
  .msg-hold { position: relative; align-self: flex-start; max-width: 100%; }
  .msg-row.mine .msg-hold { align-self: flex-end; max-width: 88%; }
  /* ---- the web's message action cluster (PI-9529) -------------------------- */
  /* The first hover rule in this file: PI-9510's "no hover anywhere" is reversed on purpose,
     and `button.pi-mark`'s note says why it still holds for that one control.
     Geometry is `.pi-convo-msg__actions`' (pi-design, PI-8941) — 64x32, two flush 32s. Only the
     placement is here. ABSOLUTE, not in flow: `openMessagePress` measures the hold's rect to
     place the press surface. 8px off the bubble and centred on it are `9170:73589`'s.
     Hidden by OPACITY and zero width, never `display`/`visibility`: both are unfocusable and
     there is no other keyboard route to a message's actions. Zero width so an invisible
     cluster has no box to overflow with — it widened the page at 393 without it.
     (doc: msg-acts) */
  .msg-acts {
    position: absolute; top: 50%; transform: translateY(-50%);
    display: flex;
    opacity: 0; pointer-events: none;
    width: 0; overflow: hidden;
  }
  /* Pi's and other people's rows: trailing edge, face nearest the words. Yours: the other
     edge, pair back in DOM order for the same result. No frame for the mirrored side. */
  .msg-row .msg-acts { left: 100%; margin-left: 8px; flex-direction: row-reverse; }
  .msg-row.mine .msg-acts { left: auto; right: 100%; margin-left: 0; margin-right: 8px; flex-direction: row; }
/* THREE REVEALS, AND ONE GATE OVER ALL THREE — `html[data-web]`, written by `applyWebSurface`
     from the same allow-list `onWebSurface()` answers in JS. The gate used to be per-arm and the
     arms disagreed: the latch asked the HOST, hover asked a MEDIA QUERY, and focus asked nothing
     at all, which was recorded here as deliberate. It fails open. Inside the iOS app a VoiceOver
     or external-keyboard reader reached the kebab through `:focus-within` and its menu offered
     Copy / Report / Delete — measured on the suite's own `pi-ios` stub, the same shape as the
     deny-list fixed in dd73ed8, through a different door. `data-host` cannot be that gate: it is
     absent for a native app AND for Pi's web host before the handshake, and those two want
     opposite answers. Absent `data-web` means no, so every arm fails closed until the host has
     named itself. The click on a revealed control is gated too, in `app.js` — either half alone
     still fails open. (doc: msg-acts) */
  html[data-web] .msg-row[data-actions="open"] .msg-acts,
  html[data-web] .msg-row:focus-within .msg-acts {
    width: auto; overflow: visible; opacity: 1; pointer-events: auto;
  }
  /* The second ring this file declares, and the same one: `.pi-icon-btn[data-variant="bare"]`
     is a transparent circle on the page's own ground, so the UA ring has no edge to land on —
     `button.pi-mark`'s argument exactly. pi-design declares no focus style for it. */
  .msg-acts .pi-icon-btn:focus-visible {
    outline: 2px solid var(--forest); outline-offset: 2px;
  }
  @media (min-width: 768px) and (hover: hover) and (pointer: fine) {
/* `html[data-web]` HERE TOO, and the media features are no longer the whole gate. "No phone
       matches `hover: hover`" is true and does not get us to "this web ticket relayouts no native
       screen": a TABLET is not a phone. Measured on the `pi-ios` stub at 1440 before this
       selector — one cluster shown on hover, and every peer hold already capped at
       `calc(100% - 72px)`. An iPad with a Magic Keyboard, or the Android app in a resizable
       Chromebook window, is a native host on a hover-and-fine surface. If the host is the
       authority for the tap half it is the authority for this half. (doc: msg-acts) */
    html[data-web] .msg-row:hover .msg-acts {
      width: auto; overflow: visible; opacity: 1; pointer-events: auto;
    }
/* AND THE CURSOR GETS AN ANSWER. `.pi-icon-btn:hover` is overridden by
       `[data-variant="bare"]`'s `background: transparent` — equal specificity at (0,2,0),
       declared later, so it wins — and the ticket's headline affordance was the one control on
       the page with a pointer and no pointer feedback at all. (0,4,0) here takes it back. A LOCAL
       rule and not a fix upstream, because transparent is the design system's own last word on
       this variant and every other screen using it wants exactly that.

       MEASURING THIS HAS A TRAP, and it cost a round of review: `.pi-icon-btn` carries
       `transition: background-color`, so `getComputedStyle` straight after the pointer arrives
       returns the value AT THE START of the transition — `rgba(0, 0, 0, 0)`, whether the rule
       below is present or not. A reading taken that way says "no feedback" about a working page.
       Settled, it is `rgb(233, 221, 205)`. Wait for `getAnimations()` before believing it; the
       e2e case for this does. The tap half is `PRESSABLE`'s, below. (doc: msg-acts) */
    .msg-acts .pi-icon-btn[data-variant="bare"]:hover { background: var(--color-fill-hover); }
  }
/* Outside the query on purpose: `.pressed` is the app's own class, set from a real pointerdown
     by `PRESSABLE`, so it needs no media feature to be honest — and `:active` is what a keyboard's
     Enter gives. Both land on the same fill the design system uses one variant along. */
  .msg-acts .pi-icon-btn[data-variant="bare"]:active,
  .msg-acts .pi-icon-btn[data-variant="bare"].pressed { background: var(--color-fill-tap); }
  /* The room the cluster needs, as the token it is made of — `--pi-control-sm` is generated,
     so a literal 72 would quietly under-reserve after an upstream change. Reserved
     unconditionally inside the query and NOT on `:hover`: keyed on hover it rewrapped the
     bubble under the cursor and broke the long press. `min()` on your own row because 88%
     leaves 0-3px of slack, which is not slack. (doc: msg-acts-room) */
  .msg-row { --msg-acts-room: calc(2 * var(--pi-control-sm) + 8px); }
  @media (min-width: 768px) and (hover: hover) and (pointer: fine) {
    html[data-web] .msg-row:not(.mine):has(.msg-acts) .msg-hold {
      max-width: calc(100% - var(--msg-acts-room));
    }
    html[data-web] .msg-row.mine:has(.msg-acts) .msg-hold {
      max-width: min(88%, calc(100% - var(--msg-acts-room)));
    }
  }
/* MOBILE WEB RESERVES NOTHING, AND THAT IS THE WHOLE FIX. There used to be a
     `.msg-row[data-actions="open"] .msg-hold` cap here, and a latch is by definition applied
     AFTER the layout the reader is looking at: tapping a peer message took it 301 -> 229 wide and
     114 -> 165 tall, dropped everything under it by 51px, and tapping a second message snapped the
     first back up as the second grew. That is precisely the defect the desktop rule above records
     finding — "hovering narrows the hold, the text rewraps" — arriving on the one arm a media
     query cannot reach.

     THE OTHER SHAPE WAS RULED OUT BY THIS FILE'S OWN HISTORY. Reserving unconditionally under
     `(hover: none)` is what the hover arm does, but at 393 the peer column is ~301 and the 72 is
     a QUARTER of every peer bubble — permanently, on every message, for an affordance shown one
     row at a time. `min-width: 768px`'s note records that exact geometry making the document
     taller and moving a scroll-invariant read by a pixel. So the CLUSTER moves rather than the
     words — it hangs off the bottom of the bubble, positioned in the rule below. Nothing on the
     screen moves when a message is tapped, and it never leaves the column.

     `(hover: none)` states the intent; the latch only ever fires there anyway, because `app.js`
     asks the same question before setting the attribute. (doc: msg-acts-room) */
  @media (hover: none) {
    html[data-web] .msg-row[data-actions="open"] .msg-acts {
/* UNDER THE BUBBLE AND NOT BESIDE IT, which is `.reacts`' own trick one row down — that chip
         hangs off the bottom edge at `bottom: -20px` for the same reason, and the idiom is already
         on this screen. Pinned to the middle of the row and it landed MID-PARAGRAPH: at 393 the
         pill covered "should" and "was" across two lines of the message being acted on, which is
         worse than the rewrap it replaced. `-26px` puts exactly 6px of the 32 over the
         bubble — its own bottom padding, and not one pixel of the words — so the pair still
         reads as belonging to that message, and the remaining 26 sit in `.stream`'s 24px gap.
         LEADING EDGE ON BOTH SHAPES and so NO `.mine` rule to mirror it, unlike desktop:
         `.reacts` is at `right: 8px` on every row and is not itself mirrored, so the trailing
         corner is already spoken for on both. The absence of a second selector here is the
         claim, not an omission. */
      top: auto; bottom: -26px; transform: none;
      left: 0; right: auto; margin: 0;
      background: var(--bg); border-radius: 999px;
    }
  }
  .msg-bubble {
    background: var(--tan); border-radius: 10px; padding: 6px 12px;
    font-family: var(--font-heading); font-size: 18px; font-weight: 400;
    line-height: var(--lh-heading); letter-spacing: var(--track-body);
    color: var(--ink);
    overflow-wrap: anywhere;
    -webkit-user-select: none; user-select: none;
    -webkit-touch-callout: none;
  }
/* THE ONE PAIR ON THIS SCREEN THAT HAS TO MOVE TOGETHER: the fill LIGHTENS in dark and the old
     ink did not move at all -- 4.55:1 light, 2.78:1 DARK. Taking only the fill would have made it
     worse (2.36:1), which is why `browser-thread-ink` asserts the RATIO and not either hex.
     `--miniapp-*` and not `--color-*`: Pi names this pair in Figma and not in code. (doc: accent-ink) */
  .msg-bubble.mine {
    background: var(--miniapp-color-message-background-accent);
    color: var(--miniapp-color-message-text-contrast);
  }
/* AFTER `.msg-bubble.mine` AND THE ORDER IS THE MECHANISM: both are (0,2,0), so the later wins
     and a retracted message of your own takes the neutral fill.
     AND THE FACE IS THE BODY ONE, WHICH REVERSES WHAT THIS COMMENT USED TO SAY -- the earlier call
     was read off a frame that settles where the row sits, not its type. The ink stays `--muted`,
     not the frame's #8C8275, which is under AA on this fill in BOTH schemes. */
  .msg-bubble.tomb {
    background: var(--tan);
    color: var(--muted);
    font-family: var(--font-body); font-size: 16px; font-weight: 500;
    line-height: var(--lh-copy);
    font-style: italic;
  }
/* An `<img>` with no dimensions is ZERO pixels tall until it decodes, so a photo answer shoved
     everything below it down ~213px AFTER the scroll offset had been restored -- and WebKit has
     no scroll anchoring at all. `contain`, not `cover`: a guess that CROPS a photo is a product
     decision rather than a layout fix. */
  .msg-photo {
    width: 100%; border-radius: 8px; display: block; background: var(--bg);
    aspect-ratio: 4 / 3; object-fit: contain;
  }
  .msg-text { white-space: pre-wrap; }
  .msg-photo + .msg-text { margin-top: 8px; }
  .msg-row .pi-mark { width: 36px; height: 36px; margin-top: 0; }
/* THE INK IS THE PACKAGE'S; THE BOX IS NOT, AND THAT IS MEASURED. These two are 36/18
     UNTRACKED — they inherit `body`'s `--track-body` (-0.01em) — where the component's
     `[data-size="lg"]` carries `--pi-track-tight` (-0.02em). PI-9852 tracked the members
     sheet's 36px seat and deliberately left these alone, so wearing `[data-size="lg"]` here
     moves the centred letter by half the difference: 116 device pixels per disc, measured
     across 138 parity shots. Closing that divergence is a design decision, not a refactor.
     `conversation.css:25` rails the OtherUser row with the tracked variant; D17 is about the
     INK, which is now one declaration in pi-design for every seat on the page. */
  .msg-av {
    flex: 0 0 auto; width: 36px; height: 36px; border-radius: 50%;
    display: grid; place-items: center; margin-top: 20px;
    color: var(--miniapp-color-seat-ink); font-size: 18px; font-weight: 500;
    line-height: var(--lh-single);
  }

  .reacts {
    position: absolute; right: 8px; bottom: -20px; display: flex; gap: 3px; z-index: 1;
    appearance: none; -webkit-appearance: none;
    margin: 0; padding: 0; border: 0; background: none; color: inherit; font: inherit;
    cursor: pointer;
  }
  /* Figma 10041:18967 (pi-design's .pi-reacts): --tan fill inside a 2px --bg ring; the ring cuts the
     chip out of the bubble, which is why the fill may equal the bubble's own. PI-9153.
     24 TALL, not 22 (PI-10245, 15331:127097). `line-height: 1` and not the inherited 1.5 is what
     makes 2 ring + 4 padding + a 12px emoji measure the 24 Figma draws; `.reacts`' `bottom: -20px`
     is that 24 less the 4px the chip is meant to overlap the bubble by. */
  .react {
    display: inline-flex; align-items: center; gap: 3px;
    background: var(--tan); border: 2px solid var(--bg); border-radius: 999px;
    padding: 4px 6px; font-size: 12px; line-height: 1;
  }
  .react b { font-weight: 500; font-size: 11px; color: var(--muted); }
  @keyframes react-pop {
    from { opacity: 0; transform: scale(.4); }
    to   { opacity: 1; transform: none; }
  }
  .react.just { animation: react-pop .26s cubic-bezier(.34,1.5,.64,1) both; }

  .reactors { list-style: none; margin: 0; padding: 0; }
  .reactor {
    display: flex; align-items: center; gap: 8px; width: 100%;
    padding: 16px 0; text-align: left;
    appearance: none; -webkit-appearance: none;
    margin: 0; border: 0; background: none; color: inherit; font: inherit;
  }
  button.reactor { cursor: pointer; }
  /* `.msg-av` without its `margin-top: 20px` — that number drops the disc past the name line
     ABOVE a bubble, and here the name is beside it. Same untracked 36/18, same reason. */
  .reactor-av {
    flex: 0 0 auto; width: 36px; height: 36px; border-radius: 50%;
    display: grid; place-items: center;
    color: var(--miniapp-color-seat-ink); font-size: 18px; font-weight: 500;
    line-height: var(--lh-single);
  }
  .reactor-who { flex: 1 1 auto; min-width: 0; }
  .reactor-name {
    display: block; font-size: 16px; font-weight: 500; line-height: var(--lh-single);
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  .reactor-hint {
    display: block; font-size: 12px; color: var(--muted);
    line-height: var(--lh-single); margin-top: 2px;
  }
  .reactor-emoji { flex: 0 0 auto; font-size: 24px; line-height: 1; }

/* Longhands, not `inset` -- see the shared rule. The dim inside this holder ends at the
     shared floor, which is right: the strip it drops is the one Pi paints its composer over. */
  .press-wrap { position: fixed; top: 0; left: 0; right: 0; z-index: 45; }
  .press-lit .msg-bubble { box-shadow: 0 8px 24px var(--shadow); }
  /* A deleted row closes up so the ones under it shift up — the ticket's rows A, B and C.
     On the CLASS and not on `.msg-row`: rows change height for reasons that are not a
     deletion. `margin-bottom` closes `.stream`'s own 24px gap, or the thread shifts up by
     everything except a hole. (doc: msg-row-going) */
  .stream .msg-row.going {
    transition: height .18s ease-in, opacity .18s ease-in, margin-bottom .18s ease-in;
    opacity: 0; margin-bottom: -24px; pointer-events: none;
  }
  .msg-hold { transition: transform .15s ease-in-out; }
  .msg-hold.holding { transform: scale(.95); }
  .msg-row .msg-hold { transform-origin: left center; }
  .msg-row.mine .msg-hold { transform-origin: right center; }
  /* The chip is drawn on the copy too, so the message does not appear to lose
     its reactions for as long as the menu is open. */
/* THE DIM, THE LIFTED COPY AND BOTH PLATTERS ARE PI-DESIGN'S NOW (`css/press.css`,
     `.pi-press-*`). The holder above is not: its rung on this app's overlay ledger and the
     band it stops above are the page's, and `openMessagePress` measures its box. The iOS
     PLATE is still here, above, because the material is this app's. (doc: menus) */

  .sheet .sub#report-what { margin-bottom: 4px; }
  .reasons { display: flex; flex-direction: column; gap: 4px; margin-bottom: 24px; }
  .reason {
    display: flex; align-items: center; gap: 8px; width: 100%; text-align: left;
    min-height: 32px; padding: 0 2px; background: none; border: none; font: inherit;
    font-size: 16px; line-height: var(--lh-copy); color: var(--ink);
    position: relative;
  }
/* THE HIT AREA IS NOT THE DRAWN BOX, and here a mistap is UNRECOVERABLE: `message_report_once`
     discards a corrected report, so the queue keeps the wrong reason. AN OVERLAY, NOT PADDING,
     which would move the drawn radios off the frame's rows. PLUS OR MINUS 2 AND NOT 6: 2 makes
     the rows contiguous, 6 would extend into the neighbour's box and the later sibling paints on
     top, so a finger just above a row's edge would select the row BELOW. (doc: report-hit-area) */
  .reason::before {
    content: ""; position: absolute; left: 0; right: 0; top: -2px; bottom: -2px;
  }
  .reason:first-child::before { top: -4px; }
  .reason:last-child::before { bottom: -8px; }
/* They still differ in what they ANNOUNCE: `.reason` is `role="radio"`, `.msg-pick` is
     `role="checkbox"`, because picking three messages and deleting them together is the feature.
     `.msg-pick`'s circle is now `.pi-radio` in the design system; this one is this app's frame. */
  .reason .radio {
    flex: 0 0 auto; width: 20px; height: 20px; border-radius: 50%;
    border: 1.6px solid var(--faint); display: inline-block; position: relative;
  }
  .reason[aria-checked="true"] .radio { border-color: var(--forest); }
  .reason[aria-checked="true"] .radio::after {
    content: ""; position: absolute; inset: 3.4px; border-radius: 50%;
    background: var(--forest);
  }
/* DELETE MODE'S CIRCLE IS NOT DECLARED HERE ANY MORE -- it is `.pi-radio` in
     `packages/pi-design/css/radio.css`, and restating any of its numbers here would be the drift
     that move was for. In the move the tick's ink went `--color-base-contrast` ->
     `--color-background`, which are NOT the same colour. (doc: radio) */
  #report-more[hidden] { display: none; }
  #report-more label.sub { display: block; margin: 0 0 4px; }
  #report-more textarea { display: block; min-height: 104px; margin: 0; }
  #report-more .counter { margin: 4px 0 12px; }
  #report-caption { margin: 8px 0 0; text-align: center; }

  /* ---- setup rows (03–06) ---- */
/* `.person-card` IS DELETED: nothing under `public/` has emitted it since the setup rows
     were reframed, and publishing a dead rule into a shared package is how it acquires a
     consumer nobody chose. The row itself is `.pi-person-row` (pi-design
     `css/person-row.css`) and this file keeps only what is Between Us's own. */
  .person-row.on { background: var(--mint); }
  .person-row.on .av { background: var(--av-you-bg); color: var(--av-you-fg); }
/* `.member-section` IS DELETED -- PI-9245: the revised frame has one flat Section and no
     heading node, and `app.js` no longer emits the class. `page-claims` holds both halves shut.
     The #655e55-vs-`--muted` divergence is recorded on `.person-detail` now. */
/* THE 68 PITCH IS A KNOWN DIVERGENCE, not the frame's number: the revised `6615:110406` stacks
     at 60, and taking that means `gap: 32` -> `24`, which is ROW STYLING and outside PI-9245's
     layout-only scope. Recorded so the next reader knows. */
  .member-list .person-row .pi-mark {
    width: 36px; height: 36px; margin-top: 0;
    box-shadow: inset 0 0 0 1px var(--hairline);
  }

  h1.title.step-title { font-size: 32px; margin: 0 0 16px; }
  .step-sub { font-size: 16px; margin: 0 0 24px; }

/* THAT SCOPING IS ONE-WAY: it keeps THIS screen's numbers off the answer sheet, but
     `setupScreen` wraps this markup in `.sheet` at `?in=sheet`, where `.sheet .prompt-row`
     (0,2,0) beats `.ask-row` (0,1,0) -- so a future edit there lands here. */
  h1.title.ask-title { font-size: 32px; margin: 0 0 24px; }
  .ask-row { margin: 0 0 24px; }
  .field-label {
    font-size: 16px; color: var(--muted); letter-spacing: var(--track-body);
    line-height: var(--lh-copy); margin: 0 0 8px;
  }
  .card.droplet {
    display: flex; align-items: center; gap: 8px; padding: 16px; border-radius: 10px;
    margin-bottom: 8px;
  }
  .card.droplet.on { border-color: var(--green); }
  .droplet-text {
    flex: 1; min-width: 0; font-size: 16px; letter-spacing: var(--track-body);
    line-height: var(--lh-copy);
  }

  /* ---- screen 08: share ---- */
  .share-center { text-align: center; margin: 6px 0 14px; }
  .mark {
    width: 64px; height: 64px; border-radius: 20px; background: var(--mint);
    display: inline-grid; place-items: center; color: var(--green);
  }
  /* Same ink as `h1.title` above, and for the same reason. */
  .share-title {
    font-family: var(--font-heading); font-size: 24px; font-weight: 400;
    letter-spacing: var(--track-heading); color: var(--color-text-accent);
    margin: 14px 0 0; line-height: var(--lh-heading);
  }
  .card.share-note { text-align: center; font-size: 14px; line-height: var(--lh-copy); }
  .btn svg { margin-right: 5px; }

      /* 8 UNDER, NOT 24: PI-9953 pairs the name with the sentence that explains it
         (12759:198039 is the one frame holding both). The 24 moved to `.preview-line`. */
      .preview-head {
        display: flex; align-items: center; justify-content: space-between;
        gap: 12px; margin: 0 0 8px;
        /* At 360dp the row wants 312.4px in 312 — name 162.1 + gap + pills 138.3 — and the h1 is
           the only shrinkable item, so it broke "Between us" over two lines. `column-gap` because
           `space-between` already spends the free space: at 393 the pair sits 44.6 apart and this
           number is inert. `flex-wrap` is the floor for widths where no gap would fit both. */
        column-gap: 8px; flex-wrap: wrap;
      }
      h1.title.big { font-size: 32px; }
      .pills { display: flex; gap: 4px; flex: 0 0 auto; }
/* ---- the description, the 320 tile under it, and the question ----
         WHAT THIS COMMENT USED TO SAY, TWICE, because the correction is the point: first that the
         frame's image was not exported, then that the art was Pi's brand `high-five`. The second
         shipped and was the wrong picture.
         144 x 131, TWO numbers: an `<img>` given a square box does not stretch an SVG, it
         LETTERBOXES, which is how the 160 this shipped drew everything ~10% too tall. */
      /* PI-10675: a 3:2 pink card on every MOBILE width, down from the 320px square that ate ~40%
         of the sheet and pushed the first question under the Start CTA. The height is DERIVED from
         the card's own width, so the ratio holds at 360, 393 and every phone between; the wash
         follows because `readPreviewWash()` measures the tile's live bottom rather than a constant.
         Mobile-first: this is the PHONE value — iOS, Android and mobile-web, and a native app on a
         phone- or small-tablet-width surface (under 768). The desktop card and Pi Web's 705 panel
         restore the pre-ticket fixed 320 below, where the wider box already reads as ~4:2. */
      .activity-tile {
        aspect-ratio: 3 / 2; border-radius: 24px 24px 24px 4px;
        background: var(--activity-swatch, #EA79A1);
        display: grid; place-items: center;
      }
      /* UNCHANGED at 144x131 (inside the 144x144 the ticket names) and centred by the grid above:
         a shorter card must not shrink the illustration. */
      .activity-tile .activity-icon { display: block; width: 144px; height: 131px; }
      /* The desktop card — a standalone browser at 768+, AND the wide native surface Pi frames as
         one: an iPad in the app draws the same card (`browser-desktop-layout` proves it at 1440) —
         keeps the pre-PI-10675 fixed height. Deliberately NOT `min-width: 600px`: that handed a
         600–767 native tablet the 320 square inside a phone layout, where the ticket wants iOS and
         Android at 3:2. So the restore keys on the same 768 the desktop card itself does. */
      @media (min-width: 768px) {
        .activity-tile { aspect-ratio: auto; height: 320px; }
      }
      /* Pi Web's ~705 panel is wider than a phone but narrower than that 768 query, so it is scoped
         by HOST, not width — and by the 600 divide this file already uses (the `.chip-track` note),
         so Pi Web on a PHONE (the mobile-web target, framed at 393) is not caught and stays 3:2. */
      @media (min-width: 600px) {
        html[data-host="pi-web"] .activity-tile { aspect-ratio: auto; height: 320px; }
      }
      /* ABOVE the tile since PI-9953, so this margin is the tile's 24px top inset — the
         same 24 the gutter gives its sides and the wash gives its base. */
      /* NO `max-width: var(--dp-preview-measure)`, deliberately: it carried the cap until the
         2026-09-02 QA asked the subtitle to fill the container, which is the call the question
         row below already took. Costs ~22px above the docked Start — see preview-measure.test. */
      .preview-line {
        margin: 0 0 24px; color: var(--muted);
        font-size: 16px; letter-spacing: var(--track-body); line-height: var(--lh-copy);
      }
/* 48 OVER IT, 8 UNDER, AND THE 45 THIS WAS UNTIL PI-9953 IS BACK AT THE FRAME'S OWN STEP.
         45 bought the band's 24px inset by absorbing 3px of accumulated drift, because the
         description sat between the tile and this heading and the wash is an absolute. It no
         longer does: the tile is now the last thing on the wash, the drift is in the wash's own
         constant (join-view.css), and 48 = 24 under the tile + 24 inside the band.
         ONE NUMBER ON ONE NODE, not 24 and 24: adjoining sibling margins would COLLAPSE to 24 and
         land the heading two rows inside the band. (doc: question-title) */
      .question-title {
        font-family: var(--font-heading); font-size: 24px; font-weight: 400;
        letter-spacing: var(--track-heading); line-height: 1.1;
        color: var(--color-text-accent); margin: 48px 0 8px;
      }
      /* #227's 65 WAS FOR PI WEB'S PANEL AND IT KEEPS IT — on the base rule it also governed the
         phone, where the extra 17 pushed the question box under the fixed Start (Wei, 2026-09-09). */
      html[data-host="pi-web"] .question-title { margin-top: 65px; }
      .question-row { display: flex; align-items: center; }
      .question-field {
        flex: 1; min-width: 0;
        background: var(--field); border: 1px solid var(--border); border-radius: 10px;
        padding: 12px 16px; color: var(--ink);
        font-size: 16px; letter-spacing: var(--track-body); line-height: var(--lh-copy);
        min-height: 52px; display: flex; align-items: center;
      }
      .question-field.busy { color: var(--faint); }
      .question-tools { display: flex; flex: 0 0 auto; width: 34px; justify-content: center; margin-left: 8px; }
      .title-row { display: flex; align-items: center; gap: 8px; margin: 0 0 24px; }
      .share-head { justify-content: center; }
      /* CAPPED TO `.share-art`'s 345 AND CENTRED UNDER IT, in every chrome. The desktop block
         said this for the hosted card alone (`body.sheet-page:has(#share)`) — the one chrome
         Pi Web is not, so both blocks overhung the illustration there.
         `min(…, 100%)` AND NOT THE BARE MEASURE: the hosted card is a COLUMN FLEX box, and
         `margin-inline: auto` on a flex item cancels the cross-axis stretch — so the block
         falls back to its min-content (579.98px for `.preview.big`, whose link never wraps)
         and the cap becomes a floor of 345. Measured at a 360 frame: 345 in a 312 column,
         9px past the viewport, which is what `browser-hosted-sheet-geometry` caught.
         `align-self: stretch` does NOT fix it — auto margins beat it (flexbox 8.1). */
      .share-room {
        text-align: center; min-height: 2lh;
        max-width: min(var(--dp-preview-measure), 100%); margin-inline: auto;
      }
      .share-art {
        display: block; width: 345px; max-width: 100%; height: auto;
        margin: 0 auto 4px;
      }
      .title-row > h1.title { font-size: 32px; }
      /* 16 ON ALL FOUR SIDES -- declared by `[data-size="big"]` in pi-design's
         `css/preview.css` now, with the radius and both text sizes. Kept here because it is
         the frame's reasoning and not the package's: the `pr: 0` this had was not the
         frame's, it borrowed the Copy button's own 96 box as the right gutter, and
         `9296:108496`'s Placeholder draws no such button -- 345 - 16 - 313 = 16.
         THE MEASURE IS WHAT STAYS. The width is this SCREEN's, shared with `.share-room`
         above it, and a `max-width` in the component would cap every preview everywhere. */
      .preview.big {
        max-width: min(var(--dp-preview-measure), 100%); margin-inline: auto;
      }
      /* The art is this app's, so its box is too. */
      .preview.big .preview-thumb .activity-icon { width: 50.09px; height: 45.56px; }
      .share-life { text-align: center; font-size: 14px; margin: 8px 0 0; }
      .share-life:empty { display: none; }

/* ================= pressed states =========================================
     `.pressed` as well as `:active`: iOS Safari only gives `:active` to things it considers
     interactive, and half the tappables here are divs. `app.js` sets `.pressed` from pointer
     events and cancels it when the press turns into a scroll. */
  .btn, .link, .icon-btn, .text-btn, .chip, .row, .person-row, .card.droplet,
  .connection, .menu-item, .tick, .pick,
  .press-menu button, .press-bar button,
  button.reactor {
    transition: background-color .2s ease, border-color .2s ease, color .2s ease;
  }
  .btn:active, .btn.pressed { background: var(--forest-tap); }
  /* Ghost actions have no fill to darken, so they take one. */
  .btn.ghost:active, .btn.ghost.pressed,
  .link:active, .link.pressed { background: var(--tap); }
  .icon-btn:active, .icon-btn.pressed,
  /* Same slot, same feel: the glyph this replaced was an `.icon-btn` and took this line. */
  .text-btn:active, .text-btn.pressed,
  .chip:active, .chip.pressed { background: var(--tap-firm); }
  .row:active, .row.pressed,
  .person-row:active, .person-row.pressed,
  .connection:active, .connection.pressed,
  .card.droplet:active, .card.droplet.pressed,
  .menu-item:active, .menu-item.pressed,
  .press-menu button:active, .press-menu button.pressed,
  button.reactor:active, button.reactor.pressed { background: var(--tap); }
  .press-bar button:not([aria-pressed="true"]):active,
  .press-bar button:not([aria-pressed="true"]).pressed { background: var(--tap-firm); }
  .chip.on:active, .chip.on.pressed { background: var(--forest-tap); }
  /* Down is instant and up eases out, the way UIKit releases a highlight. */
  .btn:active, .btn.pressed, .link:active, .link.pressed,
  .icon-btn:active, .icon-btn.pressed,
  .text-btn:active, .text-btn.pressed,
  .chip:active, .chip.pressed, .row:active, .row.pressed,
  .person-row:active, .person-row.pressed, .connection:active, .connection.pressed,
  .card.droplet:active, .card.droplet.pressed,
  .menu-item:active, .menu-item.pressed,
  .press-menu button:active, .press-menu button.pressed,
  .press-bar button:active, .press-bar button.pressed { transition-duration: 0s; }
  /* A disabled control must not answer a press. */
  .btn[disabled]:active, .btn[disabled].pressed { background: var(--btn-off); }

  body.sheet-page { padding: 0; }
/* NO inline padding, which is how this variant reaches the frame's 24: `.sheet-hosted` is the
     one sheet nested INSIDE `#app`, which already pays the page's gutter.
     THE TOP INSET IS THE HOST'S, and it is two bands -- a presented sheet reports 0 for both, a
     deep link reports both set. Without this line `body::before` painted over both exit controls.
     A THIRD HOST READS THE SAME ARITHMETIC: a Pi whose `MiniAppRouteSheet` draws the sheet's own
     close natively announces it (`drawsClose`, read by `app.js`'s `hostDrawsTheSheetClose`) and
     then sends the band it stands in (66) here like any other. On that arm the page emits NO
     `.sheet-head`, and the row's 40 comes off exactly as the band's 66 goes on -- 14+0+0 with a
     40pt head puts content at 54, 14+0+66 with none puts it at 80, clear of Pi's control by 20.
     NOTHING IN THIS RULE CHANGES for either, which is the point of reading the number rather than
     a class, and a gap where the head was is not possible: `.sheet-head` has no height of its own.
     `overflow: visible` IS THE SECOND HALF OF no scroll of its own: a box with `overflow-y: auto`
     computes its x to `auto` too (CSS Overflow 3), so the card was a HORIZONTAL SCROLLER,
     invisible to any check reading `document.scrollWidth`. Not `clip` -- the 13px of overhang is
     `.sheet-head`'s back-out and the close control's hit ring. ONE OF THOSE TWO IS CONDITIONAL NOW
     and the rule is not: an announcing host leaves no ✕ to ring, but the setup band's ← still
     backs out to 16 on the one step that has one, so `visible` stays load-bearing on every host.
     (doc: hosted-sheet) */
  body.sheet-page .sheet.sheet-hosted {
    max-height: none; overflow: visible; border-radius: 0; background: var(--bg);
    padding: calc(14px + var(--safe-top, 0px) + var(--pi-chrome-top, 0px)) 0 26px;
  }
/* THE SETUP BAND WANTS THE SAME COLUMN -- a SELECTOR ON THIS RULE and not a copy of it.
     Zeroing `#app`'s block padding is what lets the card both REACH the sheet's foot and not
     overflow; `min-height: 100dvh` alone did the first and broke the second by `#app`'s 20+40.
     THE `:has()` IS ON `body` AND NOT ON `#app`: `:has()` MAY NOT BE NESTED INSIDE `:has()`
     (selectors-4 5.2), and an invalid selector drops its whole rule SILENTLY -- written that way
     first, it cost a measurement, because a card that is too SHORT overflows nothing.
     `--activity-bleed` is `join-view.css`'s name for the pull-up past the box's own top, and
     appears twice for that reason. (doc: hosted-sheet) */
  body.sheet-page:has(.sheet-hosted #share) #app,
  body.sheet-page:has(.sheet-hosted #start) #app {
    padding-top: var(--activity-bleed, 0px);
    padding-bottom: 0;
    min-height: calc(100dvh + var(--activity-bleed, 0px));
    display: flex; flex-direction: column;
  }
  body.sheet-page .sheet.sheet-hosted:has(#share),
  body.sheet-page .sheet.sheet-hosted:has(#start) {
    flex: 1 1 auto; display: flex; flex-direction: column;
  }
  body.sheet-page .sheet.sheet-hosted:has(#share) > *,
  body.sheet-page .sheet.sheet-hosted:has(#start) > * { flex: 0 0 auto; }
  body.sheet-page .sheet.sheet-hosted:has(#share) > .btn,
  body.sheet-page .sheet.sheet-hosted:has(#start) > .btn { margin-top: auto; }
  body.sheet-page .sheet.sheet-hosted:has(#share) > .preview { margin-top: 0; }

/* THE SHARE SCREEN'S OWN RHYTHM, RESTATED INSIDE THE CARD. Two sheet-wide rules were
     re-spacing the page this card now presents, and the -6 pulled the life line UP THROUGH the
     Share button it captions. By the page's own three classes, so the exception is written where
     the exception is rather than by narrowing a rule nine sheets share. */
  body.sheet-page .sheet.sheet-hosted > .title-row.share-head { margin: 0 0 24px; }
  /* `auto` on the inline sides, not 0: this shorthand overrides the measure's own centring. */
  body.sheet-page .sheet.sheet-hosted > .share-room { margin: 0 auto 24px; }
  body.sheet-page .sheet.sheet-hosted > .share-life { margin: 8px 0 0; }

/* The old comment at both call sites blamed the plate's own padding for pushing the card down;
     that half is WRONG and worth correcting rather than repeating -- measured, the plated and
     unplated cards start on the same row. `#app.activity-plate >` and not
     `body.sheet-page .sheet-hosted`: the ANSWER screen wears the same card and is sampled FLAT. */
  body.sheet-page #app.activity-plate > .sheet.sheet-hosted { background: transparent; }

  html, body { overscroll-behavior-y: none; }
/* `.can-bounce` and not a bare `[data-platform="ios"]` arm: a native scroller has `alwaysBounceVertical`
     NO, so a page that FITS must not move (PI-9709 P5e). Android keeps `none` (a top bounce is
     pull-to-refresh); so does Pi Web, whose iPhone reads "ios" but whose overscroll chains to Pi's page. */
  :root[data-platform="ios"]:not([data-host="pi-web"]).can-bounce,
  :root[data-platform="ios"]:not([data-host="pi-web"]).can-bounce body { overscroll-behavior-y: auto; }
/* ...EXCEPT ON A PLATED SCREEN: above the document's top the HOST paints, so the gap is turned off
     rather than tinted. `[data-plated]` is seeded at parse time, so the first frame is armed too.
     Right after the pair it narrows and matching it, so source order decides the tie. (doc: plated-bounce) */
  :root[data-platform="ios"][data-plated].can-bounce,
  :root[data-platform="ios"][data-plated].can-bounce body { overscroll-behavior-y: none; }
  /* An overlay scroller contains its overscroll, or a drag past the end takes the thread with it.
     NO LONGER UNVERIFIED, and the note that stood here was wrong in a way worth recording: removing
     this DOES move the thread, in both engines, on the SECOND wheel -- a gesture latches to the box
     that starts consuming it, so a single oversized wheel is dropped rather than chained and the
     old reading saw nothing. What this rule CANNOT reach is a platter with no overflow: WebKit
     applies `overscroll-behavior` only to a box that has something to scroll, so a sheet whose
     content fits chained straight to the document. That arm is `holdBackground`'s, not this rule's.
     AND NEITHER LINE HERE CAN REACH THE BARE SCRIM, which is the third arm: `overscroll-behavior`
     is a property of a SCROLL CONTAINER and `.sheet-wrap` is not one, so however it is spelt there
     it acts on nothing. That arm is `holdBackground`'s too — its `wheel` handler, added after QA
     filed the thread scrolling behind an open modal on the desktop card. All three are held by
     `browser-sheet-scroll-contain`. */
  .sheet, .menu { overscroll-behavior: contain; }

/* ================= motion =================================================
     Screens animate `#app`'s CHILDREN, not `#app`: `.bottom-cta` is a `position: fixed` child,
     and a transform on `#app` would make it the containing block and throw the CTA down the page. */
  @keyframes screen-fwd {
    from { opacity: 0; transform: translate3d(12px, 0, 0); }
    to   { opacity: 1; transform: none; }
  }
  @keyframes screen-back {
    from { opacity: 0; transform: translate3d(-12px, 0, 0); }
    to   { opacity: 1; transform: none; }
  }
  #app.swap-fwd > *  { animation: screen-fwd .2s cubic-bezier(.32,.72,0,1) both; }
  #app.swap-back > * { animation: screen-back .2s cubic-bezier(.32,.72,0,1) both; }
  @keyframes cta-up {
    from { opacity: 0; transform: translate3d(0, 100%, 0); }
    to   { opacity: 1; transform: none; }
  }
  #app.swap-fwd > .bottom-cta,
  #app.swap-back > .bottom-cta { animation: cta-up .28s cubic-bezier(.32,.72,0,1) both; }
  #app.swap-fwd > #count,
  #app.swap-back > #count { animation-fill-mode: backwards; }
/* AND THE REVEAL STRIP, for a different reason: `swap-fwd` is never taken off (`app.js`'s
     observer only swaps it at the next navigation), so `screen-fwd` sits FINISHED on every child
     of `#app` for the life of the screen, holding `opacity: 1` at animation origin — above any
     transition. Measured: PI-9985's fade ran as a `CSSTransition` and painted nothing. */
  #app.swap-fwd > .chip-strip,
  #app.swap-back > .chip-strip { animation-fill-mode: backwards; }

/* ---- a wait that is over before you saw it should never have been drawn ---
     These reads come back in 30-80ms, so drawn instantly the dots appear and vanish in two
     frames. Held BACK rather than held ON: a minimum display time would make a fast app wait to
     look busy. Both swap pairs listed because `#app.swap-fwd > *` ties this rule and lands later.
     `pi-wait-in` itself is pi-design's (`css/wait.css`); WHICH boxes count as a wait is not. */
  #app > .loading, #app > [data-placeholder],
  #app.swap-fwd > .loading, #app.swap-fwd > [data-placeholder],
  #app.swap-back > .loading, #app.swap-back > [data-placeholder] {
    animation: pi-wait-in 250ms linear;
  }
  #app > .no-wait-in,
  #app.swap-fwd > .no-wait-in,
  #app.swap-back > .no-wait-in { animation: none; }

/* ---- why every exit below carries its OWN @keyframes name ----------------
     CSS matches a running animation to the new `animation-name` list BY NAME, so a `.closing`
     rule naming its entry's keyframes started no exit at all: the browser updated the finished
     entry in place and the fill pinned the element on the exit's last frame in ONE frame.
     `reverse` is kept because it mirrors the timing function; a forward rewrite measured 35px
     apart over a 100px travel. */
  @keyframes fade-exit       { from { opacity: 0; } to { opacity: 1; } }
  @keyframes menu-ios-exit   { from { opacity: 0; transform: scale(.86); }
                               to   { opacity: 1; transform: none; } }
  @keyframes dialog-in-exit  { from { opacity: 0; transform: scale(.94); }
                               to   { opacity: 1; transform: none; } }

/* `scrim-in` AND `fade-exit` STAY, and they are the dialog's now: the sheet went to
     pi-design (`css/sheet.css`) and the menus and the long-press layer followed
     (`css/components.css`, `css/press.css`), each with `pi-`prefixed pairs of its own.
     `.pi-dialog-scrim` below still opens on `scrim-in` and closes on `fade-exit`. */
  @keyframes scrim-in { from { opacity: 0; } to { opacity: 1; } }

/* `menu-in-ios`/`menu-ios-exit` STAY TOO, and they are the iOS RENAME ALERT's: that arm is
     a system alert, not a menu, and it borrowed the menu's pair. The menus' own pair is
     `pi-menu-in-ios`/`pi-menu-ios-exit` in the package. */
  @keyframes menu-in-ios {
    from { opacity: 0; transform: scale(.86); }
    to   { opacity: 1; transform: none; }
  }

  @keyframes options-in {
    from { opacity: 0; transform: translate3d(0, 5px, 0); }
    to   { opacity: 1; transform: none; }
  }
  #droplets.fresh > * { animation: options-in .2s cubic-bezier(.32,.72,0,1) both; }

  #droplets-label { display: flex; align-items: center; gap: 8px; }
  .options-wait { display: inline-flex; }

  .opt-skeleton { height: 64px; border-radius: 16px; margin-bottom: 10px;
                  background: var(--border); }

  .diag {
    margin: 16px 0 0; padding: 10px 12px; border-radius: 10px;
    background: var(--tile); border: 1px solid var(--hairline);
    font-family: var(--font-mono); font-size: 11px; line-height: 1.5;
    color: var(--muted); overflow-wrap: anywhere;
  }
  .diag-head {
    font-size: 10px; letter-spacing: .08em; text-transform: uppercase;
    color: var(--faint); margin-bottom: 6px;
  }
  .diag-row { display: flex; gap: 8px; align-items: baseline; }
  .diag-key { flex: 0 0 78px; color: var(--faint); }
  .diag-val { flex: 1 1 auto; color: var(--ink); }
  .diag-val.ok { color: var(--green); }
  .diag-val.warn { color: var(--miniapp-color-amber-ink); }
  .diag-val.bad { color: var(--danger); font-weight: 600; }
  /* The identity screen's panel (`useIdentityDiagnostics` in app.jsx) is the only
     `.diag` that lands INSIDE `.empty`, and `.empty` centres its text — so the fixed
     78px key gutter above bought nothing and both columns arrived centred. Scoped to
     that panel rather than added to `.diag`, so the questions panel, which sits on a
     left-aligned screen and has been read that way for months, does not move.
     Non-production only, like the panel: nothing here is reachable in a Release build. */
  .empty [data-identity-diagnostics] { text-align: left; }
  [data-identity-diagnostics] > summary { cursor: pointer; }

  /* THE ••• MENU'S `Debug tools…` SHEET. Deliberately NOT the product's own controls: `.btn`
     and `.chip` are pi-design arms that `controls-parity` counts, and a QA affordance wearing a
     shipping control's class is one refactor away from being read as one — and from moving when
     the package moves. Non-production only, like the diagnostics panel above: the gate is the
     server's (`debugToolsState`), so none of this can draw on a production host. */
  .sheet-debug .dbg-group { margin-bottom: 18px; }
  .sheet-debug .dbg-label {
    font-size: 12px; color: var(--faint); margin: 0 0 6px;
    line-height: var(--lh-single);
  }
  .sheet-debug .dbg-arms { display: flex; flex-wrap: wrap; gap: 6px; }
  .sheet-debug .dbg-do {
    appearance: none; -webkit-appearance: none; cursor: pointer;
    font: inherit; font-size: 13px; line-height: var(--lh-single);
    padding: 7px 12px; border-radius: 999px; text-align: left;
    background: var(--tile); border: 1px solid var(--hairline); color: var(--ink);
  }
  /* The readout's keys are variable names, not the identity panel's short words. */
  .sheet-debug .diag-key { flex: 0 0 124px; }
  /* MEASURED: `route` is `#/thread/act_…`, one unbreakable token, and a `flex: 1 1 auto` item will
     not shrink below it — 383px of row in a 327px sheet at 375 wide, which the whole sheet then
     scrolled sideways to reach. `.diag`'s panels never showed it because `overflow-wrap: anywhere`
     is on that wrapper and inherits; the sheet's rows are not inside one. */
  .sheet-debug .diag-val { overflow-wrap: anywhere; }
  /* The log's own sheet. MONOSPACE and no wrap: the stamps have to line up into a timeline, and
     a wrapped entry reads as two. It scrolls sideways rather than reflowing — a phone is narrow
     and the fields at the end of a line are the ones being read down the column. */
  .sheet-debug-log .dbg-log {
    margin-bottom: 14px; max-height: 52vh; overflow: auto; -webkit-overflow-scrolling: touch;
  }
  .sheet-debug-log .dbg-line {
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    font-size: 11px; line-height: 1.45; color: var(--ink);
    white-space: pre; padding: 1px 0;
  }

  /* THE COMPONENT GALLERY, `#/debug/gallery`. Same gate as the sheet above and the same reason
     it is not the product's own controls: everything a cell draws IS a pi-design component, so
     the frame around it must borrow nothing from the package. */
  .gallery-tabs { margin-bottom: 10px; }
  .gallery-toggles { display: flex; flex-wrap: wrap; gap: 8px 18px; margin-bottom: 12px; }
  .gallery-themes { display: flex; gap: 8px; }
  /* The three platforms side by side at every width — comparing them is the point, so a narrow
     glass scrolls the row rather than stacking it. 190px fits all three in Pi Web's 705 card, which
     `#app`'s 460px cap would not. */
  #app:has(> .gallery-platforms) { max-width: none; }
  /* ONE GRID FOR ALL THREE, with the wrappers down to each row dissolved: row N is the same
     component in every column, so a taller iOS cell holds its Android and Web twins level with it.
     `dense` is what starts each column back at row 1 instead of below the previous one. */
  .gallery-platforms {
    display: grid; grid-template-columns: repeat(3, minmax(190px, 1fr)); grid-auto-flow: row dense;
    gap: 14px 16px; overflow-x: auto;
  }
  .gallery-platform, .gallery-platform :is(.gallery-body, .gallery-grid, .gallery-tokens) { display: contents; }
  .gallery-platform:nth-child(1) { --column: 1; }
  .gallery-platform:nth-child(2) { --column: 2; }
  .gallery-platform:nth-child(3) { --column: 3; }
  .gallery-platform > *, .gallery-platform :is(.gallery-body, .gallery-grid, .gallery-tokens) > * {
    grid-column: var(--column);
  }
  .gallery-drawn { margin: 6px 0 18px; padding-left: 18px; }
  .gallery-drawn li, .gallery-note {
    font-size: 11px; color: var(--faint); line-height: var(--lh-copy);
  }
  .gallery-note { margin: 10px 0 0; overflow-wrap: anywhere; }
  .gallery-grid { display: flex; flex-direction: column; gap: 14px; }
  .gallery-cell {
    padding: 10px 12px 12px; border-radius: 12px;
    background: var(--tile); border: 1px solid var(--hairline);
  }
  .gallery-name {
    margin: 0; font-family: var(--font-mono); font-size: 12px; color: var(--ink);
    line-height: var(--lh-single);
  }
  .gallery-src { margin: 2px 0 10px; font-size: 11px; color: var(--faint); line-height: var(--lh-single); }
  /* `contain: paint` AND NOT ONLY `position: relative`: a scrim, a sheet and the toast are
     `position: fixed`, so without a containing block here three specimens would cover the whole
     gallery instead of drawing inside their own frame. It clips the overflow at the same time,
     which is what keeps a tall specimen from setting the page's rhythm. */
  .gallery-stage {
    position: relative; contain: paint; overflow: auto;
    display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
    min-height: 120px; max-height: 340px;
    padding: 10px; border-radius: 8px; background: var(--bg);
  }
  /* THE FRAME IS THE ANCHOR, which is the package's own rule for this one component: the menu
     ships `top: 58px; right: 16px` as "a first position only", for a host to overwrite once it has
     measured the control it hangs off (pi-design/README.md). Nothing here has a control to hang
     off, so the frame places it — without this the menu draws below the cell and is clipped away. */
  .gallery-stage .pi-menu { position: static; }
  /* AND ITS WIDTH, for the same reason: the divider declares a height and no width, because a rule
     across a list is as wide as the list. The stage is a flex row, where that comes out zero. */
  .gallery-stage .pi-divider { width: 100%; }
  /* AND THE HEAD ROW, third of the same kind: `.head` is a flex ROW whose `.grow` spacer only
     means anything at the page's width. As a flex item in the stage it shrinks to its two discs
     and the row stops being the row. */
  .gallery-stage .head { width: 100%; }
  .gallery-wrappers { margin-top: 22px; color: var(--faint); }
  /* AFTER `.gallery-name`, whose `margin: 0` this undoes at the same specificity — the same
     source-order tie `.gallery-wrappers` above relies on. */
  .gallery-section { margin-top: 24px; margin-bottom: 8px; }
  /* A TOKEN ROW IS NOT A SPECIMEN FRAME: there are 124 of them on one tab, so they are a plain
     list — swatch, name, resolved value — rather than 124 stages. */
  .gallery-tokens { display: flex; flex-direction: column; gap: 6px; }
  /* WRAPPING, and the name may not shrink: a font stack is 60 characters, and a name column that
     shrinks to fit one breaks `--pi-font-heading` character by character. The value drops to its
     own line instead. */
  .gallery-token { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 10px; min-height: 24px; }
  .gallery-token .gallery-name { flex: 1 0 auto; }
  .gallery-token .gallery-src { margin: 0 0 0 auto; text-align: right; overflow-wrap: anywhere; }
  .token-chip {
    flex: none; width: 34px; height: 22px; border-radius: 6px;
    border: 1px solid var(--hairline); background: var(--bg);
  }
  .scale-demo { flex: none; display: flex; align-items: center; width: 52px; }
  .scale-box { display: block; width: 22px; height: 22px; background: var(--dash); }
  .scale-bar { display: block; height: 10px; background: var(--dash); }
  .type-demo { flex: none; width: 132px; font-size: 12px; color: var(--ink); overflow: hidden; }
  .gallery-glyphs { display: flex; flex-wrap: wrap; gap: 8px; }
  .gallery-glyph {
    display: flex; flex-direction: column; align-items: center; gap: 4px;
    width: 104px; padding: 10px 6px; border-radius: 10px;
    background: var(--tile); border: 1px solid var(--hairline);
  }
  .gallery-glyph .gallery-name, .gallery-glyph .gallery-src {
    margin: 0; text-align: center; overflow-wrap: anywhere;
  }

  .sk-bar { height: 11px; border-radius: 6px; background: var(--skeleton); display: block; }
  .sk-fill { background: var(--skeleton); color: transparent; }
  .sk-name { width: 42%; height: 15px; margin-bottom: 6px; }
  .sk-since { width: 28%; height: 9px; }
  .sk-msg { display: flex; flex-direction: column; gap: 8px; width: 74%;
            padding: 12px 14px; border-radius: 16px; background: var(--surface);
            border: 1px solid var(--border); }
  .sk-msg .sk-bar { width: 100%; }
  .sk-msg .sk-bar.short { width: 62%; }
  .sk-ask { margin-left: 36px; }
  .sk-mine { align-self: flex-end; }


  @keyframes dialog-in {
    from { opacity: 0; transform: scale(.94); }
    to   { opacity: 1; transform: none; }
  }
  .pi-dialog-scrim { animation: scrim-in .22s ease both; }
  .pi-dialog-scrim > .pi-dialog { animation: dialog-in .2s cubic-bezier(.32,.72,0,1) both; }
  .pi-dialog-scrim[data-state="closing"] { animation: fade-exit .18s ease reverse both; }
  .pi-dialog-scrim[data-state="closing"] > .pi-dialog {
    animation: dialog-in-exit .16s ease-in reverse both;
  }

  @media (prefers-reduced-motion: reduce) {
    #app.swap-fwd > *, #app.swap-back > *, .sheet-wrap, .sheet-wrap > .sheet,
    .menu, .pi-menu[data-platform], .menu-wrap.closing .menu,
    [data-platform="ios"] .menu, [data-platform="ios"] .menu-wrap.closing .menu,
/* The `.closing` selectors are more specific than the plain ones above, so without them an
       exit could survive this block. Latent today only because `dismiss` removes the node. */
    .sheet-wrap.closing, .sheet-wrap.closing > .sheet,
    [data-platform="ios"] .sheet-wrap > .sheet.sheet-rename,
    [data-platform="ios"] .sheet-wrap.closing > .sheet.sheet-rename,
/* The Android/Web card's own pair, for the reason the two lines above it exist: those arms are
       more specific than the plain `.sheet-wrap > .sheet` beside them, so an exit could survive
       this block. These two are (0,5,0)/(0,6,0) against this block's matching (0,5,0)/(0,6,0) --
       an exact tie, settled by ORDER, which is why they are listed HERE and not beside the rules
       they answer for. Move this block above line 843 and reduced motion silently stops applying
       to the rename dialog on every non-iOS surface. */
    :root:not([data-platform="ios"]) .sheet-wrap > .sheet.sheet-rename,
    :root:not([data-platform="ios"]) .sheet-wrap.closing > .sheet.sheet-rename,
    .pi-dialog-scrim, .pi-dialog-scrim > .pi-dialog,
    .pi-dialog-scrim[data-state="closing"],
    .pi-dialog-scrim[data-state="closing"] > .pi-dialog,
    /* The long-press surface, both directions, and the chip a reaction lands on. */
    .press-scrim, .press-lit, .press-bar, .press-menu,
    .press-wrap.closing .press-scrim, .press-wrap.closing .press-lit,
    .press-wrap.closing .press-bar, .press-wrap.closing .press-menu,
    .menu-wrap.closing .press-bar,
    .react.just, #droplets.fresh > *, .banner,
/* A media query adds no specificity, so `#app.swap-fwd > .banner` would otherwise outrank the
       bare `.banner` here and animate the one element this block exists to hold still. */
    #app.swap-fwd > .banner, #app.swap-back > .banner,
    #app.swap-fwd > .bottom-cta, #app.swap-back > .bottom-cta { animation: none; }
/* The one animation a reduced-motion reader KEEPS, on purpose: this is not motion, it is a
       150ms delay before a placeholder is drawn at all, and `animation: none` would hand that
       reader back the sub-100ms flash. `steps(1, end)` holds the wait as long and makes it a cut. */
    #app > .loading, #app > [data-placeholder],
    #app.swap-fwd > .loading, #app.swap-fwd > [data-placeholder],
    #app.swap-back > .loading, #app.swap-back > [data-placeholder] {
      animation: pi-wait-in 250ms steps(1, end);
    }
    .btn, .link, .icon-btn, .text-btn, .chip, .row, .person-row, .card.droplet,
    .connection, .menu-item, .tick, .pick,
    .press-menu button, .press-bar button, .msg-hold {
      transition: none;
    }
/* PI-9985's fade, both directions, and PI-10192's web arm of it. The hide rule carries its own
       `transition` at (1,2,0) and would outrank a list of one class, so both of its selectors are
       named here as well as the bare strip. */
    .chip-strip,
    #app.has-host-keyboard .chip-strip,
    #app.has-host-composer-active .chip-strip { transition: none; }
    .msg-hold.holding { transform: none; }
    /* The collapse is already off in JS (`collapseDeleted` returns early); this is belt and
       braces on a class that carries its own `transition`. */
    .stream .msg-row.going { transition: none; }
  }
/* ================= web desktop ============================================
     PI-9510. ONE breakpoint and two states, not a fluid range: Figma specifies exactly two web
     widths (393 and 705), so anything between is a width no frame draws -- `web-figma-parity.md`
     rule 3. The hard step at 768 is what that costs, flagged rather than fixed.
     EVERYTHING is inside the query, so not one declaration below 768px changes.
     THIS IS A SHIPPING SURFACE FOR A WEB HOST: bakery frames this app at ~1060x633, and an
     earlier draft calling it unreachable outside iOS was wrong twice over. (doc: desktop) */
  @media (min-width: 768px) {
    :root {
      --dp-desk-w: 705px;
      --dp-desk-r: 28px;
      --dp-desk-inset: 48px;
    }
/* CENTRED ON THE SCREEN, NOT ON THE FRAME — the rule that spends
       `--pi-viewport-dx`. WHAT that variable is, why it is the host's to report and why it is 0
       everywhere but Pi Web is on `applyChromeInsets` in `app.jsx`, and is not repeated here.

       HERE AND NOT IN `pi-design`, deliberately: that package is vendored into four apps and its
       barrel is generated, so these lines in `sheet.css` would ship a behaviour change to reversi,
       stars-aligned, draft-with-pi and the scaffold — none of which has this problem — and
       regenerating the barrel drags in whatever design tokens have moved upstream since. `app.css`
       is linked last (`index.html`), so this ties `pi-design`'s own `.pi-sheet-scrim > .pi-sheet`
       at (0,2,0) and wins on order, the same lever the `join-view.css` note above pulls. The day a
       second vendored app opens a sheet, this moves up rather than being copied across.

       `translate` AND NOT `transform`: `.pi-sheet-scrim > .pi-sheet` animates `pi-sheet-up` on
       `transform`, which would replace a transform here for the length of the animation. The two
       properties compose. (`.pi-dialog`'s `dialog-in` scales, and composes the same way.)

       THE CLAMP IS THE POINT. The frame CLIPS this card, so a shift past its leading edge cuts it
       off rather than centring it. `50%` is half this card's own width and `50vw` half the frame's,
       so `50% - 50vw` is the room to its left, plus the scrim's 24px gutter. `max` of two negatives
       takes the smaller move, so under roughly `2 x sidebar + card` this stops short and the card
       simply stays centred in the frame.

       AND IT LEAVES AN UNRESOLVED EXPRESSION IN THE COMPUTED VALUE: a `max()` holding a PERCENTAGE
       cannot be resolved at computed-value time, so `getComputedStyle(card).translate` answers
       `"max(-140px, 50% - 592px)"` rather than a length. Nothing in the app reads it —
       `swipeToDismiss` writes and clears the INLINE property — but a naive parse of the computed
       one finds `50%` where it expects a pixel count, which is what `browser-sheet-swipe.test.js`
       did until PI-10174. */
    .pi-sheet-scrim > .pi-sheet {
      translate: max(var(--pi-viewport-dx, 0px), calc(50% - 50vw + 24px)) 0;
    }
/* AND THE CONFIRM DIALOG, which is the same geometry said the other way: `.pi-dialog-scrim`
       is `position: fixed; inset: 0; display: grid; place-items: center`, so Leave / End activity
       / Block centre in the FRAME exactly as the sheet did. Same shift, same clamp -- `50%` is
       half the dialog's own width (248/300/345, `components.css`) rather than half a full-width
       card, which is what the percentage is for. The scrim's `--pi-space-6` gutter is the 24px.
       `translate` and not `transform` for the same reason as above. */
    .pi-dialog-scrim > .pi-dialog {
      translate: max(var(--pi-viewport-dx, 0px), calc(50% - 50vw + 24px)) 0;
    }
/* ON `html` AND NOWHERE ELSE: the token is 36% alpha, so a second box declaring it composites
       it AGAIN. Declared on both, the field measured rgb(168,161,155) where one application is
       rgb(202,198,194) -- which is what `guest.html` renders, from `body` alone. `body` then has to
       be pushed to `transparent`. OPEN FOR DESIGN: in dark, field and card are one level apart. */
    html { background: var(--color-modal-backlay); }
    body {
      background: transparent;
      display: grid;
      place-items: center; place-items: safe center;
      min-height: 100vh; min-height: 100dvh;
      box-sizing: border-box;
      padding: var(--dp-desk-inset) 24px;
    }
/* AND NOT `scrollbar-gutter`, which is the obvious answer to the one QA finding and is wrong.
       Measured, `#app` does not move when a menu opens -- what moves is the FRAME, because
       `holdChrome` asks the host for a scrim. Reserving the gutter costs more than it buys: it
       shrinks the FIXED containing block while `anchorMenu` still measures `window.innerWidth`. */

/* `safe` above is INSURANCE, and an earlier draft called it load-bearing: measured, the card's
       top is y=48 under `safe center`, `center` and `normal` alike, because `min-height` lets the
       grid row grow to its item. All five body-level overlays are `position: fixed`, so none is a
       grid item; `.toast` is deliberately left ON THE WINDOW because it is a HUD. */


    #app {
      box-sizing: border-box;
      width: var(--dp-desk-w); max-width: 100%;
      min-height: 817px;
      margin: 0; position: relative;
      padding: 50px 40px 40px;
      background: var(--bg);
      border-radius: var(--dp-desk-r);
      box-shadow: var(--shadow-modal-shadow);
/* No `overflow: hidden` to go with that radius, and not by omission: a sticky box inside a
         clipped ancestor sticks to THAT box instead of to the scrollport, which is the one thing on
         this card that must not come unstuck. */
    }
/* `position: relative` and NOT a transform: a transform on `#app` makes it the containing
       block for its `position: fixed` descendants and throws the CTA down the page. */

    .bottom-cta {
      position: absolute; left: 0; right: 0; bottom: 0;
      padding: 12px 24px 32px;
      border-radius: 0 0 var(--dp-desk-r) var(--dp-desk-r);
    }

/* ---- ...AND THE PAGE PRESENTATION, WHICH UNDOES ALL OF IT -- PI-9771 ----
       AN OVERRIDE RATHER THAN A CONDITION ON EACH RULE ABOVE, so the block above goes on saying
       what the frames say. ABSENT MEANS CARD: the attribute is written only for the two strings
       the host sends, so an old host, a failed handshake or a direct visit keep the card. */
    html[data-presentation="fullscreen"] { background: var(--bg); }
    html[data-presentation="fullscreen"] body {
      background: var(--bg);
      display: block;
      padding: 0;
    }
    html[data-presentation="fullscreen"] #app {
      width: 100%;
      min-height: 100dvh;
      border-radius: 0;
      box-shadow: none;
    }
    html[data-presentation="fullscreen"] .bottom-cta { border-radius: 0; }

/* ...AND THE OTHER PINNED BAND, WHICH THIS BLOCK FORGOT -- PI-9787. Missed because the two
       bands are mutually exclusive, so no screen ever showed both; measured, the chips sat 16px
       below the card at a 900 window and 309 below at 1500.
       `html:not([data-host="pi-web"])` AND THAT GUARD IS LOAD-BEARING for the FRAMED case only:
       under `pi-web`, `#app` keeps `min-height: 817px`, so anchoring to it drops the band at y=817
       in a frame of any height. Under `fullscreen` its foot IS the viewport's. (doc: desktop) */
    html:not([data-host="pi-web"]) .thread-foot {
      position: absolute; left: 0; right: 0; bottom: 0;
/* The cap comes off with the pinning it answers for, like `off-foot` below: absolute at `#app`'s
       foot the band IS its own reservation. `pi-web` stays `fixed` and keeps it. (doc: foot-fits) */
      max-height: none; overflow: visible;
    }
/* AND ITS FOOT IS THE CARD'S EDGE, NOT A COMPOSER'S. PI-9985 took the strip to 8, which is 8
       above Pi's FIELD, and pinned absolute here the foot is the card's own 28px corner -- so the
       strip keeps the room its siblings on this card keep (`.bottom-cta.thread-cta` 32,
       `.delete-band` 22). Pi Web never reaches this rule: the guard above leaves it `fixed` at the
       frame's foot, which IS the field's top edge.
       THE GUARD IS THE HOST AND NOT THE WIDTH, AND THE DIFFERENCE IS NOT WHAT IT LOOKS LIKE. A
       native TABLET is also >=768 and also not `pi-web` -- iPad, and `MiniAppWebView.kt` on an
       Android one -- and Pi DOES draw a field there, so this rule reads like 32 above a real
       composer. Measured at 834x1194 with the composer armed: it is not. No native host sends
       `presentation` (`presentation.js`: "the only document this changes is one a host framed
       with the parameter, and today that is Pi Web alone"), so a tablet draws the 705 CARD, the
       band goes absolute at its foot, and Pi's field starts 124.5px BELOW the chips. A composer
       term here would take that surface to 8 off a rounded corner with the field still 124 away.
       WHAT WOULD BREAK IT is a native host that starts sending `fullscreen`: `#app` is then
       `min-height: 100dvh`, its foot IS the viewport's, and the same measurement reads the chips
       64px BEHIND Pi's field. Unreachable today by the line quoted above; the term to add if it
       ever is. Both numbers: `browser-desktop-width.test.js`, the two PI-9985 cases. */
    html:not([data-host="pi-web"]) .chip-strip {
      padding-bottom: calc(32px + env(safe-area-inset-bottom, 0px));
    }
    /* One row at desktop — `9170:74216`: a 56-tall centred `Content` with 22 above and below,
       holding the clear link, a 32 gap and the 345 pill.

       A GRID AND NOT `row-reverse`, BECAUSE THE ROW'S WIDTH IS NOT CONSTANT. Flex centred the
       CONTENT GROUP, which is what the frame draws — and the group is only 534 wide while the
       tail holds the clear link. `data-picked="0"` swaps that link for the empty sentence, and
       measured at 1440 the tail went 118 -> 337, the group with it, and `justify-content: center`
       re-centred the pill: `#delete-go` slid from x=622 to x=732. A red destructive button
       jumping 110px sideways is the same broken promise as a band that changes height, in a mode
       whose whole point is that the screen does not move — and only the height was ever stated,
       so both the e2e case and QA SL-05 stayed green through it.

       `1fr auto 1fr` pins the pill to the BAND's centre, which no content can move. That is a
       DELIBERATE DEPARTURE from the frame's group-centring, and it is forced: holding the group
       centred needs a fixed-width tail, and the frame's own 157 does not fit the sentence — at
       157 the 337px sentence wraps to three lines, the tail measures 59 and the band 103, so the
       fix for one axis breaks the other. The frame draws no deselect-all state at desktop to be
       faithful to, and at `picked="0"` the pill is disabled, so a centred disabled pill with its
       reason beside it is the honest reading. Both invariants now hold exactly: dx 0, dh 0.

       Explicit `grid-column`s and not DOM order, so the tab ring still reaches Delete first —
       the pill stays the first child, exactly as `row-reverse` needed. The tail's 56 floor is
       what keeps the band 100 tall whichever of its two children is drawn.
       (doc: delete-band-desktop) */
    .delete-band {
      display: grid; grid-template-columns: 1fr auto 1fr; align-items: center;
      justify-items: center; gap: 32px;
      padding: 22px var(--gutter);
    }
    .delete-band .btn { grid-area: 1 / 2; width: 345px; max-width: 100%; margin: 0; }
    .delete-band .delete-tail {
      grid-area: 1 / 1; justify-self: end; min-height: 56px; margin: 0;
      display: flex; flex-direction: column; justify-content: center;
    }
/* `off-foot` comes off with the pinning it answers for: absolute at the foot of `#app`, this band
       IS its own reservation and can no more float over a message than any other block.
       `pointer-events` is NOT restored — `.invite-wait`'s own `none` is what lets a long-press
       reach the thread. (doc: foot-off-foot) */
    html:not([data-host="pi-web"]) #app.off-foot .thread-foot {
      visibility: visible; opacity: 1;
    }
/* `.thread-foot`, not `.delete-band`: the card does NOT clip, so every band that PAINTS carries
       its bottom radius itself, and the scrim put two more bands in that set. */
    html:not([data-host="pi-web"]) .thread-foot {
      border-radius: 0 0 var(--dp-desk-r) var(--dp-desk-r);
    }
    html[data-presentation="fullscreen"] .thread-foot { border-radius: 0; }


/* THE CENTRED MODAL IS THE PACKAGE'S (pi-design `css/sheet.css`), INCLUDING
       `.pi-sheet__grab { display: none }` — which is what turns swipe-to-dismiss off up here,
       since `swipeToDismiss` gates on the handle's `offsetHeight` per gesture. It carries this
       block's `--dp-desk-inset` as a literal 48, pinned from both sides by
       `unit/sheet-parity.test.js`.

       The `body.sheet-page:has(#share)` pair that capped `#share-room` and `.preview` to the
       measure is gone: `.share-room`/`.preview.big` now carry it unconditionally. */

/* ---- the web modal's TITLE BAR — `11048:54825` -------------------------
       THE ONE RULE, NOT NINE. The Group members frame draws its phone sheet and its
       `Web - Modal` side by side, and the difference is not the heading's size: on web the
       title is a 64px `TopNavigation` — DM Sans Medium 18/1.4, CENTRED, on the ✕'s own line —
       where the phone keeps a left-aligned Grenette 32 body heading. What shipped was the
       phone's treatment at every width, which is Wei's "apps and web are using different font
       families" (the app's own web sheet against Pi's web chrome, not app-vs-app).

       THE TITLE LEAVES THE FLOW, and that is what saves a per-sheet re-step: `.sheet-head` and
       `.sheet-title` are SIBLINGS, so putting them on one line means taking the title out and
       centring it over the row. Every CLASS-weighted per-sheet margin on `.sheet-title`
       (`.sheet-members`'s 36, `.sheet-tall`'s 32px face) goes inert with it; the two the base
       picks by ID do not, and are reset below. The row ends at 52 and the column opens at 60 —
       12 + the row's 40 + 8 — on every sheet whose own first block adds nothing.
       60 IS READ OFF BOTH DESKTOP FRAMES AND NOT DERIVED FROM THE BAR: `Web-desktop_Group-members`
       (`11048:54806`, 400x382) puts its `Section` at (24, 60) and `Web-desktop_Customize`
       (`11048:54954`, 420x372) its first block at (24, 60), while the 64-tall `TopNavigation`
       draws its row at 12..52. Taking the bar's own 64 instead lands every sheet 4px low.

       `left/right: 56px` is `Center Item` (56..344 in a 400 panel) verbatim, so the title
       ellipsizes rather than running under the ✕ — which is the frame's own `text-ellipsis`.

       `:has(> .sheet-head)` EXCLUDES `.sheet-rename`, which `openSheet` builds with
       `head: false`: it has no row to sit on, and it is a DIALOG governed by `11048:55190`
       (an iOS alert on iOS, an Android/Web card elsewhere), not by this bar.
       Mobile is untouched: this is inside the 768 query, and the phone sheet is what the
       left-hand frame draws. */
    .sheet-wrap > .sheet:has(> .sheet-head) { position: relative; padding-top: 12px; }
/* AND THE HEAD DOES NOT STICK UP HERE, which is the one place the base's sticky head is
       wrong: the modal draws `.sheet-title` as an ABSOLUTELY POSITIONED bar over this very row
       (below), so a head that stays and paints would swallow the title on the first pixel of
       scroll. The phone sheet is the one with a grab handle and the one the bug was reported
       on; the desktop modal keeps the whole row in the flow it scrolls in. */
    .sheet-wrap > .sheet > .sheet-head {
      min-height: 40px;
      margin-bottom: 8px;
      position: relative;
      background: none;
      /* and the card keeps its own 12 of padding up here (above), so the base's border would
         be 16px of head on top of it */
      border-top-width: 0;
    }
/* 19.5 is the frame's own `Center Item` top: a 25.2 box centred on the row's 32, which is
       the 64 bar's centre either way you count it. `--track-tight` is the -0.02em the 18px
       `Body/M` states as -0.36; `--font-body` is the sans the whole complaint is about, where
       `.sheet-title`'s base is `--font-heading`. The colour is inherited from that base and is
       already `#131212`. */
    .sheet-wrap > .sheet:has(> .sheet-head) > .sheet-title {
      position: absolute; top: 19.5px; left: 56px; right: 56px; margin: 0;
      font-family: var(--font-body); font-size: 18px; font-weight: 500;
      line-height: var(--lh-copy); letter-spacing: var(--track-tight);
      text-align: center;
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
    }
/* TWO SHEETS DO NOT TAKE THE RULE ABOVE, and a specificity count is what says so: the base
       picks them with an ID inside `:has()` — `.sheet:has(#answer)`'s 32px face and
       `.sheet:has(#enable)`'s 20px lift — which is (1,2,0) against the bar's (0,4,0), so no
       number of classes wins and the override has to carry an ID too. Measured without it, the
       your-turn title came back 32px and ellipsized inside `Center Item`'s 288, and the enable
       sheet's sat at 39.5 across its own `.sub`. */
    .sheet-wrap > .sheet:has(> .sheet-head):is(:has(#answer), :has(#enable)) > .sheet-title {
      margin: 0; font-size: 18px;
    }
/* AND THE COMPENSATOR ON THE OTHER SIDE OF THE PAIR. `.sheet .sub`'s `-6` is tuned against
       `.sheet-title`'s 16px BOTTOM margin — its own comment says so — and here there is no bottom
       margin left to compensate: the title is absolute with `margin: 0`, so the pull-up comes off
       the bar instead and five sheets open at 54. `Web-desktop_Customize` (`11048:54954`) puts that
       very paragraph — `Frame 1422157113`, "Pi will create fun questions around these topics…" —
       at (24, 60), the same 60 `Web-desktop_Group-members` puts its `Section` at. It is a MARGIN ON
       THE SIBLING, which is why leaving the flow did not take it out with the title's own.
       Class-weighted is enough: what it beats is `.sheet .sub` (0,2,0), not one of the two the base
       picks by ID. `.answer-sub` IS EXCLUDED, and used not to need to be: PI-10122 draws the waiting
       line on every host but desktop Pi Web, so a native tablet past 768 now reaches this block with
       that paragraph first in the column. Zeroing it there collapses it onto `.sheet-head`'s own 8
       instead of the 24 every other column in this block opens at — measured 15.31 against 31.31. */
    .sheet-wrap > .sheet:has(> .sheet-head) > .sheet-title + .sub:not(.answer-sub) { margin-top: 0; }

/* The kebab menu needed NO rule here, recorded rather than left silent because the obvious
       change is wrong: `anchorMenu` positions the platter in VIEWPORT coordinates on both axes, so
       it follows its button into the card for free. Which makes `.menu-wrap` A TRAP -- give it any
       origin of its own and every one of those measured numbers is reinterpreted against it. */

    body.sheet-page { padding: var(--dp-desk-inset) 24px; }

/* ---- PI-9510 x PI-9491: the activity plate on the desktop card ---------
       EXACT-TIE SELECTORS, matching `join-view.css` character for character with no `!important`.
       What decides the tie is ORDER: this file is linked AFTER `/join-view.css`. Swap the two links
       and these two lines lose instead, with no error anywhere.
       `:not(:has(.preview-head))` and NOT a blanket rule: at (1,1,0) a blanket rule would beat the
       preview's own (0,2,0) and clobber its 539 wash. (doc: link-order) */
    #app.activity-plate { padding-top: 50px; }
    #app.activity-plate:not(:has(.preview-head)) {
      --activity-wash-h: 676px;
    }
/* THE SAME GUARD THE PREVIEW'S BLOCK BELOW CARRIES, for the same cascade reason: at (1,2,0)
       this outranks `html[data-presentation="fullscreen"] #app`'s (1,1,1), so the join route kept
       the CARD's floor on a surface Pi Web had given the page whole — 817 in a 700-tall frame,
       scrolling by 117 (PI-10394). The wash above stays unguarded: 676 is the plate's own number
       in either presentation, and on every plated screen but ONE. */
    html:not([data-presentation="fullscreen"])
      #app.activity-plate:not(:has(.preview-head)) {
      min-height: 817px;
    }
/* …AND THE FLOOR WAS NOT ALL OF IT. THE WORD ABOVE IS THE RIGHT ONE — `fullscreen` IS what
       Pi Web sends on this route, and an earlier draft of this block said the opposite and was
       wrong. `[appId].tsx` makes `fullscreen` the DEFAULT and `card` the shelf's hint
       (`present=card` on the URL); an invite arrives as
       `/apps/<id>?path=%23%2Fjoin%2F<token>&pid=User_invite` carrying no hint at all, and plaza's
       own note on that line cites `11048:49775` — the join screen drawn full-bleed. Read the
       vendored copy under `shells/web/vendor/pages/apps/[appId].tsx`; it is in this repo.

       SO THE 817 IS ALREADY GONE HERE and nothing below is about it. What was still wrong on
       that surface is that the column's height is written by JS — see `height` below — and the
       band was `#app`'s 40 of padding rather than `BottomCTA`'s own 22 + 56 + 22. Measured at
       1900 wide with `presentation: "fullscreen"` against the parent commit: loaded at 900 the
       CTA came back at 860 (40 off the foot, not 22); loaded at 620 it came back at 642, which is
       22px PAST the foot, with the document scrolling by 62; and mounted at 930 and then shrunk —
       a Chrome infobar, a download shelf, a window drag — the document overflowed by 30 / 150 /
       230 at 900 / 780 / 700 while the CTA stayed at 890 for all three. That is QA's screenshot:
       a scrollbar, Join adrift, no bottom band, one cause. After: document overflow 0 at every
       height from 1000 to 600 in both sweeps, and the CTA's bottom is the frame's height less 22
       at every one of them.

       AND `card` REACHES NONE OF THIS, which is why every rule from here down is keyed on the
       HOST and never on the word. The card is a 705x781 iframe (`md:h-[781px] md:w-[705px]`), a
       media query inside an iframe measures the IFRAME, and the block these rules live in opens
       at 768 — so inside a card `#app` is its `max-width: 460px` base and none of this exists.
       `host-card-width.test.js` is a whole file about that arithmetic and
       `browser-join-band.test.js` restates it in a browser. Keying on the word would have been
       keying on something this stylesheet cannot observe.

       INSIDE `@media (min-width: 768px)`, WHICH THE FRAMED RULES AT THE FOOT OF THIS FILE ARE
       DELIBERATELY OUTSIDE OF — so this is the exception and it is stated rather than left to be
       rediscovered. Those rules answer "Pi Web framed me" at EVERY width, which is why they must
       not sit behind a query a 705 card cannot match. These seven answer "Pi Web framed me on a
       DESKTOP", and the desktop is the query: below it this host is mweb at 393, where the phone
       composition is correct and a definite height, a scrollport and a sticky band would all be
       wrong. Hoisting them out would mean seven copies of the same query and nothing else. `host-card-width.test.js`'s nesting guard sweeps only the rules whose subject is a
       bare `#app`, for exactly this reason; `stylesheet.md` §framed carries the same note.

       `:has(.dp-join)` AND NOT the rule above's `:not(:has(.preview-head))`, which is wider
       than it looks: that one also picks the SHARE screen (`drawShare` wears the plate and
       draws no preview head), whose card on this host is a real card. The invitee column is
       the only thing this ticket is about, and all four of its states wear `.dp-join`.

       THE REDUNDANT `:not(:has(.preview-head))` BUYS ONE POINT OF SPECIFICITY, and it is still
       worth buying on a route whose floor is already withdrawn: a Pi Web frame that arrives with
       NO `data-presentation` at all — an older container that appends no `pi_presentation`, or
       any paint before `presentation.js` has run — matches the floor's
       `:not([data-presentation="fullscreen"])` and takes the 817. Without this term that is
       (1,3,1) against (1,3,1) and source order decides, which is the exact fragility the
       link-order note above is written about. With it this is (1,4,1) and the cascade says so.

       THE CTA'S NUMBERS ARE `14430:118613`'s, the 1024-tall desktop Screen, AND THE WASH EDGE'S
       ARE NOT ANY MORE — see the block below this rule. That band is 22 + 56 + 22, and the 22 is
       this `padding-bottom` — the bottom of a box with a definite height, OUTSIDE the scrollport,
       so the CTA lands at `100svh - 22` whatever the wash does.
       `fillToFoot` reads this property to place a column's foot on the other surfaces and is
       withdrawn on this one (see `min-height: 0 !important` below), so here it is arithmetic
       with no JS in it. The `min-height` restates `join-view.css`'s own formula rather than
       dropping to `auto`, which would leave the plate's painted box shorter than the frame and
       show Pi's page through the gap (`html` is transparent on this host).

       AND THE WATERMARK IS THE WHOLE MARK HERE, WHICH IS THE ONE PLACE IT IS. Everywhere else
       `--watermark` is `pi-watermark.svg`, and that file is the PHONE's window onto the mark —
       viewBox `0 0 394 676` over geometry that runs from -378.15 to 631.95, so the board shows a
       corner of it by design (`9237:96215`, and it reads as a soft curve top-left and a blob
       bottom-right). Stretching that crop across a 1440 frame is what QA reported as "the Pi logo
       takes up the full background", and it is not fixable with `background-size`: the rest of the
       mark is outside the FILE's viewport, not merely out of the box we paint it in. So this
       surface alone spends `--watermark-wide`, the same bytes re-pointed at the geometry's own box.

       THE BOX IS `14433:119628`'s, AS THREE FRACTIONS OF THE WASH RATHER THAN THREE LITERALS.
       `Background_graphic` is 1440x924 and puts `Mask group` at (215.45, 74.195), 1010.0985 wide
       and 775.614 tall — so the mark is 70.146% of the frame's width, 83.941% of the wash's height,
       and its top is 8.030% down the wash, which leaves the same 8.030% under its feet. Fractions
       and not pixels because the wash is a different height at every viewport here and the frame
       only ever drew one; a literal would be right at 1024 and wrong at every other height.
       `14491:139309` (PI-10174's frame) carries the identical numbers. */
    html[data-host="pi-web"]
      #app.activity-plate:has(.dp-join):not(:has(.preview-head)) {
/* THE FRAME'S HEIGHT, NAMED ONCE BECAUSE FOUR DECLARATIONS HAVE TO AGREE — the same reason
         `--activity-bleed` exists thirty lines into `join-view.css`. Written out four times, the
         wash and plate `calc()`s below disagreed with the box above and below them the moment
         either term stopped being zero: the plate would end a band's depth off the RAW viewport
         while the box it paints ended earlier, putting the wash edge inside the band and leaving
         a transparent
         strip under `#app` that nothing fills (`html` is transparent on this host and its `body`
         pays no inset). Neither term is reachable here today — `--kb` wants a keyboard on a screen
         with no field, and `env()` resolves to 0 inside the iframe Pi Web frames this page in — so
         this is the trap closed rather than a defect fixed. */
      --pi-web-frame-h: calc(100svh - var(--kb, 0px) - env(safe-area-inset-bottom, 0px));
      min-height: var(--pi-web-frame-h);
/* AND THE DEPTH IS `--activity-band-h` AND NOT THIS TICKET'S OWN 100, WHICH IS THE ONE NUMBER
         PI-10174 TOOK OFF IT. Two frames drew the same surface 12px apart: `14430:118613` puts the
         wash edge at 924 of 1024 (the BottomCTA's solid Content, under its 12-tall `Fade`), and
         `14491:139309` opens `Bottom area` at 912 — the top of that same fade. 912 is the one that
         shipped, in the rule ~150 lines below, and it is also the one the fade wants: drawn over
         cream that starts at its own top, a transparent-to-`--bg` ramp has nothing to ramp over.

         READ FROM THE VARIABLE RATHER THAN RE-TYPED, so the two tickets cannot drift apart again:
         112 at `fullscreen`, the board's 176 anywhere else this host could put a desktop column.
         Which is theory — `[appId].tsx` makes `fullscreen` the default and `card` the shelf's
         hint, and the card is a 705 iframe that never reaches this query at all.

         STILL STATED HERE AND NOT LEFT TO `join-view.css`'s `calc(100% - …)`, which computes the
         same number by a different route: that rule is keyed on the WORD and this one on the HOST,
         and the host is what this whole block is about. It also has to be stated for the watermark
         below, which that rule does not touch. */
/* THE DEPTH IS KEYED ON THE HOST HERE, AND PI-10174 KEYED IT ON THE WORD — which left the
         page frame's band on ONE of Pi Web's two words and the phone board's 176 on the other.
         Everything else in this block is host-keyed on purpose (the comment at its head says why);
         this was the one number that was not, and it is the whole of QA's round 4. Measured at
         1522x730 on `card`, against `fullscreen` beside it: the wash edge stood 176 off the foot
         instead of 112, so the cream ran a third taller than `Bottom area` draws it AND the 64px
         it took came off the column — `.dp-join-line` ("UserS is already in this group") ended at
         572 under a seam at 554, i.e. the seam cut 18px up through the census line. On
         `fullscreen` the same window clears it by 46.

         `card` AT A DESKTOP WIDTH IS NOT A SHAPE THE SHELF PRODUCES — its iframe is 705 and this
         block is behind `min-width: 768px` — and that is an argument for fixing it here rather
         than against it: a surface nothing can reach is a surface nothing measures, and a
         container that sends no presentation AT ALL lands on exactly the same fallback. At
         (1,5,1) this outranks PI-10174's (1,4,1) on both words and agrees with it on the one
         that ships, so no arm that any suite drives moves. */
      --activity-band-h: 112px;
      --activity-wash-h: calc(var(--pi-web-frame-h) - var(--activity-band-h));
      --watermark: var(--watermark-wide);
      --activity-plate-w: 70.146%;
      --activity-plate-h: calc(var(--activity-wash-h) * 0.83941);
      --activity-plate-y: calc(var(--activity-bleed) + var(--activity-wash-h) * 0.08030);
      padding-bottom: 22px;
/* THE 50 ABOVE IT IS THE DESKTOP CARD'S INSET (`#app.activity-plate`, ~150 lines up, the
         705x817 modal's `padding: 50px 40px 40px`) AND THIS SURFACE HAS NO CARD. Withdrawn, so the
         free space below is the frame's own — `guest-surface.md` §round 5 carries the numbers. */
      padding-top: 0;
/* AND THE SIDE GUTTER GOES TO THE SCROLLPORT, which is the difference between "the whole
         width takes the wheel" and very nearly that. The framed-page rule at the foot of this file
         gives `#app` a `padding-inline` of `var(--gutter)`, and a scrollport is a box's CONTENT
         area — so with the scroll on `.dp-join` inside that padding, 24px down each edge belonged
         to `#app`, which is not a scroll container, on a document now pinned by a definite height.
         Measured at 1820 wide: a wheel at x=4, 12 and 23 moved nothing, x=30 and beyond moved the
         full 56. That is the reported defect surviving in a thinner strip, and
         `browser-join-band.test.js` drove the wheel 80px in, so it could not see it. The gutter is
         restated on `.dp-join` below, which leaves every child where it was — they are already
         capped and auto-margined.

         THAT RULE IS NAMED IN PROSE AND NOT SPELLED OUT, AND THAT IS LOAD-BEARING RATHER THAN
         STYLE: `host-card-width.test.js` locates it by `indexOf` on its selector text and reads
         the 300 characters that follow, so the FIRST occurrence of that string in this file wins.
         Spelled out here, this comment was the first occurrence — it sits ~200 lines earlier — and
         the scrape came back with prose, failing "it fills the frame rather than keeping the phone
         cap" and the media-nesting guard beside it, on a rule nothing had touched. So no comment
         above that rule may contain its selector verbatim; say which rule you mean in words. */
      padding-inline: 0;
/* A DEFINITE HEIGHT AND NOT ONLY A FLOOR, which is what stops the WINDOW scrolling at all —
         and the floor alone was not enough, because the column's height is written by JS.
         `fillToFoot` measures ONCE, from a layout effect on mount, and writes an inline
         `min-height`; nothing re-measures on `resize`. Swept on the running app at 1900 wide, on
         `fullscreen`: mounted at 930 the column is pinned at 840 for ever, so at 900 the document
         overflows by 30, at 780 by 150, at 700 by 230, at 640 by 290 — and the CTA stays at 890
         through all of them, off the bottom of every one. That is the bar QA photographed; a
         Chrome infobar appearing after load shrinks the viewport by exactly that kind of amount.
         With a definite height the document is the frame at every size and the arithmetic has no
         JS in it — measured after: overflow 0 at 930, 900, 800, 780, 700 and 640. */
      height: var(--pi-web-frame-h);
      display: flex;
      flex-direction: column;
    }
/* THE COLUMN IS THE SCROLLPORT, SO WHAT MOVES IS THE PART ABOVE THE CTA AND NOTHING ELSE —
       `14430:118613` draws `Top` and a `Bottom area` as two bands, and Stars Aligned ships it:
       its join scrolls its middle under a fixed CTA with no bar in sight.

       `min-height: 0` IS THE FLEX IDIOM (a flex item's automatic minimum is its content, so
       without it the item refuses to shrink and there is nothing to scroll) AND it is also what
       withdraws `fillToFoot`'s inline number. `!important` IS THE ONLY THING THAT BEATS AN
       INLINE DECLARATION — that is what the keyword is for, and it is not the tie-breaking-by-
       weight the link-order note above forbids. Left standing, that stale 858 out-ranks any
       `max-height` (min beats max in CSS) and the column would keep overflowing exactly as it
       does now. `fillToFoot` still runs and is still right on the OTHER surfaces; it is only
       this one that no longer needs it. */
    html[data-host="pi-web"]
      #app.activity-plate:has(.dp-join):not(:has(.preview-head)) > .dp-join {
      min-height: 0 !important;
      overflow-y: auto;
/* THE SCROLLPORT IS THE FRAME'S WIDTH, NOT THE COLUMN'S, and that is a usability fact rather
         than a cosmetic one: a wheel only scrolls the box it is OVER, and `join-view.css` caps this
         column at 616 and centres it. On a 1820-wide frame that leaves ~600px of plate down each
         side where the wheel did nothing at all — reported, and correctly, as "it only scrolls if I
         do it from the content". The cap moves to the children below, so what the reader sees is
         unchanged.

         TO THE FRAME'S EDGE, AND THE FIRST ATTEMPT AT THIS STOPPED 24px SHORT: `max-width: none`
         alone makes the column as wide as `#app`'s CONTENT box, and `#app` pays the side gutter as
         padding — so the outermost 24px down each edge stayed outside the scrollport and stayed
         dead. The gutter is `padding-inline` here instead, which puts those 24px INSIDE the box
         that scrolls while leaving the children exactly where they were: they are capped at 616
         and auto-margined, so at 1820 they are centred on the same pixel and at 768 they clear the
         edge by the same 76. Verified by wheel at x=4. */
      max-width: none;
      padding-inline: var(--gutter);
/* The frame shows no bar, and this app already spells "scrolls, silently" this way — see
         `.chip-row`. Both halves are needed: Firefox reads the property, WebKit and Chrome the
         pseudo-element. */
      scrollbar-width: none;
/* A wheel at the end of this list must not hand the gesture to Pi's own page behind the
         frame, which is what makes a mini app feel like it is dragging its host around. */
      overscroll-behavior: contain;
    }
    html[data-host="pi-web"]
      #app.activity-plate:has(.dp-join):not(:has(.preview-head)) > .dp-join::-webkit-scrollbar {
      display: none;
    }
/* …AND THE 616 THE COLUMN GAVE UP, ON EACH OF ITS CHILDREN INSTEAD. `> *` rather than the four
       names, because the four states draw different children (`joinHtml` has a `.dp-title` of its
       own, `blockerHtml` puts its title inside `.dp-center`) and a list would go stale silently the
       next time one of them changes. Every one of them is already a block or a centred flex column,
       so a cap plus auto margins puts each back exactly where the column had it. */
    html[data-host="pi-web"]
      #app.activity-plate:has(.dp-join):not(:has(.preview-head)) > .dp-join > * {
      width: 100%;
      max-width: 616px;
      margin-inline: auto;
    }
/* AND THE LAST BLOCK KEEPS ITS DISTANCE FROM THE BAND. `.dp-foot`'s `margin-top: auto` spends
       the FREE space, so at a height where the column only just fits there is none left to spend
       and the seat line ends up against the band — measured 6px clear at 1820x670, and flush the
       moment it scrolls. This is clearance the content owns rather than padding the band owns, so
       it survives both states: with room to spare the auto margin swallows it and nothing moves,
       and with none the line still stops 24 short. `.dp-center` is the last block before the foot
       in every one of the four states. */
    html[data-host="pi-web"]
      #app.activity-plate:has(.dp-join):not(:has(.preview-head)) > .dp-join > .dp-center {
      margin-bottom: 24px;
    }
/* …AND THE TOP SPACE IS THAT SAME FREE SPACE, NOT A STEP. A second `auto` beside `.dp-foot`'s
       splits it two ways, which is `11048:51563`'s own constraint — centred in the wash,
       `top: calc(50% - 12px)` — rather than a new mechanism; alone, the foot's took all of it and the
       block hung at a fixed 157 whatever the frame's height. An auto margin absorbs POSITIVE free
       space only, so it collapses to 0 and a short frame still scrolls from the top. `.dp-head` and
       not `> *`: a third auto margin would take a third each. */
    html[data-host="pi-web"]
      #app.activity-plate:has(.dp-join):not(:has(.preview-head)) > .dp-join > .dp-head {
      margin-top: auto;
    }
/* …AND THE CTA BAND DOES NOT MOVE WITH IT. `sticky` and NOT `fixed`: `join-view.css` records
       why fixed is unusable here — `#app`'s children carry an animation with `fill: both`, and the
       leftover transform makes this element a containing block, so a fixed foot would pin itself
       to the column rather than to the frame. Sticky answers to the SCROLLPORT, which is that
       same column, so it is right for the same reason fixed is wrong.

       OPAQUE, because content now passes behind it. `--bg` and not `--tan`: below the wash the
       plate paints nothing and `#app`'s own `background-color` shows, which IS `--bg`, so the
       band is seamless in both schemes. The 22px under it is `#app`'s padding — outside the
       scrollport, so nothing can scroll into it.

       AND ITS TOP IS THE WASH'S OWN SEAM, WHICH IS ROUND 6 AND THE WHOLE OF QA'S REOPEN. The
       band was `BottomCTA`'s 100 off the foot (22 + 56 + `#app`'s 22) while the wash ends at
       `--activity-band-h` — `Bottom area`'s 112 — so 12px of the cream strip stood ABOVE the
       band's own top, and the fade spent the 12 below that. Measured at 1440x900/800/700/620:
       the seam at 788/688/588/508 against a band opening at 800/700/600/520, the same 12 at
       every height. Content scrolled through both: the reader saw the census line half-drawn
       INSIDE the bottom section, which is what QA photographed against Stars Aligned, whose
       `.sa-sheet__foot` is a sibling of its scroller and so clips at the section's top edge
       exactly. Here the foot is INSIDE the scrollport (it has to be — `sticky` answers to the
       scrollport, and see above for why `fixed` cannot), so the equivalent is to make the band
       reach the seam and paint solid from it: the clip a reader sees is where the opaque paint
       starts, and that is now the same line the wash ends on.

       SO THE FADE GOES. `Fade` IS in the frame (`BottomCTA`'s first child, 12 tall, then Content
       88 — `I14430:118603;9202:95742;6236:20645`) and it is the thing QA is objecting to: a ramp
       is content showing through the bottom section, which is the defect however softly it is
       drawn. Stars Aligned made the same call in prose — "Figma has a CLEAN edge between the top
       (wash/teaser) and the bottom section, not a soft gradient" (`stars-aligned.css`) — and it
       is the surface QA is holding this one to. A `::before` above the band was tried in round 4
       and is still wrong for the reason recorded then: anything drawn above this element's top
       edge washes out the last 12px of the seat line, which sits exactly there once the column
       scrolls. Grown INTO the band instead, the extra 12 is padding nothing can scroll into and
       `.dp-center`'s 24 of clearance is still measured to the line the reader sees.

       THE PADDING IS DERIVED AND NOT THE LITERAL 34, because the number that has to hold is a
       RELATIONSHIP: this band's top is the wash's seam. `--activity-band-h` is what puts that
       seam there (`join-view.css` sizes the plate's layers at `100% - band-h`), 56 is `.dp-btn`'s
       own height and 22 is `#app`'s padding below the scrollport — so a later round that moves
       the band depth moves both edges together instead of re-opening this ticket. A label that
       wraps grows the pill past 56 and the band simply starts higher than the seam, which covers
       more and reveals nothing. */
    html[data-host="pi-web"]
      #app.activity-plate:has(.dp-join):not(:has(.preview-head)) > .dp-join > .dp-foot {
      position: sticky;
      bottom: 0;
      background: var(--bg);
      padding-top: calc(var(--activity-band-h) - 56px - 22px);
    }
/* PI'S OWN SIGN-IN PILLS, WHICH THE SCROLLPORT CLIPPED IN HALF. `join-view.css` centres them
       on `.dp-head-row` — Pi's mark — and `html[data-host="pi-web"] .dp-wordmark` hides that mark
       here, so on THIS host alone the row is 0 tall and a 40px pill centred on it hangs 20px above
       its own top. That overflowed harmlessly into `#app`'s 50px of padding while the column had
       `overflow: visible`; a scrollport clips it, and QA photographed the top half of `Log in` /
       `Sign up` gone. Measured: pills at 30..70 in a port starting at 50.

       ANCHORED TO THE ROW'S TOP, NOT GIVEN THE ROW A HEIGHT. The row is 0 tall on purpose — the
       pills are out of flow precisely so that `.dp-head-tile`'s 107 is measured from the mark and
       the tile sits where `14430:118599` (no pills) and `14433:119586` (pills) both put it, 186 at
       1:1. A `min-height` here would buy the 20px by moving the tile 40, on the one variant that
       has pills. `left`/`translateX` are untouched, so the centring `browser-join-signin` measures
       against Pi's trailing corner is exactly what it was. */
/* …AND THE PAIR IS MEASURED FROM THE FRAME, NOT FROM WHEREVER THE HEAD LANDED — QA's third
       report, and `top: 0` above was only half of it. Anchored to `.dp-head-row`, the pills ride the
       head: round 5 gave `.dp-head` an `auto` margin, so measured on the signed-out arm they opened
       at 139 in a 900 frame, 39 in a 700, and flush at 0 the moment the column overflows — which is
       what QA photographed, the pair jammed against the frame's top edge with no inset at all. The
       fix is to take the ROW out of `position: relative` so the pair's containing block is the
       scrollport rather than a 0-tall row inside the flow; `top` is then measured from the frame's
       own top and reads the same at every height. Measured after: 20 at 900, 620 and 560 alike.

       20 AND NOT THE FRAME'S 16, because the two are the same statement about different boxes.
       `14447:121217` is Pi's own `TopNavigation`: a `Top` band inset 16, holding `buttons` 48 tall
       (y16..64) beside a 40-tall Leading mark and a 40-tall Trailing ✕, both CENTRED in that band at
       y20..60. Pi Web draws the mark and the ✕ and not the buttons, so this app supplies the pair
       (PI-10122) — and it supplies it in pi-design's sizes, where the medium control is 40 and there
       is no 48 (`--pi-control-md`/`-lg` are 40 and 56; the frame's 48 is plaza's own nav button). At
       40 tall, `top: 20` lands our pair exactly level with Pi's mark and ✕, which is the alignment
       the frame draws. A literal 16 would sit our shorter pills 4px proud of the host's own row.

       IT STILL SCROLLS AWAY WITH THE HEAD, AND THAT IS NOT REACHABLE FROM HERE. In the frame these
       buttons are chrome and stay put; pinning them needs the pair OUT of `.dp-join` in the markup,
       because the column is the containing block for everything absolute inside it — `screen-fwd`
       is declared on `#app`'s children with `fill: both` and fills at an identity matrix, which
       still establishes one. Measured, after the animation has settled: `offsetParent` is `.dp-join`
       and `getComputedStyle(col).transform` is `matrix(1, 0, 0, 1, 0, 0)`, never `none`. That is the
       same fact `join-view.css` records against a `fixed` foot, so `fixed` is no escape either. The
       markup question is the one round 5 left open — where the pair belongs on a desktop frame at
       all — and it is deliberately not answered in a stylesheet.

       `left`/`translateX` ARE STILL UNTOUCHED, and the containing block swap does not move them: the
       row is the head's full width and the head is the 616 column, both centred in the frame, so 50%
       is the same pixel either way. Measured, x 617..824 before and after — the centring
       `browser-join-signin` checks against Pi's trailing corner is unchanged. */
    html[data-host="pi-web"]
      #app.activity-plate:has(.dp-join):not(:has(.preview-head)) .dp-head-row {
      position: static;
    }
    html[data-host="pi-web"]
      #app.activity-plate:has(.dp-join):not(:has(.preview-head)) .dp-head-auth {
      top: 20px;
      transform: translateX(-50%);
    }

/* ---- PI-10174: the join PAGE's band is 112, not the card's 176 ----
       THE DEPTH ONLY. Where it is measured FROM is `join-view.css`'s rule, which is deliberately
       not in a width query -- see there. `14491:139309` is a 1440x1024 PAGE frame opening
       `Bottom area` at y=912; the 676/176 above is the CARD's, off the 852-tall board
       `9237:96215`, and stays the number below 768 where that board is what this app draws.
       `:has(.dp-join)` AND NOT `:not(:has(.preview-head))`: the negation would take the 676 off
       SHARE too. `.dp-join` is the join column and the blocker's (`join-view.jsx:358`), nothing
       else. Depths at four heights: `browser-join-no-scroll.test.js`. */
    html[data-presentation="fullscreen"] #app.activity-plate:has(.dp-join) {
      --activity-band-h: 112px;
    }

/* ---- the preview's own desktop composition — `11048:54271` -------------
       `Web_Activity_Preview` is a 705x800 modal, and every number below is one the card got
       wrong by inheriting the phone's stack instead. THE ✕ IS AN OVERLAY THERE, NOT A ROW: the frame's `TopNavigation` is absolute at the card's top with `pt-16 px-16`, so
       "Between us" starts at 66 (`Top` pt-50 + its inner `Top` pt-16) rather than under a
       58px-tall header. In the flow the row cost 108-50, and every block under it — pills,
       description, tile, question, Start — was carried 42px down the card.

       TWO GUARDS, AND BOTH ARE LOAD-BEARING. `:not(.head-hosted)` is `join-view.css`'s own:
       where Pi draws the bar the row RESERVES `--pi-chrome-top`, so pulling it out of the flow
       there would put the heading under Pi's chrome. `html:not([data-presentation="fullscreen"])`
       is the one a specificity count would miss: `:has()` takes its argument's weight, so these
       selectors run to (1,4,0) and OUTRANK the fullscreen block's own (1,1,1) — measured, the
       page the host gave the whole surface to came back 800 tall in a 900 window with its CTA
       floating 100px above the foot. THIS BLOCK IS THE CARD'S, and fullscreen has no card.
       Mobile is untouched: every declaration is inside the 768 query. */
    html:not([data-presentation="fullscreen"])
      #app.activity-plate:has(.preview-head):has(.head-pinned:not(.head-hosted)) {
      min-height: 800px;
      padding-top: 66px;
/* The frame's `Top` is 705x515 and the opaque `Bottom` opens there, so the wash's visible edge
         is 515 — 40 under the 475 tile, where the phone's is 24. The 534 this replaces was the
         phone's number reached through a 42px-lower stack.
         PI-10640: measured, like the phone's — `readPreviewWash()` reads this 40 as the tail
         under the tile. 515 + one description line stays as the first-frame fallback. */
      --preview-wash-tail: 40px;
      --activity-wash-h: var(--preview-wash-h, calc(515px + var(--preview-line-step, 22px)));
    }
    html:not([data-presentation="fullscreen"])
      #app.activity-plate:has(.preview-head) > .head-pinned:not(.head-hosted) {
      position: absolute; top: 16px; right: 16px;
      margin: 0; padding: 0;
/* The glass goes with the row: `#app.activity-plate > .head-pinned` blurs its whole box, and
         once that box is a 40px disc the blur is a square smudge on the wash. The frame's
         `RoundIconButton` sits on the wash with no fill. */
      background: none; backdrop-filter: none; -webkit-backdrop-filter: none;
    }
    html:not([data-presentation="fullscreen"])
      #app.activity-plate:has(.preview-head) > .head-pinned:not(.head-hosted)::after {
      content: none;
    }
/* 64 = the 40 of wash tail under the tile + the band's own 24 inset, which is the same
       sum the base rule spends as 24 + 24. One number on one node for the reason stated there:
       adjoining sibling margins collapse. (doc: question-title) */
    html:not([data-presentation="fullscreen"])
      #app.activity-plate:has(.preview-head) .question-title { margin-top: 64px; }

    body.sheet-page:has(.sheet-hosted #share) #app,
    body.sheet-page:has(.sheet-hosted #start) #app {
      min-height: 817px;
      padding-top: 50px;
      padding-bottom: 40px;
    }
    /* The couldn't-load column too: the card's height and inset, not the window's. */
    #app:has(> .load-failed) { min-height: 817px; padding-top: 50px; }
  }

/* ------------------------------------------------------------------ framed
     OUTSIDE the query above, and that is the point: Pi's web host frames this page at exactly the
     705 that block declares, a media query inside an iframe measures the IFRAME, and 705 < 768 --
     so the block could never fire where it was written for and `#app` fell back to its 460 cap.
     LOWERING THAT 768 IS NOT THE FIX: the e2e suite pins 767 as a phone, and 768 is a decision
     about STANDALONE browsers. (doc: framed) */
  html[data-host="pi-web"] #app {
    width: 100%;
    max-width: none;
  }
  html[data-host="pi-web"] {
    background: transparent;
    /* The framed column, stated once: the stream below and the chip stack both hang off it. */
    --dp-web-col: 625px;
  }
  html[data-host="pi-web"] body {
    display: block;
    padding: 0;
  }
  html[data-host="pi-web"] #app {
    border-radius: 0;
    box-shadow: none;
/* THE CARD'S SIDE INSETS GO WITH THE CARD -- the 768 block fires on a FRAMED page too whenever
       Pi hands over more than 768, and `.head` backs out 16-minus-24 from a gutter that was then 40.
       Specificity does it with no `!important` and no query -- (1,1,1) against (1,0,0), and a media
       query contributes nothing. INLINE ONLY: the vertical inset is `--pi-chrome-top`'s question. */
    padding-inline: var(--gutter);
  }

/* A FULL-BLEED HEADER OVER A CENTRED COLUMN. THE 40 IS NOT DECLARED ANY MORE, IT IS WHAT THE
     COLUMN LEAVES OVER: the paddings this replaces were right for the only width they were
     measured at (705) and not a width the host guarantees -- with the sidebar collapsed the
     stream stretched to 1080 of 1160. 625 and not the frames' 624, so the column is ONE number on
     both surfaces. `#app > .notice` and not `.notice`: the stream's own notices are already in
     the column. Only the thread's boxes are named here. (doc: framed) */
  html[data-host="pi-web"] #app > .stream,
  html[data-host="pi-web"] #app > .notice {
    max-width: var(--dp-web-col);
    margin-inline: auto;
  }

/* PI-10389 — THE WEB FRAME STACKS THEM ON PI'S COMPOSER FIELD, not on this page's column.
     `11048:51706` is a COLUMN, `items-start`, gap 8, at the composer's own x. Pi Web draws that
     field OUTSIDE this frame, so the 616 is the one number this rule cannot derive: measured on
     staging in both sidebar states, the field is 616 wide and centred, i.e. 4.5px inside the 625
     column on either edge (frame 1160: field 272..888, column 267.5..892.5; frame 1376: 380..996
     against 375.5..1000.5). The base `padding-inline` cancels out of the centring, so `max-width`
     alone puts the content edge at (frame - 616) / 2.
     600 SEPARATES PI WEB'S TWO FRAMES, AND IT IS ITS OWN QUERY BECAUSE 768 SEPARATES NEITHER: Pi
     frames the panel at 705 and the phone route sheet at 393, and 768 misses the first (#227's trap,
     doc: stylesheet) while no query at all leaks the stack into the second, whose frame
     (`10917:196937`) is a row. */
  @media (min-width: 600px) {
    html[data-host="pi-web"] .chip-track {
      flex-direction: column;
      align-items: flex-start;
      width: 100%;
      max-width: calc(616px + 2 * var(--bar-gutter));
    }
  }

