/* ==========================================================================
   01 · FACES — self-hosted, all of them, no third-party request
   --------------------------------------------------------------------------
   Bricolage Grotesque (SIL OFL) display · Schibsted Grotesk (SIL OFL) body ·
   Azeret Mono (Displaay, SIL OFL) for the mono's narrow remit. Licences ship
   beside the files. All three are single VARIABLE files per subset, covering
   their whole weight range.

   ONE TYPE SET SHIPS, AND IT IS SET 1 (Sam, 2026-09-12). See
   docs/design-decisions.md §2. Apfel Grotezk and General Sans were the other two
   candidates; their faces and their `.asoc-t-*` blocks are gone, because a set
   nobody can select is dead weight that still costs a download decision later.

   Bricolage and Schibsted came from Google Fonts until 2026-09-12. Self-hosting
   them removes the last third-party request on the page, which is the whole
   reason it was worth doing — not bytes. The files are Google's own, so they are
   the same faces that every screenshot since 2 Sep was judged on.

   LATIN AND LATIN-EXT, split on Google's own unicode-ranges. latin alone would
   cover every word currently on the site; latin-ext is ~30KB of insurance against
   an accented client or business name arriving later, and the browser only
   downloads it if a character in that range is actually used.

   font-display:swap so a slow font never blocks text, and the fallbacks are
   metric-ish so the swap does not reflow badly.
   ========================================================================== */
/* ⚠️ BRICOLAGE CARRIES TWO AXES AND ONE OF THEM IS LOad-BEARING. `opsz` is what
   every `font-optical-sizing: auto` rule below depends on, and pinning opsz per
   TAG rather than per SIZE was the original bug on this project — a 20px h3 got
   the 144pt display cut. Confirmed present in this exact file by measurement, not
   assumption: rendering "Handgloves" at opsz 12 vs opsz 96 moves its width by
   46.4px, against a 26.5px control on the weight axis that proves the method can
   see an axis at all. If a future re-download loses that axis, headings go subtly
   wrong with nothing erroring — re-run that check, do not eyeball it.
   `woff2-variations` with a `font-weight` RANGE, so the variable axes are exposed
   rather than the file being treated as a single static weight. */
@font-face { font-family: "Bricolage Grotesque"; src: url("fonts/BricolageGrotesque-latin.woff2") format("woff2-variations");
  font-weight: 300 800; font-stretch: 100%; font-style: normal; font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
@font-face { font-family: "Bricolage Grotesque"; src: url("fonts/BricolageGrotesque-latin-ext.woff2") format("woff2-variations");
  font-weight: 300 800; font-stretch: 100%; font-style: normal; font-display: swap;
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; }

@font-face { font-family: "Schibsted Grotesk"; src: url("fonts/SchibstedGrotesk-latin.woff2") format("woff2-variations");
  font-weight: 400 700; font-style: normal; font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
@font-face { font-family: "Schibsted Grotesk"; src: url("fonts/SchibstedGrotesk-latin-ext.woff2") format("woff2-variations");
  font-weight: 400 700; font-style: normal; font-display: swap;
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; }

@font-face { font-family: "Azeret Mono"; src: url("fonts/AzeretMono-Variable.woff2") format("woff2-variations");
  font-weight: 100 900; font-style: normal; font-display: swap; }

/* ==========================================================================
   02 · TOKENS — Admin Society's design system
   --------------------------------------------------------------------------
   Colour tokens are named SEMANTICALLY (surface / ink / accent), never
   literally (deep-slate / warm-cream / terracotta). That is what makes a future
   palette a values swap on one block rather than a rebuild — and it is why a
   token can hold Georgie's lilac today and whatever the refreshed logomark wants
   tomorrow without the name becoming a lie.

   ⚠️ THERE ARE NO VARIANT CLASSES ANY MORE (2026-09-12). Five comparison axes
   lived here — palette, type set, services ground, colour edge and narrative
   hand-off — each driven by a `_asoc_*` post meta through a body_class filter.
   Every one is decided, so every one is deleted, and a page with no class on it
   now renders the configuration that was actually reviewed. What each axis
   settled on, and what it ruled out, is in docs/design-decisions.md.
   ========================================================================== */

:root {
  /* ======================================================================
     ⛔ READ THIS BEFORE YOU WRITE A `rem` ANYWHERE IN THIS FILE.

     **1rem = 10px on this stack, NOT 16px.** Every length token below is
     authored against that. Halve your instinct, or double the number.

     WHERE IT COMES FROM, traced rather than assumed (2026-09-14): it is
     Bricks' own default. The parent theme ships `html { font-size: 62.5% }`
     in `assets/css/frontend-layer.min.css`. It is NOT a Core Framework
     hangover and it is not something anyone here set — `bricks_theme_styles`
     does not exist as an option on this install, so the default has never
     been overridden.

     ⚠️ AND THE FIRST SEARCH FOR IT FOUND NOTHING, which is the reason this
     block says where to look. Walking `document.styleSheets` at the top level
     returns zero rules setting it, indistinguishable from "nothing sets it",
     because Bricks wraps its reset in `@layer bricks.reset` and a scan that
     does not recurse into layer blocks cannot see inside. The scan that does
     walks 1,792 rules and finds exactly one hit. If you ever re-check this,
     recurse, and print the population.

     IT IS SETTABLE, and we are choosing not to set it. Bricks exposes the
     control at Theme Styles → Typography → HTML
     (`includes/theme-styles/controls/typography.php`), whose own info text
     reads "62.5% html font-size: 1rem = 10px / 100% html font-size:
     1rem = 16px".

     WHY WE LEAVE IT (Sam, 2026-09-14). Moving to 100% would rescale every
     Bricks-native default we have not explicitly overridden — its elements are
     authored against 62.5% — and would touch every spacing and type value
     here, for a readability gain only a developer ever sees.

     WHAT IT ALREADY COST, so the next person takes the warning seriously: the
     desktop page gutter shipped at 12.5px a side from 801px to 1265px, on a
     site whose phones get 24px. The rule said `min(100% - 2.5rem, …)`, written
     by someone reading 2.5rem as 40px. It is 25px. The gutter is being moved
     to a single fluid token with a lint precisely so that this class of
     mistake has nowhere left to happen — see docs/header-brief.md.
     ====================================================================== */

  /* ---- THE PALETTE (Sam, 2026-09-12) ----
     Georgie's own colours on a dark ground. Hexes sampled from her artwork, not
     eyeballed: logomark ground #303030 · lilac #BE96C8 · mint #A0D2D2.

     These values lived in a `.asoc-v-brand` block on <body> until the comparison
     closed. They are at :root now because a page with no class on it must render
     the configuration that was actually reviewed — every on-paper contrast figure
     below was measured against THIS palette, so a page that quietly fell back to
     the roadmap's terracotta would be carrying numbers that were never about it.

     A FUTURE PALETTE IS STILL A VALUES SWAP ON THIS ONE BLOCK. That is what the
     semantic names buy and it survives the comparison being closed: nothing
     downstream names a colour, so `--asoc-accent` can hold a lilac today and
     whatever the refreshed logomark wants tomorrow without a single rule moving.
     docs/design-decisions.md §1 records what was ruled out and why.

     THE THIRD ACCENT IS HER CORAL, KEPT (Sam, 2026-09-12, after seeing six
     candidates rendered side by side in the contexts an accent actually lands in).
     It is sampled from her own 2026 packages document, where it is the highlight
     colour on subheads, star ratings and the quote mark.

     It briefly held a desaturated replacement, #E3AE83. The argument for that was
     chroma: in OKLCH the palette measures
         lilac   L .727  C .084  h 319
         mint    L .829  C .052  h 196
         coral   L .782  C .143  h  60
     so coral carries roughly twice the chroma of either neighbour — a document
     colour at ~0.7 saturation beside two logomark pastels at ~0.25. Its POSITION
     was never in question: going warm-ways round, the gap between mint and lilac
     is 237 degrees and 60 sits 136 from the mint and 101 from the lilac, close to
     equidistant, with a lightness between the two.
     Rendered, the softened version read as a warm neutral rather than an accent.
     Sam's call, and the comparison is what settled it rather than the arithmetic.

     ⚠️ SO USE IT AS A HIGHLIGHT, NOT AS A FIELD. The chroma figure above is not an
     objection, it is a usage note: at C .143 it will be the loudest thing on any
     screen it appears on, which is what a highlight is for and what a large fill
     or a whole card surface is not. That is also how she uses it herself.

     A BLUE WAS CONSIDERED AND RULED OUT ON GEOMETRY. Every blue is trapped in the
     123-degree gap on the cool side, so moving one away from the mint moves it
     toward the lilac by the same amount; the best case is a near-neighbour of the
     mint. docs/design-decisions.md §1 carries the measured table.

     ⚠️ NO CONSUMER YET, DELIBERATELY. It is a palette decision, so it is recorded
     here; the first thing to use it decides what it means (a per-pillar marker, a
     tier flag, a toolkit category). Using it as TEXT additionally needs an
     --asoc-ink-accent-3 counterpart and an on-paper value — it measures 1.80:1 on
     paper — and verify-tokens.js rule two will refuse the shortcut of pointing
     `color:` straight at it. */
  --asoc-surface:        #232326;
  --asoc-surface-raised: #303030;   /* her logomark charcoal */
  --asoc-surface-sunk:   #1A1A1C;
  --asoc-ink:            #F6F3F6;
  /* ⚠️ OPAQUE, NOT rgba, AND THAT IS ABOUT TIMING (2026-09-18). These are the SAME
     rendered colours — the alpha blends against --asoc-surface were 0.72 -> #BBB9BC and
     0.46 -> #848386 — but a translucent ink has a fourth channel to interpolate when the
     ground changes, and it lands after the others: the quote's "OKAY VA" line finished 24px
     of scroll behind the name and the quote itself, which is what Sam saw as "too slow and
     not timed with the other background and text transition". Over a card (#303030) the
     difference in the rendered colour is under 4/255. */
  --asoc-ink-muted:      #BBB9BC;
  --asoc-ink-faint:      #848386;
  --asoc-line:           rgba(190, 150, 200, 0.22);

  --asoc-accent:         #BE96C8;   /* lilac */
  --asoc-accent-hover:   #CEA9D6;
  --asoc-accent-ink:     #241B27;   /* dark text on a pastel accent */
  --asoc-accent-2:       #A0D2D2;   /* mint  */
  --asoc-accent-3:       #FAA050;   /* coral — hers, from the 2026 packages document */
  --asoc-kicker:         #A0D2D2;   /* mint reads as a real secondary here */

  /* ---- THE LIGHT-RUN PALETTE ----
     ⚠️ THESE LIVE HERE, IN THE MAIN :root, ON PURPOSE. They were twice declared
     inside the colour-transition block because that is where they were first
     needed, and twice deleted along with it when the mechanism was rewritten —
     each time silently, because an undefined custom property makes the whole
     declaration invalid, so `background-image: linear-gradient(var(--run-ground)…)`
     computed to `none` and the section simply lost its background. Both spatial
     variants broke that way and neither the page nor the build said a word.
     PALETTE AND MECHANISM ARE SEPARATE CONCERNS. Colours the whole design system
     uses do not belong inside the block that happens to have introduced them. */
  --asoc-paper:          #F4EFE9;   /* the warm light ground */
  /* --asoc-paper-card and --asoc-lilac-card were deleted 2026-09-12 with the
     spatial mechanism that was their only consumer. The card colour on the paper
     ground is set by the on-ground block instead (--asoc-surface-raised #FBF8F4),
     which separates a card by TEXTURE rather than hue — a lilac-tinted card
     alternating with the paper ground measured as two more colour changes on a
     phone, which is the opposite of what the one-ground rule is for. */
  --asoc-on-light:       #241B27;   /* THE on-light ink. 6.6:1 on lilac, 14.6:1 on paper */
  --asoc-on-light-muted: #3A3140;   /* opaque, never rgba — 4.88:1 on lilac, 10.7:1 on paper */

  /* Accent colours used as TEXT need their own tokens: a ground colour goes
     invisible the moment it becomes the ground (verify-tokens.js rule two).
     LITERALS, NOT var(--asoc-accent), and that is still load-bearing with one
     palette: a custom property that references another resolves in its DECLARING
     scope, so the on-ground block further down could not override a token that
     had been written as an alias here. */
  --asoc-ink-accent:     #BE96C8;
  --asoc-ink-accent-2:   #A0D2D2;

  /* The dark-theme foregrounds, named so a section can OPT OUT of the on-ground
     flip and keep them. Literals, for the resolution-scope reason above. */
  --asoc-ink-dark:        #F6F3F6;
  --asoc-ink-faint-dark:  #848386;    /* opaque for the same timing reason as above */
  --asoc-kicker-dark:     #A0D2D2;
  --asoc-ink-accent-dark: #BE96C8;
  /* The footer's warm light ink (Sam, 2026-10-15: "one warm light"): her line, the menu links, the bottom row and back to top on
     the dark, in the site's paper colour #F4EFE9 (full, and at 72% for the plain words). A FOREGROUND token with its own
     name because --asoc-paper is a GROUND and rule two bans a ground as text. Literals, for the resolution-scope reason
     above; the chrome opt-out below restates them, so a future flip of the paper ink cannot reach the footer. */
  --asoc-ink-paper-warm:       #F4EFE9;
  --asoc-ink-paper-warm-muted: rgba(244, 239, 233, 0.72);
  /* The narrative's artwork keeps its dark surfaces on the light ground. */
  --asoc-surface-raised-dark: #303030;
  /* ⚠️ THIS IS THE DARKEST THE PAYOFF LINE MAY EVER GO, and that is structural
     rather than a promise: a CSS transition interpolates between the previous
     computed value and this one, so it is the ENDPOINT and cannot be overshot.
     It stays put for as long as the ground stays paper, including after the line
     has left the screen. Sam asked for exactly that.
     Chosen over the document-scope on-paper accent #4A1452, which scores better
     (12.14:1 vs 3.66:1) and is wrong: it is a near-black purple, so the line
     would finish reading as almost black and stop being an accent at all.
     #9F67AD is the darkest value that still reads as the lilac family. */
  --asoc-ink-accent-paper: #9F67AD;

  /* ---- Typography (Variant A) ---- */
  /* TYPE SET 1 (default). Fraunces was dropped 2026-09-02 — it tipped into
     unprofessional at display sizes — and Poppins with it. */
  --asoc-font-display: "Bricolage Grotesque", "Helvetica Neue", Arial, sans-serif;
  --asoc-font-body:    "Schibsted Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  /* The third face. Remit is deliberately narrow — see --asoc-font-mono usage. */
  --asoc-font-mono:    "Azeret Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
  --asoc-display-wght: 600;   /* uniform. 600 for h1/h2 and 700 for h3 read as
                                 two different fonts. Light-on-dark also thins
                                 strokes optically, so display type needs weight. */
  --asoc-display-tracking: -0.02em;
  --asoc-display-leading:  1.10;   /* 1.04 left only 6.5px of ink clearance at 94px —
                                     safe for THIS headline, not for the next one. */
  --asoc-body-leading: 1.65;

  /* Fraunces variable axes.
     TWO BUGS were here:
     1. WONK is a 0–1 axis, not 0–100. "WONK" 8 clamped to 1 = MAXIMUM wonk,
        the exact opposite of the intent.
     2. opsz was pinned at 120 for EVERY heading. Optical size 120 is the
        display cut — maximum stroke contrast, thinnest hairlines. Applying it
        to a 20px h3 renders display hairlines at text size, which on a dark
        ground thins out to near-invisible. opsz must track the real size. */
  --asoc-display-variation: normal;

  /* Optical size is PAIRED WITH EACH SIZE TOKEN, never with a tag. Tying it to
     the tag was the original bug: an h3 styled at 68px got the 20pt cut while
     the 94px h1 got the 144pt cut, so two similar-sized headings rendered with
     opposite stroke contrast.
     The values sit at roughly HALF the rendered size rather than matching it.
     The match-opsz-to-size convention assumes dark text on light. Light-on-dark
     thins hairlines optically, so a display cut at 94px reads as spindly. Half
     keeps Fraunces' character without the hairlines disappearing. */
  /* The --asoc-opsz-* tokens are RETIRED. They existed to hand-pilot Fraunces'
     optical-size axis, and pinning opsz per TAG rather than per SIZE was the
     bug that put a chunky 20pt cut beside a spindly 144pt cut on one page.
     None of the three replacement faces needs the manual treatment, so every
     rule now uses `font-optical-sizing: auto`, which ties opsz to the rendered
     size by definition and cannot drift. Do not reintroduce manual opsz unless
     a face is demonstrably wrong on auto. */
  /* opsz is deliberately NOT set here. Forcing it per TAG was the bug: an h3
     styled at 68px got the 20pt small-text cut (chunky, low contrast) while the
     94px h1 got the 144pt display cut (fine hairlines). Two headings of similar
     size, opposite optical cuts, on one page. font-optical-sizing:auto ties opsz
     to the RENDERED size, which is the only thing that stays correct when a
     heading is restyled. */

  /* ---- Fluid type scale ----
     ⚠️ BRICKS SETS html { font-size: 62.5% } in frontend-layer.min.css, so on this
     stack 1rem = 10px, NOT 16px. Every value below is authored against that.
     Do NOT "fix" the root to 100% — Bricks' own components and its wireframe
     library assume 62.5%, and overriding it breaks them. ---- */
  /* Scaled down 15% on 2026-09-15 (Sam). Was clamp(4.4rem, 3.4rem + 4.2vw, 10.4rem),
     44 → 104px, which measured 94.5px at 1440. Every term is multiplied, so the
     reduction holds across the whole range rather than only at the ends.
     ⚠️ `.asoc-hero__title` is this token's ONLY consumer — grepped, both hits are
     the declaration and that rule — so nothing else moves with it. */
  --asoc-text-hero:  clamp(3.7rem, 2.9rem + 3.6vw, 8.8rem);    /* 37 →  88px */
  /* ⚠️ THE HEADING SCALE KEEPS GROWING TO 1600px, FROM ~1300 (Sam, 2026-09-30, item 25,
     landed with the 1440px column below). Before, h1–h3 and lead all reached their caps by
     1024–1330px, so every wider screen showed the same headings in a wider gap: h2, the
     section heading, sat at 44px from 1024 to 2560. Each fluid step is re-anchored to pass
     through ITS OWN OLD VALUE AT 393px and its new cap at 1600px, so a 393 phone is
     unchanged to the pixel, 360–390 moves by under 0.2px, and 768 by about 1px.
     h1 and lead already had that slope, so only their caps moved. body stays 18: it is a
     reading size, and a wider column does not widen a line — `--asoc-measure` does.
     The hero is NOT part of this (Sam scaled it down on 2026-09-15; see above). */
  --asoc-text-h1:    clamp(3.6rem, 2.9rem + 3.0vw, 7.6rem);       /* 36 →  76px, cap at ~1570 */
  --asoc-text-h2:    clamp(3.4rem, 2.883rem + 1.698vw, 5.6rem);   /* 34 →  56px, cap at 1600
                       Min raised from 28: with h3 at 24, a 28px h2 left only a 1.24x
                       step on mobile and section headings stopped outranking cards. */
  --asoc-text-h3:    clamp(2.4rem, 2.187rem + 0.633vw, 3.2rem);   /* 24 →  32px, cap at 1600
                       Body is pinned at 16px (accessibility floor), so h3 must start
                       at 24 to keep a 1.5x step. At 20px the ratio was 1.31 and the
                       card title did not read as a heading on a phone. */
  --asoc-text-body:  clamp(1.6rem, 1.55rem + 0.18vw, 1.8rem);  /* 16 →  18px */
  --asoc-text-lead:  clamp(1.8rem, 1.68rem + 0.45vw, 2.4rem);  /* 18 →  24px, cap at 1600 */
  /* FIXED ON PURPOSE, and checked 2026-09-30 rather than left by default. small is the
     16px floor for secondary text, so a clamp could only grow it above 16 on wide
     screens — a pixel or two on card copy that already sits under a 32px h3. label is
     13px uppercase tracked, where a size change reads as a weight change. Neither
     carries the hierarchy the wider column put under strain; the heading steps do. */
  --asoc-text-small: 1.6rem;    /* 16px — desktop secondary text must be >=16px */
  --asoc-text-label: 1.3rem;    /* 13px, uppercase + tracked, so legible */

  /* ---- Space ---- */
  --asoc-space-3xs: 0.4rem;
  --asoc-space-2xs: 0.8rem;
  --asoc-space-xs:  1.2rem;
  --asoc-space-s:   1.6rem;
  --asoc-space-m:   2.4rem;
  --asoc-space-l:   4.0rem;
  --asoc-space-xl:  6.4rem;
  --asoc-space-2xl: 9.6rem;
  --asoc-space-3xl: clamp(8rem, 5rem + 8vw, 12rem);
  /* ⚠️ CAPPED AT 120px, FROM 160 (Sam, 2026-09-18: "section spacing is too
     generous, desktop worst"). It is every section's padding, so a join is two
     of it: measured ink-to-ink at 1440 the ordinary joins went 360/367/321 ->
     280/287/241. Only the cap moved, so the cut lands where the complaint was:
     nothing changes below 875px, where the slope never reached 120 — and the
     phone's 56px is the ≤700 override further down, untouched. Do not take the
     cap below ~100: 2xl is 96 above 700px, and 3xl must stay the larger step. */

  --asoc-measure: 68ch;          /* max reading width — About page rule */
  /* THE PAGE COLUMN'S MAXIMUM. 1440px from 1240 (Sam, 2026-09-30, item 25: "1240 feels
     too narrow and it lacks a modern feel. Plus on larger screens it leaves lots of empty
     gutter width either side of the content"). Below ~1517px the column is the viewport
     less two gutters and this number does nothing; above it, it binds. At 1920 the empty
     space either side went 340 -> 240px, and at 1440 the column went 1240 -> 1366.
     Full-bleed sections do not read it.
     1600px from 1440 (Sam, 2026-10-12: one step up for large and second screens). Binds from
     ~1728px; at 1920 the space either side goes 240 -> 160px. Nothing moves below ~1517px. */
  --asoc-container: 1600px;
  /* THE CHROME'S MAXIMUM — the header's contents and the footer's two bands. The FULL
     viewport less a gutter each side, at every width, by decision (Sam, 2026-09-30: "whichever
     design direction and style we go with for the header and footer ... I want them to be
     scoped and set to full width on desktop"; docs/header-brief.md C1–C2 already required it
     of the header). So above the column's bind the wordmark sits one gutter from the
     viewport's edge while the page content stays in the column — two edges, on purpose.
     It is a token rather than a bare 100% so the decision has one place to live if a very
     wide screen ever needs a ceiling. verify-grid.js's CHROME arm asserts it. */
  --asoc-chrome: 100%;

  /* ---- THE PAGE GUTTER, PER SIDE ---------------------------------------
     ⚠️ THIS COMMIT INTRODUCES THE TOKEN AND DELIBERATELY REPRODUCES THE
     EXISTING (BROKEN) VALUES. It is the refactor half of a two-commit change:
     nine expressions of one idea collapse into one token plus the two scoped
     overrides below, and the rendered geometry does not move by a pixel at any
     width. That zero-diff is the proof the refactor is honest. The NEXT commit
     deletes both overrides and makes the base a fluid clamp, which is a
     three-line diff carrying the entire visible improvement.

     Counted with an unfiltered search before this change, the page inset was
     written NINE times at THREE values: `2.5rem` and `4.8rem` (as a
     ≤800/≥801 pair on the container, the header inner and the footer's two
     bands), `5rem` on the pinned narrative, and two more copies in
     proto/proto.css. Every one of them is now this token. */
  --asoc-gutter: clamp(2.4rem, 0.8rem + 2vw, 6.4rem);
  /* 24px a side on a phone, growing with the viewport to a 64px ceiling, and NO
     BREAKPOINT — because a gutter defect slides rather than snapping, and the one
     this replaces hid for weeks between the two widths anyone screenshots.

     WHAT IT RESOLVES TO, and why three of the five tiers are untouched:
       393   24.00px   (the floor binds — identical to before)
       768   24.00px   (the floor binds — identical to before)
       800   24.00px   (the floor binds — identical to before)
       801   24.02px   (was 12.5px — the step is gone)
      1024   28.48px   (was 12.5px)
      1265   33.30px   (was 12.5px)
      1440  100.00px   (the 1240px column binds — identical to before)
     The change is confined to 801-~1307px, is monotonic, and only ever widens.
     ⚠️ That table is the 2026-09-17 record, against a 1240px column. Since 2026-09-30
     the column is 1440px and binds at ~1517px, so at 1440 the content now sits 36.8px
     in (the token itself), and the chrome sits one gutter in at EVERY width. */
  --asoc-radius:    14px;
  --asoc-radius-lg: 26px;
  --asoc-radius-pill: 999px;

  --asoc-ease: cubic-bezier(0.22, 0.61, 0.36, 1);
  --asoc-dur:  0.3s;

  /* ---- THE STACKING ORDER, NAMED ----------------------------------------
     Five layers, and three of them are load-bearing in ways a bare number does
     not admit to. Written down because the pinned stage's depth order IS the
     trick that makes the narrative read as paper on a desk rather than as a
     picture of one, and a stray `z-index: 2` somewhere else would break it
     without touching a line of that code.

     -2  the services icon, in its pocket, and
     -1  the service card's surface layer above it. Both live INSIDE the card's
         own stacking context (the card isolates), so their order holds whatever
         the card does — the reveal's fade and transform, a hover, anything later.
         Before 2026-09-17 the icon was -1 against the card's own background, which
         only held while the card was NOT a stacking context; see the pocket block.
      0  the colour ground, and the desk inside the stage
      1  a section, and the stage's own type layer
      2  the paper, which lies ON TOP of the type
      3  the quote once staged inside the pin — above the paper, not merely
         above the desk, or a sheet still in flight crosses in front of it

     CHROME: the header row at 10, the floating pill and its Services panel at 20
     (2026-10-11). Both sit above the whole stage, so a sheet in flight passes
     under the pill, never over it. */
  --asoc-z-pocket:  -2;
  --asoc-z-surface: -1;
  --asoc-z-ground:   0;
  --asoc-z-section:  1;
  --asoc-z-paper:    2;
  --asoc-z-staged:   3;
  --asoc-z-header:  10;
  --asoc-z-nav:     20;
  --asoc-z-sky:     20;   /* the paper plane's layer over the whole footer (2026-10-17). Inside the footer's own stacking context, so it never reaches the pill; a step of its own because it is not the nav */
  --asoc-z-review:  30;   /* the review switch (2026-10-13), above everything */

  /* ---- THE BREAKPOINTS, AND WHY EACH ONE EXISTS -------------------------
     FOUR, and a new rule must use one of them. verify-tokens.js rule four
     asserts the set of @media widths in this file equals this list, so adding a
     fifth is a decision someone makes on purpose rather than a number that
     drifts in because it happened to look right on one screen.

       520px   the hero's buttons stop sitting side by side and go full width.
               Stacked auto-width buttons read as accidental; full width reads
               as designed and gives a bigger tap target.
       700px / 701px   ONE EDGE, TWO NUMBERS, like 800/801 below. 700 is the
               single-column tier. Grids collapse to one column, the spacing
               scale drops (3xl 120->56, 2xl 96->48, xl 64->44), and centred
               blocks go left-aligned — Sam, 2026-09-03: "unless the section is
               purposefully designed for centred text, left aligned on mobile is
               the primary direction."
               701 is its complement. Nothing uses it since the header rebuild
               (2026-10-11) removed the tablet CTA rule; it stays documented as the
               edge's other half.
       800px / 801px   ⚠️ ONE EDGE, TWO NUMBERS, AND IT NOW LIVES IN TWO FILES.
               `max-width: 800px` is the phone/tablet side and is still here.
               `min-width: 801px` is NO LONGER IN THIS FILE at all (2026-09-16):
               the gutter override that used it was deleted with the fluid token,
               and the services icon pocket that used it was deleted with the
               pocket. The pinned narrative never needed it — its CSS hangs off
               the `.is-pinned` CLASS, which asoc-motion.js adds inside a
               gsap.matchMedia at 801.
               So the edge is still load-bearing, but only the JS half declares
               it. They must stay complementary: a gap or an overlap means the pin
               exists in JS while the CSS that positions the stage does not, or
               vice versa. Mutation-proven — changing the JS side to 1px turns the
               mobile exclusion red.
               ⚠️ Rule four checks every @media width IS in this list, not that
               every listed width is USED, so 801 disappearing from the CSS did
               not turn it red. That is correct behaviour and it is also why this
               note exists: the gate cannot tell you a breakpoint has gone.
      1024px   three columns become two. Purely a grid density step.

     ⚠️ THESE ARE NOT BRICKS' BREAKPOINTS. section-audit.js sweeps Bricks' own
     478 / 767 / 991 because that is what the builder ships; the page is authored
     against the four above. Both are true at once and neither list is wrong —
     just never assume a number from one list means anything in the other.
     Real phones are 360-430 (iPhone 14/15 = 393), which is why 393 and not 478
     is the width every capture uses. */
}

/* THE PALETTE AND TYPE VARIANTS LIVED HERE and are gone (2026-09-12).
   `.asoc-v-brand` held the palette now at :root; `.asoc-t-2`, `.asoc-t-3` and
   `.asoc-v-type` held the two type sets that were not chosen. Their values were
   proven identical to :root's before the block was removed, so the deletion did
   not change a single computed value.
   Deleted rather than left class-selectable: a comparison page still standing
   after its question is answered is how a decision gets un-made by accident, and
   these five classes were emitted from a `body_class` filter that no longer has
   an axis to read. docs/design-decisions.md §1 and §2. */

/* ==========================================================================
   03 · BASE — element defaults
   ========================================================================== */
body.brx-body {
  background: var(--asoc-surface);
  color: var(--asoc-ink);
  font-family: var(--asoc-font-body);
  font-size: var(--asoc-text-body);
  line-height: var(--asoc-body-leading);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

h1, h2, h3, h4, .asoc-display {
  font-family: var(--asoc-font-display);
  font-weight: var(--asoc-display-wght);
  font-variation-settings: var(--asoc-display-variation);
  font-optical-sizing: auto;
  letter-spacing: var(--asoc-display-tracking);
  line-height: var(--asoc-display-leading);
  color: var(--asoc-ink);
  text-wrap: balance;
  margin-block-start: 0;
}
/* A HEADING'S TRAILING MARGIN IS SPACING TOWARD WHAT FOLLOWS IT, so it exists only
   when something does. Bricks resets heading margins to 0, and without a default every
   heading sits flush on the copy beneath it (verify-layout.js: "TIGHT 0px"). But
   declared unconditionally it was also the bottom of every heading that ENDS a block,
   where nothing follows and it adds to the section's padding instead — the narrative's
   two lines each carried 16px that way at ≤800 (verify-grid.js MARGINS).
   ⚠️ :where() IS LOAD-BEARING. Zero specificity, so every class rule that sets a
   heading's margin still wins, exactly as it did against the old element-level rule,
   and it still beats Bricks' reset because Bricks' CSS is layered. `:not(:last-child)`
   written bare would add (0,1,0) and start overriding those class rules. */
:where(h1, h2, h3, h4, .asoc-display):where(:not(:last-child)) { margin-block-end: var(--asoc-space-s); }
:where(h1):where(:not(:last-child)) { margin-block-end: var(--asoc-space-m); }
/* HEADING TO ITS SECTION'S CONTENT (Sam, 2026-10-12). A section heading followed directly by a
   component (the folder's tabs, the FAQ list), not by its own intro paragraph, starts a new group,
   so it stands further off: 40px, 24px on a phone. Measured before: 16-26px under the heading
   against 30px between kicker and heading, so proximity read backwards. Heading-to-intro-copy
   stays at s (§18). Zero specificity, like the rule above. */
:where(h2):where(:not(:last-child)):where(:has(+ :not(p, .asoc-lead))) { margin-block-end: var(--asoc-space-l); }
@media (max-width: 700px) {
  :where(h2):where(:not(:last-child)):where(:has(+ :not(p, .asoc-lead))) { margin-block-end: var(--asoc-space-m); }
}
/* Bare headings had NO font-size from this system, so h2/h3 were silently
   inheriting Bricks' defaults and the scale below was only reaching the few
   elements that carried a class. That collapsed the section-heading to
   card-heading step to 1.3x. */
h1 { font-size: var(--asoc-text-h1);
     font-optical-sizing: auto; }
h2 { font-size: var(--asoc-text-h2);
     font-optical-sizing: auto; }
/* Small headings on a dark ground need weight, not contrast — h3 sits at 20–26px
   where a display cut disappears. */
h3 { font-size: var(--asoc-text-h3);
     font-optical-sizing: auto; }

/* Tabular figures so the pricing tiers align on the decimal. */
.asoc-price, .asoc-tabular, .asoc-credential {
  font-family: var(--asoc-font-mono);
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.01em;
}

p { max-width: var(--asoc-measure); }
a { color: inherit; }

::selection { background: var(--asoc-accent); color: var(--asoc-accent-ink); }

:where(a, button, [tabindex]):focus-visible {
  outline: 2px solid var(--asoc-accent-2);
  outline-offset: 3px;
  border-radius: 4px;
}

/* ==========================================================================
   04 · COMPONENTS — shared, page-agnostic
   ========================================================================== */
.asoc-section { padding-block: var(--asoc-space-3xl); }
/* ONE GAP WHERE THE GROUND DOES NOT CHANGE (Sam, 2026-10-13). Two sections on the same ground
   read as one space, so their paddings added up to 240px (112 on a phone) against the 120 (56)
   a reader sees at a change of colour. The second drops its top padding, so the gap is the first
   one's bottom padding. EXPLICIT GROUNDS ONLY: the home's narrative and the sections the scroll
   repaints change ground over time, so a pair of those is "the same" only some of the time. */
.asoc-hero + .asoc-ground-dark,
.asoc-ground-dark + .asoc-ground-dark,
.asoc-ground-paper + .asoc-ground-paper { padding-top: 0; }

/* THE PAGE COLUMN. Every band that holds page content uses this arithmetic and
   nothing restates it: `.asoc-container`, the pinned narrative's own container, and
   `.p-wrap` in proto/proto.css. `verify-grid.js` asserts they all land on one left and
   one right edge. The header's contents and the footer's two bands use the SAME
   arithmetic against `--asoc-chrome` instead, so they share that edge until the
   column binds (~1517px) and sit one gutter from the viewport's edge beyond it.

   ⚠️ NOT `padding-inline`. That would be the tidier modern form and it changes the
   element's BOX — `.asoc-container`'s rect is currently the content column exactly,
   and several probes and gates measure it. Keeping `width: min(…)` keeps that true. */
.asoc-container { width: min(100% - var(--asoc-gutter) * 2, var(--asoc-container)); margin-inline: auto; }


/* ⚠️ BEFORE YOU SCOPE `--asoc-gutter` TO ANYTHING: A LITERAL DOES NOT INHERIT, A
   CUSTOM PROPERTY DOES, and on this page that difference has teeth.

   asoc-motion.js MOVES the credibility quote into the pinned narrative stage. Once
   staged, that quote's container is a DESCENDANT of the narrative — so a scoped
   `.asoc-narrative.is-pinned { --asoc-gutter: … }` reaches it, where the literal it
   replaced never did. Measured when this was tried: the staged quote's container went
   776px wide to 751px at 801px and the section grew 48px taller, in a commit whose
   entire claim was that nothing moved.

   The token is deliberately UNSCOPED — one declaration, no overrides, no media query.
   Anything that needs a different inset should say so in its own `width`, not by
   re-pointing the token for a subtree whose membership the motion layer decides at
   runtime. */

/* THE MONO'S ENTIRE REMIT: kickers, figures, credentials. Nowhere else.
   It earns its place on function, not flavour — a mono is inherently tabular,
   so pricing columns align on the decimal and a registration number reads as a
   registration. Body copy, headings, buttons and nav must never take it. */
.asoc-kicker {
  font-family: var(--asoc-font-mono);
  font-size: var(--asoc-text-label);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  font-weight: 600;
  color: var(--asoc-kicker);
  /* 8px (2026-10-12, was 16): the heading's own leading adds ~14px above its capitals, so the
     kicker read 30px off the heading it labels. It belongs to that heading. */
  margin-bottom: var(--asoc-space-2xs);
}

.asoc-lead { font-size: var(--asoc-text-lead); line-height: 1.5; color: var(--asoc-ink-muted); }

.asoc-btn {
  display: inline-flex; align-items: center; gap: 0.6em;
  padding: 0.95em 1.7em;
  background: var(--asoc-accent);
  color: var(--asoc-accent-ink);
  font-family: var(--asoc-font-body);
  font-weight: 600;
  font-size: var(--asoc-text-body);
  border: 0; border-radius: var(--asoc-radius-pill);
  text-decoration: none; cursor: pointer;
  min-height: 44px;
  /* Two speeds on purpose, and the split is not arbitrary. The hover properties run
     at the UI duration; `color` and `border-color` run at the GROUND duration, because
     `.asoc-btn--ghost` takes `var(--asoc-ink)` and `var(--asoc-line)`, both of which
     the `html.asoc-fade-on body` block flips — so without them here the ghost button's
     text and hairline would snap while the page around them crossfaded. The solid
     button's own `--asoc-accent` and `--asoc-accent-ink` are NOT flipped, so it costs
     that variant nothing. Checked against that block rather than assumed. */
  transition: background var(--asoc-dur) var(--asoc-ease),
              transform var(--asoc-dur) var(--asoc-ease),
              box-shadow var(--asoc-dur) var(--asoc-ease),
              color var(--asoc-fade-dur) var(--asoc-fade-ease),
              border-color var(--asoc-fade-dur) var(--asoc-fade-ease);
}
.asoc-btn:hover { background: var(--asoc-accent-hover); transform: scale(1.02); }

.asoc-btn--ghost {
  background: transparent; color: var(--asoc-ink);
  box-shadow: inset 0 0 0 1.5px var(--asoc-line);
}
.asoc-btn--ghost:hover { background: rgba(255,255,255,0.06); box-shadow: inset 0 0 0 1.5px var(--asoc-accent); }

.asoc-card {
  background: var(--asoc-surface-raised);
  border-radius: var(--asoc-radius-lg);
  /* ⚠️ A PERCENTAGE, NOT A vw CLAMP, AND THE DIFFERENCE IS THE WHOLE POINT. The
     problem being solved is a card that is NARROW — three-up goes down to 235px,
     where a fixed 40px inset eats a third of the card. That is a property of the
     CARD, not of the viewport, and a `vw` clamp confuses the two: it shrank the
     full-width quote cards on a phone to 20px (5.8% of a 345px card) while the
     cards that actually needed relief were on a wide screen.
     A percentage resolves against the card's own inline size, so it narrows only
     what is narrow. 11% caps out at the existing 40px for anything 364px or wider,
     which means every card wide enough to afford the old inset keeps it exactly.
       235px card -> 26px    345px -> 38px    364px and up -> 40px (capped) */
  padding: clamp(2rem, 11%, 4rem);
  position: relative; overflow: hidden;
}

/* align-items MUST be stated. `.brxe-block` ships `align-items: flex-start` for
   its flex default, and overriding `display` to grid does NOT reset it — the
   flex value carries straight into grid layout and disables the stretch that
   makes a row of cards share a bottom edge. Left alone the three service cards
   ran to three different depths (58px spread at 1440), which reads as an
   unfinished section rather than as a bug. Same root cause as the documented
   `.brxe-block` trap: state the layout mode AND its alignment explicitly. */
.asoc-grid { display: grid; gap: var(--asoc-space-m); align-items: stretch; }

/* THE GRID'S MODIFIERS ARE DERIVED FROM THE PAGE, NOT GUESSED. A column template
   declared by more than one rule becomes a `.asoc-grid--*` modifier; a template used
   once stays in its own section's rule. `verify-tokens.js` rule eleven holds both
   halves: no multi-track template declared twice, and no modifier no builder emits.

   `.asoc-grid--2` stood here with no consumer in any builder, prototype or the
   database, and was deleted (2026-09-17) as exactly the speculative modifier this
   rule exists to stop. It would also have been wrong for its likely consumers: gap m
   and a 700px collapse, where every prototype's two-column shape uses 2xl or more.
   When a chosen direction brings a second two-column layout, make the modifier then,
   from the two real rules — they will say what its gap and collapse point are. */
.asoc-grid--3 { grid-template-columns: repeat(3, 1fr); }

/* ⚠️ A THREE-CARD GRID GOES 3 -> 1 AND NEVER PASSES THROUGH 2, BY CONSTRUCTION.
   Three cards can only strand one on its own row in a TWO-column layout: 3 into 3
   is a full row, 3 into 1 is a full column. The old `repeat(2, 1fr)` step at 1024
   was therefore guaranteed to orphan the third card at every width from 701 to
   1024, which is exactly what it did. Deleting the step removes the defect
   arithmetically rather than by finding a kinder breakpoint — there is no width
   left at which it can recur.
   Swept 66 widths from 380 to 1500, both sides of every declared breakpoint:
   one transition, zero orphans, worst title wrap two lines, narrowest three-up
   card 235px at 801. Sam chose this over stacking at 1024, which traded the
   orphan for ~250px of dead width beside every paragraph. */
@media (max-width: 800px) { .asoc-grid--3 { grid-template-columns: 1fr; } }

/* CENTRING IS AN OPT-IN, AND IT LETS GO ON A PHONE. Left-aligned is the default at
   every width; a section that is purposefully centred takes `.asoc-centred` on its
   container, and nothing else in the stylesheet may centre a section's text
   (verify-tokens.js rule twelve). It replaced four per-section overrides and two
   `margin-inline: auto`s that had to be undone again at ≤700.

   ≤700 IS LEFT-ALIGNED EVEN WHEN OPTED IN (Sam, 2026-09-03): "unless the section is
   purposefully designed for centred heading/body text, I want left aligned on mobile to
   be the primary direction. It looks more professional and is more legible." At 393px a
   centred block has ragged edges on both sides and no left margin for the eye to return
   to; a pull-quote only has the measure to carry centring on a wider screen.

   ⚠️ align-items IS HALF OF THIS, NOT A DETAIL. The container is a flex column whose
   Bricks default is align-items:flex-start, so without it every child shrinks to its
   content and hugs the left edge while text-align:center computes correctly and does
   nothing — the credibility quote's attribution did exactly that. The same
   align-items is also what centres a measure-capped child (the conversion copy at
   46ch), which is why no child needs its own auto margins. */
.asoc-centred { text-align: center; align-items: center; }
@media (max-width: 700px) { .asoc-centred { text-align: left; align-items: flex-start; } }

/* Grain — the roadmap's "subtle static texture for physical depth".
   THE HEADER CARRIES IT TOO (Sam, 2026-10-09: the header read "a slightly darker shade"
   than the hero). Both paint --asoc-surface (35,35,38); the grain lifts the hero's average
   to ~(37,37,40), measured on pixels, so the bare header sat a visible step darker on the
   same colour. Same texture on both is what makes them one surface. */
/* THE ALTERNATIVE DARK, GRAINED (kept as an option, Sam 2026-10-13). Until that date every hero,
   the header and the home's desk band wore this 3.5% grain, which renders the dark ground as
   (38,37,40) against the plain (35,35,38). Sam: every hero takes the plain ground, the same as
   About's dark "How I got here." band, so a hero runs into a dark band below it seamlessly. No
   section uses it now; to bring it back put `asoc-grain` on a section, and if that section is a
   hero add `.asoc-header::before` here too, or the header row above it is one step darker. */
.asoc-grain::before {
  content: ""; position: absolute; inset: 0; pointer-events: none; opacity: 0.035;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3'/></filter><rect width='120' height='120' filter='url(%23n)'/></svg>");
}
/* THE DESK BAND CARRIES IT TOO (Sam, 2026-10-11, concept item 11): bare, it measured
   (35,35,38) straight under the grained hero's (38,37,40), a visible step on a phone.
   The band is positioned so the grain has a box to fill, and the grain leaves with the
   dark ground (same 0.9s as the ground) so the paper stage holding the quote stays as
   clean as the paper sections after it. */
.asoc-narrative { position: relative; }
/* The band's grain went with the hero's (2026-10-13), so these two rules are dormant: they
   apply again only if the band is given the grain back, alongside a grained hero. */
.asoc-narrative::before { transition: opacity var(--asoc-fade-dur) var(--asoc-fade-ease); }
html.asoc-fade-on .asoc-narrative::before { opacity: 0; }

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; }
}

/* ==========================================================================
   04b · CHROME — header, nav, footer
   --------------------------------------------------------------------------
   ⚠️ NEITHER IS A `.asoc-section`, AND NOTHING HERE MAY MAKE THEM ONE. Every
   .asoc-section's direct container children are hidden before first paint by the
   reveal contract and shown again by GSAP. Site furniture built that way would be
   invisible on every page until the 2.5s failsafe — and the footer is where the
   privacy link, the contact address and the BAS registration number live.

   ⚠️ THE LOGO ROW IS STATIC AND SCROLLS AWAY; ONLY THE PILL IS FIXED (reversed for
   the pill by Sam, 2026-09-14; design-decisions §16). The reason the header was static
   still holds for the ROW: a fixed ANCESTOR creates a containing block for
   `position: fixed`, which is how ScrollTrigger pins — asoc-site-assets.php records
   three CSS rules that each broke the pin by 1260–1641px for exactly that reason. The
   pill is a fixed LEAF, an ancestor of nothing on the page, so it cannot do that. It
   paints its own dark surface, so crossing the paper costs it nothing.

   ⚠️ THE CHROME OPTS OUT OF THE PAGE'S COLOUR FLIP. The document-scope on-ground
   block re-points --asoc-ink, --asoc-kicker and --asoc-ink-accent to their
   on-light values whenever html.asoc-fade-on is set. The header and footer paint
   their OWN dark surfaces and are never on the paper, so following the flip put
   the footer's name, both kickers and the BAS number in near-black on near-black.
   Found by looking, not by a gate — verify-chrome measured the nav at 8.03:1 and
   was right, because it measured at scroll 0 where the ground is off. It now
   forces the class and re-measures.
   A section-level token beats the document-scope block because the section is the
   nearer ancestor, so this needs no !important and no ordering luck.
   ========================================================================== */

/* ⚠️ THIS LIST MUST COVER EVERY TOKEN THE ON-GROUND BLOCK RE-POINTS, and the first
   version covered five of eight. The two it missed that the chrome actually uses
   were --asoc-line: the footer's hairline rendered rgba(36,27,39,.22) — the
   ON-LIGHT line colour — against a #1A1A1C ground, so a border that is in the
   markup, in the computed styles and in every assertion was simply not visible.
   An incomplete opt-out fails exactly one token at a time, silently, and it had
   already cost one round on --asoc-ink.
   verify-tokens rule nine now asserts this block is a superset of the on-ground
   block, so the next token added there cannot quietly skip the chrome. */
.asoc-header,
.asoc-footer {
  --asoc-ink:            var(--asoc-ink-dark);
  --asoc-ink-muted:      rgba(246, 243, 246, 0.72);
  --asoc-ink-faint:      var(--asoc-ink-faint-dark);
  --asoc-kicker:         var(--asoc-kicker-dark);
  --asoc-ink-accent:     var(--asoc-ink-accent-dark);
  --asoc-ink-accent-2:   var(--asoc-accent-2);
  --asoc-line:           rgba(190, 150, 200, 0.22);
  --asoc-surface-raised: var(--asoc-surface-raised-dark);
  --asoc-ink-paper-warm:       #F4EFE9;
  --asoc-ink-paper-warm-muted: rgba(244, 239, 233, 0.72);
  color: var(--asoc-ink);
}

/* --- Header ---------------------------------------------------------------- */
.asoc-header {
  position: relative;
  z-index: var(--asoc-z-header);
  background: var(--asoc-surface);
  padding-block: var(--asoc-space-m);
}
/* `.brxe-block` is `display:flex; flex-direction:column`, so EVERY row in this
   file has to say `row` explicitly. Sixth time; see the note on the hero actions. */
.asoc-header__inner {
  display: flex; flex-direction: row; align-items: center; justify-content: space-between;
  gap: var(--asoc-space-m);
  /* The bar itself is full-bleed; only its CONTENTS respect the gutter — header-brief
     §2, C1 and C2. Full width by decision (Sam, 2026-09-30): the same arithmetic as
     .asoc-container but against `--asoc-chrome`, so the wordmark tracks the GUTTER, not
     the column. The two edges agree until the column binds and part beyond it. */
  width: min(100% - var(--asoc-gutter) * 2, var(--asoc-chrome));
  margin-inline: auto;
}

.asoc-header__logo { display: flex; align-items: center; flex: 0 0 auto; }
.asoc-header__logo img { display: block; height: var(--asoc-logo-h); width: auto; }
/* ⚠️ 87px, NOT THE 44px TAP-TARGET FLOOR. Her mark is a circular lockup with its
   name set around the rim, so its legibility is driven by the rim text and not by
   the disc: at 44px the whole thing read as a grey smudge on the bar, and 58px
   was still short of readable on desktop. The tap target is satisfied by the
   anchor's own min-height, so the image is free to be sized for reading rather
   than for touch.
   ⚠️ THE HEADER'S HEIGHT FOLLOWS THE LOGO. Nothing else in the bar is this tall,
   so the logo is what sets the chrome's height and any change here moves it. */
/* 87px and 62px are now --asoc-logo-h (section 08), so the hero can read the header's
   height (--asoc-header-h) instead of rediscovering it. Same values, one source. */

/* --- The pill (rebuilt 2026-10-11, docs/design-decisions.md §16) ---------------
   ONE <nav> IN TWO POSITIONS, chosen by one media query (header-brief §10): fixed and
   top-centred from 801px, a bottom bar at 800 and below. Fixed, so it is centred on
   the VIEWPORT (C3) and never negotiates its width against the logo.
   ⚠️ THE OLD "HEADER IS STATIC" DECISION IS REVERSED FOR THE PILL ONLY. The logo row
   still scrolls away. The hazard that made the header static was a fixed ANCESTOR of
   the pinned stage; the pill is a fixed LEAF in the header, which pins nothing.
   ⚠️ The pill's own ancestors must carry no transform or filter, or one becomes its
   containing block and it scrolls with the page. verify-header.js asserts it holds.
   The surface is `::before`, not the nav, so it can GROW into the Services panel
   (brief §8.2, option B) while the links stay exactly where they are. */
:root {
  --asoc-pill-h: 52px;                  /* 44px targets + 4px of surface each side */
  --asoc-pill-pad: 1.6rem;              /* a link's side padding; 12px below 1025 */
  --asoc-navbar-gap: 1.2rem;            /* the phone bar's lift off the screen edge */
  /* What the page must leave free under the phone bar: the bar, its lift, a gap
     above it, and the home indicator. No viewport units, so nothing to go wrong
     when a real phone's URL bar collapses (brief §6). */
  --asoc-navbar-clear: calc(var(--asoc-pill-h) + var(--asoc-navbar-gap) * 2 + env(safe-area-inset-bottom, 0px));
  /* The pill's surface and its active item (Sam, 2026-10-11): a deep, deep purple
     surface, and the current page or open menu as a dark purple pill with lilac text.
     Disciplined (Sam: "don't over-do ... the purple"): the active pill is the lilac
     mixed into the near-black, not a new plum, and hover is a NEUTRAL cream wash, so the
     only purple in the menu is the brand's own lilac. */
  --asoc-pill-bg:     #303030;   /* her logomark charcoal (Sam, 2026-10-12; was #120A15, near-black purple) */
  /* A sunken well, not a lighter patch (2026-10-12): on the charcoal pill, 16% lilac into the pill
     measured ~4.0:1 for the lilac label, under the 4.5 small text needs. Into the darkest surface
     it is ~5.7:1, and it reads as pressed into a raised pill. */
  --asoc-pill-active: color-mix(in srgb, var(--asoc-ink-accent-dark) 12%, var(--asoc-surface-sunk));
  /* Away fast, back with a slight settle (brief §8.2's starting values). */
  --asoc-ease-settle: cubic-bezier(0.34, 1.32, 0.64, 1);
  --asoc-ease-out-fast: cubic-bezier(0.22, 1, 0.36, 1);   /* the menu's open: most of the move early */
  --asoc-ease-away:   cubic-bezier(0.4, 0, 1, 1);
}
/* 1.1rem below 1025, from 1.2rem: the 4px between items and the arrow on Book a chat
   widened the pill, and at 801 it cleared the logo by 22.4px against C3's 24px floor. */
@media (max-width: 1024px) { :root { --asoc-pill-pad: 1.1rem; } }
/* The phone bar is 48px: 44px targets and 2px of surface above and below (see the bar). */
@media (max-width: 800px) { :root { --asoc-pill-h: 48px; } }

/* ARRIVING BY AN ANCHOR (2026-10-12): the header and footer link to /services/#bookkeeping,
   #systems, #admin and #packages. From 801px the target lands one gap below the pill, which
   is centred on the logo row, so its bottom edge is (header + pill) / 2. Below 801 the bar is
   at the foot of the screen and the target only needs a gap above it. `:target` only: it is
   the element the address names, so nothing else's scrolling changes. */
:target { scroll-margin-top: var(--asoc-space-m); }
@media (min-width: 801px) {
  :target { scroll-margin-top: calc((var(--asoc-header-h) + var(--asoc-pill-h)) / 2 + var(--asoc-space-m)); }
}

.asoc-pill {
  position: fixed; z-index: var(--asoc-z-nav);
  left: 50%; translate: -50% 0;
  /* ⚠️ Bricks gives every .brxe-block `width: 100%`: without this the pill is the
     whole viewport wide, measured, and "centred" by construction. */
  width: max-content;
  /* Level with the logo's centre line while the logo row is in view. */
  top: calc((var(--asoc-header-h) - var(--asoc-pill-h)) / 2);
  isolation: isolate;
  transition: transform 0.6s var(--asoc-ease-settle);
}
/* Away: up past the top edge, far enough that the shadow goes too. */
.asoc-pill.is-away {
  transform: translateY(calc(-100% - var(--asoc-header-h)));
  transition: transform 0.4s var(--asoc-ease-away);
}
/* THE SURFACE. Closed it is the pill's own box; open, asoc-header.js writes the
   panel's measured size into --hdr-surf-w/-h and this grows to it in 0.4s. Width and
   height, not scale: a scaled pill would stretch its radius and its hairline. */
.asoc-pill::before {
  content: ""; position: absolute; z-index: var(--asoc-z-surface);
  top: 0; left: 50%; translate: -50% 0;
  width: var(--hdr-surf-w, 100%); height: var(--hdr-surf-h, 100%);
  border-radius: calc(var(--asoc-pill-h) / 2);
  background: var(--asoc-pill-bg);
  /* No outline (Sam, 2026-10-11). Charcoal one step above the dark ground (2026-10-12): a
     faint top edge and a neutral shadow lift it; the lilac glow went with the purple. */
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.07),
              0 1px 2px rgba(0, 0, 0, 0.35), 0 12px 28px -10px rgba(0, 0, 0, 0.55);
  /* 0.4s on a fast-start ease, from 0.65s ease-in-out (Sam, 2026-10-13: "the menu expands
     too slowly"). An ease-in-out spends its first third barely moving. */
  transition: width 0.4s var(--asoc-ease-out-fast), height 0.4s var(--asoc-ease-out-fast),
              border-radius 0.4s var(--asoc-ease-out-fast), box-shadow 0.4s var(--asoc-ease);
}
.asoc-pill.is-open::before { border-radius: 2.4rem; }
@media (hover: hover) and (pointer: fine) {
  .asoc-pill:hover::before {
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.09),
                0 1px 2px rgba(0, 0, 0, 0.35), 0 16px 34px -10px rgba(0, 0, 0, 0.65);
  }
}
/* The open panel grows over the logo row, and measured it covered the mark's lower
   two thirds with the rim peeking out above. The panel takes over that area while it
   is open, so the mark steps out of the way rather than being half-covered. */
