/* =============================================================================
   Mandy Spanier - site styles

   PREVIEW BRANCH BUILD TRIGGER (remove before this branch is merged to main).
   The first push to a new branch is silently skipped by the ignore rule in
   netlify.toml, because an empty CACHED_COMMIT_REF makes it report "nothing
   changed" and exit 0. A second commit that touches a real published file is
   what actually starts the build. An empty commit does NOT work: the rule
   compares changed FILES, and an empty commit changes none. This comment is
   that second commit. See .webteam/runbooks/preview-deploy.md.

   This is the only stylesheet on the site. Every page loads it, including the
   blog pages build.py generates. The design values (colour, spacing, type
   size, radius, shadow, motion) are declared once as custom properties in the
   :root block below, and every rule after that uses those names rather than
   raw values.

   HOUSE RULES FOR EDITING THIS FILE:
     - Plain CSS only. No Sass, no PostCSS, no build step beyond build.py.
     - Colours, spacing, radii and type sizes come from the tokens in :root.
       Do not paste a raw hex value or a raw pixel number into a rule.
     - Text colours are chosen to meet the WCAG AA contrast minimums against
       the surface they sit on. Do not lighten one without rechecking it.
     - Breakpoint numbers cannot be written as custom properties in plain CSS,
       so 640px and 720px are typed out by hand in the @media rules below.
       If one moves, move every copy of it.
   ============================================================================= */

:root{
  /* ---- Surface ---------------------------------------------------------- */
  --color-surface-page:#F7F5F0;
  --color-surface-raised:#FFFFFF;
  --color-surface-sunken:#EEEAE0;
  /* Mandy's ground, and it appears nowhere else on this site: oat means her
     long-form voice (components.md 21.1). A third step in the same warm family,
     not a new hue - page is hue 43, sunken is hue 43, this is hue 45, all at
     24-30% saturation. It is a 1.28:1 step off the page cream, where the
     existing page-to-sunken step is only 1.10:1, so the boundary is visible
     without a border. Measured pairings on it, do not change one without
     rechecking all four:
       --color-text-primary        10.58:1  PASS, clears AAA at every size
       --color-brand-on-narrative   6.74:1  PASS, every green word and mark
       --color-brand-primary        4.37:1  FAILS the 4.5:1 body floor
       --color-text-secondary       4.09:1  FAILS. BANNED on this ground.
     If a caption is ever needed on oat it is ink, not secondary. */
  --color-surface-narrative:#DFDACB;
  /* WO-162 / components.md 23.1. A NEW HEX, deliberately, and the only new
     ground colour this wave. A fourth rung in the same warm ramp: hue 42,
     saturation 29%, sitting between --color-surface-page (hue 43, 30%) and
     --color-surface-sunken. It is the warmer LEFT half of the turn's two-tone
     split, and it exists because neither existing token gives a usable step
     against white: page is 1.09:1, below the 1.10:1 this file already records
     as too weak to read as a boundary, and sunken is 1.20:1, close enough to
     the 1.28:1 section boundary that the turn would read as two sections
     instead of one pivot. This is 1.14:1.
     Measured pairings on it, do not change one without rechecking all three:
       --color-text-primary     12.99:1  PASS
       --color-brand-primary     5.37:1  PASS, so the turn's green accent words
                                         need NO recolour rule on this ground
       --color-text-secondary    5.02:1  PASS
     Relative luminance 0.8726 against oat's 0.7014, so the turn's warmer half
     is still substantially lighter than the oat bands above and below it,
     which is what keeps the turn reading as the one break in the oat field. */
  --color-surface-turn-warm:#F3F0E9;

  /* ---- Text ------------------------------------------------------------- */
  --color-text-primary:#1B2B26;
  /* Dark enough to clear 4.5:1 on the beige "alt" sections as well as on the
     page background. Do not lighten it without rechecking both. */
  --color-text-secondary:#5A6A63;
  --color-text-muted:var(--color-text-secondary);
  --color-text-inverse:#FFFFFF;

  /* ---- Border ----------------------------------------------------------- */
  /* Only for boundaries that ALSO carry a shadow as an independent cue
     (cards, post items, the embed frame). Never as the sole boundary of an
     interactive control: it is too faint to see on its own. Use
     --color-border-strong for those. */
  --color-border-subtle:rgba(27,43,38,.12);
  /* Solid, no transparency, on purpose. A see-through border's real contrast
     depends on whatever happens to sit behind it, so it cannot be relied on.
     A solid colour has one answer everywhere: 4.05:1 on the page background,
     4.41:1 on white, 3.67:1 on the beige. */
  --color-border-strong:#6E7B75;
  --color-border-focus:#1B2B26;
  --color-focus-halo:#FFFFFF;

  /* ---- Brand ------------------------------------------------------------ */
  --color-brand-primary:#1F6E5A;
  --color-brand-primary-hover:#164F41;
  --color-brand-primary-active:var(--color-brand-primary-hover);
  /* Brand green at TEXT weight on the oat ground above. --color-brand-primary
     measures 4.37:1 there, under the 4.5:1 body floor, so every green word and
     every green mark that sits on oat uses this instead and gets 6.74:1. One
     rule per ground, deliberately, rather than a per-element size test.
     It is also the site-wide a:hover colour, which is what made footer links
     nearly vanish on the footer's own ground; on oat it is 6.74:1 and on the
     turn's white band 9.42:1, so neither new ground can repeat that failure
     (components.md 21.1). */
  --color-brand-on-narrative:var(--color-brand-primary-hover);   /* #164F41 */
  --color-on-primary:#FFFFFF;
  --color-gold:#B9862B;

  /* ---- Footer ground (hero-image-direction.md B.2) ----------------------
     No new colour enters the system: the footer's ground is the existing
     brand hover green. It is here because the footer used to sit on white
     against a cream page, 1.09:1, which is not a visible region at all.
     Measured pairings, do not change one without rechecking all four:
       ground vs. page background              8.65:1
       --color-text-on-footer on the ground    9.42:1
       --color-text-on-footer-muted on it      6.69:1  (4.5:1 floor)
       --color-rule-on-footer on it            3.25:1  (3:1 non-text floor) */
  --color-surface-footer:var(--color-brand-primary-hover);
  --color-text-on-footer:var(--color-text-inverse);
  --color-text-on-footer-muted:rgba(255,255,255,.80);
  --color-rule-on-footer:rgba(255,255,255,.45);

  /* ---- Status ----------------------------------------------------------- */
  --color-status-notice-bg:#FFF7E6;
  --color-status-notice-text:#7A5A12;
  --color-status-notice-border:var(--color-gold);
  --color-status-success-bg:#E7F2EC;
  --color-status-success-text:var(--color-brand-primary-hover);
  --color-status-danger-bg:#FBEAE3;
  --color-status-danger-text:#7A3F26;
  --color-status-info-bg:var(--color-surface-sunken);
  --color-status-info-text:var(--color-text-secondary);

  /* ---- Typography ------------------------------------------------------- */
  --font-serif:"Iowan Old Style","Palatino Linotype",Georgia,serif;
  --font-sans:"Segoe UI",system-ui,-apple-system,"Helvetica Neue",Arial,sans-serif;

  --text-2xs:.75rem;    /* 12px. The smallest size used anywhere on the site. */
  --text-xs:.8rem;
  --text-sm:.9rem;
  --text-base:1rem;
  --text-md:1.1rem;
  --text-eyebrow:1.2rem;  /* 19.2px. Read by .eyebrow alone (components.md 22.4). */
  --text-lg:1.3rem;
  --text-xl:1.5rem;

  --text-display-quote:clamp(1.3rem,3vw,1.9rem);
  /* One rung BELOW --text-display-quote, added for Paul's 2026-08-12
     instruction: about 7% off the promoted sentences in Mandy's narrative and
     About's closing turn, matching the same ~7% the narrative body voice came
     down by, so the gap between her body voice and her promoted lines is the
     one it always was rather than being quietly widened.

     WHY THIS IS A NEW RUNG AND NOT AN EDIT TO --text-display-quote ABOVE.
     Four rules read --text-display-quote and only three of them were in that
     instruction. Bookings' .offer-line ("There is no pressure, just a real
     conversation...") was NOT: its block is WIDENING instead (see the
     .section-head:has(.offer-line) rules further down), which is a different
     remedy for a different complaint. Moving the shared rung would have
     shrunk it silently as a side effect. A token rather than three literal
     clamps so the three lines that DO share this treatment cannot drift
     apart later. 19.2px floor, 28px ceiling. */
  --text-display-quote-soft:clamp(1.2rem,2.8vw,1.75rem);
  /* ABOUT'S CLOSING TURN ALONE, AND NOT A RUNG OF THE TYPE SCALE. WO-208 T4,
     from Paul's "slightly smaller, less shouty".

     WHY A NEW TOKEN RATHER THAN AN EDIT TO --text-display-quote-soft ABOVE.
     Exactly the precedent that token itself set, one rung up: it is read by
     several rules and only About's closing line was in this instruction, so
     moving it would have shrunk Home's two promoted narrative lines and its
     closing ask as a silent side effect.

     WHY IT IS A max() AND NOT A PLAIN CLAMP, which is the whole point of this
     token. The clamp half is the reduction Paul asked for: ceiling 28px down to
     25.6px. The max() half is a floor against her own
     body voice. About's "My Approach" paragraphs read --text-narrative, which
     is 17.28px at 390 but climbs to 24.8px at 1920. A plain 25.6px clamp was
     BUILT AND RENDERED at 1920 and the closing line came out the same size as
     the paragraph above it: the promotion vanished and it read as a fourth
     paragraph. 1.1em holds it at 1.10x the body voice wherever the body voice
     has caught up. Do not flatten this to a clamp.

     The em term resolves against .narrative, the parent, which is what carries
     the body voice. That context dependence is exactly why this is named for
     the one block it serves and is not offered as a general scale rung.

     THE FLOOR, 1.2rem -> 1.35rem -> 1.46rem (WO-212 T1, correcting WO-210 T1,
     settling WO-208 T5). Sketch was right that the turn was invisible on a
     phone at 1.11x the body, and the apparent deadlock with Paul's reduction
     was never two rulings about two widths: it was ONE instruction in the wrong
     UNIT. Written as 1.35em it resolves against her body voice, which climbs
     with the viewport, so it grew FASTEST at exactly the widths where Paul
     asked for less. In rem the conflict does not exist, because the wide end is
     not held by this number at all: 2.5vw overtakes 23.36px at 934px of
     viewport, so from 1024 up the floor is simply absent from the result.

     WHY 1.46 AND NOT 1.35. Swapping the unit and keeping the digits was an
     arithmetic error, not a ruling: Sketch asked for 1.35 TIMES HER BODY VOICE,
     and 1.35rem against a 17.28px body delivers 1.250x, not 1.35x. 1.46rem is
     23.36px, which is 1.352x that body. Same instruction, correct unit.
     Measured headed at 390 (clientWidth 375, measure 331.00px): 21.60 ->
     23.36px, 1.250x -> 1.352x, five lines either way. 360 and 414 likewise
     23.36px. 1024 / 1280 / 1440 / 1920 unchanged at 25.60 / 25.60 / 25.60 /
     27.28, to the hundredth of a pixel, which is Paul's reduction untouched.

     Do NOT chase a line COUNT with this number. That target was withdrawn by
     its own author: buying four lines at 390 costs a turn nearly the same size
     as the paragraph above it and a last line filling 98 percent of the
     measure, which reads as text that ran out of room. The orphan it was a
     proxy for is fixed by text-wrap:pretty on the rule itself, not here. No
     value of this floor fixes it: 23 of 84 width x face cells strand the final
     word without that property and 0 of 84 with it (WO-212 T2).
     Do NOT add a min(max(...)) cap over this. It was tried and rejected on
     measurement: it lifts 1440 from 25.60 to 27.20 and partially undoes Paul's
     reduction at a width nobody was watching. The arithmetic above already
     holds the wide end; a cap is redundant there and harmful here.

     This token is mirrored in .webteam/04-ui/tokens.css (WO-212 T4). The two
     must stay identical. */
  --text-about-turn:max(1.1em, clamp(1.46rem,2.5vw,1.6rem));
  --text-display-sm:clamp(1.7rem,3.5vw,2.4rem);
  --text-display-md:clamp(1.9rem,4.5vw,2.8rem);
  --text-display-lg:clamp(2.2rem,5.5vw,3.6rem);

  /* The size of Mandy's own long-form voice: Home's paced narrative and About's
     "My Approach". One token, both places, so the two cannot drift apart
     (components.md 20.2). 18.4px floor from 390 to 1150, then a ramp, 23.04px
     at 1440, ceiling 24px from 1500. It steps once more past 1920, in the WIDE
     VIEWPORTS query below, and nowhere else.

     PAUL, 2026-08-12: down about 7% across the whole ramp. Floor 18.4 ->
     17.28px, ceiling 24 -> 22.4px, with the vw ramp between them eased from
     1.6vw to 1.5vw so the two ends still meet at roughly the same viewport
     widths as before. This is the token itself and not a scoped override
     BECAUSE EVERY ONE OF ITS CONSUMERS WAS IN THAT INSTRUCTION: .narrative is
     the only rule that reads this token, and the only elements inside a
     .narrative that inherit it (rather than carrying a promoted size of their
     own) are Home's five paced body lines from "Now life is changing" down,
     the turn's second line, her five Imagine lines (WO-162: plain beats now,
     not list items), and About's two "My Approach" paragraphs. Nothing else on
     the site moves.

     The measure moves WITH the type and is supposed to: .narrative caps at
     min(--measure-prose,62ch) and ch is relative to this size, so the line
     still holds about 65 characters, and the hung lead-in's column 2 is capped
     the same way, from the same expression, for the same reason. Measured while
     WO-162 was building that column: 1ch in this serif stack is exactly 0.5em,
     so 62ch is exactly 31 x this token, and .narrative-lead renders 535.67px at
     1024, 669.59px at 1440 and 768.80px at 1920. Recorded because any rule that
     needs her measure OUTSIDE .narrative cannot use ch (it would resolve
     against that element's font-size, not hers) and must use that multiplier. */
  --text-narrative:clamp(1.08rem,1.5vw,1.4rem);

  /* Two hero headline sizes, chosen by the length of the headline copy, so the
     hero works whichever way that copy goes (hero-image-direction.md A.2/A.3).
     --text-hero-long is for a full-sentence headline like the one live today
     (91 characters); --text-hero-short is for a headline of 45 characters or
     fewer. The rule that keeps either honest: the h1 must never render more
     than 4 lines at 1440px or 5 at 390px. If it does, step down one token. Do
     NOT put a `ch` cap back on .hero h1: that cap is what starved the headline
     into seven lines in the first place. */
  /* The ceiling is 2.8rem (44.8px) and NOT the 3.25rem (52px) the spec
     computed. The spec derived 52px from an estimated character advance of
     about 25px; the real advance in this serif is nearer 29px, and 52px
     measured FIVE rendered lines at 1440, 1920, 2560 and 3840 in Chromium,
     against the spec's own gate of four. 2.8rem measures four at every one of
     those widths and five at 390px, which is the gate exactly. This is the
     spec's own remedy ("step down one token"), applied to the token's ceiling
     so the token keeps its name and its contract. The `3.6vw` ramp under the
     ceiling is unchanged. */
  /* 2.68rem = 42.9px ceiling, lowered from 2.8rem (44.8px) 2026-08-20, and
     the ceiling is now doing a specific job. Paul asked for this headline in
     THREE lines. The three qualities take the third on their own, so the
     setup gets two, and at 44.8px it took three at both 1280 and 1440: the
     copy column is 583px at its narrowest and the setup needs about 27
     characters a line to make it in two. Swept 44.8 down in 1px steps at both
     widths; 43px is where it turns over, at both. Used only by .hero h1. */
  --text-hero-long:clamp(1.9rem,3.6vw,2.68rem);
  --text-hero-short:clamp(2.2rem,5vw,4rem);       /* 64px ceiling */

  /* ---- Spacing (4px scale) ---------------------------------------------- */
  --space-1:4px;
  --space-2:8px;
  --space-3:12px;
  --space-4:16px;
  --space-5:20px;
  --space-6:24px;
  --space-7:32px;
  --space-8:44px;   /* also the minimum touch target */
  --space-9:64px;
  /* The page's left and right margin. Deliberately kept at 22px rather than
     snapped to 24px: it is the single most visible spacing value on the site
     and a 2px change buys nothing. */
  --space-gutter:22px;

  /* ---- Radius ----------------------------------------------------------- */
  --radius-sm:8px;
  --radius-md:12px;
  --radius-lg:16px;
  --radius-full:999px;

  /* ---- Border width ----------------------------------------------------- */
  --border-thin:1px;
  --border-md:1.5px;
  --border-thick:3px;

  /* ---- The narrative passage's derived layout anchors (WO-162) ------------
     components.md 24.3, adopted as MECHANISM and fed section 23's values.
     Both tone edges and both grids read these, so a grid column and a
     background stop are provably unable to desync: there is no second copy of
     the arithmetic to forget to update.

     The percentages inside them resolve at the USE SITE, which is why they are
     declared here and consumed on the full-bleed sections. .narrative-turn,
     .narrative-recognition and .narrative-divider are all full-bleed and all
     have zero horizontal padding, so 100% and 50% resolve to the same box on
     all three. Do not consume these inside .wrap: there they would resolve
     against the 1036px content box and silently place every edge wrong. */
  --content-width:min(calc(var(--container-max) - 2 * var(--space-gutter)),
                      calc(100% - 2 * var(--space-gutter)));
  --content-left:max(var(--space-gutter), calc(50% - var(--content-width) / 2));

  /* THE TURN'S TONE EDGE: the centre of the gutter, NEVER 50%. At the 5fr/7fr
     split a 50% edge lands about 518px into the content box while the right
     column starts at 464px, so it would run straight through beat 2's text
     (next-chapter-layout.md 9.7). Derived from the same three values as the
     grid below it, in one expression. Resolved: 42.5% at 1024, 44.3% at 1440,
     45.3% at 1920, 47.7% at 3840. It sits inside the gutter at every width,
     and that is the invariant, not any one of those numbers. */
  --turn-gap:56px;   /* 9.7's value. Read by BOTH the turn's column-gap and the
                        stop below. Not on the --space-* scale, which runs 44
                        then 64; a deliberate exception, recorded rather than
                        snapped, because the 5fr/7fr arithmetic depends on it. */
  --turn-edge:calc(var(--content-left)
                 + (var(--content-width) - var(--turn-gap)) * 5 / 12
                 + var(--turn-gap) / 2);

  /* There is deliberately NO --recognition-edge here. It was built, rendered and
     removed in the same pass; the reasoning is on .narrative-recognition below
     and the expression is held in .webteam/04-ui/tokens.css. It is not left in
     as an unused token, because an unused token is a value nobody can check. */

  /* ---- The arch (WO-162 / components.md 23.4-23.6) ------------------------ */
  /* The single source of truth for the divider's depth. Nothing else defines a
     height for that shape, and .narrative-recognition's bottom padding is
     COMPUTED from this and never typed beside it. 28px at 320 and 390 (the
     floor wins below 560), 51px at 1024, 72px at 1440, 88px from 1760 up. The
     shape gets flatter as the viewport widens, deliberately: a curve that kept
     its angle would be 192px deep at 3840 and would spend a fifth of a screen
     height on decoration. */
  --divider-travel:clamp(28px,5vw,88px);
  /* The section's own vertical padding, hoisted UNCHANGED IN EFFECT from the
     `section{padding:...}` rule below, so the clearance calc can consume it
     instead of restating the number. If this moves, clearance moves with it.
     components.md 24.6, adopted as mechanism. */
  --space-section-y:clamp(40px,6vw,72px);
  /* The arch itself. Read as: the boundary starts at the block's bottom-left,
     holds flat along the bottom until about 38% of the width, then sweeps up
     and flattens into the top-right corner. Horizontal tangents at BOTH ends,
     so the shape meets both edges without a corner, which is the entire point
     of choosing an arch over the reference's wedge. Not a symmetric dome: a
     dome puts the deep point in the centre, which breaks 9.10's structural
     rule that the deep side is the left.

     preserveAspectRatio='none' is load-bearing. It decouples the curve from the
     viewport's proportions entirely, so the rendered depth is exactly
     --divider-travel at every width and no more.

     The path fill is WHITE on a TRANSPARENT ground on purpose: that renders
     correctly under both mask-mode:alpha and mask-mode:luminance, so the shape
     cannot invert on a browser that defaults the other way. */
  --divider-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' preserveAspectRatio='none'%3E%3Cpath d='M0,100 C38,100 55,0 100,0 L100,100 Z' fill='%23fff'/%3E%3C/svg%3E");

  /* ---- Shadow ----------------------------------------------------------- */
  --shadow-sm:0 1px 2px rgba(27,43,38,.06);
  --shadow-md:0 1px 2px rgba(27,43,38,.05),0 12px 28px -14px rgba(27,43,38,.22);
  /* An optional deeper hover lift. Defined so the scale is complete, and
     deliberately not applied to anything today. */
  --shadow-lg:0 2px 4px rgba(27,43,38,.08),0 20px 40px -16px rgba(27,43,38,.28);

  /* ---- Motion ----------------------------------------------------------- */
  --motion-fast:150ms ease-out;
  --motion-base:200ms ease;

  /* ---- Layout ----------------------------------------------------------- */
  /* 1080px up to 1919px, then two steps up (components.md 19.1). The steps are
     in the two @media rules directly under this block, not here. Every 1440px
     measurement recorded anywhere in .webteam/ is taken at this value and is
     therefore untouched by them. */
  --container-max:1080px;
  /* The site's long-form reading column. Not a new value: it names the 720px
     measure .article has always used for a blog post's body, so the one other
     place that runs long prose under its own heading (About, "My Approach")
     can share it instead of inventing a second width. WO-112. Flagged to
     Pixel: .article still hard-codes the same 720px and should point here. */
  --measure-prose:720px;

  /* ---- Icons (WO-057/WO-069) --------------------------------------------- */
  --icon-size:28px;   /* one optical size, every icon, every page */
  --icon-stroke:1.75; /* one stroke weight, every icon, every page */

  /* ---------------------------------------------------------------------
     COMPATIBILITY ALIASES. These are the original variable names, kept and
     pointed at the tokens above, because build.py writes inline styles that
     use var(--line), var(--muted) and var(--accent) on the generated blog
     pages. Deleting these names would silently break those pages. Use the
     --color-* names above in any NEW rule.
     --------------------------------------------------------------------- */
  --bg:var(--color-surface-page);
  --surface:var(--color-surface-raised);
  --surface-2:var(--color-surface-sunken);
  --ink:var(--color-text-primary);
  --muted:var(--color-text-secondary);
  --line:var(--color-border-subtle);
  --accent:var(--color-brand-primary);
  --accent-dark:var(--color-brand-primary-hover);
  --gold:var(--color-gold);
  --shadow:var(--shadow-md);
  --radius:var(--radius-lg);
  --maxw:var(--container-max);
  --serif:var(--font-serif);
  --sans:var(--font-sans);
}

/* =============================================================================
   WIDE VIEWPORTS: the container steps up twice (components.md 19.1).

   At 3840px a flat 1080px container left the content on about 28% of the
   screen, a ribbon floating in an ocean of cream. These two steps take it to
   33%, and because the things that must NOT get wider are capped in their own
   units - body copy at 60ch, the prose band at --measure-prose, the hero text
   column from 1600px up - the extra width goes only to the card grids, the
   hero image panel and the footer, which are the parts that can use it.

   Nothing here fires below 1920px, so no measurement taken at 1440px moves.
   ============================================================================= */
@media(min-width:1920px){:root{
  --container-max:1180px;
  /* Her narrative takes one step with the container. At 3840px a 518px column
     held 13.5% of the screen; 26.4px type on a min(840px,62ch) measure holds
     about 21% at the same ~65 characters a line. components.md 20.6.
     Carries Paul's 2026-08-12 reduction through at the same ~6%, so the wide
     step keeps its documented relationship to the base ramp instead of
     becoming a bigger jump than it was. */
  --text-narrative:1.55rem;   /* 24.8px */
}}
@media(min-width:2560px){:root{--container-max:1280px}}   /* the ceiling; flat to 3840 */

/* =============================================================================
   BASE
   ============================================================================= */
*{box-sizing:border-box}
/* Several rules below set display on elements the Bookings page hides and
   shows. Without this, "hidden" would lose to them and a hidden block would
   stay on screen. */
[hidden]{display:none!important}
/* overflow-wrap is inherited, so setting it once here is a site-wide floor: any
   single word or unbroken run of text, anywhere on any page, is allowed to
   break inside itself rather than push its box (or, worse, a whole grid/flex
   track) wider than its container. This is the real fix behind the WCAG 1.4.4
   findings on bookings.html, index.html and about.html: at a boosted text
   size the display-heading tokens (clamp(...) in tokens.css) resolve to their
   fixed rem-based floor on every phone-class viewport, because the vw-based
   preferred value never catches up until the viewport is over 1500px wide.
   That floor grows with the zoom level, and a long word inside that
   oversized heading, or inside a button (e.g. "complimentary"), has no room
   to break, so it either overflows its own box in normal flow (bookings.html's
   h1) or forces an intrinsically-sized flex/grid box wider than the viewport
   to fit that one unbreakable run (index.html's .hero-grid and its .btn,
   about.html's .split and its .portrait). Nothing about the fixed 400px
   scrollWidth on bookings.html was a literal width value: it was
   left-gutter(22px) plus the word's own rendered pixel width, which does not
   change with viewport because it is driven entirely by the (zoom-boosted,
   but viewport-independent) font size.
   `anywhere`, not `break-word`, is deliberate and load-bearing: the two look
   identical for ordinary line-breaking, but they are specified to differ on
   exactly the case that matters here. `break-word` breaks a too-long word
   only after the box's own width has already been decided by some other
   rule, so a box that is SIZED FROM its content (an inline-flex button, a
   bare `1fr` grid track) never shrinks, because the intrinsic min-content
   measurement that decides that size is defined to ignore `break-word`
   entirely and behaves as if the word could not break at all. `anywhere` is
   specified to also count as a break opportunity for that min-content
   measurement, so the box can legitimately be sized down to fit the
   viewport, and only then does the word actually break, inside it. This is
   the standard, spec-documented fix for exactly this class of grid/flexbox
   overflow, not a workaround.
   Verified with tools/viewport-check.py's --text-zoom-pct at 200 and 400;
   full before/after numbers in HO-074. */
/* WO-146 item 3: body text up one rung. --text-md (1.1rem/17.6px), a
   previously unused rung in the scale, not a bump to --text-base (1rem/16px)
   - --text-base is also the literal token .btn and .foot nav a point at, and
   raising it would grow buttons and footer nav links, which nobody asked
   for. Only genuinely unstyled text (plain p/div/li with no font-size rule
   of its own) moves; every element with its own token (.hero p.lead,
   .hero-note, .card p, .narrative, .article p, .eyebrow, .btn, .foot nav a,
   .nap-name, .nap-note, .foot-legal) is untouched because a more specific
   rule always wins over an inherited one. */
body{margin:0;background:var(--color-surface-page);color:var(--color-text-primary);
  font-family:var(--font-sans);font-size:var(--text-md);line-height:1.65;overflow-wrap:anywhere}
h1,h2,h3{font-family:var(--font-serif);font-weight:600;line-height:1.15;margin:0}
/* Paragraph to paragraph, the first tier of the spacing ladder
   (components.md 19.2). This was `1em`, which only measured 16px because the
   body happens to be 16px, and quietly measured something else inside any
   block with its own font-size. A token says what it means and stays put. */
p{margin:0 0 var(--space-4)}
a{color:var(--color-brand-primary);text-decoration-thickness:1px}
a:hover{color:var(--color-brand-primary-hover)}
img{max-width:100%}
.wrap{max-width:var(--container-max);margin:0 auto;padding:0 var(--space-gutter)}
.muted{color:var(--color-text-muted)}
.center{text-align:center}

/* The entrance fade (`.reveal`, WO-067) was REMOVED here, WO-112, and must
   not come back in this form. It set `opacity:0` as the base state and relied
   on `animation-timeline: view()` to bring the element back to `opacity:1`.
   When that timeline does not advance, the element never becomes visible: on
   Home that hid her body copy, the three "How I Can Help" boxes, the blog
   strip and the "Where would you like to start?" cards, all at once, in a
   plain rendered page. Two audits passed it because they read the computed
   style (opacity 0 with a live animationName) instead of looking at the page.
   Content must be visible by default with animation as enhancement only; a
   decorative fade that can hide the client's own words is not worth
   repairing. The hero parallax stays: it moves an image and cannot hide
   anything. */

/* =============================================================================
   FOCUS INDICATOR - applies to every focusable element on every page.

   Why two layers instead of one colour: there is no single flat colour that
   is visible against every background this site uses. A dark ring vanishes
   against a dark surface; a gold ring vanishes against the page background.
   So a solid white halo always sits between the dark ring and whatever is
   behind the element, which makes the ring's contrast the same number
   everywhere: 14.79:1.

   WHY THE @supports WRAPPER BELOW IS LOAD-BEARING, DO NOT REMOVE IT.
   The browser draws its own focus outline by default. Taking that away
   with :focus{outline:none} and putting a better one back with
   :focus-visible is only safe on a browser that understands
   :focus-visible. On one that does not, the second rule is thrown away
   and the first one still applies, so every link, button and field on
   the whole site would end up with no visible focus indicator at all,
   which is worse than the plain browser default we started from.
   @supports selector(:focus-visible) is false on exactly those browsers,
   so there the default outline is simply left alone.
   ============================================================================= */
@supports selector(:focus-visible){
  :focus{outline:none}
}
:focus-visible{
  outline:var(--border-thick) solid var(--color-border-focus);
  outline-offset:var(--space-1);
  box-shadow:0 0 0 4px var(--color-focus-halo);
}
/* WO-182 (HO-181 finding): a `border-radius:inherit` override used to sit
   here, intended to "let the halo follow the shape on rounded controls." It
   did the opposite and squared the ring instead. `inherit` does not mean
   "keep whatever border-radius this element already computed" - it means
   "take the PARENT's border-radius", and none of .btn's, .card's,
   .post-item's or .latest a's parents (nav, grid wrappers, flex rows) carry
   one, so the parent's value is 0 and :focus-visible was overwriting the
   pill/card radius with a square corner the instant a control was focused.
   Every one of those selectors already carries its own border-radius
   unconditionally in its own base rule (.btn: var(--radius-full),
   .card/.post-item/.latest a: var(--radius-lg)), which the box-shadow halo
   above already follows on its own - box-shadow is drawn from the SAME
   element's own border box, so it needs no restating here. The fix is no
   rule at all: removing the override lets each control's real shape stand,
   confirmed by tabbing to one of each variant at 390px and 1440px.
   `.card-link` (the stretched link inside the "How I Can Help" cards, see
   its own rule and comment) is a different case and correctly untouched:
   IT has no border-radius of its own and is a genuine CHILD of `.card`, so
   `border-radius:inherit` there correctly borrows its parent's 16px. */

/* The sticky header would otherwise sit on top of whatever the browser has
   just scrolled a keyboard-focused element into view behind. The header is
   66px tall; 12px of clearance is added. */
html{scroll-padding-top:78px}
:target,[id]{scroll-margin-top:78px}

/* GLIDE TO AN IN-PAGE SECTION INSTEAD OF TELEPORTING. Paul, 2026-08-12, for
   the hero's "See how I can help" button, which is a plain link to
   #how-i-can-help and until now snapped the page there with no sense of
   having travelled, so the visitor could not tell whether they had moved down
   the page or landed on a different one.

   ZERO JAVASCRIPT, ON PURPOSE. This is one CSS property that the browser's
   own anchor navigation reads; the alternative is a click handler and
   scrollIntoView, which would mean the button silently reverts to a snap if
   the script is blocked and would need its own reduced-motion branch written
   by hand. The link keeps working as an ordinary link either way: in a
   browser that does not support scroll-behavior, this declaration is inert
   and the jump is exactly today's instant one, never a broken one.

   prefers-reduced-motion:no-preference is the WCAG opt-in and it is the whole
   guard, same pattern as every other motion rule in this file. Anyone who has
   asked their system for less motion gets the instant jump, which is the
   behaviour that shipped before this rule existed.

   NOTHING ELSE ABOUT THE JUMP CHANGES. The 78px clearance above stays exactly
   as it is - scroll-padding-top and scroll-margin-top are what keep the
   sticky header off the target, and they apply identically to a smooth scroll
   and an instant one, so the landing position is unmoved.

   IT ALSO REACHES THE SKIP LINK (#main-content), and that is acceptable
   rather than overlooked: focus still moves to <main> the instant the link is
   activated, because <main> carries tabindex="-1" and the browser sets focus
   before it animates. A screen reader announces the new location immediately
   either way; only the visible scroll is eased. */
@media(prefers-reduced-motion:no-preference){
  html{scroll-behavior:smooth}
}

/* <main> is given tabindex="-1" so the skip link genuinely moves focus into
   it. That makes it focusable, which would otherwise draw the focus ring
   around the entire page body. Suppress it: the scroll position and the
   screen reader announcement already tell the visitor where they are.

   The two selectors are written as separate rules rather than one comma
   separated list on purpose. A browser that does not understand
   :focus-visible throws away a whole list containing it, including the
   plain :focus half, so the list form would put the ring back on exactly
   the browsers this file is trying to look after. Same reason below on
   .skip-link and on the notice card headings. */
main[tabindex="-1"]:focus{outline:none;box-shadow:none}
main[tabindex="-1"]:focus-visible{outline:none;box-shadow:none}

/* =============================================================================
   SKIP LINK
   Off screen until it is focused, then it slides into the top left corner.

   WHY THIS IS A transform, NOT A top OFFSET (WCAG 1.4.4 fix, see HO-074).
   The old rule parked this at a fixed top:-56px, sized for the link's height
   at default text size (roughly 44px) plus its focused resting distance from
   the top (--space-3, 12px): -(44+12) = -56. That arithmetic is only true at
   one font size. At 200% text zoom the link's own text and padding grow, so
   its rendered height grows past 44px, but -56px does not grow with it, and
   the difference is exactly the part of the link that stays on screen,
   permanently, unfocused, covering part of the wordmark.
   translateY(-100%) moves an element up by its OWN rendered height, computed
   after layout, at any font size. Combining it with the focused resting
   offset in one calc() gives the exact box position with no size assumption
   baked in: calc(-100% - var(--space-3)) always lands the link's bottom edge
   at 0, fully clear of the viewport, whether the box is 44px or 100px tall.
   On focus the transform is simply removed, landing the link at its static
   top:var(--space-3), the same resting position as before.
   ============================================================================= */
.skip-link{
  position:absolute;
  left:var(--space-gutter);
  top:var(--space-3);
  transform:translateY(calc(-100% - var(--space-3)));
  background:var(--color-text-primary);
  color:var(--color-text-inverse);
  padding:var(--space-3) var(--space-5);
  border-radius:var(--radius-sm);
  font:600 var(--text-sm) var(--font-sans);
  text-decoration:none;
  transition:transform var(--motion-fast);
  z-index:100;
}
.skip-link:focus{transform:translateY(0)}
.skip-link:focus-visible{transform:translateY(0)}
@media(prefers-reduced-motion:reduce){.skip-link{transition:none}}

/* Visually hidden, still announced by a screen reader. Used in the brand
   wordmark to join "Mandy Spanier" and "Next Chapter" into one accessible
   name without a visible comma. */
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;
  overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}

/* =============================================================================
   BUTTONS
   ============================================================================= */
.btn{display:inline-flex;align-items:center;justify-content:center;gap:.5em;
  border:none;cursor:pointer;font-family:var(--font-sans);font-weight:600;
  font-size:var(--text-base);padding:.8em 1.5em;border-radius:var(--radius-full);
  text-decoration:none;min-height:var(--space-8);
  transition:background var(--motion-fast),border-color var(--motion-fast),color var(--motion-fast)}
.btn-primary{background:var(--color-brand-primary);color:var(--color-on-primary)}
.btn-primary:hover{background:var(--color-brand-primary-hover);color:var(--color-on-primary)}
.btn-primary:active{background:var(--color-brand-primary-active);transform:translateY(1px)}
.btn-primary:disabled,.btn-primary[aria-disabled="true"]{
  background:var(--color-surface-sunken);color:var(--color-text-muted);cursor:not-allowed}
/* The border is this button's only boundary, so it uses the strong border
   colour, which clears the 3:1 minimum for a control outline. Do not swap it
   for the subtle one. */
.btn-ghost{background:transparent;color:var(--color-text-primary);
  border:var(--border-md) solid var(--color-border-strong)}
.btn-ghost:hover{border-color:var(--color-brand-primary);color:var(--color-brand-primary)}
.btn-ghost:active{border-color:var(--color-brand-primary-hover);
  color:var(--color-brand-primary-hover);transform:translateY(1px)}
.btn-ghost:disabled,.btn-ghost[aria-disabled="true"]{
  border-color:var(--color-border-subtle);color:var(--color-text-muted);cursor:not-allowed}
@media(prefers-reduced-motion:reduce){.btn{transition:none}}

/* =============================================================================
   HEADER AND PRIMARY NAVIGATION
   ============================================================================= */
header.site{position:sticky;top:0;z-index:20;background:rgba(247,245,240,.95);
  backdrop-filter:blur(8px);border-bottom:var(--border-thin) solid var(--color-border-subtle)}
.nav{display:flex;align-items:center;justify-content:space-between;height:66px}
.brand{display:flex;align-items:center;font-family:var(--font-serif);
  font-weight:700;font-size:1.15rem;color:var(--color-text-primary);text-decoration:none;
  min-height:var(--space-8)}
.brand-text{display:flex;flex-direction:column;justify-content:center;line-height:1.15}
.brand-name{display:block}
.brand-tagline{display:block;font-family:var(--font-sans);font-weight:600;
  font-size:var(--text-2xs);letter-spacing:.02em;line-height:1.2;margin-top:1px;
  color:var(--color-text-secondary)}

/* The gap is small because the links carry 8px of their own padding. The
   arithmetic is written out so this is checkable rather than eyeballed: the
   spacing between two link words is 8px padding + 6.4px gap + 8px padding =
   22.4px. Keep that total if either value is ever changed. It matters most
   between 641px and 720px, where the header has the least room to spare. */
.nav-links{display:flex;gap:.4em;align-items:center}
/* min-height, not padding, is what guarantees the 44px tap target. The
   padding is there for the feel of the click area, not the guarantee. */
.nav-links a{display:inline-flex;align-items:center;min-height:var(--space-8);
  padding:var(--space-2);color:var(--color-text-secondary);
  text-decoration:none;font-weight:500;font-size:.98rem}
.nav-links a:hover,.nav-links a.active,.nav-links a[aria-current="page"]{
  color:var(--color-text-primary)}
/* The booking button keeps its own white text on green at every state. */
.nav-links a.book-link,.nav-links a.book-link:hover{color:var(--color-on-primary)}

/* --- Mobile menu toggle. Hidden by default; shown inside the 640px query.
   The button itself is not in any page's HTML: js/nav.js builds it and puts
   it into the header. These rules only ever match once that script has run,
   which is exactly the intent. Do not add a .nav-toggle button to the markup
   by hand, or it will appear with nothing behind it when the script does not
   run. ---------------------------------------------------------------------- */
