/* ==========================================================================
   Blessit George — site stylesheet
   Epic 1: Shared Design System & Shell

   CONTENTS
     1. TOKENS ................ US-006  <- change colours, fonts, spacing here
     2. FONT FACES ............ US-004
     3. RESET & BASE .......... US-014
     4. LAYOUT SHELL .......... US-014
     5. TYPOGRAPHY ............ US-007
     6. CTA BUTTON ............ US-008
     7. STICKY NAV ............ US-009
     8. FOOTER ................ US-010
     9. EXPANDER .............. US-011
    10. MOTION ................ US-012

   Nothing below section 1 contains a hard-coded hex value (US-006).
   ========================================================================== */


/* ==========================================================================
   1. TOKENS — US-006
   ========================================================================== */

/* --------------------------------------------------------------------------
   FONT FAMILIES — the single place to swap a typeface.

   To change a font you edit TWO things and nothing else:
     a) the two variables below, and
     b) the matching @font-face block in section 2.
   No other rule in this stylesheet names a font family.
   -------------------------------------------------------------------------- */
:root {
  --font-heading: "Merriweather", Georgia, "Times New Roman", serif;
  --font-body:    "Raleway", -apple-system, BlinkMacSystemFont, "Segoe UI",
                  Helvetica, Arial, sans-serif;

  /* Weights are tokens too, so a swapped family can re-map them in one place. */
  --weight-heading: 700;
  --weight-body:    400;
  --weight-strong:  600;
}

/* --------------------------------------------------------------------------
   COLOUR — LIGHT THEME (default; the founders page `/` never leaves it)
   Every pair verified against WCAG 2.1 AA by scripts/contrast.py.
   -------------------------------------------------------------------------- */
:root {
  --ground:        #FFFFFF;   /* page background                              */
  --surface:       #F9F7F4;   /* filled card (US-020 tier 3)   ink 13.42:1    */
  --ink:           #2A2A2A;   /* body and heading text         14.35:1        */
  --muted:         #66717F;   /* secondary text                 4.96:1        */
  --rule:          #E2DCD2;   /* hairlines and card borders (non-text)        */
  --rule-section:  #C4A97C;   /* section divider — accent at rule weight      */

  --accent:        #8A6A3B;   /* CTA fill / links               4.99:1        */
  --accent-hover:  #534023;   /* hover:   accent −40% (US-008 as amended D38) */
  --accent-active: #1C150C;   /* pressed: accent −80% (US-008 as amended D38) */
  --on-accent:     #FFFFFF;   /* CTA label in light theme       4.99:1        */

  /* Ambient decoration — D88. All four are non-text and decorative, so WCAG
     1.4.3 does not apply to them directly; what does apply is that none may
     drag a text pairing below its ratio. They sit at 5-7% alpha for exactly
     that reason, and each is behind a pointer-events:none layer. */
  --emboss-hi:     rgba(255, 255, 255, 0.85);  /* letterpress highlight       */
  --emboss-lo:     rgba(42, 42, 42, 0.05);     /* letterpress shadow          */
  --dot:           rgba(42, 42, 42, 0.07);     /* ambient dot grid            */
  --sheen:         rgba(255, 255, 255, 0.80);  /* the glint along the emboss  */

  /* Service-card elevation — experiment, D106. Two layers, because one is what
     makes a shadow look like a drop shadow: a 1px contact shadow that sits the
     card on the page, and a wide soft one that lifts it off. Both very low
     alpha — this is elevation, a UI convention, not a material. */
  --lift: 0 1px 2px rgba(42, 42, 42, 0.04), 0 10px 28px rgba(42, 42, 42, 0.05);

  /* The hero subhead, and ONLY the hero subhead — D89. The shader behind it
     ranges down to #C9C0B1 in its deepest shadow, where --muted (#66717F)
     measures 3.64:1 against a 4.5 requirement. Range-compressing the shader
     to fix that washed it out to nothing even at a 0.65 floor, so the text
     moved instead of the artwork. Same hue (214deg) and same saturation as
     --muted, carried down in lightness until it clears the shader's worst
     tone: 4.89:1 there, 8.24:1 on --surface. Still visibly secondary
     against --ink. */
  --hero-muted:    #444B55;

  /* Accent states inside the nav WHILE IT SITS OVER THE HERO — D93.
     --accent is 4.13:1 through the frosted pill and 3.76:1 on the bare shader
     behind the name and Contact, both under the 4.5 floor. Measured, not
     estimated: 70 rendered frames sampled behind the nav put the darkest tone
     at a level the plain accent cannot clear.

     This is the same hue (36deg) and saturation as --accent, carried down in
     lightness until it clears the worse of the two backdrops: 4.77:1 on bare
     shader, 5.24:1 through the glass. Beside --accent it reads as the same
     colour; against the shader it is the difference between passing and not.

     Only the founders index carries .site-header--overlay, and US-013 pins
     that route to the light theme, so there is no dark counterpart to define. */
  --accent-nav:    #765B32;

  --focus:         #2A2A2A;   /* focus ring, drawn offset onto the ground     */

  /* The sticky bar, D55. Its RGB is --ground's, restated because the alpha
     has to be baked in: color-mix() would express this as one value derived
     from --ground, but it needs Safari 16.2 and US-060's baseline is iOS 15.
     If --ground ever changes, change this with it. */

  /* ---- Semantic scale, D60 -------------------------------------------
     Added for the work samples, which carry meaning in colour that a
     single accent cannot express: MoSCoW priority, pass/fail coverage,
     Gherkin keywords. Available to the rest of the site.

     Solved rather than picked: each is the most saturated value that still
     clears 5.0:1 on --ground, and each -soft is the strongest tint that
     keeps --ink at roughly 11:1. The 0.5 over AA is deliberate headroom —
     a scale sitting exactly on 4.50 breaks the moment anything shifts.

     Hues are spaced so must/could stay separable under deuteranopia, which
     red-vs-green alone would not be.                                      */
  --sem-must:        #C14633;   /* 5.01:1 — MoSCoW Must, failures     */
  --sem-must-soft:   #F2DCD9;
  --sem-should:      #8F6826;   /* 5.03:1 — Should, warnings          */
  --sem-should-soft: #EDDFC8;
  --sem-could:       #3171B9;   /* 5.01:1 — Could, informational      */
  --sem-could-soft:  #D5E2F1;
  --sem-good:        #217D5C;   /* 5.06:1 — covered, passing          */
  --sem-good-soft:   #C1EADB;
  --nav-bg:        rgb(255 255 255 / 0.80);
  --nav-shadow:    0 1px 2px rgb(0 0 0 / 0.05), 0 6px 20px rgb(0 0 0 / 0.08);
}

/* --------------------------------------------------------------------------
   COLOUR — DARK THEME (US-013; applied only by /agencies)

   Opt-in via <html data-theme="dark">. Deliberately NOT wired to
   prefers-color-scheme: US-013 requires `/` to stay light under any system
   preference, so the theme is a property of the route, not of the visitor.
   -------------------------------------------------------------------------- */
[data-theme="dark"] {
  --ground:        #151719;
  --surface:       #1E2124;   /* ink 13.91:1                                  */
  --ink:           #ECEEF0;   /* 15.45:1                                      */
  --muted:         #A8B2BE;   /*  8.37:1                                      */
  --rule:          #332E28;
  --rule-section:  #6B5636;

  --accent:        #D8AC68;   /*  8.58:1                                      */
  --accent-hover:  #E8CDA4;   /* hover:   +40% (dark brightens — see below)   */
  --accent-active: #F7EEE1;   /* pressed: +80%. Dark BRIGHTENS because
                                 darkening moves the fill toward its own
                                 dark-ink label: −40% is 3.39:1, −80% is
                                 1.15:1. Both fail. See D38.               */
  --on-accent:     #151719;   /* dark ink, NOT white — white measures 2.10:1
                                 and is prohibited by US-013                  */

  /* Dark inverts the light source: the highlight goes faint and the shadow
     does the work, or the emboss reads as scratched on rather than pressed
     in. The sheen takes the accent because pure white on #151719 is a
     headlight, not a glint. D88. */
  --emboss-hi:     rgba(255, 255, 255, 0.045);
  --emboss-lo:     rgba(0, 0, 0, 0.34);
  --dot:           rgba(236, 238, 240, 0.07);
  --sheen:         rgba(216, 172, 104, 0.30);

  /* On a dark ground a light-alpha black shadow is invisible, so it has to be
     denser — same reasoning the --nav-shadow pair already uses. */
  --lift: 0 1px 2px rgba(0, 0, 0, 0.30), 0 10px 28px rgba(0, 0, 0, 0.38);

  --focus:         #ECEEF0;

  /* --ground's RGB at the same alpha. The shadow is heavier: on a dark ground
     a 0.08 black shadow is invisible, so the separation has to come from a
     denser one. */

  /* Semantic scale, dark. Same solve against the dark ground. D60. */
  --sem-must:        #D26857;   /* 5.01:1 */
  --sem-must-soft:   #462C27;
  --sem-should:      #AD7F2E;   /* 5.01:1 */
  --sem-should-soft: #3B3121;
  --sem-could:       #4D8AD0;   /* 5.02:1 */
  --sem-could-soft:  #263443;
  --sem-good:        #299970;   /* 5.04:1 */
  --sem-good-soft:   #1F372E;
  --nav-bg:        rgb(21 23 25 / 0.80);
  --nav-shadow:    0 1px 2px rgb(0 0 0 / 0.35), 0 6px 20px rgb(0 0 0 / 0.45);
}

/* --------------------------------------------------------------------------
   TYPE SCALE — US-007
   Fluid between the two viewports the acceptance criteria pin:
     375px -> body 17 / h1 30 / h2 22 / h3 18
    1280px -> body 17 / h1 46 / h2 28 / h3 20
   Expressed in rem so browser font-size settings still scale the page.
   -------------------------------------------------------------------------- */
:root {
  --text-body: 1.0625rem;                                       /* 17px       */
  --text-h1:   clamp(1.875rem, 1.4606rem + 1.7680vw, 2.875rem);  /* 30 -> 46  */
  --text-h2:   clamp(1.375rem, 1.2196rem + 0.6630vw, 1.75rem);   /* 22 -> 28  */
  --text-h3:   clamp(1.125rem, 1.0732rem + 0.2210vw, 1.25rem);   /* 18 -> 20  */
  --text-h2-tight: clamp(1.25rem, 1.2003rem + 0.2210vw, 1.375rem); /* 20 -> 22 */
  --text-small: 0.9375rem;                                       /* 15px      */

  --leading-body:    1.7;
  --leading-heading: 1.2;
  --tracking-heading: -0.01em;
}

/* --------------------------------------------------------------------------
   SPACE, SHAPE, LAYOUT — US-006, US-014
   -------------------------------------------------------------------------- */
:root {
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 0.75rem;
  --space-4: 1rem;
  --space-5: 1.25rem;   /* 20px — mobile side padding (US-006) */
  --space-6: 2rem;
  --space-7: 3rem;
  --space-8: 4.5rem;
  --space-9: 6rem;

  --radius: 4px;              /* hairline things only — rules, tiny chips     */

  /* D90. Two shapes now carry the whole site: buttons are pills, panels are
     squircles.

     "Squircle" is a superellipse, and CSS cannot draw one yet — corner-shape
     is Chrome-only and nowhere near the US-060 floor. A generous
     border-radius is the honest approximation, and at 18px against panels
     this size the difference from a true superellipse is not visible. If
     corner-shape ever reaches the baseline this token is the only place that
     has to change. */
  --radius-box: 18px;         /* panels, cards, media, menus                 */
  --radius-pill: 999px;       /* buttons and anything that reads as one      */
  --measure: 42.5rem;         /* 680px reading measure (US-006) */
  /* The prose cap, in ch rather than rem, so it tracks the font instead of
     assuming one size.

     62ch, NOT 72ch. The ch unit is the advance width of "0", which in a
     proportional face is narrower than the average glyph — measured here,
     72ch rendered as 86 actual characters a line, over the 50-75 band US-014
     was reaching for. 62ch measures 74. The unit is still the right one; it
     just is not a character count. */
  --prose: 62ch;
  /* D90 — the container is now fluid. It was 54.5rem, and at 1440px that left
     284px of dead margin on each side (39% of the screen) while body copy ran
     to 92 characters a line. The narrow shell existed to hold the measure at
     680px per US-014, but D49 had already let copy fill its container, so the
     cost was being paid and the benefit was not.

     Full width, with the gutters doing the adjusting instead: 20px on a phone
     up to 80px on a large desktop. Line length is now governed where it
     actually belongs — on the paragraphs, in ch, see --measure below. */
  --shell: 100%;
  --gutter: clamp(1.25rem, 4vw, 5rem);
  --nav-height: 3.5rem;
  --tap-target: 44px;         /* iOS minimum, used by nav and expander */

  --transition: 160ms ease;
}

/* The 48em gutter step is gone: --gutter is a clamp now, so it already grows
   with the viewport and a fixed override at one breakpoint would pin it back
   to 24px for every screen above 768px. D90. */


/* ==========================================================================
   2. FONT FACES — US-004

   Self-hosted, Latin-subset woff2, served from this origin. No request is
   made to fonts.googleapis.com or fonts.gstatic.com — that is what lets the
   site run without a consent banner (NFR-7).

   font-display: swap keeps text visible in the fallback stack while the
   files load, and renders the fallback permanently if a file 404s (EH-5).
   ========================================================================== */

@font-face {
  font-family: "Merriweather";
  src: url("../fonts/merriweather-latin-700.woff2") format("woff2");
  font-weight: 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: "Raleway";
  src: url("../fonts/raleway-latin-variable.woff2") format("woff2");
  font-weight: 400 600;     /* variable: one file covers body and strong */
  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;
}