.asoc-header__logo { transition: opacity var(--asoc-dur) var(--asoc-ease); }
.asoc-header:has(.asoc-pill.is-open) .asoc-header__logo { opacity: 0; }

/* `.brxe-block` is display:flex column; every row here says `row` explicitly. */
.asoc-pill__list {
  display: flex; flex-direction: row; flex-wrap: nowrap; align-items: center;
  /* 4px between items: hover and active pills sat edge to edge (Sam, 2026-10-11). */
  gap: 4px;
  margin: 0; padding: 4px; list-style: none;
}
.asoc-pill__item { display: flex; flex-direction: row; margin: 0; }
.asoc-pill__item--cta { margin-inline-start: 2px; }
@media (min-width: 801px) { .asoc-pill__item--home { display: none; } }

/* The current page and the hovered item get a soft pill of their own, drawn INSIDE
   the item's box, so it is right whatever a label's length or however late the font
   loads — the property the brief wanted from Humaan's grid-column twin (§10), without
   a twin. A mix of the ink, not a literal, so it follows the chrome's inks. */
.asoc-pill__link {
  display: inline-flex; align-items: center; gap: 0.5rem;
  min-height: 44px; padding: 0 var(--asoc-pill-pad);
  border: 0; border-radius: var(--asoc-radius-pill);
  background: transparent; color: var(--asoc-ink-muted);
  font-family: var(--asoc-font-body); font-size: var(--asoc-text-small); font-weight: 500;
  line-height: 1; white-space: nowrap; text-decoration: none; cursor: pointer;
  transition: color var(--asoc-dur) var(--asoc-ease), background-color var(--asoc-dur) var(--asoc-ease);
}
.asoc-pill__link:hover {
  color: var(--asoc-ink);
  background-color: color-mix(in srgb, var(--asoc-ink) 8%, transparent);
}
.asoc-pill__link[aria-current],
.asoc-pill__link[aria-current]:hover {
  color: var(--asoc-ink-accent-dark);     /* the lilac, as a text token */
  background-color: var(--asoc-pill-active);
}
/* OPEN IS NOT CURRENT (Sam, 2026-10-13: "Hover needs its own styling"). The open Services
   button wore the current-page lilac, so hovering it looked like being on Services. Open,
   it keeps the hover wash instead, and holds it while the pointer is down in the panel.
   On /services/ the button is also current, and current wins. */
.asoc-pill__trigger[aria-expanded="true"]:not([aria-current]) {
  color: var(--asoc-ink);
  background-color: color-mix(in srgb, var(--asoc-ink) 8%, transparent);
}
/* THE GLIDING HIGHLIGHT (Sam, 2026-10-11: the hover "follows the mouse when it's inside
   the pill"). asoc-header.js adds one .asoc-pill__glide to the list and moves it under
   whichever item the pointer is over, leaning a few px towards the pointer, with the
   settle ease so it reads as sticky rather than mechanical. Once it is there, the
   links' own hover wash is switched off so the two never double up. Fine pointers and
   desktop only; everywhere else the plain hover above still works. */
/* ⚠️ THE ITEMS MUST STAY UNPOSITIONED: the Services panel hangs from the PILL because its
   <li> is not a containing block (build-header.py). Positioning the items to lift them
   over the highlight moved the panel and clipped its first column (2026-10-11). So the
   highlight goes UNDER them instead, at -1 inside the list's own stacking context. */
.asoc-pill__list { position: relative; isolation: isolate; }
.asoc-pill__glide {
  position: absolute; z-index: var(--asoc-z-surface); top: 4px; left: 0; height: 44px; width: 0;
  border-radius: var(--asoc-radius-pill); pointer-events: none;
  background: color-mix(in srgb, var(--asoc-ink) 8%, transparent);
  opacity: 0;
  transition: transform 0.45s var(--asoc-ease-settle), width 0.45s var(--asoc-ease-settle),
              opacity 0.25s var(--asoc-ease);
}
.asoc-pill__glide.is-on { opacity: 1; }
/* Under the pointer the glide is the wash, so the open button's own one steps aside. The
   panel sits inside the list, so the glide stays under Services while the pointer is down in
   it: while the glide is showing, the open button never adds a second wash of its own. */
.asoc-pill.has-glide .asoc-pill__link:hover:not([aria-current]),
.asoc-pill.has-glide .asoc-pill__list:has(> .asoc-pill__glide.is-on) .asoc-pill__trigger[aria-expanded="true"]:not([aria-current]) { background-color: transparent; }
.asoc-pill__chev { flex: none; transition: transform 0.4s var(--asoc-ease-out-fast); }
.asoc-pill__trigger[aria-expanded="true"] .asoc-pill__chev { transform: rotate(180deg); }

/* The menu's button replaces the plain link only once the script has bound, and only
   where there is a menu to open. Without JS, every width gets the link. */
.asoc-pill__trigger { display: none; }
@media (min-width: 801px) {
  html.asoc-hdr-js .asoc-pill__trigger { display: inline-flex; }
  html.asoc-hdr-js .asoc-pill__fallback { display: none; }
}

/* "Contact" is an ordinary item since 2026-10-11 (build-header.py says why), so it has no
   rules of its own here; the lilac "Book a chat" button that stood here is gone. */

/* --- The Services panel ---------------------------------------------------------
   Hangs from the pill, centred like it, at the CHROME's width (Sam, 2026-10-10: the
   page's full chrome width, not the mock's 1100px). The width is the header row's own,
   measured by the script into --hdr-open-w, so its edges are the logo row's edges by
   construction. Closed it is visibility:hidden, so none of its links can be tabbed to;
   the visibility flips at the END of the close, so the content can leave first. */
.asoc-megamenu {
  position: absolute; top: 100%; left: 50%; translate: -50% 0;
  width: var(--hdr-open-w, 0px);
  /* ⚠️ Bricks gives every .brxe-block `max-width: 100%`, which is 100% of the PILL:
     measured, the panel came out 565px wide at 801 with --hdr-open-w at 753. */
  max-width: none;
  padding: var(--asoc-space-m) var(--asoc-space-l) var(--asoc-space-s);
  visibility: hidden; pointer-events: none;
  transition: visibility 0s linear 0.4s;   /* the surface's close */
}
.asoc-pill.is-open .asoc-megamenu { visibility: visible; pointer-events: auto; transition: visibility 0s; }

/* CONTENT-DRIVEN COLUMNS (brief §5): the pillars auto-fit, so a menu with two or four
   uses this one rule; the feature column is fixed beside them. */
.asoc-megamenu__grid {
  display: grid; grid-template-columns: minmax(0, 1fr) minmax(22rem, 30rem);
  gap: var(--asoc-space-l);
  max-width: var(--asoc-container); margin-inline: auto;
}
.asoc-megamenu__pillars {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
  gap: var(--asoc-space-s);
  margin: 0; padding: 0; list-style: none;
}
.asoc-megamenu__col { margin: 0; }
/* Below 1025 the Packages card goes UNDER the pillars, as a row, so the three keep a
   column each (~220px at 801) rather than stacking into one 145px column. */
@media (max-width: 1024px) {
  .asoc-megamenu__grid { grid-template-columns: minmax(0, 1fr); gap: var(--asoc-space-s); }
  .asoc-megamenu__pillars { grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); }
  /* ⚠️ `.asoc-megamenu` PREFIXED FOR SPECIFICITY: the feature's base rule comes LATER
     in this file, and at equal weight it won — measured, the card stayed a column and
     the 24rem basis became a 240px HEIGHT. */
  .asoc-megamenu .asoc-megamenu__feature { flex-direction: row; flex-wrap: wrap; align-items: center; column-gap: var(--asoc-space-m); }
  .asoc-megamenu .asoc-megamenu__feature > .asoc-megamenu__desc { flex: 1 1 24rem; }
  .asoc-megamenu .asoc-megamenu__feature > .asoc-kicker { flex-basis: 100%; }
}

/* Each pillar is ONE link: the number, the name and its line, so the whole column is
   the target. */
.asoc-megamenu__link {
  display: flex; flex-direction: column; align-items: flex-start; gap: 0.6rem;
  height: 100%; padding: var(--asoc-space-s);
  border-radius: 1.6rem; color: var(--asoc-ink); text-decoration: none;
  transition: background-color var(--asoc-dur) var(--asoc-ease);
}
.asoc-megamenu__link:hover { background-color: color-mix(in srgb, var(--asoc-ink) 6%, transparent); }
.asoc-megamenu__num {
  font-family: var(--asoc-font-mono); font-size: 1.3rem; letter-spacing: 0.04em;
  color: var(--asoc-ink-accent);
}
.asoc-megamenu__title {
  font-family: var(--asoc-font-display); font-weight: 600; letter-spacing: -0.01em;
  font-size: var(--asoc-text-h4, 2.2rem); line-height: 1.15;
}
.asoc-megamenu__desc {
  font-size: var(--asoc-text-small); line-height: 1.45; color: var(--asoc-ink-muted); margin: 0;
}
/* The feature: a dark object on the dark chrome, hairline all the way round (never one
   edge), like her business card in the footer. */
.asoc-megamenu__feature {
  display: flex; flex-direction: column; align-items: flex-start; gap: var(--asoc-space-xs);
  padding: var(--asoc-space-m);
  border-radius: 1.6rem; box-shadow: inset 0 0 0 1px var(--asoc-line);
  background: color-mix(in srgb, var(--asoc-ink) 4%, var(--asoc-pill-bg));   /* the surface, one neutral step up */
}
.asoc-megamenu__feature > .asoc-kicker { margin: 0; }
.asoc-megamenu__more { font-size: var(--asoc-text-small); }   /* the rest is the go-further link */
.asoc-megamenu__more::after {
  content: ""; width: 1.4rem; height: 1rem; background-color: currentColor;
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 10'%3E%3Cpath d='M1 5h11M8.5 1.5 12 5l-3.5 3.5' fill='none' stroke='%23000' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat center / contain;
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 10'%3E%3Cpath d='M1 5h11M8.5 1.5 12 5l-3.5 3.5' fill='none' stroke='%23000' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat center / contain;
}
.asoc-megamenu__foot {
  display: flex; flex-direction: row; justify-content: flex-end;
  max-width: var(--asoc-container); margin: var(--asoc-space-s) auto 0;
  padding-top: var(--asoc-space-2xs); border-top: 1px solid var(--asoc-line);
}

/* THE CONTENTS ARRIVE AFTER THE SURFACE HAS STARTED GROWING, one column at a time,
   70ms apart with a small overshoot (brief §8.2; Sam's motion rule: paced by stagger
   and easing, not by fades). They leave at once: a menu that takes 400ms to agree to
   shut feels broken. */
.asoc-megamenu__pillars > li,
.asoc-megamenu__feature,
.asoc-megamenu__foot {
  opacity: 0; transform: translateY(12px);
  transition: opacity 0.15s linear, transform 0.15s linear;
}
.asoc-pill.is-open .asoc-megamenu__pillars > li,
.asoc-pill.is-open .asoc-megamenu__feature,
.asoc-pill.is-open .asoc-megamenu__foot {
  opacity: 1; transform: none;
  transition: opacity 0.35s var(--asoc-ease), transform 0.5s var(--asoc-ease-settle);
}
/* Halved with the surface (2026-10-13): the first column lands as the surface reaches it. */
.asoc-pill.is-open .asoc-megamenu__pillars > li:nth-child(1) { transition-delay: 0.12s; }
.asoc-pill.is-open .asoc-megamenu__pillars > li:nth-child(2) { transition-delay: 0.16s; }
.asoc-pill.is-open .asoc-megamenu__pillars > li:nth-child(3) { transition-delay: 0.20s; }
.asoc-pill.is-open .asoc-megamenu__feature                   { transition-delay: 0.24s; }
.asoc-pill.is-open .asoc-megamenu__foot                      { transition-delay: 0.28s; }

/* --- The phone bar (C9–C11) -----------------------------------------------------
   The same nav, moved to the bottom and holding only Services and Book a chat (Sam,
   2026-10-10). It does NOT inherit the desktop pill's scroll-away: a bottom bar sits
   under the thumb and hiding it costs the only navigation there is (brief §6). Two
   rules, on purpose. Services is the plain link here; the page does the menu's work.
   It replaces the burger and the full-screen panel, and with them the 2.27:1 burger on
   the lilac band that the brief accepted only until this landed. */
@media (max-width: 800px) {
  .asoc-pill {
    top: auto; bottom: calc(var(--asoc-navbar-gap) + env(safe-area-inset-bottom, 0px));
  }
  .asoc-pill.is-away { transform: none; }
  /* AT THE FOOTER IT LEAVES (Sam, 2026-10-18): down past the bottom edge, the shadow with it, and out of the tab order
     once it has gone (visibility waits for the move). The footer carries every link the bar does. */
  .asoc-pill.is-footer {
    transform: translateY(calc(100% + var(--asoc-navbar-gap) + env(safe-area-inset-bottom, 0px) + 24px));
    visibility: hidden;
    transition: transform 0.4s var(--asoc-ease-away), visibility 0s linear 0.4s;
  }
  /* HUMAAN'S PROPORTIONS (Sam, 2026-10-11, from their mobile menu): the bar runs nearly
     the screen's width with the items spread evenly, smaller labels, and a smaller active
     pill inside it. Four items: Home, About, Services, Contact. Home is here so the
     bar can show the current page; Testimonials and Tips & Toolkit are in the footer
     (five did not fit at 320 without targets under 44px).
     THE ACTIVE PILL IS DRAWN SMALLER THAN THE TARGET: each item stays 44px tall (the tap
     target), and its background is clipped inside a transparent 5px top and bottom border,
     so the visible pill is 34px in a 48px bar (Humaan's ~0.66 ratio, ours 0.71). */
  /* Near the full width on a phone; capped at 42rem and centred on a tablet, where four
     items spread across 776px read as sparse rather than uniform. */
  .asoc-pill { width: calc(100% - var(--asoc-navbar-gap) * 2); max-width: 42rem; }
  .asoc-pill__item--wide,
  .asoc-pill__trigger,
  .asoc-megamenu { display: none; }
  .asoc-pill__item--wide:has(a[href*="/about/"]) { display: flex; }
  .asoc-pill__list { gap: 0; justify-content: space-between; padding: 2px 4px; }
  .asoc-pill__link {
    line-height: 1; min-height: 44px; padding: 0 clamp(1rem, 2.4vw, 1.4rem);
    border-block: 5px solid transparent; background-clip: padding-box;   /* inset top and bottom only */
    /* CONCENTRIC WITH THE PILL (Sam, 2026-10-12: "it looks like an oval"). The radius applies
       to the border box, so 999px gave the visible (padding-box) highlight 22 x 17px corners: an
       ellipse. The highlight's edge sits 7px inside the pill's (2px list + 5px border), so its
       radius is the pill's minus 7 on BOTH axes: horizontal r, vertical r + the 5px border. */
    border-radius: calc(var(--asoc-pill-h) / 2 - 7px) / calc(var(--asoc-pill-h) / 2 - 2px);
    font-size: clamp(1.35rem, 0.6rem + 2.2vw, 1.45rem);
  }
  /* C11: the page's last line must clear the bar. Paid by the footer's bottom ROW (its own
     padding-bottom, in the footer block below), not the footer's or the body's, so no strip of
     another ground shows under the footer and the clearance cannot be paid twice. */
}

/* --- Footer --------------------------------------------------------------- */
/* ── THE FOOTER: THE PAPER PLANE (Sam, 2026-10-17: "make the latest paper plane footer the site wide footer") ──────────
   Ported from the dev review page /review/paper-plane-footer/ (the concept's f2.css + kit-footer.css), keeping ONLY the rules
   its default state uses: the low layout, the monitor arrangement, the plain bottom row. Every rule conditioned on a state
   the page never opens to (?lay=wall|after, ?alt, the strip / tab / tabs styles, the charcoal tone, the review badge,
   pause and step) was deleted, not parked. scripts/build-footer.py says what is in it; src/asoc-footer.js draws the desk.
   Her line is the call to action; the links sit level with the monitor; the desk runs off the left; her logomark hangs
   top right and the plane flies into it. It is ONE screen at the end of the page and the content is ONE group anchored to
   the bottom: spare height collects above her line, never at the bottom.

   THE THREE STEPS are classes on <html> set by asoc-footer.js from the footer's own width, so CSS and script can never
   disagree about which one is showing: BIG from 1200, MID 801 to 1199 (the links stack beside the desk's corner), COL at
   800 and under (one centred column, no desk, the logo closes it). Measured 2026-10-17: 1150 is mid, 1210 is big.
   Without the script none of the three is set, the stage stays hidden, and the footer is her line, the links and the row.

   ⚠️ NO MARGIN ABOVE THE FOOTER, still. A margin here is transparent, so the page's ground paints through it: on the paper
   run that was a 132-160px cream band between the lilac conversion band and the footer.
   ⚠️ THE BOTTOM ROW IS INSIDE THE FOOTER (the last child), so the dark behind the desk's legs runs on down behind it with
   nothing to clip. The review page kept the row outside and clipped the footer round it.
   ⚠️ THE PHONE BAR'S CLEARANCE (C11) IS PAID IN ONE PLACE: the row's own bottom padding, at 800 and under. The footer's own
   bottom padding is 0, so it cannot double. Full width like the header (Sam, 2026-09-30): `--asoc-chrome`, not the column. */
.asoc-footer {
  position: relative; z-index: var(--asoc-z-section);
  overflow: hidden; overflow: clip;      /* the desk runs off the left and the plane never leaves the footer */
  background: var(--asoc-surface-sunk);
  padding-block: clamp(3.2rem, 5vh, 6.4rem) 0;   /* the top is reset by asoc-footer.js (spare height collects above her line) */
}
/* The stage is shown only once asoc-footer.js has laid it out: a no-JS footer never shows an empty button or a stray logo
   over her line. */
.asoc-footer__mid { display: block; padding: 0; border: 0; visibility: hidden; }
html.asoc-plane-ready .asoc-footer__mid { visibility: visible; }
.asoc-footer__top,
.asoc-footer__mid,
.asoc-footer__base {
  width: min(100% - var(--asoc-gutter) * 2, var(--asoc-chrome));
  margin-inline: auto;
}

/* 1 · THE CLOSE. Her line and, level with the desk's monitor, the links. The band sits on the gutter at every width; the
   56rem measure the one-column step needs lives on the inner wrapper, so the band itself still agrees with every other
   chrome band (verify-grid). */
.asoc-footer__top { display: block; }
.asoc-footer__measure { display: block; width: 100%; }
.asoc-footer__measure > * { position: relative; z-index: var(--asoc-z-paper); }
.asoc-footer__lead { display: flex; flex-direction: column; align-items: flex-start; gap: var(--asoc-space-l); }
.asoc-footer__line {
  margin: 0; max-width: 15ch;
  font-family: var(--asoc-font-display);
  font-weight: var(--asoc-display-wght);
  font-size: min(var(--asoc-text-h1), 8.6vh);
  line-height: var(--asoc-display-leading);
  letter-spacing: var(--asoc-display-tracking);
  color: var(--asoc-ink-paper-warm);
  text-wrap: balance;
}
html.asoc-plane-big .asoc-footer__line { font-size: clamp(7.2rem, 5vw, 9.576rem); }
html.asoc-plane-mid .asoc-footer__line { font-size: clamp(5.3rem, 1.476rem + 4.774vw, 7.2rem); }