.nav-toggle{
  display:none;
  align-items:center;gap:var(--space-2);
  min-width:var(--space-8);min-height:var(--space-8);
  padding:var(--space-2) var(--space-3);
  background:transparent;
  border:var(--border-md) solid var(--color-border-strong);
  border-radius:var(--radius-sm);
  color:var(--color-text-primary);
  font:600 var(--text-sm) var(--font-sans);
  cursor:pointer;
}
.nav-toggle-icon{position:relative;width:18px;height:2px;
  background:var(--color-text-primary);display:block}
.nav-toggle-icon::before,.nav-toggle-icon::after{
  content:"";position:absolute;left:0;width:18px;height:2px;
  background:var(--color-text-primary);transition:transform var(--motion-base)}
.nav-toggle-icon::before{top:-6px}
.nav-toggle-icon::after{top:6px}
.nav-toggle[aria-expanded="true"] .nav-toggle-icon{background:transparent}
.nav-toggle[aria-expanded="true"] .nav-toggle-icon::before{transform:translateY(6px) rotate(45deg)}
.nav-toggle[aria-expanded="true"] .nav-toggle-icon::after{transform:translateY(-6px) rotate(-45deg)}
@media(prefers-reduced-motion:reduce){
  .nav-toggle-icon::before,.nav-toggle-icon::after{transition:none}
}

/* -----------------------------------------------------------------------------
   MOBILE NAVIGATION, 640px and below.

   Built in three layers on purpose (ADR-043 adds the third):

   LAYER 1 (this block) is the scripted default: a floating panel, position
   :absolute, anchored below the brand row rather than flowed content under
   it (see the comment on .nav-links below) - always open, every link really
   there and really tabbable, laid over whatever the page puts under the
   header instead of pushing it down. This is what paints first, before any
   script has had a chance to run, and it is also what a browser with
   scripting genuinely available keeps forever, whether or not js/nav.js has
   loaded yet.

   LAYER 2 (the .js-collapsible block after it) is added by js/nav.js and
   folds that list behind the Menu button that same script creates. Never
   move these rules out of .js-collapsible: doing so would collapse the menu
   for visitors who have no script to open it again.

   LAYER 3 (ADR-043, the @media(scripting:none) block after LAYER 1) is the
   genuinely-no-JS state, chosen in CSS alone via the `scripting` media
   feature - no <head> change, nothing in netlify.toml, no hash to go stale,
   js/nav.js untouched. It restores .nav-links to normal flow so the panel
   never overlays the hero <h1> the way the absolute LAYER 1 panel otherwise
   would with no script running to explain why. position:static is not the
   whole change: in flow the panel sits back inside .wrap, which already
   supplies the 22px gutter, so LAYER 1's own --space-gutter horizontal
   padding (below) would double-inset the rows, and LAYER 1's box-shadow and
   border-bottom read as a floating layer on something that is no longer
   floating. LAYER 3 undoes exactly those three properties and nothing else -
   .nav itself needs no change (flex-wrap:wrap, height:auto and min-height:
   66px already permit the second row), and js/nav.js is untouched because
   this state does not depend on whether it ran, only on whether scripting
   was ever available to run it. Where the `scripting` feature is unsupported
   the block simply drops and the page renders exactly what ships today (the
   LAYER 1 floating panel), so no regression is possible for that engine.
   ----------------------------------------------------------------------------- */
@media(max-width:640px){
  /* .nav stays a single 66px-tall row, brand plus Menu button, ALWAYS - see
     .nav-links below for why. align-content keeps that row optically centred
     while it does; position:relative is what lets .nav-links (absolute) anchor
     to it instead of to the page. */
  .nav{flex-wrap:wrap;height:auto;min-height:66px;align-content:center;
    position:relative}
  .nav-toggle{display:inline-flex}
  /* WO-139. .nav-links used to be .nav's second flex row: opening the panel
     grew .nav's height and pushed everything below it down the page, closing
     it pulled that content back up, and js/nav.js closing the panel for the
     first time - AFTER the browser had already painted it open - is what a
     throttled run measured as a 207px shift and a 0.2+ CLS score. It was a
     race, not a bug in the collapse itself: whichever paint happened to land
     before that first close was the one that got dragged.
     position:absolute removes .nav-links from .nav's flow entirely, so .nav's
     own height (above) is the ONLY thing that decides how tall the header is,
     and that height no longer has a second value to change to. The panel now
     opens and closes as a layer floating over whatever sits below the header
     (the hero, out of scope here - WO-138), not as flowed content, so nothing
     under it ever moves, no matter when or whether js/nav.js runs.
     top:100% anchors it to the bottom edge of the 66px bar; left:0/right:0
     span it edge to edge, past .wrap's own 22px padding, which .nav-links no
     longer sits inside - it is why the panel needs its own background rather
     than showing whatever is behind it through the gaps between rows. */
  .nav-links{
    flex-direction:column;align-items:stretch;
    gap:0;
    position:absolute;top:100%;left:0;right:0;
    background:var(--color-surface-raised);
    box-shadow:var(--shadow-md);
    border-bottom:var(--border-thin) solid var(--color-border-subtle);
    /* Horizontal padding is the page's own --space-gutter now, not the 8px
       this used before: the panel is no longer INSIDE .wrap's own padding, so
       it has to supply that 22px gutter itself, or its rows read flush with
       the screen edge while everything else on the page sits 22px in from it.
       Vertical padding is unrelated and unchanged: 8px all round is still what
       keeps the focus ring around the first and last items clear of the
       overflow:hidden the collapse animation applies (see LAYER 2 below) -
       overflow:hidden clips at .nav-links' own outer edge, not at its padding,
       so this only has to be wider than the ring, never as wide as the gutter. */
    padding:var(--space-2) var(--space-gutter);
  }
  .nav-links a{
    /* Horizontal padding is 0, not --space-3: .nav-links now supplies the
       gutter itself (above), so a second inset here would land the row text
       12px past the brand name instead of level with it. width:100% still
       carries the full-row tap target regardless; min-height, not padding,
       is what guarantees its 44px height. */
    width:100%;justify-content:flex-start;
    padding:var(--space-3) 0;
    border-bottom:var(--border-thin) solid var(--color-border-subtle);
    min-height:var(--space-8);
  }
  .nav-links a:last-child{border-bottom:none}
  .nav-links .book-link{display:flex;justify-content:center;
    margin:var(--space-3) 0 var(--space-1);border-bottom:none}
}

/* LAYER 3 (ADR-043). Added AFTER the LAYER 1 block above so it wins on
   source order at equal specificity, restoring the panel to normal flow only
   when scripting is unavailable. position:static alone is not the whole
   change - see the extended comment above LAYER 1: .wrap already supplies
   the gutter in flow, so the horizontal padding LAYER 1 needs for a floating
   panel would double-inset these rows, and the shadow/border that read as a
   floating layer's edge read as a stray line in normal flow. Undone here,
   nothing else. Left edges land level with the brand name, matching .wrap's
   own gutter with no separate inset of their own. */
@media(max-width:640px) and (scripting:none){
  .nav-links{
    position:static;
    /* width:100% is the property the ADR's own illustrative snippet did not
       spell out. Measured, not reasoned: without it .nav-links keeps its
       column-flex shrink-to-fit width (roughly the "Book a call" pill's own
       width) and .nav's flex-wrap never triggers, so the panel sits beside
       the brand on the SAME row instead of dropping to a second one - the
       exact wrong outcome the work order flagged as the likely defect.
       width:100% forces the wrap, landing the four items on .nav's own
       second row as ADR-043's contract requires. */
    width:100%;
    padding-inline:0;
    box-shadow:none;
    border-bottom:none;
  }
}

/* LAYER 2: the collapsed panel. js/nav.js adds .js-collapsible, so these
   rules only ever apply when the script has actually run. Safe to leave
   exactly as it was pre-WO-139: max-height 0<->760px still animates the open
   and close, but .nav-links is position:absolute now (above), so growing or
   shrinking it no longer moves anything else on the page either way. */
@media(max-width:640px){
  .nav-links.js-collapsible{
    max-height:0;overflow:hidden;visibility:hidden;
    transition:max-height var(--motion-base),visibility 0s linear 200ms;
  }
  .nav-links.js-collapsible.is-open{
    /* A deliberately generous cap. 320px is enough at normal text size but
       clips the last item at 200% text zoom, where the four rows measure
       roughly 330px. A cap only ever limits; the panel still renders at its
       natural height. */
    max-height:760px;visibility:visible;
    transition:max-height var(--motion-base),visibility 0s;
  }
  /* visibility:hidden is belt and braces alongside the inert attribute the
     script sets: it keeps the collapsed links out of the tab order even in a
     browser that does not support inert. */
}
@media(max-width:640px) and (prefers-reduced-motion:reduce){
  .nav-links.js-collapsible,.nav-links.js-collapsible.is-open{transition:none}
}

/* =============================================================================
   HERO
   ============================================================================= */
/* THE HERO. ADR-041 (decisions.md ~L2140): the image is a full-bleed layer
   behind the copy, not a side panel and not a CSS background-image. The
   <picture> stays real markup (srcset, sizes, fetchpriority="high" all
   preserved) so the preload scanner still finds it in the first parse - a
   background-image would delay the exact element that is the measured LCP
   element from 430px up.

   What was wrong before this rebuild, in numbers rather than adjectives:
     - the h1 was capped at max-width:16ch, which is 461px, inside a column
       that was already 546px wide. 85px of the column it owned went unused at
       every width, forever, and her 91-character headline wrapped to SEVEN
       lines at 1440px and at 1920px alike.
     - .home-visual was a 16/10 box in a 446px column, so the photograph
       rendered 446x279: an illustration beside an essay, not a hero image.
     - at 788px, the h1 spanned its full column while the lead paragraph
       (max-width:44ch, its own individual cap) wrapped into a narrower,
       unevenly-broken column beside a void the h1 above it did not have -
       three elements sharing a left edge but not a right one.

   What this rebuild does, none of it fixing the hero on its own: moves the
   image behind the copy as an absolutely positioned layer, drops the 44ch cap
   on the lead so headline/lead/buttons/note all read their measure from one
   shared 720px cap on .hero-copy, and hides the image below 720px instead of
   letting it sit in its own row (Paul's own complaint, at a measured
   threshold rather than an approximate one). The 4-line h1 at 1280-2560 is
   NOT fixed by this change and is not supposed to be - M-14: only a shorter
   headline moves that, and that is Mandy's line, not layout's. ------------ */
.hero{padding:clamp(48px,8vw,96px) 0 clamp(32px,5vw,64px);position:relative;overflow:hidden}
/* This lives here, not as an inline style on the div in index.html, because
   no media query can override an inline style and this grid has to collapse
   to one column on a phone. */
.hero-grid{display:grid;grid-template-columns:1.15fr .85fr;gap:44px;align-items:stretch}
/* WO-146 item 4: 12px -> 14.4px (+20%). Letter-spacing (.18em) and weight
   (700) are untouched and do not need to be - both are em-relative or
   independent of size, so they scale or hold correctly with zero separate
   edit. Reaches all four labels ("Where you are now," "What could be,"
   "About your coach," "Coming soon") with this one selector - all four carry
   the shared .eyebrow class. Contrast unaffected: colour tokens untouched, a
   larger glyph at the same colour only ever helps resolvability.

   PIXEL, 2026-08-14 (WO-154, ruling on WO-153; components.md 22.4): one
   further rung again, --text-base (16px) -> --text-eyebrow (19.2px), its own
   dedicated token rather than a shared one, so it can move independently of
   .btn and .foot nav a without dragging them with it - both still read
   --text-base directly and are untouched by this change.

   Tracking drops alongside it, .18em -> .14em. The earlier comment here
   argued tracking was em-relative and therefore needed no separate edit as
   size grew; Pixel overturned that: em-relative tracking still SCALES with
   size, it does not stay constant, so raising font-size while holding
   letter-spacing in em compounds the two increases together and produces
   visibly wider gaps between letters than intended. .14em at 19.2px lands
   close to the absolute tracking the labels rendered at before this wave.
   Weight (700) and colour are unaffected by either change and stay as they
   were. Still one selector for all four labels ("Where you are now," "What
   could be," "About your coach," "Coming soon") - all four carry .eyebrow. */
.eyebrow{display:inline-block;text-transform:uppercase;letter-spacing:.14em;
  font-size:var(--text-eyebrow);font-weight:700;color:var(--color-brand-primary)}
/* max-width is deliberately absent, at every width, forever: this is the
   selector the standing warning at the top of the type-token block is about
   (search "Do NOT put a `ch` cap back on .hero h1"). The measure comes from
   the PARENT, .hero-copy, below - never from this selector. text-wrap:balance
   is a pure enhancement: where it is not supported the headline wraps exactly
   as it would have anyway. */
.hero h1{font-size:var(--text-hero-long);line-height:1.08;
  margin:0 0 var(--space-5);text-wrap:balance;max-width:none}
/* The 44ch cap that used to sit here is WITHDRAWN (ADR-041 / hero-overlay-and-
   motion.md 3.1). It was protecting against a void that no longer exists once
   .hero-copy supplies one shared measure to every element in the stack; kept
   here it was the second half of the 788px mismatch, not a fix for it. */
/* WO-146 item 2: 1.2rem/400 -> 1.35rem/600. This is stroke legibility on a
   textured backdrop, a different problem from contrast - the scrim (below)
   already supplies the predictable ground, so no new colour or scrim
   mechanism is needed here, only weight and size. 600, not 700: reuses the
   site's own existing emphasis weight (.btn, .nap a) rather than inventing a
   louder one for this fix alone. */
.hero p.lead{font-size:1.35rem;font-weight:600;max-width:none}

/* ============================================================================
   THE HERO SENTENCE, READ AS ITS PARTS.

   PAUL 2026-08-20: the headline "seems like a lot of text for a title", and
   "clarity, confidence, and compassion" are "three distinct points... three
   methods or tactics that Mandy takes", the four struggles in the lead "are
   distinct struggles that clients may be facing", and "you don't have to figure
   it out alone is her crescendo, it is the hook".

   NOT ONE WORD OF HERS CHANGES, AND NOT ONE MARK. Both sentences are still
   exactly doc2 P18 and P19, em dash included, and both are still checked
   against VOICE_MANIFEST as whole sentences: the wrappers carry data-fold, so
   build.py's extractor folds them away and reads the sentence a visitor reads
   (see _VOICE_FOLDABLE_TAGS). This is presentation deciding where the eye
   pauses. It is not an edit, and it is not permission to make one.

   EVERY TREATMENT HERE IS CONTRAST-NEUTRAL BY CONSTRUCTION. Nothing below
   changes a text colour, because this hero sits on a photograph and its
   contrast was hard won: 2.40:1 at 320 before the wash was rebuilt, 8.88:1 and
   up now. Distinction is carried by weight, family, position and a rule under
   the word, never by lightening ink against a picture. Re-measured after the
   fact anyway, at nine widths, because a bigger or repositioned glyph samples
   different pixels even when its colour is identical.
   ============================================================================ */

/* 1. THE THREE QUALITIES. Their own line, which is the direct answer to "a lot
   of text for a title": the headline becomes a setup and a payoff instead of
   one 90-character run. Each quality is then marked by a rule underneath it,
   so three separate underlines say "three things" at a glance.

   A RULE, NOT A COLOUR. Brand green on the wash measures about 5.6:1 and would
   have passed, but it would have been the only place on the site where meaning
   is carried by colour alone, which fails WCAG 1.4.1 for anyone who cannot
   separate the hue. The underline reads for everyone and costs no contrast. */
/* 1. THE THREE QUALITIES. Their own line, which is the direct answer to "a lot
   of text for a title": the headline becomes a setup and a payoff instead of
   one 90-character run.

   THREE LINES, NOT FOUR OR FIVE. Paul asked for exactly three, so the setup
   takes two and the qualities take one. That only works if all three words plus
   their commas fit a single line inside the hero's copy measure, which at 44.8px
   they do not: measured, they need about 700px against roughly 560px of column
   at 1440. Hence the .82em step down. The qualities are not being demoted by
   that; they are being made to fit the line they were given, and everything
   else here is spent making them the most distinct thing in the heading.

   ITALIC, AND A DIFFERENT FACE. The underline that was here first is GONE at
   Paul's request. In its place the three words switch to the sans stack in
   italic against the h1's serif, which is a larger difference than an underline
   was: a reader sees a change of voice mid-sentence rather than a decoration
   under three words. Letter-spacing opens very slightly because a sans italic
   set among serif caps otherwise looks crowded at display size.

   IT ALSO COSTS NOTHING IN CONTRAST, which is the constraint that governs
   everything in this hero. No colour changes here. The ink is the same ink,
   measured at 11.25:1 and up against the photograph, and the previous version's
   green rule is no longer present to be mistaken for the text ground by a
   contrast sampler (which is exactly what happened, at a precise 2.42:1). */
.hero h1 .h1-qualities{
  display:block;
  margin-top:.16em;
  font-family:var(--font-sans);
  font-style:italic;
  font-size:.78em;                 /* holds all three on ONE line, measured */
  /* .78 IS MEASURED, NOT CHOSEN BY EYE. The three words plus their commas
     need 598px at 36.7px, and the hero's copy column is narrowest at 1280,
     where it gives 583px. That is the binding case: it fits at .799em there
     and more easily at 1440 (.816) and 1920 (.847). .78 takes the tightest
     of those and leaves a little room, so one line holds from 1024 up. A
     first pass used .82 because it looked about right, and it wrapped to two
     lines at exactly the two widths nobody had rendered. */
  font-weight:600;
  letter-spacing:.005em;
}
.hero h1 .quality{white-space:nowrap}   /* never break a method across lines */
@media(max-width:520px){
  /* At 320 to 520 the three plus their commas cannot hold one line at any size
     worth reading, so the nowrap is released and they wrap as a group. The
     italic sans still marks them; only the one-line promise is dropped, and it
     is dropped on the widths where it was never keepable. */
  .hero h1 .quality{white-space:normal}
  .hero h1 .h1-qualities{font-size:.86em}
}

/* 2. THE FOUR STRUGGLES. The sentence keeps its shape and its commas; what
   changes is that her four situations now carry the weight and the words
   joining them step back. Before this, "Whether you're", "or" and every
   struggle were all one flat 600, so the four things a visitor is meant to
   recognise herself in had no more presence than the grammar around them.

   This is why .hero p.lead drops to 500 rather than the struggles climbing
   past 700: at 21.6px the difference has to come from somewhere, and taking it
   out of the connectives keeps the heaviest thing on the line her content. */
.hero p.lead{font-weight:500}
/* PAUL 2026-08-21: "change the bold in this sentence to italics instead". The
   four struggles keep the distinction, they just stop shouting it. Bold at
   21.6px across four separate phrases in one sentence made most of the lead
   heavy, which is the opposite of picking things out of it. Italic marks the
   same four items and leaves the sentence at one weight. */
.hero p.lead .struggle{font-style:italic}

/* 3. THE CRESCENDO. Own line, serif, larger than the lead and set apart from
   it. The em dash stays where she put it, at the end of the struggles, so the
   hook arrives as the turn after her dash rather than instead of it.

   Deliberately smaller than the h1 (28.8px ceiling against 44.8px). It is the
   hook, not a second headline, and two competing display sizes in one hero is
   the thing that made this block feel unresolved in the first place. */
.hero p.lead .hero-hook{
  display:block;
  position:relative;
  /* PAUL 2026-08-21: "reduce the amount of white space above". It was carrying
     space-6 of margin PLUS space-5 of padding, and the padding existed only to
     clear the 72px rule that used to sit above this line. That rule was removed
     yesterday and its padding was left behind holding a gap open for nothing.
     One margin now, one step smaller. */
  margin-top:var(--space-4);
  font-family:var(--font-serif);
  font-size:clamp(1.45rem,2.5vw,2rem);
  font-weight:600;
  line-height:1.22;
  text-wrap:pretty;
}
/* NO RULE ABOVE THE HOOK. One was added here and Paul cut it: "remove the green
   line above you don't have to figure it out alone, it looks weird". It was the
   narrative bands' 72px axis rule borrowed into the hero, and borrowing it was
   the error. In those bands the rule opens a section on an empty ground; here
   it landed mid-sentence, a few pixels under her own em dash, so it read as a
   strikethrough or a stray divider rather than a turn. The hook is carried by
   what it already had: its own line, the serif face, a size step, and the space
   above it. That is enough, and it was enough before the rule was added. */

.hero-cta{display:flex;gap:.7em;flex-wrap:wrap;margin-top:var(--space-7);max-width:none}

/* BELOW 593px THE TWO HERO BUTTONS FILL THE COLUMN.

   PAUL 2026-08-23: "widths of 350 to 592, let's make the Book a complimentary
   discovery call and See how coaching helps buttons on the top of the home page
   full container width."

   They already stacked at these widths, because .hero-cta wraps, so this is not
   about the layout breaking. It is about the RAGGED EDGE: measured at 430px,
   the primary sat at 314px and the secondary at 224px, two different widths
   left-aligned under each other, which reads as an accident rather than as a
   pair. Full width makes them a pair, and gives the primary action a target the
   whole width of the reading column, which on a phone is the point.

   Applied from the smallest supported width rather than from 350. Paul named
   350 because that is where he was looking; nothing changes at 348 that would
   justify leaving 320 to 349 ragged, and at 320 the buttons are the most
   cramped of all.

   THE BREAKPOINT IS 609px, NOT THE 592px PAUL NAMED, AND IT WAS FOUND BY
   SWEEPING RATHER THAN BY ARITHMETIC. He gave 592 as the top of the range he
   was looking at. Stopping there would have left 593 to 609 in the worst state
   of all: wrapped onto two rows AND still two different widths.

   Two attempts to derive the number from button widths and gaps landed on 592
   and then 607, and both were still wrong at the edge. Swept in 1px steps with
   the rule disabled, the pair wraps at 609 and fits one row from 610. So the
   rule ends at 609 and covers every width where these buttons wrap, which is
   what the request was actually about. */
@media(max-width:609px){
  /* PAUL 2026-08-23, refining the above: "let's instead match the width and
     centering of the CTA button in the Ready for Your Next Chapter? section
     down below. To where all three buttons are the same width and show as
     centered." Scoped, at his direction, to these viewport sizes only.

     Full-column width fixed the ragged edge but did not match that button,
     which is centred and sized to its own label rather than to the column.

     GRID, NOT A HARD-CODED WIDTH. A single implicit grid column is sized to
     max-content, which here is the widest of the two buttons, and both items
     then stretch to fill it. So they come out identical to each other AND
     identical to the closing CTA, because the closing CTA carries the very same
     label, "Book a complimentary discovery call", and is therefore the same
     314px. Measured, all three.

     Writing 314px here instead would have worked today and rotted the first
     time anyone edits that label, silently, in one of the two places only.

     min(100%, max-content) is what keeps it honest below about 350px, where the
     column is narrower than the label needs: the track stops at the container
     instead of overflowing it, which is exactly what the closing CTA does at
     those widths. */
  .hero-cta{
    display:grid;
    grid-template-columns:min(100%, max-content);
    justify-content:center;
    gap:.7em;
  }
  .hero-cta .btn{width:auto}
}
.hero-note{margin-top:var(--space-6);font-size:1.05rem;font-weight:600;max-width:none}
/* One shared measure for the whole copy stack, at every width, not just
   1024px+ (hero-overlay-and-motion.md 3.1). Below 1024px .hero-copy has no
   padding of its own - the gutter comes from the ancestor .wrap - so the
   plain 720px token is the whole story; the 720px-plus-both-paddings version
   lives in the 1024px+ query below, where .hero-copy supplies its own
   padding instead of inheriting .wrap's. position:relative is unrelated to
   the measure: it is what lets this box win the paint order against
   .home-visual (ADR-041 point 1, see the HOME section below) with no
   z-index needed, at the widths (720-1023px) where the image sits behind it
   but .hero-copy is not yet inside the 1024px+ query that would otherwise
   supply it. */
.hero-copy{position:relative;max-width:var(--measure-prose)}

/* TEXT COLOUR OVER THE PHOTOGRAPH, AT EVERY WIDTH (WO-146 item 1, reversing
   the earlier 720px hide ruling). This used to be gated at min-width:720px,
   paired 1:1 with the image-hide breakpoint that used to sit on .home-visual
   in the HOME section below. Both gates are removed together in this change,
   so the image and this colour rule are unconditional at every width and
   there is no second number left to keep in sync.

   hero-overlay-and-motion.md 1.2-1.5: every text role over the photo uses
   solid --color-text-inverse (white), never an opacity or muted tone - 1.4
   measured 85%-opacity white and the site's own .muted tone both landing
   under the 4.5:1 floor over this photograph's worst-case tone. Hierarchy
   between the lead and the fine-print note is carried by size and weight
   only from here up. .btn-ghost gets a scoped dark-backdrop variant, not a
   global change: its live rule is calibrated for a light ground and reads as
   near-invisible on the scrimmed photo. Contrast re-measured against the
   real rendered pixels, not estimated - see the handoff for the numbers. */
/* PAUL 2026-08-20: THE HERO STOPS USING DARKNESS TO MAKE ITSELF READABLE.

   His words: the "Ready for Your Next Chapter?" band "doesn't use darkness or
   black to help the viewer read the text", and next to it this one "just seems
   gloomy". That is exactly right, and the inconsistency was ours. This site has
   three photographs with words over them. "What could be" and "Ready for Your
   Next Chapter?" both lay a LIGHT oat wash over the picture and set her words
   in dark ink. The hero was the only one doing the opposite, dimming a bright
   morning wheat field to near-dusk so white type would sit on it.

   So it joins the other two: light wash, dark ink. The photograph gets brighter
   rather than darker, the page stops changing mood between its first band and
   its last, and the copy reads as the same voice throughout.

   White text is gone from this section, so every rule that existed only to
   support it goes with it: the inverse colour on the headline, lead and note,
   and the white-outlined ghost button with its translucent white hover. The
   ghost button now inherits the sitewide .btn-ghost, which is dark text on a
   strong border and is what it looks like on every other page. */
.hero h1,.hero p.lead,.hero .hero-note{color:var(--color-text-primary)}
/* The note keeps PRIMARY ink rather than dropping to secondary, and that is a
   measurement rather than a preference. Tried at secondary first: it came back
   3.43 to 4.21:1 across the width sweep, failing 4.5:1 at eight widths, because
   a light wash over a bright wheat field simply does not leave room for a grey.
   Exactly the same trade .next-chapter had to make for its own reassurance
   line, and made for the same reason. The hierarchy here was never carried by
   colour anyway: 30.4px headline, 21.6px lead, 16.8px note. */

/* --- Stacked hero, below 1024px. The .home-visual half of this - including
   the rule that hides the image entirely below 720px - lives with the base
   .home-visual rule in the HOME section further down, NOT here. It has to:
   media queries add no specificity, so a rule written here would be beaten by
   the base rule simply because the base rule comes later in the file. Every
   shape override on that box is therefore kept next to it, in one place. -- */
@media(max-width:1023px){
  .hero-grid{grid-template-columns:1fr;gap:var(--space-7)}
}

/* --- 1024px and up. .hero-grid keeps its two-track split with the second
   track deliberately left empty (ADR-041 point 3): the image is no longer a
   grid item at all, so this track's own sizing concerns no longer apply to
   it, but leaving the split in place is what keeps the padding-inline-start
   formula below and the 1600px measure cap further down untouched - deliberate
   minimum churn, not an oversight.

   The padding-inline-start formula is UNCHANGED from before this rebuild. It
   lands the first character of the headline on exactly the same left edge as
   every other section on the page: at 1440px it computes to
   (1440-1080)/2+22 = 202px. Below the container width the max() falls back to
   the plain gutter, so nothing is ever pushed off the left. This is also now
   the reason .hero-grid keeps a grid at all above 1024px even with one live
   child: nothing else on the page reproduces this axis formula. ----------- */
@media(min-width:1024px){
  .hero{padding:0}
  .hero-grid{
    max-width:none;padding:0;
    /* minmax(0,...) on BOTH tracks, and it is load-bearing, unchanged from
       before this rebuild: an fr track's automatic minimum is its content's
       min-content size, and without this a wide intrinsically-sized child
       (there is none today, since the image left the grid, but a future one
       is not impossible) could force the track past its fr share the same
       way the photograph once did. Measured, not reasoned, historically. */
    grid-template-columns:minmax(0,1.15fr) minmax(0,.85fr);
    gap:0;
  }
  .hero-copy{
    display:flex;flex-direction:column;justify-content:center;
    padding-block:clamp(56px,7vw,104px);
    /* --hero-inset is the SAME formula as the padding-inline-start value
       always used here, named so max-width below can reuse it without a
       second, drifting copy of the expression. The formula itself is
       untouched (ADR-041 point 3: "the padding-inline-start axis formula...
       untouched"). */
    --hero-inset:max(
      var(--space-gutter),
      calc((100vw - var(--container-max))/2 + var(--space-gutter))
    );
    padding-inline-start:var(--hero-inset);
    /* Was clamp(40px,4.5vw,64px), sized to buffer the text column from the
       image PANEL that used to sit beside it. There is no panel to buffer
       from any more - the image is behind the text, not beside it - so this
       is now the plain page gutter, same as every other section's right
       edge (hero-overlay-and-motion.md 3.1). */
    padding-inline-end:var(--space-gutter);
    /* This formula ALONE would give exactly 720px of content
       (--hero-inset + 720 + 22 - --hero-inset - 22, the inset cancels out).
       MEASURED, this is a ceiling, not the rendered value: the 1.15fr grid
       track this box sits in (above) is narrower than that ceiling for most
       of this query's range - e.g. 604px of content at 1440px, 545px at
       1024px, both real renders, not this arithmetic - so the box is
       track-clamped, not max-width-clamped, there. The max-width still does
       its real job either way: it is what makes h1/lead/buttons/note share
       ONE measure, whichever mechanism ends up narrower at a given width,
       which is the actual fix for the 788px void (Pixel's diagnosis: the
       mismatch between elements was the bug, not any one being too narrow).
       Re-measured, not touched further: ADR-041 keeps the 1.15fr/.85fr split
       as deliberate minimum churn, and ~600px still clears the old
       562-576px split-hero column it replaces. */
    max-width:calc(var(--hero-inset) + var(--measure-prose) + var(--space-gutter));
  }
  /* The .home-visual half of this query is in the HOME section, next to the
     base rule for that box. See the note in the 1023px query above.
     PAUL 2026-08-19: this used to say "including the parallax re-scope". There
     is no width re-scope any more; the parallax runs wherever the browser
     supports scroll-driven animations and the visitor has not asked for less
     motion, at every width. The block in the HOME section carries the reason. */
}

/* From 1600px the fr split would keep stretching the headline past a readable
   measure, so the text column stops growing: it is the left offset (dead
   margin, not text) plus a fixed 640px, which holds the headline measure at
   roughly the 576px it has at 1440px. Everything past that goes to the image
   panel. 640px is not an arbitrary number: at exactly 1600px this column
   computes to 922px and the 1.15fr split gives 920px, so the two agree at the
   breakpoint and nothing jumps. components.md 19.1. */
@media(min-width:1600px){
  .hero-grid{
    grid-template-columns:
      minmax(0,calc((100vw - var(--container-max))/2 + var(--space-gutter) + 640px)) minmax(0,1fr);
  }
}

/* =============================================================================
   SECTIONS
   ============================================================================= */
/* UNCHANGED IN EFFECT. The literal clamp moved into --space-section-y in :root
   (WO-162) purely so the arch's clearance calc can consume the same value
   rather than restating it; --space-section-y IS clamp(40px,6vw,72px). */
section{padding:var(--space-section-y) 0}
.section-alt{background:var(--color-surface-sunken)}
/* WO-206. One-off, named rather than a bare utility: a section whose last
   child is a full-bleed band meant to run into the footer with no page
   ground showing between them. Only removes THIS section's own bottom
   padding; the shared `section{padding:...}` rule above is untouched for
   every section that does not carry this class, on every other page.
   Bookings is the only page that uses it today (the contact band). */
.section--flush-end{padding-bottom:0}
/* --space-7 (32px) is the fourth tier of the ladder: a heading block to the
   section under it. The value is the same 32px that `2em` used to produce
   here; it is now a token, so it cannot drift with a font-size change. */
.section-head{max-width:60ch;margin:0 auto var(--space-7);text-align:center}
/* h1 is included so a page whose main heading sits in a .section-head does
   not need an inline font-size to match the h2 it replaced. */
.section-head h1,.section-head h2{font-size:var(--text-display-sm)}
/* Second tier of the ladder: a heading to the copy directly under it, 24px.
   The base rule zeroes every heading margin, so the gap under a .section-head
   heading was whatever line-height leading happened to be left over: 8px on
   About's "Let's take the first step". This sets it deliberately. Where the
   heading is the only child, its bottom margin simply collapses with the
   block's own, so nothing moves on the heading-only sections. */
.section-head>h1,.section-head>h2{margin-bottom:var(--space-6)}
.section-head p{color:var(--color-text-secondary);margin-top:0}

/* ---------------------------------------------------------------------------
   TEMPORARY. DELETE THIS RULE AND ITS ONE USE WHEN BAND 2 SHIPS.

   Band 2 (next-chapter-layout.md 6.4) is designed to carry the "How I Can Help"
   H2 inside the band itself, right-aligned, which retires the centred
   .section-head for that one heading. Band 2 is not built: no image is sourced.

   Meanwhile Block A has left-aligned Mandy's narrative to the 202px page axis,
   and a centred heading sitting directly under a left-aligned prose column is
   the exact defect recorded further down this file at the .prose-band comment,
   found once already on About. Sketch ruled on which inconsistency is the lesser
   (next-chapter-layout.md 8.1): one named heading stepping out of the site's
   centred-heading convention for a stated, temporary window, rather than a
   second instance of a bug already on record. Re-centring her narrative to
   protect the convention was rejected, because that restores the 461px
   misalignment this whole change exists to fix.

   margin-inline:0 rather than a new margin shorthand, so the .section-head
   bottom margin (--space-7) is untouched. The 60ch cap stays and is invisible:
   the heading is three words.
   --------------------------------------------------------------------------- */
.section-head--axis{margin-inline:0;text-align:left}
/* Page-level heading outside a .section-head (the About page). Was `.3em 0
   .5em` of a fluid font size, which measured 22px under the heading at 1440px
   and a different number at every other width. Same two tiers of the ladder as
   everywhere else now. */
.page-h1{font-size:var(--text-display-md);margin:var(--space-3) 0 var(--space-6)}

/* The fourth tier again: the last thing in a block, then the action button
   under it. Replaces three inline `style="margin-top:1.4em|1.6em"` rules,
   which were the only spacing values on the site a media query could not
   reach and a token could not describe. */
.action-row{margin-top:var(--space-7)}
/* THE SAME SPECIFICITY TRAP, ONE ELEMENT FURTHER DOWN, and it is why this rule
   exists. `.section-head p` above sets margin-top:0 at (0,1,1); `.action-row`
   is (0,1,0) and loses, so on the two closing blocks whose button sits inside a
   .section-head the ladder's 32px was silently never applied. MEASURED at
   1440px: 0px on Home's closing ask and 8px on About's, against the 32px both
   were specified to have.

   It had been invisible while the line above the button was 16px grey. It is
   not invisible now: components.md 21.4 promotes Home's ask to 30.4px serif and
   states that ".action-row's --space-7 (32px) still supplies the gap to the
   button", so with 0px the button sat on that sentence's descenders. Reaches
   exactly two elements on the site (index.html closing ask, about.html closing
   ask); every other .action-row is outside a .section-head and already had its
   32px. No spacing VALUE changes here: the ladder is untouched and simply
   arrives where it was always meant to. */
.section-head .action-row{margin-top:var(--space-7)}

/* THE LAST THING IN A BAND SHOULD NOT ALSO CARRY A PARAGRAPH'S BOTTOM MARGIN.
   PAUL, 2026-08-28: "There is too much white space underneath the 'Read all
   posts' button."

   .action-row is a <p>, so it inherits the body paragraph's 16px bottom
   margin. When it is the last child of a band, that 16px sits on top of the
   band's own 72px padding and does nothing except make the gap wrong. Measured
   at 1280: 88px under the button, against the 72px the band is specified to
   have.

   Scoped to :last-child so it only reaches an action row with nothing after
   it. An .action-row followed by anything still keeps its margin, because
   there it is doing a real job.

   The larger half of that complaint was structural, not a value: this band and
   the one after it were both .section-alt, so 72 + 72 read as one 144px void.
   That is fixed by the move recorded in index.html, not here. */
.action-row:last-child{margin-bottom:0}

/* THE ONE SENTENCE THAT CARRIES THE ASK (components.md 20.7 and 21.4).
   Two sentences that read identically become a supporting line and an ask.
   Home's closing block and the Bookings page had the same fault for the same
   reason: the reassurance and the instruction sat in two <p> elements inside a
   .section-head, so both rendered 16px --color-text-secondary, and the
   instruction disappeared into the comfort. The neighbour above each of these
   stays 16px secondary on purpose: the difference between them IS the fix.

   SCOPED WITH .section-head ON PURPOSE, ON BOTH CLASSES. DO NOT DROP IT.
   `.section-head p` above sets --color-text-secondary at specificity (0,2,1).
   A class-only `.cta-line{color:...}` is (0,1,0) and loses, so the sentence
   would silently keep rendering grey while every other declaration in the rule
   applied, which is the hardest version of this bug to see. Both classes sit
   inside a .section-head today; if either is ever used outside one, add the
   unscoped selector then, and not before.

   No border and no left rule: both blocks are centred, and .pullout's 3px rule
   plus 20px padding would push the text off its own axis. */
.section-head .cta-line,
.section-head .offer-line{
  font-family:var(--font-serif);
  font-size:var(--text-display-quote);   /* 20.8 -> 30.4px */
  line-height:1.3;
  /* PAUL, 2026-08-12: .cta-line, and ONLY .cta-line, drops one rung to
     --text-display-quote-soft in the rule further down this block. The size
     declaration stays here on the shared selector so .offer-line keeps
     reading it, because Bookings' offer was not in that instruction. Every
     other declaration in this rule - family, line-height, colour, balance -
     is still genuinely shared by both and must stay shared. */
  color:var(--color-text-primary);       /* 13.58:1 on the page ground */
  text-wrap:balance;                     /* these blocks run 2-3 lines, which is
                                            exactly what balance is for */
}
/* WO-140 / hero-overlay-and-motion.md 6.2, QA-R09-005. The stale 34ch cap
   below is WITHDRAWN, not shrunk: it was computed before the 30.4px
   promotion (components.md 21.4) and never revisited, and QA correctly
   declined to call it the cause - the actual binding constraint was always
   the ANCESTOR .section-head's own unwidened 60ch column (~474-480px),
   sized for 16px running text, wrapping a 30.4px four-word-per-line display
   sentence to four lines at 1440px. Larger type in an unchanged column wraps
   to MORE lines, not fewer.

   Scoped with :has() so no other .section-head on the site is touched - the
   shared 60ch rule above stays exactly as-is everywhere else it applies.
   Same graceful-degradation pattern already live at this file's
   .latest-block:has(.latest-empty) rule: in a browser without :has() support
   this whole amendment is inert and the page reverts to today's
   cramped-but-legible four-line wrap, not a broken one. Zero HTML changes -
   .cta-line is reliably the promoted-ask paragraph inside this one block. */