/* ==========================================================================
   3. RESET & BASE — US-014
   ========================================================================== */

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

html {
  /* The ground lives here, not on body. The ambient dot layer (D88) is a
     fixed child of body at z-index -1; if body painted the ground it would
     paint over that layer and the dots would never be seen. With body
     transparent, html paints the canvas, the dots sit above it, and every
     body child stacks above them without a z-index anywhere else. */
  background: var(--ground);
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  /* Keeps anchor targets clear of the sticky bar (US-009 + US-017). The one
     and only clearance rule — see .section, which used to duplicate it. */
  scroll-padding-top: calc(var(--nav-height) + var(--space-4));
}

body {
  margin: 0;
  color: var(--ink);
  font-family: var(--font-body);
  font-weight: var(--weight-body);
  font-size: var(--text-body);
  line-height: var(--leading-body);
  /* US-014: no horizontal scrollbar at any width from 320px to 1920px. */
  overflow-x: hidden;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
}

img, picture, svg, video {
  display: block;
  max-width: 100%;
  height: auto;
}

a { color: var(--accent); text-underline-offset: 0.2em; }
a:hover { color: var(--accent-hover); }

strong, b { font-weight: var(--weight-strong); }

hr {
  border: 0;
  border-top: 1px solid var(--rule);
  margin: var(--space-8) 0;
}

/* Shared focus treatment. The 2px ring is drawn OFFSET, so it sits on the
   page ground rather than on the component — which is why --focus only has
   to clear 3:1 against --ground (US-008). */
:where(a, button, summary, [tabindex]):focus {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
  border-radius: var(--radius);
}
/* Progressive enhancement: browsers that understand :focus-visible drop the
   ring for pointer input. iOS Safari 15.0-15.3 does not, and correctly keeps
   the always-on :focus ring above (US-060 baseline). */
:where(a, button, summary, [tabindex]):focus:not(:focus-visible) {
  outline: none;
}
:where(a, button, summary, [tabindex]):focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* Scroll-to-top target for the nav name (D73). It needs a real layout box:
   an empty INLINE element generates no line box at all, so there is nothing
   for the browser to scroll to — verified, the link updated the hash and the
   page stayed exactly where it was. Block with zero height gives it a box at
   document position 0 while occupying no space.

   It also cannot live on .site-header, which is position:sticky and therefore
   already in view at every scroll offset, making the scroll a no-op. Both
   failures looked identical from the outside: hash changes, nothing moves. */
#top {
  display: block;
  height: 0;
}

/* Keyboard-only skip link (US-062 groundwork). */
.skip-link {
  position: absolute;
  left: var(--gutter);
  top: var(--space-2);
  z-index: 100;
  padding: var(--space-2) var(--space-4);
  background: var(--ground);
  color: var(--ink);
  border: 1px solid var(--rule);
  border-radius: var(--radius-pill);
  transform: translateY(-200%);
  transition: transform var(--transition);
}
.skip-link:focus { transform: translateY(0); }

.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}


/* ==========================================================================
   4. LAYOUT SHELL — US-014
   ========================================================================== */

/* One container width across the whole page. --shell is the outer frame;
   the 680px reading measure is enforced on the text itself (see `p` in
   section 5), not by narrowing the container. That keeps every section
   edge-aligned while body copy still stops at a comfortable line length. */
.wrap {
  width: 100%;
  max-width: var(--shell);
  margin-inline: auto;
  padding-inline: var(--gutter);  /* 20px on mobile (US-006) */
}

/* Retained as a no-op alias so existing markup keeps working. */
.wrap--wide { max-width: var(--shell); }

/* Opt back in to the narrow reading column where a section is pure prose. */
.wrap--measure { max-width: var(--measure); }

/* scroll-margin-top removed. html already carries scroll-padding-top at the
   same calc, and the two STACK — every anchor landed 144px below the viewport
   top under a 56px bar, which is more than twice the clearance either rule was
   asking for. Two mechanisms for one job, each unaware of the other.

   scroll-padding-top on the scroll container is the one to keep: it governs
   every fragment landing on the page, including #top, rather than only the
   elements that happen to carry the class. Anchors now land at 72px — the bar
   plus one --space-4 of air, which is what the original calc meant. D79. */
.section {
  padding-block: var(--space-8);
}

/* 2px and accent-derived, not 1px cool grey. The old hairline was a blue-grey
   sitting in a palette that is warm everywhere else — a small discord repeated
   seven times down the page, and part of why the page read as generic once
   D66 removed the buttons that had been carrying the colour.

   --rule-section is the accent lightened to divider weight rather than the
   accent itself. Seven full-width lines at full --accent strength compete with
   the button they are supposed to lead the eye toward; at this weight the
   rhythm reads as deliberate and the CTA stays the loudest thing on screen.
   D68. */
.section + .section { border-top: 2px solid var(--rule-section); }

.stack > * + * { margin-top: var(--space-4); }
.stack--tight > * + * { margin-top: var(--space-2); }
.stack--loose > * + * { margin-top: var(--space-6); }


/* ==========================================================================
   5. TYPOGRAPHY — US-007
   ========================================================================== */

h1, h2, h3, h4 {
  font-family: var(--font-heading);
  font-weight: var(--weight-heading);
  line-height: var(--leading-heading);
  letter-spacing: var(--tracking-heading);
  margin: 0;
  /* US-007: the mobile hero headline must not overflow or clip a word. */
  overflow-wrap: break-word;
  text-wrap: balance;         /* progressive; ignored where unsupported */
}

h1 { font-size: var(--text-h1); }
h2 { font-size: var(--text-h2); }
h3 { font-size: var(--text-h3); }

/* Body copy fills its container rather than stopping at the 680px reading
   measure. Owner's decision, D49: text stopping short of the images and the
   section edge read as an unfinished layout. This knowingly breaks the
   "measure does not exceed 680px" scenario in US-006 and US-014 — at the
   872px shell a line runs about 100 characters. --measure is retained as a
   token and is still available via .wrap--measure. */
p { margin: 0; }

.lede {
  font-size: var(--text-body);
  color: var(--muted);
}

.small { font-size: var(--text-small); }
.muted { color: var(--muted); }


/* ==========================================================================
   6. CTA BUTTON — US-008
   Label is always exactly "Book a call". Four states: rest, hover, active,
   focus. Implemented as an anchor so it works with JavaScript disabled and
   degrades to an ordinary link (US-040).
   ========================================================================== */

.cta {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: var(--tap-target);
  padding: var(--space-3) var(--space-6);
  background: var(--accent);
  color: var(--on-accent);
  font-family: var(--font-body);
  font-weight: var(--weight-strong);
  font-size: var(--text-body);
  line-height: 1;
  text-decoration: none;
  border: 0;
  border-radius: var(--radius-pill);
  cursor: pointer;
  transition: background-color var(--transition);
}

.cta:hover  { background: var(--accent-hover);  color: var(--on-accent); }
.cta:active { background: var(--accent-active); color: var(--on-accent); }

.cta--block { display: flex; width: 100%; }

/* Compact variant for the sticky bar only. Keeps the 44px tap target. */
.cta--compact {
  padding: var(--space-2) var(--space-4);
  font-size: var(--text-small);
}


/* ==========================================================================
   7. STICKY NAV — US-009
   Desktop (>=768px): five anchors + CTA.
   Mobile  (<768px):  CTA only, single row, no wrap.
   ========================================================================== */

/* The sticky element must be the OUTERMOST one. A sticky child can only
   travel within its parent's content box — and a <header> that wraps only
   the nav is exactly as tall as the nav, giving zero travel. The bar then
   scrolls away on the first pixel, which is the bug this replaced. */
.site-header {
  position: sticky;
  top: 0;
  z-index: 50;
}

/* The bar separates itself from the page with a cast shadow rather than a
   hairline, and lets the page show faintly through it. D55.

   No JavaScript, and no scroll listener. The translucency is permanent — at
   the top of the page the only thing behind the bar is --ground, so it reads
   as solid; the effect appears by itself as content passes underneath. A
   scroll-triggered version would have needed a script on BOTH pages, and
   D53 kept /agencies script-free on purpose.

   CSS scroll-driven animation would have done it without script, but
   animation-timeline needs Safari 26 and US-060's floor is iOS Safari 15. */
.nav {
  min-height: var(--nav-height);
  background: var(--ground);
  box-shadow: var(--nav-shadow);
}

/* Translucency only where the blur behind it works. Without the blur, body
   text scrolling under a flat 80% bar stays sharp and legible through it,
   which is unreadable — so browsers lacking backdrop-filter keep the solid
   bar above. Progressive enhancement: the fallback is the better-looking
   failure, not a broken one. */
@supports ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
  .nav {
    background: var(--nav-bg);
    -webkit-backdrop-filter: saturate(180%) blur(12px);
    backdrop-filter: saturate(180%) blur(12px);
  }
}

/* Mobile: the bar is the anchor row and nothing else. The name and the CTA
   are removed — the CTA becomes a floating button that gets out of the way
   when a real one is on screen (D46). Desktop keeps all three inline. */
.nav__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-4);
  min-height: var(--nav-height);
  flex-wrap: nowrap;
}

/* The name is back on mobile — D90. It was hidden because the old mobile bar
   was a horizontally scrolling row of five anchors with no room beside them.
   The hamburger replaced that row, so the bar is now name on the left and one
   44px control on the right, which is what it should have been. The CTA stays
   hidden: the floating button covers that job below 48em. */
.nav .cta { display: none; }

/* The agencies header has no anchor set, so the rule above would empty it
   entirely on mobile. Here the name and the button ARE the bar at every
   width — which also means this page needs no floating CTA and no JavaScript,
   since the header button never leaves the screen. US-029. */
.nav--minimal .nav__name,
.nav--minimal .cta { display: inline-flex; }

.nav--minimal .nav__name { align-items: center; }

.nav__links {
  display: flex;
  align-items: center;
  /* space-4 on mobile, space-5 from 768px. The Services chevron costs 10px and
     the row only ever had 4px spare, so the gap gives the difference back:
     four gaps at 16px rather than 20px frees 16px and the row fits again.
     Desktop has 261px of slack and keeps the roomier gap. D61. */
  gap: var(--space-4);
  list-style: none;
  margin: 0;
  padding: 0;

  /* The "mobile bar that scrolls sideways" this used to serve stopped existing
     at D90, when the links became a hamburger panel. flex: 1 1 100% and the
     whole overflow/scrollbar apparatus below it went with that bar — kept
     here, the basis inflated the desktop pill (found in D91) and the
     scroll properties described a scroll container that can no longer
     scroll. D99. */

  /* The row is 325px of labels in a 335px container, and flex-start put all
     9.6px of that slack on the right — so the set sat 10px left of centre.
     Centring splits it.

     Declared twice on purpose. `safe center` means "centre, but fall back to
     start rather than overflow", which is what protects the narrow widths
     where the row scrolls: centred overflow pushes the first item off the
     left edge where it cannot be scrolled back to. It needs Safari 15.4+, so
     the plain `center` above it is what Safari 15.0-15.3 keeps — same
     centring, without the overflow guard. D77.

     The original note here said desktop was unaffected because the auto
     margins consumed the free space first. Those margins were removed in D91
     and the claim went stale — desktop is still unaffected, but now because
     flex: 0 0 auto makes the pill content-sized, so there is no free space for
     justify-content to distribute either way. Same outcome, different reason,
     and the reason is the part worth having right. D99. */
  justify-content: center;
  justify-content: safe center;
}

/* --------------------------------------------------------------------------
   Services disclosure, D61.

   The nav had 4px of slack at 375px, so a sixth top-level item pushed 102px
   off-screen — all of "Contact" and part of "About". Nesting the deliverables
   link under Services keeps the row at 331px and unchanged.

   The trigger is a <button>, not a link. A control that opens something is a
   button; screen readers announce the expanded state from aria-expanded, and
   keyboard users get Space and Enter for free. "All services" is the first
   item so the section itself is still reachable — that is what the old
   top-level link did, and removing it without replacement would have been a
   regression dressed as a feature.
   -------------------------------------------------------------------------- */
.nav__has-menu { position: relative; }

.nav__disclosure {
  display: inline-flex;
  align-items: center;
  /* space-1, not space-2: the chevron's own box already carries visual gap,
     and at space-2 the row measured 345px against 335px of space. */
  gap: var(--space-1);
  min-height: var(--tap-target);
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  font-size: var(--text-small);
  color: var(--ink);
  cursor: pointer;
  white-space: nowrap;
}

/* Drawn in CSS rather than shipped as an asset — one less request, and it
   inherits currentColor so it follows the theme with no second definition. */
.nav__chevron {
  width: 0.4em;
  height: 0.4em;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg) translate(-0.1em, -0.1em);
  transition: transform var(--transition);
}

.nav__disclosure[aria-expanded="true"] .nav__chevron {
  transform: rotate(-135deg) translate(-0.1em, -0.1em);
}

.nav__menu {
  list-style: none;
  margin: 0;
  padding: var(--space-2) 0;
  background: var(--ground);
  border: 1px solid var(--rule);
  border-radius: var(--radius-box);
  box-shadow: var(--nav-shadow);
}

.nav__menu[hidden] { display: none; }

.nav__menu a {
  display: flex;
  align-items: center;
  min-height: var(--tap-target);
  padding: 0 var(--space-4);
  font-size: var(--text-small);
  color: var(--ink);
  text-decoration: none;
  white-space: nowrap;
}

.nav__menu a:hover,
.nav__menu a:focus-visible { background: var(--surface); color: var(--accent); }