/* HER LINE IS THE CALL TO ACTION (Sam, 2026-10-15): "Get your admin back on track" with an arrow at the end of it, tied
   to the last word so it never wraps alone. The whole line is the link; hovering any of it fills a circle round the arrow,
   eases it forward, and runs the arrow out to the right as a fresh one slides in from the left. The arrow is sized in em,
   so it follows the line's scale at every width. Same destination as the old button, and the same name for anyone using a
   screen reader. */
.asoc-plane-cta-line { color: var(--asoc-ink-paper-warm); text-decoration: none; border-radius: 6px; -webkit-tap-highlight-color: transparent; }
.asoc-plane-cta-line:focus-visible { outline: 2px solid var(--asoc-accent); outline-offset: 6px; }
.asoc-plane-nowrap { white-space: nowrap; }
.asoc-plane-go {
  position: relative; display: inline-block; width: 0.74em; height: 0.74em; margin-left: 0.12em; vertical-align: -0.05em;
  border-radius: 50%; overflow: hidden; color: var(--asoc-ink-accent);
  box-shadow: inset 0 0 0 max(2px, 0.04em) transparent;
  transition: background-color 0.35s var(--asoc-ease), color 0.35s var(--asoc-ease), translate 0.5s var(--asoc-ease-settle), box-shadow 0.35s var(--asoc-ease);
}
.asoc-plane-go > span { position: absolute; inset: 0; display: grid; place-items: center; transition: transform 0.5s var(--asoc-ease-settle); }
/* sized in em, not %: in the circle's grid a % resolved against a different box on the live page (72px, clipped), and
   Bricks gives an svg without a width a min-width of 1em */
.asoc-plane-go svg {
  width: 0.55em; height: 0.55em; max-width: none; min-width: 0; min-height: 0; overflow: visible; fill: none; stroke: currentColor;
  stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round;
  transition: width 0.35s var(--asoc-ease), height 0.35s var(--asoc-ease);
}
.asoc-plane-go__b { transform: translateX(-110%); }
.asoc-plane-cta-line:hover .asoc-plane-go svg, .asoc-plane-cta-line:focus-visible .asoc-plane-go svg { width: 0.385em; height: 0.385em; }
.asoc-plane-cta-line:hover .asoc-plane-go, .asoc-plane-cta-line:focus-visible .asoc-plane-go { background-color: var(--asoc-accent); color: var(--asoc-plum); translate: 0.1em 0; }
.asoc-plane-cta-line:hover .asoc-plane-go__a, .asoc-plane-cta-line:focus-visible .asoc-plane-go__a { transform: translateX(110%); }
.asoc-plane-cta-line:hover .asoc-plane-go__b, .asoc-plane-cta-line:focus-visible .asoc-plane-go__b { transform: translateX(0); }

/* THE LINKS (Sam, 2026-10-15: "use a font size, colour, and spacing that is suitable for the footer. You're just applying
   the same font used in the header"). The header menu's FACE only (Schibsted Grotesk 500); footer-sized: 17px at 801 easing
   to 18px at 1200-1440, growing with the footer's scale to 24px; full ink, lilac on hover; rows 1.75em with a mouse (32px at
   18px), 44px on touch; the label close to its list; the two groups 64px apart at 1440. Group labels are h2s so a
   screen-reader user can jump between them; they LOOK like the site's kickers because that is what they are doing. */
.asoc-footer__nav {
  display: grid; grid-template-columns: repeat(2, max-content);
  row-gap: var(--asoc-space-l); column-gap: var(--asoc-space-xl);
}
/* BIG and MID: the links are placed by asoc-footer.js (left and top), level with the monitor's top */
html.asoc-plane-big .asoc-footer__measure > .asoc-footer__nav,
html.asoc-plane-mid .asoc-footer__measure > .asoc-footer__nav { position: absolute; left: 0; top: 0; margin: 0; width: max-content; max-width: none; }
html.asoc-plane-big .asoc-footer__nav { column-gap: clamp(6.4rem, 4.4444vw, 8.512rem); }
html.asoc-plane-mid .asoc-footer__nav { column-gap: clamp(4.4rem, 2rem + 3vw, 6.4rem); }
.asoc-footer__label {
  margin: 0 0 0.6rem;
  font-family: var(--asoc-font-mono); font-weight: 600; font-size: var(--asoc-text-label);
  letter-spacing: 0.14em; text-transform: uppercase; line-height: 1.4; color: var(--asoc-kicker);
}
html.asoc-plane-big .asoc-footer__label { font-size: clamp(1.3rem, 0.9028vw, 1.729rem); margin-bottom: 0.75em; }
html.asoc-plane-mid .asoc-footer__label { margin-bottom: 0.75em; }
.asoc-footer__list { list-style: none; margin: 0; padding: 0; display: block; }
.asoc-footer__link {
  display: inline-flex; align-items: center; min-height: 1.75em; padding-block: 0;
  font-family: var(--asoc-font-body); font-weight: 500; letter-spacing: 0;
  font-size: 1.7rem; line-height: 1.2;
  color: var(--asoc-ink-paper-warm); text-decoration: none;
  transition: color var(--asoc-dur) var(--asoc-ease);
}
html.asoc-plane-big .asoc-footer__link { font-size: clamp(1.8rem, 1.25vw, 2.394rem); }
html.asoc-plane-mid .asoc-footer__link { font-size: clamp(1.7rem, 1.499rem + 0.2513vw, 1.8rem); }
@media (pointer: coarse) { .asoc-footer__link { min-height: 44px; } }
/* MID on touch (2026-10-18): 44px ROWS made the links 204px tall at 820, and lowMon widens the desk until the scene is as
   tall as the links, so the desk ran under them and "Contact" sat on its top. The rows keep the mouse's 1.75em and the
   44px is the TAP AREA, drawn round each link and taking no space; neighbours overlap by about 7px in the gaps, and a tap
   on any word lands on its own link. */
@media (pointer: coarse) {
  html.asoc-plane-mid .asoc-footer__link { min-height: 1.75em; position: relative; }
  html.asoc-plane-mid .asoc-footer__link::after {
    content: ""; position: absolute; left: 0; right: 0;
    top: calc((1.75em - 44px) / 2); bottom: calc((1.75em - 44px) / 2);
  }
}
.asoc-footer__link:hover, .asoc-footer__link:focus-visible { color: var(--asoc-ink-accent); }

/* COL: one column, LEFT-ALIGNED on the page's own edge like every other section (Sam, 2026-10-18; it was centred,
   which read as a narrow column floating in a wide footer on tablets). No desk. The logo sits top right level with
   her line where there is room beside it (from ~600), and otherwise closes the column on the left under the links:
   asoc-footer.js places it (lowPlace). */
html.asoc-plane-col .asoc-footer__measure { width: 100%; margin-inline: 0; }
html.asoc-plane-col .asoc-footer__lead { align-items: flex-start; }
html.asoc-plane-col .asoc-footer__nav { margin: var(--asoc-space-2xl) 0 0; width: auto; }

/* 2 · THE STAGE: her desk (bottom left), the logo, the invoice and her note, drawn by asoc-footer.js from the footer's
   own width. Everything on it is placed from the footer's own coordinates, never by flow. Bricks blocks arrive as
   display:flex columns at width 100%: each one that is positioned says what it is instead. */
.asoc-plane-defs { position: absolute; width: 0; height: 0; overflow: hidden; }
.asoc-plane-t { font-family: var(--asoc-font-mono); }
.asoc-plane-wrap { display: contents; }
.asoc-plane-arc { fill: none; }   /* the guide "back to top" runs along: it sits in defs and never paints, this keeps it that way */
.asoc-plane-desk { display: block; width: auto; }
.asoc-plane-scene { display: contents; }
.asoc-plane-scene > svg { position: absolute; left: 0; top: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; z-index: var(--asoc-z-ground); }

/* THE TARGET: her logomark, face-on, only a soft shadow; the effects over it in one SVG. */
.asoc-plane-logo { position: absolute; left: 0; top: 0; width: 120px; aspect-ratio: 1; z-index: var(--asoc-z-section); }
.asoc-plane-logo img { display: block; width: 100%; height: 100%; border-radius: 50%; box-shadow: 0 5px 14px rgba(0, 0, 0, 0.42); }
.asoc-plane-fx { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
/* On 1x screens the iris's filters alias into fine streaks (they are worked out per device pixel). Drawn at twice the
   size on its own layer and scaled down, the layer is supersampled; 2x screens need none of it. */
@media (max-resolution: 1.5dppx) {
  .asoc-plane-fx { width: 200%; height: 200%; transform: scale(0.5); transform-origin: 0 0; will-change: transform; }
}

/* THE INVOICE: lying flat, front right. The button is the sheet's box; its own SVG draws the foreshortened sheet, so hover
   and focus lift the thing you click. */
.asoc-plane-invoice {
  position: absolute; left: 0; top: 0; display: block; width: auto; padding: 0; border: 0; margin: 0; z-index: var(--asoc-z-staged);
  background: none; cursor: pointer; border-radius: 3px; -webkit-tap-highlight-color: transparent;
  transition: translate 0.4s var(--asoc-ease);
}
.asoc-plane-invoice svg { display: block; width: 100%; height: 100%; overflow: visible; }
.asoc-plane-invoice:focus-visible { outline: 2px solid var(--asoc-accent); outline-offset: 6px; }
.asoc-plane-invoice.is-gone { visibility: hidden; }
/* On hover the sheet rises and tilts a touch, then drifts gently while the pointer stays, as paper would on a breath of
   air; its shadow stays on the desk, softens and falls back as the sheet rises (Sam, 2026-10-16). Before the click only. */
.asoc-plane-inv__sheet, .asoc-plane-inv__sh { transform-box: fill-box; transform-origin: 50% 70%;
  transition: transform 0.55s var(--asoc-ease-settle), opacity 0.55s var(--asoc-ease); }
@media (hover: hover) and (pointer: fine) {
  .asoc-plane-invoice:hover .asoc-plane-inv__sheet { transform: translateY(-5px) rotate(-1.4deg) skewX(-2deg) scale(1.015);
    animation: asoc-plane-paper 2.8s ease-in-out 0.55s infinite; }
  .asoc-plane-invoice:hover .asoc-plane-inv__sh { transform: translate(2px, 3px) scale(1.05); opacity: 0.24; }
}
.asoc-plane-invoice:focus-visible .asoc-plane-inv__sheet { transform: translateY(-5px) rotate(-1.4deg) skewX(-2deg) scale(1.015); }
@keyframes asoc-plane-paper {
  0%, 100% { transform: translateY(-5px) rotate(-1.4deg) skewX(-2deg) scale(1.015); }
  35% { transform: translateY(-6.5px) rotate(-0.5deg) skewX(-0.8deg) scale(1.02); }
  70% { transform: translateY(-5.5px) rotate(-1.9deg) skewX(-2.6deg) scale(1.012); }
}

/* HER NOTE beside the desk: placed from the footer's own top and height by asoc-footer.js, never by flow, so nothing that
   appears or disappears (the invoice, the button) can move it. "Another one?" is absolute inside the note's reserved slot
   and takes no flow space. */
.asoc-plane-note { position: absolute; left: 0; top: 0; display: block; width: 250px; z-index: var(--asoc-z-staged); color: var(--asoc-ink-accent); }
/* ⚠️ `[data-note]` (the handwriting block, later in this file) sets z-index paper and pointer-events none at (0,1,0): the note's
   own button must stay clickable and its layer stay above the desk, so the footer says so at (0,2,0). */
.asoc-footer .asoc-plane-note { z-index: var(--asoc-z-staged); pointer-events: auto; }
.asoc-plane-ink { display: block; width: 100%; height: var(--nlh, 50px); pointer-events: none; }
.asoc-plane-ink svg { overflow: visible; display: block; }
.asoc-plane-live { margin: 0; padding: 0; }   /* the live region ("Desk cleared."): read aloud, never seen (asoc-sr-only) */
/* The small arrowhead: opacity and a short nudge toward the invoice; --asoc-plane-nx / -ny carry the direction. */
.asoc-plane-arrow { display: contents; }
.asoc-plane-arrow > svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; z-index: var(--asoc-z-staged); color: var(--asoc-ink-accent); }
.asoc-plane-arrow__line, .asoc-plane-arrow__head { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
@keyframes asoc-plane-nudge {
  0%, 100% { transform: translate(0, 0); opacity: 0.6; }
  50% { transform: translate(var(--asoc-plane-nx, -2px), var(--asoc-plane-ny, 1px)); opacity: 1; }
}
.asoc-plane-arrow > svg.is-pulse .asoc-plane-arrow__head { animation: asoc-plane-nudge 1.8s cubic-bezier(0.45, 0, 0.55, 1) infinite; }
.asoc-plane-again {
  position: absolute; left: 0; top: calc(var(--nlh, 50px) + 4px); display: block; width: auto;
  min-height: 44px; padding: 0; margin: 0;
  border: 0; background: none; cursor: pointer; color: var(--asoc-ink-muted);
  font: 400 var(--asoc-text-small)/1.3 var(--asoc-font-body);
  background-image: linear-gradient(var(--asoc-ink-accent), var(--asoc-ink-accent));
  background-repeat: no-repeat; background-position: 0 calc(50% + 0.8em); background-size: 0 1.5px;
  transition: color var(--asoc-dur) var(--asoc-ease), background-size 0.35s var(--asoc-ease);
}
.asoc-plane-again[hidden] { display: none; }
.asoc-plane-again:hover, .asoc-plane-again:focus-visible { color: var(--asoc-ink); background-size: 100% 1.5px; }
html.asoc-plane-big .asoc-plane-again { font-size: clamp(1.6rem, 1.1111vw, 2.128rem); }
html.asoc-plane-col .asoc-plane-scene > svg, html.asoc-plane-col .asoc-plane-invoice,
html.asoc-plane-col .asoc-plane-note, html.asoc-plane-col .asoc-plane-arrow > svg { display: none; }

/* THE SKY: the layer the paper folds and flies in. Over the whole footer, never catches a pointer, and masked by the
   target's opening so a plane that reaches it goes IN. The footer is its own stacking context, so it never reaches the
   header. */
.asoc-plane-sky { position: absolute; inset: 0; z-index: var(--asoc-z-sky); pointer-events: none; }
.asoc-plane-fold { position: absolute; left: 0; top: 0; transform-origin: 50% 50%; }
.asoc-plane-fold__turn { width: 100%; height: 100%; }
.asoc-plane-fold svg { display: block; width: 100%; height: 100%; overflow: visible; }
.asoc-plane-plane { position: absolute; left: 0; top: 0; width: 140px; height: 50px; margin: -25px 0 0 -140px; transform-origin: 140px 25px; }
.asoc-plane-plane svg { display: block; width: 140px; height: 50px; overflow: visible; filter: drop-shadow(0 10px 7px rgba(0, 0, 0, 0.32)); }

/* 3 · THE BOTTOM ROW, plain, the last child of the footer: copyright, her registration, Privacy, Terms, and her hand-drawn
   back to top. 13px words in the warm paper ink (the plain ones at 72%), Privacy and Terms lilac on hover; from 801 the
   words sit together on the right, before back to top, clear of the desk. No divider: the footer runs on behind the row, so
   the desk's legs reach the bottom of the page and the row is not a separate bar (Sam, 2026-10-16). */
.asoc-footer__base {
  display: flex; flex-direction: row; flex-wrap: wrap; align-items: center;
  gap: 0 var(--asoc-space-l);
  min-height: clamp(60px, 4.17vw, 79.8px); padding-block: 0.4rem;
  position: relative; z-index: var(--asoc-z-paper);     /* above the scene, whose legs run on down behind the row */
  color: var(--asoc-ink-paper-warm);
}
@media (min-width: 801px) { .asoc-footer__base { justify-content: flex-end; } }
/* MID (801 to 1199, 2026-10-18): as one line the row ran 625px from the right and sat over the desk's drawers and panel.
   It now starts on the LINKS' left edge (Sam: right-aligned lines "look off-kilter with the other alignment") and uses
   the arrangement in MID'S ROW, below. --asoc-footer-row-in is the links' left
   edge measured from the row's, set by asoc-footer.js. */
html.asoc-plane-mid .asoc-footer__base { justify-content: flex-start; padding-block: 1.2rem 0.4rem; padding-left: var(--asoc-footer-row-in, 0px); }
.asoc-footer__copy {
  margin: 0; color: var(--asoc-ink-paper-warm-muted);
  font-family: var(--asoc-font-body); letter-spacing: 0;
  font-size: clamp(1.3rem, 0.9028vw, 1.729rem);
}
/* Her registration, as small print beside the copyright (Sam, 2026-10-10: not a focus, and no register link). Mono from
   .asoc-credential, so it reads as a number, not a phone. */
.asoc-footer__small {
  display: inline-flex; align-items: center; min-height: 44px;
  color: var(--asoc-ink-paper-warm-muted); font-size: clamp(1.3rem, 0.9028vw, 1.729rem);   /* the copyright's ink (Sam, 2026-10-18) */
  /* No resting underline: a line drawn in under the words on hover or focus (2026-10-11). */
  text-decoration: none;
  background: linear-gradient(var(--asoc-ink-paper-warm), var(--asoc-ink-paper-warm)) no-repeat 0 calc(50% + 0.8em) / 0 1.5px;
  transition: color var(--asoc-dur) var(--asoc-ease), background-size 0.35s var(--asoc-ease);
}
.asoc-footer__small:hover, .asoc-footer__small:focus-visible { background-size: 100% 1.5px; color: var(--asoc-ink-accent); }
.asoc-footer__small + .asoc-footer__small { margin-left: calc(var(--asoc-space-l) * -0.4); }
.asoc-footer__base a:focus-visible { outline-color: var(--asoc-accent); }

/* BACK TO TOP: her hand-drawn arrow in a hand-drawn ring. "back to top" wraps round the bottom of the ring, outside it, on
   hover or keyboard focus; always shown on touch. 64px at 1200 to 1440 on the footer's scale (85px by 1915), 56px below. */
.asoc-plane-top {
  display: block; width: 56px; height: 56px; flex: none; margin-left: auto;
  color: var(--asoc-ink-paper-warm); -webkit-tap-highlight-color: transparent; border-radius: 50%;
}
@media (min-width: 801px) { .asoc-plane-top { margin-left: var(--asoc-space-l); } }
html.asoc-plane-big .asoc-plane-top { width: clamp(64px, 4.4444vw, 85.12px); height: clamp(64px, 4.4444vw, 85.12px); }
html.asoc-plane-mid .asoc-plane-top { width: clamp(56px, calc(56px + (100vw - 801px) * 0.0201), 64px); height: clamp(56px, calc(56px + (100vw - 801px) * 0.0201), 64px); }
.asoc-plane-top svg { display: block; width: 100%; height: 100%; overflow: visible; }
.asoc-plane-top__ring { fill: transparent; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round;
  transition: fill 0.35s var(--asoc-ease); }
.asoc-plane-top__arrow { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round;
  transition: stroke 0.35s var(--asoc-ease), translate 0.45s var(--asoc-ease-settle); }
.asoc-plane-top__words { font-family: var(--asoc-font-body); font-size: 12px; font-weight: 500; letter-spacing: 0.02em;
  fill: currentColor; opacity: 0; rotate: -28deg; transform-box: view-box; transform-origin: 50% 50%;
  transition: opacity 0.3s var(--asoc-ease), rotate 0.55s var(--asoc-ease-settle); }
.asoc-plane-top:hover .asoc-plane-top__words, .asoc-plane-top:focus-visible .asoc-plane-top__words { opacity: 1; rotate: 0deg; fill: var(--asoc-ink-paper-warm); }
.asoc-plane-top:hover .asoc-plane-top__ring, .asoc-plane-top:focus-visible .asoc-plane-top__ring { fill: var(--asoc-accent); stroke: var(--asoc-plum); }
.asoc-plane-top:hover .asoc-plane-top__arrow, .asoc-plane-top:focus-visible .asoc-plane-top__arrow { stroke: var(--asoc-plum); translate: 0 -1.5px; }
.asoc-plane-top:focus-visible { outline: 2px solid var(--asoc-accent); outline-offset: 2px; }
@media (hover: none) { .asoc-plane-top__words { opacity: 1; rotate: 0deg; } }

/* The phone and tablet step (800 and under): the row starts on the left with the rest of the footer. NO MENU-BAR
   CLEARANCE ANY MORE: the phone bar leaves when the footer arrives (asoc-header.js, 2026-10-18), so the row pays only
   the device's own safe area. */
html.asoc-plane-col .asoc-footer__base { justify-content: flex-start; padding-top: 1.6rem; }

/* HER CREDENTIALS (Sam, 2026-10-19/20): the TPB registered-agent symbol and the Find a VA badge. Each is a single-colour
   mask from src/badges/ filled with the row's ink (the client-logo pattern), so it reads as part of the footer on its dark
   ground. The Find a VA badge is sized so its scalloped circle matches the TPB's seal (557 and 754 units of their 800-unit
   heights: 68px beside 92px), and the two circles share a centre line (Find a VA 1px up). The TPB's "BAS Agent 26212390"
   under its seal is under 5px tall at 92px, as on her current site, so the badges are never drawn smaller than this.
   ⚠️ THE TPB FILE IS A DEV-ONLY STAND-IN until Georgie's own download replaces it (scripts/build-badges.py).
   Where they sit is set per size step by asoc-footer.js:
     BIG  a stacked column left of the links, one column gap from them, its top level with the links' labels;
     MID  the bottom row's first line, left, with back to top on its right (the column would land on her note);
     COL  under the links, then the logo with back to top on its row, then the copyright line (Sam's option B). */
.asoc-footer__badges { display: flex; flex-direction: row; align-items: flex-start; gap: 2rem; width: auto; }   /* row: Bricks blocks are flex COLUMNS */
.asoc-badge {
  display: block; flex: none; width: auto;
  background-color: var(--asoc-ink-paper-warm-muted);
  -webkit-mask: var(--badge) no-repeat center / contain; mask: var(--badge) no-repeat center / contain;
}
.asoc-badge--tpb { --badge: url("badges/tpb-registered.svg"); height: 92px; aspect-ratio: 648 / 800; }
.asoc-badge--fva { --badge: url("badges/find-a-va.svg"); height: 68px; aspect-ratio: 928 / 800; margin-top: -1px; }
html.asoc-plane-big .asoc-footer__measure > .asoc-footer__badges {
  position: absolute; left: 0; top: 0; width: max-content; flex-direction: column; align-items: center;
}
html.asoc-plane-big .asoc-footer__badges .asoc-badge--fva { margin-top: 0; }

/* MID'S ROW: the badges (moved into the row by asoc-footer.js) with back to top on their right, then the last line of
   the footer, "© Admin Society  Privacy  Terms", so back to top is not the bottom-most element (Sam, 2026-10-19). The
   break is a zero-height item ordered between the two lines. */
html.asoc-plane-mid .asoc-footer__base::before { content: ""; order: 1; flex-basis: 100%; height: 0; }
html.asoc-plane-mid .asoc-footer__base > .asoc-footer__badges { order: 0; }
html.asoc-plane-mid .asoc-plane-top { order: 0; align-self: flex-end; margin: 0 0 16px auto; }
html.asoc-plane-mid .asoc-footer__copy, html.asoc-plane-mid .asoc-footer__small { order: 2; flex-basis: auto; max-width: none; align-self: center; margin-top: -4px; }

/* COL: the badges under the links, the logo and back to top on one row (both placed by asoc-footer.js), and the last line
   alone. Where the logo sits beside her line instead (from ~600), back to top ends the last line on the right. */
html.asoc-plane-col .asoc-footer__measure > .asoc-footer__badges { margin-top: 3.2rem; }
html.asoc-plane-col .asoc-footer__copy { flex-basis: auto; max-width: none; }
html.asoc-plane-col .asoc-plane-top { margin: 0 0 0 auto; align-self: center; }
html.asoc-plane-col .asoc-footer.is-logo-low .asoc-plane-top { position: absolute; left: 0; top: 0; margin: 0; z-index: var(--asoc-z-staged); }

/* The last line's two gaps are both 24px: the first link loses 16px of the flex gap, which its own box padding was adding to. */
.asoc-footer__copy + .asoc-footer__small { margin-left: -16px; }   /* every step: from 1200 the number used to sit between them */
@media (max-width: 800px) {
  /* clear of the device's safe area, and of "back to top", which touch shows round the bottom of its ring */
  .asoc-footer__base { padding-bottom: calc(2.4rem + env(safe-area-inset-bottom, 0px)); }
}

@media (prefers-reduced-motion: reduce) {
  .asoc-plane-invoice, .asoc-plane-top__words, .asoc-plane-top__ring, .asoc-plane-top__arrow { transition: none; }
  .asoc-plane-arrow > svg.is-pulse .asoc-plane-arrow__head { animation: none; }
  .asoc-plane-go, .asoc-plane-go > span { transition: none; }
  .asoc-plane-invoice:hover .asoc-plane-inv__sheet { animation: none; }
  .asoc-plane-inv__sheet, .asoc-plane-inv__sh { transition: none; }
}

/* ==========================================================================
   05 · PAGE SECTIONS — home, for now
   ========================================================================== */

/* --- Hero --- */
/* ⚠️ THE HERO FITS THE WINDOW ON LOAD (Sam, 2026-10-08). Measured before this change, the
   "Book a chat" buttons sat below the fold on ordinary laptop WINDOWS: 167px at 1366x625,
   129px at 1280x650, 51px at 1024x690, 2px at 1440x800. The space went on the 135px
   header, a top padding sized to the window's WIDTH only (120px from ~800px wide), and a
   three-line heading. So: on desktop the top padding also answers to the window's HEIGHT
   (see the rule below); the heading may run to 22ch, which puts it on two lines at desktop widths
   ("Bookkeeping and virtual / admin you can count on.") and changes nothing on a phone,
   where the column is narrower than 16ch anyway; its size is capped at 12vh, which only
   bites on short windows; and the gap under the intro is m, not l.
   The header is the rest of the budget; the header rebuild (docs/header-brief.md) should
   be measured against this same fit. */
.asoc-hero { position: relative; padding-block: clamp(6rem, 4rem + 10vw, 12rem) var(--asoc-space-3xl); }
/* Desktop only, and only on SHORT windows: 24vh - 108px is 120px (the old cap) from a
   950px-tall window up, 84px at 800, 58px at 690, and the 48px floor at 625. Tall windows
   and phones keep exactly the padding they had. A first pass used 9vh, which also took a
   1920x950 window from 120px to 85px; that was not asked for.
   ⚠️ THE FLOOR IS 32px, FROM 48 (2026-10-09). verify-hero-fit.js, measuring windows the
   first pass did not, found the buttons 2px below the fold at 1280x600. The floor only
   binds below a 650px-tall window, so nothing taller moves; at 600 tall it buys 16px. */
@media (min-width: 801px) {
  .asoc-hero { padding-top: min(clamp(6rem, 4rem + 10vw, 12rem), max(3.2rem, 24vh - 10.8rem)); }
}
.asoc-hero__title { font-size: min(var(--asoc-text-hero), 12vh); max-width: 22ch; margin-bottom: var(--asoc-space-m);
  font-optical-sizing: auto; }
.asoc-hero__lead  { max-width: 56ch; margin-bottom: var(--asoc-space-m); }
/* .brxe-block is display:flex; flex-direction:column by default — the row
   direction MUST be stated or the buttons stack. */
.asoc-hero__actions { display: flex; flex-direction: row; flex-wrap: wrap; align-items: center; gap: var(--asoc-space-s); }
@media (max-width: 520px) {
  /* Stacked auto-width buttons look accidental on a phone. Full width reads as
     designed and gives a bigger tap target. */
  /* ⚠️ SIDE BY SIDE, NOT STACKED (Sam, 2026-09-18). Stacked, the second button sat at
     660-717px of an 852px screen and iOS Chrome's bottom bar covered it on load — so the
     page opened with one of its two calls to action invisible. Half width each puts both on
     the same line, 74px higher, and the labels fit: "Book a chat" and "See packages" measure
     96px and ~100px of ink against 132px of room inside the padding. */
  .asoc-hero__actions { flex-direction: row; align-items: stretch; gap: var(--asoc-space-xs); }
  .asoc-hero__actions .asoc-btn {
    flex: 1 1 0; min-width: 0; justify-content: center;
    padding-inline: var(--asoc-space-s); white-space: nowrap;
  }
}

/* --- Narrative --- */
/* NO BLOCK MARGINS HERE. The grid's `margin-bottom: 2xl` separated it from the third
   "impact" line deleted on 2026-09-02; with nothing after it, it only added to the
   section's padding (48px at 393, 96px at 768). Each title also ended in 16px, and
   deleting the title's own `margin-bottom: s` did NOT remove it — that line restated the
   base heading margin, which is where it really came from and where it is now fixed (a
   heading's trailing margin applies only when something follows it). The pinned stage
   zeroes both itself, so desktop with motion never showed either. */
.asoc-narrative__grid {
  display: grid; grid-template-columns: 1fr 1fr; gap: var(--asoc-space-xl);
  align-items: start;
}
.asoc-narrative__title { font-size: var(--asoc-text-h2); }
.asoc-narrative__panel--from .asoc-narrative__title { color: var(--asoc-ink-faint); }
.asoc-narrative__panel--to   .asoc-narrative__title { color: var(--asoc-ink-accent); }
.asoc-narrative__copy { color: var(--asoc-ink-muted); }
/* `.asoc-narrative__impact` styled a third narrative beat that no element tree
   has emitted since 2026-09-02, when Sam ruled that the roadmap's "extra-large
   impact statement" describes the effect of the two paired lines landing
   together rather than a third heading. The rule is deleted as dead CSS.
   ⚠️ The JS and the motion probe keep their `.asoc-narrative__impact` lookups
   deliberately — both are written as `impact || <the payoff line>` fallbacks, so
   they already assert on whatever the sequence actually rests on. Removing a
   tolerant fallback would buy nothing and would hardcode an assumption. */
/* The scene sits BETWEEN the two lines and belongs to both, so the gap below it is
   the gap above it: --asoc-space-l, which lands within a few px of the caption's own
   16px margin plus the scene's internal margin. (It was -xl while there were two
   stills and each caption had to pair with the one below it.) */
@media (max-width: 800px) { .asoc-narrative__grid { grid-template-columns: 1fr; gap: var(--asoc-space-l); } }

/* ── THE PHONE'S DESK STILLS (Sam, 2026-09-17) ────────────────────────────
   A static before/after pair under the two lines below the pin. Built by
   asoc-motion.js (mountDeskStills), which carries the why.
   ⚠️ THE RESERVATION AND THE STILL ARE ONE SIZE, and must stay one size. The
   `::after` holds the box from first paint while the footer scripts are still
   loading; mounting a still adds `has-still`, which drops the reservation in the
   same frame. Change the width, the cap or the aspect in one and not the other
   and the page shifts under the reader by the difference.
   Left-aligned with its caption, not centred: centring is opt-in via
   `.asoc-centred`, and a caption over an off-axis picture reads as unrelated. */
.asoc-desk-still { display: none; }
@media (max-width: 800px) {
  /* ONE scene, under the first line only (asoc-motion.js says why). */
  html.asoc-desk-stills .asoc-narrative__panel--from::after,
  .asoc-desk-still {
    display: block;
    width: 100%;
    max-width: 48rem;
    aspect-ratio: 1;
  }
  /* ⚠️ THE MARGIN IS THE HEADING'S, NOT THE STILL'S. A heading's trailing margin
     applies only when something follows it (`:not(:last-child)` in the base
     heading rule), and a pseudo-element is not a child — so under the reservation
     each title had none, and mounting the real still added it: 32px of shift at
     393, measured, two titles x --asoc-space-s. The reservation carries that gap
     itself so both states are the same height. */
  html.asoc-desk-stills .asoc-narrative__panel--from::after { content: ""; }
  html.asoc-desk-stills .asoc-narrative__panel--from.has-still::after { display: none; }
  /* ⚠️ SPLITTING A CAPTION MUST NOT CHANGE ITS HEIGHT. Below the pin the captions
     are split into masked words when the motion script arrives (~5s late on this
     host), and each word's clip box carries 0.12em of bottom padding for
     descenders — which grew every line box: 39 -> 43.3px per line at 393, 8px of
     shift across the pair, measured. The negative margin takes the padding back
     out of the line box and leaves it in the clip box. Split and unsplit measured
     identical at 320 (two-line wrap), 393 and 800. */
  .asoc-narrative__title .asoc-w { margin-bottom: -0.12em; }
  /* THE DESK SITS ABOVE ITS CAPTION. Visual order only: the DOM order is untouched, so the
     ground trigger still reads the payoff line as the last narrative line in the section. */
  .asoc-narrative__panel--from { display: flex; flex-direction: column-reverse; }
  /* ⚠️ THE CAPTION'S GAP MOVES TO ITS TOP EDGE, for two reasons that are the same reason.
     Reversed, the caption is the panel's LAST box, so its trailing margin became 16px of
     dead space inside the section's padding (verify-grid MARGINS, red at 393 and 768); and
     the gap the reader sees is now between the desk ABOVE and the caption, which is this
     margin's top edge. The reservation above carries none for the same reason: the
     reserved box and the mounted scene must be the same height. */
  .asoc-narrative__panel--from .asoc-narrative__title { margin-block: var(--asoc-space-s) 0; }
  /* ONE SLOT FOR BOTH LINES. The payoff line is lifted onto the opening line's box and
     bottom-aligned, so both sit on the same baseline however either one wraps.
     ⚠️ KEYED TO THE HEAD SCRIPT'S CLASS, NOT TO A CLASS THE MOTION LAYER ADDS, and that is
     about layout shift rather than tidiness: collapsing the second line when the scripts
     land ~5s in took 79px out of the section under the reader (measured, reserved 591 ->
     mounted 512). Reserved from first paint, both states are 512.
     The degraded path is covered by the same head script: if nothing mounts a scene by
     `load` it drops this class, and the two lines stack and read normally. */
  html.asoc-desk-stills .asoc-narrative__grid { position: relative; }
  html.asoc-desk-stills .asoc-narrative__panel--to {
    position: absolute; inset-inline: 0; bottom: 0; margin: 0;
  }
  /* Clipped: a rotated sheet near the edge must not widen the page. */
  .asoc-desk-still { position: relative; overflow: hidden; }
  .asoc-desk-still > svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
  /* NO transform here: asoc-motion.js owns it, because the sheets move. It writes
     the scattered state as plain inline styles when there is no GSAP, and hands the
     same property to the scrubbed timeline when there is.
     The shadow uses the registered --asoc-sh so the timeline can INTERPOLATE it;
     an unregistered custom property is a string to the browser and would snap. */
  .asoc-desk-still__sheet {
    position: absolute;
    filter: drop-shadow(0 4px 8px rgba(0, 0, 0, var(--asoc-sh, 0.2)));
  }
  .asoc-desk-still__sheet svg { width: 100%; height: 100%; display: block; }
  /* The pinned scene's scale: an A4 sheet is 51% of the laptop deck's width, and
     the deck is 476 of this still's 1000 units. A receipt is 80/210 of A4. */
  .asoc-desk-still__sheet--invoice { width: 24.5%; aspect-ratio: 210 / 297; }
  .asoc-desk-still__sheet--receipt { width: 9.4%; }
}

/* ── THE HERO'S PARAGRAPH, RELAYED BELOW THE DESK ON A PHONE ──────────────
   The same sentence is in the tree twice and exactly one copy is ever displayed: in the
   hero at pointer widths, here below the narrative on a phone. Display, not visibility, so
   the hidden copy costs no space and is skipped by screen readers.
   It carries no section padding of its own — the narrative's bottom padding and the quote's
   top padding already set the rhythm around it. */
.asoc-relay { display: none; }
@media (max-width: 800px) {
  /* GROUPED WITH SERVICES, NOT FLOATING (2026-10-11): with no padding it sat ~60px from the
     quote above and ~60px from SERVICES below, belonging to neither. It introduces what
     follows, so the section break goes above it and only 40px below. */
  .asoc-relay { display: block; padding-block: var(--asoc-space-3xl) 0; }
  .asoc-relay + .asoc-services { padding-top: var(--asoc-space-l); }
  .asoc-relay__copy { max-width: 56ch; }     /* the hero copy's measure, kept */
  /* ONLY where the page relays it. Written bare, this hid the intro of every hero on
     every page at phone width, which the concept pages found on their first fit run
     (2026-10-09): a page with no relay lost the sentence entirely. */
  body:has(.asoc-relay) .asoc-hero__lead { display: none; }
}

/* --- Services: the fanned-card reveal --- */
.asoc-services__grid { margin-top: var(--asoc-space-xl); }
/* BASE = touch / no-hover. The graphic sits IN FLOW at the top of the card.
   An earlier attempt floated it above the card on touch too — but stacked cards
   have only a 24px gap, and the icon paints at z-index:-1, so the card above
   simply covered it. In flow there is nothing to collide with. */
.asoc-service { padding-top: var(--asoc-space-l); overflow: visible; }
.asoc-service__icon {
  display: block; height: auto;
  /* Inlined by asoc-motion.js so the artwork's own cream, dark and lilac follow the ground
     (as an <img> they are baked in, and the cream outline all but vanished on paper). The
     svg carries the same class and box, so the swap costs no layout. */
  color: var(--asoc-ink);
  /* ⚠️ AN INLINE SVG IS ONLY HIT-TESTABLE WHERE IT PAINTS, an <img> everywhere in its box —
     so inlining silently took the icon out of `elementsFromPoint`, and verify-pocket's
     control (the instrument can see an icon at all) went red at 1440 and 801. The pocket
     invariant is "a point inside both the icon and the card resolves to the card", which
     cannot be measured on an element the hit test never returns. `bounding-box` restores
     exactly the <img> behaviour; events still bubble to the card, which owns the hover. */
  pointer-events: bounding-box;
  /* Fluid, so icon:title stays ~3.5x instead of lurching at the breakpoint. */
  width: clamp(7.6rem, 6.0rem + 2.2vw, 9.6rem);
  margin-bottom: var(--asoc-space-s);
}

/* THE ICON POCKET — the graphic rests behind the card's top edge and fans out on
   hover, back.out(1.2)-style. Pointer devices only: without a hover there is no
   reveal, so a touch device gets the icon in flow inside the card instead.

   ⚠️ TWO THINGS WERE FIXED HERE ON 2026-09-16 AND THE ANIMATION ITSELF IS UNCHANGED.
   Sam's report was that the card layout looked wrong, not that the pocket did.

   1. THE EMPTY BAND ABOVE EVERY HEADING. The card reserved 96px of top padding
      "so the title clears the icon" — but the icon paints at `--asoc-z-pocket`
      (-1), BEHIND the card's own background, so there was never anything for the
      title to clear. The band was 96px of nothing, at every pointer width, and it
      read as a layout mistake. It is back to the ordinary 40px card padding, and
      the padding is no longer coupled to the transform percentages: the old note
      said "change one and you must change the other", which was only true because
      of the mistake.

   2. THE CARD ABOVE COVERED THE POPPED-OUT ICON. The icon is z-index -1 inside its
      own card, so when cards stack it was painted over by the card above — which
      is the only reason this block was ever gated on `min-width: 801px`. Fixed at
      the cause: the HOVERED CARD takes a z-index, which lifts its whole stacking
      context (icon included) above its siblings. The icon still sits behind its
      own background, which is what makes it a pocket, and now nothing else can
      cover it.

      ⚠️ PART 2 WAS WRONG, AND IT CAUSED A DEFECT — see the block below.

   ⚠️ 2026-09-17: THE POCKET NOW HOLDS BY CONSTRUCTION, NOT BY LUCK. Sam reported three
   things: on hover the icons paint IN FRONT of the card; while scrolling to them they
   appear in front for a split second, then drop behind; and at rest the leftmost icon
   peeks while the others do not. The first two are ONE mechanism.
   The icon was z-index -1 against the card's own background. A negative-z child paints
   below its parent's background ONLY while the parent is not a stacking context — and
   the card became one twice: the reveal fades and slides it (opacity < 1 and a
   transform each create one), and the 2026-09-16 hover fix gave it a z-index "so the
   card above cannot cover the icon". Inside a stacking context the parent's background
   paints FIRST, so the icon came through in front. That fix was verified by
   hit-testing the icon ABOVE the card, which reads the same whether the pocket works or
   not; `scripts/verify-pocket.js` hit-tests the OVERLAP, and reproduced all three
   reports on the old build before this change.

   So the card is now ALWAYS a stacking context (`isolation`), and a surface layer
   repaints the card above the icon: icon (-2) < surface (-1) < the card's content.
   Nothing the card does can reorder those. `isolation` also does a second job that is
   easy to delete by accident: without it the risen icon is painted — and hit-tested —
   beneath the transparent grid wrapper, so moving the pointer onto it un-hovers the
   card and the icon drops away under the cursor (verify-pocket's "risen part is
   hit-testable" control goes red on all three cards when it is removed). The surface INHERITS the card's background
   and hairline rather than restating them, so the card surface is still declared once,
   its ground crossfade still animates, and the card box keeps its own background —
   which is what the contrast gates read. Both surface colours are opaque, so painting
   it twice is identical.
   The hover z-index is DELETED, not kept as harmless: at this width the cards sit in a
   row and nothing can cover a rising icon. If the pocket is ever run into stacked
   cards, a hovered card outranking the one above is now safe to add. */
@media (hover: hover) and (pointer: fine) and (min-width: 801px) {
  .asoc-service { isolation: isolate; }
  .asoc-service::before {
    content: ""; position: absolute; inset: 0; z-index: var(--asoc-z-surface);
    border-radius: inherit; background: inherit; box-shadow: inherit;
  }
  .asoc-service__icon {
    position: absolute; top: 0; left: 50%; z-index: var(--asoc-z-pocket); margin: 0;
    width: clamp(7.6rem, 6.0rem + 2.2vw, 9.6rem);
    transform-origin: bottom center;
    transition: transform 0.45s cubic-bezier(0.34, 1.56, 0.64, 1);
  }

  /* NOTHING SHOWS AT REST (Sam, 2026-09-17: "either we show a sneak peek of each, or we
     show none"). None, because the roadmap specifies it: the graphics "reveal themselves
     from behind the top edge of the card", a mechanic chosen "to add visual delight
     without cluttering the initial viewport". The old rest (-6%) left each icon's box 5.5px
     proud, and a ±15° fan lifts a corner a further 8.7px. Sam saw only the leftmost; the
     right icon's box was exactly as proud, so its artwork presumably leaves that corner
     empty — inferred, not measured, and it does not matter now: +12% clears the rotated
     BOX, which contains the ink, with ~2px to spare. It scales with the fluid icon width
     because rotation geometry does. Hover stays at -58% and ±15°. */
  .asoc-service__icon                            { transform: translate(-50%, 12%) rotate(0deg); }
  .asoc-service:nth-child(1) .asoc-service__icon { transform: translate(-50%, 12%) rotate(-15deg); }
  .asoc-service:nth-child(3) .asoc-service__icon { transform: translate(-50%, 12%) rotate(15deg); }
  .asoc-service:hover .asoc-service__icon              { transform: translate(-50%, -58%) rotate(0deg); }
  .asoc-service:nth-child(1):hover .asoc-service__icon { transform: translate(-50%, -58%) rotate(-15deg); }
  .asoc-service:nth-child(3):hover .asoc-service__icon { transform: translate(-50%, -58%) rotate(15deg); }
}
.asoc-service__title { font-size: var(--asoc-text-h3); margin-bottom: var(--asoc-space-xs); }
.asoc-service__copy  { color: var(--asoc-ink-muted); font-size: var(--asoc-text-small); }

/* THE SERVICES-GROUND AXIS IS CLOSED (2026-09-12). It compared a dark, a lilac
   and a paper services section; paper won on 2026-09-03 and is the default in
   the light-run block below. The `dark` and `lilac` overrides are deleted with
   the rest of the comparison axes — its remaining question ("should services be
   dark after all?") is answered by the same decision that made paper default.
   docs/design-decisions.md §3. */

/* --- Testimonials: masonry-ish, avoids a rigid corporate table --- */
/* ⚠️ OUTSIDE THE GRID SYSTEM ON PURPOSE. This is a multi-column flow, not a grid, and
   not a carousel (docs/design-decisions.md §6). Do not give it an `.asoc-grid`
   modifier: a grid puts cards in rows, which is the rigid table this avoids. */
.asoc-testimonials__grid {
  /* MUST be display:block — .brxe-block defaults to display:flex, and a flex
     container silently ignores `columns`, collapsing the masonry to one column. */
  display: block;
  margin-top: var(--asoc-space-xl);
  columns: 2; column-gap: var(--asoc-space-m);
  /* THE STACK SPACING, AND ITS CANCEL, ARE ONE VALUE. A multi-column flow has no
     block-axis `gap`, so cards are spaced by their own bottom margin — and every column
     ends in one, which `:last-child` cannot reach (it finds the DOM-last card, not the
     bottom of the taller column). So the wrapper takes the margin back off itself.
     Declared once here and inherited by the cards, so the two cannot drift apart. */
  --asoc-stack: var(--asoc-space-m);
  margin-bottom: calc(-1 * var(--asoc-stack));
}
/* ⚠️ vertical-align: top IS NOT COSMETIC. The cards are inline-block (it keeps a card
   whole inside its column), and an inline-block sits on the line's BASELINE, leaving the
   font's descender gap under every card: 8.7px at 1440, 7.7px at 393. So cards stood
   32.7px apart where the token says 24, and the last one left the gap in the section's
   padding. It was written off as "multi-column rounding" until it measured the same at
   393, where there is one column. Top alignment removes it. `display: block` also does,
   and was rejected: block cards in two columns over-cancelled by exactly 24px, because
   margins at a column's end behave differently from margins in one column — the kind of
   thing that also differs between engines. */
.asoc-quote { break-inside: avoid; margin-bottom: var(--asoc-stack); display: inline-block; vertical-align: top; width: 100%; }
.asoc-quote__text { color: var(--asoc-ink); margin-bottom: var(--asoc-space-s); max-width: none; }
.asoc-quote__attr { font-size: var(--asoc-text-small); color: var(--asoc-ink-accent-2); max-width: none; }
.asoc-quote__attr strong { display: block; color: var(--asoc-ink-accent); }
/* ink-muted, not ink-faint. The business name under each testimonial measured
   4.15:1 at ink-faint (0.46 alpha) — under the 4.5:1 small-text floor. A
   pre-existing defect, found by the contrast sweep written for the colour
   ground rather than by anything looking at this section. */
.asoc-quote__attr span {
  display: block; margin-top: 0.3em;
  font-size: 0.82em; font-weight: 500; letter-spacing: 0.12em; text-transform: uppercase;
  color: var(--asoc-ink-muted);
}
@media (max-width: 800px) { .asoc-testimonials__grid { columns: 1; } }

/* --- Founder --- */
.asoc-founder__inner { display: grid; grid-template-columns: 320px 1fr; gap: var(--asoc-space-xl); align-items: center; }
.asoc-founder__mark  { width: 100%; height: auto; border-radius: 50%; }
.asoc-founder__copy  { color: var(--asoc-ink-muted); }
/* ── A BUTTON IN THE TEXT FLOW STANDS OFF THE COPY ABOVE IT ───────────────
   Structural, not a list of sections, and that is the whole point: this was
   declared on the founder and on the lead magnet and simply MISSED on the
   conversion band, where the button sat flush against the paragraph — 0px of box
   gap against 24px everywhere else, at 1440 and at 393, for as long as that
   section has existed (Sam, 2026-09-18: "the spacing between the button and the
   heading on the lilac section ... is wrong in desktop and mobile").
   A button that is a direct child of a page container, or of the founder's text
   block, is IN THE FLOW of the copy. The hero's buttons are not: they live in
   `.asoc-hero__actions`, a flex row with its own gap, and keep it.
   Guarded by verify-grid.js (BUTTON GAP), so the next section to grow a button
   cannot quietly repeat it. */
.asoc-container > .asoc-btn,
.asoc-founder__text > .asoc-btn { margin-top: var(--asoc-space-m); }
@media (max-width: 800px) {
  .asoc-founder__inner { grid-template-columns: 1fr; gap: var(--asoc-space-m); }
  .asoc-founder__media { max-width: 108px; }
}

/* --- Lead magnet --- */
.asoc-leadmagnet__inner {
  background: var(--asoc-surface-raised);
  border-radius: var(--asoc-radius-lg);
  padding: clamp(2rem, 1rem + 4vw, 4rem);
  border: 1px solid var(--asoc-line);
}
.asoc-leadmagnet__title { font-size: var(--asoc-text-h1); max-width: 18ch; margin-bottom: var(--asoc-space-s); 
  font-optical-sizing: auto;
}

/* --- Conversion: full-width accent band that ends the page --- */
.asoc-conversion { background: var(--asoc-accent); }
.asoc-conversion__title { color: var(--asoc-accent-ink); font-size: var(--asoc-text-h1); margin-bottom: var(--asoc-space-s);
  font-optical-sizing: auto;
}
.asoc-conversion__copy  { color: var(--asoc-accent-ink); opacity: 0.88; max-width: 46ch; }
/* ⚠️ A COMPONENT THAT PAINTS ITS OWN BACKGROUND MUST PAINT ITS OWN INK.
   This shipped as `color: var(--asoc-ink)` and measured 1.06:1 — invisible. The
   button hard-codes the DARK surface, but `--asoc-ink` is re-pointed to the
   on-light ink by the scope it lands in (the lilac conversion band, the paper
   run), so it resolved to near-black text on a near-black pill. The pair has to
   come from the same ground: if the background is a literal, the ink is too. */
.asoc-btn--invert { background: var(--asoc-surface); color: var(--asoc-ink-dark); }
.asoc-btn--invert:hover { background: var(--asoc-surface-sunk); }

/* ==========================================================================
   06 · MOTION — the reveal contract
   --------------------------------------------------------------------------
   `html.asoc-motion` is added by an inline <head> script (asoc-site-assets.php)
   so the from-state is in place before first paint. asoc-motion.js takes the
   from-state over as GSAP inline styles and then removes this class, so from
   that moment only elements GSAP is actually driving are hidden.

   ⚠️ THIS SELECTOR SET AND `revealTargets()` IN asoc-motion.js ARE ONE CONTRACT.
   Hide something the JS does not collect and it stays hidden until the 2.5s
   failsafe; collect something the CSS does not hide and it flashes in. Change
   them together.
   ========================================================================== */
html.asoc-motion .asoc-section > .asoc-container > *,
html.asoc-motion .asoc-service,
html.asoc-motion .asoc-quote,
html.asoc-motion .asoc-stagger > * { opacity: 0; }

/* Grid wrappers hold the reveal units, they are not reveal units themselves —
   three cards lifting as one block loses the stagger that reads across them.
   `.asoc-stagger` is the general form, for any list whose items should arrive one
   after another (section 08). */
html.asoc-motion .asoc-services__grid,
html.asoc-motion .asoc-testimonials__grid,
html.asoc-motion .asoc-stagger { opacity: 1; }

/* Reduced motion never means invisible content.
   These two mechanisms cover DISJOINT cases — they are not redundant, and an
   earlier version of this comment wrongly claimed they were. Proven by
   mutation: disabling the JS bail-out left 21 targets at opacity 0 in spite of
   the !important below.
     · This block covers the window BEFORE asoc-motion.js runs, and the case
       where it never arrives — so a reduced-motion user never sits out the
       2.5s failsafe staring at a blank page.
     · asoc-motion.js's own check covers everything after. It has to: release()
       removes .asoc-motion from <html>, so the selector below stops matching
       and GSAP's inline opacity:0 is then unopposed.
   Deleting either one leaves a real hole. */
@media (prefers-reduced-motion: reduce) {
  html.asoc-motion .asoc-section > .asoc-container > *,
  html.asoc-motion .asoc-service,
  html.asoc-motion .asoc-quote,
  html.asoc-motion .asoc-stagger > * { opacity: 1 !important; }
}

/* ==========================================================================
   07 · MOTION — the pinned narrative stage
   --------------------------------------------------------------------------
   `.is-pinned` is added by asoc-motion.js inside a gsap.matchMedia() at
   >=801px, and removed by that context's cleanup on resize. Everything below is
   therefore motion-only styling: with JS off, or on a phone, the narrative
   stays the two-column block it is above.
   ========================================================================== */
.asoc-narrative.is-pinned {
  position: relative;
  overflow: hidden;
  min-height: 100vh;
  justify-content: center;       /* .brxe-section is already flex-direction:column */
  padding-block: var(--asoc-space-xl);
  /* NO overflow:hidden. The section's edges were slicing every sheet that
     crossed them, and a hard horizontal cut through a receipt reads as a
     rendering fault. Paper now bleeds into the sections above and below, which
     is what paper on a desk does. The horizontal bleed is contained by
     `overflow-x: clip` on the root rather than here — clipping here would
     restore the very edges being removed. */
  overflow: visible;
}
/* Contains the horizontal bleed. On BODY, not html, and this matters twice:
     · on `html`, `overflow-x: clip` beside `overflow-y: visible` computes the
       visible axis to `auto` — the root becomes a scroll container and the page
       still scrolled sideways, which is exactly what the audit caught;
     · `clip` not `hidden`, because `hidden` would also make it a scroll
       container and break ScrollTrigger's pinning.
   `overflow-x: clip` on body does neither: no scroll container, no containing
   block for the fixed colour ground, and the paper still bleeds vertically into
   the sections above and below. */
body.brx-body { overflow-x: clip; }

/* ---- LENIS: ITS OWN THREE RULES, AND NOTHING ELSE ------------------------
   The child theme's Lenis feature ships these three plus four "performance"
   rules. Each of those four was injected into the live page on its own and
   measured against the pin (section top across the pin range, 0px = pinned):
       these three ...................................... 0px  pinned
       will-change:transform + contain:layout style paint  1260px  BROKEN
       content-visibility:auto on body > * ..............  1641px  BROKEN
       transform:translateZ(0) on .lenis-smooth .........  1260px  BROKEN
   All three failures are one mechanism: each creates a containing block for
   `position: fixed`, and ScrollTrigger pins with fixed positioning.

   ⚠️ NOTHING HERE MAY TOUCH `html`'s OVERFLOW. The comment above this block
   records a measured failure: `overflow-x: clip` beside `overflow-y: visible`
   on the root computes the visible axis to `auto`, the root becomes a scroll
   container, and the page scrolls sideways AND the pin breaks. That is why the
   clip lives on body. Lenis's classes go on `html`, so the two do not collide
   as written - keep it that way. */
html.lenis { height: auto; }
.lenis.lenis-smooth { scroll-behavior: auto !important; }
.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain; }

