/* Self-hosted: shared/fonts/README.md has the licence, source and subset
   provenance. The path is relative to the deployed root (this file is copied
   to app/style.css and save-the-date/style.css by sync-shared.sh, sitting
   beside app/fonts/ and save-the-date/fonts/), not to shared/ -- do not
   write ../fonts/ here.
   Regular (400) only: there is no 300 weight in the subset, so nothing may
   ask this family for one, or the browser will synthesise a smeared display
   serif. font-display: swap and no metric overrides (size-adjust,
   ascent-override): Cormorant Garamond's metrics against the fallback stack
   have not been measured, and an unmeasured override is worse than a
   visible swap.
   The five app/ pages carry a <link rel=preload> for this file -- the
   save-the-date page preloads Cormorant Garamond instead, which is the only face it
   sets -- and the type scale
   below references var(--display), so the preload now buys a face something
   actually paints. It was deliberately absent before that: with no selector
   naming --display, the preload would have been an unconditional 19KB
   download that nothing rendered -- on app/index.html it would also have
   competed with the fetchpriority="high" hero image preload on the LCP path.
   That reasoning still applies in reverse: if the type scale is ever
   unwired from --display, take the preload back out with it. */
@font-face {
  font-family: 'Cormorant Garamond';
  src: url('fonts/cormorant-garamond-400.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

:root {
  /* Instagram's "Classic" is whatever face the device itself uses, so asking
     for the system UI font reproduces it rather than approximating it. It
     carries body, nav, labels and forms. Display sizes use --display. */
  --display: 'Cormorant Garamond', 'Iowan Old Style', 'Palatino Linotype',
             Palatino, Georgia, 'Times New Roman', serif;
  /* ONE face on the whole site since 2026-09-06, the logo mark aside. --font
     and --display resolve to the same stack; they stay separate tokens
     because SIZES still differ by role, and because every rule is already
     wired to one or the other. The silent failure this opens up: font
     fallback is per character, so a codepoint absent from
     shared/fonts/cormorant-garamond-400.woff2 is drawn by the system face and
     nothing reports it. The subset covers Latin-1 plus the French
     punctuation (shared/fonts/README.md) and tools/check-arch-fit.mjs asserts
     per CHARACTER, on every page, that the face draws every one it renders. */
  --font: var(--display);

  /* The micro-arch head masks, at punctuation scale. A mask is monochrome, so
     the two colours a micro-arch needs (the ring and the fills) are two masks:
     --micro-head is the ring (the solid head with an evenodd hole 6 units
     in), --micro-head-fill is the solid head that fills the box. Both are the
     canonical two-arc d (R = 75 of 100, centres on the springing line), the
     fill closed with Z so the bottom is a straight base, the ring's inner
     arch offset 6 units so the stroke reads a constant 6 of 100 wide and
     scales with the type. */
  --micro-head: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 70.7107' preserveAspectRatio='none'%3E%3Cpath fill-rule='evenodd' d='M0 70.7107 A75 75 0 0 1 50 0 A75 75 0 0 1 100 70.7107 Z M6 70.7107 A69 69 0 0 1 50 6.4 A69 69 0 0 1 94 70.7107 Z' fill='%23000'/%3E%3C/svg%3E");
  --micro-head-fill: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 70.7107' preserveAspectRatio='none'%3E%3Cpath d='M0 70.7107 A75 75 0 0 1 50 0 A75 75 0 0 1 100 70.7107 Z' fill='%23000'/%3E%3C/svg%3E");

  /* Cream paper, warm near-black ink, pink as an accent. These are the
     save-the-date's values, promoted to the whole site on 2026-09-06 -- see
     docs/superpowers/specs/2026-09-06-one-face-design.md. Every pair is
     measured in tools/check-contrast.mjs; a palette change is a contrast
     re-derivation, not a find-and-replace. */

  /* Paper: the ground. --paper-deep is the foot of the page wash and the
     footer's ground; nothing else may paint it. */
  --paper:        #faf6ec;
  --paper-deep:   #f2ebdd;

  /* Pink: the accent, never the paper. --blush is the rail and any panel,
     --blush-deep the focus halo, --submit the button's resting fill (Pantone
     197 U), --rose-deep the deep pink for on/hover states. The two pale
     pinks are WARM on purpose: a cool pink reads lilac on cream. */
  --blush:        #f8e9e4;
  --blush-deep:   #f2d9d4;
  --submit:       #d7859b;
  --rose-deep:    #8e4a5e;   /* 5.91 on --paper; on --submit for hover only */

  /* Ink. One colour of it. A warm near-black rather than #000000, and that
     is a decision rather than a hedge: pure black measures 19.46 on this
     cream and reads as a hole punched in the paper, where #1c1a17 measures
     16.09 and still lands far above any floor. --ink-soft reproduces the old
     secondary-copy relationship against cream, at 5.93. The heart is the
     same colour as the type, kept as its own token because the two are
     separate decisions that happen to agree today. */
  --ink:          #1c1a17;
  --ink-soft:     #655e53;
  --heart:        var(--ink);

  /* Deliberately outside the palette: an error is not a brand colour, and this
     one is the only red on the site. */
  --err:          #8c2b3f;

  /* Hairlines. Decorative and structural: never a control boundary on their
     own, because neither clears the 3:1 non-text floor. */
  --line:         rgba(142, 74, 94, 0.20);
  --line-strong:  rgba(142, 74, 94, 0.38);

  /* Roundness scales with the size of the thing. Arch radii are NOT here:
     they are computed per-element from 50cqi, because an arch is a geometric
     relationship rather than a chosen value. See the arch primitives. */
  --r-xs: 4px;
  --r-sm: 10px;
  --r-md: 16px;
  --r-lg: 24px;

  /* Warm rose-grey, never blue-grey. This single choice is what stops a pastel
     site reading as cold Scandinavian minimalism. Each token is a stack with
     increasing blur and DECREASING alpha per unit of blur, which is how real
     occlusion falls off; a single 0 10px 30px reads as a sticker. */
  --sh-1: 0 1px 1px rgba(122,74,86,.05),
          0 3px 6px rgba(122,74,86,.05);
  --sh-2: 0 1px 1px rgba(122,74,86,.05),
          0 4px 10px rgba(122,74,86,.06),
          0 14px 30px rgba(122,74,86,.07);
  --sh-3: 0 1px 1px rgba(122,74,86,.05),
          0 6px 14px rgba(122,74,86,.07),
          0 20px 44px rgba(122,74,86,.09),
          0 40px 90px rgba(122,74,86,.08);
  /* The lit top edge. On an arch it runs along the crown, exactly where sun
     catches stone. */
  --rim:  inset 0 1px 0 rgba(255,255,255,.90);
}

/* Seven tokens (--white, --glass, --glass-strong, --hairline, --shadow-soft,
   --shadow-lift, --r-xl) were removed from this palette in Phase 1, while
   rules still referenced them, deliberately. Every reference has since been
   rewritten in Phase 2, onto --paper, --sh-1/--sh-2/--sh-3, --rim, --line and
   --r-lg as appropriate; none of the seven names appear anywhere below this
   point. --r-sm/--r-md/--r-lg changed value at the same time, from
   12px/18px/26px to 10px/16px/24px. */

/* Body copy is the one Instagram calls Classic: the platform's own interface
   face. San Francisco on an iPhone or a Mac, Roboto on Android, Segoe UI on
   Windows, exactly what Instagram sets story text in. THAT ENDED on
   2026-09-06: --font now resolves to --display, and every word on every page
   renders in the self-hosted Cormorant Garamond at its only shipped weight,
   Regular -- see the --display token and its @font-face above. The
   paragraph above is kept as the record of what the split was for.

   The page wash runs the length of the DOCUMENT, not the viewport. It used to
   live in the fixed body::before layer, which meant it was keyed to the window
   and painted a pink horizontal band across whatever happened to be on screen
   -- including, once the arcade lands, the paper between the arches, which has
   to read as the same paper as the rest of the page.
   Longhands, not the shorthand: this is the one background on the site that no
   var() may be allowed to invalidate. */
body {
  font-family: var(--font);
  color: var(--ink);
  background-color: var(--paper);
  background-image: linear-gradient(180deg,
                      var(--paper) 0%, var(--paper) 46%,
                      var(--paper-deep) 100%);
  background-repeat: no-repeat;
  line-height: 1.7;
}

/* Warm light entering a room, and the only warm thing on the site apart from
   the photograph itself. It is fixed because light comes from the window, not
   from the document, and it dies at 62% of the viewport so it never reaches
   the band or tints a section of type. */
body::before {
  content: '';
  position: fixed;
  inset: 0;
  z-index: -2;
  pointer-events: none;
  background: radial-gradient(120% 62% at 50% 0%, rgba(255, 233, 219, .45), transparent 62%);
}
body::after {
  content: '';
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  opacity: .085;   /* a tooth, not parchment: two levels per channel at most */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)'/%3E%3C/svg%3E");
}

* { margin: 0; padding: 0; box-sizing: border-box; }

/* The width/height attributes on <img> are presentational hints, so a bare
   `width: 100%` in CSS leaves the height attribute applying literally and the
   picture renders stretched. height:auto restores the real aspect ratio and
   still lets the attributes reserve the right space while loading. */
img { max-width: 100%; height: auto; }

html { scroll-behavior: smooth; }

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

/* Two-tone still, but the reasoning that used to justify it is gone: the nav
   glass and the hero scrim it was measured against were both removed by
   Task 5 and the 7 August palette change respectively, and this ring was
   never re-measured against what replaced them. What is actually true
   today: every focusable element on the site -- the rail, its links, the
   footer doors, the form fields, the buttons -- sits on a light surface
   (paper, a tinted wash, or the rail's own 0.90-alpha paper composite over
   the photograph; see tools/check-contrast.mjs's "sticky rail" band), where
   the dark --ink outline alone already clears the 3:1 non-text floor with
   room to spare (--ink is 8.27:1 or better on every FLAT background in that
   file). So the pale halo is not carrying a contrast obligation against any
   surface currently shipped. It stays anyway, for two reasons that are not
   contrast: it widens the ring's visible footprint, which helps on the
   smaller controls (radio-sized targets, the language toggle), and it is
   cheap insurance against a future phase putting a focusable element back
   over a photo crop before this comment is next revisited. Phase 3's day
   cards have landed since this was written, but on the .graft's plain paper,
   not over a photo crop, so that scenario still hasn't happened. (Both card
   families that stood here are gone as of 2026-09-21: the home page's day
   cards with its "Two Days of Celebration" section, Where to Stay's arched
   cards with the interior-page redesign. Every card on the site is the plain
   rectangular .card now, on plain paper.) The arched cards carried their own
   :focus-visible restatement while they existed, for an unrelated reason --
   a card's own box-shadow beats the zero-specificity :where() halo, the same
   collision .door and .field input still have. The insurance argument here is
   still unclaimed, open against Phase 4's timeline. */
:where(a, button, summary, input, select, textarea):focus-visible {
  outline: 3px solid var(--ink);
  outline-offset: 2px;
  box-shadow: 0 0 0 6px var(--blush-deep);
  border-radius: 4px;
}

h1, h2, h3, .serif, .script { font-family: var(--display); font-weight: 400; }
.script { letter-spacing: .02em; }

/* The scale. Display sizes only on the webfont; body, nav, labels and form
   fields stay on the system stack, so they are there in the first frame and
   cost nothing. Anything at or above 1.5rem (24px) may use --rose; below that
   it must be --rose-deep or --rose-ink.
   ONE PAGE OPTS OUT of the first sentence: save-the-date sets --font to its
   --display face, so everything on it is the webfont. Every size below is
   restated there rather than reused, because a scale built for a 0.52 em
   x-height does not survive a 0.306 one. See html[data-face="card"]. */
h1        { font-size: clamp(2.85rem, 9.3vw, 5.1rem); letter-spacing: .025em; line-height: 1.06; }
h2        { font-size: clamp(2.2rem, 5vw, 3.3rem); letter-spacing: 0; line-height: 1.12; }
h3        { font-size: 1.6rem; letter-spacing: 0; line-height: 1.25; }
/* line-height: 1.3, down from the inherited body value of 1.7, is not
   first-screen headroom any more -- Task 1's rebuild onto .frame-in moved
   that argument elsewhere, and a stale version of this comment kept the old
   one. Both of this rule's consumers (the home overture's .date and
   save-the-date's) sit inside a Primitive D .frame-in, bottom-anchored via
   justify-content: flex-end inside a fixed min-height floor; measured by
   raising this line-height to the inherited 1.7 and re-running
   tools/check-first-screen.mjs: the date, the note and the name field held
   at the exact same pixel in all sixteen cases, because section.overture's
   own height is pinned by that floor and both settings fit under it. Only
   the names heading moved, by this rule's own line-box growth (8.32px at
   390px, where the clamp bottoms out at 1.3rem) -- a bottom-anchored stack
   transfers 100% of a middle item's growth to what sits above it, not the
   roughly half a true vertical centring would. So the real cost of loosening
   this rule is RECIPE A margin at the names/eyebrow group, which sits that
   much closer to the arch's own crown: worst corner -13.15px at 390px,
   -52.60px at 760px, -14.81px at 1200px against 1.3's -17.77 / -57.94 /
   -21.07 (Chromium; WebKit agrees within 0.03px) -- roughly 4-6px of margin
   given up at every width, comfortably inside the floor either way. Kept at
   1.3 for that margin. */
.date { font-family: var(--display); font-size: clamp(1.63rem, 4.3vw, 2.36rem); line-height: 1.3; }
/* lining-nums, added 2026-09-21 with the Roman-to-Arabic swap: Cormorant
   Garamond ships oldstyle figures by default, so 1 2 3 4 rendered at varying
   heights with the 3 and 4 dropping below the baseline. At 2.8rem, sitting
   above a heading, that reads as a rendering fault rather than as a
   typographic choice. */
.numeral  { font-family: var(--display); font-size: 2.8rem; letter-spacing: .02em; line-height: 1; font-variant-numeric: lining-nums; }
.lede     { font-size: 1.35rem; letter-spacing: .005em; line-height: 1.55; }
.caption, .small { font-size: 1.1rem; line-height: 1.5; }

/* Sentence case in the display face, as the card set them: the tracked
   uppercase these used to be is a second voice the one-face page does not
   have. 1.35 is the line-height the card's arch was measured against. */
.eyebrow, .place {
  font-family: var(--display);
  font-size: 1.05rem;
  font-weight: 400;
  letter-spacing: .01em;
  text-transform: none;
  line-height: 1.35;
}

/* ---------- The arch system ----------
   ONE RULE generates every arch on this site. A border-radius corner is a true
   circular quadrant only when its HORIZONTAL radius equals its VERTICAL radius
   in absolute terms, and a semicircular head additionally requires the two top
   radii to sum to exactly the element's width.

   THE TRAP, and it is silent: when the radii on ANY side sum to more than that
   side's length, the browser computes f = min(side / sum) across all four
   sides and multiplies EVERY radius by that one f. A single overlong bottom
   radius quietly turns the head into an ellipse. This is why
   `border-radius: 999px 999px 24px 24px` does not work here and must never be
   written. Every arch below is arranged so that f is exactly 1.

   Arch radii are never chosen values, so they are not palette tokens. They are
   computed per element from 50cqi -- half the query container's inline size --
   because an arch is a geometric relationship rather than a taste decision. */

/* Primitive C -- the arcade.
   The band is photograph edge to edge. The arcade is a bite taken out of the
   TOP ONLY: piers and spandrels of paper hang down from the top edge, joined
   by arch heads, and die at the springline. Below that the photograph is solid
   and full bleed. That satisfies both "seen through a colonnade" and "full
   bleed" with no compromise.

   Three mask layers, unioned:
     1  the arch heads      radial, tiled across x
     2  the straight jambs  linear, tiled across x, from the springline down
     3  the body            one full-bleed rectangle below the arcade

   Why each piece is the way it is:

   - THE THREE LAYERS UNION. mask-composite defaults to `add` unprefixed and
     `source-over` in the -webkit- syntax; both mean union. mask-composite is
     therefore DELIBERATELY ABSENT from both blocks below. Writing `add` into
     -webkit-mask-composite is invalid there and can produce a SUBTRACTIVE
     mask, which inverts the arcade into arch-shaped holes of paper in a sea of
     photograph. It looks deliberate in a screenshot. tools/check-arcade.mjs
     exists mostly to catch this.
   - LAYER 3 IS no-repeat. Per-layer mask-repeat is legal. If all three tiled,
     the piers would run to the bottom of the band: a colonnade, but not full
     bleed.
   - THE JAMB LAYER'S 100% IS --tile, NOT THE BAND. Percentages inside a
     gradient resolve against that layer's own mask-size. That is what makes
     the rhythm follow --cols with no further arithmetic.
   - THE HEAD/JAMB SEAM IS EXACT. The circle spans x = tile/2 +/- r, which is
     gap/2 .. tile - gap/2; the jamb spans the same. The circle's lower half
     lies entirely inside the jamb band.
   - THE JAMB/BODY SEAM GETS A 1px OVERLAP, because at fractional device pixel
     ratios two exactly abutting mask layers can leave a sub-pixel line of
     paper straight across the photograph.
   - THE 0.5px INSIDE THE RADIAL STOP anti-aliases the curve. A hard stop
     renders stepped.
   - THE FAILURE MODE IS SAFE. Every var() here lives in a mask-* property, so
     a parse failure degrades to an unmasked rectangular photo band: the
     picture is still there, the arches are not. Nothing disappears and no text
     becomes unreadable, because no text sits over this.

   On the apex this rule is overridden by the pointed construction in the
   PASS 2 block below; what is stated here now serves only the card face
   (save-the-date's .band.tall and the card-scoped fixtures), which is why
   the radial-gradient head survives at all.

   Gated by tools/check-arcade.mjs, which renders THIS stylesheet through
   tools/fixtures/arcade.html and asserts exact pixels in Chromium and WebKit
   at two viewports. That gate runs on every deploy. */
.arcade { container-type: inline-size; }   /* 100cqi === the band's width */

.band {
  --cols:   4;
  --gap:    clamp(28px, 4cqi, 64px);              /* pier width between openings */
  --crown:  clamp(18px, 3cqi, 44px);              /* paper above the arch crowns  */
  --tile:   calc(100cqi / var(--cols));
  --a:      calc(var(--tile) - var(--gap));       /* opening width */
  --r:      calc(var(--a) / 2);                   /* head radius   */
  --spring: calc(var(--crown) + var(--r));        /* y of the arch centre */
  --shaft:  clamp(40px, 10cqi, 140px);            /* straight jamb below the head */
  --arcade: calc(var(--spring) + var(--shaft));   /* depth of the arcade wall */

  height: min(86svh, 720px);
  position: relative;
  overflow: hidden;

  -webkit-mask-image:
    radial-gradient(circle var(--r) at 50% var(--spring),
                    #000 calc(var(--r) - 0.5px), transparent var(--r)),
    linear-gradient(90deg,
                    transparent 0 calc(var(--gap) / 2),
                    #000 calc(var(--gap) / 2) calc(100% - var(--gap) / 2),
                    transparent 0),
    linear-gradient(#000, #000);
  mask-image:
    radial-gradient(circle var(--r) at 50% var(--spring),
                    #000 calc(var(--r) - 0.5px), transparent var(--r)),
    linear-gradient(90deg,
                    transparent 0 calc(var(--gap) / 2),
                    #000 calc(var(--gap) / 2) calc(100% - var(--gap) / 2),
                    transparent 0),
    linear-gradient(#000, #000);

  -webkit-mask-size:
    var(--tile) 100%,
    var(--tile) calc(var(--arcade) - var(--spring)),
    100% calc(100% - var(--arcade) + 1px);
  mask-size:
    var(--tile) 100%,
    var(--tile) calc(var(--arcade) - var(--spring)),
    100% calc(100% - var(--arcade) + 1px);

  -webkit-mask-position: 0 0, 0 var(--spring), 0 calc(var(--arcade) - 1px);
  mask-position:         0 0, 0 var(--spring), 0 calc(var(--arcade) - 1px);

  -webkit-mask-repeat: repeat-x, repeat-x, no-repeat;
  mask-repeat:         repeat-x, repeat-x, no-repeat;
}

/* PASS 2 -- pointed heads on the apex. Scoped to html:not([data-face="card"])
   because the card face (save-the-date's .band.tall, the card-scoped
   fixtures, and through them og-savethedate.jpg) must keep the radial
   construction above byte-identical -- and the card never sees this rule.

   The geometry is the canonical one (R = 3S/4, h = S/sqrt(2), the same
   --arch-head path every Pass 2 primitive carries), sized to the OPENING
   --a, not the tile: at head = 0.7071 * tile the pointed feet would overhang
   the jambs by (tile - a)/2 each side and leave little paper ledges standing
   above every opening. At 0.7071 * --a the head spans exactly gap/2 ..
   tile - gap/2, meeting the jamb layer on the same verticals as the circle
   did, so the head/jamb seam stays exact and the §5 wall-ratio table holds
   (4-col desktop wall 46.4%, 9-col segmental 48.2%, both re-derived from
   this value).

   --spring moves accordingly: the head is --a wide and 0.7071 * --a tall,
   so it springs at crown + 0.7071 * --a. (The circle's --spring/circle --r
   of the base rule stay computed but unused here.)

   LAYERS. A repeat-x head layer would pitch at its own width --a, not at
   --tile, and drift off the jambs from the second column on. So each
   opening's head is its own no-repeat layer at x = gap/2 + k * tile, sat at
   y = --crown so the crown paper stays above every head and the head's
   bottom edge lands exactly on --spring. Every variant lives under
   --cols: 9 (9-col segmental is Task 4's widest), so nine head layers cover
   every rhythm; the surplus k on narrower variants position their heads past
   the band's right edge, where the mask paints nothing. Union only:
   mask-composite is absent on purpose (see the trap note above). The jamb
   and body layers, the 1px seam overlaps and the no-repeat body are this
   rule's parents' construction, restated so the two mask stacks share no
   list a later edit could desynchronise -- and the jamb layer grows 1px
   upward (position --spring - 1px) to overlap the head's bottom edge, the
   same sub-pixel-seam cover the body layer gets. */
html:not([data-face="card"]) .band {
  --spring: calc(var(--crown) + var(--a) * 0.7071068);
  --head-h: calc(var(--a) * 0.7071068);
  --arch-head: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1000 707.107' preserveAspectRatio='none'%3E%3Cpath d='M0 707.107 A750 750 0 0 1 500 0 A750 750 0 0 1 1000 707.107 Z' fill='%23000'/%3E%3C/svg%3E");

  -webkit-mask-image:
    var(--arch-head), var(--arch-head), var(--arch-head),
    var(--arch-head), var(--arch-head), var(--arch-head),
    var(--arch-head), var(--arch-head), var(--arch-head),
    linear-gradient(90deg,
                    transparent 0 calc(var(--gap) / 2),
                    #000 calc(var(--gap) / 2) calc(100% - var(--gap) / 2),
                    transparent 0),
    linear-gradient(#000, #000);
  mask-image:
    var(--arch-head), var(--arch-head), var(--arch-head),
    var(--arch-head), var(--arch-head), var(--arch-head),
    var(--arch-head), var(--arch-head), var(--arch-head),
    linear-gradient(90deg,
                    transparent 0 calc(var(--gap) / 2),
                    #000 calc(var(--gap) / 2) calc(100% - var(--gap) / 2),
                    transparent 0),
    linear-gradient(#000, #000);

  -webkit-mask-size:
    var(--a) var(--head-h),
    var(--a) var(--head-h),
    var(--a) var(--head-h),
    var(--a) var(--head-h),
    var(--a) var(--head-h),
    var(--a) var(--head-h),
    var(--a) var(--head-h),
    var(--a) var(--head-h),
    var(--a) var(--head-h),
    var(--tile) calc(var(--arcade) - var(--spring) + 1px),
    100% calc(100% - var(--arcade) + 1px);
  mask-size:
    var(--a) var(--head-h),
    var(--a) var(--head-h),
    var(--a) var(--head-h),
    var(--a) var(--head-h),
    var(--a) var(--head-h),
    var(--a) var(--head-h),
    var(--a) var(--head-h),
    var(--a) var(--head-h),
    var(--a) var(--head-h),
    var(--tile) calc(var(--arcade) - var(--spring) + 1px),
    100% calc(100% - var(--arcade) + 1px);

  -webkit-mask-position:
    calc(var(--gap) / 2) var(--crown),
    calc(var(--gap) / 2 + var(--tile)) var(--crown),
    calc(var(--gap) / 2 + var(--tile) * 2) var(--crown),
    calc(var(--gap) / 2 + var(--tile) * 3) var(--crown),
    calc(var(--gap) / 2 + var(--tile) * 4) var(--crown),
    calc(var(--gap) / 2 + var(--tile) * 5) var(--crown),
    calc(var(--gap) / 2 + var(--tile) * 6) var(--crown),
    calc(var(--gap) / 2 + var(--tile) * 7) var(--crown),
    calc(var(--gap) / 2 + var(--tile) * 8) var(--crown),
    0 calc(var(--spring) - 1px),
    0 calc(var(--arcade) - 1px);
  mask-position:
    calc(var(--gap) / 2) var(--crown),
    calc(var(--gap) / 2 + var(--tile)) var(--crown),
    calc(var(--gap) / 2 + var(--tile) * 2) var(--crown),
    calc(var(--gap) / 2 + var(--tile) * 3) var(--crown),
    calc(var(--gap) / 2 + var(--tile) * 4) var(--crown),
    calc(var(--gap) / 2 + var(--tile) * 5) var(--crown),
    calc(var(--gap) / 2 + var(--tile) * 6) var(--crown),
    calc(var(--gap) / 2 + var(--tile) * 7) var(--crown),
    calc(var(--gap) / 2 + var(--tile) * 8) var(--crown),
    0 calc(var(--spring) - 1px),
    0 calc(var(--arcade) - 1px);

  -webkit-mask-repeat: no-repeat, no-repeat, no-repeat, no-repeat, no-repeat,
                       no-repeat, no-repeat, no-repeat, no-repeat,
                       repeat-x, no-repeat;
  mask-repeat:         no-repeat, no-repeat, no-repeat, no-repeat, no-repeat,
                       no-repeat, no-repeat, no-repeat, no-repeat,
                       repeat-x, no-repeat;
}

/* The photograph. Longhands rather than the `background` shorthand on purpose:
   a shorthand is all-or-nothing at computed-value time, and this is the LCP
   element of the pages that carry it.

   inset is -8% top and bottom purely to give the parallax somewhere to travel
   (see the motion layer). The band clips it. .band.segmental overrides it to
   0 because it opted out of the parallax.

   `center 40%` was chosen for the silver photograph this design was drafted
   against, which had no horizon and so no wrong answer for vertical position.
   sea-hero.webp now carries a real horizon inside the crop (see CROP_Y_HERO
   in tools/build-images.sh; sea-tall.webp crops to the source's full height
   rather than a chosen offset, so its horizon lands wherever the source photo
   puts it), so this value was re-checked rather than assumed: rendered in
   Chromium at 1440x900 (sea-hero) and 390x844 portrait (sea-tall), band
   clipped and measured column-wise (off the sun's glare) with the band
   scrolled to the top and to the bottom of the viewport, and again with the
   plate's transform forced to -44px and +44px -- the full travel motion.js
   clamps to. Measured at a column clear of the sun's glare, across those four
   states the horizon sits 31-44% down the band on the wide crop (a second
   column agrees within four points) and 51-65% down it on the tall crop,
   clear of both edges throughout, and the sun is never cropped. `center 40%`
   is kept unchanged. */
.plate {
  position: absolute;
  inset: -8% 0;
  background-image: url('assets/sea-hero.webp');
  background-position: center 40%;
  background-size: cover;
  background-repeat: no-repeat;
}

/* One breakpoint, and --cols and the crop switch TOGETHER on it, because it is
   the query index.html's two <link rel=preload media> attributes already
   carry. If this breakpoint ever moves, both of those move with it: they are
   exact complements, and a mismatch makes portrait phones fetch a crop they
   never paint. */
@media (max-width: 1100px) and (orientation: portrait) {
  .band       { --cols: 1; height: min(78svh, 640px); }
  .band .plate { background-image: url('assets/sea-tall.webp'); }
}

/* The shallow head band -- Primitive C at interior scale. Seven openings
   instead of the home band's four and a 380px band instead of 720 read as the
   same building seen from a lower storey. PASS 2 re-tune: five openings
   measured the pointed wall at 57.7% of the band at 1200x900 -- over the
   multi-opening threshold -- and seven brings it to 44.9%.

   ONE crop at every width, unlike the home band above. sea-head.webp carries
   the sun and the horizon since 2026-09-21; it used to be cut
   entirely below the horizon (CROP_Y_HEAD in tools/build-images.sh): water
   only, no horizon and no sun, so there is nothing whose vertical placement a
   portrait phone could get wrong, and cropping the wide strip to a narrow box
   loses nothing. That is what lets each interior page carry one unconditional
   <link rel=preload> instead of index.html's media-matched pair.

   SOURCE ORDER IS LOAD-BEARING. The home band's portrait override sits
   directly above. .band.head is (0,2,0) against its (0,1,0), so this rule's
   height and --cols already survive that query on specificity -- but the
   head's OWN portrait override below is (0,2,0) too, and only beats this rule
   by coming after it. Do not move either block. */
/* 540, up from 380, 2026-09-21. The band was a shallow strip of texture with
   the page's title in a second arch on paper below it; it is the whole header
   now, and has to hold the sun in its upper third and three lines of type in
   its lower third. 64svh, up from 46, keeps roughly the proportion of a short
   viewport that the two elements used to occupy together. */
.band.head {
  --cols:  7;
  --shaft: clamp(20px, 4cqi, 56px);
  height: min(64svh, 540px);
  position: relative;          /* .band-title is absolutely positioned in it */
}
.band.head .plate { background-image: url('assets/sea-head.webp'); }

/* The page's title, in the band, over the water.

   WHITE TYPE ON THE PHOTOGRAPH IS AN ESTABLISHED PATTERN HERE, not a new
   exception. An older comment in this file says the v2 design "separates
   photo from type completely"; that stopped being true on 2026-09-06, when
   the photograph moved inside the home page's overture arch. The colour and
   shadow below are copied exactly from that rule (".overture .frame-in
   .eyebrow, h1, .date, .place", further down) so the two headers read as one
   system.

   WHAT THE SHADOW IS FOR, measured: on the save-the-date card the same white
   type scores 1.03:1 against the brightest pixel under a glyph -- white on
   white wherever the sun's glitter falls behind it -- and is carried by the
   shadow alone. The defence here is geometric rather than typographic: the
   head crop was recut on 2026-09-21 to put the sun at y=0.3201 of the file
   and the horizon at 0.45 (measured in-browser off the output, not assumed),
   so this block, anchored to the band's foot, sits entirely over water below
   the horizon. Do not move it up into the glare.

   bottom, not top and not centring: the sun's pixel height shifts with the
   band's own height across viewports, but the dark water always runs to the
   band's foot, so anchoring to the foot is the placement that survives a
   resize. */
/* The one scrim on the site, and it is here because it was MEASURED to be
   needed rather than because it looks nice. The site removed scrims on
   2026-08-07, when no text sat on a photograph; this band puts three lines
   of type over the sunset, so that reasoning does not reach it.

   WHY THE TYPE CANNOT SIMPLY MOVE: the sun is on the band's axis (the crop
   is cut around SUN_X_SRC precisely so it is), its glitter trail runs down
   the same axis to the band's foot, and the title is centred. The bright
   ground and the type occupy the same column by construction, at every
   viewport. There is no vertical position that escapes it.

   Measured white-on-brightest-ground under each line, Chromium at 1200x900,
   type hidden and animations frozen so the sample is the true ground:

     no scrim      eyebrow 1.38   h1 1.45   sub 1.70
     .45 / .28     eyebrow 2.24   h1 2.48   sub 3.74
     .62 / .40     eyebrow 3.68   h1 4.09   sub 6.02
     .75 / .52     eyebrow 5.16   h1 5.91   sub 8.64   <- this rule

   The floors are 4.5 for the eyebrow and the sub (both under 24px) and 3.0
   for the h1 (large text). Only the last row clears all three, which is why
   it is not one of the lighter ones.

   z-index 1: above .plate (which is positioned, and therefore paints over
   in-flow content whatever the source order) and below .band-title's 2.
   pointer-events: none so it never eats a click. It is inside .band.head, so
   the arcade mask clips it to the openings like everything else -- it cannot
   spill onto the paper above. */
.band.head::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background: linear-gradient(to top,
                rgba(8, 16, 22, .75) 0%,
                rgba(8, 16, 22, .52) 28%,
                transparent 55%);
}

.band.head .band-title {
  position: absolute;
  left: 0;
  right: 0;
  bottom: clamp(1.4rem, 4cqi, 2.6rem);
  z-index: 2;
  text-align: center;
  padding-inline: 1.5rem;
}
.band.head .band-title .eyebrow,
.band.head .band-title h1,
.band.head .band-title .sub {
  color: #fff;
  text-shadow: 0 1px 10px rgba(0, 0, 0, .35);
}
.band.head .band-title h1 {
  font-size: clamp(2.4rem, 5.2vw, 3.4rem);
  letter-spacing: 0.01em;
  margin: 0.4rem 0 0;
}
.band.head .band-title .sub {
  margin-top: 0.5rem;
  font-family: var(--font);
  font-size: 1.35rem;
}

@media (max-width: 1100px) and (orientation: portrait) {
  /* PASS 2 re-tune, measured at 390x844 with pointed heads (0.7071 * --a):
     the old three openings put --a at 102px and the wall (--crown + head +
     --shaft) at 110px against the 381px fixture band, 28.9% -- comfortably
     clear of the multi-opening threshold, so --cols did not HAVE to move.
     Four is a finer rhythm, not a rescue: it drops --a to 70px (matching the
     segmental band's portrait opening exactly, so the two arcs read as one
     system on a phone) and lands the wall at 87px, 22.9%. Figures measured
     live in the browser (t3-probe's hidden-div technique), not assumed. */
  .band.head { --cols: 4; }
}

/* The segmental arcade -- Primitive C again, nine openings and almost no
   crown, opening each category of the Beirut Guide. A wide low RHYTHM, which
   is the only honest way to get one: the openings here are pointed heads at
   the canonical geometry like every other band (0.7071 * --a tall, springing
   from the same jamb verticals), because --a is derived from --tile exactly
   as it is above and only --cols, --crown and --shaft moved. Never try to
   make a wide low arch by stretching a single head instead (Primitive A,
   above): vertical% = 50 * W/H exceeds 100% as soon as H < W/2, which is
   geometrically impossible, and the attempt silently yields an ellipse with
   no error anywhere.

   PASS 2 re-tune, figures measured in the browser after this edit (not
   assumed from the spec's computed table): at 1200x900 (--cols: 9) the
   opening width --a is 85px and the arcade wall (--crown + head + --shaft)
   is 92px against a 190px band, 48.2% -- the nine openings bought the
   pointed heads back the two points the taller curve cost, against 49% at
   seven semicircles. At 390x844 portrait (--cols: 5 since 2026-09-22) --a is
   50px and the wall is 53px against a 186px band, 28%: the pointed head is
   41% taller at the same span and portrait pays all of it, with room to
   spare under the threshold. --shaft's clamp is still pinned by
   its 1.4cqi term, so there is no headroom to grow --shaft without pushing
   the wall back over the threshold the paragraph below records.

   THE "ABOUT HALF THE BAND" RULE, SCOPE CORRECTED (PASS 2): ~50% is the
   point a MULTI-OPENING rhythm turns into a wall with slots. It does not
   apply to a single window -- the home band's portrait (one opening) sits at
   54% and that is architecture, not slots -- so the threshold is judged here
   and on .band.head, not on every variant.

   Nine columns at 390px would put --a at 28px: a scallop border, not an
   arcade. Portrait runs five, an odd count so the sun's axis is an opening
   (see the override below).

   One crop at every width, for the reason .band.head gives above:
   sea-head.webp carries the sun and the horizon since 2026-09-21 (it was cut
   below the horizon before), and the same file still serves
   every viewport and this page's <link rel=preload> stays unconditional. */
.band.segmental {
  --cols:  7;
  --crown: clamp(8px, 1.2cqi, 16px);
  --shaft: clamp(10px, 1.4cqi, 22px);
  --band-h: clamp(120px, 22svh, 190px);
  height: var(--band-h);

  /* WHERE THE SUN GOES. sea-head.webp was cut (tools/build-images.sh,
     CROP_*_HEAD) to put the sun 32% and the horizon 45% down a 540px header
     band. That band left the interior pages on 2026-09-21 (1f7c6fc) and this
     120-190px strip inherited the file with `center 40%`, which parked the
     sun under the crown paper: y=23px in a 190px band at 1200 wide, y=4px at
     1440x760. Measured, not assumed -- and the parallax then slid it a
     further +-11px.

     The picture is `cover` and WIDTH-LIMITED at every width this band ships
     at (cover picks max(W/1800, H/935); with H <= 190px width wins for every
     W >= 366px), so the displayed image is always 100cqi x 935/1800 =
     51.944cqi tall. In that image, measured by pixel scan: sun centre
     16.613cqi below the top (0.3198), horizon 23.375cqi (0.45), the two
     6.762cqi apart, sun radius 1.555cqi (diameter 0.0311 of width).

     --sun-y is the sun centre's y in the band. Wanted: the centre of the
     pointed head, --crown + half of 0.7071 * --a. Clamped from above so the
     horizon still lands inside the band with 24px of water under it, and
     from below so the image's bottom edge never rises above the band's foot
     (that floor only binds in portrait, where the band is nearly as tall as
     the image; 35.331cqi is the image height less the sun's depth in it).
     CSS clamp() returns its minimum when min > max, which is the order these
     two need.

     Verified in Chromium 2026-09-22 (band y of sun / horizon / arcade foot):
     1200x900 44.6 / 125.7 / 91.5; 1440x760 45.8 / 143.2 / 108.6;
     1920x1080 36.1 / 166.0 / 143.6; 390x844 47.9 / 74.3 / 53.3.
     tools/check-sunset.mjs holds these by reading pixels, not this comment.
     Failure mode: an engine that cannot parse --sun-y drops the
     background-position declaration and the picture falls back to the
     initial `0% 0%` -- the sun at y=16.6cqi, under the crown but with the
     mask intact. Nothing structural depends on this variable. */
  --sun-y: clamp(var(--band-h) - 35.331cqi,
                 var(--crown) + 0.35355 * var(--a),
                 var(--band-h) - 6.762cqi - 24px);
}
/* inset: 0, not the base .plate's -8% 0. That 8% exists only as travel for
   the parallax, and motion.js excludes this variant (its selector is
   `.band:not(.segmental)`), so the headroom would only make --sun-y relative
   to a box 16% taller than the band. */
.band.segmental .plate {
  inset: 0;
  background-image: url('assets/sea-head.webp');
  background-position: center calc(var(--sun-y) - 16.613cqi);
}

/* PASS 2's desktop re-tune, 7 -> 9, and it is scoped for the same reason the
   .band override above is: this variant has a card-face consumer. No app page
   is the only place .band.segmental lands -- tools/fixtures/og-savethedate.html
   draws the link card's full-bleed foot strip with it, and that strip is
   Primitive C's SEMICIRCLE, which the re-tune was never judged against. Nine
   round openings across 1200px is not the seven the shipped card has.

   The base above therefore keeps the card's 7 and this adds the apex's 9,
   matching the base/override direction the .band rules already use.

   :where() IS LOAD-BEARING. A bare html:not(...) prefix would make this
   (0,3,1) and the portrait override below is (0,2,0) -- the media query would
   stop winning and the apex would carry nine 28px scallops at 390px, which is
   exactly what that rule's comment exists to prevent. :where() contributes
   nothing, so this stays (0,2,0) and loses to the later portrait rule on
   source order, as it must. Same lesson as 5f681d8. */
:where(html:not([data-face="card"])) .band.segmental { --cols: 9; }

/* FIVE, NOT FOUR, since 2026-09-22, and odd on purpose: the sun is on the
   photograph's axis (CROP_X_HEAD centres it), and with an even count the
   axis is a pier. Four columns hid the sun behind the pier between openings
   2 and 3 at 390px. Five puts it on opening 3's axis: --a is 50px, the wall
   (8 crown + 35.4 head + 10 shaft) is 53px of a 186px band, 28%, well under
   the ~50% threshold above. The sun lands at y=47.9 (the --sun-y floor
   binds here: the image is only 203px tall against a 186px band), which is
   the springline -- the 12px disc straddles the head and the jamb, whole
   and on axis. If 50px openings read as scallops on a real phone, three
   columns (102px openings, 90px wall, 48%) is the fallback and puts the sun
   mid-head; it was not chosen because 48% is the threshold itself. */
@media (max-width: 1100px) and (orientation: portrait) {
  .band.segmental { --cols: 5; }
}

/* One tall arch, at every width -- the save-the-date page's whole photograph
   is a single opening rather than a colonnade, because the page is one ask
   and one picture. --cols: 1 unconditionally, so the home band's portrait
   override cannot reach it: that rule is (0,1,0) and this is (0,2,0).

   PASS 2 NOTE: this variant renders on the CARD FACE ONLY (no app page
   carries .band.tall), and the pointed override is scoped to
   html:not([data-face="card"]) -- so .band.tall keeps the radial
   construction and the figures below stay as measured. Task 4 does not
   touch them and does not re-derive them.

   Above roughly 1098px viewport width there is no springline, no jamb and no
   full-bleed body at all: --r grows with the container's width even at one
   column, and past that point --spring exceeds the band's own pinned 560px
   cap, so the visible band is only ever the top slice of a circle that never
   reaches its own equator. The practical effect is a "foot" -- where the
   curve meets the band's bottom edge -- that lands slightly off vertical
   rather than flush: computed from --r/--spring/560 (the same values
   tools/fixtures/arcade-tall.html's probes confirm pixel-for-pixel), 5.2
   degrees off vertical at 1200px, 19.2 degrees at 1600px, 26.4 degrees at
   1920px. This was measured and accepted, not missed: no single --gap or
   --shaft percentage closes the gap at every width, because --r grows with
   the container while the band's height is pinned, and the naive fix (a
   much larger --gap) leaves an orphan full-width photograph stripe at the
   foot instead. If a true landing jamb at wide desktop widths ever matters,
   the move is a coupled --gap/--shaft override for this variant, sized
   against a wide viewport specifically -- not a change to the shared
   defaults every other band also uses. */
.band.tall {
  --cols: 1;
  height: min(64svh, 560px);
}
.band.tall .plate { background-image: url('assets/sea-tall.webp'); }

/* ---------- The low arch ----------
   .page-title stood here until 2026-09-21. The four interior pages used to
   put their eyebrow, title and sub on paper under the band, inside a
   shortened copy of the home page's keyline arch; they carry that type
   inside the band now, in white over the water (.band.head .band-title,
   above), and the second arch is gone. Its rules went with it.

   What remains is the shortened arch ITSELF, which is not interior-page
   scenery: app/index.html's overture and the save-the-date both still use
   .frame.low. The min-height below is Primitive D's own (0.7071 * width for
   the pointed head) plus a fixed allowance for the copy underneath.

   The type sizes that used to be justified here went with .page-title. The
   constraint they answered has not: an arch is narrow near its crown, so a
   line long enough to cross the curve pokes out of the hairline. Changing a
   title's words in either remaining consumer can break that; re-run
   tools/check-arch-fit.mjs. */
.frame.low .frame-in {
  min-height: calc(70.7107cqi + 6rem);
  padding: clamp(2.5rem, 11cqi, 4.5rem) clamp(1.2rem, 6cqi, 2.5rem) 1.7rem;
}
/* .sub was scoped to .page-title, which no longer exists -- the interior
   headers moved into the band on 2026-09-21. The Programme page uses this
   class for the starting time under each day's heading, so it is a free
   class now rather than a descendant. .band.head .band-title .sub restates
   the size and sets white over the photograph; this rule carries the
   paper-ground case. */
p.sub {
  margin-top: 0.7rem;
  font-family: var(--font);
  font-size: 1.35rem;
  color: var(--ink-soft);                               /* 5.97 on paper */
}

/* Primitive B -- the free-height arch.
   50cqi is a LENGTH: half the query container's inline size. So the horizontal
   and the vertical radius of each top corner are identical by construction, at
   any height, with no aspect-ratio to maintain. This is the arch that holds
   copy, because copy has no known height.

   THREE THINGS MUST HOLD, and two of them fail silently:

   1  container-type: inline-size MUST be on the PARENT. An element cannot
      query itself, so the grid cell declares it, not the arch. Forget it and
      cqi resolves against the VIEWPORT, which produces a plausible-looking
      wrong render at every width -- the worst kind of bug. Verified by
      tools/check-arcade.mjs against tools/fixtures/arch-b.html, in a real
      three-column grid, which is the only place a container's width genuinely
      differs from the viewport's.
   2  --body MUST be at least --foot. The left and right sides are `height`
      long and their radii sum to 50cqi + --foot; min-height guarantees the
      height is 50cqi + --body, so --body >= --foot keeps f at 1. .door
      clears it by a wide margin (5.5rem against 3px), as did .cell > .card
      (9rem against 16px) while it had consumers.
   3  THE ARCH MUST FILL ITS CONTAINER'S INLINE SIZE. The two top radii sum to
      100cqi, which is the CONTAINER's width. If the arch is narrower than its
      container -- a margin, a max-width, a non-stretch justify-self -- that
      sum exceeds the element's own width, f drops below 1, and the head
      becomes an ellipse. Do not put an inline margin on one of these.

   A single element, not a head plus a body: two elements show a shadow seam
   straight across the springline.

   Consumers set --foot, --body, and their own layout and appearance; they
   never restate min-height or border-radius, which is what keeps the f = 1
   arithmetic living in exactly one place. Today's consumers are .door, the
   footer colonnade's arched link (--foot: 3px, --body: 5.5rem). The
   save-the-date form (--foot: var(--r-md), --body: var(--r-md), in "Save
   the Date" further down) joins via the bare .arch-b class in its markup --
   .rsvp has no name collision,
   but it does have a pre-existing rule of its own (form.rsvp, in "Form"
   further down) that used to set border-radius directly; that declaration
   had to come out, because at (0,1,1) it would have outranked .arch-b's
   (0,1,0) regardless of source order and silently turned the head into an
   ellipse. */
/* .cell > .card was the third selector in the geometry group below until
   2026-09-21. It had two skins across the site's life -- the home page's day
   cards, then Where to Stay's hotel cards -- and both are gone: the home page
   lost its "Two Days of Celebration" section, and the interior-page redesign
   flattened Where to Stay's cards to the plain rectangular .card the Beirut
   Guide uses. Nothing wrapped a card in a .cell any more, and
   tools/check-arch-fit.mjs FAILS on a selector that matches nothing anywhere,
   so the selector came out of that gate's two lists and out of the group here
   together. An arched card comes back by putting `.cell > .card` back in all
   three places at once.
   .cell itself stays: save-the-date's form is the remaining consumer
   (.ask .cell > .rsvp, further down), and .bay is the footer's name for the
   same container. */
.cell,
.bay { container-type: inline-size; }   /* .bay is the footer's name for .cell */

:where(html:not([data-face="card"])) .arch-b,
:where(html:not([data-face="card"])) .door {
  --foot: var(--r-md);        /* bottom radius */
  --body: var(--r-md);        /* total allowance below the springline */
  position: relative;
  /* PASS 2 -- pointed head, Pass 1's construction exactly (see the card's
     .save-overture .frame-in further down). The border-radius head is gone;
     the shape is a two-layer mask: a data-URI SVG pointed head unioned with a
     rectangle body. No mask-composite -- union is the default in both
     syntaxes and writing `add` into the -webkit- syntax is invalid there and
     can come out SUBTRACTIVE. The 1px overlap between layers covers the
     sub-pixel seam across the springline at fractional DPRs. --foot is not
     stated in the mask: the bottom corners of these panels sit below the
     fold of every consumer's own content rules, which set radii or don't
     as before; the mask governs the head and cuts nothing at the foot. */
  --arch-head: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1000 707.107' preserveAspectRatio='none'%3E%3Cpath d='M0 707.107 A750 750 0 0 1 500 0 A750 750 0 0 1 1000 707.107 Z' fill='%23000'/%3E%3C/svg%3E");
  min-height: calc(70.7107cqi + var(--body));
  -webkit-mask-image: var(--arch-head), linear-gradient(#000, #000);
          mask-image: var(--arch-head), linear-gradient(#000, #000);
  -webkit-mask-size: 100% 70.7107cqi, 100% calc(100% - 70.7107cqi + 1px);
          mask-size: 100% 70.7107cqi, 100% calc(100% - 70.7107cqi + 1px);
  -webkit-mask-position: 0 0, 0 calc(70.7107cqi - 1px);
          mask-position: 0 0, 0 calc(70.7107cqi - 1px);
  -webkit-mask-repeat: no-repeat, no-repeat;
          mask-repeat: no-repeat, no-repeat;
}

/* Arches stand on something, and a box-shadow cannot say so here because
   box-shadow follows border-radius and would come out arch-shaped too. This is
   a separate elliptical smudge under the foot. z-index: -1 puts it behind the
   arch's own fill, so it reads as contact rather than as a halo. */
.arch-b::after,
.door::after {
  content: '';
  position: absolute;
  z-index: -1;
  left: 8%; right: 8%; bottom: -14px;
  height: 22px;
  border-radius: 50%;
  background: radial-gradient(ellipse at center, rgba(122,74,86,.16), transparent 70%);
  filter: blur(6px);
}

/* Primitive A -- the fixed-ratio arch. PASS 2 re-derivation: the head is the
   canonical pointed head, h = 0.7071 * W (the old 2/3 was semicircle
   arithmetic: vertical radius = 0.33333 * 1.5W = W/2, horizontal W/2, f = 1).
   A pointed head cannot be expressed as a border-radius at all -- the two
   centred arcs would need a radius pair no corner carries -- so the shape
   moves to the same data-URI mask the bands use (Primitive C's --arch-head),
   scaled to fill by preserveAspectRatio='none' + mask-size 100% 100%. The
   element is ALL head here, unlike Primitive B: there is no body layer and
   no springline seam to cover, which is what makes a one-line primitive
   possible.

   THE OLD ARITHMETIC, KEPT FOR THE RECORD, because its boundary still governs
   the mask too: vertical% = 50 * (W / H) puts
     1/1  -> 50%      3/4  -> 37.5%     2/3  -> 33.3333%
     1/2  -> 25%      1/3  -> 16.6667%
   -- and as soon as H < W/2 a SEMICIRCULAR head needs a vertical percentage
   over 100%, which is geometrically impossible: a "wide low arch" cannot be
   made by stretching one semicircular head, and the attempt silently yields
   an ellipse. The pointed mask escapes the percentage ceiling (the SVG
   stretches), but not the principle: stretch this primitive to H < 0.7071W
   and the curve flattens into something that is no longer the canonical
   two-centred head -- the tangents at the springline go past vertical and
   the opening flares. A wide low RHYTHM is still Primitive C's job, with
   more --cols. */
.arch-a {
  aspect-ratio: 1000 / 707.107;
  -webkit-mask-image: var(--arch-head, url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1000 707.107' preserveAspectRatio='none'%3E%3Cpath d='M0 707.107 A750 750 0 0 1 500 0 A750 750 0 0 1 1000 707.107 Z' fill='%23000'/%3E%3C/svg%3E"));
          mask-image: var(--arch-head, url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1000 707.107' preserveAspectRatio='none'%3E%3Cpath d='M0 707.107 A750 750 0 0 1 500 0 A750 750 0 0 1 1000 707.107 Z' fill='%23000'/%3E%3C/svg%3E"));
  -webkit-mask-size: 100% 100%;
          mask-size: 100% 100%;
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;
}

/* Primitive D -- the keyline arch. An arch drawn as one hairline, open at the
   bottom, with its contents sitting on the springline. No stone, no texture,
   no ornament: the abstraction of the whole idea in a single line.
   Same h = S/sqrt(2) derivation as Primitive B, so the same three rules
   apply -- in particular .frame carries the container-type and .frame-in
   must fill it.

   Both faces use this primitive and each keeps its own construction, because
   any edit here always lands in save-the-date's stylesheet too (sync-shared.sh
   ships this file byte-identical to both). The apex rule below is scoped to
   html:not([data-face="card"]); the card's rules are the
   .overture ... block further down, and the two
   NEVER share a declaration block -- that is what keeps a Pass 2 edit from
   moving save-the-date by a pixel. */
.frame {
  container-type: inline-size;
  max-width: min(460px, 76vw);
  margin-inline: auto;
}
.frame-in {
  position: relative;
  isolation: isolate;
  min-height: calc(70.7107cqi + 8rem);
  padding: clamp(3.5rem, 14cqi, 6rem) clamp(1.2rem, 6cqi, 2.5rem) 2.2rem;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  align-items: center;
   /* PASS 2 -- pointed head, Pass 1's construction exactly (the card's
      .save-overture .frame-in below is the reference). The border-radius head
      is gone; the shape is a two-layer mask: a data-URI SVG pointed head
      unioned with a rectangle body. No mask-composite -- union is the default
      in both syntaxes and writing `add` into the -webkit- syntax is invalid
      there and can come out SUBTRACTIVE. The 1px overlap between layers covers
      the sub-pixel seam across the springline at fractional DPRs. The mask
      governs the HEAD ONLY: it leaves the full box height to the body layer,
      so the mask bounds nothing below the springline -- borders, backgrounds
      and text reach the open bottom edge exactly as they did on the
      semicircle. 70.7107cqi is h = S/sqrt(2), the rise the span sets, stated
      against .frame's container-type so it cannot drift. */
  --arch-head: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1000 707.107' preserveAspectRatio='none'%3E%3Cpath d='M0 707.107 A750 750 0 0 1 500 0 A750 750 0 0 1 1000 707.107 Z' fill='%23000'/%3E%3C/svg%3E");
  -webkit-mask-image: var(--arch-head), linear-gradient(#000, #000);
          mask-image: var(--arch-head), linear-gradient(#000, #000);
  -webkit-mask-size: 100% 70.7107cqi, 100% calc(100% - 70.7107cqi + 1px);
          mask-size: 100% 70.7107cqi, 100% calc(100% - 70.7107cqi + 1px);
  -webkit-mask-position: 0 0, 0 calc(70.7107cqi - 1px);
          mask-position: 0 0, 0 calc(70.7107cqi - 1px);
  -webkit-mask-repeat: no-repeat, no-repeat;
          mask-repeat: no-repeat, no-repeat;
}
/* The hairline is drawn by two children, never by .frame-in itself, exactly
   as on the card. The arc is a stroked SVG path (single continuous stroke,
   arc into jambs, no fill); the jambs are hairline borders on a plain
   element, NOT an svg -- see the save-the-date markup comment for why a
   replaced element cannot fill top+bottom. The arc path is inset d = 2
   viewBox units with stroke-width 2px and the jambs are CENTRED on that same
   2/1000 line -- calc(0.2% - 1px) with a 2px border -- so arc and jambs are
   one centre line and one width, and the mask clips the stroke's outer half
   flush on the window edge. Change d or stroke-width in the markup and the
   jambs' calc() changes with it.

   --line, not the card's rgba(255,255,255,.86): the apex keyline sits on
   paper and over the arcade band, where the card's white would be invisible
   or wrong; the card keeps its own colour in its own scoped rules below. */
.frame-in > .pointed-arch-outline {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 70.7107cqi;
  z-index: 2;
  pointer-events: none;
  overflow: visible;
}
.frame-in > .arch-jambs {
  position: absolute;
  top: calc(70.7107cqi - 1px);
  bottom: 0;
  left: calc(0.2% - 1px);
  right: calc(0.2% - 1px);
  border-left: 2px solid var(--line);
  border-right: 2px solid var(--line);
  z-index: 2;
  pointer-events: none;
}
.pointed-arch-outline path {
  fill: none;
  vector-effect: non-scaling-stroke;
}
.pointed-arch-outline .outer {
  stroke: var(--line);
  stroke-width: 2px;
}
.frame-in > *:not(.pointed-arch-outline):not(.arch-jambs) {
  position: relative;
  z-index: 3;
}

/* Primitive E -- the micro-arch. PASS 2: the pointed head, h = 0.7071 * W
   (the old 2/1 was semicircle arithmetic). A stroked pointed head cannot be
   a border-radius, so the shape moves to a stroked-outline data-URI mask and
   the ink to background: currentColor -- which is the whole point of the
   primitive: it is sized in em, inherits its colour, and reads as
   punctuation. The stroke is stated in VIEWBOX units (6 of 100), so it
   scales with the type like the old 1.5px border never did -- at 1.7em and
   a 16px root the element is 27.2px wide and the stroke renders 1.63px,
   against the border's fixed 1.5px: same weight class, now proportional.
   The ring is not a stroked path: an open stroked pointed head proved
   unpaintable as a mask in both engines (the arcs fail to rasterise at
   punctuation sizes), so the ring is the fill's own two-arc silhouette with
   an evenodd inner hole offset 6 units in -- outer edge lands exactly on the
   element box, same silhouette as --micro-head-fill, bottom open exactly as
   border-bottom: 0 always was. */
.micro-arch {
  display: inline-block;
  width: 1.7em;
  aspect-ratio: 100 / 70.7107;
  background: currentColor;
  -webkit-mask-image: var(--micro-head);
          mask-image: var(--micro-head);
  -webkit-mask-size: 100% 100%;
          mask-size: 100% 100%;
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;
}

/* ---------- The rail ----------
   The floating glass pill is gone. It existed to survive sitting over a
   photograph, and after this redesign nothing sits over the photograph, so it
   solved a problem that no longer exists -- and it was the source of six of the
   twelve over-photo pairs the contrast gate used to carry.

   What replaces it is a full-width paper rail, flush to the top, IN FLOW.
   Quiet, horizontal, structural: the lintel the arches carry. Because it is in
   flow, a French bar wrapping to a third line at 390px pushes the content down
   instead of covering it, which is the bug the deleted --nav-h machinery
   existed to patch. */
/* The language toggle is pinned to the LAST column rather than left to
   auto-placement, and that is not cosmetic tidying. Auto-placement puts each
   child in the next free cell, so the toggle sits in column 3 only while a
   nav is there to take column 2 -- and the save-the-date page has no nav,
   because it is sent before the website it would link to is ready. With two
   children the toggle landed in the middle column: measured at 1200px wide,
   its right edge sat 531px short of the content edge, and 651px at 1440px.
   Below 761px the media query further down already placed it explicitly, so
   the break was invisible at every width a phone uses.

   -2 is the line that starts the last column, whatever the count, so this
   holds for both the three-column rail and the two-column one. The five app/
   pages are unaffected: column 3 IS line -2 there, which is where
   auto-placement was already putting it. */
.rail .lang-toggle { grid-column: 3; grid-row: 1; }

header.rail {
  position: sticky;
  top: 0;
  z-index: 50;
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: 1rem;
  padding: .55rem clamp(1rem, 4vw, 2.4rem);
  row-gap: .35rem;
  /* OPAQUE --blush, the form panel's pink, as the card has had since
     2026-08-25: a translucent fill is a colour PLUS whatever scrolls under
     it, and the photograph in the window scrolls under this. No backdrop
     filter: it cost a compositing layer to blur a backdrop nothing can see. */
  background: var(--blush);
  border-bottom: 1px solid var(--line);
  transition: box-shadow .3s;
}
/* Set by motion.js once the page has scrolled past 24px. Shadow only: nothing
   about the rail's box changes, so this cannot shift layout. */
header.rail[data-scrolled] { box-shadow: var(--sh-2); }

/* The mark on the rail's own centre line: column 2 of `1fr auto 1fr`, with
   the two equal side tracks either side of it and the language toggle in
   the third. The nav takes a row of its own beneath, at every width -- the
   same shape whether a page carries a nav (the site) or not (the card). */
.rail .brand { grid-column: 2; grid-row: 1; justify-self: center; display: flex; align-items: center; text-decoration: none; }
.rail .brand img {
  display: block;
  height: 3.1rem;
  width: auto;
  /* The supplied PNG is white ink on transparency. The rail is deliberately
     light, so invert only the mark at render time rather than editing the
     source artwork or leaving it invisible. */
  filter: invert(1);
}

.rail nav {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: clamp(.9rem, 2.4vw, 1.6rem);
  grid-column: 1 / -1;
  grid-row: 2;
}
.rail .lang-toggle { justify-self: end; }

.rail nav a {
  position: relative;
  text-decoration: none;
  color: var(--ink);
  font-size: 1.05rem;
  letter-spacing: .01em;
  text-transform: none;
  padding: .15rem 0 .8rem;
}
/* The current page is marked by colour, an arch cap AND aria-current. Never
   colour alone. */
.rail nav a[aria-current="page"] { color: var(--rose-deep); }

/* Primitive E, restated rather than composed: this is a pseudo-element, so it
   cannot carry the .micro-arch class. The geometry is identical and the
   comment on .micro-arch is the explanation for both -- pointed head,
   currentColor through the mask, open bottom.

   The cap sits UNDER the text, not over it: measured ink scans (3x DPR, all
   five labels, both languages) put descenders up to 7.7px below the baseline
   while the old bottom: .25rem put the 1.202em head's crown 11-18px above the
   link's bottom padding -- through the 'g' of Programme and the 'y' of Guide
   de Beyrouth -- leaving only a squashed bump visible. bottom: -0.55rem drops
   the whole head into the .8rem bottom padding, so the two-centred crown and
   the ogee shoulders read at header scale the way every other arch on the
   site does; the springline (the open bottom edge) lands .25rem up from the
   padding's foot, keeping the cap inside the rail at both breakpoints. */
.rail nav a::after {
  content: '';
  position: absolute;
  left: 50%;
  bottom: -0.55rem;
  translate: -50% 0;
  width: 1.7em;
  aspect-ratio: 100 / 70.7107;
  background: currentColor;
  -webkit-mask-image: var(--micro-head);
          mask-image: var(--micro-head);
  -webkit-mask-size: 100% 100%;
          mask-size: 100% 100%;
  -webkit-mask-repeat: no-repeat;
          mask-repeat: no-repeat;
  opacity: 0;
  transition: opacity .25s, translate .25s;
}
.rail nav a[aria-current="page"]::after,
.rail nav a:hover::after { opacity: 1; }

@media (max-width: 760px) {
  header.rail { padding-inline: 1rem; }
  .rail .brand img { height: 2.5rem; }
  /* Five items do not fit one row at 390px in this face, so the nav wraps;
     no row gap and a shorter cap allowance keep the two rows one block. */
  .rail nav   { row-gap: 0; column-gap: .9rem; }
  .rail nav a { padding: 0 0 .65rem; font-size: 1rem; }
}

.lang-toggle {
  font-size: .95rem;
  letter-spacing: .01em;
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
}
.lang-toggle button {
  background: none;
  border: none;
  padding: 0 0.1rem;
  font: inherit;
  letter-spacing: inherit;
  color: var(--ink);
  text-transform: none;
  opacity: 0.75;
  cursor: pointer;
}
.lang-toggle button.on { opacity: 1; color: var(--rose-deep); font-weight: 400; }
.lang-toggle .sep { color: var(--ink); opacity: 0.5; }

/* ---------- Home: the overture ----------
   The page announces itself on plain paper, then shows you the sea: no photo
   behind this, no scrim, no button competing for the eye. The keyline arch
   (Primitive D) holds the four lines bottom-aligned on its springline. At
   1440x900 the band's top sits at 593px (measured, not the spec's ~470px
   guess), comfortably inside the first screen, so the arch crowns render on
   load without scrolling -- verified by the Playwright check in the Phase 3
   plan, not assumed. */
section.overture {
  padding: clamp(3rem, 8vh, 6rem) 1.5rem clamp(2rem, 5vh, 3.5rem);
  text-align: center;
}
.overture .eyebrow,
.overture .place { color: var(--ink); }
.overture h1 { margin: .6rem 0 0; }
.overture .date { margin-top: .9rem; }
.overture .place { margin-top: .6rem; }

/* ---------- Home: the graft ----------
   The paper sheet rises 56px over the bottom of the band and runs to the
   footer, which is why its radius is top-only. This is Primitive B's move at
   page scale, and it is what proves the layers are separate: the water
   parallaxes behind a sheet that is demonstrably in front of it.

   position: relative is LOAD-BEARING, not tidying. .plate is absolutely
   positioned, and a positioned element paints above the in-flow background of
   every later sibling -- without this line the photograph paints straight
   over the sheet. Positioning the sheet (z-index auto) returns the contest to
   tree order, which the sheet, coming later in the document, wins. */
.graft {
  /* This was a paper sheet rising 56px over the photograph band, with an
     upward shadow to prove the layers were separate. The band went on
     2026-09-06 -- the photograph is in the window now -- so the sheet is
     just the page. The element stays as the sections' wrapper. */
  position: relative;
}
.graft section { text-align: center; }
.graft section > p { max-width: 56ch; margin-inline: auto; }

/* The arch's keystone reduced to a dot: a 9px blush disc ringed by a
   hairline. The ring is a box-shadow, not a border -- a border inside a 9px
   border-box would shrink the disc it is supposed to ring. Decorative only
   (aria-hidden in the markup), so the hairline's sub-3:1 contrast is fine. */
.keystone {
  width: 9px;
  height: 9px;
  margin: 0 auto 1.4rem;
  border-radius: 50%;
  background: var(--blush);
  box-shadow: 0 0 0 1px var(--line-strong);
}
/* h2's 3.4rem top margin separates it from a block above, which the keystone
   already is -- without this the sections open on a void. */
.keystone + h2 { margin-top: 0; }

/* ---------- Background photo sources ----------
   Three crops, because three different shapes use these photographs, and the
   old single tall crop was wrong for all of them: the head crop was being
   handed 3478px of image to fill a strip about 380px tall. That strip was
   .page-head until Phase 4; its consumer is now the shallow band, .band.head
   .plate, at the same 380px.

   Portrait phones get the tall crop, which is the only place a tall image is
   actually the right shape. Everything else gets a crop cut to its own box.

   One format, WebP, named by a plain url(). It is worth saying why, because
   this block previously carried a JPEG baseline plus an image-set() upgrade
   inside @supports, and that arrangement was wrong in a way that took two
   rounds to see.

   Declaring the head crop as a custom property mattered while .page-head
   existed, because a custom property holds whatever tokens it is given,
   including tokens the browser cannot parse, so a browser without
   image-set() accepted --head-img happily and only failed later, where it
   was substituted into a shorthand:

     background: linear-gradient(...), var(--head-img) center 20% / cover ...;

   That is one shorthand. A var() expanding to something unparseable makes the
   whole declaration invalid at computed-value time, so `background` computes to
   its initial value and the gradient scrim disappears along with the
   photograph, leaving white type on the pale body. Not a missing picture, an
   unreadable page, on the LCP element of every page here.

   A JPEG fallback was the wrong cure for that, because it answers on the wrong
   axis. The browsers that cannot parse image-set() with type() are Safari 14 to
   16, Chrome below 113 and Firefox below 113. Every one of them decodes WebP
   perfectly well. So the JPEG was never protecting a browser that would
   otherwise get no image, it was protecting a browser against a CSS syntax gap
   using an image format remedy, and it doubled the asset set to do it. It also
   made the <link rel=preload> in every page head impossible to get right:
   type="image/webp" gates on decode support, and there is no media or type
   feature that expresses "parses image-set()", so those browsers preloaded the
   WebP at high priority and then painted the JPEG, fetching both.

   A plain url() is parseable everywhere, so substitution can never produce an
   invalid declaration. This is strictly safer than either previous version, not
   a return to the one that broke.

   Phase 4 retired .page-head and the custom property that fed it. Nothing
   left in this file layers a gradient scrim over a photograph, so there is no
   shorthand left for a bad substitution to invalidate: .plate names
   sea-hero.webp / sea-tall.webp directly with a plain url(), as it always
   did, and .band.head .plate and .band.segmental .plate both name
   sea-head.webp the same way -- the same crop, two consumers -- and nothing
   in this file does otherwise. */

/* ---------- Save the Date (single-screen page) ----------
   The names sit in the same keyline arch (Primitive D) the home page's
   overture uses, shortened with .frame.low because there is no arcade band
   above it here to justify the taller crown. The ask below is plain paper --
   note, then the form as a free-height arch (Primitive B) tinted
   --blush-pale -- and Task 2's band closes the page beneath the form rather
   than sitting between the names and the ask, which is what keeps the form
   on the first screen (redesign-v2-phase-5 plan, §5). Nothing here is over an
   image, so every pair on this page is flat and fully determined -- see the
   FLAT list in tools/check-contrast.mjs. */
/* An iPhone 15 in Safari with the address bar and toolbar showing hands a
   page roughly 745px of height, not the 844px of its own screen -- see
   tools/check-first-screen.mjs's header comment for the two cases it checks
   and why 745 is the one that decides whether a guest sees the form. The
   padding below is sized against that figure.

   CORRECTION (2026-09-21): this comment used to claim ":has(.frame.low)
   reaches only this page's overture -- the home page's own wraps a bare
   .frame, never .frame.low, so this selector cannot touch it". That is
   FALSE. app/index.html's overture is `<div class="frame low">`, so this
   rule applies to the home page's overture too, and always has. The
   selector is left exactly as it is on purpose.

   DO NOT "FIX" THIS BY RESCOPING THE SELECTOR. The home overture's padding
   is an input to .plant-canopy's clearance constant: the canopy sits in
   normal flow below this section, so narrowing this rule to the
   save-the-date would shorten the home overture, raise the canopy's static
   top, and silently invalidate the 790px in .plant-canopy's
   max(0px, calc(100svh - 780px)) -- putting the canopy back inside the
   first viewport on a phone with nothing in the diff to suggest why. If
   this rule is ever genuinely rescoped, re-derive that constant in the same
   pass (see .plant-canopy's HOW TO RE-DERIVE block) and re-run
   tools/check-plants.mjs.

   The cost of reaching the save-the-date this way rather than a page-level
   class is the
   same one the Beirut Guide section already pays two screens up: :has()
   takes the specificity of its argument, so this rule is (0,3,1) against
   the bare rule's (0,1,1) and wins on rank, not on appearing later. */
section.overture:has(.frame.low) {
  padding: clamp(1.5rem, 4vh, 2.75rem) 1.5rem clamp(1rem, 3vh, 1.75rem);
}

/* Shorter still than Phase 4's own .frame.low. That rule used to be shared
   with the four interior page-titles, and this comment used to explain why
   scoping to .overture could not disturb them; since 2026-09-21 there are no
   page-titles, so .frame.low's only consumers are this overture and the
   save-the-date, and the scoping question is moot. min-height's 6rem constant left roughly 51px of blank crown
   above the eyebrow -- content is bottom-anchored inside this box (see
   .frame-in's justify-content: flex-end, above), so that blank sat between
   the arc and the text rather than under it. 4rem removes most of it, at a
   RECIPE A cost measured at all three checked widths (Chromium; WebKit
   agrees within 0.03px at every figure here): worst corner -17.77px
   against the previous -32.17px at 390px, -21.07px against -35.38px at
   1200px, -57.94px against -72.06px at 760px -- roughly 14px spent
   everywhere, not just at 390px, because .frame's width caps at
   min(560px, 86vw) well before 760px, so 50cqi and the margin it sets are
   already flat across the two wider cases either side of this change.

   "Every case stays comfortably inside the RECIPE A floor" is what this
   comment used to say next, and it was a claim about three sampled widths
   rather than about every case. Sweeping instead of sampling
   (tools/check-arch-fit.mjs, 320 to 1440 in 8px steps) finds bands nobody had
   looked at, and this arch bottom-anchors its contents, so it is at its
   most dangerous where the two names fit on ONE line: the h1 is then half as
   tall, the box is that much shorter, and everything in it sits that much
   closer to the arc. The page crosses that wrap twice. Below roughly 497px
   the h1's clamp is pinned on its 3.2rem floor while .frame's 86vw keeps
   narrowing; above 651px .frame is pinned at its 560px ceiling while the
   clamp's 10.3vw term keeps growing. The worst line box corner reaches
   +14.97px at a 396px viewport and +15.28px at 819px, both outside RECIPE A's
   tolerance-adjusted boundary.

   No ink crosses anything at either: the worst GLYPH measures 2.76px inside
   the drawn hairline at 396px (2.70px in WebKit) and 10.74px inside at 819px,
   and the sweep's ink stage is clean at every width in both engines. So the
   arch is left as it is and the claim is corrected rather than the geometry,
   because what was wrong here was the measurement, not the composition. The
   figures move with the display face. These are Sacramento's, which this page
   set from 2026-08-10 to 2026-08-12 and no longer does; they were NOT
   re-derived for Cormorant Garamond, because the sweep is what actually decides this
   arch and the sweep is clean. Treat the numbers as an illustration of the
   argument rather than as current measurements, and run
   tools/check-arch-fit.mjs rather than reasoning from them.

   Cutting further than 4rem starts eating the clamp(...) padding-top floor
   itself rather than the blank above it, which is the point past which even
   the ink stops clearing. */
.overture .frame.low .frame-in {
  /* Extra crown clearance separates the arch curve from the announcement.

     50cqi, still: this selector has TWO consumers, not one. The save-the-date
     page is the obvious one; the other is tools/fixtures/og-savethedate.html,
     which builds the link card and carries a plain section.overture. That
     fixture's arch is Primitive D's semicircle and its arcade is Primitive C's,
     both of which stay round until Pass 2, so raising the head here would grow
     the card's box while leaving its head a semicircle -- a taller arch with
     the same crown, which reads as a mistake rather than a stage.

     The pointed rise is applied to the save-the-date's own arch just below,
     where it cannot reach the fixture. When Pass 2 points Primitive D, the two
     collapse back into this one rule. */
  min-height: calc(50cqi + 5.25rem);
}

.ask {
  padding: 0 1.5rem clamp(2.5rem, 6vh, 4rem);
  text-align: center;
}
/* -0.75rem, not 0: .ask carries no top padding and no border, so a negative
   top margin on its first child collapses straight through and pulls the
   whole section up by the same 12px, not just the note inside it -- the
   arch above ends on an open bottom edge with nothing drawn to clip
   against, so there is no hairline for the collapsed margin to overlap.
   Every pixel here is a pixel the form does not need to find on a real
   phone (see the budget comment on .ask .cell > .rsvp, below). The day
   .ask gains a top padding or border of its own, the collapse stops and
   this stops buying anything -- re-measure rather than assume it still
   does. */
.ask-note {
  max-width: 26rem;
  margin: .25rem auto 0;
  font-family: var(--font);
  font-size: 1.3rem;
  line-height: 1.4;
  color: var(--ink);
}
.ask .noscript-note { margin: 1.4rem auto 0; }

/* The width limit lives on .cell, never on the arch: .ask .cell > .rsvp
   below fills 100% of this box, so its two top radii keep summing to its own
   width and f stays 1. Putting 30rem on the form instead would only matter
   once the form's own content forced it wider than 30rem, which it does
   not -- but the day it did, the head would scale into an ellipse with
   nothing here to say why. */
/* 1.05rem, down from 1.4rem. Bought back from the first-screen budget on
   2026-08-11, when the page moved its body copy into the script face and the
   note, the label and the field all grew above the element that budget
   measures. This is the cheapest 5.6px on the page: it is blank space
   between a paragraph and a panel, with no arch geometry and no line box
   depending on it. The expensive ones -- section.overture's padding, the
   names arch's min-height, the form panel's own .65 crown clearance -- were
   all left alone. */
.ask .cell {
  max-width: 30rem;
  margin: 1.5rem auto 0;
}
.ask .cell > .rsvp {
  background: var(--blush);
  text-align: left;
  border-radius: 0;
  /* This is now a plain rectangular form, not a second arch. */
  padding: 1.5rem;
}
form.rsvp.compact .field:last-of-type { margin-bottom: 1.1rem; }

/* HISTORY. The save-the-date's display face was a monoline script, and only
   this page's; app/ kept Instrument Serif until 2026-09-06, when the site
   took the card's face. The reasoning below is what the split was for --
   the card arrives as a link on a phone and is looked at once, the website is
   a reference that gets opened repeatedly -- and a connected script earns its
   keep in the first case and not the second.

   html[data-face="card"] rather than a body class, and the reason is not
   that a body class could not work -- it could, since every consumer of
   --display is a descendant of body. It is that --display is a root-level
   token, so the thing that changes it belongs on the same element the
   default is declared on; a data attribute rather than a class because this
   is a page mode, not a style hook, and it cannot be silently added to by a
   later classList call. The attribute is on the root element in
   save-the-date/index.html and nowhere else. The face is
   named on --display alone, so everything already wired to that token follows
   without being listed here: h1, h2, h3, .serif, .script, .date and .numeral
   (see the type scale near the top of this file).

   ON 2026-08-11 IT STOPPED BEING A SPLIT. This page now sets --font to the
   same value, so body copy, the form's labels, its inputs, its button, the
   language toggle and the footer are all in the script too: one face on the
   page, the logo mark aside, which is what was asked for. The paragraph that
   stood here said the opposite in some detail, and every consequence it drew
   from the split -- that UI type stays readable at 14px, that the contrast
   rows could assume it, that nothing but four display lines could ever
   silently fall back -- stopped holding at the same moment. What replaced
   each of them is in the html[data-face="card"] block below.

   The @font-face sits outside the attribute selector because an @-rule cannot
   be nested in one. It costs app/ nothing: app/style.css is this file byte for
   byte, so the declaration ships there too, but a font is fetched only when a
   glyph actually needs it and nothing on app/ names Cormorant Garamond. Verified the
   same way build-images.sh verifies its unused crops -- by recording every
   request the six pages make.

   EVERYTHING FROM HERE TO THE END OF THIS COMMENT IS HISTORY, kept because it
   is the record of how the sizes below were arrived at and why they are not
   simply the site's own scale. The face it argues about -- Sacramento, a
   connected script -- was replaced by EB Garamond on 2026-08-11 and by
   Cormorant Garamond later the same day, and the sizes were re-derived
   against each in turn (see the block above the .ask-note rule). Read it for
   the method, not for the numbers. THE METHOD IS THE DURABLE PART: this page
   has now been set in four faces, and every one of them needed the sizes
   bought again rather than carried.

   The three sizing rules below existed because the two faces could not be
   matched on one number, and the reason was not the obvious one. Read off the
   outlines (fontTools): the CAPITALS already agreed -- Instrument Serif's J
   tops at 0.720 em and its G at 0.730, Sacramento's at 0.783 and 0.722. What
   differed was the lowercase. Instrument Serif's x-height is 0.510 em;
   Sacramento's was 0.306, two fifths smaller, which is why the same font-size
   read as a caption. Matching cap heights would have meant a factor of about
   1.0 and changed nothing; matching x-heights would have meant 1.67 and given
   capitals half again too big. A Garamond's x-height is what removed the
   dilemma rather than solving it: EB Garamond measures 0.405 and Cormorant
   Garamond, which ships here now, 0.386 -- both close enough to the system
   stack's ~0.52 that one factor serves the whole page.

   So the factor is not derived from a metric at all -- it is bought from the
   first-screen budget, which is the constraint that actually binds this page.
   Measured through tools/check-first-screen.mjs at its tightest of sixteen
   cases (390x745, French). The comparison has to hold the copy still as well
   as the face, or it compares two different pages: with THIS copy and the
   serif face -- set the root's data-face back and re-run -- the name field
   lands at 677px of 745, and the other three cases at 649 (745 EN), 684
   (844 FR) and 656 (844 EN). Every 1rem added to the names costs about 34px
   there, two lines at 1.06, and the eyebrow, the place and the date add
   their own on top.

   The three values below (3.2rem / 1.1rem / 1.55rem) landed the field at
   708px, 37px of headroom against the serif's 68px. The design specimen's
   values (3.8 / 1.35 / 1.75) landed it at 743px: 2px of headroom, which
   passes the gate and is not a page anyone should ship. The 35px between
   those two is what the smaller face costs in presence, and it is the trade
   this page makes deliberately.

   Those three figures are the composition as it stood before 2026-08-11 --
   display type in the script, body copy in the system face. They are kept
   because the argument they carry is about the two FACES and is still the
   argument; the page they were measured on no longer exists. As it ships now
   the field lands at 719px, 26px of headroom, and the 11px between the two
   is what the body copy's own move into the script cost after the sizes
   below were re-bought against it. Re-measure, do not interpolate.

   1.06 rather than the h1 rule's 1.02. THE ARGUMENT THAT PUT IT THERE IS
   GONE, and the value is kept on different grounds -- worth spelling out,
   because the original reasoning is specific and completely inapplicable now.

   It was about a connected script's descenders. Sacramento's are deep (J to
   -0.342 em, G to -0.376, against Instrument Serif's J at -0.215), so the two
   name lines interleaved vertically at every line-height tried, and 1.06
   bought 4.09px more room for the J's tail to pass Geoffrey's ascenders. EB
   Garamond is an old-style serif with ordinary descenders and the two lines
   do not come near each other, and Cormorant Garamond is the same kind of
   face. Measured on the page as it ships, the heart sits between the two
   names with 4.25px of clear paper above it and 2.25px below at 390px, and
   6.75 / 5.75 at 1200 -- ink to ink, read off a 4x raster inside the heart's
   own x-window rather than off the line boxes.

   So 1.06 is now simply a display line-height for two short lines of large
   type -- tight, which is what display type wants, and with no collision for
   it to prevent. Nothing here would break at 1.02 or 1.10; the reason to
   leave it alone is that the first-screen budget and the arch fit were both
   measured against it, not that the geometry demands it.

   Those gap figures are Cormorant Garamond's, measured 2026-08-11. The
   heart still overlaps both name boxes, which is what its negative margins
   buy and why the extra line costs the budget so little -- and the margins
   are re-derived per face, not carried: see html[data-face="card"]
   h1 .hero-heart below for the pair this face actually ships.
   THAT SENTENCE NAMED h1 .heart until 2026-08-25, and the rule it pointed at
   is no longer in this file at all: 7594ed2 made the lockup heart a drawn
   PNG and rewrote the rule to name .hero-heart. The stroked one it replaced
   -- .46em on a 3.312 stroke, margins -.123em / -.05em -- now lives in
   tools/fixtures/og-savethedate.html, which is its only remaining consumer
   and which spent that whole period unable to build because of it.
   AND h1 .hero-heart HAS NOW GONE THE SAME WAY, later on 2026-08-25: the
   arch's lockup is one drawn monogram, so there is no heart between the
   names to give margins to and no per-face pair left to re-derive. The
   paragraph above is kept as the record of why the names stack, which
   tools/fixtures/og-savethedate.html still depends on; h1 .monogram is what
   sizes the arch now. */
/* The @font-face for Cormorant Garamond is at the top of this file: since
   2026-09-06 it is the site's one face, not the card's. The fallbacks are old-style serifs that ship almost everywhere, so a
   machine without the file still gets something of the right colour and
   proportion rather than a different kind of face. That matters more here
   than it did under the script, whose named fallbacks were macOS-only and
   whose generic (`cursive`) could be anything at all. It is still only a
   frame-long swap: the file is preloaded, and tools/check-arch-fit.mjs
   fails the build if the face actually drawing is not this one. */
/* The token block that stood here -- html[data-face="card"] { --display,
   --font, --card-paper, --card-paper-deep, --card-blush, --card-blush-deep,
   --card-submit, --card-ink, --heart } -- became :root on 2026-09-06. The
   card and the site are one face now; see the spec. */
/* The Save the Date arch is deliberately narrower and pointed: its ogival
   silhouette is taken from the invitation reference, not the site's rounded
   keyline-arch primitive. Pass 2 scoped the base Primitive D rules above to
   html:not([data-face="card"]) so the apex's pointed construction cannot
   reach this face; the declarations this face used to inherit from those
   unscoped base rules -- .frame's container-type and margin-inline, and
   .frame-in's layout box (min-height, padding, flex) -- are restated here so
   the card's own rules below remain a self-contained copy, byte-identical to
   what shipped before this pass. The card's pointed head, mask, keyline and
   jambs live in this block too and were never touched. */
/* Three rules stood here -- html[data-face="card"] .frame, .frame-in and
   .frame.low .frame-in -- restating the semicircular head for the OG-card
   fixture (tools/fixtures/og-savethedate.html), which carries a plain
   section.overture. They went on 2026-09-06: with the html type selector
   they were (0,2,1), and outranked the site's own .overture .frame (0,2,0)
   the moment the window's rules were renamed to it, widening the card's
   frame to 560px. The fixture renders the pointed window now, like the page.
   The base .frame / .frame-in primitives below are unscoped and the window's
   .overture rules override them by specificity. */
.overture .frame {
  container-type: inline-size;
  max-width: min(460px, 76vw);
  margin-inline: auto;
}
/* The only remaining arch on the save-the-date page is a view onto the sea
   photograph that previously occupied the third band. The shade preserves
   legible white type over both the bright horizon and dark water.
   The flex column below is the copy of the base Primitive D layout box this
   face used to inherit before Pass 2 scoped that base to
   html:not([data-face="card"]). Only the flex declarations are restated:
   padding and min-height were never inherited from the base -- the
   .overture .frame.low .frame-in rule above and this block's own
   .frame.low override further down set those, and both still reach this
   face unscoped. The pointed head, mask, keyline and jambs that follow are
   untouched. */
.overture .frame-in {
  --heart: #fff;
  position: relative;
  isolation: isolate;
  border: 0;
  border-radius: 0;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  align-items: center;
   /* Technique 1 of the pointed-arch spec, and Primitive C's composition
      exactly: a head layer and a body rectangle, UNIONED. mask-composite is
      DELIBERATELY ABSENT -- it defaults to union in both syntaxes, and writing
      `add` into the -webkit- syntax is invalid there and can produce a
      SUBTRACTIVE mask: an arch-shaped hole that looks deliberate in a
      screenshot. Do not add it. (The arcade's comment above says the same
      thing, and tools/check-arcade.mjs exists largely to catch it.)

      The 1px overlap between the two layers is Primitive C's too: at
      fractional device pixel ratios two exactly abutting mask layers leave a
      sub-pixel line straight across the picture.

      70.7107cqi is h = S/sqrt(2), the rise the span sets. .frame carries the
      container-type and .frame-in fills it (border: 0, just above), so the
      head's box is exactly 1000:707.107 and the curve is the curve. This is
      what the url(#save-arch-clip) bezier could not do: clipPathUnits was
      objectBoundingBox, so the head was a fraction of a CONTENT-DRIVEN height
      and adding a line of type silently reshaped the arch.

      Failure mode: every value here sits in a mask-* property, so a parse
      failure degrades to an unmasked rectangle. The photograph and the type
      are still there; the arch is not. Nothing disappears.

      CSP: mask images fall under img-src, and shared/_headers already reads
      `img-src 'self' data:`. No header change. */
  --arch-head: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1000 707.107' preserveAspectRatio='none'%3E%3Cpath d='M0 707.107 A750 750 0 0 1 500 0 A750 750 0 0 1 1000 707.107 Z' fill='%23000'/%3E%3C/svg%3E");
  -webkit-mask-image: var(--arch-head), linear-gradient(#000, #000);
          mask-image: var(--arch-head), linear-gradient(#000, #000);
  -webkit-mask-size: 100% 70.7107cqi, 100% calc(100% - 70.7107cqi + 1px);
          mask-size: 100% 70.7107cqi, 100% calc(100% - 70.7107cqi + 1px);
  -webkit-mask-position: 0 0, 0 calc(70.7107cqi - 1px);
          mask-position: 0 0, 0 calc(70.7107cqi - 1px);
  -webkit-mask-repeat: no-repeat, no-repeat;
          mask-repeat: no-repeat, no-repeat;
  /* 2026-08-29: THE SCRIM IS GONE, and that is a decision rather than an
     oversight -- it was removed on request, after the cost below was measured
     and put in front of the person asking.

     It was linear-gradient(rgba(18, 29, 35, .54), rgba(18, 29, 35, .38)) over
     this photograph, and its job was to hold the white type. Without it the
     arch gains 28% of mean luminance (117.2 -> 150.4 at 560px, 113.6 -> 148.0
     at 390, 116.5 -> 150.1 at 1200) and the sea reads as sea.

     What it costs, measured in Chromium with the animations frozen and the
     type hidden so the sample is the true ground, worst (brightest) pixel
     under each line, against #fff:

       eyebrow  3.10 -> 1.03     date   2.92 -> 1.15
       monogram 2.77 -> 1.03     place  3.22 -> 1.37

     1.03:1 is not low contrast, it is white on white wherever the sun's
     glitter falls behind a glyph. The words survive elsewhere -- the footer's
     script line, the <title>, og:title, and the .sr span a screen reader gets
     -- so nothing is unreachable, but on the card itself the type is carried
     by its text-shadow alone.

     IF THIS EVER NEEDS UNDOING, put the gradient back as the first background
     layer; nothing else here changed. Raising the type's text-shadow is the
     cheaper half-measure and does not need the scrim back. */
  background: url('assets/sea-tall.webp') center / cover no-repeat;
  box-shadow:
    0 1px 0 rgba(255, 255, 255, .46),
    0 16px 28px rgba(7, 15, 20, .24),
    inset 0 2px 0 rgba(255, 255, 255, .42),
    inset 0 -24px 30px rgba(5, 12, 17, .24);
}
/* A second inset keyline and a diagonal highlight give the sea arch a shallow,
   tactile edge without turning the invitation into a glossy card. */
.overture .frame-in::before,
.overture .frame-in::after {
  content: "";
  position: absolute;
  pointer-events: none;
}
.overture .frame-in::before {
   /* A band of light crossing the water, the way sun tracks across a real
      sea: slower than the shimmer and wider in its travel, on its own
      pseudo-element so the photograph underneath never moves with it. */
   inset: 0;
   z-index: 1;
   background: linear-gradient(115deg,
               transparent 28%,
               rgba(255, 255, 255, .12) 46%,
               rgba(255, 255, 255, .04) 54%,
               transparent 72%);
}
.overture .frame-in::after {
  inset: 0;
  z-index: 0;
  border-radius: inherit;
  background: linear-gradient(135deg, rgba(255, 255, 255, .16), transparent 38%, rgba(0, 0, 0, .16));
}
.overture .frame-in > * {
  position: relative;
  z-index: 3;
}
/* 70.7107cqi, not 50cqi: h = S/sqrt(2) is the pointed head's rise where 50cqi
   was the semicircle's. The 5.25rem allowance below the springing is unchanged
   and deliberately so -- Pass 1 changes the head, not the allowance.

   It lives here rather than on `.overture .frame.low .frame-in` above so it
   cannot reach the OG-card fixture, which shares that selector and is still
   round. Both .frame and .low are in the selector on purpose: (0,5,1) beats the
   (0,4,0) of the rule it overrides, and dropping either loses the cascade.

   Non-binding at both ends of the sweep -- the box's content height already
   exceeds it on phones and above roughly 824px, and it binds at 600px alone,
   which is where the spec measured this change's whole vertical cost. It stays
   anyway, because it is what GUARANTEES the box is never shorter than its own
   head. Today that holds by accident of how long the names are, and an accident
   is not a geometry. */
.overture .frame.low .frame-in {
  min-height: calc(70.7107cqi + 5.25rem);
  /* padding restated from the base .frame.low .frame-in rule above, which
     Pass 2 scoped to html:not([data-face="card"]). This face's head sits on
     the ogival silhouette, not the pointed one, so the padding that sets the
     save-the-date arch's crown clearance stays exactly as it was. */
  padding: clamp(2.5rem, 11cqi, 4.5rem) clamp(1.2rem, 6cqi, 2.5rem) 1.7rem;
}
/* The head's own box, not the element's. height is 70.7107cqi -- the rise the
   span sets -- so this box's aspect ratio is exactly the viewBox's
   1000:707.107 and preserveAspectRatio="none" is a no-op rather than a
   distortion. That is the correctness argument for the whole change: the shape
   can no longer be altered by adding a line of type to the box below it, which
   the objectBoundingBox clipPath this replaces silently allowed. */
.overture .frame-in > .pointed-arch-outline {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 70.7107cqi;
  z-index: 2;
  pointer-events: none;
  overflow: visible;
}
/* The two straight sides, from the springing down to the open bottom edge.
   Borders on a plain element, NOT an SVG: an <svg> is a replaced element, so
   top + bottom with height: auto is over-constrained -- it sizes from the
   viewBox and ignores bottom, which silently left the jambs stopping partway
   down the window.

   -1px on top so the straight run overlaps the arc's last pixel instead of
   abutting it; at fractional device pixel ratios an exact join shows as a nick
   at the springing.

   THE JAMB KEYLINE IS CENTRED ON 0.2%, NOT STARTED AT IT, and the -1px in
   both calcs is the whole of that. This rule read `left: 0.2%` with a 1px
   border until 2026-08-25, on the stated argument that "the jambs' 1px
   borders are drawn wholly inside the mask and need no such allowance". That
   argument was wrong at the outer edge and the render said so. A border is
   painted INWARD from its border box, so starting the box on the path's own
   inset line left everything OUTSIDE that line -- 0.92px of it at 1440px --
   as bare photograph against the paper. Probed at 8x on the shipped page
   (deviceScaleFactor 8, sampling across the left edge of .frame-in), the
   springline read white 0.00-1.88 ABOVE it and photo 0.00-0.88, white
   1.00-1.88, photo 2.00+ BELOW it: one pixel of sea outside the keyline,
   running the full height of both jambs. That is what was reported as "the
   sea is bleeding into the white in form of 2 lines on the bottom sides of
   the arch", and it is one defect, not two.

   The arc does not have the problem because a stroke is centred on its path:
   d = 2 with stroke-width 2px spans 1px either side of 2/1000, so its outer
   half hangs outside the silhouette and .frame-in's mask cuts it. Backing the
   border box out by exactly the border's own half-width puts the jamb on the
   same footing -- same centre line, same 2px, same outer half clipped by the
   same mask -- so the two now meet as one continuous line of one width.

   The 1px is the border's half-width and MUST move with it; 0.2% is the
   path's inset and must keep matching the `d` in save-the-date/index.html.
   They are different numbers doing different jobs, which is why one is a
   percentage and one is not: at 390px .frame is 296px wide, so 0.2% is
   0.59px there rather than 0.92px, and a flat `left: 0` would only have been
   right at one width.

   Consequence, named rather than discovered: the jamb keyline is now as
   thick as the arc's, which is ~1.9px rather than the ~0.9px that was
   visible before. If the outline ever reads heavy, the lever is the arc's
   coupled d/stroke-width pair -- BOTH of them, and this rule follows -- not
   these borders on their own. */
.overture .frame-in > .arch-jambs {
  position: absolute;
  top: calc(70.7107cqi - 1px);
  bottom: 0;
  left: calc(0.2% - 1px);
  right: calc(0.2% - 1px);
  border-left: 2px solid rgba(255, 255, 255, .86);
  border-right: 2px solid rgba(255, 255, 255, .86);
  z-index: 2;
  pointer-events: none;
}
/* 2px, not 1: the path is inset only 2 viewBox units, so .frame-in's mask clips
   the outer half of the stroke. 2px leaves a full visible pixel sitting flush
   on the window's edge -- one line, and no second arch inboard of it.

   THIS PARAGRAPH USED TO END "the jambs' 1px borders are drawn wholly inside
   the mask and need no such allowance, which is why the two numbers differ",
   and that sentence was the defect: a border paints INWARD from its box while
   a stroke is centred on its path, so jambs whose box started on the path's
   own 2/1000 left a pixel of photograph outside the keyline down both sides.
   They are 2px borders on calc(0.2% - 1px) now -- the same centre line and
   the same width as this stroke -- so the two numbers do not differ at all.
   See .arch-jambs below for the render that measured it.

   There is no .inner rule any more. The second keyline is gone from the markup
   and its rule goes with it: a dead rule is how a search for "the extra line in
   the window" ends up at the wrong element. */
.overture .pointed-arch-outline .outer {
  stroke: rgba(255, 255, 255, .86);
  stroke-width: 2px;
}
/* A restrained pan and passing shimmer make the still sea feel present. The
   media query leaves the art completely still for motion-sensitive guests. */
@media (prefers-reduced-motion: no-preference) {
  .overture .frame-in {
    animation: save-sea-drift 16s ease-in-out infinite alternate;
  }
  .overture .frame-in::before {
    animation: save-sea-light 13s ease-in-out infinite alternate;
  }
  .overture .frame-in::after {
    animation: save-sea-shimmer 8s ease-in-out infinite alternate;
  }
}
/* Three keyframe stops rather than two: a mid-point off the straight line
   between the ends is what keeps the pan from reading as a slide. Only the
   background moves -- the type sits above it and never travels. */
@keyframes save-sea-drift {
  0%   { background-position: 46% 46%; }
  50%  { background-position: 53% 53%; }
  100% { background-position: 56% 48%; }
}
@keyframes save-sea-light {
  from { transform: translateX(-6%); opacity: .55; }
  to   { transform: translateX(6%); opacity: 1; }
}
@keyframes save-sea-shimmer {
  from { opacity: .7; transform: translateX(-2%); }
  to   { opacity: 1; transform: translateX(2%); }
}
.overture .frame-in .eyebrow,
.overture .frame-in h1,
.overture .frame-in .date,
.overture .frame-in .place {
  color: #fff;
  text-shadow: 0 1px 10px rgba(0, 0, 0, .35);
}
/* The site's eyebrow is four words where the card's is three, and its ink
   crossed the hairline near the crown -- by 0.37px at 384px in English and
   12px of line box in French (tools/check-arch-fit.mjs, 2026-09-06). Type
   size was not the lever: the corner that crosses is the line box's top
   corner, so the fix is to start the stack lower, where the curve is wider.
   The window is content-driven at phone widths (the stack outgrows the
   min-height floor), so padding-top places the eyebrow directly. The class
   is only on app/index.html; the card's window keeps its own clearance. */
.overture .frame-in:has(.eyebrow.wide) { padding-top: clamp(4.1rem, 15cqi, 5.2rem); }
/* And a step down in size for that line alone: with the clearance above,
   1.05rem still crossed by 0.14px at 384px. Both levers, because the
   probe's worst case is 4 device pixels and the margin should not be zero. */
.overture .frame-in .eyebrow.wide { font-size: .95rem; }
/* LESS BOLD, AND IT PAYS FOR THE BIGGER HEART. Both were asked for in the
   same sentence on 2026-08-11, and they are one decision rather than two:
   the first-screen budget had 27.87px in it, so anything the heart gains has
   to come from somewhere, and the names are where.

   THE LEVER IS NOT WEIGHT. shared/fonts/eb-garamond-400.woff2 is instanced
   at wght=400, EB Garamond's LIGHTEST -- `font-weight: 300` here would be a
   silent no-op that looks like a fix in the diff. So: size, tracking, and a
   softer ink (see --card-ink above). Line-height is deliberately NOT a
   fourth: 1.10 was measured and returned about 0.9px for no visible gain,
   and 1.06 is the value both the first-screen budget and the arch-fit sweep
   were bought against, so leaving it removes a variable from four gates for
   nothing.

   All three clamp terms take the same ~10%, so the ramp keeps its shape
   (the crossovers move 497->490px and 885->877px). The middle term is inert
   at 390px -- 9.3vw of 390 is 36.3px, under the floor -- so it buys nothing
   on the case that decides the gate, but "less bold" was not a mobile-only
   instruction. The tracking is the free half of this: it opens the word and
   drops its density at zero cost to height. */
/* Each name on its own line, and this is a real change rather than a
   formalisation of what already happened -- which is worth saying, because
   the obvious reading is the wrong one. The page did set the names on two
   lines before, and it is easy to assume the type was simply too wide for
   .frame. It was not: measured at this clamp against .frame-in's own width,
   "Julie Geoffrey" runs 270.5px inside 272.0px at 320, 270.5 inside 335.4 at
   390, 354.2 inside 559.9 at 651, and 481.8 inside 560.0 at 1000 and above.
   It fits on one line at every width from 320 to 1440. (Re-measured for EB
   Garamond on 2026-08-12. The script's figures were 235.2 / 235.2 / 308.0 /
   418.9 -- the conclusion held under both faces, but the margin at 320px is
   now 1.5px rather than 36.8, so this is a claim to re-check rather than
   assume the next time the face or the names change.) What broke the line
   was the typed U+2661 rendering in a fallback face at display size -- an
   ornament wide enough to push a name that fits onto a line of its own.

   So with the heart drawn instead, doing nothing here would have put all
   three on one line. That is rejected on purpose: "Julie" is narrower than
   "Geoffrey", so an inline heart sits left of the line's centre while the
   date under it is centred in the same box, and the two would not line up --
   which is the whole thing this lockup was asked to fix. Stacking costs one
   line of height and buys an ornament on the centre axis. See the ledger under
   h1 .monogram, below, for the measurement that it lands there. (That read
   h1 .heart until 2026-08-25; that rule left this file in 7594ed2, with the
   stroked drawing it sized -- see the note at the @font-face block above.)

   NOTHING ON THE PAGE CARRIES .nm ANY MORE, as of later the same day: the
   save-the-date's arch is one drawn monogram now (h1 .monogram, below) and
   there are no name spans left in it to stack. The rule stays because it has
   a second consumer and always did -- tools/fixtures/og-savethedate.html
   renders the OG link card out of the page's OWN markup, .nm spans and all,
   and that card was deliberately left showing the names in full. Delete this
   and the card sets "Julie" and "Geoffrey" on one line, which is the exact
   misalignment against the centred date that the paragraph above exists to
   prevent, and build-og.mjs's clearance assertions would not catch it: they
   measure the lockup's top, and a one-line lockup is shorter, not taller. */
.nm { display: block; }

/* The drawn heart. It is a path rather than U+2661 for the reason given in
   save-the-date/index.html: that codepoint is absent from Cormorant Garamond, as it
   was from Sacramento before it and is from almost every text face, so as
   text it was set by a fallback -- a second typeface, at display size, in the
   middle of the page's largest element.

   width is in em and height is auto, so it tracks the h1's own clamp and
   needs no second breakpoint of its own; .46em against Cormorant Garamond's 0.386
   x-height puts it a little above the names' x-height and well below their
   capitals, which is where an ornament belongs. The negative bottom
   margin is not decoration: a block SVG contributes its full box to the
   line stack, and this is the one place on this page where a whole extra
   line of height has to be found inside a first-screen budget with 26px in
   it. -0.16em gives back most of what the heart's line costs, by letting the
   names' own deep descenders and tall ascenders close back over it: measured
   at 390px, the heart's box runs 1.01px into Julie's and 8.19px into
   Geoffrey's, and the whole lockup costs the budget 9px rather than the 19px
   the heart's own box height would suggest.

   WHAT THIS IS FOR, and the thing to re-measure if any of it moves: the
   heart's centre and the date's centre are the same x. Measured in Chromium
   at 390, 760, 1200 and 1440px, the difference is 0.01px or less at every
   one -- both are centred in the same .frame-in, so the alignment is
   structural rather than tuned, which is exactly why it should be checked
   after anything that gives either of them a margin, a padding or a width of
   its own. */
/* DRAWN AT THE FACE'S OWN STROKE WEIGHT, and no fill. That is what stopped
   the first version reading as an ornament stuck onto the page: a filled
   heart is a solid block of ink beside letters that are mostly white space,
   and it looks oversized however small it gets.

   THE WEIGHT IS A MEASUREMENT, AND IT MOVED WITH THE FACE. Rasterise "Julie"
   at 4x at 51.2px and take the median horizontal ink run: Sacramento, a
   monoline script, gave 2.25px; EB Garamond, which is modulated, gave
   3.75px; Cormorant Garamond gives 3.25px (upper quartile 3.50 -- the stems
   rather than the hairlines). Cormorant is a lighter cut of the same idea,
   which is most of why it reads as less bold at the same size, and it is
   also why the constant below dropped from 1.7578 to 1.5234 rather than
   staying put. The measurement was reproduced against EB Garamond first --
   3.75px on the nose -- before the new number was trusted.

   stroke-width is in USER UNITS, which is what makes this hold at every size
   for free: the width is in em, so the viewBox scales with the font, and a
   fixed user-unit stroke scales with it in exactly the same proportion.
   Solving `units * k * fs / 24 = 3.25 * fs / 51.2` gives units = 1.5234 / k,
   with no fs left in it -- the same arithmetic for all three hearts, which is
   why their numbers differ only because their widths do. Re-measure that
   constant the next time the face changes; nothing here will complain.

   The path leaves 2 units of margin inside its own 24-unit box (it spans
   x 2..22, y 3..21.35), which is now NARROWER than half the stroke at the
   h1's width -- 2.93 units. overflow: visible is therefore load-bearing
   rather than belt and braces: without it the heart's outer edge is clipped
   square at the top and sides. */
.heart {
  fill: none;
  stroke: var(--heart, currentColor);
  stroke-linejoin: round;
  overflow: visible;
}
/* HISTORY, AND THE ORNAMENT IT SIZES IS NO LONGER IN THE H1. The drawn heart
   between the names left the arch on 2026-08-25 with the names themselves,
   when the whole lockup became one drawn monogram -- see
   html[data-face="card"] h1 .monogram below, which is what stands where this
   rule's <img class="hero-heart"> did. The drawing is not gone from the
   page: .heart-mark and .form-msg.ok::after still mask the same PNG, so
   assets/hero-heart.png is still shipped and still sync-shared's business.
   The ledger is kept rather than deleted because it is the record of how the
   ornament's size was arrived at, and the monogram's own em is derived
   against the box this one helped set.

   BIGGER, asked for on 2026-08-11, and the margins are re-derived rather
   than scaled. .46em against .30em is +25.7% painted width and +25.0%
   painted height at 390px (16.55x15.49 -> 20.81x19.37), and +53% relative to
   the names, which lost 10% in the same change.

   The stroke is re-solved, not carried: 1.7578 / .46 = 3.821, which is the
   number .heart.inline carried too, so the file held ONE value for this width
   rather than two differing in the fourth decimal. (Both of those hearts have
   since been redrawn: the lockup is a PNG, and .heart.inline was replaced by
   .heart-mark below on 2026-08-25. The stroked path survives on this page
   only in .form-msg.ok::after, and in the OG card's fixture.)

   THE MARGINS MOVE IN OPPOSITE DIRECTIONS, which looks wrong in a diff and
   is the whole point: the lockup shifts UPWARD instead of inflating. The
   room above and below the heart is not symmetrical -- Julie's l and i feet
   leave about 7px clear, while Geoffrey's ff ascenders sit directly under
   it -- so measured on 4x screenshots inside the heart's own x-window, the
   gaps go 7.25px above / 2.50px below to 4.25px / 3.25px. Two earlier
   passes (-.09/-.18 and -.11/-.19) left 0.50px and 0.25px below: the heart
   resting on the ff. Any further growth comes out of the top gap, not the
   bottom one.

   Re-check the heart/date centre alignment after touching these -- the
   standing instruction on the block above, and these margins are exactly
   the kind of change it is about. */
/* THE ARCH'S LOCKUP, and since 2026-08-25 it is one drawn monogram where it
   was two names and a heart. save-the-date/index.html carries where the
   artwork came from; this is what sizes it.

   THE WIDTH IS A HEIGHT, SOLVED BACKWARDS, and that is the whole of why it
   is 3.42 and not a round number. The lockup it replaces measured
   3.599em x 2.7024em -- the same box at every viewport from 320 to 1440,
   because both terms are em -- and the h1's height is what four other things
   are already bought against: the first-screen budget that
   tools/check-first-screen.mjs asserts, the arch fit that
   tools/check-arch-fit.mjs sweeps in 8px steps, .frame.low .frame-in's
   min-height, and the OG card's 1.14/120 clearance ledger. So the canvas's
   own 900:711 is solved for the height rather than the width:
   2.7024 x (900/711) = 3.4222em, which reproduces the old box to within
   0.03px at 1440 and leaves all four where they were measured. Change the
   art and this number is re-solved from the new canvas, not carried over.

   IT IS NOT THE SAME INK, though, and the number above does not say so. The
   names filled about three quarters of that box -- the rest was
   line-height 1.06 and the face's own leading -- where the monogram's
   trimmed canvas fills it nearly edge to edge. Same box, more drawing in it,
   which is what makes it read as the hero element rather than as a name set
   smaller.

   display: block is load-bearing for the arithmetic. The .sr span beside the
   img is position: absolute, so with the img out of the inline flow the h1
   has no line box at all and its height is exactly the image's. As an inline
   image it would pick up the h1's line-height and the box would grow.

   THE SHADOW IS THE NAMES', in the one form that reaches an image. The four
   lines in the window carry text-shadow: 0 1px 10px rgba(0,0,0,.35), which
   does nothing to an <img>; this is the same offset and the same blur as a
   filter, at .45 rather than .35 because the ink it has to hold up is a
   hairline and the ground is sparkling water -- white-on-white at every
   highlight the sea puts under it. hero-heart's own 0 1px 1px / .28 was
   sized for a heart drawn at 26px against stems; it is not enough here.

   NOTHING GATES THIS ONE, and the number that says it does not have to be
   gated today is 17.52px. tools/check-arch-fit.mjs walks text nodes in both
   of its stages, so an <img> in an arch is invisible to it -- the "WHAT IT
   DOES NOT SEE" section in that file's header carries the whole ledger,
   including what closing the hole would cost. The clearance was measured
   instead from the artwork's own alpha channel against that file's own
   two-centre arithmetic, over its own 320-1440 sweep at its own 8px step, in
   Chromium and WebKit: the monogram's worst ink sits 17.52px INSIDE the
   tolerance-adjusted boundary, at an 880px viewport, on the artwork's top
   row. Raise this em and that is the measurement to repeat -- the gate will
   not repeat it for you. */
h1 .monogram {
  display: block;
  width: 3.4222em;
  height: auto;
  margin-inline: auto;
  filter: drop-shadow(0 1px 10px rgba(0, 0, 0, .45));
}
/* THE FOOTER'S HEART. It was the ARCH'S heart too for part of 2026-08-25 --
   the same assets/hero-heart.png artwork at the same .69em, replacing the
   stroked .heart.inline path that stood here at .46em, asked for as "a
   bigger one that matches the one in the top arch". Both halves of that were
   one edit: the em value WAS the match, because the two hearts shared one
   canvas, so .69em reproduced the arch's proportion against its own type
   exactly and did so at every size for free.

   THAT MATCH IS GONE, later the same day. The arch's lockup is now a drawn
   monogram with its own heart inside it (h1 .monogram above), so the two are
   different drawings again and no em here can make them agree. Nothing about
   this rule changes -- the size below was solved against the FOOTER's own
   type, and the footer's type has not moved -- but the reason it reads .69em
   is now history rather than a constraint. A future change here is free to
   pick a number on the footer's own merits.

   WHAT IT GAINS, measured rather than asserted, at 390px where the footer
   line is 2rem = 32px. The PNG's painted ink occupies 78.05% x 86.00% of its
   1376x1143 canvas (alpha > 32, which is the outline proper; a faint fringe
   runs to 95.9% x 98.4% and is not the shape). So .69em of box is .5386em of
   painted width and .4929em of painted height. The old path spanned x 2..22,
   y 3..21.35 in a 24-unit box, plus half of its 3.312 stroke on each side --
   .4468em x .4153em painted. The ornament is therefore 20.5% wider and 18.7%
   taller than it was: 17.2 x 15.8px against 14.3 x 13.3px.

   NOT height: auto, which is what the <svg> could use and a masked span
   cannot: an empty span has no intrinsic ratio to resolve auto against, so
   the height comes from aspect-ratio and the canvas's own 1376/1143. Get
   this wrong and mask-size: contain silently letterboxes the heart inside a
   square box, which reads as the heart having shrunk rather than as a bug.

   MASK, NOT <img>. The PNG is painted white for the photograph the arch sits
   on; on cream it would be invisible. The mask takes only its alpha, so the
   colour still comes from --heart, exactly as .form-msg.ok::after below does
   for the success line -- and the two are now the page's only remaining
   users of that token. Longhands rather than the shorthand, for the reason
   spelled out there. */
.heart-mark {
  display: inline-block;
  width: .69em;
  aspect-ratio: 1376 / 1143;
  vertical-align: -.10em;
  background-color: var(--heart);
  -webkit-mask-image: url("assets/hero-heart.png");
  -webkit-mask-repeat: no-repeat;
  -webkit-mask-position: center;
  -webkit-mask-size: contain;
  mask-image: url("assets/hero-heart.png");
  mask-repeat: no-repeat;
  mask-position: center;
  mask-size: contain;
}

/* The third heart, and the only one that cannot be an element: the success
   line is written with textContent by shared/submit.js, which builds even its
   error variant out of DOM nodes rather than innerHTML because the guest's
   own answers travel that path. So this one is a mask on a pseudo-element,
   and the colour comes from the token rather than from a hex baked into the
   image -- which is the whole reason for a mask instead of a
   background-image.

   IT IS THE FOOTER'S HEART, changed on 2026-08-25 in the same pass that
   gave the footer one. Until then this carried its own copy of the stroked
   path, inlined twice as a data URI, and the page showed a guest two
   different heart drawings in the one moment it shows both: submit the form
   and the success line's stroked outline answered the footer's PNG four
   inches below it. One drawing for both is the whole of the change. (It
   named the ARCH's heart as well for a few hours that day. The arch became a
   drawn monogram later on 2026-08-25 and carries its own heart inside the
   artwork, so this and .heart-mark are the page's two users of the PNG, not
   three.)

   .69em ON 2rem TYPE, which is the footer's box on the footer's size --
   html[data-face="card"] .form-msg is 2rem and .plinth .script is 2rem, so
   the two ornaments render at an identical 22.08px and no separate solve is
   needed. aspect-ratio for the same reason .heart-mark gives: the canvas is
   1376x1143 and a box that assumes square letterboxes the drawing inside it.

   AND IT RETIRES A HAND-KEPT CONSTANT. The stroke used to appear here twice
   as a literal inside the two data URIs, so changing the width meant three
   edits with no gate watching for a missed one -- and it HAD gone stale
   before, reading 2.293 from 2026-08-11's face change until the next one
   caught it. There is no constant left to keep in step: the drawing is a
   file, and both users of it name the same file.

   LONGHANDS, prefixed then unprefixed, not the mask shorthand. Two reasons,
   and the arcade above takes the same care for the first of them: the
   shorthand resets every mask longhand it does not mention, and its
   `position / size` slot is the one piece of mask syntax WebKit has
   historically been unreliable about. Neither risk buys anything here --
   this is one layer, four values, written out. */
.form-msg.ok::after {
  content: '';
  display: inline-block;
  width: .69em;
  aspect-ratio: 1376 / 1143;
  margin-left: .18em;
  vertical-align: -.10em;
  background-color: var(--heart);
  -webkit-mask-image: url("assets/hero-heart.png");
  -webkit-mask-repeat: no-repeat;
  -webkit-mask-position: center;
  -webkit-mask-size: contain;
  mask-image: url("assets/hero-heart.png");
  mask-repeat: no-repeat;
  mask-position: center;
  mask-size: contain;
}
/* BOTH MASKED HEARTS GO IN FORCED COLOURS, and saying so is the point. A
   mask paints nothing itself: the ink is background-color, which the forced
   palette overrides, so in high contrast these two would resolve to a heart
   drawn in the canvas colour on the canvas -- present in the layout, absent
   on the screen, and impossible to tell apart from a bug. Both are
   aria-hidden ornament, so removing them loses a guest nothing and is what
   the ornament layer already does one section down for body::after's grain.
   The arch's own heart needs no entry: it is drawn inside the monogram, and
   that is an <img>, which forced colours leave alone. */
@media (forced-colors: active) {
  .heart-mark,
  .form-msg.ok::after { display: none; }
}
/* Sentence case, where the rest of the site sets these two as tracked-out
   uppercase at 0.30em.

   THE ORIGINAL REASON FOR THIS IS GONE and the rule stays anyway, which is
   worth saying plainly. Until 2026-08-12 this page's face was a connected
   script, and uppercase broke its joins while 0.30em of tracking pulled the
   rest apart -- the treatment was impossible, not merely unfashionable. EB
   Garamond has no such problem; an old-style serif is exactly the kind of
   face that wears tracked-out small capitals well, and app/ still does. It
   is kept because it is what was reviewed and chosen, and because the whole
   page moved to one treatment on purpose: sentence case at 0.01em from the
   eyebrow down to the footer. Restoring uppercase here means restoring it on
   the labels, the button, the footer line and the toggle too, or the page
   goes back to mixing.

   Colour is untouched -- .overture .eyebrow and .overture .place still
   resolve to --ink-soft at 5.97 on paper, which is the pair
   tools/check-contrast.mjs measures. The old note here warned that those
   rows were derived for 12px tracked uppercase and that the script set only
   a 5.39px x-height at 1.1rem; at 1.05rem of Cormorant Garamond it is 6.5px, which is
   larger than the caption it replaced rather than smaller. */

/* The same move on the four controls: the site's labels, submit button,
   footer line and language toggle are tracked-out uppercase, and on this page
   they are sentence case at 0.01em. Same reasoning as the eyebrow above --
   originally forced by the script, now kept so the page holds one treatment
   throughout. */

/* And the sizes, RE-DERIVED AGAIN for Cormorant Garamond on 2026-08-11 --
   the second re-derivation in one day, and the reason to spell that out is
   that it is the rule here rather than the exception. Every size on this
   page has now been bought three times: against Sacramento's 0.306 em
   x-height, against EB Garamond's 0.405, and against Cormorant Garamond's
   0.386. Scaling the previous set by the ratio is NOT the method and never
   was. Each set is derived back to the apparent size the page had BEFORE it
   went to one face, when body copy was the system stack at ~0.52 x-height.
   That is the size the layout was designed around.

   size = (target x-height in px) / 0.386 / 16, in rem:
     .ask-note      1.15rem system, 9.57px x-height  ->  1.55  (1.30 shipped:
                    it is the first-screen budget that binds here, not the
                    target -- see the ledger below)
     .field input   1rem system,    8.32px           ->  1.35  (1.30 shipped)
     .privacy-note  0.78rem system, 6.50px           ->  1.05 (the note itself
                    was cut on 2026-08-25; the row is left for the method)
   The controls that were UPPERCASE before have no x-height to match, so they
   are set by cap height instead (Cormorant Garamond's is 0.625 em, against
   EB Garamond's 0.653):
     .lang-toggle   0.8rem uppercase, 9.22px caps    ->  0.95
     .plinth p      0.8rem uppercase, 9.22px caps    ->  1.05 (sentence case
                    now, so its lowercase does the work: 6.5px x-height)
     .field label   0.78rem uppercase, 8.99px caps   ->  1.05
     button.submit  0.9rem uppercase, 10.37px caps   ->  1.20

   WHAT THE FACE CHANGE COST THE BUDGET, stated plainly because it went the
   wrong way. A 0.386 x-height needs about 5% more font-size than a 0.405 one
   to read the same, and the page pays that on every line of body copy. The
   h1's own 10% reduction (see its rule) bought the tightest case from 717px
   to 711px; this face gives that back and a little more, and the shipped
   figure is 721px of 745 at 390x745 in French, against the 717.13px the page
   carried before either change. 24px of headroom rather than 27.9. It buys the
   heart at 25% larger, the names visibly lighter, and a face with a lighter
   stem, and it stays clear of the gate -- but it is a slightly tighter page
   than it was, not a roomier one, and the next size increase here has less
   room to come out of than the last one did.

   NOT COMPENSATED: the h1. Cormorant's capitals are 4.3% smaller than EB
   Garamond's at the same size, and scaling the clamp up to hold the names at
   a constant physical size was tried and reverted -- it cost 5px of the
   budget to undo part of an instruction ("less bold") the smaller cap was
   already serving.

   MEASURED after, not assumed: tools/check-first-screen.mjs at the tightest
   of the sixteen cases (390x745, French). */
/* Everything below the name field, on the same derivation. These sit outside
   the first-screen budget, so nothing here is a compromise -- they are simply
   the sizes the arithmetic gives.

   Under the script these two were the page's smallest apparent text and had
   to be pushed to 1.3rem and 1.35rem to claw back a 6.4px x-height. The
   serif reaches the same figure at 1rem. That is the whole difference the
   face makes, stated in one line: the same legibility for a third less
   physical size, which is why the rest of this page stopped fighting its
   layout the moment the face changed.

   .form-msg and .plinth .script keep 1.9rem. Both were derived from the
   ORIGINAL faces rather than from the script -- the success line against the
   system stack's 1.5rem (12.5px x-height -> 1.93rem here) and the footer
   wordmark against Instrument Serif's 1.5rem (12.2px -> 1.88rem) -- so EB
   Garamond lands on the sizes they already had. */

/* The colours the handful of rules that name their own resolve to here. */
/* The separator between EN and FR, and it is here because an audit of every
   text node's COMPUTED colour found it, not because anybody spotted it.
   .lang-toggle .sep names --ink at (0,2,0), which the group above does not
   reach, so this one aria-hidden middot stayed the site's blue-grey #263445
   through both face changes -- invisible while the page's own ink was also
   blue, and the only cool mark left on it once the ink went black. The
   opacity: 0.5 it carries is untouched. */

/* AND THE TWO STATE RULES THOSE JUST OUTRANKED. Both were found by reading
   computed colours out of the rendered page, not by counting selectors, and
   both were live defects in the first draft of this block:
   - button.submit:hover is (0,2,1) and the rule above is (0,2,2), so the
     hovered button kept --card-ink and put #2a5670 on the --rose-deep
     fill. Dark on dark, in the state a guest is in at the moment they
     commit.
   - .lang-toggle button.on is (0,2,1) against the same (0,2,2), so the
     current language lost its --rose-deep and the toggle was left marking
     it with opacity and font-weight alone.
   Anything else that colours one of these elements by state has to be
   restated here too. */
/* The success line was the last piece of type on the page in a colour of its
   own: --rose-deep, which is neither --card-ink nor --heart, left that way
   by omission rather than by decision. It is --card-ink now, like every
   other word here, and the drawn heart beside it is what marks the state.
   The error line keeps --err on purpose -- an error is not a brand colour,
   and it is the only red on the site. */

/* The rail rules that stood here (opaque --blush, no backdrop filter, the mark
   centred in column 2) became the base rail on 2026-09-06. */
/* ---------- The card's own surfaces ----------
   Everything below repaints a ground this page shares with app/ by default.
   Five washes had to be restated, not the two the brief named -- the page
   wash, the rail, the form panel, the fields and the footer -- and the rail
   is the one that explains why this is a list rather than a token swap: its
   white is an rgba() literal inside header.rail, which no redefinition of
   any custom property can reach.

   THE PAGE WASH goes tonal. It ran --paper -> --blush-pale -> --sky-pale,
   white through pink to BLUE, which on cream would have ended the document
   on a cold band under a warm page. Cream to a deeper cream instead: one
   sheet, lit from the top. Pink stops being a tint of the paper and becomes
   what the brief asked for, an accent.
   Longhands, for the reason the base rule gives: this is the one background
   on the site no var() may be allowed to invalidate. */
/* The sun wash survives at a lower alpha. 0.62 was chosen over white; over
   cream the same value reads peach. At 0.45 it composites to #fcf0e4, which
   is LIGHTER than the ground and therefore the worst backdrop on the page --
   it gets its own rows in tools/check-contrast.mjs rather than an argument
   that it must be fine. */
/* THE PAPER TEXTURE, and it is not a new layer. body::after has been an
   inline feTurbulence grain since the redesign -- data URI, CSP-clean under
   img-src 'self' data:, 180px tile, already in both folders. The brief asked
   for paper feel, so the answer is a number, not a second asset: at .085 the
   grain moves the ground by two levels per channel at most. A tooth, not
   parchment.
   Zeroed where a texture is actively unhelpful. prefers-reduced-motion needs
   nothing -- there is no animation here.
   ONE KNOWN LIMIT, accepted: body::after is position: fixed, so the grain is
   keyed to the window and does not travel with the paper as you scroll. At
   8.5% on a 180px tile it is not trackable by eye. It does NOT grain the
   photograph: this layer is z-index -1 and .plate is a positioned in-flow
   descendant, so the picture paints above it. */
@media (prefers-contrast: more) { body::after { opacity: 0; } }
@media (forced-colors: active)  { body::after { display: none; } }


/* The form panel. --blush-pale is a COOL pink -- fine on white, lilac on
   cream -- so the panel takes the warm one. (0,4,0) against the base rule's
   (0,3,0). */
/* form.rsvp's --paper never actually shows on this page, because the rule
   above outranks it. Restated anyway: it is a trap, not a no-op -- anything
   that ever renders this form outside .ask .cell (the OG fixture is one
   render away from doing exactly that) would paint white on cream. */
/* The fields keep the relationship they have today -- the page's own paper,
   inset in a tinted panel -- which means they go cream. A white field on a
   cream page reads as a hole rather than as a field. The border literal is
   the site's blue-grey and moves to the card's ink at the same alpha.
   (0,2,1), the same as .field input:focus below it in the file, so the
   focus border still wins on cascade order -- which is the intended
   outcome: the resting border is neutral, the focused one is pink. */
/* The footer was a blue plinth under a blue-inked page. Under a cream one it
   was the single most obviously wrong surface left, so it took the card's own
   cream gradient -- and on 2026-08-25 it gave up having a background at all.

   THE GRADIENT WAS THE SEAM. body already paints
   linear-gradient(card-paper 0%, card-paper 46%, card-paper-deep 100%) down
   the whole page, and the footer was painting the SAME ramp over its own box
   -- which means the ramp RESTARTED at the footer's top edge, jumping back to
   the lightest end of it. Sampled at 390px in Chromium, three rows either
   side of the edge at five x positions: rgb(242,236,223) above against
   rgb(250,246,236) below, a step of 8-13 levels per channel drawn dead
   straight across the full width. That is a visible edge on a page with no
   other horizontal rules in it, and it is the last thing that made this
   footer read as a panel. With no background of its own the body's ramp runs
   straight through and there is nothing to see.

   The colour stays: --card-ink is inherited by body already, but <footer> is
   one of the elements the site's blue-ink block names directly. */
/* THE EMPTY PANEL, closed on 2026-08-25. Reported as a blank box between the
   form and the names, and it was exactly that: <footer>'s own geometry is
   written for the four-arch colonnade the app/ pages carry, and this page has
   no colonnade -- only the plinth. So its 3.5rem of top padding, the plinth's
   2.5rem top margin, the plinth's 1.6rem top padding and the plinth's
   hairline cornice were all standing over nothing. Measured at 390px before
   this rule: 123.60px between the footer's top edge and the first word of
   "Julie", with a rule drawn across it 66px down. A guest reads that as a
   panel that failed to load, because a rule with nothing above it is not a
   cornice, it is an empty frame.

   The footer's own top border and 24px radii go for the same reason and are
   part of the same defect: they draw the panel's LID. Precedent is .arcade +
   footer below, which strips both where a full-bleed band meets the footer;
   the argument here is different -- there is no seam to protect, the lid just
   has nothing under it -- but the remedy is the one the file already uses.

   2rem is kept, not zeroed. The circled region was the excess, not the
   separation: the ask section ends on its own clamp(2.5rem, 6vh, 4rem) of
   bottom padding, and the names still need air under that. Scoped to the card
   face throughout -- app/index.html and the four interior pages share this
   stylesheet byte for byte and DO have the colonnade, where every one of
   these numbers is still load-bearing. */
html[data-face="card"] footer {
  padding-top: 2rem;
  border-top: 0;
  border-radius: 0;
}
html[data-face="card"] .plinth {
  margin-top: 0;
  padding-top: 0;
  border-top: 0;
}
/* The resting submit fill. Warm, for the panel's reason, and --card-submit
   rather than --card-blush-deep since 2026-08-25 -- see the token. */
/* AMENDED WITH THE FILL: the hovered label was --paper, and --paper is now
   the one white left on this page -- it read as a different design landing
   for the duration of a hover. Cream on --rose-deep measures 5.91 against
   the white row's 6.38; both clear 4.5 at 1.15rem. */
/* The focus ring, both halves. The global one is :where(...) at (0,1,0) and
   the field-specific restatement is (0,2,1) -- the second exists because the
   fields' resting box-shadow would otherwise win on the very property the
   ring sets, which is a regression this file has already had once. Both are
   restated here at one specificity step higher, in the card's own colours.
   --card-blush-deep rather than --card-blush: the halo has to read against
   the blush PANEL as well as against the cream ground. */

/* ---------- The window ----------
   "The quality of the image is poor, make the window smaller" is one
   instruction, not two: the band was full bleed at every width over a
   1200x1758 crop, so at 1440 it upscaled 1.20x and on a retina laptop
   2.40x. Capping the container turns every one of those into a DOWNSCALE --
   1.19x down at 390/DPR3, 1.07x at 768/DPR2, 2.14x at 1440/DPR1, 1.07x at
   1440/DPR2. No new asset, no re-derivation of the byte ceiling,
   tools/build-images.sh untouched. Worth saying to whoever asks next: at
   390px on a phone the crop was already essentially 1:1 (1200 source over
   1170 device pixels). The softness was a desktop artefact throughout.

   THE CAP GOES ON .arcade, AND THIS IS THE TRAP. container-type:
   inline-size is on .arcade, not .band, so every cqi in the mask resolves
   against .arcade's inline size. A max-width on .band.tall instead would
   leave the mask computed for a 1440px container while the band rendered at
   560: the arch head enormous and mostly off-canvas, the mask still valid,
   the photograph still there, and not one gate saying so.

   min(560px, 86vw) is the expression .frame already carries, so the window
   is exactly as wide as the keyline arch above it. Same opening, two
   depths. Height is left alone -- the window is already smaller in both
   dimensions at desktop, and svh is doing real work on phones.

   --gap AND --crown GO TO ZERO. At the shared defaults the arch head is
   narrower than the body below it; full bleed, that step happens at the
   viewport edge and is invisible, but inset it reads as a shoulder cut into
   the photograph. Zeroed, the head is exactly the band's width and springs
   from its top edge: a semicircle on a rectangle, which is what a window
   looks like. This pushes the mask into its DEGENERATE READING -- the jamb
   layer's first stop has zero width and its last is clamped behind its
   predecessor, and the circle is tangent to the band on three sides. That
   is precisely the arrangement the .band comment warns can leave a
   sub-pixel line of paper across the photograph at fractional device pixel
   ratios, and an integer-DPR screenshot cannot see it. So it was probed the
   way tools/check-arcade.mjs does -- flat fills, every interior row
   classified -- at DPR 1, 1.25, 1.5, 1.75, 2, 2.5 and 3 against
   390/560/1440, in Chromium and WebKit. 42 combinations: no springline seam
   anywhere, worst interior row 0.239% paper, which at that width is a
   single antialiased device pixel on the outer edge of a curve tangent to
   its own box. The crown feathers by one device pixel at DPR 2 and above
   and by none below it -- the same antialiasing seen end-on.
   SCAN THE BOTTOM 28px OUT if repeating this: the border-radius below is
   SUPPOSED to be paper at the corners, and counting those rows reports a
   13% "seam" that is the radius working correctly. That false positive cost
   a re-run here.
   If a seam ever does appear on some untested device, the fix is
   --crown: -1px HERE, not a change to the shared .band defaults.

   A DOCUMENTED DEFECT MOSTLY DIES WITH THIS, and "mostly" is the honest
   word. The long comment on .band.tall records that above ~1098px --spring
   exceeds the band's pinned height, so the foot lands 5.2 / 19.2 / 26.4
   degrees off vertical at 1200 / 1600 / 1920. Capping the container ends the
   WIDTH-driven version of that at every width: --spring is 280
   inside a 560px band, so the circle completes and the feet land flush.

   THERE IS A HEIGHT-DRIVEN VERSION LEFT, and it was found by checking rather
   than by claiming the fix was total. The band is min(64svh, 560px) and
   --spring is a fixed 280 once the container caps, so below 437px of
   viewport height the band is shorter than the spring and the circle stops
   reaching its equator again. Measured at 844x390, a landscape phone: band
   560x249.6, foot 6.2 degrees off vertical, in Chromium and WebKit alike.
   What it looks like is a shallow dome with slightly splayed feet rather
   than a window -- not broken, and accepted rather than missed. Portrait
   phones never reach it (at 390px the container is 335.39, so --spring is
   167.70 and 64svh clears it from about 262px of height up), and this page
   is read in portrait. If it ever matters, the move is a height-aware
   --crown for this variant, not a change to the shared .band defaults.

   NO RIM, and not for want of trying. box-shadow follows border-radius and
   not the mask, so it would draw a rectangular halo straight across the top
   where the mask cut the paper away; a border IS masked, so it would
   survive as stray segments inside the arch head. The window is defined by
   the cut plus cream on three sides. The only honest tool if one is ever
   wanted is filter: drop-shadow(), which traces the masked alpha -- and it
   re-composites a 560px blurred photograph on every parallax frame
   motion.js drives, so measure before shipping it. */
html[data-face="card"] .arcade {
  max-width: min(560px, 86vw);
  margin-inline: auto;
  margin-bottom: clamp(2rem, 5vh, 3.5rem);
}
html[data-face="card"] .band.tall {
  --gap: 0px;
  --crown: 0px;
  border-radius: 0 0 var(--r-lg) var(--r-lg);   /* .band already clips */
}
/* .arcade + footer strips the footer's top radii, because a full-bleed band
   meeting a rounded footer leaves two notches of page wash. The band is not
   full bleed here, so the notches are just paper and the footer keeps its
   corners. (0,2,1) against that rule's (0,1,1). */
html[data-face="card"] .arcade + footer {
  border-top-left-radius:  var(--r-lg);
  border-top-right-radius: var(--r-lg);
}

/* ---------- Sections ---------- */
section {
  max-width: 860px;
  margin: 0 auto;
  padding: 4.5rem 1.5rem;
}
/* --rose on a heading is large-text-only territory: this rule fixes the size
   at 1.85rem (29.6px), clear of the 24px floor at every viewport, so the
   colour is legal here regardless of what the base h2 clamp would give it. */
section h2 {
  font-size: 2.2rem;
  color: var(--ink);
  font-weight: 400;
  letter-spacing: 0.01em;
  margin: 3.4rem 0 1.1rem;
}
/* h2 carries 3.4rem of top margin to separate it from the block above, which
   it does not need when it IS the top of the section. */
section > h2:first-child { margin-top: 0; }
section h3 {
  font-size: 1.55rem;
  color: var(--ink);
  font-weight: 400;
  margin: 1.6rem 0 0.5rem;
}
section p + p { margin-top: 0.8rem; }

/* Running copy's own links -- program.html's "Open in Google Maps" line and
   save-the-date's two noscript mailto: paragraphs -- carry no colour rule
   anywhere else in this file, so without one they render in the browser's
   default blue, the one hue this palette does not have. --lapis is the
   token the palette already declares for exactly this pairing (6.84 on
   --paper); tools/check-contrast.mjs's three flat --lapis rows (on --paper,
   --blush-pale, --sky-pale) already cover the whole span body's own
   background wash can put behind a <p> at any scroll depth on these pages,
   so this needed no new gate row. --lapis-deep on :hover is the palette's
   "safe anywhere" variant, reused here for a state rather than a surface.

   :where() carries this selector's specificity to flat zero ON PURPOSE, so
   it can never win a fight it has no business entering. Two other anchors
   also sit inside a <p> inside a <section> and already carry their own
   colour: .card a (Where to Stay's "Map ->" links, --rose-deep, further
   down), (0,1,1), and .form-msg.err a (the submit-failure mailto: that
   submit.js builds from DOM nodes, --err, in "Form"), (0,2,1). Either
   specificity outranks :where()'s (0,0,0) -- and this rule's own :hover
   variant's (0,1,0) -- regardless of source order, so this rule cannot
   touch either one's colour -- confirmed by rendering both pages before and
   after in Chromium and WebKit and reading computed colour, not assumed.
   (The footer's .plinth a needs no such protection: <footer> is never a
   descendant of <section>, so this selector does not match it at all.)
   Losing that colour fight still leaves text-decoration and
   text-underline-offset unopposed on .card a, which sets neither -- a
   Map -> link keeps --rose-deep but also picks up this rule's 3px offset, a
   harmless side effect, not a second one. Setting no box-shadow of its own,
   this rule also leaves the global :focus-visible ring (top of file)
   undisturbed on both anchors it does colour -- checked the same way, not
   assumed. */
:where(section p a) {
  color: var(--ink);
  text-decoration: underline;
  text-underline-offset: 3px;
}
:where(section p a):hover { color: var(--rose-deep); }

/* ---------- Beirut Guide: three sections, not one ----------
   Task 6 split the guide's single <section> into three, one per category,
   each preceded by its own segmental band. `section > h2:first-child` above
   already covers the heading side of that split for free: every category's
   h2 IS its own section's first child now, so all three land at 0 margin-top
   with no further rule. `section`'s own 4.5rem top-and-bottom padding does
   not get the same free ride -- two sections meeting at a shared band now
   stack 4.5rem of trailing padding against 4.5rem of leading padding, on
   top of the band's own height, which reads as a much longer pause than the
   single gap every other interior page uses.

   Scoped past `section:has(+ .arcade)` alone: the home page's own overture
   has this exact shape too -- section.overture is immediately followed by
   an .arcade, the plain home band, with no .segmental descendant -- and
   `section:has(+ .arcade)` is (0,1,1), the same specificity as
   `section.overture`, later in the file, so it won that tie and shaved the
   overture's own clamp(2rem, 5vh, 3.5rem) padding-bottom (45px at
   1200x900, 42.2px at 390x844, both measured) down to this rule's flat
   40px. `.segmental` is the hook that actually distinguishes the two: only
   the Beirut Guide's category-transition bands carry it (see .band.segmental,
   above), so `+ .arcade .segmental` reaches the guide's arcades and not the
   home page's. Its cost: `:has()` takes the specificity of its most specific
   argument, so this rule is (0,2,1), not the (0,1,1) it replaced -- narrowing
   the match raised the rank as well. Two alternatives would have held it at
   (0,1,1), a page-level class on the guide's body and wrapping the argument
   in `:where()`, and either would do if a later rule ever needs to overrule
   this one; today nothing does, because the only other rules setting a
   section's padding are `section` (0,0,1) and `section.overture` (0,1,1),
   both lower and both earlier. It is scoped this way rather than to
   `section` itself, which the other four pages
   open and close a single instance of and must keep its 4.5rem unchanged.

   2.5rem (40px) each side, 80px combined at a seam, was chosen by rendering
   the page and looking at it rather than by a formula: 4.5rem each side
   (144px combined) read as a much longer pause between categories than
   between the band and the heading that opens them; the original 4.5rem
   top-and-bottom, unscoped, was tried first and measured exactly that. The
   last section keeps the unscoped 4.5rem bottom before the footer, matching
   every other interior page's single section. */
.arcade + section { padding-top: 2.5rem; }
section:has(+ .arcade .segmental) { padding-bottom: 2.5rem; }

/* ---------- The page head ----------
   The four interior pages (Programme, Where to Stay, FAQ, Beirut Guide)
   opened with .band.head -- a seven-opening arcade over the sea, the title in
   white at its foot -- until 2026-09-21, when they were rebuilt on the Beirut
   Guide's plainer rhythm: square tiles, segmental arcades between sections,
   and no photograph above the fold. The title block that replaces it sits on
   the page's own paper in --ink, so none of .band.head's apparatus is needed
   here: no scrim (that rule's measured .75/.52 gradient existed only to carry
   white type over the sun's glitter), no white restatement, no crop geometry.

   .band.head's rules, its two fixtures and its cases in tools/check-arcade.mjs
   all survive deliberately. No page uses .band.head any more, but it is the
   gated instance of the pointed-head primitive .band.segmental derives from,
   and the segmental band is now the ONLY arcade these pages carry -- deleting
   the head's coverage would leave that geometry tested at one rhythm instead
   of two. Prune both together or not at all.

   The h1 clamp below is .band.head's (2.4rem..3.4rem), not the base h1's
   (2.85..5.1rem). The base scale was cut for the home page's single
   full-bleed pair of names; at 5.1rem a one-word title like FAQ standing
   alone on paper is all there is on the first screen and reads as a
   different page's typography.

   padding-top 3.4rem, not section's 4.5rem: the rail sits directly above with
   nothing between, where an interior section always had a band or an arcade
   before it. Measured against the rail's own foot rather than derived. */
.page-head {
  max-width: 860px;
  margin: 0 auto;
  padding: 3.4rem 1.5rem 3rem;
  text-align: center;
}
.page-head h1 {
  font-size: clamp(2.4rem, 5.2vw, 3.4rem);
  letter-spacing: 0.01em;
  margin: 0.4rem 0 0;
}
/* p.sub (above) carries the size, face and --ink-soft for the paper ground;
   only the gap to the h1 differs here, which is tighter than the Programme
   page's under-a-heading use of the same class. */
.page-head .sub { margin-top: 0.5rem; }
/* Every interior page puts a segmental arcade directly under the page head,
   which is why the head carries its own 3rem foot rather than leaning on the
   following element's top padding the way a section does: .arcade has no
   margin of its own and the band would otherwise sit tight against the sub.
   The rule below covers the other case -- a head followed straight by a
   section -- so the two orders cost the same air. No page ships that order
   today; it is one line, and the alternative is a head that collapses if one
   ever does. */
.page-head + section { padding-top: 0.5rem; }

/* ---------- Cards ---------- */
.cards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: 1.2rem;
  margin-top: 1.5rem;
}
.card {
  background: var(--paper);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  padding: 1.7rem;
  box-shadow: var(--sh-2), var(--rim);
  transition: transform 0.25s ease, box-shadow 0.25s ease, background 0.25s ease;
}
.card:hover {
  transform: translateY(-4px);
  box-shadow: var(--sh-3), var(--rim);
}
.card h3 { margin-top: 0; }
.card .tag {
  display: inline-block;
  font-size: .95rem;
  letter-spacing: .02em;
  text-transform: none;
  background: var(--blush);
  color: var(--ink);
  border: 1px solid rgba(142, 74, 94, 0.18);
  border-radius: 999px;
  padding: 0.25rem 0.75rem;
  margin-bottom: 0.8rem;
}
/* Body-size, so 4.5:1. .card's background is a gradient (see .card, above),
   paper at the top to --blush-pale at the foot -- rose reaches only 4.13 on
   --paper, 3.70 on the paler-but-lower-luminance --blush-pale end, so this
   uses rose-deep instead: 6.38 on --paper, 5.71 on --blush-pale, the worse
   of the two and the figure that governs. Name the surface, not just the
   number -- this is the third time this phase conflated the 6.38-on-paper
   figure with the 5.71-on-blush-pale one. */
.card a { color: var(--ink); }

/* ---------- Where to Stay: the arched cards ----------
   GONE 2026-09-21, with the rest of the interior-page redesign. Where to
   Stay's four hotel cards were Primitive B: each wrapped in a .cell, given a
   pointed head, --blush fill and a crown allowance of padding-top tuned
   against the French "Maisons d'hotes a Hamra / Mar Mikhael" heading -- the
   longest line on the site and the one tools/check-arch-fit.mjs kept catching
   as it crossed the hairline. The cards are plain rectangular .card now, the
   same as the Beirut Guide's, so the .cell wrappers, the skin, the .niche
   variant and the text rules that answered the crown are all removed.

   .cell > .card went out of the Primitive B geometry group above and out of
   both lists in tools/check-arch-fit.mjs at the same time, for one reason:
   that gate FAILS on a selector matching nothing anywhere, and with the home
   page's day cards already gone this was the last consumer. .cell itself
   survives -- save-the-date's form sits in one (.ask .cell > .rsvp) -- so only
   the `> .card` descendant went.

   What does NOT come back if these cards are ever re-arched: the padding-top
   factor. It was .5 for the semicircle, .62 after the head was pointed, .75
   after the French heading crossed at 520px. Re-derive it, do not copy it. */

/* ---------- FAQ (the colonnade) ----------
   GONE 2026-09-21. The FAQ was eight <details> rows styled as a colonnade --
   padding moved off details onto summary and the answer so the answer's
   --blush fill could run the row's full width, with matching radii on both
   rather than an overflow clip (which had swallowed summary's :focus-visible
   ring). The page is eight plain .card tiles now, every answer always open,
   so nothing on either domain uses details or summary and the whole block
   came out with the markup. The one surviving reference is the global
   :focus-visible group near the top of this file, which names summary among
   its elements; that is a harmless listing, not a consumer.

   WHAT WAS TRADED, because the block above only records the mechanism:
   closed-by-default was carrying scan length, not just tidiness. Eight
   collapsed rows put every question on one screen; eight always-open tiles
   run 2347px at 1200px wide and a good deal longer on a phone, so the last
   question is now a scroll rather than a glance. That was the explicit
   choice on 2026-09-21 -- the tiles are the point, and the answers are short
   -- but it is a choice, and it gets worse with every question added. Past
   roughly a dozen the accordion starts earning its keep again. Reverting is
   markup AND this block, not this block alone. */

/* ---------- Form ----------
   form.rsvp has exactly one consumer, the save-the-date form, and that form
   also carries .arch-b (see "Save the Date", below): a border-radius here
   would be (0,1,1) against .arch-b's (0,1,0) and would win regardless of
   source order, silently overwriting the arch's own corners with a plain
   rounded rectangle. So border-radius stays out of this rule; the arch group
   owns it, as every Primitive B consumer's must. max-width and margin stay
   out for the same reason the arch page's own comment gives -- the width
   limit belongs on .cell, never on the element the radius arithmetic runs
   against. */
form.rsvp {
  background: var(--paper);
  border: 1px solid var(--line);
  padding: 2.3rem;
  box-shadow: var(--sh-2), var(--rim);
}
.field { margin-bottom: 1.3rem; }
.field label {
  display: block;
  font-size: 1.05rem;
  letter-spacing: .01em;
  text-transform: none;
  color: var(--ink);
  margin-bottom: .3rem;
}
.field input, .field select, .field textarea {
  width: 100%;
  padding: 0.8rem 1rem;
  border: 1px solid rgba(28, 26, 23, .16);
  border-radius: var(--r-sm);
  font-family: var(--font);
  font-size: 1.3rem;
  background: var(--paper);
  box-shadow: var(--sh-1);
  color: var(--ink);
  transition: border-color 0.2s, background 0.2s;
}
/* No outline of its own: the two-tone :focus-visible ring at the top of this
   file already handles that (see the comment there). Border only here --
   there is no dark surface left on the site for a field-specific outline
   colour to have to also work against: the one place .field sits on
   anything but plain paper is save-the-date's own form panel
   (.ask .cell > .rsvp, "Save the Date" below), and that panel is tinted
   --blush-pale, a pale wash rather than a dark card. */
.field input:focus, .field select:focus, .field textarea:focus {
  border-color: var(--submit);
}
/* THE FIX for a real regression: .field input/select/textarea's resting
   box-shadow (var(--sh-1), just above) is (0,1,1) specificity and applies
   unconditionally, so it wins over the global :focus-visible ring's
   box-shadow ((0,1,0), :where() contributes zero) on the very property both
   rules set, even while the field is focused -- the ring's outline still
   paints (outline is a different property, so it never collided), but its
   pale halo silently did not. Confirmed in Chromium: a focused #name's
   computed box-shadow was the resting shadow alone. This rule re-states
   both shadows together at (0,2,1), the field's own selector plus
   :focus-visible, which beats the resting rule outright rather than
   relying on cascade order. */
.field input:focus-visible, .field select:focus-visible, .field textarea:focus-visible {
  box-shadow: var(--sh-1), 0 0 0 6px var(--blush-deep);
}
button.submit {
  width: 100%;
  padding: 1rem;
  background: var(--submit);
  color: var(--ink);
  border: none;
  border-radius: 999px;
  font-family: var(--font);
  font-size: 1.2rem;
  letter-spacing: .01em;
  text-transform: none;
  cursor: pointer;
  transition: background 0.25s;
}
/* --rose-deep, not --rose. White on --rose is 4.13:1, and this label has never
   been large text -- 0.9rem before this page's script sizes, 1.35rem (21.6px)
   after, both under the 24px that would drop the floor to 3:1. So the hovered
   button was a live 4.5 failure, in the one state a guest is in at the moment
   they commit. It predates this branch; it is fixed here because this rule's
   resting colour was being rewritten anyway and check-contrast.mjs now carries
   a row for the pair. --rose-deep is the same hue one step down and reaches
   6.38. */
button.submit:hover { background: var(--rose-deep); color: var(--paper); }
button.submit:disabled { opacity: 0.6; cursor: wait; }
/* 1.5rem, up from 1.3rem. This used to be load-bearing for contrast: at
   20.8px it needed 4.5:1, and the success colour reached only 3.87 on the
   glass card form.rsvp sat on, so 24px's 3:1 large-text floor was the only
   way it passed. Task 8 replaced that glass card with flat --paper, where
   --rose-deep reaches 6.38 -- clearing 4.5 at any size -- so there is no
   glass card left for this size to rescue. It stays 1.5rem on taste alone
   now: it is the one moment on the page worth a little size, the reply
   landed. The error variant below keeps its own 1rem and clears 4.5 on
   its own. */
.form-msg { text-align: center; margin-top: 1rem; font-size: 2rem; }
.form-msg:empty { margin-top: 0; }
.form-msg.ok { color: var(--ink); }
.form-msg.err { color: var(--err); font-size: 1.3rem; line-height: 1.5; }
.form-msg.err a { color: var(--err); }

/* [hidden] loses to any element with a display rule, so it is stated once here
   and stated strongly. */
[hidden] { display: none !important; }

/* Visible to a screen reader, to nothing else. */
.sr {
  position: absolute; width: 1px; height: 1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}

/* .privacy-note is gone, and so is every rule that dressed it: the base block
   that stood here, html[data-face="card"] .privacy-note's 1.05rem, the .ask
   .privacy-note colour, and the --card-ink-soft token that existed for that
   one line alone. The save-the-date page was its only consumer in the repo --
   the note read "We'll only use this to send you the invitation." under the
   form -- and it was cut on 2026-08-25. Kept as a note rather than a silent
   deletion because the contrast argument the old comment carried is worth
   not re-discovering: at opacity 0.7 the note measured 3.11 on flat paper
   against a 4.5 floor at 12.5px, a live WCAG failure that check-contrast.mjs
   could not see, because it composites colour and never opacity. If a note of
   this kind ever comes back, it comes back at full strength. */

.noscript-note {
  max-width: 560px;
  margin: 0 auto 1.5rem;
  padding: 1rem 1.3rem;
  border: 1px solid;
  border-color: var(--err);
  border-radius: var(--r-md);
  background: var(--paper);
  text-align: center;
  font-size: 1.3rem;
}

/* ---------- Footer: the colonnade ----------
   Four arched doorways, each a link, each numbered, standing on a sky ground
   under a hairline cornice. It is the site map: without it a guest who reaches
   the bottom of the Beirut Guide has nowhere to go but back up. */
footer {
  padding: 3.5rem clamp(1rem, 4vw, 2rem) 2.5rem;
  background: none;
  color: var(--ink);
  border-radius: var(--r-lg) var(--r-lg) 0 0;
  border-top: 1px solid var(--line);
  /* No margin-top. It was 2rem, and where a photo block is the footer's
     immediate sibling those 2rem of page wash showed between the rounded
     bottom of the picture and the rounded top of the footer, reading as a
     white gap rather than as breathing room. Sections carry their own bottom
     padding, so nothing else needed it. */
}

/* Where an arcade band is the footer's immediate sibling the footer's own top
   radii have to go, because the band is full bleed and its bottom edge is
   straight: the two 24px quarter-rounds cut into the corners of the picture
   and let the page wash through, which is the same defect the old
   `.hero + footer` rule was written for and which came back when .hero
   stopped being the element above the footer.

   Measured on the save-the-date page before this rule, with the footer's own
   ground sampled per row (the footer is a vertical gradient, so a single
   reference colour will not do): of the 576 pixels in each 24x24 corner
   square below the seam, 157 left and 159 right in Chromium and 156/156 in
   WebKit were not the footer -- at 360, 390, 430, 600, 768, 1024 and 1200px
   alike. It reads as a defect only where the photograph actually reaches the
   corner, which is why it was first reported clean: at 1024 and 1200 the
   desktop dome has already masked the picture away from the bottom corners,
   so wash meets wash and the same 157 pixels are invisible. Below 1024 the
   pixel two rows above the seam is photograph in both engines.

   Both sides of the seam, as the deleted rule's own comment records --
   removing one leaves the other one's notches. .arcade's bottom radii measure
   0px today, because its arches are cut with a mask rather than a
   border-radius, so that arm changes nothing this minute; it is here so that
   a band which ever does gain a bottom radius cannot reopen the notch from
   the other side without anybody noticing.

   No margin-top: -1px, unlike the deleted rule. The arcade's bottom and the
   footer's top measure gap = 0.00px at all seven widths in both engines, so
   there is no seam line to pull closed and a negative margin would be an
   unexplained pixel.

   Scoped to .arcade deliberately. The interior pages put a <section> before
   the footer and the home page puts the .graft div there; neither is reached
   by these selectors, so the intentional 24px curve where paper meets the
   colonnade survives -- verified at 24px on program.html and index.html
   after this change. */
.arcade + footer {
  border-top-left-radius: 0;
  border-top-right-radius: 0;
}
.arcade:has(+ footer) {
  border-bottom-left-radius: 0;
  border-bottom-right-radius: 0;
}

.colonnade {
  max-width: 1080px;
  margin-inline: auto;
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: clamp(.5rem, 1.6vw, 1.2rem);
}

/* Primitive B. --foot and --body are the only two things a consumer sets; the
   50cqi head geometry and the min-height that keeps f at 1 come from .arch-b.
   The bay above it carries the container-type. */
.door {
  --foot: 3px;
  --body: 5.5rem;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  align-items: center;
  background: var(--paper);
  border: 1px solid var(--line);
  box-shadow: var(--sh-1), var(--rim);
  padding: 1rem .6rem 1.1rem;
  text-decoration: none;
  color: var(--ink);
  font-size: .95rem;
  letter-spacing: .02em;
  text-transform: none;
  text-align: center;
  line-height: 1.35;
  transition: transform .3s, box-shadow .3s;
}
.door:hover { transform: translateY(-3px); box-shadow: var(--sh-2), var(--rim); }
/* THE SAME FIX as .field's, above, and found the same way: .door IS the
   focusable element (it is the <a> itself, not a wrapper around one), so its
   own unconditional box-shadow above is a second live instance of the Step
   1d regression. .door is one class, (0,1,0); the global :focus-visible
   ring is also (0,1,0) (:where() contributes zero); a specificity TIE is
   broken by source order, and this rule sits below the ring in the
   cascade, so it was winning outright, focused or not -- confirmed in
   Chromium against a fixture carrying a real .door anchor: the focused
   computed box-shadow was the resting shadow alone, halo absent. Restated
   here at (0,2,0), which beats the tie regardless of where either rule
   sits in the file. */
.door:focus-visible { box-shadow: var(--sh-1), var(--rim), 0 0 0 6px var(--blush-deep); }
.door .numeral {
  font-family: var(--display);
  font-size: 1.9rem;
  color: var(--ink);
  letter-spacing: 0;
  text-transform: none;
  line-height: 1;
  margin-bottom: .4rem;
}

.plinth {
  max-width: 1080px;
  margin: 2.5rem auto 0;
  padding-top: 1.6rem;
  border-top: 1px solid var(--line);
  text-align: center;
}
.plinth .script { font-size: 2rem; color: var(--ink); letter-spacing: .02em; }
/* --ink, not --ink-soft. This line sits on the deepest part of the footer
   wash, where --ink-soft measures 4.52 against a 4.5 floor -- the same
   near-miss tools/check-contrast.mjs's NEAR_MISS band and the palette's own
   header comment both carry. A 0.02 margin is not a margin, so it still uses
   full --ink; small tracked-out uppercase is exactly the type that cannot
   afford a soft colour, even a passing one. (This comment previously said
   4.15, predating this phase and never re-checked; the conclusion did not
   change, but the number was wrong.) */
.plinth p {
  font-size: 1.05rem;
  letter-spacing: .01em;
  text-transform: none;
  margin-top: .5rem;
  color: var(--ink);
}
.plinth a { color: var(--ink); text-decoration: underline; text-underline-offset: 3px; }

/* Two bays per row on a phone. Four 90px-wide arches is not a colonnade, it is
   a comb. */
@media (max-width: 760px) {
  .colonnade { grid-template-columns: repeat(2, 1fr); }
}

/* ---------- Motion ----------
   All opacity and transform, none of it moving layout. The reduced-motion
   block near the top of this file already zeroes every animation and
   transition with !important, so this one is neutralised there without
   needing its own opt-out. Do not weaken that block to make something here
   work. */

/* The mark settles in, once, on load. */
@keyframes mark-in {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: none; }
}
.rail .brand img { animation: mark-in 0.6s ease-out both; }

/* Everything below is scoped html[data-motion], which motion.js sets only when
   the guest has NOT asked for reduced motion. The default state -- no
   attribute -- is the finished, visible, static page. Do not add a rule here
   that leaves an element invisible outside that scope. */

/* Sections rise as they arrive. --i staggers siblings; unset it is 0. */
html[data-motion] [data-reveal] {
  opacity: 0;
  transform: translateY(18px);
  transition: opacity .7s cubic-bezier(.22, 1, .36, 1),
              transform .7s cubic-bezier(.22, 1, .36, 1);
  transition-delay: calc(var(--i, 0) * 90ms);
}
html[data-motion] [data-reveal].is-in { opacity: 1; transform: none; }

/* The band settles once, on first paint. This animates `scale`, not
   `transform`: the parallax writes an inline transform to the same element and
   the two would clobber each other. They are separate CSS properties and they
   compose. */
@keyframes band-settle { from { scale: 1.06; } to { scale: 1; } }
html[data-motion] .plate { animation: band-settle 1.2s cubic-bezier(.22, 1, .36, 1) both; }

/* html:not([data-motion]) matches two different guests as one: someone who
   asked for reduced motion, and someone whose JavaScript never ran at all,
   so data-motion was never set -- motion.js's own header comment names both
   explicitly, and this gate is what makes their pages identical. Measured
   with JavaScript disabled: day cards and footer doors both hover to
   transform: none, exactly as under prefers-reduced-motion.
   Hover lift is a transform (.card and .door both set translateY on :hover,
   near their own declarations above), and the reduced-motion block at the top
   of this file zeroes DURATIONS, not transforms -- a hover destination still
   applies under that block, just as an instant jump rather than a tween. That
   is motion under this section's own contract ("visible and un-transformed by
   default"), so it needs the same gate the reveal and the band settle already
   get. Written as the static case (transform: none) under
   html:not([data-motion]) rather than moving the lift itself behind
   html[data-motion], which would leave the no-JavaScript guest's page
   depending on a class that never arrives.
   The third selector this list carried until 2026-09-21 was Where to Stay's
   arched hotel cards (.stay-cards .cell > .card), plus the note on how it
   beat the .niche variant's own unconditional transform: none. Those cards
   are plain .card now -- the first selector already covers them -- and the
   skin is gone from this file entirely ("Where to Stay: the arched cards",
   above).
   Box-shadow is untouched on either side of this gate: it is a resting-to-
   lifted colour and blur change, not a transform, so it carries no motion
   under this file's own definition and stays as the hover affordance for a
   guest who gets no lift. With one skin gone there is no longer a rule scoped
   past the bare .card that has to restate the deepen for itself; if one is
   added again it will have to, since a skin at (0,4,0) outranks .card:hover's
   (0,2,0) on specificity alone. */
html:not([data-motion]) .card:hover,
html:not([data-motion]) .door:hover {
  transform: none;
}

/* ---- Plant motifs -------------------------------------------------------
   Seven photographic cutouts, one gesture per page, graded at build time so
   they sit on the cream rather than on top of it (tools/build-images.sh).
   Every one of them is decoration: none carries meaning, none animates, none
   sits under body copy, and none is in a first viewport. tools/check-plants.mjs
   asserts the last two, because only a browser can. */
.plant {
  display: block;
  pointer-events: none;      /* decoration must never eat a click */
  user-select: none;
  max-width: 100%;
  height: auto;
}

/* The home page's canopy, and the reason it is centred rather than full
   bleed: the source is 1086px wide and build-images.sh does not upscale, so
   a 1440px ceiling is not available from this file at all. That turned out
   to suit it -- the cutout's transparent opening is an arch, the same shape
   as --micro-head and the arcade, and an arch wants to be a centred aperture
   rather than a stretched band. Paper shows either side, exactly as it does
   around the arcade. If the source is ever re-extracted wider, this is the
   rule to revisit.

   It sits above .days, not above .welcome as first placed: section.overture
   has no min-height anywhere in this file, so it is shorter than the
   "full-height hero" the original placement assumed -- measured at 518px
   (390x745) and 623px (1440x900), both well short of the fold. A canopy
   placed directly after it lands partly inside the first viewport, which
   tools/check-plants.mjs treats as a hard failure (no plant may appear in
   the first viewport of any page) and which no mask or crop can fix, since
   a mask never touches layout. Moving the canopy one section further down
   clears the fold without inventing a min-height on .overture that would
   resize the hero to make room for a decoration.

   That placement alone only clears the fold at SHORT viewports, though --
   see margin-top below for the part that holds at any height. */
.plant-canopy {
  width: 800px;
  margin: 0 auto -2.5rem;    /* the negative foot lets the type rise into the opening */
  opacity: .92;

  /* This element is normal flow, not `position: absolute` like the palm's
     and the bough's -- its top is wherever the header, .overture and
     .welcome above it add up to, and none of those three has a min-height
     tied to the viewport. That top is therefore nearly independent of
     viewport HEIGHT -- and strongly dependent on viewport WIDTH, which the
     first derivation of this constant missed entirely: it was measured at
     1440 wide only (937-946px) and 930 was set a few px under that. At
     narrower widths the copy above rewraps into a taller stack and the
     static top drops by up to 100px, so 930 was too large and the canopy
     sat INSIDE the first viewport on ordinary phones -- top=837 on an
     iPhone 15 Pro Max (430x932), top=871 on a Pixel 7 (412x915) -- fetched
     eagerly on the LCP path, which is exactly what spec 3.3 forbids and no
     mask or crop can touch, since neither affects layout.

     790px is the CROSS-WIDTH, CROSS-LANGUAGE MINIMUM, not a single width's
     measurement: the binding sample is 430x800 in English, where the static
     top is 825.6px. Every other sampled width/height/language pair sits
     above that. Verified live afterwards over the same sweep at 10px height
     steps: the worst clearance anywhere is 34.9px, at 430x790 in English.

     HOW TO RE-DERIVE (all SIX of this file's clearance constants --
     the canopy, the palm, the bough, the lead frangipani, the FAQ frame
     and the save-the-date's tree, whose 880 lives in this file too):
     tools/measure-plant-clearance.mjs IS this recipe, already written --
     `--static` to derive, no flag to verify what ships, `--dense` for 10px
     height steps. What it does, so the method survives the script:
     force this rule's margin-top to 0, then read the element's static top
     (getBoundingClientRect().top + scrollY) across a sweep of widths --
     320, 360, 390, 412, 430, 600, 744, 834, 1024, 1180, 1280, 1440, 1920 --
     crossed with heights 600 through 2000, in BOTH languages (French runs
     longer and rewraps differently). For every sampled (width, height) the
     rule must satisfy  static_top + max(<floor>, height - N) >= height,
     with a real margin to spare. N is safe in ONE direction only: too small
     costs whitespace at tall viewports, too large puts the plant on the LCP
     path. Take the cross-width, cross-language MINIMUM, never a single
     width's figure. Then VERIFY by measuring the RENDERED top rather than
     by adding the margin to the static top on paper: margins collapse with
     a preceding sibling, and that arithmetic once produced an edit that was
     correct on paper and failed the gate.

     The floor stays 0px: below ~790px tall the calc term goes negative,
     max() clamps it, and the placement above already clears the fold on its
     own at every such height. */
  margin-top: max(0px, calc(100svh - 780px));

  /* A centred aperture is what the resolution cap forces (above), but it is
     also what exposes the crop: the source was extracted to bleed off-frame,
     which read fine full-bleed and reads as a photograph's rectangular
     border once paper shows on either side (left, right and bottom -- the
     top edge already tapers to nothing in the source, foliage thinning into
     transparency on its own). A mask hides that termination for free -- no
     re-crop, no extra bytes, just the same pixels fading into --paper
     instead of stopping dead against it. Two layers, intersected: a
     horizontal fade so left and right both dissolve, a vertical one that
     only touches the bottom (the "to top" direction starts its 0% at the
     bottom edge and is solid again well before it reaches the top). */
  /* mask-composite/-webkit-mask-composite are read TOP LAYER FIRST: the
     first value says how the first-listed gradient composites with what is
     below it. "intersect" (destination-in) on the first entry is what makes
     this an AND of the two gradients rather than a union -- written the
     other way round, the bottom-fade layer (solid everywhere except the
     bottom strip) stays opaque under the transparent ends of the horizontal
     fade and the left/right edges do not feather at all. */
  --canopy-feather: 64px;
  -webkit-mask-image:
    linear-gradient(to right,
                    transparent, #000 var(--canopy-feather),
                    #000 calc(100% - var(--canopy-feather)), transparent),
    linear-gradient(to top, transparent, #000 var(--canopy-feather));
  -webkit-mask-composite: destination-in, source-over;
          mask-image:
    linear-gradient(to right,
                    transparent, #000 var(--canopy-feather),
                    #000 calc(100% - var(--canopy-feather)), transparent),
    linear-gradient(to top, transparent, #000 var(--canopy-feather));
          mask-composite: intersect, add;
}
@media (max-width: 700px) {
  /* Portrait keeps the canopy but crops to its left mass: at 390px the full
     frame shrinks to the point where the foliage reads as texture, not as
     leaves. object-fit does the crop so the file stays one file.

     The feather distance is a fraction of the box, not of the source pixels
     -- at 390x180 the desktop's 64px would eat a third of the width and a
     third of the height on every edge, dissolving the crop into fog rather
     than hiding its cut. 24px is proportioned to this smaller box instead. */
  .plant-canopy {
    width: 100%;
    height: 180px;
    object-fit: cover;
    object-position: 18% top;
    margin-bottom: -1.5rem;
    --canopy-feather: 24px;
  }
}

/* A gutter plant stands beside the content column, never in it. section is
   max-width 860px centred, so the gutter is (100vw - 860px) / 2 on each
   side: below about 1180px there is no gutter left to stand in and the plant
   goes, rather than shrinking into the margin or overlapping the type. */
.plant-gutter {
  position: absolute;
  z-index: 0;                /* under the type, which has none but paints later */
  opacity: .55;
  display: none;
}
@media (min-width: 1180px) {
  .plant-gutter { display: block; }
}

/* The programme's single palm: one trunk, three days. It is anchored to the
   page rather than to a section, because it should pass all three rather
   than belong to any one of them. */
.plant-palm {
  left: max(1rem, calc((100vw - 860px) / 2 - 320px));
  /* The 1180px display threshold on .plant-gutter guards against a
     negative-width gutter, not a 320px-wide one: at 1180px the gutter is
     only 160px, and it does not reach 320px until ~1532px. Between those
     two points `left`'s max(1rem, ...) clamp holds the trunk against the
     viewport edge while a fixed 320px width would still reach past the
     section's left edge into the type -- measured at 1440x900, a flush
     320px trunk overlapped .lede by 46px. width shrinks with the same
     gutter left uses, so the two stay in lockstep and the right edge never
     passes the section's left edge, at any width the gutter is visible at. */
  width: min(320px, calc((100vw - 860px) / 2 - 1rem));
  /* Viewport-height-relative, not a flat rem: a fixed margin is tuned
     against one viewport height and wrong at every other one. .band.head is
     itself min(64svh, 540px), so the palm's static (pre-margin) position is
     also svh-dependent below the point that cap bites -- it holds flat once
     the viewport is >=843.75px tall (where 64svh caps out at 540px) and
     shrinks below that. A flat 16rem is not enough on its own: at 1920x1000
     it left the top at 903.5px, inside the 1000px screen.

     calc(100svh - 440px) tracks the first viewport's actual height instead.
     455 is the CROSS-WIDTH, CROSS-LANGUAGE MINIMUM, re-derived on 2026-09-22
     after main's programme restructure (13e866d) shed the page's mid-page
     band. That shortened everything above this plant and dropped its static
     top from 657.5px to 483.3px, which left the previous N of 625 far too
     large: the palm shipped 37,092 bytes INSIDE the first viewport at four
     landscape sizes, in both languages, and reached production before the
     gate was re-run. The binding sample is now 1180x600 in English, static
     top 483.3px, less ~28px of margin. 1180 is the narrowest width this
     plant is displayed at at all (below it .plant-gutter is hidden, so
     nothing narrower constrains anything).

     The lesson the number carries: an N derived against one page layout is
     invalidated by any edit to the content ABOVE the plant. Re-run
     tools/measure-plant-clearance.mjs --static after restructuring a page,
     not just after touching a plant.

     max() keeps 16rem (256px) as the floor for shorter screens, where the
     svh term would otherwise undershoot as .band.head's own height drops
     below its 540px cap -- 16rem is itself derived: at every sampled height
     below the crossover, static_top + 256px still clears the viewport.
     See .plant-canopy's HOW TO RE-DERIVE block for the method. */
  margin-top: max(16rem, calc(100svh - 440px));
}

/* .plant-gutter is absolute, so something must be its containing block. body
   rather than main: the palm is anchored to the page. */
body { position: relative; }

/* A ground run sits ON the foot of a section, flush to the footer's top
   line, so the page appears to rest on it. Left-anchored rather than
   centred: a centred ground run reads as an object, an anchored one reads as
   a continuation of something off-frame, which is what a planting is.
   No negative margin into the footer -- footer carries a border-top, and a
   plant crossing it would make the border look broken rather than crossed. */
.plant-ground {
  display: block;
  margin: 0 0 -1px;
  opacity: .88;
}
/* Where to stay's ground run, the frangipani-and-grasses cutout that replaced
   the oleander on 2026-09-24. Two things changed with it, and only one was
   the picture: the oleander also sat MID-PAGE, butting the segmental band,
   which contradicted .plant-ground's own note directly above -- a ground run
   rests on the footer's top line. This one does.

   FEATHER. Measured on the shipped rendition, the fraction of each edge that
   is opaque (alpha > 200) is T=0.00 B=1.00 L=0.18 R=0.30. The bottom being
   wholly cut is not a defect: the footer's top line covers it, which is what
   a .plant-ground is for. The top needs nothing -- the source's own
   silhouette is already clear of its frame, and build-images.sh carries the
   note about the top crop that briefly was NOT clear of it. This feathers
   left and right ONLY, and
   needs just one gradient where .plant-canopy needed two intersected. No
   mask-composite: a single layer has nothing to composite with, and naming
   one here would re-open the -webkit- subtractive trap for no gain. */
.plant-grasses {
  width: 420px;
  margin-left: clamp(1rem, 6vw, 5rem);
  --grasses-feather: 48px;
  -webkit-mask-image:
    linear-gradient(to right,
                    transparent, #000 var(--grasses-feather),
                    #000 calc(100% - var(--grasses-feather)), transparent);
          mask-image:
    linear-gradient(to right,
                    transparent, #000 var(--grasses-feather),
                    #000 calc(100% - var(--grasses-feather)), transparent);
}
@media (max-width: 700px) {
  /* The narrow rendition is 300px wide and the phone paints it at ~78vw, so
     the feather comes down with it -- 48px on a 300px box would eat a third
     of the picture from each side. */
  .plant-grasses { width: 78%; margin-left: 0; --grasses-feather: 28px; }
}

/* The guide's bough hangs into the top-left of the card grid. It reuses
   .plant-gutter's "no gutter, no plant" rule but not its vertical anchoring:
   a bough is cut at its top edge, so it must hang FROM something -- here the
   top of the Eat group -- rather than float at a page offset. */
.plant-bough {
  left: max(0px, calc((100vw - 860px) / 2 - 300px));
  /* Spec 3.3 is absolute -- "no text overlaps them at any viewport" -- and a
     flat 640px did not hold that at any width the gutter is visible at: at
     1440x900, measured, a 640px bough at this `left` covered the Eat
     heading and the whole first card, 616px past where the card column
     starts. width: min(300px, gutter) keeps the two calcs in lockstep the
     same way .plant-palm's width does -- 300px is the same number `left`
     already subtracts, so whenever left is clamped to 0 (gutter < 300px)
     width shrinks to the gutter itself, and whenever it is not (gutter >=
     300px) width caps at exactly the 300px left leaves spare. Either way
     left + width == gutter, flush against the section's left edge, never
     past it.

     That 300px cap is reachable at every viewport this rule is visible at
     (>=1180px, .plant-gutter's threshold) and never exceeded by any of
     them -- there is no viewport where this element is wider than 300px.
     Shipping the source at 640px and clamping in CSS meant 62,070 bytes of
     pixel detail (14% of the whole plant budget) that no guest could ever
     see. tools/build-images.sh now renders bough.webp at 320px: the
     gutter, not the source, is what caps this element, so the source was
     cut down to match rather than shipped wide. 320 rather than exactly
     300 leaves a small margin of native resolution above the CSS ceiling,
     same as every other plant carries against its own display size, so
     nothing is upscaled even right at the cap. */
  width: min(300px, calc((100vw - 860px) / 2));
  opacity: .5;
  /* Placed as a sibling before <section data-reveal>, not as its first
     child: [data-reveal] carries a `transform` (motion.js's reveal
     animation, see the html[data-motion] rules below) whenever a section
     has not yet scrolled into view, and a transformed ancestor becomes the
     containing block for an absolute descendant regardless of `position`.
     The Eat section starts below the fold, so this img would resolve
     `left` against the SECTION's box instead of body's -- measured, that
     doubled the gutter offset and put the bough behind the Eat heading and
     the first card. Outside the section, body -- always untransformed --
     is the containing block, exactly like the palm's placement in Task 5.

     4rem cleared the first viewport at 900px tall (top 911.5px), the same
     way the palm's flat 16rem did -- and fails the same way at a taller
     screen: the img's static (pre-offset) position holds flat at 847.5px at
     1440 wide for any height >=900px (it sits below the same capped-at-540px
     .band.head the palm does, plus one more fixed-height band), so a taller
     viewport outgrows a flat rem offset just as fast as it outgrows the
     first viewport. Hence the same calc(100svh - Npx) pattern as
     .plant-palm.

     815px is the CROSS-WIDTH, CROSS-LANGUAGE MINIMUM. The first derivation
     used 840 -- 7.5px under the single 1440-wide measurement -- and that
     margin is not real, because the static top also moves with WIDTH. The
     binding sample is 1180x900 in English, static top 847.5px; 1180 is the
     narrowest width the bough is displayed at at all (.plant-gutter's media
     query hides it below that), so no narrower width constrains it.
     Verified live afterwards over the same sweep at 10px height steps: the
     worst clearance anywhere is 32.5px, at 1180x880 in English.

     max() keeps 4rem as the floor for shorter screens, where .band.head's
     own height (and so the img's static position) shrinks below the 540px
     cap; at every sampled height below the crossover, static_top + 4rem
     still clears the viewport, which is why this floor -- unlike
     .plant-frame's, which did not -- can stay where it is. See
     .plant-canopy's HOW TO RE-DERIVE block for the method. */
  margin-top: max(4rem, calc(100svh - 440px));

  /* Same problem .plant-canopy solves, different two edges: the source was
     extracted to bleed off-frame on every side, but only two of those sides
     still show a cut once the bough is placed. The left edge runs off the
     viewport at every width the gutter is visible at (see .plant-gutter's
     media query), so paper never shows beside it there is nothing to
     feather. The top edge is the bough's OWN cut -- it hangs from a branch
     sheared off-frame above it -- and that reads as correct without help,
     the same way the canopy's top edge already tapers to nothing in the
     source. That leaves the right edge (paper shows past it once the
     rendered width -- see the width: min() above -- is narrower than the
     box the mask was designed against) and the bottom edge (the underside
     of the mass, hanging free with paper below) as the two terminations
     that still read as a photograph's rectangular border. */
  --bough-feather: 56px;
  -webkit-mask-image:
    linear-gradient(to left, transparent, #000 var(--bough-feather)),
    linear-gradient(to top, transparent, #000 var(--bough-feather));
  -webkit-mask-composite: destination-in, source-over;
          mask-image:
    linear-gradient(to left, transparent, #000 var(--bough-feather)),
    linear-gradient(to top, transparent, #000 var(--bough-feather));
          mask-composite: intersect, add;
}

/* The frangipani is the one cutout that works as a MARK rather than as
   scenery: small, radially symmetric, and already white-and-yellow, which is
   to say already inside the palette -- it is the only rendition
   build-images.sh does not grade. It is the through-line that makes six
   different page gestures read as one system.
   A background rather than an <img>: it repeats too often per page to be
   worth a DOM node each. */
.plant-mark,
details > summary::before {
  background-image: url('assets/plants/mark.webp');
  background-size: contain;
  background-repeat: no-repeat;
}
/* Sits as the first flex item in summary (display: flex, justify-content:
   space-between, from the details rules above), ahead of the question text
   and the arch chevron. list-style: none on summary already suppresses the
   native marker (and its ::-webkit-details-marker restatement for older
   Safari), so this is the only marker any engine paints -- no double bullet,
   no double indent. */
details > summary::before {
  content: '';
  flex: none;
  display: inline-block;
  width: 14px;
  height: 14px;
  margin-right: .6rem;
  vertical-align: baseline;
  opacity: .8;
}

/* The section divider, the frangipani's other job. */
.plant-mark {
  width: 28px;
  height: 28px;
  margin: 3.4rem auto;
  opacity: .75;
}

/* One oversized bloom behind the FAQ -- the ONLY place on this site where
   type composites over a plant, which is why check-contrast.mjs carries a
   pair for it: --paper blended 90/10 with the bloom's darkest opaque pixel
   (its yellow throat, rgb(114, 29, 0)) against the body ink, measured rather
   than assumed at 13.38:1 against the 4.5 floor body copy owes. Do not raise
   this opacity without re-deriving that pair.

   .plant-frame is the containing block, not body. body already carries
   position: relative for .plant-palm and .plant-bough (see body's own rule,
   above), which makes it the containing block for ANY absolutely positioned
   descendant, including this one -- and `right: -120px` resolved against
   body pushes the whole document 120px wider, raising a horizontal
   scrollbar on every FAQ viewport (confirmed: document.documentElement.
   scrollWidth > clientWidth at 390/700/1180/1440/1920 before this wrapper
   was added). .plant-frame's own overflow: hidden clips exactly that 120px
   excess -- and nothing else, since its height is fixed to the bloom's own
   760px rather than to auto, so overflow: hidden never touches the answers
   below it, which live in the sibling <section> this frame's height is
   collapsed out from under (margin-bottom: -760px, the same
   take-no-space-of-its-own technique .plant-canopy's negative foot uses). */
.plant-frame {
  position: relative;
  overflow: hidden;
  height: 760px;
  z-index: 0;
  pointer-events: none;

  /* THIS WRAPPER MUST CONTRIBUTE ZERO FLOW HEIGHT. Decoration does not move
     the guest's content -- .plant-canopy uses a negative foot, the ground
     runs sit flush, the gutter plants are absolutely positioned, and this
     is the one that got it wrong. margin-bottom: -760px cancelled the
     760px HEIGHT, correctly, but the clearance margin-top below was still
     in flow, so the wrapper's net contribution was the clearance, not
     zero. It pushed the first question down by 161px at 745-tall viewports
     -- at 1280x745 the band ended at 594px and the first <details> started
     at 940px, which is a third of a screen of empty cream before the guest
     reaches anything to read. Raising the floor to 16rem is what made it
     visible; it was wrong at 4rem too, just by less.

     The negative bottom margin therefore cancels BOTH terms:

       margin-top + height + margin-bottom
         = C + 760px + (-760px - C)
         = 0, at every viewport, for every value of C.

     The clearance lives in --bloom-clearance and is referenced twice so
     the two expressions CANNOT DRIFT. If the 610px is ever re-derived and
     changed in only one of them, the wrapper stops summing to zero and the
     gap comes back silently, with nothing in the diff to say why. Keep it
     one custom property. */
  --bloom-clearance: max(16rem, calc(100svh - 420px));
  margin-bottom: calc(-760px - var(--bloom-clearance));
  /* Sits right after .band.head, same as .plant-palm, and measures nearly
     the same static top -- 657.5px at 1440 wide with margin-top at 0, for
     any height >=900px, since .band.head is itself min(64svh, 540px) and
     holds flat once a viewport is tall enough to hit that cap. It shrinks
     with the viewport below that cap, and it shrinks with WIDTH as the copy
     above rewraps taller.

     610px is the CROSS-WIDTH, CROSS-LANGUAGE MINIMUM. The binding sample is
     744x900 in English, static top 641.8px. 744 is the narrowest width that
     constrains this rule at all: .plant-bloom -- the only plant this frame
     carries -- is display:none at <=700px (see its media query below), and a
     plant that is not laid out cannot be in anyone's first viewport. The
     previous 650 came from the single 1440-wide figure and put the bloom at
     top=630 in a 1280x700 window. Verified live afterwards over the same
     sweep at 10px height steps: the worst clearance anywhere is 31.8px, at
     744x870 in English.

     THE FLOOR IS 16rem, NOT 4rem, and that is the second half of this fix.
     The 4rem it carried was inherited from .plant-palm's calc without
     .plant-palm's floor, from the same 657.5px base -- and the floor is a
     separate constraint that no value of N can rescue: below the crossover
     at height = N + floor, the calc term loses the max() and the rule is
     the floor alone, so it must satisfy static_top + floor >= height by
     itself. At 744x745 the static top is 578.6px, which needs 194.4px; 4rem
     is 64px, so the rule failed at every width above 700 whenever the
     viewport was shorter than ~845px. 16rem (256px) satisfies every sampled
     point below the crossover, and matches .plant-palm, whose geometry this
     rule shares. See .plant-canopy's HOW TO RE-DERIVE block for the
     method. Changing this number means changing --bloom-clearance above,
     which is the single place it is written. */
  margin-top: var(--bloom-clearance);
}
.plant-frame + section {
  position: relative;
  z-index: 1;
}
.plant-bloom {
  position: absolute;
  top: 0;
  right: -120px;
  width: 760px;
  opacity: .10;
}
@media (max-width: 700px) {
  /* No room for it beside a 390px column, and shrinking it makes a grey
     smudge rather than a flower. */
  .plant-bloom { display: none; }
}

/* The card's tree, reduced harder than any other plant in the set. This page
   has ONE job -- be asked, and answer, without scrolling
   (tools/check-first-screen.mjs) -- so a plant here is permitted only to the
   extent that it never competes with the form and never moves it. It is
   therefore below the card, low, and faint. */
.plant-tree {
  /* 360px, down from 420px on 2026-09-24: this folder's whole ceiling is
     120 KiB and it now carries two widths (360 + 280 = 107,510 bytes). The
     880px clearance constant below is NOT affected -- it is derived from
     this element's static TOP, which is set by the header, arch and ask form
     above it, none of which this width touches. A shorter tree can only
     increase the clearance it already has. */
  width: 360px;
  opacity: .5;
  margin: 0 auto;

  /* Normal flow, like .plant-canopy: its static top is wherever the header,
     the arch and the ask form above it add up to, and none of those has a
     viewport-relative height -- so the top barely moves with viewport
     HEIGHT, and moves a great deal with WIDTH, as the names and the ask
     rewrap. The first derivation measured 1440 wide only (1043px at 900
     tall, flat at 1062px from 1100 on) and set 1050 with a 12px margin.
     That margin is not real at any other width: at 834x1194, the iPad Pro
     11 in portrait, the tree landed at top=1193 -- inside the first
     viewport of the one page on this site whose entire job is to be asked
     and answered without scrolling.

     880px is the CROSS-WIDTH, CROSS-LANGUAGE MINIMUM. The binding sample is
     390x932 in English, static top 914.6px. Verified live afterwards over
     the same sweep at 10px height steps: the worst clearance anywhere is
     33.0px, at 390x920 in English.

     max(2rem, ...) keeps the original 2rem gap below the ~880px crossover,
     where at every sampled point the static top plus 2rem already clears
     the fold on its own. See .plant-canopy's HOW TO RE-DERIVE block for the
     method; tools/check-first-screen.mjs independently guards the card
     itself. */
  margin-top: max(2rem, calc(100svh - 880px));

  /* FEATHER, added 2026-09-24. This rendition was shipping with hard straight
     cut edges and had been since it landed -- opaque-edge fractions T=0.40
     R=0.59, against a cream page -- camouflaged by opacity .5 rather than
     fixed by it. The cut pixels are dark olive foliage (the right edge
     averages rgb(92,92,45)), not sky, so they terminate against --paper at
     high contrast. Bottom and left measured 0.00 and 0.02 and are left
     alone. Two layers intersected, the same reading order .plant-canopy's
     block explains: first value composites the first-listed gradient. */
  --tree-feather: 40px;
  -webkit-mask-image:
    linear-gradient(to left, transparent, #000 var(--tree-feather)),
    linear-gradient(to bottom, transparent, #000 var(--tree-feather));
  -webkit-mask-composite: destination-in, source-over;
          mask-image:
    linear-gradient(to left, transparent, #000 var(--tree-feather)),
    linear-gradient(to bottom, transparent, #000 var(--tree-feather));
          mask-composite: intersect, add;
}
@media (max-width: 700px) {
  .plant-tree { width: 68%; opacity: .42; --tree-feather: 24px; }
}