/* Only z-index survives here — D101.

   This block used to position the submenu as a fixed full-width sheet, because
   the mobile nav row scrolled sideways and a menu anchored inside a scrolling
   container travelled off-screen with it. That row stopped existing at D90 and
   its scroll machinery was removed at D99; D94 then made the submenu static so
   it expands the panel instead of covering it.

   Measured after all that: position resolved to `absolute` at 1440 and
   `static` at 375. The fixed positioning applied at NO width — every
   declaration below it was shadowed at both ends of the breakpoint, which is
   why removing them changes nothing.

   D99's sweep missed this: it compared selectors against markup, and this
   selector is very much in use. Declarations shadowed at every breakpoint are
   a second kind of dead code that needs a different check. */
.nav__menu {
  z-index: 55;
}

@media (min-width: 48em) {
  .nav__menu {
    position: absolute;
    top: calc(100% + var(--space-2));
    left: 0;
    right: auto;
    min-width: 15rem;
    max-height: none;
  }
}

.nav__links a {
  display: inline-flex;
  align-items: center;
  min-height: var(--tap-target);
  color: var(--ink);
  font-size: var(--text-small);
  text-decoration: none;
  white-space: nowrap;        /* never break a label mid-scroll */
}
.nav__links a:hover { color: var(--accent); }

/* Which section you are actually in. Colour alone was carrying this and it was
   not enough to notice — which is also WCAG 1.4.1: a state signalled only by
   hue is invisible to anyone who cannot separate that hue from --ink, and easy
   to miss for everyone else. The underline adds a change of SHAPE, so the
   indicator survives being read in greyscale.

   text-decoration rather than a border or a pseudo-element: these links are
   44px-tall flex boxes (the tap target), so a border-bottom would draw a rule
   at the bottom of the box rather than under the words. text-decoration tracks
   the text itself and shifts no layout. text-underline-offset and
   -thickness are both Safari 12.1+, inside the US-060 floor.

   Distinct from :hover on purpose — hover changes colour, current changes
   colour AND gains the rule, so the two never read as the same state. D76. */
.nav__links > li > a[aria-current] {
  color: var(--accent);
  text-decoration: underline;
  text-decoration-color: var(--accent);
  text-decoration-thickness: 2px;
  text-underline-offset: 6px;
}

/* Merriweather, restored. D73 tried Raleway bold on the grounds that the sans
   is more legible at 14px; that was true and still lost. The serif is the only
   one in the bar, and being the only one is what makes this read as a mark
   rather than as a sixth menu item — authority the weight alone could not
   carry. The link behaviour D73 added is unaffected and stays. D75. */
.nav__name {
  display: inline-flex;       /* shown at every width since D90 */
  align-items: center;
  font-family: var(--font-heading);
  font-weight: var(--weight-heading);
  font-size: var(--text-small);
  color: var(--ink);
  text-decoration: none;
  white-space: nowrap;
  align-items: center;
  min-height: var(--tap-target);
}

.nav__name:hover { color: var(--accent); }

@media (min-width: 48em) {    /* 768px */
  .nav__name,
  .nav .cta { display: inline-flex; }
  .nav__links {
    gap: var(--space-5);
    /* Was 0 0 auto, which let space-between park the whole row hard right
       against the vacated button slot. Growing instead lets the row own the
       width so the last item can be pushed to the far edge. D66. */
    flex: 1 1 auto;
    overflow-x: visible;
  }

  /* Removing the header button left a hole on the right of the desktop bar.
     Contact fills it: the section anchors stay grouped beside the name and
     the conversation-starter sits alone at the edge, which is roughly where
     the eye had learned to look for it.

     Desktop only, and CSS only. On mobile the bar is a single scrolling
     anchor row (D46) and Contact stays in sequence — pushing it to an edge
     there would strand it past the fold of a horizontal scroll. One element,
     two positions, no duplicated link. D66. */
  /* D67's two auto margins are GONE, and had to be. They centred a five-item
     list inside a full-width bar by absorbing the free space at both ends.
     Since D91 the bar is three flex children — name, pill, Contact — under
     justify-content: space-between, which centres the pill on its own. Left
     in place, the auto margin was absorbed INSIDE the pill instead: measured
     at 1440px it stretched the glass to 1097px with 771px of empty frosting
     before the first link. A centring trick only works on the box it was
     written for. */
}


/* ==========================================================================
   8. FOOTER — US-010
   The .footer--no-home variant renders the owner's name as plain text with
   no link, which is the mechanism the two-audience separation rests on.
   ========================================================================== */

.footer {
  border-top: 1px solid var(--rule);
  padding-block: var(--space-8);
  color: var(--muted);
  font-size: var(--text-small);
}

.footer__name {
  font-family: var(--font-heading);
  font-weight: var(--weight-heading);
  font-size: var(--text-h3);
  color: var(--ink);
}

.footer__links {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-4);
  list-style: none;
  margin: 0;
  padding: 0;
}

.footer__links a {
  display: inline-flex;
  align-items: center;
  min-height: var(--tap-target);
}


/* ==========================================================================
   9. EXPANDER — US-011
   Native <details>/<summary>. Opens with JavaScript disabled, is keyboard
   operable for free, and its full text is present in the served HTML before
   any script runs — which is what FR-45 and the crawlers require.
   ========================================================================== */

.expander {
  border-top: 1px solid var(--rule);
}
.expander:last-of-type { border-bottom: 1px solid var(--rule); }

.expander__summary {
  display: flex;
  align-items: flex-start;
  gap: var(--space-4);
  /* US-011: the whole summary row is the hit area. */
  width: 100%;
  padding-block: var(--space-5);
  min-height: var(--tap-target);
  cursor: pointer;
  list-style: none;
}

/* Remove the default disclosure triangle in both engines. */
.expander__summary::-webkit-details-marker { display: none; }
.expander__summary::marker { content: ""; }

.expander__heading {
  flex: 1 1 auto;
  font-family: var(--font-heading);
  font-weight: var(--weight-heading);
  font-size: var(--text-h3);
  line-height: var(--leading-heading);
  color: var(--ink);
  margin: 0;
}

.expander__teaser {
  margin-top: var(--space-2);
  color: var(--muted);
  font-size: var(--text-small);
}

/* Once open, the teaser is redundant with the full text below it. */
.expander[open] .expander__teaser { display: none; }

/* Chevron drawn in CSS, not an icon set — US-006 prohibits loading an icon
   font or SVG icon set. */
.expander__chevron {
  flex: 0 0 auto;
  width: 0.6em;
  height: 0.6em;
  margin-top: 0.45em;
  border-right: 2px solid var(--accent);
  border-bottom: 2px solid var(--accent);
  transform: rotate(45deg);
  transform-origin: center;
  transition: transform var(--transition);
}

.expander[open] .expander__chevron {
  transform: rotate(-135deg);
}

.expander__body {
  padding-bottom: var(--space-6);
}
.expander__body > * + * { margin-top: var(--space-4); }


/* ==========================================================================
   10. MOTION — US-012
   Default: chevron rotates, colours transition.
   Reduced: everything resolves instantly. The rotation still happens, it
   just is not animated — the open state must stay legible.
   ========================================================================== */

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


/* ==========================================================================
   11. HERO — US-015, US-016
   At 375x667 the headline, subhead and CTA must all sit above the fold, with
   the photograph below it. The four-way conflict between FR-2, FR-3, FR-20
   and FR-38 was resolved at GAP-060 by pushing the photograph down; this
   block is where that resolution is enforced.
   ========================================================================== */

/* The hero is a tinted panel at page width, not a full-bleed band. Because
   .wrap is `width:100%; max-width:--shell`, the same declaration gives both
   behaviours for free: below 872px the panel is the viewport, above it the
   panel is the container with ground either side. No media query, and no thin
   white margins on a phone, which is what a fixed-width tint would have left.

   Deliberately the opposite treatment to .section--band on contact: that one
   breaks the container to close the page, this one respects it to open it.
   Contained reads as a masthead, full-bleed reads as a footer.

   The foot padding drops from --space-8 to --space-6. The whitespace used to
   be the only thing separating hero from Problem; the tint does that job now,
   so the gap can stop doing it twice. D69. */
.hero {
  padding-block: var(--space-7) var(--space-6);
  background: var(--surface);
  border-radius: var(--radius-box);
}

.hero__headline {
  margin-bottom: var(--space-5);
}

.hero__subhead {
  /* --hero-muted, not --muted. The shader behind this paragraph runs much
     darker in its deepest shadow than --surface does, and --muted measures
     3.64:1 against that tone. See the token for the working. */
  color: var(--hero-muted);
  margin-bottom: var(--space-6);
}

.hero__cta {
  margin-bottom: var(--space-4);
}

/* The photograph is deliberately absent until a real one exists — EH-6 makes
   a shipped placeholder a launch failure, and the decision was to ship none
   rather than a stand-in. No reserved box, so the layout holds with no empty
   gap and no broken-image icon (US-016). */
.hero__portrait {
  margin-top: var(--space-8);
  max-width: 320px;
  border-radius: var(--radius-box);
}


/* ==========================================================================
   12. SERVICE TIERS — US-020, US-021
   Visual weight increases across the three: plain, bordered, filled. No tier
   carries a "recommended" badge — the ladder is commitment, not a
   sales ranking.
   ========================================================================== */

.tiers {
  display: grid;
  gap: var(--space-5);
}

.tier {
  padding: var(--space-5);
  border-radius: var(--radius-box);
}

.tier__name {
  margin-bottom: var(--space-3);
}

.tier__meta {
  color: var(--muted);
  font-size: var(--text-small);
  margin-block: var(--space-4);
}

.tier__cta {
  margin-top: var(--space-4);
}

/* Tier 1 — plain. No border, no fill; it is the lightest commitment. */
/* Same treatment as tier 2 — owner's call, D93. US-020 asked for visual weight
   to increase across the three, and this flattens the first step of that: the
   ladder now runs bordered, bordered, filled rather than plain, bordered,
   filled. The escalation still reads, because the fill is the loud step and
   tier 3 keeps it; what goes is the hint that tier 1 is the lightweight one.
   In a 2x2 that hint was working against the grid anyway — one card without a
   box among three with one read as unfinished rather than as entry-level. */
.tier--plain {
  /* padding-inline: 0 is GONE, at every width. It existed because tier 1 had
     no box: with nothing drawn around it, pulling the copy flush to the
     section edge was what made it read as plain rather than as a card missing
     its border. D93 gave it tier 2's border and that reasoning expired — the
     rule survived, so on mobile the text and the banner sat 1px inside their
     own outline while the other three cards held 20px.

     The 48em override that put the padding back was treating the symptom at
     one breakpoint. Removing the declaration fixes both. D97. */
  border: 1px solid var(--rule);
}

/* Tier 2 — bordered. */
.tier--bordered {
  border: 1px solid var(--rule);
}

/* Tier 3 — filled, with a heavier heading. */
.tier--filled {
  background: var(--surface);
  /* Same hairline as tiers 1 and 2 — D106. It had none: its fill alone was
     meant to define its edge, which worked while the cards were flat and
     nothing was being lifted off the page.

     With elevation it stopped working, and the reason is measurable. The
     border is a 28% luminance step against the ground; the fill is 6.8% — an
     edge about four times weaker. The shadow was identical on all three cards
     the whole time; on this one there was simply no edge for it to lift, so it
     read as a faint smudge rather than a raised card.

     The fill still does its job as the tier-3 differentiator. It just is not
     asked to be the outline as well. */
  border: 1px solid var(--rule);
}

/* US-020 requires this tier to carry "a heavier heading". It was set to
   --text-h2, which is a jump from 20px to 28px — 40% at identical card
   widths, which read as a mistake rather than as emphasis. --text-h2-tight is
   a 10% step: clearly heavier beside the other two, and no longer competing
   with the section's own h2 two inches above it.

   The accent colour carries the rest of the emphasis, so the escalation no
   longer rests on size alone. D62. */
.tier--filled .tier__name {
  font-size: var(--text-h2-tight);
  color: var(--accent);
}


/* ==========================================================================
   13. HOW IT WORKS — US-026
   Four sequential steps. Numbered by CSS counter rather than a list marker,
   so the number is decorative and screen readers get the heading text.
   ========================================================================== */

.steps {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--space-6);
}

/* The reset above clears the UA <ol> margins, and margin-top with them — which
   is the one .stack > * + * had already set. So "How it works" had its list
   welded to the heading at 0px while every other section held 16px between the
   two. The reset is still right; the rhythm just has to be put back for the
   stack case. D98. */
.stack > .steps {
  margin-top: var(--space-4);
}

.step {
  display: grid;
  grid-template-columns: 40px minmax(0, 1fr);
  gap: var(--space-4);
  align-items: start;
}

.step__name {
  margin-bottom: var(--space-2);
}

/* Bespoke line icons drawn in the markup — not an icon font and not a
   third-party set, so nothing extra is loaded and US-006's "no icon set"
   clause is not triggered by a network request. They inherit --accent
   through currentColor, so both themes are handled for free. */
.step__icon {
  width: 40px;
  height: 40px;
  color: var(--accent);
  flex: 0 0 auto;
}


/* ==========================================================================
   14. CONTACT — US-027
   No form element anywhere: FR-25 excludes capture mechanisms, and the
   mailto is the only non-Calendly route (FR-19).
   ========================================================================== */

/* A tint at page width, matching the hero panel exactly. It was full-bleed at
   D68 — box-shadow spread plus clip-path to break the container — on the
   argument that contained reads as a masthead and full-bleed reads as a
   footer. The owner preferred the two panels to agree, so that distinction is
   withdrawn and the page now opens and closes on the same shape. Because
   .section is also a .wrap, the plain background gives page width on desktop
   and full width on a phone with no media query. D68, amended D74. */