/* ---- Depth order. This is the whole trick. -------------------------------
   The desk is the ground, the TYPE is printed on it, and the paper lies on
   top of the type. Reading the line through the gaps between sheets is what
   makes it feel like it is on the desk rather than floating over a picture
   of one. */
.asoc-desk  { position: absolute; inset: 0; z-index: var(--asoc-z-ground); pointer-events: none; }
.asoc-desk svg { width: 100%; height: 100%; display: block; }
/* SHORT, WIDE WINDOWS: THE LAPTOP SHRINKS TOWARDS ITS CORNER (Sam, 2026-10-17). The desk SVG fills the stage's
   WIDTH (`slice`), so the wider the window is for its height, the larger the laptop and the closer its lid comes
   to "From disorganised.": no drift, the gap was -17px at 1366x625 (the lid on the line's descender) and 13px at
   1536x760, against 36-78px at every window under 2:1. It also ran 7px off the bottom at 1366x625, so moving it
   down would have cropped it; scaling it from its bottom-left corner clears the line and keeps it whole. */
@media (min-width: 801px) and (min-aspect-ratio: 2/1) {
  .asoc-narrative.is-pinned .asoc-desk svg { scale: 0.86; transform-origin: 27% 100%; }
}
.asoc-narrative.is-pinned > .asoc-container { position: relative; z-index: var(--asoc-z-section); }
.asoc-paper { position: absolute; inset: 0; z-index: var(--asoc-z-paper); pointer-events: none; overflow: visible; }

/* The two lines sit in opposite corners of the stage and BOTH stay. */
/* ⚠️ THE PINNED STAGE CARRIED ITS OWN GUTTER AND IT WAS OFF THE PAGE COLUMN. `5rem`
   here is 25px a side against the 12.5px every other band had at the same widths, so
   the narrative's content sat 12.5px inboard of every other section from 801px until
   the 1240px max bound at ~1290px. Nothing caught it: the first version of
   verify-grid.js sampled three ordinary sections plus the chrome, reported 0.00px
   spread at every tier, and did not look at this one. The band is in that gate's list
   now. It takes the shared token like everything else. */
.asoc-narrative.is-pinned > .asoc-container {
  display: block;
  width: min(100% - var(--asoc-gutter) * 2, var(--asoc-container));
  min-height: calc(100vh - var(--asoc-space-xl) * 2);
}
.asoc-narrative.is-pinned .asoc-narrative__grid {
  display: block; position: absolute; inset: 0; margin: 0; gap: 0;
}
.asoc-narrative.is-pinned .asoc-narrative__panel { position: absolute; max-width: none; margin: 0; }
/* align-items is stated, and it is what actually positions the line.
   `.brxe-block` is display:flex; flex-direction:column; align-items:flex-start,
   so the heading shrinks to its content and hugs the left edge no matter what
   `text-align` says — the payoff rendered bottom-LEFT with text-align:right
   applied and computing correctly. Third distinct symptom of that one Bricks
   default in this project, after the stacked button row and the grid that
   would not stretch. `left/right: auto` is stated for the same reason: both
   offsets resolved to 0 and stretched the box full width. */
/* Back to the top corner. It sat at 25% to clear the monitor that used to run
   across the back of the desk; with the monitor gone and a single laptop in the
   lower left, the corner is free again — and at 25% the line landed straight on
   the laptop's lid, which read as text on the screen rather than on the desk. */
.asoc-narrative.is-pinned .asoc-narrative__panel--from {
  top: 9%; left: 0; right: auto; align-items: flex-start; text-align: left;
}
.asoc-narrative.is-pinned .asoc-narrative__panel--to {
  bottom: 8%; right: 0; left: auto; align-items: flex-end; text-align: right;
}

/* One line each, never wrapped — the pair reads as two stamps on the desk, and
   a wrap would break the diagonal. Sized to FIT unwrapped at 801px, which is
   what caps the clamp: at the hero token "From disorganised." overflows a
   narrow desktop. */
.asoc-narrative.is-pinned .asoc-narrative__title {
  white-space: nowrap;
  margin: 0;
  font-size: clamp(3.2rem, 1.1rem + 5.3vw, 9.2rem);
  font-optical-sizing: auto;
  line-height: 1.02;
}
/* Full cream, NOT the ink-faint the two-column layout gives it. There, "from"
   is deliberately recessive against the terracotta payoff beside it. Here the
   paper lies ON TOP of the words, so anything less than full strength reads as
   greyed-out rather than as quiet — and the pair is meant to be legible
   together at the end, not one loud and one faded. */
.asoc-narrative.is-pinned .asoc-narrative__panel--from .asoc-narrative__title { color: var(--asoc-ink); }
.asoc-narrative.is-pinned .asoc-narrative__panel--to   .asoc-narrative__title { color: var(--asoc-ink-accent); }

/* Word-mask reveal. The box clips, the inner span slides up out of it.
   padding-bottom keeps descenders (the g in "disorganised") off the clip edge —
   without it the line looks cropped rather than masked. */
.asoc-w    { display: inline-block; overflow: hidden; vertical-align: bottom; padding-bottom: 0.12em; }
.asoc-w__i { display: inline-block; will-change: transform; }

/* --- The paper ------------------------------------------------------------
   Receipts hold a CONSTANT width and vary only in length; the widths below are
   to scale against A4 (80mm vs 210mm), so a pile reads as real stationery. */
/* Shadow alpha is a registered custom property so GSAP can INTERPOLATE it.
   Without @property the browser treats it as a string and the tween would snap. */
@property --asoc-sh { syntax: "<number>"; inherits: false; initial-value: 0.2; }

.asoc-paper__sheet {
  position: absolute;
  will-change: transform;
  --asoc-sh: 0.2;
  /* Lift per sheet, deliberately light. drop-shadow ACCUMULATES: six sheets
     converging on one pile stack six shadows on the same few pixels and the
     pile reads as a dark blob rather than as paper. The per-sheet value is low
     enough to survive that, and asoc-motion.js fades --asoc-sh toward nothing
     on every sheet except the top of each pile as they converge, so a settled
     pile carries ONE shadow instead of six. */
  filter: drop-shadow(0 6px 11px rgba(0, 0, 0, var(--asoc-sh)));
}
.asoc-paper__sheet svg { width: 100%; height: 100%; display: block; }
/* SIZED AGAINST THE LAPTOP, not by eye. The desk SVG uses a 1440-wide viewBox
   scaled to the stage, and the laptop deck is 476 of those units — so one unit
   is 1/1440 of the stage width and the deck is 33% of it. A4 is 210mm against
   the deck's 355.7mm, i.e. 59% of the deck, which is 19.5% of the stage. A
   receipt is 80mm, so 80/210 of that.
   Percentages, not clamps: a clamp caps at a fixed pixel width and the paper
   would quietly fall out of scale with the laptop on a large screen. */
/* 17%, a shade under the geometric 19.5%. At the exact ratio a settled A4 pile
   is 44% of the stage tall and could not both sit near the payoff line and stay
   clear of it. The laptop is a prop, not a measurement — this still renders an
   invoice at 3x its old size and reads unmistakably as A4 beside the machine. */
.asoc-paper__sheet--invoice { width: 17%; aspect-ratio: 210 / 297; }
.asoc-paper__sheet--receipt { width: 6.5%; }

/* NO SCRIM. There was one here to protect the type from the paper. It is gone
   because the depth order inverted: the paper now sits ON TOP of the type, so a
   layer between them dims the words instead of protecting them — the headline
   rendered grey rather than cream. The desk is subtle enough that full-strength
   cream reads on it unaided. */


/* ==========================================================================
   08 · THE COLOUR RUN — background, and why the SHIPPING mechanism looks like it does
   --------------------------------------------------------------------------
   ⚠️ READ THIS AS HISTORY. The spatial mechanism described below was built on
   2026-09-03, compared against the temporal crossfade, and LOST. Its code is
   deleted (see the note further down where its token blocks stood). What is kept
   here is the measured reasoning, because the crossfade that did ship inherited
   the constraint it discovered — an incomplete on-ground token set is what makes
   a colour transition expensive, and the one document-scope block below is the
   answer to it. docs/design-decisions.md §4.

   Two adjacent sections are light on an otherwise dark page: the credibility
   quote, then services. Everything above and below is dark.

   WHAT THE SPATIAL BUILD REPLACED, AND WHY. The build before it faded a `position:fixed`
   full-viewport layer in TIME, driven by ScrollTrigger, with a text step-flip
   at the midpoint. It cost 1040px of deliberately empty page, because a
   crossfade makes the WHOLE VIEWPORT a mid-tone at once, so the only defence
   is to guarantee no text is on screen while it runs.

   The governing rule is the Hot Chip Social build's, taken from instrumenting
   humaan.com/about (braude/docs/design-system/colour-transition-map.md §12):

     "changing colour under text is reliable WHEN THE GROUND RANGE KEEPS ONE
      FOREGROUND LEGIBLE AT EVERY FRAME."

   Humaan meet that trivially — pale-blue to blue, both light, one dark
   foreground throughout, and their text colour never changes. #232326 to
   #BE96C8 is a full dark-to-light flip and meets no such condition. HCS's
   answer to the same wall was to step-flip EVERY foreground in the chapter,
   backed by a complete on-ground token set. We took the step-flip and applied
   it to two elements, so the services kicker sat cream on lilac at 1.51:1 and
   the gaps were bought to keep it off screen. THE GAPS WERE COMPENSATING FOR
   AN INCOMPLETE TOKEN SET.

   So the transition is now SPATIAL, not temporal. Each section paints its own
   background, and the colour change happens inside a band of padding that
   contains no text. The consequences are the point:

     · The unsafe zone is a fraction of a BAND, not a fraction of the VIEWPORT.
       Only the sub-4.5:1 slice must stay text-free — 19% of the band for
       dark-to-lilac, 12% for paper-to-dark. At a 240px band that is 46px and
       29px, against a whole viewport before.
     · Contrast stops depending on scroll position. The background behind a
       given piece of text is fixed in DOCUMENT space, so there is no longer
       any such thing as a state that only exists mid-fade. That entire class
       of invisible failure is gone, which is why verify-ground.js could be
       rewritten as a scroll-independent check.
     · The colour is correct in the FIRST PAINT. It used to exist only because
       JavaScript injected a div, so with JS off — or after the 2.5s motion
       failsafe fired — there was no lilac on the page at all.
     · No scroll-dependent measurement survives, which is the mechanism behind
       the iOS address-bar/ScrollTrigger re-measure bug. A headless harness is
       structurally blind to that class, so removing the dependency is worth
       more than any test we could write for it.

   Researched against ~24 production sites: Stripe, Clerk, Figma, Sanity,
   Attio, Anthropic and Apple ALL cut dark-to-light in a single pixel row, and
   none carried a full-viewport fixed layer. Nobody pays a viewport of dead
   space for this. Apple get their hard edge from a clamped gradient —
   `linear-gradient(rgb(245,245,247) 85%, rgb(0,0,0) 0px)`, the out-of-order
   stop collapsing the blend to zero width — which is exactly what
   `--asoc-ramp: 0` does below.
   ========================================================================== */

/* `--asoc-ramp` — the spatial build's one dial, 0 for Apple's hard edge and a
   positive value for a soft band — stood here with its `hard` and `soft` review
   bodies. All three are deleted: the dial only ever fed the gradients, the
   gradients are gone, and on the shipping page it resolved to 0 regardless. */

/* ==========================================================================
   THE COLOUR TRANSITION — a temporal crossfade. THIS IS THE MECHANISM.
   --------------------------------------------------------------------------
   Two stacked fixed layers — lilac, then paper over it — whose opacity
   crossfades as you scroll, so the whole room changes colour around you.

   ── THE ASSUMPTION THAT WAS WRONG, AND IT WAS MINE ──

   Sam, 2026-09-03: "Text that is being scrolled off the screen — whether above
   or below the main text and elements in focus — doesn't need to have perfect
   accessibility scores or contrast. It does when it's the content in focus —
   but when it's leaving the screen, or in the process of coming into focus, it
   really doesn't need to be perfectly contrasted right away."

   That is correct, it is WCAG-defensible, and it is the standard HCS already
   apply (hcs-colour-gate.mjs: CORE = ground settled AND the element sitting in
   [0.12vh, 0.66vh]; edge-of-screen and mid-fade dips are expected and allowed).
   I had that standard implemented and then replaced it with "every frame must
   clear WCAG", which is a far stricter bar than anyone asked for. Everything
   that followed was downstream of that: the 520px gaps, then the step-flip to
   survive the mid-blend, then a deep purple ground chosen so no flip was needed
   at all. A chain of real engineering resting on an invented requirement.

   ── WHAT THAT UNLOCKS ──

   The colour change no longer has to be INSTANT. A step-flip existed only
   because a cross-fading foreground passes through ~1:1 against a cross-fading
   ground — which matters if that instant must pass WCAG, and does not if the
   text is still arriving. So the foreground now crossfades on exactly the same
   duration and easing as the ground. They move in lockstep, which is precisely
   the complaint being answered: "their change isn't seamless, smooth, or timed
   perfectly with the background change… extremely sudden and quick."

   No step-flip. No gradient ramps. No hard edges. No breathing gaps. One
   mechanism doing all three boundaries, which is what it was always for.

   ⚠️ The token block below is still the ONE document-scope on-ground override,
   and verify-tokens.js still guards both rules. Being able to tolerate a
   transient dip does NOT mean an element may sit on the wrong ground once the
   fade has settled — that is the bug that cost 1040px, and it is unrelated.
   ========================================================================== */
:root {
  --asoc-fade-dur:  0.9s;
  --asoc-fade-ease: cubic-bezier(0.45, 0, 0.15, 1);
}

/* ⚠️ NO `display: none` + class-gated `display: block` PAIR ANY MORE, and the
   reason is a specificity trap rather than tidiness. Both rules were (0,1,0)
   once the body class went, so the later one would have won unconditionally and
   the stack would have been `block` on every page regardless.
   THE ELEMENT'S EXISTENCE IS THE GATE. asoc-motion.js injects this stack only
   when the page has both a `.asoc-quotelead` and a `.asoc-services` — i.e. only
   on the home page — so a page that should not have a colour ground does not
   have this element at all, and there is nothing for a `display` rule to hide. */
.asoc-fade-stack { position: fixed; inset: 0; z-index: var(--asoc-z-ground); pointer-events: none; }
.asoc-fade-layer {
  position: absolute; inset: 0; opacity: 0; will-change: opacity;
  transition: opacity var(--asoc-fade-dur) var(--asoc-fade-ease);
}
/* ONE GROUND FOR THE WHOLE RUN (Sam, 2026-09-03, counted on a phone: "there are
   so many colour changes in a short period… ideally there are only two colour
   changes on the whole page, but seemingly there are 3-5").
   He was right, and it was measurable: 3 changes on desktop, 5 on mobile, with
   each colour held for only 264-288px at 393px wide. Two of those were a second
   ground back-to-back with the first — lilac handing off to paper — which is
   exactly what HCS's own discipline forbids (colour-transition-map.md §3.2,
   "never place two colour grounds back-to-back without a canvas buffer between
   them"). That rule was read earlier in the same session and not applied.
   So: one ground, spanning both sections. dark → paper → dark, two changes. */
.asoc-fade-layer { background: var(--asoc-paper); }
html.asoc-fade-on .asoc-fade-layer { opacity: 1; }
html.asoc-fade-noanim .asoc-fade-layer { transition-duration: 0s; }

/* Both sections are TRANSPARENT so the layers show through: no section paints a
   background, so there is no gradient ramp and no hard edge anywhere.
   This is also where both sections get their padding, now that the ramp-inclusive
   base pair is deleted — the quote's top is overridden to 2xl below, and services'
   to 0 while the quote is staged. */
.asoc-quotelead,
.asoc-services {
  background-image: none;
  background-color: transparent;
  padding-block: var(--asoc-space-3xl);
}

/* ── THE QUOTE SITS CLOSER, BECAUSE WHAT IS ABOVE IT IS NOT A SECTION ──────
   Sam, 2026-09-07, against humaan.com/about: the distance between the payoff
   line and the quote reads as too much white space, "particularly because 'To
   this organised' is only one line carrying significant weight until the
   testimonial appears".

   The spacing VALUE was never the problem. A normal gap between two adjacent
   sections here is two paddings, 2 x --asoc-space-3xl = 320px, and this gap
   measured 286px once the payoff line's lift was removed - already tighter than
   the system's own default. What makes it read as empty is that the thing above
   is a single line on a 100vh stage rather than a block of content.

   And the stage is why the arithmetic is different: an ordinary section pairs
   its bottom padding with the next section's top padding. The pinned narrative
   has no bottom padding at all - just 126px of leftover viewport below the line
   - so this section's top padding is carrying the whole gap on its own.

   ⚠️ 320px IS NOT REACHABLE WITH A TOKEN, and the reason is the same anomaly.
   The standard 320px is TWO paddings, one from each neighbour. Here the stage
   contributes none, so the gap is 126px of leftover viewport plus this one
   padding - and hitting 320 exactly would need a 194px value that is nowhere on
   the scale. --asoc-space-3xl (160px) is the largest token that applies, giving
   286px: the standard-token answer, 34px under the standard-two-section gap.

   [Superseded 2026-09-07: this briefly used --asoc-space-2xl for 222px. Sam:
   "it doesn't need to be less than this" - so it is back to the full 3xl. The
   note below is kept because the reasoning about aliasing still applies.]
   NOT a new token:
   --asoc-space-2xl already is the design system's answer, and aliasing it to
   something like --asoc-space-after-stage would walk straight into the trap
   recorded elsewhere in this file - a custom property referencing another
   resolves in its DECLARING scope, so an alias at :root would bind to 9.6rem and
   silently ignore the 6.0rem mobile override below. Bottom padding is untouched:
   what follows is an ordinary section and pairs normally. */
.asoc-quotelead {
  padding-block-start: var(--asoc-space-2xl);
}

/* ── AND `release` REVERSES THE SAME ANOMALY ON THE OTHER SIDE ─────────────
   The note above is about the gap ABOVE the quote: the pinned stage has no
   bottom padding, so the quote's top padding carries that whole gap on its own.
   Once the quote is staged INSIDE the pin (see `.asoc-quotelead.is-staged`) the
   anomaly appears again at the other end, inverted — and it is the same
   arithmetic, not a new rule.

   The quote is held at 25% of the stage and its credit's last ink lands 318px
   above the stage's bottom edge. So the stage now supplies a full gap by itself
   and the section below must supply none. Measured with services' ordinary
   160px on: credit ink to services' first ink was 481px, against the project's
   320px for two adjacent sections and the 484px Sam rejected as too wide on the
   other side of this very quote. With it off: 321px.
   ⚠️ NO LONGER THE STANDARD (2026-09-18). 3xl was capped at 120px, so an
   ordinary join is 240px of padding (241-287px ink at 1440). This one stayed at
   321px because the stage sets it, not the token — it is now the widest join on
   the page. Matching it means moving the hold or pulling services up; open with
   Sam, not changed here.

   ⚠️ KEYED TO `asoc-quote-staged`, NOT TO THE VARIANT OR THE BREAKPOINT, and
   the distinction is load-bearing. The stage tail only exists when the quote is
   actually staged: asoc-motion.js adds this class in the same breath as the DOM
   move, so it is absent below 801px, absent under reduced motion, and absent
   with JS off — all three of which leave the quote as an ordinary in-flow
   section whose own bottom padding is doing the normal job. Keying this to
   `.asoc-seq-release` alone would collapse the boundary in every one of those
   states to fix the one state that needs it.
   No magic number either: the 318px is a consequence of the 25% hold and the
   section's own height, so it tracks them instead of needing to be kept in
   step. */
