/* Block Party — waitlist. Ink on warm paper, one meaning-scoped accent.
   Source of truth: bp-control-room/.claude/skills/block-party-brand/SKILL.md */

/* Self-hosted so no third-party origin can block or stall the page.

   Switzer (Indian Type Foundry, ITF Free Font License) is the page's one
   webfont -- read off Fixa's own @font-face rules at fixaplan.com rather than
   guessed from the letterforms. Latin subset only.

   It replaced Jost, which was the brand display face. That is a deliberate
   override: the brief was to match the reference opening exactly, and the
   typeface is the single largest part of why it did not.

   Instrument Serif was the second face and is gone. Its last serif setting
   was brought in line with the rest of the page, and with nothing left to set,
   three @font-face blocks
   and 46KB of woff2 went with it. It is one revert away in git if the pairing
   is ever wanted back. */
@font-face {
  font-family: "Switzer";
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url("fonts/switzer-500-latin.woff2") format("woff2");
}

@font-face {
  font-family: "Switzer";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("fonts/switzer-600-latin.woff2") format("woff2");
}

/* THE HEADLINE'S FALLBACK, AND ONLY EVER THE FALLBACK. Jost (SIL OFL, latin
   subset, 10.3KB) is the free Futura-alike, and it exists here for the machines
   that have no Futura of their own — Android and Windows, where --font-headline
   was otherwise landing on the platform sans and the opening line stopped being
   the town's face at all. Apple ships a real Futura and gets it: the stack asks
   for "Futura" first and only falls through to this.

   Not preloaded, deliberately, and it is the one font file here that is not.
   The devices that need it are exactly the devices that do not have the face
   the page prefers, so preloading would cost every iPhone in St. Joseph 10KB
   for a file it will never paint. font-display:swap covers the rest. */
@font-face {
  font-family: "Jost";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("fonts/jost-700-latin.woff2") format("woff2");
}


/* THE SECOND FACE, AND IT SETS EXACTLY ONE LINE. Shadows Into Light (SIL OFL)
   is the closing scene's handwriting, chosen off a photograph of Jesse's own
   note rather than picked from a list: fine-liner weight, uppercase, narrow
   letters on a slightly restless baseline. The heavier hands (Rock Salt, Gochi
   Hand) read as marker and the slanted ones (Caveat, Kalam) as brush; neither
   is a pen on paper, which is the only thing this line is imitating.

   Latin subset only, self-hosted next to Switzer for the same reason — no
   third-party origin gets to stall this page. 16KB, spent on one sentence,
   which is affordable precisely because it is one sentence. Do not reach for it
   again: a second use turns a written note into a font. */
@font-face {
  font-family: "Shadows Into Light";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("fonts/shadows-into-light-400-latin.woff2") format("woff2");
}


:root {
  /* The brand is a single light ink-on-paper system — BlockPartyColor.swift
     ships no dark variant, so there is no dark palette to honour here. Declaring
     it stops the browser auto-darkening form controls and the scrollbar on a
     dark-mode device, which is what produces a half-inverted page. This is a
     deliberate single-look commitment, not a missing theme. */
  color-scheme: light;

  /* No synthetic weights or slants. @font-face ships Switzer 500 and 600 only,
     and the sheet title asked for 700 for months — the browser was free to
     smear a fake bold out of the 600 and nobody could see that it had. With this
     off, a weight with no face falls back to the nearest real cut instead, so
     the mistake shows up as a wrong weight rather than as a bad rendering. */
  font-synthesis-weight: none;
  font-synthesis-style: none;

  --ink:           #111111;  /* headings, body, wordmark, input text */
  /* --ink, one step toward the paper. The page's hovers all DARKEN their ground
     (filter: brightness(0.92)), which has nowhere to go on a control that is
     already --ink — so the sheet's inverted quiet button lifts instead. It is a
     token rather than the raw #2A2A2A it used to be for the same reason as
     everything else here: the hover has to move when --ink does. */
  --ink-lift:      #2A2A2A;  /* hover on an --ink ground; --paper is 13.7:1 */
  --paper:         #FFFFFF;  /* page background — plain white, no grain */
  /* THE OPENING PLATE, LIGHTENED TWICE ON JESSE'S INSTRUCTION AND NOW A SHADE
     OFF PAPER — he supplied the swatch. #7A7A7A carried white type, #AFAFAF
     forced it back to ink, and at #E5E5E5 the plate stops making demands at
     all: every colour the page already uses lands on it the way it lands on
     --paper, so the section needs no type rules of its own and the two
     override tokens this plate used to carry (--on-grey-mute, then
     --on-grey-accent, a burnt orange for the creed's phrases) are deleted
     rather than re-tuned. Measured here:
       --ink            14.99   the creed's phrases and the calendar title
       --ink-secondary   4.29   the creed's prose — the creed is large text
                                (22-38px at 600), where the floor is 3:1
     The plate is now a change of PAPER STOCK rather than a change of polarity,
     which is what the closing band already does with --cream. Darken it again
     and the type rules have to come back with it. */
  --band-grey:     #E5E5E5;  /* the grey plate under the opening */
  --surface:       #FFFFFF;  /* input fills, and the survey band */
  /* 6A, not the 6E it was. --cream deepened when the grain went page-wide (see
     below), and on the new ground #6E6E6E computes to 4.353:1 — under the 4.5
     floor for the ask body and the note under the button, both of which sit on
     it. #6A6A6A is 4.617 there, 5.172 on --paper and 5.409 on --surface, so one
     value still clears every ground this token lands on. Four levels darker is
     not visible on any single element; the floor is. */
  --ink-secondary: #6A6A6A;  /* sub copy, labels, footer */
  --hairline:      #E7E7E4;  /* decorative rules only — the footer divider */
  /* Same warm neutral as --hairline, darkened until it clears WCAG 1.4.11's
     3:1 for a UI component boundary: 3.14:1 on --paper, 3.29:1 on --surface.
     --hairline itself is 1.18:1, which is invisible to low-vision users and
     is what made the three fields hard to find on the page. */
  --hairline-strong: #8E8E8B; /* 1px borders on inputs and other controls */
  --fill:          #F1F1EF;  /* disabled button */

  /* The closing waitlist band's ground: a shade darker than --paper so the page
     reads as changing paper stock, which is what replaced the card that used to
     frame the ask. The band now carries a scanned paper texture on top of this
     (paper-grain-tile.webp, see .join); the token is the flat colour underneath
     it, which is what shows while the image loads or if it never arrives.

     IT IS THE SCAN'S GRAIN, NOT THE SCAN'S TONE. The scan's mean is #D7D2CB — a
     mid grey, far under anything this page uses as a ground — and its native
     tone was tried first and dropped, not by eye: at #D7D2CB --ink-secondary
     falls to 3.70:1 and the input border to 2.62:1, so every piece of type on
     the band would have had to be re-derived to keep the floor. So the sheet is
     high-passed instead — a heavy blur subtracted, leaving only the fibre — and
     the fibre is then laid on this colour at 70% of its native amplitude.

     THE HIGH PASS IS NOT COSMETIC. .join draws the texture with cover, so every
     viewport width crops a different part of the sheet; the scan's own lighting
     gradient would have made the ground's contrast depend on the window. With
     the gradient gone the crops agree: the ground measured off the rendered
     page reads 4.63:1 at 1440 and 4.62:1 at 402.

     CHOSEN AGAINST THE CONTRAST FLOOR. On this ground --ink is 17.14:1 and
     --ink-secondary is 4.63:1, so the labels and the note under the button
     still clear 4.5:1 with room — both a shade better than the #F7F2E4 cream
     this replaced (16.88:1 and 4.56:1), so nothing below is now tighter than it
     was. All of it measured off the rendered page rather than off the source
     file, and the grain survives the webp round trip unchanged (luma p1 235,
     p50 244, p99 252).

     The grain's own darkest 1% of pixels reaches 4.3:1. That is deliberate and
     it is not the number that governs: those are single fibres one or two
     pixels across, and what a glyph stroke sits on is the local average, which
     is the 4.62:1 above. Flattening the grain until every last fibre cleared
     4.5:1 would need a spread of about three luma levels, which is a flat fill
     with extra bytes.

     The catch is the input borders — the ones the no-JS path still renders on
     this band. A white input on it is only 1.05:1, so the FILL cannot show
     where a field is; the border carries the whole boundary on its own, and
     --hairline-strong (#8E8E8B) drops to 2.94:1 here, under the 3:1 WCAG 1.4.11
     needs for a control edge. Hence --hairline-join below, which is the same
     warm neutral darkened until it clears on BOTH grounds it touches: 3.61:1
     against the white fill inside it and 3.27:1 against the band outside it. */
  /* DEEPENED, because the grain is no longer what makes this band different.
     At #F5F4F0 the band sat 2.03 L* under --paper — below the just-noticeable
     difference for a large flat field — so the whole "this is another stock"
     reading was carried by the texture, and the fallback this comment claims
     ("what it stays as if the request fails") was not true in practice: with
     the image gone the band was very nearly invisible. Now the page carries
     paper of its own (see .page), so the band has to differ by TONE as well or
     it stops differing at all. #EFEDE6 is 4.47 L* under --paper, which reads as
     a heavier sheet rather than as a shadow.

     Everything measured against it was re-derived: --ink 16.12:1, --ink-secondary
     4.617:1 on the new #6A6A6A, --hairline-join 3.078:1 (unchanged — it still
     clears 1.4.11 without moving), --ink on --primrose untouched at 12.74:1
     because the button carries its own ground. */
  --cream:         #EFEDE6;
  --hairline-join: #878783;

  /* Tints. BACKGROUNDS ONLY — never a text colour, never a control fill. That
     restriction is what keeps --accent the single saturated thing on the page,
     so the submit button stays unmistakably the one action.

     The hues are not invented: warm is --accent, and sky and moss are the app's
     own #A8D8EE and #D9E8C8. They map onto the cards the way the app's map
     already reads — green for the map, sky for the calendar, warm for Today —
     which is the same cartography carve-out BlockPartyColor documents.

     Each is mixed toward white until --ink-secondary clears 4.63:1 on it. That
     is deliberately ABOVE the 4.51:1 the old flat grey managed, so adding
     colour raised the floor rather than lowering it. */
  --tint-warm:     #FFF1EE;
  --tint-sky:      #EAF6FB;
  --tint-moss:     #EFF6E8;

  /* Glyph tones, one per tint. Darkened until each clears 4.5:1 on its own
     card — decorative marks only owe 3:1, so this is headroom, not the bar. */
  --glyph-warm:    #BB4B30;
  --glyph-sky:     #2A6F94;
  --glyph-moss:    #4A6E38;

  /* --glyph-moss taken down until --paper clears 7.25:1 on it, which leaves
     room to mute the captions to 0.82 and still hold 5.53:1. The one filled
     panel on the page.

     Green, and DARK rather than saturated. Saturation is spoken for: the tint
     block above reserves it so --accent reads as the single loud thing and the
     submit button stays unmistakably the one action. A bright panel would take
     that away; a dark one takes value instead, which nothing else is using.
     It is also the hero photograph's own colour — the fields and the treeline
     that fill the top half of the frame. */
  --moss-deep:     #3D5C2E;

  /* THE SAME GREEN, LIFTED, AND IT EXISTS FOR iOS SAFARI RATHER THAN FOR THE
     EYE. Safari 26 tints its status bar and its bottom bar with a colour it
     SAMPLES off the page — theme-color is ignored, verified on iOS 26.5 with
     the meta set to magenta and the bars still neutral — and what it samples
     is whatever fixed or sticky element covers the top of the window. On this
     page that is .scale-heading's pinned band, so the whole phone went green
     and STAYED green after the plate had scrolled away: the sampled colour is
     latched, not live. That is the "green bars in Safari" report.

     The sampler only adopts a fill it reads as ONE flat colour, so the ground
     under the band is a shallow ramp between these two greens instead. The
     tolerance was measured on the simulator, same page, same scroll, one
     variable at a time:

         flat --moss-deep                    bars green
         ramp of 4/255 (a white color-mix)   bars green
         2px stripes of 5/255                bars green
         98% alpha instead of opaque         bars green
         ramp to a 95% white color-mix       bars green
         RAMP TO THIS (+7 R, +9 G, +1 B)     bars neutral

     A white mix of the same magnitude does NOT work — it lifts blue hardest and
     the sampler kept reading through it. What defeats it is the small shift
     toward yellow-green, which is why this is a hex and not a color-mix.

     2.9 L* off --moss-deep, under this file's own 4.47 L* "plainly visible"
     line, spread as a smooth ramp across the whole width — the plate still
     reads as one flat green. */
  --moss-lift:     #44652F;

  /* THE GROUND UNDER EVERYTHING AFTER THE GREEN PLATE, AND IT IS A CHOSEN
     COLOUR RATHER THAN A SAMPLED ONE. It started as the Joetown roundel's own
     teal #72C5B6: the crest is an orange sky over a teal river, the page
     already spends that orange on the Join buttons, and the ground was going to
     finish the quote. Jesse saw it rendered and it read too green.

     What is true now, and what DESIGN.md says: --accent is still the crest's
     orange, and this is a river blue picked to sit under it. Do not write that
     it is the crest's. The retired teal is not a "more correct" value to
     restore.

     ONE STEP DEEPER THAN THE SWATCH, AND THE STEP IS WORTH A NUMBER. The ask
     sits on a white postcard because --accent computes 1.81:1 here and a
     control's boundary owes 3:1 — so the postcard's own separation from the
     ground is load-bearing, and separation is exactly what moves when the
     ground lightens:

         #72C5B6  the retired teal    white card 2.02:1
         #87CEEB  the swatch sent     white card 1.74:1
         #7BC4E2  THIS                white card 1.94:1
         #74BEDE  one step further    white card 2.07:1

     1.94 is teal parity within 0.08 and still plainly the same sky blue.
     --ink is 9.74:1 on it. L* 75.56. */
  --river:         #7BC4E2;

  /* THE SAME BLUE, LIFTED, AND IT EXISTS FOR iOS SAFARI RATHER THAN FOR THE
     EYE — the construction --moss-lift documents above, for the same reason.
     On a phone the river is taller than the viewport, so at the bottom of the
     page it covers the top of the window, and Safari 26 samples what covers the
     top of the window and latches it.

     THE SHIFT WAS EXPECTED TO FAIL HERE AND DID NOT. --moss-lift's table
     proves +7 R, +9 G, +1 B beats the sampler on a GREEN, and records that a
     white color-mix of the same magnitude does NOT — it lifts blue hardest and
     the sampler reads through it. On a BLUE base that same shift desaturates
     toward cyan-white, which is nearer the failing case than the working one,
     so it was carried over as provisional and tested rather than assumed.

     Simulator, iPhone Air, iOS 26.5, Safari, page served over http:

         ramp to this (+7 R, +9 G, +1 B)     bars neutral at the bottom
                                             bars neutral scrolled back to top
                                             (no latch on the return trip)

     One row, because the first value passed. If a future change to --river
     moves it far enough to bring the bars back, the moss note's own table is
     the procedure: one variable at a time, and try the shift the OTHER way
     before trying more of the same.

     2.87 L* off --river, under this file's own 4.47 "plainly visible" line, so
     the river still reads as one flat colour. */
  --river-lift:    #82CDE3;

  /* Secondary text on the river, and with --ink the only text colour that goes
     there. --ink-secondary computes 2.79:1 on --river, which is not a near miss
     — it is the footer line being unreadable — so the river carries its own.

     5.20:1 on --river, which is the ratio the retired teal ground gave at
     #1B4640: the value was searched for that number rather than picked, so the
     footer line keeps exactly the weight it had. 9.63:1 on --paper, so one
     value clears both grounds the way --ink-secondary does. */
  --ink-river:     #1B4757;

  /* The footer rule where it crosses the river. --hairline (#E7E7E4) is a warm
     near-white and on a saturated ground it reads as a scratch rather than as a
     rule. This is the page's own ink at 18%, compositing to #68A4BC for 1.42:1
     — decorative, the same class --hairline's 1.18:1 is in. A rule is not a
     boundary and owes no 3:1. */
  --river-hairline: rgba(17, 17, 17, 0.18);

  /* The mark's orange, sampled from the "bp" letterforms in AppIcon.png (median
     of 212k letterform pixels). This is a DELIBERATE OVERRIDE, made on Jesse's
     instruction: the block-party-brand skill scopes the accent to plum #8E3B6B
     and lists the coral ramp and "any orange as UI chrome" as retired. He asked
     for the logo colour on the web page, so the page follows the icon rather
     than the iOS app — at the icon's exact hue, though no longer at its exact
     value, for the reason set out below.

     Meaning-scoped exactly as before: the submit button is this page's only
     primary CTA and nothing else takes it.

     THE LABEL IS WHITE, AND THAT IS WHY THIS IS NOT THE ICON'S HEX. It used to
     be the other way round: the fill was the icon's #FD6541 and the label went
     dark, because white on that orange is 2.95:1 against the 4.5:1 that 16px
     600-weight button text owes. Jesse asked for no black letters in the
     orange, so the brand moves instead of the label.

     The value below is not a new colour, it is the SAME ONE DARKER. All three
     linear-light channels are scaled by one factor, which leaves the
     chromaticity — the hue and the saturation — exactly the icon's and lowers
     only the luminance, to the point where white clears the bar. Picking a
     "nice darker orange" by eye would have shifted the hue; this cannot.

     4.60:1 rather than the 4.51:1 that would just scrape it, the same headroom
     the tint block above gives itself. #C94F32 is where it lands at exactly
     4.5:1, and the extra two points of darkness cost nothing visible.

     THE SOURCE MOVED FROM THE APP ICON TO THE TOWN'S OWN CREST, on Jesse's
     instruction: the Joetown roundel — orange sky over a teal river, the same
     mark the water tower carries — is what St. Joseph already recognises, and
     the page borrows the town's colour rather than the app's. The orange in
     that mark is #E2732F.

     THE SCALE IS THE SAME ONE, THE STOP IS NOT. #E2732F is 3.12:1 under white,
     so the same single linear-light scale is applied to all three channels —
     holding the crest's hue and saturation exactly and lowering only its value
     — but it now stops at 3.51:1 rather than at the 4.5:1 the paragraph above
     argues for. That is Jesse's call, made after seeing 4.62:1 on the page:
     the compliant value reads brown beside the crest it is meant to quote, and
     he asked for the orange to be brighter with the label left white.

     SO THIS IS THE PAGE'S ONE KNOWN AA EXCEPTION, recorded rather than
     quietly taken. White on #D46C2C is 3.51:1 against the 4.5:1 that 16px
     600-weight text owes — it clears the 3:1 bar for large text and for
     non-text contrast, so the control's shape and its focus ring are fine and
     it is the LABEL that is under. Everything else on the page still computes
     over 4.5. Two ways out if it ever has to comply: a near-black label
     (6.05:1 on the full crest orange, which is the option Jesse turned down),
     or a 19px+ 600 label, which is large text and would pass at this value.
     Do not "fix" this by darkening the token — that is the change that was
     already reverted once. */
  --accent:        #D46C2C;  /* the crest's orange, one stop down from the mark */

  --on-accent:     #FFFFFF;  /* button label on --accent, 3.51:1 — see above */

  /* THE FORM'S PURPLE, and the one colour on this page that is not the page's.
     It is Google Forms', worn by both anchors to the survey so the control
     matches the thing it opens. It was a literal inside .survey-cta until the
     confirmation's second ask became a button too — at four occurrences across
     two rules it is a number waiting to drift, which is the same argument the
     easings are held to.

     White on it is 4.586:1, so unlike --accent this one clears 4.5 at any size
     and needs no note excusing it. The lift is the hover, one step darker on
     the same hue. */
  --form-purple:      #6B62E9;
  --form-purple-lift: #5B52D6;

  /* THE GLASS TINT, on Jesse's instruction, and it is a SECOND saturated thing
     on a page whose whole system is one. That is the cost and it is his call;
     what follows is the arithmetic that keeps it honest.

     Named for the job, not the hue, because the job is the constraint: this is
     the only colour on the page that has to survive being composited over two
     opposite grounds. The Join pill sits on a dark aerial photograph in the
     hero and on plain white in the closing scene, so every number below is
     derived TWICE — once over black, once over white — and the value that
     ships is the one that clears the floor on both.

     At 0.80 the fill lands #FFFFFF over white and #CCCCCC over the darkest
     pixel a photo can offer, and --ink reads 18.88:1 and 10.87:1 on those.
     Both are far past 4.5:1, which is what buys the alpha: a lighter mix would
     read as more glass and start failing over the photo, and there is nothing
     to be gained by trading a legible button for a prettier one.

     NO TINT AT ALL, on Jesse's instruction — the pill is bright white and the
     depth is grey. This started lavender, went warm, and has ended where the
     rest of the page already lives: the page keeps ONE saturated thing,
     --accent on the sheet's submit, and the hero pill stops competing with it.
     The name survives the change because the job did: this is still the value
     that has to composite over both grounds. The alpha now does the whole
     shaping — white at 0.80 self-greys over the photograph, which is where the
     depth comes from, and the edge and shadow carry it everywhere else. */
  --cta-glass-tint:    255, 255, 255;  /* #FFFFFF, as channels — the fill mixes it */
  /* The fill over white is 1.21:1 against the page, which is nothing: on the
     closing scene the FILL cannot show where the button is, exactly as the
     no-JS inputs on the old cream band could not. The border carries the whole
     boundary there, so it is derived the same way --hairline-join was — one
     value that clears 1.4.11's 3:1 on BOTH sides of itself: 3.85:1 against the
     white page outside it and 3.19:1 against its own fill inside it.

     On the photograph it is 5.7:1 against black and the hero pill's own glow is
     doing the separating anyway; this border only ever has to earn its keep on
     white, and it does. Rederived neutral alongside the tint, and it is now the
     grey doing the depth: with a white fill both sides of the border are the
     same white, so one number answers both halves of 1.4.11 — 3.45:1 against
     the page outside it and against its own fill inside it. */
  --cta-glass-edge:    #8A8A8A;

  /* --- The calendar's glass, ported whole from bp-glass-calendar -----------
     A SEPARATE MATERIAL FROM THE CTA ABOVE, AND THE NAMES SAY SO. This family
     is dark glass over a photograph; the button is light glass over a page.
     They share a word and nothing else, so the button's two tokens took the
     --cta- prefix when this family arrived rather than the two quietly
     resolving to whichever happened to be declared last.

     Every value is the source's, unchanged. The card carries its own
     photograph and its own contrast table, so it does not read off the page's
     ground and needed no re-deriving when it landed on a white sheet instead
     of a warm one. The single exception is the veil — see .week::after, whose
     tint follows --paper, and --paper is white now. */
  --glass:         #141A1E;
  --glass-fill:    rgba(20, 26, 30, 0.26);
  --glass-filter:  blur(24px) saturate(1.5) brightness(0.5);
  --glass-edge:    rgba(255, 255, 255, 0.55);
  --glass-sheen:   rgba(255, 255, 255, 0.55);
  --glass-rule:    rgba(255, 255, 255, 0.58);
  --glass-hatch:   rgba(255, 255, 255, 0.11);
  --on-glass:      #FFFFFF;
  --on-glass-dim:  rgba(255, 255, 255, 0.78);
  --on-glass-mute: rgba(255, 255, 255, 0.72);
  /* The opaque fallback ground when backdrop-filter is missing. Same value as
     --cream, declared separately because it answers to the card rather than to
     the page and the two are free to diverge. */
  --glass-anchor:  #EFEDE6;
  /* The calendar's day numerals are tabular; the page carried no mono stack. */
  --font-mono:    ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;

  /* Pantone 13-0755 TCX Primrose Yellow, at Pantone's own sRGB. Supplied by
     Jesse as a chip. It does not join the tint set
     above and it is never a text colour.

     ONE JOB AGAIN: the confirmation plate. It had two for a while — the
     waitlist trigger took it as well, so the button you press and the card that
     thanks you were the same yellow — and the trigger went black and white when
     the cream band under it went paper (that ink ground is gone; the two page
     Join buttons are .button--glass now, and only the sheet's submit still
     takes --accent — see the note where .button--paper and .button--ink were
     deleted). The plate keeps it
     because the plate is the reward rather than the ask, and a saturated ground
     earns itself there. --accent stays the sheet's submit, which is still the
     only saturated thing inside the form.

     It replaced an --ink plate with an orange accent word. That version was a
     faithful port of the reference and wrong for this page: the reference's
     dark card is reassuring you about a permission you just granted, which is a
     job with some weight to it, and this card is thanking somebody for joining
     a waitlist. Black was carrying a seriousness the moment does not have.

     Everything on it is --ink, at 12.74:1. That is the highest contrast any
     text on this page runs at, which is the compensation for a ground this
     saturated. */
  /* Was the confirmation plate's ground until that plate became a photograph.
     It is still the page's yellow — the survey band's tint and one of the five
     confetti colours read it — so the token stays where it is. */
  --primrose:      #F6D155;
  /* --- Opening timeline -------------------------------------------------
     Every delay in the intro derives from these, so the sequence stays
     coherent when one number moves.

     --intro-lead is a dead beat with nothing on screen. It is not styling:
     an animation with no delay starts at first paint, and the main thread is
     busy decoding the hero plate right then. Any hitch in that moment is time
     the first word has already spent fading, so it is half in before it is
     ever seen. The lead-in is the slack that absorbs it. */
  --intro-lead:    340ms;  /* bare paper after paint, before word one */
  /* RE-MEASURED off the reference recording, this time by integrating each
     word's ink over its own glyph mask per frame rather than tracking its
     darkest pixel. The darkest pixel saturates while most of the word is still
     arriving, which is what put the old numbers out: it read the fade as an
     ease-out finishing at ~650ms when the word itself is only halfway home.

     Words cross 50% at 0.522 / 0.735 / 0.930 / 1.132s — stagger 203ms — and
     each takes 437ms to travel 10%→90%, ~700ms end to end, crossing its own
     halfway point at 47% of that. Symmetric: a sine ease-in-out, NOT an
     ease-out. The old curve put a word past 85% in its first third, which is
     why they landed as a snap after a long smear.

     BACK TO THE MEASURED NUMBERS, on Jesse's instruction to match the reference
     exactly. These carried a x1.25 for a while (250/875/875), which held the
     reveal's shape but ran it a quarter slower than the thing it is copied
     from; the ask now is the reference's own cadence, so the multiplier is
     gone rather than re-tuned.

     Re-confirmed against a second capture of the reference (2026-09-06, phone,
     30fps, same ink-over-glyph-mask method): words cross 50% 195ms apart and
     each travels 10%->90% in 400ms, which is a 700ms span under the sine
     ease-in-out below (10-90 covers 57.5% of it), crossing its own halfway
     point at 46-48%. Both readings agree with the numbers below inside a frame.

     The hold and the lift are untouched. Everything downstream is a calc off
     --intro-reveal and follows on its own — EXCEPT the scroll-lock release in
     index.html, which is a hard millisecond count. It moved with these. */
  --intro-stagger: 200ms;  /* word to word */
  --intro-fade:    700ms;  /* opacity, per word */
  --intro-focus:   700ms;  /* blur, per word — same span, different curve */
  /* Settled line holds before the curtain moves. Also measured: in the
     reference the last word lands at 1.48s and the hero starts moving at
     2.13s, so the finished line rests for 650ms. This is the beat that was
     ours to spend and it is where the opening gets its calm — the words
     themselves are now quicker to start and slower to resolve. */
  --intro-hold:    650ms;
  --intro-lift:    500ms;
  /* Headline entrance, measured from the moment the curtain starts to lift.
     Was 440ms, which put the finished headline at 3770ms. */
  --intro-chrome:  220ms;

  /* When the curtain starts to lift: after the LAST word has fully crisped,
     plus the hold. The multiplier is (word count - 1), so it went 2 -> 4 when
     the line ran to five words and back to 2 now that it is "Intertwining the
     community" again. It is also restated as a hard millisecond count by the
     scroll lock in index.html, which has to move with it. */
  --intro-reveal:  calc(var(--intro-lead) + 2 * var(--intro-stagger)
                        + var(--intro-focus) + var(--intro-hold));
  /* The plate's load settle runs the whole opening, so it is already home by
     the time the curtain lifts. One variable, read by both the plain rule and
     the scroll-timeline rule, so the repeat-visit override has a single knob. */
  --hero-settle:   calc(var(--intro-reveal) + var(--intro-lift));

  /* ONE RADIUS SYSTEM, WHICH DESIGN.md ALREADY ASKED FOR AND THE FILE HAD
     STOPPED KEEPING. Nine radii shipped — 7, 8, 10, 12, 14, 16, 20, 24, 50% —
     each one individually reasoned in its own comment and never counted against
     the others. Two of them (the sheet's 10 and 14) came in with a ported
     reference, and the reference gave this page its TIMING, not its shapes.

     Three steps, and the step is the object's scale:
       chip    small inline things — the eyebrow, the footer mark
       button  anything you press or type into, at any size
       card    panels, plates, the modal itself

     --radius-card-inner is the nested case and is not a fourth step: a 20px
     plate inside a 20px card is the textbook nesting error, because two equal
     radii separated by padding draw non-parallel curves and the inner one reads
     as a sticker laid on the outer. Strict concentricity would be outer minus
     the 20px gap, which is 0, and a square-cornered plate reads as a crop; 10
     is the compromise the audit proposed and it is the only place it is used.

     TWO ACKNOWLEDGED EXCEPTIONS, both of them not-a-box: .page's 24px sheet lip
     (a full-bleed page edge, the largest gesture here, and the one radius the
     ramp has no business governing) and the spinner's 50%, which is a ring. */
  /* ONE CONTROL, and these are its two numbers. Join used to render as five
     different objects — a paper pill 52-56 tall, a yellow pill at 48, a black
     bar at 50, a white outlined pill and a paper pill in the survey (the
     survey's is a quiet pill in the community-input card now) — across
     three fills, three heights and five horizontal paddings (20/20/22/26/28-32).
     The file argued the COLOUR count carefully in four places and never once
     counted the shapes. .button owns both now; the two exceptions left are the
     hero's (it grows under 66px display type, or a 52/16 pill reads as a
     secondary control) and the sheet's 50, which matches the fields it submits.
     Both are declared as overrides on top of these rather than as separate
     controls that happen to look similar. */
  --btn-h:         48px;
  --btn-pad-x:     20px;

  --radius-chip:   8px;
  --radius-button: 12px;
  /* A real pill, and the ramp above deliberately does not contain it: this is
     not a fourth step in a radius scale, it is the end of one. Any box tall
     enough clamps it to half its own height, so one number covers a 50px
     submit and a 52px confirmation button without either being measured. */
  --radius-pill:   999px;
  /* The strip of page left showing above the confirmation when the sheet takes
     the whole screen. The reference leaves exactly the status bar; on a browser
     with no safe area to report, 10px is enough to say "there is a page under
     this" without becoming a margin. */
  --confirm-top-gap: max(10px, env(safe-area-inset-top));
  --radius-card:   20px;
  --radius-card-inner: 10px;

  /* --- Motion ----------------------------------------------------------
     Two curves for the whole page. The browser's built-in ease-out is too
     weak to read as deliberate; this is the standard strong variant.
     ease-in is deliberately absent — it starts slowly, which delays the exact
     frame the user is watching hardest, and there is nothing on this page that
     wants it. */
  --ease-out:      cubic-bezier(0.23, 1, 0.32, 1);
  --press:         160ms;  /* button press feedback */
  --hover:         180ms;  /* hover and colour changes */

  /* Added for the waitlist sheet, and measured off the reference recording
     rather than chosen. --ease-arrive is ease-out-expo: it is the curve the
     sheet and the thank-you card enter on, and nothing else takes it, so
     "arriving" has one motion signature on this page.

     --ease-in-out is for the two measured EXITS — the sheet leaving and the
     height morph. It is the only place an in-out curve appears; `ease-in` on
     its own still appears nowhere. */
  --ease-in-out:   cubic-bezier(0.77, 0, 0.175, 1);
  --ease-arrive:   cubic-bezier(0.16, 1, 0.3, 1);

  /* THE SHEET'S OWN TWO CURVES, RE-DERIVED FROM A SECOND RECORDING of the same
     reference (fixaplan.com, 2026-08-27) after the first port read its
     entrance wrong. Both were fitted numerically — the card's WIDTH at a fixed
     row and its TOP EDGE were measured on every frame of a 60fps extract, the
     pair solved for scale and offset, and seven standard curves were then fitted
     against the result at every duration from 200-800ms. These two won by a
     wide margin. See docs/waitlist-sheet-spec.md.

     --ease-sheet-in is ease-out-QUAD, and it is deliberately gentler than
     --ease-arrive above. The first port had the entrance on ease-out-expo,
     which is 95% resolved at 160ms; the reference is 67% resolved there. Expo
     fitted 3x worse than quad against the same frames (rms 0.034 vs 0.012).

     --ease-sheet-out is a plain ease-in, and it is the ONE place on this page
     where the family appears — the note above ("ease-in is deliberately
     absent") is about arrivals, where a slow start delays the frame the visitor
     is watching. This is a departure: the card accelerates away and off the
     bottom of the screen, which is what falling looks like and what the
     reference measurably does (fitted rms 0.029 against 0.102 for the
     --ease-in-out this replaced). The press is answered instantly by the close
     button retracting, so nothing waits on the slow first frames. */
  --ease-sheet-in:  cubic-bezier(0.5, 1, 0.89, 1);
  --ease-sheet-out: cubic-bezier(0.42, 0, 1, 1);

  /* READ BY app.js, not by this file. The sheet's choreography is WAAPI, so
     these four are declared here — where DESIGN.md says the source of truth
     lives — and pulled out with getComputedStyle rather than written out again
     in JavaScript, which would be the same number in two files.
     --dur-fast was speced alongside them and is gone: nothing read it, and a
     token nothing reads is just a claim. */
  --dur-base:      260ms;
  /* 380 -> 420, ON JESSE'S INSTRUCTION: 10% slower on the way in. The reference
     fitted to 380 (see docs/waitlist-sheet-spec.md §6.0) and that measurement is
     unchanged — this is a deliberate departure from it, not a correction of it.
     The two beats timed against the sheet's arrival went up by the same 10% so
     the choreography stays in proportion: the close button's rise 360 -> 396 and
     its radius 440 -> 484, both in app.js. The exit is untouched. */
  --dur-sheet-in:  420ms;
  /* 240 -> 400. The exit was re-fitted off the second recording along with its
     curve: the card is still measurably moving 250ms after the press and does
     not clear the bottom of the screen until ~290ms, which a 240ms animation
     cannot describe. Still shorter than the 380 in — the visitor is deciding on
     the way in and the system is answering on the way out. */
  --dur-sheet-out: 400ms;
  /* The card's growth from form to confirmation. Declared here because the
     sheet's own geometry transitions on it in CSS while app.js animates the
     card's height on the same number — one value, two owners, no drift. */
  --dur-morph:     320ms;

  /* WHERE THE SHEET RESTS WHEN IT IS AWAY, and the single fact the first port
     got wrong. It read the entrance as a pure scale(0.5) about the card's own
     bottom edge — the card growing in place. It is not: the card travels UP
     from below the screen while scaling only slightly, and the two channels run
     on one curve.

     Both numbers are solved, not chosen. Measuring the card's width and its top
     edge on every frame gives a scale s and a downward offset Y at each instant,
     and Y/(1-s) is constant at 2040pt across the whole entrance — which is what
     "one shared progress" looks like. Feeding the rest geometry back in:

       Y0 = 2040 x (1 - s0),  and s0 = 0.57  =>  Y0 = 877pt ~= 100svh

     So the away state is one viewport-height down at 57% — the card starts fully
     off the bottom of the screen and is 87% of full size by the time its top
     edge crosses back into view. Read by app.js; the transform is composed
     there because the choreography is WAAPI. */
  --sheet-away-y:     100svh;
  --sheet-away-scale: 0.57;

  /* The close button's box and the gap it keeps above the sheet. --close-travel
     is DERIVED from them, not chosen: it is exactly the distance that parks the
     button behind the sheet's top edge, which is where it rises from. Move
     either of the first two and this follows; never write 62px as a literal. */
  /* The reveal's own curve. linear is right for a scroll timeline that maps
     position to position, which is why lines-out was given an eased one and this
     was left alone — but reveal-in maps position to a SETTLE, and a settle wants
     soft ends for the same reason. linear left a first-derivative discontinuity
     at both range edges, so the most-seen animation on the page started and
     stopped abruptly. Same curve, same argument, as recorded on lines-out. */
  --ease-reveal:   cubic-bezier(0.45, 0, 0.55, 1);

  --close-size:    46px;
  --close-gap:     16px;
  --close-travel:  calc(var(--close-size) + var(--close-gap));

  /* The desktop column. The page was a 496px phone column stranded in a 1440px
     viewport with two thirds of the screen left as empty paper. This is the
     wide measure the layout opens up to; prose inside it keeps its own
     max-width so the reading line never stretches past ~65 characters. */
  /* THE HEADLINE'S SIZE, HOISTED INTO A TOKEN because .hero-crest has to
     reserve space against it and a second copy of the clamp would drift. See
     the crest's height for what reads it. */
  /* joetown.org's OWN SIZES, measured off their hero at four widths and fitted
     rather than eyeballed:

         375    33.5px      (this clamp: 33.5, the floor)
         768    32.6px      (33.5 — they step down at their tablet breakpoint,
                             this holds the floor, 0.9px apart)
         1280   43.6px      (43.6, dead on)
         1600   44.8px      (44.8, the cap — which is their 2.8rem)

     THAT WAS THE WRONG SPEC TO COPY, and the audit is what named it: those
     three numbers are joetown.org's H2 — a heading under a logo, above body
     copy, on a white page. This is a full-bleed 100svh photograph with one
     sentence on it, which is a DISPLAY setting, not a heading one. 44.8px is
     3.1% of a 1440px viewport: a caption. Measured on the deployed build the
     tower ran 3.9x the headline's mass, so the decoration led and the message
     supported.

     38/5.2vw/68 is the display scale restored: 44.8 -> 68 on desktop,
     33.5 -> 38 on a phone. Not the old 34/5.6vw/66 either — the floor comes up
     because 34 left the headline too small for the page below it, and the
     ceiling comes up because the tower is capped now (see
     .hero-crest) and there is room for it.

     6.62vw/119 WAS THE REFERENCE STILL'S OWN PROPORTION: in the still Jesse
     gave, "An app for Saint Joseph." covers 84% of the frame's width on one
     line, and the sentence measures 12.685em wide in this face at this
     tracking, so 0.84 / 12.685 = 6.62vw put exactly that much of the viewport
     under it, ceilinged at the still's own frame width (1798 x 0.0662 = 119px).

     HALVED ON JESSE'S INSTRUCTION — 3.31vw/59.5, so the line covers 42% of the
     viewport rather than 84%. The proportion is still the still's, read at half
     scale; nothing else about the derivation changed.

     THE FLOOR IS NOT HALVED, and that is deliberate. 19px would put the
     headline under the body copy further down the page, which is the exact
     failure the floor was raised to 38 to fix, and the halving was asked for
     against the desktop setting — the thing that was actually on screen. The
     phone therefore looks the same as it did; only the desktop line shrinks.
     Say so if the phone should come down too.

     Because the floor holds to 38 and the vw term is now half, the two meet at
     1148px rather than 574 — so everything from a phone up to a small laptop
     sits at the floor, and the line only starts growing on a real desktop.

     UP 20.4% WITH THE LINE SET LIVE, and that number is a width match rather
     than a size change. The drawn cut measured 12.65em of ink; the same
     sentence in Switzer 700 measures 10.51em, so at the old token the live
     line ran 90px short of the artwork's on a 1280 and the hero stopped
     filling. 12.65 / 10.51 = 1.204 puts the line back on exactly the width it
     drew before — 38 -> 45.8, 3.31 -> 3.99, 59.5 -> 71.6. The glyphs are
     larger than the drawn ones because this face is narrower; the LINE is the
     same length, and the line is what the composition was built around.

     Re-measure this if the face or the copy changes: it is a ratio between two
     specific strings in two specific faces, not a constant. */
  --headline-size: min(96px, 7.5vw);

  --col-narrow:    544px;
  /* The step between them. Six breakpoints used to ship — 360, 560, 600, 640,
     700, 940 — five of them inside a 340px span and then a 240px hole, so from
     700 to 939 NOTHING on the page changed width while every type clamp kept
     growing against the viewport. At 939 that was a 496px column of 51px
     display type ragged into five lines with 200px of empty band either side of
     it, and one pixel later it was three balanced lines. See the
     `min-width: 700px` block. */
  --col-mid:       700px;

  --col-wide:      1020px;

  /* THE PAGE'S MARGIN, IN ONE PLACE. This number was written out by hand in
     eleven rules, and the two passes that change it — the phone one down to 20
     and the desktop one up to 40 — each reached only some of them. .creed
     and .join were missed by the phone pass, .creed and .survey by the
     desktop pass, so the left edge ran 20 · 24 · 20 · 24 · 20 · 20 down a 390px
     screen and landed on seven different values at 1440. A page whose whole
     argument is one quiet column has to have exactly one margin. */
  --gutter:        24px;

  /* What the gutter leaves: the width of the type column itself. .page-inner
     resolves it as max-width minus its own padding, and it is written out here
     so the full-bleed bands can match. They hold their column as a BARE CONTENT
     BOX, so max-width means something different to them, and copying
     --col-narrow across put their type outside every heading on the page. */
  --measure:       496px;

  /* THE NO-JS FORM'S COLUMN, AND NOTHING ELSE'S ANY MORE. The name is
     historical three times over: it was shared by the ask copy and the closing
     scene, the ask is gone, the cream band it measured is gone, and the scene
     now takes the page's own column (see .closing). What is left holding it is
     .join-inner — which is empty the moment the pre-paint script moves the form
     into the sheet, and is the whole layout only when script is blocked. It
     stays 496 on desktop where --measure widens: two fields and a submit, not
     prose. */
  --col-ask:       496px;

  --font-display: "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  /* THE HEADLINE'S FACE AND NOWHERE ELSE'S, and it is not self-hosted like the
     other two — it cannot be. joetown.org, the town's own site and the thing
     Jesse asked this line to match, sets its hero in futura-pt at 700, read off
     their live element rather than guessed: font-family "futura-pt",
     font-weight 700, letter-spacing normal, line-height 1.299. That is Adobe's
     Futura, served through a domain-locked Typekit kit — it cannot be licensed
     into this page, so the page asks for the Futura already installed on the
     machine instead.

     Apple ships one: "Futura" resolves to Futura Bold at 700 on both macOS and
     iOS, which is where nearly all of this town reads its phone. Everywhere
     else the stack falls to Jost, the free Futura-alike, self-hosted beside
     Switzer — see its @font-face above for why it is the one font file on this
     page that is not preloaded. The platform sans is the third rung and now
     only ever a rung, not the outcome. */
  --font-headline: "Futura", "Jost", -apple-system, BlinkMacSystemFont, sans-serif;
  /* The closing line only. `cursive` is the correct generic fallback: if the
     woff2 never lands, the sentence should still not arrive set in the body
     face pretending to be handwriting. */
  --font-hand:    "Shadows Into Light", cursive;
  --font-system:  -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  /* The headline's accent word — the actual face the reference uses, read off
     its own @font-face rules rather than approximated. A ui-serif stack stood
     here and was close, but "match it exactly" rules out an approximation. */
}

/* The UA's own `[hidden] { display: none }` loses to ANY author display rule,
   so it is restated here where it outranks them. It was written for the survey
   band, which used to ship `hidden` with its form URL in a data attribute and be
   un-hidden by app.js once the script had written the href — a `display: grid`
   added to .survey would have quietly published a band whose link still pointed
   at "#". That indirection is gone and the survey is plain markup now — but
   THE RULE IS STILL LOAD-BEARING, on a different element. .join-trigger ships
   `hidden` in the markup and `.closing .join-trigger` sets `display: flex`, so
   without this rule the Join control publishes itself before app.js has
   un-hidden it: permanently with script disabled, as a flash with it on. Do not
   sweep this as dead weight because the section it was written for stopped
   needing it. */
[hidden] {
  display: none !important;
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  scroll-behavior: smooth;
  /* THE PAGE BEHIND THE SHEET MUST NOT SHIFT WHEN THE SHEET OPENS, and this one
     line is what holds it still. Opening the sheet locks the body with
     `position: fixed` (see lockScroll in app.js — overflow:hidden does not hold
     on iOS Safari, which is why the lock is what it is). A fixed body
     contributes no height, so the document's scrollbar disappears for as long as
     the sheet is up — and on any browser with a CLASSIC scrollbar that widens
     the viewport by ~15px, which re-centres every centred thing on the page and
     re-runs `text-wrap: balance` on the headline. The visible symptom is the
     hero's text jumping sideways on open and back on close.

     `stable` reserves the gutter whether or not the scrollbar is drawn, so the
     width never changes and nothing re-centres. It is inert where scrollbars are
     overlays (macOS default, iOS, Android) — which is exactly why this bug is
     invisible on the machines it is usually developed on. */
  scrollbar-gutter: stable;

  /* The canvas appears during rubber-band overscroll below the document. Keep
     it continuous with the new dark footer card; the dedicated top overscroll
     element still carries the hero colour above the page. */
  background: #121210;
}

body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--font-system);
}

/* THE TOP HALF OF THE OVERSCROLL, and it is a box rather than a background
   because there is only one canvas and the river has it. Pulled down from the
   top of the page a visitor should see the hero's ground, not the river's.

   IT SITS ABOVE THE DOCUMENT ON PURPOSE. Overflow above the origin does not
   extend the scroll range, so a screen-tall box at top: -100svh is invisible
   until the page is pulled and costs nothing. The same box placed BELOW the
   river would lengthen the document by a screen and leave a dark band under the
   water. Never background-attachment: fixed — the other way to do this, and it
   repaints on every scroll frame on iOS.

   A REAL ELEMENT AND NOT body::before, WHICH IS NOT A STYLE PREFERENCE. As a
   pseudo-element on body it blinded axe-core across the whole page: axe checks
   every node's ancestors for a pseudo-element that might paint behind it, body
   is the ancestor of everything, and the result was "incomplete" instead of a
   ratio. Measured at 390 with axe-core 4.10.2 — 0 violations / 35 incomplete /
   3 pass on the pseudo, against 1 / 25 / 11 on this element, where the 1 is the
   Join buttons' own documented 3.51:1. Zero violations because the checker gave
   up is worse than one violation the page has always declared. The markup note
   on .overscroll-top carries the full table.

   #1A1A1A raw, matching .hero's own literal: this hex is written three times in
   the file now and should become a token in a pass that is allowed to touch the
   hero. theme-color stays #111111. */
.overscroll-top {
  position: absolute;
  left: 0;
  right: 0;
  top: -100svh;
  height: 100svh;
  background: #1A1A1A;
}

/* The paper sheet that rides up over the pinned hero. Full-bleed and opaque —
   it has to cover the photo edge to edge, so the column width moved down to
   .page-inner. z-index puts it above the sticky hero; without it the two share
   a stacking context and the photo wins. */
.page {
  position: relative;
  z-index: 1;
  /* REAL PAPER, NOT A FLAT FILL. Until now one surface out of eight carried the
     scan and the other seven were flat, which does not read as "one band is a
     different stock" — it reads as one band being the only real thing on a
     website. The whole page is the printed object; the band is a heavier sheet
     laid on it.

     Half amplitude, not the band's full strength: the page is the ground and
     the band is the object, so the wash has to be legible up close and gone at
     a glance (measured: sd 1.15 against the band's 3.10). It is the same fibre
     from the same scan, re-toned onto --paper rather than --cream, so the two
     surfaces are the same stock at two weights and not two different papers.

     Same seamless 620px mirror tile as the band, drawn 1:1 — see the note on
     .join for why it tiles instead of covering. 7.7KB, and it scrolls with the
     page rather than sitting fixed behind it: the paper moves with the ink. */
  background: var(--paper);
  /* No horizontal padding here any more — it moved to .page-inner so that the
     survey band, which is a direct child, can run edge to edge.

     No bottom padding: the image-backed closing stage runs directly to the
     document edge. */
  /* THE 64 MOVED TO .page-grey, WHICH IS NOW THE FIRST CHILD AND PAINTS THE
     PLATE. Left here it would have been 64px of white above a grey box — the
     sheet would have come off the photograph as a white lip and only then gone
     grey. Same number, one level in. */
  padding: 0;
  /* The sheet lip. This is most of why the reference's transition reads as a
     sheet of paper riding up over the town rather than the background simply
     changing colour, and it is only ever visible during that pass.

     THE ONE RADIUS OFF THE RAMP, and deliberately: everything --radius-card
     governs is a box sitting ON this page, and this is the page's own edge
     against a photograph. It is also the largest radius here by a long way, so
     pulling it down to 20 would make the ramp's top step do two jobs at two
     scales. Acknowledged rather than absorbed. */
  border-radius: 24px 24px 0 0;
}

.page-inner {
  max-width: var(--col-narrow);
  margin: 0 auto;
  padding: 0 var(--gutter);
}

/* --- The grey opening plate ---------------------------------------------- */

/* ONE BOX BEHIND THREE SECTIONS. The brands strip, the creed and the calendar
   band are separated by MARGINS, and a margin takes no background — three
   individual fills would have left a white stripe at every seam. This is the
   whole reason the wrapper exists in the markup.

   IT INHERITS THE SHEET'S JOB: the 64px top padding and the 24px lip both came
   off .page (see there), because whatever paints the top edge is what has to
   round it against the photograph.

   IT ENDS AT THE CALENDAR AND NOT A PIXEL LOWER. .week already carries 64px of
   padding-block, so the plate closes 64px under the card; .about's own 56px
   top margin then sits OUTSIDE this box and is white, which is the seam Jesse
   asked for — grey through the calendar, paper from the person on. No
   padding-bottom here: it would be a second gap stacked on the band's. */
.page-grey {
  background: var(--band-grey);
  border-radius: 24px 24px 0 0;
  padding-top: 64px;
  /* THE PLATE RUNS ON PAST THE PHOTOGRAPH, on Jesse's instruction. It used to
     stop dead on the band's bottom edge — .week's own 64px of padding is the
     LAST thing inside the plate and the photograph fills that box, so grey and
     picture ended on the same line and the seam read as the photo being cut
     out of the paper. 48px of plate below it turns that into a border: the
     picture sits ON the grey rather than being flush with the end of it.

     48 and not 64: it is a ledge under the photograph, not another section
     gap, and .about's own 56/96 margin still follows it. */
  padding-bottom: 48px;
}

/* NO TYPE RULES HERE, AND THAT IS THE POINT OF THE VALUE ABOVE. Two earlier
   versions of this plate needed them — a dark one that flipped everything to
   white, then a mid one that pulled the creed's prose up to --ink and gave its
   phrases a burnt orange, because --ink-secondary measured 2.47:1 there. At
   #E5E5E5 the paper's own pairing holds (4.29:1 and 14.99:1), so the creed
   keeps the step between prose and phrases it has everywhere else and the
   calendar title inherits --ink. The brands strip needs nothing either: its
   names are visually hidden and its marks are images, and the black
   silhouette at 0.3 that reads on paper reads the same here.

   The calendar card is dark glass on a photograph with its own measured
   contrast table, which this plate does not touch at any lightness. */

/* THE MARKS NEED NOTHING HERE, AND THEY DID WHILE THE PLATE WAS DARK: they
   were inverted to white silhouettes at 0.45 because black at 0.3 disappeared
   into a #7A7A7A ground. On a plate this light the base treatment works as
   designed — a black silhouette at 0.3 lands 53 levels off this ground against
   76 on paper, which is the same quiet line. The override is deleted rather
   than tuned; one treatment is the point of the rule at .brand-logo. */

/* Focus: one ring, everywhere, keyboard only. */
:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 2px;
}

/* --- Headline ----------------------------------------------------------- */

/* The hand-drawn water tower over the photograph, above the headline.

   It works here only because the asset is a SOLID object rather than open line
   work — its interior is flooded --paper and opaque, so what sits on the photo
   is a cut-out of the page rather than a few pen strokes competing with an
   aerial view of the same town. Nothing in this rule can rescue a line-art
   version; that was tried twice and is written up in the markup and the README.

   The shadow is a lift, not a legibility fix — the body already carries the
   legibility. It is what keeps the cut-out from looking pasted flat onto the
   frame, and it is the second shadow on the page, after the hero CTA's, for
   exactly the same reason: this genuinely floats over a photograph of
   unpredictable brightness.

   Sized by height with width auto, which is also why it can never overflow
   sideways: at its 481px ceiling the tower is ~313px wide, and the widest it
   ever gets relative to its viewport is 64% (320x830, 203px wide) — swept over
   every size from 320x480 to 3440x1600.

   IT IS NO LONGER SUBORDINATE TO THE HEADLINE, AND THAT IS THE CHANGE. The old
   148px ceiling was set deliberately under the headline's 66px cap line pair so
   the crest read as a mark ABOVE the sentence rather than as its equal. It has
   since doubled, gone up 30%, and gone up another 25%, all on Jesse's
   instruction, and at 481px it is not the headline's equal, it is the hero's
   subject. The old intent is recorded rather than silently reversed.

   481px IS 1.33x ON THE SHIPPED ASSET, DOWN FROM 1.66x. town-watertower.webp is
   640px tall and that is the master's whole tower — see README. The drawing has
   to be re-scanned before it can grow again; this step spends the last of the
   margin the 385px slot had. */
.hero-crest {
  display: block;
  /* The loose interval of the three (see .hero-copy): the tower is one object,
     the sentence under it is another. The auto margins went with it —
     .hero-copy centres on the cross axis now. */
  margin: 0 0 var(--hero-gap-loose);
  width: auto;
  /* Raised four times on Jesse's instruction:
     96/12vw/148 -> 192/24vw/296 -> 250/31.2vw/385 -> 313/39vw/481 -> 320/48vw/640.

     THE svh CAP IS GONE, AND THAT IS THE POINT OF THIS PASS. Every earlier
     version carried `min(clamp(...), calc(46svh - 112px))` because .hero was a
     fixed 100svh box with `overflow: hidden` and absolutely positioned copy —
     anything that did not fit was CLIPPED, and the thing at the bottom of the
     stack is the Join button. So the tower was rationed against viewport
     height, and on a laptop window (~560-700px of page height) that rationing
     left it at 146-227px: a small mark, not the hero's subject. That is the
     "why did the tower shrink" everyone was seeing. Nothing had shrunk; the
     window was short and the cap was doing exactly what it was written to do.

     .hero now grows instead of clipping (see its own comment), so height is a
     plain clamp and the tower is the size it is asked to be at every viewport.

     640 IS THE ASSET'S CEILING, NOT A ROUND NUMBER. town-watertower.webp is
     640px tall and that is the master's whole tower, so at a 640px slot it is
     rendering 1:1 — soft on a retina display, and there is nothing left to
     re-cut. Do NOT raise this again without a re-scan; past 640 the browser is
     upscaling pixels that were never drawn.

     48vw carries the middle: on the ~1000px-wide laptop window that started
     this, it puts the tower at 480px against the 146px it was getting. */
  /* The svh term is NOT the old rationing cap coming back. That one was sized
     against the whole stack (`46svh - 112px`) so the BUTTON would survive, and
     it is what held the tower to 146px on a laptop. This one is much looser and
     protects one thing only: that the HEADLINE still breaks the first screen.
     Built at 48vw with no ceiling at all, a 1000x560 window put a 480px tower
     on a 560px screen and the opening line — "There's more here than you'd
     think." — was entirely below the fold. The first thing a visitor saw was a
     water tower on black.

     THE TERM IS NOW A SUBTRACTION, NOT A FRACTION, and the reason is the copy.
     72svh was sized against a TWO-LINE headline: it left roughly that headline's
     own height underneath the tower and no more. The headline is now one long
     sentence that sets as three lines on a desktop and four on a phone, so the
     same 72svh pushed the last line ("than you know about.") off the bottom of
     the screen — the exact failure this term exists to prevent, reappearing
     because the thing it was measured against changed.

     RE-MEASURED AFTER THE HEADLINE BECAME ONE LINE, which is what the last
     paragraph of this note asks for. The sentence now sets as a single line at
     every width above ~530px, so its contribution to the stack fell from 144px
     (two lines at the old 68px ceiling) to 101px at 1440x900 — the constant
     over-reserves by roughly 40px rather than being wrong. Measured need is
     342px at 1000x560 and 388px at 390x844, both comfortably under it, and
     over-reserving is the safe direction: the flex centring absorbs the slack.
     Left at 470 deliberately; the alternative is re-deriving a number that is
     already conservative at every viewport tested.

     A fraction cannot hold that: the headline's height is roughly CONSTANT in
     px at a given width (three lines of a clamped font), while a fraction of
     the viewport shrinks with it, so the two only agree at one height. The
     subtraction reserves the space the headline actually occupies — ~206px of
     type at the 66px ceiling, plus .hero-copy's 12svh of padding and the
     crest's own margin — and hands the tower the rest.

     Still not the old rationing cap. That one was `46svh - 112px` and sized
     against the whole stack so the BUTTON would survive, which is what held the
     tower to 146px on a laptop. This reserves the HEADLINE only and nothing
     else: on a 1440x900 window the tower is 560px, on the 1000x560 window that
     started all this it is 220px against the 146px it used to get, and in both
     cases the whole sentence is above the fold.

     THE max() IS A GUARD, NOT A DESIGN FLOOR. Below a 330px-tall viewport the
     subtraction goes NEGATIVE, and a negative height is an invalid declaration
     — it would be dropped, `height: auto` would win, and the tower would render
     at its full 640px intrinsic height, which is the worst possible outcome of
     a rule written to stop it overflowing. 120px is simply a value that is
     never negative and never large; nothing is designed to look right there. */
  /* THE RESERVATION IS NO LONGER ONE FLAT NUMBER, and that is what fixed the
     desktop. It reserves the space BELOW the crest, and the biggest thing down
     there — the headline — is CLAMPED: it is 106px of type on a phone and 206px
     on a 1440px window. A single constant therefore cannot be right at both
     ends. 486 was measured at the desktop end, so it over-reserved on a phone
     (a smaller tower than the screen could hold) and still UNDER-reserved on
     desktop by ~15px, which is why the hero ran 915 tall in a 900 window and
     the strip and the button were squeezed against the bottom edge.

     Split in two: a constant for everything whose height does not scale with
     type, plus the headline's own height, which is exactly 3.12 x its font size
     (three lines at line-height 1.04, no margins).

       300px = .hero-copy's 12svh of padding (108 at 900) + the crest's own
               bottom margin (22) + the gap to the strip (26) + the strip (73)
               + the gap to the button (14) + the button (56)

     Measured at 1440x900, where the whole stack now lands at 899 of 900:
     54 + 394 + 22 + 206 + 26 + 73 + 14 + 56 + 54. On shorter viewports the
     constant over-reserves — the svh padding and the clamps are smaller there —
     which leaves slack the flex centring absorbs, and is the safe direction.

     Re-measure whenever anything is added to or taken out of .hero-copy. */
  /* CUT AND CAPPED, by the same audit that put the headline back on a display
     scale. 460 -> 330 desktop, 320 -> 210 phone. Against the new headline that
     is a mass ratio of 2.3:1 instead of 3.9:1 — the tower still reads as the
     town's mark, and the sentence leads.

     The subtraction is a flat `100svh - 470px` now, not the old
     `300px + 3.12 x headline`. That formula sized the tower to fill whatever
     the headline left over, which is what put 124px of dead space above it on a
     phone while the copy below sat in a 14px band. A constant reserves the
     stack under it and stops there. */
  /* Up 15% on Jesse's instruction: 210/30vw/330 -> 242/34.5vw/380. The svh
     reserve is untouched — it is the room the stack under the tower needs, not
     a size, and the tower still gives way to it on a short window. */
  height: max(96px, min(clamp(242px, 34.5vw, 380px),
              calc(100svh - 470px)));
  /* TWO shadows, not one, and that is the whole trick. A single mid-blur cast
     is what a cut-out pasted onto a photo looks like; real lift is a tight
     contact shadow hugging the alpha edge (reads as the object's own weight)
     plus a wide, soft ambient that falls away (reads as distance from the
     plate). Both are drop-shadow() rather than box-shadow because the crest is
     a transparent PNG-cut WebP — box-shadow would trace its 416x640 BOX.
     Offsets stay vertical: the page has one light source, straight down, same
     as the hero CTA's shadow. */
  filter:
    drop-shadow(0 2px 4px rgba(0, 0, 0, 0.34))
    drop-shadow(0 14px 34px rgba(0, 0, 0, 0.42));
}

/* Lives only in the hero now, so it is styled for white-on-video rather than
   ink-on-paper. */
.headline {
  margin: 0;
  /* WIDTH 100%, AND IT IS LOAD-BEARING NOW. This heading shrink-wrapped for as
     long as one of its two lines was set type — the sentence's own measure was
     what gave the box a width, and the wordmark above it could then take a
     percentage of that. Both lines are artwork now, both sized as a percentage,
     and a percentage of a shrink-to-fit parent whose only content is that
     percentage resolves to zero: the heading collapsed to 0x4 and the hero went
     blank. The column is what they are proportions OF, so the heading has to
     be the column. */
  width: 100%;
  /* THE TYPE LIVES ON THE TWO CHILDREN, not here. Both lines are set type
     again and they take different faces of the same family — 500 at -0.04em
     for the name, 700 at -0.025em for the sentence — so there is nothing left
     for the heading itself to declare. It carries the shadow, the entrance and
     the dissolve, which is what a wrapper is for.

     THE SHADOW IS A FILTER, NOT text-shadow, and it stays one: --line-rest and
     --line-lift are byte-for-byte the values text-shadow carried, drop-shadow()
     takes the same offset/blur/colour triple, and the filter is what the intro
     and scroll rules animate. Moving it back would mean touching all four. */
  --line-rest: 0 2px 12px rgba(0, 0, 0, 0.32);
  --line-lift: 0 30px 46px rgba(0, 0, 0, 0.48);
  /* Where this line dissolves as you scroll. A token because TWO rules need
     the same pair — see the [data-intro="seen"] note in the @supports block. */
  --line-out: 0 32vh;
  filter: drop-shadow(var(--line-rest));
}

/* THE SENTENCE, LIVE. It was headline.svg, sized `--headline-size * 12.650`
   because 12650 is the outlined cut's ink width at the 1000-unit em it was
   drawn on. Set type has no such multiplier to apply — the string measures
   whatever the face makes it — so the size token goes straight on the type and
   the WIDTH is what has to be watched instead.

   ONE LINE AT EVERY WIDTH, on Jesse's instruction that the sentence run across
   the screen, and `min()` is what holds it without a media query. The string
   is ~10.51em wide set solid, and .hero-copy's content box plus the 12px of
   gutter the artwork used to eat is ~93.6vw on a phone, so 93.6 / 10.51 = 8.9vw
   is where it wraps. Below 529 this vw half wins and the type shrinks to fit
   the screen — 32.6px on a 375 phone, filling 343 of the 351px available, which
   is the width the artwork filled there. Above it --headline-size wins and the
   sentence is at its designed size. Change the copy and re-measure at 320: it
   is the width that breaks first.

   nowrap is the backstop, not the mechanism. If a future string outgrows the
   min, this overflows visibly rather than quietly breaking the line — which is
   the failure mode that gets noticed.

   vw AND NOT A PERCENTAGE, because font-size has no percentage of the parent's
   WIDTH to take. That means the scrollbar caveat .closing-figure records
   applies: on a desktop window narrowed under 529 the vw includes the
   scrollbar, so 7.2 rather than the 7.4 the arithmetic gives, which is the
   slack that covers it. */
.headline-line {
  display: block;
  color: var(--paper);
  font-family: var(--font-display);
  /* 8.7, not the 8.9 the arithmetic gives: vw includes the scrollbar on a
     desktop window narrowed under this breakpoint, and that is the slack which
     covers it. */
  font-size: var(--headline-size);
  /* 600, MATCHING THE OPENING. This line asked for 400 and got 500 — the
     @font-face block ships 500 and 600 only and font-synthesis-weight is off,
     so the browser resolved 400 up to the nearest cut it had. The opening line
     moved to 600 last pass and this one did not, which left the first sentence
     the page says and the second set in different weights of the same face.

     Costs 1.5% of width, measured rather than assumed: the string is 9.149em
     set solid at the resolved 500 and 9.285em at 600, so the one-line rule this
     block exists to hold keeps the slack it always had — 274px of the 345
     available at 393, and 891 of 1232 at 1280. */
  font-weight: 600;
  line-height: 0.95;
  letter-spacing: -0.03em;
  white-space: nowrap;
}

/* Sized FROM --headline-size rather than from a clamp of its own, so it can
   never drift out of ratio with the sentence under it.

   LIVE TYPE, NOT ARTWORK, and set in the closing wordmark's face: same
   --font-display, same 500, same -0.04em, on Jesse's instruction that the
   bottom's font be the top's. The absolute-positioned .headline-name-art that
   used to sit in here is gone with it — a text node needs no wrapper trick to
   sit on its own baseline.

   THE NAME IS NOW THE LARGEST THING IN THE HERO, on Jesse's instruction that
   it pop out most. The ratio is INVERTED, not nudged: 0.8 of the sentence for
   as long as the pair was drawn, 1.5 of it now, so the line that used to
   introduce the sentence is the line the sentence sits under. The sentence is
   unchanged at --headline-size — this is the name growing, not the pair.

   The colour is set HERE and was not before. The drawn cut carried --paper in
   its own fill because an <img> cannot inherit currentColor; live type can and
   .headline sets no colour of its own. .headline's drop-shadow filter still
   covers this, artwork and text alike. */
/* EACH LINE IS A MASK, which is the half of the reference's entrance that a
   plain transform cannot reproduce. Frame-stepping the recording shows the
   words CUT along a horizontal edge as they travel — "Plan your day" arrives
   with its lower half missing and fills in from the top — and its DOM says why:
   each animated line sits inside a wrapper at `overflow: clip`, so the line
   rises INTO a box rather than across the photograph. Fixa's initial offsets
   (130px and 100px against a 91px line) are larger than the line itself for
   exactly that reason: the text starts fully outside its own window.

   The padding is descender room, and the matching negative margin gives it
   back so the stack measures what it did before: at line-height 0.95 the line
   box is SHORTER than the type's own em box, so clipping to the content edge
   would cut the y of "Party" and the p of "app" at rest — with the animation
   finished and nothing moving. The reference's own first mask is 120px around
   a 91px line for the same reason. */
.headline-name,
.headline-line {
  overflow: clip;
  /* SYMMETRIC, and that is what keeps the stack the height it was: an em is
     resolved against this element's own font-size, so equal padding and margin
     cancel to the pixel. The 0.12/0.16 pair this replaces left the hero 3px
     taller than the reference's 800 and pushed the bar off its y. 0.14em is
     the overflow at line-height 0.95 — the line box is shorter than the type's
     em box by about a tenth of an em at each end, so clipping to the content
     edge would cut the y of "Party" and the p of "app" at rest. */
  padding: 0.14em 0;
  margin-block: -0.14em;
}

/* The thing that actually travels. It has to be its own box: the mask cannot
   both clip and move, and a transform on the clipping element would take the
   window along with the words. */
.headline-in {
  display: block;
}

.headline-name {
  display: block;
  /* NO GAP. The reference sets its two lines as one paragraph, so the only
     space between them is the 0.95 line-height both carry. */
  margin-bottom: 0;
  color: var(--paper);
  font-family: var(--font-display);
  font-size: var(--headline-size);
  font-weight: 400;
  line-height: 0.95;
  letter-spacing: -0.03em;
}

/* THE PHONE QUERY IS GONE. It existed to hold the name's factor against a
   sentence that was sized differently down there; both lines take one token
   now — min(96px, 7.5vw), which is the reference's own fixed 96 on any screen
   wide enough to hold it and a width-driven size below that — so there is
   nothing left to reconcile. headline-stacked.svg, the two-line phone cut this
   query used to swap in, has been unused since the lines became live type. */

/* --- Intro curtain ------------------------------------------------------ */

/* Covers the page, writes the town's name, lifts.

   RESTING STATE IS LIFTED. The animation brings it down and takes it away
   again; it is never what removes it. Anything that stops the animation from
   running — Reduce Motion tripping the blanket reset at the foot of this file,
   a browser that cannot animate — leaves this off-screen and the visitor on the
   finished page. The opposite arrangement strands them on a blank sheet. */
.curtain {
  position: fixed;
  inset: 0;
  z-index: 10;
  display: grid;
  place-items: center;
  padding: 24px;
  /* THE REFERENCE'S OWN GREY, on Jesse's instruction — sampled #E7E7E7 off the
     recording's ground (fixaplan.com, frame 36, four points clear of the type).
     It replaces the page's paper stock and grain, which is a deliberate step
     back from the argument in the note this replaces: the curtain is a title
     card in the reference's material, and the lift still ends on paper because
     the page under it is paper.

     Flat, and no grain image. The fibre was only ever legible against the type
     it sat behind, and under a blur that is now twice as deep it reads as
     noise in the smear rather than as stock. One fewer paint over a full-screen
     element on the frame the words are resolving. */
  background: #E7E7E7;
  transform: translateY(-100%);
  /* Measured off the reference rather than fitted to it. Sampling the curtain's
     bottom edge frame by frame gives 100% → 35% → 21% → 11% → 4% → 0 in even
     100ms steps: a violently front-loaded curve with a long decelerating tail,
     which no cubic-bezier tracks well — expo-out overshoots it badly in the
     middle third. linear() takes those samples as-is.
     The bezier below it is the fallback, and must come FIRST: browsers without
     linear() drop the second declaration and keep this one. */
  animation: curtain-lift var(--intro-lift) cubic-bezier(0.16, 1, 0.3, 1) var(--intro-reveal) both;
  animation-timing-function: linear(0, 0.65 11%, 0.79 33%, 0.89 56%, 0.96 78%, 1);
}

@keyframes curtain-lift {
  from { transform: translateY(0); }
  to   { transform: translateY(-100%); }
}

/* HELD STILL WHILE THE CURTAIN IS DOWN. Set pre-paint by the inline script in
   <head> and removed by it 2600ms later — see the note there for why the
   release lives in that script and not in app.js.

   On both the root and the body because the two engines disagree about which
   one a touch scroll obeys; overscroll-behavior kills the rubber-band, and
   touch-action stops the gesture before it starts rather than after a frame of
   movement. Keyboard scrolling stops with them: there is nothing to scroll.

   The attribute is never set for a returning visitor or for Reduce Motion, so
   the finished page is never locked. */
[data-curtain="playing"],
[data-curtain="playing"] body {
  overflow: hidden;
  overscroll-behavior: none;
  touch-action: none;
}

/* --- The opening, second time round -------------------------------------

   Set by the inline script in <head> when this session has already watched the
   curtain. The rule is subtractive by design: it only ever REMOVES intro
   animation, and every element's resting state is already its finished state,
   so the worst this attribute can do is show the finished hero immediately.
   It can never hide anything.

   Skipping the curtain alone would not be enough — the crest and the headline
   are on absolute delays timed to land after the lift, so without this they
   would sit invisible for two and a half seconds over a hero with no curtain
   in front of it, which is strictly worse than the curtain. All three go
   together or none do. */
[data-intro="seen"] .curtain {
  display: none;
}

/* .headline-name and .headline-line, NOT .headline: the entrance sits on the
   two children now, and `animation: none` on the parent would take its scroll
   exit with it — which is the exact bug the deleted returning-visitor
   re-declaration existed to patch. Naming the children means the parent's
   dissolve is never in range of this rule at all. */
[data-intro="seen"] .hero-crest,
[data-intro="seen"] .headline-in,
[data-intro="seen"] .hero-bar,
[data-intro="seen"] .hero-glass,
[data-intro="seen"] .hero-cta {
  animation: none;
}

/* The plate still settles, just without waiting for a curtain that is not
   there. Keeps the arrival from being a hard cut to a static photograph.

   Expressed as a variable rather than an animation-* override because
   .hero-photo carries TWO animations in the @supports block below (the settle
   on the document clock, the zoom on a scroll timeline). A bare
   `animation-duration` here would apply to both, and a time value on a
   scroll-timeline animation is read as a proportion of the range, which would
   quietly break the push-in. Overriding the input to the calc touches only the
   settle. */
[data-intro="seen"] {
  --hero-settle: 620ms;
}

.curtain-line {
  margin: 0;
  /* SWITZER, NOT FUTURA BOLD, from Jesse's own mock of this card: the
     letterforms in it are a neutral grotesk at a medium weight — a
     double-storey g, a straight-tailed y, no geometric o — and Futura Bold is
     none of those. It is the page's own display face at the weight the file
     actually ships, which is also the only Switzer cut lighter than 600 that is
     self-hosted (see the @font-face block at the top).

     THE SIZE IS THE MOCK'S PROPORTION, not the reference's ratio. In it the
     line occupies 56.2% of the frame's width, which at 11.891px of width per
     1px of size — this face at the weight and tracking below, measured — is
     4.726vw. The
     old 10.7vw was Futura's, measured off a phone capture of fixaplan.com, and
     a face this much narrower at a weight this much lighter cannot take it.

     ONE ROW, ALWAYS — Jesse's instruction, and the second term is what
     enforces it: (100vw - 56px) is what the curtain's 24px padding leaves after
     a 4px margin, and dividing by the same 11.667 is the largest size the box
     can hold. It is a BACKSTOP rather than the phone's governing term, and
     that is the difference from the Futura version it replaces — in a face this
     narrow the mock's ratio only crosses the guard at about 128px of viewport,
     so what actually binds on a phone is the 24px floor: 280px of a 375 screen,
     three quarters of it rather than the mock's 56%. The guard is left in
     because a face with other metrics is the failure this line cannot have.

     Change the words and both numbers are wrong. Re-measure the string at
     100px in this face and divide.

     `white-space: nowrap` states the rule the guard implements, so a face with
     unexpected metrics overhangs by a hair instead of silently stacking — the
     one failure mode this line is not allowed to have. */
  font-family: var(--font-display);
  /* 600, UP FROM 500, on Jesse's instruction and against a render he sent of
     "An app for St. Joseph." set in this weight. The face does not change —
     the opening was already the page's own display face — only the cut, and
     600 is the heavier of the two this project self-hosts. It is also the
     weight the closing card's ask and the sheet's title already take, so the
     first line the page says and the last one now match. */
  font-weight: 600;
  /* BOTH NUMBERS ARE RE-MEASURED FOR 600, per the rule above this block: the
     string runs 1189.1px at 100px in this cut against 1166.7 at 500, so the
     ratio is 11.891 and the mock's 56.2% of frame width resolves to 4.726vw.
     Carrying the 500 numbers over would have overhung the guard by 2% — which
     at 320px is the difference between fitting the row and stacking it. */
  font-size: min(clamp(24px, 4.726vw, 72px), calc((100vw - 56px) / 11.891));
  line-height: 1.1;
  white-space: nowrap;
  /* -0.03em, and it is the number the size above was measured at — change one
     and the ratio behind the other is stale. The mock's word gaps are ordinary,
     so the word-spacing correction that Futura needed is gone with Futura. */
  letter-spacing: -0.03em;
  color: var(--ink);
  text-align: center;
}

/* Word by word, not letter by letter -- the reference resolves whole words.

   Every number here is measured off it. See --intro-stagger for the cadence;
   this block is the two curves and the amount of blur, and both were re-derived
   the same way: integrate each word's ink over its own glyph mask, frame by
   frame, then recover the blur by blurring the FINAL frame at known radii until
   the ink spilling outside the glyphs matches. That second step also calibrates
   the measurement against CSS: run against this page it reads 6.36px where the
   stylesheet says 6.30px, so the numbers below are in the same units the
   browser uses and not in a scale of their own.

   OPACITY ONLY. This used to ramp `color` from #B4B4B4 to --ink alongside the
   opacity, which multiplies two ramps together: the ink a word actually puts on
   the page was opacity x (ground - colour), so the word crossed its halfway
   point at 64% of the fade instead of the reference's 47%. Over a flat ground a
   pure alpha ramp IS the darkening, which is all the reference does.

   BLUR IS A RATIO OF THE TYPE, NOT A FIXED PIXEL COUNT -- 0.077em, from 3.28px
   of blur on the reference's 42px line. It was 9px on a 20px line, which is
   0.45em: six times the reference, and the reason a word spent its first third
   as an unreadable smear rather than as soft-but-legible type. In em it also
   holds that ratio across the clamp, so the line blurs the same amount at 30px
   as at 20px.

   The blur and the opacity stay SEPARATE animations because they do not travel
   together: sampling gives blur ~ peak x (1 - alpha)^1.35, so focus runs ahead
   of the fade and a word is legible well before it is solid. Same 700ms span,
   different curve -- the linear() below is that relationship tabulated, with a
   bezier ahead of it as the fallback for browsers that drop it.

   No translate. The reference's ink centroid moves less than a point across the
   whole reveal, and that much is the blur's own doing; the words do not move,
   they only resolve. */
.curtain-word {
  display: inline-block;
  animation:
    curtain-word-fade  var(--intro-fade)  cubic-bezier(0.37, 0, 0.63, 1) both,
    curtain-word-focus var(--intro-focus) cubic-bezier(0.42, 0, 0.35, 1) both;
  animation-delay:
    calc(var(--intro-lead) + var(--i) * var(--intro-stagger)),
    calc(var(--intro-lead) + var(--i) * var(--intro-stagger));
  /* Fade stays the sine ease-in-out above; focus takes the measured curve.
     Browsers without linear() drop this whole declaration and keep both
     beziers, which is why the fade's is restated here rather than defaulted. */
  animation-timing-function:
    cubic-bezier(0.37, 0, 0.63, 1),
    linear(0, 0.033 10%, 0.127 20%, 0.270 30%, 0.436 40%, 0.608 50%,
           0.762 60%, 0.879 70%, 0.956 80%, 0.993 90%, 1);
}

@keyframes curtain-word-fade {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* 0.16em -> 0.077em, back to the reference's own measurement (3.28px of blur on
   its 42px line), with the same instruction that took the timings back: match
   it. 0.16em was that ratio pushed to make the arrival read across a room, and
   it was chosen while the line was 24-36px — now that the line IS the
   reference's 42px, the em ratio delivers the reference's blur in pixels too,
   which is the point of holding it in em.

   The ceiling on this is legibility, not taste. 0.45em is the setting the note
   above records as "an unreadable smear for the first third", and past ~0.25em
   the two curves stop reading as one motion and the word arrives twice. At
   0.077em a word is soft on arrival and readable well before it is solid, which
   is the relationship the separate focus curve exists to hold. */
@keyframes curtain-word-focus {
  from { filter: blur(0.077em); }
  to   { filter: blur(0); }
}


/* --- Hero --------------------------------------------------------------- */

/* svh, not vh: on mobile 100vh is the URL-bar-collapsed height, so a vh hero is
   taller than the screen on load. vh first as the fallback for browsers
   without svh.
   Still pinned — the paper sheet scrolls up over the town rather than the town
   sliding away with it. sticky rather than a scroll listener because the
   compositor drives it, so it cannot drop a frame fighting the main thread,
   and it works everywhere without a support gate.

   The pin itself must stay sticky for that reason. The plate's scroll zoom
   below does use `animation-timeline: scroll()`, which is why it sits behind an
   @supports gate: if it does not run, the hero is still correctly pinned and
   correctly framed, it just does not push in. The layout never depends on it. */
/* THE HERO IS NO LONGER EXACTLY ONE SCREEN. It was `height: 100svh` with the
   copy absolutely positioned inside it, which is precisely why the tower could
   never be big on a short window: absolute content contributes no height, so
   anything that did not fit had to be capped or it was clipped outright.

   min-height instead, with the copy in normal flow. The hero is one screen tall
   when the stack fits and grows past it when the tower is large, so on a laptop
   you now scroll a little to reach the button. That is the trade Jesse chose
   over shrinking the tower to fit.

   NOT sticky any more. A sticky element taller than the viewport pins its TOP
   at 0 and never moves, so its bottom — the Join button — becomes permanently
   unreachable. The pinned-hero-under-sliding-paper effect is the cost of a
   full-size tower on a short screen; the paper still arrives with its lip, it
   just no longer rides over a held photograph. */
.hero {
  position: relative;
  z-index: 0;
  min-height: 100svh;
  overflow: hidden;
  /* Holds the ground while the plate decodes, so the hero never flashes. */
  background: #1A1A1A;
  display: flex;
  /* BOTTOM-WEIGHTED, from the same Fixa reference as --hero-gap-action: its
     copy sits in the lower half of the frame and the open photograph carries
     the top, where ours split the slack evenly above and below and left the
     button 136px off the floor on a phone. End-aligned, the stack lands its
     own bottom padding from the edge and every spare pixel goes above the
     tower — which is sky and field, the part of the plate with nothing in it.

     On any screen where the copy is taller than the hero (a desktop, where the
     tower alone is 614px) there is no slack to place and this reads exactly as
     `center` did. */
  align-items: flex-end;
}

/* A background layer now, not a sibling that sets the height. inset:0 keeps it
   filling whatever the copy grows the hero to, so a tall hero gets more
   photograph rather than a stretched one. */
.hero-photo {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* Biased below centre: the town and the crowd are the subject, the sky is not. */
  object-position: center 58%;
}

/* A scrim, not the old paper wash — the headline is WHITE here, so the job is
   inverted. ONE band, behind the headline. The top darkener and the bottom ramp
   went with the nav pill and the CTA bar they existed for; with nothing over the
   plate at either edge, darkening it there only costs picture.

   The band's alpha is measured, not eyeballed. Rasterising the plate and
   sampling the 110k pixels the headline actually covers gives luminance
   p50 0.062 / p90 0.260 / p99 0.412 / max 0.759 — so white lands at 9.4:1 on
   the median but 3.39:1 at p90 and 2.27:1 at p99, which fails. Black at alpha a
   over an sRGB value v composites to v(1-a); carrying p99 (v 0.674) down to the
   0.183 luminance that white needs for 4.5:1 takes a >= 0.31. At 0.40 the p99
   pixel clears 5.66:1 and even the single brightest pixel in the band holds
   3.21:1, which is over the 3:1 this size of text requires.

   AN ELLIPSE BEHIND THE COPY, plus a shallow bottom ramp — the flat band that
   used to be here is gone. That band darkened the crowd and Main Street, the
   best part of the photograph, exactly as hard as it darkened the text: the
   density was spent on the picture rather than on the legibility problem. An
   ellipse anchored behind the copy puts it where the problem is and lets the
   town breathe at the edges.

   RE-AIMED AND RAISED WHEN THE HEADLINE'S GLASS PANE CAME OFF. While the
   sentence carried its own ground the scrim only had to hold the strip and the
   button, so the ellipse sat low, at 84% with a 0.58 peak. White type straight
   onto the plate is a different job and the old aim failed it — measured, not
   guessed.

   THE NUMBERS, sampled off the rasterised plate under the headline's own box,
   every pixel of it, not a stride. At 1440x900 (74,448 px of hero-town-3200):
   raw luminance p50 0.046 / p90 0.241 / p99 0.510 / max 0.795, so white on the
   raw p99 is 1.88:1 and on the brightest pixel 1.24:1. Compositing this
   gradient per pixel and re-measuring, the worst single pixel under the
   headline holds 3.66:1, the 1st percentile 6.13 and the 10th 9.64. At 390x844
   (23,409 px of hero-phone-800, where the crop puts Main Street under the
   type) it is 4.07 / 6.06 / 9.33. Both clear the 3:1 that display type this
   size requires, with the body of the box far above it. The old aim, sized for
   a headline that had its own glass ground, left that worst pixel under 3:1,
   which is why this moved.

   Centre 84% -> 70% (between the sentence and the strip), peak 0.58 -> 0.62,
   and the fade runs to 80% of the ellipse rather than 72% so the falloff is
   gentler across the type. A radial is at its stated alpha only at its centre,
   which is why the peak has to sit above the flat band's old 0.50.

   Re-measure with a canvas sample over the headline's rect whenever the copy,
   the type size or the plate changes. The bottom ramp also closes a real bug:
   the stack this replaced returned to alpha 0 at 100%, leaving a bright sliver
   of unscrimmed plate along the bottom edge under the button. */
.hero-scrim {
  position: absolute;
  inset: 0;
  pointer-events: none;
  /* RE-AIMED FOR THE TALLER HERO. The 40-74% band was measured when .hero was a
     fixed 100svh box with the copy pinned at top:58%, so the headline always
     sat inside it. Now the hero grows with the tower and the copy sits LOW in
     it — at 1440x900 the headline runs 716-853px of a 1084px hero, i.e. 66-79%,
     with its bottom half hanging below the old band's 74% end and straight onto
     unscrimmed photograph. The alpha argument above is unchanged and still the
     reason 0.50 is the number; only where it is aimed has moved.

     52-96% covers the text at every size measured (1000x560, 1440x900,
     390x844). Re-check it if the copy's padding or the tower's ceiling move,
     because both change where the text lands as a fraction of the hero.

     RE-AIMED AGAIN FOR THE ONE-LINE HEADLINE, and this time it was solved for
     rather than nudged. The sentence now runs 84% of the viewport on one line
     instead of two short centred lines, so its ends reach out past 110%/60%'s
     useful radius and onto the sunlit lot on the left of the plate: measured
     under the glyphs themselves — the plate composited with this scrim, sampled
     through a mask rendered from the live element's own font, size and tracking
     — the worst pixel fell to 2.31:1, under the 3:1 that large text needs.

     RE-FITTED AGAIN WHEN THE LINE WAS HALVED, and it got CHEAPER twice over.
     A line at 42% of the viewport instead of 84% sits entirely inside the
     scrim's strong core and never reaches the sunlit lot on the left of the
     plate that was setting the worst pixel, so the veil that a full-width line
     needed is simply too much veil.

     70% 20% at 50% 52% with a 0.92 peak fading to 100%, and the linear's mid
     stop back DOWN to 0.18, is what a search over (peak, width, height, centre,
     stop, mid) returns as the cheapest setting clearing 3.35:1 at all six
     windows measured — 320x700, 390x844, 1000x560, 1280x720, 1440x900,
     1920x1080. Mean alpha over the plate 0.2885, against 0.3349 for the
     full-width line's setting and 0.3466 for what shipped before today: the
     photograph is materially less veiled than it has been, and the type reads
     better than it did at either.

     It is a small bright core under the line rather than a band across the
     hero, which is what a half-width line asks for.

     For scale, the setting this replaces measured 2.36-2.68:1 across those four
     windows: white display type over the plate has been under 3:1 for a while
     and no screenshot was going to show it.

     The number to re-run if the copy moves is the min-under-glyph, not the
     band: composite the plate with the scrim and sample it through a mask
     rendered from the live element's own font, size and tracking. Do not
     eyeball a screenshot, and do not measure the text's bounding box — its gaps
     between words are not type and they hide the worst pixel. */
  background:
    radial-gradient(70% 20% at 50% 52%,
      rgba(0, 0, 0, 0.92) 0%,
      rgba(0, 0, 0, 0) 100%),
    linear-gradient(to bottom,
      rgba(0, 0, 0, 0) 26%,
      rgba(0, 0, 0, 0.18) 58%,
      rgba(0, 0, 0, 0.55) 100%);
}





/* Centred on the hero, biased below the middle so the town reads above it. */
/* IN FLOW, which is the whole change. As an absolutely positioned block it
   contributed nothing to the hero's height, so the tower had to be capped
   against the viewport or the button got clipped. In flow it pushes the hero
   taller instead, and the cap on .hero-crest could come off.

   The old `top: 58%` bias is now padding. The flex centring above puts the
   stack in the middle and the asymmetric padding biases it off centre — the
   heavier bottom value lifts the copy so the town still reads under it, which
   is the half of the 58% that survived the audit. (Measured: 59 above / 73
   below at 1000x560, 65 / 92 at 1440x900.)

   THREE INTERVALS, NOT ONE, and this box is where they are declared. Every gap
   inside the hero used to land in the same 14-26px band — clamp(14,2vw,22)
   under the tower, clamp(18,2.2vw,26) over the strip, a hardcoded 14 over the
   button — three separately-authored values that happened to agree. Nothing
   grouped, because nothing separated.

   Grouped by meaning instead: the tower is one object, the headline and the
   strip are ONE message, the button is the action.

       tower --45-- headline --27-- strip --36-- button      (desktop)
       tower --30-- headline --18-- strip --24-- button      (phone)

   ONE STEP, THREE MULTIPLES, rather than three independently-authored clamps
   that had to be kept in agreement by hand. --hero-step is the base and the
   intervals are 2.5x / 1.5x / 2x of it, so the ratio between them is fixed at
   every viewport and a future change to the rhythm is one number instead of
   three. The multiples are 5 : 3 : 4 — the tight interval stays the smallest
   of the three by a clear margin, which is what makes the headline and the
   strip read as one message, and the loose one stays the largest.

   THE TIGHT INTERVAL WENT 20 -> 27 (12 -> 18 on a phone) on Jesse's
   instruction to drop the strip. It is still the tightest gap in the stack, so
   the grouping the diagram exists to protect is intact; the strip simply sits
   a line lower under the headline than it did.

   flex, and that is load-bearing rather than tidy: the children are
   shrink-to-fit blocks with auto margins, sitting over a `.button {
   margin-top: 24px }` global that .hero-cta was overriding with a hardcoded 14.
   A flex column has no margin collapsing, and align-items does the centring the
   auto margins were for. */
.hero-copy {
  /* The stack's spacing scale. Read by .hero-crest, .hero-glass and
     .hero-cta below — they inherit these, they do not redeclare them. */
  --hero-step: clamp(12px, 1.25vw, 18px);
  --hero-gap-loose: calc(var(--hero-step) * 2.5);
  --hero-gap-tight: calc(var(--hero-step) * 1.5);
  /* 2 -> 4.5, from the Fixa reference (fixaplan.com, 2026-09-09): its bottom
     row sits a long way under the sentence it follows — the void between the
     two is wider than the sentence's own type is tall, and it is what makes
     the button read as the bottom of the screen rather than as the next item
     in a stack. Ours had the CTA 36px under the strip on a desktop, which is
     nearer the strip than the strip is to the headline.

     The 5 : 3 : 4 rhythm the note above documents is now 5 : 3 : 9. The tight
     interval is still the smallest by a clear margin, so the headline and the
     strip still group; what changed is that the action is now the largest gap
     in the stack rather than the middle one, which is the reference's own
     order.

     STILL A MULTIPLE OF THE STEP, not an svh, so it cannot outgrow the phone
     it is measured on: 54px at the 12px floor against the ~65px of slack a
     375x812 hero has left after the tower, and 81px on a desktop where the
     hero is taller than the viewport anyway. */
  --hero-gap-action: calc(var(--hero-step) * 4.5);
  position: relative;
  z-index: 2;
  width: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  /* Was 6svh top and bottom. The audit measured 124px of nothing above the
     tower and 124px below the button on a phone while the elements that needed
     room to separate got 14px — generous where it did no work, tight where it
     was needed. The bottom stays the larger of the two, which is the old 58%
     bias still doing its job. */
  /* The bottom is the reference's, measured: its bar's underside sits 52px off
     the viewport floor at 1280x800, where ours sat at 72 and pushed the hero
     7px past the screen. 6.5svh reproduces 52 at that height and stays within a
     pixel of it on a phone. The top is unchanged — it is slack above the tower
     either way. */
  padding: clamp(36px, 6svh, 64px) 24px clamp(44px, 6.5svh, 64px);
  text-align: center;
}

/* .hero-sub IS GONE, NOT HIDDEN. It read "Block Party shows you what's on in
   St. Joseph, with enough notice to be there. / Coming to iPhone." and sat 20px
   under the headline in 15-18px white, with its own shadow pair and its own
   entrance delay. Removed on Jesse's instruction: the first screen is the
   headline and nothing else, and the next words a visitor meets are on the
   paper sheet, a scroll away.

   DELETED RATHER THAN display:none-d, deliberately. A hidden paragraph still
   ships, still has to have its contrast argued every time someone audits the
   page, and would have come back the first time a reader toggled it looking for
   the copy. Four other rules referenced it — the intro skip, the reduced-motion
   entrance, the scroll exit, and the returning-visitor exit — and all four were
   cut with it rather than left pointing at nothing.

   IT IS NOT WHAT .hero-glass BELOW IS. The sub was two sentences of body copy
   set naked on the photograph and argued for on contrast every audit; the strip
   is one ten-word line on its own dark ground, which is a different object with
   a different failure mode. The lesson that killed the sub — do not put a
   paragraph over a picture — is why the strip has a word ceiling. */

/* --- The glass strip and the hero's action ------------------------------
   Both ported from the Fixa reference recording (fixaplan.com, 2026-08-27),
   measured off the frames at 1206x2622 / 3x rather than eyeballed:

     panel   x 69-333pt of a 402pt viewport (264 wide), y 474.7-544.7 (70 tall)
             -> 16pt padding around two 19.3pt lines of ~15px type
     radius  ~16pt
     ground  photo luminance 63 -> 36 across the panel edge, i.e. black at ~0.43
     gap     8pt from the panel's bottom to the button's top
     button  42pt tall, white, centred, auto width

   What was NOT copied: the 42pt button height (this page holds a 48px minimum
   touch target — see PRODUCT.md) and the pure white (this page's white is
   --paper). */

/* The strip is a shrink-to-fit box centred by .hero-copy's text-align, not a
   full-width band: a band across the photograph would read as a second scrim
   and would fight the plate the way the old CTA bar did.

   THE GROUND IS ITS OWN, not borrowed from .hero-scrim. The strip may sit
   outside the scrim's 52-96% band on a short viewport, so its contrast cannot
   depend on it. White on 0.42 black over the plate's BRIGHTEST measured pixel
   (v 0.759, the value .hero-scrim's own note derives) is 6.1:1 with no scrim
   under it at all, and 9.5:1 with one. The blur is what makes it read as glass
   rather than as a grey box; the ratio above holds without it, so a browser
   that drops backdrop-filter loses the material and keeps the legibility. */
.hero-glass {
  /* block + fit-content, NOT inline-block, and that is a bug fix rather than a
     preference. Both this and .hero-cta are inline-level boxes inside a
     text-align:center parent, so on a wide viewport they fit on one line
     together and the strip and the button sat SIDE BY SIDE at 1440. A block box
     that shrink-wraps keeps the shrink-to-fit shape and forces the button onto
     its own line at every width. */
  /* A FLEX ROW, not a block of text with an image in it: the icon sits beside
     the sentence at the height of both lines, which an inline box cannot do
     because inline boxes are sized against ONE line. width:fit-content still
     shrink-wraps the pair, so the strip is only ever as wide as it needs. */
  display: flex;
  align-items: center;
  /* 14px -> 0.5em, on Jesse's instruction to move the icon closer to the words.
     In em so it tracks the clamp below rather than staying a phone-sized gap
     next to desktop-sized type. */
  gap: 0.72em;
  width: fit-content;
  /* THE TIGHT INTERVAL of the three (see .hero-copy): the strip finishes the
     headline's thought, so it sits closer to it than anything else in the
     stack. The auto margins are gone — the flex column centres it. */
  margin: var(--hero-gap-tight) 0 0;
  /* 14/18 -> 10/14: trim the excess glass, so the panel hugs the pair instead of
     framing it. Then 12/16 with 18 on the LEFT, which is an optical correction
     rather than a second trim: the icon's own bounding box already carries
     visual margin on its right, so equal padding looks unequal.

     NOW IN em, AND THAT IS THE SAME ARGUMENT THE gap ABOVE MAKES. Those three
     px values were measured against the 15px floor, so on a 1440 desktop the
     type had grown by half and the glass around it had not — the panel got
     tighter as the words got bigger, which is the wrong direction. 0.75/1/1.125em
     reproduces 12/16/18 exactly at the floor and tracks the clamp from there,
     so the panel holds one proportion at every width. The left-heavy ratio (and
     therefore the optical correction) survives the unit change unchanged. */
  /* SYMMETRIC AGAIN now that the mark is out of the strip. The extra 0.125em
     on the left was an optical correction for the icon's own bounding-box
     margin on its right; with no icon there is nothing to correct for and the
     sentence just sits off-centre in its panel. Put 1.125em back with the
     icon. */
  /* 0.75/1em -> 0.6/0.8em, on Jesse's instruction to trim the panel. Same move
     its own note above records the last time ("trim the excess glass, so the
     panel hugs the pair instead of framing it"), one step further now that the
     icon is gone and there is only a sentence to hug. 297x74 -> 290x69 at 393.
     Still in em, so the proportion holds from the 18px phone floor to the
     26.4px desktop ceiling. */
  padding: 0.6em 0.8em;
  /* ROUND, NOT SQUARE, and it had never been either on purpose: this panel
     carried no radius at all, so below 1280 it was a hard-cornered rectangle on
     a photograph while every other object on the page was rounded. Above 1280
     it disappears into .hero-bar, which has its own radius, which is why the
     square only ever showed on a phone.

     --radius-button rather than a radius of its own. The strip and the Join
     button under it are one message in two boxes, so they take one shape. */
  border-radius: var(--radius-button);
  /* Ceiling 17 -> 20, then 20 -> 22 with the floor lifted 15 -> 16, on Jesse's
     instruction to make the strip larger. At 17px under a 66px headline it read
     as a caption on a footnote, which is most of what was wrong with the desktop
     hero; 15px on a phone was the same problem one size down, a body-copy line
     under a drawn headline.

     16 IS ALSO THE FLOOR THE REST OF THE PAGE USES for anything a visitor has
     to read on a phone (see the form inputs and PRODUCT.md), so the strip is no
     longer the one line on the first screen set under it.

     The measure below is pinned to this floor — see its note. Moving either end
     of this clamp means re-checking the two-line break at 390 AND at 1440.

     UP 20% ON JESSE'S INSTRUCTION: 16/1.55vw/22 -> 19.2/1.86vw/26.4 for the
     slope and the ceiling. The padding and the gap are in em and the icon is
     2.2em, so the whole panel grows with this one number — that is what the
     unit change above bought.

     THE PHONE FLOOR IS 18, NOT 19.2, AND THE TWO-LINE RULE IS WHAT SETS IT.
     The strip spends 5.045em of its own width on chrome before the words get
     any (2.125em of padding, a 0.72em gap, a 2.2em icon), and what is left has
     to hold the sentence on TWO lines: at three, the icon stands short beside a
     taller block, which is the exact failure the note under .hero-glass-line
     describes.

     RE-MEASURED for "Know what's happening in your town.", which is 16.65em set
     solid where the sentence before it ("Block Party puts all community
     happenings in one app.") was 24.37em. The shorter line does NOT buy back
     the missing 1.2px, because the break is set by its longest two-line half —
     "Know what's happening" — not by its total: a 320 phone holds two lines up
     to 18.5px and goes to three at 18.6, and 320 is the width that governs.
     18 is that ceiling with the same slack it always had, for a font that loads
     at a fractionally different width.

     So 18 is +12.5% where the slope and the ceiling take the full +20%, and
     that gap is the narrow phone's rather than a hedge. Every width above ~530
     takes the whole 20%.

     Change this sentence and re-measure: the floor is the largest size at which
     the narrowest phone still balances it onto two lines beside the icon. */
  font-size: clamp(18px, 1.86vw, 26.4px);
  font-weight: 500;
  line-height: 1.32;
  letter-spacing: -0.01em;
  /* GLASS, on Jesse's instruction — back to the Fixa reference's own material
     after the paper pass. White on 0.42 black over the plate's BRIGHTEST
     measured pixel (v 0.759, the value .hero-scrim's note derives) is 6.1:1
     with no scrim under it at all, and 9.5:1 with one, so the legibility does
     not depend on the blur: a browser that drops backdrop-filter loses the
     material and keeps the contrast.

     No box-shadow. The two warm shadows belonged to the paper strip — a lifted
     drop shadow under a translucent panel reads as a card floating over the
     photo rather than as a pane cut into it.

     RADIUS IS THE PILL NOW, and the argument that used to sit here — that 16
     against the strip's own radius reads as unresolved because the two nearly
     match — is what settles it rather than what blocks it. --radius-pill does
     not nearly match anything; it is decisively a different shape, so the two
     stacked objects read as a control on a panel instead of as two panels. It
     also carries .button--pill, so leaving a radius here at all would have
     meant this one control opting out of the shape every other pill takes. */
  color: #FFFFFF;
  background: rgba(0, 0, 0, 0.42);
  -webkit-backdrop-filter: blur(12px) saturate(1.08);
  backdrop-filter: blur(12px) saturate(1.08);
}

/* THE 20% BOUGHT THE TYPE AND SPENT THE MEASURE, so the strip takes 10px of
   the gutter back on a phone. At 19.2px the sentence's balanced two-line width
   is ~12.2em, and .hero-copy's 24px padding leaves the text box only 12.0em
   once the strip's own padding, its gap and the 2.2em icon are taken out —
   four pixels short, which broke it to THREE lines and left the icon standing
   short beside a taller block, the exact failure its own note below describes.

   A negative inline margin on a fit-content box RAISES ITS CEILING rather than
   moving it: the shrink-wrap still only takes what the words need, so the strip
   lands ~22px from each screen edge instead of stretching. The headline above
   eats 12px of the same gutter for the same reason, so the two agree.

   Still three lines at 320, where the text box is 10.2em however this is
   written — that is the 320 column, not this rule, and it was three there
   before the type grew. */
/* THE -10px BLEED IS GONE, and measuring after removing it is what showed the
   rule had never done anything. The panel is `width: fit-content`, so it
   shrink-wraps its two balanced lines; a negative inline margin on a
   shrink-to-fit box widens the SPACE it may use, not the box, and the content
   only ever asked for 297px of it. Same 297px before and after — the bleed was
   a rule that read as a decision and was a no-op. The trim is in the padding,
   which is where this panel's previous trim was too. */

/* TWO LINES AT EVERY SIZE, and the unit is what guarantees it. The measure has
   to hold the sentence to two lines, because that is the height the icon beside
   it is sized against — one line and the icon is twice the text's height with a
   band of empty glass above and below it, which is exactly what the desktop
   hero was doing at 20px type.

   12em, AND THE TIGHTENING FROM 18 IS THE TRIM. A max-width is what the text
   wraps IN, not what it ends up occupying: `text-wrap: balance` evens the two
   lines rather than filling the first, so at 18em the lines measured 155 and
   163px inside a 267px box — 104px of dead glass to the right of the words,
   which is what "excess" was. The sentence's own balanced lines run ~10.9em, so
   12em is the smallest measure that still holds them to two lines with rag to
   spare, and the panel is now the width of the words.

   em, not `ch`. `ch` is the width of the font's zero, and this face's is wide
   enough that the old 32ch landed at ~410px at 20px — WIDER than the whole
   sentence, so the cap went inert on desktop and the line stopped wrapping at
   all. em tracks the type directly and holds the break at every size in the
   clamp: natural width ~20.4em, longest balanced line ~10.9em.

   It lives on the span rather than on the strip, because the strip is a flex
   container and a max-width there would cap the PAIR rather than the text.
   text-wrap:balance moved with it for the same reason — it belongs to the
   sentence, not to the row.

   12 -> 14.5em with the longer sentence. "Block Party puts all community
   happenings in one app." measures ~24.4em natural against the old line's
   ~20.4, so at 12em it balanced to THREE lines and the icon beside it — sized
   at 2.2em against two — became a short mark against a tall text block.

   THE MEASURE IS SET AT THE FLOOR, NOT THE CEILING, and that is why it is not
   14. em tracks the type but glyph advances do not scale perfectly linearly
   across sizes, so the break lands differently at each end of the clamp: swept
   in quarter-em steps, 14em holds two lines at the 17.9px ceiling and breaks to
   THREE at the 15px floor, where the first two-line measure is 14.25em. 14.5
   clears the floor's threshold with a quarter-em to spare and is still two
   lines at the ceiling, so both ends hold. Check any future copy at 375 as well
   as at 1280 — the phone is the end that breaks first. */
.hero-glass-line {
  max-width: 14.5em;
  text-wrap: balance;
}


/* THE BOTTOM ROW, FROM THE FIXA REFERENCE (fixaplan.com, measured at 1280x800
   on 2026-09-09). Its two objects are ours: a translucent pill carrying one
   sentence, with the white waitlist button seated inside the pill's right end
   rather than under it. Every number below is read off that page:

     pill      rgba(0,0,0,0.25), blur(10px), radius 12, 50px tall,
               padding 4px 4px 4px 16px, 24px between text and button
     text      16px / 500 / -0.32px, white
     button    white, radius 12, 42px tall, padding 0 16, 16px / 500

   The 16px left padding against 4px everywhere else is not a typo — it is what
   centres the button's own box inside the pill while the text still clears the
   left edge.

   1280px IS THE REFERENCE'S OWN BREAKPOINT, not a round number, and it is the
   TOP one of its four: (min-width:1440), (1280-1439.98), (810-1279.98),
   (max-width:809.98). The row was built at 810 on the assumption that its
   tablet band matched its desktop; a 936px-wide screen recording of the live
   page settles it — at that width Fixa stacks these two exactly the way this
   page already did, pill over button. So everything under 1280 keeps the rules
   it has and this query is the only place the row exists. */
.hero-bar {
  /* Not a box at all below the query — the strip and the button stay direct
     children of .hero-copy's column and nothing they carry has to be undone. */
  display: contents;
}

@media (min-width: 1280px) {
  .hero-bar {
    display: flex;
    align-items: center;
    gap: 24px;
    /* The reference's own interval between the line above and this row. */
    margin-top: 85px;
    padding: 4px 4px 4px 16px;
    border-radius: 12px;
    background: rgba(0, 0, 0, 0.25);
    -webkit-backdrop-filter: blur(10px);
    backdrop-filter: blur(10px);
  }

  /* The strip stops being a pill and becomes the row's text: the material
     moved up to .hero-bar, which is now the only pane. */
  .hero-bar .hero-glass {
    margin: 0;
    padding: 0;
    background: none;
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
    border-radius: 0;
    font-size: 16px;
    font-weight: 500;
    line-height: 0.95;
    letter-spacing: -0.02em;
  }

  /* One line in a row that is sized by its contents; the two-line measure and
     the balance belong to the stacked pill below the query. */
  .hero-bar .hero-glass-line {
    max-width: none;
    text-wrap: nowrap;
    white-space: nowrap;
  }

  /* Seated in the pill: the reference's 42/16/12, against the 61.6px pill this
     button is on its own. Colour untouched — it is already the white glass the
     reference uses. */
  .hero-bar .button.hero-cta {
    height: 42px;
    min-height: 0;
    margin: 0;
    padding: 0 16px;
    font-size: 16px;
    font-weight: 500;
    border-radius: 12px;
  }
}

/* JUST UNDER the height of both lines stacked, in em rather than px so it
   tracks the strip's own clamp. Two lines x 1.32 line-height is 2.64em, and at
   exactly that the icon is fractionally TALLER than the text block beside it —
   the last line's descender space is not ink — so the strip read as
   icon-with-caption rather than as a line with a mark on it. 2.2em sits just
   under the text block and the sentence leads the row. Not `align-self:
   stretch`, which would look equivalent and is not — a stretched flex item
   contributes its INTRINSIC height to the row first, so a 256px icon would set
   the strip's height and then stretch to the height it had just caused. Stating
   the two lines is both the intent and the only version that terminates.

   flex:none so it never shrinks under a narrow viewport; the sentence wraps
   instead, which is what should give.

   NO border-radius, and that is the point of the asset rather than an omission.
   bp-icon-512.webp is already cut to the squircle with transparent corners (see
   index.html), so it lands as a real app icon rather than as a rounded-off
   square. A CSS radius on top would only re-cut a shape that is already cut, and
   at a different curve than Apple's. */
.hero-glass-mark {
  flex: none;
  width: 2.2em;
  height: 2.2em;
  /* The tile is a light object on a dark strip, so it needs the same treatment
     as the crest and the CTA over the photograph: one tight contact shadow for
     weight, one wide soft one for lift. drop-shadow, not box-shadow — the
     corners are transparent and box-shadow would trace the square. */
  filter:
    drop-shadow(0 1px 1px rgba(0, 0, 0, 0.35))
    drop-shadow(0 4px 10px rgba(0, 0, 0, 0.30));
}

/* The hero's one action. 625c699 removed the coral pill that used to live here
   because --accent appeared twice on a page with one ask; it came back as
   PAPER to keep that count at one, and it is now ORANGE again on Jesse's
   instruction: every Join button on the page wears the crest's colour, so the
   accent no longer marks the single control, it marks the single ASK. Three
   buttons say "Join the waitlist" — here, on the closing band, and the sheet's
   own submit — and they are one thing said three times rather than three
   controls competing. Nothing else on the page has taken the accent.

   It also does a different job than the pill 625c699 removed: that was a jump
   link to a form four screens down, this opens the sheet in place.

   The shadow is not decoration. It sits on a photograph whose brightness under
   the button cannot be predicted, and a soft dark drop is what keeps the pill's
   edge legible over a pale roof as well as a dark tree line. Same light source
   as the crest above it: straight down. */
/* WHAT IS LEFT AFTER .button AND .button--pill: the scaling and
   the shadow, which are the only two things about this control that are not
   true of the others. Everything else it used to declare for itself — the flex
   row, the radius, the weight, the tracking, the fill, the transition, the
   hover and the press — is the shared primitive now. */
/* .button.hero-cta, not .hero-cta — (0,2,0) against the primitive's (0,1,0).
   .button and .button--pill are declared far below this block, so at equal
   specificity source order handed them the height, the padding and the size
   this rule exists to override, and the hero's pill silently came out at the
   page default. Every override on top of the primitive has to out-specify it,
   the same way .sheet-card .button does. */
.button.hero-cta {
  /* A ROUNDED RECTANGLE, NOT A PILL, on Jesse's instruction — and the same
     radius the strip above it now takes, because the two are one message in two
     boxes. It carries .button--pill for its layout (inline-flex, the gap, the
     shrink-to-fit width), and that class hands out --radius-pill; this rule is
     (0,2,0) against its (0,1,0), so the shape comes back here without touching
     the pill every other Join button on the page still takes.

     THE OTHER TWO JOIN BUTTONS ARE STILL PILLS: the closing card's and the
     sheet's submit. That is a live inconsistency rather than a decision — this
     pass was called on the hero. */
  border-radius: var(--radius-button);
  /* Grows a little at the desktop end for the same reason the strip does — a
     52px/16px pill under 66px display type reads as a secondary control. Floors
     untouched: a phone gets exactly what it got before. height:auto so the
     primitive's fixed --btn-h does not pin it. */
  height: auto;
  min-height: clamp(57.2px, 4.29vw, 61.6px);
  /* The other loose interval (see .hero-copy). The old 14 was a hardcoded
     override of the .button primitive's own margin-top:24 — a number picked to
     beat another number rather than to say anything. .hero-copy is a flex
     column now, so this is simply the gap. */
  margin: var(--hero-gap-action) 0 0;
  /* Up 10% on Jesse's instruction, all three together so the pill keeps its
     proportion: 52/3.9vw/56 -> 57.2/4.29vw/61.6 tall, 28/2.2vw/32 -> 30.8/2.42vw/
     35.2 of side padding, 16/1.25vw/18 -> 17.6/1.375vw/19.8 of type. */
  padding: 0 clamp(30.8px, 2.42vw, 35.2px);
  font-size: clamp(17.6px, 1.375vw, 19.8px);
  /* THREE layers, not one — this file's own rule, stated 480 lines up and
     obeyed by the crest and the app icon but not by the button the page exists
     for: "a single mid-blur cast is what a cut-out pasted onto a photo looks
     like." A contact layer seats the pill's bottom edge, a cast layer lifts it,
     both warmed toward the paper rather than left neutral black — on a warm
     page that is the difference between a shadow and a grey smear.

     The third is a GLOW rather than a shadow, and it is the one doing the
     separating: the ground here is a dark aerial photo, where a black ring is
     black on black and does nothing. A zero-offset warm light (the paper, not
     white — white on a warm page reads as a screen artifact) rings the pill on
     every side, so the shape is cut out of the trees left and right as well as
     below. 0.475 — up a quarter from 0.38 on Jesse's read of it live, and
     still under half: a glow that announces itself is a button that looks
     like it is loading. */
  box-shadow:
    0 1px 2px rgba(30, 22, 16, 0.30),
    0 0 18px rgba(250, 250, 247, 0.475),
    0 10px 30px rgba(30, 22, 16, 0.30);
  /* Named properties, never `all`: this element also runs the fixa-settle
     entrance on opacity/transform, and a blanket transition would fight that
     animation frame for frame. */
  transition: transform var(--press) var(--ease-out),
              filter var(--hover) ease;
}



/* --- Motion ------------------------------------------------------------- */

/* Every animation here is additive: the resting state of each element is its
   finished state, so reduce-motion, an unsupported browser, and a crawler all
   land on the same fully-rendered page. */
/* THE HERO ARRIVES AS FIXA'S DOES, and the numbers below are that page's own,
   read out of its appear-animation data (fixaplan.com, 2026-09-09) rather than
   eyeballed off a recording. Its five hero elements each carry an initial
   transform, a delay and a duration:

     nav pill            y -85     900ms  cubic-bezier(0.53, 0, 0.47, 1)   +0
     headline line 1     y +130    1000ms cubic-bezier(0.12, 0.23, 0.5, 1) +100ms
       with rotate 2deg
     headline line 2     y +100    1000ms same curve                      +300ms
       with rotate 2deg
     sub-line            y +15     600ms  same curve, fading              +500ms
     bottom bar          y +15     600ms  same curve, fading              +1000ms

   The offsets are relative: Fixa's absolute delays are 2.3-3.3s because its own
   loader runs first, so what is copied is the CADENCE off the first element,
   and ours counts from the beat the curtain finishes on exactly as the old
   sequence did.

   ITS TWO HEADLINE LINES DO NOT FADE, and that is the character of the thing:
   they are already opaque and they swing down into place from below with a 2
   degree tilt coming out, which is what reads as falling into position rather
   than resolving out of nothing. Only the two small elements underneath fade.
   Our lines land on OUR elements the same way — the name is line 1 (the longer
   throw), the sentence is line 2 — and the tower takes the nav pill's drop from
   above, being the one thing on our page that arrives from off the top.

   The blur that chrome-focus and lines-in carried is gone with them. Fixa uses
   none, and a blur on a 76px wordmark is the expensive half of the effect.

   Every resting state below is the FINISHED state and each keyframe runs `from`
   the hidden one, so Reduce Motion and any browser that cannot animate land on
   the complete hero rather than an empty one. */
@media (prefers-reduced-motion: no-preference) {
  .hero-crest {
    animation: fixa-drop 900ms cubic-bezier(0.53, 0, 0.47, 1)
               calc(var(--intro-reveal) + var(--intro-chrome)) both;
  }

  /* ON THE TWO CHILDREN, NOT ON .headline, because Fixa moves its lines
     independently and 200ms apart — one animation on the heading could only
     move them together. .headline keeps the filter and the scroll exit; the
     entrance is the children's. */
  .headline-name .headline-in {
    animation: fixa-line-1 1000ms cubic-bezier(0.12, 0.23, 0.5, 1)
               calc(var(--intro-reveal) + var(--intro-chrome) + 100ms) both;
  }

  .headline-line .headline-in {
    animation: fixa-line-2 1000ms cubic-bezier(0.12, 0.23, 0.5, 1)
               calc(var(--intro-reveal) + var(--intro-chrome) + 300ms) both;
  }

  /* THE PILL AND THE BUTTON SHARE THE BAR'S BEAT AT EVERY WIDTH. +500 is the
     reference's SUB-LINE — the dim two-liner between its headline and its bar,
     which this page does not have — and giving it to the strip put our pill
     half a second ahead of the button it belongs with. Both take +1000, which
     is the beat the reference gives the bar itself, stacked or not. */
  .hero-glass {
    animation: fixa-settle 600ms cubic-bezier(0.12, 0.23, 0.5, 1)
               calc(var(--intro-reveal) + var(--intro-chrome) + 1000ms) both;
  }

  .hero-cta {
    animation: fixa-settle 600ms cubic-bezier(0.12, 0.23, 0.5, 1)
               calc(var(--intro-reveal) + var(--intro-chrome) + 1000ms) both;
  }

  /* ONE OBJECT ONCE IT IS ONE OBJECT. Above 1280 the strip and the button are
     inside a single pill, and Fixa animates that pill as a unit on its own
     beat — the +500 sentence and the +1000 button are its PHONE arrangement,
     where they are two separate objects stacked. Left split inside the pill,
     the text would fade in half a second before the button it shares a pane
     with. The bar takes the later of the two delays, which is the one the
     reference gives the bar itself. */
  @media (min-width: 1280px) {
    .hero-bar {
      animation: fixa-settle 600ms cubic-bezier(0.12, 0.23, 0.5, 1)
                 calc(var(--intro-reveal) + var(--intro-chrome) + 1000ms) both;
    }

    .hero-glass,
    .hero-cta {
      animation: none;
    }
  }

  /* The plate settles under the curtain, so by the time it lifts the scale is
     already most of the way home and the reveal is of a still image, not a
     moving one. */
  .hero-photo { animation: hero-settle var(--hero-settle) cubic-bezier(0.22, 1, 0.36, 1) both; }
}


/* Fixa's own four. The two lines carry the tilt; the two small elements carry
   the fade. Nothing here blurs. */
@keyframes fixa-drop {
  from { transform: translateY(-85px); }
  to   { transform: none; }
}

@keyframes fixa-line-1 {
  from { transform: translateY(130px) rotate(2deg); }
  to   { transform: none; }
}

@keyframes fixa-line-2 {
  from { transform: translateY(100px) rotate(2deg); }
  to   { transform: none; }
}

@keyframes fixa-settle {
  from { opacity: 0; transform: translateY(15px); }
  to   { opacity: 1; transform: none; }
}


/* THE SHADOW IS THE ALTITUDE OF THE WORDS. That is the whole conceit, and both
   keyframes below are the same idea read in opposite directions: a line lying
   on the photograph casts the tight --line-rest shadow it has always rested
   with; a line held high above it casts --line-lift, thrown much longer and
   much softer. Nothing else has to explain itself — the shadow says how far off
   the page the words are, so arriving and leaving are one gesture, not two.

   ONLY THE EXIT IS LEFT. lines-in used to be the other half of this — the
   lines falling out of a blur from 12px up with the shadow tightening as they
   landed — and the Fixa entrance replaced it: that one moves the two lines
   separately, and it moves them on the children, where a shadow that belongs
   to the whole heading cannot follow. The arrival no longer travels the
   shadow; the departure still does.

   lines-out runs on scroll: they lift back off the plate, the shadow
   lengthens and softens as they climb, and they fade out on the way up. Up
   rather than down on purpose — the paper sheet is rising to meet them, so the
   words get out of its way rather than sinking into it.

   NO blur() HERE. lines-out is driven by a scroll timeline, so it repaints on
   every frame the visitor scrolls, and stacking a filter blur on top of an
   animated shadow means two full-cost paint passes per frame on the largest
   thing on the page. The growing shadow already reads as "out of focus,
   further away"; the blur would only buy jank.

   drop-shadow(), not text-shadow(): the shadow is declared on .headline, which
   wraps both lines, and a filter is what applies to a box rather than to the
   glyphs inside one. */
@keyframes lines-out {
  from { opacity: 1; filter: drop-shadow(var(--line-rest)); transform: none; }
  to   { opacity: 0; filter: drop-shadow(var(--line-lift)); transform: translateY(-26px); }
}


@keyframes hero-settle {
  from { transform: scale(1.08); }
  to   { transform: none; }
}


/* The plate is a still now, so left alone it would just sit there. This gives it
   a slow push-in as the paper sheet climbs over it — 10% across one viewport of
   scroll, which is enough to read as alive and not enough to notice as an effect.

   The `scale` property rather than `transform: scale()`: hero-settle above
   already animates transform on this element, and the two individual transform
   properties compose rather than overwrite. So the load settle and the scroll
   push-in can both run without either having to know about the other.

   transform-origin matches object-position, so the zoom pushes into the town and
   the crowd rather than into the sky.

   Behind the same @supports gate the text reveal uses, and for the same reason:
   the resting state is the finished one, so a browser that cannot drive a scroll
   timeline shows exactly what it shows today — a sharp, static plate. Nothing is
   withheld from it, it just does not move. */
@supports (animation-timeline: scroll()) {
  @media (prefers-reduced-motion: no-preference) {
    .hero-photo {
      transform-origin: center 58%;
      /* BOTH animations are named here on purpose. This rule and the
         hero-settle rule above have identical specificity, so this one wins —
         and `animation` is a shorthand, so declaring only hero-zoom RESET
         animation-name and silently deleted the load settle in every browser
         that supports a scroll timeline, which is all of them that get here.
         The settle therefore has to be re-declared in this rule rather than
         left to compose from the one above.
         The two timelines are positional: settle runs on the document clock,
         zoom on the scroll timeline. */
      animation:
        hero-settle var(--hero-settle) cubic-bezier(0.22, 1, 0.36, 1) both,
        hero-zoom linear both;
      animation-timeline: auto, scroll(root block);
      animation-range: normal, 0 100vh;
    }

    /* THE OPENING LINES LIFT AWAY AS YOU SCROLL. Same two-timeline shape as the
       plate above it: the entrance still runs on the document clock, and a
       second animation runs on the scroll timeline. The plate pushes IN while
       the words rise OUT, which is what separates them in depth — the town
       advances, the sentence recedes.

       forwards, NOT both, AND THAT IS STILL LOAD-BEARING even though the
       entrance left this element. A scroll-timeline animation with `both` fill
       applies its from-keyframe through the whole before-phase — page load, at
       scroll 0 — and lines-out's from is the resting state with the resting
       shadow. Holding that from the first frame would pin .headline's filter
       and opacity for the entire intro, over children that are mid-entrance.
       `forwards` contributes nothing before the range opens.

       THE RANGES ARE SET BY WHEN THE PAPER ARRIVES, NOT BY TASTE. .hero is
       sticky and .page rides up over it, so the sheet's top edge sits at
       (100vh - scrollY) and it swallows each line from the bottom up.

       THE FIRST CUT MEASURED THE WRONG EDGE AND IT FELT LIKE A BURST. It ended
       each fade when the paper first TOUCHED the line, which on 1920x1080 gave
       the sub 8vh — about 72px, less than one notch of a mouse wheel — to go
       from solid to gone. No easing rescues a range that short; the words just
       snapped out.

       What actually matters is when the line is FULLY covered, because past
       that point its opacity is unobservable. Scroll offset at which the paper
       reaches each line's top:

         1920x1080  headline 33.6vh  sub 19.0vh   <- binding
         1366x1024           34.4        19.0     <- binding
         1680x1050           34.3        19.3
         1512x982            35.9        19.9
         1920x1200           34.4        21.3
         2560x1440           35.7        24.8
         1440x900            38.2        20.7
          390x844            36.9        26.1
         1280x720            45.1        23.3
          375x667            43.6        30.0
         1024x600            50.7        27.5

       The minimum binds, so the ranges close at 32vh and 18vh — roughly DOUBLE
       the first cut, which is the whole fix. Re-measure this table if the
       crest, the headline size or .hero-copy's 58% ever move, because all three
       change where the lines sit in the viewport.

       WHAT THE PAPER NOW MEETS. On the binding viewport the sheet first touches
       the headline at 20.9vh — 65% through a 32vh range — where the eased
       opacity is already down to 0.24 (the sub, touched at 11.3vh, is at 0.28).
       So the edge rides over a ghost rather than over solid type, which is what
       the short range was protecting against in the first place. Everywhere
       else the fade finishes earlier still, and on a phone it is long done
       before the paper is anywhere near.

       WHAT "SMOOTHER" IS WORTH, MEASURED. Scroll distance needed to cross the
       visible band (opacity 0.9 down to 0.1) at 1920x1080:

         headline   121px -> 187px
         sub         69px -> 105px

       Half again as much travel each, and the sub is no longer crossing the
       whole band inside a single 100px wheel notch — which is what "burst"
       was.

       STARTING AT 0 IS SAFE BECAUSE OF THE CURVE, not because of a dead zone.
       The first cut held a 5vh/2vh pause at the top so a stray one-pixel scroll
       could not start dissolving the headline. cubic-bezier(0.45, 0, 0.55, 1)
       does that job better: it is near-flat at both ends, so 5% into the range
       opacity is still 0.996. The lines now answer the very first pixel of
       scroll instead of ignoring it and then lurching, and they land softly
       instead of being cut off at full speed. linear is right for a scroll
       timeline that maps position to position; this one maps position to a
       DISSOLVE, and a dissolve wants soft ends.

       THE TABLE ABOVE IS A RECORD, NOT A LIVE SPEC, in one respect: the `sub`
       column measured .hero-sub, which no longer exists — only the headline
       column still drives anything, and 32vh is still what it sets. The rows
       are kept because they are the measurement that produced the number, and
       deleting them would leave 32vh looking arbitrary.

       That measurement is now CONSERVATIVE rather than stale. The headline
       grew from two lines to three when the copy changed, so its top edge sits
       HIGHER in the viewport and the paper reaches it later than every figure
       above — the fade therefore finishes further ahead of the sheet than it
       used to, which is the safe direction. Re-measure properly if the crest,
       the headline size, or .hero-copy's padding move.

       The CTA and the crest are deliberately NOT here. Never dissolve the one
       thing the page exists to collect, and the tower is the hero's subject
       rather than its message — the words go, the town stays. */
    /* ONE ANIMATION NOW, WHERE THERE WERE TWO. The entrance moved to
       .headline-name and .headline-line (see the Fixa block above), so the
       heading itself is left holding the scroll exit alone and the paired
       `animation-timeline` / `animation-range` lists collapse to one value
       each. */
    .headline {
      animation: lines-out cubic-bezier(0.45, 0, 0.55, 1) forwards;
      animation-timeline: scroll(root block);
      animation-range: var(--line-out);
    }

  }
}

@keyframes hero-zoom {
  from { scale: 1; }
  to   { scale: 1.10; }
}

/* Scroll-driven, no JS and no library. Gated behind @supports so browsers that
   cannot drive it simply show the text — which is why the resting state is
   visible and the animation only ever removes and restores it. */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    /* ENDS IN `cover`, NOT `entry`, AND THAT IS THE POINT. `entry 70%` is over
       while the element is still in the bottom third of the screen — the
       resolve finished before the reader's eye had arrived, so all it could
       ever read as was a pop. Carrying the end into the `cover` phase keeps it
       resolving after the element is fully on screen, which is the difference
       between "it animated" and "it is alive".

       Measured off the reference: its text sits muted at roughly 75% viewport
       height and is committed by 50%, i.e. it resolves across about a quarter
       of a viewport of travel AFTER full entry. For a paragraph-sized element
       against an 812px viewport that lands near cover 19%-38%; the band below
       starts a little earlier so nothing near the fold is held muted.

       AND THE WHOLE LADDER CAME UP, on Jesse's instruction: "move words/titles
       up so I can read them when I scroll so I don't have to scroll over
       halfway just to see them render." That is a literal description of what
       the numbers said. The last rung ended at `cover 50%` — the element is
       half way through crossing the screen before it is finally opaque — so a
       heading on rung 3 or 4 was still resolving at the middle of the viewport
       and a reader moving at a normal pace was reading it underneath the
       animation rather than after it.

       Every rung keeps its ORDER and its stagger; only the whole ramp slides
       earlier. Rungs are now 4/16, 10/22, 15/26, 20/30 against the old
       15/40, 28/46, 35/48, 42/50, so the latest thing on the page finishes at
       cover 30% instead of cover 50% — comfortably above the middle, which is
       the complaint. The reference's own "resolves after full entry" reading
       survives: entry still ends before cover begins, so nothing snaps to
       opaque while it is still climbing onto the screen.

       The px-based ranges moved with them by the same proportion rather than
       being left in place — a pixel range that no longer agrees with the
       percentage ladder around it is how one section ends up animating on a
       schedule nobody measured. */
    [data-reveal] > * {
      animation: reveal-in var(--ease-reveal) both;
      animation-timeline: view();
      animation-range: entry 4% cover 16%;
    }

    /* One beat later, so the headline leads and the sub follows. */
    [data-reveal] > * + * {
      animation-range: entry 10% cover 22%;
    }

    /* The About block runs to three children and .join's to four, so the cascade
       needs two more rungs. (The survey used to be counted here too — four
       children, then two; it holds exactly one now, so it never reaches this
       rung at all. The phone demo did not change that: it is inside the card,
       not beside it, so the card is still .survey-inner's only child.) Stagger is expressed as range offsets, not
       animation-delay: on a scroll timeline a delay is meaningless, since
       progress is driven by position rather than by elapsed time. */
    [data-reveal] > * + * + * {
      animation-range: entry 15% cover 26%;
    }

    [data-reveal] > * + * + * + * {
      animation-range: entry 20% cover 30%;
    }

    /* THE TALLEST ELEMENT ON THE RAMP, AND IT WAS ON THE LATEST RUNG — the two
       worst properties to combine. .scale has six direct children but the
       `+ *` chain only defines four rungs, so children 4, 5 and 6 all land on
       rung 4 (`entry 42% cover 50%`); .scale-list is child 4, and at 951px its
       range did not close until its top edge was already off the top of the
       screen. The whole list was therefore read at partial opacity — the one
       element the range could not defend, because the range defends body copy
       by finishing while the element's top is still above the middle.

       Handed down to its three items (329 / 426 / 110px at 375px wide), which
       are all comfortably shorter than the viewport and so behave like every
       other element here. The list itself stops animating and each item gets
       its own timeline; equal specificity to the [data-reveal] rules above, so
       these win purely by coming later — keep them below.

       .scale now has TWO direct children — heading, list — so it takes rungs 1/2
       and never reaches the shared third or fourth. The "The town" chip that
       held rung 1 is gone, .scale-close moved inside the card and .scale-source
       is gone; none of them takes a rung of its own any more. */
    .scale-list {
      animation: none;
    }

    .scale-list > * {
      animation: reveal-in var(--ease-reveal) both;
      animation-timeline: view();
      animation-range: entry 4% cover 16%;
    }

    .scale-list > * + * {
      animation-range: entry 9% cover 22%;
    }

    .scale-list > * + * + * {
      animation-range: entry 15% cover 28%;
    }

    /* SAME DEFECT AS .scale-list, AND THE ONE THAT ACTUALLY COSTS CONTRAST.
       .about-copy is child 4 of .about, so it too sits on rung 4, and at 638px
       it is the second-tallest thing on the ramp. Unlike display type, which
       owes 3:1, this block is 16px --ink-secondary body
       copy, which owes 4.5:1, and it was still at 2.41:1 with its top at the
       middle of the screen: squarely in the reading zone, well under the bar,
       and only reaching 3.70:1 by the time its top hit the upper quarter.

       Handed down to its paragraphs. Three rungs now covers its three children
       one apiece — it used to be four sharing the last rung, until the
       where-it-stands paragraph came out (see the note in index.html). Either
       arrangement is fine here: these are consecutive paragraphs of one
       passage, not separate claims that need to land apart. */
    .about-copy {
      animation: none;
    }

    .about-copy > * {
      animation: reveal-in var(--ease-reveal) both;
      animation-timeline: view();
      animation-range: entry 4% cover 16%;
    }

    .about-copy > * + * {
      animation-range: entry 9% cover 22%;
    }

    .about-copy > * + * + * {
      animation-range: entry 15% cover 28%;
    }

    /* THE DEFECT .scale-list HAD, AND IT WENT UNFIXED LONGER. .closing carries
       [data-reveal] and its only child is .closing-inner — so `[data-reveal] > *`
       matched the wrapper and the whole scene, the 480x753 mailbox and the Join
       button beside it, resolved as ONE beat on rung 1. The page's primary
       control sat at --reveal-floor while it was in the reading zone.

       Handed down a level, the same move .scale-list makes above and for the
       same reason. The drawing leads and the control follows: the mailbox is the
       thing the button is standing next to, and a control that commits before
       the picture it belongs to reads backwards. */
    .closing-inner {
      animation: none;
    }

    .closing-figure,
    .closing-ask,
    .closing .join-trigger {
      animation: reveal-in var(--ease-reveal) both;
      animation-timeline: view();
      animation-range: entry 4% cover 140px;
    }

    /* .closing-copy is a grandchild of [data-reveal] and takes no rung of its
       own — it is a layout box, and animating it would resolve the line and the
       control as one beat. Its two children keep the rungs they had. */

    /* THE ENDS ARE PIXELS, AND THAT IS THE WHOLE FIX. Every range in this
       section used to end on `cover N%`, and a cover phase is `subject height +
       VIEWPORT height` long — so the distance a percentage stands for grows
       with the window. The scroll left underneath does not: this is the last
       section on the page, so what remains below it is `document height minus
       the subject's top`, and the hero being 100vh makes that quantity the same
       at every viewport height (measured: 530px under .closing-figure at
       393x852 and 473 at 1440x2560, and the difference is the desktop layout,
       not the height).

       So the taller the window, the further past the floor the end point sat.
       At 1440x2560 the figure's `cover 40%` asked for 1144px of scroll where
       530 exist, and .mailbox-letter never started at all — the envelope was
       simply missing from the drawing on a 4K panel at 100% scaling, on a
       portrait-rotated monitor, or at 50% browser zoom. `.closing .join-trigger`
       was worse: `cover 35%` wanted 336px against the 265 that exist under it,
       so it never reached `finished` even on a phone.

       A pixel offset into the cover range is measured from the same origin and
       does not scale, so one number holds at every height. These five are the
       windows the section actually occupied at 393x852, read off the live page
       by stepping the scroll and sampling each animation's progress, then
       trimmed to leave at least 40px under the floor. The motion on a phone is
       the motion that was tuned; what changed is that it now also finishes on a
       tall window.

       THE PIXELS ARE THE DURABLE PART NOW, NOT THE PERCENTAGES — the opposite
       of what the note under .mailbox-door used to say. Re-measure if the
       section's own height changes (that is what moves the floor), not when the
       viewport does. The headroom per subject, phone layout: figure 530, ask
       322, trigger 265. */
    .closing-ask {
      animation-range: entry 7% cover 110px;
    }

    .closing .join-trigger {
      animation-range: entry 10% cover 95px;
    }

    /* THE MAILBOX OPENS ON THE WAY UP THE SCREEN, on the same timeline as
       everything else in the section — no observer, no JS, and no state to get
       out of sync with the reveal it is part of. The base file draws the box
       shut; these two ranges are what put the door and the letter back.

       THE SWING IS PLACED WHERE THE READER IS ACTUALLY LOOKING, which is the
       one thing that makes it read. The figure enters at the very bottom of the
       screen; if the door opens there it has finished before the eye arrives,
       and all that is left is a mailbox that was always open. So the box stays
       shut until the figure is about half way in (entry 50% — its top around
       690px on an 812 viewport) and the swing runs until its top is near 440,
       the middle of the screen, where the picture is being looked at. The
       letter follows into the mouth the door just opened, and is written in
       cover rather than entry because by then the entry phase is spent.

       THE CEILING ON THESE TWO RANGES IS THE END OF THE PAGE, NOT TASTE.
       .closing is the last thing above the footer, so the scroll runs out
       partway through its cover phase and every percent past that point is
       scroll the reader cannot reach. Measured at 375x812: the figure's cover
       phase runs 1059 long and the document ends with only 46% of it spent.
       `cover 52%` therefore never resolved — the door stopped around 87% open
       and stayed there, which is most of why the motion did not read. Both ends
       now finish inside that ceiling with room to spare.

       THE CEILING IS WHY THESE END IN PIXELS — see the long note above
       .closing-ask. A percentage of the cover phase grows with the viewport
       while the scroll under this section does not, so every percentage end
       point here eventually lands past the floor. Both of these are absolute
       offsets into the cover range and hold at every window height.

       The overshoot curve is the door's own, and it is why it is written out
       rather than taking --ease-reveal: that token is a settle for text
       arriving, and it was landing this door as softly as a paragraph. A door
       has weight and swings a little past its stop, so this one runs a beat
       under 1 and comes back. On a scroll timeline that is not a wobble in
       time — it is a few pixels of scroll where the door is fully down and
       lifts back, which is what the motion looks like in life.

       THE OVERSHOOT WAS ALSO EATING THE SLOWDOWN, which is why it is 1.12 and
       not the 1.35 it started at. A curve that aggressive reaches its endpoint
       around 42% of the range and spends the rest settling, so lengthening the
       range bought mostly settle and very little swing — the door still appeared
       to snap open, just with more scroll after it. At 1.12 the value climbs
       through most of the range before it goes past, so the extra scroll below
       is spent on the thing being slowed. It still lands with weight.

       transform-origin has to be stated here rather than in the keyframes — it
       is a property, not something a keyframe can carry into the resting state,
       and at the default 50% 50% the door would hinge on the middle of the
       drawing and swing through the box. */
    .mailbox-door {
      transform-origin: 27% 41.5%;
      animation: mailbox-door-open cubic-bezier(0.42, 1.12, 0.6, 1) both;
      animation-timeline: view();
      /* HALF AS FAST AGAIN, on Jesse's instruction. On a scroll timeline slower
         means MORE SCROLL, not more time, so the swing is deliberately long:
         it opens 21px after the figure's own box has begun to enter and runs
         for 280px of scroll after that. The letter OVERLAPS its tail rather
         than waiting for it — at this length a strict queue left the envelope
         arriving well after the door had stopped moving, which reads as two
         effects; overlapped, it appears while the door is still swinging, which
         is what happens when a mailbox is opened.

         350, not the 280 the other rungs would suggest, and the difference is
         this element rather than a taste call. The door is rotateX'd, so the
         box the view() timeline measures it by is the FLATTENED one — 20px
         tall against the figure's 160 — and its cover range therefore starts
         about 60px further down the scroll than the figure's does. Declared at
         280 the swing measured 230px of travel where the tuned one is 300; 350
         puts it back. Measure this one by the scroll it actually occupies, not
         by matching the number above it. */
      animation-range: entry 10% cover 150px;
    }

    .mailbox-letter {
      animation: mailbox-letter-in var(--ease-reveal) both;
      animation-timeline: view();
      animation-range: cover 60px cover 150px;
    }

    /* THE SURVEY SECTION NEEDS NO OVERRIDE ANY MORE, and it needs none for a
       different reason than it used to. The first version wrapped a heading and
       a row, so `[data-reveal] > *` matched a wrapper and resolved three things
       as one beat; the fix then was to hand the reveal down a level, and the
       section spent a while with two flat children — a drawn flyer and the
       handwritten note that pointed at it — taking the first two rungs.

       Both of those are gone. .survey-inner now holds exactly ONE child, the
       .survey-card, so only the first rung above ever matches and the card
       arrives as a single object. That is the whole reason the card is a box in
       the markup rather than six flat siblings: six children would land on
       rungs 1/2/3/4/4/4 and slide independently INSIDE a container that is
       itself sitting still, which reads as a card coming apart. The argument is
       written out beside the markup in index.html.

       Height, MEASURED rather than assumed, because the note above .scale-list
       says anything tall goes to its children instead of onto a later rung, and
       that note exists because a height somebody guessed once broke a reveal.
       Read off the rendered card with reduced motion emulated, so the reveal's
       own transform is out of the rect: 414.3px at 320, 341.3 at 375 and 393,
       355.3 at 600, and 363.3 from 768 up. It gets TALLER as the column widens,
       not shorter — the padding clamp outruns the reflow it buys — and its
       worst case is the narrowest phone, where the heading and the subline each
       take a third line. 414 is still well inside any viewport this ships to,
       so nothing waits at partial opacity for a range that cannot close. */

    /* A slow push-in on the photo as it travels the viewport — the same move
       the hero plate makes, so the page has one motion idea rather than two.
       Runs the full entry-to-exit range, not a short burst, which is what keeps
       it reading as drift rather than as an effect firing.
       `scale` (the property, not a transform function) for the same reason the
       hero uses it: it composes instead of fighting anything else.
       6% is deliberately small. The figure clips it, so the frame never moves —
       only what is inside it. Resting state is scale 1, so reduce-motion and
       any browser without a view timeline get the photo at its natural size. */
    .about-photo {
      animation: about-drift linear both;
      animation-timeline: view();
      animation-range: entry 0% exit 100%;
    }

    /* --- The calendar arrives in two stages ------------------------------ */

    /* STAGE ONE: THE CARD IS ONE OBJECT, AND IT IS THE ONLY ONE THAT ARRIVES.
       .week has TWO direct children now — the photograph and the card — and
       only one of them is on a rung. That is a deliberate pair of declarations
       rather than an accident of ordering (agent-os/standards/reveal-child-count.md):

         .week-photo      `animation: none`, below. It is the STAGE, not a
                          performer. Two reasons, and either alone would settle
                          it. reveal-in translates 12px, and a translate on an
                          absolutely positioned ground slides the picture inside
                          its own frame while the frame stays put — the band
                          would arrive with a 12px seam of paper along its top.
                          And a ground that fades in behind a card that is also
                          fading in reads as one soft blur rather than as a card
                          landing on a photograph. The precedent is .scale-list
                          forty lines up, which takes `animation: none` for the
                          same structural reason.
         .week > .week-card  keeps its measured range, and the specificity is
                          load-bearing: with a sibling in front of it the card
                          now matches `[data-reveal] > * + *` (0,1,0), the
                          generic rung this section does not want. `.week >
                          .week-card` is (0,2,0) and wins. Delete that selector
                          and the card silently moves to a schedule nobody
                          measured.

       Its strip, its weekday row and its fourteen ruled cells are still
       grandchildren and still take no rung, which is the whole difference
       between a card arriving and a card coming apart.

       IT TAKES RUNG 1's SHAPE NOW, NOT RUNG 3's, and that is the substance of
       the re-derivation rather than a unit change. `entry 35% cover 545px` was
       rung 3 pixelized, because the card followed a label and a heading; with
       nothing above it in the section there is nothing to stagger against, and
       a lone card waiting two beats for siblings that no longer exist reads as
       a hang. It moves to `[data-reveal] > *`'s own `entry 15% cover 40%`,
       which is exactly what the page's other single-child reveal does —
       .closing-figure runs `entry 15% cover 330px`, the same shape pixelized
       against its own subject.

       THE DARK-GLASS PASS (2026-09-06) MOVED ONE PIXEL AND NO RANGE. The grid
       gained a top rule on its first row, so the CARD is 283 tall where this
       block says 282 — `cover 455px` goes from 40.1% to 40.06% of its phase.
       Stage two's subject did not move at all: a .week-cell-in is still exactly
       96px tall (the extra pixel is on the <td>'s border, and the timeline's
       subject is the inner box). Re-sampled at 2px steps with the ladder
       untouched, the two bounds this block holds itself to came back: the first
       cell goes opaque 156px of scroll after the card does at BOTH widths — the
       same 156 recorded below — and all fourteen settle 344px (390) and 392px
       (1440) before the card's top edge reaches the top of the viewport. The
       polarity flip is a colour change; nothing in this ladder is a colour.

       THE PHOTOGRAPH MOVED EVERY LANDMARK BY EXACTLY 64 AND CHANGED NOTHING
       ELSE, WHICH IS WHY NO RANGE HERE WAS RE-TUNED. .week gained 64px of
       padding above and below the card; the section's own top edge did not move
       (2422.91 at 390x852, 2408.86 at 1440x900, both unchanged) and the card's
       top edge moved down by the padding and nothing more (2422.91 -> 2486.91,
       2408.86 -> 2472.86). A view() timeline is measured against its SUBJECT,
       and neither subject changed: the card is still 282 tall, a cell is still
       96, and a cell still sits exactly 88.00px below the card's top edge. So
       both ranges keep their numbers and every absolute landmark in this block
       is simply 64 further down the document than it was. All of them were
       re-sampled anyway rather than added to — the tables below are readings,
       not arithmetic — and the wipe reproduced cell for cell.

       (The section was 523.81 tall when it carried prose, 282 when it was the
       card alone, and 410 now.)

       THE ENDS ARE PIXELS, AND FOR STAGE TWO IT IS NOT OPTIONAL. A cover phase
       is `subject height + VIEWPORT height` long, so a percentage of it grows
       with the window while nothing else does. A .week-cell-in is 96px tall: at
       390x852 its cover phase is 948px and `cover 40%` means 379px of scroll,
       but on a portrait 4K panel at 2560 it is 1062px — the cell would still be
       resolving long after it had left the top of the screen. Percentages are
       unusable at that subject size, so stage two ends in pixels, and stage one
       follows it so the two stages are stated in one unit and can be read
       against each other.

       Stage one would have survived percentages, and that is worth recording so
       nobody "fixes" it back: unlike .closing, this section is nowhere near the
       end of the document — re-read with the band in place: 2891.09px of scroll
       remain under it at 390x852 and 3198.14 at 1440x900, measured as
       scrollHeight minus the section's own bottom (the band is 128px taller and
       the document grew by the same 128, so these are the same two numbers as
       before) — so no end point here can outrun the page. The pixels
       cost one thing in exchange: on a very tall window they resolve lower on
       the screen than a percentage would. That is a taste price on an unusual
       window, against a range that cannot close on a common one.

       THE NUMBER IS THE PERCENTAGE IT REPLACES, MEASURED at 390x852 rather
       than converted on paper. The card is 282 tall, so its cover phase is
       282 + 852 = 1134 and rung 1's `cover 40%` lands on 453.6 — rounded to
       455, the same round-to-5 the four old rungs took. 455 is 40.1% of the
       phase at 390x852 and 38.5% at 1440x900 (1182), which is the price of a
       fixed pixel and is the price the whole section already pays.
       The `entry` start is untouched at 15%: entry is a percentage of the
       SUBJECT's own height, which does not move with the viewport, so it was
       never the broken half. Re-measure if the card's height changes, not when
       the window does.

       WHERE IT PUTS THE CARD, re-sampled on the live page in absolute document
       scroll, stepping 2px with a frame allowed to settle before each read:

                                  390x852        1440x900
         cover 0                  S = 1634.91    S = 1572.86   (card top at the
                                                                viewport bottom)
         reaches opacity 1        S = 1799       S = 1737
         range ends, transform 0  S = 2091       S = 2029

       Both rows are the geometry's own: the card's top is 2486.91 at 390, so
       cover 0 is 2486.91 - 852 = 1634.91 and a range ending at `cover 455px`
       cannot end anywhere but 2089.91, which is the 2091 sampled at a 2px step.
       Same at 1440: 2472.86 - 900 + 455 = 2027.86 against a sampled 2029. The
       three numbers each moved +64 from the pre-photograph readings (1735/2030
       at 390, 1673/1964 at 1440) and by nothing else.

       A MEASUREMENT TRAP THAT COSTS AN HOUR IF YOU MISS IT, and it is NOT
       caused by anything in this section — it reproduces identically on the
       commit before the photograph. For a beat after load this page computes
       `overflow: hidden` on the root, and any view() timeline that resolves
       inside that window binds to a scrollport that never scrolls: its
       currentTime then sits frozen at some fixed percentage for the rest of the
       page's life, playState reads "finished", and every element in the section
       samples as fully arrived at every scroll position. A scan that starts
       then reads a perfectly staggered reveal as a card that was never
       animated. Wait for the root to compute `overflow: visible`, then confirm
       the card's currentTime actually CHANGES between two scroll positions
       before trusting a single number below. */
    .week-photo {
      animation: none;
    }

    /* THE CARD GROWS INTO PLACE INSTEAD OF SLIDING, on Jesse's instruction to
       make the calendar expand when you reach it. Everything else on the page
       arrives with reveal-in — twelve pixels up and a fade — and that is right
       for a paragraph. The calendar is the one object on this page that is a
       THING rather than a passage, so it gets to behave like one.

       It only overrides the NAME. The range, the easing and the timeline are
       the ones already measured for this element; the arrival is retimed
       nowhere, it just has a different shape. */
    .week > .week-card {
      animation-name: week-expand;
      animation-range: entry 4% cover 190px;
    }

    /* STAGE TWO: THE WEEK FILLS IN. Each cell's CONTENT gets its own view()
       timeline while the ruled box it sits in stays put — the borders and the
       elapsed hatch belong to the <td>, and .week-cell-in holds everything that
       is not structure. So the calendar arrives drawn and empty, and then the
       happenings appear in it, which is the order the thing actually happens in.

       STEPPED BY WEEKDAY COLUMN, so it reads as one wipe left to right rather
       than fourteen elements each doing their own thing. The two rows step
       themselves: a row-2 cell sits 97px lower in the document than the row-1
       cell above it (measured, and the same at 320, 390, 768 and 1440), so its
       identical range starts and finishes 97px of scroll later. One set of
       seven rungs therefore produces a wipe across the week and then a second
       wipe under it, from a single stagger.

       BOTH ENDS ARE IN `cover`, NOT `entry`, AND THAT IS WHY THE STAGES SEPARATE.
       A cell is 96px tall, so its whole entry phase is 96px of scroll — starting
       anywhere in `entry` starts it within 96px of the card's own start, and the
       first tuning did exactly that: sampled, the cells were already opaque
       while the card was 58% through its own range, so the two stages read as
       one. Holding the starts out in `cover` is what buys the beat.
       (`cover Npx cover Npx` is not new here — .mailbox-letter is written the
       same way, for the same reason.)

       PULLED 60px EARLIER WHEN THE SECTION'S PROSE CAME OUT, and that remains
       the whole of the re-tune this ladder has ever had. It was `cover
       210-300px` to `cover 490-610px`; the card moved from rung 3 to rung 1 and
       reached opacity 1 191px of scroll earlier, which would have opened a
       119px hole where the tuning was built to hold 33 — long enough to read as
       two events instead of one arrival — and 60px back closed it.

       THE PHOTOGRAPH DID NOT TOUCH IT, and that is worth being explicit about
       because the band is 128px taller than the section used to be. A view()
       timeline is measured against its own SUBJECT: a cell is still 96px tall
       in a 948px cover phase at 390x852, and it still sits exactly 88.00px
       below the card's top edge (2574.91 against 2486.91; it was 2510.91
       against 2422.91 before the band, the same 88.00). The card and the cells
       moved down together by the padding, so every relative figure — the 156px
       opacity beat, the 62px settle-to-settle, the 218px wipe — came back
       unchanged when re-sampled. Only the absolute landmarks moved, by 64.

       RE-READ OFF THE LIVE PAGE WITH THE PHOTOGRAPH BEHIND IT, by stepping the
       scroll 2px and letting a frame settle before each read — the method the
       note above .closing-ask records, and the resolution matters: read in the
       same task as the scroll, sampled progress lags and quantises into ~45px
       plateaus. NOTHING IN THIS LADDER WAS RE-TUNED. The band added 64px above
       the card, which moves the card and the cells down together, so every
       relative figure below is identical to the pre-photograph reading and
       every absolute one is exactly +64.

                                          390x852        1440x900
         card reaches opacity 1           S = 1799       S = 1737
         card (stage one) settles         S = 2091       S = 2029
         row 1 cells settle               2153 -> 2273   2091 -> 2211
         row 2 cells settle               2251 -> 2371   2189 -> 2309

       And the wipe itself, computed opacity across the seven columns
       (Sun -> Sat) at 390x852, which is what a reader actually sees:

         S = 1919   r1  0.59 0.27 0.06 0    0    0    0     r2 all 0
         S = 1969   r1  1    1    0.89 0.64 0.32 0.10 0.01  r2 all 0
         S = 2019   r1  1    1    1    1    1    0.91 0.68  r2  0.65 0.32 0.09 0 0 0 0
         S = 2069   r1  all 1                               r2  1 1 0.92 0.69 0.38 0.13 0.02
         S = 2129   r1  all 1                               r2  1 1 1 1 1 0.99 0.87

       At 1440x900 the same five rows come back to the digit, 62px earlier. One
       diagonal front crossing the fortnight, 218px of scroll from the first
       cell settling to the last. THE TWO BOUNDS THAT MATTER, both held and both
       sampled at both widths:

         - EVERY CELL COMMITS AFTER THE CARD DOES. Opacity is the commit a
           reader sees, and reveal-in reaches 1 at 30% of the range: the card is
           opaque at 1799 (390) and the first cell not until 1955 — a beat of
           156px, and 156 at 1440 as well, which is the same beat this ladder
           held before the band existed. Settle to settle is 62px. (The RANGES
           overlap, and always did; what would collapse the two stages is a cell
           going OPAQUE before the card, and this holds 156px clear of it.)
         - THE WIPE IS OVER WHILE THE WHOLE CARD IS STILL ON SCREEN. The last
           cell settles at 2371 (390) against a card top of 2486.91 — 116px of
           clearance before that edge reaches the top of the viewport, and 164px
           at 1440 (2309 against 2472.86). Both are a hair looser than the
           113/165 the pre-photograph tuning measured, because the band's own
           padding buys a little.

       AND NOTHING RESTS LOW. Parked past the section at both widths, the card,
       the photograph and all fourteen cells read computed opacity 1.000; with
       `prefers-reduced-motion: reduce` emulated, document.getAnimations() is
       empty and the same fourteen read 1. That is the check the section's whole
       degradation story stands on and it is a reading, not an inference.

       The floor is generous: a cell's cover phase is 948px at 390x852 and the
       longest range here ends at 550, with 2891px of scroll under the section.
       Nothing waits at partial opacity for a range that cannot close. */
    .week-cell-in {
      animation: reveal-in var(--ease-reveal) both;
      animation-timeline: view();
      animation-range: cover 40px cover 170px;
    }

    .week-cell:nth-child(2) .week-cell-in {
      animation-range: cover 55px cover 190px;
    }

    .week-cell:nth-child(3) .week-cell-in {
      animation-range: cover 70px cover 210px;
    }

    .week-cell:nth-child(4) .week-cell-in {
      animation-range: cover 85px cover 230px;
    }

    .week-cell:nth-child(5) .week-cell-in {
      animation-range: cover 100px cover 250px;
    }

    .week-cell:nth-child(6) .week-cell-in {
      animation-range: cover 115px cover 270px;
    }

    .week-cell:nth-child(7) .week-cell-in {
      animation-range: cover 130px cover 290px;
    }
  }
}

@keyframes about-drift {
  from { scale: 1; }
  to   { scale: 1.06; }
}

/* THE CALENDAR EXPANDS. Two independent properties rather than one transform,
   which is this file's existing habit (see .about-photo): `scale` and
   `translate` compose with each other and with anything else that lands on
   this element, where a transform function would have to restate whatever it
   did not want to clobber.

   0.9, NOT SMALLER. The card carries backdrop-filter: blur(24px) over a
   photograph, and a scaled element with a backdrop filter has its backdrop
   re-sampled and re-blurred at the new size on every frame. That is the most
   expensive thing on this page and the cost scales with how much the size
   actually changes, so the growth is the smallest that still reads as growth.
   A dramatic 0.6 would look better in a screenshot and drop frames on a phone.

   NO OVERSHOOT. There was one — 1.012 at 84%, meant to read as a spring — and
   it is gone on Jesse's read of it live: "it hits max size then shrinks back a
   little." That is exactly what an overshoot is, and on a SCROLL timeline it is
   the wrong instinct. In time-driven motion the peak is a moment nobody can
   stop on; here progress is position, so the reader can and does park anywhere,
   including on the shrink, where the card is visibly settling backwards for as
   long as they sit there.

   The peak is now the end. Growth runs 0.9 -> 1 and stops, so the largest size
   the card ever reaches is the one it keeps.

   It ends at 1 rather than at 1.012 for a reason worth stating, since keeping
   the bigger number was the other way to honour the same instruction: a card
   resting at a fractional scale is permanently resampled — its 13px title, its
   hairline grid and its 1px border all render off the pixel grid, and the
   backdrop-filter layer underneath never gets to rasterise once and stop. The
   card's own layout size IS its maximum; the animation now just arrives at it.

   Opacity finishes at 30% to match reveal-in exactly. The card is legible for
   the last two thirds of its own arrival, so what the scroll is timing is the
   shape settling, never whether the thing can be read. */
@keyframes week-expand {
  from { opacity: 0; scale: 0.9; translate: 0 12px; }
  30%  { opacity: 1; }
  to   { opacity: 1; scale: 1; translate: none; }
}

/* THE FLOOR IS NOT ZERO, AND THAT IS THE WHOLE EFFECT. Reveal used to run
   0 -> 1, which meant a band of this page was genuinely blank until the scroll
   had carried it most of the way up: read fast, or land mid-page from a shared
   link, and you were looking at nothing. Resting muted instead, the words are
   on the page the moment they are on screen and the scroll only decides when
   they COMMIT. Nothing is ever missing, so nothing has to arrive.

   THE FLOOR IS A CONTRAST FLOOR, AND IT IS A FUNCTION OF THE GROUND. The
   muted state is meant to be transient, but it stops being transient the
   moment someone lands mid-page from a shared link or simply stops scrolling
   — whatever is held part-way is what they sit and look at. So it has to
   survive being a RESTING state, which means clearing 3:1 (WCAG's minimum for
   large text, and everything on this ramp is large: display leads and section
   heads).

   It was written as one number first, measured only against --ink on --paper,
   and that was the mistake: fading multiplies the ground's EXISTING headroom,
   so a band that starts closer to its text degrades faster. The three grounds
   are not close to each other —

       ground                          full     @0.45    needs
       --ink       on --paper         18.06:1   3.01:1   0.45
       --paper     on --moss-deep      7.25:1   2.83:1   0.48

   — so a single 0.45 read as a ghost on the darker ground. Hence
   --reveal-floor as a token: --paper default, raised on .scale to the value
   that ground actually needs. Going much past 0.65 costs the effect — the
   resolve stops registering as a change at all — which is the real ceiling on
   how dark a ground this reveal can run on.

   Body copy in --ink-secondary sits lower than its heading and is not
   defended by the floor either. It is defended by the RANGE: the `cover` end
   finishes a normally-sized element while its top is still above the middle
   of the screen, so anything actually in the reading zone has already
   committed. That defence has one requirement: no element on the ramp may be
   so tall that its own range outlives its top edge. .scale-list broke it at
   951px on the fourth rung; .about-copy broke it at 638px on the same rung,
   and body copy pays more for it than display type does. Both are handed down
   to their children for exactly that reason — see the notes there. Anything
   new and tall wants the same treatment, not a later rung.

   8px, down from 12px. The lifted floor now carries the effect, and this is
   the most-seen animation on the page — it fires on every section on every
   scroll. The move that repeats most should be the quietest one.

   IT USED TO BE SHARED WITH THE FORM FOLD, which rode this keyframe on purpose
   so that opening the waitlist read as this page's motion rather than as that
   section's effect. The fold is retired (see "The sheet"), and the sheet does
   NOT borrow it — a modal that enters on the scroll-reveal keyframe would be
   claiming a continuity with the page that it does not have. The sheet enters
   on its own measured curve. This keyframe is the scroll ramp's alone again. */
/* NO OPACITY ANY MORE, AND THAT IS THE FIX. Everything above is the reasoning
   that produced --reveal-floor, and its arithmetic is right; what it measured
   was the HEADING colour on each ground. Body copy in --ink-secondary was never
   computed, and it is not defended by the range either — a reader who stops
   scrolling with a paragraph in the bottom third of the window rests on the
   floor indefinitely, and at 200% zoom the "no element taller than the viewport"
   requirement that defence depends on stops holding.

   Measured at rest on the old floors: .about-copy 1.84:1, .ask-body 1.81:1, the
   closing note 1.81:1, .scale-body 2.60:1, .survey-body 2.84:1, where body
   copy at weight 500 owes 4.5:1. Ten pairs failed, and Reduce
   Motion users were the only ones spared — so it hit hardest the people who
   never thought to turn motion off, which for this town is most of the audience.

   Tuning cannot fix it: the floor a 4.5:1 body pair would need on the darker
   grounds is no longer a reveal. So the fade went and the travel stayed.

   THE FADE IS BACK, ON JESSE'S INSTRUCTION, AND IT IS FRONT-LOADED SO THE
   FAILURE ABOVE CANNOT HAPPEN AGAIN. Read the two paragraphs before this one
   carefully: every one of those eleven pairs failed as a RESTING state — a
   reader stops scrolling with a paragraph part-way through its range and sits
   on a fraction of its contrast indefinitely. None of them failed in motion.
   So the fix is not a floor and not a different ground; it is to make sure the
   fraction is never something anyone can rest on while reading it.

   Opacity finishes at 30% of the range and the travel carries the other 70%.
   Worked through for rung 1 (entry 15% cover 40%) on a 200px element in an
   800px window: the range opens with the element's top at y=770 and closes
   with it at y=400, so 30% of it puts full ink at y=659 — the element is
   solid by the time its top is in the bottom sixth of the screen, and the
   reading zone only ever sees it at 1. That is the whole defence, and it is
   the same shape as the `cover` end above: put the commitment before the eye
   arrives, not after.

   0 -> 1, not floor -> 1. With the fade finishing that early there is nothing
   to be gained from resting muted, and a floor is what dragged eleven grounds
   into the arithmetic in the first place. An element below its range is simply
   not there yet, which is what a reveal is.

   WHAT IS STILL TRUE AT VERY HIGH ZOOM, recorded rather than papered over. The
   defence depends on the element being shorter than the window; at 200% zoom
   a 500px card in a 400px viewport gets full ink with its top at mid-screen
   instead of near the bottom, so its first lines can be read while the fade is
   still running. That is strictly better than the floor was — the state is
   transient and self-clearing rather than a resting one — but it is not
   nothing, and the mitigation is the rule stated above: anything tall is
   handed down to its children rather than given a later rung.

   12px of travel, kept from the no-fade version: the fade is over in the first
   third now, so the translate is still carrying most of the effect. */
@keyframes reveal-in {
  from { opacity: 0; transform: translateY(12px); }
  30%  { opacity: 1; }
  to   { opacity: 1; transform: none; }
}

/* --- Small shared components -------------------------------------------- */

/* .eyebrow AND .eyebrow--dark ARE GONE, not orphaned. The chip was a shared
   component with exactly one user left — .scale's "The town" — and that one was
   removed on Jesse's instruction, so the base rule, the dark variant, the green
   re-measure in .scale and the sticky rule that pinned it went with it rather
   than sitting here as three rules nothing selects. The whole set is one revert
   away if a section ever wants a label above its heading again; bringing it
   back means bringing back the 59px derivation in .scale-heading too. */

/* --- The opening statement ---------------------------------------------- */

/* A direct child of .page, like .survey, so its own padding sets
   the rhythm around it rather than inheriting .page-inner's column. Nothing
   here is full-bleed — the ground stays --paper — but the measure wants to be
   its own, wider than body copy and narrower than the page. */
.creed {
  /* Bottom is 0, not the clamp(56px, 8vw, 92px) it used to carry. That stacked
     with .about's own 56/96 margin below it and made this seam 112px on a phone
     and 188px on desktop, against 56/96 at every other seam on the page — and
     the two scaled differently, a vw ramp against a stepped margin, so the gap
     jumped 40px in a single pixel of viewport width at the 940 breakpoint. The
     section below owns the whole gap now. */
  padding: clamp(16px, 4vw, 48px) var(--gutter) 0;
}

/* Sized to the LENGTH, not to the reference's screenshot. The reference sets
   about 230 characters, so it can afford ~90px. This passage is roughly three
   times that; at 90px it would be five screens of type and stop being a
   statement. clamp(22px, 2.9vw, 38px) lands it around eleven lines on a desktop
   and keeps it a single held thought.

   MEASURE IN em, NOT px, so it scales WITH the type: the line length stays the
   same number of characters at every size instead of running to 60 characters
   on a big screen and 20 on a small one. 27em is ~60 characters here, which is
   long for centred type and deliberately so — it is the reference's actual
   proportion. A narrower 19em was tried first and gave seventeen short lines: a
   tall column of fragments rather than a block of speech. Long lines and few of
   them is the shape; short lines and many of them is a poem.

   line-height 1.12 and -0.03em tracking are the reference's signature, with one
   concession: the reference sets FIVE lines and can afford 1.06. This is
   eleven, and at that length 1.06 stops being tight and starts being airless.
   1.12 keeps the words reading as a block while leaving the eye a return path.

   text-wrap: pretty, not balance — balance is for a few lines and browsers stop
   applying it well past about six. pretty is what keeps the last line off a
   single orphaned word here. */
.creed-text {
  max-width: 27em;
  margin: 0 auto;
  text-align: center;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(22px, 2.9vw, 38px);
  line-height: 1.12;
  letter-spacing: -0.03em;
  color: var(--ink);
  text-wrap: pretty;
}

/* .creed-drawing IS GONE. The people-in-a-circle drawing that sat under the
   creed paragraph has been replaced by the calendar itself, on Jesse's
   instruction — the section now shows the thing the app does rather than an
   illustration of it. community-circle.webp is unreferenced as a result and is
   denied in .vercelignore rather than deleted: it is the only drawing on the
   page in its own colours and is worth keeping to hand. */

/* THE RING BREATHES AS IT PASSES, AND IT IS BIGGEST WHEN IT IS IN FRONT OF YOU.
   Driven by scroll position, not by the cursor: it grows on the way up the
   screen, reaches full size when it is centred in the viewport, and eases back
   down as it leaves. A hover version was tried first and was the wrong answer —
   this page is mostly read on a phone, where a hover never fires at all, so the
   effect simply did not exist for most visitors.

   `cover 0% cover 100%` is what makes "directly on it" exact rather than
   approximate. The cover range runs from the moment the drawing first breaks
   the bottom edge to the moment it clears the top, so cover 50% is precisely
   where the element's centre meets the viewport's centre. The peak keyframe
   sits there.

   IT PEAKS AT 1, IT DOES NOT OVERSHOOT IT. The size rationale above is that 340
   is exactly half the asset's 680 so the drawing is never upscaled — pushing
   the peak past 1 would spend that on nothing. So the breathe is written the
   other way round: 0.94 at the edges of the pass, its designed size in the
   middle. The eye reads the same 6% of growth either way, and the biggest the
   ring ever gets is still the size it was drawn for.

   `scale`, NOT `transform: scale()`. reveal-in animates this element's
   `transform` and holds `transform: none` afterwards with `both` fill, and an
   animation beats a normal declaration — so a `transform` here is silently
   discarded. The individual transform properties compose instead of
   overwriting. Same reason hero-zoom uses it over hero-settle.

   BOTH ANIMATIONS ARE NAMED HERE, and that is not optional. `animation` is a
   shorthand: this rule matches `[data-reveal] > * + *` at equal specificity and
   sits later in the file, so declaring only ring-breathe would RESET
   animation-name and silently delete the reveal. Its range is restated for the
   same reason. (The identical trap is written up on .hero-photo and on the
   opening lines — it has now bitten this file three times.)

   The timing function eases into and out of the 50% keyframe, which rounds the
   peak; a linear ramp would put a visible corner at the exact moment the ring
   is centred, which is the one frame the visitor is looking straight at. */
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
  }
}

@keyframes ring-breathe {
  from { scale: 0.94; }
  50%  { scale: 1; }
  to   { scale: 0.94; }
}

/* The reference's one device: the itemised specifics drop back so the claim
   around them reads as the spine. --ink-secondary is 4.87:1 on --paper, so
   this is de-emphasis by VALUE and never by legibility — it stays readable at
   the 22px floor, which the reference's much lighter grey would not. */
/* THE STRESSED PHRASES ADVANCE, THEY DO NOT RETREAT — and the class kept the
   old name because it is what the markup says, so read it as "the quiet is
   everything else". These spans are the list of what the app actually does
   ("Events, clubs, activities, news, and civics"; where the feed is sourced),
   and they were set LIGHTER than the prose around them. Emphasis that recedes
   is de-emphasis.

   Flipped rather than restyled: the connective prose steps back to
   --ink-secondary and the phrases keep full --ink, so nothing gains a hue, a
   weight or an underline — the relationship simply runs the right way. Weight
   was not available anyway: the display face ships 500 and 600 only, the creed
   is already 600, and font-synthesis-weight is off. At 22-38px both colours are
   large text, where the floor is 3:1; --ink-secondary is 5.17:1 on the page. */
.creed-text {
  color: var(--ink-secondary);
}

.creed-quiet {
  color: var(--ink);
}


/* --- Section headings --------------------------------------------------- */

/* Ink on paper below the fold, deliberately — the hero is the loud half, and a
   second loud half would leave the eye nowhere to rest. The one thing carried
   down from the hero is the type pairing. */

.about-heading {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(26px, 6.2vw, 34px);
  line-height: 1.1;
  letter-spacing: -0.026em;
  color: var(--ink);
  /* balance, not pretty. This file states the distinction twice: pretty only
     refuses a one-word last line, it does not EVEN two lines, and these heads wrap to two on a
     phone. balance's practical ceiling is about six lines; a section head is
     never near it. */
  text-wrap: balance;
}

/* --- About -------------------------------------------------------------- */

.about {
  margin-top: 56px;
}

.about-figure {
  margin: 22px 0 0;
  border-radius: var(--radius-card);
  overflow: hidden;
  /* Holds the box while the photo decodes, so the copy below does not jump. */
  background: var(--fill);
}

/* Shown at the source's own 3:2. Deliberately NOT re-cropped to a square or a
   4:3: the dock, the waterline and the trees are half of what this picture
   says, and every crop tighter than the original throws some of that away to
   keep a face that is already small. A crop nobody has looked at is also
   exactly how you behead someone — the same rule the park photos follow. */
.about-photo {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 3 / 2;
  object-fit: cover;
}

/* The lead. One step up from the body and at full ink, which is the whole
   distinction being drawn — this is the sentence, the three paragraphs under it
   are the explanation. Sized between the body (16px) and the section heading
   (26-34px) rather than at either, so it reads as its own rung. */
.about-lead {
  margin: 18px 0 0;
  max-width: 46ch;
  font-size: clamp(18px, 2.1vw, 21px);
  line-height: 1.45;
  letter-spacing: -0.011em;
  color: var(--ink);
  text-wrap: pretty;
}

.about-copy {
  margin-top: 22px;
}

.about-copy p {
  margin: 14px 0 0;
  max-width: 52ch;
  font-size: 16px;
  line-height: 1.62;
  color: var(--ink-secondary);
}

.about-copy p:first-child {
  margin-top: 0;
}

/* The closing line carries the promise, so it comes back up to full ink while
   staying the same size as the copy around it. */
.about-sign {
  color: var(--ink);
  font-weight: 500;
}

/* THE SIGNATURE, beside the line rather than under it. inline-block so it
   follows the text and wraps with it if the measure runs out — on a narrow
   phone it drops to its own line, which is what a signature does on paper
   anyway.

   MIDDLE, NOT BASELINE. The artwork is trimmed to its own bounding box, so its
   baseline is somewhere inside the file rather than at its bottom edge, and
   aligning the box's bottom to the text's baseline would hang the whole hand
   too low. Centring it on the x-height is both closer and not a number that
   has to be measured out of the image. */
.about-signature {
  display: inline-block;
  vertical-align: middle;
  /* 9.6em against the artwork's own 4.63 ratio puts the hand a little over two
     lines tall — the proportion a signature takes beside a sentence on paper.
     Sized in em so it tracks the paragraph rather than the viewport. */
  width: 9.6em;
  aspect-ratio: 1708 / 369;
  margin-left: 0.6em;
  /* Lifted a touch: the strokes sit high in their own bounding box (the long
     descending loops are what fill the bottom of it), so a box centred on the
     x-height still reads as sitting low. */
  transform: translateY(-0.12em);
  background: currentColor;
  -webkit-mask: url("jesse-signature.webp") center / contain no-repeat;
  mask: url("jesse-signature.webp") center / contain no-repeat;
}

/* --- The shared calendar ------------------------------------------------- */

/* Ported from useorigin.com's "UPCOMING TRANSACTIONS" card, on Jesse's
   instruction to copy it 95% and set it in August. The reference is a dark
   translucent panel over a photograph of water. This was built first WITHOUT
   the photograph — ink on warm paper, one hairline, no fourth ground — and that
   is the decision 2026-09-06 reversed: "run it but make sure its a glass
   overlay", plus an aerial of the town to put behind it. The panel now has
   something to frost, and the whole colour argument below was re-derived
   against it rather than adjusted.

   THE SAME 56 THE PAGE ALREADY RUNS ON, so the card is one more block in the
   column rather than a new rhythm. .about takes 56 above it and .scale takes 56
   below it; both go to 96 at 940, and so does this.

   RE-MEASURED AFTER THE SECTION LOST ITS PROSE, because the number was set when
   the section's top edge was an eyebrow and its bottom edge was a paragraph —
   the card was 178.19px in from the top of the section and 175.63 up from the
   bottom, so the 56 was buying rhythm around a text block, not around a card.
   Read off the live page with reduced motion emulated, .about's bottom edge to
   the CARD's top edge and the card's bottom edge to .scale's top:

                 320      390      768      1440
     above       56       56       56       96
     below       56       56       56       96

   which is the page's own seam at every width and needed no new number. What
   did have to go is .week-card's own 22px top margin (see there): it was the
   heading-to-card gap, it now has no heading to sit under, and left in place it
   only survived by collapsing through this one.

   THAT TABLE IS NOW THE SEAM AROUND THE PHOTO BAND, NOT AROUND THE CARD, and
   the distinction is the only thing the band changed about the page's rhythm.
   The section box used to BE the card box; it is now the card plus 64px of
   photograph above and below it. Re-read on the live page with reduced motion
   emulated: .week's own top edge is unmoved at 2422.91 (390x852) and 2408.86
   (1440x900) — the margin above it never changed — and the card's top edge
   moved down by exactly the padding, 2422.91 -> 2486.91 and 2408.86 ->
   2472.86. Everything below the section moved 128px down and nothing below it
   changed shape: .scale still measures 1747.58 at 390 and 1949.09 at 1440,
   .about 922.98 and 527.58, which are the same four numbers this note carried
   before the photograph existed. No horizontal overflow at 320, 390, 768 or
   1440 (documentElement.scrollWidth - clientWidth = 0 at all four).

   64px, NOT A GUESS AND NOT A TOKEN. It is the smallest padding that leaves a
   band of photograph readable as a photograph at the width where the card is
   widest relative to the page: at 320 and 390 the card fills the column, so the
   top and bottom bands are the ONLY photograph a phone ever sees. At 40 the
   band read as a mount around a print; at 64 it reads as a card set down on a
   picture, which is the reference's own composition. Above 768 it costs
   nothing, because the band is wider than the card there and the photograph
   already has a flank to stand in. */
/* --- The band's title ---------------------------------------------------- */

/* THE 56 MOVED UP HERE FROM .week, IT WAS NOT ADDED TO IT. The band used to
   open the run with its own 56px; the heading opens it now, so the gap above
   the title is the one the band had and the title-to-band gap is the only new
   number in the stack. 20 is a heading sitting ON the thing it names rather
   than floating between two sections — the band's own 64px of padding-block
   then carries the air between the title and the card. */
.week-intro {
  margin-top: 56px;
}

.week-intro + .week {
  margin-top: 20px;
}

/* One step under .scale-heading (26-34), because that one titles a whole plate
   and this one titles a single card. --ink on paper, so no contrast question
   arises. */
/* HARD LEFT ON THE BAND, ON JESSE'S INSTRUCTION, AND THE CAP CAME OFF WITH IT.
   It was capped at --col-narrow with auto margins so its left edge sat on the
   card's; the card is centred, so that put the title 198px in from the band's
   own edge on a desktop. It starts at the band's left edge now and runs the
   full column, which is what lets it hold the longer line at the larger size.

   40% LARGER, ON THE SAME CLAMP. 24/5.4vw/30 x 1.4 — the ramp is multiplied
   rather than re-picked so the phone-to-desktop relationship is untouched. At
   42px it is the second-largest type on the page after the hero, which is the
   point: it is the claim the calendar under it illustrates. */
.week-heading {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(33.6px, 7.56vw, 42px);
  line-height: 1.15;
  letter-spacing: -0.024em;
  color: var(--ink);
  text-wrap: pretty;
}

.week {
  margin-top: 56px;
  /* The band is the containing block for the photograph and the veil, both of
     which are absolutely positioned. NOT overflow:clip as well — the photo and
     the veil carry the same --radius-card and cut their own corners, and this
     file has one hard-won reason (see `overflow: clip` on .week-card) to put no
     more overflow than necessary anywhere near a subject with a view()
     timeline. */
  position: relative;
  padding-block: 64px;
}

/* THE PHOTOGRAPH, AND WHERE IT STOPS. It bleeds to .page-inner — the page
   column plus one gutter each side — and no further. That is a deliberate
   middle setting between the two the page already has: .about is inside the
   column, .scale runs to both window edges. .page-inner is
   `min(100vw, max-width)` with max-width stepping 544 / 700 / 1020, so the band
   IS the full window at 320 and 390 (it measures 320 and 390, x=0), 700 at 768
   (x=34) and 1020 at 1440 (x=210). A phone therefore gets the full-bleed
   photograph the reference has, and a desktop gets a band with paper either
   side, from one rule.

   NO 100vw. Line ~3957 of this file bans it outright and the reason still
   holds: `scrollbar-gutter: stable` means 100vw is wider than the viewport on a
   classic scrollbar and the whole page starts scrolling sideways. Reaching the
   gutter with a negative offset lands on exactly .page-inner's box and cannot
   overrun it.

   WIDTH IS A CALC RATHER THAN `inset`, AND THAT IS A REPLACED-ELEMENT RULE, not
   a preference. For an absolutely positioned REPLACED element, `width: auto`
   resolves to the intrinsic width and the left/right offsets are then
   over-constrained — an <img> with `inset: 0 -24px` sizes itself to 2500px, not
   to its box. Stating the width outright is the only form of this that works.

   object-position 50% 38%: the band is far wider than 16:9 at desktop
   (1020x410, 2.49:1) so `cover` crops the HEIGHT there and 38% picks the band
   that holds the horizon, the water tower, Main Street and the abbey's dome.
   At 390 the band is 0.95:1, so cover crops the WIDTH instead and the vertical
   term is inert — the y number is a desktop control and the x number is a phone
   one, from the same declaration.

   WHAT ENDS UP UNDER THE CARD, measured rather than assumed, because it is the
   whole input to the panel's alpha below: at 1440 the card covers band x
   3.9%-57.3% and band y 15.6%-84.4%, which is photo x 4%-57%, y 22%-71%. Both
   of the storefront signs legible in this frame — ST. JOE MEAT MARKET at photo
   x 9.5-13.7% and Local Blend at 36.4-37.8%, two of the six businesses whose
   logos sit in the cells — fall inside that rectangle. They are behind the
   glass, not beside it. So does the copper dome, at photo x 51%: the card's
   right edge lands at 57% and the dome is 6% inside it. Panning the frame to
   put the dome in the open would mean zooming the image past the band and
   giving up the abbey wings on the right; it was not taken, and it is the one
   framing choice here worth a second opinion.

   `animation: none` is in the reveal block, not here — the photo is a direct
   child of a [data-reveal] section and would otherwise take a rung. See the
   note there. */
.week-photo {
  position: absolute;
  top: 0;
  left: calc(-1 * var(--gutter));
  width: calc(100% + 2 * var(--gutter));
  height: 100%;
  object-fit: cover;
  object-position: 50% 38%;
  border-radius: var(--radius-card);
}

/* THE VEIL, AND IT IS NOT LOAD-BEARING FOR CONTRAST — that is worth stating up
   front, because it looks like it should be. A flat wash over the photograph
   raises the floor under the card AND compresses the range by the same factor,
   so the panel's achievable step is (hi-lo)/(1-lo) either way and the veil
   cancels out of it exactly. What buys the panel its range is the brightness in
   the backdrop filter, which is multiplicative; see .week-card.

   So this is here for the one job it can actually do: putting the photograph in
   the page's palette. --paper at 0.18 is the difference between a photograph ON
   this page and a photograph pasted into it. 0.18 was walked up from 0 — at
   0.10 the picture still read colder than everything around it, and by 0.26 the
   exposed flanks were going milky.

   THE LITERAL FOLLOWED --paper WHEN --paper MOVED. It was rgba(250,250,247)
   here, the warm sheet this section was built on; that sheet is plain white
   now, so the wash is rgba(255,255,255) at the same 0.18. Chasing the token
   rather than keeping the number is the whole point of the rule — a veil tuned
   to a ground the page no longer has would tint the photo AWAY from the page
   it is meant to belong to. The alpha did not need re-walking: the endpoints
   this was chosen between are 4 levels apart on each channel.

   A PSEUDO-ELEMENT, NOT A DIV, AND THAT IS THE REVEAL AGAIN. ::after does not
   match `[data-reveal] > *`, so the veil costs no rung and leaves the section
   at exactly two element children. */
.week::after {
  content: "";
  position: absolute;
  top: 0;
  left: calc(-1 * var(--gutter));
  width: calc(100% + 2 * var(--gutter));
  height: 100%;
  border-radius: var(--radius-card);
  /* TWO LAYERS ON ONE ELEMENT: the black fade Jesse asked for over the paper
     wash that was already here. The wash is the bottom layer and unchanged —
     the note above is still the whole of why it exists.

     THE FADE POOLS UNDER THE CARD, IT DOES NOT FRAME THE PICTURE. Both other
     shapes were built and both were wrong: a radial vignette read as almost
     nothing at 1020x570, and four edge gradients darkened the picture's rim,
     which pushes the PHOTOGRAPH back and does nothing for the thing sitting on
     it. Jesse's note was that it should lift the card. So the dark is centred
     where the card is and clears toward the edges — the card floats out of a
     pool of shadow, which is the same read a drop shadow gives, drawn on the
     ground instead of on the object because the object is translucent glass
     and cannot carry one convincingly.

     THE ELLIPSE IS WIDER AND TALLER THAN THE CARD ON PURPOSE. The card is 544
     wide inside a 1020 band, and a pool that stopped at its edge would draw a
     visible ring around it; 72% x 96% carries the shadow well past all four
     sides and lets it run out into the corners of the picture, which stay in
     full sun. The card is above this layer (see z-index there), so the pool
     never touches the glass or the type inside it.

     0.34 AT THE CENTRE AND NOT THE 0.62 IT WAS BUILT AT, BECAUSE THE CARD IS
     GLASS. backdrop-filter samples everything painted underneath, so the pool
     goes THROUGH the panel as well as around it: at 0.62 the card came out
     near-black and the town behind it disappeared, which is the one thing
     Jesse asked this card to keep when he supplied the photograph ("make sure
     its a glass overlay"). 0.34 still separates the card from the picture and
     leaves the streets and the dome readable through the panel. Raising this
     number darkens the glass first and the ground second — check the card,
     not the corners, before touching it. */
  background:
    radial-gradient(ellipse 72% 96% at 50% 50%,
      rgba(0, 0, 0, 0.34) 0%,
      rgba(0, 0, 0, 0.24) 45%,
      rgba(0, 0, 0, 0) 100%),
    rgba(255, 255, 255, 0.18);
  pointer-events: none;
}

/* --- The card's contrast table ------------------------------------------ */

/* RE-DERIVED FOR THE OPPOSITE POLARITY. This card was ink on a near-white
   panel until 2026-09-06, when Jesse put the reference beside the build and
   said "the calender is not a glass". He was right, and the cause was
   STRUCTURAL rather than a value: ink type on a light panel forces an opaque
   ground. rgba(255,255,255,0.92) was not a taste failure, it was the most
   transparent a LIGHT panel could be and still carry ink — and it measured
   4.49 L* of visible range, which is paint. The reference is see-through
   because it is built the other way round: a DARK translucent panel with WHITE
   type. Dark glass can sit near a quarter of an alpha and still carry white at
   6:1, and that headroom is what buys the transparency.

   Every pair below was MEASURED off the rendered page, not computed from
   tokens — a panel over a photograph has no single ground, since what sits
   under the strip is a parking lot and what sits under Sep 4 is a slate roof:

     headless Chrome at deviceScaleFactor 2, prefers-reduced-motion emulated to
     `reduce` so every reveal rung renders in its final state and the card can
     sit still in the viewport; the card's text set to `color: transparent` and
     its logo discs, anchor lozenge and hatch hidden, so what the camera sees
     inside each element's box is the composited GROUND and nothing else; each
     text box taken from Range.getClientRects() so it is the glyphs' own extent
     and not the padding box; then the WORST pixel in that box — which on this
     polarity is the BRIGHTEST one, the exact inverse of the old table — put
     against the known foreground token. Four widths, 320 / 390 / 768 / 1440.

   A TRAP THAT COST A ROUND OF FALSE NUMBERS: sample the card mid-reveal and
   every rect comes back TRANSLATED by up to 12px, so a day number's box
   straddles the grid's top rule and reads the rule as its ground. The first
   run of this table had white text at 1.90:1 for that reason. Park the reveal
   before reading a rect.

   TEXT — floor 4.6:1                                     worst   at
     day number      --on-glass                            6.10   320
     event name      --on-glass                            7.29   1440
     day number over a hatch stripe                        4.72   320
     out-of-month number --on-glass-dim                    5.75   1440
     weekday label   --on-glass-dim                        4.74   320
     strip label     --on-glass-mute                       4.84   320/390
     --ink on the anchor lozenge                          16.12   all

   RULES AND MARKS — floor 3:1
     cell rules      --glass-rule vs the panel             3.25   320
     anchor lozenge  --glass-anchor vs the panel           5.20   320
     logo disc       its white ground vs the panel         6.33   320
     hatch           --glass-hatch vs the panel            1.29   all   (a
                                                                  texture, no
                                                                  floor; it was
                                                                  1.24 on the
                                                                  light build)

   WHAT THE POLARITY COST, AND IT IS ONE TOKEN
     --accent ON THE ANCHOR LOZENGE DOES NOT SURVIVE. It measured 3.10:1
     against the old near-white panel and it measures 1.74-1.77:1 against this
     one, because #D46C2C sits at Y 0.264 and the panel's brightest pixel is at
     Y 0.123 — the two are a hue apart and barely a value apart. There is no
     alpha of this panel that fixes it: 3:1 for --accent needs the panel under
     Y 0.055, which is an alpha near 0.66 with no filter and costs about a
     third of the transparency for one 22x18 marker. contrast-headroom.md is
     explicit that a pair under the floor is replaced rather than noted, so it
     is replaced — with the reference's own construction, a filled light
     lozenge (--glass-anchor, the value of --cream), 5.20:1 against the panel
     with --ink at 16.12:1 on it. --primrose was the other candidate that
     clears (6.18:1) and is the one to take if the anchor should stay
     saturated; it was not taken because the reference's lozenge is neutral.

     THE TINT DISCS AND THEIR GLYPH TONES LEAVE THE CARD ENTIRELY, not because
     they failed on dark but because the thing they stood in for is now here:
     the businesses carry their real logos. --tint-* / --glyph-* are untouched
     and still in use on the survey cards.

   THE OUTER EDGE IS A TWO-TONE BOUNDARY AND HAS TO BE READ AS ONE, and on this
   polarity the two halves swapped jobs. A DARK fill carries the edge wherever
   the photograph is bright, and the LIGHTER-than-fill hairline the reference
   describes carries it wherever the photograph is dark. Sampled every 4px
   along all four sides at 2x, 26px in from each end so the corner arc is out of
   it, taking max(fill-vs-photo, hairline-vs-photo) at each position and then
   the WEAKEST position on each side:

                 320     390     768    1440
     top        1.94    1.91    1.91   1.90
     bottom     1.90    1.45    1.95   1.98
     left       1.89    2.03    1.90   2.10
     right      1.82    1.93    2.02   1.99

   TWO SAMPLING FACTS THAT BELONG WITH THAT TABLE. The card's box lands on a
   fractional CSS y, so one fixed inward offset can miss the 1px hairline
   entirely and read the photograph as the border — the reading above takes the
   brightest of the card's first four half-pixels instead. And the FOUR
   SCANLINES WHERE A GRID RULE REACHES THE PERIMETER are excluded: there the
   card's own edge pixel IS the rule, a bright line, and where the photograph
   beside it is also bright the pair reads 1.02-1.11. That is 2px of a 283px
   side and it is a rule meeting a sunlit field, not the panel failing.

   1.45 IS THE WEAKEST POSITION OTHERWISE AND IT IS REPORTED RATHER THAN FIXED.
   Two things are true about it. It is not under a floor — this card is not a UI
   component (no focusable element inside it, no :hover / :focus / :active on
   any .week selector, no cursor change; grepped, all three), so 1.4.11 does not
   reach its outer edge, and the light build's own 3.92 was recorded as a
   reading rather than held as a bar. And it is the CAST's doing: the shadow
   exists to seat the card and it darkens the photograph in exactly the band
   where a dark panel needs the ground to be light. Raising --glass-edge is what
   was tried — 0.34 measured 1.45 at its weakest, 0.55 measures 1.45-2.10, 0.66
   measures 1.69-2.29 — and past about 0.6 the hairline stops reading as a
   hairline and starts reading as a chalk outline, which is not what the
   reference has. 0.55 is where that trade was left.

   STILL REJECTED, and the reasons are measurements
     white at 0.40, the reference's own dim   2.4:1  -> --on-glass-dim at 0.78
     --accent on the anchor                   1.74:1 -> --glass-anchor
     a third, quieter white for the strip at
       the reference's 55-65%                 4.03:1 -> 0.72, the lowest that
                                                        clears on the strip's
                                                        own local ground */

/* --- The panel ---------------------------------------------------------- */

/* THE FILL IS A QUARTER OF AN ALPHA AND THE PICTURE DOES THE REST, WHICH IS THE
   WHOLE ARGUMENT FOR FLIPPING THE POLARITY. On flat paper the fill bought
   1.06 L*. Over a photograph, a NEAR-WHITE panel bought 4.49 L* — better, and
   still paint, because a light panel over a bright aerial has to sit at 0.92
   alpha to keep ink readable and 0.92 leaves 8% of the picture. Inverted, the
   same construction has room to spare: white type only needs the composited
   ground under Y 0.178, and a dark panel gets there with a quarter of an alpha.

   ===== THE GLASS, AND THE NUMBER THAT IS THE TRANSPARENCY =====
   Same method as the old block: sampled inside the fourteen cell interiors and
   the strip, 10px in from every edge so no rule or corner is in the sample, at
   2x, with the rules painted transparent.

                                     320     390     768    1440
     panel L* range, shipped        15.24   15.36   17.80   18.00
     panel L*, low end              26.31   25.74   23.30   23.40
     panel L*, high end             41.54   41.10   41.10   41.39
     panel chroma (max-min RGB)        13      18      17      19
     the same card, light polarity   4.49    4.49    n/a     5.16
     paper grain, recorded elsewhere            2.08

   That is between three and four times the range the light panel could show,
   and it is what "the water's ripples are plainly visible through it" measures
   as. Take the photograph away and the panel is dead flat to two decimals; put
   it back and it carries eight times the page's own grain in luminance and up
   to 19/255 of chroma, which grain cannot produce at all.

   THE ALPHA IS DERIVED, AND THE THING THAT SETS IT IS NOT THE ONE THE BRIEF
   NAMED. Let g be the brightest composited pixel under any type. White clears
   4.6:1 while Y(g) <= 0.178, i.e. L*(g) <= 49.28, and the cell rules clear 3:1
   at any alpha up to 1.0 on the same ground. Held to THOSE TWO ALONE the panel
   can go to rgba(20,26,30,0.52) with no filter dimming at all, which measures
   L* 26.4..49.2 — a range of 22.3, the most transparent this card can ever be.
   It is not what shipped, because the card carries a THIRD thing those two
   floors do not name: a DIM white. The out-of-month tail, the weekday row and
   the strip label are all text and all owe 4.6:1 too, and at L*(g) = 49.28
   pure white is already exactly at the floor, so no dim exists there at all.
   The panel is therefore set by --on-glass-dim, and the tightest pair on the
   whole card is the weekday label at 4.74:1.

   WHERE THE DARKENING COMES FROM: A LOW ALPHA PLUS brightness() BEATS A HIGH
   ALPHA, and it is not a preference. Compositing is affine, so with fill alpha
   a and backdrop brightness b the visible range is (1-a)*b*(hi-lo) while the
   floor pins (1-a)*b*hi — meaning the product is fixed by the floor and every
   pixel of alpha is spent on a constant that shows nothing. Sampled rather than
   argued, at four settings that all hold the same floors:

     fill        filter                 panel L* range, worst of four widths
     0.49        blur+sat               20.08   (fails: white at 4.32:1)
     0.62        blur+sat               15.21
     0.45        + brightness(0.70)     15.46
     0.45        + brightness(0.62)     13.88
     0.26        + brightness(0.50)     15.24   <- shipped

   0.26 and 0.62 measure the same range; 0.26 gets there with a thinner fill, so
   more of what is on screen is photograph. Pushed further (0.24 at 0.50) it
   gains 0.2 L* and starts to give up the panel's own material, and the fill
   below about 0.20 stops carrying a colour of its own at all. 20,26,30 is
   --glass's own value written into an rgba, the same sanctioned exception the
   old fill took with 255,255,255 (agent-os/standards/color-tokens-only.md).

   blur(24px) IS UNCHANGED AND FOR THE SAME REASON. Blur raises the low end and
   lowers the high one; 24 is where the town stops being legible through the
   panel and starts being weather, which is the reference's own reading of its
   water.

   saturate(1.5) costs nothing on any floor — the saturate matrix is
   luminance-preserving — and it is what puts the 19/255 of chroma in the table
   above. Without it a panel this dark is a grey wash and the picture behind it
   could be anywhere.

   THE SHEEN IS THE ONE THING THE POLARITY MADE EASIER. `inset 0 1px 0` in white
   measured 0.02 L* against a light fill on paper and 1.56 on the photograph;
   against a dark fill it is a plain highlight along the top edge and needs no
   argument at all. The fill gradient was NOT brought back with it — the panel
   already varies 15-18 L* on its own, and a second vertical ramp on top of that
   reads as a smear.

   THE FALLBACK GIVES UP THE GLASS AND KEEPS EVERY RATIO. `@supports not
   (backdrop-filter)` swaps the fill for opaque --glass — not a higher alpha,
   because without the blur the panel would be sampling a SHARP roofline and the
   floor drops wherever a bright edge lands. Opaque #141A1E puts white at
   17.9:1 and every other pair above it; what is lost is the panel L* range,
   which goes to 0.00, and that is the honest thing for a browser that cannot
   draw it. ===== */

/* THE CAST, RE-TUNED FOR A PHOTOGRAPH. Jesse approved a cast on this card when
   the ground was paper and the fill could not be found; it stays, because it is
   still what seats the card, but the numbers moved. On paper the pair was
   .05/.06 at 1px and 10/24px and it bought 6.24 L* against a ground whose own
   grain was 2.08. Against a photograph a shadow that faint is invisible over
   half the frame — the picture is already darker and busier than any shadow at
   .06 — so the alphas went to .10 and .16 and the cast blur to 34px.

   IT IS THE SAME SPLIT CONSTRUCTION, not a new one: a contact layer at 1px to
   seat the bottom edge and a cast at 34px to lift it, both warmed to
   rgba(40,30,22) the way .sheet-card and .hero-cta are, because a neutral black
   cast over a warm photograph is a grey smear. This file's own rule, stated at
   .hero-cta, is why there are two and not one: "a single mid-blur cast is what
   a cut-out pasted onto a photo looks like" — which is the literal failure mode
   here.

   IT NO LONGER TOUCHES ANY MEASURED FLOOR, and that is the one thing the
   photograph made easier. On paper the cast pushed the border's outside pair
   from 3.14:1 down to 2.66:1 and that shortfall had to be argued at length. The
   outer edge is now read as the two-tone pair it always was — near-white fill
   plus an --ink hairline, whichever carries at each position — and it measures
   3.92-4.74 at its weakest point on every side at every width, WITH the cast
   shipped. The cast is part of what buys that: it darkens the photograph in the
   band immediately under the card, which is exactly where a light fill needs the
   ground to be dark.

   INSIDE THE CARD IT REACHES NOTHING. An outer shadow does not fall inside the
   panel, so the cell rules sit on the panel fill on both flanks at the same
   4.01:1 the table records. That classification has not changed: this card is
   not a UI component — no focusable element anywhere inside it, no :hover /
   :focus / :active rule on any .week selector, no cursor change (grepped, all
   three) — so 1.4.11's user-interface-component clause does not reach it, and
   the part a reader must resolve is the RULE-WORK that makes the picture legible
   as a calendar. That is held. */
/* MARGIN 0, NOT 22. The 22px top margin here was the gap under .week-heading,
   and the heading is gone. It used to be invisible for a reason that no longer
   applies and is worth recording because the reason FLIPPED: with no padding on
   .week, a first child's top margin collapsed through the section's edge and
   lost to the section's own 56/96. .week now has 64px of padding-block, so
   margin collapsing is off and a 22 here would be a REAL 22 — the card would
   sit 86px below the band's top edge and 64 above its bottom, and the
   photograph would be lopsided. Explicit 0 keeps the band symmetrical and hands
   the rhythm to .week, which is the element that owns it.

   `position: relative` so the card paints above the photograph and the veil,
   both of which are positioned and would otherwise cover it whatever the source
   order — the img is first in the markup for the same reason, and index.html
   says so there. */
.week-card {
  position: relative;
  /* ABOVE THE VEIL AND THE FADE, WHICH IT WAS NOT. Both are ::after, which is
     generated as the LAST child, so a card with `position: relative` and no
     z-index lost to it on paint order and the white wash was landing over the
     glass. Nobody noticed while the wash was a flat 0.18; a vignette painted
     over the card would be obvious immediately. z-index: 1 puts the card on
     top of both, which is what the comment above this rule always claimed.

     It also lifts the wash off the card's own contrast table, in the safe
     direction: the table was measured with a white film over it, and removing
     that film only increases the separation between the panel and the white
     type on it. */
  z-index: 1;
  max-width: var(--col-narrow);
  /* CENTRED IN THE BAND, on Jesse's instruction. It was `margin: 0` — hard left,
     with the photograph running out to the right of it, which is how the
     reference frames its own panel. Centred, the picture reads as a frame
     around the card rather than as a scene the card is parked at one end of.

     The measured note at .week-photo — the card covering band x 3.9%-57.3%,
     with both storefront signs and the abbey dome behind the glass — described
     the LEFT-aligned box and no longer holds. At centre the card sits x
     21%-79% of the band, so on a desktop the signs at photo x 9.5-13.7% and
     36.4-37.8% are now split: the Local Blend sign stays behind the glass and
     the meat market's moves out into the open at the left. The dome, at 51%,
     is closer to dead centre behind the card than it was. Below the column
     width the card is the full band and none of this applies. */
  margin-inline: auto;
  background: var(--glass-fill);
  backdrop-filter: var(--glass-filter);
  -webkit-backdrop-filter: var(--glass-filter);
  border: 1px solid var(--glass-edge);
  border-radius: var(--radius-card);
  /* THE AMBIENT SHADOW GOES WITH THE POOL BEHIND IT. 14/34 at 0.22 was tuned
     for a card sitting on a plain photograph; against a ground that is now
     deliberately dark under the card it disappeared, and the lift the pool
     buys was landing without a contact shadow to sell it. 20/48 at 0.34 is the
     same shape one step deeper — the near shadow at 1/2 is untouched, because
     that one is the card's contact with its ground and did not change. */
  box-shadow:
    inset 0 1px 0 var(--glass-sheen),
    0 1px 2px rgba(40, 30, 22, 0.10),
    0 20px 48px rgba(40, 30, 22, 0.34);
  overflow: hidden;
  overflow: clip;
}

@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .week-card {
    background: var(--glass);
  }
}

/* `table-layout: fixed` is the seven equal columns and `border-collapse:
   collapse` is the ruled paper — both of them browser features rather than
   things this file has to compute. Collapse also means the shared edge between
   two cells is drawn once, so the rules below only ever set a right and a
   bottom and the six internal verticals fall out on their own.

   The table paints no fill of its own. The panel above it is the glass; a
   second background here would double the veil. */
/* THE GRID IS INSET FROM THE CARD; THE CAPTION'S RULE IS NOT. That split is
   the reference's and it is what Jesse means by the lines not hitting the end:
   its grid stops about 4.5% of the card width short of each edge and closes
   itself with a border on all four sides, while the rule under its title runs
   the full width of the panel. Ours did the opposite — the grid ran into the
   card's edges with no left, right or bottom border, so it read as a table
   that had been cropped rather than as one sitting inside a card.

   16px, which is not a new number: it is .week-strip's own horizontal padding,
   so the grid's left edge lands exactly under the first letter of the title.
   The bottom margin matches, leaving the same air under the grid as beside it. */
.week-table {
  width: calc(100% - 32px);
  margin: 0 16px 16px;
  border-collapse: collapse;
  table-layout: fixed;
}

/* The reference's header strip, and it is the table's own <caption>. The label
   is the entire header there — "no icon, no button, no count" — and it is the
   entire header here too, except that this one has a job the reference's did
   not: IT IS WHERE THE CARD ADMITS THE DATES ARE AN EXAMPLE. A dated grid that
   does not say so is a listings claim (PRODUCT.md principle 1), and the
   admission has to travel with the card, because the card is the thing that
   gets screenshotted — and it is also the only thing .week contains, so there
   is nowhere else in the section for the admission to live.

   UPPERCASE IN CSS RATHER THAN IN THE MARKUP, and this is the page's first
   text-transform — grep the file, there was not one before this. The reason is
   assistive tech: VoiceOver spells short all-caps strings letter by letter, and
   "AUGUST" typed into the HTML would be read out as six letters. Transformed
   here it stays the word "August" to a screen reader and reads as a strip label
   to everyone else.

   THE FACE IS A MONO STACK, WHICH IS THE REFERENCE'S OWN "monospace-ish" AND
   THE PAGE'S FIRST. --font-mono is `ui-monospace, SFMono-Regular, "SF Mono",
   Menlo, Consolas, monospace` — a system stack, so it costs no webfont on a
   page that ships four woff2 and no more. It is what separates the strip from
   everything else in the card at a glance without spending a value on it: a
   tracked mono line reads as a label the way a tracked Switzer line reads as
   small caps of the body face.

   11px ON A 12px LINE BOX, AND THE LINE BOX IS LOAD-BEARING. The size came down
   from 12 with the face change (mono runs optically larger at the same size and
   the strip was the loudest thing on the card in the light build), but the LINE
   BOX stayed at 12 so the strip still measures 53px tall at every width —
   20 + 12 + 20 + the 1px rule. Declaring it as a length rather than as `1`
   keeps the card at a known height while the type inside it moves.

   0.12em is the reference's own measured tracking. Substituted at 0 on the live
   page the strip's text narrows and its tracking falls UNDER the weekday row's
   own 0.08em, so the card's header ends up set tighter than the column heads
   beneath it and the pair reads as two sizes of one small grey row rather than
   as a label over a table.

   --on-glass-mute, AND IT IS THE ONLY PLACE ON THE PAGE WITH A THIRD VALUE OF
   ITS INK. The reference sets the strip at 55-65% white and the weekday row
   "slightly brighter" — three values of one white. On the light polarity this
   card had two inks and no third, and the strip and the weekday row shared
   --ink-secondary. Dark glass buys the third back, but not at the reference's
   number: measured on the strip's OWN local ground (L* 35.9, darker than the
   grid's 41.5 because the panel sits over a parking lot up there rather than
   over roofs) white at 0.62 lands at 4.03:1 and white at 0.72 lands at 4.84:1.
   0.72 is the lowest that clears 4.6, so that is the strip.

   20px above and below is the reference's own padding. The 16px inset is NOT
   --gutter: the gutter is the PAGE's margin and steps 20 / 24 / 40 across the
   breakpoints, so a label set from it would slide inward as the window grew
   while the card underneath it stopped growing at 544 — 40px is 7.4% of that
   cap and 14.3% of the 280 the card renders at 320, i.e. two different insets
   on one box. 16 is the card's own inset and it holds at every width.

   The hairline that closes the strip is the same --glass-rule the grid runs on,
   because it is the grid's top edge as much as it is the strip's floor. */
.week-strip {
  padding: 20px 16px;
  font-family: var(--font-mono);
  /* 13, up from 11, on Jesse's pick. At 11 it was the same size as the SUN/MON
     row directly beneath it, so the card had two headers of equal weight and no
     title — the reference gets away with 11 because its string is four
     characters shorter and its weekday row is set quieter than ours.

     It still holds one line at the 320 floor: at 13px with this tracking the
     string measures 234.8 against 247.5 of available width there, and 315 at
     390. No wrap, no second step needed. */
  font-size: 13px;
  font-weight: 500;
  line-height: 15px;
  letter-spacing: 0.12em;
  text-align: left;
  text-transform: uppercase;
  color: var(--on-glass-mute);
  /* PULLED BACK OUT TO THE CARD'S EDGES. The table below it is inset 16, and a
     <caption> is a box inside the table, so without this the title's rule would
     inset with it. In the reference this rule is the one line that crosses the
     whole panel — it separates the header from everything under it, which is a
     job an inset line does not do. */
  width: calc(100% + 32px);
  margin: 0 -16px;
  /* AND IT FADES OUT BEFORE IT REACHES EITHER SIDE, on Jesse's read of the
     reference, where the same line softens off rather than butting into the
     card's edge. A hard 1px border cannot do that, so the rule is drawn as a
     background instead: a 1px strip pinned to the bottom of the caption's box,
     carrying a gradient that is solid across the middle 72% and falls to zero
     alpha at both ends.

     The stops are rgba(255,255,255,0) rather than `transparent`. They are the
     same colour in every engine that interpolates premultiplied — which all
     current ones do — but `transparent` is defined as rgba(0,0,0,0), and any
     engine that ever interpolates unpremultiplied would drag this fade through
     grey on its way out. Naming the colour costs nothing and cannot be wrong.

     14% and 86%: the grid below is inset 16 of the card's ~350, which is 4.6%,
     so the line is at full strength well before the first column's edge and
     only gives up in the margin outside it. The fade is in the part of the card
     the grid does not use. */
  background-image: linear-gradient(90deg,
    rgba(255, 255, 255, 0) 0%,
    var(--glass-rule) 14%,
    var(--glass-rule) 86%,
    rgba(255, 255, 255, 0) 100%);
  background-repeat: no-repeat;
  background-position: 0 100%;
  background-size: 100% 1px;
}

/* SUN MON TUE …, seven equal columns, and no rule under them — the reference is
   explicit that the row "simply sits above the grid with air". The line the eye
   reads below this row belongs to the GRID (see the first-row border-top a
   block down); the row itself closes on nothing, which is what leaves the air.

   THE SAME MONO AS THE STRIP, one step smaller and one value brighter, which is
   the reference's own relationship: "the same small-caps letter-spaced
   treatment as the header but slightly brighter". Brighter is --on-glass-dim
   against the strip's --on-glass-mute, and it measures 4.74:1 at its worst
   pixel — the tightest pair anywhere on this card, and the one the panel's
   alpha is derived from. The weekday row sits higher up the photograph than the
   grid does, so its ground is the brightest ground any text on this card has.

   10px and 0.08em, both tighter than the strip's. Measured at 320, where the
   panel is 280 wide and a column renders 39.7: the widest label is MON, not
   WED, and the strip's own 12px/0.12em sets it at 33.27 inside a 35.70 content
   box — 1.22px of air a side, which is a jammed column. 10px/0.08em sets MON at
   26.88, so the air goes to 4.41 a side. The tracking comes down with the size
   on purpose: tracking is a proportion of the size everywhere except in a box
   this narrow, where it is the thing that overflows.

   2px OF SIDE PADDING, AND IT IS HEADROOM RATHER THAN SPACING. The label is
   centred, so while it fits the box the padding moves nothing; what it does is
   set the width at which the label would WRAP. Measured at 320 by substituting
   the value on the live page: 0 gives a 39.70 box, 2 gives 35.70, 4 gives
   31.70, 6 gives 27.70 — against MON's 26.88. So 2 leaves 8.82px of headroom, 6
   leaves 0.82 and is one substituted font away from a two-line weekday row that
   would push the whole grid down, and 0 spends headroom the row does not need
   on paper it cannot use. 2 is the value that keeps a full character of slack
   without letting a label reach the cell rule.

   14px above and 10px below: more air over the row than under it, so the row
   belongs to the grid beneath rather than floating between the two. Read off
   the page, the row measures 34px tall at every width. */
.week-weekday {
  /* The reference gives its weekday band noticeably more air than the caption
     rule above it — about 48px against our 34.5 — and sets the labels a shade
     larger. Both moved; the band is the calendar's own header and was reading
     as a caption to the grid rather than as part of it. */
  padding: 17px 2px 13px;
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 500;
  line-height: 1;
  letter-spacing: 0.08em;
  text-align: center;
  text-transform: uppercase;
  color: var(--on-glass-dim);
}

/* THE RULES ARE THE STRUCTURE. The reference transcription puts it in one line:
   "Take them away and it stops being a calendar and becomes a list." They are
   boundaries between cells, not ornament, and
   agent-os/standards/contrast-headroom.md puts a 3:1 floor under exactly that.

   --glass-rule, WHITE AT 0.58, AND THE ALPHA IS THE LOWEST THAT CLEARS. The
   panel is not one value — it runs L* 23.3-41.5 (measured, 1440) — and a
   semi-transparent rule moves WITH its ground, so the pair that binds is the
   rule over the panel's BRIGHTEST pixel, where the rule has least to add.
   Solved against that pixel, 3:1 needs white at 0.534; 0.58 ships and measures
   3.25:1 at its worst position and about 4:1 against the panel's mean. Going
   further makes the graph paper louder than the numbers on it. On the light
   polarity this line was --ink-secondary at 4.01:1; there is no ink token that
   works here, which is why --glass-rule exists.

   ONLY A RIGHT AND A BOTTOM ARE SET. Under border-collapse the shared edge
   between two cells is one line, so 14 cells with a right border give the six
   internal verticals per row and nothing doubled. The last column drops its
   right and the last row drops its bottom, because the panel's own 1px border
   is already sitting there and two 1px lines a pixel apart read as a 2px line.

   96px, AND IT IS THE REFERENCE'S OWN CELL RATIO WORKED BACKWARDS. The
   reference cell measures roughly 55x105 on a 390pt screen, a ratio of 1.909.
   At 390 this panel is 350 wide (390 less two 20px gutters), so a column is 50
   by arithmetic and 49.7 as rendered once the collapsed rules take their share;
   50 x 1.909 = 95.5. One number, not a clamp: above ~584px the panel stops
   growing at its 544 cap, so the columns top out at a rendered 77.42 and the
   cells go squarer as the calendar gets wider — which is what a wider calendar
   does.
   A vw-driven height would keep the phone's proportion and give a 148px-tall
   cell on a laptop.

   RE-CUT AGAINST THE REAL CONTENT AND KEPT AT 96, which is the point of
   re-cutting it rather than the point of leaving it alone. The 95.5 above was
   derived against placeholder cells; with the six populated cells in, the two
   tallest are level at 390 — Sat 29 is 8 top pad + 18 number band + 51 of two
   stacked 24px logo discs + 6 bottom pad = 83.0 (it was 79.0 with the 22px
   monograms), and Sun 30 is 8 + 18 + a 47.2 name box + 6 = 79.2. That name box is FOUR lines, not three:
   read off the rendered cell it is 47.2px tall at a 10.8px line box plus this
   rule's 4px pad, and the break is Mill- / stream / Arts / Festival. So 96
   leaves 17px of slack at the phone width, which is air the reference has too:
   fourteen cells, six of them carrying anything. The number that would actually
   bind is 320, where the name runs to five lines and the box measures 58:
   8 + 18 + 58 + 6 = 90, still inside 96. Nothing is clipped and no row has to
   grow past the floor at any width this card ships to — measured, every row is
   96.5px tall at 320, 390, 768 and 1440.

   THE CELL CARRIES NO PADDING AND NO CONTENT LAYOUT — .week-cell-in below does
   both. All this box owns is the rule-work, the state grounds and the height
   floor. A <td> cannot be a flex container (display:flex takes it out of the
   table box model and the row wraps it in an anonymous cell, which is where
   `table-layout: fixed`'s seven equal columns come apart), so the three stacked
   zones live one level in.

   vertical-align: top so the inner box starts at the cell's top edge rather than
   floating in the middle of a tall row. */
.week-cell {
  height: 96px;
  padding: 0;
  vertical-align: top;
  border-right: 1px solid var(--glass-rule);
  border-bottom: 1px solid var(--glass-rule);
}

/* THE GRID'S OWN TOP EDGE, which is what "bounded on all four sides" was
   missing. The perimeter's other three sides are the panel's own frame; row
   one had nothing above it, so the top band of cells was open at the top and
   the grid read as columns hanging off the weekday row rather than as ruled
   paper. The weekday row keeps its air above this line — the reference's "no
   divider under it" is about the row, and this rule belongs to the grid. */
.week-table tbody tr:first-child .week-cell {
  border-top: 1px solid var(--glass-rule);
}

/* THE GRID CLOSES ITSELF ON ALL FOUR SIDES now that it no longer runs into the
   card. The right border on the last column and the bottom on the last row were
   both set to 0 for the opposite arrangement, where an outer line would have sat
   on the card's own edge; inset, their absence is a box missing two sides. The
   left border is new for the same reason — .week-cell only ever drew right and
   bottom, so the first column had no edge of its own. */
.week-cell:first-child {
  border-left: 1px solid var(--glass-rule);
}

.week-table tbody tr:last-child .week-cell {
  border-bottom: 1px solid var(--glass-rule);
}

/* THE THREE ZONES, AND THEY ARE A FLEX COLUMN RATHER THAN THREE ABSOLUTE BOXES.
   The reference's cell is number-top / cluster-middle / line-bottom with the
   line sitting ON the cell's bottom edge, and the first build of this stacked
   all three at the top and left ~50px of dead paper under them — the card read
   as a grid of numbers with debris.

   ABSOLUTE POSITIONING WAS THE OBVIOUS FIX AND IS THE WRONG ONE. Pinning the
   line to `bottom: 6px` puts it out of flow, so a multi-line name grows UP
   THROUGH the day number instead of making the row taller. "Millstream Arts
   Festival" runs to four lines at 390 and five at 320 (measured; see
   .week-line's rendered-break table), so that overlap is the common case here,
   not the edge one. A flex column cannot
   overlap: the row grows and every cell in it grows with it.

   height: 100% is what makes the bottom line sit on the cell's bottom EDGE
   rather than 96px down from its top. A table row is as tall as its tallest
   cell, so the <td> a short cell gets is taller than its own content; without
   this the inner box would stop at its own min-height and the line would float
   mid-cell in every column except the tallest one. min-height carries the floor
   for the case where the whole row is empty.

   8px top / 6px bottom / 4px sides: the top is the reference's own inset, the
   bottom is one step tighter because the line is type sitting on a rule and
   type wants less air under it than a number wants over it. 4px sides is what
   leaves 30.70px of usable column at 320 — the number every measurement in
   .week-line and .week-mark is taken against. (An earlier draft wrote 31.2
   here and propagated it through both blocks; the content box is 30.70.) */
.week-cell-in {
  display: flex;
  flex-direction: column;
  box-sizing: border-box;
  min-height: 96px;
  height: 100%;
  /* 2px of side padding, not 4. On a 390px screen the cell is 44px wide and
     every one of those four pixels is the difference between a label wrapping
     on its own spaces and a label breaking inside a word. */
  padding: 8px 2px 6px;
}

/* THE CLUSTER, CENTRED IN WHAT IS LEFT. flex: 1 is the whole centring mechanism
   — the band takes every pixel between the number and the line, and justifies
   its discs into the middle of it, which is exactly the reference's "centred in
   the cell". A cell with no cluster has no band, and .week-line's own auto
   margin drops the name to the bottom instead.

   3px of gap, and the reference is explicit that a two-item day stacks
   VERTICALLY with "a few px of overlap-free gap". Two 24px logo discs plus 3 is
   a 51px band, which is the tallest thing any cell carries. */
/* STACKED, NOT SPACED, which is what the reference does and what makes two
   happenings on one day read as a pile rather than as a list. The gap was 3px
   of clear air between discs; they now overlap by 6 and the upper one sits in
   front, so a two-mark day is visibly one object.

   flex: none, not 1 1 auto. Growing to fill the cell was what pushed the label
   to the bottom edge and, on a 44px phone cell, pushed it past the card
   entirely — see .week-line. The marks now sit directly under the numeral and
   the label directly under them. */
.week-marks {
  display: flex;
  flex: none;
  flex-direction: column;
  align-items: center;
  gap: 0;
  min-height: 0;
  padding-top: 2px;
}

/* THE SEPARATOR IS A RING IN THE PANEL'S OWN COLOUR, not a gap. Overlapping
   two white discs with nothing between them merges them into one blob; the
   reference cuts the same problem with a dark rim. --glass is the panel's flat
   colour, so the rim reads as the card showing between the discs rather than
   as an outline drawn around them. */
.week-mark + .week-mark {
  /* -14 = -6 x (42/18), scaled with the discs so a stacked pair keeps the same
     proportion of overlap it was drawn with rather than merging further as the
     marks grow. It is a third of the disc either way. */
  margin-top: -14px;
  box-shadow: 0 0 0 2px var(--glass);
}

/* THE MARK IS THE BUSINESS'S OWN LOGO, AND THAT REVERSES A DECISION THIS FILE
   ARGUED AT LENGTH. What stood here was a monogram — one letter in a --tint-*
   disc — chosen over a real logo on a trademark question nobody had asked. It
   was a substitution, and Jesse asked twice for the thing itself: "just put
   there logo no title or words", then "use real buisnesses logos". The
   monograms are gone. THAT LEFT --tint-* AND --glyph-* UNREFERENCED — this card
   was their last use in the file. One of the six came back: the confirmation's
   confetti reads --glyph-warm out of :root (see confettiColors in app.js). It
   was three for a while — the confetti also carried --glyph-sky and
   --glyph-moss — until the burst was rebuilt against its reference, whose field
   has no blue and no green anywhere in it and takes its two neutrals from --ink
   and --ink-secondary instead. So --tint-* (three) and --glyph-sky and
   --glyph-moss (two) are declarations and nothing else now. They are left in
   :root rather than deleted because DESIGN.md documents the six of them and the
   survey cards they were cut for are still on the roadmap; whoever confirms
   those are not coming back should take the remaining five out in one commit.

   THE DISC IS BAKED INTO THE ASSET, NOT DRAWN IN CSS. Each mark ships as a
   96x96 webp that already contains its white circle with the logo fitted
   inside it and everything outside the circle clipped to alpha, so this rule
   is a size and a radius and nothing else, and each logo could be fitted by
   eye rather than by one rule that suits none of them. Two of the six (Bad
   Habit's roundel, the Local Blend's cup) are circular marks and fill the disc
   edge to edge; the other four are marks on white and sit inside it. That
   matches how the reference treats its own brand marks — each circle is the
   brand's own ground, not a chip the page has coloured. The ground measures
   6.33:1 against the panel at its worst, on a 3:1 floor.

   24px, AND IT IS THE REFERENCE'S OWN RATIO WORKED BACKWARDS RATHER THAN ITS
   NUMBER COPIED. Its disc is 26 on a 55px column, a ratio of 0.473; this
   column renders 49.2 at 390, so 0.473 x 49.2 = 23.3. 26 copied straight across
   would leave 2.35px of air a side in the 30.7px content box at 320 and read as
   a disc jammed into a box; 24 leaves 3.35 at 320 and 8.6 at 390. One number
   rather than a clamp, for the same reason the cell height is one number: the
   panel stops growing at 544.

   TWO STACKED IS 24 + 3 + 24 = 51, which is 4px taller than the monogram stack
   it replaces and still 45px inside the cell — the tallest cell at 390 comes to
   8 + 18 + 51 + 6 = 83 against a 96 floor, so nothing moved.

   IT IS AN <img> WITH A REAL alt AND NO aria-hidden, WHICH ALSO REVERSES WHAT
   WAS HERE. The monogram was hidden from the accessibility tree because "B"
   read out is noise and "Bad Habit Brewing" read out would put a claim into the
   tree that the visible card is careful not to make. A real logo is not a
   letter: it names its business to everyone who can see it, so hiding it from a
   screen reader would be showing sighted readers something and telling everyone
   else nothing. The <caption> used to carry an "as an example" disclosure into
   the same tree, which is what kept the pair honest; it says "August" now, so
   the honesty argument for showing these logos is down to "everyone gets the
   same thing", which is a fair bar but a lower one. See .week-strip. */
.week-mark {
  display: block;
  flex: none;
  /* 42 = 21 x 2, on Jesse's instruction that the marks read twice as large. The
     history is worth keeping because this number now sits well past every
     ceiling this block was measured against: 18 was the reference's own ratio
     worked backwards, 21 was the middle ground, and 24 is recorded here as
     crowding a 44px phone cell. 42 is bigger than the phone cell's content box
     outright.

     WHICH IS WHY THE WIDTH IS A min() AND NOT A NUMBER. The column is 68.4px of
     content at the panel's 544 cap and 30px at 320, so 42 is comfortable on a
     desktop and impossible on a phone — a fixed 42 would push the disc through
     the cell walls into its neighbours. min() takes the smaller of the two: the
     full 42 wherever the column can hold it with 3px of air a side, and the
     column itself where it cannot, so a phone gets the largest disc that fits
     rather than a broken grid.

     aspect-ratio, not a matching height, because the width is now conditional
     and a fixed height would go oval the moment min() picked the column. */
  width: min(42px, calc(100% - 6px));
  aspect-ratio: 1;
  height: auto;
  border-radius: 50%;
}

/* THE BOTTOM LINE, AND IT IS THE ONE PLACE THE REFERENCE'S CONTENT DOES NOT
   TRANSLATE. Its line is a dollar total — "$144.99", seven glyphs, comfortable
   in a 49px column. Ours is an event name, and "Millstream Arts Festival" holds
   a ten-letter word that does not fit on one line in any column this card ever
   has: 30.70px of usable width at 320, 40.70 at 390, 68.42 at the 544 cap
   (the content box inside .week-cell-in's 4px of side padding).

   SO IT WRAPS, AND THE RULES BELOW ARE WHAT KEEP IT INSIDE THE COLUMN AT 320
   RATHER THAN CLIPPED MID-WORD, which is what the first build did on the
   Saturday column. Three of them, in the order the browser tries them:
     - normal wrapping first, at spaces. "Farmers market" is two short words and
       never needs anything else.
     - SOFT HYPHENS IN THE MARKUP second — Mill&shy;stream, Festi&shy;val,
       Farm&shy;ers. They are invisible until the break is needed, and they are
       the reason a broken name still reads as typography rather than as a chop.

   ONE SOFT HYPHEN PER WORD, AS LATE IN THE WORD AS THE NARROWEST COLUMN
   ALLOWS, AND ONLY IN WORDS THAT ACTUALLY NEED ONE. A soft hyphen is a break
   OPPORTUNITY, not an instruction, and greedy wrapping takes the widest
   opportunity that fits — so every extra one is a chance to fill a line with
   half a word. Both of the first draft's extras were caught in a screenshot:
     - "mar&shy;ket" let the 768 column fill its first line to "Farmers mar-"
       rather than break at the space. Removed outright: "market" measures
       30.08 against the narrowest column's 31.2, so it never needs a hyphen at
       all and now always sets whole.
     - "Fes&shy;ti&shy;val" let 390 fill a line to "Arts Fes-" (40.05 against
       41.2 — it fits, which is exactly why it was taken) when "Festival" fits
       whole on the next line. Cut to one hyphen at "Festi&shy;val": "Arts
       Festi-" is 45.73, too wide to be taken, so 390 gets "Arts / Festival" —
       and 320 still gets "Festi- / val", which it needs, because "Festival" is
       33.23 against a 31.2 column.
     - overflow-wrap: break-word last, and it only fires for a word longer than
       the column with no soft hyphen in it. It is the guard that means no
       future name can overflow this cell, however it is spelled.

   `hyphens: auto` WAS THE FIRST TRY AND IT IS NOT DEPENDABLE. Two screenshots
   killed it. It needs a hyphenation dictionary the engine may simply not have —
   headless Chrome hyphenated "mar-ket" and did NOT hyphenate "Millstream" or
   "Farmers" in the same paragraph, on the same run — and where it declines,
   the next rule down chops mid-stem with no hyphen at all: the 390 shot came
   back reading "Millstrea / m Arts / Festival". Soft hyphens are the same
   typography with none of the dependency: `hyphens` stays at its `manual`
   default and the break points are facts of the markup, identical in every
   engine.

   `break-word` RATHER THAN `anywhere` for the guard. `anywhere` was tried for
   its one real advantage — it shrinks the line's min-content width, so a long
   word can never force the table wider — but in Chrome it also breaks EAGERLY,
   at the exact overflow point, ahead of any soft hyphen. The min-content
   advantage was worth nothing here anyway: the table is `table-layout: fixed`,
   so the column widths come from the table's own width and no cell's content
   can widen them.

   margin-top: auto pins it to the bottom in the cells with no cluster above it
   (Aug 28, Aug 30). Where there is a cluster, .week-marks' flex: 1 has already
   eaten the free space and the auto margin resolves to zero.

   padding-top: 4px IS A FLOOR AND IT DOES NOT BIND TODAY — measured, not
   assumed. Substituted on the live page — 0, 4 and 10 at 390, and 0 and 4 at
   320 — the first and last text rows of all three names land on the SAME y to
   two decimals and every row of the table stays 96.5px tall; only the box
   grows. That is the
   auto margin above doing its job: the box is pinned to the cell's bottom edge,
   so padding on its top pushes nothing down. What the 4 buys is the case the
   auto margin cannot cover — a name long enough to consume the cell's free
   space, where the row grows instead and this is the gap that stops the name
   touching the disc or the number above it. Kept rather than deleted for that
   reason, and named here so nobody re-derives it as spacing.

   9px, WHICH IS SMALLER THAN ANYTHING ELSE ON THIS PAGE AND IS ALLOWED TO BE.
   Grepped: before this card the page's smallest declared size was 13px, and the
   card brings 12 (day numbers), 11 (the mono strip), 10 (weekday row) and this
   9. The card
   is a PICTURE OF A SCREEN, and its type is at the scale a phone calendar's is;
   the page's real copy floor (16px on inputs, 13 on the eyebrow) governs text a
   reader has to read, not the labels inside an illustration.
   --ink at 18.54:1 carries it — the same ink as the day numbers, because the
   reference sets its total in the same near-white as its numbers.

   -0.02em, AND IT IS BUYING 2.01px OF LABEL WIDTH. Measured on the rendered
   page with the cell's own computed font (500 9px system), against the usable
   column width (30.70px at 320, 40.70 at 390, 68.42 at 768 and at the 544 cap).
   THOSE THREE ARE THE CONTENT BOX, NOT THE CELL: .week-cell-in is 76.42 wide at
   768 with 4px of padding a side, so the line gets 68.42. An earlier draft of
   this note ran the whole block against 31.2 / 41.2 / 68.92 — uniformly 0.50px
   generous — and every margin it quoted was half a pixel too comfortable. The
   sweep that pins it: at -0.014em the label is 68.47 unwrapped and still takes
   two lines; at -0.015em it is 68.34 and sets on one.

                        at -0.004em   at -0.02em   against
     "Farmers market"       69.73        67.72      68.42 at 768  <- one line
     "market"               30.95        30.08      30.70 at 320  <- see below
     "Farmers"              36.19        35.19      breaks at 320, fits at 390
     "Millstream"           46.44        45.00      breaks at 390 and below

   At -0.004em "Farmers market" was 1.31px too wide for the 768 column (69.73
   against 68.42), so the widest layout the card ever gets wrapped a two-word
   label — to "Farmers / market", at the space, which is the least bad break
   available and still one line more than the label needs. -0.02em takes 2.01px
   off it and lands 0.70px inside the column. It buys the 320 case at the same
   time, and that case was not a near miss but an actual break: "market" at
   30.95 was 0.25px OVER a 30.70 column, so 320 break-worded it mid-stem to
   "Farm- / ers / marke / t". At -0.02em it is 30.08 with 0.62px to spare and
   the word survives whole. Everything that SHOULD still break still does —
   "Millstream" is 45.00 against 40.70, so the soft hyphen in it still fires
   at 390.

   THE RENDERED BREAKS, read off the page rather than predicted:
     320  "Farm- / ers / market"   3 lines, 36.4px box
          "Mill- / stream / Arts / Festi- / val"   5 lines, 58px box
     390  "Farmers / market"       2 lines, 25.6px box
          "Mill- / stream / Arts / Festival"       4 lines, 47.2px box
     768  "Farmers market"         1 line, 14.8px box
          "Millstream Arts / Festival"             2 lines, 25.6px box
   "Farmers / market" is NOT what 320 gets and the earlier draft of this note
   said it was: "Farmers" is 35.19 against a 31.2 column, so 320 has to take the
   hyphen in it.

   line-height 1.2 is as tight as stacked lines go before the descenders of one
   touch the ascenders of the next; at 320 the festival name runs to five of
   them and still clears the cell.

   NO `text-wrap: pretty` HERE, AND IT IS THE ONE PIECE OF TYPE ON THIS PAGE
   THAT MUST NOT HAVE IT. `pretty` optimises a paragraph for even line lengths
   and will spend a hyphen to get them — at 768 the usable width is 68.42px,
   "Farmers market" fits on ONE line at the shipped -0.02em, and `pretty` leaves
   it there: re-tested at 768, one 14.8px line, on the 0.70px of slack the
   tracking bought. THIS RULE IS A FORWARD GUARD, NOT THE RECORD OF A LIVE
   FAILURE, and an earlier draft of this note claimed otherwise — it said
   `pretty` broke the label to "Farm- / ers market" at -0.004em. Re-measured, it
   does not: at -0.004em the label simply does not fit (69.73 against 68.42) and
   wraps to "Farmers / market" identically under `auto`, `pretty` and `balance`.
   `pretty` is not the cause of anything here today. It is excluded because
   0.70px is the entire margin the one-line setting wins on, and `pretty` is
   free to spend a hyphen for even lines the moment that margin goes. Every
   other block on this page is running text where that trade is right; this is a
   two-word label in a 30-70px box, where the only good line break is the one at
   the space. Greedy wrapping gives "Farmers market" whole at 768, "Farmers /
   market" at 390 and the soft hyphens only where the column genuinely cannot
   take the word. */
/* NOT PINNED TO THE BOTTOM ANY MORE, on Jesse's instruction — "they dont need
   to be bottem on the day". `margin-top: auto` shoved the label onto the cell's
   floor, which at 544 merely looked detached from the marks it belongs to and
   at 390 was worse than that: the cell is 44px wide there, "Farmers market"
   takes three lines, and the third line rendered BELOW the card's own edge.

   The soft hyphens went with it. "Farm&shy;ers market" and
   "Mill&shy;stream Arts Festi&shy;val" broke to "Farm-" / "ers market" in a
   44px column, which is the "extra lines" in the instruction — a hyphen mid
   word reads as a typo at 9px. Plain words wrap on their own spaces now. */
.week-line {
  padding-top: 3px;
  /* MEASURED AGAINST THE LONGEST WORD, NOT CHOSEN. The binding constraint is
     "Millstream" — ten characters that have to fit one 44px cell minus its
     padding, or overflow-wrap breaks it mid-word and the label reads as a typo.
     Rendered widths at this weight and tracking: 9px needs 45.0, 8px needs
     40.6, 7.5px needs 38.3, against 40px of content. 7.5 is the largest that
     clears it, so it is the size. .week-line goes back to 9px at 940 where the
     cell is 77px wide and the constraint is gone.

     overflow-wrap: break-word stays below as a guard rather than as the
     mechanism: with this size it never fires, and if a longer name is ever
     added it degrades to a broken word instead of to text over a grid line. */
  font-size: 7.5px;
  font-weight: 500;
  line-height: 1.2;
  letter-spacing: -0.02em;
  text-align: center;
  color: var(--on-glass);
  overflow-wrap: break-word;
}

/* A COLOURED DOT IN FRONT OF THE NAME, one hue per kind of happening, on
   Jesse's instruction: sky for the Millstream Arts Festival, moss for the
   farmers market. It is the card's only colour and it is doing the job colour
   is actually good at — telling two kinds of thing apart at a glance, on a
   grid where every label is 7.5px and reads as texture until you look at it.

   THE HUES ARE THE APP'S OWN, NOT NEW ONES. #A8D8EE and #D9E8C8 are the source
   values the --tint-sky / --tint-moss pair is mixed from, taken here at full
   strength because this panel is dark glass and the tints are mixed toward
   white for ink on paper. They land at 11:1 and 13:1 on --glass, against the
   3:1 a decorative mark owes.

   IT DOES NOT BREAK THE TINT RULE ABOVE ("backgrounds only, never a text
   colour"). That rule exists so --accent stays the single saturated thing on
   the page and the submit button stays the one action; these are pastels on a
   dark card, 4px across, and nothing about them competes with an orange
   button.

   SIZED IN em SO IT TRACKS THE LABEL. .week-line is 7.5px in the phone cell
   and 9px from 940 up, and a fixed 4px dot would be proportionally larger on
   the phone, where the cell is already the tight case. 0.55em is 4.1px and
   4.95px respectively.

   THE SPACE BEFORE THE FIRST WORD IS A margin, NOT A SPACE CHARACTER, so there
   is no break opportunity between the dot and the word it belongs to — a
   centred label that wraps must never leave the dot alone on line one.

   THAT ALONE WAS NOT ENOUGH, AND IT IS WHY THE SOFT HYPHENS ARE BACK IN THE
   MARKUP. A break is still allowed at an inline-block's edge, and the dot
   spends 5.9px of a column that carries 30px at 320 and 40px at 390 — enough
   that "Farmers" (30px of type) and "Millstream" (38) each stopped fitting
   beside it and the browser put the dot on a line of its own. Farm&shy;ers and
   Mill&shy;stream/Festi&shy;val give it a break point INSIDE the first word
   instead, so line one is the dot and a hyphenated stem. Measured after: no
   dot is alone at 320, 390 or the 544 cap, no label got taller, and neither
   hyphen is taken on a desktop, where both names fit whole. */
.week-line--fest::before,
.week-line--market::before {
  content: "";
  display: inline-block;
  width: 0.55em;
  height: 0.55em;
  margin-right: 0.35em;
  border-radius: 50%;
  /* The dot sits on the x-height rather than the baseline: a circle on the
     baseline reads as a full stop that wandered to the front of the line. */
  vertical-align: 0.06em;
}

.week-line--fest::before {
  background: #A8D8EE;
}

/* GREENER THAN THE TINT IT STARTED AS, on Jesse's instruction. #D9E8C8 is the
   app's moss taken at the value it has on paper, and on a dark card at 4px
   across it read as an off-white chip rather than as a green one — the hue was
   there and too pale to carry at that size. Same hue (88 degrees), saturation
   up from 45% to 55% and lightness down from 85% to 70%, which is what makes
   it read green next to the sky dot instead of merely lighter than it.

   It measures 11.42:1 on --glass, within a rounding error of the sky dot's
   11.47, so the pair still carries the same weight. Both are far above the 3:1
   a decorative mark owes. */
.week-line--market::before {
  background: #B5DD88;
}


/* The day number. --on-glass at 6.10:1 on the panel at its worst pixel — the
   reference's "near-white for in-range days", which on this polarity is
   literally that. Tabular so 26 and 1 sit on the same stem width down a column,
   the same reason .scale-figure asks for it.

   12px IS THE CARD'S LARGEST AND THE LOZENGE IS WHAT CAPS IT. The card runs
   four type sizes — 12 for the numbers, 11 for the mono strip, 10 for the
   weekday row, 9 for the bottom line — and the numbers are the top of that.
   (The strip used to share this 12; it came down to 11 when it took the mono
   face, which runs optically larger at the same size.) The ceiling is the
   anchor pill: a two-digit tabular
   number has to sit inside 22px of lozenge, and substituted on the live page at
   320 it measures 15.45 at 12px, 16.59 at 13 and 17.72 at 14 — 3.28, 2.71 and
   2.14px of air a side. 12 is the largest size that keeps three whole pixels
   either side of the number, and nothing else in the cell moves with it: cell
   height stayed 96.5 and the festival name's box stayed 58px at every size
   tested, because the 18px band below is declared independently.

   LINE-HEIGHT 18 RATHER THAN 1, AND IT IS THE LOZENGE THAT SETS IT. The anchor
   cell's number wears an 18px-tall pill (.week-cell--today below); at
   line-height 1 the number band would be 12px in thirteen cells and 18 in one,
   so the anchor's cluster and bottom line would sit 6px lower than its
   neighbours' and the row would visibly step. Declaring the band once here
   costs nothing in the plain cells and keeps all fourteen on one baseline.

   flex: none because the cell is a flex column — without it the number would
   stretch to share the free space with the cluster instead of sitting at the
   top of it. */
.week-day {
  display: block;
  flex: none;
  /* 13, measured off the reference rather than picked: its day numerals stand
     about 1.5x its caption, and at 11px of caption that is 13 here. They were
     12, which read as a label rather than as the thing each cell is about. */
  font-size: 13px;
  font-weight: 500;
  line-height: 18px;
  text-align: center;
  font-variant-numeric: tabular-nums;
  color: var(--on-glass);
}

/* --- The three states --------------------------------------------------- */

/* OUT OF RANGE — 1-5 September, the tail outside the month the strip names.

   THE POLARITY GAVE THIS STATE BACK ITS MECHANISM AND STILL NOT THE
   REFERENCE'S NUMBER. On the light build this was the one place the reference
   could not be inverted at all: ink at 40% on a near-white panel composites to
   #9F9F9D, 2.60:1, an unreadable day rather than a dim one, so the state had to
   be a value step between two ink tokens. On dark glass an opacity step works
   again — but 0.40, the reference's own, measures 2.4:1 here, because our
   photograph is a sunlit aerial and its is dark water. --on-glass-dim at 0.78
   is the shipped step and measures 5.75:1 at its worst pixel, on a ground that
   is the darkest any text on this card stands on. It is a quieter number that
   is still plainly readable, which is what "out of range" should mean; it is a
   smaller step than the reference's, and that is the photograph's doing, not a
   choice.

   THE NUMBER ONLY. Sep 1 and Sep 4 carry a disc and a name, and dimming a 9px
   name to say something about which month it is in would spend legibility on a
   distinction the number already makes on its own. */
.week-cell--out .week-day {
  color: var(--on-glass-dim);
}

/* ELAPSED — Sat 29 Aug, the day before the anchor, hatched the way the
   reference hatches the day before its own today.

   45deg hairlines at 6px pitch, which is the reference's "thin parallel 45
   degree lines at low opacity" measured off its own cell. THE ALPHA IS CAPPED
   BY THE DAY NUMBER STANDING ON IT, not by taste. A white hatch lightens the
   local ground, and the "29" sitting in this cell is white on that lightened
   ground: at 0.16 the pair measures 4.22:1 and is under the floor. Solved
   against the panel's brightest pixel the hatch has to stay under 0.119 for the
   number to hold 4.6; 0.11 ships and the number measures 4.72:1. The hatch
   itself then reads 1.29:1 against the panel — the light build's stripes were
   1.24, so the texture is very slightly stronger than it was, on a ground with
   more room.

   --glass-hatch is a token rather than an inline rgba because a gradient stop
   needs an alpha and a custom property CAN carry one; the exception
   .scale-card takes with rgba(250,250,247,x) is not needed here.

   THE HATCH IS DECORATION AND CARRIES NO INFORMATION ALONE. It is a
   background-image on the cell, so nothing in the text depends on it — and
   nothing in the text states the elapsed reading either. The caption said
   "August, as an example" when this was written — the one claim the card made
   about its dates — and says "August" now, so the card states nothing at all
   about whether its dates are real. A reader who never decodes the stripes
   still loses nothing; that part never depended on the caption.
   --ink over a stripe measures 14.96:1 on the panel over the photograph (it
   was 14.98 on paper), so nothing sitting on it loses contrast. The two disc
   rings that share that cell measure 4.14 and 4.44 against a 3:1 floor; both
   are in the table above. */
/* .week-cell--past IS GONE, on Jesse's instruction: "remove the cross from
   Sat". It drew a 45deg hatch at --glass-hatch over the one elapsed cell, and
   with a single cell wearing it the diagonals did not read as "this day has
   passed" — they read as the Saturday being crossed out, which is the opposite
   of an invitation on a page asking people to show up.

   The class came off the markup too rather than being left inert. A styled-
   nothing class is the one someone re-applies by accident.

   --glass-hatch stays declared and is now unused; it is the reference point one
   row of the card's contrast table is written against, and deleting it would
   leave that row measuring a value the file no longer states. */

/* THE ANCHOR — Sun 30 Aug, the festival day, and the reference's filled grey
   lozenge in this page's own accent.

   IT IS NOT --accent ANY MORE, AND THAT IS A MEASUREMENT RATHER THAN A CHANGE
   OF MIND. The accent held this lozenge on the light panel at 3.10:1, just past
   the floor. Against dark glass it measures 1.74-1.77:1 across the four widths:
   #D46C2C sits at Y 0.264 and the panel's brightest pixel at Y 0.123, so the
   two are a hue apart and barely a value apart, and no alpha of this panel
   fixes it — 3:1 for --accent needs the panel under Y 0.055, an alpha near 0.66
   with no filter, which spends about a third of the transparency on one 22x18
   marker. contrast-headroom.md says a pair under the floor is replaced rather
   than shipped with a note, so it is replaced.

   --glass-anchor IS THE REFERENCE'S OWN ANSWER. Its lozenge is a filled
   NEUTRAL, not a saturated one, because dark glass wants a light lift there;
   --glass-anchor carries --cream's value and measures 5.20:1 against the panel
   with --ink at 16.12:1 on it. --primrose also clears (6.18:1) and is the token
   to take if the anchor should stay saturated — it was not taken because the
   reference is neutral and because primrose means "confirmed" elsewhere on this
   page. The one thing lost is that the card no longer carries any --accent at
   all, which makes the two Join buttons the only orange on the page.

   THE NUMBER IS --ink ON A LIGHT LOZENGE, which is the same pairing the light
   build had and the inverse of the cell around it — the anchor is the one place
   in the card where the polarity flips back, and that is what makes it read as
   a filled marker rather than as a brighter cell.

   IT IS .week-day ITSELF RATHER THAN A NESTED PILL. The day number is already a
   centred block; a fixed width plus auto margins turns that same box into the
   lozenge with no extra element in fourteen cells' worth of markup.

   22 x 18 with a 9px radius — the reference measures "roughly 28 x 20" on a
   55px column, which scales to 25 x 18 here; 22 is one step further in so the
   lozenge still has 4.35px of air a side in the 30.70px column at 320. The radius
   is half the height, so the ends are true semicircles. The 18px height is
   .week-day's own line-height, declared up there rather than here so the plain
   cells share the band and the row does not step. */
.week-cell--today .week-day {
  width: 22px;
  margin-inline: auto;
  border-radius: 9px;
  color: var(--ink);
  background: var(--glass-anchor);
}

/* .week-note IS GONE, AND SO IS THE 22-ABOVE / 16-BELOW ARGUMENT THAT SAT HERE.
   That asymmetry existed to bind a caption paragraph to the card above it; the
   section carries no paragraph now (Jesse writes the page copy), so there is
   nothing to bind and no second gap to make unequal. The card's own margin is
   argued at .week-card. */


/* --- The scale of the town ---------------------------------------------- */

/* The one filled panel on the page, so the figures read as a plate rather than
   as more page. */
/* FULL-BLEED, AND STILL A ROUNDED PLATE. On Jesse's instruction the green runs
   to both edges of the window with no paper beside it, keeping its corners.

   It gets there the way the survey band does — by being a DIRECT CHILD OF
   .page instead of of .page-inner (see the comment above the section in
   index.html). Not with a 100vw break-out: the reason that one is banned here
   is this file's own
   `scrollbar-gutter: stable` — on a desktop with a classic scrollbar 100vw is
   wider than the viewport and the whole page starts scrolling sideways.

   THE HORIZONTAL PADDING NOW DOES TWO JOBS AT ONCE, which is why it is a calc
   rather than a number. It is the plate's own inset PLUS whatever it takes to
   put the column back where .page-inner had it: half the paper that used to sit
   outside the column, plus the gutter that used to sit inside it. Below the
   column width the first term is 0 and the padding collapses to the old 24 + a
   gutter, so a phone is untouched — 287px of content either way. Above it the
   term opens and the type track stops widening, which is the entire point: the
   GROUND goes edge to edge, the column does not move — arithmetic instead of a
   wrapper.

   NO WRAPPER, DELIBERATELY. A .scale-inner would be the obvious way to hold the
   column and it is the one way that breaks this section: the reveal cascade
   hands its rungs to .scale's DIRECT children, so a single wrapper child would
   collapse five staggered timelines into one slab. That is the bug
   .closing-inner already documents.

   THE RADIUS IS KEPT AT --radius-card, which is now a corner curving against
   the window edge rather than against paper on both sides. Asked for. */
.scale {
  margin-top: 56px;
  /* The plate's OWN inset, held inside the column the padding above rebuilds.
     EVERYTHING on the plate keeps it — the heading, the drawings, the cards,
     the closing line, the source — so what runs to the window edges is the
     green and only the green.

     ONE rule still undoes it: the pinned heading's band, which reaches back out
     through this so it is wider than anything that passes under it. Change the
     number here and the band follows. */
  --plate-inset: 24px;
  padding: 36px calc(max(0px, (100% - var(--col-narrow)) / 2) + var(--gutter) + var(--plate-inset)) 40px;
  /* NOT A FLAT FILL — see --moss-lift for the whole reason, which is iOS
     Safari sampling a flat ground and painting its own chrome with it. The
     ramp that actually stops that has to be on .scale-heading, since the
     pinned band is what covers the top of the window; the plate carries the
     SAME ramp so the two never disagree behind it. */
  background: linear-gradient(
    100deg,
    var(--moss-deep) 0%,
    var(--moss-lift) 50%,
    var(--moss-deep) 100%
  );
  border-radius: var(--radius-card);
  color: var(--paper);
  /* --paper on --moss-deep has 7.25:1 to spend, not --paper/--ink's 18:1, so
     the default reveal floor lands at 2.83:1 here. 0.50 -> 3.13:1. */
}

/* Inherits the shared section-head ramp with .about-heading. */
.scale-heading {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(26px, 6.2vw, 34px);
  line-height: 1.1;
  letter-spacing: -0.026em;
  color: var(--paper);
  text-wrap: pretty;
}

/* THE TWO DRAWINGS ON THIS PLATE. The shopfront sits directly above the figure
   it illustrates; the market no longer does — the "200+ events a month" figure
   it used to head was cut and the drawing was kept, so it is an item of its own
   now. The SIZING relationship between them survives that unchanged, which is
   the only thing these rules are about.

   Sized as a PERCENTAGE OF THE PLATE rather than in vw, so neither can outgrow
   the panel it sits in: the percentage resolves against the panel's own content
   box, which already has the 24/48px padding and the 544/1020px column cap
   taken out of it. A vw width knows about none of that.

   THE TWO PERCENTAGES ARE PICKED SO BOTH DRAWINGS ARE THE SAME HEIGHT. The
   market is the wider subject (1.36 against the shopfront's 1.26), so matching
   them on width would have made it the shorter of the two and the pair would
   read as one drawing shrunk. Matching the height is what makes them read as a
   set, and that is the only thing tying the two numbers together.

   CENTRED ON THE PLATE, AND BOTH ON THE SAME AXIS. Both live inside a
   .scale-item, so both centre on the list; the list no longer caps itself at
   620px (see below), so the list and the panel share a centre and so do they.

   SPACE BELOW, NEVER ABOVE: the shopfront's card sits under it, and the air
   over each drawing is .scale-list's own margin or the list gap.

   THE PERCENTAGE RESOLVES AGAINST THE ITEM, NOT THE CARD. .scale-item carries
   no padding, so the containing block is the full plate column — the box these
   percentages were originally written against. */
/* THE 20px MOVED FROM UNDER THE DRAWING TO OVER IT, and that is the whole
   consequence of the figures going above their pictures on Jesse's
   instruction. The air between a figure and its drawing is the same 20px it
   always was; it is the drawing's TOP margin now because the drawing is the
   second thing in the item rather than the first. Left as margin-bottom it
   would have padded the foot of each item instead, on top of .scale-list's own
   gap, and pushed the two picture items apart from the card below them. */
.scale-frame {
  display: block;
  /* THE CARD IS SIZED BY ITS INK, not the other way round. The two drawings
     have to render at the same HEIGHT off different aspects (1.26 and 1.362),
     which is what the pair of widths below is for — and any padding added
     around them breaks that pairing the moment it is the same number inside
     two cards of different widths. Sizing the card to fit its ink keeps the
     old geometry exactly: the drawings are the size they were before the cards
     went under them, and the card is that plus its padding. */
  width: fit-content;
  margin: 20px auto 0;
  /* .scale-card's own padding one rung down. Three cards on this plate, one
     inset, one corner. */
  padding: clamp(20px, 5vw, 24px);
  background: var(--paper);
  border-radius: var(--radius-card-inner);
}

/* THE DRAWING IS THE CARD'S INK, NOT AN IMAGE INSIDE IT. Both files are
   stencils — ink darkness mapped to alpha — so the pseudo-element is painted
   green and the file cuts the shape out of it. Same pattern as .bp-mark and
   the signature: the colour stays a token here rather than being baked into an
   asset, so the plate and its drawings cannot drift apart, and recolouring
   them again is one line rather than a re-export.

   --moss-deep, the plate's own darker green, is 7.6:1 on --paper. This is a
   line drawing rather than type, but the pen is fine enough in places — the
   window mullions, the price boards — that a paler green would lose them. */
.scale-frame::before {
  content: "";
  display: block;
  background: var(--moss-deep);
  -webkit-mask-position: center;
  mask-position: center;
  -webkit-mask-size: contain;
  mask-size: contain;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
}

/* cqw, NOT %, AND THAT IS THE WHOLE COST OF THE CARDS. These are the same two
   numbers the drawings carried before — 73.25/406 and 79.1/440, picked so both
   render at one height and neither can outgrow the plate — but they are the
   INK's size now, and the ink's percentage would otherwise resolve against a
   card that is itself sized by the ink. A container query unit resolves
   against the list item instead, which is what the percentages always meant.

   They must still move together: the market is the wider subject at 1.362
   against the shopfront's 1.26, so the pair exists to render the two at the
   same HEIGHT. Change one, change the other, and MEASURE the two rendered
   heights before believing it. */
.scale-item {
  container-type: inline-size;
}

.scale-frame--shop::before {
  width: min(406px, 73.25cqw);
  aspect-ratio: 630 / 500;
  -webkit-mask-image: url("town-localblend.webp");
  mask-image: url("town-localblend.webp");
}

.scale-frame--market::before {
  width: min(440px, 79.1cqw);
  aspect-ratio: 681 / 500;
  -webkit-mask-image: url("town-farmersmarket.webp");
  mask-image: url("town-farmersmarket.webp");
}

/* A stacked column, not a row of tiles. Figures side by side are a
   dashboard and read as one glanceable unit; stacked with this much air
   between, each one is its own statement and gets read on its own — which is
   the whole difference between evidence and a metrics strip. The list is
   already at a reading measure, so it stays a single column at every width.

   NO max-width HERE, AND THE MEASURE IS UNAFFECTED. It used to cap at 620px,
   which capped nothing that was visible — .scale-body already holds the
   reading measure at 38ch and a figure line is short. What the cap DID do was
   give the market drawing a different centre from the shopfront. Dropping it
   lets both centre on the panel; the text still starts at the panel's left
   edge, in line with the heading. */
.scale-list {
  margin: 40px 0 0;
  padding: 0;
  list-style: none;
  display: grid;
  /* The air BETWEEN two cards is the same clamp as the air INSIDE one, which is
     what makes the stack read as one set rather than three objects that happen
     to be lined up. Down from clamp(44px, 9vw, 72px): that gap was holding
     three UNBOXED figures apart on bare plate, and a card holds itself apart.
     The total separation between one card's last line and the next card's first
     is still larger than it was — gap plus two paddings — the change is that
     most of it now sits inside the boxes, which is the difference between three
     figures and three cards. */
  gap: clamp(20px, 5vw, 24px);
}

/* EACH FIGURE IS ITS OWN CARD NOW, ON THE REFERENCE'S GLASS, IN PINE.
   (fixaplan.com, "Tools that work with your mind" — recorded 2026-08-29.) No
   THE DRAWING IS OUT OF THE GLASS AND THE GLASS HOLDS ONLY THE WORDS. On
   Jesse's instruction the two drawings sit on the bare plate with their card
   beneath them rather than inside it, so .scale-item goes back to being the
   grouping hook it always was and .scale-card — one div per item — carries this
   treatment. The <li> keeps its reveal rung either way: the ladder hands rungs
   to .scale-list's DIRECT children, and a div inside one of them changes
   nothing about which elements get a timeline.

   THE FILL IS A MULTIPLY, NOT A COLOUR, AND THAT IS THE WHOLE TRICK. The
   reference's cards are a translucent BLACK veil over their ground — measured
   off the recording, not guessed: sampled across a 3x brightness range of
   backdrop its card fill comes back at 0.746 x ground + 3, a flat ~0.80
   darkening with no vertical gradient. Reaching for the usual
   rgba(255,255,255,0.05) glass veil gets the polarity backwards; on a dark
   ground the card is DARKER than the panel it sits on. 0.20 is the reference's
   own alpha and it is kept.

   Left translucent rather than frozen to the hex it resolves to here (#314A25)
   so it tracks --moss-deep: change the pine and the cards follow, with no
   second number to re-measure. Same argument every rgba(250,250,247,x) on this
   panel already makes.

   WHAT THE VEIL DOES TO THE CONTRAST BUDGET: it does not spend it, it adds to
   it. Every colour here was measured against bare --moss-deep, and darkening
   the ground under light type raises all of them. On the card ground
   (Y 0.0568 against the plate's 0.0885):

       --paper                      9.40:1   (7.25:1 on bare plate)
       .scale-body's 0.82 --paper   6.93:1   (5.53:1 on bare plate)

   16px body owes 4.5:1 and clears it with 2.4 to spare; the figures and units
   owe 3:1 and clear with 6.4. No floor was moved to fit this veil and none
   needed to be.

   NO BACKDROP-FILTER — not because the reference lacks one, but because this
   ground is FLAT. The reference's section carries an enormous warm radial glow,
   so its cards genuinely filter something and no two corners of one card share
   a hex. Ours filters one solid green: a blur here is a literal no-op that buys
   a compositing layer per card and nothing else.

   NO SHADOW, for the house reason — hairline over shadow, and the plate itself
   carries none.

   RADIUS IS --radius-card-inner, NOT the reference's measured 16px. Its card is
   top-level; this one is nested in a 20px plate, and the radius ramp already
   names that case: two equal radii separated by padding draw non-parallel
   curves and the inner one reads as a sticker laid on the outer.

   THE HAIRLINE IS DOUBLE THE REFERENCE'S AND STILL DECORATIVE. White at 0.07
   reads there because a live glow moves underneath it; against one flat green
   it measures 1.21:1 on the card fill, which is nothing. 0.14 gives 1.49:1 —
   deliberately one step under the 0.22 rule .scale-source used to draw, which
   had to be SEEN as a rule where this edge only has to finish a shape. That
   line is gone and this one did not move: 0.14 was derived against the card
   fill, not against it. It is
   not a UI boundary and does not owe 3:1: 1.4.11 covers controls, and there is
   no control on this plate. What separates card from plate is the FILL, not the
   line — 15.4 L* of step, more than three times the 4.47 L* this file already
   treats as plainly visible. */
/* THE CARD KEEPS THE PLATE'S INSET AND NO LONGER REACHES THROUGH IT. It carried
   a negative inline margin that pulled it out to the plate's own edges, which
   was invisible while the plate was an inset card on paper and is the whole
   shape once the plate bleeds: a glass card running into both sides of the
   window, green only above and below it. Jesse's call is the opposite — the
   GREEN hits the edges, the cards do not.

   So the card is the type column plus --plate-inset either side, same box the
   heading, the drawings, the closing line and the source sit in, and the rim of
   plate down both sides of it is the point rather than a leftover. */
.scale-card {
  padding: clamp(20px, 5vw, 24px);
  background: rgba(0, 0, 0, 0.2);
  border: 1px solid rgba(250, 250, 247, 0.14);
  border-radius: var(--radius-card-inner);
}

/* The figure carries the section. Its ceiling is 60px against the hero's 66px,
   so the largest type the page can ever set stays the sentence the visitor
   lands on. That only holds at the top of the range: on a narrow phone the
   headline sits at its 34px floor because it is a sentence that has to fit,
   while a three-character figure has no such constraint and is bigger. They
   are never on screen together, and the comparison that matters is which one
   the page is willing to set LARGEST, not which wins on a 320px phone.
   Tabular so 90+ and 7,000 share a stem width.

   A COLUMN: the number, then its noun on the line beneath it, on every rung
   that has a noun to stack. It was a wrapping row first — the noun sat beside
   the number and dropped under it only when it did not fit — which meant the
   rungs did not agree with each other and the same rung changed shape at
   different widths. Stacking always is what makes them one pattern instead of
   several outcomes of a fitting rule.

   THE CLOSING RUNG IS THE EXCEPTION AND NEEDS NO RULE. It carries no
   .scale-unit at all: it is not a number and the thing it counts, it is the
   section's conclusion. As plain text it is a single anonymous flex item, and a
   column of one renders as one row. It reads "Spotlight" and read "One place"
   before that — a two-word phrase whose halves were equal, where the same rule
   also bought a real word space between the words rather than the 14px gap a
   figure keeps from its label.

   Flex rather than a block with a `display: block` span, only so the space
   between the two lines is a `gap` — `line-height: 1` on the figure means a
   margin here would be fighting a collapsed box rather than setting a
   distance. */
.scale-figure {
  margin: 0;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 6px;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(44px, 10vw, 60px);
  line-height: 1;
  letter-spacing: -0.035em;
  color: var(--paper);
  font-variant-numeric: tabular-nums;
}

/* The noun the figure counts, moved OUT of the caption and onto the figure
   line so the two are read as one phrase — "90+ businesses" rather than a
   number, a beat, and then a sentence starting with the word it refers to.

   Lowercase on purpose. Sentence-capping it ("90+ Businesses") reads as a
   dashboard label; lowercase reads as the phrase a person would say, which is
   the point of moving it. It also keeps the closing rung — "Spotlight" — from
   looking like a title.

   Roughly 57% of the figure, held at that ratio across the whole clamp range
   (34/60 at the top, 25/44 on a phone). The first pass sat at 40% and read as a
   caption that had drifted onto the figure line rather than as half of a
   phrase. Still clearly the smaller of the two, so the number is plainly the
   thing being displayed. Full --paper, inherited: at 7.25:1 this is headline
   text now, not the 0.82 the captions carry.

   ONE SIZE ON ALL FOUR RUNGS. The nouns are a label system, and a label system
   that changes size with whatever it happens to be labelling is not one. The
   regional figure used to step its number down a rung and no longer does — it
   sits on the market item now rather than under 90+, so there is no pair left
   to rank.

   The repetition of "businesses" across the first two rungs is deliberate and
   is the same argument .scale-body strong used to make from inside the
   captions — 90+ and 5,200+ counting the same thing at two scales. It reads
   more plainly from up here.

   "regional" QUALIFIES THE SECOND ONE RATHER THAN LEAVING IT TO THE CAPTION.
   Stacked, the two rungs are read as a pair before either caption is, and
   "90+ businesses" over "5,200+ businesses" invites the obvious wrong reading
   that the town has 5,200 of them. The word that resolves it belongs where the
   misreading happens. It also keeps the repetition intact — "businesses" is
   still the shared noun, now with a scope in front of one of them. The caption
   below says "the wider St. Cloud area" rather than repeating the qualifier a
   second time in twelve words. "regional" over "metro" is Jesse's call and is
   the plainer word for a neighbour. The exact statistical area it comes from
   (the St. Cloud metro) used to be named in .scale-source; that line is gone,
   so "the wider St. Cloud area" in the caption is now all the scope the page
   states. */
.scale-unit {
  font-size: clamp(25px, 5.8vw, 34px);
  line-height: 1.15;
  letter-spacing: -0.02em;
}

/* The regional figure, NOW THE SAME SIZE AS 90+, on Jesse's instruction. It
   used to be two thirds of the main figure — the supporting number, set as one.
   It no longer sits under 90+ to be compared with it: it caps the market item,
   its own rung of the plate, so a step down would read as an accident rather
   than as a rank. Nothing of its own is left: .scale-frame carries the 20px
   that holds a figure off its drawing, which is the same air 90+ gets over the
   shopfront. The class stays as the hook the markup names. */
.scale-figure--sub {
  margin-top: 0;
}

/* Struck off --paper rather than a grey, so the quiet tone stays in the same
   warm family as the sheet. 0.82 is measured, not picked: 5.53:1 on the panel,
   where 16px owes 4.5:1. */
.scale-body {
  margin: 12px 0 0;
  max-width: 38ch;
  font-size: 16px;
  line-height: 1.62;
  color: rgba(250, 250, 247, 0.82);
  text-wrap: pretty;
}

/* The plate's conclusion, so it sits a rung above the captions it follows:
   full --paper at 7.25:1 and one step up in size, the same relationship
   .about-lead has to the copy under it. Wider measure than a caption because
   it is a paragraph rather than a label. */
/* THE CLOSING LINE IS INSIDE THE LAST CARD NOW, on Jesse's instruction, and it
   replaced the one-line body that used to sit under that card's figure. It was
   a direct child of .scale, set apart under the whole list; it is now the
   card's own paragraph, which is what finally gives that card something to
   hold. It had been the odd one — a short figure and a short sentence under a
   card several times its height, reading as an offcut rather than as the
   conclusion the figure is.

   32 -> 14 on the top margin. That number was air between the list and a
   paragraph outside it; inside a card it only has to sit under a figure, which
   is the job .scale-body's 12 does, plus a little for the larger type.

   The class name still fits: it is the section's closing statement, wherever it
   sits. With .scale-source since removed, .scale is down to three direct
   children and the reveal ladder's fourth rung is unused. */
.scale-close {
  margin: 14px 0 0;
  max-width: 52ch;
  font-size: clamp(17px, 1.9vw, 19px);
  line-height: 1.55;
  color: var(--paper);
  text-wrap: balance;   /* see .about-heading — same reasoning */
}

/* .scale-source IS GONE — the sourcing line under the plate was removed on
   Jesse's instruction; see the note where it stood in index.html. Deleted
   rather than left unused, because an unstyled class is a rule someone
   re-applies by accident. It was a 13px hairline-topped block at 0.82 --paper,
   46ch, 28 above and 20 inside the rule. */

/* --- Form panel --------------------------------------------------------- */

/* Holds the ask, the fields and the confirmation as one object.
   No shadow: a form does not float, and the house rule is hairline over shadow
   unless something genuinely sits above the page.
   The edge is --hairline (decorative) while the FIELDS inside are
   --hairline-strong (3:1, a real control boundary). That ladder is deliberate —
   raising this border to match would put the container in competition with the
   controls it contains. */
/* NO CARD, NO RADIUS, NO BORDER — a full-bleed change of ground instead. The
   old .panel was a white 20px-radius box in the column, which made the one
   thing the page exists for the same shape as everything above it. A band that
   runs edge to edge and changes colour reads as arriving somewhere.

   No margin-top. The band's own padding does the separating, and a margin above
   a full-bleed element would show a stripe of --paper over it. */
.join {
  padding: 0;
}

/* 448, not the 544 the rest of .page uses. This column holds one control and
   two fields rather than running prose, and at 544 the button stretched wide
   enough to read as a bar across the band instead of as a thing to press.
   Narrower also pulls the fine print under the button into one line more often.
   .ask-body caps at 44ch (~400px) on its own, so the measure was never the
   constraint here — the control was. */
/* 496, AND IT NO LONGER MATCHES .closing — which is fine, because the two are
   never on screen together. This box holds the form, and the form is in the
   sheet unless script is blocked; .closing is the scene that stands where the
   form used to. It was 448, justified by a submit button that has since moved
   into the sheet, and 448 put the button 24px LEFT of the sentence asking for
   it while the two were still siblings on one sheet of paper. 496 is the
   no-JS form's own measure now: two fields and a submit read better narrow. */
.join-inner {
  max-width: none;
  margin: 0;
}

/* With JavaScript blocked the form stays here and gets a complete fallback
   layout. During normal use the pre-paint script moves it into the modal sheet,
   so this direct-child spacing disappears without leaving an empty band. */
.join-inner > .form {
  max-width: var(--col-ask);
  margin: 52px auto;
  padding-inline: var(--gutter);
}


/* The fields' boundary has to be redrawn on this ground — see --hairline-join.
   Scoped to the band so the rest of the page keeps --hairline-strong, which is
   still correct on --paper and on --surface. */
.join .input {
  border-color: var(--hairline-join);
}


/* --- The sheet -----------------------------------------------------------

   THE WAITLIST IS A MODAL SHEET, ported 1:1 from a measured reference
   (reference/fixa-waitlist/ — MEASUREMENTS.md is the source of truth for every
   number below, and docs/waitlist-sheet-spec.md records why each one is what
   it is). The image-backed closing card is now the trigger's visual home.

   WHAT THIS REPLACED, because the argument was good and should not be
   re-derived by anyone reading a diff: the band used to rest as ONE button
   which opened two fields ABOVE itself and then submitted them. It never moved
   sideways, never changed colour or label, and was never swapped for another
   node — the press promoted it from opener to submit and the same pixels did
   both jobs, so the visitor watched a gap open rather than one thing become
   another. It was a 0fr -> 1fr grid row (no layout property animated, nothing
   measured in JS) with a 1.5px coral seam bursting out of the button's own top
   edge. All of it is gone: the reference won, on Jesse's instruction. The one
   thing that survives is the DISCIPLINE — the markup still ships the form
   expanded in its fallback host, and script is what promotes it into a sheet, so a
   blocked script leaves a finished page rather than a trigger that opens
   nothing.

   Reduce Motion: this file's blanket `* { animation: none !important }` reset
   does NOT reach the sheet's choreography, which is WAAPI and therefore invisible
   to CSS. It is gated in app.js instead. The reset DOES kill the crossfades
   below, so the ones that carry state are restored explicitly in the
   reduce-motion block at the bottom of this file. */

/* <dialog> is doing real work here, not decoration: showModal() gives a focus
   trap, Escape, and `inert` on the whole page behind, none of which is worth
   hand-writing. Every UA default has to be undone though — it ships centred,
   with a border, padding, a background and max-width/max-height caps. */
/* ONE SHAPE AT EVERY WIDTH, which is what the note above already claims it is.
   This used to be `inset: auto 16px ... 16px` — full width minus 32 — with a
   640px query capping it at 430 and centring it. Between 601 and 639 that made
   the modal a 607px slab with 567px-wide text inputs, 41% wider than the
   reference every number in it was measured against, at exactly the width where
   the page behind it had just re-tightened its own gutter. The cap belongs in
   the base rule: min() gives the phone the full-width-minus-32 it always had and
   the tablet the 430 it was supposed to have, with no width in between where the
   sheet is a different object. */
.sheet {
  position: fixed;
  inset: auto auto calc(16px + env(safe-area-inset-bottom)) 50%;
  width: min(430px, calc(100vw - 32px));
  /* Only the box is centred here. Every animated transform in app.js targets
     #sheet-stack inside it, never this element, so nothing overwrites it. */
  transform: translateX(-50%);
  max-width: none;
  max-height: none;
  margin: 0;
  padding: 0;
  color: inherit;
  background: transparent;
  border: 0;
  /* The close button sits ABOVE the card, outside this box. <dialog> defaults
     to overflow:auto, which would clip it and hang a scrollbar off the sheet. */
  overflow: visible;
}

/* THE CONFETTI'S ONE LAYER, appended and removed by app.js. It has to be a
   child of the <dialog>: the sheet is in the top layer, and a canvas on <body>
   paints under it at any z-index. Absolute rather than fixed for the same
   reason it can be — .sheet is position:fixed, so .sheet IS the containing
   block here, and a fixed child would resolve against the sheet's own transform
   anyway.

   Anchored to the sheet's bottom edge rather than the viewport's, which puts
   its floor the same 16px + safe-area up that the sheet sits at. A piece is
   culled 16px before the true bottom of the screen, behind a dimmed and blurred
   backdrop, mid-fade. Restating that inset here to gain those 16px would be the
   sheet's offset written in a second place, waiting to drift.

   100lvh, not svh: the layer is the area the burst travels THROUGH, and the
   larger viewport is the one that covers a phone whose chrome is still
   retracted. Anything drawn above the visible top is clipped and costs a fill
   nobody sees, which is the cheap direction to be wrong in. */
.confetti-layer {
  position: absolute;
  bottom: 0;
  left: 50%;
  width: 100vw;
  height: 100vh;
  height: 100lvh;
  transform: translateX(-50%);
  /* ABOVE THE CARD, and that number is .sheet-card's, not a guess: the card
     carries z-index:1 so it paints over the close button rising from behind it,
     and z-index:auto here put the confetti under the card — pieces vanished at
     its top edge instead of falling across the plate, which is the half of the
     effect worth having. 2 is one step past the only competitor in this
     dialog. */
  z-index: 2;
  /* The close button is under this. It stays clickable. */
  pointer-events: none;
}

/* 20% dim + a 14px blur. OPACITY IS THE ONLY THING THAT ANIMATES — animating a
   blur radius re-rasterises the whole page behind it every frame. */
.sheet::backdrop {
  background: rgba(0, 0, 0, 0.20);
  -webkit-backdrop-filter: blur(14px);
  backdrop-filter: blur(14px);
  opacity: 0;
  transition: opacity 220ms var(--ease-out);
}

/* The delay lives on the target state, not on the base rule, so it applies to
   the way IN and not the way out. The backdrop starts 60ms after the sheet: the
   sheet is already moving before the page behind it starts to recede. */
.sheet[open]::backdrop {
  opacity: 1;
  /* 220/60 -> 260/50, measured off the second recording: sampling the top of the
     screen (which the sheet never covers) the page's mean luminance runs 80 ->
     62 between 45ms and 305ms after the press. */
  transition: opacity 260ms var(--ease-out) 50ms;
}

@starting-style {
  .sheet[open]::backdrop { opacity: 0; }
}

/* Leaves later and slower than the sheet. The page comes back into focus after
   the sheet has already gone, not with it.

   180 -> 460, AND THAT GAP IS THE EFFECT. The same luminance sampling that gave
   the entrance its 45ms puts the un-dim at 490-810ms after the press, while the
   card has been off the bottom of the screen since ~290. So the reference holds
   the page behind a blur for a beat with nothing in front of it, and only then
   lets it come back — which is the frame this recording is full of and the
   thing that made the first port's close feel abrupt. The duration was already
   right at 320. */
.sheet.is-closing::backdrop {
  opacity: 0;
  transition: opacity 320ms var(--ease-out) 460ms;
}

/* The card and the close button scale as ONE object from the pair's own bottom
   edge, which is the edge that never moves. This is also the close button's
   percentage anchor: `bottom: calc(100% + gap)` resolves against this box, so
   when the card's height animates during the morph the button rides the top
   edge for free. */
.sheet-stack {
  position: relative;
  transform-origin: 50% 100%;
}

/* NOTHING IN HERE COULD EVER SCROLL. .sheet has max-height:none and
   overflow:visible (which overrides the dialog UA default), the card clipped
   with overflow:hidden, and the page behind is locked to position:fixed — so a
   bottom-anchored sheet simply grew upward off the top of the screen. The form
   state needs about 595px from the viewport bottom; a 390x844 phone at 200%
   zoom has 372, and a landscape phone has 390. What went off the top was the
   close button, the title and the whole required First name field, with no way
   to reach any of them.

   The cap leaves the close button its own travel above the card, so the pair
   still scales as one object out of the bottom edge.

   overflow:hidden is still load-bearing for the morph — the height animates and
   the content has to clip while it does — so morphToThanks() sets it inline for
   exactly as long as it holds an inline height, and clears both together. Not
   gated on .is-morphing: that class comes off when the form finishes fading at
   180ms while the grow runs to 320, which would return the scrollbar for the
   last 140ms of the animation. At rest the card scrolls; mid-morph it clips.
   overscroll-behavior stops a flick at the end of the card from chaining to the
   locked page underneath. */
.sheet-card {
  position: relative;
  z-index: 1;
  max-height: calc(100svh - var(--close-travel) - 2 * var(--close-gap)
                   - env(safe-area-inset-bottom));
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 20px;
  background: var(--surface);
  border-radius: var(--radius-card);
  /* Split and warmed for the same reason as .hero-cta. This was the blackest,
     heaviest cast on the page, and it falls on blurred WARM paper. */
  box-shadow:
    0 2px 4px rgba(40, 30, 22, 0.28),
    0 14px 40px rgba(40, 30, 22, 0.32);
}

/* z-index BELOW the card, which is the whole trick: the button rises out from
   behind the sheet's top edge instead of appearing over it, and the card's own
   opaque white is what hides it on the way.

   left as a calc rather than left:50% + translateX(-50%), because `transform`
   belongs to the rise animation and a centring transform would be clobbered by
   it the moment WAAPI takes the property. */
.sheet-close {
  position: absolute;
  bottom: calc(100% + var(--close-gap));
  left: calc(50% - var(--close-size) / 2);
  z-index: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--close-size);
  height: var(--close-size);
  padding: 0;
  background: var(--surface);
  border: 0;
  /* 14 in the reference, 12 here. It is a control and it takes the control
     radius; the reference is the source of truth for this sheet's TIMING, which
     is what was actually measured off it, not for a shape system it never had
     to share a page with. */
  border-radius: var(--radius-button);
  cursor: pointer;
}

/* 46x46 is the reference's painted box; this project's own commitment is 48px
   touch targets. The pseudo-element buys the missing 2px as hit area only, so
   the square that gets painted is still exactly the reference's. */
.sheet-close::before {
  content: "";
  position: absolute;
  inset: -1px;
}

/* The glyph arrives well after the button does — the shape settles first and
   then takes on its meaning. Delay on the target state so it applies inbound
   only; on the way out the glyph goes instantly (below). */
.sheet-close-glyph {
  width: 16px;
  height: 16px;
  color: var(--ink);
  opacity: 0;
  transition: opacity 250ms ease;
}

.sheet.is-open .sheet-close-glyph {
  opacity: 1;
  transition: opacity 250ms ease 560ms;
}

/* Hidden the instant a close begins — pressed x, Escape or backdrop. The button
   retracts as a blank rounded square, exactly as it arrived. */
.sheet.is-closing .sheet-close-glyph,
.sheet.is-pressing-close .sheet-close-glyph {
  opacity: 0;
  transition: none;
}

/* --- Sheet contents ------------------------------------------------------
   The two sentences here are the band's own, repeated rather than moved. */

.sheet-title {
  margin: 0;
  font-family: var(--font-display);
  font-size: 28px;
  /* 600, not the 700 this asked for: @font-face ships 500 and 600 only, so 700
     had no face to match and the browser either substituted 600 anyway or
     synthesised a smeared fake bold — on the modal that carries every
     conversion. See font-synthesis-weight on :root, which now makes a missing
     weight fail visibly instead of silently. */
  font-weight: 600;
  /* THE TRACKING EVERY OTHER DISPLAY LINE ON THIS PAGE CARRIES, and this one
     did not. -0.026em is the shared section-head value, and 28px sits inside
     the range that ramp covers, so this is the same treatment at a fixed size
     rather than a new one. Set at 0 it was the only large type here drawn at
     the face's default fit. */
  letter-spacing: -0.026em;
  line-height: 1.2;
  /* Without it, "Be there when it opens." strands `opens.` alone on line two at
     375px — a one-word line directly above the fields, on the modal that
     carries every conversion. balance, not pretty: pretty only refuses the
     one-word last line at the END of a paragraph and does nothing to even a
     two-line heading. */
  text-wrap: balance;
  color: var(--ink);
}

.sheet-subtext {
  margin: 8px 0 0;
  max-width: none;
  font-size: 15px;
  line-height: 20px;
  /* Was #555. This sentence is .ask-body repeated — literally the band's own
     copy, moved into the sheet — so it takes the band's colour. 5.41:1 on
     --surface, down from 7.46 and still well clear of the floor. */
  color: var(--ink-secondary);
}

.sheet-card .form {
  margin-top: 44px;
}

/* WAS #767676, ITSELF A REPAIR OF THE REFERENCE'S #777 (4.48:1 on white, under
   the floor). --ink-secondary is 5.41:1 here, so the token is both on-system and
   further clear of the line than the hand-picked value it replaces. The original
   note is kept below because the reasoning is still why the reference's own
   grey cannot be used:

   #777 is the reference's label grey and it computes to 4.48:1 on white, under
   the 4.5:1 a 13px label owes — and this page has already rejected 4.48:1 once
   by name, when a warmer --cream dropped --ink-secondary to exactly that.
   #767676 is 4.54:1 and is not tellable apart from it. */
.sheet-card .label {
  margin-bottom: 10px;
  padding-left: 16px;
  font-size: 13px;
  font-weight: 400;
  color: var(--ink-secondary);
}

/* No border at all: on the white sheet the FILL is what shows where the field
   is, which is the opposite of the join band's problem (see --hairline-join,
   which does not come in here). */
.sheet-card .input {
  height: 50px;
  padding: 0 13px;
  color: var(--ink);
  /* --fill is the page's one light fill and it is within a level of the
     reference's #F0F0F0 — 16.70:1 for --ink against 16.57. */
  background: var(--fill);
  border: 0;
  border-radius: var(--radius-button);
}

.sheet-card .input::placeholder {
  color: var(--ink-secondary);
}

/* 2px rather than the page's 1px: with no border to thicken, the inset ring is
   carrying the whole signal, and 1px of ink on --fill is too quiet to be the
   non-colour half of "never carried by colour alone". Still an inset shadow, so
   the field does not resize and shove the layout when it goes invalid. */
.sheet-card .input[aria-invalid="true"] {
  box-shadow: inset 0 0 0 2px var(--ink);
}

.sheet-card .field + .field {
  margin-top: 18px;
}
/* --- The headcount -------------------------------------------------------
   The reference's own object: a bordered box carrying one number and a caps
   label, seated directly on top of the join button. It is the last thing a
   visitor reads before pressing, which is the only position that makes social
   proof do any work.

   --hairline, not --hairline-strong. 1.4.11's 3:1 is owed by a control's edge,
   and this box is not a control: nothing in it is focusable, clickable or
   readable through the border. The number and the label carry their own
   contrast (--ink at 16.12:1, --ink-secondary at 5.41:1 on --surface). */
.tally {
  margin-top: 44px;
  padding: 14px 16px 13px;
  text-align: center;
  border: 1px solid var(--hairline);
  border-radius: var(--radius-button);
}

/* tabular-nums is load-bearing, not a nicety: the digits are crossfaded in
   place, so a proportional face would shift every column the moment a 1 became
   a 2 and the whole number would slide under its own animation. */
.tally-count {
  margin: 0;
  font-family: var(--font-display);
  font-size: 30px;
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.02em;
  line-height: 1.1;
  color: var(--ink);
}

.tally-digits {
  display: inline-flex;
  align-items: baseline;
}

/* The positioning context for the outgoing character. Each digit is its own
   stacking box so the two halves of a crossfade overlap exactly, rather than
   the old number fading out across the whole row while the new one fades in. */
.tally-digit {
  position: relative;
  display: inline-block;
}

/* The leaving character, cloned into the digit it is leaving. inset:0 rather
   than a width, so it inherits the box the incoming digit already measured —
   a comma and a 9 are different widths and this must not care. */
.tally-digit-out {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

.tally-label {
  margin: 5px 0 0;
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--ink-secondary);
}

/* The button's own 60px is the air under a lone control. With the headcount
   above it the two are one object, and 60px between them reads as the count
   belonging to the fields instead. Specificity 0,3,0 so it beats the 0,2,0
   below without touching it. */
.sheet-card .tally + .button {
  margin-top: 16px;
}

.sheet-card .button {
  position: relative;
  height: 50px;
  margin-top: 60px;
  font-weight: 500;
  /* --paper, not #FFF, which is this file's own stated rule for type on a dark
     ground: "it matches the sheet above". 18.06:1 against the old 18.88. */
  color: var(--paper);
  background: var(--ink);
  /* The reference's own shape, and the reason the whole pill token exists —
     see .button--pill. This button does not carry that class (it is a
     .button--block submit spanning its fields), so it takes the radius
     directly. */
  border-radius: var(--radius-pill);
}

/* THE PENDING BUTTON MUST NOT DIM, and .button:disabled would dim it. The
   handler sets `disabled` while the request is in flight — correct, it is what
   stops a second submit — but the page's disabled styling drops the fill to
   --fill and the label to --ink-secondary, which is exactly the "opacity drop
   or colour change on the pending button" this rebuild is not allowed to ship.
   A button that greys out the moment it is pressed reads as refusing, at the
   one moment the visitor needs to see it working.

   Same specificity class (both 0,2,0) and .button:disabled sits later in the
   file, so it wins on order — this rule has to out-specify it, not just repeat
   it. Scoped to the sheet: the disabled styling is still right everywhere else,
   including the no-JS form in its fallback host. */
.sheet-card .button:disabled {
  color: var(--paper);
  background: var(--ink);
  cursor: default;
}

.sheet-card .form-note {
  margin: 15px 0 0;
  font-size: 13px;
  /* Was #444. Same argument as .sheet-subtext: this is the band's .form-note
     repeated, so it takes the same token. 5.41:1 on --surface. */
  color: var(--ink-secondary);
  text-align: center;
}

.sheet-card .status {
  text-align: center;
  opacity: 0;
  transform: translateY(4px);
  transition: opacity 200ms var(--ease-out), transform 200ms var(--ease-out);
}

.sheet-card .status.is-in {
  opacity: 1;
  transform: none;
}

/* The refusal. Transform only, once, and gone — it never moves the layout and
   it never touches colour, because red is banned brand-wide and colour alone
   fails 1.4.1 anyway. The message under the button is the half that carries the
   meaning; this is the half that carries the timing. */
@keyframes sheet-shake {
  0%   { transform: translateX(0); }
  25%  { transform: translateX(-4px); }
  50%  { transform: translateX(4px); }
  75%  { transform: translateX(-3px); }
  100% { transform: translateX(0); }
}

.sheet-card .button.is-shaking {
  /* linear, not --ease-in-out. A timing function applies across the WHOLE
     keyframe set, not per segment, so an in-out curve maps t=0.25 to progress
     0.08 and squashes the -4px and +4px into the middle third while both ends
     crawl — a slow lean out, a fast twitch, a slow lean back. The note above
     defends the keyframe over a transition, correctly, and never reached the
     curve. */
  animation: sheet-shake 300ms linear;
}

/* --- The submit button's three faces -------------------------------------

   Never a textContent swap. The pending button keeps its width, its --ink and
   its full opacity — a button that dims while it works reads as disabled at the
   exact moment the visitor most needs to know it heard them. Three stacked
   spans, and only opacity moves.

   The blur on the idle label is the crossfade mask: without it the eye reads a
   word and a spinner as two objects trading places rather than one control
   changing state. */
.btn-face {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0;
  pointer-events: none;
}

.btn-face.is-on {
  opacity: 1;
}

#btn-idle {
  filter: blur(2px);
  transition: opacity 120ms var(--ease-out), filter 120ms var(--ease-out);
}

#btn-idle.is-on {
  filter: blur(0);
}

/* Arriving is 120ms, leaving is 100ms — the durations are on the states, which
   is the only way one transition can be asymmetric. */
#btn-pending {
  transition: opacity 100ms var(--ease-out);
}

#btn-pending.is-on {
  transition: opacity 120ms var(--ease-out);
}

#btn-done {
  transition: opacity 150ms var(--ease-out);
}

.spinner {
  width: 16px;
  height: 16px;
  border: 1.5px solid rgba(250, 250, 247, 0.3);
  border-top-color: var(--paper);
  border-radius: 50%;
  animation: sheet-spin 800ms linear infinite;
}

@keyframes sheet-spin {
  to { transform: rotate(360deg); }
}

/* The reduce-motion pending state is a WORD, not a still ring. A spinner whose
   animation has been reset is not a calmer spinner, it is a broken one — and
   "Joining..." is a string this page already owned. */
.btn-pending-text {
  display: none;
}

/* --- The morph -----------------------------------------------------------
   The form group goes out of flow for the 180ms it spends fading, so the card
   can be measured at its thank-you height while the old content is still on
   screen. Both heights are measured; the height animation never runs to auto. */
.sheet-card.is-morphing .sheet-form-group {
  position: absolute;
  top: 20px;
  right: 20px;
  left: 20px;
}

/* transform-origin is the one thing the old plate rule left behind that is
   still load-bearing: the arrival scales from 0.94, and from the BOTTOM edge,
   which is the edge the sheet and the close button also grow out of. Everything
   else that was here — the plate's padding and its nested radius — belongs to
   .success-screen now. */
.sheet-card .success-banner {
  transform-origin: 50% 100%;
}

/* --- The band's trigger --------------------------------------------------
   Revealed by the pre-paint script, in the same breath that the form is moved
   into the sheet. Hidden in the markup so the no-JS page shows the form and no
   trigger, rather than both. */
.join-trigger .form-note {
  text-align: left;
}

/* --- Desktop -------------------------------------------------------------
   Same sheet, same motion, centred and capped. Still bottom-anchored: it is a
   sheet on every width, not a sheet that becomes a centred modal.

   translateX on the DIALOG, never on .sheet-stack — the stack's transform
   belongs to the scale animation. */
/* All that is left here is the resting gap. The width cap and the centring
   moved into the base rule — see the note on .sheet — so this block no longer
   changes the sheet's shape, only how far off the bottom edge it sits once
   there is room for it to breathe. */
@media (min-width: 640px) {
  .sheet {
    bottom: calc(24px + env(safe-area-inset-bottom));
  }
}

/* --- Form -------------------------------------------------------------- */

.form {
  margin-top: 28px;
  scroll-margin-top: 32px;
}

.field + .field {
  margin-top: 16px;
}

.label {
  display: block;
  margin-bottom: 6px;
  font-size: 14px;
  font-weight: 500;
  color: var(--ink-secondary);
}

.input {
  display: block;
  width: 100%;
  height: 48px;
  padding: 0 14px;
  /* 16px is not cosmetic: mobile Safari force-zooms anything smaller on focus
     and does not zoom back out. */
  font-size: 16px;
  font-family: inherit;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--hairline-strong);
  border-radius: var(--radius-button);
}

/* Error is carried by border WEIGHT plus the message below the field — never by
   hue, both because the brand bans red and because colour alone fails WCAG 1.4.1.
   Border stays 1px in the box model (inset shadow, not a width change) so the
   field does not resize and shove the layout when it goes invalid. */
.input[aria-invalid="true"] {
  border-color: var(--ink);
  box-shadow: inset 0 0 0 1px var(--ink);
}

.error {
  margin: 6px 0 0;
  font-size: 14px;
  line-height: 1.4;
  color: var(--ink);
  font-weight: 500;
}

.error:empty {
  display: none;
}

/* THE PRIMITIVE. Everything a control on this page has in common, and nothing
   about where it sits: no display, no width, no padding — those are the two
   shape modifiers below, because "form submit" and "standalone pill" are the
   only two shapes this page actually uses and they should not be re-derived per
   element. Fill is the third axis and is the modifiers after that.

   text-decoration: none is here rather than on a variant because two of the
   five are <a> elements — the hero's among them — and an anchor that
   looks like a button and draws an underline is a control that looks broken. */
.button {
  height: var(--btn-h);
  margin-top: 24px;
  font-family: inherit;
  font-size: 16px;
  font-weight: 600;
  letter-spacing: -0.01em;
  color: var(--on-accent);
  background: var(--accent);
  border: 0;
  border-radius: var(--radius-button);
  text-decoration: none;
  cursor: pointer;
}

/* SHAPE, AXIS ONE. A form's submit spans its fields; a standalone control is
   sized to its own label, so it reads as the thing beside it rather than as a
   bar across whatever it is sitting on. */
.button--block {
  display: block;
  width: 100%;
}

.button--pill {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* The share glyph sits beside its label. */
  gap: 8px;
  width: auto;
  padding: 0 var(--btn-pad-x);
  /* THE CLASS FINALLY MEANS WHAT IT IS CALLED. Every "pill" on this page was
     drawing the 12px --radius-button, which is the same shape the inputs and
     the cards take — so the control that carries the whole page read as one
     more rounded rectangle among many. The reference Jesse marked up is a full
     pill, and a fully-round end is the one button shape nothing else here can
     be confused with. */
  border-radius: var(--radius-pill);
}

/* Named properties, never `all`: a blanket transition would fight any
   entrance animation frame for frame. Hover is gated behind a fine pointer
   because a touch device fires :hover on tap and then keeps it, leaving the
   button looking pressed after the finger is gone. */
.button {
  transition: transform var(--press) var(--ease-out),
              filter var(--hover) ease,
              background-color var(--hover) ease;
}

@media (hover: hover) and (pointer: fine) {
  .button:hover:not(:disabled) { filter: brightness(0.92); }
}

.button:active:not(:disabled) {
  transform: scale(0.97);
}

.button:disabled {
  color: var(--ink-secondary);
  background: var(--fill);
  cursor: not-allowed;
}

/* FILL, AXIS TWO. Three variants, and each one exists because of the ground it
   lands on rather than because it wanted to look different: --paper is for the
   control that sits on the photograph — it was two until the survey band went
   white —
   --primrose is the trigger, and --quiet is the secondary that must read as
   available without taking the accent — which on this page means "the one thing
   to do" and is already spent on joining. The base's --accent fill is the
   no-JS submit, which is the only thing that ever renders it. */

/* .button--paper AND .button--ink ARE GONE, and the base is why. Both existed
   to take the accent OFF a Join button — paper for the one on the photograph,
   ink for the one on the cream band — back when the page held one saturated
   control and that control was the sheet's submit. Jesse asked for every Join
   button to be the crest's orange, so all three now take `.button`'s own
   --accent ground and neither override has anything left to say. Deleted
   rather than left unused: an unused ground is a colour someone re-applies by
   accident. The paper-stock trick is recorded here in case a future control on
   the photograph wants it back:
   `background: var(--paper) url("paper-grain-page.webp") 0 0/620px 620px repeat`. */

/* .button--quiet IS GONE TOO, and for the same reason .button--paper and
   .button--ink went above: its only wearer was the confirmation's share
   control, and that control was deleted with the plate it stood on. It was an
   ink outline on --surface — the secondary rank, available without taking the
   accent. Nothing on the page is at that rank any more. */

/* LIQUID GLASS, on Jesse's instruction — the two page-level Join buttons stop
   being a solid orange fill and become a pane of plain white glass. It carries the
   crest's job without carrying the crest's colour, which is the trade being
   made; --accent is still on the sheet's submit, so the saturated thing the
   page ends on has not moved, it has just stopped being the thing you press to
   get there.

   IT HAS TO BE GLASS OVER A PHOTOGRAPH AND OVER NOTHING. The hero pill sits on
   an aerial shot of the town, where a blur and a saturate have something to
   work on and the material reads exactly as the name promises. The closing
   button sits on plain white, where a backdrop-filter has NOTHING to refract —
   blur white and you get white. So the material cannot live in the filter, or
   half the buttons on this page would be a flat white rectangle wearing the
   name of an effect. It lives in the EDGES: a specular sweep across the top, a
   returned bounce along the bottom, and a rim. Those draw on any ground, and
   the filter is then a bonus the hero collects and the closing scene does not.

   THE SHEEN IS A BACKGROUND LAYER, NOT AN INSET SHADOW, and that is structural
   rather than stylistic. `.button.hero-cta` (0,2,0) already owns box-shadow —
   it carries the three-layer contact/cast/glow stack the photo needs — so any
   rim written as an inset shadow here would be silently dropped on the one
   button that most needs to look like glass. Layered backgrounds compose with
   that override instead of losing to it.

   0.55 at the top and 0.14 at the bottom: a real pane is brightest where the
   light meets it and picks up a weaker second highlight off whatever it is
   sitting on. Equal stops read as a gradient someone applied to a button.

   THE STOP IS AT 46%, NOT 50%. Halfway puts the sheen's edge exactly on the
   text's centre line, and a horizontal boundary running through the middle of
   a 16px label is visible as a seam across the glyphs. Above centre it falls in
   the ascender band, where it reads as light rather than as a line. */
.button--glass {
  /* --ink, not --on-accent. White on this fill is 1.19:1 over the white page —
     the label would be gone. The whole point of deriving the alpha over both
     grounds was to get a dark label that holds on both: 15.64:1 and 9.37:1. */
  color: var(--ink);
  background:
    linear-gradient(180deg,
      rgba(255, 255, 255, 0.55) 0%,
      rgba(255, 255, 255, 0) 46%,
      rgba(255, 255, 255, 0.14) 100%),
    rgba(var(--cta-glass-tint), 0.80);
  /* saturate ABOVE 1 is the liquid half — it pulls the colour out of whatever
     is behind the pane rather than just softening it, which is what separates
     glass from frosting. It does nothing over white, by definition, and that is
     fine: over white the edges are already carrying the material. */
  -webkit-backdrop-filter: blur(14px) saturate(1.6);
  backdrop-filter: blur(14px) saturate(1.6);
  border: 1px solid var(--cta-glass-edge);
  /* Soft, short and low-alpha. A pane of glass rests ON the page; it does not
     hover over it, and a long cast shadow here would put the button back into
     the card-floating-above-the-paper language this page removed everywhere
     else. The hero's own stack overrides this entirely — see above. */
  box-shadow: 0 1px 2px rgba(17, 17, 17, 0.10),
              0 6px 18px rgba(17, 17, 17, 0.08);
}

/* WITHOUT backdrop-filter THE FILL IS STILL 20% TRANSPARENT, which over the
   hero means a sharp, unblurred photograph reading through the label. The
   contrast survives it — 9.37:1 was derived over black with no blur in the
   maths at all — but legibility is not only contrast, and detailed edges under
   type are the thing blur was hiding. So the fallback goes opaque at exactly
   the colour the translucent fill composites to over white, and the button
   loses the material while keeping the design. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .button--glass {
    background:
      linear-gradient(180deg,
        rgba(255, 255, 255, 0.55) 0%,
        rgba(255, 255, 255, 0) 46%,
        rgba(255, 255, 255, 0.14) 100%),
      #FFFFFF;
  }
}

/* THE HERO'S GLOW COMES OFF, and only for the glass pill. (0,2,0) matches
   `.button.hero-cta` exactly, so this wins on source order alone — it is
   declared ~2,200 lines below it. Keep the two in that order.

   The glow was derived for an ORANGE pill on an aerial photograph, where the
   note above it is right: a black ring is black on black, so a zero-offset
   paper light was the only thing that could cut the shape out of the trees. A
   white pane on that same photograph cuts itself out — it is lighter
   than anything behind it — and the 0.475 paper light then has nothing to
   separate and lands as a bloom off a light edge. Rendered, it read as the
   exact failure its own comment warns about two hundred lines up: "a glow that
   announces itself is a button that looks like it is loading."

   The contact and cast layers stay. They were never doing the separating; they
   seat the pill on the photograph, and a pane of glass resting on something
   still needs that. They are NEUTRAL GREY now rather than warmed toward the
   paper: the warm cast was derived under a warm pill, and under a white one it
   reads as a dirty edge instead of a shadow. Grey under white is the depth. */
.button--glass.hero-cta {
  box-shadow:
    0 1px 2px rgba(26, 26, 26, 0.30),
    0 10px 30px rgba(26, 26, 26, 0.30);
  /* THE STRIP'S OWN MATERIAL AS THE EDGE, on Jesse's instruction to put a small
     border of that glass around the button. --cta-glass-edge is a flat #8A8A8A,
     a grey line drawn around a white pane — it separates the button from the
     photograph but says nothing about what the button is made of.

     rgba(0, 0, 0, 0.42) is not a new colour: it is the exact fill of
     .hero-glass above, so the strip's pane and the button's edge are literally
     the same material at the same alpha. Over the photograph it reads as the
     panel's own boundary continued around the control, which is what ties the
     two boxes into one object.

     Scoped to the hero. The closing card's glass button sits on white, where a
     0.42 black hairline would be a hard outline rather than an edge, and keeps
     the grey. */
  border-color: rgba(0, 0, 0, 0.42);
}

/* Reserves one line so an arriving message does not shove the button, but
   collapses when empty — the reserved line was leaving a visible dead band
   between the button and the bottom edge of the panel, which read as the card
   failing to close rather than as space. :empty is exact here: app.js only
   ever writes text into this node. */
.status {
  margin: 16px 0 0;
  min-height: 1.4em;
  font-size: 15px;
  line-height: 1.4;
  color: var(--ink-secondary);
}

.status:empty {
  margin: 0;
  min-height: 0;
}

/* The one thing a visitor wants to know with their thumb already on the
   button. Quiet, small, and directly under the control it qualifies. */
.form-note {
  margin: 14px 0 0;
  font-size: 13px;
  line-height: 1.45;
  color: var(--ink-secondary);
}

/* Errors darken to ink. Never red — red is banned brand-wide. */
.status--error {
  color: var(--ink);
}

/* --- Success ----------------------------------------------------------- */

/* No top margin: by the time this is visible the form above it is hidden, so it
   is the only thing in the sheet and any margin here is just a band of empty
   white above the confirmation.

   IT RESTS VISIBLE, AND THAT IS THE CHANGE. This used to sit at opacity 0 and
   translateY(12px) and be lifted into place by an .is-visible class, back when
   the confirmation swapped into the cream band and had to announce itself. It
   is now inside the sheet, and its entrance is the morph (WAAPI, in app.js) —
   which animates the banner, the share line and the share button individually,
   because a single lift on the wrapper cannot also grow the card it sits in.

   A resting opacity of 0 with nothing left to add the class would have left the
   confirmation INVISIBLE on any path the morph does not drive — including the
   fallback in morphToThanks when the sheet markup is missing. Resting visible is
   the same discipline as the rest of this file: the finished state is the
   default, and animation only ever removes and restores it. */
.success {
  margin-top: 0;
}

/* .success-banner CARRIES NO LOOK OF ITS OWN ANY MORE, and the class is kept
   only because two things address it by name: the arrival animation in
   morphToThanks, and burstConfetti, which refuses to fire without it. The plate
   it used to describe — Primrose ground, 52px of air, a tail pointing at the
   share block — is gone; .success-screen below is what this element looks like.
   Renaming the class would be tidier and would silently take the confetti with
   it, so it stays and says so. */

/* Display face and display size — this is the one moment the page is allowed to
   be pleased with itself, and the reference earns its calm by setting the
   headline big and giving it room rather than by adding anything.
   500, not the 600 the old inline confirmation used: at this size 600 reads as
   a shout, and the size is already carrying the emphasis. */
.success-heading {
  margin: 0;
  font-family: var(--font-display);
  font-size: clamp(30px, 7.6vw, 40px);
  font-weight: 500;
  /* Looser than the page's other display type, which sits at -0.03em or
     tighter. Tight tracking is how a headline gets urgent, and this is the one
     headline on the page with nothing to sell. */
  letter-spacing: -0.015em;
  line-height: 1.15;
  /* Not full --ink. The reference's two-tone headline is the thing worth
     keeping from it — the last word lifts out of the rest — and on a ground
     this saturated there is no second hue available to lift it WITH: anything
     bright enough to read as an accent on Primrose Yellow is a third colour on
     a card that is already the loudest object on the page. So the lift runs on
     value instead of hue, and it runs in the other direction: the base is held
     back to 68% and the name comes forward at full --ink. 5.55:1 here, against
     the 3:1 display type owes. */
  color: rgba(17, 17, 17, 0.68);
  /* The accent word is a name typed by a stranger. Long ones wrap instead of
     pushing the card wider than the column. */
  overflow-wrap: anywhere;
}

/* THE ANNOUNCEMENT TARGET DOES NOT DRAW A RING. app.js focuses this heading so
   a screen reader reads the confirmation on arrival rather than leaving the
   reader standing on a button that just vanished. It carries tabindex="-1", so
   it is NOT in the tab order and there is no keyboard path to it — but Chrome's
   :focus-visible heuristic matches a programmatic focus anyway, and the
   page-wide ring was drawing a 2px box around the headline.

   That bug predates the colour: the ring was there on the --ink plate too, as
   an --ink outline on an --ink card, and could not be seen. Primrose Yellow is
   what made it visible.

   Suppressing a focus ring is normally the wrong instinct. It is right here
   precisely because nothing can tab to this element, so a keyboard user loses
   no way of knowing where they are — while the focus move itself, which is the
   part that carries the accessibility, is untouched. */
.success-heading:focus,
.success-heading:focus-visible {
  outline: none;
}

/* Capped at 34ch and centred, so the lines break near the middle rather than
   leaving a short orphan under a full one.

   The 26px gap is the reference's, and it is doing something: the headline and
   the body are two separate statements — you are thanked, and then you are
   told what happens next — and a normal 8px gap runs them together into one
   paragraph with a big first line. */
.success-body {
  margin: 26px auto 0;
  max-width: 34ch;
  font-size: 17px;
  /* Tighter than the page's 1.6 running copy. Centred text reads as a block
     rather than as lines, and at 1.6 the block loosens into a list. The
     reference sits near 1.35; this splits the difference. */
  line-height: 1.45;
  /* 6.22:1 — the detail line, held under the lead the same way the headline's
     base is held under the name. */
  color: rgba(17, 17, 17, 0.72);
  /* The last line was landing as a single orphan word under three full ones,
     which is the one wrap failure a centred block cannot hide. This is the
     browser's own fix for it — it rebalances the last lines rather than
     greedily filling — and where it is not supported the copy simply wraps the
     way it did before, so nothing is riding on it. Preferred over hand-tuning
     the measure, which only ever solves it at the width it was tuned at. */
  text-wrap: pretty;
}

/* =========================================================================
   THE CONFIRMATION SCREEN

   The whole phone, and that is the point rather than a size. Two earlier
   versions were cards — a Primrose plate inside the sheet's padding, then a
   tall photo card with the picture cropped against its own edges — and the
   reference is neither. It is a flat coloured screen with a photograph
   dissolving into it at both ends, and the words stand on the part that has
   dissolved.

   --confirm-ground IS THE ONE KNOB. Everything on this screen is derived from
   it: the ground, the mask the photograph fades into, the type, the button.
   Flipping it to --cream and the type tokens with it gives the light version
   of the same screen, which is the other half of the argument Jesse and I have
   not settled — he asked for the wordmark in black, which needs a light ground,
   and he asked for 1:1 with a reference whose ground is dark. Dark wins for now
   because it is the more specific instruction; it is one declaration to undo.
   ========================================================================= */
.success-screen {
  --confirm-ground: var(--ink);

  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  overflow: hidden;
  /* The bottom edge is the phone's, so the safe area has to be paid here and
     not by the sheet — nothing sits under this to absorb it. */
  /* 38 -> 60 at the bottom, on Jesse's instruction to lift the button off the
     edge. It reads tighter in a desktop browser than it is on the phone this
     screen is for: env(safe-area-inset-bottom) resolves to 0 here and to about
     34px on the iPhone, so the button was already sitting ~72px off the
     physical bottom and is now ~94 — clear of the home indicator without moving
     the composition.

     The copy rides up the same 22px, because it is bottom-anchored directly
     above the button and the two are one block. That is small enough to stay
     well inside the scrim; a bigger lift is not, which is what a 25svh attempt
     showed — the heading landed on the lit street with the fade nowhere near
     it. */
  padding: calc(env(safe-area-inset-top) + 10px) 24px
           calc(60px + env(safe-area-inset-bottom));
  text-align: center;
  background: var(--confirm-ground);
  color: var(--paper);
}

/* NEGATIVE MARGINS RATHER THAN A PADDING SWITCH ON THE CARD. .sheet-card's 20px
   is what the FORM is measured against, and the morph interpolates the card's
   height between the two states — moving the card's own padding mid-morph would
   move the form that is still fading out inside it. Pulling only this element
   out by the same 20px leaves everything above untouched. 0,2,0 to clear
   .sheet-card .success-banner. */
.sheet-card .success-screen {
  margin: -20px;
  border-radius: inherit;
}

/* THE FADE IS A MASK, NOT A STACK OF GRADIENTS OVER THE TOP. An overlay has to
   restate the ground colour in two more places and drifts the day the ground
   moves; a mask takes the photograph itself down to nothing and lets whatever
   is behind it — one declaration, one colour — simply be there.

   The stops are the reference's: opaque through the middle half of the screen,
   gone well before the heading so the type stands on flat ground rather than on
   a faint picture, and gone at the top before the lockup. The top fade is the
   longer of the two on purpose — that end of the photograph is open sky, which
   is the brightest thing in the frame and needs the most distance to reach an
   --ink ground without reading as a seam. */
.success-photo {
  position: absolute;
  inset: 0;
  z-index: 0;
  width: 100%;
  height: 100%;
  /* The cut is already portrait, so `cover` crops horizontally and the full
     height of the frame maps onto the full height of the screen — which is what
     puts the sky at the top of the mask and the crosswalk at the bottom of it.
     object-position would do nothing on the axis that is exactly filled, so
     there is none. */
  object-fit: cover;
  -webkit-mask-image: linear-gradient(to bottom,
    transparent 4%, #000 25%, #000 52%, transparent 70%);
  mask-image: linear-gradient(to bottom,
    transparent 4%, #000 25%, #000 52%, transparent 70%);
}

/* Everything with words in it stands above the photograph. */
.success-grabber,
.success-lockup,
.success-copy,
.success-back {
  position: relative;
  z-index: 2;
}

/* The reference's drag handle. A real control here, not a decoration: at full
   height the sheet's close button has nowhere to sit above the card, and the
   backdrop it would otherwise fall back to is not reachable either. The bar is
   the reference's 36x5; the 44px box around it is this project's touch-target
   commitment, spent as padding so the drawn bar is untouched. */
.success-grabber {
  display: block;
  width: 36px;
  height: 44px;
  padding: 0;
  background: none;
  border: 0;
  cursor: pointer;
}

.success-grabber::before {
  content: "";
  display: block;
  height: 5px;
  border-radius: 999px;
  /* Not full --paper. A handle at the same value as the wordmark competes with
     it, and this one only has to be seen, not read. */
  background: rgba(255, 255, 255, 0.42);
}

.success-lockup {
  margin-top: 6px;
}

/* The place name, in the page's ordinary display face — the reference's own
   grammar, where the small line is set plainly and the brand under it carries
   the personality. Caps and tracked because it is a label above a wordmark, not
   a sentence: the same exception .tally-label takes, for the same reason. */
.success-town {
  margin: 0;
  font-family: var(--font-display);
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  line-height: 1;
  color: var(--paper);
}

/* THE WORDMARK. line-height:0 because the only thing in this paragraph that
   takes space is the mark itself — the accessible name beside it is off-screen,
   and a paragraph leading under it would push the two lines of the lockup
   apart by an amount nothing on screen explains. */
.success-brand {
  margin: 6px 0 0;
  line-height: 0;
  color: var(--paper);
}

/* THE WORDMARK, AND IT IS NOW EVERY "BLOCK PARTY" ON THE PAGE THAT IS A NAME
   RATHER THAN A SENTENCE: the hero's first line, the closing card's signature,
   and the confirmation screen. Three sites, one file, one rule.

   MASKED, NOT DRAWN. The file is the glyphs' alpha and nothing else (see the
   note in index.html); the colour comes from currentColor, so the mark follows
   the type around it onto any ground — --paper on the hero photograph, --ink on
   the closing card's white, --paper again on the confirmation. No second file,
   no colour to keep in sync, and the day a ground flips it follows for free.

   height:1em IS WHY THIS NEEDS NO PER-SITE SIZING. Measured before the swap:
   "Block Party" set in the page's display face ran 4.87em wide in the hero and
   4.72em in the closing card, and the artwork's own ratio is 4.74 — so a mark
   one em tall lands within a couple of percent of the width the type it
   replaces occupied at both sites, at every breakpoint their clamps already
   handle. The confirmation is the exception below, because that screen sizes
   its lockup against the viewport rather than against a line of type.

   A block box rather than an inline one, deliberately: inline would put the
   artwork's baseline against the line box's and the descender of the "y" would
   have to be measured out of the file. A block ignores all of it. */
.bp-mark {
  display: block;
  height: 1em;
  aspect-ratio: 1066 / 225;
  /* Centres it under a centred line (the hero, the confirmation). The closing
     card sets it back to 0 below — that signature is left-aligned, and auto
     margins would centre the mark in the card instead. */
  margin-inline: auto;
  background: currentColor;
  -webkit-mask: url("blockparty-wordmark.webp") center / contain no-repeat;
  mask: url("blockparty-wordmark.webp") center / contain no-repeat;
}

/* THE TWO ENDS OF THE PAGE SET THE MARK AGAINST THEIR OWN MEASURE, and this
   number was arrived at from both directions.

   height:1em put it at exactly the footprint the type it replaced occupied,
   which was faithful and wrong: a logo the same size as its own tagline reads
   as a kicker above the tagline rather than as the thing the page is signed
   with. The hero's mark was 141px under a 271px sentence.

   The full measure was wrong the other way. At 100% the mark matched the
   sentence exactly, which is the tidy rule and too much object — Jesse's call
   after seeing it, and the fold agreed: on a 1280x860 window it put the Join
   button 33px BELOW the fold, the one control the first screen has.

   60% IS THE SETTLED SIZE. On a phone it is 163px against the 141px the type
   held and the 271px of the sentence; on the closing card, 189px against 223px
   of type. Written as a fraction of the column rather than as an em so it
   tracks the sentence's own clamp at every breakpoint without a second one to
   keep in step with --headline-size. */
.closing-brand .bp-mark {
  width: 60%;
  height: auto;
}

/* 47% RATHER THAN THE CLOSING CARD'S 60, AND IT IS THE SAME MARK AT THE SAME
   SIZE. Both were 60% of their own parent while the hero's parent was a
   shrink-wrapped heading 270px wide — 162px of wordmark. Giving .headline a
   definite width (see its note) moved that parent to the full 345px column, and
   60% of the wider box would have grown the mark to 208 on its own. 47% of 345
   is 162: the number Jesse settled on, held through a change of what the
   percentage is measured against. */
.headline-name .bp-mark {
  /* TWO TERMS BECAUSE THE REFERENCE BOX MOVED BY DIFFERENT AMOUNTS. This mark
     was 162px on a phone and 527px on a 1280 desktop, both settled by eye. They
     were one rule — 60% — while the heading shrink-wrapped its type, and that
     box grew by 1.28x on the phone (270 -> 345) and 1.40x on the desktop
     (883 -> 1232) when the heading took the column. No single percentage holds
     both any more: 47% keeps the phone at 162, and the 527px cap keeps the
     desktop where it was rather than letting it grow to 583. */
  width: min(47%, 527px);
  height: auto;
}

/* THE FOLD GUARD, and at 60% it no longer binds on an ordinary desktop — it is
   here for the short window. The hero stack is crest + name + sentence + strip
   + button and the drawn crest alone is 380px of it, so headroom above the fold
   is measured in tens of pixels: at 1280x860 the button lands with 42px to
   spare and at 1280x600 the mark would take it past the edge without this.

   16svh is the cap, derived rather than guessed — at the full measure 17svh
   left the button inside by 7px, which is a coincidence rather than a margin.
   The 4.738 is the artwork's own ratio, so this is a height cap written as the
   width it implies. The closing card is not in the rule at all: it scrolls, so
   nothing competes with it for a fold. */
@media (min-width: 700px) {
  .headline-name .bp-mark {
    width: min(47%, 527px, calc(16svh * 4.738));
  }
}

.closing-brand .bp-mark {
  margin-inline: 0;
}

/* THE HERO'S SECOND LINE, and it is artwork rather than type again. Same
   machinery as .bp-mark above — the file is the glyphs' alpha, the box behind
   it is currentColor — so the sentence takes --paper on the photograph and
   would follow the hero onto any other ground without a second file.

   THE RATIO IS THE ARTWORK'S OWN, 1849x192 trimmed to its bounding box, which
   is 9.63:1 against the 12.65:1 the deleted headline.svg had. That difference
   is the render's, not a crop choice: it sets the same words larger in its own
   frame.

   width:100% RATHER THAN A FONT-SIZE, which is what the sizing note above
   .headline-line spends thirty lines guarding. "One line across the screen" was
   a property that had to be defended with a min() and a measured em width and a
   nowrap backstop; as a percentage of the column it is true by construction, at
   every width, and cannot be broken by changing the words — because changing
   the words means changing the file. */
.headline-mark {
  display: block;
  /* THE CEILING IS THE ONE THE TYPE HAD, expressed as the width it produced.
     --headline-size tops out at 96px and the string measured 9.285em set solid,
     so as type this sentence stopped growing at 891px. As a percentage of the
     column it did not stop at all: at 1280 it came out 1236px wide and 171 tall
     against the type's 878x122, which put the Join button 21px BELOW the fold —
     the one control the first screen has.

     min() rather than max-width because the cap has to beat the percentage, and
     891px is not a magic number: it is 96 x 9.285, the size this line already
     had. It engages at about a 939px viewport, which is a breakpoint this file
     already turns on. */
  width: min(100%, 891px);
  aspect-ratio: 1849 / 192;
  margin-inline: auto;
  background: currentColor;
  -webkit-mask: url("headline-sentence.webp") center / contain no-repeat;
  mask: url("headline-sentence.webp") center / contain no-repeat;
}

/* The confirmation signs the screen, not a line, so it is sized against the
   viewport: wide enough to be the thing the screen is signed with, short of the
   gutters the rest of the screen keeps. The cap stops it becoming a banner on a
   desktop sheet. */
.success-brand .bp-mark {
  height: auto;
  width: min(74vw, 316px);
}

/* margin-top:auto is the whole layout: the lockup is pinned under the grabber,
   this block falls to the bottom, and the column spends every remaining pixel
   on the photograph between them. */
.success-copy {
  margin-top: auto;
}

.success-screen .success-heading {
  font-weight: 600;
  font-size: clamp(28px, 7.4vw, 34px);
  letter-spacing: -0.025em;
  color: var(--paper);
  text-wrap: balance;
}

/* THE SAME NUMBER AS THE JOIN SHEET'S, SAID AS A SENTENCE. The box on the
   sheet is selling the list to somebody deciding; this is telling somebody who
   has already decided what they just joined, so it is a line of type held under
   the two above it rather than an object competing with them.

   0.5 white composites to about #888 on this ground — 5.9:1, clear of the 4.5
   a 13px line owes, and quiet enough that the eye reaches it last. */
.success-tally {
  margin: 16px 0 0;
  font-size: 13px;
  letter-spacing: 0.005em;
  color: rgba(255, 255, 255, 0.5);
}

.success-screen .success-body {
  margin: 10px auto 0;
  max-width: 28ch;
  font-size: 15px;
  line-height: 1.45;
  /* 13.2:1 on --ink. Held under the heading the way every secondary line on
     this page is, by value rather than by size alone. */
  color: rgba(255, 255, 255, 0.82);
}

/* THE ONLY FILLED CONTROL ON THE SCREEN, and cream rather than white: it is the
   reference's value, and against a photograph a warm off-white reads as an
   object sitting on the image where pure white reads as a hole cut in it.

   Uppercase and tracked, which this page now does in exactly two places — here
   and the headcount's label — and both are labels on a shape rather than lines
   of running copy. */
.success-back {
  width: 100%;
  max-width: 420px;
  height: 54px;
  /* 76 -> 34, on Jesse's instruction to move the bottom text down. The copy
     block above is bottom-anchored (margin-top:auto), so this gap is the only
     thing holding it up off the button — halving it drops the whole answer 42px
     down the screen without touching the lockup at the top, which is what he
     asked to leave alone.

     THE ARGUMENT FOR 76 IS WORTH KEEPING because it was a real one: the copy is
     the answer and the button is what comes after it, and at a normal 24px they
     read as one block with a control stuck to the bottom. The reference spends
     about 11% of the screen here — but it FILLS that space with a second
     paragraph and a game tile, neither of which exists on this screen, so what
     was carried as air read as a hole. 34 still separates them; it just stops
     the separation being the loudest interval on the screen. */
  margin-top: 34px;
  font-family: inherit;
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--ink);
  background: var(--cream);
  border: 0;
  border-radius: var(--radius-pill);
  cursor: pointer;
  transition: transform var(--press) var(--ease-out),
              filter var(--hover) ease;
}

/* The press, which every other button on this page already has and which this
   one owes doubly: it is the last thing anybody touches here. */
.success-back:active {
  transform: scale(0.97);
}

@media (hover: hover) and (pointer: fine) {
  .success-back:hover { filter: brightness(0.95); }
}

@media (prefers-reduced-motion: reduce) {
  .success-back { transition: none; }
}

/* --- The confirmed sheet -------------------------------------------------
   .is-confirmed is put on the dialog by app.js at the moment the morph starts
   and taken off after the close finishes. It is the only state in which this
   sheet is not a sheet: it stops being a card standing off the bottom of the
   screen and becomes the screen.

   THE GEOMETRY IS ON THE DIALOG AND THE TRANSITION IS TOO, which is what keeps
   it in step with the morph without a second animation to keep in sync. The
   card's height is animated by WAAPI in morphToThanks over 320ms on
   --ease-in-out; these are the same 320ms on the same curve, so the sheet's
   edges reach the screen's edges exactly as the card finishes growing into
   them. transform is deliberately untouched — it is the centring translate, and
   app.js owns every transform on the stack inside. */
.sheet {
  transition: width var(--dur-morph) var(--ease-in-out),
              bottom var(--dur-morph) var(--ease-in-out);
}

.sheet.is-confirmed .sheet-card {
  /* The cap that keeps the FORM clear of the close button has nothing to do
     here — the close button is gone in this state. */
  max-height: none;
}

/* The height itself, and it is on the screen rather than the card because the
   card is measured FROM it: morphToThanks reads the card at height:auto, so
   whatever this resolves to is what the card grows to. */
.sheet.is-confirmed .success-screen {
  min-height: calc(100svh - var(--confirm-top-gap) - 16px
                   - env(safe-area-inset-bottom));
}

.sheet.is-confirmed .sheet-close {
  display: none;
}

@media (prefers-reduced-motion: reduce) {
  .sheet { transition: none; }
}

/* Phone only: the sheet gives up its side margins and its bottom inset and
   goes edge to edge, top corners only — the reference's shape. Above this the
   sheet keeps its 430px cap and its 16px stand-off, where a full-viewport-width
   confirmation would be a wall rather than a screen. */
@media (max-width: 559px) {
  .sheet.is-confirmed {
    width: 100vw;
    bottom: 0;
  }

  .sheet.is-confirmed .sheet-card {
    border-radius: var(--radius-card) var(--radius-card) 0 0;
  }

  .sheet.is-confirmed .success-screen {
    min-height: calc(100svh - var(--confirm-top-gap));
  }
}

/* THE CONFIRMATION'S SHARE BLOCK USED TO END THE SUCCESS SECTION HERE.
   Roughly sixty lines: the block, its sentence, the Web Share button sized
   against the band's full-width control, and the survey link in the survey
   band's purple. All of it went with the markup — the new confirmation screen
   is two sentences and one button, and nothing else. See the note where the
   share module used to be in app.js for what that costs. */

/* --- The community-input card ------------------------------------------- */

/* WHAT WAS HERE, AND WHY IT IS NOT. This section shipped a hand-drawn tear-off
   flyer: an inline SVG of a sheet of paper pinned through its own torn top
   edge, TELL US / WHAT TO / BUILD lettered in the page's handwriting face, and
   seven perforated tabs along the bottom reading "invite", one of them already
   torn away by an imaginary neighbour who got here first. The whole drawing was
   a single anchor to the Google form, and beside it, in the same hand, a
   scrawled arrow and the words "Click for Google form".

   THAT ANNOTATION IS THE WHOLE ARGUMENT AGAINST THE DRAWING. A control that
   needs a note telling you it is a control is a control that does not read as
   one, and the note was a band-aid written by the same hand that drew the thing
   it was compensating for. Nielsen Norman put a number on what a weak
   clickability signifier costs: Moran's 2017 eyetracking study, 71 users across
   nine sites, measured 22% more viewing time and 25% more fixations on the
   pages whose controls were flat or ambiguous. This band was paying that on a
   secondary ask, at the bottom of a page, for a link nobody is obliged to
   follow.

   Five further faults, each of which stands on its own:
     - Nothing set expectation. The next thing after the drawing was a raw
       Google Form, with no indication of how long it ran or whether it would
       demand a sign-in first.
     - Two different asks were fused into one object. The tabs said "invite" —
       tear one off, give it to a neighbour — while the sheet said tell us what
       to build. Those are separate actions and a tear-off can only perform one.
     - The visual language was borrowed from somewhere else. A skeuomorphic
       pinned poster with a drop shadow and a -2deg tilt belongs to a different
       design system than the flat, hairline-bounded surfaces this page is
       otherwise built from.
     - "Click" is the wrong verb on the device most of these visitors are
       holding, and vertical all-caps marker lettering set at 11 user units is
       the hardest legibility case anywhere on the page.
     - It had no button semantics and no press state beyond a 2% scale, so a
       keyboard visitor got a focus ring drawn around a picture.

   Written out at this length so that nobody redraws it. The flyer was good; it
   was good at being a drawing, and what this section needed was a control.

   WHAT REPLACES IT is one card holding an eyebrow, a heading, a subline, two
   lines of microcopy and one button — the ordinary version, which is what a
   secondary ask on a quiet page should have been from the start.

   THE SECTION STILL PAINTS NO BACKGROUND OF ITS OWN, and that predates this
   pass. It used to paint itself --surface — the one white on a page that argues
   a white band reads as a printing error — for exactly one reason:
   google-form-flyer.webp had an OPAQUE #FEFEFE ground with ink running to
   within 9px of its edges, so on --paper it laid a visible cool rectangle down
   and the only way to hide that seam was to paint the entire band the same
   white. The picture went, the exception it bought was given back, and .page's
   grain now shows straight through the way it does under every other section on
   the sheet. The white in here belongs to the card, and a 544px box with a
   hairline round it is not a band.

   --font-hand AND ITS @font-face ARE LEFT IN PLACE, AND ARE NOW UNREFERENCED.
   Shadows Into Light had exactly three uses in this file and all three were the
   flyer's lettering, its tab text and the note beside it. The face is not
   preloaded, so an @font-face that matches nothing downloads zero bytes and
   costs a visitor nothing; sweeping the token, the rule and
   fonts/shadows-into-light-400-latin.woff2 is a separate pass and is
   deliberately not folded into this one. Until that happens, the note above
   that @font-face describes a use that no longer exists.

   Still a direct child of .page rather than of .page-inner, so its own padding
   sets the gutter. That is unchanged, and it is why the padding shorthand below
   carries both axes — the two responsive overrides reach in with padding-block
   alone and rely on this one declaration for the inline side. */

/* DO NOT CHANGE THE THREE PADDING NUMBERS THIS SECTION CARRIES. 64 here, 88 in
   the 940 block and 48 in the 600 block are not this section's private
   business: the closing band's spacing is DERIVED from them rather than
   declared against the viewport. The brief asked for 96px of paper between this
   section and the closing scene on a phone and 128 on a desktop, and .join
   spends only the DIFFERENCE on top of whatever .survey has already spent below
   itself. Its 940 rule says so in words — "32 on top of .survey's 88" — the 700
   pass re-derives the same figure against that same 88, and its base rule was
   MEASURED at 393x852, which is inside the 600 block and so was measured
   against the 48. Move any of these three and two gaps in another section move
   with them, silently and in the wrong direction, without a single line of
   .join changing to show it. If the spacing INSIDE this section needs
   adjusting, adjust the card's padding, which nothing else on the page reads. */
/* --- Closing wrapper -----------------------------------------------------

   This wrapper keeps the purple survey and image-backed footer together in the
   document. It intentionally paints no ground of its own. */
.river {
  margin-top: 56px;
  background: transparent;
  padding-bottom: 0;
}

.survey {
  margin-top: 0;
  padding: 64px var(--gutter);
  background: #868bfe;
  border-radius: 24px 24px 0 0;
}

/* The ground runs full-bleed; the copy stays on the page's established column. */
.survey-inner {
  max-width: var(--col-wide);
  margin: 0 auto;
}

/* A transparent content wrapper replaces the former inset white card.

   CENTRED, AND ALL FOUR THINGS IN IT ARE. The card ran ranged left when it held
   only type and a link. It now holds the phone demo, which is a 264px object
   with no edge to hold, and centring that alone left the badge and the heading
   ranged against nothing — a left column of type with a centred picture under
   it reads as a mistake rather than as a choice. One axis for the whole card:
   badge, heading, phone, link.

   The badge and the link centre on this declaration and their own auto margins
   respectively; both are inline-level or fit-content boxes, so neither needs a
   width. The heading's spans are display:block and inherit it. */
.survey-card {
  max-width: 640px;
  /* The 640 cap sits in a 1020 column, so `margin: 0` held the card against the
     column's left edge. That was right while the card's own contents ranged
     left — the type and the band shared an edge. With everything inside it
     centred, the card's left edge is the only thing left pointing anywhere, and
     a centred column parked off-centre reads as a layout that slipped. Below
     940 the column is narrower than the cap and auto margins are zero, so this
     changes nothing on a phone. */
  margin-inline: auto;
  padding: 0;
  background: transparent;
  border: 0;
  border-radius: 0;
  text-align: center;
}

.survey-badge {
  position: relative;
  z-index: 0;
  display: inline-flex;
  align-items: center;
  gap: 9px;
  min-height: 48px;
  margin: 0 24px 30px;
  padding: 0 14px 0 20px;
  border-radius: 999px;
  background: #6b62e9;
  color: #fff;
  box-shadow: -20px 0 0 #c5cdff, 20px 0 0 #c5cdff;
  font-family: var(--font-display);
  font-size: 15px;
  font-weight: 600;
  line-height: 1;
  letter-spacing: 0.18em;
  text-transform: uppercase;
}

.survey-badge::before,
.survey-badge::after {
  content: "";
  position: absolute;
  border-radius: inherit;
  pointer-events: none;
}

.survey-badge::before {
  z-index: 1;
  inset: 0 -2px;
  transform: translateX(14px);
  border: 2px solid rgba(255, 255, 255, 0.72);
}

.survey-badge::after {
  content: none;
}

.survey-badge-icon {
  display: block;
  width: 28px;
  height: 28px;
  padding: 3px;
  border-radius: 8px;
  background: #fff;
}

.survey-badge-icon img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
}

.survey-heading {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(46px, 10vw, 72px);
  line-height: 0.98;
  letter-spacing: -0.045em;
  text-wrap: pretty;
}

.survey-heading span {
  display: block;
}

.survey-heading-primary { color: #fff; }

.survey-heading-accent { color: #3930a8; }

.survey-cta {
  position: relative;
  /* CENTRED UNDER THE PHONE, not ranged left under the heading. It is the one
     control in the card and it now follows a centred object, so a left edge
     would put it under the phone's left bezel and nothing else.

     display:flex over the pill's inline-flex, with width:fit-content, so the
     auto margins have something to divide: an inline-flex box is sized by its
     line box and auto margins on it resolve to zero. It keeps every other
     .button--pill declaration — the axis, the gap, the padding. */
  display: flex;
  width: fit-content;
  min-height: 60px;
  margin-top: 64px;
  margin-inline: auto;
  padding-inline: 26px;
  background: var(--form-purple);
  border-color: var(--form-purple);
  color: #fff;
  font-size: 18px;
}

@media (hover: hover) {
  .survey-cta:hover:not(:disabled) {
    filter: none;
    background: var(--form-purple-lift);
    border-color: var(--form-purple-lift);
  }
}

/* THE FORM PLAYING ON A PHONE, between the heading and the link — the thing
   the link opens, shown before the visitor is asked to leave the page for it.
   One <figure>, no caption on screen: the offscreen figcaption in the markup is
   the whole accessible text.

   CENTRED IN THE CARD while every line of type in it is ranged left, and that
   is deliberate. A 264px object under a 640px column of left-aligned type has
   no edge to hold — ranged left it reads as a fifth paragraph that happens to
   be a picture, centred it reads as an exhibit. The card is the frame it
   centres on, not the band: it is a child of the card and inherits the card's
   left edge, so on a phone (where the card is the viewport) it is centred on
   the screen, and on a desktop it is centred under the heading it belongs to. */
.survey-demo {
  margin: 44px auto 0;
}

/* THE BEZEL IS DRAWN, NOT FILMED, and that is why the recording is screen-only.
   A device frame baked into the video is fixed pixels that soften on a retina
   screen and lock the page to one phone; two radii and a dark pad cost nothing,
   stay sharp at any density, and can be restyled without re-encoding.

   THE TWO RADII ARE NOT INDEPENDENT. Concentric corners need the outer radius
   to be the inner one plus the padding between them — 35 + 7 = 42. Change the
   padding and the outer radius moves with it, or the bezel goes visibly thin at
   the corners and thick on the flats. */
.survey-demo-phone {
  max-width: 264px;
  margin-inline: auto;
  padding: 7px;
  background: var(--ink);
  border-radius: 42px;
  /* Struck off --ink rather than black: the shadow of an ink object on a
     coloured band, not a generic drop shadow. */
  box-shadow: 0 18px 40px rgba(17, 17, 17, 0.28);
}

.survey-demo-video {
  display: block;
  width: 100%;
  height: auto;
  border-radius: 35px;
  /* The poster and the first frame are both white; declaring it stops a dark
     flash in the frame between layout and first paint. */
  background: var(--paper);
}

/* --- Image-backed closing footer -----------------------------------------

   The supplied St. Joseph aerial replaces the river-blue ground. The footer
   card is an inset black tab with only its top corners rounded, matching the
   reference's scroll-revealed footer without adding a second interaction. */
.closing {
  position: relative;
  isolation: isolate;
  display: flex;
  align-items: flex-end;
  min-height: max(720px, 100dvh);
  margin: 0;
  overflow: clip;
  color: var(--paper);
  background: #2b261f;
}

.closing::after {
  content: "";
  position: absolute;
  z-index: -1;
  inset: 38% 0 0;
  pointer-events: none;
  background: linear-gradient(180deg, rgba(18, 18, 16, 0), rgba(18, 18, 16, 0.34));
}

.closing-photo {
  position: absolute;
  z-index: -2;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 45% center;
}

.closing-inner {
  position: relative;
  z-index: 1;
  width: 100%;
  max-width: 1180px;
  margin: 0 auto;
  padding-inline: clamp(14px, 3vw, 32px);
}

/* NO min-height. It was 470 on a phone and 410 on a desktop, and on both it was
   within ~6px of what the content already measured — so it never reserved a
   shape, it just froze the card at whatever the old spacing happened to sum to
   and quietly outlawed tightening anything inside it. The card is content-sized
   now; the composition it sits in is held by .closing's own
   `min-height: max(720px, 100dvh)`, which is where a floor belongs — the stage,
   not the card. Every point the spacing below gives back goes to the
   photograph. */
.closing-card {
  width: 100%;
  padding: 32px 24px max(34px, env(safe-area-inset-bottom));
  color: var(--paper);
  background: #121210;
  border: 1px solid rgba(250, 250, 247, 0.12);
  border-bottom: 0;
  border-radius: 28px 28px 0 0;
  box-shadow: 0 -16px 46px rgba(29, 20, 13, 0.24);
}

.closing-brand {
  margin: 0;
  font-family: var(--font-display);
  font-size: clamp(45px, 12vw, 57px);
  font-weight: 500;
  line-height: 1;
  letter-spacing: -0.04em;
}

/* TIGHTENED TWICE. 58 sat between the wordmark and the ask and read as a gap
   rather than as a break; 52 sat between the CTA and the contact rule, a
   stronger separator than the space around it needed to be. 38 / 36 fixed the
   dead height and kept the order — the wordmark break wider than the type
   inside each block.

   THEN THE WORDMARK STOPPED BEING TYPE. It is the drawn lockup now, and 38
   below it left it stranded: the card's own padding above the mark is 32, so
   the mark was sitting closer to the top edge of the card than to the sentence
   it signs. A break also has to be read against the weight of what it follows,
   and a heavy compact mark separates on less air than a 57px line of type does.

   24 puts the mark inside the pitch rather than above it — nearer the ask than
   the card edge — and the grid's own 36 still holds the two blocks apart, so
   the card reads as [mark + ask + button] / [contact] the way it always did.
   It is now the tightest interval in the card, 4px under the 28 between the ask
   and its button, and that inversion is the optical compensation rather than a
   slip: the mark is a solid black object and the ask is two lines of type, so
   the same measured space reads wider under the mark than under the sentence.
   Measured from the mark's box, which ends at the tail of the "y" — the eye
   puts the mark's bottom at the baseline, about 7px higher. */
.closing-card-grid {
  display: grid;
  gap: 36px;
  margin-top: 24px;
}

.closing-ask {
  max-width: 12ch;
  margin: 0;
  color: var(--paper);
  font-family: var(--font-display);
  font-size: clamp(34px, 10vw, 48px);
  font-weight: 700;
  line-height: 0.98;
  letter-spacing: -0.045em;
  text-wrap: balance;
}

.closing .join-trigger {
  display: block;
  width: fit-content;
  margin-top: 28px;
}

.closing-cta {
  min-width: 190px;
  height: 58px;
  margin: 0;
  padding-inline: 24px;
  color: var(--ink);
  background: var(--paper);
  border: 1px solid rgba(250, 250, 247, 0.72);
  box-shadow: 0 8px 28px rgba(0, 0, 0, 0.2);
}

.closing-cta:focus-visible,
.footer-instagram:focus-visible,
.closing-email:focus-visible {
  outline: 3px solid var(--paper);
  outline-offset: 4px;
}

@media (hover: hover) and (pointer: fine) {
  .closing-cta:hover:not(:disabled) {
    filter: none;
    background: #e9e9e4;
  }

  .closing-email:hover {
    color: var(--paper);
  }

  .footer-instagram:hover {
    color: var(--paper);
  }
}

.closing-contact {
  display: grid;
  /* 20, not the 18 the rest of this tightening pass would suggest, and the
     touch targets below are why: each link expands its hit area by 10px top and
     bottom to reach the 44px minimum, which spends exactly this gap. At 18 the
     two targets would overlap and the row above would steal taps meant for the
     one below. */
  gap: 20px;
  padding-top: 22px;
  border-top: 1px solid rgba(250, 250, 247, 0.16);
}

.footer-instagram-block,
.footer-instagram-callout {
  display: grid;
}

.footer-instagram-block {
  gap: 18px;
}

.footer-instagram-callout {
  gap: 7px;
  max-width: 34ch;
}

/* 28 is the interval between the ask and its button, reused between the button
   and this note — the two gaps below the ask stay equal, so the pitch column
   reads as one stack rather than a block with an afterthought under it.

   BLOCK, NOT GRID, unlike the Instagram note it borrows its type from: a
   closed <details> still counts its hidden content as a grid item, so a `gap`
   here left 7px of dead space under "Pricing" whenever the note was shut. The
   same 7px is spent as a margin on the copy instead, where it only exists
   while the copy does. */
.closing-pricing {
  max-width: 34ch;
  margin-top: 28px;
}

/* THE SUMMARY IS THE WHOLE CONTROL, so it is sized to its text rather than to
   the column: at `display: grid` (inherited from the rules above) a summary
   fills 34ch and the click target would run 200px past the word, which on a
   phone means a tap anywhere right of "Pricing" opens it. inline-flex also
   drops the disclosure triangle in Chrome — a summary that is not
   `display: list-item` has no marker box — and `list-style: none` plus the
   WebKit pseudo-element cover Safari and the older engines. */
.closing-pricing-title {
  display: inline-flex;
  align-items: center;
  gap: 0.34em;
  width: fit-content;
  list-style: none;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

.closing-pricing-title::-webkit-details-marker {
  display: none;
}

/* Same 44px-target-without-44px-of-layout trick the two footer links use
   below, and for the same reason: this is one line of ink in a card whose
   height was fought for. */
.closing-pricing-title {
  position: relative;
}

.closing-pricing-title::before {
  content: "";
  position: absolute;
  inset: -10px -12px;
}

.closing-pricing-title:focus-visible {
  outline: 3px solid var(--paper);
  outline-offset: 4px;
}

/* A RING, NOT A GLYPH ON ITS OWN. "Pricing?" reads as a question being asked;
   "Pricing (?)" reads as a thing you can press, which is what it is. Sized in
   em so it tracks the title's own clamp from 22 to 28 without a second one.
   The 0.5 border and 0.62 fill are the same weights the divider rules in this
   card use, so the ring belongs to the footer rather than to a UI kit. */
.closing-pricing-mark {
  display: grid;
  place-items: center;
  width: 1.16em;
  height: 1.16em;
  color: rgba(250, 250, 247, 0.82);
  font-family: var(--font-body);
  font-size: 0.62em;
  font-weight: 600;
  line-height: 1;
  letter-spacing: 0;
  border: 1.5px solid rgba(250, 250, 247, 0.5);
  border-radius: 50%;
}

/* Open state fills the ring instead of moving it. A rotation or a slide would
   be motion nobody asked for on a two-line answer, and would need a
   reduced-motion branch to go with it. */
.closing-pricing[open] .closing-pricing-mark {
  color: var(--ink);
  background: var(--paper);
  border-color: var(--paper);
}

@media (hover: hover) and (pointer: fine) {
  .closing-pricing-title:hover .closing-pricing-mark {
    border-color: rgba(250, 250, 247, 0.82);
  }
}

.footer-instagram-title,
.footer-instagram-copy,
.closing-pricing-title,
.closing-pricing-copy {
  margin: 0;
}

/* After the reset above, which would otherwise take this back to 0. */
.closing-pricing-copy {
  margin-top: 7px;
}

/* THE TEXT IS THE ONLY THING THAT MOVES. ON JESSE'S INSTRUCTION.
   This animated its height as well — block-size 0 -> auto over 320ms, with
   interpolate-size switched on for the disclosure to make the keyword
   interpolate at all. It looked right in isolation and wrong in place: the
   footer card is anchored to the bottom of its stage, so a card that grows
   grows UPWARD, and 123px of new copy lifted the wordmark, the ask and the
   button with it. The visitor asked what it costs and the whole card left.

   So the box is instant and the copy is the animation. The card takes its new
   size in one frame — before anything is legible — and the words then fade up
   into the space over 520ms. There is still exactly one moving thing, which
   was always the point; it is now the thing that was asked for.

   The height snapping is not a compromise being tolerated. A jump nobody can
   read is cheaper than a lift everybody can, and the reserve-the-space
   alternative — hold the open height at all times so nothing moves ever —
   costs a 116px hole under "Pricing" on a phone whenever it is shut.

   Out is 240, under half the way in: the visitor is deciding on the way in and
   the page is answering on the way out, the same split as the sheet's 420/400
   above. Each direction reads its own rule — opening resolves against the open
   state, closing against the shut one.

   content-visibility still has to come along as a discrete step or the content
   is gone on frame one and there is nothing left to fade. ::details-content is
   the box the browser already hides and shows, so a browser without the
   pseudo-element drops both rules and keeps the instant open it has today —
   the same working fallback the disclosure itself relies on. */
.closing-pricing::details-content {
  opacity: 0;
  transition: opacity 240ms var(--ease-out),
              content-visibility 240ms allow-discrete;
}

.closing-pricing[open]::details-content {
  opacity: 1;
  /* 0s ON THE ENTRY, and it is the line that keeps the card still. A discrete
     transition changes at the HALFWAY point of its duration, so a
     content-visibility that ran with the fade left the copy out of layout for
     260ms and then dropped it in at half opacity — the card jumping a beat
     after the tap, which is the exact thing this pass removed. At 0s the box
     takes its whole size on the first frame and the fade happens inside a card
     that has already finished moving. The exit keeps its duration: there the
     flip has to wait for the copy to finish dimming. */
  transition: opacity 520ms var(--ease-out),
              content-visibility 0s;
}

.footer-instagram-title,
.closing-pricing-title {
  color: var(--paper);
  font-family: var(--font-display);
  font-size: clamp(22px, 6vw, 28px);
  font-weight: 600;
  line-height: 1.08;
  letter-spacing: -0.025em;
}

.footer-instagram-copy,
.closing-pricing-copy {
  color: rgba(250, 250, 247, 0.76);
  font-size: 16px;
  line-height: 1.45;
  text-wrap: pretty;
}

/* 44px TOUCH TARGETS WITHOUT 44px OF LAYOUT. Both links are 24px of ink — an
   icon row and one line of type — which is a comfortable size to read and a
   small one to hit with a thumb on the phone this page is mostly opened on.
   The hit area is expanded with a pseudo-element rather than with padding, so
   the card keeps the height this pass just gave back to the photograph. */
.footer-instagram,
.closing-email {
  position: relative;
}

.footer-instagram::before,
.closing-email::before {
  content: "";
  position: absolute;
  inset: -10px -12px;
}

.footer-instagram {
  display: grid;
  grid-template-columns: 24px auto;
  align-items: center;
  justify-content: start;
  gap: 11px;
  width: fit-content;
  min-height: 24px;
  color: rgba(250, 250, 247, 0.76);
  font-size: 17px;
  line-height: 1.2;
  text-decoration: none;
  transition: color var(--hover) ease;
}

.footer-instagram img {
  display: block;
  width: 24px;
  height: 24px;
  border-radius: 6px;
}

.closing-email {
  width: fit-content;
  color: rgba(250, 250, 247, 0.76);
  font-size: clamp(15px, 4.2vw, 17px);
  line-height: 1.4;
  text-decoration-color: rgba(250, 250, 247, 0.34);
  text-underline-offset: 5px;
  overflow-wrap: anywhere;
  transition: color var(--hover) ease;
}

@media (min-width: 900px) {
  .closing-photo {
    object-position: center 56%;
  }

  /* THE CARD IS AS WIDE AS ITS CONTENT NEEDS, NOT AS WIDE AS THE PAGE'S
     MEASURE. 1180 is the page's own column and every other section wants it;
     this card holds fifteen words, and at 1180 it ran 1116 wide against ~920 of
     content — the last 200px of the middle band were the card being wide for
     the sake of the page rather than for the sake of what is in it.
     1010 sizes it off MEASURED content rather than off the estimate that put
     the first cut at 1100: "Be the first" — the longer of the ask's two rows —
     renders 4.43px of width per 1px of size in this face, not the 6.05 the
     column was first sized against, so the block is 416 wide at the 94 below
     and not the ~570 that was budgeted. The card now runs 946: 434 for the
     pitch, 56 of gutter, 402 for the contact block (54 of it the rule's
     stand-off). Narrower still and the ask takes a third row. */
  .closing-inner {
    max-width: 1010px;
    padding-inline: 32px;
  }

  .closing-card {
    padding: 40px 44px 46px;
  }

  /* THE HORIZONTAL VOID, which the vertical pass left standing. Measured at
     1440: the card's content box is 1028 wide, the old 1.15/0.85 split with a
     110 gutter gave the pitch 527 of it, and the ask only filled 415 of that —
     so 112 of slack, plus the 110 gutter, plus the 54 the rule stands off,
     put 223px of nothing between the sentence and the divider. On a 1116px
     card that band is the first thing the eye lands on.
     Three numbers close it, and each one closes a different part: the ask below
     grows to 82 and fills 496 of the pitch column on its own; the gutter comes
     down to 64; and the contact type goes up so the right block occupies its
     column instead of floating in it. The split stays 1.15/0.85 — which now
     hands the pitch 554, eight points of slack past the ask rather than 112 —
     so the rule stands 122px off the sentence where it stood 223. */
  .closing-card-grid {
    grid-template-columns: minmax(0, 1.05fr) minmax(300px, 0.95fr);
    align-items: end;
    gap: clamp(40px, 4vw, 56px);
    /* 76 -> 48. The wordmark and the ask are the same voice saying the same
       thing twice; at 76 they read as two unrelated blocks with a band of
       nothing between them, which is the space this pass was called on. */
    margin-top: 48px;
  }

  /* 60 -> 94, one point under the 96 the shared rules cap a display line at.
     Twenty-one characters cannot fill a footer card at 60; they can at 94,
     where "Be the first" measures 416 (4.43px of width per 1px of size, taken
     off the render rather than estimated) inside the 434 the pitch column gets.
     The 42 floor is the tablet's and does not move. */
  .closing-ask {
    font-size: clamp(42px, 6.6vw, 94px);
  }

  /* THE RULE IS AS TALL AS THE CONTACT BLOCK, NOT AS TALL AS THE ROW. It was
     `align-self: stretch` with the links pushed to the bottom, so the divider
     ran the full 204px of the row and 130 of those were empty — a ruled panel
     with nothing in it, which is the single largest piece of blank in the card
     and the one the eye finds first on a wide screen. `align-self: end` bottom
     aligns the block against the CTA beside it (the row's own `align-items:
     end` is what lines the two up) and the rule stops where the content does. */
  .closing-contact {
    align-self: end;
    padding: 0 0 4px clamp(32px, 4vw, 54px);
    border-top: 0;
    border-left: 1px solid rgba(250, 250, 247, 0.16);
  }

  /* 17 -> 20 on the desktop card only. Phone type is sized for a 345px card and
     was carried onto a 1116px one, where two 17px links beside an 82px ask read
     as a footnote that happens to be in the same box. 20 is still secondary to
     everything above it and it fills the column: the email measures ~300 of the
     315 the block gets. */
  .footer-instagram,
  .closing-email {
    font-size: 20px;
  }

  .footer-instagram {
    grid-template-columns: 28px auto;
    gap: 13px;
  }

  .footer-instagram img {
    width: 28px;
    height: 28px;
  }
}

/* --- Utility ----------------------------------------------------------- */

/* Off-screen, not display:none — bots skip display:none. */
.offscreen {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* --- The step between phone and desktop ----------------------------------

   NOT A SECOND DESKTOP LAYOUT. Nothing here changes what is beside what — the
   cards stay stacked, the About photo stays above its copy, and the closing
   composition keeps the 496px it shares with the band it stands on. The only
   thing this block does is let the COLUMN grow with the viewport, because the
   type already does.

   660 for the measure against 700 for the box: the same 40px inset --col-wide
   keeps from --measure at desktop width, so a full-bleed band's column stays
   flush with every heading above and below it. */
@media (min-width: 700px) {
  :root {
    --measure: 660px;
  }

  /* Let the page's reading column grow with the viewport. */
  .page-inner {
    max-width: var(--col-mid);
  }
}

/* --- Desktop ------------------------------------------------------------

   Everything above this is the phone layout, and until this block existed it
   was ALSO the desktop layout: a 496px column centred in a 1440px viewport,
   with two thirds of the screen left as empty paper and a card 686px wide
   holding two lines of text. It did not read as restraint, it read as a page
   that had not been finished.

   The fix is composition, not decoration. The column opens to 1020px and the
   three blocks that have a natural second axis take one:
     - the three cards become three columns, which is what "Three things" has
       been claiming in words all along
     - the About photo and the copy sit side by side
     - the form panel and the plate stay narrow, because a 1020px-wide text
       input is a worse input and a 1020px measure is a worse read

   940px, not 768: the cards need ~300px each to hold two lines of body without
   going ragged, and the About photo needs to stay above 380px to be worth
   showing. Below that width the single column is genuinely the better layout,
   so it keeps it. */
@media (min-width: 940px) {
  /* The sheet's top air, on .page-grey because the plate is what paints the top
     edge now — see the base rule. Same number, same job, one level in. */
  .page-grey {
    padding-top: 96px;
  }

  /* Same 96 as the two blocks either side of it — see .week. */
  .week {
    margin-top: 96px;
  }

  /* The cell is 77px wide here against 44 on a phone, so the longest label word
     fits with room to spare and the type goes back up. See .week-line. */
  .week-line {
    font-size: 9px;
  }

  /* One declaration shared by every full-bleed section. */
  :root {
    --gutter: 40px;
    --measure: 940px;
  }

  .page-inner {
    max-width: var(--col-wide);
  }

  /* The closing ask, as one composition instead of a phone card marooned in the
     left third of the screen. The pitch sits beside the fields rather than
     stacked on top of them, which is also what lets the whole panel be read
     without scrolling: heading, reason and form in one glance.

     Grid, not flex: two tracks of different widths that must share a top edge
     is the 2D case. The form track is the wider of the two because a text
     input below ~320px starts to feel cramped, and the pitch is three short
     lines that read better narrow. */
  /* NO SPLIT HERE ANY MORE. The two-track grid (pitch | form) earned its place
     when the form was five controls standing open; against a single button it
     left the band a thin strip with a headline on one side and a control on the
     other, reading as a divider rather than as the end of the page.

     One centred column instead, on the same 544px measure the rest of .page
     uses, so the closing ask is one object at one width — the last fold is a
     single idea, which is exactly what a section that rests as one button
     wants. The form re-fills this column when it opens; nothing has to move
     sideways. */

  /* The form keeps its phone margin now that it sits under the pitch rather
     than beside it. */

  .scale {
    margin-top: 96px;
    /* The phone's 24px was set against a 350px card. On a 940px one it reads as
       no margin at all — matches the form panel's desktop inset instead.
       The horizontal term is the base rule's, with --col-wide in place of
       --col-narrow because that is the column .page-inner opens to here. */
    --plate-inset: 48px;
    padding: 48px calc(max(0px, (100% - var(--col-wide)) / 2) + var(--gutter) + var(--plate-inset)) 48px;
  }

  .scale-list {
    margin-top: 48px;
  }

  /* Photo and copy side by side. The photo leads because it is the thing that
     makes the section land — it is a person, not an illustration. */
  .about {
    display: grid;
    grid-template-columns: minmax(0, 5fr) minmax(0, 6fr);
    column-gap: 44px;
    align-items: start;
    margin-top: 96px;
  }

  .about-heading {
    grid-column: 1 / -1;
  }

  /* Full width, above the split. The lead is addressed to everyone reading the
     section; dropping it into the copy column would make it look like the
     first paragraph of the body, which is the one thing it is not. */
  .about-lead {
    grid-column: 1 / -1;
    margin-top: 20px;
    max-width: 62ch;
  }

  .about-figure {
    margin-top: 40px;
    /* Sticks to nothing and spans nothing; it simply sits in column one at the
       top of the grid row, level with the first paragraph. */
    grid-column: 1;
  }

  .about-copy {
    grid-column: 2;
    margin-top: 40px;
  }

  /* The same 96 the rest of this block spends. The river is a section like any
     other above its own top edge. */
  .river {
    margin-top: 96px;
  }

  /* .survey was absent from this block entirely, so the ink band kept its phone
     padding while every band around it stepped up: 88 above it, 104/112 below,
     and 64 in it. */
  .survey {
    padding-block: 88px;
  }
}

/* MATCHES THE <source> CONDITION IN index.html EXACTLY. It used to be
   `max-width: 700px` on both sides, which is a proxy for "phone" and is wrong on
   a portrait tablet (768 wide, so it took the landscape plate AND the landscape
   bias) and wrong on a phone held sideways. Both now ask about shape. If one of
   the two is ever edited, the other has to move with it: this rule exists only
   to serve the crop the <source> above picked. */
@media (max-aspect-ratio: 1/1) {
  /* Centred, because at this shape the <picture> in index.html has already
     swapped in a frame cut FOR a portrait box. The old 36%/28% nudging existed
     to choose which quarter of a 1.89:1 landscape survived a portrait crop, and
     to dodge the pause overlay that used to sit dead centre; both problems are
     gone. This crop is 0.53 against a 0.46-0.75 viewport, so cover trims from
     one axis only and the middle is exactly what should stay. */
  .hero-photo {
    object-position: center;
  }

}

/* THE HEAD HOLDS AND THE CARDS RUN UNDER IT. The plate used to be one long
   scroll past a heading that had gone by two screens earlier; now the heading
   locks at the top of the window and the cards pass beneath it. Ported from the fixaplan section recorded 2026-08-29 — where, read
   frame by frame, the pinned column is the ONLY thing that holds still: the
   cards under it are plain document scroll, tracking the page to within a
   pixel. No stepper, no fixed frame, no cross-fade, and above all no snap.

   THERE IS DELIBERATELY NO SCROLL-DRIVEN ANYTHING HERE. position: sticky is
   layout: it resolves from the current scroll offset at first layout, with no
   progress to restore and nothing to hydrate, so a reload deep inside the plate
   costs nothing. A timeline-driven version of this would have to resolve its
   range before first paint.

   NO WRAPPER, and none is needed: two siblings with staggered `top` offsets pin
   as one rigid block. See the note above .scale-item for why a wrapper is the
   one thing that would break this section's reveal.

   THE BAND IS ON THE HEADING AND IT REACHES UP PAST THE TYPE. The heading's
   opaque --moss-deep band runs from the top of the window down to under the
   type. On a plate of the same colour it has no visible edge anywhere — it only
   ever occludes a card.

   16 IS THE TYPE'S INSET FROM THE WINDOW TOP, and it used to be 59: the "The
   town" chip pinned above the heading and the band had to reach up over it, so
   the number was 16 + 27 (the chip's box, 7 + 13 + 7) + 16 (its margin). The
   chip is gone and the band starts at the type's own inset again. The matching
   -16px top margin gives the space back, and the heading's 16px of
   padding-bottom is taken off .scale-list, so THE PINNED AND UNPINNED LAYOUTS
   HAVE IDENTICAL DOCUMENT HEIGHT. That is why dragging the window across the
   guard cannot move the scroll position.

   THE GUARD IS THE MEDIA QUERY, NOT AN ENTRY IN THE REDUCED-MOTION BLOCK.
   `* { animation: none !important }` cannot touch position: sticky, so a pin
   declared unconditionally would survive a request for less motion. Declaring
   it inside no-preference means the rule is never generated in the first place,
   and under `reduce` the section is the plain scrolling column it has always
   been. An unpin bolted onto the reduce block three thousand lines away is the
   pattern that rotted once already — .hero's `position: relative` there is a
   vestigial no-op, left behind when .hero stopped being sticky. Gating at the
   declaration site cannot drift.

   THE PHONE PINS TOO NOW, on Jesse's instruction, and both of the reasons it
   did not are worth correcting rather than deleting.

   THE FIRST WAS REAL, AND OTHER WORK FIXED IT. The head plus the tallest card
   has to fit, and when the drawing lived INSIDE the card that came to 727px,
   which no phone has. The drawing has since moved out onto the plate, so the
   tallest card is 359 on a 375-wide phone against a 132px head — 491, which
   fits a 568px window with room over. What is NOT required is that the whole
   ITEM fit: the drawing is outside the card, and a picture scrolling past under
   a held heading is the section working.

   THE SECOND WAS WRONG, and that is the kind of wrong worth writing down rather
   than quietly deleting. It said a phone's URL bar re-maps the viewport
   mid-scroll — that svh would leave a strip of the next section showing and dvh
   would move the runway out from under the pin. All of that is true of a pinned
   STAGE: the pattern where a section is given a 100vh box and a runway measured
   in viewport units. This pin has neither. It is two sticky siblings with `top`
   in pixels, and the runway is the plate's own height in normal flow. There is
   no viewport-HEIGHT unit anywhere in this section — the heading's font-size,
   the list gap and the card's padding are all vw, and a URL bar moves height,
   not width — so a URL bar sliding away changes what is visible and nothing
   else, and sticky re-resolves from the new offset the way it does for any
   scroll. The objection was carried over from a design this never was.

   The guard below IS a min-height and does re-evaluate when a toolbar
   collapses, which is a real coupling and a new one. With the compensation on
   the heading it costs no content shift — only the head pinning and unpinning,
   which is visible behaviour rather than a defect. An SE-class phone whose
   window straddles 560 between toolbar states would see that; it is unmeasured
   because there is no device here.

   THE CEILING, RECORDED SO IT IS NOT REDISCOVERED: text scaling breaks the
   premise and no CSS fixes it. At 150% text on a 375x667 phone the head is 204
   and the tallest card 572 — 776 against a 667 window — and the viewport is
   unchanged, so the guard still matches. Worse, the 59 below is built from a
   chip measured at 13px type; at 150% the chip is 33.5 and the gap under it
   closes from 16 to 9.5, and above about 223% the two collide. There is no CSS
   expression for a rendered element's height, so this wants JS or nothing. It
   is nothing.

   min-height: 560px, derived from measurement: 491 is the worst case (320 and
   375 wide both land there) and 560 leaves 69px over. It also turns the pin off
   in landscape on every phone, where the window is 375-430 tall and the same
   content needs 538 — the right outcome, and it comes free. Browser zoom
   self-disables it for the same reason, since zoom scales CSS pixels.

   NO min-width ANY MORE. Nothing in this block is width-dependent: the 16 below
   is a fixed inset and the band's width tracks the cards through
   --plate-inset. */
@media (min-height: 560px) and (prefers-reduced-motion: no-preference) {
  /* THE HEAD KEEPS ITS REVEAL, and the reason is arithmetic rather than nerve. A
     view() timeline does freeze on a subject that is pinned — the subject stops
     moving against the scrollport, so progress stops — but it finishes long
     before it gets there. With the chip gone the heading moves UP the reveal
     ladder, from rung 2 to rung 1, so its range closes EARLIER than the one this
     note was written against — it resolves while its top is still most of the
     way down the window, and it does not pin until y=0. The gap between the last
     frame of the reveal and the first frame of the pin only grew, and it held at
     the 760px floor before it grew. The freeze that follows is a freeze at
     progress 1, which is the resting state.

     If the range is ever pushed later — a taller heading, another rung above it —
     check this again before believing it still works. The failure is silent: the
     head would simply arrive part-faded and stay that way for the whole pin. */

  /* THE BAND REACHES PAST THE COLUMN. Its whole job is to occlude what passes
     under it, so it has to be at least as wide as the widest thing that passes.
     Nothing on the plate is wider than the column any more, so --plate-inset
     either side is now margin rather than a requirement — cheap insurance, and
     the inset is given straight back as padding so the type does not move a
     pixel. Get this wrong and a card's left and right edges appear through the
     head while its middle is hidden, which is the single worst thing this
     section can do. */
  .scale-heading {
    position: sticky;
    top: 0;
    z-index: 1;
    /* THE BOTTOM -16 IS WHAT KEEPS THE PINNED AND UNPINNED LAYOUTS THE SAME
       HEIGHT, and it has to live here rather than on the list. The padding
       paints the band 16px below the type, which the occlusion needs; the
       negative margin gives that same 16px back in flow, so the band grows
       without the document growing.

       It was a `.scale-list { margin-top: 32px }` first, and that was a bug:
       32 + 16 cancels the list's DESKTOP margin of 48, but below 940 the list
       sits on 40, so the pin was adding 8px to the plate on every phone and
       tablet — measured at both 375 and 700, zero at 1280. Every crossing of
       the guard shifted everything under the plate by 8px, and
       a phone's resting head-to-card gap was silently the desktop's 48 instead
       of its own 40. Compensating here is width-agnostic by construction and
       deletes a rule instead of adding a second breakpoint to it. */
    margin: -16px calc(-1 * var(--plate-inset));
    padding: 16px var(--plate-inset);
    /* THE RAMP THAT KEEPS SAFARI'S CHROME NEUTRAL, and this is the element that
       has to carry it: the sampler reads whatever fixed or sticky thing covers
       the top of the window, which is this band. See --moss-lift. The plate
       behind it runs the same ramp over a slightly wider box, so the two differ
       by a unit or two at most — and the band is opaque over it anyway. */
    background: linear-gradient(
      100deg,
      var(--moss-deep) 0%,
      var(--moss-lift) 50%,
      var(--moss-deep) 100%
    );
  }

  /* NO DISSOLVE UNDER THE HEAD ANY MORE. A `.scale-heading::after` painted a
     48px black-to-transparent gradient below the band so a card faded into it
     instead of being cut by it. Over the bare plate between cards the same
     gradient read as a dark stripe across the green — a shadow the section was
     never meant to have — and Jesse called it. The band's bottom edge is a hard
     line again, which is what every other pinned head does.

     If the cut ever needs softening again it belongs on the pseudo-element and
     nowhere else: a real div here would land in .scale's child list and take a
     rung off the reveal ladder, or land in .scale-list and take one off the
     cards. And it has to be BLACK, not --moss-deep — a card is rgba(0,0,0,0.2)
     over the plate, so a plate-green gradient laid on it LIGHTENS it and reads
     as a pale wash creeping down out of the head. */

  /* Find-in-page, :target and sequential focus all scroll a match to the top of
     the scrollport, which is now a band of opaque green. One declaration puts
     every one of them back below the head.

     10.5rem = 168px, and it now clears the head by a wide margin. Re-measured
     after the "The town" chip was removed, with the heading reading "A small
     town with a lot of business.", the head is:

         280px wide   118   (26px floor, wraps to THREE lines)
         320-375       89   (26px floor, two lines)
         601-939      107   (34px cap, two lines)
         1280          69   (34px cap, one line)

     Every one is 43px shorter than the figure this value was set against — the
     chip's 27px box plus its 16px margin — so 168 is deliberately generous
     rather than tight, and a shorter number is not worth re-deriving for the
     43px it would buy. It was tight before: 156 stood here and had 6.2px of
     headroom at 601-939, and at 280 the head was 160.8 and 156 was not enough,
     which put a find-in-page match 4.8px behind opaque green on a cover-screen
     phone. Whatever this value is, it has to clear the TALLEST head, which is
     the 280 case and not the phone or desktop one a reader assumes.

     In rem rather than px so it also tracks a raised default font size, which
     is the only free mitigation for the text-scaling ceiling noted above.

     It does NOT fix paging — Space and PageDown move by roughly a viewport, so
     the top of each new screenful arrives behind the band. That is the standing
     cost of any sticky header, and it is why this one is a head and not a
     chrome bar. */
  html {
    scroll-padding-top: 10.5rem;
  }
}

/* THE 320 FLOOR, WHICH .week-line's OWN SIZE DOES NOT REACH. It is 7.5px
   because that is the largest that fits "Millstream" in a 390px phone's 44px
   cell. At 320 — a width this file supports and measures against — the cell is
   about 39px and 7.5 needs 38.3 against roughly 34 of content, so the guard
   fires and the label breaks mid-word. 6.5 needs 33.2 and clears it.

   380, NOT 600. The nearest existing breakpoint is the gutter's at 600, and
   putting this there would take a 390 and a 430 phone down to 6.5 as well —
   both of which have room for 7.5 and were measured at it. A size derived from
   one width belongs in a query that only that width enters. */
@media (max-width: 380px) {
  .week-line {
    font-size: 6.5px;
  }
}

/* THE TITLE'S OWN FLOOR, AND IT IS A FONT-STACK HEDGE RATHER THAN A LAYOUT ONE.
   At 13px the string measures 234.7 against 247 of available width at 320 —
   it fits, but by 5%. --font-mono is a stack, and the faces below SF Mono
   (Menlo, Consolas, and whatever `monospace` resolves to on Android) do not
   share its advance width; a face 6% wider wraps the title to two lines and
   pushes the grid down. 12px restores 12% of headroom, which no reasonable
   mono exceeds.

   340, not the 380 above it: a 375 phone has ~290px of width here and holds 13
   comfortably, so it keeps the size Jesse picked. Only the 320 floor gives it
   up, and only by one step. */
@media (max-width: 340px) {
  .week-strip {
    font-size: 12px;
  }
}

@media (max-width: 600px) {
  /* One declaration shared by every full-bleed section. */
  :root {
    --gutter: 20px;
  }

  .page-grey {
    padding-top: 40px;
  }

  .survey {
    padding-block: 48px;
  }
}

@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }

  /* Unpin the hero. Parallax is exactly the kind of background-moving-against-
     foreground effect that triggers motion sensitivity, and it is invisible to
     the blanket animation/transition reset below because sticky is layout, not
     animation. The page then scrolls as one flat document. */
  .hero {
    position: relative;
  }

  /* The curtain is already off-screen at rest, and `animation: none` below is
     what keeps it there. Stated explicitly so a future edit to that reset
     cannot quietly drop a grey panel over the whole page. */
  .curtain {
    display: none;
  }

  * {
    animation: none !important;
    transition: none !important;
  }

  /* --- THE SHEET'S EXCEPTIONS TO THAT RESET ------------------------------

     Stated here, next to the reset, for the same reason .curtain is: a blanket
     rule that something has to escape should say so where the rule lives, not
     three thousand lines away where a future edit will not see it.

     Reduce Motion means FEWER AND GENTLER, not zero. Everything below is an
     opacity or a colour — the state changes a visitor still needs to be able to
     read — while every transform, blur and radius morph stays cancelled. Each
     selector outranks `*` on specificity, so !important ties break their way
     regardless of source order.

     The sheet's own choreography is NOT here and cannot be: it is WAAPI, which
     CSS cannot reach at all. app.js gates it on the same media query. */
  .sheet::backdrop,
  .sheet[open]::backdrop,
  .sheet.is-closing::backdrop {
    transition: opacity 200ms linear !important;
  }

  .sheet-close-glyph,
  .btn-face,
  .error,
  .status {
    transition: opacity 150ms linear !important;
  }

  /* The pricing note keeps its crossfade, shortened. Nothing else about it
     moves any more — the height it used to animate went with the lift it put
     through the card — so this is the whole of its exception. */
  .closing-pricing::details-content {
    transition: opacity 150ms linear !important;
  }

  /* The crossfade mask is motion; the crossfade is not. */
  #btn-idle {
    filter: none !important;
  }

  /* A spinner with its animation reset is not a calmer spinner, it is a stalled
     one. The pending state becomes the word instead. */
  .spinner {
    display: none;
  }

  .btn-pending-text {
    display: inline;
  }
}

/* ===================================================================
   THE BRAND MARQUEE
   Reference: pangram.com's "Trusted by Global Brands" strip. Numbers taken
   off the live element rather than guessed — 60px label gap, 75px rail,
   30s linear loop, no gap between items (the spacing is each item's own
   padding, which is what lets a long name and a short one both sit in a
   rhythm instead of on a grid).
   =================================================================== */

/* NO GROUND OF ITS OWN. This floats on the page's paper — the white band it
   used to sit on made the strip read as a separate module bolted under the
   hero, which is exactly what it is not. It is a quiet line of marks passing
   across the page, and the page's own texture running behind it uninterrupted
   is what makes it belong. Nothing here may set a background. */
.brands {
  /* DELIBERATELY TIGHT. The marks are large enough now to be the object; the
     air around them was reading as a gap in the page rather than as breathing
     room, and the strip has to sit between the hero and the opening paragraph
     as one continuous move. Nothing here may set a background. */
  padding-block: clamp(10px, 1vw, 16px);
}

/* THE SEAM BELOW BELONGS TO THIS SECTION, NOT TO .creed. .creed carries a
   16/48 top padding for the case where it is the first thing under the hero —
   which it no longer is. Left alone it reopened the gap this section just
   closed, so it is overridden HERE, scoped to the adjacency, rather than by
   editing .creed's own rule and breaking it for any page that still leads
   with it. */
.brands + .creed { padding-top: clamp(2px, 0.4vw, 6px); }

/* THE RAIL IS THE WHOLE SECTION NOW. With the label gone there is nothing to
   sit beside, so this is no longer a two-column row — it is a full-bleed strip
   with a left inset that lines the first mark up with the page's column. */
.brands-inner {
  display: flex;
  align-items: center;
  padding-left: max(var(--gutter), (100% - var(--col-wide)) / 2);
}


/* THE MASK IS THE WHOLE ILLUSION. Without it a name is chopped in half by a
   hard edge at each end and the rail reads as a cropped list; with it names
   arrive and leave. 56px is about one glyph's worth at this size — enough to
   fade, short enough that a name is never unreadable for long. */
.brands-viewport {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  -webkit-mask-image: linear-gradient(90deg, transparent 0, #000 56px, #000 calc(100% - 56px), transparent 100%);
  mask-image: linear-gradient(90deg, transparent 0, #000 56px, #000 calc(100% - 56px), transparent 100%);
}

/* width:max-content, so the track is as wide as its twenty children and the
   -50% below is exactly one full copy of the list. */
/* PINNED TO ITS OWN LAYER, and the reason is iOS rather than smoothness. This
   track runs an infinite transform, so WebKit promotes it and expands the
   coverage rect to the animation's full extent — about 7890px x 74px on a phone
   and 9990px on a desktop, both well past the ~4096px texture ceiling iOS works
   to. With the promotion left implicit, Safari is free to drop the backing
   store under memory pressure and re-attach it later, which is the whole strip
   going blank and coming back on its own. Declaring it does not raise the
   ceiling; it stops the layer being treated as disposable.

   THE TRACK IS STILL OVER THAT CEILING. Getting under it needs FEWER LOGOS —
   13 a half rather than 25 — which is a decision about which businesses come
   off the page, not something a stylesheet can do. Written down here so the
   next person measuring this does not go looking for a CSS answer. */
.brands-track {
  will-change: transform;
  backface-visibility: hidden;
  display: flex;
  align-items: center;
  width: max-content;
  height: 80px;
  gap: 0;
  margin: 0;
  padding: 0;
  list-style: none;
  /* 36s, not the reference's 30s — 20% slower. Linear stays: an eased
     marquee visibly hesitates at the loop point, and the whole illusion
     is that there is no loop point. */
  animation: bp-marquee 36s linear infinite;
  /* One number governs the whole row's weight. Was 52px inside a disc; the
     marks were taken to a quarter of that, which proved too small to read —
     they are now 66px, three times that, and the rail is sized to sit close
     around them rather than to leave them floating in it. */
  --brand-h: 66px;
}

/* Pause under the pointer. A reader who wants to actually read a name should
   not have to chase it, and the reference's own rail does the same. */
.brands-viewport:hover .brands-track { animation-play-state: paused; }

.brand {
  position: relative;
  display: flex;
  align-items: center;
  padding-inline: clamp(18px, 2vw, 34px);
}

/* NO SLOT, NO DISC, NO BOX. Every mark is sized by HEIGHT alone and takes
   whatever width it naturally wants — which is how a real logo row works and
   why the data-wide split is gone: a circular badge is 22px wide, a horizontal
   lockup is nearer 60px, and letting each keep its own proportion is what
   stops the row looking like objects stuffed into cells.

   The tinted discs that used to sit behind these are removed entirely. They
   were scaffolding for the days before the files existed. */
.brand-icon {
  position: relative;
  display: flex;
  align-items: center;
  height: var(--brand-h);
}

/* ONE SHADE FOR ALL TEN. brightness(0) crushes every mark to a flat silhouette
   whatever colours it arrived in, and the opacity lifts that back to a light
   grey on the paper. This is the standard logo-row treatment and it is doing
   real work here: ten logos in ten different brand palettes at this size read
   as confetti, and the row has to read as one quiet line or it fights the hero
   directly above it.

   IT MEANS INTERNAL KNOCKOUTS FILL IN. A mark with white shapes cut out of it
   becomes solid at this size. That is the cost of a single shade and it was
   chosen deliberately; if a specific logo turns to mush, give that one its own
   rule rather than lifting the filter for all of them. */
.brand-logo {
  height: var(--brand-h);
  width: auto;
  max-width: 250px;
  object-fit: contain;
  filter: brightness(0);
  opacity: 0.3;
  transition: opacity var(--hover) var(--ease-out);
}

.brand:hover .brand-logo { opacity: 0.62; }

/* THE TWO EXCEPTIONS, AND WHY THEY ARE ALLOWED TO EXIST. brightness(0) makes
   every mark one flat silhouette, which is what holds the row together — but a
   logo that is mostly SOLID ARTWORK rather than line work has nothing left once
   its interior tone is crushed, and lands as a grey blob among twenty-three
   legible marks. Rockhouse (a filled badge) and the Meat Market (a filled silo)
   are the only two in this set that do it.

   They keep their own luminance instead, greyed and set to a weight that reads
   at the same strength as the silhouettes beside them. This is the escape hatch
   the rule above anticipated: exempt a specific logo, never lift the filter for
   all of them, or the row goes back to twenty-five different brand palettes. */
.brand-logo[src*="rockhouse"],
.brand-logo[src*="meat-market"] {
  filter: grayscale(1) contrast(0.92);
  opacity: 0.42;
}

.brand:hover .brand-logo[src*="rockhouse"],
.brand:hover .brand-logo[src*="meat-market"] { opacity: 0.78; }



.brand-name {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  white-space: nowrap;
  border: 0;
  clip-path: inset(50%);
}

@keyframes bp-marquee {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}

/* The label stacks above the rail rather than shrinking beside it — at this
   width a 60px gap plus two columns leaves the rail too short to read as
   movement, which is the only thing this section is for. */
@media (max-width: 860px) {
  .brands-viewport { width: 100%; }
  .brands-track { height: 74px; --brand-h: 54px; }
}

/* MOTION THE VISITOR DID NOT ASK FOR, which is exactly what this setting is
   about. The rail stops and becomes an ordinary scroller, and the doubled
   second half is harmless there — it reads as a longer list, not a stutter. */
@media (prefers-reduced-motion: reduce) {
  .brands-track { animation: none; }
  /* WITH THE ANIMATION OFF, overflow: hidden STRANDS EVERY LOGO PAST THE FIRST
     SCREENFUL. The rail's whole job is to bring them past; stop it and the mask
     becomes a wall. auto gives the row back as an ordinary scroller, which is
     what the note above says this block is for. x only — the track is one row
     and a vertical scrollbar here would be a bug, not a fallback. */
  .brands-viewport {
    overflow-x: auto;
    /* THE MASK COMES OFF WITH THE ANIMATION. It fades 56px at each end so logos
       enter and leave a moving rail rather than popping at a hard edge — a job
       only a moving rail has. On a manual scroller it dims 112px of a 393px
       screen, better than a quarter of the strip, permanently and for nothing. */
    -webkit-mask-image: none;
    mask-image: none;
  }
}