.section--band {
  background: var(--surface);
  border-radius: var(--radius-box);
}

/* The tint is the separation; a rule on top of it would be belt and braces. */
.section + .section--band { border-top: 0; }

.contact__routes {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-5);
  margin-top: var(--space-6);
}


/* ==========================================================================
   15. MEDIA — US-016 and the image slots added 2026-08-23 (D43)

   Every image carries width/height in the markup so the box is reserved
   before the file arrives. Without that, each image shifts the page as it
   loads and Cumulative Layout Shift drags the US-061 Performance score down.
   ========================================================================== */

.media {
  display: block;
  width: 100%;
  height: auto;
  border-radius: var(--radius-box);
  background: var(--surface);
}

/* The layout classes moved onto <picture> for the two portraits (US-016
   wants a JPEG fallback). <picture> is inline by default and would not take
   a grid placement or a width, so it needs an explicit block box. */
.hero__media,
.about__media { display: block; }

.media--square { aspect-ratio: 1 / 1; object-fit: cover; }
.media--portrait { aspect-ratio: 4 / 5; object-fit: cover; }
/* 3/1 as of D56 — the service photographs are supplied in that ratio. The
   files are 1616x534, a hair over 3:1, so `cover` trims about 0.9% off the
   sides. Imperceptible, and it keeps all three boxes identical. */
.media--wide { aspect-ratio: 3 / 1; object-fit: cover; }


/* ==========================================================================
   16. HERO LAYOUT — US-015, US-016, as amended by D43 and D46

   One <img>, two placements, driven by grid rather than duplicated markup:

   Mobile   headline and subhead across the top, then a single row of
            CTA on the left and an 80px portrait on the right. The portrait
            is a presence marker at that size, not the credibility shot —
            that one lives in About, where there is room for it.

   Desktop  text column and full portrait side by side, both above the fold.
   ========================================================================== */

/* Mobile row 2 is [portrait][button over name]: the portrait on the left, and
   on the right edge a stacked block of the button with the name beneath it.
   For one-handed right-thumb use — about half of mobile sessions — the
   bottom-right is the easiest reach, so the button keeps the good corner.
   The name moved under the button because the button is 44px against the
   portrait's 80px: top-aligning the two leaves 36px of dead space below the
   button, and the name's 19px line fits inside it. The row height stays 80px,
   driven by the portrait, so the name costs no height at all — which matters,
   because a dedicated CTA row would push the button past the visible edge on
   a small iPhone once Safari's chrome is accounted for, and FR-2 makes that
   unacceptable. D50. */
.hero__inner {
  display: grid;
  /* 120px, was 80. D50 sized this at 80 as "a presence marker, not the
     credibility shot" — but that was decided when the column was competing
     with a CTA row AND the name beneath the button, and both of those are
     gone (D94). The file is also a transparent round cut-out over a live
     shader now rather than a small baked-in avatar, and at 80px it read as
     one. D95. */
  grid-template-columns: 120px minmax(0, 1fr);
  column-gap: var(--space-3);
  row-gap: var(--space-6);
  align-items: start;
}

/* Rows are stated explicitly. Relying on auto-placement here put the
   portrait on a row of its own instead of beside the button. */
.hero__copy { grid-column: 1 / -1; grid-row: 1; }

.hero__media {
  grid-column: 1;
  grid-row: 2;
  align-self: end;   /* see .hero__ident — shared baseline, D66 */
  width: 120px;
  height: 120px;
}

/* Button and name as one grid child rather than two. Grid has no per-row gap
   control, so keeping them in separate rows would have forced the 2rem row
   gap between them — or spanning the portrait across both rows, which makes
   the rows sum to the portrait's 80px and pushes the name away from the
   button. One flex column sidesteps both. */
.hero__ident {
  grid-column: 2;
  grid-row: 2;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: var(--space-2);

  /* Portrait, button and name share a floor. The column stretches to the row
     height and pushes its two children to the ends, so the name's last line
     lands on the portrait's bottom edge instead of floating in the middle of
     a top-aligned stack.

     Paired with align-self:end on the portrait, this holds whichever of the
     two is taller. Normally the 80px portrait sets the height and the name
     drops to meet it; if the name ever wraps and the column wins, the
     portrait rises to meet the name instead. Either way the bottom edges
     agree, which is the thing being asked for. D66. */
  align-self: stretch;
  justify-content: space-between;
}

/* .hero__name and .hero__name-lead are GONE — D99.

   D50 built them: "with Blessit George" under the button, the lead-in doing
   the work styling could not, so small text beneath a button read as a
   signature rather than as a disclaimer. Desktop hid it from the start
   (the name is the masthead there). D94 hid it on mobile too, once the
   hamburger put the name in the bar.

   That left an element with display:none at every width — twenty-five lines
   of CSS and a paragraph of markup arguing carefully for something no reader
   could see. Removed at both ends. */


/* A connector, not the payload. Set at the same size and colour but in the
   body face at body weight, so the eye lands on the name and "with" reads as
   grammar. Bold here would claim equal importance with the name. */

.hero__cta {
  margin-bottom: 0;
}

@media (min-width: 48em) {   /* 768px */
  /* Reverted to the pre-D69 arrangement at the owner's request: headline and
     subhead together in the left column, portrait beside them in its own,
     vertically centred against the pair. D69's masthead — headline across the
     full container, subhead and button bottom-aligned against the portrait —
     measured well and read badly, which is the whole reason a layout gets
     looked at rather than calculated. D70.

     What D69 leaves behind: the --surface panel and the tightened foot
     padding. Those were separate requests and are untouched. */
  .hero__inner {
    grid-template-columns: 1fr 1fr;
    column-gap: var(--space-7);
    /* Was --space-6 (32px), which measured as 56px on the page — and shrinking
       the gap alone barely moved it, because most of that distance was not
       the gap. The 380px portrait spans both rows, so it forces them taller
       than their content and the slack lands wherever the rows put it: right
       between the last line of description and the button.

       Four rows fix it without giving up the centring. The copy and the button
       take rows 2 and 3 at their own heights, so the space between them is
       exactly the row gap and nothing else. Rows 1 and 4 are each `1fr`, so
       the height the portrait forces is split equally above and below the
       pair — which leaves the text block centred against the portrait rather
       than pinned to one end of it.

       Dumping the slack on a single side would have been simpler and would
       have put the text about 15px off the portrait's centre line. Both of
       the owner's asks are satisfiable at once; only one of them was
       satisfiable by shrinking the gap. D70. */
    /* Six rows, four of them slack, all `1fr` and therefore all equal. D70
       built this as `1fr auto auto 1fr` — slack, copy, button, slack — which
       centred the pair against the portrait and put the button hard under the
       last line of copy. The credentials line then arrived (D112) and
       auto-placed after the trailing slack, so all of the remaining height
       fell into the one gap between the button and it: measured 16px above the
       button and 134px below.

       Giving the button a `1fr` on each side puts it in the middle of the
       space between the copy and the credentials, by construction rather than
       by a number that is right until the copy changes length. The outer two
       keep D70's centring against the portrait. */
    grid-template-rows: 1fr auto 1fr auto 1fr auto 1fr;
    row-gap: var(--space-4);
  }
  .hero__copy  { grid-column: 1; grid-row: 2; }
  .hero__subhead { margin-bottom: 0; }
  .hero__ident { grid-column: 1; grid-row: 4; justify-self: start; align-self: center; }
  .hero__credentials { grid-column: 1; grid-row: 6; }
  .hero__media {
    grid-column: 2;
    grid-row: 1 / -1;
    width: 100%;
    height: auto;
    align-self: center;
  }
}


/* ==========================================================================
   15c. WORK SAMPLES — D59

   Two anonymised backlogs, linked from both audiences. The cards are built
   from the same tokens as everything else, so each page renders them in its
   own theme with no per-page rules: light on /, dark on /agencies.
   ========================================================================== */

.samples {
  display: grid;
  gap: var(--space-5);
  /* The preview is positioned against this box, so the coordinates
     sample-preview.js measures are the ones the browser lays out against. */
  position: relative;
}

/* ---- POINTER PREVIEW — E1, assets/js/sample-preview.js -------------------
   A screenshot of the backlog, trailing the pointer. The script builds this
   element; nothing here exists in the no-JS document.

   `position: absolute` inside .samples rather than fixed to the viewport, so
   it scrolls with the section it belongs to and cannot be left stranded
   mid-page by a scroll that outruns a mouseleave.

   translate(-50%, -50%) is on the CHILD, not this element: the script owns
   this element's transform to move it, and the two would overwrite each
   other. Centring the image inside a zero-size box separates the concerns —
   the script positions a point, the CSS hangs the picture off it. */
.sample-preview {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 2;
  width: 0;
  height: 0;
  pointer-events: none;   /* never eat a click meant for the card */
  opacity: 0;
  visibility: hidden;
  transition: opacity 260ms ease, visibility 0s linear 260ms;
}

.sample-preview.is-visible {
  opacity: 1;
  visibility: visible;
  transition: opacity 260ms ease, visibility 0s linear 0s;
}

.sample-preview img {
  display: block;
  /* The global reset caps every image at max-width: 100%. This one's parent is
     deliberately 0x0 — it is a coordinate, not a box — so 100% of it is zero
     and the picture rendered at 0x0 while reporting itself visible. Opting out
     of the cap is the whole reason this line exists. */
  max-width: none;
  /* 16rem, down from 22rem. At 22 the picture covered the card it was hovering,
     so you saw the screenshot instead of the description under it. Smaller
     reads as a glimpse of what opens rather than a takeover of the card. */
  width: 16rem;
  height: auto;
  transform: translate(-50%, -50%) rotate(-1.5deg);
  border-radius: var(--radius-box);
  background: var(--ground);
  /* Heavier than the card shadows on purpose. This one is genuinely floating
     above the page rather than raised within it, and D108's "do not out-lift
     your container" does not apply to something with no container. */
  box-shadow:
    0 0 0 1px var(--emboss-lo),
    0 18px 40px rgba(42, 42, 42, 0.18);
}

/* The script already refuses to run without a fine pointer, reduced motion
   off, and 64em of width. This is the belt to that pair of braces: if the
   element is ever present without the script's consent, it stays invisible. */
@media (prefers-reduced-motion: reduce), (hover: none), (max-width: 63.999em) {
  .sample-preview { display: none; }
}

@media (min-width: 48em) {
  .samples { grid-template-columns: 1fr 1fr; gap: var(--space-6); }
}

/* The whole card is the link. A card with a small "read more" inside it gives
   a 44px target inside a 300px one people are already trying to click. */
.sample {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  padding: var(--space-5);
  border: 1px solid var(--rule);
  border-radius: var(--radius-box);
  background: var(--ground);
  color: inherit;
  text-decoration: none;
  transition: border-color var(--transition), box-shadow var(--transition);
}

.sample:hover,
.sample:focus-visible {
  border-color: var(--accent);
  box-shadow: var(--nav-shadow);
}

.sample__kind {
  margin: 0;
  font-size: var(--text-small);
  font-weight: var(--weight-strong);
  color: var(--accent);
}

.sample__name {
  margin: 0;
  font-family: var(--font-heading);
  font-weight: var(--weight-heading);
  font-size: var(--text-h3);
  color: var(--ink);
}

.sample__desc {
  margin: 0;
  font-size: var(--text-small);
  color: var(--muted);
}

/* Figures pulled from the documents themselves, not rounded for effect. */
.sample__stats {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-4);
  margin: 0;
  padding: 0;
  list-style: none;
  margin-top: auto;
  padding-top: var(--space-3);
  border-top: 1px solid var(--rule);
}

.sample__stats li {
  font-size: var(--text-small);
  color: var(--muted);
}

.sample__stats b {
  font-weight: var(--weight-strong);
  color: var(--ink);
}

/* Carries the affordance the removed underline used to. */
.sample__open {
  margin: 0;
  font-size: var(--text-small);
  font-weight: var(--weight-strong);
  color: var(--accent);
}


/* ==========================================================================
   16b. FLOATING CTA — mobile only, D46

   FR-21 requires a persistent booking button. This one hides itself, but
   only ever while another "Book a call" is already on screen — so a booking
   route is visible at every scroll position, which is what FR-21 protects.

   Default state is VISIBLE. The script only ever adds the hiding, so with
   JavaScript off the button simply stays put and nothing breaks.
   ========================================================================== */

/* Scroll-to-top — mobile only. D74.

   Outlined rather than accent-filled. Both treatments clear WCAG AA and by a
   similar margin (accent on --surface is 4.67:1, --on-accent on --accent is
   4.99:1), so contrast did not decide it — hierarchy did. Two accent-filled
   buttons on screen at once read as equally important, and this one is
   utility navigation sitting beside the page's only conversion action. The
   outline puts it a tier below without making it hard to see.

   The fill matters even though it nearly matches the page: it stops body text
   showing through the button, and the shadow lifts it off both --ground and
   the --surface panels it passes over. */
.to-top {
  position: fixed;
  left: var(--gutter);
  bottom: calc(var(--space-5) + env(safe-area-inset-bottom, 0px));
  z-index: 60;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--tap-target);
  height: var(--tap-target);
  border: 2px solid var(--accent);
  border-radius: var(--radius-pill);
  background: var(--surface);
  color: var(--accent);
  font-size: 1.25rem;
  line-height: 1;
  text-decoration: none;
  box-shadow: 0 2px 12px rgb(0 0 0 / 0.18);
  transition: opacity var(--transition), transform var(--transition);
}

/* Same contract as .floating-cta: visible by default, the script only ever
   adds the hiding. With JavaScript off the button stays put and still works —
   it is a plain fragment link. Pointless at scroll-top, never broken. */