/* WO-146 item 6. Bookings uses .offer-line, not .cta-line, for the identical
   promoted-line treatment above (font/colour/weight already shared by both
   selectors) - a different class this widening rule never named, so
   Bookings' block was never covered. Item 5 (About) already matched the
   .cta-line selector alone and needed no edit - verified by reading
   about.html's markup: its closing block is class-for-class identical to
   Home's (.wrap.section-head > h2, p, p.cta-line, p.action-row). Same
   formula, one selector added. */
.section-head:has(.cta-line),
.section-head:has(.offer-line){
  max-width:calc(960px + var(--space-gutter)*2);
}
/* PAUL, 2026-08-14 (WO-177): About, Home and Bookings share ONE width, set by
   the rule above. The old 764/924 split is closed deliberately; if you find
   yourself adding a second max-width here, that is the drift this note exists
   to stop.
   TWO THINGS THAT STILL BIND. (1) The Bookings h1 has no max-width of its own
   and must not be given one - the parent supplies the measure. (2) The map is
   held at 764x382 by .office-map .map-frame in the BOOKINGS section below.
   That cap is what stops the 2:1 frame taking the container's full width, and
   it is now doing 240px of work rather than 160px. Do not remove it. */
/* WO-154 item 9 (components.md 22.12): insurance, not a fix. Pixel observed
   the Bookings h1 fills this 880px box with essentially zero tolerance today,
   so any future copy change orphans a word onto its own line. text-wrap does
   not change the measure and the h1 still has no cap of its own (see above -
   do not add one); where balance is unsupported the heading wraps exactly as
   it does today. Scoped to :has(.offer-line) alone so the shared
   .section-head h1,.section-head h2 rule elsewhere in this file, which other
   pages also use, is untouched. */
.section-head:has(.offer-line) h1{text-wrap:balance}
/* The reassurance sentence above the ask keeps exactly the measure it
   already renders at today - 68ch of its OWN font (measured, dated
   2026-08-14, QA-R15), stated on itself now rather than inherited from a
   parent about to grow, so it does not stretch into an overlong line once
   the parent above widens. WO-146 moved body to
   --text-md (17.6px), which this rule reads by inheritance, so the true
   figure is ~606-640px (inferred: 68ch scaled from the same per-character
   ratio this note already used for 60ch, not re-measured against the live
   page), not the ~474-480px a 16px font would have produced.
   Targeted by position, not a new class: it is reliably the first <p>
   before the promoted line in this one block. text-wrap:pretty (WO-154,
   components.md 22.4) guards against the line-ending orphan observed on
   About's copy of this sentence; Home's identical block gets it for free
   since both share this one selector. */
.section-head:has(.cta-line) > p:first-of-type{
  max-width:68ch;margin-inline:auto;text-wrap:pretty;
}
/* Home's ask. The parent above now supplies the ask's own room directly
   (same "parent supplies the measure, the child reads max-width:none"
   pattern the hero fix already uses), so the cap here is withdrawn rather
   than resized. 24px gap to the reassurance line above is unchanged;
   .action-row's 32px still supplies the gap down to the button.

   PAUL, 2026-08-12: the size drop lands here rather than on the shared
   .cta-line/.offer-line selector above, so Bookings' offer line is untouched.
   Reaches both copies of this sentence, Home's and About's, because both are
   class-for-class identical blocks. */
.section-head .cta-line{max-width:none;margin:var(--space-6) auto 0;
  font-size:var(--text-display-quote-soft);text-wrap:balance}   /* 19.2 -> 28px */
/* Bookings' offer. Not capped: it is the page's own promise, directly under the
   h1, and it holds #booking-intro-live, the hidden span js/bookings.js reveals
   when a real calendar is configured. That span must stay inside this sentence,
   so this treatment may never be turned into discrete fields or the live
   sentence is stranded. */
/* PAUL 2026-08-19: "reduce this text size some". One rung down, from
   --text-display-quote (30.4px ceiling) to --text-display-quote-soft (28px),
   which is EXACTLY the drop he asked for on the sibling promoted lines on
   2026-08-12 and which this line was deliberately left out of at the time
   (the token block's own comment records why: its complaint then was width,
   not size, and it got a widening instead). Both remedies now apply.
   Declared HERE, on the one selector, and not by moving the shared rung
   above, which is the same discipline the 2026-08-12 change followed and
   which the token's comment exists to protect: three other rules read
   --text-display-quote and none of them was in this instruction. No fourth
   token, because the rung this needs already exists. */
.section-head .offer-line{margin:0 0 var(--space-6);max-width:none;
  font-size:var(--text-display-quote-soft)}   /* 19.2 -> 28px */
/* Explicit for clarity and future-proofing, matching .cta-line's own explicit
   max-width:none two rules up - harmless today since nothing constrains this
   line without it, but states the intent instead of leaving it to inherit
   silently. */

/* Long-form prose block (Home's body copy, WO-100). Same 60ch reading
   column as .section-head, left-aligned rather than centered because this
   is running narrative, not a heading.

   HOW MANDY'S PACED LINES ARE BUILT, AND WHY IT MUST STAY THIS WAY.
   Her copy is written as single lines with deliberate breaks between them:
   eight lines, a break, then seven on Home; three and three and four in the
   cards. Those breaks are hers and they are correct. They used to be <br>
   tags, which cannot be styled, cannot take a token and cannot respond to a
   media query, so the space between one of her lines and the next was
   whatever the font's leading happened to leave and it differed from block to
   block. Each line is now its own .beat element at exactly the same break
   position, and .stanza is one group of lines.

   Do not merge her lines back into prose. Do not turn them into a list. Do
   not add, remove or move a break. Only the mechanism changed here; not one
   word and not one break position did.

   THE VOICE (components.md 20.1-20.2, WO-120 Block A). This block is now set
   as the page's second voice rather than as default body copy, which is what
   it was: no font-size, no colour and no family of its own, so it rendered at
   16px sans while the three cards beside it carried four decisions each. The
   fault was never that it was small. It was that no type decision had ever
   been made about the most important passage on the site.

   One rule for both places it appears: Home's paced block and About's "My
   Approach". Nothing below adds, removes, merges or re-times one of her
   lines; every promotion is addressed by selector, so her markup is
   untouched. */
.narrative{
  /* was 60ch, which measured 518px only because the block was 16px sans. ch is
     relative to this element's own font-size, so capping with min() against the
     site's prose measure keeps measure and type size moving together: about 65
     real characters a line at every width, and the 24px ceiling can never
     stretch the line past --measure-prose. */
  max-width:min(var(--measure-prose),62ch);
  margin:0 auto;                    /* base unchanged; Home opts out below */
  font-family:var(--font-serif);
  font-size:var(--text-narrative);
  line-height:1.4;
  color:var(--color-text-primary);  /* 13.58:1 on the page ground, 12.28:1 on
                                       sunken. Both cited from components.md
                                       20.2 and 20.4, not re-derived here. */
}
/* Reaches About's two paragraphs only. .narrative .beat below outranks it on
   Home, so her paced lines keep their own tighter rhythm. */
.narrative p{margin:0 0 var(--space-6)}

/* HOME ONLY, and this one declaration is the whole alignment fix.
   `margin:0 auto` above centred a 518px box inside 1036px of content, which put
   her first character at (1036-518)/2+202 = 461px while the hero headline sat at
   202px and the card grid at 202px. Her narrative was the one element on the
   page that opted out of the axis the hero's own comment (see line ~596) commits
   the page to. Removing the auto puts it back on that edge.

   Scoped to a class rather than changed on .narrative because About's "My
   Approach" keeps the centred prose band WO-112 built specifically to stop a
   stranded left column, and must not inherit this. */
.narrative-lead{margin-inline:0}
@media(min-width:1920px){.narrative-lead{max-width:min(840px,62ch)}}

/* One line of hers. 16px, one step up from the 8px this used to be, because a
   32px line box needs more than 8px to keep one line of hers from reading as
   the wrapped continuation of the line above it. Same 4px scale, one step. */
.narrative .beat{margin:0 0 var(--space-4)}
.narrative .beat:last-child{margin-bottom:0}
/* Outside .narrative (the three help cards, About's closing block) her lines
   keep the original 8px step. */
.beat{margin:0 0 var(--space-2)}
.beat:last-child{margin-bottom:0}
/* One group of her lines. This is the blank line in her own document. */
.stanza{margin:0 0 var(--space-7)}
.stanza:last-child{margin-bottom:0}

/* THE FIRST AND LAST LINE OF A MOVEMENT CARRY THAT MOVEMENT'S WEIGHT. Her own
   words, promoted by selector. No copy is added and none is cut.

   THIS REPLACES the two positional rules that used to live here
   (`.narrative .stanza .beat:first-child` and `:last-child`, components.md
   20.2). They were DELETED, not extended, and the deletion is not optional.
   With the turn split into its own section (see 21.2 and index.html), Movement
   1's :last-child is no longer her turn line: it is "Or maybe you've simply
   looked around..." at 124 characters. The old rule promoted it to 30.4px,
   which gave her single longest line the second-heaviest treatment in the
   block and stood a three-line 30.4px slab directly above the turn it is
   supposed to lead into. Positional selectors were right when the block was
   two stanzas; they are wrong now, so every promotion below is addressed per
   movement instead.

   STILL SCOPED, AND THIS IS STILL THE ONE BUG THESE RULES CAN SHIP. `.beat`
   is also used inside `.card` (see .card .beat above) and in About's closing
   block, so an unscoped `.beat:first-child` would blow the three "How I Can
   Help" card openers from 15.36px up to 38px and destroy that section. Every
   selector below is scoped by a movement class AND by .stanza, so the cards
   are twice out of reach. If .stanza is ever added inside a card, re-check
   this first. */
/* WO-162: the Imagine half of this pair is now addressed by wrapper class
   rather than by position. "Imagine…" is no longer the first child of .stanza -
   it sits inside .imagine-lead with its eyebrow, which is what puts the pair in
   column 1 of the hung lead-in - so `.stanza .beat:first-child` stopped
   reaching it and her lead-in would have silently dropped to body size. The
   recognition half is untouched and still positional, because that stanza's
   shape did not change. Both are still scoped by a movement class AND by a
   second class, so the "How I Can Help" cards remain twice out of reach.

   EVERY POSITIONAL SELECTOR IN THIS GROUP NOW USES A CHILD COMBINATOR, AND
   THAT IS NOT TIDYING. `.stanza .beat:last-child` is a DESCENDANT selector, so
   the moment .stanza gained wrapper divs it started matching the last .beat
   inside EACH wrapper: it caught "Imagine…" (last child of .imagine-lead) and
   her fifth line (last child of .imagine-stanza), and set both to 28px/600.
   Measured in the browser before the fix: "Learning to care for yourself with
   the same compassion you've always offered everyone else." rendered at 28px
   bold, and "Imagine…" LOST its 38.4px promotion to the same rule, because the
   two selectors tie on specificity and the landing line's is later in the file.
   `>` is what makes each of these address exactly one element. */
.narrative-recognition .stanza>.beat:first-child,
.narrative-imagine .imagine-lead>.beat{
  font-size:var(--text-display-sm);   /* 27.2 -> 38.4px, the same token
                                         .section-head h2 uses, so her line
                                         reads at heading scale */
  line-height:1.15;                   /* = h1/h2 */
  font-weight:600;                    /* = h1/h2 */
  margin-bottom:var(--space-6);
  text-wrap:balance;                  /* pure enhancement, same rationale as
                                         .hero h1: unsupported means it wraps
                                         exactly as it would have anyway */
}
/* Her close, "That next chapter is possible." */
.narrative-imagine .stanza>.beat:last-child{
  font-size:var(--text-display-quote-soft);/* PAUL 2026-08-12: 19.2 -> 28px.
                                              UNCHANGED by WO-162. His value,
                                              not ours, and the size step is not
                                              what was broken. */
  line-height:1.25;
  /* WO-162 / components.md 23.7, was 400. The declared SIZE step already
     existed and still failed to land, because this line and the five above it
     were the same weight, the same serif, the same ink and the same ground; at
     1920 --text-narrative overrides to 24.8px, so the step compresses to 28
     against 24.8, a ratio of 1.13, under the threshold at which a serif size
     step is perceptible at all. More of a failing device is not the fix.
     None of the three serif faces ships a 600, so this resolves to 700 - which
     is the point: it is exactly the weight "Imagine…" above the stanza renders
     at, so her opener and her closer bracket the stanza as a matched pair, one
     at 38.4px and one at 28px. That pairing is what makes the passage read as
     a sentence with a beginning and an end rather than a list with something
     after it. Ink on oat, 10.58:1, unaffected. DO NOT ALSO RAISE THE SIZE. */
  font-weight:600;
  /* WO-195 / 10.12 ruling 2: 24 -> 32px. This is the OUTSIDE gap below her
     five lines, and it has to stay larger than the 12px inside gap between
     them or the group has no edge. Paired with the marker and the 12px in
     .imagine-stanza below; all three are one change. */
  margin-top:var(--space-7);
  text-wrap:balance;
}
/* The turn's setup line. One step above her body voice, so the turn's own two
   lines are a two-step crescendo rather than a matched pair. */
.narrative-turn .stanza>.beat:first-child{
  font-size:var(--text-display-quote-soft);/* PAUL 2026-08-12: 19.2 -> 28px */
  line-height:1.3;
  font-weight:400;
  margin-bottom:var(--space-6);
  text-wrap:balance;
}
/* The hinge of her whole argument, and the largest type on this site that is
   not the hero h1 (44.8px). Above every h2 (38.4px) on purpose: it is a
   promoted sentence of hers, not a heading, and an editorial pull line
   outranking a section heading is normal. It wins on three axes, not one:
   size (44.8 against the openers' 38.4), contrast (14.79:1 on white against
   10.58:1 on oat) and ground (the only white full-bleed band on Home).

   PAUL, 2026-08-12: 44.8 -> 41.6px, about 7% off, the same reduction her setup
   line above and every other promoted line took, because his instruction named
   the turn's two lines together as one block and this is the second of them.

   ALL THREE OF THIS LINE'S ADVANTAGES SURVIVE THE REDUCTION AND THAT IS THE
   CONSTRAINT, not the number: 41.6px still outranks every h2 on the site
   (38.4px) and still outranks her movement openers (38.4px), so it is still
   the largest type on Home that is not the hero h1. Contrast and ground are
   untouched by a size change. Do not take this below 38.4px: at or under it
   this stops being the hinge of her argument and starts reading as one more
   heading, which is the whole thing this rule exists to prevent.

   WRITTEN AS ITS OWN CLAMP RATHER THAN A TOKEN EDIT. --text-display-md has
   three consumers and only this one was in the instruction: About's .page-h1
   ("Hello! My name is Mandy.") and a blog post's .article h1 also read it and
   must not move. The ramp is the token's own shape stepped down in place -
   4.2vw against 4.5vw - so the floor and the ceiling still meet the ramp at
   the same viewport widths they always did (about 678px and about 990px),
   measured rather than assumed. */
.narrative-turn .stanza>.beat:last-child{
  font-size:clamp(1.78rem,4.2vw,2.6rem);   /* 28.48 -> 41.6px */
  line-height:1.1;
  font-weight:600;
  margin:0;
  text-wrap:balance;
}

/* The eyebrow labels the line under it. 12px is not a new value: it is the
   eyebrow-to-heading gap already shipped on About, where .page-h1's margin-top
   supplies it. Both rules are inert until the eyebrow copy exists: it is
   Wordsmith's to write and it has not been written (see handoffs/HO-122.md).
   They are here so that landing it is a one-span markup edit and no CSS. */
.eyebrow+.narrative-lead{margin-top:var(--space-4)}
.section-head .eyebrow{display:block;margin-bottom:var(--space-4)}
/* WO-154 item 5 (components.md 22.4): About's heading is a .page-h1 outside
   any .section-head, so neither rule above ever reached it. Without this,
   About would be the one instance where the label grew but the gap under it
   did not, crowding the heading it labels. */
.eyebrow+.page-h1{margin-top:var(--space-4)}
/* WO-160 (QA-R13-001): Blog's "Coming soon" eyebrow sits in .notice-card,
   directly above a <p>, not a .narrative-lead, .page-h1 or .section-head, so
   none of the three rules above ever reach it. It is also the one eyebrow
   instance with no adjacent heading, so a missing gap reads as the card's
   own heading rather than as a label. Same step, same var, matching
   .section-head .eyebrow above rather than a one-off number. Scoped to
   .notice-card so it cannot touch the two notice-card instances on Bookings,
   which use <h2> and never carry .eyebrow. */
.notice-card .eyebrow{display:block;margin-bottom:var(--space-4)}

/* =============================================================================
   HER GROUND, THE BOUNDARY ABOVE IT, AND THE TURN (components.md 21)

   Oat is her voice. It appears in exactly three places, all of them inside her
   narrative block, and nowhere else on the site. Every ratio is recorded on
   the --color-surface-narrative token in :root; the two that govern what may
   be written on it: ink is 10.58:1 and passes, --color-text-secondary is
   4.09:1 and is BANNED, and any green word or mark uses
   --color-brand-on-narrative (6.74:1) rather than --color-brand-primary
   (4.37:1). One rule per ground, no per-element judgement.
   ============================================================================= */
.narrative-band{background:var(--color-surface-narrative)}
/* .eyebrow ships --color-brand-primary (see the EYEBROW rule above), which
   fails on oat at 4.37:1. This is not optional and it is not visible until the
   eyebrow copy exists, so it goes in now rather than landing as a defect on
   the day the copy does. */
.narrative-band .eyebrow{color:var(--color-brand-on-narrative)}

/* THE BOUNDARY. The hero and her narrative used to be one continuous field of
   cream with 144px of nothing in the middle of it, which reads as dead space
   rather than as a section break. The cream-to-oat edge is the boundary
   (1.28:1, stronger than any ground change already on the site); this strip is
   where the oat starts, and the 72px rule on the 202px page axis is what makes
   that edge read as designed rather than as a colour that happens to change.
   The hero's own 72px of bottom padding becomes the hero's closing space
   instead of half of a shared void.

   No bottom padding, deliberately: this is the head of one continuous oat
   field, not a separate stripe with a gap under it. */
.transition-band{
  background:var(--color-surface-narrative);
  padding:clamp(32px,4vw,56px) 0 0;      /* 56px at 1440, 32px at 390 */
}
.transition-band .axis-rule{
  display:block;width:72px;height:var(--border-thick);   /* 3px, existing token */
  background:var(--color-brand-on-narrative);            /* 6.74:1 on oat */
}
/* Her first line would otherwise sit 131px below the cream edge: 56 + 3 + the
   section default's 72. The band pays for the entry space, so the section
   stops paying for it a second time. 32px is also the distance
   .section-head .eyebrow already sits under a heading, so the rhythm here is
   the site's own. */
.transition-band+.narrative-band{padding-top:var(--space-7)}

/* =============================================================================
   MOVEMENT 1, RECOGNITION: THE TWO-TONE GROUND, THE CLOSING RULE, AND THE ARCH
   (WO-162; components.md 23.1, 23.2, 23.4, 23.5; next-chapter-layout.md 9.8)

   THIS BAND GAINS NO LAYOUT. Her six beats stay in one column on the page axis,
   in her order, at her measure. Everything below is ground and ornament: the
   band is two tones of one warm family with the edge placed past where her text
   stops, a 72px rule closing the light half, and a curved bottom edge. Not one
   of them touches her words.
   ============================================================================= */
.narrative-recognition{
  position:relative;   /* containing block for the arch */
  overflow-x:clip;     /* NOT overflow:hidden - clip does not establish a scroll
                          container, so nothing here can trap a focus ring or
                          create a nested scroller (9.10) */
  /* THE CLEARANCE GUARANTEE, and it is arithmetic rather than inspection. The
     arch's box is exactly --divider-travel tall and the path's maximum
     intrusion sits at y=0 of that box, i.e. at its top edge, so the distance
     from her last beat to the deepest pixel of the shape would be exactly
     --space-section-y (components.md 24.6, adopted as mechanism) IF NOTHING
     ELSE TOUCHED THE WRAP'S CONTENT BOX. Below 1024 nothing does. At 1024 and
     above, `.narrative-recognition .closing-rule` (defined further down, and
     `display:block` only from 1024px up) contributes its own height plus its
     own top margin to the same box. This comment previously stated "61px at
     1024 and 72px from 1440 up" here, which was a PREDICTION that missed that
     contribution entirely (QA-R14-002).

     WO-208 T2 CHANGED THAT CONTRIBUTION, AND THE OLD NUMBERS ARE GONE FROM
     THIS BLOCK ON PURPOSE. The rule used to be height:3px with
     margin-top:-24px, a net -21px pulled OUT of the box. It is now height:3px
     with margin-top:var(--space-7), a net +35px ADDED to it. Delta +56px.

     MEASURED BY BOLT (WO-208, 2026-08-15, Chromium headed, .narrative-recognition
     border-box height, before -> after): 1024 511.05 -> 567.05, 1280
     559.11 -> 615.11, 1440 590.52 -> 646.52, 1920 637.91 -> 693.91. Exactly
     +56.00px at each, which is the arithmetic above observed rather than
     assumed. Below 1024 the rule is display:none, so nothing moves there and
     the section heights are unchanged.

     THE CLEARANCE IS NOW OBSERVED AT EVERY WIDTH. From 1024 up it is
     --space-section-y plus 35px. OBSERVED BY BUGSY (QA-R19, 2026-08-15, on the
     post-change page): 40.00px at 320, 375 and 390; 46.08px at 768; 96.44px at
     1024; 107.00px at 1280, 1440, 1920, 2560 and 3840. Every figure this block
     previously derived by applying the +56px delta to QA-R14 was correct, so
     the arithmetic and the ruler agree. The 768 figure had been carried here as
     an INFERRED ~46px and is now a reading of 46.08px.

     The clearance moved AWAY from 9.10's 24px floor, not towards it, so this is
     a bookkeeping obligation rather than a risk. Never type these numbers into
     the rule below: change --divider-travel and the padding follows; the
     closing rule's +35px above 1024 is a fact about that rule, not something
     padding-bottom controls, and if its height or margin ever change again,
     these figures need remeasuring. */
  /* PAUL 2026-08-20: "we may also be able to reduce the white space some at the
     bottom of the Where you are now section, it seems a little more than what's
     between the other sections". Measured, and the excess is a different shape
     than it looks: at 1440 this band ends 144px below her last line against
     96px on .narrative-turn, but 72px of that 144 is the curved divider's own
     travel. The clear air is 72px, LESS than the turn band's. What reads as
     extra is the arch: its curve dips low on the left, so the ground under her
     last line is empty for longer on that side than a straight edge would be.

     THE DIVIDER TERM IS UNTOUCHED, deliberately. The clearance guarantee
     documented above is arithmetic on these two terms, and --divider-travel is
     the one the arch's geometry depends on. Taking height out of that makes the
     shape climb towards her last line, which is the failure 9.10's 24px floor
     exists to prevent. Only the air term is cut.

     -24px, WITH A FLOOR. Air goes 72px to 48px at 1440 and above. The max()
     matters at the small end: --space-section-y is only 40px at 390, where a
     flat subtraction would leave 16px and break the floor. It holds at 32px
     there, a cut of 8px rather than 24. No number is typed into the rule that
     the tokens do not supply. */
  padding-bottom:calc(
    max(32px, var(--space-section-y) - 24px) + var(--divider-travel)
  );
}
/* THIS BAND IS ONE UNIFORM OAT GROUND AND THAT IS A DECISION, NOT AN OMISSION.

   A two-tone split was specified for it (warmer oat left, --color-surface-sunken
   right, 1.16:1, edge derived from her measure plus 64px). It was BUILT, then
   RENDERED, then removed, which is the fallback the spec itself pre-decided for
   exactly this outcome (components.md 23.8: "if it reads as a wobble, drop
   recognition's tone split and let the divider plus the closing rule carry the
   right field").

   What the render actually showed, at 1024, 1440 and 1920: the lighter half had
   no text in it, so it did not read as "the tone changes where her words stop".
   It read as a rectangle - hard vertical left edge, hard top edge at the section
   boundary, closed at the bottom by the arch - with one small green dash
   floating inside it. At 1920 it was a 650x560 pale box in the upper right of
   the page. It read as an image that had failed to load. That is a worse defect
   than the emptiness it was drawn to fix, and it got worse as the viewport got
   wider, which is the opposite of what the device was for.

   The turn's split is the one that survives, and the asymmetry between the two
   bands is now the point rather than a problem: the turn's edge separates two
   things she wrote, so it expresses a contrast in her sentence. Recognition's
   was atmosphere. Atmosphere is the half that gives way.

   The expression is not lost. It is held, unbuilt, in .webteam/04-ui/tokens.css
   under "HELD, NOT BUILT", with its derivation and its resolved edges at every
   width, so a future wave that puts content or imagery in that field can pick it
   up rather than re-derive it. It is not left here as dead tokens. */
@media(min-width:1024px){
  /* The mirror of .transition-band .axis-rule: same 72px, same 3px, same green,
     on the same content-box edge. The band opens top left and closes bottom
     right with the one ornament the page already owns; a second rule length
     would be an inconsistency for no gain. #164F41 on oat is 6.74:1, well over
     the 3:1 non-text floor. Decorative, aria-hidden, nothing to read. */
  /* WO-208 T2, and BOTH declarations below are required together. Ruled by
     Pixel in HO-205 T2 after rendering, not reasoned from source.

     WHERE IT WAS: margin-inline-start:auto put the rule at x=1456 at 1920,
     roughly 300px right of where her words stop, vertically level with the last
     two lines of her final paragraph. It read as a dangling dash belonging to
     that paragraph, or as a rendering artifact. It never read as a bookend.
     The reason it was placed there has expired: it was drawn to give the
     lighter right half of a two-tone ground one fixed point, and that two-tone
     split was built, rendered, judged a failure and removed (see the long
     comment above). The rule was anchoring a field that no longer exists.

     WHY THE MARGIN MOVES TOO, AND WHY THIS IS NOT A REFINEMENT: the axis move
     was rendered on its own, keeping margin-top:calc(-1 * var(--space-6)), and
     the rule STRIKES THROUGH the word "built" on her last line. That candidate
     was discarded, not tuned. On the page axis the rule sits under her text
     rather than beside it, so a negative top margin is no longer a lift into
     empty ground, it is an overlap on her words. Do not reintroduce it.

     --space-7 is the same 32px that already sits between the opening rule and
     her first line, so the two ends of the band are the same mark, on the same
     axis, at the same distance. That is what makes it a bookend, and all four
     parts are measured rather than asserted. */
  .narrative-recognition .closing-rule{
    display:block;width:72px;height:var(--border-thick);
    background:var(--color-brand-on-narrative);
    margin-inline-start:0;margin-top:var(--space-7);
  }
}
@media(max-width:1023.98px){
  /* One ground below the split, never two stacked horizontal tone bands (9.9),
     and no ornament stranded under a single column. */
  .narrative-recognition .closing-rule{display:none}
}

/* THE ARCH. Painted in the colours of the section BELOW, because the region the
   mask keeps is the region the turn's ground rises into; the region the mask
   drops is transparent and lets this band's own two-tone show through, so one
   gradient here is enough for both halves of the seam.

   An SVG `fill` cannot take a CSS gradient, which is why this is a
   gradient-filled block with a mask rather than an inline <svg>.

   In Windows High Contrast a masked gradient disappears entirely. That is
   correct and expected: the shape is decorative and aria-hidden, the two
   sections still differ by their forced-colours backgrounds, and no information
   is carried by the curve. (Contrast this with a structural line, which would
   have to be a border for exactly that reason.)

   No horizontal overflow is possible: inset-inline:0, no rotation, no skew, no
   negative inset, and the section's overflow-x:clip is a second belt. */
.narrative-divider{
  position:absolute;inset-inline:0;bottom:0;
  height:var(--divider-travel);
  pointer-events:none;
  background:var(--color-surface-raised);
  -webkit-mask-image:var(--divider-mask);
          mask-image:var(--divider-mask);
  -webkit-mask-size:100% 100%;   mask-size:100% 100%;
  -webkit-mask-repeat:no-repeat; mask-repeat:no-repeat;
}
@media(min-width:1024px){
  /* Two-tone above 1024, matching the turn exactly, so the seam and the band it
     leads into are painted from the one --turn-edge expression and cannot
     disagree by a pixel. */
  .narrative-divider{
    background:linear-gradient(to right,
      var(--color-surface-turn-warm) 0 var(--turn-edge),
      var(--color-surface-raised)    var(--turn-edge) 100%);
  }
}

/* MOVEMENT 2, THE TURN. The one break in the oat field, and the whole reason
   the block reads as three movements rather than two. White is the brightest
   ground on the page and gives her hinge sentence 14.79:1, the highest text
   contrast anywhere on this site.

   Deliberately NOT an inverted dark band: the passage immediately above it is
   her most vulnerable ("uncertain, overwhelmed, and disconnected"), a dark
   slab there reads as gloom, and Band 1's photograph lands directly under this
   band, where a second heavy full-bleed element would fight it for the same
   beat.

   Air is the third weight device. 64px above and 96px below is more than any
   other section on the page (the section default is 72px), and the asymmetry
   is load-bearing: it keeps the M1-to-turn gap (72+64 = 136px) SMALLER than
   the turn-to-M3 gap (96+72 = 168px), so her one authored blank line stays the
   largest break in the block and this new movement boundary stays subordinate
   to it. Do not equalise these.

   THIS BAND IS NOT INTERACTIVE AND MUST NEVER LOOK IT. No hover, no
   transform, no shadow, no border, no border-radius, no cursor change, ever.
   The three "How I Can Help" cards below it are stretched links on a white
   fill; what keeps a 100vw white band from reading as one of them is that it
   has none of a card's affordances and spans the full viewport rather than
   sitting in a 1080px grid. Add any one of them and this becomes a 100vw
   element that looks clickable and is not. */
.narrative-turn{
  background:var(--color-surface-raised);
  padding:clamp(32px,4.5vw,64px) 0 clamp(48px,7vw,96px);
}

/* THE TURN'S TWO-TONE AND ITS 5fr/7fr SPLIT (WO-162; next-chapter-layout.md
   9.7; components.md 23.1, 23.2).

   Two beats and a "But" pivot: the two halves of this band are literally a
   contrast, which is the one thing a two-tone split expresses rather than
   decorates. Left half is what is hard, right half is what opens. Reading order
   survives, because left to right IS the reading order and "But" still lands
   second.

   ASYMMETRIC, NOT 50/50, AND THE ASYMMETRY IS THE POINT. Equal columns invite
   equal typography, and two beats of equal weight is a pair, not a pivot. Beat
   2 keeps its size step over beat 1; that step is never flattened.

   WHITE STAYS ON THE RIGHT AND MUST NOT BE FLIPPED. Beat 2 is the hinge of her
   whole argument and white gives it 14.79:1, the highest text contrast anywhere
   on this site. Warming the right half would cost that. The warm half measures
   12.99:1 for ink and 5.37:1 for brand green, so nothing on the left half needs
   a recolour rule and none is added. */
@media(min-width:1024px){
  .narrative-turn{
    background:linear-gradient(to right,
      var(--color-surface-turn-warm) 0 var(--turn-edge),
      var(--color-surface-raised)    var(--turn-edge) 100%);
  }
  /* The cap comes off so the two columns can use the content box; each column
     carries one of her two beats and neither runs long. */
  .narrative-turn .narrative-lead{max-width:none}
  .narrative-turn .stanza{
    display:grid;
    /* minmax(0,...) rather than a bare 5fr/7fr: an fr track's implicit min is
       auto, so a long unbreakable run in either beat could otherwise push the
       column past its share and drag the text under the tone edge the grid is
       supposed to stay clear of. */
    grid-template-columns:minmax(0,5fr) minmax(0,7fr);
    column-gap:var(--turn-gap);
    /* Two short blocks of different height read as a counterweight when centred
       and as an accident when top-aligned (9.7). */
    align-items:center;
  }
  /* Auto-placement puts beat 1 in column 1 and beat 2 in column 2 from source
     order alone. There is no `order`, no explicit placement and no reversal
     anywhere in this band, so DOM order is visual order (9.9). */
  .narrative-turn .stanza>.beat:first-child{margin-bottom:0}
}
@media(max-width:1023.98px){
  /* Below the split the tone collapses to one ground. Never two stacked
     horizontal tone bands (9.9). */
  .narrative-turn{background:var(--color-surface-raised)}
}

/* =============================================================================
   MOVEMENT 3, ONE COLUMN AND THE DRAWN MARKER
   (WO-195; next-chapter-layout.md 10.12, which supersedes 10.4 and one bullet
   of 10.5 in full)

   WHAT WAS HERE BEFORE AND WHY IT IS GONE, TWICE OVER.

   First it was a <ul class="imagine-list"> with a CSS-drawn dash marker. The
   <ul> was OURS - her document carries no list markup - so WO-162 removed it,
   and that removal (M-16) STANDS and is not reopened here. There is still no
   <ul>, no <li>, no role="list" and no aria-anything in this band. Five
   paragraphs go into the accessibility tree and five paragraphs come out.

   Then WO-162 built a hung lead-in in its place: "Imagine…" and its eyebrow in
   a narrow left column at 1024 and up, her five lines in a wider right column,
   her landing line spanning under both. Paul rejected it in those words:
   "disjointed, doesn't flow." Sketch rendered both versions and found why
   (10.12): at that split the eyebrow and her first line share a baseline, so
   the label reads as a NEIGHBOUR of her first line instead of a heading over
   the group, and her closing line lands beside the end of her stanza rather
   than after it. The grid, the --imagine-measure variable, the 1 / -1 span and
   the 1024 and 1920 queries for this band are all DELETED, not disabled. Do
   not rebuild any of them, and do not rebuild the five-column field from an
   older section either: her five clauses run 42/65/31/18/91 characters, a 5:1
   ratio, and that field is dead.

   WHAT SHIPS NOW. One column at every width, 320 to 1920, so there is no
   breakpoint in this band to build, verify or remember. Eyebrow on its own
   line, "Imagine…" under it, her five lines under that, her close after them,
   all on the one left edge the hero headline and the card grid already share.

   THE MARKER COMES BACK, AND IT IS DRAWN, NEVER TYPED. 10.12 overturns
   10.5's "the dash goes" on a fact in her own text rather than on taste: her
   five Imagine lines are five gerund fragments with no finite verb, all of
   them grammatical completions of the stem "Imagine…", while her six
   recognition beats are six independent complete sentences. A marker that
   groups the five renders a structure her sentences already have; the same
   marker on the recognition beats would invent one. That is why it is correct
   here and stays out of there.

   `content:""` PLUS A BOX. NOT `content:"-"`, NOT `content:"\2022"`, NOT ANY
   GLYPH, EVER. This is the one line in this block that is not negotiable.
   Generated TEXT content is announced by some screen readers, so a bullet
   character read aloud before each of her lines would put the list claim M-16
   removed from the markup straight back into the audio channel, through a door
   nobody is watching. A drawn box is not in the accessibility tree at all.
   Same short dash, same measured .65em offset and same border colour the mark
   shipped with before it was removed (6.74:1 on oat).

   HER FIVE LINES STILL GAIN NO REVEAL MOTION. Unchanged decision, unchanged
   reason: the old stagger was written for list items, her six recognition
   beats never had it, and two stanzas of the same authored shape animate the
   same way. About's .checklist keeps the rule below; it is a real list and
   still the only one on the site. */

/* THE THREE SPACING VALUES AND THE MARKER SHIP TOGETHER OR NEITHER SHIPS.
   Sketch's words, and the coupling is the ruling rather than the numbers. A
   group needs an inside gap smaller than its outside gaps: 12px between her
   five, 24px above them (supplied by "Imagine…"'s own margin-bottom, unchanged
   at --space-6), 32px below them. The 16px these lines used to carry was set
   at style.css:1309-1312 so that a wrapped line could not read as the line
   above it continuing, and her fifth line DOES wrap at every width. With a
   marker present an item begins where a mark is, so the wrap stays unambiguous
   at 12px. IF ANYONE EVER DROPS THE MARKER, THIS REVERTS TO --space-4 IN THE
   SAME EDIT. */
.imagine-stanza{
  /* Marker box plus its gutter, in em so it tracks her type at every width
     exactly as the marker itself does: .55em of dash plus .6em of clear space,
     which is the same 1.15em total the flex version of this mark used to
     produce from `gap`. Substituted into padding-inline-start below, where it
     computes against the PARAGRAPH's font-size, not this wrapper's. */
  --imagine-indent:1.15em;
}
.imagine-stanza>.beat{
  position:relative;              /* containing block for the drawn mark only */
  padding-inline-start:var(--imagine-indent);
  margin-bottom:var(--space-3);   /* 12px, and see the coupling note above */
}
.imagine-stanza>.beat:last-child{margin-bottom:0}
/* THE HANGING INDENT, AND WHICH EDGE EACH PART LANDS ON.
   The mark is taken out of flow at the paragraph's inline start, which is the
   page axis, and the padding above holds EVERY line of the paragraph - not
   just the first - clear of it. That is what makes her fifth line's wrap align
   to the TEXT above it rather than to the marker. Padding plus an absolutely
   positioned mark is chosen over the old flex pair for one reason: it leaves
   the paragraph a normal block box, so text-wrap and her line breaking behave
   identically to the four other .beat stanzas on this page. */
/* WO-208 T3. AN OPEN RING, and the reason is the system's own vocabulary
   rather than a preference. The mark used to be 9.5x3px with
   border-radius:999px, which at reading size is a hyphen or a minus sign.

   The ten icons on this site are drawn `fill:none` with `stroke-linecap:round`
   (see .ic svg): this site's drawn language is rounded, open, unfilled strokes.
   An open ring is that language at ornament scale. A FILLED DOT CONTRADICTS
   fill:none and must not be substituted here. A diamond or a chevron was ruled
   out before it was drawn, by this project's own recorded principle in the arch
   comment: every shape in this system is --radius-full or --radius-lg, so a
   straight diagonal would be the only acute angle drawn on purpose anywhere on
   the site. A rotated square is exactly that.

   There is also a reading of it that suits the words. An open circle is a mark
   that has not been filled in yet. Under "Imagine..." that is closer to right
   than a dash, and much closer than a checkmark, which would assert that these
   things are already done. */
