/* ==========================================================================
   DESIGN TOKENS
   Single source of truth. Components below reference these — never a raw hex/
   px/z-index inline — so a brand or motion change happens in one place. Raw
   palette values (--navy etc.) are the only "leaf" colors; everything else
   (surfaces, text, glass recipe) is a semantic token that points at a leaf,
   so renaming/recoloring a role never means hunting through components.
   ========================================================================== */
:root{
  /* ---- palette (leaf colors, §0) ---- */
  --navy:       #0B2A6F;
  --navy-deep:  #051436; /* deeper shade for opaque matte surfaces */
  --royal:      #1E5BD6;
  --azure:      #2E9BE6;
  --teal:       #25C4C4;
  --lime:       #7ED957;
  --white:      #ffffff;

  /* ---- surfaces ---- */
  --surface-page:   var(--white);
  --surface-dark:   var(--navy);      /* intro overlay background */
  --surface-matte:  var(--navy-deep); /* opaque dark glass panels: nav strip, future cards */

  /* ---- text ---- */
  --text-heading: var(--navy);
  --text-body:    var(--navy);
  --text-muted:   #3a4a72;
  --text-on-dark: var(--white);
  --text-link:    var(--royal);

  /* ---- glass recipe: a sandwich, not a tint-over-image trick ---- */
  /* Layer 1 (bottom): solid --surface-matte background-color, set directly
     on .glass/#nav-strip. Layer 2 (middle): the glass image, at LOW opacity
     — texture, not a picture. Layer 3 (optional, per-instance): can be added
     later for extra grain if the middle layer alone reads too flat — not
     built preemptively since nothing has needed it yet.
     --glass-picture-opacity is intentionally low by default (ambient chrome:
     nav, dividers, generic containers — seen on every scroll, shouldn't
     compete with content) and overridden higher on the handful of elements
     that are meant to be an actual *picture*, not texture — feature moments:
     service-card accents, the CTA bloom. See build plan Revision 7's
     "quiet chrome vs. loud feature moments" split. */
  --glass-image: url("/assets/brand/no-cursor-flat.svg");
  /* hand-authored, ~3KB vs. the real asset's ~2.6MB — layered *behind* it in
     background-image (see .glass/#nav-strip below), so it's invisible in
     normal operation and only shows through automatically if the real asset
     is slow/fails to load. CSS does this natively: a failed background-image
     layer just renders transparent, letting the layer behind it show. */
  --glass-fallback-image: url("/assets/brand/glass-fallback.svg");
  /* CURRENTLY UNUSED — kept deliberately, not overlooked. Its only consumers
     were the `backdrop-filter` declarations on .glass::before and
     #nav-strip::before, removed because they were measurably no-ops over the
     sandwich's opaque layer 1 (full explanation at #nav-strip::before). The
     token survives because the *decision* it encodes — how frosted this glass
     would be — is still the right one to reach for if --surface-matte is ever
     given an alpha. Nothing on the site is blurred today: if you are here
     because you expected frosted glass and can't find it, that is why. */
  --glass-blur: 6px;
  /* Two tiers, which is what the comment above always described. In practice
     it had drifted to six values (0.2 / 0.45 / 0.55 / 0.6 / 0.8) — a
     continuum doesn't read as "quiet chrome vs loud feature moment", it just
     reads as noise, so the distinction the spec wanted never landed. Anything
     that isn't one of these two is a new tier and needs a reason. */
  --glass-opacity-ambient: 0.2;
  --glass-opacity-feature: 0.6;
  --glass-picture-opacity: var(--glass-opacity-ambient);

  /* ---- motion ---- */
  --duration-fast: .15s;
  --duration-base: .3s;
  --duration-slow: .4s;
  --ease-standard:   ease;
  --ease-mechanical: cubic-bezier(.65, 0, .35, 1); /* tight/mechanical, no bounce — §2 State 6 */

  /* ---- elevation (z-index scale) ---- */
  --z-nav-bg: 0;
  --z-nav-content: 1;
  --z-nav: 100;
  --z-cursor-trail: 9998;
  --z-cursor: 9999;
  --z-intro: 10000;

  /* ---- spacing ---- */
  --space-1: 8px;
  --space-2: 16px;
  --space-3: 24px;
  --space-4: 40px;
  --space-5: 64px;
  --space-6: 96px;

  /* ---- typography ----
     A real modular scale now: ratio 1.25 from a 17px body. The old set was
     14 / 15 / 17 / 17.6 / 20 / 41.6 / 48 / 57.6 — five sizes crammed into a
     1.43x span doing no hierarchical work, a 2.08x chasm between h3 and h2
     with nothing available in between, and --text-md (17) sitting 0.6px from
     --text-lg (17.6): two tokens that rendered identically. Steps are named
     rather than roles so a new role picks an existing step instead of
     inventing a size. */
  --font-body: "Glacial Indifference", -apple-system, "Segoe UI", sans-serif;
  --step-000: 13.6px;  /* 17 / 1.25 */
  --step-0:   17px;    /* body */
  --step-1:   21.25px;
  --step-2:   26.56px; /* the step that didn't exist before */
  --step-3:   33.2px;
  --step-4:   41.5px;
  --step-5:   51.9px;
  --step-6:   64.9px;

  --text-sm:   var(--step-000);
  /* The one deliberate off-ratio step. Card columns are ~324px, and body copy
     there already measures only 42 characters per line — under the 45 floor.
     Moving it up to --step-0 would cost another five characters, so the
     compact size is buying back measure, not just inherited. Documented so it
     reads as a decision rather than a leftover. */
  --text-base: 15px;
  --text-md:   var(--step-0);
  --text-lg:   var(--step-1); /* was 17.6px — now a step you can actually see */
  --text-h3:   var(--step-1);
  --text-h2:   clamp(var(--step-2), 4vw, var(--step-4));
  /* h1 ceiling raised from 3.6rem (57.6px). It capped out at a 1047px-wide
     viewport, so the site's dominant element was the same size on a 13" laptop
     and a 27" monitor — the one thing artificially held small. */
  --text-h1:   clamp(var(--step-3), 5.5vw, var(--step-6));

  /* ---- alpha variants of the leaf palette ----
     These were raw rgba() literals scattered through the components, against
     this file's own stated rule. Named here so a palette change reaches them.
     The two inline data-URI SVGs below still hardcode their colours — tokens
     can't cross into a data URI, which is a genuine exception, not an oversight. */
  --glow-white:        rgba(255, 255, 255, .9);
  --glow-white-out:    rgba(255, 255, 255, 0);
  --glow-azure:        rgba(46, 155, 230, .75);
  --glow-azure-out:    rgba(46, 155, 230, 0);
  --glow-teal:         rgba(37, 196, 196, .6);
  --glow-teal-out:     rgba(37, 196, 196, 0);
  --hairline:          rgba(11, 42, 111, .1);
  --hairline-strong:   rgba(11, 42, 111, .28);
  --edge-light:        rgba(255, 255, 255, .28);
  --edge-light-strong: rgba(255, 255, 255, .55);
  --sheen-white:       rgba(255, 255, 255, .14);
  --text-on-glass:     rgba(255, 255, 255, .75);
  /* Layered flat shadows, not a gradient — guarantees contrast over whichever
     part of the busy glass sits behind the text, without a backing panel. */
  --shadow-text: 0 2px 4px rgba(5, 20, 58, .7), 0 8px 24px rgba(5, 20, 58, .5);
  /* Elevation for glass pills on a dark ground: an outer shadow reads because
     it's darker than the navy, and the inset highlight is light catching the
     top edge — that pair is what makes them read as lifted, not just outlined. */
  --shadow-lift:        0 6px 20px rgba(5, 20, 58, .45), inset 0 1px 0 rgba(255, 255, 255, .35);
  --shadow-lift-strong: 0 10px 28px rgba(5, 20, 58, .6), inset 0 1px 0 rgba(255, 255, 255, .5);

  /* ---- component-specific ---- */
  --nav-h: 88px;
  /* The two radii encode a real distinction, not two sizes to pick from:
     panel = a CARD or SURFACE you navigate to, pill = an ACTION you take.
     Keeping them separate is what lets tools/check-drift.py assert that
     --radius-pill has exactly one consumer, and so catch a forked button. */
  --radius-panel: 16px; /* every card/panel: .glass, .service-card, .sample-frame, .other-services-list a */
  --radius-pill: 999px; /* every button-shaped element: .cta-link — and ONLY .cta-link (enforced) */
  --section-max-w: 1100px;
  --cta-medallion-size: 120px; /* the medallion AND the glow that overlays it — one number so they can't drift apart */
}

/* ---------- fonts (self-hosted, §0) ---------- */
@font-face {
  font-family: "Glacial Indifference";
  src: url("/assets/fonts/glacial-indifference-regular.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Glacial Indifference";
  src: url("/assets/fonts/glacial-indifference-bold.woff2") format("woff2");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}
/* Italic dropped — never used anywhere on the site (checked: no font-style:
   italic, no <em>/<i>), so no reason to ship it. Only Regular + Bold, both
   actually used (body text and headings). */

/* ---------- reset ---------- */
*, *::before, *::after{ box-sizing: border-box; }
html, body{ margin:0; padding:0; }
body{
  background: var(--surface-page);
  color: var(--text-body);
  font-family: var(--font-body);
  font-size: var(--text-md);
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}
img{ max-width:100%; display:block; }
a{ color: var(--text-link); text-decoration:none; }
h1, h2, h3{ color: var(--text-heading); font-weight: 700; margin: 0 0 var(--space-2); line-height:1.1; }

/* ---------- accessibility: focus + skip link ---------- */
/* No focus-visible styling existed anywhere on the site before this pass —
   a real gap for keyboard users, not a design choice. :focus-visible (not a
   blanket :focus) so mouse/touch clicks don't show a ring, only keyboard
   navigation does. */
:focus-visible{
  outline: 2px solid var(--azure);
  outline-offset: 2px;
  border-radius: 4px;
}
/* Sheen (see [data-sheen] below) is otherwise :hover-only, which a keyboard
   user tabbing to the same link/medallion would never see — added parity so
   focus gets the same feedback hover does. */
[data-sheen]:focus-visible::after{ opacity: 1; }

.skip-link{
  position: absolute;
  left: -9999px;
  top: 0;
  background: var(--navy);
  color: var(--white);
  padding: var(--space-1) var(--space-2);
  z-index: calc(var(--z-intro) + 1); /* above even the intro overlay, so it's reachable immediately on load */
  border-radius: 0 0 var(--radius-panel) 0;
}
.skip-link:focus{
  left: 0;
}

/* Wraps all real page content (nav + main) — the counter-scale "zoom into
   the site" effect (Revision 10) animates THIS, not <body> (which also
   contains #intro itself, unaffected since it sits above at a higher
   z-index and this transform doesn't touch it). */
/* `isolation: isolate` makes this a stacking context UNCONDITIONALLY. Without
   it, whether #page is one depends on whether the intro ran: intro.js tweens
   `scale` to 1 but never clears the property, so a first visit leaves
   `transform: matrix(1,0,0,1,0,0)` — an identity transform, but still a
   stacking context — while the sessionStorage fast path leaves `transform:
   none` and no context at all. Measured both; they really do differ.
   Nothing renders differently today (verified: 0 of 14,699,520 px change on a
   full-page diff), because the only root-level competitors — #scroll-progress
   at 101 and .skip-link at 10001 — both outrank everything inside #page (max
   100, #site-nav). But that is arithmetic, not structure: give anything in
   here a z-index above 101 and it would paint above the progress bar on a
   repeat visit and below it on a first visit. One line removes the whole
   class of bug. Safe because nothing inside #page uses mix-blend-mode (the
   only .rush-glow instances live in #intro, outside this wrapper). */
#page{ transform-origin: 50% 50%; isolation: isolate; }

/* ---------- custom cursor (§1) — scrapped for now, see build plan Revision 5 ---------- */
/* The `cursor: none` gating that hid the native pointer is removed — that was
   the actual cause of the "device cursor seeps in" bug: native cursor hidden
   with nothing reliable drawn in its place. Native cursor now behaves
   normally everywhere. Rules below are unused (no #cursor markup exists
   currently) but left in place so re-enabling later is "add the script tag
   and markup back," not a rebuild. */
#cursor{
  position: fixed;
  top:0; left:0;
  width: 0; height: 0;
  pointer-events: none;
  z-index: var(--z-cursor);
  display: none; /* flipped on by cursor.js only after it confirms desktop pointer */
  will-change: transform;
}
/* Position (translate3d) is driven every rAF tick from cursor.js. Shape/scale
   live on these children so a CSS transition can own state changes without
   fighting the per-frame position write on the same `transform` property.
   Three separate elements (not one shape-shifting div) since the retro arrow
   (fill+stroke, needs real SVG) and the hover ring/text caret (flat CSS
   shapes) aren't expressible as the same element — only one is ever visible
   at a time, gated by [data-state] below. */