.to-top[data-hidden="true"] {
  opacity: 0;
  transform: translateY(8px);
  visibility: hidden;
  pointer-events: none;
}

/* Desktop has the nav name for this (D73) and does not need a second control. */
@media (min-width: 48em) {
  .to-top { display: none; }
}

.floating-cta {
  position: fixed;
  right: var(--gutter);
  bottom: calc(var(--space-5) + env(safe-area-inset-bottom, 0px));
  z-index: 60;
  box-shadow: 0 2px 12px rgb(0 0 0 / 0.18);
  transition: opacity var(--transition), transform var(--transition);
}

/* visibility:hidden (not display:none) so it leaves the tab order while
   hidden but can still animate back in. */
.floating-cta[data-hidden="true"] {
  opacity: 0;
  transform: translateY(8px);
  visibility: hidden;
  pointer-events: none;
}

/* Desktop keeps it too, since D66 removed the header button that used to be
   the desktop's persistent route. Nudged clear of the mobile position: there
   is room for a wider margin, and the lower-right corner is where the
   Deliverables cards and the sample tiles end. */
@media (min-width: 48em) {
  .floating-cta {
    right: var(--space-6);
    bottom: var(--space-6);
  }
}


/* ==========================================================================
   17. SECTION MEDIA — services, about, steps (D43)
   ========================================================================== */

.tier__media {
  margin-bottom: var(--space-4);
  /* A slight raise, D108. The artwork is flat vector on a near-white ground,
     so with nothing under it the image dissolves into the card and the card
     into the page. Four layers, smallest first: a hairline ring that gives the
     plate an edge, a 1px light catch under the ring for the letterpress read,
     then two soft shadows for the lift.

     Not `inset`. Inset shadows on a replaced element paint beneath the
     replaced content, so an inset ring here would be hidden behind the
     photograph in exactly the browsers that matter. The ring is a spread
     shadow at zero offset instead, which paints outside the box and is
     reliable everywhere back to the US-060 baseline.

     Deliberately lighter than --lift, which is the CARD's shadow (D106). The
     image sits inside the card; if it lifted as hard as the thing containing
     it the stack would read inside out. */
  box-shadow:
    0 0 0 1px var(--emboss-lo),
    0 1px 0 var(--emboss-hi),
    0 2px 4px rgba(42, 42, 42, 0.05),
    0 8px 18px rgba(42, 42, 42, 0.06);
}

.about__inner {
  display: grid;
  gap: var(--space-6);
}

/* Mobile: the portrait sits directly under the "About me" heading, ahead of
   the copy. Done with `order` rather than by moving the element, so the DOM
   keeps the sequence the desktop grid needs — text into column 1, portrait
   into column 2 — and only the mobile painting order changes. D63.

   Reordering visually away from DOM order is safe here specifically because
   nothing in this section is focusable: a <picture> takes no tab stop, so
   there is no way for the keyboard order and the visual order to disagree.
   The same trick around a link or a button would be a real accessibility
   bug. */
.about__media {
  order: -1;
  /* Capped and left-aligned, D109 — reversing the full-width treatment D64
     chose. Full width made the portrait 335x419 on a 375px screen: over half
     the visible page spent on a photograph before the reader reaches the first
     word. D64 weighed that against the 55px of empty gutter a capped image
     leaves and picked the gutter as the worse fault. Seeing it built, it isn't.
     Owner's call, and it supersedes D64 for this element only — D49's argument
     about ragged body copy still stands, because a photograph is an object
     with edges and a paragraph is not.

     `width`, not `max-width`, so the box tracks the cap on the way down as
     well: below 220px of column the image simply fills what there is. A grid
     item with an explicit width sits at the start of its track, which is the
     left alignment — no justify-self needed. */
  width: min(100%, 220px);
}

/* Two columns only when there is room for TWO. Previously 48em, which was the
   bug: the copy track is capped at --prose (62ch, ~645px) and the portrait took
   `1fr` of whatever survived. At a 797px viewport that remainder measured
   39.97px and the portrait rendered at icon size. D109.

   64em is derived, not picked: 645px of prose + 48px of gap + a 240px floor for
   the portrait needs 933px of inner width, and the gutter is 4vw a side, so
   0.92V = 933 gives V = 1014px. 64em (1024px) clears it with 249px for the
   portrait.

   The 15rem floor on the second track is the part that stops this recurring.
   The derivation above is true for today's --prose and today's gap; the floor
   stays true if either changes, because the first track's minmax(0, ...) will
   give ground to protect it. That is the D97/D98/D99 failure mode — a value
   correct when written, left standing after its premise moved — answered with
   a constraint instead of another number. */
@media (min-width: 64em) {
  .about__inner {
    grid-template-columns: minmax(0, var(--prose)) minmax(15rem, 1fr);
    gap: var(--space-7);
    align-items: start;
  }
  .about__media {
    order: 0;
    width: auto;
    max-width: none;
    position: sticky;
    top: calc(var(--nav-height) + var(--space-5));
  }
}



/* ==========================================================================
   18. SCROLL REVEAL — D86, revised D87
   Sections rise and fade in as they arrive. Two properties only, opacity and
   transform, because both are composited: no layout, no repaint, nothing that
   costs us US-061. The source this came from also blurred the text on the way
   in, which looks expensive in a demo and forces a filter layer over live type
   for the duration — dropped.

   NOTHING HERE HIDES ANYTHING BY DEFAULT. The resting state below is the
   finished state; `.is-pending` is the departure, and only assets/js/reveal.js
   ever applies it — to groups that are entirely below the fold when it runs.
   With the script absent, failed, or unsupported, no element is ever hidden and
   the page renders exactly as it would without this section. That is the whole
   reason the script could go back to `defer`. See its header.

   The hero is deliberately NOT a reveal group. It holds the LCP element, and
   starting the largest thing on the page at opacity 0 delays the metric that
   US-061 is measured on for the sake of an effect nobody scrolled to see.
   ========================================================================== */

[data-reveal] > * {
  transition:
    opacity 700ms cubic-bezier(0.16, 1, 0.3, 1),
    transform 700ms cubic-bezier(0.16, 1, 0.3, 1);
}

[data-reveal].is-pending > * {
  opacity: 0;
  transform: translateY(16px);
}

/* Held by assets/js/reveal.js for one frame while it applies .is-pending, so
   that hiding is instant and only revealing animates. See the note there. */
[data-reveal].reveal-init > * {
  transition: none !important;
}

/* Section 10 already flattens every transition to 0.01ms, which would leave
   these elements snapping in from 16px away rather than simply being there.
   State the resting position outright instead. */
@media (prefers-reduced-motion: reduce) {
  [data-reveal].is-pending > * {
    opacity: 1;
    transform: none;
    transition-delay: 0ms !important;
  }
}


/* ==========================================================================
   19. NAV HOVER UNDERLINE — experiment, D86
   A rule that grows from the left under the label on hover.

   Not a border-bottom, and not at `bottom: 0`, for the reason D76 gives: these
   links are 44px-tall flex boxes for the tap target, so the bottom of the box
   is nowhere near the bottom of the words. This is pinned to the text instead —
   half the box, plus 0.72em — which lands it just under the descenders and,
   more to the point, at the same height as the aria-current underline. The two
   indicators have to agree on where "under" is.

   scaleX rather than width: transforms are composited, width is layout.

   1px, not 2px, and this is the whole difficulty. :hover already sets
   colour: var(--accent), so a 2px accent bar would render a hovered link
   pixel-identical to the current-section link — accent text over a 2px accent
   rule at the same offset — which is precisely the collision D76 set out to
   avoid. A hairline keeps the family resemblance and still loses to the solid
   2px rule at a glance. The transience helps too: hover follows the cursor,
   aria-current does not.

   Suppressed on the current section, which already carries a permanent rule.
   Two rules on one word is not emphasis, it is a bug that shipped.

   Guarded on (hover: hover) so a touch device does not paint it on tap and
   leave it stuck, and on the desktop breakpoint because in the mobile
   disclosure panel the links are full-width blocks and a rule spanning the
   panel reads as a divider, not an underline.
   ========================================================================== */

@media (hover: hover) and (min-width: 48em) {
  .nav__links > li > a {
    position: relative;
  }

  .nav__links > li > a::after {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    top: calc(50% + 0.72em);
    height: 1px;
    background: var(--accent);
    transform: scaleX(0);
    transform-origin: left center;
    transition: transform var(--transition);
  }

  .nav__links > li > a:hover::after,
  .nav__links > li > a:focus-visible::after {
    transform: scaleX(1);
  }

  .nav__links > li > a[aria-current]::after {
    content: none;
  }
}


/* ==========================================================================
   20. AMBIENT DOT GRID — D88
   A fixed, full-viewport field of dots behind everything. Fixed rather than
   scrolling is the whole trick: the content moves and the field does not, so
   the page reads as having depth without a single scroll handler. The drift
   on top of that is assets/js/ambient.js, and it is an enhancement — with the
   script absent the field still parallaxes, just at rate zero.

   One radial-gradient, no image bytes. z-index -1 works only because html
   now carries the ground and body does not; see the note there.

   Masked out of the middle. Texture behind body copy hurts reading even at
   contrast ratios that pass, so the field is present in the gutters, absent
   across the centre column, and the transition between is wide enough that
   the edge of the mask is not itself a visible line.
   ========================================================================== */