.imagine-stanza>.beat::before{
  content:"";                     /* EMPTY. Never a character. See above. */
  position:absolute;
  inset-inline-start:0;
  /* The mark's box, in em so it tracks her type at every width. Kept at the
     SAME .55em the dash used, which is what makes this change free:
     --imagine-indent above is unchanged, the .95em floor at <=359.98px is
     still valid against the same 9.5px mark, and no line in her stanza can
     rebreak. Measured: identical line counts at 320, 390 and 1280. */
  --imagine-mark:.55em;
  /* The dash's optical anchor, preserved. Its centre sat at .65em + 1.5px,
     which is .735em at the band's base size; that value was measured out of a
     browser when the mark first shipped and is not re-derived here. Writing
     the centre and subtracting half the box means the mark stays optically
     centred on the first line if its size is ever retuned. */
  top:calc(.735em - var(--imagine-mark)/2);
  width:var(--imagine-mark);
  height:var(--imagine-mark);
  box-sizing:border-box;          /* so .55em is the OUTER diameter */
  border:2px solid var(--color-brand-on-narrative);  /* #164F41, 6.74:1 on oat */
  border-radius:var(--radius-full);
  /* `background` is deliberately NOT set: the ring is hollow, which is the
     whole point. A future edit that adds one turns this into a filled dot with
     a ring around it and should be rejected.

     2px and not an em value: a ring this small needs a stroke that lands on
     whole device pixels. 1.5px computed to a 1px border in Chromium and the
     ring went soft. 2px at every width, rendered and compared at 390 and 1280. */
}
/* THE MOBILE FALLBACK 10.12 ASKED FOR, WITH ITS VALUE AND ITS TRIGGER BOTH
   CORRECTED BY MEASUREMENT. 10.12 said: if the indent pushes her fifth line to
   a fourth line at 390, drop the indent to 20px at mobile only.

   MEASURED IN CHROMIUM AT 4px STEPS FROM 320 TO 372, SHIPPED INDENT AGAINST NO
   INDENT: at 390 her fifth line runs to three lines with the indent and three
   without it, so the indent costs her nothing there and the case 10.12 was
   worried about does not occur. It occurs at 320 ALONE - 324 and every width
   above it are clean - where the indent takes her fifth line from three lines
   to four and her second from two to three.

   AND 20px CANNOT FIX IT, which is why this is .95em and not 20px. At 320 the
   band's type is 17.28px, so the 1.15em above already computes to 19.87px: the
   specified 20px fallback is LARGER than the value it was meant to reduce and
   renders identically to it, four lines and all. The line cliff at 320 sits
   between 18px and 19px of indent, measured at 1px steps. .95em is 16.42px
   there, which clears it with room and still leaves 6.9px of clear space after
   the 9.5px mark.

   IT IS SCOPED TO THE FLOOR AND COSTS NOTHING ELSE. .95em was rendered at every
   width from 320 to 372 and at 768, 1024, 1440 and 1920: identical line counts
   to 1.15em at every one of them. The wider query bound is deliberate, so this
   is a conventional mobile-floor rule rather than one tuned to a single pixel
   width. THE MARKER IS NOT DROPPED HERE, AND MUST NOT BE: 10.12 forbids solving
   either case that way, and the 12px inside gap depends on it. */
@media(max-width:359.98px){
  .imagine-stanza{--imagine-indent:.95em}
}
/* The eyebrow moved inside .narrative to travel with "Imagine…" into column 1,
   so `.eyebrow+.narrative-lead` no longer reaches it. This restores the same
   16px step that rule was supplying, from the same token. Nothing about the
   label's size (19.2px) or its tracking (.14em) changes, and the recolour on
   .narrative-band .eyebrow still applies: #164F41 on oat, 6.74:1. */
.imagine-lead .eyebrow{display:block;margin-bottom:var(--space-4)}

/* Reveal motion, WO-134/hero-overlay-and-motion.md 5.2. Now About's .checklist
   alone: it is the last real <ul> on the site (WO-162 removed the other one).

   DEVIATION FROM THE SPEC AS WRITTEN, FLAGGED PLAINLY. The spec (written
   without a render session, see its own method disclosure) animates opacity
   AND transform together, `<li>` starting at opacity:0. This is the exact
   shape of the .reveal entrance fade this project removed in WO-112 (see the
   comment near the top of this file) after opacity:0 plus a scroll-linked
   timeline that did not always advance hid the client's own words on a
   plain rendered page - "content must be visible by default with animation
   as enhancement only." A list item stuck at opacity:0 is that same failure,
   not a smaller version of it. Built transform-only instead, for the same
   reason the icon settle above is transform-only: a stagger stuck at
   translateY(10px) is a minor, cosmetic miss, never a content-hiding one.
   The visible stagger itself still comes from layout alone, with no
   authored delay value - each <li> gets its own view() timeline tied to its
   own position in the flow, so items lower in the list cross the entry
   threshold later as the visitor scrolls. Flagged for Pixel/Maestro: if a
   render-verified case for the opacity layer exists, this can be revisited,
   but it should not ship on an estimate alone given this project's own
   recorded incident. */
@supports (animation-timeline: scroll()) {
  @media (prefers-reduced-motion: no-preference) {
    .checklist li{
      transform:translateY(10px);
      animation-name:pxl-reveal-rise;
      animation-duration:auto;
      animation-timing-function:ease-out;
      animation-fill-mode:both;
      animation-timeline:view();
      animation-range:entry;
    }
    @keyframes pxl-reveal-rise{ to{ transform:translateY(0); } }
  }
}

/* Long-form band: one reading column, with the heading on the same axis as
   the text under it (About, "My Approach"). WO-112.

   What it fixes, seen in a rendered page rather than reasoned from source: a
   centred .section-head sets a page-centre axis, and the left-aligned 60ch
   .narrative under it then reads as a stranded, left-offset column with a
   large void beside it. Both boxes were in fact centred and the same width;
   the mismatch was alignment, not position, and no measurement of either box
   could show it. Sharing one column at the site's own prose measure, with the
   heading left-aligned to the same edge, makes the block read as one
   deliberate unit.

   Deliberately a new class rather than a change to .section-head or
   .narrative: both are used on Home, where the centred heading sits over a
   full-width card grid and is correct as it is. */
.prose-band .section-head{max-width:var(--measure-prose);text-align:left;
  margin:0 auto 1em}
.prose-band .narrative{max-width:var(--measure-prose)}

/* THE APPROACH SPLIT. This is the section's visual device, replacing the mark
   Paul removed, and it is made only of layout, scale and space.

   Below 1024px nothing happens at all: one column, source order, her two
   paragraphs exactly as before. The device is opt-in at the widths where the
   problem actually existed, which is where a reading column sat alone in a wide
   field of tinted ground with nothing to its right.

   From 1024px the closing line moves into a second column. That paragraph is
   SEPARATE from the credential-and-disclaimer run, which stays whole and
   unmoved in the left column. Those two are never split across columns, never
   fenced, never made smaller or lighter. That rule is why this is a grid and
   not a panel: a grid cannot become a box the disclaimer gets filed into.

   The measure widens here and only here, from --measure-prose to the two
   tracks, because a prose measure is a rule about line length in a single
   column and this is no longer one column. Each track stays well inside a
   comfortable measure on its own. */
@media (min-width:1024px){
  .prose-band .narrative.approach-split{
    max-width:none;
    display:grid;
    grid-template-columns:minmax(0,1fr) minmax(0,.85fr);
    gap:clamp(var(--space-7),5vw,var(--space-9));
    align-items:center;
  }
  /* Her closing line goes back UNDER her paragraphs, in the same column, where
     it reads as the end of the passage. Paul tried it in the second track and
     it read as stranded: a sentence parked to one side rather than the turn the
     section builds to. The second track carries the image instead, which is
     what a second track is actually good for. */
  .prose-band .narrative.approach-split .approach-close{margin-top:var(--space-6)}
  /* The heading has to leave the prose measure when the body does.
     .prose-band .section-head is capped at --measure-prose and centred with
     margin auto, which is right for a single column. Once the body spans both
     tracks, that same rule leaves the heading floating indented above a wider
     block, aligned to nothing. This re-anchors it to the grid's left edge.

     :has() is already load-bearing on this site (build.py enforces a contract
     on the blog-visibility rule that uses it), so it is not a new dependency. */
  .prose-band:has(.approach-split) .section-head{
    max-width:none;
    margin-inline:0;
  }
}

/* The image is a wide-viewport device only. Below 1024px the column is already
   narrow, and an image there would push her credential and disclaimer further
   down a page a visitor is scrolling through on a phone. display:none rather
   than a visually-hidden shim is correct here: it is decorative and aria-hidden,
   so there is nothing to keep available to assistive technology. */
.approach-figure{display:none}
@media (min-width:1024px){
  .approach-figure{display:block}
  /* THE ARCH. Paul asked for a shape rather than a rectangle with rounded
     corners, and asked that it support what the section says rather than just
     decorate it.

     An arch is a doorway, and the section it sits beside is about a threshold:
     her whole practice is named for the next chapter, and this page's closing
     line is "I believe you already have many of the answers you're searching
     for." A dome reads as something you walk through. A rounded rectangle reads
     as a photo.

     It is also vocabulary this site already speaks. The curved divider under
     Home's recognition band is the same gesture, so this borrows an existing
     hand instead of introducing a second one. That was the whole failure of the
     three-circles glyph that used to open this section: a shape that referred
     to nothing else on the page.

     The two-value radius syntax is what makes it an arch rather than a
     lozenge: 50% horizontal against 38% vertical on the top corners gives a
     dome slightly taller than a half-circle, which suits a 4:5 portrait. Equal
     values would bulge into an oval. The bottom corners keep the site's own
     --radius-md so the image still belongs to the same family as the portrait
     above it. */
  /* THE ECHO. A second arch, same curve, offset behind the photograph.

     Paul asked for more than a shape, and the research direction that fits this
     site is layering: photography combined with a graphic element rather than
     photography cut into a fancier outline. An offset echo does that with one
     pseudo-element and no new asset.

     It also earns its place in meaning, which is the test the three-circles
     glyph failed. One arch is a doorway. Two, one behind the other, is a
     doorway with something beyond it, which is what the section is about: not
     the threshold itself but what is on the other side of it. The echo sits up
     and to the left, so it reads as the space she is inviting you into rather
     than a drop shadow.

     Brand green at low alpha rather than a grey: on the oat ground a neutral
     offset reads as a printing misregistration, while the brand tint reads as
     deliberate. */
  .approach-figure{position:relative}
  .approach-figure::before{
    content:"";
    position:absolute;
    inset:-18px 18px 18px -18px;
    border-radius:50% 50% var(--radius-md) var(--radius-md)
                / 38% 38% var(--radius-md) var(--radius-md);
    background:color-mix(in srgb, var(--color-brand-primary) 16%, transparent);
    z-index:0;
    pointer-events:none;
  }
  .approach-figure img{
    position:relative;z-index:1;
    display:block;width:100%;height:auto;
    border-radius:50% 50% var(--radius-md) var(--radius-md)
                / 38% 38% var(--radius-md) var(--radius-md);
    box-shadow:var(--shadow-lg);
  }
  @supports not (background:color-mix(in srgb, red 16%, transparent)){
    /* Older engines get the same shape at a fixed tint rather than nothing. */
    .approach-figure::before{background:rgba(31,110,90,.16)}
  }

  /* ---- PAUL 2026-08-19: the arch fades in as you scroll toward it and out as
     you scroll away.

     THIS FILE BANS ONE SHAPE OF THIS EFFECT AND THIS IS NOT IT. Read the
     comment near the top ("The entrance fade (.reveal, WO-067) was REMOVED
     here, WO-112, and must not come back in this form"). What was banned is
     precise: `opacity:0` as a BASE STATE, with a scroll-linked timeline
     relied on to bring the element back to 1. When that timeline does not
     advance, the element never becomes visible, and on Home that hid Mandy's
     own body copy, three cards and the blog strip at once, on a plain
     rendered page. The rule that came out of it is "content must be visible
     by default with animation as enhancement only."

     Nothing here sets opacity:0 as a base state. There is no `opacity` in
     .approach-figure's own rules at all. The value 0 exists only inside the
     keyframes below, and `animation-fill-mode: none` is what makes that
     structural rather than a promise: outside the animation's active range,
     and on any timeline that never becomes active, NO keyframe style is
     applied and the element renders at its base opacity, which is 1. A
     stalled timeline here leaves a fully visible photograph, which is the
     opposite of the WO-112 failure mode rather than a smaller version of it.
     Verified by rendering with the timeline inactive, not by reading the
     rule back.

     fill-mode:none is therefore load-bearing. If anyone changes it to `both`
     or `forwards` to "make it smoother", they reinstate WO-112 exactly. That
     is not a guess: `forwards` was BUILT AND MEASURED here, and with the
     timeline inactive it left the element at opacity 0.00. Chrome treats the
     inactive case as the after-phase, so `forwards` fills it with the last
     keyframe. Measured, `none` gives 1.00 and `forwards` gives 0.00 in the
     identical test. Do not swap it.

     ENTRY ONLY, AND THAT IS THE WHOLE BEHAVIOUR. Paul, 2026-08-19: it should
     fade IN on the way down, then stay visible as he keeps scrolling past it,
     but fade back out if he scrolls back UP above it.

     That sounds directional, and CSS scroll-driven animations have no notion
     of direction -- they map a scroll POSITION to a progress value and nothing
     else. It resolves to a position rule anyway, which is why it is buildable:
     all three of those behaviours are one entry-only fade. Scrolling down
     through `entry` runs it 0 -> 1. Past `entry` there is no range left, so it
     simply stays at its base opacity, which is 1. Scrolling back up re-enters
     the same range from the other side and the browser runs it in reverse,
     which IS the fade-out he wants, in the one place he wants it.

     WHAT THIS REPLACED, so nobody restores half of it: a `cover 0% cover 100%`
     range with a four-stop 0/1/1/0 keyframe set, which also faded out on the
     way down. That version needed `animation-timeline: view(block -80px)`,
     because `html{scroll-padding-top:78px}` shrinks the scrollport a view()
     timeline measures against, so its `cover` range ended 78px early and the
     element snapped from opacity 0.01 back to 1 while 74px of it was still on
     screen. The extra keyframes are gone with the fade-out that needed them,
     and the END of the range no longer needs protecting: `entry` finishes on
     the scrollport's bottom edge, which scroll-padding-top does not touch, and
     its last keyframe is 1, which is also the base value, so there is nothing
     left to snap between.

     THE INSET STAYED, BUT MOVED TO THE OTHER EDGE, and the reason is the
     mirror image of the old one. fill-mode:none means that BEFORE the range
     begins no keyframe applies at all, so the element sits at its base opacity
     of 1 and then drops to 0 the instant the range starts. Measured with a
     plain `view()`: a 0.98 drop while 9px of the arch was already on screen.
     Small, but it is the tip of the dome popping. `view(block 0px -80px)`
     extends only the END inset -- the bottom edge of the scrollport, which is
     the edge the element enters through -- so the range now starts 80px before
     the arch appears and that drop happens with nothing on screen. The first
     value stays 0px on purpose: the top edge needs no help here. Both forms
     were built and measured; the symmetric one also works, this is the smaller
     claim.

     Verified as three separate journeys rather than one scan, because the
     behaviour is meant to differ by where you are and which way you came:
     down toward it 0.02 -> 1.00 smoothly; onward past it 1.00 held at every
     one of 27 samples; back up 1.00 held, then 0.96 -> 0.04 as it leaves.

     It is also decorative: aria-hidden, alt="", and display:none below
     1024px. Even in the worst case it can hide nothing a visitor needs, which
     is the second reason this is allowed here and would not be allowed on her
     words. The stricter guarantee above is what makes it safe; this is only
     why the stakes are low if both are somehow wrong.

     The ramp is the whole of `entry`: from the moment the arch's top edge
     reaches the bottom of the viewport to the moment its own bottom edge does.
     It is therefore fully opaque from the first instant it is completely on
     screen, and every part of the fade happens while it is arriving. */
  @supports (animation-timeline: scroll()) {
    @media (prefers-reduced-motion: no-preference) {
      .approach-figure{
        animation-name:pxl-approach-fade;
        animation-duration:auto;
        animation-timing-function:linear;
        animation-fill-mode:none;   /* load-bearing, see above */
        animation-timeline:view(block 0px -80px);   /* also load-bearing */
        animation-range:entry;
      }
      @keyframes pxl-approach-fade{
        from { opacity:0 }
        to   { opacity:1 }
      }
    }
  }
}

/* --- About, "My Approach" (WO-120 Block C). Three devices: the eyebrow label
   (waiting on copy, see the .eyebrow rules above), the existing H2, and her
   closing thought promoted. No image, ruled out by Sketch in
   next-chapter-layout.md 8.3: this section already sits on .section-alt so it
   has no ground change left to spend, and a second image on a short page would
   sit beside the one paragraph on the site that must never be visually
   diminished.

   Space is the only device here that separates two ideas without ranking one
   above the other, which is why it is the one used. --- */
/* WO-208 T5. The section opener: a 56px line-art mark and a 120px accent rule
   on the reading column's own axis, above the heading.

   ABOVE THE HEADING ON PURPOSE. components.md 20.8 forbids a panel, tint,
   callout or border INSIDE this section, because the credential sentence and
   the not-therapy disclaimer are one unbroken run. This device is outside the
   prose entirely: it adds nothing inside .narrative, draws no box around
   anything, and leaves the disclaimer's 600 weight, size, colour and position
   immediately after the credential exactly as they were. Nothing here may
   later be turned into a panel to "tidy" the section.

   max-width matches .prose-band's own measure, so the mark starts on exactly
   the same left edge as the H2 and the prose beneath it. */
/* WO-213, Paul 2026-08-16. The opener mark and hairline are GONE. Keeping the
   rules but unused would leave a second, contradictory account of how this
   section is composed, so they are deleted rather than commented out.

   Why it went: an abstract three-node glyph referred to nothing else on the
   page, appeared in no other section, and read as a stray mark rather than a
   device. The reasoning that produced it was sound - this section had no visual
   anchor at any width - but the answer was wrong.

   THE PROBLEM IT WAS SOLVING IS STILL REAL, and is now solved by layout. At
   1024px and up the section's own closing line moves into a second column,
   which fills the empty tinted ground on the right and gives the eye somewhere
   to land. See .approach-split below.

   The constraint that ruled out a panel, tint, callout or border still stands
   and is not weakened by this: layout cannot fence the disclaimer off, because
   the credential and the disclaimer never leave the left column together or
   apart. They stay one unbroken run, unmoved. */
/* 56px, set by overriding --icon-size locally rather than by sizing the svg,
   so the shared .ic svg rule (stroke, linecap, currentColor, fill:none) still
   owns everything else and the icon set can never drift to a second stroke
   weight or a second optical size. */
/* WO-210 T2: --icon-stroke is halved HERE ONLY, and this is a scaling fix, not
   a second stroke weight. stroke-width is in viewBox units, so the ink a
   viewer sees is the token times (rendered size / 24). At the shared 28px that
   is 1.75 * 28/24 = 2.04px. This is the site's only 56px icon, so the same
   token was painting 1.75 * 56/24 = 4.08px here: one token, two apparent
   weights, differing by exactly 2x. Halving it locally returns the ink to
   0.875 * 56/24 = 2.04px, so this mark and the 28px icon set carry the SAME
   absolute stroke weight despite a 2x size difference, which is what makes
   them read as one hand. Do NOT "tidy" this back to the global token, and do
   NOT change --icon-stroke in :root to compensate; every other icon reads it.
   If a second oversized icon is ever added, this belongs in a shared rule
   keyed off size, not copied. */

.approach-run{display:block;margin-top:var(--space-5)}
/* The disclaimer. Emphasis is ADDED here and may never be taken away: this is
   the sentence that keeps the line between coaching and therapy clear. Same
   size, same colour, same family as everything around it, heaviest weight in
   the section, immediately adjacent to the credential. */
.approach-disclaimer{font-weight:600}
/* Her closing thought, in her own words, as the section's turn.
   PAUL 2026-08-12: 19.2 -> 28px, the same one-rung drop as Home's two promoted
   narrative lines and the closing ask, so About's turn and Home's turn stay
   the matched pair they are meant to be.

   WO-208 T4: the size, and only the size, now comes from --text-about-turn
   (see the token's own comment in :root for why it is a max() and not a plain
   clamp). Measured before -> after: 390 19.2 -> 19.2 (unchanged, and
   deliberately: it is already at the floor against a 17.28px body voice, a
   1.11x step, and lowering it would collapse the turn into the body text),
   768 21.50 -> 19.20, 1280 28 -> 25.60, 1920 28 -> 27.28. Line counts
   identical at every width. The line stays promoted by everything it was
   promoted by before: her serif voice, the 32px above it, the tighter 1.3
   line-height, and a size step over the body that is never below 1.10x.

   RESOLVED (WO-210 T1). The note that stood here called Sketch's floor and
   Paul's reduction irreconcilable. They were not: the clash was a UNIT, not a
   disagreement about widths. Sketch's 1.35em resolves against her body voice,
   so it climbed with the viewport and collided with the reduction at 1280;
   the same intent written as 1.35rem is a fixed 21.6px that only ever binds
   below 864px, which is precisely where his complaint was. Both rulings now
   hold. The turn is 1.25x the body at 390 instead of 1.11x, and every width
   from 1024 up is untouched to the hundredth of a pixel. The number itself
   lives in --text-about-turn; see that token's comment in :root. Sketch's
   device was NOT rejected and must not be weakened back out. */
.prose-band .narrative p:last-child{
  font-size:var(--text-about-turn);line-height:1.3;
  /* WO-212 T2. The orphan fix, and the only thing that reaches it. No font
     size does: Pixel swept 4 floors x 14 widths x 3 faces of the serif stack
     and EVERY candidate strands the final word in 10 to 13 of 42 cells. The
     shipped 1.46rem is one of them. This property changes no computed size
     and none of her words, and is ignored harmlessly where unsupported.
     &nbsp; was rejected for the opposite reason: it edits the text. */
  text-wrap:pretty;
  margin-top:var(--space-7)}

/* =============================================================================
   ICONS (WO-057, WO-069)
   The ten decorative glyphs on Home's next-step cards, Home's values row and
   About's three-step cards. One shared rule so no future edit can drift the
   set to a second stroke weight or a second optical size. Presentation lives
   here, not on the individual <svg> elements, on purpose: the markup only
   ever needs a viewBox and the shapes. Decorative and aria-hidden on both the
   wrapping div and the inner svg; no states beyond static apply. */
/* WO-204 T2's "My Approach" section opener lived here and was REMOVED on
   2026-08-21 at Paul's request: "it just looks out of place even being there at
   all." Its rules (.approach-opener, .approach-mark, .approach-hairline) are
   deleted rather than commented out, because a disabled rule is a rule someone
   re-enables without reading why it stopped.

   ONE RULE FROM THAT BLOCK IS DELIBERATELY KEPT, just below: the eyebrow slot
   for .prose-band .section-head. It is not part of the opener, it costs
   nothing, it currently matches nothing, and the eyebrow is a separate item
   still waiting on copy that Wordsmith has not written. Deleting it would
   quietly remove the only piece of that work that is still wanted. */
.prose-band .section-head .eyebrow{display:block;margin-bottom:12px}

/* THE FOUR THINGS HER COACHING DOES, PICKED OUT OF THE SENTENCE THAT NAMES
   THEM. Paul 2026-08-21: "These are the focal points of this sentence... let's
   italicize those."

   THIS ADDS EMPHASIS AND TAKES NONE AWAY, which is the only direction this
   particular sentence is allowed to move. It is the second half of the
   not-therapy disclaimer, and the standing rule on this project is that the
   credential sentence and the disclaimer are one unbroken run, never separated,
   never made smaller, never made lighter, never made a footnote. Italic on four
   nouns changes no size, no weight and no colour, and it changes nothing at all
   about the clause before the semicolon, which is the part that carries the
   disclaimer itself. Anything that dimmed or boxed any of this would be the
   forbidden thing; this is the opposite move.

   NOT ONE WORD OF HERS CHANGES. The wrappers carry data-fold, so build.py's
   extractor folds them away and still checks her sentence against
   VOICE_MANIFEST as the whole string a visitor reads. */
.approach-run .focal{font-style:italic}

.ic{color:var(--color-brand-primary);display:inline-flex}
.ic svg{
  width:var(--icon-size);height:var(--icon-size);display:block;
  fill:none;stroke:currentColor;stroke-width:var(--icon-stroke);
  stroke-linecap:round;stroke-linejoin:round;
}
/* Reveal motion, WO-134/hero-overlay-and-motion.md 5.1. Transform only, never
   opacity: every .ic icon already sits inside a card-type box, and this
   project's own .reveal entrance fade (WO-067) is not currently live in the
   stylesheet - it was REMOVED in WO-112 (see the long comment near the top of
   this file, "must not come back in this form") because opacity:0 as a base
   state, paired with a scroll-linked timeline that does not always advance,
   hid the client's own words on a plain rendered page. Building this as
   transform-only sidesteps that failure category categorically, whether or
   not a future .reveal returns: a settle that gets stuck at scale(.82) is a
   minor cosmetic miss, never a content-hiding one. @supports wraps the whole
   rule so a non-supporting browser never sees the initial scale(.82) at all;
   prefers-reduced-motion:no-preference is the WCAG opt-in, not a nicety;
   animation-duration:auto because a scroll-linked animation's real speed is
   the visitor's scroll speed, not a timer. 0.82, not a more dramatic value,
   because a small icon settling into place should read as a detail, not a
   performance. */
@supports (animation-timeline: scroll()) {
  @media (prefers-reduced-motion: no-preference) {
    .ic svg{
      transform:scale(.82);
      animation-name:pxl-icon-settle;
      animation-duration:auto;
      animation-timing-function:ease-out;
      animation-fill-mode:both;
      animation-timeline:view();
      animation-range:entry;
    }
    @keyframes pxl-icon-settle{ to{ transform:scale(1); } }
  }
}

/* =============================================================================
   CARDS
   ============================================================================= */
/* ADR-042, REVISED WO-135 / QA-R09-001. Below the threshold below, both .cards
   grids (index.html:242 and :309, each with exactly 3 children) are a single
   column, in DOM order, capped at 560px and left aligned, not centred. This is
   a mobile-first rule, not a max-width override: a fractional layout width
   (browser zoom or display scaling) could otherwise satisfy neither query and
   fall through to a 2 column orphan.

   THE THRESHOLD IS 791px, NOT 774px, AND HERE IS WHY, SO NOBODY RE-DERIVES 774
   FROM A HEADLESS RUN AGAIN (QA-R09-001, reopened twice on this exact number).

   `min-width` in a media query is evaluated against the window width the
   browser reports for viewport units - which INCLUDES a classic (non-overlay)
   scrollbar's own width - while the grid's actual available space is the
   CONTENT box: the same window width, minus that scrollbar, minus .wrap's
   left+right padding (2 x --space-gutter, 44px). Those two widths are not the
   same number, and 774 was measured headless, where Chromium reports a
   zero-width scrollbar, so the gap between them was invisible to the
   measurement that produced it.

   The arithmetic a real, worst-case (up to 17px classic scrollbar) browser
   needs to guarantee 3 tracks fit once the query is already active:
     3 tracks x 230px minimum            =  690px
     2 gaps x 20px                       =   40px
     tracks + gaps required              =  730px  <- .cards needs this much
     + up to 17px classic scrollbar      =  747px
     + .wrap padding, 2 x 22px gutter    =  791px  <- the min-width below
   So at a reported window width of exactly 791px, the worst case (17px
   scrollbar) leaves 791-17-44 = 730px of content box, which is exactly enough
   for 3 tracks and 2 gaps. Below 791px the grid is still forced to 1 column by
   the base rule above, so there is no width at which 2 columns can appear: the
   orphan is structurally impossible, not just moved further out.

   A raised 230px track minimum cannot do this job instead: auto-fit steps 1,
   2, 3 and can never skip a column count, so no single minimum gives 1 column
   just under this threshold and 3 just over it (measured with 355px injected:
   2 columns at 1024, 1280 and 1440px). The 560px cap holds the single-column
   line length near 72 characters instead of the 86-97 an uncapped column would
   run just under the threshold; Pixel may retune the cap between 520 and
   600px without a new ADR. Left aligned, not centred, to keep this on the same
   axis every other section uses.

   VERIFY HEADED, NEVER HEADLESS: headless reports a zero-width scrollbar, so
   it will pass at 774 and hide this exact defect again. */
.cards{display:grid;grid-template-columns:1fr;max-width:560px;margin-inline:0;gap:20px}
@media(min-width:791px){
  .cards{grid-template-columns:repeat(auto-fit,minmax(230px,1fr));max-width:none}
}
.card{background:var(--color-surface-raised);
  border:var(--border-thin) solid var(--color-border-subtle);
  border-radius:var(--radius-lg);padding:26px;box-shadow:var(--shadow-md);
  text-decoration:none;color:inherit;display:block;
  /* The containing block for .card-link below. .card had no absolutely
     positioned descendant before this, so adding it changes nothing on the
     cards that are already anchors. */
  position:relative;
  transition:transform var(--motion-base),border-color var(--motion-base)}
.card:hover{transform:translateY(-3px);border-color:var(--color-brand-primary)}
.card h3{font-size:1.25rem;margin:.5em 0 .3em}
.card p{color:var(--color-text-secondary);font-size:.96rem;margin:0}
/* The three "How I Can Help" cards carry Mandy's paced lines, three or four
   of them each. `.card p` above zeroes the margin, so the beat rhythm has to
   be restated here at a specificity that can reach it. Same 8px step as every
   other beat on the site; the last one keeps the card's own bottom padding. */
.card .beat{margin:0 0 var(--space-2)}
.card .beat:last-child{margin-bottom:0}
/* PAUL 2026-08-19. THE LAST LINE OF EACH "How I Can Help" CARD, promoted.

   Every one of these three cards is built the same way: two or three lines
   naming the situation, then one line saying what Mandy will actually DO about
   it. That last line is the answer the visitor came for, and it was rendering
   in exactly the same 15.4px secondary grey as the setup lines above it, so
   the most useful sentence on the card was also its quietest.

   THE BRIEF WAS A MIDDLE RANK, NOT A LOUD ONE. Paul's words: promoted enough
   to read as the answer, "not so strong that they become the title of each
   card". So the size does NOT move. It stays at .96rem, the same as the lines
   above it, because size is what the <h3> owns and lending any of it here is
   what would start a fight with the title. The promotion is carried by three
   quieter signals instead:

     colour   secondary -> primary. The single biggest change, and the one that
              does the most work: it moves the line from "supporting text" to
              "text", at 14.79:1 on the card ground (already measured, contrast
              .md line 24, reused not recomputed).
     weight   400 -> 500. A half-step. 600 was tried and read as a second
              heading against a 1.25rem <h3> only four points heavier.
     a rule   2px of brand green down the left, with the line inset from it.
              This is the "stylistic thing next to it" half of Paul's note: it
              marks the line as a different KIND of statement rather than just
              a louder one, and it is the same gesture as the site's pullouts
              so it introduces no new vocabulary.

   Hierarchy, top to bottom, after this: <h3> 20px serif bold primary; this
   line 15.4px sans 500 primary with an accent; the setup lines 15.4px sans 400
   secondary; ".more" 14.4px brand green. Four distinguishable ranks, and this
   one sits second, which is where it was asked to sit.

   The margin-top is what separates "the situation" from "what happens about
   it". Without it the accent rule reads as decoration on the last paragraph of
   one block rather than as the start of a different statement. */
.card .beat.card-do{
  color:var(--color-text-primary);
  font-weight:500;
  margin-top:var(--space-3);
  padding-left:var(--space-3);
  border-left:2px solid var(--color-brand-primary);
}
/* PAUL 2026-08-19: THE ROWS INSIDE THE THREE CARDS LINE UP NOW.

   The grid already made the cards equal height, so they looked like a row.
   Their CONTENTS did not: each card's copy is a different length, ".more"
   followed straight after it, and at 792px the three "Talk about..." links
   measured 391px, 543px and 425px from their own card's top. A 152px spread
   inside what reads as one row, with the middle card's link floating in the
   middle of its own white space and the others stranded well above it.

   flex column plus margin-top:auto on the last item is the fix: the copy keeps
   its natural height, the leftover space collects in one place, and the links
   land on one line across all three. Measured after: 0px spread at every width
   where the cards sit side by side.

   padding-top carries the .8em that margin-top used to provide, because
   margin-top is now doing the pushing and cannot also be a minimum gap. On the
   narrowest card, where there is no slack to push with, that padding is what
   stops the link sitting flush against the copy above it.

   This reaches the "Where would you like to start?" cards too, which are the
   same .card class. They are already near-uniform, so the effect there is
   small, and it is the right direction for the same reason. */
.cards .card{display:flex;flex-direction:column}
.card .more{color:var(--color-brand-primary);font-weight:600;font-size:var(--text-sm);
  display:inline-block;margin-top:auto;padding-top:.8em}
@media(prefers-reduced-motion:reduce){.card{transition:none}}

/* THE STRETCHED LINK. One positioned link covering the whole card, used by the
   three "How I Can Help" cards so the card is the target rather than a word
   inside it.

   WHY IT IS NOT `<a class="card">` LIKE THE "WHERE WOULD YOU LIKE TO START"
   CARDS BELOW IT. Those three carry one short sentence each and nothing else.
   These three carry an <h3> and three or four of Mandy's paced lines. Wrapping
   them in an anchor would put a heading and up to four sentences inside the
   link text, so the accessible name of each card would be its entire contents
   read aloud, and the <h3> would stop being reachable as a heading in a screen
   reader's heading list, which is how someone using one finds the three topics
   in the first place. The <h3> stays an <h3>, outside the link.

   The link carries its name in .sr-only text instead, one per card, so the
   three names are distinguishable in a links list rather than three identical
   "Book a call" entries. Nothing is nested inside it: no button, no second
   link, no control.

   No z-index. The link comes last in source order inside the card and there is
   nothing positioned to compete with it, so it already paints on top. Adding
   one would only invite a stacking-context argument later. */
.card-link{position:absolute;inset:0;border-radius:inherit}
/* The focus ring must show the CARD, not a hairline somewhere inside it. The
   link fills the card's padding box, and :focus-visible's 4px outline-offset
   puts the ring out on the card's own border. border-radius:inherit takes 16px
   from .card so the ring follows the card's shape, which is what the
   .card:focus-visible rule in the FOCUS block does for the anchor cards. */


/* =============================================================================
   SPLIT LAYOUT (About)
   ============================================================================= */
.split{display:grid;grid-template-columns:.8fr 1.2fr;gap:44px;align-items:center}
@media(max-width:720px){.split{grid-template-columns:1fr;gap:28px}}
/* The prose half keeps a reading measure of its own instead of taking whatever
   the grid track gives it, which past 1920px was well beyond a comfortable
   line. Same 60ch convention .narrative already uses. components.md 19.1. */
.split>div:last-child{max-width:60ch}
.photo{aspect-ratio:4/5;border-radius:var(--radius-lg);
  background:linear-gradient(135deg,var(--color-brand-primary),var(--color-gold));
  box-shadow:var(--shadow-md);position:relative;overflow:hidden;display:grid;
  place-items:center;color:rgba(255,255,255,.85);font-family:var(--font-sans);
  font-size:.85rem;text-align:center;padding:1em}
/* Third tier of the ladder: copy to the list under it, 20px. Was `1.2em`,
   which measured 19px, a step that is not on the scale at all. */
.checklist{list-style:none;padding:0;margin:var(--space-5) 0 0;display:grid;gap:.6em}
.checklist li{display:flex;gap:.6em;color:var(--color-text-secondary)}
.checklist li::before{content:"\2713";color:var(--color-brand-primary);font-weight:800}

/* Portrait illustration (placeholder for Mandy's real headshot). */
.portrait{aspect-ratio:4/5;border-radius:var(--radius-lg);overflow:hidden;
  box-shadow:var(--shadow-md);display:block}
.portrait svg,.portrait picture,.portrait img{width:100%;height:100%;display:block;object-fit:cover}
.portrait-caption{margin-top:.7em;font-size:.85rem}

/* =============================================================================
   NOTICE CARD
   The calm, on-brand container used for the Bookings page's "scheduling is
   being set up" and "the calendar did not load" states. Both are "we cannot
   show you the calendar right now, here is what to do instead", so they share
   one container rather than inventing a second, alarming red one.
   ============================================================================= */
.notice-card{
  background:var(--color-status-notice-bg);
  border:var(--border-thin) dashed var(--color-status-notice-border);
  color:var(--color-status-notice-text);
  border-radius:var(--radius-md);
  box-shadow:var(--shadow-sm);
  padding:var(--space-6) var(--space-5);
  margin:var(--space-6) 0;
  text-align:center;
}
/* h2 and h3 are both styled the same way. Which one the markup uses depends
   on where the card sits in the page's heading order, not on how it looks:
   on the Bookings page it is an h2 because it sits directly under the page's
   h1, and skipping a level would misdescribe the page's structure. */
.notice-card h2,.notice-card h3{font-family:var(--font-sans);font-size:var(--text-lg);
  color:var(--color-text-primary);margin:0 0 var(--space-2)}
.notice-card p{margin:0 0 var(--space-4);font-size:var(--text-sm)}
.notice-card p:last-child{margin-bottom:0}
.notice-card .actions{display:flex;gap:var(--space-3);justify-content:center;flex-wrap:wrap}
.notice-card h2:focus,.notice-card h3:focus{outline:none;box-shadow:none}
.notice-card h2:focus-visible,
.notice-card h3:focus-visible{outline:none;box-shadow:none}

/* =============================================================================
   PLACEHOLDER SLOT
   A piece of content that is still waiting on a real fact from the client.
   It is styled to be impossible to mistake for finished copy, on purpose: a
   placeholder must stay visibly labelled as a placeholder so it cannot ship
   by accident. Delete the element, do not restyle it, when the real value
   arrives.
   ============================================================================= */
.placeholder-slot{
  display:inline-block;
  background:var(--color-status-danger-bg);
  border:var(--border-thin) dashed var(--color-status-danger-text);
  color:var(--color-status-danger-text);
  border-radius:var(--radius-sm);
  padding:var(--space-2) var(--space-3);
  font-family:var(--font-sans);font-size:var(--text-xs);font-weight:600;
  line-height:1.5;text-align:left;
}

/* =============================================================================
   BOOKINGS
   ============================================================================= */
/* The office map. No rule of any kind lived here before this fix, which is
   the defect: the figure had no CSS at all, so the img's html width="960"
   height="480" attributes were the only thing sizing it, and its own
   width shrank to fit the .section-head column while its height stayed the
   literal 480px attribute value. Two definite dimensions in a mismatched box
   (960/480 = 2.00 source against ~437/480 = 0.91 rendered) is exactly the
   case object-fit's default value of "fill" exists for: it stretched the
   photograph rather than cropping it, so the map read as visibly squashed.
   Measured at 1440px before this rule existed: box 437.5x480. Fixed by
   giving the box the source's own ratio instead of fighting it, and letting
   object-fit:cover crop rather than stretch if the box is ever a pixel off
   that ratio at some other width.

   The ratio box moved from the <figure> to a .map-frame div inside it (WO-120
   Block B). Same box, same ratio, same crop behaviour, measured identical at
   438x219 at 1440px before and after. It had to move because the figure now
   carries her logistics note as a lead label, and because with the ratio on the
   figure the <figcaption> was pushed out of a 2:1 overflow:hidden box by an
   image filling 100% of its height and clipped to nothing. The OpenStreetMap
   and Geoapify credit was therefore invisible on the live page, which
   decisions.md ADR-037 forbids in as many words ("nobody may hide it to tidy
   the layout"). Do not move the ratio back onto .office-map.

   PAUL, 2026-08-12: the max-width is the map's half of the intro-block
   widening (see .section-head:has(.offer-line) above). The intro block grew
   764px -> 924px so her four lines of text could breathe; the map is a child
   of that same block and would otherwise have grown with it, from 764x382 to
   924x462. The cap holds the photograph at exactly the width it renders at
   today, stated with the SAME expression the shared .section-head widening
   rule uses so the two can never drift, and margin-inline:auto keeps it
   centred under the centred text above it rather than stranded on the left.
   The ratio, the crop and the measured geometry are all unchanged. */
