/**
 * The invitee's join view — the one screen two surfaces render.
 *
 * Somebody without Pi meets it in a browser; somebody with Pi meets it inside the
 * container. It is the same screen either way, so it is one implementation: these
 * rules and `join-view.js`, linked by both `guest.html` and `index.html`.
 *
 * The `dp-` prefix is the guest page's, kept because these rules came from it
 * unchanged. It collides with nothing on the member page — which is a check worth
 * making before adding any class here, since `.empty` already cost us a bug.
 *
 * Self-contained on purpose: every custom property carries its own fallback,
 * because the two hosts do not name their colours the same way. The container
 * has `--forest`; the guest page has `--ink-deep`. Relying on the host to
 * declare one rendered the Join button as white text on nothing. The type
 * tokens from `pi-type.css` follow the same rule — both hosts load it, and the
 * fallbacks are the same values it declares, so a missing link degrades to the
 * right numbers in a system face rather than to no numbers at all.
 */
  /* The view brings its own column layout, so both hosts space it identically
     rather than inheriting whatever their own page happens to do.
     48, not 14 — `7607:44873`. Four children, so three gaps, so the join view
     grows ~102px: the frame gives the headline, the tan section, the group and
     the foot a full step between them and this ran them at a card's gap.
     `.dp-blocker` and `.dp-blocker-body` already say `gap: 0` and are UNAFFECTED
     by this, deliberately — every gap on the blocker page is one of that frame's
     own numbers and this one was being silently added to each of them. Verified
     in Chromium at 393x852 on all three states before and after, because that
     override is the one thing that turns this from a spacing change into a
     redraw of two screens that are not this one. */
  .dp-join { display: flex; flex-direction: column; gap: 48px; }
  .dp-center { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 8px; }
  /* The first block is NOT on `.dp-center`'s uniform step. `6103:88907` and
     `6103:89194` both put 41 between the chip's swatch (ink y83..132) and the
     headline's first ink row (y174); the shared 8 renders that step at 13.3 on
     the same 393pt surface. The gap is additive to an ink-to-ink measurement, so
     8 + (41 - 13.3) = 35.7, i.e. 36.
     Scoped to the FIRST child on purpose. The second `.dp-center` — the seat row
     and the join line — is right where it is (frame 15, built 18), and the
     blocker's `.dp-center.dp-blocker-body` is `gap: 0` and is never a first
     child, because `.dp-blocker-head` is. */
  .dp-join > .dp-center:first-child { gap: 36px; }
  /* Every accent fallback here is Button.accent_alt_default and every --ink-deep
     fallback is Button.accent_default — the values Pi's own buttons use. */
  .dp-ink { color: var(--accent, #038247); display: inline-flex; vertical-align: -3px; }
  .dp-mark {
    width: 56px; height: 56px; border-radius: 18px;
    background: color-mix(in srgb, var(--accent, #038247) 18%, var(--field, #FFFFFF));
    color: var(--accent, #038247); display: grid; place-items: center; margin-top: 12px;
  }
  /* heading_s: 24, not the 27 that was on no ramp, and Grenette's own 400.
     The ink is `#base/text-accent`, not the dark green this used to draw.
     `--ink-deep` is Button.accent_default — a BUTTON FILL being spent as
     heading text, which is what the build happened to do rather than what any
     frame asks for. Two headline nodes measured independently say the same
     token: the join view's `6103:89246` and the blocker's `6589:31936`, both
     `text-[color:var(--#base/text-accent,#131212)]`. F04-12.
     Through the DS token rather than the literal `#131212`, because the same
     class draws inside Pi, where the token carries the dark value (#FCFAF7)
     and a literal would be a black headline on a black page. Both hosts load
     pi-design.css, so the name resolves on both; the fallback is the light
     value for the case where it somehow does not. */
  /* 1.1 — `Headings/S` — and NOT `--lh-heading`. That token is 1.42, and its own
     comment in `pi-design.css` says where it comes from: Pi's NATIVE heading
     line boxes in `Font+.swift`. This screen is drawn in Figma, and the frames
     do not use them. `6103:89123` and `6103:89246` are the same sentence in two
     frames and both measure 345x52 — two lines of 26 in Grenette 24, i.e. 1.08 —
     where 1.42 gave the identical two lines a 68.2 box, 16px taller than the
     frame's whole headline. The design system's own `.pi-type-heading-s` (24 /
     1.1, generated from plaza) says the same number, and the blocker's
     `Headings/M` below already sets 1.1 for exactly this reason.
     `--lh-heading` stays on `.dp-prompt-text`, which is where it is right:
     `6103:89127` is Grenette 18 in a 26 box, 1.44. Two ramps, and only the
     heading one was wrong. */
  .dp-title {
    font-family: var(--font-heading, ui-serif, Georgia, "Iowan Old Style", serif);
    font-size: 24px; font-weight: 400; color: var(--color-text-accent, #131212);
    letter-spacing: var(--track-heading, -0.015em);
    line-height: 1.1; margin: 0;
  }
  /* -2%, not -1%: `7607:44893` is -0.28px on 14. The blocker's own sub line
     restates its tracking below, so this moves the join view's and nothing else. */
  .dp-sub {
    font-size: 14px; color: var(--muted, #6B6258); letter-spacing: var(--track-tight, -0.02em);
    line-height: var(--lh-copy, 1.4); margin: 0;
  }
  /* The same clock the member surface shows, quieter than the prompt it sits
     under. Shared here rather than in guest.html so both hosts inherit it. */
  .dp-countdown {
    font-size: 14px; color: var(--faint, #8A8079); letter-spacing: var(--track-tight, -0.02em);
    line-height: var(--lh-single, 1.3); margin: 10px 0 0;
  }
  /* The group's seats and how many are left, on ONE line — the design puts
     the count beside the faces, not under them. */
  /* `6103:88966`: five 40x40 seats at a 30 pitch (so 10 of overlap), then 8, then
     the count. Was 34 at a 26 pitch and a 14 gap — a whole group drawn 15%
     smaller than the frame's, on the one row that says how many people this is
     for. */
  .dp-spots-row { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
  .dp-spots { display: flex; }
  .dp-spot {
    width: 40px; height: 40px; border-radius: 50%;
    /* #FAF3EA, matching --bg. The fallback used to be the live value — --paper
       was read here and declared nowhere — and said #FAF3E8 against the member
       page's #FAF3EA. index.html now declares `--paper: var(--bg)`, so in Pi
       this ring follows the scheme and the fallback only serves the guest page. */
    /* `#button/disabled-fill` #BEB09D, which is what the frames draw an empty
       seat in: every one of the four blanks on `6103:88907` and the three on
       `6103:89194` samples that exact value. This was #D8CDBC — 26/29/31 lighter
       per channel and on no token. */
    background: var(--seat-free, #BEB09D); border: 2px solid var(--paper, #FAF3EA);
    margin-left: -10px; display: grid; place-items: center;
    /* `--on-accent`, not a raw `#FFFFFF`: the fill under this letter is a seat
       tint in both schemes, so the label is constant too — the same token, and
       the same reasoning, as the member surface's `.av.taken`. `7607:44887` also
       tracks it at -2%, where this had none. */
    font-size: 18px; font-weight: 500; letter-spacing: var(--track-tight, -0.02em);
    color: var(--on-accent, #FBF6EE);
  }
  .dp-spot:first-child { margin-left: 0; }
  /* Held moves with free, and only because free moved onto its token. No frame
     on this page draws a held seat — an invitee who has not opened their link is
     a Droplets state, not a Figma one — so the STEP is preserved from the build
     (free minus ~31 per channel) rather than read off a swatch that does not
     exist. Left at #B9AE9C it would sit 5 from the new free and the two states
     would be one colour, which is a worse bug than the one being fixed. */
  .dp-spot-held { background: var(--seat-held, #A79B8A); }
  /* 12/1.4 — `7607:44892`. It was 14/1.3, one tier up from the label the frame
     draws, which is why the count read as a peer of the join line under it. */
  /* -1%, not -2%: `Label/XS` tracks at -1% (data/13-tokens.json reads -0.12px on
     12), and this was the one rule on the ramp still carrying the -2% it had
     when it was a 14px Label/S. Size and leading were already taken. */
  .dp-spots-count {
    font-size: 12px; color: var(--muted, #6B6258); letter-spacing: var(--track-body, -0.01em);
    line-height: var(--lh-copy, 1.4); margin: 0; white-space: nowrap;
  }
  .dp-join-line { margin-top: 2px; }
  /* Activity_title (`6573:12319`): the activity's own 50x50 swatch, 8, then its
     name in a 70x25 label — `base/text-secondary` at 18, not the 14 muted this
     used for the whole chip. The swatch colour is the activity's; Droplets' is
     the #EA79A1 the Activities showcase card draws it in. */
  .dp-activity {
    display: inline-flex; align-items: center; gap: 8px;
    font-size: 18px; color: var(--ink-secondary, #655E55); letter-spacing: var(--track-tight, -0.02em);
    line-height: 25px; margin-top: 14px;
  }
  /* The tile carries the activity's own mark. `charcoal50` fixed rather than a
     themed ink, exactly as `PeopleView.activityLeading` does it: the tint under
     it is a literal pastel in both appearances, so an ink that follows the
     scheme would go light on a light tile. 26 in a 50 tile is Pi's own 15-in-36
     ratio, allowing for the 24-box padding this path carries. */
  .dp-activity-swatch {
    flex: none; width: 50px; height: 50px; border-radius: 10px;
    background: var(--activity-swatch, #EA79A1);
    display: grid; place-items: center; color: #131212;
  }
  /* The frame's `BottomCTA` (`6103:88978`) — the foot pushed to the bottom of the
     screen, and only where this view IS the screen (see `joinHtml`).
     The host gives the column its height (`fillToFoot`); a host that does not,
     like the guest page, leaves the foot in the flow, which is what it wants.

     Pushed rather than `position: fixed`, which does not work here: `#app`'s
     children carry the screen-transition animation, and an animation with
     `fill: both` leaves a `transform` on the element FOREVER — so `.dp-join` is
     a containing block and a fixed foot pins to the column, not the viewport.
     It measured 630 in an 852 frame, which looks exactly like no fix at all. */
  /* A column, so "What's Pi?" is a stretched item whose `text-align: center`
     still centres it. As an inline child of a plain block it went hard left. */
  .dp-foot { display: flex; flex-direction: column; }
  .dp-join-pinned .dp-foot { margin-top: auto; padding-top: 14px; }
  /* Its own `margin-top: auto` would fight the foot's for the free space. */
  .dp-join-pinned .dp-whatspi { margin-top: 0; }
  /* ---- the blocker page (`6103:89329`, `6589:31891`) ----------------------
     A full group and a link that is gone are the same page, and it is a PAGE:
     Pi's wordmark at the head, the message block in the body, "What's Pi?" alone
     on a `BottomCTA` at the foot. Built, it was none of that — a tight stack of
     five things against the top edge with 530pt of empty screen under it, the
     one thing there is to tap ending at y=319 of 852.

     Not `position: fixed` and no arithmetic off `100dvh`; both are already
     written up above. The guest page's `.dp-card` is a stretched flex item of a
     `min-height: 100svh` row, so claiming its height is one `flex: 1` and the
     foot lands on the foot with nothing measured. In Pi there is no foot to pin
     — `whatsPi` draws nothing there — so nothing needs the host's help.

     `gap: 0`, because every gap here is one of the frame's own numbers and
     `.dp-join`'s 14 was silently added to each of them. Pi's own band is
     reserved further down, on the head's first child — see `.dp-wordmark`. */
  .dp-blocker { flex: 1; gap: 0; }
  /* The head and the foot are one height, whatever they hold — which is the
     whole of "the message box is centred on the screen". Both frames put
     `Frame 1422157196` on y=426, the 852 frame's exact middle: 293..559 when it
     carries the group's faces and 325..527 when it does not. Equal grow from a
     ZERO basis is what makes the two bands identical, so the space above the box
     always equals the space below it and the box lands on the middle without a
     single number being measured. The head pins its content to the top and the
     foot pins its own to the bottom, so the wordmark keeps y=76 and the chip
     y=213 while the middle floats.

     The skew is the one thing a host has to own up to. Both of ours inset the
     page 20 further at the bottom than at the top (the guest page's 28/48, the
     container's `#app` 20/40), which moves the box's middle down by half that
     unless the head takes it back — so the head's basis is the difference and
     the frames' own numbers come out. A host that insets evenly declares 0. */
  .dp-blocker-head, .dp-blocker-foot {
    display: flex; flex-direction: column; align-items: center;
    flex-grow: 1; flex-shrink: 0;
  }
  .dp-blocker-head { flex-basis: var(--dp-blocker-skew, 20px); justify-content: flex-start; }
  .dp-blocker-foot { flex-basis: 0; justify-content: flex-end; }
  /* `Pi Wordmark` `6573:12336`: 42.4x40, centred, at y=76 of 852, then 97 to the
     chip at 213. Exported from the component rather than approximated — `.dp-pi`
     stands in for the mark at 34px inside a prompt row and does not at 40.

     The band is deliberately NOT added here, and that is round 67's correction.
     It used to be `calc(48px + var(--pi-chrome-top, 0px))`, written when this
     mark drew on the guest surface ONLY — where the band is 0 and the term cost
     nothing. Wei reported the mark missing inside Pi (`6589:31891`), the gate
     that hid it came off, and the term stopped being free: `.dp-blocker-head` is
     `flex-shrink: 0`, so the moment its content is taller than its equal share
     of the free space the band stops being a band and becomes its content — and
     the head and foot being the SAME height is the entire mechanism that puts
     the message box on the frames' y=426. Measured in Chromium at 393x852 with
     `--pi-chrome-top: 66px`: the sum made the head 331 against a 305 share and
     shoved the box's middle to 484, 58 low.

     Nothing here needs to clear that band, because Pi's chrome is in the
     CORNERS — `chromeInsets` is leading 58 / trailing 58 wide — and this mark is
     centred, x=175..217 of 393. They cannot collide horizontally, which is what
     `browser-join-chrome-corners` asserts for every element on this page. So the
     frame's own 48 is right on both hosts, and with it both blocker states land
     on the frames' own numbers inside Pi: the head measures 265, the full group's
     box tops at 293 and the dead link's at 325, and both centre on 426.

     The chip's `:first-child` fallback below still carries the band, because a
     head with no mark above the chip has nothing else to. */
  .dp-wordmark {
    display: block; width: 42px; height: 40px;
    margin: 48px auto 97px;
  }
  /* The frame's own rhythm, not `.dp-center`'s uniform 10: 24 to the faces, 40
     to the sub line, 16 to the button. Both frames measure to it exactly — 266
     tall with the faces and 202 without. The chip-to-headline gap is NOT on this
     list: it is 30 on one frame and 62 on the other, because it is whatever
     centring the box leaves over. */
  .dp-blocker-body { gap: 0; }
  /* 30 is the tightest the frames ever draw chip-to-headline (`6103:89329`; the
     dead link's is 62), so it is the head's floor rather than its rhythm. It is
     slack in a band that has room and the only gap there is in one that has
     none — which is the container today, where nothing gives this page the
     screen's height. */
  .dp-blocker-head > .dp-activity, .dp-blocker-head > .dp-mark {
    margin-top: 0; margin-bottom: 30px;
  }
  /* The chip's own fallback for a head with no wordmark above it.
     It used to be the IN-PI case — `piWordmark` was gated on `inPi` and drew
     nothing there, so the chip was `:first-child` and had to clear the chrome
     band itself. Round 67 removed that gate (Wei: the mark is missing on
     `6589:31891`), so on both hosts the wordmark is now the first child and
     carries the band. Kept, not deleted: `:first-child` is exactly the condition
     it describes, and it is what a host that stops drawing the mark falls back
     to rather than putting the chip under Pi's ←. */
  .dp-blocker-head > .dp-activity:first-child,
  .dp-blocker-head > .dp-mark:first-child {
    margin-top: calc(14px + var(--pi-chrome-top, 0px));
  }
  /* `Headings/M` — Grenette 32/1.1, not the join view's 24/1.42. Read off
     `6589:31940` and `6103:89468`, both of which are 345x70, i.e. two lines of
     35.2. At 24 the dead link's sentence measured 344 and fitted one line, which
     read as a pixel of wrap luck; at the frame's own 32 it measures 458 and the
     frame's break is the only thing it can do. The blocker's headline is a
     bigger type than the join view's, and this is the one place that matters. */
  .dp-blocker-body > .dp-title { font-size: 32px; line-height: 1.1; }
  .dp-blocker-body > .dp-spots-row { margin-top: 24px; }
  /* `Headings/XS` — 18/1.1 in the display face, not 14 in the body one.
     `6589:31955` is 343x20 and `6103:89483` matches; at 14 DM Sans this line
     measured 277 and sat two type sizes below the sentence above it.
     Its ink is `#base/text-accent` too — `6589:31936` sets BOTH its text nodes
     in that token, so on this screen the sub line is the same ink as the
     headline rather than the muted grey `.dp-sub` carries elsewhere. Scoped,
     because the join view's own sub line is NOT: `6103:89327` is
     `#base/text-secondary` #655e55, which is what `--muted` already draws. Two
     surfaces, two inks, and only one of them changes. F04-12. */
  .dp-blocker-body > .dp-sub {
    margin-top: 40px;
    font-family: var(--font-heading, ui-serif, Georgia, "Iowan Old Style", serif);
    font-size: 18px; font-weight: 400; line-height: 1.1;
    letter-spacing: var(--track-body, -0.01em);
    color: var(--color-text-accent, #131212);
  }
  .dp-blocker-body > .dp-btn { margin-top: 16px; }
  /* `6589:31958` is at y=751.7 of 852 and carries only this. It was 10px under
     the button. Its own `margin-top: auto` would fight the band it now sits in. */
  .dp-blocker-foot > .dp-whatspi { margin-top: 0; padding-top: 26px; }
  /* One tan section: Pi's prompt, and whatever is behind the lock nested in
     it. Two sibling cards read as two subjects; the design draws one. */
  .dp-section { background: var(--tan, #F3EADE); border-radius: 18px; padding: 16px; }
  /* 8, not 11 — `7607:44879`. With `.dp-pi` at the frame's 36 below, this is the
     same 44 the member surface's `.prompt-row` now makes. `frame-metrics` used to
     record both as a known, untaken gap; they are taken together here. */
  .dp-prompt-row { display: flex; gap: 8px; align-items: flex-start; }
  /* Pi's own mark, marking the prompt as Pi's — the ARTWORK now, not a letter
     set in the heading face. `7607:44879` puts an `App Icon` here, and the App
     Icon is Pi's own app logo: the letterform filled with Pi's green texture,
     which no font and no flat token can spell. Taken from the iOS app the user
     named — `Pi_applogo.icon/Assets/Mask group.png` in apple-pudding — trimmed
     and rescaled into `/img/pi-app-icon.png`, NOT traced: it is a photograph
     inside a letter and a vector of it would be a different mark.
     24x20 in the 36 tile is the frame's own ink (x6..29, y8..27 of the 36),
     measured off `7607:44879` rather than assumed from the tile.
     `/img/pi-wordmark.svg` stays what it is — the flat WORDMARK, which the
     blocker page and the App Store card both draw and which the frames name
     separately. Two marks, because the design has two. */
  .dp-pi {
    flex: none; width: 36px; height: 36px; border-radius: 10px;
    background: var(--tile, #FBF6EE) url("/img/pi-app-icon.png") center / 24px 20px no-repeat;
  }
  /* heading_xs / body_alt_m: 18, Grenette, and Pi's line height rather than the
     1.35 that set every prompt tighter than any heading in the app around it. */
  .dp-prompt-text {
    font-family: var(--font-heading, ui-serif, Georgia, "Iowan Old Style", serif);
    font-size: 18px; font-weight: 400; letter-spacing: var(--track-body, -0.01em);
    line-height: var(--lh-heading, 1.42); color: var(--ink, #3E3A35); padding-top: 4px;
  }
  /* Nested in the section, so it is white against the tan rather than another
     card on the page.
     `6103:88964` is 313x139 and every number here is read off it. Its own inset
     is 16 both ways (Badge and the hidden answer are both x=16, and 16 + 25 + 16
     + 66 + 16 = 139 exactly), where this drew 22/15; and it sits 16 under the
     prompt row (`6103:89125` ends at y=68, the placeholder starts at 84), where
     this drew 14. The section then measures the frame's own 239: 16 + 52 + 16 +
     139 + 16.
     `box-sizing`, declared here rather than relied on: `index.html` sets it
     globally and `guest.html` does not, so `min-height` meant the border box in
     one host and the content box in the other — a 2px drift between two surfaces
     that are meant to be one screen. */
  .dp-locked {
    position: relative; box-sizing: border-box;
    background: var(--field, #FFFFFF); border: 1px solid var(--border, #E4DACA);
    border-radius: 10px; padding: 16px; overflow: hidden; min-height: 139px;
    margin-top: 16px;
  }
  /* Out of the flow, because the card's height is the frame's number and not
     whatever the skeleton behind the lock happens to add up to. That is what the
     frame does too: `Blur` is its own 313x139 layer over the placeholder, and the
     placeholder clips a 56-tall Button at y=113 without growing for it. */
  .dp-locked-blur {
    position: absolute; inset: 16px 16px auto;
    filter: blur(7px); opacity: 0.5;
  }
  .dp-locked-line { height: 13px; border-radius: 6px; background: var(--skeleton, #C9BCA8); margin-bottom: 9px; }
  .dp-locked-line-short { width: 62%; }
  /* The shape of the photo that wasn't sent. Neutral, not a colour: blurred
     orange read as a warning smear behind the lock, and the design's
     placeholder is the same grey as the lines above it. */
  .dp-locked-photo {
    height: 70px; border-radius: 10px; margin-top: 8px; background: var(--skeleton, #C9BCA8);
  }
  /* Plain sans, per the design — the copy is a plain statement of the rule,
     not the app's voice. */
  /* `6000:54937`: 12px in `#base/text-accent`, gapped 8 (`6000:54976`) — where
     this drew 14px of `--ink` (`#base/contrast-inverted`) at a 3px gap. The
     member surface's twin, `.locked .over`, lands on the same three numbers, so
     the one card two surfaces draw stays one card. */
  .dp-locked-overlay {
    position: absolute; inset: 0; display: flex; flex-direction: column;
    align-items: center; justify-content: center; gap: 8px; padding: 0 18px;
    font-size: 12px; letter-spacing: var(--track-body, -0.01em);
    line-height: var(--lh-copy, 1.4); color: var(--color-text-accent, #131212);
    text-align: center;
  }
  .dp-lock { color: var(--ink-deep, #1A4631); display: inline-flex; }
  /* action_s: 16. Every button in Pi is DM Sans Medium at 16.
     56 tall, which is `Button/Large/Primary`'s own height on every frame that
     draws one (`6103:89484`, `6589:31956`) and `--pi-control-lg` in the design
     system, read there off bakery's `h-14`. It used to be 50.8 — 15 + 20.8 + 15,
     i.e. whatever the padding and the line box happened to add up to — and that
     5.2 was the whole residual F04-7's centred blocker measured: each node ran
     2.6 late because centring a box that is 5.2 short splits the difference.
     `min-height` and not `height`, so a label that wraps grows the pill instead
     of spilling out of it; the flex box is what centres the label once the
     padding no longer decides the height. F04-12. */
  .dp-btn {
    width: 100%; min-height: 56px;
    display: flex; align-items: center; justify-content: center;
    background: var(--ink-deep, #1A4631); color: var(--on-accent, #FBF6EE); border: none; padding: 8px 15px;
    border-radius: 100px; font-size: 16px; font-weight: 500; font-family: inherit;
    letter-spacing: var(--track-body, -0.01em); line-height: var(--lh-single, 1.3);
    cursor: pointer; margin-top: 4px; text-align: center; text-decoration: none;
  }
  .dp-btn:disabled { opacity: 0.45; cursor: default; }
  /* The Join button had no pressed state either, and it is the only button on
     the screen. Same pair as the member surface's `.btn`: Button.accent_default
     to Button.accent_tap, instant down and eased up. `.pressed` is set by
     `app.js` in Pi; `:active` covers the guest page's own browser. */
  .dp-btn { transition: background-color .2s ease; }
  .dp-btn:active, .dp-btn.pressed {
    background: var(--ink-deep-tap, #21322A); transition-duration: 0s;
  }
  .dp-btn:disabled:active, .dp-btn:disabled.pressed { background: var(--ink-deep, #1A4631); }
  @media (prefers-reduced-motion: reduce) { .dp-btn { transition: none; } }
  /* Parked near the foot of the frame, centred, accent green, undecorated.
     18, not 14 — the frames' own ink box is 84x13 at x155..238, identical on
     `6103:88907`, `6103:89329` and `6589:31891`. Measured against the shipped
     DM Sans Medium binary: "What's Pi?" sets 65.5 at 14/-2% (the build renders
     64.3) and 85.8 at 18/-2%, so the same 2% model bias puts 18 on the frame's
     84 exactly, and 16 and 17 land 10 and 7 short. Tracking stays at -2%, which
     is what that fit picks and what `Body/M` declares; the leading is not
     recoverable from one line's ink and is left alone, so the join line -> link
     gap this rule already lands (frame 29, built 28.6) does not move. */
  .dp-whatspi {
    margin-top: auto; padding-top: 26px; text-align: center; font-size: 18px;
    letter-spacing: var(--track-tight, -0.02em); line-height: var(--lh-single, 1.3);
    color: var(--accent, #038247); text-decoration: none;
  }

  /* ---- Pi's loading dots (PI-9134) ----------------------------------------
     "Loading page should be the 3 dot animation" — and the 3 dot animation is
     an object that already exists, so this is a port and not an invention:
     `Pi_loading_dots.json`, the Lottie `PILoadingStateAnimation` plays
     (apple-pudding, ViewComponents/Resources). Read off that file: 30fps over
     38 frames (1267ms), three dots on a 1080 canvas at x 220/540/860 with a
     198 diameter — so the gap is 0.61 of a dot — each five frames (167ms)
     behind the one before, rising 280/198 = 1.41 diameters and squashing on
     the way out and back (110/90 at t+6, 80/120 at t+21, 120/80 at t+23).
     The keyframes below are those frames as percentages of the loop.
     `currentColor` so the dots take the ink of whatever screen they are on.

     TWO keyframe sets, on `translate` and `scale` rather than one on
     `transform`, because the Lottie is two independent tracks: `ks.p` moves the
     dot on frames 5/14/21 and `ks.s` squashes it on 0/6/10/12/21/23/28, and
     each segment of each carries its OWN Bezier. Merged into one `transform`
     list they have to share a timing function per segment, and the first port
     shared `linear` — measured against the Lottie's own maths that was up to
     7.16px out on a 14px rise (at frame 6, where `linear` had the dot 8px up
     and the Bezier has it 0.84px up), and 0.134 out on scale. Split, every
     segment gets the file's own `i`/`o` control points and the error is zero:

       rise  f5 -> f14  cubic-bezier(.333, 0, 0, 1)   — slow off the floor, then
                                                        it arrives and hangs
       fall  f14 -> f21 cubic-bezier(.9, 0, .99, 1)   — holds at the apex and
                                                        drops like gravity: at
                                                        f20, one frame from the
                                                        floor, it is still 6.4px
                                                        up. `linear` gave a
                                                        constant 2px per frame,
                                                        which is the difference
                                                        between a bounce and a
                                                        metronome.

     `transform-origin: 50% 100%`, because the Lottie's anchor is `a = [0,
     99.137, 0]` and the shape's own path runs y -99.137..+99.137 — so the
     anchor is the dot's BOTTOM vertex, not its centre, and every squash pivots
     on the floor. Centred, `scale(1.2, .8)` at the impact frame lifted the
     dot's baseline 1px off the ground on the one frame the eye is on it.

     Individual `translate`/`scale` (Transforms Level 2) rather than nesting a
     `::before` to get two transforms: one node, one `transform-origin`, and no
     way for the two tracks to be scheduled a frame apart. Safari 14.1 / iOS
     14.5, well under anything that runs Pi or reaches the guest page.

     HERE, not in `index.html`'s inline `<style>`, because both surfaces wait.
     The guest page's first paint was still a 14px grey `Loading…` sentence —
     the exact screen PI-9134 replaced inside Pi — and it cannot reach the
     member page's inline rules. Both hosts link this file, so one copy is the
     only arrangement in which the two cannot drift apart again.

     The geometry is custom properties rather than literals so that a control
     waiting on a write can carry the SAME three dots at a smaller size without
     a second set of keyframes to keep in step with this one. The values on
     `.dots` below are the Lottie's own, unchanged: 10px dots, 6.1px gap, and a
     -14px rise (280/198 of a 10px dot is 14.14; 14 is what the native side's
     own parity arithmetic is written against, and a tenth of a pixel is not
     worth two files disagreeing over). */
  .loading {
    display: grid; place-items: center; gap: 0;
    min-height: 40vh; color: var(--color-text-accent, #131212);
  }
  .dots {
    --dot: 10px; --dot-gap: 6.1px; --dot-row: 24px; --dot-peak: -14px;
    display: flex; align-items: center; gap: var(--dot-gap); height: var(--dot-row);
  }
  .dots i {
    width: var(--dot); height: var(--dot); border-radius: 50%; background: currentColor;
    transform-origin: 50% 100%;
    animation: pi-dots 1267ms infinite, pi-dots-squash 1267ms infinite;
  }
  /* 5 and 10 frames at 30fps, which is the Lottie's own stagger. Written TWICE
     rather than once: a shorter list is repeated to fill the longer `animation-
     name`, so one value would behave identically — but it would also read as if
     only the first track were being staggered, and the two tracks are one dot
     and must not drift. */
  .dots i:nth-child(2) { animation-delay: 167ms, 167ms; }
  .dots i:nth-child(3) { animation-delay: 334ms, 334ms; }
  /* `ks.p.y`: 639.137 -> 359.137 -> 639.137 on frames 5, 14, 21 of 38. The two
     keyframes that only hold a value carry no timing function because both ends
     of those segments are the same number. */
  @keyframes pi-dots {
    0%,      13.158% { translate: 0 0; animation-timing-function: cubic-bezier(.333, 0, 0, 1); }
    36.842%          { translate: 0 var(--dot-peak); animation-timing-function: cubic-bezier(.9, 0, .99, 1); }
    55.263%, 100%    { translate: 0 0; }
  }
  /* `ks.s`: 100 -> 110/90 -> 100 -> 100 -> 80/120 -> 120/80 -> 100 on frames
     0, 6, 10, 12, 21, 23, 28 of 38. Frames 10 and 12 hold the same value and are
     both kept: the pause between the landing stretch starting and the recovery
     is what the file says, and collapsing them would start the stretch 67ms early. */
  @keyframes pi-dots-squash {
    0%               { scale: 1 1;     animation-timing-function: cubic-bezier(.333, 0, .1, 1); }
    15.789%          { scale: 1.1 .9;  animation-timing-function: cubic-bezier(.9, 0, .9, 1); }
    26.316%          { scale: 1 1;     animation-timing-function: cubic-bezier(.114, 0, .1, 1); }
    31.579%          { scale: 1 1;     animation-timing-function: cubic-bezier(1, 0, .846, 1); }
    55.263%          { scale: .8 1.2;  animation-timing-function: cubic-bezier(.333, 0, .1, 1); }
    60.526%          { scale: 1.2 .8;  animation-timing-function: cubic-bezier(.333, 0, 0, 1); }
    73.684%, 100%    { scale: 1 1; }
  }
  /* The same three dots, inside a line rather than owning a screen: a button
     waiting on its write, or the heading row over a list being re-read.
     `inline-flex` because the host is a `.btn`'s flex centre or a label row,
     neither of which wants a block. 0.8 of the full size for a control, 0.6 for
     a 14px heading row — every number below is that fraction of the ones above,
     so the motion is the same motion and not a second one that looks like it.
     `--dot-row` is exactly the 14px the ring it replaces measured, so a heading
     row does not change height when the wait appears. */
  .dots.inline {
    display: inline-flex; vertical-align: middle;
    --dot: 8px; --dot-gap: 4.88px; --dot-row: 19.2px; --dot-peak: -11.2px;
  }
  .dots.inline.tiny {
    --dot: 6px; --dot-gap: 3.66px; --dot-row: 14px; --dot-peak: -8.4px;
  }
  /* Three still dots, and the word beside them, still say "working" without
     anything moving. After the base rule, not before: a media query adds no
     specificity, so the order in this file is the only thing that decides it.

     No fill-mode anywhere above, and none wanted: both keyframe sets run
     forever, so neither has a final keyframe to pin — and every caller takes the
     NODE out of the document when the wait ends rather than hiding it, which is
     the other half of the same rule. A dots element merely `display: none`d
     would animate for as long as the page lived.

     `animation: none` is the shorthand, so it takes both names down together —
     a longhand `animation-name: none` would leave `translate` and `scale` on
     two separate schedules to stop on different frames. */
  @media (prefers-reduced-motion: reduce) {
    .dots i { animation: none; }
  }
  /* The sentence beside the dots is for a screen reader, and only for one: the
     dots say "working" to everybody who can see them, and `PLACEHOLDERS` in
     `app.js` matches on this exact text to know that a screen is a placeholder
     rather than an arrival — so it has to be IN the document, not an
     `aria-label`. In a control it does the second job too: it is the button's
     accessible name for as long as the write is in flight. */
  .sr-only {
    position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
    overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
  }