.ambient-dots {
  position: fixed;
  inset: -10vh 0;        /* overscan, so the drift never exposes a bare edge */
  z-index: -1;
  pointer-events: none;
  background-image: radial-gradient(circle, var(--dot) 1px, transparent 1.6px);
  background-size: 26px 26px;
  -webkit-mask-image: linear-gradient(90deg, #000 0%, #000 10%, transparent 34%, transparent 66%, #000 90%, #000 100%);
          mask-image: linear-gradient(90deg, #000 0%, #000 10%, transparent 34%, transparent 66%, #000 90%, #000 100%);
}

/* Below the shell there are no gutters to put dots in — the text runs edge to
   edge, and a masked field would either sit under the words or not appear at
   all. Off. */
@media (max-width: 61.99em) {
  .ambient-dots { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .ambient-dots { transform: none !important; }
}


/* ==========================================================================
   21. HERO EMBOSS AND SHEEN — D88
   A letterpress contour motif across the lower half of the hero panel, with a
   glint that travels along its top edge.

   Abstract, not lettering — owner's call. A slogan embossed behind the hero
   would have had to be aria-hidden to keep a screen reader from announcing it
   mid-headline, and would have read as stock consultancy language against
   copy written specifically to avoid that register.

   The emboss is two copies of the same repeating-radial-gradient, the shadow
   painted over the highlight and the highlight pushed 2px down, so each arc
   catches light from above. That is the entire letterpress effect: no image,
   no filter, no blur, and it resolves at any size.

   inset: 50% 0 0 0 — the lower half exactly, as asked.
   ========================================================================== */

.hero { position: relative; }
.hero__inner { position: relative; z-index: 1; }

.hero__texture {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;               /* the sheen travels past both edges */
  border-radius: var(--radius-box);
  pointer-events: none;
}

/* The shader surface — D89. Opaque, so wherever WebGL2 starts it simply
   covers the emboss below; where it does not, it never lifts off opacity 0
   and the emboss is what remains. That is the entire fallback story.

   It fades ITSELF in on the first drawn frame rather than being revealed by a
   preloader holding the page back. Nothing is withheld from the reader while
   it warms up: the headline is readable at first paint and the background
   arrives underneath it a moment later. */
.hero__canvas {
  position: absolute;
  inset: 0;
  border-radius: var(--radius-box);   /* follow the panel it fills */
  width: 100%;
  height: 100%;
  display: block;
  opacity: 0;
  transition: opacity 600ms ease;
}

.hero__canvas.is-lit { opacity: 1; }

/* With the shader live there is no emboss edge left for the glint to ride,
   and a CSS light source over an artwork that has its own reads as two suns.
   Set by assets/js/hero-shader.js once the context is up. */
.hero--shader .hero__sheen { display: none; }

@media (prefers-reduced-motion: reduce) {
  .hero__canvas { transition: none; }
}

/* FIVE arcs with explicit stops, not repeating-radial-gradient.

   The first cut used repeating-radial-gradient with the centre at 168% — far
   outside the box — which is pathological: the radii grow enormous, the
   pattern has to be evaluated over hundreds of 3px rings, and rasterizing the
   hero took long enough that the renderer stopped answering screenshot
   requests altogether while scripting stayed responsive. That is the shape of
   a paint-bound problem, and it would have been US-061's problem next.

   Bounded stops fix it outright: five rings, evaluated once, no repetition.
   The motif is identical to look at. D88. */
.hero__texture::before {
  content: "";
  position: absolute;
  inset: 50% 0 0 0;
  background-repeat: no-repeat;
  background-image:
    radial-gradient(circle at 84% 150%,
      transparent 0 21.6%,  var(--emboss-lo) 21.6% 22%,
      transparent 22%   33.6%, var(--emboss-lo) 33.6% 34%,
      transparent 34%   45.6%, var(--emboss-lo) 45.6% 46%,
      transparent 46%   57.6%, var(--emboss-lo) 57.6% 58%,
      transparent 58%   69.6%, var(--emboss-lo) 69.6% 70%,
      transparent 70%),
    radial-gradient(circle at 84% 150%,
      transparent 0 21.6%,  var(--emboss-hi) 21.6% 22%,
      transparent 22%   33.6%, var(--emboss-hi) 33.6% 34%,
      transparent 34%   45.6%, var(--emboss-hi) 45.6% 46%,
      transparent 46%   57.6%, var(--emboss-hi) 57.6% 58%,
      transparent 58%   69.6%, var(--emboss-hi) 69.6% 70%,
      transparent 70%);
  background-position: 0 0, 0 2px;

  /* MASKED OFF THE COPY COLUMN, and this is a hard requirement rather than a
     refinement. --muted on --surface is 4.53:1 — three hundredths above the
     WCAG 1.4.3 floor — so ANY darkening behind the subhead fails, and the
     measured overlap was 116px. Alpha cannot solve that: even 0.03 lands at
     4.40:1. The only fix is for the emboss not to be there.

     So the motif is centred bottom-right and cut before the copy: fully
     transparent across the left 52% (the desktop copy column ends near 47%),
     ramping in over 18% so the mask's own edge is never a visible line. The
     lower half is still the lower half, as asked; it simply carries its
     density on the side with no words on it. */
  -webkit-mask-image: linear-gradient(90deg, transparent 0 52%, #000 70%);
          mask-image: linear-gradient(90deg, transparent 0 52%, #000 70%);
}

/* Below the two-column hero the copy runs full width, so there is no right
   side to hide in and the mask above cannot help. The shadow layer comes off
   entirely and the highlight does the whole motif: white at 85% LIGHTENS
   --surface toward --ground, which moves every text pairing up rather than
   down (muted reaches 4.96:1 on white). Fainter, and safe by construction. */
@media (max-width: 47.99em) {
  .hero__texture::before {
    background-image:
      radial-gradient(circle at 84% 150%,
        transparent 0 21.6%,  var(--emboss-hi) 21.6% 22%,
        transparent 22%   33.6%, var(--emboss-hi) 33.6% 34%,
        transparent 34%   45.6%, var(--emboss-hi) 45.6% 46%,
        transparent 46%   57.6%, var(--emboss-hi) 57.6% 58%,
        transparent 58%   69.6%, var(--emboss-hi) 69.6% 70%,
        transparent 70%);
    background-position: 0 0;
    -webkit-mask-image: none;
            mask-image: none;
  }
}

/* The glint, riding the emboss's top edge. */
.hero__sheen {
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: 3px;
  margin-top: -1px;
  overflow: hidden;
}

.hero__sheen::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  width: 38%;
  background: linear-gradient(90deg, transparent, var(--sheen) 50%, transparent);
  transform: translateX(-130%);
  animation: hero-sheen 11s linear infinite;
}

/* It crosses in the first 45% and waits out the rest. A glint that recurs
   every eleven seconds is punctuation; one that sweeps continuously is a
   progress bar, and WCAG 2.2.2 starts to have a point about it. The pause is
   also when the compositor gets to idle. */
@keyframes hero-sheen {
  0%   { transform: translateX(-130%); }
  45%  { transform: translateX(360%); }
  100% { transform: translateX(360%); }
}

/* Set by assets/js/ambient.js once the hero leaves the viewport. An infinite
   animation on an off-screen element is battery spent on nothing. */
.hero--rest .hero__sheen::before { animation-play-state: paused; }

@media (prefers-reduced-motion: reduce) {
  .hero__sheen { display: none; }
}


/* ==========================================================================
   22. SERVICE TIERS AS A 2x2 — D88
   Three stacked full-width cards became four cards in a grid. The stack was
   the single longest stretch of the page and the likeliest source of the
   "static" note: three big blocks of prose, each with a 3:1 banner, one after
   another.

   The fourth cell is not filler. Three services in a 2x2 leaves a hole, and
   the honest thing to put in it is the sentence the section already makes in
   prose: that the right answer might be none of them.

   THE LADDER STILL HAS TO READ. plain -> bordered -> filled is an escalating
   commitment, and in a grid the left-to-right order carries it less strongly
   than a stack did. Source order is preserved and the fill still lands on the
   heaviest engagement, so the weight gradient runs across then down.
   ========================================================================== */

@media (min-width: 48em) {
  .tiers {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    align-items: stretch;
  }
  /* The tier-1 padding override that used to live here is gone: .tier--plain
     no longer zeroes its padding, so it inherits .tier's at every width and
     there is nothing to put back. D97. */
}

/* The fourth cell. Dashed, so it reads as an opening rather than as a fourth
   product, and it carries no image — the other three earn their banners by
   being things you buy. */
.tier--invite {
  border: 1px dashed var(--rule);
  display: flex;
  flex-direction: column;
  justify-content: center;
}

.tier--invite .tier__name { color: var(--accent); }

/* A text link, deliberately NOT a .cta button. D68 cut this page from seven
   Book-a-call buttons down to two because testers found the repetition
   relentless, and a third filled button in the middle of the page walks that
   back. It is also not a [data-floating-anchor]: the floater should keep
   standing by here, because this card is an invitation to read on, not the
   booking moment. */
.tier__invite-link {
  /* inline-flex with the tap target, not a bare inline link. Measured at 95x18,
     which fails WCAG 2.2 AA 2.5.8 — 24x24 is the minimum — and the "inline"
     exception that criterion allows does not apply, because this sits alone in
     its own paragraph as a call to action rather than inside a sentence.

     It still reads as a text link: no fill, no border, just the underline. The
     height is target, not decoration. D101. */
  display: inline-flex;
  align-items: center;
  min-height: var(--tap-target);
  color: var(--accent);
  font-weight: var(--weight-heading);
  text-decoration-thickness: 1px;
  text-underline-offset: 4px;
}

/* Entry from the four corners, as asked — but 32px, not off-screen. Cards
   flying in from beyond the viewport need the container clipped or they widen
   the document, and US-014 forbids a horizontal scrollbar at any width from
   320px to 1920px. A short diagonal reads as the same gesture and cannot
   overflow anything.

   Scoped to the breakpoint where the 2x2 exists. Below it the grid is one
   column, "corners" means nothing, and the plain rise from section 18 is
   what runs. */
@media (min-width: 48em) {
  .tiers.is-pending > .tier:nth-child(1) { transform: translate(-32px, -32px); }
  .tiers.is-pending > .tier:nth-child(2) { transform: translate( 32px, -32px); }
  .tiers.is-pending > .tier:nth-child(3) { transform: translate(-32px,  32px); }
  .tiers.is-pending > .tier:nth-child(4) { transform: translate( 32px,  32px); }
}

/* Per-item stagger, E1. The four corners already arrive; without a delay they
   arrive in unison, which reads as one object splitting rather than four
   things settling. 70ms apart in reading order.

   The delay belongs on the RESTING rule, not on .is-pending. A delay declared
   on the hidden state would also delay the way out if a group is ever hidden
   again, and reveal.js only ever hides once — but the resting rule is the one
   that actually runs the animation, so that is where the timing goes.

   210ms total for the last card. Past roughly 300ms a stagger stops reading
   as choreography and starts reading as lag. */
.tiers > .tier:nth-child(1) { transition-delay:   0ms; }
.tiers > .tier:nth-child(2) { transition-delay:  70ms; }
.tiers > .tier:nth-child(3) { transition-delay: 140ms; }
.tiers > .tier:nth-child(4) { transition-delay: 210ms; }

.samples > .sample:nth-child(1) { transition-delay:  0ms; }
.samples > .sample:nth-child(2) { transition-delay: 90ms; }

@media (prefers-reduced-motion: reduce) {
  .tiers.is-pending > .tier { transform: none; }
  /* Section 18's reduced-motion block already forces transition-delay to 0 on
     every direct child of a [data-reveal], which is what these are. Restated
     here only so that removing this section cannot silently leave a stagger
     running for someone who asked for no motion. */
  .tiers > .tier,
  .samples > .sample { transition-delay: 0ms !important; }
}


/* ==========================================================================
   23. PROSE MEASURE IN A FULL-WIDTH SHELL — D90, US-014
   The container is fluid now, so nothing else is holding line length. Measured
   at 1440px before this section existed, body copy ran to 151 characters a
   line in the work samples — roughly twice the point where the eye starts
   losing its place on the return sweep.

   The cap goes on the PARAGRAPHS, not the container. That is the difference
   between this and the old 54.5rem shell: the shell throttled every section
   equally, including the ones that wanted the width, and paid 39% of the
   screen for it. Here the tiers, the samples and the media all still span the
   full viewport, and only running text is held back.

   D49 is preserved, not reversed. Its objection was to copy stopping short of
   the IMAGES beside it, which read as an unfinished layout. Every block capped
   below is prose with nothing beside it. Where there is media — the about
   section — the column itself is sized to the measure instead, so the text
   still fills what it is given.

   Section ledes were briefly given a wider cap so they would not read as a
   dropped sentence above full-width content. Measured, that put them at 83
   characters, outside the band the rest of this section exists to hold. One
   cap for all running text was both simpler and correct.
   ========================================================================== */

/* OWNER'S CALL, D91: running text fills the container again. The cap above is
   kept as a token but no longer applied to body copy — the numbers are in the
   decision log, and at 1440px this puts a line at about 146 characters against
   the 50-75 the typographic rule asks for. Recorded here so the next person to
   read this file knows it is a decision and not an oversight.

   The one place still capped is the about section's COLUMN, in section 23,
   because there the constraint is the grid track rather than the paragraph. */

/* The about section's column rule used to be restated here, at 48em, silently
   overriding the one in section 17. Folded into that single rule at D109 —
   two blocks setting grid-template-columns on the same element at the same
   breakpoint is how the icon-sized portrait hid for as long as it did. */

/* ==========================================================================
   24. GLASS NAV AND THE MOBILE HAMBURGER — D90
   Skin taken from the owner's hero design; behaviour is ours, because that
   design has none — its nav is display:none below 820px with nothing behind
   it. Here the links collapse into a real disclosure.
   ========================================================================== */

.nav__toggle {
  display: none;                    /* desktop shows the links themselves */
  align-items: center;
  justify-content: center;
  width: var(--tap-target);
  height: var(--tap-target);
  margin-left: auto;
  padding: 0;
  border: 1px solid var(--rule);
  border-radius: var(--radius-pill);
  background: var(--surface);
  color: var(--ink);
  cursor: pointer;
}

.nav__toggle:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* Three bars from one element: the middle is the box, the outer two are its
   pseudo-elements. On open the outer two rotate onto the centre and the
   middle fades, so the control shows its own state without a second icon. */
.nav__burger,
.nav__burger::before,
.nav__burger::after {
  display: block;
  width: 18px;
  height: 2px;
  border-radius: var(--radius-pill);
  background: currentColor;
  transition: transform var(--transition), opacity var(--transition);
}

.nav__burger::before,
.nav__burger::after {
  content: "";
  position: absolute;
}

.nav__burger { position: relative; }
.nav__burger::before { transform: translateY(-6px); }
.nav__burger::after  { transform: translateY(6px); }

.nav__toggle[aria-expanded="true"] .nav__burger { background: transparent; }
.nav__toggle[aria-expanded="true"] .nav__burger::before { transform: rotate(45deg); }
.nav__toggle[aria-expanded="true"] .nav__burger::after  { transform: rotate(-45deg); }

@media (max-width: 47.99em) {
  .nav__toggle { display: inline-flex; }
  .nav { position: relative; }

  /* The panel is the dropdown — it holds the four anchors AND Contact, which
     is the reason Contact moved inside a wrapper in D91 rather than sitting
     beside the list. Not display:none when closed: that kills the transition.
     visibility does the same job for the tab order and can be animated. */
  .nav__panel {
    position: absolute;
    top: calc(100% + var(--space-3));

    /* 70% of the width it used to span, anchored to the right — owner's call,
       D96. Pinning the right edge rather than the left is what makes the
       narrowing read as intentional: the panel now hangs from the control
       that opened it instead of floating in the middle of the bar.

       The 100% here resolves against .nav, which is the positioned ancestor
       and spans the full viewport, so the subtraction has to put the gutters
       back before taking the percentage. */
    left: auto;
    right: var(--gutter);
    width: calc((100% - 2 * var(--gutter)) * 0.7);
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: var(--space-3);
    padding: var(--space-4);
    border: 1px solid var(--rule);
    border-radius: var(--radius-box);
    background: var(--surface);
    box-shadow: 0 18px 40px rgba(42, 42, 42, 0.10);
    visibility: hidden;
    opacity: 0;
    transform: translateY(-6px);
    transition: opacity var(--transition), transform var(--transition), visibility var(--transition);
  }

  .nav__panel[data-open="true"] {
    visibility: visible;
    opacity: 1;
    transform: none;
  }

  .nav__links {
    flex-direction: column;
    align-items: stretch;
    gap: 2px;
    overflow: visible;
    max-height: none;
  }

  .nav__links > li { width: 100%; }
  .nav__links > li > a { width: 100%; border-radius: var(--radius-pill); padding-inline: var(--space-4); }

  /* The desktop centring trick (D67) pushes items around in a column. */
  .nav__links > li:first-child { margin-left: 0; }

  /* flex-start, like the other four. Centred, it was the only row in the
     column whose label did not start on the same left edge, which is exactly
     the thing that makes a vertical list hard to scan. */
  .nav__contact { justify-content: flex-start; width: 100%; }
}

/* --- desktop: the glass pill -------------------------------------------- */
@media (min-width: 48em) {
  /* Shape only. The frosted fill moved to section 26, which owns it per
     header state — D90 painted it here unconditionally, which left the pill
     showing after the hero had passed. */
  .nav__links {
    gap: 2px;
    /* No vertical padding and NO border box. Contact is 44px because its
       min-height includes its 1px border; the pill was 44px of links plus
       6px padding top and bottom plus 2px of border — 58px, measured, and
       visibly taller beside it. Zeroing the padding still leaves 46px while
       a real border is in the box, so the hairline is drawn as an inset
       shadow instead and the two now match exactly at 44px.

       The links keep min-height: 44px. That is the iOS tap target and is not
       negotiable to make a shape fit. D93. */
    padding: 0 8px;
    border: 0;
    border-radius: var(--radius-pill);
    /* border-color and box-shadow only — NOT background. This element carries
       backdrop-filter, which makes it a composited layer, and transitioning
       its background across the hero boundary left the fill a full state
       behind: measured still frosted 4s after the swap, correct by 7s, while
       backdrop-filter (untransitioned) flipped immediately. An instant swap is
       also the better look — a 160ms fade on a panel that loses its blur in
       one frame reads as two separate events. D92. */
    transition: border-color var(--transition), box-shadow var(--transition);

    /* flex: 1 1 100% and overflow-x: auto belong to the MOBILE bar, where the
       list is the whole row and scrolls sideways if the labels overrun. As a
       desktop flex child inside a bar of three, that basis made the pill grow
       to fill everything left over — measured at 1440px, 1097px of frosted
       glass around 320px of links. A pill has to be the size of its contents.
       D91. */
    flex: 0 0 auto;
    overflow: visible;
  }

  .nav__links > li > a { border-radius: var(--radius-pill); padding-inline: 13px; }
}


/* The portrait's bob is driven by assets/js/hero-shader.js. The transition
   here is only so that the transform settles rather than snapping if the
   loop halts mid-bob — the per-frame motion is far faster than 160ms and is
   unaffected by it. D90. */
.hero__media {
  transition: transform var(--transition);
  will-change: transform;
}

@media (prefers-reduced-motion: reduce) {
  .hero__media { transition: none; }
}


/* ==========================================================================
   25. FIXES FROM OWNER REVIEW — D91
   ========================================================================== */

/* The portrait's transparency was not lost in conversion — the WebP round-trips
   with 610,804 fully transparent pixels, identical to the source PNG. It was
   .media painting --surface behind every image and clipping to --radius-box.
   Behind a transparent round cut-out that fill IS the lost background, and the
   squircle corner was cropping a circle into a rounded square.

   The hero portrait opts out of both. Every other .media keeps them: those are
   opaque photographs where the fill is what stops a slow load showing through
   and the corner is the house shape. */
.hero__media {
  background: none;
  border-radius: 0;
  /* contain, not cover. Square source in a square box makes the two identical
     today, but the cut-out now carries its own alpha silhouette — the moment
     that box stops being square, cover would crop the subject rather than
     letterbox the transparency, and transparency is free to letterbox. */
  object-fit: contain;
}

/* Owner's call: the hero is not a card. Square corners, so it reads as the top
   of the page rather than as the first panel on it. The texture layer and the
   shader canvas have to follow, or they clip against a corner the panel below
   them no longer has. */
.hero,
.hero__texture,
.hero__canvas {
  border-radius: 0;
}


/* ==========================================================================
   26. THE HEADER OVER THE HERO — D91
   Owner's design puts the bar inside the hero's top band: nothing behind the
   name, a frosted pill behind the four section anchors alone, nothing behind
   Contact. Once the hero is past, the bar becomes the translucent white one it
   used to be and the pill goes away.

   Scoped to .site-header--overlay, which only the founders index carries. The
   work samples, the 404 and the agencies bar have no hero to sit over, and a
   fixed transparent header there would let their first paragraph slide under
   it.
   ========================================================================== */

.site-header--overlay {
  position: fixed;
  left: 0;
  right: 0;
  top: 0;
  background: transparent;
  transition: background var(--transition), box-shadow var(--transition);
}

/* The hero starts at the top of the document now, so it has to open its own
   clearance — the header no longer occupies flow above it. */
.site-header--overlay ~ main .hero,
body > .site-header--overlay + main .hero {
  padding-block-start: calc(var(--nav-height) + var(--space-6));
}

/* Past the hero: the bar it always was. Translucent rather than solid so the
   page still reads as passing underneath, with the same @supports guard the
   nav pill uses — without it, a browser with no backdrop-filter shows body
   copy straight through the bar. */
.site-header--overlay[data-past-hero="true"] {
  background: var(--ground);
  box-shadow: 0 1px 0 var(--rule);
}

@supports ((backdrop-filter: blur(12px)) or (-webkit-backdrop-filter: blur(12px))) {
  .site-header--overlay[data-past-hero="true"] {
    background: rgba(255, 255, 255, 0.72);
    -webkit-backdrop-filter: blur(12px);
            backdrop-filter: blur(12px);
  }
}

/* --- the panel ----------------------------------------------------------- */
/* display:contents so the wrapper adds no box of its own on desktop: the list
   and Contact become direct flex children of .nav__inner, which is what lets
   the pill wrap the four without wrapping the fifth. Safari 11.1+, inside the
   US-060 floor. */
@media (min-width: 48em) {
  .nav__panel {
    display: contents;
  }
}

/* --- Contact: no fill, gold hairline, pill ------------------------------- */
.nav__contact {
  display: inline-flex;
  align-items: center;
  min-height: var(--tap-target);
  padding-inline: 17px;
  border: 1px solid var(--rule-section);
  border-radius: var(--radius-pill);
  background: transparent;
  color: var(--ink);
  font-size: var(--text-small);
  text-decoration: none;
  white-space: nowrap;
  transition: color var(--transition), border-color var(--transition);
}

.nav__contact:hover { color: var(--accent); border-color: var(--accent); }

/* Same reasoning as the four anchors in D76: colour alone is not enough, and
   a border that merely changes hue is still only hue. The underline gives this
   one the same change of SHAPE, at the same offset, so the five read as one
   indicator rather than two conventions. */
.nav__contact[aria-current] {
  color: var(--accent);
  border-color: var(--accent);
  text-decoration: underline;
  text-decoration-color: var(--accent);
  text-decoration-thickness: 2px;
  text-underline-offset: 6px;
}

/* --- the frosted pill, and its absence ----------------------------------- */
@media (min-width: 48em) {
  /* Over the hero the four anchors sit on frosted glass. The @supports guard
     is load-bearing: at 0.62 alpha with no blur the shader would scroll
     visibly through the link text. */
  .site-header--overlay .nav__links {
    background: var(--surface);
    box-shadow: inset 0 0 0 1px var(--rule), 0 6px 18px rgba(42, 42, 42, 0.06);
  }

  /* WHY THE OLD ONE LOOKED FLAT. It was rgba(249,247,244,0.62) — 62% of a
     near-white fill sitting on a near-white shader. Blur only shows where
     there is variation behind to smear, and at 62% opacity there was almost
     nothing left showing through to smear in the first place. It was a pale
     panel, not glass.

     Three changes make it read: the fill drops to 32% so the shader is
     genuinely visible through it, saturate(1.7) pushes what shows through
     warmer than its surroundings (the cue the eye actually reads as glass),
     and a bright inset hairline gives the pane a lit top edge. The outer
     shadow lifts it off the hero. D93. */
  @supports ((backdrop-filter: blur(12px)) or (-webkit-backdrop-filter: blur(12px))) {
    .site-header--overlay .nav__links {
      background: rgba(255, 255, 255, 0.32);
      -webkit-backdrop-filter: saturate(1.7) blur(14px);
              backdrop-filter: saturate(1.7) blur(14px);
      box-shadow:
        inset 0 1px 0 rgba(255, 255, 255, 0.75),
        inset 0 0 0 1px rgba(255, 255, 255, 0.40),
        0 8px 22px rgba(42, 42, 42, 0.10);
    }
  }

  /* Past the hero the bar itself is the frosted surface, so a second frosted
     panel inside it would read as a panel on a panel. The pill goes — and it
     has to unset the backdrop filter too, not just the fill, or the blur
     stays behind transparent glass and smears the bar under the links. */
  .site-header--overlay[data-past-hero="true"] .nav__links {
    background: transparent;
    box-shadow: none;              /* the hairline is an inset shadow now */
    -webkit-backdrop-filter: none;
            backdrop-filter: none;
  }

  /* Contact loses its outline on the scrolled bar too — owner's call. Past
     the hero the five items should read as one row of plain links, and a
     single outlined one among four bare ones reads as a button nobody else
     is. Over the hero it keeps the outline, which is where the design puts
     it. Hover and aria-current still bring their own colours back. D93. */
  .site-header--overlay[data-past-hero="true"] .nav__contact {
    border-color: transparent;
  }
}


/* ==========================================================================
   27. THE BAR IS INVISIBLE OVER THE HERO — D92
   D91 set .site-header--overlay to transparent and stopped there, which was
   the wrong element: .nav inside it has carried its own --ground fill, its
   --nav-shadow and (under @supports) a saturate/blur backdrop since long
   before the overlay existed. Measured, that was still painting
   rgba(255,255,255,0.8) with blur(12px) across the top of the hero.

   So the override goes on .nav, and it has to clear the backdrop filter as
   well as the fill — a transparent element with a live backdrop-filter still
   blurs everything behind it, which over a moving shader is more obvious than
   the fill was.

   Everything here is scoped to data-past-hero="false". Once the hero is gone
   .nav's own rules take back over untouched, which is what makes the scrolled
   state identical to what it always was.
   ========================================================================== */

.site-header--overlay[data-past-hero="false"] .nav {
  background: transparent;
  box-shadow: none;
  -webkit-backdrop-filter: none;
          backdrop-filter: none;
}

/* D91 put a second translucent fill on the header itself, which now
   double-paints with .nav's. .nav owns the bar; the header owns only its
   position. */
.site-header--overlay[data-past-hero="true"] {
  background: transparent;
  box-shadow: none;
  -webkit-backdrop-filter: none;
          backdrop-filter: none;
}


/* ==========================================================================
   28. TWO-COLUMN PROSE — D92, US-014
   The container went full width in D90 and the measure came off body copy in
   D91 at the owner's call, which put a line at about 146 characters. Columns
   are the way to have both: the block still spans the viewport, and each line
   is half of it.

   Only from 62em. Below that the viewport is already close to a comfortable
   measure, and two columns of a five-line paragraph is a newspaper clipping.

   break-inside: avoid on the paragraphs. Without it a paragraph splits across
   the fold, so the reader finishes a sentence at the bottom of the left column
   and hunts for its ending at the top of the right — which is the one thing
   multicol does worse than a long line.
   ========================================================================== */

@media (min-width: 62em) {
  .prose-cols {
    columns: 2;
    column-gap: var(--space-8);
  }

  .prose-cols > p {
    break-inside: avoid;
    -webkit-column-break-inside: avoid;   /* Safari 15 still wants the prefix */
  }

  /* .stack puts margin-top on every sibling after the first. At the top of the
     second column that margin lands as leading space, so the two columns start
     at different heights. */
  .prose-cols > *:first-child { margin-top: 0; }
}


/* ==========================================================================
   29. NAV ACCENT OVER THE HERO — D93, US-006
   Every accent state in the bar, restated against the shader. See --accent-nav
   for the measurements. Scoped to data-past-hero="false": once the bar is the
   opaque white one again, --accent is back on --ground where it measures
   4.99:1 and needs no help.
   ========================================================================== */

.site-header--overlay[data-past-hero="false"] .nav__name:hover,
.site-header--overlay[data-past-hero="false"] .nav__links a:hover,
.site-header--overlay[data-past-hero="false"] .nav__contact:hover {
  color: var(--accent-nav);
}

.site-header--overlay[data-past-hero="false"] .nav__contact:hover {
  border-color: var(--accent-nav);
}

.site-header--overlay[data-past-hero="false"] .nav__links > li > a[aria-current],
.site-header--overlay[data-past-hero="false"] .nav__contact[aria-current] {
  color: var(--accent-nav);
  text-decoration-color: var(--accent-nav);
}

.site-header--overlay[data-past-hero="false"] .nav__contact[aria-current] {
  border-color: var(--accent-nav);
}

/* The hover underline from D86 rides the same backdrop. */
@media (hover: hover) and (min-width: 48em) {
  .site-header--overlay[data-past-hero="false"] .nav__links > li > a::after {
    background: var(--accent-nav);
  }
}


/* ==========================================================================
   30. MOBILE NAV AND HERO CORRECTIONS — D94
   ========================================================================== */

@media (max-width: 47.99em) {

  /* Contact is one of five links in the panel, not a button sitting among
     them. It kept the outline the desktop bar gives it over the hero, where
     it is the only item outside the pill and the outline is what separates
     it. Inside a column of five identical rows there is nothing to separate
     it from, so the outline just made one row look clickable in a way the
     other four are not. Hover and aria-current still bring their colours. */
  /* Every state, not just the resting one. D94 set this as a plain rule, which
     :hover and [aria-current] both outrank — and the two overlay rules from
     D93 outrank it twice over — so the outline came straight back the moment
     the row was touched or became current. Matching those selectors here, from
     later in the file, is what actually holds it off.

     Colour still changes on both, so the states are not lost; they just stop
     drawing a box around one row in a column of five. */
  .nav__contact,
  .nav__contact:hover,
  .nav__contact:focus-visible,
  .nav__contact:active,
  .nav__contact[aria-current],
  .site-header--overlay[data-past-hero="false"] .nav__contact:hover,
  .site-header--overlay[data-past-hero="false"] .nav__contact[aria-current],
  .site-header--overlay[data-past-hero="true"] .nav__contact {
    border-color: transparent;
  }

  /* The Services submenu expands the panel instead of covering it.

     It was position:fixed at top:--nav-height spanning the gutters, which is
     a full-width sheet laid OVER the open menu — so opening Services hid the
     four links the reader had just opened the menu to see. In flow it pushes
     the rest of the list down, which is what a disclosure is supposed to do.

     Indented, with a rule down the left edge: the indent says these belong to
     Services, and the rule holds that relationship while the reader scrolls
     past the fold on a small phone. */
  .nav__menu {
    position: static;
    max-height: none;
    overflow: visible;
    margin: 2px 0 var(--space-2) var(--space-5);
    padding: 0;
    background: transparent;
    border: 0;
    border-left: 1px solid var(--rule);
    border-radius: 0;
    box-shadow: none;
  }

  .nav__menu a {
    padding-inline: var(--space-4);
    border-radius: var(--radius-pill);
  }

  /* Labels wrap inside the narrowed panel — D96. white-space: nowrap is right
     for the desktop bar and for the mobile row that scrolls sideways, where a
     broken label is worse than a clipped one. In a 235px dropdown it is not:
     "Discovery to Buildable Spec" measured 226px inside a 181px submenu and
     ran 29px past the panel's right edge.

     Nothing reported that. body carries overflow-x: hidden for US-014, so the
     document scrollWidth check stayed clean while the text was simply cut off
     — the guard that stops a horizontal scrollbar also hides the evidence
     that something needs one. Compare each element against its own container,
     not the document against the viewport. */
  .nav__panel a {
    white-space: normal;
  }

  .nav__menu a {
    padding-block: var(--space-2);
  }

  /* The name lives in the bar now, so the hero does not need to say it twice.
     Removing it leaves .hero__ident holding only the button, and
     space-between with one child pushes that child to the TOP — the opposite
     of the shared floor D66 built. flex-end puts it back on the portrait's
     bottom edge, which is the alignment being asked for. */
  /* align-self: end, not stretch. Stretch was D66's mechanism for the shared
     floor: the column filled the row and space-between pushed the button to
     the top and the name to the bottom. With the name gone there is nothing
     for space-between to separate, so the column is sized to the button and
     ended at the row — two boxes, one edge, no arithmetic.

     NOT a bug fix. getBoundingClientRect said the two bottoms were 5-6px
     apart and I spent three rounds chasing it; the boxes were aligned the
     whole time. getBoundingClientRect reports the PAINTED position, and the
     portrait carries the bob transform, so its rect oscillates by 6px either
     way while its layout box never moves. offsetTop + offsetHeight ignore
     transforms and put both bottoms at exactly 476.

     The rule: to measure layout on an element that is being animated by
     transform, do not use getBoundingClientRect. */
  .hero__ident {
    align-self: end;
    justify-content: flex-end;
  }

  .hero__cta {
    display: flex;
    justify-content: flex-end;
    margin-bottom: 0;
  }
}


/* ==========================================================================
   31. SERVICE-CARD ELEVATION — experiment, D106

   The question that produced this was whether the four service boxes should be
   glassmorphic or skeuomorphic. Neither, for reasons the codebase had already
   proved:

   GLASS needs something behind it to blur, and behind these cards is flat
   white — html paints --ground, the cards carry no fill, and the dot field
   is masked out of the centre column (D88) and only 7% alpha where it reaches
   at all. D93 measured exactly this failure on the nav pill, where a 62% fill
   over a near-flat backdrop read as a pale panel rather than glass. That pill
   works because the shader sits behind it. These cards have nothing.

   SKEUOMORPHISM is the wrong register for a page where every other card is
   box-shadow: none, and it would smuggle back the visual-weight hierarchy that
   D93 deliberately flattened.

   Elevation is the third option and the honest one: a UI convention rather
   than an imitation of a material, consistent with the shadows already on the
   floating CTA and the mobile panel, and free of any contrast or performance
   cost because nothing moves and nothing is composited over text.
   ========================================================================== */

.tier {
  box-shadow: var(--lift);
}

/* The dashed invite card keeps its outline and gets no lift: it is an opening
   rather than a fourth product, and a card that is not a thing you buy should
   not sit on the page as solidly as the three that are. */
.tier--invite {
  box-shadow: none;
}

/* ==========================================================================
   25. CASE STUDIES AND THE CASE DIALOG — D110
   Teaser cards on the page, the whole thing in a modal. The full text is in
   the document and only assets/js/case-dialog.js hides it, so with no script
   the section is three teasers followed by three complete case studies.
   ========================================================================== */

.cases {
  display: grid;
  gap: var(--space-5);
}

@media (min-width: 48em) {
  .cases { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-5); }
}

.case {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  padding: var(--space-5);
  border: 1px solid var(--rule);
  border-radius: var(--radius-box);
  background: var(--ground);
  box-shadow: var(--lift);
}

.case__who {
  margin: 0;
  font-size: var(--text-small);
  font-weight: var(--weight-strong);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--accent);
}

.case__head { margin: 0; font-size: var(--text-h3); line-height: 1.25; }
.case__teaser { margin: 0; color: var(--muted); }

/* The number is the reason to open the card, so it sits on its own line above
   the link rather than inside the prose. margin-top:auto pins it to the foot
   of the card so all three align regardless of how long the teaser runs. */
.case__stat {
  margin: auto 0 0;
  padding-top: var(--space-3);
  border-top: 1px solid var(--rule);
  font-size: var(--text-small);
  color: var(--muted);
}
.case__stat b {
  display: block;
  font-size: var(--text-h3);
  font-weight: var(--weight-strong);
  color: var(--ink);
}

.case__more { margin: 0; }

/* The whole card is NOT the link here, unlike .sample. A case study card
   carries a number and a teaser that a reader may want to select, and the
   destination is a dialog rather than a page. D110. */
.case__link {
  font-weight: var(--weight-strong);
  text-decoration: none;
}
.case__link:hover, .case__link:focus-visible { text-decoration: underline; }

/* Script-hidden. Visible, and perfectly readable, when the script never runs. */
.case-fulls { display: grid; gap: var(--space-7); margin-top: var(--space-7); }

/* An author `display` beats the user-agent's `[hidden] { display: none }`, so
   setting .hidden from the script did nothing and all three case studies stayed
   on the page under the cards. Caught in a full-page render, not in the DOM:
   the element reported hidden === true the whole time. */
.case-fulls[hidden] { display: none; }
.case-full { scroll-margin-top: calc(var(--nav-height) + var(--space-5)); }
.case-full__head { margin: var(--space-2) 0 var(--space-4); }

/* The one line in each case study that carries the number. */
.case__pull {
  margin: var(--space-4) 0;
  padding-left: var(--space-4);
  border-left: 3px solid var(--accent);
  font-size: var(--text-lead);
  font-weight: var(--weight-strong);
  color: var(--ink);
}

/* ---- the dialog ---------------------------------------------------------- */

.case-dialog {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  padding: var(--space-5) var(--gutter);
  overflow-y: auto;
  background: rgba(28, 26, 23, 0.42);
  opacity: 0;
  transition: opacity 200ms ease;
}

@supports ((backdrop-filter: blur(3px)) or (-webkit-backdrop-filter: blur(3px))) {
  .case-dialog {
    -webkit-backdrop-filter: blur(3px);
            backdrop-filter: blur(3px);
  }
}

.case-dialog.is-open { opacity: 1; }

/* THE SAME BUG AS .case-fulls, in the same file, an hour later. An author
   `display` beats the user agent's `[hidden] { display: none }`, so
   `backdrop.hidden = true` left a fixed, inset:0, opacity:0 sheet lying over
   the whole document. Every click on the page hit the backdrop instead of the
   link under it, and the backdrop handler ran close(), which scrolled to the
   saved position — still 0, because the dialog had never opened. Hence
   "nothing is clickable and everything scrolls to the top".

   Any element in this stylesheet that the script toggles with .hidden needs
   this line. There are now two. */
.case-dialog[hidden] { display: none; }

.case-dialog__panel {
  position: relative;
  width: 100%;
  /* --prose is the measure the rest of the page reads at; a dialog is no
     reason to widen it. The padding is added on top, hence max-width rather
     than width. */
  max-width: calc(var(--prose) + var(--space-7) * 2);
  margin: auto;
  padding: var(--space-6) var(--space-6) var(--space-7);
  border-radius: var(--radius-box);
  background: var(--ground);
  box-shadow: 0 24px 60px rgba(42, 42, 42, 0.28);
  transform: translateY(12px);
  transition: transform 220ms cubic-bezier(0.16, 1, 0.3, 1);
}

.case-dialog.is-open .case-dialog__panel { transform: none; }
.case-dialog__panel:focus { outline: none; }

.case-dialog__close {
  position: absolute;
  top: var(--space-3);
  right: var(--space-3);
  /* 44px, the house target size. WCAG 2.2 AA 2.5.8 asks for 24. */
  width: 44px;
  height: 44px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1.5rem;
  line-height: 1;
  color: var(--muted);
  background: none;
  border: 0;
  border-radius: var(--radius-pill);
  cursor: pointer;
}
.case-dialog__close:hover { color: var(--ink); background: var(--surface); }

/* The close button is absolutely positioned, so it does not push anything out
   of its way. At 375px the eyebrow line ran under it and wrapped around the
   glyph. Reserving the gutter on the first line only keeps the rest of the
   case study at full measure. */
.case-dialog__body > :first-child { padding-right: 2.75rem; }

/* Body scroll lock. position:fixed rather than overflow:hidden because iOS
   Safari ignores overflow:hidden on body and scrolls the page under the
   modal; the script restores the scroll position on close. */
body.has-dialog {
  position: fixed;
  width: 100%;
  overflow: hidden;
}

@media (prefers-reduced-motion: reduce) {
  .case-dialog,
  .case-dialog__panel { transition: none; }
  .case-dialog__panel { transform: none; }
}

/* ---- hero secondary CTA — v5 gives the hero two actions ------------------ */
/* Deliberately does NOT set justify-content. The 48em block above puts the row
   on the right, level with the portrait's bottom edge (D66), and this rule is
   later in the file — declaring justify-content here would silently win inside
   that media query and undo it. */
.hero__cta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-4);
}