/* The map is a link to directions. display:block matters and is not cosmetic:
   an <a> is inline by default, and .map-frame below centres itself with
   margin-inline:auto, which needs a block containing box to resolve against.
   Left inline, the frame's centring resolves against a line box instead and the
   map drifts off the column axis at the widths where the cap is in force. */
.office-map .map-link{display:block;text-decoration:none;border-radius:var(--radius-sm)}
.office-map .map-link:focus-visible{outline:3px solid var(--color-brand-primary);
  outline-offset:3px}
.office-map .map-frame{aspect-ratio:2/1;overflow:hidden;
  max-width:calc(var(--measure-prose) + var(--space-gutter)*2);
  margin-inline:auto}
.office-map img{width:100%;height:100%;display:block;object-fit:cover}
/* Her logistics, as the map's label. Deliberately NOT smaller and NOT lighter
   than it was: it holds her street address. It stops competing with the offer
   above it by belonging to the map instead, not by being quieter.
   components.md 20.7. */
/* WO-146 item 3: was var(--text-base) (16px), now var(--text-md) (17.6px),
   matching body's own new size - this line opts out of inheriting body's
   size by pointing at its own token, so the body change alone would not have
   moved it; this is a one-line, one-selector edit, not a token redefinition,
   so --text-base itself (and therefore .btn/.foot nav a) stays untouched. */
/* WO-208 T1: the ancestor is dropped because the notes have left the <figure>
   and now sit in .intro-copy. Declarations are untouched. .map-note and
   .map-note--minor appear on this one page only (grep-verified across site/),
   so an unscoped selector cannot reach anything else. */
.map-note{margin:0 0 var(--space-4);font-size:var(--text-md);
  color:var(--color-text-secondary)}
/* WO-154 item 11, Maestro-approved, not one of Pixel's four questions.
   <figure class="office-map"> is a direct child of the 880px
   .section-head:has(.offer-line) above. .map-frame caps the IMAGE at 764px,
   but the figure and its notes are not capped by anything, so the address
   line was setting about 103 characters on line one, past the top of the
   site's own measure band, and orphaning "for you." onto line two alone.
   62ch reuses the constant already at style.css:1194 rather than inventing a
   number; centred under the 764px map by the same margin-inline:auto pattern
   .map-frame uses. Does not touch .map-frame, so the photograph stays
   764px wide. */
.map-note{max-width:62ch;margin-inline:auto}
/* M-13. Scopes components.md 20.7 to the address line only, now that the two
   facts sit on separate lines. "All sessions are by appointment" carries this
   modifier to go slightly smaller than the address; a modifier class rather
   than an edit to .map-note above so the address rule cannot ever
   catch it. Size only, same --color-text-secondary: Paul asked for "slightly
   smaller", not quieter. WO-146: .map-note--minor is var(--text-sm), a
   separate rem-based token (0.9rem/14.4px) not relative to .map-note's own
   size, so it does not move with either the body or .map-note change above -
   verified by measurement, not by diffing the CSS: 14.4px against the
   address line's new 17.6px, the gap widens (was 90% of 16px, now 82% of
   17.6px), never narrows. */
.map-note--minor{font-size:var(--text-sm)}
/* The licence credit, now that it is visible for the first time. It had never
   been sized, so it inherited 16px and rendered as loud as her address, on two
   lines, with "Geoapify." alone on the second. Sized to the site's existing
   caption register (.article figcaption is .85rem, --text-xs is .8rem) and no
   smaller. This is NOT the rule ADR-037 forbids: it stays visible, on the page,
   in verified-contrast ink, adjacent to the map it credits. What ADR-037
   forbids is hiding it, and it was hidden until this build. Do not remove it,
   do not sr-only it, and do not take it below --text-xs. */
.office-map figcaption{font-size:var(--text-xs)}

/* WO-208 T1, spec .webteam/03-ux/bookings-two-column.md. The intro splits into
   copy | map at 1024px and stacks below it.

   1fr 1fr, NEVER auto-fit. An explicit two-value track list cannot step to a
   track count nobody wrote, which is the whole answer to the 2+1 wrap that
   `repeat(auto-fit,minmax(230px,1fr))` produced on .cards at 788px. That
   substitution is the exact defect this rule exists to prevent: do not
   "improve" it back.

   Why 1024 and not a new number: this file already uses min-width:1024px in
   four places plus max-width:1023.98px and 1023px, so this is a fifth consumer
   of a breakpoint the reader already knows rather than a sixth distinct number.

   Worst case at the threshold, and it is the 791px rule's lesson applied: a
   min-width query is measured against the window width INCLUDING a classic
   scrollbar while the grid gets the content box, so 1024 - 17 scrollbar - 44
   gutter = 963px of content box, giving 459px a column after the 44px gap.
   Both columns are comfortable there. Verified headed, not headless.

   .intro-split and .intro-copy get NO base rule at all. Below 1024 they are
   ordinary blocks and the stacked layout is the one that already shipped. */
@media(min-width:1024px){
  .intro-split{display:grid;grid-template-columns:1fr 1fr;
    column-gap:var(--space-8);align-items:center;text-align:left}
  /* The h1 joins the copy column's left axis. A centred heading over a
     left-aligned column is the stranded-column defect already recorded at
     .section-head--axis and in the .prose-band comment; this is that same fix,
     not a new opinion. The h1 still spans the full width and is not a column. */
  .section-head:has(.offer-line) h1{text-align:left}
  .intro-copy .map-note{margin-inline:0}
  .intro-copy .map-note--minor{margin-bottom:0}
  /* The 40px inset was the browser's default <figure> margin, never a designed
     value. Inside a column it is 80px of dead space. */
  .intro-split .office-map{margin:0}
  /* The 764px cap is what stops the 2:1 frame filling the container when the
     map is full width. In a column the column IS the cap, so it is withdrawn
     here and only here. The rule above stays exactly as it is. */
  .intro-split .map-frame{max-width:none}
}

/* min-height:0 on purpose. The frame takes its height from whatever is
   inside it, so it can never push the email fallback line underneath it off
   the bottom of a phone screen. Do not put a fixed min-height back. */
.embed-frame{position:relative;background:var(--color-surface-raised);
  border:var(--border-thin) solid var(--color-border-subtle);
  border-radius:var(--radius-lg);box-shadow:var(--shadow-md);
  padding:10px;margin-top:1.5em;min-height:0}

/* Capped at 640px, so desktop and tablet get a full-size calendar; shrinks
   toward 80% of the screen height on a short screen, with a 360px floor so it
   can never collapse to something unusable. */
/* THE CALENDAR'S HEIGHT IS MEASURED FROM CALENDLY, NOT CHOSEN.

   PAUL 2026-08-23: "there is a scrollbar on the calendly section. Let's see if
   we can size things appropriately to where there is no need for that."

   The old value was clamp(360px, 80vh, 640px), which was a guess dressed as a
   rule: it sized the box to the SCREEN, when the thing that decides whether it
   scrolls is the height of CALENDLY'S CONTENT. At 640px the content is 685px,
   so it overflowed by 45px and grew an internal scrollbar on every desktop.

   MEASURED by reading document.documentElement.scrollHeight inside Calendly's
   own frame, with the container deliberately under-sized so its document always
   overflows and reports its natural height. Worst case of the initial view and
   the taller state after a day is chosen and times appear:

       viewport        content needs
       320 to 700      905px
       768 to 1024     1060px
       1100 and up     685px

   The middle band needing the MOST is not a mistake and is why one clamp could
   never have worked. Calendly runs three layouts: side by side above about
   1100, a wide stacked layout in the middle, and a narrow stacked one on
   phones. The middle is the widest calendar with the time list underneath it.

   Each band gets its measured worst case plus about 40px, because the month
   grid is a row taller in months that span six weeks than in those that span
   five, and this was measured in one month. */
.calendly-inline-widget{min-width:320px;height:950px}
@media(min-width:701px){.calendly-inline-widget{height:1100px}}
@media(min-width:1100px){.calendly-inline-widget{height:730px}}

/* THE IFRAME IS display:block, WHICH IS WORTH SEVEN PIXELS.

   Calendly's script inserts a plain <iframe>, and an iframe is a replaced
   INLINE element sitting on the text baseline, so the line box reserves room
   under it for descenders. Measured at 390px: the widget box is 640px tall,
   the iframe is 640px tall, and the container's scrollHeight is 647.

   Those 7px are invisible wherever overflow is visible, which is why they went
   unnoticed at desktop. The moment the narrow branch below turns on scrolling
   they become a vertical scrollbar on a box whose content fits. */
#booking-widget iframe,
.calendly-inline-widget iframe{display:block}

/* THE CALENDAR CANNOT BE NARROWER THAN 320px, SO AT 320px THE PAGE HAS TO GIVE
   IT THE WHOLE WIDTH.

   Found the day booking was switched on. viewport-check: "/bookings.html @320:
   horizontal overflow 33px, div.calendly-inline-widget". The 320px minimum
   above is ours and it is correct, because Calendly's own interface compresses
   badly below it. The problem is that at a 320px viewport the reading column
   offers only 261px: 44px of page gutter and 20px of this frame's padding.
   The widget stuck out and dragged the document to 353px.

   That also broke tools/contrast-gate.py, which refused to measure the 320px
   cell at all: it read innerWidth back as 353, exactly the overflowing document
   width, and its first guard correctly stopped rather than reporting numbers
   labelled with a width they were not rendered at.

   So below 400px the frame gives up its gutter, its padding and its rounded
   border and runs edge to edge, which is exactly 320px at the narrowest
   supported viewport. The negative margin cancels .wrap's padding rather than
   using 50vw tricks, which would themselves overflow when a scrollbar exists.

   THE overflow-x BACKSTOP IS SCOPED TO NARROW WIDTHS, AND THAT IS NOT A
   TIDY-UP. It was first written unscoped, as .embed-frame{overflow-x:auto} at
   every width, and Paul saw the result immediately: "there is a scrollbar on
   the calendly section".

   CSS says that if either overflow axis is set to something other than
   `visible`, the OTHER axis computes to `auto` rather than staying visible. So
   asking for a horizontal backstop silently turned the vertical axis into a
   scroll container as well, and a 640px calendar in a 660px box grew a full
   scrollbar gutter down the right of the card on every desktop screen. The
   frame was not even scrolling: scrollHeight and clientHeight both measured
   660. The gutter was the whole cost, for nothing.

   Below 401px the backstop is still wanted, because that is where the column
   is narrower than the calendar's 320px minimum. Above it the column is always
   wider (at 401px: 401 less 44px of gutter and 20px of padding leaves 337),
   so a horizontal overflow cannot occur and neither axis needs to be touched. */
@media(max-width:400px){
  /* At a 320px viewport the usable width is 305px, because the page's own
     scrollbar takes 15px, so the 320px floor above cannot be met and would put
     a horizontal scrollbar inside the frame. Released here: rendered at 320,
     Calendly lays out perfectly happily at 305px. */
  .calendly-inline-widget{min-width:0}
  .embed-frame{
    overflow-x:auto;
    margin-inline:calc(var(--space-gutter) * -1);
    padding:0;
    border-inline:0;
    border-radius:0;
  }
}
/* THE dvh OVERRIDE IS DELETED, AND IT IS WHY THE FIRST ATTEMPT AT THIS FIX DID
   NOTHING AT ALL.

   It sat here, AFTER the rules above, saying:

       @supports (height:1dvh){
         .calendly-inline-widget{height:clamp(360px,80dvh,640px)}
       }

   Every browser this project renders in supports dvh, so this always won, and
   the measured heights set above were computed and then discarded. The symptom
   was exact and worth recognising again: after changing the heights, the widget
   still measured 640px and Calendly still overflowed by precisely 45px, the
   same figure as before the change. A number that does not move when you change
   its input means something downstream owns it, not that the change was too
   small.

   Deleted rather than updated, because the idea behind it no longer applies.
   Sizing this box to the viewport, however accurately dvh measures a phone's
   address bar, is the wrong model: what decides whether the calendar scrolls is
   the height of CALENDLY'S content, which does not care how tall the window is.
   The heights above are measured from that content instead. */

/* The loading placeholder sits ON TOP of the calendar's own container rather
   than above it, so the two never occupy the page at the same time and there
   is no jump when the real calendar paints and the placeholder is removed.
   The 10px inset matches .embed-frame's padding. */
.embed-skeleton{
  position:absolute;inset:10px;z-index:2;
  background:var(--color-status-info-bg);
  border-radius:var(--radius-lg);
  display:flex;align-items:center;justify-content:center;
  overflow:hidden;
}
.embed-skeleton::after{
  content:"";position:absolute;inset:0;
  background:linear-gradient(90deg,transparent,rgba(255,255,255,.55),transparent);
  transform:translateX(-100%);
  animation:pxl-shimmer 1.6s ease-in-out infinite;
}
.embed-skeleton .label{position:relative;z-index:1;
  color:var(--color-status-info-text);font:var(--text-sm) var(--font-sans)}
@keyframes pxl-shimmer{to{transform:translateX(100%)}}
@media(prefers-reduced-motion:reduce){
  .embed-skeleton::after{animation:none;display:none}
}

/* The "prefer email" line used to live here. WT-148 / WO-148 replaced it
   with the contact form below, so .fallback-line is no longer used by any
   page; kept only because deleting a rule nobody references costs nothing
   and risks nothing, unlike deleting one that still is. */
.fallback-line{margin-top:1.5em;color:var(--color-text-secondary);text-align:center}

/* =============================================================================
   CONTACT FORM (Bookings page, "Reach Mandy" section, WT-148 / WO-148)

   Plain static HTML, zero JavaScript: this must work exactly as written with
   scripting off, so no rule here depends on a class or attribute a script
   would need to add. 560px matches the site's own existing precedent
   (.cards, below) rather than inventing a new width: a form reads better
   narrower than the 1080px container, and this is already the value used
   elsewhere for "narrower than full width, still comfortable".
   ============================================================================= */
.contact{max-width:560px;margin:var(--space-7) auto 0}
.contact h2{font-size:var(--text-display-sm);margin-bottom:var(--space-4)}
.contact>p{color:var(--color-text-secondary)}
.contact-form{margin-top:var(--space-6)}
.field{margin:0 0 var(--space-6)}
.field label{display:block;font-weight:600;margin-bottom:var(--space-2)}
.field-hint{margin:0 0 var(--space-2);font-size:var(--text-sm);
  color:var(--color-text-secondary)}
.field input,.field textarea{
  display:block;width:100%;font:inherit;color:var(--color-text-primary);
  background:var(--color-surface-raised);
  border:var(--border-md) solid var(--color-border-strong);
  border-radius:var(--radius-sm);
  padding:var(--space-3) var(--space-4);
}
.field textarea{resize:vertical;min-height:9em}
.contact-form .btn{margin-top:var(--space-2)}

/* The one quiet block under the textarea: the 988 line and the consent line.
   PAUL asked for this to read as informational text "as much as we can get away
   with and still be readable", and that last clause is the binding half.
   --color-text-secondary on the form's ground measures 7.4:1, comfortably past
   the 4.5:1 this size requires, so it can be quiet without being weak. It is NOT
   dropped to --color-text-muted: that is an alias of the same token, so it would
   buy no additional quietness and only imply one. */
/* --space-7 below, not --space-5. The note block sat closer to the submit
   button than the form's own fields sit to each other (--space-6, 24px), so the
   button read as attached to the small print rather than as the end of the
   form. Paul reported exactly that. Above it stays --space-5: the notes belong
   to the message field they follow, and should sit nearer to it than to the
   button. The asymmetry is the point. */
.form-note{margin-top:var(--space-5);margin-bottom:var(--space-7);
  display:flex;flex-direction:column;gap:var(--space-2)}
/* The class is named on each note, not inferred from `.form-note p`, because
   ADR-063's pattern-match precondition is defined as the three notes SHARING A
   REAL CLASS. A bare descendant selector styles them identically but leaves the
   guard nothing to assert, and build.py rejected exactly that. The class is the
   contract; the selector is just how it is painted. */
.form-note-line{margin:0;font-size:var(--text-sm);line-height:1.5;
  color:var(--color-text-secondary)}
/* The honeypot wrapper. `hidden` is what does the real work here (see
   [hidden]{display:none!important} in BASE, above): it removes this field
   from layout, from the accessibility tree, and from the tab order, for
   every visitor equally. This rule only removes the field's own margin so
   it takes no space in the (already display:none) flow; it must never
   become the thing that hides the field, `hidden` already is. Do not add a
   position:absolute/clip "visually-hidden" treatment here instead: that
   pattern leaves the field in the accessibility tree and the tab order,
   which turns the honeypot into a trap a screen reader user can walk into
   (05-content/contact-form-and-privacy.md, note to Bolt). */
.hp-field{margin:0}

/* =============================================================================
   CONTACT BAND (Bookings, the full-bleed photograph behind "Reach Mandy")
   WT-181 / WO-181

   The band is a sibling of .wrap in site/bookings.html, not a child of it, so
   it reaches both edges of the screen without a 100vw breakout. That matters:
   100vw includes the desktop scrollbar's width, so the margin-inline:calc(50%
   - 50vw) trick would put a horizontal scrollbar on this page at every width
   where a scrollbar exists. The band holds its OWN .wrap for the form, so the
   form is capped and gutter-padded by exactly the same rule as everything
   above it and no second copy of those numbers exists.
   ============================================================================= */
/* background, not just the picture: the tint is painted by ::after over the
   photograph, but this flat ground underneath means the band is a legible
   dark surface for white text from the first paint, before the (lazy,
   async-decoded) photograph arrives, and permanently if it never does.
   --color-text-primary IS #1B2B26, the same ink the tint below is mixed
   from, so the two cannot drift apart. */
.contact-band{
  position:relative;overflow:hidden;
  margin-top:var(--space-9);
  padding-block:var(--space-9);
  background:var(--color-text-primary);
  /* THE TINT'S ALPHA. Not chosen by eye. Set to the value that clears 4.5:1
     with real margin against the LIGHTEST composited pixel found inside
     every text box in this band, measured off a screenshot taken with the
     text set to visibility:hidden so the layout is identical and no
     anti-aliased glyph edge could contaminate the sample, at 390px and at
     1440px, for all nine pieces of text in the band.

     Swept in seven steps against each of WO-181's three candidate
     photographs. The worst text in the band, which was the heading every
     time, measured:

       alpha   candidate a   candidate b   candidate c
        .20       4.33          5.43          5.62
        .25       4.64          5.77          5.92
        .30       5.03          6.20          6.36
        .35       5.42          6.59          6.76
        .40       5.85          7.04          7.24
        .45       6.35          7.52          7.78

     .45 WAS THE VALUE UNTIL WO-193 and is now .55. What changed is not the
     method, the target or the photograph: it is that
     tools/make-contact-bg.py no longer Gaussian-blurs the picture. Paul
     reported the band as blurry twice, the blur was the cause, and it came
     off entirely. Read that script's docstring for the full correction.

     WHY THAT MOVED THIS NUMBER, WHICH IS NOT OBVIOUS. A blur is a local
     average, so it flattens the brightest specular highlights on the lit
     awns into their darker surroundings before this wash ever sees them.
     The measurement method here, deliberately conservative, takes the
     LIGHTEST composited pixel anywhere inside each text box. Removing the
     blur puts those highlight pixels back, so the same photograph under the
     same wash measures worse without having become any harder to read in
     the ordinary sense. Re-measured at .45 with the blur gone, on candidate
     c, the worst element fell from 7.30:1 to 5.00:1. That still clears the
     4.5:1 floor, but not the 6:1 design target below.

     THE FIX WAS THE TINT, NOT THE BLUR, AND THAT WAS AN EXPLICIT RULING
     (WO-193). Re-blurring would have restored the number and reinstated the
     defect. Swept again on the unblurred files, worst element of the five
     measured, candidate c, across 390 / 768 / 1024 / 1440 / 1920 / 2560 at
     DPR1 and DPR2:

       alpha   worst element
        .45       5.00
        .50       5.49
        .52       5.75
        .55       6.04
        .58       6.43

     .55 is here on exactly the same rule that put .45 here before it: the
     lowest step that clears 6:1. The worst case is the small privacy
     paragraph at 1024px DPR2, which is the spot Pixel identified, and the
     heading is no longer the worst text in the band because the heading sits
     high in the frame where the picture is darkest.

     THE FIRST VERSION OF THIS NEEDED .70 AND WAS WRONG, and .55 is still a
     long way below it. Read tools/make-contact-bg.py's BRIGHTNESS/SATURATION
     note before raising this number further to solve a legibility problem:
     most of the darkening lives in the image file, where it is a multiply
     and preserves colour, rather than in this wash, where it is an
     interpolation toward one colour and drains the photograph grey. At .70
     the band passed every contrast check in this file and looked like olive
     fog. The move from .45 to .55 was rendered and looked at, at every width
     above, before being accepted: the gold survives it. If a future change
     needs more than this, prefer lowering BRIGHTNESS in that script over
     raising this, because the multiply keeps the colour and this does not.

     BACK TO .45 IN WO-197, AND THE COMPANION MOVE IS IN THE GENERATOR, NOT
     HERE. Pixel ruled (HO-196) that the wash is the expensive lever for
     colour and BRIGHTNESS in tools/make-contact-bg.py is the cheap one, so
     this drops .55 -> .45 and that script drops 0.62 -> 0.54 to pay the
     contrast back. MEASURED on the built page after the change, same method as
     everything above, six widths, DPR1 and DPR2, worst case per element:
     heading 6.98, lead 6.62, labels 6.91, hints 6.19, privacy 6.0039. All five
     clear the 6:1 design target, which is the whole condition this pair of
     numbers has to satisfy.

     THE PRIVACY PARAGRAPH NOW CLEARS BY 0.004 AND THAT IS NOT A TYPO. It is
     the tightest number on the site and it is too tight to survive an
     unrelated change by luck. RE-MEASURE IT AFTER ANY EDIT TO ANY ENCODER
     QUALITY IN tools/make-contact-bg.py, not just after edits to this line.
     That is not a hypothetical: WO-195 dropped -narrow-1060 from quality 42 to
     36 purely to make the phone's byte budget, on the reasoning that encoder
     tuning cannot affect the picture. It can. Lower quality means more ringing
     overshoot on high-contrast edges, ringing puts individual pixels BRIGHTER
     than the source ever was, and this measurement takes the lightest pixel
     anywhere in the box. Measured: that change alone moved the privacy
     paragraph from 6.65 to 5.90 at 768px DPR2 and from 6.83 to 6.45 at 390px
     DPR2, the two viewports that fetch that rung, and it went unnoticed for a
     wave because nobody re-ran this harness. So the number this file was
     carrying, 6.04, was stale, and the true worst case before WO-197 was 5.90,
     already under target. WO-197 also fixes that.

     READ THIS BEFORE MOVING EITHER NUMBER AGAIN. The two are LOCKED, and the
     exchange rate between them is far worse than it looks. Both scale the same
     quantity: the composited pixel is

         (1 - alpha) * BRIGHTNESS * photograph + alpha * tint

     so lowering this alpha and lowering BRIGHTNESS pull the photograph's
     contribution in opposite directions and very nearly cancel. Holding the
     privacy paragraph at 6.00 and letting BRIGHTNESS find its own level at each
     alpha, the band's mean chroma goes (MODELLED from the generator's real
     pipeline, except the .45 row which is measured on the built page):

       alpha .55  BRIGHTNESS 0.620   chroma 28.9   (measured, the old state)
       alpha .50  BRIGHTNESS 0.575   chroma 30.4   (modelled)
       alpha .45  BRIGHTNESS 0.540   chroma 31.6   (measured, this state)
       alpha .40  BRIGHTNESS 0.500   chroma 33.0   (modelled)
       alpha .30  BRIGHTNESS 0.450   chroma 35.9   (modelled)

     So ten points of alpha buys under one and a half points of colour once
     contrast is held. Anyone hoping to move this band's colour materially by
     editing this line is going to be disappointed, and the lever that does move
     it is SATURATION in tools/make-contact-bg.py. See WO-197 / HO-197.

     One named value so Pixel can retune the look by editing one number, and
     so whoever does knows this is a measured floor to be re-measured, not a
     taste call to be judged by eye. */
  /* PAUL 2026-08-19: .45 -> .48, and it is a re-measurement, not a taste call,
     exactly as the note above requires.

     WHY IT MOVED. This band's photograph became a fixed background on the same
     day, so `cover` is now satisfied against the VIEWPORT rather than against
     this element's box. Different framing, different composited pixels under
     the text, so the whole sweep had to be re-run rather than carried over.

     WHAT THE GATE SAID, tools/contrast-gate.py, full 36-cell matrix:

       at .45, fixed    5 measurements under the 6.0 target, worst 5.93
       at .45, scroll   2 measurements under it, worst 5.94   <- PRE-EXISTING
       at .48, fixed    0 under it. 324 of 324 clear. Worst 6.13.

     Read the middle row before blaming the fixed background for all of it. Two
     of the five failures were already there and were failing before any of this
     was touched: p.form-note-line[1] at 390 and 430 at DPR3, at 5.94, on the
     old framing. The new framing added three desktop cells at 5.93, all of them
     a SINGLE lightest pixel out of about 12,000 (0.008%), and none of them ever
     near the 4.5:1 WCAG floor, which the worst case clears by 32%. So .48 fixes
     a defect this change did not cause as well as the one it did.

     Three points of alpha is the smallest step tried that cleared the whole
     matrix; .46 and .47 were not swept once .48 came back clean at every cell
     with margin, and a smaller step would be shaving the target rather than
     clearing it. The photograph is measurably darker for it and that is the
     trade the gate exists to price. */
  --contact-tint-alpha:.48;
}
/* PAUL 2026-08-19: the photograph stays put and the page travels over it, the
   same treatment the Home hero and .narrative-imagine now carry. The <img> that
   used to fill this box is gone; see site/bookings.html for why the effect
   requires a background rather than an element, and the hero's own block in
   this file for the full argument.

   LADDER DERIVED AGAINST THE VIEWPORT, not against this box, because
   background-attachment:fixed makes the viewport the positioning area. That is
   also what retires this band's worst sizing problem: the band's height is set
   by the form in front of it, so cover used to be governed by HEIGHT and a
   1440px laptop magnified a 1600w file 1.24x. A desktop VIEWPORT is landscape,
   so above 1000px width governs again and the requirement is simply the
   viewport width. Requirement at 1x is max(viewportW, viewportH * 1.78) for the
   16:9 landscape files, and max(viewportW, viewportH * 0.625) for the 5:8
   narrow crop.

   THE 1000px BREAK AND THE NARROW CROP ARE KEPT (WO-188), and the reason
   survives: a phone viewport is a tall portrait rectangle, and covering it with
   a 16:9 file throws away most of the file's width. */
.contact-visual{
  position:absolute;inset:0;
  background-repeat:no-repeat;
  background-position:center;
  background-size:cover;
  background-attachment:fixed;
  /* Below 1000px: the 5:8 narrow crop. A 390x844 viewport needs about 528px at
     1x, a 999x1200 about 999px; 530w and 1060w bracket that at 1x and 2x. 1060
     is the largest exact 5:8 rectangle the 1700-row master yields, so there is
     no higher rung and none is invented. */
  background-image:image-set(
    url("/images/contact-bg-narrow-530.webp") type("image/webp") 1x,
    url("/images/contact-bg-narrow-1060.webp") type("image/webp") 2x,
    url("/images/contact-bg-narrow-530.jpg") 1x,
    url("/images/contact-bg-narrow-1060.jpg") 2x);
}
@media (min-width:1000px){
  .contact-visual{
    /* 1000-1599. 2550 is the master's true width (measured, not assumed: see
       the ACCEPTED upscale note in the review tooling), so it is the ceiling at
       every density. */
    background-image:image-set(
      url("/images/contact-bg-1600.webp") type("image/webp") 1x,
      url("/images/contact-bg-2550.webp") type("image/webp") 2x,
      url("/images/contact-bg-1600.jpg") 1x,
      url("/images/contact-bg-2550.jpg") 2x);
  }
}
@media (min-width:1600px){
  .contact-visual{
    background-image:image-set(
      url("/images/contact-bg-2000.webp") type("image/webp") 1x,
      url("/images/contact-bg-2550.webp") type("image/webp") 2x,
      url("/images/contact-bg-2000.jpg") 1x,
      url("/images/contact-bg-2550.jpg") 2x);
  }
}
@media (min-width:2001px){
  .contact-visual{
    background-image:image-set(
      url("/images/contact-bg-2550.webp") type("image/webp") 1x,
      url("/images/contact-bg-2550.jpg") 1x);
  }
}
/* The honest fallback, identical in form and reasoning to .narrative-imagine's
   and the hero's: iOS Safari does not implement background-attachment:fixed and
   degrades it to a stretched, badly-cropped still, so touch devices and anyone
   who has asked for reduced motion get `scroll` instead. Under `scroll` the
   positioning area returns to this element's own box, which is the framing the
   still version was always composed for. */
@media (hover:none),(prefers-reduced-motion:reduce){
  .contact-visual{background-attachment:scroll}
}
/* The tint. rgba(27,43,38,...) restated literally rather than colour-mixed
   from the token, matching .home-visual::after in the HOME section below,
   which is the site's other dark-scrim-over-a-photograph and uses this same
   ink at this same kind of alpha. A flat wash, not a gradient: unlike the
   hero, the text here sits in one narrow centred column over a picture with
   no dark side to protect, so there is no width-dependent crop problem for a
   scoped gradient to solve, and a flat value is one number to measure
   instead of a curve. */
.contact-visual::after{
  content:"";position:absolute;inset:0;pointer-events:none;
  background:rgba(27,43,38,var(--contact-tint-alpha));
}
/* position:relative and nothing else. .contact-visual is absolutely
   positioned and the form's wrapper is not, so without this the photograph
   would paint over the form. Same mechanism, same one declaration, as
   .hero-copy in the HERO section above. No z-index on either side. */
.contact-band>.wrap{position:relative}
/* The band's own padding-block supplies this gap now, top and bottom, and
   ONLY the band's padding: WO-206 found this rule was zeroing .contact's
   margin but not its padding, and .contact is marked up as a <section> (see
   bookings.html), so it was quietly drawing the page's generic
   `section{padding:var(--space-section-y) 0}` rhythm rule on top of the
   band's own --space-9. Two independent tokens were stacking into one
   visual gap that nobody had actually chosen: --space-9 (a flat 64px) plus
   --space-section-y (a responsive 40-72px), for an unintended, uneven
   104-136px total, MEASURED at 390/788/1280/1920px before this fix. Paul
   asked for that cut to about half. Zeroing .contact's own padding-block
   here (this line) makes --space-9 on .contact-band the ONLY source again,
   which is what the comment above always claimed was true. MEASURED after:
   a flat 64px at all four widths, a 38-53% cut (about half, and a side
   benefit: the gap no longer grows with viewport width by accident).
   components.md WT-206. */
.contact-band .contact{margin-top:0;padding-block:0}
/* WHITE, EVERY LINE OF IT. The band is the site's second dark ground after
   the footer, and it borrows the footer's vocabulary rather than inventing
   one: --color-text-inverse for text, and an underline in
   --color-rule-on-footer for the one link, so the link is identifiable
   without relying on colour. It is deliberately NOT lightened in tiers the
   way the footer separates --color-text-on-footer from
   --color-text-on-footer-muted: the hint lines and the consent line already
   sit below the labels by size, and a translucent white would lower the one
   measured floor this whole band is built on. If Pixel wants a muted tier
   here, it needs re-measuring against the photograph, not just picking the
   footer's 80% value, because the footer's ground is a flat colour and this
   one is a photograph.

   .form-consent a MUST be in this list. Unstyled it inherits the site-wide
   a{color:var(--color-brand-primary)}, which is brand green on a dark green
   tint: the exact failure the footer's own comment warns about two sections
   below. */
/* WHY THIS LIST NAMES EVERY CLASS EXPLICITLY, AND WHAT THAT COSTS.
   This band paints white text on a dark photograph, so every text class inside
   it has to be listed here or it falls back to a token meant for a light
   ground. That is exactly what happened on 2026-08-17: .field-hint and
   .form-consent were replaced by .form-note and .label-note in the markup, this
   list was not updated with them, and the new copy rendered dark grey-green on
   a dark photo. Measured at 1.00:1 and 1.32:1, which is invisible, not merely
   low. The build passed, the voice guard passed, and nothing else noticed.

   If you add a text class inside .contact-band, add it here in the same edit,
   and MEASURE it against the composited photograph rather than against the
   token's name. */
.contact-band .contact h2,
.contact-band .contact>p,
.contact-band .field label,
.contact-band .field-hint,
.contact-band .form-note-line,
.contact-band .form-consent,
.contact-band .form-note a,
.contact-band .form-consent a{color:var(--color-text-inverse)}
/* Quietness on this ground comes from OPACITY, not from a darker token. A
   darker grey is what a light background uses to recede; on a dark photograph
   it advances toward the backdrop and disappears. */
.contact-band .form-note-line{opacity:.82}
.contact-band .form-note a,
.contact-band .form-consent a{text-decoration-color:var(--color-rule-on-footer)}
.contact-band .form-note a:hover,
.contact-band .form-note a:focus,
.contact-band .form-consent a:hover,
.contact-band .form-consent a:focus{color:var(--color-text-inverse);
  text-decoration-color:currentColor}
/* THE SUBMIT BUTTON'S BOUNDARY, and the one thing in this band that did not
   survive the move to a dark ground unchanged. Measured: brand green
   (#1F6E5A) against the lightest composited backdrop pixel beside it is
   1.66:1 at 390px and 1.71:1 at 1440px, well under the 3:1 a control's own
   boundary needs, so the pill's edge was being carried by its white label
   alone. A 1.5px white ring puts that boundary back at the same contrast as
   the heading, and keeps the button brand green rather than inverting it to
   a white pill, which would have been a hierarchy decision rather than a
   contrast fix and is Pixel's to make, not mine.

   box-shadow rather than a border, because .btn is border:none by design and
   a border would eat 3px of the pill's inner width; a shadow paints outside
   the box and moves no geometry at all. :focus-visible's own box-shadow
   halo overrides this one while focused, which is correct: that halo is
   white, 4px, and strictly more visible than the ring it replaces. */
.contact-band .contact-form .btn-primary{
  box-shadow:0 0 0 var(--border-md) var(--color-text-inverse);
}
/* The fields themselves are NOT restyled and that is the decision, not an
   omission. They keep --color-surface-raised (white) fill,
   --color-text-primary ink and the --color-border-strong border they have on
   every other ground, which means everything already verified about reading
   and filling them in stays true here. On a dark band a solid white fill is
   its own boundary at far higher contrast than any border could give it, so
   there is nothing for a translucent "glass" treatment to buy except a
   legibility problem. The focus ring needs no override either: it is a dark
   outline INSIDE a white halo (see the FOCUS block near the top of this
   file), so the halo is what carries it on a dark ground and the outline is
   what carries it on a light one. It was built to work on both. */

/* =============================================================================
   404
   ============================================================================= */
.error-actions{display:flex;gap:var(--space-3);justify-content:center;
  flex-wrap:wrap;margin-top:var(--space-6)}

/* =============================================================================
   BLOG
   ============================================================================= */
/* Card grid, not a single reading column: repeat(auto-fit,minmax(280,360))
   keeps 1 and 2 posts capped and centred instead of stretching to the full
   1036px content width (the "looks broken" case at low post counts), while
   still collapsing to one column below roughly 580px of content width with
   zero added media query, the same free behaviour .cards/.values/.latest
   already have. No max-width belongs on .post-list itself: it already sits
   inside .wrap, which already caps the block at --container-max. Gap moves
   to --space-5 (20px) to match .cards/.latest, this grid's two closest
   siblings; .post-list's old 18px was never a token, only a coincidence
   with .values. Spec: .webteam/03-ux/blog-listing-layout.md. */
.post-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,360px));
  gap:var(--space-5);justify-content:center}
.post-item{background:var(--color-surface-raised);
  border:var(--border-thin) solid var(--color-border-subtle);
  border-radius:var(--radius-lg);box-shadow:var(--shadow-md);
  text-decoration:none;color:inherit;display:block;overflow:hidden;padding:0;
  transition:transform var(--motion-base),border-color var(--motion-base)}
.post-item:hover{border-color:var(--color-brand-primary);transform:translateY(-2px)}
.post-item .date{font-size:var(--text-xs);color:var(--color-text-secondary);
  text-transform:uppercase;letter-spacing:.05em}
/* The card title is an <h2> because the listing's own heading is the page
   <h1>. The tag follows the heading order, not the size. */
.post-item h2{font-size:var(--text-lg);margin:.3em 0}
/* 2-line clamp so one long excerpt cannot make its card taller than its row
   neighbours now that cards sit side by side; the single-column layout never
   exposed this because only one card was ever visible per row. */