.cursor-mark{
  position: absolute;
  top: 0; left: 0;
  width: 26px; height: 32px;
  transform: scale(1);
  transform-origin: 0 0;
  transition: transform var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard);
}
.cursor-ring, .cursor-caret{
  position: absolute;
  top: 0; left: 0;
  opacity: 0;
  transform: scale(1);
  transform-origin: 0 0;
  transition: transform var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard);
}
.cursor-ring{
  width: 36px; height: 36px;
  border-radius: 50%;
  border: 3px solid var(--navy);
  box-sizing: border-box;
}
.cursor-caret{
  width: 2px; height: 24px;
  background: var(--navy);
}
#cursor[data-state="hover"] .cursor-mark,
#cursor[data-state="hover-glass"] .cursor-mark,
#cursor[data-state="text"] .cursor-mark{ opacity: 0; }
#cursor[data-state="hover"] .cursor-ring,
#cursor[data-state="hover-glass"] .cursor-ring{ opacity: 1; transform: scale(1.6); }
#cursor[data-state="hover-glass"] .cursor-ring{ border-color: var(--cursor-hue, var(--navy)); }
#cursor[data-state="text"] .cursor-caret{ opacity: 1; }
#cursor[data-state="press"] .cursor-mark{ transform: scale(.85); transition-duration: .08s; }
#cursor[data-reduced="true"] .cursor-mark,
#cursor[data-reduced="true"] .cursor-ring,
#cursor[data-reduced="true"] .cursor-caret{ transition: none; }

/* trailing dots (§1) — pooled elements positioned/faded from cursor.js */
#cursor-trail{
  position: fixed; inset: 0;
  pointer-events: none;
  z-index: var(--z-cursor-trail);
}
.trail-dot{
  position: absolute;
  top:0; left:0;
  width: 6px; height: 6px;
  border-radius: 50%;
  opacity: .25;
  will-change: transform, opacity;
}

/* ---------- reduced motion (§2, §7) ---------- */
/* No blanket "* { animation:none }" — each module defines its own reduced-motion
   collapse per §2/§7 rather than a global override, since Settle/Bloom degrade to
   an opacity fade (still animated) rather than a hard stop. */

/* ---------- shared glass container utility (the "sandwich") ---------- */
/* One frosted-glass treatment, reused on nav strip, service cards, work
   tiles, and CTA. Three real layers, not a tint-over-image trick:
     1. background-color below — solid --surface-matte, always there.
     2. ::before — the glass image, at --glass-picture-opacity (low by
        default: texture, not a picture — see the token comment above).
     3. (space reserved for an optional grain/texture layer later, per-
        instance, if the image alone ever reads too flat — not built until
        something actually needs it.)
   Feature-moment elements (service-card accents, CTA bloom) override
   --glass-picture-opacity higher, locally, to be a deliberate picture
   instead of ambient texture. */
.glass{
  position: relative;
  overflow: hidden;
  border-radius: var(--radius-panel);
  background-color: var(--surface-matte); /* layer 1 */
}
.glass::before{ /* layer 2 */
  content: "";
  position: absolute; inset: 0;
  background-image: var(--glass-image), var(--glass-fallback-image);
  background-repeat: no-repeat, no-repeat;
  background-size: 1400px 1400px, cover;
  background-position: var(--glass-position, center), center;
  opacity: var(--glass-picture-opacity);
  /* NO backdrop-filter here — see the note on #nav-strip::before. Layer 1 of
     this sandwich is an opaque background-color, so there is never a backdrop
     to frost. */
}
/* Any content placed inside a .glass element MUST be wrapped in a real child
   element (a <span>, not bare text) to be visible — this only lifts actual
   elements above ::before's layer via CSS stacking order, and bare text
   nodes can't be targeted by a selector at all. This exact mistake already
   broke the CTA buttons once (bare text sat *under* the glass layer, reading
   as blank) — fixed by wrapping labels in <span>, see index.html. */
.glass > *{ position: relative; z-index: 1; }

/* ---------- intro overlay (§2) ---------- */
/* Dark navy, not white — a lit stained-glass window glows against a dark
   surround in reality (think cathedral interior), and it gives the backdrop's
   colors somewhere to pop against instead of washing out on white.
   Bloom's tear (Revision 10) is a plain `clip-path: circle(Rpx at 50% 50%)`
   applied directly to THIS element by intro.js, grown via GSAP's native
   clip-path tweening — not a hand-computed path string on a child element.
   Two earlier versions had real bugs this fixes structurally, not by
   tuning: (1) an SVG <mask> whose content lived unrendered, which GSAP
   couldn't reliably animate; (2) a keyhole silhouette (circle+trapezoid)
   that isn't symmetric around its own anchor point, so it looked
   off-center once scaled up regardless of how correctly the anchor itself
   was computed. A plain circle has neither problem — its center IS its
   anchor, at any scale. Clipping THIS element (not just #intro-backdrop)
   also means the growing circle now reveals the real page directly, not
   this element's own navy background first — literal entering, not a
   tear-then-separate-fade. */
#intro{
  position: fixed; inset: 0;
  background: var(--surface-dark);
  z-index: var(--z-intro);
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}
#intro[data-state="done"]{ display: none; }
/* No-JS fallback. #intro is fixed/inset:0/opaque and the ONLY thing that ever
   uncovers it is js/intro.js setting data-state="done" — so with scripting off
   the homepage was a blank navy screen with no content and no way out
   (reproduced in WebKit: elementFromPoint at viewport centre returned
   #intro-backdrop, forever). Every other progressive-enhancement contract on
   this site degrades to complete content (404.js's static headline, the
   project panels shipping open); this overlay was the one that degraded to
   nothing. Specificity is load-bearing: `html:not(.js) #intro` is (1,1,1),
   which must outrank the plain `#intro` rule above AND must not be reachable
   once .js is present, or it would fight [data-state="done"] at (1,1,0).
   Gated on the same `.js` class every page sets inline in <head>. */
html:not(.js) #intro{ display: none; }

#intro-skip{
  position: absolute;
  right: var(--space-3);
  bottom: var(--space-3);
  background: none;
  border: none;
  color: var(--text-on-dark);
  opacity: 0;
  font-family: var(--font-body);
  font-size: var(--text-sm);
  padding: var(--space-1) var(--space-2);
  transition: opacity var(--duration-slow) var(--ease-standard);
}
/* Was .4 — white at 40% over the intro's navy computes to 3.6:1, under WCAG
   AA's 4.5:1 for 14px text, and the real backdrop is the bright multi-color
   mandala rather than flat navy, so actual contrast is lower still and varies
   per pixel (measured: the control is not locatable in a 500ms frame).
   .65 computes to 6.43:1 on the navy ground and still reads as quiet chrome —
   SPEC §2 asks for a "low-contrast" Skip, which this stays; low-contrast was
   never meant to mean below the legibility floor. */
#intro-skip.in{ opacity: .65; }
#intro-skip:hover, #intro-skip:focus-visible{ opacity: 1; }

/* Backdrop: the raw glass material, fades in ambiently during Blank Stage
   (State 1) rather than popping in at Trigger, sitting on top of #intro's
   navy — a static ambient layer now. It no longer carries the clip-path
   itself (that moved to #intro, see above) — it just needs to be visually
   present so there's glass to look at before the tear starts, and it gets
   clipped away automatically as part of #intro's own reveal since it's a
   normal child element. Full-screen for this one-time moment (unlike the
   resting page's restrained negative space, §0) — sized past 100vw/vh
   (130vmax) rather than exactly to it, so the circular mandala's white
   corner padding gets pushed off-screen. Revision 14: centered via margin,
   not `transform: translate()` — same fix as #intro-glow/.cta-glow (Gotcha
   #2): GSAP now owns `transform` here too (js/intro.js scales this in sync
   with the hole, see runBloom), so a competing CSS transform on the same
   property is the wrong place to center it. */
#intro-backdrop{
  position: fixed;
  top: 50%; left: 50%;
  width: 130vmax;
  height: 130vmax;
  margin: -65vmax 0 0 -65vmax;
  background-color: var(--navy); /* last-resort fallback if both images fail */
  background-image: var(--glass-image), var(--glass-fallback-image);
  background-repeat: no-repeat, no-repeat;
  /* ponytail: 101.5% + position 0% is a tiny trial nudge (~half the slack of
     the first attempt, which overshot) to pull the art's center down toward
     the glow dot's fixed screen-center — both measured independently
     correct, this just papers over their blended-overlap look. transform-
     origin below MUST track this: js/intro.js scales this element in Phase
     2, and scale-from-box-center would drift the art away from the dot as
     it grows if the origin doesn't match where this shifted it to. Revert
     to 100%/center + origin 50% 50% if it doesn't hold up. */
  background-size: 101.5% 101.5%, cover;
  background-position: center 0%, center;
  transform-origin: 50% 50.75%;
  border-radius: 50%;
  opacity: 0;
}