/* Stacked below 48em. Side by side, the pill and the link shared one row and
   the flex algorithm squeezed the button until "Book a 30-minute call" wrapped
   onto two lines inside its own pill at 375px. */
@media (max-width: 47.999em) {
  .hero__cta {
    flex-direction: column;
    /* flex-END, not start. The rule at 2727 — inside the max-width block, not
       the desktop one, which is easy to misread — right-aligns the hero button
       on mobile so it shares an edge with the portrait above it (D46, D74).
       Stacking the two actions in a column with flex-start would have quietly
       moved the button to the left margin and undone that. */
    align-items: flex-end;
    gap: var(--space-3);
  }
}

/* A pill that wraps stops reading as a button. */
.hero__cta .cta { white-space: nowrap; }

.hero__cta-secondary {
  font-weight: var(--weight-strong);
  text-decoration: none;
  /* Against the shader, not the page. --accent-nav is the tone that clears
     4.5:1 over the moving background; --accent does not. D89. */
  color: var(--accent-nav);
}
.hero__cta-secondary:hover,
.hero__cta-secondary:focus-visible { text-decoration: underline; }

/* The credentials line is support, not the promise. */
.hero__subhead--credentials {
  font-size: var(--text-small);
  color: var(--hero-muted);
}

/* The line under the case-study row. It answers a question all three raise, so
   it reads as the section's closing remark rather than a fourth card. */