body.asoc-quote-staged .asoc-services {
  padding-block-start: 0;
  /* ⚠️ AND IT RISES INTO THE STAGE'S EMPTY TAIL ON TALL WINDOWS (Sam, 2026-09-30, item 22:
     bring the join after the quote into line with the ordinary ones, 241–287px ink at 1440).
     The quote is centred in a 100vh stage, so the space under its credit is half of whatever
     height the quote does not use — it GROWS WITH THE WINDOW: measured credit -> services
     first ink 266px at 800 tall, 345 at 900, 473 at 1080. So the fix cannot be a fixed number.
     Chosen over lowering the quote, which fixes the same gap by moving the quote below centre
     (188px low at 1080 tall). Here the quote keeps the composition that was signed off; the
     cost is that on a tall window services starts rising into view in the last part of the
     quote's still hold (58px of scroll at 900 tall, 188 at 1080, out of ~250) instead of
     after it. MEASURED, it does not show: the heading is inside the window but its own reveal
     has not fired, so the held frame stays still (1920x1080, 2026-09-30). A change to the reveal
     threshold would expose it. Lowering the quote is the recorded alternative.
     618px = the quote's height above its credit (~367px at 1440, within ~8px from 1024 to 1920)
     + the 280px target join − services' own 33px from its box to its first ink.
     BOTH WAYS, deliberately: under ~824px tall the term goes positive and pushes services
     DOWN, because short windows had the opposite fault — 154–203px at 700 tall, tighter than
     any ordinary join. A first version wrapped this in min(0px, …) and left them there.
     Measured after, at 1024 / 1440 / 1920: 296 / 254 / 247px at every height 800–1200.
     At 700 tall it is 326 / 284 / 277, because there the stage grows past the window and
     75vh under-reads it; was 203 / 161 / 154 before this rule.
     Staged-only, like the rule above. */
  /* 626 from 618 (2026-10-12): the kicker above the services heading went 16 -> 8px, which
     brought the heading 8px nearer the credit; this gives the join back. */
  margin-block-start: calc(626px - 75vh);
}

/* ── THE ONE ON-GROUND TOKEN BLOCK ────────────────────────────────────────
   Document scope, so every component inherits and nothing has to be remembered.
   It used to need `body` in the selector to outrank the `.asoc-v-brand` class
   that sat there; with the palette at :root that is no longer what earns it, but
   `html.asoc-fade-on body` is kept exactly as-is because it still has to beat
   :root, and because a section-level opt-out (the narrative's, below) must in
   turn beat THIS — the cascade here is three deep and was tuned by measurement,
   so it is not the place to economise on a selector. */
html.asoc-fade-on body {
  --asoc-ink:            var(--asoc-on-light);
  --asoc-ink-muted:      var(--asoc-on-light-muted);
  --asoc-ink-faint:      var(--asoc-on-light-muted);
  /* ⚠️ THE KICKER GOES PLUM ON PAPER, NOT NEUTRAL (Sam, 2026-09-18, asking whether mint
     could stay). It cannot: mint on paper measures 1.45:1. Nor can lilac (2.19:1) or the
     payoff line's lighter plum (3.66:1, and a kicker is 13px, so it needs 4.5:1). The deep
     plum this block already uses for accents measures ~10:1 and keeps a brand colour
     through the change where near-black ink dropped it. */
  --asoc-kicker:         #4A1452;
  --asoc-ink-accent:     #4A1452;
  --asoc-ink-accent-2:   #143535;
  --asoc-line:           rgba(36, 27, 39, 0.22);
  /* ⚠️ THE CARDS WERE READING AS TWO MORE COLOUR CHANGES ON MOBILE. At 393px the
     service cards are full-width and stacked, so a lilac-tinted card (#E7DAEC)
     alternating with the paper ground (#F4EFE9) fills the middle of the screen and
     back again — measured as changes 2 and 4 of 5. Side by side on desktop they
     never dominate, so this was invisible at 1440 and obvious on a phone.
     A card must therefore separate from the ground by TEXTURE, not by hue: barely
     lighter, plus a hairline. Different enough to read as a card, not different
     enough to read as the page changing colour. */
  --asoc-surface-raised: #FBF8F4;
}

/* ── THE CREDIBILITY QUOTE IS DARK FROM THE START ────────────────────────
   Sam, 2026-09-07: "removing the light to dark text transition on this removes
   one unnecessary visual change that only currently accounts for an extremely
   small fraction of time that the text might be seen on a darker background…
   The priority is smoothness and removing unnecessary visual changes given that
   the background colour changes the hero of this section."

   He is right about the arithmetic. The ground trigger fires at `top bottom` on
   the quote itself, so the paper is already arriving as the quote enters the
   viewport bottom. The stretch where this text is both on screen AND on the dark
   ground is a fraction of a second at the very bottom edge — and for that
   fraction it was performing a colour change of its own, on top of the ground
   change that IS the section's moment. Two changes competing for one beat.

   ⚠️ IT IS DARK-ON-DARK FOR THAT FRACTION, AND THAT IS THE ACCEPTED TRADE, not
   an oversight: "if there's a brief moment of time where it is dark text on a
   dark background then I am okay with that." It is defensible on the standard
   this project already uses — HCS CORE is ground settled AND element top in
   [0.12vh, 0.66vh]; text arriving at the bottom edge is neither.

   HOW, and why it is not element enumeration: this re-scopes the SAME on-light
   token set to a section, exactly as the block above scopes it to the document.
   No colour literal, no ground colour used as a foreground - both verify-tokens
   lint rules still hold. The transition is removed rather than shortened,
   because a 0.001s transition is still a transition and would still be a change
   the eye can catch. */
/* ⚠️ POINTER-WIDTH ONLY (2026-09-16). BELOW 801 THIS BLOCK WOULD MAKE THE QUOTE
   UNREADABLE, and it did.

   Sam accepted "a brief moment of dark text on a dark background" for this section.
   That trade was measured on DESKTOP, where the ground trigger fires at
   `top bottom` on the quote itself, so the paper is already arriving as the quote
   enters the viewport — the overlap really is a fraction of a second at the very
   bottom edge.

   Mobile inherited the trade with a DIFFERENT TRIGGER. Below 801 the ground fires
   at `top top` on the quote's section, which is roughly a viewport-height of
   scrolling later, because firing it any earlier put the narrative's cream text on
   paper at ~1.05:1. So the accepted fraction of a second became the whole time the
   quote is legibly on screen: measured 1.06:1, dark on dark, with the last line
   still clipped by its own word mask.

   The bind is real and asoc-motion.js already named it — "the real fix is a mobile
   layout that leaves a colour-change band between the two sections; there are 96px
   between them today". On desktop the pin CREATES that band. On mobile there is no
   moment when neither section's text is on screen, so no trigger position can
   satisfy both.

   ⚠️ CORRECTED 2026-09-17 (item 6). This note used to end by saying the quote is
   "constant light on mobile". It never was: below 801 nothing pins its tokens, so
   it FOLLOWS the ground like every other line — measured cream -> ink over 46
   frames, in step with the ground. That is the intended phone behaviour, and it
   is the only safe one there: the phone's ground now turns once the narrative's
   last line has left the screen (asoc-motion.js), with a lower turn-off point on
   the way back, so the quote is on screen through both changes and any held
   colour would be invisible on one of the two grounds. verify-fade asserts it
   MOVES at a phone width and HOLDS at 1440. */
@media (min-width: 801px) {
.asoc-quotelead {
  --asoc-ink:        var(--asoc-on-light);
  --asoc-ink-muted:  var(--asoc-on-light-muted);
  --asoc-ink-faint:  var(--asoc-on-light-muted);
  --asoc-kicker:     var(--asoc-on-light);
  --asoc-line:       rgba(36, 27, 39, 0.22);
  /* ⚠️ THE TOKEN OVERRIDE ALONE IS NOT ENOUGH, and the reason is the recurring
     one on this project. Re-scoping --asoc-ink only helps elements that CONSUME
     it. `.asoc-quotelead__text` sets `color: var(--asoc-ink)` so it flipped
     immediately; the attribution sets no colour at all and inherits a RESOLVED
     value from <main>, so it kept the cream and went on doing the very
     transition this block exists to remove. Measured: the section carried
     --asoc-ink #241B27 while `strong` still computed rgb(246,243,246).
     Declaring the colour here makes the whole subtree consume the token. */
  color: var(--asoc-ink);
}
}
/* ⚠️ THIS RULE IS PROVABLY INERT AND IS KEPT DELIBERATELY. Mutating it — putting
   the colour transition back — left verify-fade GREEN, because with the tokens
   above pinned there is nothing for it to transition BETWEEN. The token override
   is the whole defence; this is a second line that only matters if someone
   removes the first, and without it that removal would produce an instant
   step-flip instead of a crossfade. Kept, and labelled, rather than left looking
   like the thing that holds the invariant. */
/* ⚠️ ALSO POINTER-WIDTH ONLY, AND FOR THE SAME REASON. This rule is inert at
   >=801 because the tokens above are pinned there — there is nothing to
   transition between, which is what the note calls out. Below 801 those tokens
   are NOT pinned, so the rule stops being inert and becomes the defect: the
   quote's ink STEPS instantly the moment `asoc-fade-on` is added, while the
   ground takes 0.9s to arrive under it. Measured mid-flight at 393 — ink already
   fully dark, ground layer 8.8% of the way to paper, 1.06:1.
   Below 801 the quote now moves WITH the ground like every other section, which
   is what "the foreground moves with the ground" guarantees: there is no moment
   where the two disagree, by construction rather than by timing. */
@media (min-width: 801px) {
  .asoc-quotelead :is(h1, h2, h3, h4, p, span, strong, em, a, li) {
    transition: none;
  }
}

/* THE FOREGROUND MOVES WITH THE GROUND — same duration, same easing, so they
   are in sync by construction rather than by a timing constant someone has to
   keep correct. Applied broadly on purpose: same anti-enumeration principle as
   the token block above.
   The credibility quote is deliberately exempted above — see that block.

   ⚠️ `:where()`, NOT `:is()`, AND THE DIFFERENCE WAS A LIVE DEFECT ON EVERY BUTTON
   AND LINK ON THE SITE. `:is()` takes the specificity of its most specific argument,
   so one class in a list of element selectors lifted this whole rule to (0,1,1) —
   above every single-class component rule in the file. And `transition` is a
   SHORTHAND, so it did not merge with what it outranked, it REPLACED it. Measured
   before the fix, on named elements rather than the first match:

       hero button        got  color, border-color 0.9s   — not background, not transform
       ghost button       got  color, border-color 0.9s
       conversion button  got  color, border-color 0.9s
       desktop nav link   got  color, border-color 0.9s   — intended 0.3s
       footer link/email  got  color, border-color 0.9s   — intended 0.3s

   So every button's hover fill and 1.02 scale STEPPED with no easing at all, and
   every link hover ran three times slower than it was written to. Nothing errored
   and a still screenshot cannot show it.

   `:where()` contributes zero specificity, so this rule is now a DEFAULT any
   component may override — which is what "applied broadly" was always meant to mean.
   The mobile nav was never affected: its rules are `.asoc-header__nav > .asoc-…`,
   already (0,2,0), which is why the menu stagger still worked and why checking it
   mattered rather than assuming the blast radius. */
body :where(h1, h2, h3, h4, p, span, strong, em, a, li, button, .asoc-btn) {
  transition: color var(--asoc-fade-dur) var(--asoc-fade-ease),
              border-color var(--asoc-fade-dur) var(--asoc-fade-ease);
}
/* ⚠️ A SPLIT WORD INHERITS ITS COLOUR, SO IT MUST NOT TRANSITION IT (2026-09-17).
   `.asoc-w` and `.asoc-w__i` are spans, so the rule above gave every word of a
   split heading its own 0.9s colour transition on top of the heading's. That is
   harmless while nothing else touches the word — and a defect while its mask
   reveal is running: the reveal writes an inline transform every frame, each
   write restarts the word's colour transition from wherever it had got to, and
   the word chases its own heading. Measured at 393 on a forward scroll, where the
   quote's reveal overlaps the ground change: words still cream ~420px after the
   ground had settled, scattered word by word ("beautiful, chaotic" light,
   "mess." dark), lag 0.97 against the ground. Scrolling back, with no reveal
   running, the same words measured lag 0.
   With no transition of their own the words simply inherit the heading's
   animated colour: lag 0, same 46 frames as every other line
   (scripts/colour-strip.js, MUTATE_CSS on the live build before this shipped). */
.asoc-w,
.asoc-w__i {
  transition: none;
}
.asoc-card {
  transition: background-color var(--asoc-fade-dur) var(--asoc-fade-ease);
}
/* The hairline that lets a near-ground card still read as a card. */
html.asoc-fade-on body .asoc-card {
  box-shadow: inset 0 0 0 1px rgba(36, 27, 39, 0.12);
}
@media (prefers-reduced-motion: reduce) {
  .asoc-fade-layer,
  body :where(h1, h2, h3, h4, p, span, strong, em, a, li, button, .asoc-btn),
  .asoc-card { transition-duration: 0.001ms; }
}

/* ==========================================================================
   THE NARRATIVE HAND-OFF — three versions for comparison (Sam, 2026-09-04)
   --------------------------------------------------------------------------
   The problem, measured before any of this was built: the ground turned on at
   scrollY 3375, when the narrative filled 90% of the viewport and "To this
   organised." sat at mid-screen. It then stayed on for another 700px while the
   narrative was still there. The colour was changing UNDER the narrative rather
   than between sections, which is also why so much appeared to change at once -
   at that moment almost everything on screen was narrative.

     _asoc_seq = timed    keep the mechanism, fix the timing. The ground fires
                          as the quote enters the bottom of the viewport, and
                          the narrative is exempted from the flip entirely.
                 release  the narrative's contents scroll up and off the pinned
                          stage in a stagger, emptying it; the quote then scrolls
                          in normally after the pin releases.
                 carry    same exit, but the quote is carried INTO the pinned
                          stage and rises as the narrative leaves.

   `release` and `carry` are the same idea Sam described: get "change colour over
   empty space" from MOTION instead of from padding. The 520px gaps were buying
   the same thing, badly.
   ========================================================================== */

/* THE NARRATIVE NEVER FLIPS, in any of the three. Sam: "don't change the colour
   of any elements that are scrolling out on the from disorganised section
   because they don't need to contrast... the laptop and invoices and receipts
   are already dark."
   A section-level token beats the document-scope on-ground block because the
   section is the nearer ancestor, so this opt-out needs no !important and no
   ordering luck. */
.asoc-narrative {
  --asoc-ink:        var(--asoc-ink-dark);
  --asoc-ink-muted:  var(--asoc-ink-dark);
  --asoc-ink-faint:  var(--asoc-ink-faint-dark);
  --asoc-kicker:     var(--asoc-kicker-dark);
  --asoc-ink-accent: var(--asoc-ink-accent-dark);
  /* ⚠️ THE ARTWORK MUST NOT RECOLOUR WITH THE GROUND. Sam, 2026-09-09: "the
     laptop, receipts, and invoices DON'T need to change colour when the
     background transitions to paper. They are ALREADY dark, so there's no need
     to add that visual complexity layer."
     They were changing, and the exemption above is why it was missed: it lists
     the INK tokens, and the desk is built from SURFACE tokens
     (asoc-desk.js mixes --asoc-surface-raised with --asoc-surface). The
     on-paper block lightens --asoc-surface-raised to #FBF8F4 so that cards
     separate from the paper by texture, and the artwork inherited it.
     Measured before this line: the desk's fill went from srgb 0.16 to 0.53 and
     the receipts from 0.17 to 0.66 - near-black lightening to mid-grey exactly
     as the ground arrived. */
  --asoc-surface-raised: var(--asoc-surface-raised-dark);
  color: var(--asoc-ink);
}

/* ── ONE TOKEN THROUGH THE OPT-OUT: THE PAYOFF LINE FOLLOWS THE GROUND ──────
   The block above exempts the whole narrative from the colour change, on the
   2026-09-03 call that elements scrolling out need no contrast. That is still
   right for everything in it EXCEPT the payoff line, and the exception is a
   measurement rather than an opinion: "To this organised." is lilac, and lilac
   settled on the paper ground is 2.19:1 at 31% and 19% of the viewport. It is
   in focus, the ground has finished changing, and it is unreadable. Shipping,
   and verify-fade could not see it (see the two-pass note in that file).

   Sam's fix, and it is better than the static colour I proposed:
       today  static lilac ....... dark 6.26:1   paper 2.19:1 ✗
       mine   static #9F67AD ..... dark 3.74:1   paper 3.66:1
       his    lilac -> #9F67AD ... dark 6.26:1   paper 3.66:1
   The airy lilac is kept where it works and only the paper end moves. The
   mid-blend dips to ~1.07:1, and all three options dip to ~1.05:1 anyway - the
   ground passes through a mid-tone and every foreground crosses it - so it
   costs nothing to choose the version that is better at both ends.

   No transition property is declared here on purpose. The broad foreground rule
   already gives every h2 the ground's own duration and easing, so this moves in
   lockstep by construction rather than by a constant kept in step by hand. */
html.asoc-fade-on body .asoc-narrative {
  --asoc-ink-accent: var(--asoc-ink-accent-paper);
}

/* `carry`'s z-index rule stood here. `carry` lifted the quote OVER the pinned
   narrative rather than staging it inside, and it is deleted with the rest of
   the hand-off comparison — it also had a known blank-screen defect and was
   never a candidate to ship. `release` stages the quote instead, and the rule
   that stacks it is `.asoc-quotelead.is-staged` below: a different job, which is
   why the two were never merged. docs/design-decisions.md §5. */

/* ── `release`: THE QUOTE JOINS THE PINNED STAGE ───────────────────────────
   `is-staged` is added by asoc-motion.js, which in the same breath MOVES this
   section into `.asoc-narrative`. THE CLASS AND THE DOM MOVE ARE ONE CHANGE and
   neither means anything without the other — the same contract the reveal
   from-state and revealTargets() have, and the same failure mode if they drift:
   a section positioned for a stage it is not inside. Both live only in the
   (min-width: 801px) matchMedia context and both are undone by its cleanup.

   WHY IT IS HERE AT ALL. The quote used to sit below the pin in normal flow and
   be dragged up onto the stage by a transform that then had to unwind to zero,
   which is exactly why it could only ever move SLOWER than the page — measured
   at 0.50 and then 0.91, and both read as broken. Positioned here it is already
   where it is wanted, so there is no offset and nothing to unwind.
   The reasoning in full: docs/scroll-sequence-brief.md §5.

   25% IS THE SAME GEOMETRY THE OLD TRANSFORM USED, and deliberately so: that
   hold parked the section BOX at 0.25 of the viewport, and the text landed below
   it by this section's own top padding. 0.25 centres what you READ rather than
   what the box measures — 0.50 put the text at 60% and the credit in the bottom
   half. Keeping the number keeps the composition that was signed off.

   ⚠️ ABOVE THE PAPER, NOT MERELY ABOVE THE DESK. The stage stacks desk (0),
   type (1), paper (2); at z-index 2 a sheet still in flight would cross in
   FRONT of the quote. This is a different job from the `carry` rule above, which
   stacks the quote against its SIBLING sections rather than inside the stage —
   they are not one rule with two selectors, so they are not merged. */
.asoc-quotelead.is-staged {
  position: absolute;
  top: 25%;
  left: 0;
  right: 0;
  z-index: var(--asoc-z-staged);
}


/* ⚠️ THE SPATIAL MECHANISM STOOD HERE, AND HOW IT WAS REMOVED IS THE POINT.
   Two token blocks and two gradient rules, all scoped `body:not(.asoc-ramp-fade)`.

   They were DELETED, not unscoped, and that distinction is the whole hazard of
   this change. Dropping the class from <body> while leaving these blocks in place
   would have made every `:not()` match every page: the spatial tokens would apply
   with no gradient painted behind them, both mechanisms half-on, nothing erroring
   and nothing in a settled screenshot looking wrong.

   Their muted ink was a TRANSLUCENT rgba, which composites against the very ground
   it has to contrast with — that is what measured 3.92:1 on the lilac. The
   document-scope on-ground block that replaces them uses the opaque
   --asoc-on-light-muted for exactly that reason, so removing these is a
   simplification and a contrast fix in one move.

   --run-ground, --asoc-ramp, --asoc-paper-card and --asoc-lilac-card were consumed
   only from here and are deleted with it. --asoc-paper is NOT: `.asoc-fade-layer`
   still paints it, and it is one of the two tokens this stylesheet has silently
   lost twice before, which is why verify-tokens.js carries a named control for it.
   docs/design-decisions.md §4. */

/* ⚠️ THE POCKET NO LONGER DEPENDS ON THIS z-index (2026-09-17). It used to be the
   stacking context the services icon's negative z-index resolved against; each
   service card now isolates its own layering, so the pocket holds without it.
   Whether anything ELSE still depends on it was not re-examined — deleting it is its
   own change with its own blast radius, not a side effect of the pocket fix. */
.asoc-section { position: relative; z-index: var(--asoc-z-section); }

/* The ramp-inclusive padding pair that stood here is gone with the ramp. Both
   sections now take their padding from the light-run block above
   (`--asoc-space-3xl`, with the quote's top overridden to 2xl and services' top
   to 0 while the quote is staged) — which is what they computed to anyway, since
   the ramp was 0 in fade mode. Deleting the pair removes the last two
   var(--asoc-ramp) references rather than leaving them resolving to zero. */

/* The quote is LEFT-ALIGNED at every width since 2026-09-18 (a trial for Sam):
   it no longer takes `.asoc-centred`, so it and its credit sit on the page
   column's left edge. Re-centring it is that one class in build-home.py. */
/* MOBILE VERTICAL RHYTHM (Sam, 2026-09-03: "enormous white space dominates the
   view"). Measured on a 393x852 phone, the runs between sections were 364px and
   467px against roughly 750px of visible page — up to 62% of the screen empty.
   Only part of that was the colour-transition gap; the rest is the desktop
   spacing scale applied unchanged to a screen a third the width, where the same
   gap reads several times larger relative to what surrounds it. Runs of 271px
   existed between sections that have nothing to do with the transition at all,
   which is the tell that this is a scale problem, not a mechanism problem. */
@media (max-width: 700px) {
  :root {
    /* ⚠️ THE SCALE WAS INVERTED HERE. 2xl was 6.0rem against 3xl's 5.6rem, so
       the largest step on the scale was SMALLER than the step below it - meaning
       any rule that reaches for 3xl to get the biggest gap got a smaller one on
       a phone than a rule reaching for 2xl. Measured on the page: section
       padding 56px, quotelead top padding 60px. 4.8rem restores 3xl > 2xl > xl
       while leaving the section rhythm where it was. */
    --asoc-space-3xl: 5.6rem;   /* 56px, from 80 */
    --asoc-space-2xl: 4.8rem;   /* 48px, from 96 */
    --asoc-space-xl:  4.4rem;   /* 44px, from 64 */
    /* Body leading 1.55 on a phone, from 1.65: a 16px line on a ~340px measure needs less
       air than an 18px line on a 64ch one (design-decisions §18, the mobile rhythm). */
    --asoc-body-leading: 1.55;
  }
  /* THE MOBILE RHYTHM (design-decisions §18): proximity decides grouping. A kicker sits
     8px above its heading (its leading adds the rest), a heading 16px above its copy and
     24px above a component, copy 24px above its action, and a section break is two section
     paddings (112px). The kicker was 12px here until 2026-10-12. */
}

@media (max-width: 700px) {
  .asoc-quotelead__text { text-wrap: pretty; }
}
.asoc-quotelead__text {
  /* Deliberately larger than any heading except the narrative's two lines: this
     is the page's only single-voice moment and it has to carry the transition. */
  /* TWO LINES, running wide. At 17ch it broke to four narrow lines and read as
     a column rather than a statement. Sized so ~41 characters fit per line at
     the container's full width; text-wrap:balance then evens the pair. */
  /* ⚠️ LARGER AND WIDER ABOVE ~1000px (Sam, 2026-09-30, item 25: "grow larger and occupy
     more width, without being absurdly large"). Was clamp(2.4rem, 1.05rem + 3.45vw, 5.9rem),
     59px from 1406. The max() keeps the old line wherever it is larger — below ~1000px,
     so phones and tablets do not move — and above it a steeper line tracks the wider
     column: 57px at 1280, 63 at 1440, 66 from ~1510, where the 1440px column is full.
     THE WIDTH, NOT 44ch, IS WHAT HOLDS IT TO TWO LINES: 44ch at 66px is ~1810px, wider
     than the column, so the column binds. The slope sits ~5% under the width at which the
     pair would break to three, measured in Chromium and WebKit on fresh loads at nine
     widths 1001–1921 and in a 10px sweep 801–2561 (three lines only at 801–821, as
     before this change). A bigger cap needs a wider column, not a bigger number. */
  font-size: clamp(2.4rem, max(1.05rem + 3.45vw, 0.4rem + 4.1vw), 6.6rem);
  line-height: 1.12;
  max-width: min(100%, 44ch);
  text-wrap: balance;
}

/* THE STEP-FLIP IS GONE, and deliberately so. It existed because the quote had
   to be legible both before and after a fade; with the ground painted into the
   section there is only ever one state, and the token override above already
   sets the ink. Leaving the old rules in place would have been the quietest
   possible failure here: `html.asoc-ground-on` is added by JS that no longer
   exists, so the quote would have stayed at var(--asoc-ink) — cream on lilac,
   about 1.5:1, permanently, looking like a colour choice rather than a bug. */
.asoc-quotelead__attr {
  margin-top: var(--asoc-space-l);
  /* The NAME in the body face (2026-10-11): 19px bold mono read heavier than the quote's
     own copy on a phone. Same size and weight, for the large-text reason below; the
     business under it stays the mono label, the same pattern as the testimonials. */
  font-family: var(--asoc-font-body); line-height: 1.3;
  /* 19px/700, deliberately over the 18.66px-bold line where WCAG's LARGE-text
     threshold takes over at 3:1 instead of 4:1... 4.5:1.
     Mid-crossfade the ground is a mid-tone and small text is marginal against
     it whichever way the step-flip lands — measured 2.54:1 at 16px. Large text
     clears it in both directions, and a testimonial credit reads better at this
     size regardless. */
  font-size: 1.9rem;
  font-weight: 700;
  letter-spacing: 0.01em;
  max-width: none;
}
/* Dark ink on the lilac, like the conversion band. */
/* ⚠️ AFTER the base rule above, not in the mobile block further up - same
   specificity, so source order decides and an earlier override silently loses.
   The first attempt at this sat above and did nothing.

   THE MEASUREMENT THAT MATTERS IS THE INK, AND THEN IT STILL HAD TO BE JUDGED.
   Box-to-box the quote and its credit are 86px apart on a phone; on the ink -
   a Range over the text rather than the element box - it is 57px, because this
   quote's line-height is tighter than its glyph box and 29px of the difference
   is empty line-box nobody can see. Having found the first number wrong I first
   concluded 57px was fine, which was a rationalisation rather than a judgement:
   on an 852px screen it is 6.7% of the viewport, against a 24px card gap and a
   30px heading-to-grid gap - so the credit sat nearly twice as far from its
   quote as a heading sits from the grid it introduces, and a credit belongs to
   its quote far more tightly than that. Grouping, not separation. */
@media (max-width: 800px) {
  .asoc-quotelead__attr { margin-top: var(--asoc-space-s); }
}

.asoc-quotelead__attr strong { display: block; font-weight: 700; }  /* 600 fell under the bold line */
/* ── CAT & KATIE GO LILAC ON PAPER, NOT DARK (Sam, 2026-09-18) ──────────────
   The paper end is --asoc-ink-accent-paper, the payoff line's paper colour, for
   the reason recorded on that token: true lilac on paper is 2.19:1 and fails
   even the large-text bar, while #9F67AD is 3.66:1 — a pass ONLY because this
   line is 19px/700, over the 18.66px-bold large-text line (3:1). Take the name
   below that size or weight and this colour fails.
   Two rules because the two tiers treat the quote's colour differently:
   · ≥801 the quote's colour HOLDS (the pinned tokens above), so the name holds
     too — lilac from the start, never stepping when the ground turns.
   · ≤800 the quote FOLLOWS the ground, so the name only changes once the ground
     is paper, and the broad foreground rule moves it in step with the ground. */
@media (min-width: 801px) {
  .asoc-quotelead__attr strong { color: var(--asoc-ink-accent-paper); }
}
@media (max-width: 800px) {
  html.asoc-fade-on body .asoc-quotelead__attr strong { color: var(--asoc-ink-accent-paper); }
}
/* NAME vs ORGANISATION. Sam, 2026-09-03: "they look too similar and are
   competing for focus." They were: both 19px mono 700, separated only by 12%
   opacity, which is not a distinction at all — it reads as one four-word label.
   The name keeps the weight and size; the organisation drops to a smaller,
   letter-spaced, uppercase credential line in the muted ink. Distinction is now
   carried by THREE signals (size, weight, case) rather than one imperceptible
   one.
   The old comment here argued 700 was required because at 500 the line fell
   under WCAG's large-text allowance and failed mid-crossfade at 2.85:1. That
   reasoning belonged to the old mechanism, where a translucent token composited
   against a changing ground. This value is opaque --asoc-ink-muted (#3A3140 on a
   light ground: 4.88:1 on lilac, 10.7:1 on paper), so it clears the SMALL-text
   floor on its own merits and does not need the bold allowance. */
.asoc-quotelead__attr span {
  display: block; font-family: var(--asoc-font-mono);
  margin-top: 0.35em;
  font-size: 0.72em;
  font-weight: 500;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--asoc-ink-muted);
  opacity: 1;
}

/* ==========================================================================
   08 · THE CONCEPT PAGES (Sam, 2026-10-09) — and the hero component they share
   --------------------------------------------------------------------------
   Three new pages for review: /concept-home/, /concept-about/, /concept-services/.
   Sam's brief: keep the home page's scrolling desk, and redesign everything else
   "with no creative limits", on this design system. Nothing here is consumed by
   the live home page except the hero tokens, which it already used by value.

   THE IDEA. Once the desk has tidied itself, the rest of the site IS the tidy desk:
   the dark ground is the desk's surface and the content sits on it as the things a
   bookkeeper actually handles — an index card, a manila folder, a pile of client
   notes squared up, a receipt, a polaroid, a sticky note. Flat colour, hairlines and
   one soft shadow; never texture, never skeuomorphic chrome. Her handwriting is the
   only hand-made layer, and it obeys the notes rule.

   ⚠️ DELETABLE AS ONE BLOCK. If the concepts are not taken forward, this whole
   section goes, along with build-concept-*.py, src/asoc-concepts.js and the three
   pages. The class-contract lint (verify-tokens rule seven) will name anything left
   behind.
   ⚠️ THE TOKENS BELOW ARE NOT MIRRORED INTO BRICKS. sync-bricks-vars.py and
   verify-token-drift.js both read only the FIRST :root block, so this one is outside
   the builder's catalogue and outside the drift check, on purpose while these are review
   pages. If a concept is adopted, move its tokens into the first block and re-sync.
   ========================================================================== */

:root {
  /* THE HEADER'S HEIGHT, AS A TOKEN (design-decisions §14, layer 1). The header was
     87px of logo plus two m paddings, and a hero that has to fit the window needs to
     know that number rather than rediscover it. The header's own rule reads the logo
     size from here, so the two cannot disagree. */
  --asoc-logo-h: 87px;
  --asoc-header-h: calc(var(--asoc-logo-h) + var(--asoc-space-m) * 2);

  /* Desk objects. Paper that is not the page's paper, so an object reads as lying ON
     a paper ground rather than being a hole in it. */
  --asoc-sheet: #FBF8F4;
  --asoc-sheet-back: #E9E1D6;
  --asoc-sheet-edge: rgba(36, 27, 39, 0.10);
  /* SHADOWS, LAYERED (refined 2026-10-11, Sam: "more realistic ... more refined"). Each is
     a tight contact shadow where the object touches, then softer, wider layers that fade,
     which is how light falls off a real object; one big blur reads as a smudge under it.
     The old desk shadow was 70% black pooled 28px down, and on the polaroid (paper ground)
     it made a muddy patch below the photo.
       desk   objects on the DARK ground: black, because a warm tint vanishes on charcoal.
       paper  sheets on the paper ground: tinted with the ink's own hue, never grey, and low.
       print  a photo print on paper: the same warmth, lifted a little more than a sheet. */
  --asoc-desk-shadow: 0 1px 1px rgba(0, 0, 0, 0.24), 0 2px 4px rgba(0, 0, 0, 0.18),
                      0 8px 16px -4px rgba(0, 0, 0, 0.26), 0 24px 40px -16px rgba(0, 0, 0, 0.38);
  --asoc-paper-shadow: 0 1px 1px rgba(36, 27, 39, 0.05), 0 2px 6px -2px rgba(36, 27, 39, 0.07),
                       0 14px 28px -14px rgba(36, 27, 39, 0.16);
  --asoc-print-shadow: 0 1px 1px rgba(36, 27, 39, 0.10), 0 3px 6px -2px rgba(36, 27, 39, 0.10),
                       0 12px 20px -8px rgba(36, 27, 39, 0.13), 0 30px 44px -26px rgba(36, 27, 39, 0.22);

  /* Her coral, legible on paper as a GRAPHIC (stars, crosses): 3.9:1 on #FBF8F4, over
     the 3:1 non-text bar. Never text — see §1 on --asoc-accent-3. */
  --asoc-mark-3-paper: #B4561A;
  --asoc-plum: #4A1452;

  /* Her pad of sticky notes, and the pen she writes on them with. */
  --asoc-note-lilac: #E9D9EE;
  --asoc-note-mint: #D2EBE8;
  --asoc-pen: #4E2A59;
  --asoc-pen-mint: #2E4A57;

  /* A hand-drawn tick, as a mask so it takes whatever colour its ground needs. */
  --asoc-tick-svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M3.5 13.2c1.6 1.2 3.4 3 4.6 4.9 2.6-5 6.6-9.6 12.4-13.4' fill='none' stroke='%23000' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  /* A torn-off receipt edge. */
  --asoc-zigzag: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 8'%3E%3Cpath d='M0 0h16v0L8 8z' fill='%23000'/%3E%3C/svg%3E");
}
/* 62px up to 1024, not 800 (Sam, 2026-10-11): between 801 and 1024 the centred pill
   cleared the 87px mark by only 21px at 801; with the phone's 62px it clears by 46. */
@media (max-width: 1024px) { :root { --asoc-logo-h: 62px; } }

/* ── PAPER, AS A SCOPE ──────────────────────────────────────────────────────
   One block of on-light tokens for anything that is paper: a whole section that
   paints the paper ground, or a single object lying on the dark desk. The same
   values the fade ground uses (`html.asoc-fade-on body`), so a section painted
   paper and the page's own paper run read as one colour system. Declared on the
   element, so it wins over whatever the ground is doing around it. */
.asoc-on-paper,
.asoc-ground-paper {
  --asoc-ink: var(--asoc-on-light);
  --asoc-ink-muted: var(--asoc-on-light-muted);
  --asoc-ink-faint: var(--asoc-on-light-muted);
  --asoc-kicker: var(--asoc-plum);
  --asoc-ink-accent: var(--asoc-plum);
  --asoc-ink-accent-2: #143535;
  --asoc-line: rgba(36, 27, 39, 0.18);
  --asoc-surface-raised: var(--asoc-sheet);
  /* LILAC FOR ACCENTS ON PAPER, NOT PLUM (Sam, 2026-10-10, concept feedback item 1).
     Ticks, the big display numerals (folder, story chapters, pillars) and the pull quote's
     mark (item 4) all take true lilac, 2.37:1 on the sheet. ⚠️ That is under the 3:1 large-text bar ON PURPOSE, and it
     is Sam's call, made from a side-by-side with the deeper lilac (#9F67AD, 3.95:1) and both
     figures in front of him: "The numbers are the secondary focus to the heading for each
     tab, so the lessened contrast compared to darker options isn't an issue." Every numeral
     sits beside a heading that carries the same meaning in full contrast. Do not "fix" it
     back without asking him. */
  --tick: var(--asoc-accent);
  --asoc-figure: var(--asoc-accent);
  color: var(--asoc-ink);
}
.asoc-ground-paper { background: var(--asoc-paper); }
:root { --tick: var(--asoc-accent-2); }

/* ── THE HERO, AS A COMPONENT (design-decisions §14, layer 1) ───────────────
   `.asoc-hero` above already carries the fit: top padding that also answers to the
   window's HEIGHT on desktop, a heading capped at 12vh, a 22ch heading measure.
   These are its layouts. Each one keeps the fit by construction: anything that is
   not the heading, the intro or the buttons sits BESIDE them, never above, so it can
   never push the call to action down. Proven by scripts/verify-hero-fit.js. */
.asoc-hero__grid {
  display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 34rem);
  column-gap: var(--asoc-space-2xl); align-items: end;
}
.asoc-hero__grid > .asoc-kicker,
.asoc-hero__grid > .asoc-hero__title { grid-column: 1 / -1; }
.asoc-hero__body > .asoc-hero__lead:last-child,
.asoc-hero > .asoc-container > .asoc-hero__lead:last-child { margin-bottom: 0; }   /* a hero with no buttons (legal, Contact): 24px of residual otherwise */
.asoc-hero__aside { align-self: start; }
/* THE STATEMENT HERO (concept home and Services). The heading runs the full column at
   up to 120px, held to two lines on desktop by the column rather than by a ch measure.
   It is capped at 13vh so a short window shrinks it, which is the same fit mechanism
   as the standard hero, one step larger. */