/* Glow — light rushing outward at the moment the hole ignites, ahead of the
   fast zoom-through (Revision 11: physically, an opening aperture's edge
   catches and reflects light, and a shaft of light through a small opening
   is its own visible thing — separate from the "camera moving through"
   zoom, not the same effect). Revision 12: three staggered layers instead
   of one — same "rushing toward camera" read as a competitor's staggered
   concentric rings (buckssauce.com), but built from light (blend-mode
   screen, additive) instead of opaque solid-color discs, since flat rings
   would fight the stained-glass/light-through-glass metaphor. `mix-blend-
   mode: screen` is what makes them additive: overlapping glows brighten
   each other rather than the later one just painting over the earlier one,
   which is what actually sells "light," not "circles." Each layer tints
   toward the next accent color out from white (white → azure → teal),
   echoing the palette order used elsewhere (e.g. the word-cycle gradient).
   Centered via margin, not a percentage `transform: translate()` — same fix
   as .cta-glow/.logo-burst (Revision 11): GSAP owns `transform` for the
   scale tween, so a competing CSS transform on the same property is the
   wrong place to center this. */
.rush-glow{
  position: fixed;
  top: 50%; left: 50%;
  width: 40px; height: 40px;
  margin: -20px 0 0 -20px;
  border-radius: 50%;
  opacity: 0;
  pointer-events: none;
  z-index: calc(var(--z-intro) + 1);
  mix-blend-mode: screen;
}
#intro-glow{   background: radial-gradient(circle, var(--glow-white) 0%, var(--glow-white-out) 70%); }
#intro-glow-2{ background: radial-gradient(circle, var(--glow-azure) 0%, var(--glow-azure-out) 70%); }
#intro-glow-3{ background: radial-gradient(circle, var(--glow-teal) 0%, var(--glow-teal-out) 70%); }


/* ---- reveal-gated hero content (§2 State 7) ---- */
/* Default state is visible — intro.js only hides these via inline GSAP styles
   right before it actually plays the sequence, so the sessionStorage fast path
   (State 0 Guard) never has to un-hide anything and can't flash. */
.reveal-item{ opacity: 1; }

/* ---------- nav (Phase 3 container + Phase 5 strip) ---------- */
/* Two independent layers sharing the same box: #nav-strip is decorative-only
   background (pointer-events:none — never intercepts clicks/hover), .nav-content
   is the real, fully interactive navigation sitting above it. They cannot
   interfere with each other by construction. Simplified from 6 wedge tiles to
   one rectangle per build plan Revision 2 — the "nav is the assembled logo"
   narrative now lives in the intro itself, and under frost blur the two looked
   nearly identical anyway, so one rectangle removes real layout risk for free. */
#site-nav{
  position: sticky; top: 0;
  z-index: var(--z-nav);
  height: var(--nav-h);
}

/* Fixed in its frame — real glass doesn't tilt when you walk up to it, so no
   pointer-driven transform here (removed the earlier parallax-tilt idea; it
   fought the "this is rigid glass" premise instead of supporting it). Same
   sandwich as .glass above: solid navy on the element itself, the image at
   low opacity on ::before — texture, not a picture, since this is ambient
   chrome seen on every scroll, not a feature moment. */
#nav-strip{
  position: absolute;
  inset: 0;
  z-index: var(--z-nav-bg);
  pointer-events: none;
  background-color: var(--surface-matte); /* layer 1 */
}
#nav-strip::before{ /* layer 2 */
  content: "";
  position: absolute; inset: 0;
  background-image: var(--glass-image), var(--glass-fallback-image);
  background-repeat: no-repeat, no-repeat;
  background-size: 1400px 1400px, cover;
  background-position: center -520px, center;
  opacity: var(--glass-picture-opacity);
  /* This and .glass::before both carried `backdrop-filter: blur(6px)
     saturate(1.1)` and it was doing NOTHING, on every glass panel on the site
     (13 on index.html, 9 per service page). backdrop-filter frosts whatever is
     painted behind the element — but layer 1 of the sandwich is this element's
     own `background-color: var(--surface-matte)`, which computes to a fully
     opaque rgb(5,20,54). Blurring a uniform opaque field returns the same
     uniform field, so there was no visible output to buy.
     Not reasoned, measured: full-page renders with these declarations on vs.
     off, across all five pages, differed by 0 pixels — including the nav strip
     with real page content scrolled underneath it, which is the one place a
     backdrop could plausibly have existed. Removed rather than "fixed": making
     it work would mean giving --surface-matte an alpha, and the sandwich is
     deliberately built on a solid base (see .glass above) so the label
     contrast on every pill stays predictable. If translucent glass is ever
     wanted, that's a deliberate change to layer 1, not a filter to re-add. */
}

.nav-content{
  position: relative;
  z-index: var(--z-nav-content);
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  padding: 0 var(--space-4);
}
#nav-logo img{ width: 40px; height: 40px; }
#nav-links{
  list-style: none;
  display: flex;
  gap: var(--space-3);
  margin: 0; padding: 0;
  font-size: var(--text-base);
  font-weight: 600;
}
/* Padding, not just line-height, gives the tap target real height — measured
   only 18px tall without it (text box alone), well under the ~44px WCAG
   2.5.5 touch-target guidance. Visual text size is unchanged; this only
   grows the hit area. */
#nav-links a{ color: var(--text-on-dark); padding: var(--space-2) 0; display: inline-block; } /* nav-strip is a dark navy matte, not white frost — light text for contrast */
/* No hover state existed on the primary nav at all. The only pointer feedback
   was the magnetic pull from js/magnetic.js, which returned early under
   prefers-reduced-motion — so a reduced-motion visitor got zero hover response
   on the site's main navigation. (magnetic.js is unwired entirely now, GOTCHAS
   #30, so this underline is the only nav hover feedback there is — it went
   from a fallback to the whole mechanism.) An underline (not a color or opacity shift)
   because it's a second signal that survives greyscale and colorblindness per
   the never-color-alone rule, and because it costs no contrast: white stays
   white. Instant, not transitioned — this is the fallback for people who asked
   for less motion, so it shouldn't animate. Focus gets the same treatment for
   parity, same reasoning as [data-sheen]:focus-visible above. */
#nav-links a:hover,
#nav-links a:focus-visible{
  text-decoration: underline;
  text-decoration-thickness: 2px;
  text-underline-offset: 6px;
}

/* The one deliberate breakpoint on an otherwise media-query-free site —
   every other layout here reflows fluidly via grid auto-fit/flex-wrap/
   clamp() and genuinely doesn't need one, but this specific row (a fixed
   4-item list next to a fixed-size logo) has no fluid escape hatch of its
   own. Measured, not guessed: a real 22px horizontal overflow at 320px
   (iPhone SE-class widths) before this rule existed. 360px gives a little
   margin above that measured failure point. */
@media (max-width: 360px){
  .nav-content{ padding: 0 var(--space-2); }
  /* var(--text-sm), not a raw 13px. The literal was the only off-scale
     font-size left on the site and it was 0.6px from --text-sm (13.6px) — the
     same sub-pixel gap GOTCHAS calls out as rendering identically. Re-measured
     the overflow this rule exists to fix before touching it: 13.6px gives
     0px page overflow and 0px #nav-links overflow at BOTH 320px and 360px,
     so the raw value was never load-bearing. Now every font-size in the file
     comes from the scale, which is what tools/check-drift.py asserts. */
  #nav-links{ gap: var(--space-2); font-size: var(--text-sm); }
}

/* Logo click replay burst (js/easter-eggs.js, Tier D #11) — centered via
   negative margin on JS-computed left/top, same fix as #intro-glow/.cta-glow
   (Revision 11): never fight GSAP for the `transform` property. */
.logo-burst{
  position: fixed;
  width: 48px; height: 48px;
  margin: -24px 0 0 -24px;
  border-radius: 50%;
  background: radial-gradient(circle, var(--glow-white) 0%, var(--glow-white-out) 70%);
  pointer-events: none;
  z-index: calc(var(--z-nav) + 1);
}

/* ---------- hero (§4) ---------- */
/* Deliberately restrained (Revision 9) — a corner accent + animated cursor
   line were tried here and both read as clutter/overlap rather than
   presence (see build plan). The intro's keyhole reveal is the site's one
   big "wow" moment; the resting hero's job is confident clarity, not
   competing decoration — clean typography and real space doing the work. */
/* Now carries .section-wrap in the markup instead of its own max-width:900px
   with no auto-margin. That old geometry left the hero flush to the page edge
   (40px) while every section below it sat in a centred 1100px column, so above
   a 1100px viewport the site had no single left edge and the break widened
   with the screen — measured at 170px (1440), 410px (1920), 730px (2560).
   The hero is the one block that most needs to sit on the page's leading line,
   and it was the only one off it. Reusing .section-wrap rather than
   hand-matching numbers means the two can't drift apart again.
   Only the top padding differs from the shared container — the extra breathing
   room above the headline is deliberate, so it's the single override. */
#hero{
  padding-top: calc(var(--space-6) * 1.3);
}
#hero h1{ font-size: var(--text-h1); letter-spacing: -.015em; text-wrap: balance; }
#hero .subhead{ font-size: var(--text-lg); color: var(--text-muted); max-width: 34ch; }

/* ---- glass-cut headline (hero) ----
   Same mechanism .word-cycle already uses — background-clip:text — pointed at
   the glass image instead of a gradient, so this adds no new technique.
   The image is TILED, not `cover`: a single mandala stretched across a wide,
   short headline shows one flat band per letter and reads as randomly coloured
   type. At tile scale each letterform holds several leaded cells, which is
   what actually reads as glass.
   Two stacked copies rather than one animated fill: the base is dimmed with a
   filter, the overlay is full strength and masked. Masking the overlay is the
   only way to light PART of the type — a filter can't be applied to a region. */
/* line-height MUST stay >= the font's own content height (1.2002em: hhea
   ascent 1946 + descent 512 over 2048upm). The global h1/h2/h3 rule sets 1.1,
   which is FINE for normal text — glyphs just overflow the line box and still
   paint. It is not fine here. With `background-clip: text` the glyph IS the
   background, and background only paints inside the element's box, so anything
   below the box bottom gets no paint and silently disappears.
   At 1.1 the baseline lands 0.9001em down and descenders reach 0.25em past it
   = 1.1501em in a 1.1em box: measured, exactly 3.26px of every `g`, `y`, `p`
   was sliced flat at --text-h1 (64.9px), and 2.08px at --text-h2 on the
   service pages ("Brand Design"). 1.25 clears it with room to spare.
   Do NOT "fix" this with padding-bottom + negative margin-bottom to keep the
   tighter leading: .ht-lit is position:absolute;inset:0, so padding changes
   the two copies' box heights unequally (measured 76.56px vs 81.75px), and
   different boxes mean different background-size sampling — which is the exact
   misalignment the block-vs-inline note below exists to prevent. */
.hero-title{ position: relative; display: block; line-height: 1.25; }
/* Both copies MUST be block, not inline. As an inline span the base's
   background box is the text run (~850px) while the absolutely-positioned lit
   overlay fills the h1 (1020px) — different boxes mean different
   background-size, so the two sampled different parts of the mandala and the
   lit copy didn't line up with the letters underneath it. Blockifying both
   makes their geometry identical by construction. */