/* The credentials line sits under the hero's two actions now, not under the
   promise. It is support: who this is for and what stands behind it. */
/* A child of .hero__inner, not of .hero__ident. Inside .hero__ident it
   inherited that element's grid track, which on mobile is a 203px column
   beside the 120px portrait — seven lines of ragged text in a narrow gutter.
   Spanning every column puts it on its own row under the portrait and the two
   actions, which is where it was asked for on both breakpoints.

   Left-aligned. An earlier pass right-aligned it to sit with the mobile
   actions; that is fine for a button and wrong for a paragraph. */
.hero__credentials {
  grid-column: 1 / -1;
  margin: var(--space-4) 0 0;
  max-width: 60ch;
  font-size: var(--text-small);
  /* Over the shader, not the page. --hero-muted is the tone that clears
     4.5:1 against the darkest frame the canvas paints. D89. */
  color: var(--hero-muted);
}

/* ---- E1 revisions -------------------------------------------------------- */

/* The hero's second clause is dropped below 48em rather than duplicated into a
   second element. One copy of the sentence in the document means the short
   version a phone reads is the same text, minus a clause — not a second
   paragraph for a crawler to weigh against the first. */
@media (max-width: 47.99em) {
  .u-wide-only { display: none; }
}

/* The case-study note: the question on the left, the answer on the right.
   Two columns from 48em, one below it. */
.cases__note {
  display: grid;
  gap: var(--space-4);
  margin-top: var(--space-6);
  padding-top: var(--space-5);
  border-top: 1px solid var(--rule);
}

.cases__note-claim {
  margin: 0;
  font-weight: var(--weight-strong);
  color: var(--ink);
}

.cases__note-answer { margin: 0; color: var(--muted); }

@media (min-width: 48em) {
  .cases__note {
    grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
    column-gap: var(--space-7);
    /* The claim is one sentence against six. Centring it holds the two
       against each other instead of leaving it stranded at the top of a
       column three times its height. */
    align-items: center;
  }
}

/* Service cards read as one block of text otherwise. The card is a flex column
   with no row gap, so every paragraph sat directly on the one above it. */
.tier p + p { margin-top: var(--space-3); }
.tier .tier__meta { margin-top: var(--space-4); }

/* Zeroing the credentials' top margin on desktop, and it has to live HERE.
   The same declaration inside the 48em hero block at section 14 had no effect:
   the base `.hero__credentials` rule is defined later in this file, the two
   have identical specificity, and a media query adds none — so the later one
   won and the 16px stayed. Measured 33px above the button against 49px below.

   On desktop the grid's row-gap already does this job. On mobile there is no
   grid row between them, so the margin stays. */
@media (min-width: 48em) {
  .hero__credentials { margin-top: 0; }
}