.asoc-hero--xl .asoc-hero__title {
  font-size: min(clamp(4rem, 1.6rem + 6.2vw, 12rem), 13vh);
  max-width: 17ch; line-height: 1.0; letter-spacing: -0.035em;
}
.asoc-hero--xl .asoc-hero__grid { align-items: start; }
.asoc-hero--xl .asoc-hero__aside { margin-top: 0.6rem; }
@media (max-width: 1024px) {
  .asoc-hero__grid { grid-template-columns: minmax(0, 1fr) minmax(0, 30rem); column-gap: var(--asoc-space-l); }
  .asoc-indexcard { padding-inline: var(--asoc-space-s); }
}
@media (max-width: 800px) {
  .asoc-hero__grid { grid-template-columns: minmax(0, 1fr); }
  .asoc-hero__aside { display: none; }
}

/* The split hero: words left, a picture right. The picture is the thing that gives
   way — it shrinks with the window's height, so the words never have to. */
.asoc-hero--split .asoc-hero__grid { grid-template-columns: minmax(0, 1fr) minmax(0, 0.72fr); align-items: center; }
.asoc-hero--split .asoc-hero__title { max-width: 14ch; }
.asoc-hero--split .asoc-hero__aside { align-self: center; justify-self: center; margin-top: 0; }
/* margin-top 0 (2026-10-12): the statement hero's 0.6rem nudge lines a START-aligned index
   card up with the heading's cap height; a centred picture has nothing to line up with, and on
   About (split + xl) it left 6px of space above the polaroid inside the hero's padding. */
@media (max-width: 800px) {
  .asoc-hero--split .asoc-hero__grid { grid-template-columns: minmax(0, 1fr); }
  /* On a phone the picture follows the buttons, below the fold, rather than going. */
  .asoc-hero--split .asoc-hero__aside { display: block; justify-self: start; margin-top: var(--asoc-space-xl); }
}

/* THE PAGE HEROES FILL THE WINDOW FROM 801 (Sam, 2026-10-14). About and Services opt in with
   --fill; the home does not (its hero hands over to the pinned desk, measured separately).
   The header is in the flow above the hero, so the hero's share of the window is the window
   less --asoc-header-h. Content-height heroes left a strip of the next section showing: 11px of
   paper under Services at 1440x900, 120px of the story under About. Taller content still grows
   the hero, so the fit (verify-hero-fit.js) is unchanged. svh with a vh fallback: on a tablet the
   small viewport is the one the visitor sees on load. Headless browsers cannot tell the two apart. */
@media (min-width: 801px) {
  .asoc-hero--fill {
    min-height: calc(100vh - var(--asoc-header-h));
    min-height: calc(100svh - var(--asoc-header-h));
    justify-content: center;
  }
}


/* ── SECTION HEADS ─────────────────────────────────────────────────────────── */
.asoc-head { margin-bottom: var(--asoc-space-xl); max-width: 64rem; }
.asoc-head > p:not(.asoc-kicker) { color: var(--asoc-ink-muted); }
.asoc-head > :last-child { margin-bottom: 0; }
.asoc-head--wide {
  max-width: none; display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
  column-gap: var(--asoc-space-2xl); align-items: end;
}
.asoc-head--wide > .asoc-kicker { grid-column: 1 / -1; }
.asoc-head--wide > h2 { margin-bottom: 0; }
@media (max-width: 800px) {
  .asoc-head--wide { grid-template-columns: minmax(0, 1fr); }
  .asoc-head--wide > h2 { margin-bottom: var(--asoc-space-s); }
}

/* Read by screen readers, not drawn: a heading the page outline needs where the section
   carries no visible one (the testimonials, 2026-10-11). */
.asoc-sr-only {
  position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}
/* A heading in the accent: lilac on the dark ground, the deeper plum on paper. */
.asoc-heading--accent { color: var(--asoc-ink-accent); }

/* A grid whose CHILDREN are the reveal units (asoc-motion.js GRID_WRAPPERS). */
.asoc-stagger { list-style: none; margin: 0; padding: 0; }

/* ── LISTS: ticks drawn in, strikes drawn through ─────────────────────────── */
.asoc-ticks, .asoc-strikes { list-style: none; margin: 0; padding: 0; }
/* ⚠️ EVERY LIST HERE IS display:block, AND IT HAS TO SAY SO. A list built as a Bricks
   block is `display:flex; flex-direction:column; align-items:flex-start`, so each row
   shrinks to its own text: hairlines stop at the end of the words, `columns` is
   ignored, and a right-aligned figure sits beside its label instead of at the edge.
   Seen on the first render of all three pages. */
.asoc-ticks, .asoc-strikes, .asoc-chapters, .asoc-pile, .asoc-receipt__lines,
.asoc-indexcard__rows, .asoc-faq { display: block; }
.asoc-ticks > li, .asoc-strikes > li {
  position: relative; padding: 0.7rem 0 0.7rem 3.2rem; line-height: 1.45;
  border-top: 1px solid var(--asoc-line);
}
.asoc-ticks > li:first-child, .asoc-strikes > li:first-child { border-top: 0; }
.asoc-ticks > li::before {
  content: ""; position: absolute; left: 0.2rem; top: 0.85rem; width: 1.9rem; height: 1.9rem;
  background-color: var(--tick);
  -webkit-mask: var(--asoc-tick-svg) no-repeat center / contain; mask: var(--asoc-tick-svg) no-repeat center / contain;
  clip-path: inset(0 0 0 0);
  transition: clip-path 0.45s var(--asoc-ease) calc(0.2s + var(--i, 0) * 60ms);
}
/* Full ink, not muted, and a hairline strike (Sam, 2026-10-10, item 7): each line has to
   stay readable through its strike, because the joke is in reading what she doesn't do. */
.asoc-strikes > li { padding-left: 0; color: var(--asoc-ink); }
.asoc-strikes > li > span {
  /* A BACKGROUND, CLONED PER LINE, not a pseudo-element. An absolutely placed stroke on
     an inline span is one box: on a phone, where an item wraps, it drew a short dash
     under the first words of the first line and left the rest unstruck. With
     box-decoration-break: clone every line fragment gets its own stroke. */
  background: var(--asoc-strike-img) no-repeat 0 55% / 100% 0.62em;
  -webkit-box-decoration-break: clone; box-decoration-break: clone;
  padding-inline: 0.2rem;
  /* 0.85s a stroke, 160ms apart, from 0.6s and 110ms (Sam, 2026-10-09: "decrease the speed
     slightly"). Each item is now visibly crossed out rather than flicked. */
  transition: background-size 0.85s var(--asoc-ease) calc(0.3s + var(--i, 0) * 160ms);
}
:root {
  /* The strike as an image, because a per-line background cannot be a mask. One per
     ground, as tokens: lilac on the desk (--asoc-accent), the paper lilac (--asoc-ink-accent-paper)
     on paper. Coral until 2026-10-13 (Sam: "I'd prefer if the strike-throughs were lilac").
     Stroke 1.1 of a 12-unit box, from 2.4 (Sam, 2026-10-10: "a much thinner strike"). */
  --asoc-strike-img: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 12' preserveAspectRatio='none'%3E%3Cpath d='M2 8.4C40 6.6 92 7.2 130 5.4c24-1.1 46-2.6 66-3.2' fill='none' stroke='%23BE96C8' stroke-width='1.1' stroke-linecap='round'/%3E%3C/svg%3E");
}
.asoc-on-paper, .asoc-ground-paper {
  --asoc-strike-img: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 12' preserveAspectRatio='none'%3E%3Cpath d='M2 8.4C40 6.6 92 7.2 130 5.4c24-1.1 46-2.6 66-3.2' fill='none' stroke='%239F67AD' stroke-width='1.1' stroke-linecap='round'/%3E%3C/svg%3E");
}
/* Drawn in only when the script is there to say "now": with no script they are
   simply drawn. */
html.asoc-draw-js [data-draw]:not(.is-in) .asoc-ticks > li::before { clip-path: inset(0 100% 0 0); }
html.asoc-draw-js [data-draw]:not(.is-in) .asoc-strikes > li > span { background-size: 0% 0.62em; }

/* ── THE "GO FURTHER" LINK (one style, 2026-10-11) ───────────────────────────
   A standalone link with an arrow: "See everything we take on", the menu's "Compare
   packages" and "All services". NOT underlined at rest: it is not in
   running text, and the arrow is the affordance. (Sam: the old resting underline broke
   at the arrow, a line under the words, a gap, a stub under the arrow.) On hover or
   keyboard focus a line draws in under the WORDS only, in the accent, and the arrow
   nudges on. The line is a background sized to the box minus the arrow and its gap
   (--go-arrow), so it can never run under the arrow or break.
   Links INSIDE running text (FAQ answers, the business card's email) keep a real
   underline; that is the rule for links in prose (WCAG 1.4.1). */
.asoc-link, .asoc-megamenu__more {
  --go-arrow: 2rem;                      /* the arrow's 1.4rem + the 0.6rem gap */
  display: inline-flex; align-items: center; gap: 0.6rem; min-height: 44px;
  font-weight: 600; color: var(--asoc-ink); text-decoration: none; border: 0;
  background: linear-gradient(var(--asoc-ink-accent), var(--asoc-ink-accent)) no-repeat
              0 calc(50% + 0.8em) / 0 1.5px;
  transition: background-size 0.35s var(--asoc-ease), color var(--asoc-dur) var(--asoc-ease);
}
.asoc-link:hover, .asoc-link:focus-visible,
.asoc-megamenu__more:hover, .asoc-megamenu__more:focus-visible {
  background-size: calc(100% - var(--go-arrow)) 1.5px;
}
.asoc-link::after, .asoc-megamenu__more::after {
  transition: translate 0.35s var(--asoc-ease-settle);
}
.asoc-link:hover::after, .asoc-link:focus-visible::after,
.asoc-megamenu__more:hover::after, .asoc-megamenu__more:focus-visible::after { translate: 0.4rem 0; }
.asoc-link::after {
  content: ""; width: 1.4rem; height: 1rem; background-color: currentColor;
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 10'%3E%3Cpath d='M1 5h11M8.5 1.5 12 5l-3.5 3.5' fill='none' stroke='%23000' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat center / contain;
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 10'%3E%3Cpath d='M1 5h11M8.5 1.5 12 5l-3.5 3.5' fill='none' stroke='%23000' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat center / contain;
}

/* ══ THE DESK OBJECTS ══════════════════════════════════════════════════════ */

/* ── THE INDEX CARD (home hero) ─────────────────────────────────────────────
   Four facts a first-time visitor checks before anything else, ruled like a card
   from a box on her desk. Lies on the dark desk, so it is paper. */
.asoc-indexcard {
  background: var(--asoc-sheet); border-radius: 3px; box-shadow: var(--asoc-desk-shadow);
  padding: var(--asoc-space-m) var(--asoc-space-m) var(--asoc-space-s);
  rotate: 1.2deg;
}
.asoc-indexcard__label {
  font-family: var(--asoc-font-mono); font-size: var(--asoc-text-label); font-weight: 600;
  letter-spacing: 0.14em; text-transform: uppercase; color: var(--asoc-kicker);
  padding-bottom: var(--asoc-space-xs); margin: 0; border-bottom: 2px solid var(--asoc-line);
}
.asoc-indexcard__rows { list-style: none; margin: 0; padding: 0; }
.asoc-indexcard__rows > li {
  display: flex; flex-direction: row; align-items: baseline; gap: var(--asoc-space-xs);
  padding-block: 0.9rem; border-bottom: 1px solid var(--asoc-line); font-size: var(--asoc-text-small);
}
.asoc-indexcard__rows > li:last-child { border-bottom: 0; }
.asoc-indexcard__rows span { color: var(--asoc-ink-muted); white-space: nowrap; }
.asoc-indexcard__rows strong {
  margin-inline-start: auto; text-align: right; font-weight: 600; color: var(--asoc-ink);
  white-space: nowrap;   /* "$240 a / month" broke in two at 801-1024; the leader gives way instead */
}
/* Dot leaders: the ledger's own way of joining a label to its figure. */
.asoc-indexcard__rows i {
  flex: 1 1 auto; align-self: center; height: 0; min-width: 1.6rem;
  border-bottom: 1.5px dotted var(--asoc-line); translate: 0 0.3rem;
}

/* ── THE FOLDER (home services) ─────────────────────────────────────────────
   Three pillars filed in one manila folder. With the script, the tabs along the top
   switch which sheet is at the front; without it, the three sheets simply stack and
   read as a list. The tabs are BUILT by asoc-concepts.js from each sheet's own
   heading, so there is one string per pillar and nothing to keep in step. */
.asoc-folder { position: relative; }
/* THE TABS SHARE THE SHEET'S EDGE (Sam, 2026-10-11). They were borderless, with gaps
   between them, and the sheet's 1px edge ran under all three and across the gaps, so the
   page showed through as pale slots with a line across. Now: no gaps (neighbours share a
   1px edge), every tab carries the sheet's own edge colour, and the row overlaps the
   sheet's top edge by 1px. An inactive tab draws that edge along its own foot, so it
   reads as filed BEHIND the sheet; the active tab has no foot and the sheet's colour,
   so it reads as the top of the sheet itself. */
/* EQUAL WIDTHS, SET BY THE WIDEST TAB (Sam, 2026-10-11): a max-content grid whose columns
   all share 1fr takes the widest label's width for every tab. minmax(0, 1fr) and the
   max-width keep a narrow phone from pushing the row past the sheet. */
.asoc-folder__tabs {
  display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr);
  /* Flush with the sheet's left edge, 2px apart (Sam, 2026-10-12): offset, and sharing one 1px
     edge, they read as overlapping. Each tab now has its whole border. */
  /* ON THE SHEET'S DRAWN EDGE, not its box (Sam, 2026-10-12): the sheet's edge is a 1px ring
     OUTSIDE its box, so the row moves 1px left and ends exactly at the box top. An inactive
     tab's foot line then lies on the ring's top line; the active tab covers it. Measured
     before: tab 01 1px right of the edge, every foot 1px below the sheet's line. */
  width: max-content; max-width: 100%; gap: 2px; margin-inline-start: -1px;
  position: relative; z-index: var(--asoc-z-section); margin-bottom: 0;
}
.asoc-folder__tab {
  appearance: none; cursor: pointer; min-height: 48px; position: relative;
  font: 600 var(--asoc-text-small) / 1.2 var(--asoc-font-body); color: var(--asoc-ink-muted);
  background: var(--asoc-sheet-back);
  border: 1px solid var(--asoc-sheet-edge); border-bottom: 0;
  padding: 1.1rem 2.4rem 1.2rem; border-radius: 12px 12px 0 0;
  box-shadow: inset 0 -1px 0 var(--asoc-sheet-edge);
  transition: background var(--asoc-dur) var(--asoc-ease), color var(--asoc-dur) var(--asoc-ease);
}
.asoc-folder__tab:hover { color: var(--asoc-ink); background: color-mix(in srgb, var(--asoc-sheet) 45%, var(--asoc-sheet-back)); }
.asoc-folder__tab[aria-selected="true"] { z-index: var(--asoc-z-section); background: var(--asoc-sheet); color: var(--asoc-ink); box-shadow: none; }
.asoc-folder__tab[aria-selected="true"] span { color: var(--asoc-ink-accent); }
.asoc-folder__tab span {
  font-family: var(--asoc-font-mono); font-size: var(--asoc-text-label); letter-spacing: 0.1em;
  margin-inline-end: 0.8rem; color: var(--asoc-ink-muted);
}
.asoc-folder__sheets {
  --sheets-pad: clamp(2.4rem, 1rem + 4vw, 6.4rem);
  /* Square top-left: the first tab rises straight out of that corner, as on a manila folder. */
  position: relative; background: var(--asoc-sheet); border-radius: 0 var(--asoc-radius) var(--asoc-radius) var(--asoc-radius);
  box-shadow: var(--asoc-paper-shadow), 0 0 0 1px var(--asoc-sheet-edge);
  padding: var(--sheets-pad);
}
.asoc-folder__sheet {
  display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr); column-gap: var(--asoc-space-2xl);
  align-items: start;
}
/* The link moves to the sheet's bottom-right corner (Sam, 2026-10-11), under both columns:
   the intro's box dissolves so its children are the sheet's own grid items. */
.asoc-folder__intro { display: contents; }
.asoc-folder__intro > * { grid-column: 1; }
.asoc-folder__sheet > .asoc-folder__list { grid-column: 2; grid-row: 1 / span 3; }
.asoc-folder__intro > .asoc-folder__more { grid-column: 1 / -1; justify-self: end; }
.asoc-folder__sheet + .asoc-folder__sheet { margin-top: var(--asoc-space-xl); padding-top: var(--asoc-space-xl); border-top: 1px solid var(--asoc-line); }
html.asoc-folder-js .asoc-folder__sheet + .asoc-folder__sheet { margin-top: 0; padding-top: 0; border-top: 0; }
/* A CROSSFADE, AND THE FOLDER TAKES EACH SHEET'S OWN HEIGHT (Sam, 2026-10-11). First
   built as one shared cell, so every sheet was as tall as the longest: on a phone tabs 02
   and 03 sat on ~600px of empty paper. Now only the front sheet is in the flow; the others
   lie on top of it, out of the flow, while they fade. asoc-concepts.js animates the
   folder's height from the old sheet's to the new one's (0.4s), so nothing below it jumps.
   The outgoing sheet fades in 0.2s; the incoming one rises 0.6rem and fades in over 0.4s.
   visibility (not display) hides the others, which also takes them out of the
   accessibility tree. */
html.asoc-folder-js .asoc-folder__sheets { transition: height 0.4s var(--asoc-ease); }
html.asoc-folder-js .asoc-folder__sheet {
  transition: opacity 0.4s var(--asoc-ease) 0.12s, translate 0.4s var(--asoc-ease) 0.12s, visibility 0s;
}
html.asoc-folder-js .asoc-folder__sheet[hidden] {
  /* width:auto, or Bricks' width:100% plus the left offset hangs the hidden sheets past the
     screen's edge: phones laid out 2-17px wider than the screen (verify-hero-fit CONTROL). */
  display: grid; position: absolute; top: var(--sheets-pad); left: var(--sheets-pad); right: var(--sheets-pad); width: auto;
  visibility: hidden; opacity: 0; translate: 0 0.6rem;
  transition: opacity 0.2s var(--asoc-ease), translate 0.2s var(--asoc-ease), visibility 0s linear 0.2s;
}
.asoc-folder__num {
  font-family: var(--asoc-font-display); font-weight: 600; letter-spacing: -0.04em; line-height: 0.9;
  /* 120px -> ~82px at 1440 (Sam asked for the hierarchy to be reviewed, 2026-10-11). The
     title is the heading; the numeral is an index the tab already shows, so it should
     not outweigh the words. At ~1.5x the title it still reads as the sheet's mark. */
  font-size: clamp(5.2rem, 2.4rem + 4vw, 8.8rem); color: var(--asoc-figure, var(--asoc-ink-accent)); margin: 0 0 var(--asoc-space-s);
  font-variant-numeric: tabular-nums;
}
.asoc-folder__title { font-size: var(--asoc-text-h2); margin-bottom: var(--asoc-space-s); }
/* One line beside the list (Sam, 2026-10-11: "Bookkeeping & finance" wrapped). The intro
   column is narrowest just above 1024, where the heading size alone wrapped it from 1025
   to ~1200; 3.6vw keeps the longest title on one line there and never exceeds the token. */
.asoc-folder__title { font-size: min(var(--asoc-text-h2), 3.6vw); }
.asoc-folder__desc { color: var(--asoc-ink-muted); font-size: var(--asoc-text-lead); line-height: 1.5; }
.asoc-folder__more { margin-top: var(--asoc-space-l); }
.asoc-folder__list { columns: 2; column-gap: var(--asoc-space-l); }
/* CENTRED AGAINST THE INTRO, not hung from the top (Sam, 2026-10-11, concept item 12): the
   numeral, title, line and link run ~470px at 1440 and the twelve-task list ~250px, so a
   top-aligned list left a ~210px empty band under it. Centred, the space splits above and
   below and the two columns read as one composition. Below 1025 the sheet is one column,
   where centring in its own row changes nothing, so no breakpoint is needed. */
.asoc-folder__list { align-self: center; }
.asoc-folder__list > li { break-inside: avoid; }
/* NO RULE ABOVE THE FIRST ROW OF EITHER COLUMN (Sam, 2026-10-11: nothing sits above them,
   so the lines separate nothing). The first column's comes off with .asoc-ticks'
   first-child rule; the second column's first row has no selector of its own in a
   multi-column list, so the list clips its own top pixel, which is exactly where both
   columns' first rules would be. */
.asoc-folder__list { clip-path: inset(1px 0 0 0); margin-top: -1px; }
@media (max-width: 1024px) {
  .asoc-folder__sheet { grid-template-columns: minmax(0, 1fr); row-gap: var(--asoc-space-l); }
  .asoc-folder__title { font-size: var(--asoc-text-h2); }   /* one column: the full width, the full size */
  /* ONE COLUMN, GROUPED (Sam, 2026-10-11: on a phone every gap was the same and too
     wide). No row gap; each element says how close it belongs to the one above: the
     numeral, title and line as one group, the list a step away, the link closing it. */
  .asoc-folder__sheet { row-gap: 0; }
  .asoc-folder__sheet > .asoc-folder__list { grid-column: 1; grid-row: auto; margin-top: var(--asoc-space-l); }
  .asoc-folder__num { margin-bottom: var(--asoc-space-2xs); }
  .asoc-folder__title { margin-bottom: var(--asoc-space-xs); }
  .asoc-folder__desc { margin: 0; }
  .asoc-folder__intro > .asoc-folder__more { order: 1; margin-top: var(--asoc-space-m); }
}
@media (max-width: 700px) {
  /* The phone keeps the joined tabs (no gaps) and drops the big numeral: the tab above
     already says 01, and on a phone the numeral pushed the title half a screen down. */
  /* Scaled with the screen: at 320 "Bookkeeping" overflowed its equal-width tab by 9px. */
  .asoc-folder__tab {
    padding: 0.8rem clamp(0.6rem, 2.8vw - 0.3rem, 1rem) 0.9rem; text-align: left;
    font-size: clamp(1.25rem, 0.15rem + 3.2vw, 1.4rem);
  }
  .asoc-folder__tab span { display: block; margin: 0 0 0.1rem; font-size: 1.1rem; }
  .asoc-folder__num { display: none; }
  .asoc-folder__list { columns: 1; }
  .asoc-folder__sheets { border-radius: 0 0 var(--asoc-radius) var(--asoc-radius); }
}

/* ── FAIR QUESTIONS (home) ──────────────────────────────────────────────────
   The four objections Georgie hears most, in her own list, each answered plainly.
   A ruled ledger: question left, answer right, a hairline between rows. */
.asoc-faq { list-style: none; margin: 0; padding: 0; counter-reset: faq; border-bottom: 1px solid var(--asoc-line); }
.asoc-faq > li {
  counter-increment: faq;
  display: grid; grid-template-columns: 6rem minmax(0, 1.15fr) minmax(0, 0.85fr); column-gap: var(--asoc-space-l);
  padding-block: var(--asoc-space-l); border-top: 1px solid var(--asoc-line);
}
.asoc-faq > li::before {
  content: counter(faq, decimal-leading-zero);
  font-family: var(--asoc-font-mono); font-size: var(--asoc-text-label); letter-spacing: 0.14em;
  color: var(--asoc-kicker); padding-top: 0.9rem;
}
.asoc-faq__q { font-size: clamp(2.4rem, 1.5rem + 1.7vw, 4.4rem); line-height: 1.08; margin: 0; max-width: 17ch; }
.asoc-faq__a { margin: 0; color: var(--asoc-ink-muted); padding-top: 0.4rem; }
.asoc-faq__a strong { color: var(--asoc-ink); font-weight: 600; }
@media (max-width: 800px) {
  .asoc-faq > li { grid-template-columns: minmax(0, 1fr); row-gap: var(--asoc-space-xs); padding-block: var(--asoc-space-m); }
  .asoc-faq > li::before { padding-top: 0; }
}
/* AS AN ACCORDION (asoc-concepts.js, faqs(); Sam, 2026-10-11). The question is a full-width
   button with a plus that turns to a minus; the answer folds open beneath the question, in
   the same column, by a grid row going 0fr to 1fr. Without the script the ledger above is
   what renders, every answer showing. */
html.asoc-faq-js .asoc-faq > li { grid-template-columns: 6rem minmax(0, 1fr); padding-block: var(--asoc-space-m); }
html.asoc-faq-js .asoc-faq__q { max-width: none; grid-column: 2; }
.asoc-faq__btn {
  appearance: none; border: 0; background: none; padding: 0; margin: 0; width: 100%;
  display: flex; flex-direction: row; align-items: center; justify-content: space-between; gap: var(--asoc-space-m);
  min-height: 44px; font: inherit; color: inherit; text-align: left; cursor: pointer;
}
.asoc-faq__qtext { max-width: 22ch; }
.asoc-faq__icon { position: relative; flex: none; width: 2.4rem; height: 2.4rem; color: var(--asoc-ink-muted);
  transition: color var(--asoc-dur) var(--asoc-ease), rotate 0.4s var(--asoc-ease-settle); }
.asoc-faq__icon::before, .asoc-faq__icon::after {
  content: ""; position: absolute; left: 50%; top: 50%; width: 100%; height: 2px; border-radius: 2px;
  background: currentColor; translate: -50% -50%;
}
.asoc-faq__icon::after { rotate: 90deg; transition: rotate 0.4s var(--asoc-ease-settle); }
.asoc-faq__btn:hover .asoc-faq__icon, .asoc-faq__btn:focus-visible .asoc-faq__icon { color: var(--asoc-ink-accent); }
.asoc-faq > li.is-open .asoc-faq__icon { color: var(--asoc-ink-accent); }
.asoc-faq > li.is-open .asoc-faq__icon::after { rotate: 0deg; }
.asoc-faq__panel {
  grid-column: 2; display: grid; grid-template-rows: 0fr;
  transition: grid-template-rows 0.4s var(--asoc-ease);
}
.asoc-faq > li.is-open .asoc-faq__panel { grid-template-rows: 1fr; }
.asoc-faq__panel > .asoc-faq__a { min-height: 0; overflow: hidden; max-width: 62ch; padding-top: 0; }
.asoc-faq > li.is-open .asoc-faq__panel > .asoc-faq__a { padding-top: var(--asoc-space-s); }
.asoc-faq__panel > .asoc-faq__a { transition: padding-top 0.4s var(--asoc-ease); }
@media (max-width: 800px) {
  html.asoc-faq-js .asoc-faq > li { grid-template-columns: minmax(0, 1fr); row-gap: 0; }
  html.asoc-faq-js .asoc-faq__q, .asoc-faq__panel { grid-column: 1; }
  html.asoc-faq-js .asoc-faq > li::before { margin-bottom: var(--asoc-space-2xs); }
}

/* ── IN THEIR WORDS: ONE LARGE QUOTE AT A TIME (concept item 10, 2026-10-11) ──────
   Sam: the pile "makes people scroll to read; not ideal". One quote at the size of a
   statement, like the credibility quote under the desk, with arrows and a counter that
   asoc-concepts.js (quotes()) builds. No autoplay. WITHOUT JS the four are an ordinary
   list, all readable; the controls do not exist.
   THE HEIGHT NEVER CHANGES: with the script, every quote sits in the SAME grid cell, so
   the stage is as tall as the longest one and moving between them moves nothing below.
   Paced by easing, not by a fade (Sam's motion rule): the quote slides 40px with a small
   settle and its credit follows 120ms later. */
/* No visible heading since 2026-10-11 (Sam): the h2 is screen-reader only and the head
   block carries nothing visible, so it takes no space. The arrows now live on the credit
   row at the bottom right (Humaan's layout). */
.asoc-quotes { position: relative; }
/* ⚠️ THE WHOLE HEAD IS HIDDEN, NOT JUST THE h2: the intro block kept its 32rem flex-basis
   in Bricks' column flex and reserved ~320px of empty dark above the quote (2026-10-11). */
.asoc-quotes__head {
  position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}
.asoc-quotes__intro > h2 { margin-bottom: var(--asoc-space-xs); }
.asoc-quotes__intro > p { margin: 0; color: var(--asoc-ink-muted); max-width: 46ch; }
.asoc-quotes__stage {
  list-style: none; margin: 0; padding: 0;
  display: flex; flex-direction: column; gap: var(--asoc-space-2xl);
}
.asoc-quotes__slide { margin: 0; }
/* ONE QUOTE STYLE (Sam, 2026-10-13): the About and Services pull quote shares these rules, so
   the two cannot drift apart again. The pull quote keeps only its lilac mark. */
.asoc-quotes__text, .asoc-pullquote__text {
  font-family: var(--asoc-font-display); font-weight: var(--asoc-display-wght);
  letter-spacing: var(--asoc-display-tracking); line-height: 1.16;
  font-size: clamp(2.4rem, 1.4rem + 2.4vw, 4.8rem);   /* 24px on a phone: the credibility quote's size */
  color: var(--asoc-ink); margin: 0; max-width: 40ch; text-wrap: pretty;
}
.asoc-quotes__who, .asoc-pullquote__attr { margin: var(--asoc-space-m) 0 0; }
/* With the script every quote shares one cell, so the credit is pushed to the cell's foot:
   it sits on the same row for every quote, level with the arrows, and leaves room for them. */
html.asoc-quotes-js .asoc-quotes__slide { display: flex; flex-direction: column; }
html.asoc-quotes-js .asoc-quotes__who { margin-top: auto; padding-top: var(--asoc-space-m); padding-inline-end: 12rem; }
/* THE NAME IN THE BUSINESS NAME'S INK (Sam, 2026-10-13). Plum until that morning ("I really
   don't like the dark purple on paper"), then lilac for a few hours, which measured 2.19:1 on
   paper; the muted ink is 10.85:1 there and follows the ground. The lilac stays on the mark
   and the client's logo. The home's quotes on dark are the reverse: the same rule (light grey
   there), and the same lilac mark above (Sam, 2026-10-13). */
.asoc-quotes__who strong, .asoc-pullquote__attr strong { display: block; font-weight: 700; color: var(--asoc-ink-muted); }
.asoc-quotes__who span, .asoc-pullquote__attr span {
  display: block; margin-top: 0.4rem; font-family: var(--asoc-font-mono); font-size: var(--asoc-text-label);
  letter-spacing: 0.1em; text-transform: uppercase; color: var(--asoc-ink-muted);
}

/* The controls (Sam, 2026-10-11): two arrows on the credit row, bottom right, no counter.
   The circle is smaller (36px) and only drawn on hover, keyboard focus or press; the button
   itself stays a 44px target, so only the drawing shrank. */
.asoc-quotes__controls {
  position: absolute; right: 0; bottom: 0;
  display: flex; flex-direction: row; align-items: center; gap: 0.4rem;
}
.asoc-quotes__btn {
  position: relative; display: inline-flex; align-items: center; justify-content: center;
  width: 4.4rem; height: 4.4rem; padding: 0; border: 0; border-radius: var(--asoc-radius-pill);
  background: transparent; color: var(--asoc-ink); cursor: pointer;
}
.asoc-quotes__btn::before {
  content: ""; position: absolute; inset: 0.4rem; border-radius: var(--asoc-radius-pill);
  box-shadow: inset 0 0 0 1.5px var(--asoc-ink-accent);
  background-color: color-mix(in srgb, var(--asoc-ink) 6%, transparent);
  opacity: 0; scale: 0.85;
  transition: opacity var(--asoc-dur) var(--asoc-ease), scale var(--asoc-dur) var(--asoc-ease-settle);
}
.asoc-quotes__btn:hover::before, .asoc-quotes__btn:focus-visible::before, .asoc-quotes__btn:active::before { opacity: 1; scale: 1; }
.asoc-quotes__btn:focus-visible { outline: none; }
.asoc-quotes__btn svg { position: relative; width: 2rem; height: 2rem; }

html.asoc-quotes-js .asoc-quotes__stage { display: grid; }
html.asoc-quotes-js .asoc-quotes__slide {
  grid-area: 1 / 1; visibility: hidden;
  transition: visibility 0s linear 0.3s;
}
html.asoc-quotes-js .asoc-quotes__slide.is-active { visibility: visible; transition: visibility 0s; }
html.asoc-quotes-js .asoc-quotes__slide > * {
  opacity: 0; translate: var(--to, -40px) 0;
  transition: opacity 0.3s var(--asoc-ease), translate 0.3s var(--asoc-ease);
}
html.asoc-quotes-js .asoc-quotes__slide.is-active > * {
  opacity: 1; translate: 0 0;
  transition: opacity 0.45s var(--asoc-ease), translate 0.7s var(--asoc-ease-settle);
}
html.asoc-quotes-js .asoc-quotes__slide.is-active > .asoc-quotes__who { transition-delay: 0.12s; }
/* The entering quote is parked on the side it arrives from, with no transition, for one
   frame; the script then lets it go. */
html.asoc-quotes-js .asoc-quotes__slide.is-entering > * { transition: none; translate: var(--from, 40px) 0; opacity: 0; }

/* ── THE PILE (home testimonials) ───────────────────────────────────────────
   Client notes that square up into a neat pile as you scroll: each card holds its
   place a few millimetres below the last, so the stack builds on screen the way the
   desk's piles did. Sticky positioning only, no script, no pin. The heading holds
   its place beside the pile on a wide screen. */
.asoc-pile-wrap {
  display: grid; grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr); column-gap: var(--asoc-space-2xl);
  align-items: start;
}
.asoc-pile-wrap > .asoc-head { position: sticky; top: 14vh; margin-bottom: 0; }
.asoc-pile { list-style: none; margin: 0; padding: 0; }
.asoc-pile__card {
  position: sticky; top: calc(14vh + var(--i, 0) * 1.4rem);
  background: var(--asoc-sheet); border-radius: 6px; box-shadow: var(--asoc-desk-shadow);
  padding: clamp(2.8rem, 1.6rem + 3vw, 5.6rem);
  min-height: min(56vh, 46rem);
  display: flex; flex-direction: column; justify-content: space-between; gap: var(--asoc-space-l);
  rotate: var(--r, 0deg);
}
.asoc-pile__card + .asoc-pile__card { margin-top: 32vh; }
.asoc-pile__card:nth-child(1) { --i: 0; --r: -0.6deg; }
.asoc-pile__card:nth-child(2) { --i: 1; --r: 0.5deg; }
.asoc-pile__card:nth-child(3) { --i: 2; --r: -0.3deg; }
.asoc-pile__card:nth-child(4) { --i: 3; --r: 0.4deg; }
.asoc-pile__stars {
  margin: 0; height: 1.8rem; width: 10.6rem; background-color: var(--asoc-mark-3-paper);
  --asoc-stars: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 106 18'%3E%3Cg fill='%23000'%3E%3Cpath transform='translate(0 0)' d='M9 1l2.2 5.1 5.5.5-4.2 3.6 1.3 5.4L9 12.8 4.2 15.6l1.3-5.4L1.3 6.6l5.5-.5z'/%3E%3Cpath transform='translate(22 0)' d='M9 1l2.2 5.1 5.5.5-4.2 3.6 1.3 5.4L9 12.8 4.2 15.6l1.3-5.4L1.3 6.6l5.5-.5z'/%3E%3Cpath transform='translate(44 0)' d='M9 1l2.2 5.1 5.5.5-4.2 3.6 1.3 5.4L9 12.8 4.2 15.6l1.3-5.4L1.3 6.6l5.5-.5z'/%3E%3Cpath transform='translate(66 0)' d='M9 1l2.2 5.1 5.5.5-4.2 3.6 1.3 5.4L9 12.8 4.2 15.6l1.3-5.4L1.3 6.6l5.5-.5z'/%3E%3Cpath transform='translate(88 0)' d='M9 1l2.2 5.1 5.5.5-4.2 3.6 1.3 5.4L9 12.8 4.2 15.6l1.3-5.4L1.3 6.6l5.5-.5z'/%3E%3C/g%3E%3C/svg%3E");
  -webkit-mask: var(--asoc-stars) no-repeat left center / contain;
  mask: var(--asoc-stars) no-repeat left center / contain;
  font-size: 0; color: transparent;
  /* ⚠️ max-width:none IS LOAD-BEARING. Every <p> is capped at 68ch, and a ch at
     font-size 0 is 0px, so the star row measured 0.19px wide and painted nothing. */
  max-width: none;
}
.asoc-pile__quote {
  font-family: var(--asoc-font-display); font-weight: 500; letter-spacing: -0.015em;
  font-size: clamp(2.2rem, 1.6rem + 1.4vw, 3.4rem); line-height: 1.22; color: var(--asoc-ink);
  margin: 0; max-width: 30ch; text-wrap: pretty;
}
.asoc-pile__who { margin: 0; font-size: var(--asoc-text-small); }
.asoc-pile__who strong { display: block; color: var(--asoc-ink); font-weight: 700; }
.asoc-pile__who span {
  display: block; margin-top: 0.3em; font-family: var(--asoc-font-mono); font-size: var(--asoc-text-label);
  letter-spacing: 0.14em; text-transform: uppercase; color: var(--asoc-ink-muted);
}
@media (max-width: 1024px) {
  .asoc-pile-wrap { grid-template-columns: minmax(0, 1fr); }
  .asoc-pile-wrap > .asoc-head { position: static; margin-bottom: var(--asoc-space-xl); }
}
@media (max-width: 800px) {
  .asoc-pile__card { top: calc(var(--asoc-space-l) + var(--i, 0) * 1.2rem); min-height: 0; }
  .asoc-pile__card + .asoc-pile__card { margin-top: 24vh; }
}