.hero-title .ht{
  display: block;
  background-image: var(--glass-image), var(--glass-fallback-image);
  background-repeat: no-repeat, no-repeat;
  /* Sized off the box WIDTH, not height, and not `cover`. The mandala is a
     circle inside a square, so its corners are white padding — the same
     padding #intro-backdrop already sizes past (130vmax) for this exact
     reason. Tiling it, or `cover` on a wide short headline, drags those white
     corners into the letterforms: measured, the lit region rendered nearly
     white and "this" all but disappeared.
     At 130% of the box width the circle more than spans the text and only its
     middle band shows — all glass, no corners — and because it's width-driven
     it stays correct as the headline reflows to two lines on a phone.
     Verified at 390px and 1440px. */
  background-size: 130% auto, cover;
  /* 25%, not centre. Dead-centre runs the band straight through the mandala's
     pale central gem, which rendered the letterforms grey and washed out
     regardless of how the dim filter was tuned — the filter was never the
     problem, the crop was. A quarter of the way down sits in the dense
     coloured ring instead. Swept 15/25/35/50/72% in a real render to pick it. */
  background-position: center 25%, center;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  -webkit-text-fill-color: transparent;
}
/* Resting state. Dimmed, not blacked out: with JS unavailable this IS the
   headline, so it has to look deliberate on its own rather than like an
   effect that failed to start. Dark glass on white is high contrast either way. */