.post-item p{color:var(--color-text-secondary);margin:0;
  display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.post-item .thumb{aspect-ratio:16/6}
.post-item .thumb svg{width:100%;height:100%;display:block}
.post-item .pi-body{padding:22px 24px}
@media(prefers-reduced-motion:reduce){.post-item{transition:none}}

.article{max-width:720px;margin:0 auto}
.article .date{color:var(--color-text-secondary);font-size:var(--text-sm)}
.article h1{font-size:var(--text-display-md);margin:.3em 0 .6em}
.article p{font-size:1.08rem}
/* WO-146 item 8: WO-135's QA-R09-004 fix, `.article .beat + p:not(.beat)`,
   is REMOVED here - superseded, not broken. It was written to reach the one
   case build.py's renderer used to make distinguishable: a multi-line
   paragraph's last line was a bare `<p class="beat">` sibling of the plain,
   unclassed `<p>` after it, so the `+` adjacency selector could tell the two
   apart. Forge's `<div class="stanza">` wrapper (build.py, STANZA_CLASS) now
   groups every multi-line paragraph's `.beat` lines inside that wrapper, so
   `.beat` never sits as a bare sibling of `.article` any more - the plain
   `<p>` after a stanza is now a sibling of `.stanza`, not of `.beat`, and
   this selector can no longer match anything real. A rule that looks
   meaningful but does nothing misleads whoever edits next, so it is removed
   rather than left in place. The stanza-to-stanza and stanza-to-plain-p
   spacing this rule used to approximate is now build.py's/`.stanza`'s own
   concern (style.css:1078-1079, `.stanza{margin:0 0 var(--space-7)}`), out
   of scope for this order. */
.article h2{font-size:var(--text-xl);margin:1.4em 0 .4em}
.article .tag{margin-bottom:.8em}
.article figure{margin:1.8em 0}
.article figure .fig-visual{border-radius:var(--radius-md);overflow:hidden;
  box-shadow:var(--shadow-md);aspect-ratio:16/8}
/* The "img" half of this selector is the one that matters and it is easy to
   miss, because no real photo exists on the site yet. The box above is a fixed
   16/8 letterbox. A drawn SVG is generated at exactly that shape so it fits
   without being told to. A photo Mandy uploads through the CMS will be whatever
   shape her phone took it in, and without these four declarations it would sit
   at its own size inside the letterbox and either overflow or leave a gap.
   object-fit:cover makes it fill the box and crop, which is what the About page
   portrait already does. Same rule, same reason. */
.article figure .fig-visual svg,.article figure .fig-visual img{
  width:100%;height:100%;display:block;object-fit:cover}
.article figcaption{font-size:.85rem;color:var(--color-text-secondary);
  margin-top:.6em;text-align:center;font-style:italic}
.back-link{display:inline-block;margin-top:2em;color:var(--color-brand-primary);
  font-weight:600;text-decoration:none}
.pull{border-left:var(--border-thick) solid var(--color-brand-primary);
  padding:.3em 0 .3em 1.1em;margin:1.6em 0;font-family:var(--font-serif);
  font-size:1.35rem;line-height:1.4;color:var(--color-text-primary);font-style:italic}
/* --text-2xs is 12px, the legibility floor. Do not set this smaller. */
.tag{display:inline-block;font-size:var(--text-2xs);font-weight:700;
  text-transform:uppercase;letter-spacing:.07em;color:var(--color-brand-primary);
  background:color-mix(in srgb,var(--color-brand-primary) 13%,transparent);
  padding:4px 11px;border-radius:var(--radius-full)}
.byline{display:flex;align-items:center;gap:.6em;margin:1.4em 0 2em;
  color:var(--color-text-secondary);font-size:var(--text-sm)}
.byline .av{width:34px;height:34px;border-radius:50%;overflow:hidden;flex:none}
.byline .av svg{width:100%;height:100%;display:block}
.post-hero{border-radius:var(--radius-lg);overflow:hidden;box-shadow:var(--shadow-md);
  margin:0 0 1.4em;aspect-ratio:16/6}
/* Same point as the figure rule above, for the big picture at the top of a post.
   The box is a fixed 16/6 band. Today it only ever holds a generated drawing
   built to that shape. The moment a real hero photo is uploaded, "img" and
   object-fit:cover are what keep the band the right height instead of letting
   a tall photo stretch the page. */
.post-hero svg,.post-hero img{width:100%;height:100%;display:block;object-fit:cover}
/* PAUL 2026-08-20: AN UPLOADED POST PHOTO IS HELD STILL AND THE PAGE TRAVELS
   OVER IT, matching the Home hero, "What could be" and "Ready for Your Next
   Chapter?". Set up now, before there are any posts, so the first one Mandy
   publishes gets the treatment without anybody remembering to add it.

   It is a div with a background rather than an <img> because that is the only
   way the effect exists: background-attachment:fixed is a background property.
   build.py gives the div role="img" and an aria-label from the post's own
   hero_alt, so the photo is still announced to a screen reader exactly as the
   <img> was. The generated fallback banner, used when a post has no photo,
   stays an inline SVG and is unaffected by any of this.

   position:absolute inside the existing .post-hero box, which already has
   aspect-ratio 16/6, overflow:hidden and the rounded corners. So the band keeps
   its shape and the photograph is clipped to it.

   TWO THINGS TO KNOW WHEN THE FIRST REAL POST ARRIVES.

   The picture must be HIGH RESOLUTION, and more so than the 16/6 band suggests.
   Under `fixed` the background is sized to cover the VIEWPORT, not this short
   band, so on a 1920x1080 screen it is being asked to fill 1920x1080 and the
   band shows a slice of that. A 1200px-wide photo that looks fine in the band
   under `scroll` will be visibly soft here. Aim for 2560 wide at least, 3840 if
   the source allows, and downscale rather than upscale.

   There is no srcset. A single background URL is what a per-post value in front
   matter can express, so one adequately large file does all densities. That is
   the trade for the effect, it is why the size guidance above matters, and it
   is why we supply these images rather than leaving Mandy to find them.

   Same honest fallback as the other three bands: iOS Safari does not implement
   background-attachment:fixed and degrades it to a stretched, badly cropped
   still, so touch devices and anyone who has asked for reduced motion get
   `scroll`, which is an ordinary well-composed banner. */
.post-hero{position:relative}
.post-hero-photo{
  position:absolute;inset:0;
  background-size:cover;
  background-position:center;
  background-repeat:no-repeat;
  background-attachment:fixed;
}
@media (hover:none),(prefers-reduced-motion:reduce){
  .post-hero-photo{background-attachment:scroll}
}

/* =============================================================================
   HOME: hero visual (full-bleed layer, ADR-041), values row, latest posts
   ============================================================================= */
/* .home-visual is now a full-bleed layer behind .hero's copy, not a side
   panel: absolutely positioned, inset to .hero (which is position:relative,
   overflow:hidden - both load-bearing for a new reason now, see the HERO
   section above), with the 40px/20px asymmetric overscan built into the box
   ITSELF rather than into the <img> inside it (restated from WO-119/HO-119
   for an absolutely positioned layer, ADR-041: "inset:0 alone would give the
   layer the box height and leave nothing to travel in"). .hero's own
   overflow:hidden is what clips the resulting 20px top/bottom spill, not a
   property on this box - there is nothing left inside this box that needs
   its own clipping, so it does not carry overflow:hidden itself.

   THE SHAPE RULES THAT USED TO LIVE HERE (aspect-ratio:16/10, then 16/9,
   then 3/2, then min-height:clamp(460px,40vw,600px), border-radius,
   box-shadow) are gone. They described a side panel that no longer exists
   (ADR-041 names this rule set explicitly as needing neutralising) - a
   full-bleed layer takes its shape from .hero itself, which in turn takes its
   height from the copy stack's own content and padding. */
/* PAUL 2026-08-19. THE OVERSCAN IS GONE, AND THAT IS A SIMPLIFICATION, NOT AN
   OVERSIGHT. This box carried 20px of spill top and bottom (later 80, under the
   scroll-driven parallax that this replaces) so an <img> inside it had somewhere
   to travel. A fixed background does not travel inside its box at all: it is
   painted against the VIEWPORT and this box simply shows the part of it that
   falls inside its own edges. Nothing to overrun, so nothing to reserve.
   That also retires the whole cover-scale cost the parallax was paying (up to
   +25% zoom at 768) -- it is not reduced, it no longer exists. */
.home-visual{
  position:absolute;inset:0;
}
/* The scrim. WITHDRAWN premise, kept visible rather than deleted so the next
   editor sees why this is not a uniform wash: hero-overlay-and-motion.md
   1.1-1.2 claimed the photograph "has no naturally dark region anywhere in
   it." AUDIT-BEACON-06 BEA6-003 measured that false - 24.03% of the
   photograph sits below luminance 0.10, concentrated in the bottom two of
   four horizontal bands - and BEA6-001 measured the OLD uniform
   rgba(27,43,38,.58) fill failing 4.5:1 on the lead paragraph at 13 of 17
   widths (worst 3.86:1 at 2560px), non-monotonically, because
   object-fit:cover re-crops the photograph at every width so a flat wash's
   pass/fail is a function of the crop rather than of the text.

   WO-140 / hero-overlay-and-motion.md 9.3 fix: confine a stronger alpha
   (0.65, Beacon's own measured-safe value, clears all 12 previously-failing
   widths at a worst case of 4.77:1) to the text's OWN horizontal footprint,
   feathered at both edges, instead of the full 100vw layer. The text's
   position is a plain layout formula (the same --space-gutter/--measure-
   prose/--container-max tokens .hero-copy already uses, restated here on
   this sibling element rather than shared via one custom property, since
   .home-visual and .hero-copy are siblings, not ancestor/descendant), not a
   function of the crop - scoping the scrim to that formula removes the
   width-dependence that made the failure hard to catch by sampling round
   numbers. The dark lower band, which sits outside this flat zone at every
   width from 1024px up, is left at its own real tone: raising the alpha
   there would crush an area that already measures legible and buys zero
   contrast benefit.

   A ::after on .home-visual, not a separate element in the HTML: it paints
   after the <picture> (later in this box's own local order) but the whole
   box still precedes .wrap in the DOM, so the copy still wins the paint
   order with no z-index anywhere (ADR-041 point 1).

   --scrim-inset/--scrim-edge use the CEILING measure (--measure-prose, not
   the narrower track-clamped value section 7 uses for 1024-1599px), so the
   flat zone is always at least as wide as the real rendered text column,
   never narrower - the same safe-direction choice .hero-copy's own max-width
   already makes. Feather width past the text's own right edge is 2x
   --space-9 (128px), a cosmetic fade, never a box a real pixel of text sits
   inside.

   WO-184 (Pixel, by looking, HO-183): below this comment, --scrim-inset
   itself is UNCHANGED - it still floors at the bare --space-gutter (22px)
   below 1080px, because that is the text column's own left edge and moving
   it would move where the flat 0.65 protection starts relative to real
   glyph ink. A first attempt did move it (a bigger floor on --scrim-inset,
   86px), rendered a genuinely softer edge, and then FAILED contrast hard
   when measured against the real composited pixels: 1.83-1.93:1 on the h1
   and lead's own leftmost glyphs at 320-390px, because those widths' text
   starts almost flush against the gutter and the first several characters
   of every line sit inside whatever gap opens up between the gutter and a
   moved-out --scrim-inset. Reverted; see HO-184 for the measured numbers.

   The actual fix is below: --scrim-ramp-start, a SEPARATE stop that moves
   the gradient's TRANSPARENT end, never its OPAQUE end. Below 1080px the
   naive gradient was "transparent 0, opaque at 22px" - a full 0-to-65%
   swing packed into 22 real screen pixels, which is what reads as a hard
   rim. --scrim-ramp-start instead lets that transparent stop sit BEFORE the
   box's own left edge (a negative length, off-canvas, never rendered) so
   the conceptual ramp is --scrim-ramp-floor (96px) long even though only
   its last 22px are ever on screen. The visible slice of that longer ramp
   is shallower - at the true edge the gradient is already partway toward
   opaque instead of literally transparent, which is what "bright and
   undimmed" actually meant - while the point where full 0.65 opacity is
   reached is BYTE IDENTICAL to before (still --scrim-inset, still exactly
   the text column's left edge). Nothing in the flat protected zone moves,
   so nothing in Beacon's measured-safe band moves either.

   min(0px, ...) is what makes this self-limiting: once --scrim-inset itself
   is already >= the 96px floor (everything from about 1208px up, and in
   particular 1440px at 202px), --scrim-ramp-start resolves to a flat 0px
   and the whole mechanism goes inert - the gradient is the exact rule that
   shipped before this order, unchanged in every respect including at the
   sub-pixel level. */
.home-visual::after{
  content:"";position:absolute;inset:0;
  --scrim-inset:max(
    var(--space-gutter),
    calc((100vw - var(--container-max))/2 + var(--space-gutter))
  );
  --scrim-edge:calc(var(--scrim-inset) + var(--measure-prose) + var(--space-gutter));
  /* 96px: var(--space-9) + var(--space-7), 64+32, both existing tokens. Only
     value picked for this order; not derived from a spec, so named here
     rather than pretending it is load-bearing arithmetic. */
  --scrim-ramp-floor:calc(var(--space-9) + var(--space-7));
  --scrim-ramp-start:min(0px, calc(var(--scrim-inset) - var(--scrim-ramp-floor)));
  /* PAUL 2026-08-20: THE STRAIGHT EDGES ARE GONE, AND THAT WAS THE WHOLE
     PROBLEM. He said this band "doesn't look as professional as it should" and
     wondered about "the dark colour just behind the text". He was right, and it
     is visible in a screenshot the moment you look for it: the old rule was a
     LEFT-TO-RIGHT gradient with a flat 0.65 plateau, which paints a hard-edged
     grey-green rectangle across the sky. Measured on the built page at 1920,
     that rectangle had a vertical edge at x=392 where it reached full opacity
     and another at x=1134 where the feather began. Over the busy wheat in the
     lower half you cannot see them. Over a smooth blue sky you absolutely can,
     and a flat wash with a visible boundary reads as a slab someone forgot to
     blend, not as lighting.

     A soft ellipse has no boundary to notice. It is anchored on the copy rather
     than on the viewport's middle, so the darkest part sits under the headline
     and falls away in every direction at once, which is how a photographer
     would burn in a corner. The second layer is a short top-down wash: it seats
     the sticky header against the sky and stops the top edge of the frame
     glaring, and it is far too gentle to read as an edge of its own.

     WHAT DID NOT CHANGE, AND MUST NOT. This scrim is contrast-critical and has
     a measured history: AUDIT-BEACON-06 found the ORIGINAL uniform wash failing
     4.5:1 on the lead paragraph at 13 of 17 widths, worst 3.86:1 at 2560,
     non-monotonically, because object-fit re-crops the photograph at every
     width so a flat wash's pass or fail is a function of the crop rather than
     of the text. That is why the numbers below are measured across a width
     sweep and not spot-checked, and why the alpha at the centre is HIGHER than
     the old plateau rather than lower: a gradient gives less cover than a
     plateau everywhere except its centre, so it has to start deeper to end up
     safer. Re-measured after this change; the figures live in
     assets-source/README.md next to the hero's own entry.

     The --scrim-* custom properties above are kept and still used: the ellipse
     is centred on --scrim-inset, which is the text column's real left edge
     computed from the same tokens .hero-copy itself uses, so the darkest point
     tracks the copy at every width instead of being a guessed percentage. */
  /* OAT, NOT INK, AND RELEASED ACROSS THE RIGHT LIKE "What could be".

     PAUL 2026-08-20, second pass. The first light version washed the whole
     frame fairly evenly and he said we should be "replicating the What could
     be / Imagine section for how the image and text are shown". He is right,
     and that band's gradient is the reason it works: it is heavy where her
     words are and RELEASES across the empty side, so the picture is genuinely
     visible in the space that was doing nothing rather than being dimmed
     everywhere to protect text that only occupies part of the frame.

     WHY THIS IS NOT THE OLD HARD-EDGED BAND COMING BACK. The rule this
     replaced ran transparent -> 0.65 -> 0.65 -> transparent, which put TWO
     boundaries in the middle of the canvas, and over a smooth sky you could see
     both. This one starts at full strength on the left EDGE and ends at the
     right EDGE, so the only boundaries are the frame's own. The single
     transition inside it is spread over a fifth of the width, which is a
     release rather than an edge. That is the difference, and it is the same
     shape .narrative-imagine has been using since 2026-08-18.

     ANCHORED TO THE TEXT, NOT TO A PERCENTAGE, and that part matters more here
     than it does on "What could be". Her copy occupies about 53 percent of the
     frame at 1920 but about 80 percent at 1280, because the column stops
     growing while the viewport does not. A fixed 46 percent release would leave
     the end of the headline hanging over the clear part at the middle widths.
     --scrim-edge is computed from the same --space-gutter / --measure-prose /
     --container-max tokens .hero-copy itself uses, so the release always begins
     where the text actually ends. */
  background:linear-gradient(
    to right,
    rgba(247,245,240,.93) 0%,
    rgba(247,245,240,.90) var(--scrim-edge),
    rgba(247,245,240,.46) calc(var(--scrim-edge) + 12%),
    rgba(247,245,240,.20) calc(var(--scrim-edge) + 24%),
    rgba(247,245,240,.14) 100%
  );
  pointer-events:none;
}
/* NARROW SCREENS GET AN EVEN WASH, NOT THE ELLIPSE, AND THIS FIXES A FAILURE
   THAT PREDATES THE ELLIPSE. PAUL 2026-08-20.

   Measured while checking the ellipse, at the widths a phone actually uses, and
   confirmed against the OLD rule to see which of us caused it:

     width   old flat-plateau rule   soft ellipse
      768          4.52:1               4.37:1
      430          4.03:1               3.99:1
      390          3.33:1               3.29:1
      375          3.10:1               3.11:1
      320          2.40:1               2.41:1

   So the ellipse moved these by at most 0.15 and the failure is not its doing:
   the hero has been under 4.5:1 on phones for a long time and nobody had swept
   these widths since AUDIT-BEACON-06 looked at the desktop range. It is fixed
   here because Paul asked that everything read clearly, not because it was
   introduced today.

   WHY AN ELLIPSE IS THE WRONG SHAPE DOWN HERE. Above 600px the copy occupies a
   column on the left and the picture has room to breathe on the right, so
   burning in one side is both enough and attractive. Below that the copy runs
   the FULL width, there is no uncovered side left to protect, and the narrow
   5:8 crop is bright wheat and sky almost edge to edge. A wash centred anywhere
   leaves some of the text over the bright part. So the shape goes away and the
   cover goes even, which is the same call .narrative-imagine's own 820px branch
   makes for the same reason.

   Still a gradient rather than a flat fill: it is slightly deeper behind the
   headline and the lead, and eases at top and bottom, so the photograph is not
   simply dimmed to a uniform grey. */
@media (max-width:820px){
  .home-visual::after{
    background:linear-gradient(
      to bottom,
      rgba(247,245,240,.86) 0%,
      rgba(247,245,240,.92) 30%,
      rgba(247,245,240,.92) 72%,
      rgba(247,245,240,.86) 100%
    );
  }
}
/* WO-146 item 1: the below-720px display:none gate that used to sit here is
   REMOVED. Paul approved the full-width hero image and asked for it at every
   width; the paired text-colour query in the HERO section above was removed
   in the same change, so there is no width left where the image is hidden
   and no second number to keep in sync with this one any more. */
/* ---------------------------------------------------------------------------
   THE HERO PHOTOGRAPH. PAUL 2026-08-19: it stays put and the page travels over
   it, matching .narrative-imagine ("What could be") further down this file.
   --------------------------------------------------------------------------- */

/* WHAT THIS REPLACES, so nobody restores it by halves. Everything that used to
   sit here is gone deliberately:

     - `.home-visual img{width:100%;height:100%;object-fit:cover}` and its
       object-position note. There is no <img> in the hero any more; the
       photograph is this element's background. See site/index.html for why an
       element could not be pinned and a background could.
     - The whole @supports (animation-timeline: scroll()) parallax block: an
       overscanned box with the image translated +/-72px against root scroll.
       That was an approximation of this effect built out of the parts an <img>
       could offer. This is the effect itself, so the approximation goes rather
       than stacking on top of it.
     - With it goes the cost that approximation carried: up to +25% cover-scale
       at 768px, and the `sizes` contract it had put out of date. Neither is
       reduced. Both stop existing.

   THE LADDER IS DERIVED AGAINST THE VIEWPORT, NOT AGAINST THIS BOX, and that is
   the one thing to hold on to if you edit it. background-attachment:fixed makes
   the background positioning area the viewport, so `cover` is satisfied against
   the viewport's width and height, whatever shape this element happens to be.
   The old <picture> ladder was derived from this element's own box; do not port
   its numbers here, they answer a different question.

   Requirement at 1x is max(viewportW, viewportH * 1.6) for the 16:10 landscape
   files, and max(viewportW, viewportH * 0.625) for the 5:8 narrow crop.

   THE 600px BREAK AND THE NARROW CROP ARE KEPT, and for the reason they were
   introduced (ADR-044/WO-182), which survives the change: below 600px the area
   being covered is a tall portrait rectangle, and covering it with a 16:10 file
   throws away most of the file's width. Under `fixed` that area is the phone's
   VIEWPORT rather than the hero box, which is still portrait, so the argument
   and the break both stand. tools/make-hero.py NARROW_CROP builds these. */
.home-visual{
  background-repeat:no-repeat;
  background-position:center;
  background-size:cover;
  background-attachment:fixed;
  /* Below 600px: the 5:8 narrow crop. A 390x844 viewport needs about 528px at
     1x (height-governed: 844 * 0.625), so 500w is within a hair of exact and
     1000w covers 2x and 3x phones alike -- 1000 is the largest exact 5:8
     rectangle the master can yield, so there is no 3x rung to add and none is
     invented. */
  background-image:image-set(
    url("/images/home-hero-narrow-500.webp") type("image/webp") 1x,
    url("/images/home-hero-narrow-1000.webp") type("image/webp") 2x,
    url("/images/home-hero-narrow-500.jpg") 1x,
    url("/images/home-hero-narrow-1000.jpg") 2x);
}
/* 600px and up: the landscape master. Two rungs rather than seven, because
   image-set() selects on resolution and not on width, so each branch has to
   name one file per density and the branches themselves do the width work. */
@media (min-width:600px){
  .home-visual{
    /* 600-1439. A 1280x800 viewport needs 1280; a 1439x900 needs 1440, which
       the 1280 file misses by 12% -- the 2000 rung is 175 KB against 88 and is
       not worth that on the LCP image, and the shortfall lands on a defocused
       field under a scrim. */
    background-image:image-set(
      url("/images/home-hero-1280.webp") type("image/webp") 1x,
      url("/images/home-hero-2560.webp") type("image/webp") 2x,
      url("/images/home-hero-1280.jpg") 1x,
      url("/images/home-hero-2560.jpg") 2x);
  }
}
@media (min-width:1440px){
  .home-visual{
    /* 1440-1919 needs up to 1920 (a 1920x1080 viewport: max(1920, 1728)). */
    background-image:image-set(
      url("/images/home-hero-2000.webp") type("image/webp") 1x,
      url("/images/home-hero-3840.webp") type("image/webp") 2x,
      url("/images/home-hero-2000.jpg") 1x,
      url("/images/home-hero-3840.jpg") 2x);
  }
}
@media (min-width:1920px){
  .home-visual{
    /* 2560 and 3840 viewports. 3840 is the master's own ceiling; a 3840px
       viewport at 2x would want 7680 and no such file exists or should. */
    background-image:image-set(
      url("/images/home-hero-3840.webp") type("image/webp") 1x,
      url("/images/home-hero-3840.jpg") 1x);
  }
}
/* THE HONEST FALLBACK, copied in form and reasoning from .narrative-imagine's
   own, which has been carrying it since 2026-08-18.

   Apple disabled background-attachment:fixed on iOS Safari years ago, where it
   degrades to a stretched, badly-cropped still rather than to a normal
   background. Declaring `scroll` for any device without hover (touch) means
   those devices get a well-composed still image instead, which is what the hero
   looked like before any of this. prefers-reduced-motion gets the same, because
   a picture that holds still while the page moves is motion whether or not
   anything is animating.

   `scroll` also moves the positioning area back to this element's own box, so
   `cover` is satisfied against the hero rather than the viewport, which is the
   framing the still version was always composed for. */
@media (hover:none),(prefers-reduced-motion:reduce){
  .home-visual{background-attachment:scroll}
}
.latest{display:grid;grid-template-columns:repeat(auto-fit,minmax(255px,1fr));gap:20px}
.latest a{display:block;background:var(--color-surface-raised);
  border:var(--border-thin) solid var(--color-border-subtle);
  border-radius:var(--radius-lg);overflow:hidden;box-shadow:var(--shadow-md);
  text-decoration:none;color:inherit;
  transition:transform var(--motion-base),border-color var(--motion-base)}
.latest a:hover{transform:translateY(-3px);border-color:var(--color-brand-primary)}
.latest .thumb{aspect-ratio:16/8}
.latest .thumb svg{width:100%;height:100%;display:block}
.latest .lp-body{padding:16px 18px}
/* --text-2xs is 12px, the legibility floor. Do not set this smaller. */
.latest .date{font-size:var(--text-2xs);color:var(--color-text-secondary);
  text-transform:uppercase;letter-spacing:.05em}
.latest h3{font-size:1.1rem;margin:.3em 0}
.latest p{color:var(--color-text-secondary);font-size:var(--text-sm);margin:0}
@media(prefers-reduced-motion:reduce){.latest a{transition:none}}

/* The home page blog section shows and hides itself, from what build.py put
   between the LATEST_POSTS markers. With no posts, build.py emits one
   <p class="latest-empty"> and this rule hides the whole section: heading,
   standfirst, card area and the "Read all posts" button (Sketch's D3 ruling).
   With posts, the paragraph is not there, the rule does not match, and the
   section shows. Nothing hand-written decides this, so the page can never
   disagree with the number of posts. See ADR-035.

   Do not put a hidden attribute or a display:none on that <section>. build.py
   checks for both and stops the build, because a hand-written hidden attribute
   is what made a published post invisible once already.

   The next line must stay byte for byte as it is written here, including the
   absence of spaces. build.py holds the same string as a constant and checks
   that this file contains it. If :has() is ever unsupported, the section stays
   visible and shows the "new posts are on their way" sentence, which is the
   safe way round to fail. */
.latest-block:has(.latest-empty){display:none}
.latest-empty{grid-column:1/-1;color:var(--muted);margin:0}

/* =============================================================================
   STICKY FOOTER LAYOUT
   Goal: the footer sits at the bottom of the viewport when the page content
   is shorter than the screen, and directly after the content when the page
   content is taller than the screen. It must never cover content and it is
   never position:fixed; "sticky" here just means "pushed down as far as it
   can go, then stop".

   How: body becomes a column flex container that is at least one full
   viewport tall (min-height:100vh, not height, so a page that needs MORE
   than the viewport can still grow). main is the one item allowed to grow
   (flex:1 0 auto), so it soaks up whatever empty space is left below a
   short page. The footer takes only the space it needs and ends up sitting
   right under main, which puts it at the bottom of the viewport on a short
   page and right after the content on a long one.

   Why the skip link is safe: .skip-link is position:absolute (see the SKIP
   LINK section above), and an absolutely positioned element is taken out of
   normal flow completely. A flex container does not lay out an out-of-flow
   child as a flex item at all, so turning body into a flex column cannot
   add a gap above the skip link or move it. Confirmed in the browser, not
   just assumed by reading the spec.

   Why the sticky header is safe: header.site uses position:sticky, which
   measures "top:0" against the nearest scrolling ancestor. Nothing on this
   page sets overflow on html or body, so that ancestor is still the
   viewport, exactly as it was before body became a flex item's parent.
   Being a flex item does not change what position:sticky measures against.
   Confirmed in the browser as well.

   main keeps its own tabindex="-1" focus rules exactly as they were above;
   flex:1 0 auto only changes how tall the box is, not whether it can take
   focus or what happens when it does.
   ============================================================================= */
body{display:flex;flex-direction:column;min-height:100vh}
main[tabindex="-1"]{flex:1 0 auto}

/* =============================================================================
   FOOTER
   ============================================================================= */
/* THE FOOTER. Spec: 04-ui/hero-image-direction.md B. Presentation only: not
   one word is added, removed or reordered, and the markup is untouched.

   Why it kept looking unfinished no matter what was adjusted inside it: it
   had no visible edge and no internal hierarchy.
     - Its ground was white on a cream page. That is 1.09:1. Its only boundary,
       a 12%-alpha hairline, read at 1.15:1 against the page. WCAG asks 3:1 of
       a meaningful non-text boundary, so the footer was, measured rather than
       judged, very nearly invisible as a region, and lighter than the page it
       was supposed to close.
     - The name, the street, the locality, the directions link, "By
       appointment", four nav links and the copyright all rendered at 14.4px in
       one colour. One type treatment for four different kinds of information.

   So: its own dark ground, 8.65:1 against the page, and four type levels.
   Zero new colours, zero images, zero JavaScript, zero bytes of page weight.

   The focus ring needs no special case here and this is why, so nobody
   reopens it: the ring is a 3px ink outline with a solid white halo filling
   the offset. Ink on this green alone would be 1.58:1, but the halo always
   sits between them, so the ring's inner neighbour is white at 14.79:1 and
   the halo's outer neighbour is this ground at 9.42:1. -------------------- */
footer.site{background:var(--color-surface-footer);
  color:var(--color-text-on-footer-muted);
  /* Both WITHDRAWN on purpose. The hairline was the 1.15:1 boundary described
     above; the ground replaces it. The 20px top margin left a stripe of page
     cream above the footer, which was invisible against a white footer and
     would be an obvious cream band above a green one. */
  border-top:none;margin-top:0;
  /* PAUL 2026-08-18: was a single symmetric clamp(--space-7,6vw,--space-9),
     64px a side at 1280. Paul reported the whole footer reading as empty.
     Now asymmetric, because the two sides are not doing the same job: the top
     separates the footer from the section above it and still needs to read as
     a break, so it comes down 64 -> 40. The bottom is closing the document
     and needs far less than the top, so it comes down 86 -> 24. */
  padding:clamp(var(--space-7),3.2vw,40px) 0 clamp(var(--space-5),2vw,var(--space-6));
  font-size:var(--text-sm)}
/* Two columns, not space-between. space-between on three items of unequal
   width left two arbitrary ~182px gaps that moved whenever any string changed
   length, which reads as leftover space rather than as columns. minmax(0,...)
   on both tracks so a long unbreakable string shrinks its track instead of
   forcing the page sideways. DOM order, visual order and tab order stay
   identical; nothing here reorders anything. */
/* PAUL 2026-08-19: THREE columns, not two. The map and its attribution moved
   out of .nap into their own middle track.

   WHY, and it is a height problem rather than a taste one. A grid row is as
   tall as its tallest cell. The first column used to carry the name, the
   address, the map, the attribution AND "By appointment" stacked vertically,
   which measured about three times the height of the nav column beside it, so
   every page carried that difference as empty green under the links. Splitting
   the tallest item into its own track makes the map's own height the footer's
   height instead of the map PLUS the address block's.

   Track sizes are not equal thirds. The address needs the least (it is capped
   at 28ch and always has been), the map column is capped in its own rule so it
   cannot drive the footer's height back up, and the nav needs enough for two
   columns of links at their own font size. */
/* PAUL 2026-08-19, SAME DAY, SECOND PASS: THE MIDDLE TRACK IS WIDER THAN THE
   OTHER TWO, AND THE GAP TIGHTENS BELOW 1024. Both exist for one reason, and it
   is a legibility one rather than a taste one.

   The footer map's roads, labels and its burned-in tile credit are drawn at a
   size meant for a 352 CSS px map (tools/make-map.py requests width=352 with
   scaleFactor=2, which is what makes a small map crisp instead of a smudge --
   read that file's comment before touching any of this). Shown smaller than
   that, every mark shrinks with it, and the credit strip along the bottom is
   the first thing to become unreadable. It was tuned against a 217px box.

   Three equal columns starved it. Measured across the three-column range,
   before this rule: 211px at 820, 202 at 792, 194 at 768, 171 at 700, 151 at
   641. Paul reported it as garbled at 792, and he was right -- that is 202px
   of a file drawn for 352.

   1024px and up was always 264 and has never been complained about, so 264 is
   the target and 217 the floor. Measured after, at the same widths: 264 from
   768 up, then 244 / 228 / 220 at 700 / 660 / 641. Every width at or above the
   tuned floor, and the width Paul is looking at back to the size the asset was
   built for.

   THE 1.4 IS NOT A ROUND NUMBER PICKED BY EYE. 1.6 was measured too: it holds
   264 all the way down to 700, which is better for the map, and wraps
   "Bookings" and "Blog" onto two lines at 641, which is worse for the nav. A
   footer that fixes the map by breaking the links is not a fix. 1.4 is the
   largest value tested that never wraps a link at any width.

   The tighter gap is the other half. --space-9 is 64px, which is 128px of a
   748px row at 792 -- 17% of the footer spent on air, at exactly the widths
   with the least to spare. It stays 64 on desktop, where there is room. */
.foot{display:grid;
  grid-template-columns:minmax(0,1fr) minmax(0,1.4fr) minmax(0,1fr);
  column-gap:var(--space-9);row-gap:var(--space-7);
  align-items:start}
@media(max-width:1023px){
  .foot{column-gap:var(--space-6)}
}
/* Bookings hides the map (see the body.page-bookings rule further down), which
   would otherwise leave an empty middle track and a 64px gap where it was. The
   grid goes back to two columns on that page so the nav closes up. */
body.page-bookings .foot{
  grid-template-columns:minmax(0,1.15fr) minmax(0,1fr);
}

/* L1 - identity. The most important fact in the footer, previously the least
   prominent thing in it. */
.nap-name{font-family:var(--font-serif);font-size:var(--text-lg);
  font-weight:600;line-height:1.3;
  color:var(--color-text-on-footer);margin-bottom:var(--space-2)}
/* L2 - the facts. font-style:normal resets <address>'s italic default, the one
   reason anyone reaches for a <div> instead of the semantic element. max-width
   stops the address stretching across a wide footer. */
.nap{max-width:28ch}
.nap address{font-style:normal;display:flex;flex-direction:column;
  gap:var(--space-1);line-height:1.6}
.nap a{display:inline-flex;align-items:center;gap:var(--space-2);
  min-height:var(--space-8);margin:var(--space-2) 0 0;padding:0;
  font-weight:600;color:var(--color-text-on-footer);
  text-decoration:underline;text-underline-offset:3px}

/* L2b - the footer map, WO-217 / HO-216. The whole picture is the directions
   control, so on the six pages that carry it the "Get directions" text link is
   the same control said twice and is hidden. Bookings does the reverse: see the
   page-bookings rules below.

   THE SPECIFICITY TRAP, measured by Pixel, not argued. .nap a above is (0,1,1)
   and sets display:inline-flex, min-height and margin. A rule written as
   .nap-map is (0,1,0) and LOSES: the map rendered 78.22px wide at 320 instead
   of 261, shrink-wrapped by inline-flex. The selector must carry the element,
   .nap a.nap-map, and must explicitly undo min-height and padding. Do not
   "simplify" this to .nap-map.

   NO border-radius, and this is a licence constraint wearing a style
   constraint's clothes. The OpenStreetMap and Geoapify credit is burned into
   the bottom LEFT of the image over about 78 of its 380px, wrapped onto two
   lines, and a 12px radius eats the word "contributors".

   The box is 16/9 (1.7778, border-box). The delivered image is now 704x380,
   aspect 1.8526, WT-233 - deliberately NOT 16/9. See the block below for why
   that mismatch is the fix, not a bug. Never scale, zoom or translate the
   image on hover: the box is overflow:hidden at a fixed aspect, so any
   transform crops the burned-in credit. Hover moves the border colour only,
   which is why there is nothing here for reduced-motion to turn off.

   READ THIS BEFORE CHANGING THE BORDER OR ADDING PADDING, AND BEFORE
   "CORRECTING" THE IMAGE BACK TO 16:9. WT-219 first measured the risk; WT-233
   is that risk arriving. aspect-ratio applies to the BORDER box, and the
   hairline below sits inside it, so the CONTENT box is narrower than the
   border box at every width - 259x144.81 at 320, 1.7885, not 1.7778 - and the
   worst content-box aspect ever measured across all nine widths is 1.7908.

   Under the old 704x396 asset (1.7778, equal to the border-box ratio), the
   content box was WIDER, proportionally, than the image, so cover cropped
   TOP AND BOTTOM: about 0.88px of image height at 320, 0.44px each edge,
   landing right on the credit's second line. This block called that
   harmless, and predicted that a future border-width or padding change
   would widen the gap and crop further into "contributors". That is
   exactly what this caution was for, and it fired: WT-233 is a real
   licence-credit clip, caught without any border or padding change at all.
   The 0.44px margin this block called harmless was already that tight.

   The fix, made by Pixel in the asset itself and not in this file, is
   704x380, aspect 1.8526. That is DELIBERATELY GREATER than 1.7908, the
   worst content-box aspect this box has ever measured - the image is now
   reliably WIDER, proportionally, than the box. That flips which edge
   cover crops: instead of shaving the top and bottom, where the credit
   lives, it shaves the LEFT and RIGHT, where there is 12-plus px of plain
   image with nothing printed on it. Measured after the fix: 0.000px
   cropped top or bottom at all nine widths.

   DO NOT "tidy" the image back to a 16:9 pair (704x396 or any other) to
   match the box. That re-equalises the two aspects and silently
   reintroduces exactly the defect above: no build guard, contrast check or
   viewport test would catch it, because it fails as a slightly shorter
   image, not as an error. The image's aspect must stay measurably GREATER
   than the content box's worst case, 1.7908, or this returns. If you change
   --border-thin here or add padding, re-magnify the bottom-left at 320 and
   confirm the second line still reads "contributors" before you ship it. */
/* PAUL 2026-08-19: RE-SCOPED from `.nap a.nap-map` to `.foot-map a.nap-map`.
   The map is no longer inside .nap, so the old selector matched nothing and
   the map lost its border, its aspect-ratio and its crop in one go.

   The `min-height:0;padding:0` pair that used to sit here is GONE, not
   forgotten: it existed only to undo `.nap a`'s inline-flex, min-height and
   padding, and outside .nap there is nothing left to undo. The old comment
   warning against "simplifying this to .nap-map" was about specificity inside
   .nap and no longer applies either; what matters now is only that this rule
   is more specific than nothing else claims it.

   THE 34ch CAP IS THE POINT OF THE WHOLE CHANGE. Given its own grid track the
   map would stretch to fill it, and a 16:9 box that is wider is also taller,
   which would put the footer's height straight back where it was. Capped, the
   map is the same size it has always been and the saving is real. */
.foot-map{max-width:34ch}
.foot-map a.nap-map{display:block;margin:0;
  aspect-ratio:16/9;overflow:hidden;
  border:var(--border-thin) solid var(--color-rule-on-footer)}
.foot-map a.nap-map img{display:block;width:100%;height:100%;object-fit:cover}
.foot-map a.nap-map:hover{border-color:var(--color-text-on-footer)}
/* The attribution is a licence obligation, not styling, and not optional.
   Verbatim per ADR-037 and byte-identical to the <figcaption> on
   bookings.html. 6.69:1 on the footer ground, measured. */
.nap-attrib{margin:var(--space-2) 0 0;font-size:var(--text-2xs);
  line-height:1.5;color:var(--color-text-on-footer-muted)}
/* The map used to carry `margin-top:--space-4` because it followed the address
   inside .nap. In its own column it is the first thing in the track, so that
   margin would push it out of line with the address and the nav beside it.
   Zeroed on the rule above; this line records why, so nobody puts it back to
   "match the old spacing" and reintroduces the misalignment. */

/* Bookings only. That page already carries its own 960x480 map of the same
   address, and at 390 the footer map would render 331x186 against it at
   251x126: the small reassurance thumbnail bigger than the real map. They are
   1651px apart at 390 and 1489px at 1280, so no viewport ever shows both.
   display:none on the <a> also keeps office-map-footer off the network on the
   heaviest page on the site.

   The second line is the half that matters. Bookings' ONLY directions
   affordance anywhere on the page is this footer link, so hiding the map
   without putting the text link back would take the "how do I get there"
   control off the one page where that decision is actually made.

   The .nap-attrib line here hides a licence notice, so it was ruled rather
   than assumed. MAESTRO RULING M-25 (WT-219): the obligation is that the
   attribution be VISIBLE on any page displaying the map. Bookings does not
   display the footer map, and it carries its own map with its own visible
   <figcaption> holding byte-identical text, so the obligation is met on that
   page by the caption. Crediting a map that is not on the page would be noise,
   not compliance. This rule is therefore correct ONLY while the map above it
   is also hidden. If the footer map is ever restored on bookings.html, this
   line must go with it. */
/* PAUL 2026-08-19: one wrapper now, where it used to be two elements. The map
   and the attribution live in .foot-map together, so hiding that hides both,
   and it cannot drift into hiding one without the other -- which for a licence
   notice beside a visible map would be the wrong half to lose. Ruling M-25 is
   unchanged and still the reason this is allowed at all. body.page-bookings
   .foot also drops the grid back to two columns, further up this file, so
   nothing is left holding an empty track. */
body.page-bookings .foot-map{display:none}
/* WO-213: the "Get directions" text link this line used to reveal no longer
   exists in any page. Paul asked for the footer map to REPLACE it, so the markup
   went from all seven pages and these rules described nothing.

   READ THIS BEFORE RESTORING THE FOOTER MAP ON BOOKINGS. The rule above hides
   the footer map here because this page already carries a large map of its own,
   in the content, which links to the same directions URL. That is now the ONLY
   directions route on this page: the footer address block below it is plain
   text. That is acceptable while the content map exists and is a link. If the
   content map is ever removed, made non-interactive, or moved off this page,
   this footer loses its last route to directions and something must replace it.

/* L3 - the label. "By appointment" stops being a bolted-on sentence and
   becomes a label. WO-154 (components.md 22.4): .eyebrow moved up a rung and
   dropped its tracking; .nap-note does NOT follow it and keeps its own
   --text-2xs size and .12em tracking, so as of this wave the two no longer
   share a register, only the same uppercase-small-caps-weight treatment. The
   footer label reads as a treatment, not the Signposted Block pattern:
   components.md 18.4 keeps that pattern off footers, and a footer wants
   hierarchy rather than persuasion. No words change. */
.nap-note{margin-top:var(--space-3);font-size:var(--text-2xs);
  text-transform:uppercase;letter-spacing:.12em;font-weight:700;
  color:var(--color-text-on-footer-muted)}
/* L4 - navigation, told apart from the address by size, weight and
   arrangement rather than by colour. Two columns at every width down to
   320px: that is what keeps the block from growing a row per link the way an
   inline wrap does. min-height, never height, anywhere in this footer - a
   label that wraps to two lines must grow its cell, not clip. */
.foot nav{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));
  gap:var(--space-1) var(--space-4)}