/* ── THE POLAROID (home founder, About hero) ────────────────────────────────
   Her photograph has not arrived; the logomark stands in it until it does, and the
   swap is one image. */
.asoc-polaroid {
  margin: 0; width: min(100%, 38rem); background: var(--asoc-sheet);
  padding: 1.6rem 1.6rem 0; border-radius: 3px; box-shadow: var(--asoc-print-shadow);
  rotate: -2.4deg;
}
.asoc-polaroid img, .asoc-polaroid .asoc-polaroid__img {
  display: block; width: 100%; aspect-ratio: 1; object-fit: cover; background: var(--asoc-surface);
}
.asoc-polaroid figcaption, .asoc-polaroid__cap {
  margin: 0; padding: 1.4rem 0.4rem 1.8rem; font-family: var(--asoc-font-mono);
  font-size: var(--asoc-text-label); letter-spacing: 0.12em; text-transform: uppercase; color: var(--asoc-ink-muted);
}
.asoc-hero--split .asoc-polaroid { width: min(100%, 38rem, 48vh); }
@media (max-width: 800px) { .asoc-hero--split .asoc-polaroid { width: min(78%, 30rem); } }

/* ── MEET GEORGIE (home) ───────────────────────────────────────────────────── */
.asoc-meet { display: grid; grid-template-columns: minmax(0, 36rem) minmax(0, 1fr); column-gap: var(--asoc-space-2xl); align-items: center; }
.asoc-meet__media { justify-self: center; }
.asoc-meet__text > .asoc-btn, .asoc-meet__text > .asoc-link { margin-top: var(--asoc-space-m); }
.asoc-meet__text > p:not(.asoc-kicker) { color: var(--asoc-ink-muted); font-size: var(--asoc-text-lead); line-height: 1.5; }
@media (max-width: 800px) {
  .asoc-meet { grid-template-columns: minmax(0, 1fr); row-gap: var(--asoc-space-l); }
  .asoc-meet__media { justify-self: start; width: min(70%, 26rem); }
}

/* ── THE RECEIPT (About) ────────────────────────────────────────────────────
   Her qualifications, itemised. It prints when it scrolls into view — the paper
   feeds down out of a slot — and with no script it is simply printed. */
.asoc-receipt {
  position: relative; background: var(--asoc-sheet); color: var(--asoc-on-light);
  font-family: var(--asoc-font-mono); font-size: 1.4rem; line-height: 1.5; letter-spacing: 0.02em;
  padding: var(--asoc-space-l) var(--asoc-space-m) var(--asoc-space-xl);
  box-shadow: var(--asoc-paper-shadow);
  -webkit-mask: var(--asoc-zigzag) repeat-x left bottom / 1.6rem 0.8rem, linear-gradient(#000 0 0) no-repeat left top / 100% calc(100% - 0.79rem);
  mask: var(--asoc-zigzag) repeat-x left bottom / 1.6rem 0.8rem, linear-gradient(#000 0 0) no-repeat left top / 100% calc(100% - 0.79rem);
  clip-path: inset(0 0 0 0);
  transition: clip-path 1.6s cubic-bezier(0.55, 0.1, 0.35, 1) 0.2s;
}
/* The trigger is on the receipt's WRAPPER: a fully clipped element never reads as in
   view to an IntersectionObserver, so a trigger on the receipt itself never fires. */
html.asoc-draw-js [data-draw]:not(.is-in) .asoc-receipt { clip-path: inset(0 0 100% 0); }
.asoc-receipt__head { margin: 0 0 var(--asoc-space-s); text-align: left; font-weight: 600; letter-spacing: 0.18em; text-transform: uppercase; font-size: var(--asoc-text-label); }
.asoc-receipt__head span { display: block; font-weight: 400; letter-spacing: 0.08em; color: var(--asoc-on-light-muted); text-transform: none; font-size: 1.3rem; }
.asoc-receipt__lines { list-style: none; margin: 0; padding: var(--asoc-space-xs) 0; border-block: 1.5px dashed var(--asoc-line); }
.asoc-receipt__lines > li { display: flex; flex-direction: row; gap: 0.8rem; padding-block: 0.45rem; align-items: baseline; }
.asoc-receipt__lines > li > span:first-child { flex: 0 1 auto; }
.asoc-receipt__lines > li > i { flex: 1 1 auto; min-width: 1rem; border-bottom: 1.5px dotted var(--asoc-line); translate: 0 -0.35rem; }
.asoc-receipt__lines > li > span:last-child { flex: none; color: var(--asoc-on-light-muted); text-align: right; }
.asoc-receipt__total {
  display: flex; flex-direction: row; justify-content: space-between; gap: var(--asoc-space-s); align-items: baseline;
  margin: var(--asoc-space-s) 0 0; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em;
}
/* align-self: stretch is load-bearing. The receipt is a Bricks block, a column flex box
   with align-items: flex-start, so without it this row shrank to its own text (231px of
   428 at 1440) and the "right-aligned" total sat mid-card (Sam, 2026-10-10, item 6). */
.asoc-receipt__total { align-self: stretch; }
.asoc-receipt__total span:last-child { text-align: right; text-transform: none; letter-spacing: 0; font-weight: 600; min-width: 0; }
.asoc-receipt__foot { margin: var(--asoc-space-m) 0 0; color: var(--asoc-on-light-muted); font-size: 1.2rem; letter-spacing: 0.1em; text-transform: uppercase; }

/* ── THE STORY (About): three chapters beside the receipt ─────────────────── */
.asoc-story { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 0.75fr); column-gap: var(--asoc-space-2xl); align-items: start; }
.asoc-story__receipt { position: sticky; top: 8vh; }
/* HER NOTE BESIDE THE RECEIPT (Sam, 2026-10-10, item 6). Out of flow ([data-note]), so the
   page lays out identically without it. From 1025 it sits in the right column's empty top,
   above the receipt and beside the heading, and travels with the receipt while it sticks:
   that column holds nothing else, so the note can never land on text. 801-1024, where the
   story stacks, it sits to the receipt's right. From 800 down there is no room, so no note. */
.asoc-story__hand { --pen: var(--asoc-pen); rotate: -1.5deg; left: 0; right: 0; bottom: calc(100% + var(--asoc-space-s)); }
.asoc-story__hand .asoc-hand { --hand-size: 22px; --hand-spacing: 2; }  /* 2: "accounts", not "accants" */
.asoc-chapters { list-style: none; margin: 0; padding: 0; counter-reset: ch; }
.asoc-chapters > li {
  counter-increment: ch; display: grid; grid-template-columns: 7.2rem minmax(0, 1fr); column-gap: var(--asoc-space-m);
  padding-block: var(--asoc-space-l); border-top: 1px solid var(--asoc-line);
}
.asoc-chapters > li:first-child { border-top: 0; padding-top: 0; }
.asoc-chapters > li::before {
  content: counter(ch); font-family: var(--asoc-font-display); font-weight: 600; font-size: 5.6rem; line-height: 0.9;
  letter-spacing: -0.04em; color: var(--asoc-figure, var(--asoc-ink-accent));
}
.asoc-chapters > li > * { grid-column: 2; }
.asoc-chapters > li::before { grid-row: 1 / span 2; }
.asoc-chapters h3 { font-size: var(--asoc-text-h3); }
.asoc-chapters p { color: var(--asoc-ink-muted); margin: 0; }
@media (max-width: 1024px) {
  .asoc-story { grid-template-columns: minmax(0, 1fr); row-gap: var(--asoc-space-xl); }
  /* `top: auto` (2026-10-12): the sticky rule's `top: 8vh` survived into this relative
     position and pushed the stacked receipt 8% of the screen height down, past its section
     into the dark band below (68px at 393x852; layout gap 152px above it). */
  .asoc-story__receipt { position: relative; top: auto; max-width: 46rem; }
  /* As wide as the space beside the receipt allows, up to 34rem: 100cqw is the story's
     width (it is the container), 100% is the receipt's. A fixed 26rem left "left." on a
     line of its own at 1024 with ~470px free. */
  .asoc-story { container-type: inline-size; }
  .asoc-story__hand { left: calc(100% + var(--asoc-space-l)); right: auto; bottom: auto; top: var(--asoc-space-l);
    width: min(34rem, 100cqw - 100% - var(--asoc-space-l)); }
}
@media (max-width: 700px) {
  .asoc-chapters > li { grid-template-columns: minmax(0, 1fr); }
  .asoc-chapters > li > * { grid-column: 1; }
  .asoc-chapters > li::before { grid-row: auto; }
  .asoc-chapters > li::before { font-size: 4rem; margin-bottom: var(--asoc-space-xs); }
}

/* ── VALUES (About): what I do, what I don't ────────────────────────────────── */
.asoc-values { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); column-gap: var(--asoc-space-2xl); }
/* No rule under the column heading (Sam, 2026-10-10, concept feedback item 3): the list's
   own hairlines already separate the rows, and the heading reads as a heading without it. */
.asoc-values__col > h3 { padding-bottom: var(--asoc-space-xs); margin-bottom: 0; }
.asoc-values__col > ul > li { font-size: var(--asoc-text-lead); padding-block: 1.1rem; }
/* THE LISTS ARE PLAIN NOW (Sam, 2026-10-13): no tick or strike on each item. The tick follows
   "What I do" and the strike runs through "What I don't", so each mark is made once, on the
   heading, and the rows between are soft dotted rules rather than hairlines. The dots mix the
   muted ink, so they follow the ground. */
.asoc-values__list { list-style: none; margin: 0; padding: 0; display: block; }
.asoc-values__list > li {
  line-height: 1.45;
  /* Round dots 6px apart, drawn rather than `border: dotted`: Chrome's 1px dotted border
     packs square dots edge to edge and reads as a solid hairline at 1440 (2026-10-13). */
  background: radial-gradient(circle, color-mix(in srgb, var(--asoc-ink-muted) 55%, transparent) 0.9px, transparent 1.3px)
              0 0 / 6px 3px repeat-x;
}
.asoc-values__list > li:first-child { background: none; }
.asoc-values__do::after {
  content: ""; display: inline-block; vertical-align: -0.04em; width: 0.78em; height: 0.78em; margin-inline-start: 0.3em;
  background-color: var(--tick);
  -webkit-mask: var(--asoc-tick-svg) no-repeat center / contain; mask: var(--asoc-tick-svg) no-repeat center / contain;
  clip-path: inset(0 0 0 0); transition: clip-path 0.45s var(--asoc-ease) 0.2s;
}
.asoc-values__dont > span {
  background: var(--asoc-strike-img) no-repeat 0 55% / 100% 0.62em;
  -webkit-box-decoration-break: clone; box-decoration-break: clone;
  transition: background-size 0.85s var(--asoc-ease) 0.3s;
}
html.asoc-draw-js [data-draw]:not(.is-in) .asoc-values__do::after { clip-path: inset(0 100% 0 0); }
html.asoc-draw-js [data-draw]:not(.is-in) .asoc-values__dont > span { background-size: 0% 0.62em; }
@media (max-width: 800px) { .asoc-values { grid-template-columns: minmax(0, 1fr); row-gap: var(--asoc-space-xl); } }

/* ── THE PULL QUOTE (About, Services) ─────────────────────────────────────── */
/* TYPE, MEASURE AND CREDIT ARE THE HOME QUOTES' (shared rules at .asoc-quotes__text, 2026-10-13).
   Until then this ran larger (56px), at 44ch, balanced, with the name in plain ink. The mark is
   its own: lilac through --asoc-figure, the paper scope's display-accent token. */
.asoc-pullquote__text::before, .asoc-quotes__text::before {
  content: "\201C"; display: block; font-size: 1.6em; line-height: 0.6; height: 0.42em;
  color: var(--asoc-figure, var(--asoc-ink-accent));
}
/* THE CLIENT'S LOGO BESIDE THE CREDIT (Sam, 2026-10-13, option A of three mockups). Set by
   data-logo on the credit; a credit without one is unchanged. Each logo is a single-colour mask
   in src/logos/, filled with lilac, so every client's mark reads as one family on
   any ground. Sized by height; a wide wordmark simply takes more room on the left. The mark is
   drawn by CSS, so screen readers skip it: the business name is in the text beside it.
   Grid, not flex: the logo and its rule span both lines of the credit. */
.asoc-pullquote__attr[data-logo] {
  display: grid; grid-template-columns: auto 1px minmax(0, 1fr); column-gap: 1.4rem; align-items: center;
}
.asoc-pullquote__attr[data-logo]::before {
  content: ""; grid-column: 1; grid-row: 1 / 3; height: 5.2rem; aspect-ratio: var(--logo-ratio, 1);
  /* THE TEXT SETS THE CREDIT'S HEIGHT, NEVER THE LOGO: -0.8rem a side takes it to 36px of layout
     while it still draws 52px, centred on the two lines. At 393 the lines are ~51px, so a 52px
     logo grew the credit and verify-grid read the 0.69px it added as space inside the section's
     padding (the walk cannot see a pseudo-element). A credit is now the same height with or
     without a logo. */
  margin-block: -0.8rem;
  background-color: var(--asoc-ink-accent-dark);
  -webkit-mask: var(--logo) no-repeat center / contain; mask: var(--logo) no-repeat center / contain;
}
.asoc-pullquote__attr[data-logo]::after { content: ""; grid-column: 2; grid-row: 1 / 3; align-self: stretch; background: var(--asoc-line); }
.asoc-pullquote__attr[data-logo] > strong, .asoc-pullquote__attr[data-logo] > span { grid-column: 3; }
.asoc-pullquote__attr[data-logo="okay-va"] { --logo: url("logos/okay-va.png"); --logo-ratio: 781 / 926; }

/* CENTRED AS A BLOCK, LEFT-ALIGNED INSIDE (Sam, 2026-10-13: "stay left aligned but be centered").
   CSS cannot shrink a wrapped paragraph to its longest line (its box stays the column's width),
   so asoc-concepts.js measures that line into --pq-w and both the quote and its credit take it,
   centred. Without the script the quote sits at the column's left edge, as before. */
.asoc-pullquote__text.is-measured, .asoc-pullquote__text.is-measured + .asoc-pullquote__attr {
  width: var(--pq-w); max-width: 100%; margin-inline: auto;
}

/* ── PILLARS (Services) ───────────────────────────────────────────────────── */
.asoc-pillar {
  background: var(--asoc-sheet); border-radius: 4px; box-shadow: var(--asoc-paper-shadow), 0 0 0 1px var(--asoc-sheet-edge);
  padding: clamp(2.4rem, 1.6rem + 1.6vw, 4rem);
}
.asoc-pillar__num {
  font-family: var(--asoc-font-display); font-weight: 600; letter-spacing: -0.04em; line-height: 0.9;
  font-size: clamp(5rem, 3rem + 3vw, 8rem); color: var(--asoc-figure, var(--asoc-ink-accent)); margin: 0 0 var(--asoc-space-s);
}
.asoc-pillar > h3 { margin-bottom: var(--asoc-space-xs); }
.asoc-pillar > p:not(.asoc-pillar__num) { color: var(--asoc-ink-muted); margin-bottom: var(--asoc-space-m); }
.asoc-pillar > .asoc-ticks { border-top: 2px solid var(--asoc-ink); padding-top: 0.4rem; }

/* ── THE PICKER AND THE PACKAGES (Services) ─────────────────────────────────
   "What would you hand over?" The tasks are an ordinary list in the page; the
   script turns each into a toggle and works out the smallest package that covers
   everything ticked, from the package each task first appears in (her own matrix).
   The answer is said in words in a live region, and the card is marked as well. */
.asoc-packages { display: flex; flex-direction: column; gap: var(--asoc-space-xl); }
.asoc-picker {
  display: grid; grid-template-columns: minmax(0, 0.75fr) minmax(0, 1.25fr); column-gap: var(--asoc-space-2xl);
  background: var(--asoc-surface-raised); border-radius: var(--asoc-radius-lg);
  padding: clamp(2.4rem, 1.4rem + 2.4vw, 5.6rem);
}
.asoc-picker__intro > :last-child { margin-bottom: 0; }
.asoc-picker__groups > .asoc-picker__group:first-child { margin-top: 0; }
.asoc-picker > h3 { margin-bottom: var(--asoc-space-2xs); }
.asoc-picker__group { font-family: var(--asoc-font-mono); font-size: var(--asoc-text-label); letter-spacing: 0.14em; text-transform: uppercase; color: var(--asoc-kicker); margin: var(--asoc-space-s) 0 var(--asoc-space-2xs); }
.asoc-picker__tasks { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: row; flex-wrap: wrap; gap: 1.2rem 0.6rem; }   /* rows 12px: 6px read cramped (Sam, 2026-10-09) */
.asoc-picker__tasks > li { margin: 0; }
.asoc-picker__tasks > li:not(:has(button)) {
  padding: 0.6rem 1.2rem; border-radius: var(--asoc-radius-pill); box-shadow: inset 0 0 0 1px var(--asoc-line);
  font-size: var(--asoc-text-small);
}
.asoc-chip {
  appearance: none; border: 0; cursor: pointer; min-height: 44px;
  display: inline-flex; align-items: center; gap: 0.6rem;
  padding: 0.6rem 1.4rem; border-radius: var(--asoc-radius-pill);
  font: 500 var(--asoc-text-small) / 1.2 var(--asoc-font-body); color: var(--asoc-ink);
  background: transparent; box-shadow: inset 0 0 0 1.5px var(--asoc-line);
  transition: background var(--asoc-dur) var(--asoc-ease), box-shadow var(--asoc-dur) var(--asoc-ease), color var(--asoc-dur) var(--asoc-ease);
}
.asoc-chip:hover { box-shadow: inset 0 0 0 1.5px var(--asoc-accent); }
.asoc-chip::before {
  /* The empty box takes the chip's own faint border, not the label's colour: in full ink it
     competed with the words beside it (Sam, 2026-10-09). */
  content: ""; width: 1.4rem; height: 1.4rem; border-radius: 4px; box-shadow: inset 0 0 0 1.5px var(--asoc-line); flex: none;
  background: transparent var(--asoc-tick-svg) no-repeat center / 0 0;
}
.asoc-chip[aria-pressed="true"] { background: var(--asoc-accent); color: var(--asoc-accent-ink); box-shadow: none; }
.asoc-chip[aria-pressed="true"]::before { box-shadow: none; background-color: var(--asoc-accent-ink);
  -webkit-mask: var(--asoc-tick-svg) no-repeat center / contain; mask: var(--asoc-tick-svg) no-repeat center / contain; }
.asoc-picker__result {
  margin: var(--asoc-space-m) 0 0; padding-top: var(--asoc-space-s); border-top: 1px solid var(--asoc-line);
  font-size: var(--asoc-text-small); color: var(--asoc-ink-muted); min-height: 5.4em;
}
.asoc-picker__result strong { display: block; font-family: var(--asoc-font-display); font-size: var(--asoc-text-h3); color: var(--asoc-ink); font-weight: 600; letter-spacing: -0.02em; margin-bottom: 0.2rem; }
.asoc-picker__note { font-size: var(--asoc-text-small); color: var(--asoc-ink-muted); margin: var(--asoc-space-xs) 0 0; }
.asoc-picker__tier { color: var(--asoc-kicker); }   /* the tier eyebrows' own token, so they cannot drift apart */
/* The picker's question outranks its answer (Sam, 2026-10-09: it read smaller than the
   package line). 44px at 1440 against the answer's 32px; 28px on a phone against 24. */
.asoc-picker__intro > h3 { font-size: clamp(2.8rem, 2rem + 1.6vw, 4.4rem); line-height: 1.08; }
/* The caveat in her hand, at the foot of the left column (from 1025 up; see the builder).
   The column stretches to the chips' height, so its foot is empty space by construction. */
.asoc-picker { position: relative; align-items: stretch; }   /* Bricks' flex-start would stop the column reaching the foot */
.asoc-picker__intro { position: relative; }
.asoc-picker__intro > .asoc-picker__note { display: none; }
.asoc-picker__hand { left: 0; right: 0; bottom: 0; --pen: var(--asoc-ink-accent); }
.asoc-picker__hand .asoc-hand { --hand-size: 21px; max-width: 34rem; }
/* Clear all: a small hand-drawn bin beside the answer, shown once anything is ticked. Its
   box sits on the answer's first line; the answer gives up the bin's width while it shows. */
.asoc-picker__answer { position: relative; }
.asoc-picker__answer:has(.asoc-picker__clear:not([hidden])) .asoc-picker__result { padding-right: 5.2rem; }
.asoc-picker__clear {
  position: absolute; top: calc(var(--asoc-space-m) + var(--asoc-space-s) - 0.6rem); right: -0.8rem;
  width: 44px; height: 44px; display: grid; place-items: center;
  appearance: none; border: 0; border-radius: 50%; background: transparent; cursor: pointer;
  color: var(--asoc-ink-muted); transition: color var(--asoc-dur) var(--asoc-ease), background var(--asoc-dur) var(--asoc-ease);
}
.asoc-picker__clear[hidden] { display: none; }
.asoc-picker__clear:hover { color: var(--asoc-ink); background: var(--asoc-line); }
.asoc-picker__clear svg { width: 2.4rem; height: 2.4rem; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }

.asoc-tiers { align-items: stretch; }
/* TOP-ALIGNED, EACH CARD ITS OWN HEIGHT (Sam, 2026-10-13). Names and prices sit on one line
   for comparing across; each card ends where its list ends, so the row still lengthens towards
   Growth. Bottom-aligned was tried the same morning: it put $240 about 260px below $980. Side by
   side only; stacked, the cards follow each other. */
@media (min-width: 801px) { .asoc-tiers { align-items: start; } }
.asoc-tier {
  position: relative; display: flex; flex-direction: column;
  background: var(--asoc-surface-raised); border-radius: var(--asoc-radius-lg);
  padding: clamp(2.2rem, 1.4rem + 1.4vw, 3.6rem);
  box-shadow: inset 0 0 0 1.5px transparent;
  transition: box-shadow var(--asoc-dur) var(--asoc-ease), transform var(--asoc-dur) var(--asoc-ease), opacity var(--asoc-dur) var(--asoc-ease);
}
.asoc-tier__name { font-family: var(--asoc-font-mono); font-size: var(--asoc-text-label); font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase; color: var(--asoc-kicker); margin: 0 0 var(--asoc-space-xs); }
.asoc-tier__price {
  font-family: var(--asoc-font-display); font-weight: 600; letter-spacing: -0.03em; line-height: 1;
  font-size: clamp(4rem, 3rem + 2vw, 5.6rem); color: var(--asoc-ink); margin: 0 0 var(--asoc-space-s);
  font-variant-numeric: tabular-nums;
}
.asoc-tier__price span { font-family: var(--asoc-font-body); font-size: var(--asoc-text-small); font-weight: 500; letter-spacing: 0; color: var(--asoc-ink-muted); margin-inline-start: 0.4rem; }
.asoc-tier__for { color: var(--asoc-ink-muted); font-size: var(--asoc-text-small); margin-bottom: var(--asoc-space-s); }
.asoc-tier__plus { font-family: var(--asoc-font-mono); font-size: var(--asoc-text-label); letter-spacing: 0.1em; text-transform: uppercase; color: var(--asoc-ink-muted); margin: 0 0 0.2rem; }
.asoc-tier > .asoc-ticks { font-size: var(--asoc-text-small); }
.asoc-tier > .asoc-ticks > li::before { width: 1.6rem; height: 1.6rem; top: 0.8rem; }
.asoc-tier__fit {
  position: absolute; top: calc(-1 * var(--asoc-space-xs)); right: var(--asoc-space-m);
  background: var(--asoc-accent); color: var(--asoc-accent-ink); border-radius: var(--asoc-radius-pill);
  font-family: var(--asoc-font-mono); font-size: 1.2rem; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase;
  padding: 0.5rem 1.2rem;
}
.asoc-tiers[data-fit] .asoc-tier { opacity: 0.55; }
.asoc-tiers[data-fit] .asoc-tier.is-fit { opacity: 1; box-shadow: inset 0 0 0 1.5px var(--asoc-accent); transform: translateY(-0.6rem); }
@media (max-width: 1024px) {
  .asoc-picker { grid-template-columns: minmax(0, 1fr); row-gap: var(--asoc-space-m); }
  /* STACKED, THE ANSWER FOLLOWS THE CHOICES. Side by side the answer sits beside the
     chips; stacked it sat ABOVE them, so on a phone a tap changed a line that had
     already scrolled off the top. The intro's box is dissolved so its parts can be
     ordered around the chips without changing the element tree. */
  .asoc-picker__intro { display: contents; }
  .asoc-picker__groups { order: 1; }
  .asoc-picker__answer, .asoc-picker__note { order: 2; }
  .asoc-picker__clear { top: calc(var(--asoc-space-s) - 0.6rem); }   /* the stacked answer has no top margin */
  .asoc-picker__intro > .asoc-picker__note { display: block; }
  .asoc-picker__hand { display: none; }
  .asoc-picker__result { margin-top: 0; }
}

/* The personalised package: her fourth option, deliberately unpriced. */
.asoc-custom {
  position: relative; margin-top: var(--asoc-space-l);
  border-radius: var(--asoc-radius-lg); box-shadow: inset 0 0 0 1.5px var(--asoc-line);
  padding: clamp(2.4rem, 1.4rem + 2vw, 4.8rem);
}
.asoc-custom > p { color: var(--asoc-ink-muted); max-width: 50ch; }
.asoc-custom > .asoc-btn { margin-top: var(--asoc-space-m); }   /* the site's button gap (verify-grid BUTTON GAP); was space-s, 16px, until 2026-10-12 */

/* ── HOW WE WORK (Services): three steps along a drawn line ─────────────── */
.asoc-steps { list-style: none; margin: 0; padding: 0; position: relative; counter-reset: st; }
.asoc-steps > li { counter-increment: st; position: relative; padding-top: var(--asoc-space-xl); }
.asoc-steps > li::before {
  content: counter(st); position: absolute; top: 0; left: 0;
  width: 4.8rem; height: 4.8rem; border-radius: 50%; display: grid; place-items: center;
  font-family: var(--asoc-font-display); font-weight: 600; font-size: 2.2rem; color: var(--asoc-ink);
  background: var(--asoc-paper); box-shadow: inset 0 0 0 2px var(--asoc-ink);
  translate: 0 -1.2rem;
}
.asoc-steps > li > p { color: var(--asoc-ink-muted); }
.asoc-steps::before {
  content: ""; position: absolute; left: 2.4rem; right: 0; top: 1.2rem; height: 0;
  border-top: 2px dashed var(--asoc-line);
}
@media (max-width: 800px) {
  .asoc-steps::before { left: 2.4rem; right: auto; top: 0; bottom: 0; height: auto; border-top: 0; border-left: 2px dashed var(--asoc-line); }
  .asoc-steps > li { padding: 0 0 var(--asoc-space-l) 7.2rem; }
  .asoc-steps > li::before { translate: 0 0; }
}

/* ── THE WORD WALL (Services): software and industries ──────────────────── */
.asoc-wall { display: grid; grid-template-columns: minmax(0, 0.7fr) minmax(0, 1.3fr); column-gap: var(--asoc-space-2xl); align-items: start; }
.asoc-wall__label { font-family: var(--asoc-font-mono); font-size: var(--asoc-text-label); letter-spacing: 0.14em; text-transform: uppercase; color: var(--asoc-kicker); margin: 0 0 var(--asoc-space-xs); }
.asoc-wall__words { list-style: none; margin: 0 0 var(--asoc-space-l); padding: 0; display: flex; flex-direction: row; flex-wrap: wrap; column-gap: 0.32em; row-gap: 0.1em; }
.asoc-wall__words:last-child { margin-bottom: 0; }
.asoc-wall__words > li {
  font-family: var(--asoc-font-display); font-weight: 600; letter-spacing: -0.02em; line-height: 1.15;
  font-size: clamp(2.4rem, 1.8rem + 1.4vw, 4rem); color: var(--asoc-ink);
}
.asoc-wall__words > li:not(:last-child)::after { content: "/"; margin-inline-start: 0.32em; color: var(--asoc-ink-faint); font-weight: 400; }
.asoc-wall__words--soft > li { color: var(--asoc-ink-muted); font-size: clamp(2rem, 1.6rem + 0.9vw, 2.8rem); font-weight: 500; }
@media (max-width: 1024px) { .asoc-wall { grid-template-columns: minmax(0, 1fr); row-gap: var(--asoc-space-l); } }

/* ── SERVICE JUMP CHIPS (Services hero) ─────────────────────────────────── */
/* The route to the three pillars (header-brief §6; back in the hero, Sam, 2026-10-14): on a
   phone the menu links to Services, not into it. The arrow says the chips go down this page. */
.asoc-jump { display: flex; flex-direction: row; flex-wrap: wrap; gap: var(--asoc-space-xs);
  margin-top: var(--asoc-space-m); }
.asoc-jump > a {
  display: inline-flex; align-items: center; min-height: 44px; padding: 0.6rem 1.6rem; border-radius: var(--asoc-radius-pill);
  box-shadow: inset 0 0 0 1.5px var(--asoc-line); color: var(--asoc-ink); text-decoration: none; font-size: var(--asoc-text-small); font-weight: 500;
  transition: box-shadow var(--asoc-dur) var(--asoc-ease);
}
.asoc-jump > a:hover { box-shadow: inset 0 0 0 1.5px var(--asoc-accent); }
.asoc-jump > a::after { content: "\2193"; margin-inline-start: 0.6rem; color: var(--asoc-ink-accent); }

/* ── HER HANDWRITING, AND THE STICKY NOTE IT IS WRITTEN ON ──────────────────
   Lifted from the tidy-desk prototype, which Sam reviewed (design-decisions §13),
   with its colours moved into tokens. Every note is out of flow ([data-note]) and
   is only shown where whitespace already exists — .claude/rules/handwritten-notes.md,
   checked by scripts/verify-notes.js. Vara draws the words; the real words stay in
   the DOM for screen readers and show as plain italic if the script never runs. */
[data-note] { position: absolute; z-index: var(--asoc-z-paper); pointer-events: none; }
.asoc-hand { color: var(--pen, var(--asoc-pen)); position: relative; }
.asoc-hand__text { font-style: italic; font-size: var(--hand-size, 18px); }
html.asoc-hand-js .asoc-hand__text {
  position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
}
.asoc-hand__ink svg { display: block; overflow: visible; }
.asoc-sticky { isolation: isolate; width: 19rem; rotate: -2.6deg; --note: var(--asoc-note-lilac); --pen: var(--asoc-pen); }
.asoc-sticky--mint { rotate: 2deg; --note: var(--asoc-note-mint); --pen: var(--asoc-pen-mint); }
.asoc-sticky::before {
  content: ""; position: absolute; inset: 0; z-index: var(--asoc-z-ground); border-radius: 1px;
  background: var(--note); box-shadow: 0 1px 1px rgba(0, 0, 0, 0.12);
}
.asoc-sticky::after {
  content: ""; position: absolute; z-index: var(--asoc-z-surface); left: 8%; right: 4%; bottom: 6px; height: 34%;
  box-shadow: 0 14px 16px -6px rgba(0, 0, 0, 0.38); border-radius: 50%;
  transform: perspective(300px) rotateX(18deg) rotate(1.4deg);
}
.asoc-sticky .asoc-hand { --hand-size: 20px; position: relative; z-index: var(--asoc-z-section); padding: 2rem 1.8rem 3.4rem; min-height: 17rem; }
.asoc-sticky__brand {
  position: absolute; z-index: var(--asoc-z-section); left: 1.8rem; bottom: 1.3rem;
  font-family: var(--asoc-font-mono); font-size: 1rem; letter-spacing: 0.22em; text-transform: uppercase;
  color: var(--asoc-on-light-muted); opacity: 0.6;
}
/* Where each one lives. Services: in the right-hand side of the personalised-package
   card, which its 50ch paragraph leaves empty from 801 up (verify-notes.js: no cover). */
.asoc-custom > .asoc-sticky { right: clamp(2.4rem, 6vw, 9.6rem); top: 50%; translate: 0 -50%; }
/* No room, no note (handwritten-notes.md rule 3), judged on the CARD's own width: at
   801 the card is 730px and the note landed on the paragraph (verify-notes.js COVER). */
.asoc-custom { container-type: inline-size; }
@container (max-width: 820px) { .asoc-custom > .asoc-sticky { display: none; } }

/* ── DARK, AS A SCOPE ───────────────────────────────────────────────────────
   The mirror of `.asoc-ground-paper`. A section that must be the dark desk whatever the
   colour ground is doing paints it and pins the dark inks. Needed on /concept-home/
   because on desktop the ground does not turn back after services (open item, found
   2026-10-09, live home included): the pinned timeline's onUpdate re-adds asoc-fade-on
   ~28px after the services trigger removes it. Painting the section makes the concept
   independent of that fix rather than waiting on it. */
.asoc-ground-dark {
  background: var(--asoc-surface);
  --asoc-ink: var(--asoc-ink-dark);
  --asoc-ink-muted: #BBB9BC;
  --asoc-ink-faint: var(--asoc-ink-faint-dark);
  --asoc-kicker: var(--asoc-kicker-dark);
  --asoc-ink-accent: var(--asoc-ink-accent-dark);
  --asoc-ink-accent-2: var(--asoc-accent-2);
  --asoc-line: rgba(190, 150, 200, 0.22);
  --asoc-surface-raised: var(--asoc-surface-raised-dark);
  --tick: var(--asoc-accent-2);
  color: var(--asoc-ink);
}
.asoc-faq__a a { color: var(--asoc-ink); text-decoration-thickness: 1.5px; text-underline-offset: 0.2em; }
/* THE NEW HEROES ANSWER TO HEIGHT BELOW 801 TOO. The live home only does so from 801 up,
   because below that its hero is a heading and two buttons (the intro is relayed below
   the desk). These heroes keep their intro on a phone, so an 800x600 window measured the
   button 8px below the fold (verify-hero-fit.js, 2026-10-09). Portrait phones are tall
   enough that this changes nothing for them above ~667px of height. */
@media (max-width: 800px) {
  .asoc-hero--xl, .asoc-hero--split { padding-top: min(clamp(6rem, 4rem + 10vw, 12rem), max(3.2rem, 24vh - 10.8rem)); }
}

/* ── HOW WE WORK: THE FOURTH STEP (Sam, 2026-10-09) ──────────────────────────
   A "?" on the end of the line. Opening it narrows steps 1-3 and a fourth column takes
   the room: "What you get back", over a loose stack of labels. CLOSED IS TODAY'S LAYOUT
   EXACTLY: the list runs one gap wider than its column (negative margin), so the three
   columns keep their widths while the fourth track is 0; the line stops at the column edge
   and the "?" sits on its end. Open, all of it relaxes on one ease with no overshoot.
   The fourth step's contents are out of flow, so a zero-width column has no min-content
   to hold it open, and the row never grows for the stack.
   Click or tap only, never hover: the circle slides left as the columns narrow, so on
   hover it would leave the pointer, close, return and flicker.
   With no script this is an ordinary fourth step, shown open. */
.asoc-tag {
  display: inline-block; white-space: nowrap; padding: .5rem 1.1rem; border-radius: 6px;
  font-family: var(--asoc-font-display); font-weight: 600; font-size: 1.6rem; letter-spacing: -.01em; line-height: 1.4;
  color: var(--asoc-accent-ink); background: var(--asoc-accent);
  box-shadow: 0 1px 1px rgba(0, 0, 0, 0.08), 0 8px 14px -9px rgba(36, 27, 39, 0.45);
}
.asoc-tag:nth-child(even) { background: var(--asoc-accent-2); }
.asoc-steps__tags { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; align-items: flex-start; gap: .2rem; }
/* gap .2rem from .4rem (2026-10-13): starting level with the copy put the stack 10px lower,
   9px past the longest step's last line at 1440; the tighter gap gives back 8 of them. */
/* Smaller than the standard label, so five of them end about where the squeezed steps'
   text ends (Sam, 2026-10-09: "approximately the height ... and no more"). */
.asoc-steps__tags > .asoc-tag { font-size: 1.5rem; padding: .35rem .9rem; }
.asoc-steps__tags > li { border: 0; }
.asoc-steps__tags > li:nth-child(1) { rotate: -1.6deg; }
.asoc-steps__tags > li:nth-child(2) { rotate: 1.2deg; translate: .6rem 0; }
.asoc-steps__tags > li:nth-child(3) { rotate: .8deg; translate: .2rem 0; }
.asoc-steps__tags > li:nth-child(4) { rotate: -1.2deg; translate: .8rem 0; }
.asoc-steps__tags > li:nth-child(5) { rotate: 1.4deg; }
.asoc-steps__tags > li:nth-child(6) { rotate: -.8deg; translate: .5rem 0; }
/* The labels start level with the other steps' descriptions (Sam, 2026-10-13): the same
   heading-to-copy gap as steps 1-3, which take the base h3 margin. It was .6rem, which
   put the first label 10px above the copy beside it at 1440. */
.asoc-steps__reveal > h3 { margin-bottom: var(--asoc-space-s); }