.hero-title .ht-base{ filter: brightness(.42) saturate(1.45); }
.hero-title .ht-lit{
  position: absolute;
  inset: 0;
  pointer-events: none;
  /* Parked off-screen until the pointer arrives, so nothing is lit at rest. */
  -webkit-mask-image: radial-gradient(circle var(--lit-r, 0px) at var(--lit-x, -999px) var(--lit-y, -999px), #000 0%, rgba(0,0,0,.72) 42%, transparent 74%);
  mask-image: radial-gradient(circle var(--lit-r, 0px) at var(--lit-x, -999px) var(--lit-y, -999px), #000 0%, rgba(0,0,0,.72) 42%, transparent 74%);
}
/* Reduced motion: no chasing light. The headline simply reads as fully lit —
   a static state, not a disabled one. */
@media (prefers-reduced-motion: reduce){
  .hero-title .ht-base{ filter: none; }
  .hero-title .ht-lit{ display: none; }
}



/* ---------- shared section container ---------- */
/* One consistent wrapper for every section below — same max-width, same
   vertical rhythm — rather than each section inventing its own padding.
   Requested explicitly: consistency, easy global changes. */
.section-wrap{
  max-width: var(--section-max-w);
  margin: 0 auto;
  padding: var(--space-6) var(--space-4);
}
.section-wrap h2{ font-size: var(--text-h2); }

/* #site-nav is sticky and 88px tall, so anything reached by an anchor jump
   lands with its top 88px under the nav. This was always true and never
   mattered, because every anchor on the site pointed within the page the
   visitor was already scrolled into. The service pages changed that: their nav
   links are cross-page (/index.html#services), so the browser now jumps
   straight to a section on a cold load, where the clipping is obvious. Applies
   to project rows too — see .project below. */
main > section{ scroll-margin-top: calc(var(--nav-h) + var(--space-3)); }

/* Dividers styled like lead came — the metal strip that actually holds real
   stained glass panels together — rather than a full bordered
   container/outline (would read boxy against §0's "gallery, generous
   negative space" foundation) or a plain hairline (too thin to read as
   material, just a rule). Revision 8: a perfectly straight CSS border read
   as computer-drawn, undercutting the "handcrafted" idea, so the line is now
   a tiled SVG with a slight organic wave (still two flat colors, no
   gradient), plus a small circular glass "gem" centered on it — the same
   glass sandwich as everything else, just tiny — echoing the process
   medallions rather than inventing a new decoration. border-top itself
   stays a plain 1px transparent rule purely to reserve consistent layout
   spacing; the visible wave + gem are ::before/::after overlays anchored to
   that same position.
   The CTA section used to be EXCLUDED here, on the reasoning that its
   white-to-navy background change already does the divider's job. True as far
   as it goes — but it made that one seam the only joint on the site with no
   came in it, which is backwards for the metaphor: in real leaded glass the
   came is exactly where two different panes meet, so the strongest colour
   transition on the page is the last place it should be missing. It also gets
   the two-tone stroke for free, and this is the one divider where BOTH halves
   are visible at once: the dark stroke reads against the white above, the
   light offset reads against the navy below (the same inversion noted on
   .cta-link's edge). It is now included. This required dropping
   `overflow: hidden` from .cta-section — see the note there. */
main > section:not(:first-child){
  position: relative;
  border-top: 1px solid transparent;
}
main > section:not(:first-child)::before{
  content: "";
  position: absolute;
  top: -3px; left: 0; right: 0;
  height: 6px;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='6' viewBox='0 0 60 6'%3E%3Cpath d='M0,3 Q15,1.2 30,3 T60,3' stroke='%23051436' stroke-width='2' fill='none'/%3E%3Cpath d='M0,4.3 Q15,2.5 30,4.3 T60,4.3' stroke='%23ffffff' stroke-opacity='0.5' stroke-width='1' fill='none'/%3E%3C/svg%3E");
  background-repeat: repeat-x;
  background-size: 60px 6px;
  pointer-events: none;
}
main > section:not(:first-child)::after{
  content: "";
  position: absolute;
  top: -8px; left: 50%;
  width: 16px; height: 16px;
  transform: translateX(-50%);
  border-radius: 50%;
  background-color: var(--surface-matte);
  background-image: var(--glass-image), var(--glass-fallback-image);
  background-repeat: no-repeat, no-repeat;
  background-size: 1400px 1400px, cover;
  background-position: center, center;
  opacity: .8;
  box-shadow: 0 0 0 3px var(--surface-page);
  pointer-events: none;
}
/* Divider-gem pulse (js/scroll-accents.js, Tier C #7) — a plain CSS
   @keyframes animation, toggled by a class the JS adds once the section
   scrolls into view, not a GSAP tween: GSAP's core CSSPlugin can't animate
   ::before/::after directly (confirmed in a real browser run — it needs the
   separate, unvendored CSSRulePlugin, which also patches the shared
   stylesheet rule rather than one instance). Every keyframe restates the
   base rule's `translateX(-50%)` since a CSS animation replaces the whole
   `transform` value for its duration, not just the parts it changes. */
@keyframes gem-pulse{
  0%, 100%{ opacity: .8; transform: translateX(-50%) scale(1); }
  40%{ opacity: 1; transform: translateX(-50%) scale(1.7); }
}
main > section.gem-pulse::after{
  animation: gem-pulse .7s ease-out;
}

/* ---------- "What is good design to you?" (§5) ---------- */
#what-is-design{ text-align: center; }
#what-is-design h2{ font-weight: 600; line-height: 1.3; }
/* Fixed gradient text fill, not per-word solid-color swaps — hard color cuts
   between saturated hues read garish; light through real glass shifts
   smoothly, it doesn't cut. word-cycle.js only swaps the text content now,
   never the color. Dark->light->dark (not a one-directional dark->light
   ramp) — light catching glass brightens toward the middle and falls off at
   the edges, it doesn't ramp one direction across a whole word. */
.word-cycle{
  display: inline-block;
  font-weight: 700;
  background: linear-gradient(90deg, var(--navy), var(--azure), var(--navy));
  background-clip: text;
  -webkit-background-clip: text;
  color: transparent;
  -webkit-text-fill-color: transparent;
}
/* Per-letter spans (js/word-cycle.js, Tier C #9) — background-image is
   forced to inherit the exact same gradient function from .word-cycle;
   background-size/-position are set inline per span (word's full pixel
   width, negative-offset by the span's own position) so every letter shows
   its own slice of ONE continuous ramp instead of an independent copy of
   the whole gradient — keeps Revision 8's "smooth, not cut" rule intact
   while letting each letter animate in individually. */
.word-cycle .wc-ch{
  display: inline-block;
  background-image: inherit;
  background-clip: text;
  -webkit-background-clip: text;
  color: transparent;
  -webkit-text-fill-color: transparent;
}

/* ---------- Services cards (§5) ---------- */
.service-cards{
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: var(--space-3);
  margin-top: var(--space-4);
}
/* Now an <a> to its service page (work/*.html), not an <article>.
   `display:block` is doing real work here, not tidying: an <a> is inline by
   default, and `overflow:hidden` — which is what clips .card-accent to the
   rounded corners — does not apply to inline boxes, so an inline card would
   paint its accent as an unclipped rectangle over the radius. That is exactly
   the bug .cta-link shipped with (GOTCHAS #8). These are grid items, so they'd
   be blockified by their parent anyway; the declaration is here so the class
   doesn't depend on its container, which is #8's actual lesson.
   `color` is set because an <a> otherwise inherits --text-link, which would
   recolor any text in the card not covered by a more specific rule. */
.service-card{
  display: block;
  color: var(--text-body);
  position: relative;
  overflow: hidden;
  border-radius: var(--radius-panel);
  padding: 130px var(--space-3) var(--space-3);
  background: var(--surface-page);
  border: 1px solid var(--hairline);
  transition: border-color var(--duration-base) var(--ease-standard);
}
/* The card looked identical before it became clickable, so it needed an
   affordance. An explicit "View work" line plus an arrow is a second signal
   beyond color (survives greyscale, per the never-color-alone rule) and it
   names the destination instead of relying on the card being guessed at.
   (It was also deliberately NOT given [data-magnetic], back when that existed,
   to avoid adding three more elements to magnetic.js's rAF loop. magnetic.js
   is unwired now — GOTCHAS #31 — so that constraint is gone; the reason not to
   add a pull here is simply that the card already has two clearer signals.) */
.service-card-cta{
  display: inline-block;
  margin-top: var(--space-2);
  color: var(--text-link);
  font-size: var(--text-base);
  font-weight: 600;
}
.service-card-arrow{
  display: inline-block;
  transition: transform var(--duration-base) var(--ease-standard);
}
.service-card:hover .service-card-arrow,
.service-card:focus-visible .service-card-arrow{ transform: translateX(4px); }
.service-card:hover{ border-color: var(--hairline-strong); }
/* The sheen only fires when the pointer is over the accent strip itself,
   because js/sheen.js binds pointermove to the [data-sheen] element (see
   sheen.js) — hovering the card's text half gave no response at all now that
   the whole card is the target. This lights the sweep from anywhere on the
   card; it just sits centred (--sheen-x's 50% default) rather than tracking,
   until the pointer actually reaches the strip.
   NOT solved by moving [data-sheen] to .service-card: the [data-sheen] comment
   below explains why a full-card sheen would paint over the body copy. */
.service-card:hover .card-accent::after{ opacity: 1; }
/* bounded accent — a top strip, not a full-tile background, per §5's
   confirmed decision: keeps body text on plain white, keeps contrast free.
   Feature moment (§7 analysis): a deliberate picture, not ambient texture —
   higher --glass-picture-opacity than the .glass default. */
.service-card .card-accent{
  position: absolute;
  top: 0; left: 0; right: 0;
  height: 110px;
  border-radius: 0;
  --glass-position: center 20%;
  --glass-picture-opacity: var(--glass-opacity-feature);
}
.service-card h3{ font-size: var(--text-h3); margin-bottom: var(--space-1); }
.service-card p{ color: var(--text-muted); font-size: var(--text-base); margin: 0; }

/* ---------- Process strip (§5) — static panes, not a scroll-pinned
   carousel; see build plan Revision 7 for why that was dropped (a generic
   "modern site" technique competing with the intro's one signature moment,
   plus real fragility on mobile touch-scroll). Content is always visible;
   each medallion just brightens in via the shared reveal.js mechanism as it
   scrolls into view — no pin, no scroll-hijack, no separate JS module. ---- */
#process{ background: var(--surface-page); }
/* Was a one-off margin-bottom:var(--space-5) (64px) on the heading itself —
   every other section (Services, Work) gets its heading-to-content gap from
   margin-top:var(--space-4) (40px) on the content block instead, using the
   heading's own default margin unchanged. Normalized to the same mechanism
   and the same value, not just a similar-looking number. */
.process-row{
  position: relative;
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3); /* was --space-4 (40px) — every other item-row (.service-cards, .samples, .cta-links) uses --space-3 (24px); no stated reason this one needed more */
  margin-top: var(--space-4);
}
/* the lead-came "thread" connecting the medallions — static, no traveling
   marker (that needed scroll-position tracking the pin used to provide).
   Uses the SAME tiled wave as the section dividers rather than a straight rule.
   The site had two different treatments for what is conceptually one thing —
   lead came holding glass — and the straight version quietly contradicted the
   reason the dividers wave at all (Revision 8: a perfectly straight CSS border
   read as computer-drawn, undercutting "handcrafted"). The thread is the most
   literal piece of came on the page, so if anything earns the wave it does.
   It already shared the dividers' two-tone idea (dark stroke + faint light
   offset); that now comes from the same SVG instead of a border + box-shadow.
   `top` is 29px, not 32px, so the wave's centre line (y=3 in a 6px tile) lands
   on the medallion centre at y=32 — the old 2px rule sat 1px low. */
.process-thread{
  position: absolute;
  top: 29px; left: 0; right: 0;
  height: 6px;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='6' viewBox='0 0 60 6'%3E%3Cpath d='M0,3 Q15,1.2 30,3 T60,3' stroke='%23051436' stroke-width='2' fill='none'/%3E%3Cpath d='M0,4.3 Q15,2.5 30,4.3 T60,4.3' stroke='%23ffffff' stroke-opacity='0.5' stroke-width='1' fill='none'/%3E%3C/svg%3E");
  background-repeat: repeat-x;
  background-size: 60px 6px;
  z-index: 0;
}
/* The thread is a horizontal bar pinned at y=32px, which only describes
   anything while .process-row is genuinely ONE row. Once it wraps it stayed
   put and became a stray full-width rule crossing the first medallion while
   the other three sat 219/437/656px below it, unconnected — the lead-came
   metaphor inverted into a line holding nothing together. Measured, not
   eyeballed (WebKit + Chromium agree), stepping the viewport 1px at a time:
     >= 952px  one row            -> horizontal thread is correct
     504-951px two rows (2x2)     -> no single straight line connects four
                                     medallions at four (x,y) positions
     <= 503px  one column, all four medallions share the same centre x
   A vertical thread WAS built for that last case and then removed: the
   medallions do line up, but each step's copy sits centred between them, so a
   centre line runs straight through the body text (verified in a real render).
   Text stays legible — .process-step is z-index 1 over the thread's 0 — but a
   rule crossing every paragraph costs more reading comfort than a missing
   decoration does. A centred timeline with copy between the nodes cannot carry
   a centre line; the fix would be left-aligning the whole mobile step stack
   like a conventional vertical timeline, which is a layout decision, not a
   bug fix. Logged in GOTCHAS instead of half-built here.
   So: the thread shows only when the row is genuinely a row.
   GOTCHAS #7's bar is "don't add a media query without a measured reason" —
   this is the measurement, and it needs exactly one. */
@media (max-width: 951px){
  .process-thread{ display: none; }
}
.process-step{
  position: relative;
  z-index: 1;
  flex: 1 1 200px;
  text-align: center;
}
/* A RING, not a filled disc, with the step numeral living inside it — the
   numeral used to be a sibling stacked underneath. Three reasons this is
   structural rather than decorative:
     1. It gives the medallion a job. As a filled disc it was ornament carrying
        no meaning; as a ring around its own number it IS the step marker.
     2. At 64px the mandala's detail is illegible anyway, so a filled disc
        spends its whole area on texture nobody can resolve. A ring shows just
        enough to read as glass and hands the middle to something legible.
     3. It drops each step from four stacked elements to three, tightening the
        group (proximity), and the opaque centre means .process-thread now
        passes BEHIND a solid disc instead of touching a circle's edge — beads
        on a wire, which is what the lead-came metaphor wanted.
   Paint order works out without any z-index bookkeeping: background-color,
   then ::before (texture), then ::after (this disc), then .glass > * children
   which are already lifted to z-index 1 by the shared glass rule. */
.process-medallion{
  width: 64px; height: 64px;
  margin: 0 auto var(--space-2);
  border-radius: 50%;
  --glass-picture-opacity: var(--glass-opacity-feature);
  --glass-position: center; /* overridden per-step below — four identical muddy circles was the actual bug, not "we need more images" */
  display: flex;
  align-items: center;
  justify-content: center;
}
.process-medallion::after{
  content: "";
  position: absolute;
  inset: 11px; /* 64 - 22 = 42px inner disc; the 14px numeral sits in it with room */
  border-radius: 50%;
  background: var(--surface-page);
}
.process-step:nth-child(2) .process-medallion{ --glass-position: 72% 32%; }
.process-step:nth-child(3) .process-medallion{ --glass-position: 30% 72%; }
.process-step:nth-child(4) .process-medallion{ --glass-position: 82% 80%; }
/* Was var(--azure) — measured contrast (browser audit) is only 3.02:1 at this
   size/weight against white, failing WCAG AA's 4.5:1 for normal text. §0
   already flags azure as "fails WCAG on white at body size"; this rule just
   hadn't been brought in line with its own stated constraint. --royal is
   already the site's link/emphasis color at body size (5.96:1, passes) —
   reused rather than introducing a new accent just for this label. */
/* Now centred inside .process-medallion rather than stacked beneath it, so
   `letter-spacing` loses its trailing-space centring problem too. --royal on
   the white inner disc measures 5.96:1 — unchanged by the move, since the disc
   is --surface-page, the same white it sat on before. */
.process-num{ color: var(--royal); font-weight: 700; font-size: var(--text-sm); letter-spacing: .08em; text-indent: .08em; /* offsets the trailing letter-space so the numeral optically centres */ }
.process-step h3{ font-size: var(--text-h3); margin: var(--space-1) 0; }
.process-step p{ color: var(--text-muted); margin: 0; }

/* ---------- Work / portfolio (§5) — REMOVED, superseded not orphaned ----------
   `.work-tiles`/`.work-tile` styled the homepage #work section: three 4:3 dark
   glass tiles reading "In progress". That section is gone (SPEC §11) — the
   portfolio now lives on the three service pages, reached by clicking a
   service card, so a second portfolio entry point on the homepage would have
   been two doors to one room.
   Classified before deleting, per policy: superseded, not mid-build. Its only
   markup was the #work section itself (grep-confirmed: no other consumer in
   any HTML, CSS or JS), and the replacement — .sample-frame on the service
   pages — does not reuse it, because a placeholder that reads as a heavy dark
   picture is exactly the failure GOTCHAS logged against these tiles: the
   loudest content on the page being the content that doesn't exist yet.
   The one thing worth keeping was its small-uppercase-label idiom, which now
   lives on .sample-placeholder rather than being lost with the class. */

/* ---------- Contact / CTA finale (§5) ---------- */
/* Real bug found, not just "still too big": .cta-content carries both its
   own class AND .section-wrap, and .section-wrap applies its own
   padding:var(--space-6). This element (.cta-section) was ALSO applying
   padding:var(--space-6) on itself — double-padding, 192px top/bottom
   instead of 96px, which is why every previous size reduction still looked
   oversized. No padding here at all now; .cta-content's own .section-wrap
   padding is the single source, exactly matching how every other section
   applies it exactly once. */
.cta-section{
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  /* NO `overflow: hidden`. It was here defensively, in case the cta-bloom glow
     burst past the section — but the divider above draws its came at top:-3px
     and its gem at top:-8px, and overflow would clip exactly the halves that
     have to sit on the white side of the seam, leaving a half-submerged bead.
     Removed only after measuring that it was clipping nothing: a full-page
     diff of hidden vs visible came back 0 px different, and horizontal
     overflow stayed 0 at 390 / 1440 / 2560 through the entire bloom.
     If a future change does make the burst escape, clip `.cta-content`
     instead — an inner wrapper can be clipped without eating the section's
     own top edge, which is what the divider needs. */
  background: var(--surface-dark);
}
/* Bounded medallion, not a full-bleed picture (Revision 9) — same "bounded
   accent over full-tile" rule §5 already set for service cards, applied
   here too now. Still the largest/most visible glass moment outside the
   intro itself, just contained rather than covering the whole section. */
/* Was 180px. In a squint test of the CTA the read order came out medallion →
   headline → links, i.e. the ACTION was third on the section whose entire job
   is the action: 180px at the site's highest --glass-picture-opacity (0.8),
   plus a scale-and-glow burst, against 58px link pills. Reduced to 120px so it
   crowns the group instead of leading it. The links were made larger in the
   same pass — the fix is the RATIO between them, and shrinking the ornament is
   the cheaper half of it.
   Note the medallion is still the bookend to the intro's bloom (§9), which is
   why it shrank rather than went away. --glass-picture-opacity stays 0.8: this
   is a genuine feature moment, and at 120px it needs the density to still read
   as glass rather than a smudge.
   .cta-glow MUST track this size — it's a separate sibling element sized to
   overlap the medallion exactly, so growing past it reads as the medallion
   bursting. See its rule below; changing one without the other puts the burst
   off-centre. */
/* The glow was misaligned by exactly 96px — .cta-content's own padding-top.
   .cta-glow is position:absolute; top:0, and its containing block was
   .cta-content, so top:0 resolved to that element's PADDING box, above the
   padding, while the medallion begins below it. It was wrong from the start
   and only became visible when the medallion shrank 180px → 120px: at 180px a
   96px offset still left the two overlapping, at 120px it doesn't.
   Fixed structurally rather than by writing `top: 96px`, which would silently
   re-break the moment .section-wrap's padding changes. A wrapper sized to the
   medallion makes the glow's containing block the medallion's own box, so
   inset:0 means "exactly the medallion" by construction and cannot drift.
   The glow stays a SIBLING inside that wrapper — as a child of .cta-medallion
   its own overflow:hidden would clip the burst, which is the original reason
   these are separate elements. */
.cta-medallion-wrap{
  position: relative;
  width: var(--cta-medallion-size);
  height: var(--cta-medallion-size);
  margin: 0 auto var(--space-4);
}
.cta-medallion-wrap .cta-medallion{ width: 100%; height: 100%; margin: 0; }

.cta-medallion{
  width: var(--cta-medallion-size); height: var(--cta-medallion-size);
  margin: 0 auto var(--space-4);
  border-radius: 50%;
  --glass-picture-opacity: var(--glass-opacity-feature);
  /* Was the .glass default (center = 50%), which left the mandala's bright core
     sitting high in the circle with a heavy dark band below it. Nudged down to
     centre the gem — chosen from a rendered sweep, not by eye or by arithmetic.
     Note how small this number has to be: with a 1400px background in a 120px
     window, 1% of position moves the image 12.8px, i.e. more than a tenth of
     the whole circle (see GOTCHAS #9). 0.8% here is ~10px of travel. Lowering
     the value moves the image DOWN, since a smaller percentage aligns a
     higher point of the image with the container. */
  --glass-position: center 49.2%;
}
/* CTA finale re-bloom (js/cta-bloom.js) — a sibling of .cta-medallion, not a
   child, so the glow burst isn't clipped by the medallion's own
   overflow:hidden circle. Sized/positioned to exactly overlap the medallion
   (same width, same top-left origin) so growing past it reads as the medallion
   itself bursting outward — same radial-gradient recipe as #intro-glow,
   replayed in miniature as a bookend on scroll-into-view, not a new device.
   Centered via negative margin, not a percentage `transform: translate()` —
   same fix as #intro-glow (Revision 11): GSAP owns `transform` for the scale
   tween, so a competing CSS transform on the same property is the wrong
   place to center this. */
.cta-glow{
  position: absolute;
  inset: 0; /* == the medallion exactly, via .cta-medallion-wrap. No width/height
               to keep in sync and no margin-centring hack; GSAP still owns
               transform for the scale burst, so GOTCHAS #2 holds. */
  border-radius: 50%;
  background: radial-gradient(circle, var(--glow-white) 0%, var(--glow-white-out) 70%);
  opacity: 0;
  pointer-events: none;
  z-index: 2;
}
.cta-content{ position: relative; z-index: 1; text-align: center; }
/* The CTA ran 612px tall and read as a mostly-empty blue field. It is the one
   section that stacks FOUR separate blocks (medallion, headline, pills,
   footer), so it pays every gap in the rhythm plus .section-wrap's own 96px
   top and bottom — 192px of that 612 was section padding alone, and the
   trailing 96px below the footer was the visible "why is this so tall".
   Everywhere else a section is a heading plus one grid, where 96px is right.
   Measured contributors: padding 192, medallion+margin 160, headline+margin
   93, pills 82, footer+margin 84. Trimmed to 492px (-20%) by stepping four
   values DOWN THE EXISTING SCALE — space-6 to space-5, space-4 to space-3,
   space-5 to space-4 — rather than inventing tighter numbers, so the section
   still sits on the same rhythm as every other one, just at a closer step.
   Deliberately NOT shrunk further by touching --cta-medallion-size: that 120px
   is the result of a squint-test read-order fix (it was 180px and led the
   group instead of crowning it), and the note there is explicit that the fix
   is the RATIO between medallion and pills. Shrinking it again to save 24px
   would re-open a solved problem for the cheapest possible pixels. */
.cta-section .cta-content{ padding-block: var(--space-5); }
.cta-section .cta-medallion-wrap{ margin-bottom: var(--space-3); }
.cta-section .site-footer{ margin-top: var(--space-4); }

/* Pointer-driven sheen sweep (js/sheen.js) — wow-list Tier A #2, spec'd in
   SPEC.md §3 as "one shimmer implementation... reused on nav tiles, service
   cards, and the CTA," never built. Applied to bounded feature elements only
   (card-accent / cta-link / cta-medallion), not whole cards — same "bounded
   accent" rule as everywhere else, and it sidesteps a real stacking risk:
   .service-card's own text isn't wrapped in a z-index:1 layer the way
   .glass's children are, so a full-card sheen would paint over the body
   copy. Kept very translucent — same visual weight as the ambient
   .glass texture (--glass-picture-opacity default 0.2), not a UI-kit glossy
   shine. No z-index needed for correct stacking: ::after paints after
   ::before in the default stacking order, but explicit z-index:1 content
   (.glass > *) still paints above both regardless of source order. */
[data-sheen]{ position: relative; }
[data-sheen]::after{
  content: "";
  position: absolute; inset: 0;
  opacity: 0;
  background: linear-gradient(115deg, transparent 35%, var(--sheen-white) 50%, transparent 65%);
  background-size: 220% 220%;
  background-position: var(--sheen-x, 50%) center;
  transition: opacity var(--duration-base) var(--ease-standard);
  pointer-events: none;
}
[data-sheen]:hover::after{ opacity: 1; }
/* Sized between hero h1 and the standard section h2 — the CTA is the
   conversion climax, not a peer content section, and should read that way
   at a glance, not just via its dramatic background. */
/* text-shadow, not a backing panel — keeps the mandala fully visible behind
   it while guaranteeing contrast regardless of which part of the busy,
   multi-color glass happens to sit behind the text (flat solid-color
   shadows layered for depth, not a gradient). */
.cta-content h2{
  color: var(--text-on-dark);
  font-size: clamp(2rem, 4.5vw, 3rem);
  margin-bottom: var(--space-3);
  text-shadow: var(--shadow-text);
}
.cta-links{
  display: flex;
  justify-content: center;
  gap: var(--space-3);
  flex-wrap: wrap;
}
/* The three pills read as three windows cut from ONE continuous strip of glass
   rather than three separate crops of the same picture. Each shows the slice of
   the image that would sit behind it if a single sheet ran the width of the
   row: pill pitch is 180px wide + 24px gap = 204px, so the offsets step by 204
   and the 24px gaps read as the wall between windows. The group is centred on
   the image's own centre (x=700): span is 3×180 + 2×24 = 588, so the first
   window starts at 700 − 294 = 406.
   Y is deliberately a different band from the medallion's, which sits on the
   mandala's bright core — these take a lower, darker band (chosen from a
   rendered sweep of four candidates) so the strip has its own character AND
   the white labels keep their contrast; the mid-green bands tested noticeably
   worse behind text.
   Uses px, not percentages, because the whole point is a fixed relationship
   between three elements — percentages would resolve against each pill's own
   box and break the continuity (and see GOTCHAS #9 for how violent a
   percentage is at this background size).
   When the row wraps on narrow screens the strip illusion no longer applies;
   each pill just shows its own slice, which is harmless. */
.cta-links .cta-link:nth-child(1){ --glass-position: -406px -1080px; }
.cta-links .cta-link:nth-child(2){ --glass-position: -610px -1080px; }
.cta-links .cta-link:nth-child(3){ --glass-position: -814px -1080px; }

/* Equal width regardless of label length. Left to shrink-wrap they came out
   130 / 175 / 177px — three controls of identical importance rendered at three
   different sizes purely because "Email" is a shorter word than "WhatsApp",
   which reads as accidental hierarchy. Sized to clear the longest current
   label; they still wrap to a column on narrow screens (180px fits the 310px
   content width at 390px). If a label ever gets longer than "Instagram", this
   number needs revisiting — it's the one thing here that doesn't self-adjust. */
.cta-links .cta-link{ min-width: 180px; }
/* Instance crop for the hero button, sibling to the .cta-links nth-child crops
   above — not a ground rule. Chosen by a 196-crop sweep at 2x DPR; read the
   history in styles.css/GOTCHAS before changing it. */
#hero .cta-link{ --glass-position: -200px -1100px; }

/* ---------- site footer ---------- */
/* A single credit line, inside .cta-section rather than a slab beneath it, so
   the navy CTA remains the page's last beat (see the markup comment for what
   was deliberately left OUT of here and why).
   --text-on-glass, not a new token: it is the site's existing "quiet text on
   the dark ground" value and measures 8.1:1 on --navy, so this is comfortably
   past AA at 13.6px without inventing a tier. Per GOTCHAS #16, that number is
   evidence for THIS ground only — a footer on white would need re-measuring. */
.site-footer{
  margin-top: var(--space-5);
  color: var(--text-on-glass);
  font-size: var(--text-sm);
}
.site-footer p{ margin: 0; }
/* Base rule so .cta-link is self-sufficient, not dependent on its parent being
   a flex container. `.glass` sets overflow:hidden to clip its ::before texture
   layer to the border-radius — but overflow does NOT apply to inline boxes, so
   an inline .cta-link painted its glass layer as an unclipped RECTANGLE over
   the rounded background: border-radius was computed and applied, just painted
   over. That's exactly what 404.html did (the only .cta-link on the site not
   inside .cta-links), so its "Back to home" button shipped square-cornered
   while the identical markup on index.html was a pill. Measured in both
   Chromium and WebKit before/after. index.html is unaffected: its links are
   flex items, which are blockified regardless of this declaration.
   Sibling of GOTCHAS #5 — reusing a class outside the container it was written
   for can change its *display type*, not just which declarations it inherits. */
/* ============================ BUTTON COMPONENT ============================
   ONE button on this site. Everything that defines its shape — size, weight,
   radius, padding, transition — lives HERE, on the base class, so the
   component exists on its own rather than only inside a particular container.
   Ground-specific treatment (what changes between a navy section and the white
   page) is the only thing a modifier is allowed to set.

   This was previously the other way round: the shape lived on
   `.cta-section .cta-link`, so a button outside that container had no shape at
   all. When the hero needed its first CTA, that made writing a whole separate
   `.hero-cta` the path of least resistance — and it drifted immediately:
   58px tall vs 82px, 17px vs 21.25px, weight 700 vs 600, against eight
   consistent instances across three pages. The stray was a symptom; the
   container-scoped component was the cause. Same root as GOTCHAS #5 and #8.
   ========================================================================= */
.cta-link{
  /* inline-block, not inline: `.glass` clips its texture with overflow:hidden,
     which does not apply to inline boxes — that's GOTCHAS #8, and it shipped
     once as a square-cornered button on 404.html. */
  display: inline-block;
  padding: var(--space-3) var(--space-4);
  font-size: var(--text-h3);
  font-weight: 600;
  border-radius: var(--radius-pill);
  border: 1px solid transparent; /* reserved so a ground variant only recolors it and nothing reflows */
  /* These four used to be duplicated in BOTH ground variants, which meant two
     places to keep in sync for values that were never actually ground-dependent.
     Rendering both treatments on both grounds settled it: the shadow, the glass
     tier, the label colour and the text-shadow all read correctly on white AND
     on navy. Only the edge genuinely flips, so only the edge stays a variant. */
  color: var(--text-on-dark);
  --glass-picture-opacity: var(--glass-opacity-feature);
  box-shadow: var(--shadow-lift);
  text-shadow: var(--shadow-text);
  /* The edge too. It was the last ground-dependent property, kept on the theory
     that came inverts — dark stroke reads on white, light offset reads on navy.
     Rendering both treatments on both grounds showed the difference is barely
     perceptible: the mismatched edge never looks wrong, it just goes quiet,
     because a dark glass pill separates itself from either ground on its own.
     So there are no ground variants at all now. One class, one look, nothing to
     keep in sync — which is the whole point after three drift bugs (#5/#8/#21). */
  border-color: var(--edge-light);
  text-align: center;
  transition: background var(--duration-base) var(--ease-standard),
              border-color var(--duration-base) var(--ease-standard),
              box-shadow var(--duration-base) var(--ease-standard),
              transform var(--duration-base) var(--ease-standard);
}
/* The hover lift, on the base class so BOTH grounds get it — it is the shape
   responding to the pointer, which doesn't change with the background.
   This was impossible until now, and its absence was not a style choice:
   js/magnetic.js owned `transform` on these elements, so a CSS hover transform
   would have been overwritten every frame. Worse, magnetic set
   `el.style.transition = "transform .2s ease"` as an INLINE style, which
   replaces the whole property — measured, the computed transition on every CTA
   was `transform 0.2s` while the rule above asked for background + border +
   box-shadow at 0.3s. So the eased hover feedback this component declares was
   silently discarded on all 13 buttons. Removing magnetic restores it and
   frees `transform`; 2px is a nudge, matching the restraint of the existing
   shadow/border hover rather than a bounce. See GOTCHAS #31. */
.cta-link:hover,
.cta-link:focus-visible{ transform: translateY(-2px); }
@media (prefers-reduced-motion: reduce){
  .cta-link:hover,
  .cta-link:focus-visible{ transform: none; }
}

/* Hover/focus lift. Focus gets the identical treatment, not just the global
   outline — these are the site's only real conversion targets, so a keyboard
   user should get the same "this is raised and ready" read a mouse user does. */
.cta-link:hover,
.cta-link:focus-visible{
  border-color: var(--edge-light-strong);
  box-shadow: var(--shadow-lift-strong);
}

/* Was the .glass default (0.2) — opacity blends *toward the solid base
   color*, it doesn't just dim the image, so 0.2 over solid navy-deep read as
   washed-out flat, not "darker glass." Bumped so the glass is genuinely
   visible, just contained to a small pill. */
/* Grown from 17px/16px-padding (58px tall) to 20px/24px-padding (~78px) as the
   other half of rebalancing this section — see .cta-medallion above. The three
   stay deliberately IDENTICAL in weight: they are three routes to one action
   ("contact me"), not three competing actions, so the usual one-primary-button
   rule doesn't apply. Ranking them would add friction for everyone whose
   preferred channel wasn't the one promoted, which is the opposite of what a
   contact section is for. If a primary is ever wanted, vary SIZE rather than
   opacity — a dimmed option reads as deprecated, a smaller one reads as
   secondary.
   Uses the existing --text-h3 step rather than inventing a size; the type
   scale needs rebuilding on a ratio (logged in GOTCHAS) and adding a one-off
   here would make that job harder. */
/* ---- ground: a navy section ----
   Shape now comes from `.cta-link`; this sets only what the dark ground
   changes. */

/* ---------- scroll-progress thread (js/scroll-accents.js, Tier C #10) ---------- */
/* A generic pattern (scroll progress bar), reskinned into the site's own
   material — the same navy-to-teal gradient as the palette, filling
   top-to-bottom, rather than a plain flat UI-kit bar. Sits above the sticky
   nav (z-nav + 1) but below the intro overlay (z-intro), so it's invisible
   until the intro finishes on its own without any extra guard logic. Scaled
   from JS, not tied to reduced-motion — it's 1:1 driven by the visitor's own
   scroll position, the same category as a native scrollbar, not an
   autonomous animation. */
#scroll-progress{
  position: fixed;
  top: 0; right: 0;
  width: 3px; height: 100%;
  transform-origin: top;
  transform: scaleY(0);
  background: linear-gradient(180deg, var(--navy), var(--royal), var(--azure), var(--teal));
  z-index: calc(var(--z-nav) + 1);
  pointer-events: none;
}