.foot nav a{display:flex;align-items:center;min-height:var(--space-8);
  margin:0;padding:var(--space-2) 0;
  font-size:var(--text-base);font-weight:600;
  color:var(--color-text-on-footer);
  text-decoration:underline;
  text-decoration-color:var(--color-rule-on-footer);
  text-underline-offset:4px}
/* The underline is there at rest, so a nav link is identifiable as a link
   without relying on colour. Hover strengthens that underline; it is a second
   cue on top of one that is already there, not the only one. The colour must
   NOT change on hover: the site-wide a:hover is the same green as this ground
   and would make the link vanish. */
.foot a:hover,.foot a:focus{color:var(--color-text-on-footer)}
.foot nav a:hover{text-decoration-color:currentColor}
/* The one rule in the footer, at 3.25:1, which is genuinely visible rather
   than decorative-and-hoped-for. */
.foot-legal{grid-column:1/-1;padding-top:var(--space-6);
  border-top:var(--border-thin) solid var(--color-rule-on-footer);
  font-size:var(--text-2xs);color:var(--color-text-on-footer-muted)}
/* 40em, which is 640px at the default text size: the same collapse point the
   header already uses, not the old standalone 700px.

   WHY THIS ONE IS IN em WHEN EVERY OTHER BREAKPOINT IN THIS FILE IS IN px.
   An em breakpoint is measured against the reader's own text size, so it
   fires when the TEXT gets too big for two columns as well as when the screen
   does. It was reasoned that a 683px viewport (200% zoom on a 1366px screen)
   would stay two-column and still fit, on the arithmetic that "Bookings" is
   about 72px wide. At 200% it is about 144px wide in a 125px column, and
   rendering it showed exactly that: the word broke across two lines as
   "Booking / s". Nothing was lost and nothing overflowed, so it was not a
   WCAG failure, but it looked broken, which is the thing this pass exists to
   fix. In em the same 683px viewport is 21em, well under 40em, so the footer
   goes to one column and the label has the whole width. Measured, both ways,
   in Chromium. */
@media(max-width:40em){
  .foot{grid-template-columns:1fr;row-gap:var(--space-6)}
  /* PAUL 2026-08-19: the bookings override further up sets two columns; at
     this width everything is one column, so it has to be undone here too or
     that page alone would stay two-up on a phone. */
  body.page-bookings .foot{grid-template-columns:1fr}
  .nap{max-width:none}
  /* THE 34ch CAP IS RELEASED HERE, REVERSING AN EARLIER DECISION ON MEASURED
     GROUNDS. That decision read: "the map keeps a cap even one-column: full
     width on a phone would make it the largest thing in the footer by a wide
     margin." The concern was real and the remedy overshot.

     PAUL 2026-08-23 asked what could be improved in the footer between 350 and
     592, and the map is the answer. Capped at 264px it does not fill the
     column, so it reads as content that failed to load rather than as a
     decision: at 390 it leaves a 67px gap down the right, and at 592 it leaves
     269px, half the width. Scaled down that far, the Geoapify and OpenStreetMap
     credit burned into the image is also at its least legible, and that credit
     is a licence obligation.

     Filling costs +15px of footer height at 350 and +38px at 390, which is not
     "the largest thing by a wide margin". At the top of the range it would have
     cost +134px, and that is handled by the two-column rule below rather than
     by shrinking the map. */
  .foot-map{max-width:none}
  .foot-legal{grid-column:auto}
}

/* TWO COLUMNS FROM 480px TO 640px, INSTEAD OF ONE VERY TALL ONE.

   Between the phone widths and the 40em collapse there is enough room for two
   columns, and using it is what actually improved this range. Measured at
   592px: one column with a filled map is a 712px footer; two columns is 424px,
   with the map filling its own track and no gap anywhere. That is 154px shorter
   than the CURRENT footer, not just shorter than the alternative.

   The bookings override has to be repeated here for the same reason it is
   repeated above: that page sets its own column count further up the file, and
   without this it alone would stay one-column through this range. Its footer
   map is hidden, so the two tracks there are the address and the nav. */
@media(min-width:480px) and (max-width:40em){
  .foot,
  body.page-bookings .foot{
    grid-template-columns:repeat(2,minmax(0,1fr));
    column-gap:var(--space-6);
  }
  .foot-legal{grid-column:1/-1}
}

/* =============================================================================
   MESSAGE BUBBLE (WO-295, visual rebuild WO-329 / WT-P17-D against WO-326's
   spec and WO-328's UX amendments. Spec: .webteam/04-ui/message-bubble-
   visual.md, .webteam/03-ux/message-bubble/14-wo-328-amendments...md, copy:
   .webteam/05-content/pass-12-copy/036-p22...md)

   Three controls, fixed bottom right, last child of <body> on every page
   (HO-174-E: forms cannot nest, and position:fixed makes source order
   irrelevant to rendering): a round icon-only trigger, an optional
   dismissible teaser card stacked above it, and the <details> disclosure the
   trigger opens. All three sit in one flex column, .ask, so the card and the
   trigger stay anchored to the same corner.

   Breakpoint kept at 40em/640px, matching nav.js and the footer's own
   collapse point, not tokens.css's literal "41em": that value is 656px, 15px
   past the 641px its own comment names, and nothing in Sketch's or Pixel's
   later rulings argues for moving off the site's one shared breakpoint.

   z-index:30 is below the skip-link's 100 (style.css BASE) and above the
   sticky header's 20, unchanged from WO-295.
   ============================================================================= */
:root{
  --bubble-trigger-size:56px;
  --bubble-panel-width:380px;
  --bubble-mark-size:28px;
  /* .ask-header's own real rendered height: content (the 44px close
     control, the tallest of the row's three items) plus its own top/bottom
     padding. 68px, matching BEACON-14-001's own measurement. Used below to
     keep the sticky header from painting over whatever it scrolls up to
     reveal beneath it -- see the T1 comment above .ask-panel. */
  --bubble-header-height:calc(2 * var(--space-3) + var(--space-8));
}
.ask{
  position:fixed;right:var(--space-gutter);bottom:var(--space-gutter);
  z-index:30;max-width:calc(100vw - 2 * var(--space-gutter));
  display:flex;flex-direction:column;align-items:flex-end;gap:var(--space-3);
  /* PAUL 2026-08-18: the container is a LAYOUT box, not a target.

     It is a fixed flex column sized to its widest child, so at 320 it spans
     nearly the viewport and, with z-index:30, it was swallowing clicks on the
     footer nav links underneath even after the teaser above went invisible.
     Measured: all three links at 320, "About" at 390, reported as going to
     "ask" rather than to the link.

     Only the real controls take pointer events; the column between and around
     them lets clicks through to the page. */
  pointer-events:none;
}
.ask-teaser,.ask-details{pointer-events:auto}
.ask[data-ask-yield] .ask-teaser{pointer-events:none}
.ask-details{position:relative;display:block}

/* ---- Trigger. Round, icon-only (Sketch 14.1a, BENDS). The glyph is
   aria-hidden and decorative; the accessible name comes from the visually
   hidden span beside it in the markup, never from the glyph or a
   description of it. Reuses .btn-primary's own colour states rather than
   inventing new ones, so this control ages with every other brand button on
   the site if those tokens ever move. 56px sits between the 44px a11y floor
   and the 64px scale step -- a literal value tied to the reference match,
   stated as such rather than dressed up as a token that "just happened" to
   fit (message-bubble-visual.md T2). */
.ask-toggle{
  list-style:none;
  width:var(--bubble-trigger-size);height:var(--bubble-trigger-size);
  padding:14px;box-sizing:border-box;
  /* leaves a 28px content box = --icon-size exactly, so the glyph renders at
     the sitewide-standard 28px/1.75-stroke calibration with no recompute. */
  border-radius:var(--radius-full);
  background:var(--color-brand-primary);color:var(--color-on-primary);
  border:none;cursor:pointer;
  display:flex;align-items:center;justify-content:center;
  box-shadow:var(--shadow-lg);
  transition:background var(--motion-fast);
}
.ask-toggle::-webkit-details-marker{display:none}
.ask-toggle::marker{content:""}
.ask-toggle .ic{color:var(--color-on-primary)}
.ask-toggle:hover{background:var(--color-brand-primary-hover)}
.ask-toggle:active{background:var(--color-brand-primary-active);transform:translateY(1px)}
.ask-toggle:focus-visible{
  outline:var(--border-thick) solid var(--color-border-focus);
  outline-offset:var(--space-1);box-shadow:0 0 0 4px var(--color-focus-halo),var(--shadow-lg);
}
@media(prefers-reduced-motion:reduce){.ask-toggle{transition:none}}
/* White glyph on brand-primary: 6.11:1 (contrast.md, .btn-primary's own
   pairing, reused verbatim). */

/* ---- Teaser card (Sketch 14.2, BENDS on three conditions). Present on
   load or not at all (2.3, unchanged): no timer, no scroll depth, no exit
   intent. Its body is a real <a href="#ask-title">, not a <button> with a
   script-only handler -- see the HTML comment above the markup for why:
   navigating to a fragment target inside a closed <details> natively opens
   it, so "press the card, get the panel" (C1) costs nothing extra and works
   with scripting off (C3). js/ask.js intercepts the same click to open
   through the enhanced path instead, and builds the dismiss control -- it
   is never written into this markup, so a visible control that cannot work
   without script is never shipped (C3). Hides completely once the panel is
   open (14.4), by :has(), no script required for that half. */
.ask-teaser{
  /* PAUL 2026-08-19: 260 -> 300. At 260 the card was too narrow for its own
     copy, so the dismiss X in the top right corner sat on top of the first
     line of text. The card is sized to its longest line ("Hello, have a
     question?") plus the gutter the X needs, rather than the X being made to
     fit a width that was picked before the copy existed.
     The parent .ask already caps at calc(100vw - 2 * --space-gutter), so this
     never overflows a narrow screen; it just wraps there instead. */
  position:relative;max-width:300px;
  background:var(--color-surface-raised);
  border-radius:var(--radius-md);
  box-shadow:var(--shadow-lg);
  padding:var(--space-4);
}
.ask-teaser::after{
  content:"";position:absolute;bottom:-8px;right:28px;
  width:16px;height:16px;background:var(--color-surface-raised);
  transform:rotate(45deg);
  box-shadow:4px 4px 6px -2px rgba(27,43,38,.12);
  clip-path:polygon(100% 0,0 100%,100% 100%);
}
.ask:has(.ask-details[open]) .ask-teaser{display:none}
/* PAUL 2026-08-18: the teaser yields to the footer.

   This is what actually keeps the widget off the footer's links, and it
   replaces the footer padding-bottom that used to be doing the job badly.
   Measured before this rule: at 320 the teaser covered ALL THREE footer nav
   links and ate their clicks, and it did so under the OLD generous padding
   too, so that padding was never a fix. At 390 it covered "About".

   Padding could not have solved it. The teaser is 260px wide and pinned
   right, so at 320 it spans nearly the full width: clearing it would mean
   reserving its whole height under every page forever, which is the empty
   ground Paul reported twice.

   pointer-events:none is the part that matters; opacity and visibility are
   what make it honest, so nothing invisible is left holding a click. */
.ask[data-ask-yield] .ask-teaser{
  opacity:0;visibility:hidden;pointer-events:none;transform:translateY(4px);
}
.ask-teaser{transition:opacity .18s ease,transform .18s ease,visibility .18s}
@media (prefers-reduced-motion:reduce){
  .ask-teaser{transition:none}
  .ask[data-ask-yield] .ask-teaser{transform:none}
}
.ask-teaser-body{
  display:flex;gap:var(--space-3);align-items:flex-start;
  color:inherit;text-decoration:none;
}
/* PAUL 2026-08-20: THE TEASER IS ONE OBJECT.

   Three things he asked for, and they are one idea: clicking anywhere on the
   card opens the panel, the words do not look like a link, and nothing in it
   can be selected. Only the X behaves differently, and it already did.

   THE WHOLE CARD IS THE TARGET, via the stretched-link pattern this file
   already uses for the "How I Can Help" cards (.card-link). The anchor stays a
   real <a href="#ask-title">, so with scripting off it still opens the panel
   natively, and its ::after simply covers the card so the padding, the avatar
   and the empty space are all part of the same click. Nothing is nested inside
   it, so no control is swallowed.

   THE X STAYS ON TOP. It is later in source order but the stretched ::after
   would otherwise cover it, so it takes a z-index and the ::after does not.
   That is the one place the two overlap and the X has to win, because dismiss
   and open are opposite intentions.

   THE UNDERLINE ON HOVER IS GONE. It was the only thing making that sentence
   read as a hyperlink. The card still has to answer "is this clickable", so the
   affordance moves to the whole object: it lifts a little and its shadow
   deepens, which is the same language .card already speaks on this site. */
.ask-teaser{cursor:pointer;user-select:none;-webkit-user-select:none}
.ask-teaser-body::after{
  content:"";position:absolute;inset:0;border-radius:inherit;
}
.ask-teaser-close{z-index:1}
.ask-teaser{transition:transform var(--motion-base),box-shadow var(--motion-base)}
.ask-teaser:hover{transform:translateY(-2px);box-shadow:var(--shadow-lg),0 10px 24px -12px rgba(27,43,38,.35)}
@media(prefers-reduced-motion:reduce){
  .ask-teaser{transition:none}
  .ask-teaser:hover{transform:none}
}

/* NOTHING IN THE PANEL IS SELECTABLE EXCEPT WHAT SHE TYPES. Paul's rule, and
   the reason is that this is a control, not a document: dragging across "Reply
   by" or the 988 line selects a chunk of interface and looks broken. The two
   fields opt back in, because a visitor absolutely must be able to select,
   correct and copy her own words.

   user-select does not affect CLICKING, so the privacy link, both reply-by
   buttons, Send and the X all keep working exactly as before. Verified by
   clicking them, not assumed.

   WORTH KNOWING, AND FLAGGED TO PAUL RATHER THAN BURIED: this makes the 988
   crisis number uncopyable. It is three digits in a sentence that also says
   what it is, so the cost is small, but it is a real cost on the one line of
   this site that exists for someone in trouble. Reversing it for that one
   paragraph is a single selector if he wants it. */
.ask-panel{user-select:none;-webkit-user-select:none}
.ask-panel input,.ask-panel textarea{user-select:text;-webkit-user-select:text}
.ask-teaser-body:focus-visible{
  outline:var(--border-thick) solid var(--color-border-focus);outline-offset:var(--space-1);
  border-radius:var(--radius-sm);
}
.ask-teaser-text{
  font-size:var(--text-sm);line-height:1.4;color:var(--color-text-primary);
  /* PAUL 2026-08-19: the gutter the dismiss X lives in. 28px clears the drawn
     control (28px circle sitting 8px in from the card's right edge, see
     .ask-teaser-close below) AND its 44px hit box, whose left edge lands at
     card-right minus 44, exactly where this padding starts the text. So the
     X can neither cover a word nor steal a click meant for one.
     On both lines, not only the first: an even gutter reads as a margin,
     while clearing only the top line reads as text that ran into something. */
  padding-right:28px;
}
/* Dismiss X. Built by js/ask.js only, matching the header close control's
   own reasoning (never written into this markup -- C3).
   PAUL 2026-08-19, two faults, one cause. The button WAS the drawn control:
   44x44, border-radius 50%, its own hover background, sitting at top/right
   -4px. So the hover circle was 44px wide and hung 4px OUTSIDE the card on
   two sides, and the drawn X inside it was 24px, larger than it needed to be.
   Split the two jobs apart instead of shrinking one number:
     - the BUTTON is the hit area, 44x44 and invisible, and it moves inside
       the card (top/right 0) so nothing about it can extend past the corner;
     - the .ic is the drawn control, 28px, and it is what takes the hover
       background and the focus ring.
   28px centred in 44px leaves 8px to the card's edges, and the focus ring
   (2px, offset 2) reaches 4px, so every painted state stays inside the card.
   The 44px target itself is untouched: it is this project's floor
   (--space-8), above WCAG 2.2's own minimum, and shrinking it to make the
   circle smaller would have paid for a visual fix with an access defect. */
.ask-teaser-close{
  position:absolute;top:0;right:0;width:44px;height:44px;
  padding:0;box-sizing:border-box;
  border:none;background:transparent;color:var(--color-text-secondary);
  display:flex;align-items:center;justify-content:center;cursor:pointer;
}
.ask-teaser-close .ic{
  --icon-size:16px;
  width:28px;height:28px;border-radius:50%;
  display:flex;align-items:center;justify-content:center;
  color:var(--color-text-secondary);background:transparent;
  transition:background var(--motion-fast),color var(--motion-fast);
}
.ask-teaser-close:hover .ic{background:var(--color-surface-page);color:var(--color-text-primary)}
.ask-teaser-close:focus-visible{outline:none}
.ask-teaser-close:focus-visible .ic{
  outline:var(--border-thick) solid var(--color-border-focus);outline-offset:2px;
  color:var(--color-text-primary);
}
@media (prefers-reduced-motion:reduce){.ask-teaser-close .ic{transition:none}}

/* ---- Shared 28px "mark" badge: the trigger's own glyph, reused inside the
   teaser card, the header and the expectation line, so the meaning stays
   one thing everywhere it appears -- send, never converse (Sketch 14.1b).
   Content box after padding is 16px; stroke stays the sitewide 1.75,
   deliberately unscaled (message-bubble-visual.md T4): it resolves to
   1.17px ink, thinner than the 28px set's 2.04px baseline and never
   heavier, so the only failure mode available here is "too faint," never
   "reads as solid" (checked by render, not only by arithmetic). */
.ask-mark{
  flex:none;width:var(--bubble-mark-size);height:var(--bubble-mark-size);
  border-radius:50%;display:flex;align-items:center;justify-content:center;
  padding:6px;box-sizing:border-box;--icon-size:16px;
}
.ask-teaser .ask-mark{
  background:var(--color-brand-primary);color:var(--color-on-primary);
}
/* ---- WO-343/WT-P18-G T2. Header mark is Mandy's photo, not the chat
   glyph (teaser and toggle keep the glyph, T1). Spec:
   .webteam/04-ui/tokens.css's WO-338 block; approval and provenance:
   assets-source/README.md, mandy-spanier-portrait-source.png entry
   ("SCOPE: THE WHOLE SITE. Confirmed by Paul 2026-08-18") and the
   message-bubble-trigger-avatar derivative entry beneath it. Selector
   matches this file's own ancestor-scoping convention rather than a
   second class, same as every other .ask-mark variant above. */
.ask-header .ask-mark{
  padding:0; /* no icon padding on a photo; the crop already fills the box */
  border:var(--border-md) solid var(--color-on-primary);
  box-sizing:border-box;object-fit:cover;display:block;
  background:rgba(255,255,255,.18); /* decode-wait fill, matches the icon
    slot's own resting colour so there is no bare-white flash before the
    28px file (958 bytes) decodes */
}
/* border-radius:50% is already set by the shared .ask-mark base rule
   above; not repeated here. White 1.5px ring against the green header
   field, carried over unchanged from the icon version so the header does
   not gain a second visual language for one slot. */

/* ---- Open panel. Header/body split at every width, not only >=641px:
   Pixel's own renders show the ribbon at 320 and 390 too (T5), so the panel
   carries no padding of its own at any size; the header bleeds edge to
   edge and .ask-body owns the inner padding. */
.ask-panel{
  position:fixed;inset:0;z-index:31;
  background:var(--color-surface-raised);
  box-shadow:var(--shadow-lg);
  padding:0;
  overflow-y:auto;-webkit-overflow-scrolling:touch;
  display:flex;flex-direction:column;
}
/* ---- T1/BEACON-14-001, Critical. The 988 line sits directly above the
   message field, and .ask-header's position:sticky pins it over whatever
   THIS scroll container scrolls up to reveal beneath it -- the identical
   problem the outer site header already has a fix for
   (html{scroll-padding-top:78px} / :target,[id]{scroll-margin-top:78px},
   near the top of this file), one scroll container down, with THIS
   header's own height, not the outer one's, which the outer rule's 78px
   was never sized for.

   Measured, not guessed (review/wo-332/): the native "bring the focused
   control into view" scroll -- what a mobile keyboard triggers, and what
   Playwright's own actionability check performs identically, confirmed by
   render -- only expands the FOCUSED element's own rect by ITS
   scroll-margin. Content sitting above it, like the 988 line above the
   textarea it describes, only clears the header as a side effect of how
   much room the focused control itself demands. That is why the number
   doing the real work below lives on #ask-message, not on the hint: header
   height (68px, --bubble-header-height) + the hint's own rendered height
   at the narrowest supported width, 320, where it wraps to its most lines
   (measured 86.44px) + this file's own 12px clearance convention (the
   outer header rule's comment, above) = 166.44px, rounded up to 168px.
   scroll-padding-top on the panel itself is the belt, not the buckle: a
   flat header-height clearance for every OTHER focusable control in this
   form, should keyboard focus ever land there instead while the keyboard
   is up, using the same property the outer document already uses for the
   same reason.

   Costs no layout height, BEACON-14-001's own constraint: both properties
   only shape where the browser's OWN auto-scroll lands, never any
   element's box. Confirmed by render at 320x308 and 320x268 with text
   typed into the message field: .ask-panel's own height equals the
   viewport's, unchanged, before and after (review/wo-332/).

   WO-335 follow-up, still true but no longer the whole story below 40em:
   scroll-padding/scroll-margin are consulted only by the browser's OWN
   focus-into-view scroll. A manual drag/wheel/momentum scroll never
   triggers them, so a visitor scrolling by hand -- rereading what she
   typed, message field still focused -- can park .ask-panel at ANY
   scrollTop, including one the two rules above were never asked about.
   Beacon proved this by moving scrollTop directly, no new focus event
   (review/wo-334/): the 988 line's entire first line vanished behind the
   header, worse than this rule's own original partial-cover finding. See
   the comment on .ask-header's media queries below for the fix -- these
   two declarations stay, they are still the correct, cheap belt for the
   focus-into-view case and for >=40em, where the header is still sticky
   and this is still the only mechanism guarding it. */
.ask-panel{scroll-padding-top:var(--bubble-header-height)}
#ask-message{scroll-margin-top:168px}
@media(min-width:40em){
  .ask-panel{
    position:absolute;inset:auto;top:auto;left:auto;
    right:0;bottom:calc(100% + var(--space-3));
    width:var(--bubble-panel-width);
    max-width:calc(100vw - 2 * var(--space-gutter));
    max-height:calc(100vh - 2 * var(--space-gutter));
    border-radius:var(--radius-lg);
  }
}
.ask-header{
  flex:none;
  background:var(--color-brand-primary);color:var(--color-on-primary);
  min-height:64px;display:flex;align-items:center;gap:var(--space-3);
  padding:var(--space-3) var(--space-4);
  border-radius:var(--radius-lg) var(--radius-lg) 0 0;
}
/* ---- T2/BEACON-14-001 follow-up, WO-335. position:sticky is what lets an
   opaque header sit ON TOP of content .ask-panel scrolls beneath it, by
   construction, for ANY reason the scroll happened -- native focus-into-
   view (T1 above, still fine) or a visitor's own manual drag/wheel/
   momentum scroll (not fine: no scroll-alignment CSS is ever consulted by
   that second kind, so it can park the header directly over the 988 line
   at any scrollTop). No amount of scroll-padding/scroll-margin closes that
   because it is not a scroll-alignment problem, it is an overlap problem:
   two boxes painting the same pixels.

   Considered and rejected: a scroll listener clamping .ask-panel's
   scrollTop away from the occluding range while #ask-message holds focus.
   Rejected because it is strictly more machinery for a narrower guarantee
   -- it would still need re-deriving whenever the hint's rendered height
   changes (it already varies by width, T1's own 86.44px-at-320 number),
   runs on every scroll event, and is one more place to be silently wrong.
   Unstickying is a structural fix: an in-flow header cannot overlap
   anything below it, at ANY scrollTop, because nothing is pinned. It is
   also the narrower loss: on screens this short (308/268px tall once a
   keyboard is up), the header was already spending a fifth of the
   viewport to stay pinned, for a persistent-close-button benefit nobody
   had measured against the cost of a Critical crisis-line occlusion.

   Kept sticky at >=40em, where the panel is never full-viewport (it is a
   small fixed-position dropdown near the trigger, T1's own media query
   above) and BEACON-14-001 was never measured there -- narrowing the
   change to the exact width this defect exists at, not the whole
   component. Escape (document-level keydown, unaffected by scroll
   position) and the trigger's own native <summary> toggle remain live
   regardless of where .ask-header has scrolled to; only the pointer route
   to the header's own close X requires scrolling back up first below
   40em now, the same trade every non-sticky mobile form makes. */
@media(max-width:39.98em){.ask-header{border-radius:0;position:static}}
@media(min-width:40em){.ask-header{position:sticky;top:0}}
.ask-header h2{
  flex:1;margin:0;font-size:var(--text-md);font-weight:600;
  text-align:center;padding-right:16px; /* rough optical balance against the
    44px close control outweighing the 28px mark on the other side */
  user-select:none; /* WO-343/WT-P18-G T5: a heading, not copyable body
    text; stops an accidental double-tap/drag selecting it instead of
    opening or reading the panel. */
}
/* Close control. Built by js/ask.js, not written into this markup -- same
   reasoning as the teaser dismiss: a close button that does nothing because
   this file never ran is worse than none, and the <summary> itself still
   closes the panel natively (HO-174-B). This IS the panel's real close
   mechanism (Pixel's design, HO-326 T3, confirmed against Sketch 14.4's
   note on the HO-326/HO-327 disagreement). 44x44: the project's own
   enhanced floor for a PRIMARY control. Pixel's literal 32px is not carried
   forward here -- 14.2's small-target exception was named for a secondary,
   decorative dismiss only, never for the panel's one real close affordance.
   18px drawn glyph inside the 44px box, matching Pixel's visual weight. */
.ask-close{
  flex:none;width:44px;height:44px;padding:13px;box-sizing:border-box;
  border:none;background:transparent;color:var(--color-on-primary);
  display:flex;align-items:center;justify-content:center;cursor:pointer;border-radius:50%;
}
.ask-close .ic{--icon-size:18px;color:var(--color-on-primary)}
.ask-close:hover{background:rgba(255,255,255,.15)}
.ask-close:focus-visible{outline:var(--border-thick) solid #fff;outline-offset:2px}

.ask-body{padding:var(--space-4)}
.ask-body .field{margin:0 0 var(--space-4)}

.ask-expect{display:flex;gap:var(--space-2);align-items:flex-start;margin-bottom:var(--space-5)}
.ask-expect p{
  margin:0;background:var(--color-surface-sunken);color:var(--color-text-primary);
  border-radius:var(--radius-md) var(--radius-md) var(--radius-md) 4px;
  padding:var(--space-3) var(--space-4);font-size:var(--text-sm);line-height:1.5;
} /* text-primary on surface-sunken: 12.31:1, computed. */

/* ---- Compact fields with a floating label (Sketch 14.3's replacement, not
   message-bubble-visual.md T3's placeholder-as-only-cue): a real,
   always-visible <label>, positioned to read exactly like a placeholder at
   rest -- so the reference's density survives -- and shrunk to sit above
   the field, never over the typed value, the instant the field is focused
   or filled. The label carries the field's real name (Wordsmith's
   hidden-label string, now visible); the native `placeholder` carries her
   second string (requiredness cues on email/phone, the reassurance on
   message) and only becomes visible once the label has moved out of the
   way, so the two never draw on top of each other. */
.field-compact{position:relative}
.field-compact input,.field-compact textarea{
  width:100%;border:var(--border-md) solid var(--color-border-strong);
  border-radius:var(--radius-md);
  background:var(--color-surface-raised);color:var(--color-text-primary);
  padding:var(--space-4) var(--space-4) var(--space-2);font:var(--text-base) var(--font-sans);
  min-height:var(--space-8);box-sizing:border-box;
} /* border-strong on white: 4.42:1, clears the 3:1 non-text floor. */
.field-compact textarea{min-height:6.5em;resize:vertical}
.field-compact input::placeholder,.field-compact textarea::placeholder{color:transparent}
.field-compact input:focus::placeholder,.field-compact textarea:focus::placeholder{
  color:var(--color-text-secondary);
} /* text-secondary already proven >=4.5:1 on every surface, contrast.md;
     shown only once the label has floated clear, so it never overlaps it. */
.field-compact input:focus-visible,.field-compact textarea:focus-visible{
  outline:none;border-color:var(--color-border-focus);
  box-shadow:0 0 0 3px rgba(27,43,38,.15);
}
.field-compact .field-label{
  position:absolute;left:var(--space-4);top:50%;transform:translateY(-50%);
  font-size:var(--text-base);line-height:1;font-weight:400;margin:0;
  color:var(--color-text-secondary);background:var(--color-surface-raised);
  padding:0 4px;pointer-events:none;
  transition:top var(--motion-fast),transform var(--motion-fast),color var(--motion-fast),font-weight var(--motion-fast);
}
.field-compact input:focus + .field-label,
.field-compact input:not(:placeholder-shown) + .field-label,
.field-compact textarea:focus + .field-label,
.field-compact textarea:not(:placeholder-shown) + .field-label{
  top:0;transform:translateY(-50%) scale(.82);
  color:var(--color-text-primary);font-weight:600;
} /* text-primary on surface-raised (white): 14.79:1 (contrast.md line 24,
     reused, not recomputed), well past the 4.5:1 Sketch 14.3 requires
     there. */
@media(prefers-reduced-motion:reduce){.field-compact .field-label{transition:none}}

/* WO-348/BEACON-15-001. Exactly one field-compact on the site has a
   .field-hint as its immediately preceding sibling: the message field,
   preceded by the 988 line (`.ask-body .field-hint`, margin-bottom:0 by
   design, above -- that zero is load-bearing, it is what groups the line
   with this box rather than with the visitor, and T5 requires it stay
   exactly as measured).

   The generic floated label above straddles the field's own top border
   (top:0, translateY(-50%) -- half the label's height lands above the
   border, half below). Every OTHER field-compact has nothing but a
   --space-6 margin up there, so the excursion lands on whitespace. This
   one does not: with zero gap, it lands squarely on the crisis line's own
   last row, an opaque background over live glyphs (measured, Beacon:
   1,576-1,766 ink pixels hidden at 320x308 and 390x844; the comma after
   "Lifeline" and the descender on "any" specifically).

   Fix is scoped to fields actually preceded by a hint, so it never touches
   name/phone/email's ordinary notch-on-the-border look, and it does not
   move the border (the flush gap stays byte-identical): the label simply
   never crosses above its own field's top edge, landing just inside it
   instead of straddling it. */
.field-hint + .field-compact input:focus + .field-label,
.field-hint + .field-compact input:not(:placeholder-shown) + .field-label,
.field-hint + .field-compact textarea:focus + .field-label,
.field-hint + .field-compact textarea:not(:placeholder-shown) + .field-label{
  top:2px;transform:scale(.82);
}

.ask-pref{border:none;padding:0}
.ask-pref legend{font-size:var(--text-xs);font-weight:600;color:var(--color-text-secondary);
  margin-bottom:var(--space-2);padding:0}
.ask-pref-options{display:flex;gap:var(--space-4)}

/* ---- WO-343/WT-P18-G T7. Reply-by becomes buttons, CSS only.
   03-ux/message-bubble/15-wo-339-reply-by-buttons-and-the-name-field-move.md
   15.1: the native <input type="radio"> pair stays byte-for-byte
   unchanged (id, name, value, required, <label for>) -- refused the ARIA
   role="radiogroup"/role="radio" route, because it means hand-rebuilding
   native keyboard roving focus and validation on the one control this
   project has broken twice (ADR-060, HO-295, QA-R33). Only this file
   changes. Markup diff: none. */

/* .ask-pref-option is the positioning context AND the box the input must
   exactly cover (15.1.1): once the input is taken out of flow (absolute,
   below), this element's rendered size collapses to its one remaining
   flow child, the label, so "cover exactly the same box as the label"
   falls out of the layout rather than needing a matched pair of literal
   values kept in sync by hand. */
.ask-pref-option{position:relative;display:inline-flex}

/* The visible surface. Centred content, >=44x44 CSS px (--space-8),
   Pixel's pill treatment reusing this file's own border/radius/motion
   tokens rather than inventing new values. */
.ask-pref-option label{
  display:inline-flex;align-items:center;justify-content:center;gap:var(--space-1);
  min-width:var(--space-8);min-height:var(--space-8);box-sizing:border-box;
  padding:var(--space-2) var(--space-5);
  font-weight:500;font-size:var(--text-sm);margin:0;line-height:1.2;
  border-radius:var(--radius-full);
  border:var(--border-md) solid var(--color-border-strong);
  background:var(--color-surface-raised);color:var(--color-text-primary);
  cursor:pointer;
  transition:background var(--motion-fast),border-color var(--motion-fast),color var(--motion-fast);
} /* text-primary on white, unselected: 14.79:1 (contrast.md, reused). */

/* The input becomes a full-size, invisible overlay -- opacity:0, never
   display:none or visibility:hidden, never sized smaller than the label
   (15.1.1). This is what keeps native keyboard roving focus, :focus-visible
   and the browser's own "Please select one of these options" validation
   bubble anchored to the visible button's own rendered box, not to a
   shrunk or offset proxy (HO-174-C's hazard, named again in 15.1.1).
   Overrides `.field input{...}` (BASE, the Bookings contact form's own
   rule handed to these radios by the fieldset's shared "field" class);
   higher specificity on purpose, narrowing nothing about Bookings. */
.ask-pref-option input[type="radio"]{
  position:absolute;inset:0;width:100%;height:100%;margin:0;
  opacity:0;cursor:pointer;appearance:none;-webkit-appearance:none;
  border:none;padding:0;background:none;accent-color:var(--color-brand-primary);
}

/* Selected: two independent cues (15.1.3), neither alone. Fill/border
   colour change, PLUS a checkmark that does not depend on colour vision.
   Generated content, so it adds nothing for a screen reader to announce
   on top of the input's own native checked state. */
.ask-pref-option input:checked + label{
  background:var(--color-brand-primary);border-color:var(--color-brand-primary);
  color:var(--color-on-primary);
} /* on-primary on brand-primary: 6.11:1 (contrast.md, .btn-primary's own
     pairing, reused verbatim). */
.ask-pref-option input:checked + label::before{
  content:"\2713";font-weight:800;
}

/* Focus: a separate, additional indicator on whichever option currently
   holds keyboard focus, visible whether or not that option is also
   selected (15.1.3). Same outline/halo pairing as every other focus-
   visible control in this component (.ask-toggle, above); :focus-visible
   only, never suppressed on mouse interaction, no outline:none anywhere
   in this rule set. */
.ask-pref-option input:focus-visible + label{
  outline:var(--border-thick) solid var(--color-border-focus);
  outline-offset:var(--space-1);
  box-shadow:0 0 0 4px var(--color-focus-halo);
}

/* Animation (15.1.4): driven by the transition above, tied to :checked
   itself rather than a click handler, so it fires identically for a
   pointer click, a tap, Space, or an arrow-key move between the two
   options -- ask.js never needs to know this exists. 150ms ease-out
   (--motion-fast) is exactly Pixel's named 150-200ms band. One-shot by
   construction: neither option starts checked (4.2, no default), so nothing
   transitions on page load, and nothing plays on hover because hover is
   never a selector this rule set reads. */
@media(prefers-reduced-motion:reduce){
  .ask-pref-option label{transition:none}
} /* 15.2: the state change itself does not disappear, only the motion --
     selected still reads selected and focus still reads focus by their
     static end-state styling alone, instantly rather than transitioned. */

.ask-counter:empty{display:none}
.ask-body .field-hint{margin:var(--space-2) 0 0;font-size:var(--text-sm);
  color:var(--color-text-secondary);line-height:1.5}
/* PAUL 2026-08-19. js/ask.js builds a hint under the phone field explaining
   that the number formats itself. It sits AFTER the field's box rather than
   inside it, because .field-compact is the positioning context for the
   floating label (top:50% of that box) and a paragraph inside makes the box
   taller, dropping the label out of the input and onto its bottom border --
   measured at 390 before it was moved.
   Outside the box, the two would read as separate rows, so the field's own
   bottom margin moves onto the hint: input, 8px, hint, 24px, next field. The
   group is unchanged, only its markup is. Both selectors deliberately outrank
   the .field / .ask-body .field-hint rules they correct. */
.ask-form [data-ask-field="phone"]{margin-bottom:0}
#ask-phone-hint{margin:var(--space-2) 0 var(--space-6)}
/* WO-343/WT-P18-G T10: the used/limit counter's over-limit state. A second,
   colour-independent cue (bold weight) travels with the colour change, same
   two-cue discipline as the reply-by buttons above. */
.ask-counter{color:var(--color-text-secondary)}
.ask-counter.is-over-limit{color:var(--color-status-danger-text);font-weight:600}

/* WO-348/BEACON-15-003: tint removed. It was spent on this line and on
   .ask-expect both, so the warmest sentence in the panel ("Nobody reads it
   as you type. She replies herself.") and the most administrative one
   (where the message goes, what counts as agreeing) read as the same kind
   of thing -- while the 988 line carries none at all. Beacon's suggestion,
   taken as written: untint consent rather than tint 988. .ask-expect keeps
   its tint; this is the only change. */
.ask-consent{
  color:var(--color-text-secondary);
  font-size:var(--text-2xs);line-height:1.5;margin:var(--space-2) 0 var(--space-4);
} /* text-secondary on surface-raised (white): >=4.5:1 on every surface this
     site uses, already established at line 3851, reused not recomputed. */
.ask-consent a{color:inherit;text-decoration:underline}

/* Send button reuses .btn.btn-primary exactly; only new rule is the icon. */
.ask-form .btn .ic{--icon-size:18px;color:var(--color-on-primary)}
/* PAUL 2026-08-19: "grey out Send until something is typed in the message
   box." The grey itself is already in this file -- .btn-primary[aria-disabled]
   near the top of BUTTONS -- and js/ask.js sets and clears that attribute, so
   the ONLY thing missing was the send arrow, which the rule directly above
   pins to on-primary white and would have left as a white glyph floating on
   the grey. It follows the label instead.

   js/ask.js uses aria-disabled rather than the `disabled` attribute on
   purpose; the reason is written out beside the code that sets it. What
   matters here is that the button stays focusable, so it needs a real focus
   ring in this state, not the "nothing to see" treatment a truly dead control
   would get. text-muted on surface-sunken is a pairing this file already uses
   for both disabled button variants; it is not a new colour decision. */
.ask-form .btn[aria-disabled="true"] .ic{color:var(--color-text-muted)}
/* Rule P4 (7-placement.md 7.2): every page gains bottom clearance under the
   footer equal to the WIDGET's own footprint (control height + edge offset
   + one more gutter), so nothing fixed in this corner ever sits over the
   footer address or "Get directions" link at full scroll on any page,
   including a page short enough that the footer is otherwise the last
   thing in the viewport. WO-329 re-measures this against the WORST case,
   not just the trigger: the teaser card is visible by default (until
   dismissed or the panel has opened once), and it carries two real
   interactive controls (the "open the panel" link, the dismiss X), so an
   uncleared overlap with a footer link would not just look wrong, it would
   steal the click (z-index:30 sits above the footer). Measured against the
   BUILT stack, not guessed: .ask (card + gap + trigger) renders 140.31px
   tall at every width tested (review/wo-329/*.png, 320/390/768/1440), so
   --bubble-stack-clearance below adds Rule P4's own "one more gutter" on
   top of that and on top of the edge offset the widget already carries via
   its own `bottom`. Additive to the footer's own existing padding (BASE,
   footer.site), never a replacement for it. Once the card is dismissed the
   real footprint shrinks to the 56px trigger alone, so this is a
   deliberately generous floor, not a tight fit. */
/* PAUL 2026-08-18: this was reserving room for the trigger AND the teaser card,
   permanently, on every page. Measured result: 216px of empty ground at 390 and
   248px at 1280, below the footer's own content, which Paul reported.

   The teaser is dismissible and session-scoped: it disappears on the first ×
   and does not come back that visit. Reserving its full height forever, so a
   card that may never be shown again cannot overlap a footer, is the wrong
   trade. A floating dismissible card overlapping a footer is what floating
   dismissible cards do.

   So the clearance is now the TRIGGER ALONE plus one gutter: 56px measured, not
   assumed, plus its own 22px bottom offset, plus a gutter of breathing room. If
   the teaser is showing it may cover the top of the footer, which is
   acceptable and reversible by the visitor in one tap. */
/* PAUL 2026-08-18, second pass. Still too much: 164px of empty ground under the
   copyright line at 1280.

   The reason it can be small is positional, not generous. The trigger is 56px
   pinned to the bottom RIGHT; the footer's own content, address, nav and
   copyright, is left-aligned and does not reach that corner at any width this
   site is tested at. So the clearance only has to stop the trigger sitting ON
   a line of text, not clear the whole widget stack.

   One gutter. Verified at 320 through 1920 that nothing in the footer sits
   under the trigger's footprint. */
/* PAUL 2026-08-18, third and final pass: the clearance is now ZERO, and the
   override is gone. The two passes above kept shrinking a reservation that
   should never have existed, instead of asking whether it protected anything.

   It does not. Measured at 320 through 1920 with the page scrolled to its true
   end: the trigger occupies the bottom RIGHT corner, and every piece of footer
   content, address, nav, copyright, is left-aligned and ends by x=237 at 1280
   and x=145 at 390. The trigger's own footprint starts at x=1190 and x=313.
   They do not share horizontal space at any tested width, so no amount of
   footer padding-bottom was ever what kept them apart. The widget's own
   `bottom` offset is what holds it off the edge, and that is unchanged.

   Left as a defined token at 0 rather than deleted: it is the anchor for this
   reasoning, and a future widget that is not corner-pinned would need it back. */
:root{ --bubble-stack-clearance: 0px; }

/* -----------------------------------------------------------------------
   The mandatory CSS floor (7-placement.md 7.5, WO-291/HO-291). Two named
   collision bands where the fixed trigger would otherwise sit on top of a
   real .btn at landing scroll, before any interaction. Static @media only:
   no script, no scroll listener, nothing moves. This is a safety net, not
   the fix; the real remedy is bottom clearance on index.html's .hero-cta
   and blog.html's notice-card .actions row, and it is Paul's/Sketch's to
   schedule (7.5's own words: "not mine to build").

   page-index / page-blog: the two body classes this rule needs did not
   exist before this work order (only page-bookings did, WO-217/HO-216);
   added to index.html and blog.html's <body> tags, matching that precedent,
   because a page-scoped static floor needs a page-scoped selector and no
   other hook exists (HO-174-E: no page carries a body class besides
   Bookings' own).
   ----------------------------------------------------------------------- */
@media(min-width:320.02px) and (max-width:1023.98px) and (min-height:560px) and (max-height:710px){
  .page-index .ask{display:none}
}
@media(min-width:320.02px) and (max-width:1023.98px) and (min-height:480px) and (max-height:610px){
  .page-blog .ask{display:none}
}

/* -----------------------------------------------------------------------
   WO-302 (WT-P15-Q) / AUDIT-BEACON-13-006. Unconditional, not a band: the
   fixed trigger is suppressed on Bookings at every width and height, not
   only where it collides with a button on landing scroll.

   Rule used, stated plainly because this is a suppression, not a placement
   decision (placement stays Sketch's, being tasked separately): a visitor
   on this page is already looking at a contact form. A second, floating
   way to send a message, sitting on top of the first, adds nothing and
   currently breaks the primary path -- it overlaps the submit button at
   390 and 320, and at 320 it also covers part of the consent line's
   "privacy notice" link (Beacon's render). Bookings already carries its
   own message bubble, .page-bookings existed before this WO (WO-217/
   HO-216), so no new body class or markup change was needed.

   This does not touch .page-index or .page-blog above, and does not
   change where the trigger sits on any page that keeps it.
   ----------------------------------------------------------------------- */
.page-bookings .ask{display:none}

/* MANDY'S AVATAR in the panel header and the teaser card.
   PAUL 2026-08-18: the previous asset was declared at 28x28 and cropped from the
   full portrait, so it was small, soft and off-centre, and it carried a 1px white
   ring that read as an artifact rather than a frame. All four are fixed here.

   56px display, with 2x and 3x sources from a 660px square crop centred on her
   face, so it is at or above native density on every screen this site is tested
   at. Same size the reference widget uses.

   No border. The circle sits on the green header band and on the white teaser
   card, and on both it reads cleanly without a ring; the earlier 1px white line
   was doing nothing on green and showing as a seam on white. */
.ask-avatar{
  width:56px;height:56px;flex:none;
  border-radius:50%;
  object-fit:cover;
  /* The crop is already face-centred, so 50% 50% is correct and object-position
     is stated rather than left to default so a future crop change is a visible
     decision instead of a silent shift. */
  object-position:50% 50%;
  border:0;
  display:block;
}
.ask-teaser-body .ask-avatar{width:44px;height:44px}

/* ---------------------------------------------------------------------------
   PAUL 2026-08-18. Reply-by buttons and Send button: full width, evenly split.
   Plus the no-JS floor for the conditional fields.
   --------------------------------------------------------------------------- */

/* THE NO-JS FLOOR, and it has to come first.
   ask.js hides both contact fields until a reply method is chosen. Without the
   script nothing can ever reveal them, so this rule keeps them visible until
   the script announces itself by stamping data-ask-ready on the form. A
   visitor with JavaScript off sees both fields and a working form; a visitor
   with it on sees neither until they choose. */
.ask-form:not([data-ask-ready]) [data-ask-field]{display:block}

/* Two equal buttons filling the row, rather than two pills hugging their text.
   1fr each with the same gap the form already uses, so they line up with the
   inputs above and below them instead of floating at the left edge. */
.ask-pref-options{display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)}
.ask-pref-option{display:block;width:100%}
.ask-pref-option label{
  display:flex;align-items:center;justify-content:center;
  width:100%;min-height:44px;text-align:center;
  /* The transition is the "interact visually" part. Transform and colour
     only: both are compositor-friendly, so the press reads as immediate.
     Honoured through prefers-reduced-motion below. */
  transition:background-color .16s ease, border-color .16s ease,
             color .16s ease, transform .12s ease}