.asoc-steps__q {
  position: absolute; top: 0; left: 0; z-index: var(--asoc-z-section);
  width: 4.8rem; height: 4.8rem; border-radius: 50%; border: 0; cursor: pointer; padding: 0;
  display: grid; place-items: center; translate: 0 -1.2rem;
  font-family: var(--asoc-font-display); font-weight: 600; font-size: 2.2rem; color: var(--asoc-ink);
  background: var(--asoc-paper); box-shadow: inset 0 0 0 2px var(--asoc-ink);
  transition: transform 1s cubic-bezier(0.65, 0, 0.35, 1), background var(--asoc-dur) var(--asoc-ease),
              color var(--asoc-dur) var(--asoc-ease), box-shadow var(--asoc-dur) var(--asoc-ease);
}
.asoc-steps__q:hover { box-shadow: inset 0 0 0 2px var(--asoc-accent); }
html.asoc-steps-js .asoc-steps__more::before { display: none; }
.asoc-steps.is-open .asoc-steps__q { background: var(--asoc-accent); color: var(--asoc-accent-ink); box-shadow: none; }

html.asoc-steps-js .asoc-steps__more { min-width: 0; }
/* THE PACING (Sam, 2026-10-09: the labels appeared "so suddenly" and the squeeze ran "so
   rapidly"; don't rely on fade for the pacing). The columns glide for 1s on an ease-in-out;
   the heading slides in as they settle; then each label is set down on its own, 120ms apart,
   from a little above its place, a touch larger and tilted further, settling to its resting
   tilt with a small overshoot. Opacity only switches each one on in the first 0.12s.
   Closing is quicker and plain: the labels lift off together, then the columns glide back. */
html.asoc-steps-js .asoc-steps__reveal > h3 {
  visibility: hidden; opacity: 0; transform: translateX(2.4rem);
  transition: opacity .2s ease, transform .3s ease, visibility 0s linear .3s;
}
html.asoc-steps-js .asoc-steps.is-open .asoc-steps__reveal > h3 {
  visibility: visible; opacity: 1; transform: none;
  /* .78s, when the 1s ease-in-out has the column at ~95% width: started earlier, the
     heading appeared on two lines in the narrow column and then jumped to one. */
  transition: opacity .3s ease .78s, transform .6s cubic-bezier(0.22, 1, 0.36, 1) .78s, visibility 0s;
}
html.asoc-steps-js .asoc-steps__reveal > .asoc-steps__tags { visibility: hidden; transition: visibility 0s linear .3s; }
html.asoc-steps-js .asoc-steps.is-open .asoc-steps__reveal > .asoc-steps__tags { visibility: visible; transition: visibility 0s; }
html.asoc-steps-js .asoc-steps__tags > li {
  opacity: 0; transform: translateY(-1.8rem) scale(1.08) rotate(7deg);
  transition: opacity .15s ease, transform .25s ease;
}
html.asoc-steps-js .asoc-steps__tags > li:nth-child(even) { transform: translateY(-1.8rem) scale(1.08) rotate(-7deg); }
html.asoc-steps-js .asoc-steps.is-open .asoc-steps__tags > li {
  opacity: 1; transform: none;
  transition: opacity .12s linear calc(.95s + var(--i, 0) * 120ms),
              transform .55s cubic-bezier(0.34, 1.35, 0.64, 1) calc(.95s + var(--i, 0) * 120ms);
}

@media (min-width: 801px) {
  html.asoc-steps-js .asoc-steps {
    /* WIDTH, not a negative margin: Bricks pins a block to width:100%, so a negative
       margin did not widen it and the columns measured 431px against today's 439. */
    grid-template-columns: 1fr 1fr 1fr 0fr; width: calc(100% + var(--asoc-space-m)); max-width: none;
    transition: grid-template-columns 1s cubic-bezier(0.65, 0, 0.35, 1), width 1s cubic-bezier(0.65, 0, 0.35, 1);
  }
  html.asoc-steps-js .asoc-steps.is-open { grid-template-columns: 1fr 1fr 1fr .86fr; width: 100%; }
  html.asoc-steps-js .asoc-steps::before { right: var(--asoc-space-m); transition: right 1s cubic-bezier(0.65, 0, 0.35, 1); }
  html.asoc-steps-js .asoc-steps.is-open::before { right: 0; }
  /* Out of flow, so a zero-width column has no min-content and the row never grows for it.
     As wide as its column, so the heading wraps there when the column is narrow. */
  html.asoc-steps-js .asoc-steps__reveal { position: absolute; left: 0; right: 0; top: var(--asoc-space-xl); }
  /* CLOSED, CLIPPED (2026-10-12). In the 0-wide column the hidden, unwrapping labels spilled
     past the window's right edge and made the document 1,583px wide at 1440 (section-audit:
     "the page SCROLLS sideways"). They are invisible then, so clipping them changes nothing
     on screen; open, the column is wide and the rule does not apply. */
  /* The clip arrives 0.3s AFTER closing, when the labels have faded and gone hidden: while
     they fade they tilt up to 4.9px past the box's left edge, and an immediate clip shaved
     them (measured 2026-10-12; a 1rem clip margin instead let the spill back at 1025-1100).
     Opening, it goes at once. Where discrete transitions are unsupported it clips at once. */
  html.asoc-steps-js .asoc-steps:not(.is-open) .asoc-steps__reveal {
    overflow: clip; transition: overflow 0s linear .3s; transition-behavior: allow-discrete;
  }
  html.asoc-steps-js .asoc-steps__q { transform: translateX(calc(-100% - var(--asoc-space-m))); }
  html.asoc-steps-js .asoc-steps.is-open .asoc-steps__q { transform: none; }
}
/* 801-1024: NO ROOM, NO FOURTH STEP. Squeezed, the columns measured 176px at 801, the
   section grew 113px (nearly all its bottom padding) and the heading ran off the edge.
   Phones keep their stacked version below. */
@media (min-width: 801px) and (max-width: 1024px) {
  /* The closed layout is already today's, so hiding the step is all it takes. A step
     opened on a wider window is closed by asoc-concepts.js when the window narrows here. */
  html.asoc-steps-js .asoc-steps__more { display: none; }
}
@media (max-width: 800px) {
  .asoc-steps__q { translate: 0 0; }
  html.asoc-steps-js .asoc-steps:not(.is-open) .asoc-steps__more { padding-bottom: 0; min-height: 4.8rem; }
  html.asoc-steps-js .asoc-steps:not(.is-open) .asoc-steps__more > .asoc-steps__reveal { display: none; }
  html.asoc-steps-js .asoc-steps:not(.is-open) .asoc-steps__more > .asoc-steps__q { display: grid; }
}

/* ── THE PICKER'S ANSWER, FLOATING, ON A STACKED PICKER (Sam, 2026-10-09) ─────
   Stacked (1024 and down) the answer sits below fifteen chips, so a tap at the top
   changes a line that is off the screen. This pill carries the same answer at the foot
   of the screen, only while it is useful: something ticked, the picker on screen, and
   neither the answer line nor the package cards already visible. A tap scrolls to the
   marked card. Screen readers already hear the answer from the live region. */
.asoc-picker__bar {
  position: fixed; left: 50%; bottom: calc(env(safe-area-inset-bottom, 0px) + 1.2rem); z-index: var(--asoc-z-nav);
  translate: -50% 0; transform: translateY(160%); opacity: 0; pointer-events: none;
  transition: transform .35s cubic-bezier(0.22, 1, 0.36, 1), opacity .25s ease;
}
.asoc-picker__bar.is-shown { transform: none; opacity: 1; pointer-events: auto; }
/* ABOVE the phone's nav bar, not on it (2026-10-11): both are fixed to the bottom at
   800 and below, so the answer stacks over the nav. From 801 the nav is at the top. */
@media (max-width: 800px) { .asoc-picker__bar { bottom: var(--asoc-navbar-clear); } }
.asoc-picker__bar button {
  /* 56px, from 48 (Sam, 2026-10-09: "slightly bigger"): an easier thumb target, still one line. */
  appearance: none; border: 0; cursor: pointer; min-height: 56px; white-space: nowrap;
  display: inline-flex; align-items: center; gap: 1.6rem; padding: .8rem .8rem .8rem 2.2rem; border-radius: var(--asoc-radius-pill);
  font: 600 1.7rem / 1.2 var(--asoc-font-body); color: var(--asoc-ink-dark);
  background: var(--asoc-surface-raised-dark);
  box-shadow: inset 0 0 0 1px var(--asoc-line), 0 14px 28px -10px rgba(0, 0, 0, 0.7);
}
.asoc-picker__bar .asoc-picker__tier { color: var(--asoc-kicker-dark); }   /* the dark run's mint, pinned: the pill is always dark */
.asoc-picker__bar-go {
  display: inline-flex; align-items: center; gap: .5rem; padding: .9rem 1.5rem; border-radius: var(--asoc-radius-pill);
  background: var(--asoc-accent); color: var(--asoc-accent-ink); font-size: 1.5rem;
}
.asoc-picker__bar-go::after {
  content: ""; width: 1rem; height: 1rem; background-color: currentColor;
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'%3E%3Cpath d='M5 1v7M1.8 5.2 5 8.4l3.2-3.2' fill='none' stroke='%23000' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat center / contain;
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'%3E%3Cpath d='M5 1v7M1.8 5.2 5 8.4l3.2-3.2' fill='none' stroke='%23000' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat center / contain;
}
/* Desktop never shows it: asoc-concepts.js only sets .is-shown under max-width 1024px,
   the width at which the picker stacks (no new breakpoint for a JS-gated element). */

/* ── THE CONVERSION COPY AT LEAD SIZE (concept pages) ──────────────────────────
   Sam, 2026-10-10, concept feedback item 5: the description "reads too small beside the
   heading". It was body size (18px) under an h1-size heading (76px), a 4:1 step that also
   left the heading looking like a banner graphic on its own. Lead size (24px at the top
   of its clamp) narrows that to about 3:1. A modifier rather than a change to
   .asoc-conversion__copy because the live home's band shares that class; this is on the
   three concept bands only. */
.asoc-conversion--lead .asoc-conversion__copy { font-size: var(--asoc-text-lead); line-height: 1.45; }
@media (max-width: 800px) { .asoc-story__hand { display: none; } }

/* A phrase that wraps as one unit: the line may break between phrases, never inside one.
   Still wraps internally if a single phrase is wider than its line, because an
   inline-block's width is capped at its container. First use: the About CTA heading. */
.asoc-phrase { display: inline-block; }

/* ── THE REVIEW SWITCH (Sam, 2026-10-13) ────────────────────────────────────
   asoc-concepts.js adds one for each [data-ground-toggle] section: a small switch in the
   bottom-right corner for comparing that section dark against paper. A review tool, so
   it is quiet: the chrome's charcoal, small mono type, a 44px target. On phones it sits
   above the bottom bar (48px, 12px off the bottom). */
.asoc-review-switch {
  position: fixed; right: 1.2rem; bottom: 1.2rem; z-index: var(--asoc-z-review);
  display: inline-flex; align-items: center; gap: 0.8rem;
  min-height: 44px; padding: 0 1.2rem 0 0.9rem; border: 0; border-radius: var(--asoc-radius-pill);
  background: var(--asoc-pill-bg); color: var(--asoc-ink-dark); opacity: 0.82; cursor: pointer;
  font-family: var(--asoc-font-mono); font-size: 1.2rem; letter-spacing: 0.04em; line-height: 1;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 8px 18px -10px rgba(0, 0, 0, 0.5);
  transition: opacity var(--asoc-dur) var(--asoc-ease);
}
.asoc-review-switch:hover, .asoc-review-switch:focus-visible { opacity: 1; }
.asoc-review-switch__track {
  position: relative; width: 2.6rem; height: 1.5rem; border-radius: 1rem;
  background: color-mix(in srgb, var(--asoc-ink-dark) 22%, transparent); transition: background var(--asoc-dur) var(--asoc-ease);
}
.asoc-review-switch__track::after {
  content: ""; position: absolute; top: 0.2rem; left: 0.2rem; width: 1.1rem; height: 1.1rem;
  border-radius: 50%; background: var(--asoc-ink-dark); transition: translate var(--asoc-dur) var(--asoc-ease);
}
.asoc-review-switch[aria-checked="true"] .asoc-review-switch__track { background: var(--asoc-accent); }
.asoc-review-switch[aria-checked="true"] .asoc-review-switch__track::after { translate: 1.1rem 0; background: var(--asoc-accent-ink); }
@media (max-width: 800px) { .asoc-review-switch { bottom: calc(1.2rem + 48px + 1.2rem); } }
/* Her note follows the ground: lilac on the dark version. Vara writes the stroke as an
   attribute when it draws, so the path takes currentColor here to follow a later switch. */
.asoc-ground-dark .asoc-story__hand { --pen: var(--asoc-accent); }   /* her note on the dark story */
/* :not(transparent): Vara joins words with invisible strokes, and painting those drew
   underscores between them (measured: 7 of 51 paths). */
[data-ground-toggle] .asoc-hand__ink path:not([stroke="transparent"]) { stroke: currentColor; }

/* ==========================================================================
   09 · CONTACT, PRIVACY AND TERMS (2026-10-22)
   --------------------------------------------------------------------------
   Three pages Sam asked for in one brief: her privacy policy (her own wording, from her
   current site), terms written for her, and a contact page whose Send button throws a
   paper plane into her inbox. Built by build-privacy.py, build-terms.py (both through
   asoc_build.legal()) and build-contact.py; the flight is src/asoc-contact.js, loaded
   on the contact page only.
   ========================================================================== */
:root {
  /* The flight's paper and ink: the footer plane's own values (asoc-footer.js SHEET, BACK,
     INK), so the site's two paper planes are one object. */
  --asoc-plane-sheet: #E9E3DA;
  --asoc-plane-back: #D5CCC0;
  --asoc-plane-ink: #2A2226;
  --asoc-flight-shadow: drop-shadow(0 8px 6px rgba(0, 0, 0, 0.3));
  /* The inbox tray: a dark object on the dark desk, toned like the laptop and the footer's
     desk (near-ground fills, a faint light edge), never a paper one (Sam, 2026-10-10). */
  --asoc-tray-front: color-mix(in srgb, var(--asoc-surface-raised) 78%, var(--asoc-surface));
  --asoc-tray-back: var(--asoc-surface-sunk);
  --asoc-tray-edge: color-mix(in srgb, var(--asoc-ink-dark) 14%, transparent);
  --asoc-tray-label: color-mix(in srgb, var(--asoc-ink-dark) 9%, var(--asoc-tray-front));
  --asoc-field-bg: color-mix(in srgb, var(--asoc-surface-sunk) 70%, var(--asoc-surface-raised));
  --asoc-z-flight: 25;   /* the contact page's plane: over the page and the nav pill, under the review switch */
}

/* ── THE LEGAL PAGES: contents beside a reading column ───────────────────── */
.asoc-legal {
  display: grid; grid-template-columns: minmax(0, 22rem) minmax(0, 68ch);
  column-gap: var(--asoc-space-2xl); align-items: start;
}
.asoc-legal__nav { position: sticky; top: var(--asoc-space-xl); display: block; }
.asoc-legal__nav ul { list-style: none; margin: 0; padding: 0; display: block; }
.asoc-legal__nav li { margin: 0; }
.asoc-legal__nav a {
  display: block; padding: 0.5rem 0; font-size: var(--asoc-text-small); line-height: 1.35;
  color: var(--asoc-ink-muted); text-decoration: none; transition: color var(--asoc-dur) var(--asoc-ease);
}
.asoc-legal__nav a:hover, .asoc-legal__nav a:focus-visible { color: var(--asoc-ink); }
.asoc-legal__body { display: block; }
.asoc-legal__clause { display: block; }
.asoc-legal__clause + .asoc-legal__clause { margin-top: var(--asoc-space-xl); }
.asoc-legal__clause > h2 { font-size: var(--asoc-text-h3); margin-bottom: var(--asoc-space-s); }
@media (max-width: 1024px) {
  .asoc-legal { grid-template-columns: minmax(0, 1fr); }
  .asoc-legal__nav { position: static; margin-bottom: var(--asoc-space-xl); }
  .asoc-legal__nav ul { columns: 2; column-gap: var(--asoc-space-l); }
}
@media (max-width: 700px) { .asoc-legal__nav { display: none; } }

/* Prose: paragraphs and lists in running text. Links in prose keep a real underline
   (the rule for links in running text, WCAG 1.4.1; see the "go further" link above). */
.asoc-prose > :first-child { margin-top: 0; }
.asoc-prose > :last-child { margin-bottom: 0; }
.asoc-prose p { margin: 0 0 1.6rem; }
.asoc-prose ul { margin: 0 0 1.6rem; padding-left: 2.2rem; max-width: var(--asoc-measure); }
.asoc-prose li { margin: 0 0 0.6rem; padding-left: 0.4rem; }
.asoc-prose li::marker { color: var(--asoc-ink-muted); }
.asoc-prose a { color: var(--asoc-ink); text-decoration-thickness: 1.5px; text-underline-offset: 0.2em; overflow-wrap: anywhere; }
.asoc-prose a:hover { text-decoration-color: var(--asoc-ink-accent); }

/* ── CONTACT: the form card beside her inbox ─────────────────────────────── */
.asoc-contact {
  display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 38rem);
  column-gap: var(--asoc-space-2xl);
}
/* The inbox at the FOOT of the column, level with the Send button, so the two are on screen
   together whenever the button is: the column stretches to the card's height and its first child
   (the inbox, first in the DOM so a phone meets it straight after the form) is moved to the end.
   First built sticky at the top of the column, where it was off the screen when Send was pressed. */
.asoc-contact { align-items: stretch; }
/* NO HERO BAND (2026-10-30): the heading and intro head the right column, so the form starts under the header.
   On a 1470x830 window (a 13-inch MacBook Air) the old hero took 425px and Send sat 330px below the window.
   The card spans the intro and inbox rows, so the inbox sits at its foot, level with Send; the steps are a row of
   three under both (in the right column they pushed the inbox off the screen). Phones: intro, form, inbox, steps. */
.asoc-contact { grid-template-areas: "card intro" "card aside" "next next"; grid-template-rows: auto 1fr auto; }
.asoc-contact__card { grid-area: card; }
.asoc-contact__intro { grid-area: intro; padding: 0; margin-bottom: var(--asoc-space-xl); }
.asoc-contact__intro > .asoc-hero__title { font-size: var(--asoc-text-h2); max-width: none; }
.asoc-contact__intro > .asoc-hero__lead { margin-bottom: 0; }
.asoc-contact__aside { grid-area: aside; display: flex; flex-direction: column; justify-content: flex-end; }
.asoc-contact__aside > .asoc-inbox { margin: 0; }
.asoc-contact__next { grid-area: next; margin-top: var(--asoc-space-2xl); }
@media (min-width: 801px) {
  .asoc-contact__next > .asoc-next { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); column-gap: var(--asoc-space-l); }
  .asoc-contact__next > .asoc-next > li { padding-bottom: 0; }
}
.asoc-contact-band { padding-top: var(--asoc-space-xl); }
@media (max-width: 1024px) {
  .asoc-contact { grid-template-columns: minmax(0, 1fr) minmax(0, 31rem); column-gap: var(--asoc-space-l); }
}
@media (max-width: 800px) {
  .asoc-contact { grid-template-columns: minmax(0, 1fr); grid-template-areas: "intro" "card" "aside" "next"; grid-template-rows: auto; }
  .asoc-contact__aside { margin-top: var(--asoc-space-xl); justify-content: flex-start; }
  .asoc-contact__next { margin-top: var(--asoc-space-xl); }
}
.asoc-contact__card { display: block; }
/* The thank-you takes the form's place in a card held at the form's height, so nothing
   below it moves (asoc-contact.js sets the min-height). */
.asoc-contact__card.is-done { display: flex; flex-direction: column; justify-content: center; }
@media (max-width: 800px) { .asoc-contact__card.is-done { justify-content: flex-end; } }
.asoc-contact__done[hidden], .asoc-contact__error[hidden], .asoc-form.brxe-form[hidden] { display: none; }
.asoc-contact__done { display: block; }
.asoc-contact__done > h2 { font-size: var(--asoc-text-h2); }
.asoc-contact__done > h2:focus { outline: 0; }
.asoc-contact__done p { color: var(--asoc-ink-muted); }
.asoc-contact__again {
  appearance: none; border: 0; padding: 0; cursor: pointer; font: inherit; font-weight: 600;
  background-color: transparent;
}
.asoc-contact__error { display: block; margin: var(--asoc-space-s) 0 0; color: var(--asoc-ink); }
.asoc-contact__error a { color: var(--asoc-ink); text-underline-offset: 0.2em; }

/* THE FORM: Bricks' own form element (wired later with its Email action), restyled. Its
   markup: .form-group > label + input/textarea; checkboxes as ul.options-wrapper > li >
   input + label; the button in .submit-button-wrapper. */
.asoc-form.brxe-form {
  /* Two columns where a field can be 30rem wide, one where it cannot, never three (each track is at
     least half the row): no breakpoint needed. */
  display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, max(30rem, calc(50% - 0.8rem))), 1fr));
  gap: var(--asoc-space-m) var(--asoc-space-s); width: 100%;
}
.asoc-form .form-group { margin: 0; padding: 0; width: auto; display: flex; flex-direction: column; }
.asoc-form .form-group:has(textarea), .asoc-form .form-group:has(.options-wrapper),
.asoc-form .submit-button-wrapper, .asoc-form .message { grid-column: 1 / -1; }
.asoc-form .form-group > label, .asoc-form .form-group > .label {
  margin: 0 0 var(--asoc-space-2xs); padding: 0; font-size: var(--asoc-text-small); font-weight: 600;
  line-height: 1.3; color: var(--asoc-ink);
  text-transform: none; letter-spacing: 0;   /* Bricks capitalises form labels; ours are sentence case */
}
.asoc-form .form-group input:not([type="checkbox"]), .asoc-form .form-group textarea {
  width: 100%; min-height: 50px; margin: 0; padding: 1.2rem 1.6rem;
  font: 400 var(--asoc-text-body) / 1.45 var(--asoc-font-body); color: var(--asoc-ink);
  background: var(--asoc-field-bg); border: 0; border-radius: var(--asoc-radius);
  /* TWO LAYERS IN EVERY STATE, AND ONLY THE EDGE CHANGES (Sam, 2026-10-22: autofilled fields "flashing purple a
     couple of times"). The first layer is a cover in the field's own colour, over whatever the browser paints
     beneath (Chrome's autofill blue cannot be outranked by a background rule). States set --field-edge and
     nothing else. When the autofill rule swapped a one-layer shadow for this two-layer one, the browser could not
     pair the layers and animated the thin lilac edge swelling to 100px: the purple flash, on fill and again on
     hover and focus. Same layers in the same order, so only the edge colour ever transitions. */
  --field-edge: var(--asoc-line);
  box-shadow: inset 0 0 0 1000px var(--asoc-field-bg), inset 0 0 0 1.5px var(--field-edge);
  transition: box-shadow var(--asoc-dur) var(--asoc-ease);
}
.asoc-form .form-group textarea { min-height: 9rem; resize: vertical; }   /* 14rem until 2026-10-30: Send below a 720px window */
.asoc-form .form-group input:not([type="checkbox"]):hover, .asoc-form .form-group textarea:hover { --field-edge: var(--asoc-accent); }
.asoc-form .form-group input:not([type="checkbox"]):focus-visible, .asoc-form .form-group textarea:focus-visible {
  outline: 2px solid var(--asoc-accent-2); outline-offset: 2px; --field-edge: var(--asoc-accent);
}
/* The checkboxes as the Services picker's chips (.asoc-chip): same pill, same tick box,
   lilac when ticked. The input stays a real, focusable checkbox laid over its chip. */
.asoc-form .options-wrapper { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 1.2rem 0.6rem; }
.asoc-form .options-wrapper li { position: relative; margin: 0; display: flex; }
.asoc-form .options-wrapper input[type="checkbox"] {
  position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer;
}
.asoc-form .options-wrapper label {
  display: inline-flex; align-items: center; gap: 0.6rem; min-height: 44px; margin: 0;
  padding: 0.6rem 1.4rem; border-radius: var(--asoc-radius-pill);
  font: 500 var(--asoc-text-small) / 1.2 var(--asoc-font-body); color: var(--asoc-ink);
  box-shadow: inset 0 0 0 1.5px var(--asoc-line); pointer-events: none;
  transition: background var(--asoc-dur) var(--asoc-ease), box-shadow var(--asoc-dur) var(--asoc-ease), color var(--asoc-dur) var(--asoc-ease);
}
.asoc-form .options-wrapper label::before {
  content: ""; width: 1.4rem; height: 1.4rem; border-radius: 4px; box-shadow: inset 0 0 0 1.5px var(--asoc-line); flex: none;
}
.asoc-form .options-wrapper li:hover label { box-shadow: inset 0 0 0 1.5px var(--asoc-accent); }
.asoc-form .options-wrapper li:has(input:focus-visible) label { outline: 2px solid var(--asoc-accent-2); outline-offset: 3px; }
.asoc-form .options-wrapper li:has(input:checked) label { background: var(--asoc-accent); color: var(--asoc-accent-ink); box-shadow: none; }
.asoc-form .options-wrapper li:has(input:checked) label::before {
  box-shadow: none; background-color: var(--asoc-accent-ink);
  -webkit-mask: var(--asoc-tick-svg) no-repeat center / contain; mask: var(--asoc-tick-svg) no-repeat center / contain;
}
/* The button is the site's own size, not the width of the form (Bricks stretches it). */
.asoc-form .submit-button-wrapper { margin: var(--asoc-space-2xs) 0 0; width: auto; align-items: flex-start; }   /* a column, as every .form-group: align, not justify */
.asoc-form .submit-button-wrapper .bricks-button { width: auto; }
/* The Send button: the site's lilac button, with her paper plane in it. The plane is drawn
   here so it is there without JavaScript; the script hides it when it flies. */
:root {
  /* The flying plane's own geometry (asoc-contact.js), as a mask: the near wing solid, the far wing at 55%, and the
     fold between them open, so at 26px it still reads as a folded plane. It was one filled shape with a 1px slit at
     the tail, which read as a solid triangle (Sam, 2026-10-22). */
  --asoc-dart-svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 140 50'%3E%3Cpath d='M140 25 0 0v21z' fill='%23000'/%3E%3Cpath d='M140 25 0 29v21z' fill='%23000' fill-opacity='.55'/%3E%3C/svg%3E");
}
.asoc-form .submit-button-wrapper .bricks-button {
  display: inline-flex; align-items: center; gap: 1rem; min-height: 52px;
  padding: 0.95em 1.6em 0.95em 2em; margin: 0;
  background: var(--asoc-accent); color: var(--asoc-accent-ink); border: 0; border-radius: var(--asoc-radius-pill);
  font: 600 var(--asoc-text-body) / 1.2 var(--asoc-font-body); cursor: pointer;
  transition: background var(--asoc-dur) var(--asoc-ease), transform var(--asoc-dur) var(--asoc-ease);
}
.asoc-form .submit-button-wrapper .bricks-button:hover { background: var(--asoc-accent-hover); transform: scale(1.02); }
.asoc-form .submit-button-wrapper .bricks-button .loading { display: none; }
.asoc-form .submit-button-wrapper .bricks-button::after {
  content: ""; flex: none; width: 2.8rem; height: 1.1rem; rotate: -28deg; translate: 0 -0.1rem;
  background-color: currentColor;
  -webkit-mask: var(--asoc-dart-svg) no-repeat center / contain; mask: var(--asoc-dart-svg) no-repeat center / contain;
  transition: opacity 0.12s linear;
}
.asoc-form.is-flying .submit-button-wrapper .bricks-button::after { opacity: 0; }
/* WITH THE SCRIPT (asoc-form--js): the icon is a real element, so the plane's start can be read off it and it can
   become a tick; the button is held at the width of its widest label ("Sending") and its contents sit centred, so it
   never changes width between Send, Sending and Sent (Sam, 2026-10-22). */
.asoc-form--js .submit-button-wrapper .bricks-button { justify-content: center; }
.asoc-form--js .submit-button-wrapper .bricks-button::after { content: none; }
.asoc-form--js .submit-button-wrapper .bricks-button { white-space: nowrap; }
.asoc-form--js .submit-button-wrapper .bricks-button .text { flex: none; }
.asoc-send__icon {
  flex: none; width: 2.8rem; height: 1.1rem; rotate: -28deg; translate: 0 -0.1rem;
  background-color: currentColor;
  -webkit-mask: var(--asoc-dart-svg) no-repeat center / contain; mask: var(--asoc-dart-svg) no-repeat center / contain;
  transition: opacity 0.12s linear;
}
.asoc-form.is-flying .asoc-send__icon { opacity: 0; transition: none; }   /* at once: the flying plane is drawn on top of it, and a fade left a ghost behind it */
/* SENT: the site's hand-drawn tick takes the plane's place, drawn in left to right like the lists' ticks. Same box
   width as the plane, so the button does not move; level, not tilted. */
.asoc-form.is-sent .asoc-send__icon {
  height: 1.8rem; rotate: 0deg; translate: 0 0; opacity: 1;
  -webkit-mask-image: var(--asoc-tick-svg); mask-image: var(--asoc-tick-svg);
  animation: asoc-tick-in 0.45s var(--asoc-ease) both;
}
@keyframes asoc-tick-in { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0 0 0 0); } }
.asoc-form.is-flying .submit-button-wrapper .bricks-button,
.asoc-form.is-sent .submit-button-wrapper .bricks-button { pointer-events: none; }   /* no hover zoom on a button that has just sent */
/* Bricks' own notices: the thank-you and the error are ours (asoc-contact.js), so its
   success message never shows; its error message reads in the card's ink if it does. */
.asoc-form .message.success { display: none; }
.asoc-form .message { margin: 0; font-size: var(--asoc-text-small); color: var(--asoc-ink); }

/* ── HER INBOX: a desk letter tray ───────────────────────────────────────── */
.asoc-inbox { margin: 0 0 var(--asoc-space-xl); display: block; }
.asoc-inbox__tray { position: relative; width: min(100%, 26rem); }
.asoc-inbox__tray svg { display: block; width: 100%; height: auto; overflow: visible; }
.asoc-inbox__back { fill: var(--asoc-tray-back); stroke: var(--asoc-tray-edge); stroke-width: 1.2; }
.asoc-inbox__front { fill: var(--asoc-tray-front); stroke: var(--asoc-tray-edge); stroke-width: 1.2; }
.asoc-inbox__sheet { fill: var(--asoc-plane-sheet); }
.asoc-inbox__sheet--b { fill: var(--asoc-plane-back); }
.asoc-inbox__rule { fill: var(--asoc-plane-ink); opacity: 0.2; }
.asoc-inbox__plate { fill: var(--asoc-tray-label); }
.asoc-inbox__word { fill: var(--asoc-ink-dark); opacity: 0.62; font-family: var(--asoc-font-mono); font-weight: 600; letter-spacing: 0.16em; }
.asoc-inbox__shadow { fill: var(--asoc-surface-sunk); opacity: 0.55; }
/* The new-mail count, which pops when the plane lands. */
.asoc-inbox__count {
  position: absolute; top: 18%; right: 1%; min-width: 2.6rem; height: 2.6rem; padding: 0 0.6rem;
  display: grid; place-items: center; border-radius: var(--asoc-radius-pill);
  background: var(--asoc-accent); color: var(--asoc-accent-ink);
  font: 700 1.3rem / 1 var(--asoc-font-mono); scale: 0; pointer-events: none;
}
.asoc-inbox.is-new .asoc-inbox__count { scale: 1; transition: scale 0.45s var(--asoc-ease-settle); }
.asoc-inbox__cap { margin: var(--asoc-space-s) 0 0; font-size: var(--asoc-text-small); color: var(--asoc-ink-muted); }
.asoc-inbox__cap a { color: var(--asoc-ink); text-underline-offset: 0.2em; text-decoration-thickness: 1.5px; overflow-wrap: anywhere; }
/* On a phone the tray in the page is usually below the screen when Send is tapped, so a
   small one slides in at the top right to catch the plane, and slides away again. */
.asoc-inbox--dock {
  position: fixed; z-index: var(--asoc-z-flight); top: calc(1.2rem + env(safe-area-inset-top, 0px)); right: 1.2rem;
  width: 13.6rem; margin: 0; padding: 1rem 1.2rem 0.8rem; border-radius: var(--asoc-radius);
  background: var(--asoc-surface); box-shadow: var(--asoc-desk-shadow);
  translate: calc(100% + 2.4rem) 0; transition: translate 0.55s var(--asoc-ease-out-fast);
  pointer-events: none;
}
.asoc-inbox--dock.is-in { translate: 0 0; }
.asoc-inbox--dock .asoc-inbox__tray { width: 100%; }

/* ── THE FLIGHT LAYER ────────────────────────────────────────────────────── */
.asoc-flight { position: fixed; inset: 0; z-index: var(--asoc-z-flight); pointer-events: none; overflow: hidden; }
.asoc-flight__plane { position: absolute; left: 0; top: 0; width: 140px; height: 50px; margin: -25px 0 0 -140px; transform-origin: 140px 25px; will-change: transform; }
.asoc-flight__plane svg { display: block; width: 140px; height: 50px; overflow: visible; filter: var(--asoc-flight-shadow); }
.asoc-flight__wl { fill: var(--asoc-plane-sheet); }
.asoc-flight__wr { fill: var(--asoc-plane-back); }
.asoc-flight__line { fill: none; stroke: var(--asoc-plane-ink); }
.asoc-flight__ink { fill: var(--asoc-accent-ink); }
/* the far wing and the fold, as the button's icon shows them over the lilac: the ink at 55% over the lilac, and the
   lilac itself through the fold */
.asoc-flight__ink--far { fill: color-mix(in srgb, var(--asoc-accent-ink) 55%, var(--asoc-accent)); }
.asoc-flight__keel { fill: var(--asoc-accent); }
.asoc-flight__trail { fill: none; stroke: var(--asoc-plane-sheet); stroke-width: 1.6; stroke-linecap: round; stroke-dasharray: 2 7; opacity: 0.5; }
/* What happens next: three short steps under her inbox, numbered in the mono. */
.asoc-next__title { font-size: var(--asoc-text-h3); }
.asoc-next { list-style: none; margin: 0; padding: 0; display: block; counter-reset: asoc-next; }
.asoc-next > li { position: relative; margin: 0; padding: 0 0 var(--asoc-space-m) 4.4rem; counter-increment: asoc-next; }
.asoc-next > li:last-child { padding-bottom: 0; }
.asoc-next > li::before {
  content: counter(asoc-next, decimal-leading-zero); position: absolute; left: 0; top: 0.3rem;
  font: 600 var(--asoc-text-label) / 1 var(--asoc-font-mono); letter-spacing: 0.08em; color: var(--asoc-kicker);
}
.asoc-next > li > h3 { font-size: var(--asoc-text-body); font-family: var(--asoc-font-body); letter-spacing: 0; margin-bottom: var(--asoc-space-2xs); }
.asoc-next > li > p { margin: 0; font-size: var(--asoc-text-small); color: var(--asoc-ink-muted); }
/* Her phone in step 1 (2026-10-28): the same treatment as the email link in the inbox caption beside it. */
.asoc-next > li > p a { color: var(--asoc-ink); text-underline-offset: 0.2em; text-decoration-thickness: 1.5px; white-space: nowrap; }
/* A FIELD THAT NEEDS ATTENTION (Sam, 2026-10-22: the browser's own bubble is not on brand). Her coral marks
   it, as a highlight and never as text (§1): the field's outline and a small "!" disc; the words are in the
   card's ink, under the field, where the eye already is. Written by asoc-contact.js into Bricks' own element. */
/* Bricks paints this element as a pale pink box with 15px of padding (frontend.min.css, read 2026-10-22), which
   left light text on pink: plain text under the field instead. */
.asoc-form .form-group-error-message { display: none; background: none; padding: 0; width: auto; border: 0; }
.asoc-form .form-group-error-message.show {
  display: flex; align-items: flex-start; gap: 0.8rem; margin: var(--asoc-space-2xs) 0 0;
  font-size: var(--asoc-text-small); line-height: 1.4; color: var(--asoc-ink);
}
.asoc-form .form-group-error-message.show::before {
  content: "!"; flex: none; width: 1.8rem; height: 1.8rem; margin-top: 0.1rem; border-radius: 50%;
  display: grid; place-items: center; background: var(--asoc-accent-3); color: var(--asoc-accent-ink);
  font: 700 1.2rem / 1 var(--asoc-font-body);
}
.asoc-form .form-group input[aria-invalid="true"], .asoc-form .form-group textarea[aria-invalid="true"],
.asoc-form .form-group input[aria-invalid="true"]:hover, .asoc-form .form-group textarea[aria-invalid="true"]:hover,
.asoc-form .form-group input[aria-invalid="true"]:focus-visible, .asoc-form .form-group textarea[aria-invalid="true"]:focus-visible {
  --field-edge: var(--asoc-accent-3);   /* coral until it is right, focused or not; the edge only, as above */
}
/* AUTOFILL (Sam, 2026-10-22: "are the entered fields meant to be this soft blue colour?"). No: that is the
   browser's autofill paint. The field's own two-layer shadow (above) already covers it in every state, so these
   rules set the text colour ONLY. ⚠️ Never give them a box-shadow: a different shadow here is what flashed.
   One rule per pseudo-class, never one list: a browser that does not know one drops the whole list. */
.asoc-form .form-group input:-webkit-autofill { -webkit-text-fill-color: var(--asoc-ink); caret-color: var(--asoc-ink); }
.asoc-form .form-group input:autofill { -webkit-text-fill-color: var(--asoc-ink); caret-color: var(--asoc-ink); }