/* ---------- 404 (404.html) ---------- */
/* Reuses .cta-section/.glass/.cta-medallion/.cta-link — the CTA's own
   navy-ground/glass language is the closest existing "dead end" moment
   already on the site, rather than a new visual vocabulary invented just for
   a page whose entire job is sending someone back. 404.html's section
   carries BOTH #notfound and .cta-section: the white/bold/opacity overrides
   on the link (padding, --glass-picture-opacity, color:white, font-weight)
   all live on the compound selector `.cta-section .cta-link`, so dropping
   the class (an earlier draft did) silently fell back to a plain link
   style — caught by an actual contrast check, not assumed. #notfound only
   adds what .cta-section doesn't already do: full-viewport height, since
   .cta-section's own sizing is tuned for sitting mid-page between other
   sections, not filling a standalone page. The core experience (message +
   link home) needs no JS — js/404.js only adds bonus flavor (headline
   variety, a console wink) on top of a real static fallback, so a script
   failing to load degrades to "slightly less clever," never "broken." */
#notfound{
  min-height: 100vh;
  text-align: center;
}
#notfound .cta-medallion{ margin: 0 auto var(--space-4); }
#notfound h1{
  color: var(--text-on-dark);
  font-size: clamp(1.8rem, 4vw, 2.6rem);
  margin-bottom: var(--space-2);
  text-shadow: var(--shadow-text);
}
#notfound p{
  color: var(--text-on-glass);
  max-width: 46ch;
  margin: 0 auto var(--space-4);
  font-size: var(--text-lg);
}
/* Cracked glass (404 easter egg #1) — a hairline crack layered above the
   normal .glass texture (::before) via a second pseudo-element, scoped only
   to this page's medallion (the real CTA medallion elsewhere stays intact).
   Same two-tone technique as the section-divider line elsewhere (dark
   stroke + a faint light offset, not one flat line) so it reads as this
   site's existing hand-drawn-glass language, not a new decoration. Hover
   (#2): the crack brightens and nudges outward slightly, like the piece is
   still settling — cheap (CSS-only, no JS) and matches the "unsettled
   piece" idea the copy is already making in words. */