.ask-pref-option label:active{transform:scale(.97)}
.ask-pref-option input:checked + label{transform:none}

/* Send button matches the pair above it: same full width, same alignment. */
.ask-form button[type="submit"]{
  width:100%;justify-content:center;
  transition:background-color .16s ease, transform .12s ease}
.ask-form button[type="submit"]:active{transform:scale(.99)}

@media (prefers-reduced-motion:reduce){
  .ask-pref-option label,
  .ask-form button[type="submit"]{transition:none}
  .ask-pref-option label:active,
  .ask-form button[type="submit"]:active{transform:none}
}

/* The address is a link now (PAUL 2026-08-18). Without display:block the <a>
   stays inline and its two <div> children lay out beside each other, which
   rendered "5319 S Lewis Ave, Suite 200" and "Tulsa, OK 74105" as two columns.
   The address is one address on two lines, and it must read that way. */
.nap .nap-address{display:block;color:inherit}
.nap .nap-address > div{display:block}

/* ---------------------------------------------------------------------------
   "WHAT COULD BE" PARALLAX BACKGROUND. PAUL 2026-08-18.
   --------------------------------------------------------------------------- */

/* The image is hills receding into sunrise haze: an opening-up picture behind
   the one section on this site that is about what opens up. It is not a third
   wheat field, deliberately, but it is the same hour and the same palette, so
   it belongs to the hero's world without repeating it.

   PARALLAX, AND THE HONEST FALLBACK. background-attachment:fixed is what
   produces the effect, and Apple disabled it on iOS Safari years ago, where it
   silently degrades to a stretched, badly-cropped still. Rather than let that
   happen, the fallback is declared: any device without hover (touch), and
   anyone who has asked for reduced motion, gets `scroll` instead. That is a
   normal, well-composed background image, not a broken parallax.

   THE SCRIM IS NOT DECORATION. Her five lines are dark ink. Painted straight
   onto the photograph the darker hills would eat them. The oat wash below is
   the page's own ground colour at high alpha, so the section still reads as
   the oat band it was, with the photograph showing through it rather than
   replacing it. Measured after building, not assumed. */
.narrative-imagine{
  background-color:var(--color-surface-sunken);
  /* THE SCRIM IS HORIZONTAL, NOT FLAT, AND THAT IS THE WHOLE FIX.
     The first version washed the band evenly at 82 to 90 percent. Measured, a
     pixel row across it ran 240 to 229: about 4 percent of tonal range, so the
     photograph was invisible and the section was just oat with a rumour in it.

     Her text occupies the left 56 percent of the band; the right 44 percent is
     empty. So the wash is heavy only where words are and releases across the
     empty half, which is where the hills and the lit grass now read. Nothing
     is traded: the text side keeps the cover it needs and the picture becomes
     visible in the space that was doing nothing. */
  background-image:
    linear-gradient(to right,
      rgba(238,234,224,.93) 0%,
      rgba(238,234,224,.90) 46%,
      rgba(238,234,224,.62) 68%,
      rgba(238,234,224,.30) 88%,
      rgba(238,234,224,.22) 100%),
    image-set(
      url("/images/imagine-bg-1920.webp") type("image/webp") 1x,
      url("/images/imagine-bg-3840.webp") type("image/webp") 2x,
      url("/images/imagine-bg-1920.jpg") 1x,
      url("/images/imagine-bg-3840.jpg") 2x);
  background-size:cover;
  background-position:center 40%;
  background-repeat:no-repeat;
  background-attachment:fixed;
}
@media (hover:none),(prefers-reduced-motion:reduce){
  .narrative-imagine{background-attachment:scroll}
}
/* Narrow screens take the smaller pair: a 3840 file on a 390px phone is 189 KB
   of detail nobody can see. */
@media (max-width:820px){
  .narrative-imagine{
    background-image:
      /* Below 820px her lines run the full width, so there is no empty half to
         release and the wash goes back to even. Slightly lighter than the old
         flat value because the new crop carries more contrast of its own. */
      linear-gradient(to bottom,
        rgba(238,234,224,.88) 0%,
        rgba(238,234,224,.80) 45%,
        rgba(238,234,224,.88) 100%),
      image-set(
        url("/images/imagine-bg-1280.webp") type("image/webp") 1x,
        url("/images/imagine-bg-2560.webp") type("image/webp") 2x,
        url("/images/imagine-bg-1280.jpg") 1x,
        url("/images/imagine-bg-2560.jpg") 2x);
  }
}

/* ---------------------------------------------------------------------------
   HOME, "Ready for Your Next Chapter?". PAUL 2026-08-20.
   --------------------------------------------------------------------------- */

/* The third and last fixed-background band on this site, and the brightest on
   purpose. The hero is the season she is in, "What could be" is what opens up,
   and this is the payoff: clover in full bloom, backlit, under open sky, at the
   moment the page asks for the call.

   THE WASH IS DOING MORE WORK HERE THAN ANYWHERE ELSE ON THE SITE, and the
   reason is in the photograph rather than in the design. Measured across full
   rows of the original, mean luminance runs from 238 at the sun down to 33 in
   the shaded foreground. That is nearly the whole tonal range in one frame, and
   this section's text is DARK ink, not white like the hero's.

   Worse, there is no safe region to aim the text at. background-attachment:fixed
   paints the picture against the VIEWPORT, so this band shows whichever
   horizontal slice sits behind it at the current scroll position, and scrolling
   sweeps that slice across the entire image. Every row of the crop ends up
   behind her words at some point. So the wash cannot be a gradient tuned to
   where the text happens to sit; it has to hold for the darkest row.

   Hence a flat, high-alpha oat wash rather than the horizontal release
   .narrative-imagine uses. Same ink, same intent, different problem: that band
   has an empty right half to let the picture through, and this one has text
   across its full width with a photograph that goes nearly black at the bottom.
   The alpha is measured, not chosen; see the figure recorded beside it below.

   Same honest fallback as the other two: Apple disabled
   background-attachment:fixed on iOS Safari, where it degrades to a stretched,
   badly cropped still, so touch devices and anyone who has asked for reduced
   motion get `scroll` instead, which is an ordinary well-composed background. */
.next-chapter{
  background-color:var(--color-surface-page);
  background-image:
    linear-gradient(rgba(238,234,224,.88), rgba(238,234,224,.88)),
    image-set(
      url("/images/next-chapter-bg-1920.webp") type("image/webp") 1x,
      url("/images/next-chapter-bg-3840.webp") type("image/webp") 2x,
      url("/images/next-chapter-bg-1920.jpg") 1x,
      url("/images/next-chapter-bg-3840.jpg") 2x);
  background-size:cover;
  background-position:center;
  background-repeat:no-repeat;
  background-attachment:fixed;
}
/* Narrow screens take the smaller pair: a 3840 file on a 390px phone is a third
   of a megabyte of detail nobody can see. Same reasoning as
   .narrative-imagine's own 820px branch. */
@media (max-width:820px){
  .next-chapter{
    background-image:
      linear-gradient(rgba(238,234,224,.88), rgba(238,234,224,.88)),
      image-set(
        url("/images/next-chapter-bg-1280.webp") type("image/webp") 1x,
        url("/images/next-chapter-bg-2560.webp") type("image/webp") 2x,
        url("/images/next-chapter-bg-1280.jpg") 1x,
        url("/images/next-chapter-bg-2560.jpg") 2x);
  }
}
@media (hover:none),(prefers-reduced-motion:reduce){
  .next-chapter{background-attachment:scroll}
}

/* THE REASSURANCE LINE MOVES FROM SECONDARY TO PRIMARY INK, IN THIS BAND ONLY,
   AND IT IS A CONTRAST FIX RATHER THAN A DESIGN CHANGE.

   Measured over the photograph, sweeping the band through the viewport at seven
   widths and sampling every text row against the composited pixels: this
   paragraph came out at 3.66:1 against a 4.5:1 floor, at every width. Nothing
   else in the band failed.

   WHY THE WASH CANNOT FIX IT. Solve the blend for the worst case. The wash is
   oat 238 at alpha a over a photo pixel P, and the darkest pixel that lands
   behind this line is essentially black: 210 = 238(0.88) + P(0.12) gives P = 5,
   which is a shaded clover stem in silhouette. Reaching 4.5:1 against
   --color-text-secondary needs a composited background near 229, and
   229 = 238a + 5(1 - a) solves to a = 0.96. At 96 percent the photograph is a
   rumour. Paul asked for this band to be bright and full of life, so spending
   the picture to save a grey is the wrong trade.

   Cropping the dark foreground out does not fix it either, and that was checked
   before this was written: even the bright upper rows carry silhouetted stems
   and flower heads, so the MINIMUM pixel behind the text stays low wherever the
   crop lands.

   So the ink changes instead. Primary on that same worst-case background
   measures about 9:1, which is not marginal. The hierarchy this paragraph sits
   in is unaffected: the promoted ask below it is 28px serif and this is 17.6px
   sans, so the two are still told apart by size and family, which is what
   .section-head .cta-line's own comment says the difference is carried by. Only
   the colour step is spent, and only where a photograph made it unaffordable.

   Scoped to this band. Every other .section-head on the site keeps secondary. */
.next-chapter .section-head > p{color:var(--color-text-primary)}


/* =============================================================================
   PAUL 2026-08-20. Four separate asks, gathered here at the end of the file so
   the diff is readable, each one commented with what it is for.
   ============================================================================= */

/* ---- 1. THE "Maybe" LINES, SHOWN AS A SET RATHER THAN AS MORE PARAGRAPHS.

   These four sentences are the ones a visitor is meant to recognise herself in.
   As plain beats they read at exactly the same weight as the two lines above
   them, so the most important moment on the page was also its flattest.

   They become tiles: a quiet raised surface on the band's own ground, a brand
   rule down the left, and a two-up grid so they read as a set of situations
   rather than a list. The fourth draws the other three together, so it spans
   the full width and is marked as the closing thought.

   The emphasis is carried entirely by the tile, never by re-typesetting her
   words. See the markup comment for why: the voice guard treats an inline span
   inside her sentence as a separate piece of author text, and fragmenting her
   copy to win a typographic flourish is the wrong trade. */
/* HER FOUR "MAYBE" LINES: A STYLIZED MARK PER LINE.

   PAUL 2026-08-21: "get rid of the line and just do stylized bullets instead."
   The drawn descending line, its two curls and its integrated arrowhead are
   REMOVED, along with the whole 64px gutter they needed. Three versions of that
   line were built and the last one was good work; it is gone because he asked
   for something else, not because it failed. Do not reinstate it as a
   compromise alongside these marks: a coil down the left AND a mark per line is
   two systems of marking in one 300px run, which is the reason the first
   version's per-line nodes were dropped in the first place.

   WHAT THE MARK CAN BE IS TIGHTLY CONSTRAINED BY THIS PROJECT'S OWN RECORDED
   RULES, and all three constraints are in this stylesheet already:

   1. STROKE ONLY, ROUNDED, UNFILLED. The ten .ic icons are drawn `fill:none`
      with `stroke-linecap:round`, and WO-208 T3 states plainly that a filled
      dot contradicts that language. So no solid bullet.
   2. NO DIAMOND AND NO CHEVRON. Ruled out before being drawn, by the principle
      recorded in the arch comment: every shape in this system is --radius-full
      or --radius-lg, so a straight diagonal would be the only acute angle drawn
      on purpose anywhere on this site.
   3. THE OPEN RING IS TAKEN, AND IT MEANS SOMETHING ELSE. It marks her lines
      under "Imagine...", where WO-208 T3's reading is "a mark that has not been
      filled in yet". These four lines are the opposite: things that have
      already happened to her. Borrowing that ring here would quietly say the
      wrong thing, which is why it was rejected once before on this same block.

   SO: A SHORT UPRIGHT STROKE WITH ROUNDED ENDS. It is the site's one rule form
   (.axis-rule and .closing-rule, brand green, rounded) at ornament scale and
   stood on end. Upright rather than flat on purpose: a small horizontal bar at
   reading size is a hyphen or a minus sign, which is exactly the complaint that
   retired the previous marker in the Imagine band. It has no acute angle, no
   fill, and it does not collide with the ring's meaning. It also suits the
   element it marks, which is called .beat.

   Sized in em so it tracks her type at every width, the same approach
   --imagine-mark uses, rather than in px which would drift from the text. */
.maybe-set{
  position:relative;
  margin:var(--space-6) 0 0;
  padding-inline-start:1.15em;   /* = --imagine-indent, the page's one indent */
}
.maybe-set .beat.maybe{
  position:relative;
  margin:0 0 var(--space-5);
  color:var(--color-text-primary);
}
.maybe-set .beat.maybe:last-child{margin-bottom:0}
.maybe-set .beat.maybe::before{
  content:"";                      /* EMPTY. Never a character. */
  position:absolute;
  inset-inline-start:-1.15em;
  /* Optically centred on the FIRST line of the paragraph, not on the paragraph,
     so a line that wraps to three at 390 keeps its mark beside the words rather
     than letting it drift to the middle of the block. .735em is the same
     first-line anchor --imagine-mark was measured onto out of a browser. */
  top:calc(.735em - var(--maybe-mark)/2);
  --maybe-mark:.92em;
  width:3px;
  height:var(--maybe-mark);
  border-radius:var(--radius-full);
  background:var(--color-brand-on-narrative);   /* #164F41, 6.74:1 on oat */
}

/* The line that gathers the other three. Serif, because on this page the serif
   face marks a thought closing rather than continuing, and it is the treatment
   .imagine-lead uses one band down. More air above it, so the three
   recognitions read as a group and this reads as the answer to them. Its mark
   is gold and a little taller: the site's one accent, spent on the one line
   that turns, and the only place these four marks are not identical. */
.maybe-set .beat.maybe.maybe-close{
  margin-top:var(--space-6);
  font-family:var(--font-serif);
  font-size:var(--text-narrative);
  line-height:1.45;
  text-wrap:pretty;
}
.maybe-set .beat.maybe.maybe-close::before{
  /* PAUL 2026-08-21: "we're not using orange anywhere else on the page, it
     doesn't seem to fit in here". Correct, and checkable: --color-gold appears
     on this site as the notice card's border on Bookings and inside one
     gradient, and nowhere at all on Home. A single gold mark here was the one
     warm accent on the page, which made it read as a stray rather than as an
     accent. The closing line keeps its emphasis through the taller mark and the
     serif face; only the colour goes back to the green the other three use. */
  --maybe-mark:1.15em;
}

/* THE LINES ARRIVE AS SHE SCROLLS PAST THEM, which is the "leading the viewer
   through" half of what Paul asked for. Scroll-driven, not timed: each element
   carries its own view() timeline, so they stagger naturally by position with
   no delays to keep in sync, and a reader who scrolls back up sees them again
   rather than finding the effect spent.

   animation-fill-mode:none IS LOAD-BEARING AND IS NOT A TYPO. Measured on this
   project (WO-112, re-measured 2026-08-20 on .approach-figure): with `forwards`
   a browser that parses the animation but has no active timeline holds the
   element at the from-state, opacity 0.00, and the text never appears. With
   `none` the same browser resolves to 1.00. The fallback for "no scroll
   timeline" has to be fully visible, and this property decides it. */
@supports (animation-timeline:view()){
  @media(prefers-reduced-motion:no-preference){
    .maybe-set .beat.maybe{
      animation-name:pxl-maybe-arrive;
      animation-duration:auto;
      animation-timing-function:linear;
      animation-fill-mode:none;          /* load-bearing, see above */
      animation-timeline:view(block 0px -120px);
      animation-range:entry;
    }
  }
}
@keyframes pxl-maybe-arrive{
  from{opacity:0;transform:translateY(10px)}
  to{opacity:1;transform:translateY(0)}
}

/* =============================================================================
   THE SEND CONFIRMATION TOAST - PAUL 2026-08-22
   "I just want it to give a Message Sent toast message and close the widget
   form... then clear the form."

   Created and shown by js/form-submit.js. It has no markup in any page,
   because it must not exist for a visitor whose scripting is off: that route
   still posts natively and lands on thank-you.html, and an empty confirmation
   box sitting on the page would be a lie waiting to be styled.

   IT CLEARS THE MESSAGE WIDGET BY ARITHMETIC, NOT BY EYE. The widget's bubble
   is pinned bottom right; the toast is pinned bottom LEFT and its width is
   capped at the viewport less both gutters less 76px, which is that bubble
   plus a gap. So the two cannot meet at any width, including 320, without
   anyone having to remember to re-check it.

   COLOUR: brand-primary-hover, not brand-primary. White on #1F6E5A measures
   6.08:1, which clears tools/contrast-gate.py's 6.0 floor by 0.08 and would
   fail the day anything about that green is nudged. #164F41 measures 9.42:1
   and has room to survive an edit.
   ============================================================================= */
.form-toast{
  /* CENTRED ON SCREEN, PAUL 2026-08-22: "may be good to put it in the middle of
     the screen, where we know it will get seen by the submitter." It used to be
     pinned bottom left, which is the busiest corner of the page and, on a long
     page scrolled to the end, sits directly over the footer.

     Centring also retires a whole class of problem rather than solving it. The
     bottom-left position needed its width capped against the message widget's
     bubble, plus a separate rule below 600px to sit above that bubble instead
     of beside it. None of that arithmetic is needed once the toast is nowhere
     near the corner the widget lives in, and both rules are deleted. */
  position:fixed;
  left:50%;
  top:50%;
  z-index:70;                  /* above the widget's 30/31, below the skip link's 100 */
  width:max-content;
  max-width:min(28rem, calc(100vw - (var(--space-5) * 2)));
  margin:0;
  padding:var(--space-5) var(--space-6);
  background:var(--color-brand-primary-hover);
  color:#fff;                  /* 9.42:1, computed */
  border-radius:var(--radius-md);
  /* THE WHITE RING, PAUL 2026-08-22: "should get a white outline around the
     green, as it was difficult to see when it appears over the footer." The
     footer is dark, so a dark green panel landing on it had almost no edge.

     Drawn as the first shadow rather than as a border, so it adds no width and
     cannot shift the centring by a pixel, and the real drop shadow still sits
     outside it. White on this green is 9.42:1, so the panel now has a hard edge
     against any ground this site has, light or dark. */
  box-shadow:0 0 0 3px #fff, var(--shadow-lg);
  font:var(--text-base) var(--font-sans);
  line-height:1.45;
  text-align:center;
  text-wrap:pretty;
  opacity:0;
  transform:translate(-50%, -50%) translateY(8px);
  transition:opacity var(--motion-base), transform var(--motion-base);
}
.form-toast.is-in{
  opacity:1;
  transform:translate(-50%, -50%);
}
@media(prefers-reduced-motion:reduce){
  /* transform:none WOULD UN-CENTRE IT. The centring lives in the same property
     the entrance animates, so this branch has to keep the translate and drop
     only the 8px rise. The previous version of this rule said transform:none,
     which was harmless while the toast was pinned to a corner and would have
     thrown it off centre here. */
  .form-toast{transition:none;transform:translate(-50%, -50%)}
  .form-toast.is-in{transform:translate(-50%, -50%)}
}

/* WHILE THE TOAST IS UP, THE TEASER STANDS DOWN.

   The original reason was geometric: the two collided below 600px. That reason
   is GONE now the toast is centred, and the rule stays for the reason that was
   always the better one. The teaser asks "Hello, have a question? Reach out to
   Mandy." Someone reading "Message sent" has just done exactly that, so the
   prompt is wrong at that moment wherever it sits. It comes back when the toast
   goes, five seconds later.

   Attribute on <html> rather than a class on .ask, so js/form-submit.js never
   has to know the widget's markup or share a class with ask.js. */
[data-toast] .ask-teaser{
  opacity:0;
  visibility:hidden;
  transition:opacity var(--motion-fast), visibility var(--motion-fast);
}
@media(prefers-reduced-motion:reduce){
  [data-toast] .ask-teaser{transition:none}
}

/* While the background post is in flight. The button keeps its own greyed
   styling from the aria-disabled rule the two forms already share; this only
   stops a second click landing while the first is still going. */
.btn[data-sending]{pointer-events:none}

/* ---- 2. THE "Reach Mandy" BAND IS NOT SELECTABLE, EXCEPT WHAT SHE TYPES.

   Same rule and same reasoning as the message panel further up this file: this
   is a form, not a document, and dragging across its labels selects a chunk of
   interface. The two fields opt back in, because a visitor must be able to
   select, correct and copy her own words.

   user-select does not affect clicking, so "the privacy notice" link and the
   Send button both keep working. The same 988 caveat applies here as in the
   panel: that number becomes uncopyable, which is flagged to Paul rather than
   buried, and is one selector to reverse. */
.contact-band{user-select:none;-webkit-user-select:none}
.contact-band input,.contact-band textarea{user-select:text;-webkit-user-select:text}

/* ---- 3. THE LEAD LINE UNDER "Reach Mandy".

   "You do not need the right words. A few sentences about what is going on is
   plenty." It was body text in the secondary colour, doing the same job as a
   form hint while actually being the sentence that gives someone permission to
   write badly. It now reads in her serif voice at the same rung the other
   promoted lines on this site use, with the measure held so it breaks where a
   sentence should rather than running the full band.

   The band's own colour rules already give this element the light ink it needs
   on the photograph; only size, family and measure change here, so
   tools/contrast-gate.py's 6.0 floor is unaffected in kind. Re-run anyway,
   because a bigger glyph samples different pixels. */
/* PAUL 2026-08-20: the reassurance line that used to sit here as its own
   paragraph ("You do not need the right words...") HAS MOVED INTO THE MESSAGE
   FIELD, so this section no longer has a lead paragraph to style and the rule
   that used to live here is gone rather than left matching nothing.

   Worth keeping the lesson though, because it was a real mistake: the line was
   first promoted with --text-display-quote-soft, which tops out at 28px against
   this section's h2 at 38.4px. Paul: "the text got bigger... it is almost as
   large as the section title now". Two things that close in size read as two
   headings. The serif face was doing the work; the size never needed to.

   In the end the size was the smaller problem. It ran to three lines above a
   form, which is where a second heading looks like an instruction nobody asked
   for, and it was describing one field while floating above all four.

   The placeholder is visible at rest here, unlike .field-compact in the widget
   where it must stay transparent until focus. The difference is the label: this
   form's <label> sits ABOVE the field and never moves, so there is nothing for
   a resting placeholder to collide with. text-secondary is >= 4.5:1 on every
   surface on this site (contrast.md), and the always-visible label means no
   information depends on the placeholder surviving. */
.contact-band textarea::placeholder{
  color:var(--color-text-secondary);
  opacity:1;                     /* Firefox dims placeholders by default */
}

/* The submit button is centred on the same axis as the fields above it, which
   means margin-inline:auto inside the form's own content box rather than a
   text-align on the form, since that would drag the labels and notes centre
   too. display:block is required first: a <button> is inline-block, and auto
   margins do nothing on an inline-level box. */
.contact-band form .btn[type="submit"],
.contact-band form button[type="submit"]{
  display:block;
  margin-inline:auto;
}

/* ---- 4. BOTH MAPS GET A FRAME.

   Paul asked for the Bookings map and the footer map to earn their place
   rather than sit there as bare rectangles.

   The bookings map is the bigger of the two and sits in content, so it takes
   the site's own card language: rounded, a subtle border, the same shadow the
   cards use. A hover lift is deliberately NOT added; it is a link, but it is a
   500px-wide picture of a street and lifting it would read as a card that is
   about to do something surprising.

   The footer map is small and sits on the dark green ground, where a shadow
   does nothing and a hard edge reads as a hole. It gets a slightly softer
   corner and a rule in the footer's own rule colour, brightening to the footer
   text colour on hover, which is the same two-state treatment the footer's nav
   links already use. Its corners stay SMALL on purpose: tools/make-map.py's own
   comment warns that rounding this image's corners clips the burned-in
   attribution in the bottom left, so this is 6px and no more, which was checked
   against that corner rather than assumed. */
/* THE BOOKINGS MAP, MADE TO BELONG TO THE PAGE.

   It arrived looking like a screenshot of somebody else's product dropped into
   the layout: a pale grey-pink rectangle with no relationship to the oat and
   green everything around it is made of. Rounding the corners was not enough,
   and Paul said so.

   Three things, all of them borrowing language this site already speaks rather
   than inventing a fourth:

   1. THE CARD FRAME. Same radius, border and shadow as .card, so it reads as an
      object on the page instead of a hole in it.

   2. AN OFFSET ECHO BEHIND IT, up and to the left, in brand green at low alpha.
      This is the About page's arch treatment (.approach-figure::before) applied
      to a rectangle, which is what ties the two pages together: one hand, used
      twice. It also gives the map somewhere to sit rather than floating.

   3. A TINT THAT STOPS AT 65 PERCENT, and that number is a licence decision
      rather than a taste one. The map carries its OpenStreetMap and Geoapify
      credit burned into the bottom of the image. Anything laid over it reduces
      that credit's contrast, so the tint is strongest at the top, is already
      almost gone by 40 percent, and is fully transparent well above the credit
      strip. Verified by magnifying both bottom corners after this shipped, not
      assumed.

   The corner radius is the one thing here that make-map.py's own comment warns
   about: rounding this image counts as cropping, and it clips the credit if the
   radius reaches it. Checked at both bottom corners at the rendered size; the
   credit sits clear. If the map file is ever regenerated at a different size,
   check it again rather than trusting this line. */
/* THE FIGURE IS CAPPED AND CENTRED TO THE SAME MEASURE AS THE MAP ITSELF.

   PAUL 2026-08-23: "the shadow behind the map on the bookings page does not
   size properly starting at about a width of 890 through 1022." Measured, that
   window is exact, and it is arithmetic rather than coincidence.

   .map-frame carries max-width:calc(--measure-prose + --space-gutter*2), which
   computes to 764px, and centres itself. The echo is drawn on the FIGURE, using
   insets from the figure's edges, because anchoring it to the frame meant
   taking away that element's overflow:hidden, and overflow:hidden is what clips
   the map to its rounded corners.

   So the two only agree while the figure is no wider than 764px. Measured:

       890px viewport   figure 751px = frame 751px      echo correct
      1023px viewport   figure 884px, frame 764px       echo 882px, 118px too wide
      1100px viewport   two-column layout, both 480px   echo correct

   Below 890 the figure has not reached the cap; at 1100 the layout goes
   two-column and the figure is narrow again. In between, the figure outgrows
   the map and drags the echo with it.

   Capping the FIGURE to the same measure makes the two boxes identical at every
   width, so the echo cannot be sized against anything but the map. It also puts
   the caption under the picture rather than under a wider invisible box, which
   is where a caption belongs. The frame keeps its own cap: harmless now, and it
   is the rule that actually governs the image. */
.office-map{
  position:relative;
  max-width:calc(var(--measure-prose) + var(--space-gutter) * 2);
  margin-inline:auto;
}
/* THE ECHO, ON THE FIGURE, SIZED BY THE FRAME'S OWN RATIO.

   PAUL 2026-08-20, and this is the second correction to it, so both mistakes
   are written down rather than quietly replaced.

   FIRST MISTAKE: it hung off .office-map, which is the <figure> and therefore
   includes the caption, so an inset of 20px from the figure's bottom landed
   below the map image entirely.

   SECOND MISTAKE, fixing the first: I moved it onto .map-frame and had to take
   away that element's overflow:hidden to stop it being clipped. Two things
   broke at once. The rounded corners went, because overflow:hidden was what was
   actually clipping the image to the radius. And the echo vanished on hover,
   because the hover added a transform, a transform makes an element a stacking
   context, and a ::before at z-index:-1 can no longer paint behind an element
   that has become one.

   THIS VERSION TOUCHES NEITHER. .map-frame keeps overflow:hidden and its
   rounding, and the echo goes back on the figure, where nothing is transformed.
   It is sized by aspect-ratio 2/1, which is the frame's own ratio declared in
   the BOOKINGS section above, so its height tracks the frame's height at every
   width without measuring the caption at all. Its bottom then lands about 20px
   above the image's lower edge, which is what Paul asked for.

   THE HOVER IS SHADOW ONLY. No transform, so the stacking context never forms
   and the echo cannot disappear again. It is also the better hover here: this
   is a link to a map, and lifting a 480px-wide picture of a street implies it
   is about to do something more surprising than open directions. */
.office-map{position:relative}
.office-map::before{
  content:"";position:absolute;
  top:-20px;left:-20px;right:22px;
  aspect-ratio:2/1;                 /* = .map-frame's own ratio, see above */
  border-radius:var(--radius-lg);
  background:color-mix(in srgb, var(--color-brand-primary) 18%, transparent);
  z-index:0;pointer-events:none;
}
@supports not (background:color-mix(in srgb, red 18%, transparent)){
  .office-map::before{background:rgba(31,110,90,.18)}
}
.office-map .map-frame{
  position:relative;z-index:1;
  border-radius:var(--radius-lg);
  border:var(--border-thin) solid var(--color-border-subtle);
  box-shadow:var(--shadow-md);
  transition:box-shadow var(--motion-base);
}
/* The tint stops at 65 percent because the OpenStreetMap and Geoapify credit is
   burned into the bottom of this image and anything laid over it costs that
   credit contrast. Checked under magnification at both bottom corners. */
.office-map .map-frame::after{
  content:"";position:absolute;inset:0;pointer-events:none;
  border-radius:inherit;
  background:linear-gradient(
    to bottom,
    rgba(31,110,90,.12) 0%,
    rgba(31,110,90,.04) 40%,
    rgba(31,110,90,0) 65%
  );
}
.office-map a:hover .map-frame,
.office-map .map-frame:hover{
  box-shadow:var(--shadow-md),0 16px 30px -18px rgba(27,43,38,.45);
}
@media(prefers-reduced-motion:reduce){
  .office-map .map-frame{transition:none}
}
/* Centred under the picture rather than hung off its left edge: the caption is
   a credit for the whole picture, not a note about its left-hand side. */
.office-map figcaption{position:relative;z-index:1;text-align:center}
.foot-map a.nap-map{
  border-radius:6px;
  transition:border-color var(--motion-fast);
}
@media(prefers-reduced-motion:reduce){
  .foot-map a.nap-map{transition:none}
}