#notfound .cta-medallion::after{
  content: "";
  position: absolute; inset: 0;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 180 180'%3E%3Cpath d='M92 14 L102 58 L78 92 L112 124 L92 166 M102 58 L144 74 M78 92 L40 102 M112 124 L146 146' stroke='%23051436' stroke-width='2' fill='none' stroke-linecap='round' opacity='.55'/%3E%3Cpath d='M92 14 L102 58 L78 92 L112 124 L92 166' stroke='%23ffffff' stroke-width='1' fill='none' stroke-linecap='round' opacity='.35'/%3E%3C/svg%3E");
  background-size: cover;
  border-radius: 50%;
  pointer-events: none;
  transition: transform var(--duration-slow) var(--ease-mechanical), filter var(--duration-slow) var(--ease-mechanical);
}
#notfound .cta-medallion:hover::after{
  transform: scale(1.04);
  filter: brightness(1.3);
}

/* ==========================================================================
   SERVICE PAGES (work/websites.html, work/social-media.html,
   work/brand-design.html) — SPEC §11
   One template, three copies. Reached by clicking a service card; each page
   runs: what the service is + what you get -> work samples -> how it works ->
   other services -> the same contact CTA.
   Almost everything here is existing site vocabulary reused as-is: the nav,
   .section-wrap, the divider wave + gem (which apply automatically to any
   `main > section`), .process-row/.process-medallion, and the whole #contact
   block. The genuinely new pieces are the expandable project rows and the
   sample grid, below.
   ========================================================================== */

/* A second-level page needs a way back that isn't the browser button — the nav
   logo goes home, but "up one level" to the services list had no affordance. */
.back-link{
  display: inline-block;
  margin-bottom: var(--space-2);
  font-size: var(--text-base);
  font-weight: 600;
}
.back-link:hover, .back-link:focus-visible{ text-decoration: underline; text-underline-offset: 4px; }

/* Sized one step down from the homepage h1 (which stays the site's largest
   type) by reusing .cta-content h2's existing clamp rather than inventing a
   size — the type scale is due a rebuild on a ratio (GOTCHAS), and one-off
   values here would make that job harder. */
/* Was a raw clamp(2rem, 4.5vw, 3rem) — a size invented outside the modular
   scale, which is the same class of stray as a hardcoded hex. Uses the h2 step
   because a service page's title is one level below the site's own headline,
   not equal to it. */
.service-hero h1{ font-size: var(--text-h2); }
.service-hero .subhead{ font-size: var(--text-lg); color: var(--text-muted); max-width: 46ch; }
.service-lead{ max-width: 62ch; }

/* Gem-marked list — shared by the service pages' deliverables and the
   homepage's principles. ONE definition of the marker, extended rather than
   copied: duplicating it would be exactly the stray that #21 is about.
   The marker is the section dividers' own glass gem at 10px —
   the same recipe (matte base + the mandala at .8), minus the white ring that
   exists there only to punch the gem out of the wave line. Reusing it means
   the list reads as this site's material rather than as a generic bullet, for
   no new visual language. Real <ul>/<li> underneath; the gem is decoration on
   ::before, so the semantics don't depend on it. */
.deliverables,
.principles{
  list-style: none;
  margin: var(--space-4) 0 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: var(--space-2) var(--space-3);
}
.deliverables li,
.principles li{
  position: relative;
  padding-left: var(--space-3);
}
.deliverables li::before,
.principles li::before{
  content: "";
  position: absolute;
  left: 0; top: .5em;
  width: 10px; height: 10px;
  border-radius: 50%;
  background-color: var(--surface-matte);
  background-image: var(--glass-image), var(--glass-fallback-image);
  background-repeat: no-repeat, no-repeat;
  background-size: 1400px 1400px, cover;
  background-position: center, center;
  opacity: .8;
}

/* Principles carry a claim plus a sentence, so they need more room per item
   than a deliverable's single line — everything else (marker, spacing, grid
   behaviour) is inherited above. Three columns mirror the three service cards,
   which is deliberate rhythm rather than coincidence: same page, same beat.
   Quieter than those cards on purpose — the cards are the conversion path,
   this is the reason to trust it. */
.principles{
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: var(--space-4) var(--space-3);
}
/* Piping. Its own token — the button's edge is --edge-light and this is
   --hairline-strong, because the
   outlined things on the white page read as one family rather than two
   coincidences. The gem moves inside the padding box, which is why its `left`
   is restated here rather than inherited. */
.principles li{
  padding: var(--space-3);
  padding-left: calc(var(--space-3) + var(--space-3));
  border: 1px solid var(--hairline-strong);
  border-radius: var(--radius-panel);
}
.principles li::before{ left: var(--space-3); top: calc(var(--space-3) + .45em); }
.principles b{
  display: block;
  color: var(--text-heading);
  font-weight: 700;
  margin-bottom: var(--space-1);
}
.principles span{
  display: block;
  color: var(--text-muted);
  font-size: var(--text-base);
  max-width: 34ch;
}

/* ---- expandable project rows ---- */
/* Separated by a plain hairline, NOT the divider wave. The wave means "new
   section" everywhere else on the site; using it between items inside one
   section would spend the meaning it has. Section = wave, item = hairline. */
.project-list{ margin-top: var(--space-4); }
.project{
  border-top: 1px solid var(--hairline);
  scroll-margin-top: calc(var(--nav-h) + var(--space-3)); /* same sticky-nav reason as `main > section` above; these are deep-link targets */
}
.project:last-child{ border-bottom: 1px solid var(--hairline); }

/* <h3><button></button></h3>: the heading keeps the document outline intact
   for screen-reader navigation, the button carries the disclosure semantics
   (aria-expanded/aria-controls). This is a disclosure, not a modal — no focus
   trap, no scroll lock, no ESC handling needed, which is the whole reason it
   is cheap where a lightbox wasn't. */
.project-title{ margin: 0; }
/* flex-wrap so the meta line drops below the project name when the row runs
   out of width, instead of squeezing the name into a two-line column beside a
   half-empty meta column (measured at 390px). Fluid, so no media query — and
   the toggle keeps its margin-left:auto, so it stays pinned right on the first
   line either way. */
.project-header{
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-1) var(--space-3);
  width: 100%;
  padding: var(--space-3) 0;
  background: none;
  border: 0;
  font-family: var(--font-body); /* buttons do NOT inherit font-family — without this it falls back to the UA's system font mid-page */
  font-size: var(--text-h3);
  font-weight: 700;
  color: var(--text-heading);
  text-align: left;
  cursor: pointer;
}
.project-name{ flex: 0 1 auto; }
.project-meta{
  flex: 1 1 auto;
  color: var(--text-muted);
  font-size: var(--text-base);
  font-weight: 400;
}
/* Two bars: a "+" when collapsed (one rotated 90deg), a "−" when expanded
   (both flat, overlapping). A shape change, not a color change, so it reads
   in greyscale. Safe to animate with a CSS transform — js/project-expand.js
   only ever writes `height` on .project-panel and never touches this element,
   so GOTCHAS #2's "GSAP owns transform, or nothing does" isn't in play. */
.project-toggle{
  position: relative;
  flex: 0 0 auto;
  width: 18px; height: 18px;
  margin-left: auto;
}
.project-toggle::before,
.project-toggle::after{
  content: "";
  position: absolute;
  left: 0; top: 8px;
  width: 18px; height: 2px;
  background: var(--royal);
  transition: transform var(--duration-base) var(--ease-mechanical);
}
.project-toggle::after{ transform: rotate(90deg); }
.project-header[aria-expanded="true"] .project-toggle::after{ transform: rotate(0deg); }

/* Panels ship OPEN in the markup and are collapsed by CSS only once the `.js`
   class is set (one inline line in each page's <head>). Two things this buys:
   a page whose script is blocked or fails still shows every sample rather than
   hiding content behind a control that will never work — the same rule 404.js
   already follows — and setting it in <head> rather than from this module
   means no flash of fully-expanded content before the collapse lands.
   `visibility:hidden` is not redundant with height:0 + overflow:hidden: the
   latter clips a collapsed panel visually but leaves its links in the tab
   order, so a keyboard user would tab into content they cannot see.
   js/project-expand.js flips it back before it animates open. */
.js .project-panel{
  height: 0;
  overflow: hidden;
  visibility: hidden;
}
/* No padding on the panel itself — padding renders even at height:0, which
   would leave a phantom gap under every collapsed row. Spacing lives on the
   children instead, and overflow:hidden makes the panel a BFC so their margins
   are contained rather than collapsing through (which also keeps scrollHeight
   an honest measurement for the height tween). */
.project-intro{ margin: 0 0 var(--space-3); max-width: 62ch; color: var(--text-muted); }
.project-intro a{ text-decoration: underline; text-underline-offset: 3px; }
.project-intro a:hover,
.project-intro a:focus-visible{ color: var(--azure); }

/* ---- sample grid ---- */
/* On white, framed with the same hairline as .service-card — deliberately NOT
   wrapped in .glass. The bounded-accent rule (SPEC §5) exists to keep text off
   busy glass; here the picture IS the content and carries no text, so the
   glass recipe has no job to do and would only compete with the work.
   auto-fit + minmax reflows on its own, so no media query (GOTCHAS #7); a
   hero shot opts into the full row with .sample--wide. */
.samples{
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: var(--space-3);
  margin: 0 0 var(--space-4);
}
.sample{ margin: 0; }
.sample--wide{ grid-column: 1 / -1; }
/* ONE ratio for every frame, and the frame owns it — not the image. Real
   samples arrive at whatever the source was (the live pieces here are 1.47,
   1.60 and 1.66), and a grid row sizes to its tallest item, so three honest
   screenshots rendered at their own ratios read as three different sizes.
   3/2 sits in the middle of that spread, so nothing is letterboxed much.

   The earlier rule here was "no forced aspect-ratio and no object-fit:cover",
   and the reason given was that cropping throws away the part of a piece worth
   showing (SPEC §11). That reason is intact: this is `contain`, not `cover` —
   the whole image is always visible, and the leftover space becomes matte navy
   bars rather than a crop. Uniform size and no cropping were never actually in
   conflict; `cover` was just the wrong tool for getting the first one.

   Bonus, and the reason this is better than it looks: the frame now reserves
   its box from CSS alone, so scrollHeight is honest for the expand tween the
   moment the markup parses, instead of once every image has loaded. The
   width/height attributes are still required (drift check) and still carry the
   intrinsic size, they're just no longer what holds the layout open. */
.sample-frame{
  border: 1px solid var(--hairline);
  border-radius: var(--radius-panel);
  overflow: hidden; /* a block box, so this genuinely clips — cf. GOTCHAS #8 */
  aspect-ratio: 3 / 2;
  background: var(--surface-matte);
}
.sample-frame img,
.sample-frame video{
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
}
/* A sample can be the click-through to the live piece itself, not just a
   picture of it — reset the <a> defaults (no link color/underline on what's
   visually a photo/video) and give the frame a visible hover/focus state so
   "this is clickable" isn't left to a bare cursor change. */
.sample-link{ display: block; color: inherit; text-decoration: none; }
.sample-link:hover .sample-frame,
.sample-link:focus-visible .sample-frame{ outline: 2px solid var(--azure); outline-offset: 2px; }
/* The frame owns the ratio now, so this just fills it. A missing sample reads
   as the site's own glass rather than as a blank box — same ground as the
   letterbox bars above, so an empty frame and a letterboxed one are visibly
   the same object. */
.sample-placeholder{
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-1);
  height: 100%;
  color: var(--text-on-glass);
  font-size: var(--text-sm);
  font-weight: 600;
  letter-spacing: .04em;
  text-transform: uppercase;
  /* Ground changed from white to matte navy, so the label colour had to change
     with it — this is GOTCHAS #16 exactly (a value keeps its number while the
     background moves underneath it). --text-muted measured 8.74:1 on white and
     would be unreadable here; --text-on-glass measures 10.7:1 on
     --surface-matte, checked in the browser rather than computed. The old note
     about `opacity: .7` still applies for the same reason and is still not set. */
}
/* The motif is the mandala the whole site is already built from
   (--glass-image, loaded on every page for the glass texture), so this costs
   zero new bytes and no new file — reusing the brand mark, not drawing a
   second one. border-radius clips it to a circle on purpose: the mandala is a
   circle inside a SQUARE whose corners are white padding (GOTCHAS #14), and on
   navy those corners would render as a white box around the flower. */
.sample-placeholder::before{
  content: "";
  width: 88px;
  height: 88px;
  border-radius: 50%;
  /* Both layers, like every other glass surface: the real mandala in front,
     the 3KB hand-authored one behind it, which is the whole point of a
     placeholder — a stand-in for a missing image must not itself go missing.
     `cover` rather than the usual `1400px 1400px`: at 88px a fixed 1400px
     background is an 88px window onto one arbitrary fragment, where cover
     fits the whole flower into the circle, which is what makes this read as
     the mark rather than as texture. */
  background-color: var(--surface-matte);
  background-image: var(--glass-image), var(--glass-fallback-image);
  background-repeat: no-repeat, no-repeat;
  background-size: cover, cover;
  background-position: center, center;
  opacity: .9;
}
/* Captions are real text (§7, no text-in-images) and are meant to say what the
   piece SOLVED, not just what it is — that's the part a visitor can't get from
   looking, and it's the job a testimonial would do if one existed yet. */
.sample figcaption{
  margin-top: var(--space-1);
  color: var(--text-muted);
  font-size: var(--text-base);
}

/* ---- other services ---- */
/* Someone who opened Websites but actually needs branding shouldn't have to go
   back to the homepage to discover that.
   These were bare text links, and the comment here used to justify that with
   "the site has no light-ground button style, and inventing one wasn't in scope
   for this pass." Both halves of that are now false: `.cta-link` was lifted so
   its shape no longer depends on `.cta-section` (GOTCHAS #21), and it carries
   no ground variants at all any more — one class works on white and on navy.
   The reason not to
   reach for it here is different and still holds — these are secondary
   wayfinding at the end of a page, and a full CTA pill would give them the same
   weight as the site's one primary action.
   So: the CARD family, not the button family. That split is deliberate and the
   drift check enforces half of it —
     --radius-pill  (999px) = an ACTION           -> .cta-link, and only .cta-link
     --radius-panel (16px)  = a CARD or PANEL     -> .glass, .service-card,
                                                    .sample-frame, and now these
   which is why this uses --radius-panel and does not trip the "second pill
   button" assertion in tools/check-drift.py. It borrows .service-card's exact
   container language (1px --hairline, --radius-panel, --surface-page, hover to
   --hairline-strong) because that component already solves "a bordered box on
   white that navigates to a service page" — this is the small version of the
   same idea, not a new one. No markup change was needed: the <a> was already
   there inside the <li>, which also keeps the three copies from drifting. */
.other-services-list{
  list-style: none;
  margin: var(--space-4) 0 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  font-weight: 600;
}
/* --text-base (15px), not the list's old --text-h3 (21.25px). Once each link
   has a box around it, the box is doing the work the size used to do; keeping
   21.25px inside a border would have read as a row of buttons competing with
   the CTA pills directly below. Smaller type + a container is *more* structured
   and *less* loud at the same time. */
.other-services-list a{
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  padding: var(--space-2) var(--space-3);
  font-size: var(--text-base);
  color: var(--text-link);
  background: var(--surface-page);
  /* --hairline-strong, not the --hairline that .service-card uses. Same token
     set, different amount, because the two boxes are not doing equal work:
     a service card is 240px+ with a 110px glass accent defining its top edge,
     so a 10% edge is enough to close a shape the eye has already found. These
     have nothing but the border, and at 10% it composites to ~1.14:1 on white
     — measured, effectively invisible, i.e. not a container at all. */
  border: 1px solid var(--hairline-strong);
  border-radius: var(--radius-panel);
  transition: border-color var(--duration-base) var(--ease-standard),
              transform var(--duration-base) var(--ease-standard);
}
/* The underline that used to be the hover affordance is gone — inside a
   bordered box it reads as a mistake rather than a state. Three non-color
   signals replace it and all survive greyscale (never-color-alone): the border
   darkens, the box lifts 2px, and the arrow still nudges. */
.other-services-list a:hover,
.other-services-list a:focus-visible{
  border-color: var(--navy);
  transform: translateY(-2px);
}
@media (prefers-reduced-motion: reduce){
  .other-services-list a:hover,
  .other-services-list a:focus-visible{ transform: none; }
}
.other-services-arrow{
  display: inline-block;
  transition: transform var(--duration-base) var(--ease-standard);
}
.other-services-list a:hover .other-services-arrow,
.other-services-list a:focus-visible .other-services-arrow{ transform: translateX(4px); }

/* ---------- reduced motion: static fallbacks (§2, §7) ---------- */
/* Nothing left to override here — Bloom/Settle already collapse to a plain
   fade in intro.js under reduced motion (§2), and the hint text (the other
   thing this block used to touch) was removed, see Revision 6. */
