/*
 * THE SITE STYLESHEET.
 *
 * This lived inline in index.html, which was right while the site was one page. It is external
 * now because the site has several: /apps, each app page, and the privacy and support pages the
 * App Store requires. Inline styles are re-downloaded by every page; one external sheet is
 * fetched once and served from cache for every navigation after it, so the second page and the
 * tenth cost nothing.
 *
 * It is deliberately NOT split into shared and page-specific halves. The hero rules are inert on
 * a page that has no hero, the responsive block interleaves hero and section rules inside the
 * same media queries, and a wrong split is a silent visual bug. One file, no judgement calls.
 *
 * build/standalone.mjs reads this back and inlines it, so the portable single-file cut still
 * opens with no server and nothing relative.
 */
/* ─────────────────────────────────────────────────────────────
   TOKENS. The four live ones are --accent-h, --radius, --scale,
   and the scheme stamp. Everything else resolves from them.
   ───────────────────────────────────────────────────────────── */
:root{
  --accent-h: 38;
  --accent-s: 92%;
  --accent-l: 58%;
  --radius: 10px;
  --scale: 1;

  --accent:      hsl(var(--accent-h) var(--accent-s) var(--accent-l));
  --accent-text: hsl(var(--accent-h) var(--accent-s) var(--accent-l));
  --accent-dim:  hsl(var(--accent-h) var(--accent-s) var(--accent-l) / .30);
  /* Ink for text sitting ON the accent, not beside it. The accent stays yellow in both schemes,
     so this is one value in both: 10.3:1 on the dark scheme's accent, 6.3:1 on the light
     scheme's. White on either is under 3:1. */
  --on-accent:   hsl(222 40% 10%);
  --accent-wash: hsl(var(--accent-h) var(--accent-s) var(--accent-l) / .12);
  --accent-ink:  hsl(var(--accent-h) 80% 9%);

  --bg:      hsl(222 30% 5.5%);
  --bg-2:    hsl(222 26% 7.5%);
  --surface: hsl(222 22% 10.5%);
  --raised:  hsl(222 20% 13.5%);
  --line:    hsl(222 18% 17%);
  --line-2:  hsl(222 16% 25%);
  /* the one positive-signal colour on the page: used where a number has improved */
  --pos:     hsl(152 64% 46%);
  /* The AI section is lit from two corners, in two colours borrowed from the ledger below it:
     WeightWatchers blue and Tabya amber. Both are lifted well above their brand lightness --
     #0222D0 sits at 41% and is almost black against this background, and a wash you cannot see
     is not a wash. Hue and saturation are the brand's; only the lightness is ours. */
  --ai-c1:   231 98% 60%;   /* WeightWatchers #0222D0, lifted from 41% */
  --ai-c2:    40 96% 54%;   /* Tabya #F7A600, lifted from 48%          */
  --fg:      hsl(40 14% 92%);
  --fg-2:    hsl(220 11% 63%);
  --fg-3:    hsl(220 12% 55%);
  /* Elevation is a scale, not a value. One token cannot serve a full-width panel and a 90px
     tooltip — the same blur radius reads as depth on one and as a smudge on the other. */
  --shadow-sm: 0 4px 12px -6px hsl(222 60% 2% / .8);
  --shadow:    0 1px 0 hsl(0 0% 100% / .04) inset, 0 18px 40px -24px hsl(222 60% 2% / .9);
  --shadow-lg: 0 1px 0 hsl(0 0% 100% / .05) inset, 0 40px 80px -40px hsl(222 60% 2% / .95);

  --u: calc(1rem * var(--scale));
  --maxw: 1180px;
  --gutter: clamp(1.25rem, 5vw, 4.5rem);

  --sans: "Archivo", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  --mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;

  --ease: cubic-bezier(.2,.7,.25,1);
  /* Stronger than the built-ins, which lack the punch that makes motion look decided.
     --press is short and hard: the user is watching that first frame most closely. */
  --ease-out: cubic-bezier(.23,1,.32,1);
  --ease-in-out: cubic-bezier(.77,0,.175,1);
  --press: 140ms var(--ease-out);
  color-scheme: dark;
}
/* PARKED, and deliberately unreachable. The site is dark only: `color-scheme: dark` is set above,
   there is no prefers-color-scheme query anywhere in this file, and nothing sets data-scheme, so
   this block never matches. It is kept rather than deleted because it is complete and correct,
   and because deleting it would touch the -d/-l colour pairs the client, contact and AI-tools
   generators emit, which is a change that needs its own audit round.

   Do not wire this up halfway. Turning it on means: a prefers-color-scheme query or a control
   that sets data-scheme, the second theme-color meta in build/seo.py, and a full contrast pass,
   because every measurement taken on this page so far was taken on the dark ground. */
:root[data-scheme="light"]{
  --accent-l: 44%;
  --accent-wash: hsl(var(--accent-h) var(--accent-s) var(--accent-l) / .10);
  --bg:      hsl(220 26% 97%);
  --bg-2:    hsl(220 22% 94.5%);
  --surface: hsl(0 0% 100%);
  --raised:  hsl(220 24% 98%);
  --line:    hsl(220 16% 87%);
  --line-2:  hsl(220 14% 76%);
  --fg:      hsl(222 34% 10%);
  --fg-2:    hsl(222 12% 38%);
  --fg-3:    hsl(222 12% 45%);
  --shadow:  0 1px 0 hsl(0 0% 100% / .8) inset, 0 16px 36px -26px hsl(222 40% 20% / .35);
  color-scheme: light;
}

*,*::before,*::after{ box-sizing:border-box; }
html{ -webkit-text-size-adjust:100%; scroll-behavior:smooth; }
body{
  margin:0;
  background:var(--bg);
  color:var(--fg);
  font-family:var(--sans);
  font-size:clamp(15px,.55vw + 13.4px,17px);
  line-height:1.6;
  font-variation-settings:"wdth" 100;
  -webkit-font-smoothing:antialiased;
  transition:background .5s var(--ease), color .5s var(--ease);
}
/* Sections that break out of .wrap to reach the viewport edge do it with calc(50% - 50vw),
   which overshoots by the scrollbar width wherever scrollbars take up space. Clipping the
   inline axis here turns that overshoot into nothing instead of into a horizontal scrollbar.
   clip, not hidden: hidden would make main a scroll container and change how the page scrolls. */
main{ overflow-x:clip; }
img{max-width:100%}
::selection{ background:var(--accent); color:var(--accent-ink); }

a{ color:inherit; }
:where(a,button,input,summary):focus-visible{
  outline:2px solid var(--accent-text);
  outline-offset:3px;
  border-radius:calc(var(--radius) * .4);
}

/* The nav grew from 72px to 85px when the mark did; 84 landed a section 1px under it. */
section[id]{ scroll-margin-top:100px; }
/* There was no .sr-only rule on this page — the one existing screen-reader label carried the
   whole declaration inline. Anything using the class would simply have rendered. */
.sr-only{
  position:absolute; width:1px; height:1px; margin:-1px; padding:0;
  overflow:hidden; clip-path:inset(50%); white-space:nowrap; border:0;
}
/* The ruled grid that used to sit behind this container is gone. It was there to stop the lower
   page reading as one flat plane, but at 120px it competed with the section rules and the ledger
   rows rather than sitting under them. position:relative stays: it is the containing block for
   anything a section positions against the container. */
.wrap{
  position:relative;
  width:100%; max-width:var(--maxw); margin-inline:auto; padding-inline:var(--gutter);
}

/* ── mono utility layer ─────────────────────────────────────── */
.mono{ font-family:var(--mono); font-size:.71rem; letter-spacing:.1em; text-transform:uppercase; font-weight:500; }

/* ── nav ────────────────────────────────────────────────────── */
.nav{
  position:fixed; inset:0 0 auto 0; z-index:60;
  /* Three columns, not space-between: the links have to be centred on the FRAME, and
     space-between centres them on whatever is left after the mark and the CTA, which are
     different widths and both change with the viewport. */
  display:grid; grid-template-columns:1fr auto 1fr; align-items:center; gap:1rem;
  padding:1.55rem var(--gutter) .95rem;
  background:color-mix(in srgb, var(--bg) 78%, transparent);
  backdrop-filter:saturate(1.4) blur(14px);
  -webkit-backdrop-filter:saturate(1.4) blur(14px);
  border-bottom:1px solid transparent;
  transition:border-color .3s var(--ease), background .5s var(--ease), box-shadow .3s var(--ease);
}
/* Once it is over content the nav becomes a surface: a lit top edge and a cast shadow, which
   is what makes a fixed bar read as glass rather than as a rectangle that failed to scroll. */
.nav[data-stuck="true"]{
  border-bottom-color:var(--line);
  box-shadow:0 1px 0 hsl(0 0% 100% / .04) inset, 0 12px 28px -22px hsl(222 60% 2% / .9);
}
.brand{ display:flex; align-items:center; text-decoration:none; justify-self:start; transition:transform var(--press); }
/* The mark. It arrived as a 62-facet trace whose fills all sit at the same lightness and
   saturation and differ only in hue, a 21-degree sweep. The sweep is gone: at nav size the
   facets read as a smudge rather than as shading, so every facet now takes one flat colour and
   the silhouette carries the mark by itself. It is currentColor, which puts the value on the
   token instead of on 62 path attributes, and the hairline stroke stays because the facets are
   a trace and meet exactly, so without it the anti-aliaser leaves a seam between every pair. */
.brand-mark{
  color:var(--accent);
  height:clamp(30px, 3.4vw, 44px);
  width:calc(clamp(30px, 3.4vw, 44px) * 524 / 373);
  display:block; overflow:visible;
  /* it cannot draw itself on any more, so it is laid down instead */
  animation:mb-wipe 1s var(--ease) .15s both;
  transition:filter .3s var(--ease);
}
@keyframes mb-wipe{ from{ clip-path:inset(0 100% 0 0); } to{ clip-path:inset(0 0 0 0); } }
.nav-links{ display:flex; gap:.35rem; justify-self:center; }
/* The nav carries the only persistent CTA and is on screen for every scroll gesture, so it
   says where you are — a hairline under the current section, not a colour change, which would
   compete with the accent. */
.nav-links a[aria-current="true"]{ color:var(--fg); }
.nav-links a::after{
  content:""; position:absolute; left:.6rem; right:.6rem; bottom:.2rem; height:1px;
  background:var(--accent); transform:scaleX(0); transform-origin:left;
  transition:transform .3s var(--ease-out);
}
.nav-links a[aria-current="true"]::after{ transform:scaleX(1); }
.nav-links a{
  position:relative;
  font-family:var(--mono); font-size:.8rem; letter-spacing:.1em; text-transform:uppercase;
  color:var(--fg-2); text-decoration:none; padding:.45rem .6rem;
  border-radius:calc(var(--radius) * .5);
  transition:color .2s, background .2s, transform var(--press);
}
/* The one filled control on the page. It was an outlined button in the same grey as everything
   else in the header, which made the single thing a visitor is meant to take away look like a
   tertiary link. Filled in the brand's own yellow it is the only saturated rectangle above the
   fold, and the arrow says the click produces a file rather than another page. */
.nav-cta{
  justify-self:end;
  display:inline-flex; align-items:center; gap:.45rem;
  font-family:var(--mono); font-size:.78rem; letter-spacing:.1em; text-transform:uppercase;
  text-decoration:none; color:var(--on-accent);
  background:var(--accent); border:1px solid var(--accent);
  padding:.5rem .85rem; border-radius:calc(var(--radius) * .6);
  transition:filter .2s, transform var(--press);
  white-space:nowrap;
}
.cta-i{
  width:14px; height:14px; flex:none; fill:none; stroke:currentColor; stroke-width:2;
  stroke-linecap:round; stroke-linejoin:round;
}
/* Everything pressable confirms the press. 0.97 is felt rather than seen, and transform is
   named explicitly — `all` would both cost more and clobber each element's own transitions. */
.nav-cta:active, .cl-site:active, .inline-link:active,
.link:active, .tt-cta:active, .nav-links a:active{ transform:scale(.97); }

/* The phone's navigation, standing exactly where the CV button stands on a wide screen and
   wearing its shape, because it is taking its place rather than joining it. Hidden above 760,
   which is where the real links come back. */
.menu{ display:none; justify-self:end; position:relative; }
.menu > summary{
  display:inline-flex; align-items:center; gap:.55rem; cursor:pointer;
  font-family:var(--mono); font-size:.78rem; letter-spacing:.1em; text-transform:uppercase;
  color:var(--fg); border:1px solid var(--line-2);
  padding:.5rem .8rem; border-radius:calc(var(--radius) * .6);
  white-space:nowrap; list-style:none;
  transition:border-color .2s, color .2s, background .2s, transform var(--press);
}
/* both spellings: the standard one and the one Safari still needs */
.menu > summary::-webkit-details-marker{ display:none; }
.menu > summary::marker{ content:""; }
.menu > summary:active{ transform:scale(.97); }
.menu[open] > summary{ border-color:var(--accent-text); color:var(--accent-text); }
/* three rules that become a cross, so the control says what it will do next rather than only
   what it is */
.menu-bars{ display:grid; gap:3px; width:14px; }
.menu-bars i{
  display:block; height:1.5px; background:currentColor; border-radius:1px;
  transition:transform .22s var(--ease), opacity .22s var(--ease);
}
.menu[open] .menu-bars i:nth-child(1){ transform:translateY(4.5px) rotate(45deg); }
.menu[open] .menu-bars i:nth-child(2){ opacity:0; }
.menu[open] .menu-bars i:nth-child(3){ transform:translateY(-4.5px) rotate(-45deg); }
.menu-panel{
  position:absolute; right:0; top:calc(100% + .55rem); z-index:70;
  min-width:min(15rem, 70vw);
  display:grid; gap:.1rem; padding:.45rem;
  /* the nav's own glass, a shade more opaque: this one has text under it rather than beside it */
  background:color-mix(in srgb, var(--bg) 93%, transparent);
  backdrop-filter:saturate(1.4) blur(14px);
  -webkit-backdrop-filter:saturate(1.4) blur(14px);
  border:1px solid var(--line); border-radius:var(--radius);
  box-shadow:var(--shadow-lg);
}
.menu-nav{ display:grid; }
.menu-panel a{
  display:block; text-decoration:none; color:var(--fg-2);
  font-size:.95rem; line-height:1.2; padding:.62rem .7rem;
  border-radius:calc(var(--radius) * .5);
  transition:color .2s var(--ease), background .2s var(--ease);
}
.menu-panel a[aria-current="true"]{ color:var(--fg); background:var(--raised); }
/* The CV keeps its own register below a rule: it is the one item here that leaves the page. */
/* The same control as the header's, because on a phone this IS the header's. A grey row in a
   list of grey rows would have buried the one item here that is not navigation. */
.menu-panel .menu-cv{
  display:flex; align-items:center; justify-content:center; gap:.45rem;
  margin-top:.45rem; padding:.68rem .7rem;
  color:var(--on-accent); background:var(--accent);
  border-radius:calc(var(--radius) * .5);
  font-family:var(--mono); font-size:.72rem; letter-spacing:.1em; text-transform:uppercase;
}
.tt-cta{ transition:transform var(--press); }
.hero-next:active{ transform:scale(.93); }
.brand:active{ transform:scale(.985); }

/* ── hero ───────────────────────────────────────────────────────────
 * The hero IS the viewport. Nothing else lives in it: no role line, no CTA, no scroll cue.
 * Five acts, each owning the whole frame, and the banner words are the spine that carries
 * across them.
 */
.hero{
  /* 560 was taller than a landscape phone, so each act's payoff sat below the fold on a
     932x430 screen. 420 was the replacement, and it had the same fault one size down: on a
     844x390 landscape phone a 420px floor is 30px taller than the viewport it is meant to fit
     inside. Clamped to the viewport now, so the floor can never exceed the thing it floors. */
  position:relative; height:100dvh; min-height:min(420px, 100dvh);
  /* Where the text spine sits. The narration parks here, the hinge passes through it and the
     banner word lands on it, so it has to be ONE value: --banner is derived from it below, and
     moving the narration without moving the banner would break the one rule the sequence is
     built on, that the frame's text never moves between acts. */
  --slot:clamp(5.5rem, 11vh, 9rem);
  overflow:hidden; isolation:isolate;
}
.acts{
  position:absolute; z-index:3; inset:auto 0 auto 0; top:clamp(5.5rem, 11vh, 9rem);
  margin:0; display:flex; flex-wrap:wrap; justify-content:center; gap:0 .38em;
  font-size:clamp(1.5rem, 4vw, 3.6rem);
  line-height:1.04; letter-spacing:-.026em;
  font-weight:600; text-align:center;
  pointer-events:none;
}
.acts{ transform-origin:50% 0; will-change:transform; }
/* opacity and transform are written straight onto these three spans by the runtime; with no
   JavaScript they stay as authored, which is the finished sentence. */
.act{ opacity:1; }
/* what the act on screen actually is, under the word that names it */
/* Left-aligned under the first word and staying there: the sentence grows to the right as the
   acts accumulate, so its left edge is the one thing that never moves. */
.act-sub{
  position:absolute; z-index:3; left:50%;
  transform:translateX(calc(-50% + var(--sub-cx, 0px)));
  top:calc(clamp(5.5rem, 11vh, 9rem) + clamp(2.1rem, 5vw, 4.6rem));
  width:min(520px, 74vw); text-align:left; opacity:0; pointer-events:none;
  font-size:clamp(.94rem, 1.3vw, 1.16rem); line-height:1.4; color:var(--fg); opacity:.74;
  text-wrap:pretty;
}

/* The one control allowed in the hero. It exists because the sequence is 37 seconds long and a
   visitor has no other way to know the page continues; it is a link to a real section, so it
   works with no JavaScript and gives the keyboard a genuine target. Its own ground, because the
   corner it sits in is occupied by paper, cylinders and a phone at different points in the run. */
.hero-next{
  position:absolute; z-index:5; right:var(--gutter); bottom:clamp(1.4rem, 4vh, 2.6rem);
  width:clamp(44px, 4.4vw, 56px); aspect-ratio:1; border-radius:50%;
  display:grid; place-items:center; text-decoration:none;
  background:color-mix(in srgb, var(--bg) 72%, transparent);
  border:1px solid var(--line-2);
  backdrop-filter:blur(8px); -webkit-backdrop-filter:blur(8px);
  animation:next-bob 2.8s var(--ease) infinite;
  transition:border-color .25s var(--ease), background .25s var(--ease), transform var(--press);
}
.hero-next svg{
  width:42%; height:42%; fill:none; stroke:var(--fg-2); stroke-width:1.8;
  stroke-linecap:round; stroke-linejoin:round;
  transition:stroke .25s var(--ease), transform .25s var(--ease);
}
.hero-next:focus-visible{ outline:2px solid var(--accent); outline-offset:3px; }
@keyframes next-bob{
  0%, 68%, 100% { translate:0 0; }
  82%           { translate:0 5px; }
}

.stage{
  position:absolute; inset:0; z-index:1;
  /* The banner is absolutely positioned above the stage, so nothing here knows about it. This
     is its footprint: its top offset plus its own line box. Every act that centres content
     reserves it, or a short window centres the artwork straight through the headline. */
  --banner:calc(var(--slot) + clamp(1.9rem, 4.6vw, 4.3rem));
  /* The mirror of it at the bottom: the next-section control's footprint. On a phone the
     console is 88vw, so there is no horizontal room beside the button at all — the only way
     to clear it is vertically, and every centring act has to know that. */
  --foot:calc(clamp(1.4rem, 4vh, 2.6rem) + clamp(44px, 4.4vw, 56px) + 14px);
}
.sc{ position:absolute; inset:0; }

/* ── act 1: the wall ──────────────────────────────────────────────── */
/* 56vw, not 86vw. The wall's top row lives beside this sentence, and a narration that grows
   with the viewport eats the two notes at x4 and x79 somewhere between 660 and 1150px wide.
   Capping it in vw keeps its left edge at a constant 22% and the notes clear of it at every
   width the scatter is used at. */
.narr{
  position:absolute; left:50%; top:var(--slot); transform:translateX(-50%);
  width:min(620px, 56vw); text-align:center; z-index:20;
}
/* A little light behind the sentences that carry the story, so they read as lit rather than
   printed. Kept under 24% alpha and wide: any tighter and it looks like a blur filter. */
/* The marker round the opening line. The one stroke of colour before act 3, and it is an
   annotation rather than a fill: a hand goes round the sentence while the wall of notes is still
   settling. Round caps and joins, because a marker has no corners. */
.narr-ring{
  position:absolute; z-index:-1; pointer-events:none; overflow:visible;
  fill:none; stroke:var(--accent);
  stroke-width:clamp(2px, .26vw, 3.6px);
  stroke-linecap:round; stroke-linejoin:round;
  opacity:.92;
}
.narr-lead{
  margin:0; font-size:clamp(1.4rem, 3.4vw, 2.9rem); font-weight:600;
  letter-spacing:-.028em; line-height:1.12; text-wrap:balance;
  text-shadow:0 0 52px hsl(210 40% 96% / .22), 0 0 16px hsl(210 40% 96% / .10);
}
.narr-sub{
  margin:.8rem 0 0; font-size:clamp(1rem, 1.5vw, 1.24rem); line-height:1.34;
  color:var(--fg); opacity:.72; text-wrap:balance;
}

/* Confluence-style: an actual square, flat paper, one corner folded back. The clip removes
   the corner and the pseudo-element draws the flap that was folded, which is why it reads as
   paper rather than as a rounded rectangle with a triangle stuck on it. */
.note{
  /* Square, so its width also sets its height. The vh cap is what keeps a short wide window
     honest: the three bands sit 24% of the height apart, and without it a 1440x640 laptop
     pushes the bottom row off the frame. */
  --nw:min(clamp(110px, 15.5vw, 210px), 23.5vh);
  --fold:clamp(24px, 2.6vw, 38px);
  position:absolute; width:var(--nw); aspect-ratio:1;
  left:min(var(--nx, 0%), calc(100% - var(--nw) - 14px));
  top:var(--ny, 0%);
  display:flex; align-items:center;
  padding:clamp(.6rem, .8vw, 1.15rem) clamp(.65rem, .84vw, 1.2rem) calc(var(--fold) * .55);
  background:var(--paper); color:var(--paper-ink);
  font-size:clamp(.74rem, 1.28vw, 1.22rem); line-height:1.28; font-weight:600;
  letter-spacing:-.006em;
  clip-path:polygon(0 0, 100% 0, 100% calc(100% - var(--fold)), calc(100% - var(--fold)) 100%, 0 100%);
  box-shadow:0 18px 30px -16px rgb(0 0 0 / .78), 0 3px 8px -3px rgb(0 0 0 / .5);
  transform-origin:50% 50%;
  will-change:transform, opacity;
}
/* Six papers, assigned per note in the story data. A real wall is not one colour, and the
   pad a thing got written on is never a decision anyone made. Every ink sits at L<19 on a
   paper at L>75, so the text clears AA by a distance no matter which pad it landed on. */
.note.t0{ --paper:hsl(45 88% 77%); --paper-ink:hsl(38 55% 15%); --fold-ink:hsl(43 52% 61%); }
.note.t1{ --paper:hsl(148 48% 77%); --paper-ink:hsl(158 46% 13%); --fold-ink:hsl(148 30% 61%); }
.note.t2{ --paper:hsl(209 76% 81%); --paper-ink:hsl(214 55% 16%); --fold-ink:hsl(209 42% 65%); }
.note.t3{ --paper:hsl(6 78% 81%); --paper-ink:hsl(6 52% 17%); --fold-ink:hsl(6 44% 65%); }
.note.t4{ --paper:hsl(268 56% 84%); --paper-ink:hsl(266 44% 18%); --fold-ink:hsl(266 34% 67%); }
.note.t5{ --paper:hsl(186 52% 77%); --paper-ink:hsl(190 50% 12%); --fold-ink:hsl(186 32% 61%); }
/* the folded-back flap, sitting just inside the cut */
.note::after{
  content:""; position:absolute; right:0; bottom:0;
  width:var(--fold); height:var(--fold);
  background:linear-gradient(135deg, var(--fold-ink) 50%, transparent 50%);
}
.note-t{ display:block; overflow-wrap:anywhere; }

/* the hinge line, in the same slot as the narration and the banner word */
/* Centred, not in the top slot. By the time it arrives the wall has gone and the frame is
   empty, so the sentence is the only thing in it and belongs in the middle. */
.bridge{
  position:absolute; left:50%; top:50%; transform:translate(-50%, -50%);
  width:min(660px, 62vw); text-align:center; z-index:20; pointer-events:none;
}
/* The swash under the hinge. Same hand as the ring round the narration: same colour, same round
   caps, drawn rather than placed. */
.bridge-under{
  position:absolute; pointer-events:none; overflow:visible;
  fill:none; stroke:var(--accent);
  stroke-width:clamp(2px, .26vw, 3.6px);
  stroke-linecap:round; stroke-linejoin:round;
  opacity:.92;
}
.bridge p{
  margin:0; font-size:clamp(1.4rem, 3.4vw, 2.9rem); font-weight:600;
  letter-spacing:-.028em; line-height:1.12; text-wrap:balance;
  text-shadow:0 0 52px hsl(210 40% 96% / .24), 0 0 16px hsl(210 40% 96% / .11);
}

/* ── act 2: the plan ──────────────────────────────────────────────── */
.plan-inner{ position:absolute; inset:var(--banner) 0 0 0; }
/* Portrait stacks the plan into six rows instead of four columns, so it uses the full height and
   the last row lands exactly where the next-section button sits. --foot is that button's own
   footprint, already measured for the acts that centre themselves; the plan has to reserve it
   too. Landscape is untouched: there the diagram is wide and stops well short of the bottom. */
@media (orientation: portrait){
  .plan-inner{ inset:var(--banner) 0 var(--foot) 0; }
}
.grid, .plan-boxes, .plan-links{ position:absolute; inset:0; width:100%; height:100%; }
.grid line{ stroke:var(--fg); stroke-width:1; opacity:0; }
/* Colour is doing a job here, not decorating: it says what kind of thing each box is, the same
   way the shape does. The gateway gets the site accent because it is the one way in, and every
   other kind gets a muted hue that clears 5:1 on the ground. */
.plan-boxes .nfill{ fill:var(--surface); stroke:none; opacity:0; }
.plan-boxes .nline{ fill:none; stroke-width:1.7; opacity:0; stroke-linejoin:round; }
.nline.k-client { stroke:hsl(199 60% 62%); }
.nline.k-edge   { stroke:var(--accent); stroke-width:2.1; }
.nline.k-service{ stroke:hsl(158 42% 58%); }
.nline.k-queue  { stroke:hsl(272 46% 72%); }
.nline.k-store  { stroke:hsl(342 48% 70%); }
.plan-links polyline{ fill:none; stroke:var(--accent); stroke-width:1.5; opacity:0; stroke-linecap:round; stroke-linejoin:round; }
/* Fill and outline are both SVG now, because a hexagon and a cylinder cannot be a div. This
   is the label and nothing else. */
.node{ position:absolute; display:grid; place-items:center; opacity:0; pointer-events:none; }
.node span{
  position:relative; white-space:nowrap;
  font-family:var(--mono); font-size:clamp(.68rem, .95vw, .92rem);
  letter-spacing:.08em; color:var(--fg); opacity:0;
}

/* ── act 3: the design ────────────────────────────────────────────────
 * The CEO's palette, used as given. Five colours plus a ground, because none of the five works
 * as a full-bleed background behind a UI. Weighted rather than flat: indigo carries the hero,
 * cyan the one highlighted card, slate every neutral surface, amber the single action, and
 * coral appears exactly once per screen as a badge.
 */
:root{
  --d-slate:    #484A6F;
  --d-indigo:   #6967CE;
  --d-cyan:     #19AFD0;
  --d-coral:    #FD636B;
  --d-amber:    #FFB902;

  --d-bg:       hsl(236 27% 10%);
  --d-surface:  hsl(236 21% 16%);
  --d-surface2: var(--d-slate);
  --d-line:     hsl(236 18% 32%);
  --d-text:     hsl(232 34% 96%);
  --d-muted:    hsl(236 16% 66%);
  --d-primary:  var(--d-amber);
  --d-brand:    var(--d-indigo);
  --d-brand2:   var(--d-cyan);
  --d-onBrand:  hsl(196 62% 11%);
}/* justify-content:center, not the default. Auto columns STRETCH to fill the stage, and the
   devices then centre inside their own oversized tracks, which put ~130px of dead space
   between them however small the gap was set. */
.sc-design{
  display:grid; place-items:center; justify-content:center;
  grid-auto-flow:column; gap:clamp(.5rem, 1.2vw, 1.6rem); padding-top:var(--banner); padding-bottom:var(--foot);
}

/* The tablet belongs to the stacked layout only. Beside a browser and a phone in one wide row it
   is a third thing competing for the same horizontal space and all three come out too small to
   read; on a phone there is a whole second row for it to share with the handset. */
.tablet{ display:none; }

/* Both devices are also capped against the viewport height, so a short window shrinks them
   instead of running them into the banner. 1.6 and 2.167 are their own aspect ratios. */
.win{
  --chrome:calc(var(--tabh) + var(--toolh));
  --tabh:clamp(20px, 2.5vh, 30px);
  --toolh:clamp(22px, 2.8vh, 34px);
  width:min(64vw, 980px, 96vh); aspect-ratio:16/10;
  background:var(--d-bg); border:1px solid var(--d-line);
  border-radius:12px; overflow:hidden;
  box-shadow:0 40px 80px -40px rgb(0 0 0 / .9);
}
.win-tabs, .win-tools{
  display:flex; align-items:center; gap:.55rem; padding-inline:.7rem;
  font-family:var(--mono); font-size:clamp(.46rem, .66vw, .64rem); color:hsl(213 12% 62%);
}
.win-tabs{ height:var(--tabh); background:hsl(213 24% 9%); }
.win-tools{ height:var(--toolh); background:var(--d-surface); border-bottom:1px solid hsl(213 16% 24%); }
.dots{ display:inline-flex; gap:5px; margin-right:.35rem; }
.dots i{ width:8px; height:8px; border-radius:50%; background:hsl(213 12% 38%); }
.dots i:first-child{ background:hsl(4 62% 58%); }
.dots i:nth-child(2){ background:hsl(42 68% 56%); }
.dots i:last-child{ background:hsl(140 42% 50%); }
/* Height from the strip, not from padding: a padded line box computes taller than --tabh on a
   short window, and .win's overflow:hidden then shaves the tab's top corners flat. */
.tab{
  height:calc(var(--tabh) - 3px); display:flex; align-items:center;
  border-radius:5px 5px 0 0; padding:0 .85em; white-space:nowrap;
  align-self:flex-end; line-height:1;
}
.tab.on{ background:var(--d-surface); color:hsl(213 18% 80%); }
.tab.off{ background:transparent; color:hsl(213 10% 44%); }
.tab-add{ color:hsl(213 10% 44%); padding-inline:.2em; }
.ico{
  width:1.6em; height:1.6em; flex:none;
  fill:none; stroke:hsl(213 12% 58%); stroke-width:1.9;
  stroke-linecap:round; stroke-linejoin:round;
}
.ico.dim{ stroke:hsl(213 10% 36%); }
.ico.fill{ fill:hsl(213 12% 52%); stroke:none; }
.ico.lock{ width:1.15em; height:1.15em; stroke-width:2.1; }
.urlbar{
  flex:1; max-width:56%; display:flex; align-items:center; gap:.35em;
  background:hsl(213 26% 8%); border:1px solid hsl(213 16% 22%);
  border-radius:99px; padding:.26em .8em; margin-inline:.35rem; white-space:nowrap;
}
.win-body{ position:relative; height:calc(100% - var(--chrome)); }

.phone{
  position:relative; width:min(18.5vw, 262px, 28.6vh); aspect-ratio:9/19.5;
  background:hsl(213 24% 7%);
  border-radius:clamp(20px, 2.4vw, 34px); padding:.5%;
  /* the bezel is a ring, not a border, so it can carry a lit edge like real hardware */
  box-shadow:0 0 0 2px hsl(213 14% 30%), inset 0 0 0 1px hsl(213 18% 44% / .5),
             0 40px 80px -40px rgb(0 0 0 / .9);
}
.pbtn{
  position:absolute; width:2px; border-radius:2px; background:hsl(213 14% 32%);
}
.phone .mute { left:-3px; top:13%; height:4%; }
.phone .volup{ left:-3px; top:20%; height:7%; }
.phone .voldn{ left:-3px; top:29%; height:7%; }
.phone .pwr  { right:-3px; top:22%; height:11%; }
.phone-island{
  position:absolute; left:50%; top:2.2%; transform:translateX(-50%);
  width:30%; height:2.4%; min-height:10px; border-radius:99px; background:hsl(213 26% 5%); z-index:3;
}
/* The tablet. Plainer hardware than the phone on purpose: an even bezel, one camera dot, and no
   side buttons, so the two devices beside each other do not read as the same machine twice. */
.tablet{
  position:relative; width:min(24vw, 340px, 34vh); aspect-ratio:4/3;
  background:hsl(213 24% 7%);
  border-radius:clamp(14px, 1.6vw, 22px); padding:1.6%;
  box-shadow:0 0 0 2px hsl(213 14% 30%), inset 0 0 0 1px hsl(213 18% 44% / .5),
             0 40px 80px -40px rgb(0 0 0 / .9);
}
.tcam{
  position:absolute; top:50%; left:.5%; width:4px; height:4px; margin-top:-2px;
  border-radius:50%; background:hsl(213 14% 34%);
}
.tablet-screen{
  position:relative; height:100%; background:var(--d-bg);
  border-radius:clamp(8px, 1vw, 14px); overflow:hidden;
}
.phone-screen{
  position:relative; height:100%; background:var(--d-bg);
  border-radius:clamp(14px, 1.8vw, 26px); overflow:hidden;
}
/* iOS chrome: arrives with the device, not with the design drawn inside it */
.ios-status{
  position:absolute; inset:0 0 auto 0; height:6.5%; z-index:2;
  display:flex; align-items:center; justify-content:space-between;
  padding:0 7%; color:var(--d-text);
  font-size:clamp(.4rem, .58vw, .56rem); font-weight:700; letter-spacing:.01em;
}
.ios-sys{ display:inline-flex; align-items:center; gap:.3em; }
.ios-sys svg{ height:.85em; width:auto; fill:var(--d-text); stroke:none; }
.ios-sys .wifi{ fill:none; stroke:var(--d-text); stroke-width:1.5; stroke-linecap:round; }
.ios-sys .batt rect:first-child{ fill:none; stroke:var(--d-text); stroke-width:1.1; opacity:.45; }
.ios-sys .batt .cell{ fill:var(--d-text); }
.ios-home{
  position:absolute; left:50%; bottom:1.1%; transform:translateX(-50%); z-index:2;
  width:34%; height:.55%; min-height:3px; border-radius:99px; background:var(--d-text); opacity:.55;
}

/* The block is the colour; the outline above it is the stroke that draws. */
.blk-lines{ position:absolute; inset:0; width:100%; height:100%; pointer-events:none; }
.blk-lines rect{ fill:none; stroke:var(--d-line); stroke-width:1.1; opacity:0; }
.blk{
  position:absolute; --fill:0;
  /* colour is wiped across each block, not faded onto it, with a soft leading edge */
  background:linear-gradient(96deg,
    var(--tone) calc(var(--fill) * 118% - 18%),
    transparent calc(var(--fill) * 118%));
}

/* ── act 4: the release ─────────────────────────────────────────────
 * A deploy console filling the frame. Terminal chrome, not browser chrome, so it does not read
 * as a second copy of act 3, and three rhythms running slightly out of phase: the log filling
 * in, the edges lighting up beside it, the bar and the clock underneath.
 */
.sc-ship{ display:grid; place-items:center; padding-top:var(--banner); padding-bottom:var(--foot); }
.term{
  /* also capped against height, since a console is sized by whichever axis runs out first */
  width:min(1140px, 92vw, 112vh); aspect-ratio:16/9.4;
  display:grid; grid-template-rows:auto minmax(0,1fr) auto;
  background:hsl(222 30% 6.5%); border:1px solid var(--line); border-radius:14px;
  overflow:hidden; box-shadow:0 50px 90px -50px rgb(0 0 0 / .95);
  font-family:var(--mono);
}
.term-bar{
  height:clamp(28px, 3.6vh, 42px); display:flex; align-items:center; gap:.9rem;
  padding-inline:1rem; background:hsl(222 26% 9.5%); border-bottom:1px solid var(--line);
}
.term-title{ font-size:clamp(.55rem, .74vw, .72rem); letter-spacing:.1em; color:var(--fg-3); }
.term-body{ display:grid; grid-template-columns:minmax(0,1.85fr) minmax(0,1fr); min-height:0; }

.term-log{
  padding:clamp(.9rem, 2.6vh, 2rem) clamp(1rem, 2vw, 2.2rem);
  display:grid; align-content:start; gap:clamp(.4rem, 1.35vh, 1rem);
  font-size:clamp(.6rem, .88vw, .86rem);
}
.log{
  display:grid; grid-template-columns:1.6em 1fr auto; align-items:center; gap:.7em;
  color:var(--fg-3); opacity:0; letter-spacing:.08em;
}
.log-sub{
  grid-column:1 / -1; margin:-.25em 0 .35em 2.3em; opacity:0;
  font-size:.86em; color:var(--fg-3); opacity:0; letter-spacing:.08em;
}
.log-cmd{ color:var(--fg-2); margin-bottom:.4em; }
.log-cmd .lg-p{ color:var(--accent-text); }
.lg-p{ display:grid; place-items:center; }
.lg-p svg{
  width:1.15em; height:1.15em; fill:none; stroke:var(--line-2);
  stroke-width:2.6; stroke-linecap:round; stroke-linejoin:round;
}
.log[data-state="done"]{ color:var(--fg); }
.log[data-state="done"] .lg-p svg{ stroke:var(--accent); }
.log[data-state="running"] .lg-p svg{ stroke:var(--accent-dim); }
.lg-time{ color:var(--fg-3); font-variant-numeric:tabular-nums; }
.log[data-state="done"] .lg-time{ color:var(--fg-2); }
.log-done{ margin-top:.9em; color:var(--accent-text); }
.log-done .lg-name{ letter-spacing:.08em; }
.log-done .lg-time{ color:var(--accent-text); }

.term-side{
  border-left:1px solid var(--line); background:hsl(222 28% 5.5%);
  padding:clamp(.9rem, 2.6vh, 2rem) clamp(.9rem, 1.5vw, 1.6rem);
  display:grid; grid-template-rows:auto 1fr auto; gap:clamp(.6rem, 1.8vh, 1.2rem); min-width:0;
}
.side-f{
  margin:0; opacity:0; align-self:end;
  font-size:clamp(.5rem, .68vw, .64rem); letter-spacing:.1em; color:var(--fg-3);
}
.side-h{
  margin:0; font-size:clamp(.5rem, .68vw, .64rem); letter-spacing:.24em;
  text-transform:uppercase; color:var(--fg-3);
}
.edges{ display:grid; align-content:start; gap:clamp(.4rem, 1.35vh, 1rem); }
.edge{
  display:grid; grid-template-columns:auto 1fr auto; align-items:center; gap:.7em;
  font-size:clamp(.58rem, .82vw, .8rem); color:var(--fg-3); letter-spacing:.08em;
}
.e-dot{
  width:7px; height:7px; border-radius:50%; background:var(--line-2);
}
.edge[data-state="running"] .e-dot{
  background:var(--accent-dim);
  animation:beat 1.1s var(--ease) infinite;
}
.edge[data-state="done"] .e-dot{ background:var(--accent); }
.edge[data-state="done"] .e-name{ color:var(--fg); }
.e-ms{ font-variant-numeric:tabular-nums; font-size:.86em; opacity:.7; }
@keyframes beat{
  0%   { box-shadow:0 0 0 0 hsl(var(--accent-h) var(--accent-s) var(--accent-l) / .55); }
  70%  { box-shadow:0 0 0 7px hsl(var(--accent-h) var(--accent-s) var(--accent-l) / 0); }
  100% { box-shadow:0 0 0 0 hsl(var(--accent-h) var(--accent-s) var(--accent-l) / 0); }
}

.term-foot{
  border-top:1px solid var(--line); background:hsl(222 26% 8%);
  height:clamp(30px, 4.2vh, 48px); display:flex; align-items:center; gap:1rem;
  padding-inline:clamp(1rem, 2vw, 2.2rem);
  font-size:clamp(.55rem, .78vw, .74rem); color:var(--fg-2);
  letter-spacing:.1em; font-variant-numeric:tabular-nums;
}
.fbar{ flex:1; height:3px; border-radius:3px; background:var(--line); overflow:hidden; }
.fbar i{ display:block; height:100%; background:var(--accent); transform:scaleX(0); transform-origin:left; }
.fpct{ color:var(--accent-text); min-width:3.4em; text-align:right; }
.fclock{ min-width:4.2em; text-align:right; }

/* ── act 5: the close ─────────────────────────────────────────────── */
.sc-end{ display:grid; place-content:center; text-align:center; padding-inline:var(--gutter); padding-top:var(--banner); padding-bottom:var(--foot); }
/* Three rings around the name, behind it. Each is a hairline, and the bigger it is the fainter
   it gets, so the whole thing reads as one ripple rather than as three circles. */
.ripple{ position:absolute; left:50%; z-index:0; pointer-events:none; }
.ripple i{
  position:absolute; left:0; top:0;
  width:calc(var(--ring, 0px) * var(--k)); aspect-ratio:1;
  border:1px solid var(--fg); border-radius:50%; opacity:var(--o, 0);
  transform:translate(-50%, -50%) scale(var(--s, 1));
  will-change:transform, opacity;
}
.end-name, .end-line, .end-kick{ position:relative; z-index:1; }
.end-name{
  margin:0; font-size:clamp(2.2rem, 6.2vw, 5.2rem); font-weight:640;
  letter-spacing:-.028em; line-height:.98;
  font-variation-settings:"wdth" 98;
  text-shadow:0 0 70px hsl(210 40% 96% / .26), 0 0 22px hsl(210 40% 96% / .12);
}
/* Both lines were quieter than the sentence they are the payoff for: --fg-2 is 63% lightness,
   which is a caption colour, and the kicker was 11px of tracked-out mono. */
.end-line{
  margin:1.25rem auto 0; max-width:46ch;
  font-size:clamp(1.1rem, 2vw, 1.6rem); line-height:1.35; color:var(--fg);
  text-wrap:balance; text-shadow:0 0 44px hsl(210 40% 96% / .20);
}
.end-kick{
  margin:1.1rem auto 0; max-width:54ch;
  font-size:clamp(.92rem, 1.35vw, 1.12rem); line-height:1.45;
  color:var(--accent-text); font-weight:500; text-wrap:balance;
  text-shadow:0 0 40px hsl(var(--accent-h) var(--accent-s) 62% / .34);
}

/* ── section frame ──────────────────────────────────────────── */
/* No rule between sections. The generous padding-block is what separates them; a hairline on top
   of that was drawing the seam twice, and it fought the AI section's ground where the light
   crosses the boundary. */
.sec{ padding-block:calc(var(--u) * 6); }
/* The ledger and the capability grid are scanned, not read, so they take the full frame while
   the prose above them keeps its measure. Without this the lower page is one column at one
   width with one left edge, which is what every dark portfolio looks like. */
@media (min-width: 1080px){
  #work .clients, #stack .jobs{
    width:calc(100% + var(--gutter) * 1.6);
    margin-inline:calc(var(--gutter) * -.8);
  }
  .sec{ padding-block:calc(var(--u) * 7); }
}
.sec-label{
  font-family:var(--mono); font-size:.68rem; letter-spacing:.2em; text-transform:uppercase;
  color:var(--accent-text); display:inline-flex; align-items:center; gap:.6rem;
  transition:color .35s var(--ease);
}
.sec-label::before{ content:""; width:22px; height:1px; background:currentColor; display:block; }

.sec h2{
  margin:calc(var(--u) * .8) 0 0;
  font-size:clamp(1.85rem,3.6vw,3.05rem); line-height:1.04;
  letter-spacing:-.024em;
  /* 19ch was set when these headings were long and left-aligned, and it forced a two-line break
     on every one of them. The copy is shorter now, so the cap only has to stop a heading running
     the full container: short ones sit on one line, long ones still break, and balance decides
     where. */
  font-weight:700; font-variation-settings:"wdth" 96; text-wrap:balance; max-width:34ch;
}
.sec-lede{
  /* Wide enough that a two-sentence lede breaks between its sentences instead of mid-phrase.
     At 56ch the contact line split "long-term" across the break, leaving a hanging hyphen. */
  margin:calc(var(--u) * 1.1) 0 0; max-width:70ch; color:var(--fg-2);
  font-size:clamp(1.02rem, 1.35vw, 1.16rem); line-height:1.55;
}
/* Placed after .sec h2 and .sec-lede deliberately. `.sec > h2` and `.sec h2` have identical
   specificity, so whichever comes last wins -- and .sec h2 sets `margin: X 0 0`, whose shorthand
   resets margin-inline to 0 and un-centres the heading. Sitting above it, this rule looked right
   in the stylesheet and did nothing on the page. *//* Section headers are centred, and the label carries a rule on BOTH sides rather than only on
   its left. Scoped to direct children of .sec on purpose: the vetted panel puts a .sec-label and
   a .sec-lede inside a copy column beside the badge, and that is a two-column component rather
   than a section header. Centring a column against the thing it sits next to reads as a mistake,
   so it keeps the single left rule and its own alignment. */
.sec > .sec-label{ display:flex; justify-content:center; }
.sec > .sec-label::after{
  content:""; width:22px; height:1px; background:currentColor; display:block;
}
.sec > h2{ text-align:center; margin-inline:auto; }
/* balance, not the default: centred ragged text puts any orphan dead in the middle of the
   column where it is impossible to miss. The contact lede was ending on the word "too." alone. */
/* pretty, not balance. balance equalises line LENGTHS, which on a two-sentence lede drags words
   back across the sentence boundary to even the halves out; pretty just refuses to leave an
   orphan. The copy is short enough now that the orphan balance was hired to fix is gone. */
.sec > .sec-lede{ text-align:center; margin-inline:auto; text-wrap:pretty; }
/* Everything between the two rules is one label and reads as one colour. This span used to drop
   to --fg-3, which split the line into a bright half and a grey half for no reason the reader
   could see. Only the separator stays dimmer, and it is dimmer accent, not grey. */
.sec-n{ letter-spacing:.08em; }
.sec-n::before{ content:"· "; opacity:.55; }

.inline-link{
  color:var(--accent-text); text-decoration-color:var(--accent-dim);
  text-underline-offset:3px;
  transition:text-decoration-color .2s var(--ease), transform var(--press);
}
/* Emphasis in the page's own family. A serif italic dropped into an Archivo sentence is
   mixed-family emphasis, which reads as decoration; the same emphasis in Archivo reads as a
   decision and does not cost a font request on the critical path. */
.sec-lede strong{ font-weight:650; color:var(--fg); }

/* ── vetted ──────────────────────────────────────────────────
 * The one place on the page that is allowed to carry another company's colour. A credential has
 * to look like it came from somewhere else — blending it into the site's palette would quietly
 * destroy the only thing it is for.
 */
/* The panel keeps the same padding as every other section. It used to run 4.5u against their
   6u, which made the two gaps around it 179px where every other gap on the page was 203. It is
   a panel rather than a run of prose, but that is a reason for it to look different inside its
   own border, not a reason for the page's rhythm to break around it. */
.vetted{ border-top:0; }
/* The first section after the hero carries the whole seam by itself. Every other boundary on the
   page is two paddings meeting, one from each side; above this one is a full-viewport stage
   whose gridlines run to its own bottom edge, so it contributes nothing and a single 6u here
   read as exactly half the space of every other seam. 12u makes the first gap the same size as
   the rest. */
#vetted{ padding-top:calc(var(--u) * 12); }
@media (min-width: 1080px){ #vetted{ padding-top:calc(var(--u) * 14); } }
.vet{
  display:grid; grid-template-columns:auto minmax(0,1fr); gap:clamp(1.6rem, 4vw, 3.4rem);
  align-items:center;
  background:linear-gradient(100deg, hsl(214 60% 12% / .55), transparent 62%);
  border:1px solid hsl(214 40% 24%); border-radius:calc(var(--radius) * 1.6);
  box-shadow:var(--shadow-lg);
  padding:clamp(1.4rem, 3vw, 2.4rem);
}
.vet-badge{ flex:none; display:grid; place-items:center; }

/* Toptal's badge. Geometry, colour and type are theirs, unchanged; only the names are. Their
   snippet ships six single-letter classes (.a .b .c .d .f .h) and puts --h on :root — none of
   which collide with this page TODAY, and all of which are a collision waiting for the first
   person who reaches for .c. Namespaced rather than trusted. */
.tt-hex{
  --tt-clip:polygon(50% 0, 100% 24%, 100% 76%, 50% 100%, 0 76%, 0 24%);
  display:inline-block; background:#25a9ef; padding:6px; clip-path:var(--tt-clip);
  font-family:proxima-nova, Arial, Helvetica, sans-serif;
}
.tt-face{
  width:200px; padding:24px 0 40px;
  display:flex; flex-direction:column; align-items:center; gap:8px;
  color:#fff; text-align:center;
  background:linear-gradient(153deg, #0667ff 18%, #204ecf 40%, #0f256e 80%);
  clip-path:var(--tt-clip);
}
.tt-top{ margin:0; font-size:19px; font-weight:700; line-height:1; }
.tt-rule{ width:120px; height:1px; background:#25a9ef; }
.tt-by{ font-size:16px; opacity:.8; margin-bottom:-6px; }
.tt-cta{
  display:inline-flex; align-items:center; justify-content:center;
  padding:4px 20px; border-radius:6px; background:#296bff; color:#fff;
  font-size:16px; font-weight:500;
  text-decoration-thickness:.5px; text-underline-offset:2px;
}
.vet-copy .sec-label{ color:hsl(210 80% 66%); }
.vet-copy h2{ max-width:none; }
.vet-copy .sec-lede{ margin-top:calc(var(--u) * .9); }

/* ── work / clients ──────────────────────────────────────────
 * Cards, not a ledger. The ledger was a name and a row of glyphs per row, which answered "who"
 * and stopped there: a buyer who recognised a name still had no idea what the name had bought.
 * The card carries the role and one sentence, which is the part that was missing.
 *
 * auto-fit rather than a fixed column count, so one rule gives three columns at 1440, two
 * around 900 and one on a phone, with no breakpoint of its own.
 */
.tech-sprite{ position:absolute; width:0; height:0; overflow:hidden; }
/* A bento, placed rather than flowed.
 *
 * The first attempt auto-flowed eleven identical tiles and simply made three of them 2x2, which
 * is not a bento: identical content in a bigger box is the same card with more air in it, and
 * the three big tiles read as holes. Two things fix that, and both are needed.
 *
 * One, the composition is POSITIONED. Each tile carries its own grid-area from build/clients.py,
 * so the layout is a decision in the data rather than whatever the packing algorithm produced.
 *
 * Two, tile size changes what is IN the tile. A small tile is a compact identity: name, role,
 * two lines of what the work was, the stack, the link. A big tile gets the full sentence, larger
 * type and larger marks. Density scales with the box, so nothing is padded out to fit.
 */
.clients{
  list-style:none; margin:calc(var(--u) * 2.6) 0 0; padding:0;
  display:grid; gap:clamp(.7rem, 1vw, .95rem);
  /* Two columns even on a phone, with the three biggest tiles running full width. One flat stack
     of eleven identical rectangles is not a bento, it is a list: the composition disappears and
     the section stops saying that some of this work was bigger than the rest. Two tracks keep the
     size difference legible and cut the section's height roughly in half. */
  grid-template-columns:repeat(2, 1fr); grid-auto-flow:dense;
}
.client-hero, .client-md{ grid-column:span 2; }
/* One card per row on a phone. Two tracks halve the section's height, but they also halve the
   width each card has to say what the work was, and at 390 that is 169px for a name, a role and
   a sentence. The bento reads at 620 and up. */
/* Two columns is now the base, so 760 has nothing left to add. Dense packing is what stops a
   small tile that lands just before a full-width one from leaving the rest of its row empty. */
@media (min-width: 1180px){
  .clients{
    grid-template-columns:repeat(4, 1fr);
    /* A floor, not a target. Nothing is truncated, so a row grows to whatever its tallest tile
       needs and grid stretches the rest of that row to match. The minimum only stops a row of
       very short tiles from collapsing out of rhythm with the others. */
    grid-auto-rows:minmax(158px, auto);
  }
  .client{ grid-area:var(--ga); }
}

.client{
  display:flex; flex-direction:column; gap:.5rem;
  padding:clamp(1.05rem, 1.8vw, 1.4rem);
  position:relative;
  /* No border at all: the ring in ::after is the border. border-image would have been the
     obvious tool and is useless here, because it ignores border-radius and would square off
     every corner.
     The card must NOT keep a transparent 1px border alongside the ring. An absolutely positioned
     child is laid out against its ancestor's PADDING box, so inset:0 lands the ring inside that
     border while border-radius:inherit copies the radius meant for the outer box; the two arcs
     then disagree and the ring escapes the card at the corners. With no border the padding box
     and the border box are the same rectangle and the arcs match exactly. box-sizing is
     border-box globally, so removing it costs no width. */
  border:0; border-radius:var(--radius);
  transition:background-color .25s var(--ease), transform var(--press);
  /* The client's own colour, washed in from the corner the eye enters by. This is a deliberate
     reversal of the note on the vetted band, which used to claim that band was the only place
     allowed to carry another company's colour: eleven identical cards read as a list, and the
     wash is what makes a name recognisable before it is read.
     --cl-rgb is a space-separated triplet set per card. A client whose colour has not been
     confirmed from their own site leaves it undefined, which makes the gradient invalid at
     computed-value time, so it simply does not paint and the card falls back to flat surface. */
  background-color:var(--surface);
  /* --cl-a damps a brand brighter than the wash ceiling; it is 1 for every brand under it.
     See build/clients.py for the measurement this is derived from. */
  background-image:radial-gradient(150% 125% at 100% 0%,
     rgb(var(--cl-rgb) / calc(.34 * var(--cl-a, 1))) 0%,
     rgb(var(--cl-rgb) / calc(.17 * var(--cl-a, 1))) 38%,
     rgb(var(--cl-rgb) / calc(.07 * var(--cl-a, 1))) 68%,
     rgb(var(--cl-rgb) / calc(.02 * var(--cl-a, 1))) 100%);
}

/* A brand with four colours gets four layers, swept right to left across the head of the card,
   each one damped by its own luminance. They overlap deliberately: the blend between them is the
   point, and it is what stops this card reading as a near-twin of whichever single hue would
   otherwise have been picked. */
/* The ring. inset:0 covers the border box, padding:1px plus an xor mask leaves exactly the 1px
   the border would have occupied, and border-radius:inherit keeps it on the corner curve. A card
   with no --cl-rgb falls back to the flat line colour, same as before. */
.client::after{
  content:""; position:absolute; inset:0; border-radius:inherit; padding:1px;
  pointer-events:none; z-index:1;
  background:var(--line);
  -webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
          mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite:xor; mask-composite:exclude;
  transition:opacity .25s var(--ease);
}
.client[style*="--cl-rgb"]::after{
  background:radial-gradient(150% 125% at 100% 0%,
     rgb(var(--cl-rgb) / calc(.62 * var(--cl-a, 1))) 0%,
     rgb(var(--cl-rgb) / calc(.34 * var(--cl-a, 1))) 40%,
     var(--line-2) 78%, var(--line) 100%);
}
.client-multi::after{
  background:
    radial-gradient(78% 78% at 100%  0%, rgb(var(--cl-1) / calc(.55 * var(--cl-1a,1))), transparent 70%),
    radial-gradient(72% 72% at  68%  0%, rgb(var(--cl-2) / calc(.5  * var(--cl-2a,1))), transparent 68%),
    radial-gradient(72% 74% at  34%  2%, rgb(var(--cl-3) / calc(.48 * var(--cl-3a,1))), transparent 68%),
    radial-gradient(76% 80% at   0% 10%, rgb(var(--cl-4) / calc(.48 * var(--cl-4a,1))), transparent 72%),
    linear-gradient(var(--line), var(--line));
}

.client-multi{
  background-image:
    radial-gradient(78% 78% at 100%  0%, rgb(var(--cl-1) / calc(.30 * var(--cl-1a,1))), transparent 68%),
    radial-gradient(72% 72% at  68%  0%, rgb(var(--cl-2) / calc(.28 * var(--cl-2a,1))), transparent 66%),
    radial-gradient(72% 74% at  34%  2%, rgb(var(--cl-3) / calc(.26 * var(--cl-3a,1))), transparent 66%),
    radial-gradient(76% 80% at   0% 10%, rgb(var(--cl-4) / calc(.26 * var(--cl-4a,1))), transparent 70%);
}

/* The small tile. Two lines of the sentence is enough to say what the work was; the full version
   belongs on a tile with room for it, and clamping is what keeps a 196px unit at 196px instead of
   letting one long blurb stretch the whole row it sits in. */
@media (min-width: 1180px){
  .client:not(.client-hero):not(.client-md) .cl-note{
    font-size:.8rem; line-height:1.45; margin-top:.1rem;
  }
  .client:not(.client-hero):not(.client-md) .cl-name{ font-size:clamp(.98rem, 1.25vw, 1.12rem); }
  .client:not(.client-hero):not(.client-md) .t svg{ width:18px; height:18px; }
  .client:not(.client-hero):not(.client-md) .cl-tech{ gap:.5rem; padding-top:.3rem; }
  .client:not(.client-hero):not(.client-md){ padding:1.05rem 1.15rem; gap:.35rem; }
  .client:not(.client-hero):not(.client-md) .cl-site{ font-size:.63rem; }
}
/* The big tile is not the small one enlarged. The name steps up two sizes, the sentence runs in
   full at a reading size, and the marks grow with it, so the extra room is spent on content
   rather than on air. */
.client-hero{ padding:clamp(1.4rem, 2.2vw, 2rem); gap:.6rem; }
.client-hero .cl-name{ font-size:clamp(1.45rem, 2.4vw, 2.05rem); letter-spacing:-.03em; }
.client-hero .cl-role{ font-size:.68rem; }
.client-hero .cl-note{ font-size:clamp(1rem, 1.25vw, 1.14rem); line-height:1.62; max-width:31ch; }
.client-hero .cl-tech{ gap:clamp(.6rem, 1.1vw, .95rem); }
.client-hero .t svg{ width:clamp(23px, 2vw, 28px); height:clamp(23px, 2vw, 28px); }
/* On a big tile the stack is NAMED, not just drawn. The markup already carries each name in a
   .sr-only span for screen readers, so the big tile simply stops hiding it: the row becomes
   labelled pills in the same language the Skills section speaks, which fills the height with
   information instead of the air the first attempt padded it out with. A buyer scanning a
   feature tile can read "Storybook" rather than being asked to recognise a glyph. */
.client-hero .cl-tech{ gap:.45rem; margin-top:auto; padding-top:.9rem; }
.client-hero .t{
  display:inline-flex; align-items:center; gap:.4em;
  padding:.34em .7em .34em .52em;
  border:1px solid var(--line-2); border-radius:99px;
  transition:border-color .2s var(--ease), background .2s var(--ease);
}
.client-hero .t svg{ width:16px; height:16px; flex:none; }
.client-hero .t .sr-only{
  position:static; width:auto; height:auto; margin:0; padding:0; overflow:visible;
  clip-path:none; white-space:nowrap;
  font-size:.76rem; font-weight:550; line-height:1; color:var(--fg);
}
/* the hover tooltip was only ever there because the glyph was unlabelled */
.client-hero .t::after{ content:none; }

/* The middle tier. Wide and short: two columns of width but one row of height, which is a shape
   neither of its neighbours has, and that is the whole job of a medium tile. It takes the
   labelled pills because it has the width for them, and keeps the sentence to two lines because
   it does not have the height. */
.client-md{ padding:clamp(1.15rem, 1.7vw, 1.5rem); }
.client-md .cl-name{ font-size:clamp(1.15rem, 1.7vw, 1.4rem); letter-spacing:-.025em; }
.client-md .cl-note{ font-size:.88rem; line-height:1.5; max-width:62ch; }
.client-md .cl-tech{ gap:.4rem; margin-top:auto; padding-top:.7rem; }
.client-md .t{
  display:inline-flex; align-items:center; gap:.38em;
  padding:.3em .62em .3em .46em;
  border:1px solid var(--line-2); border-radius:99px;
  transition:border-color .2s var(--ease), background .2s var(--ease);
}
.client-md .t svg{ width:15px; height:15px; flex:none; }
.client-md .t .sr-only{
  position:static; width:auto; height:auto; margin:0; padding:0; overflow:visible;
  clip-path:none; white-space:nowrap;
  font-size:.72rem; font-weight:550; line-height:1; color:var(--fg);
}
.client-md .t::after{ content:none; }

/* Placed AFTER the three tier blocks, not before them. `.client .cl-name` and
   `.client-md .cl-name` have identical specificity, so the one that comes last wins -- sitting
   above them this whole block set the name and the sentence for the plain cards only, and the
   two tiers that already had rules kept theirs. Glassdoor stayed a size below Subject7. */
@media (max-width: 620px){
  .clients{ grid-template-columns:1fr; }
  /* The span has to go with the track. `span 2` inside a one-column grid does not clamp to the
     column that exists -- it creates an implicit second one -- so the grid was still two wide
     with every small card in the left half. */
  .client-hero, .client-md{ grid-column:auto; }

  /* Three tiers of card is a size language, and a size language needs sizes to differ. Every
     card is the same width here, so the tiers say nothing and the only thing left of them is
     that some cards name their stack and some show it as unlabelled glyphs, which reads as an
     inconsistency rather than as a hierarchy. So every card takes the big tile's treatment:
     the same name, the same sentence, and the labelled pills, which is the version that
     actually answers "what was this built with" without asking anyone to recognise a logo. */
  .client, .client-hero, .client-md{ padding:1.4rem; gap:.6rem; }
  .client .cl-name{ font-size:1.45rem; letter-spacing:-.03em; }
  .client .cl-role{ font-size:.68rem; }
  .client .cl-note{ font-size:1rem; line-height:1.62; max-width:none; }
  .client .cl-tech{ gap:.45rem; margin-top:auto; padding-top:.9rem; }
  .client .t{
    display:inline-flex; align-items:center; gap:.4em;
    padding:.34em .7em .34em .52em;
    border:1px solid var(--line-2); border-radius:99px;
    transition:border-color .2s var(--ease), background .2s var(--ease);
  }
  .client .t svg{ width:16px; height:16px; flex:none; }
  .client .t .sr-only{
    position:static; width:auto; height:auto; margin:0; padding:0; overflow:visible;
    clip-path:none; white-space:nowrap;
    font-size:.76rem; font-weight:550; line-height:1; color:var(--fg);
  }
  /* the tooltip only existed because the glyph was unlabelled */
  .client .t::after{ content:none; }
}
.cl-head{ display:flex; align-items:center; gap:.55rem; flex-wrap:wrap; min-width:0; }
.cl-name{
  margin:0; font-size:clamp(1.02rem, 1.45vw, 1.25rem); font-weight:650;
  letter-spacing:-.02em; line-height:1.15; color:var(--fg);
}
/* The role carries the accent and the name stays neutral. Two accented lines inside a 300px
   box and neither one is emphasis any more, and the role is the part a buyer is scanning for
   once they already recognise the name. */
.cl-role{
  margin:0; font-family:var(--mono); font-size:.63rem; letter-spacing:.13em;
  text-transform:uppercase; color:var(--accent-text);
}
.cl-note{ margin:.2rem 0 0; color:var(--fg-2); font-size:.865rem; line-height:1.5; }
/* via: absent means hired directly, which needs no word for it. Toptal is accented because it
   is the credential the Proof section is already selling; Aspire is only an agency. */
/* The routing badge. It was 9px of --fg-3, the dimmest text token on the page, sitting straight
   on a brand wash whose colour and brightness change per card, so on the amber and coral cards
   it effectively disappeared. It is bigger now and set in --fg, which carries on every wash
   without needing a backdrop of its own.
   Toptal and Aspire are styled identically. An earlier cut accented Toptal because the Proof
   band sells it, but two badges that mean the same kind of thing reading as two different
   weights made the neutral one look like an afterthought. */
.cl-via{
  font-family:var(--mono); font-size:.66rem; letter-spacing:.12em; text-transform:uppercase;
  color:var(--fg); border:1px solid var(--fg); border-radius:99px; padding:.3em .68em;
  white-space:nowrap; margin-left:auto; line-height:1;
}
/* The url, spelled out. An arrow on its own made the reader guess where it went; a card that
   names the host is one a buyer can verify without spending a click to find out. */
.cl-site{
  margin-top:.45rem; align-self:flex-start;
  display:inline-flex; align-items:center; gap:.35em;
  font-family:var(--mono); font-size:.67rem; letter-spacing:.03em;
  color:var(--fg-3); text-decoration:none;
  transition:color .2s var(--ease), transform var(--press);
}
.cl-out{
  width:.85em; height:.85em; fill:none; stroke:currentColor; stroke-width:2.2;
  stroke-linecap:round; stroke-linejoin:round; opacity:.55; transition:opacity .2s var(--ease);
}
.cl-tech{
  list-style:none; margin:auto 0 0; padding:.35rem 0 0; display:flex; flex-wrap:wrap;
  gap:clamp(.5rem, .9vw, .8rem); justify-content:flex-start;
}
.t{ position:relative; display:grid; place-items:center; }
.t svg{
  width:clamp(19px, 1.7vw, 24px); height:clamp(19px, 1.7vw, 24px);
  fill:var(--fg-3); transition:fill .18s var(--ease), transform .18s var(--ease);
}
/* Copy that only makes sense where a pointer exists. Hidden by default so touch never
   reads an instruction it cannot follow; revealed in the hover-gated block below. */
.ptr{ display:none; }

/* the tooltip: one element, no JS, and it never widens the row it sits in */
.t::after{
  content:attr(data-tip); position:absolute; bottom:calc(100% + 8px); left:50%;
  transform:translateX(-50%) translateY(3px) scale(.96); pointer-events:none;
  background:var(--raised); color:var(--fg); border:1px solid var(--line-2);
  border-radius:calc(var(--radius) * .5); padding:.3em .6em; white-space:nowrap;
  font-family:var(--mono); font-size:.64rem; letter-spacing:.08em;
  transform-origin:50% 100%;
  opacity:0; transition:opacity .14s var(--ease), transform .14s var(--ease);
  box-shadow:var(--shadow-sm); z-index:4;
}

/* The section's own ground: two very soft fields, one in the accent and one in the positive
   green the core resolves to, drifting slowly against each other. It is deliberately near the
   threshold of visible; the point is that the section feels lit from somewhere rather than that
   anyone notices a gradient moving. Pinned behind everything. */
/* No isolation here, deliberately. isolation:isolate would make this section one stacking context
   that paints as a unit AFTER the section above it, and then z-index:-1 on the ground would only
   put it below THIS section's content -- it still landed on top of the Skills cards, because the
   ground overhangs upward by 14% so its light has somewhere to fade out. Without the isolate, the
   -1 falls through to the root stacking context, which paints it under every element on the page
   and above nothing but the canvas. The layering the orbit actually needs is kept on .orbit. */
#ai{ position:relative; }
/* Light entering from two corners, not two orbs floating in the middle. That distinction is the
   whole look: a radial gradient centred at 30% of the box reads as a blurred circle because you
   can see the whole circle, while the same gradient centred ON a corner only ever shows a quarter
   of its falloff, which reads as light coming from off-frame.
 *
 * The corners are the SECTION's, not this element's. This element overhangs by 14% on each side so
 * that the light has somewhere to die: the mask is fully opaque across the section and fades only
 * out in that overhang, so the wash is at full strength right up to the section's own corners and
 * is gone before it reaches the sections either side. 11% and 89% are where the section's edges
 * fall once the overhang is included (14/128). */
#ai::before{
  content:""; position:absolute; z-index:-1; pointer-events:none;
  /* The container holds the text to a readable measure. Light has no reason to obey it, so this
     breaks out past the viewport: .wrap is centred, making the distance from this box's edge to
     the viewport edge exactly 50% - 50vw, plus 8vw of margin. */
  inset-inline:calc(50% - 58vw);
  inset-block:-14%;
  /* Sized to travel most of the way across rather than to pool in the corners. Two fields this
     large overlap through the middle, which is what keeps the centre of the section from reading
     as a dead patch between two lights -- and spreading the same alpha over more area drops the
     peak, so the wash stops competing with the orbit sitting on top of it. */
  background:
    radial-gradient(118% 96% at 100% 11%, hsl(var(--ai-c2) / .20), transparent 74%),
    radial-gradient(114% 92% at 0% 89%,   hsl(var(--ai-c1) / .22), transparent 74%);
  -webkit-mask-image:linear-gradient(to bottom, transparent 0, #000 11%, #000 89%, transparent 100%);
          mask-image:linear-gradient(to bottom, transparent 0, #000 11%, #000 89%, transparent 100%);
}

/* ── ai tools ────────────────────────────────────────────────
 * A productivity core with the tools in orbit around it, feeding it.
 *
 * All of it is CSS. The ring placement comes from one angle per tile (--a); the orbit turns by
 * spinning the whole list and counter-spinning each tile so it stays upright; the particles are
 * one pseudo element per tile travelling down its own spoke; and the percentage is an animated
 * <integer> custom property read back through a CSS counter, which is why it counts without a
 * script. Nothing here needs JavaScript, so nothing here breaks without it.
 *
 * The discs borrow the client cards' language on purpose: the same brand wash, and the same
 * masked ring for a gradient border, because border-image cannot follow a border-radius.
 */
@property --count{ syntax:'<integer>'; initial-value:80; inherits:true; }

/* The core-over-grid order lives here now rather than on #ai, so that moving the ground out of a
   stacking context does not take the orbit's own layering with it. */
.orbit{ margin-top:calc(var(--u) * 2.2); display:grid; gap:calc(var(--u) * 1.8); isolation:isolate; }

/* ── the core ── */
.orbit-core{
  position:relative; display:grid; place-items:center; align-content:center; justify-self:center;
  width:clamp(210px, 46vw, 268px); aspect-ratio:1;
}
/* The core has to be an OBJECT, not a ring drawn on air. Raising its z-index put the particles
   behind it and changed nothing visible, because a transparent thing occludes nothing: a dot
   travelling "under" the core still showed straight through the number. This is the disc that
   makes behind mean hidden. It fades out before the ring so the section's drifting ground still
   reads around it rather than being punched out by a hard circle. */
.orbit-core::before{
  content:""; position:absolute; inset:0; border-radius:50%; z-index:-1; pointer-events:none;
  /* Reaches the ring now instead of stopping well inside it, which is what read as padding: the
     dark ended, then there was ground, then the gauge. Still a fade rather than a hard circle, so
     the section's corner light carries across it. */
  background:radial-gradient(circle,
     var(--bg) 0 78%,
     color-mix(in srgb, var(--bg) 88%, transparent) 92%,
     transparent 100%);
}
.orbit-ring{ position:absolute; inset:0; width:100%; height:100%; transform:rotate(-90deg); }
/* Thicker than it was: at 2.5 on a 120 unit box the ring read as a hairline outline of the disc
   rather than as a gauge that fills. */
/* r is 60 minus half the stroke, which puts the OUTER edge of the ring exactly on the box edge
   rather than 4.3 units short of it. Anything smaller leaves the gauge floating inside the disc
   instead of bounding it. The dasharray below is this circle's circumference and has to be
   recomputed with it -- leave it at the old 339.3 and the arc under-draws by 8%. */
.orbit-track, .orbit-fill{ fill:none; stroke-width:3.4; }
.orbit-track{ stroke:var(--line); }
.orbit-fill{
  stroke:var(--accent); stroke-linecap:round;
  stroke-dasharray:366.3;                    /* 2 * pi * 58.3 */
  stroke-dashoffset:183.2;                   /* starts half drawn: 80% is already on the board */
  animation:orbit-fill 5.2s var(--ease-out) 1.1s forwards;
  animation-play-state:paused;
}
@keyframes orbit-fill{ to{ stroke-dashoffset:0; stroke:var(--pos); } }

/* This label is the section's actual claim, and it was set in the dimmest text token on the page
   at .62rem -- smaller and fainter than the tile names orbiting it, which are supporting cast.
   Up two steps in size and two in colour so the thing being measured is legible before the
   number that measures it. */
.orbit-cap{
  font-family:var(--mono); font-size:clamp(.84rem, 1.15vw, 1rem); letter-spacing:.15em;
  text-transform:uppercase; font-weight:600; color:var(--fg);
}
.orbit-num{
  margin-top:.34rem; font-size:clamp(2.7rem, 6.3vw, 3.7rem); font-weight:700;
  letter-spacing:-.045em; line-height:1; color:var(--fg);
  animation:orbit-count 5.2s var(--ease-out) 1.1s forwards;
  animation-play-state:paused;
}
/* The count is a six second climb, and it used to start on page load. Anyone who arrived at this
   section later than that -- which is most people, it is the fourth section down -- scrolled into
   a number that had already finished, so the one thing the section is trying to say never got
   said. Both animations are declared but held at their first frame until the core itself is
   properly on screen. Paused at frame 0 IS the designed opening state: 80% on the board and the
   ring half drawn, which is what these two rules already set. */
.orbit-core.counting .orbit-fill,
.orbit-core.counting .orbit-num{ animation-play-state:running; }
.orbit-num::after{ counter-reset:c var(--count); content:counter(c) "%"; }
@keyframes orbit-count{
  to{ --count:160; color:var(--pos); }
}

/* ── the tools ── */
/* Each tile carries its brand twice, once resolved against the dark ground and once against the
   light one, and this is where one of them is chosen. Everything downstream -- the mark, the disc
   wash, the ring, the particle -- reads --ai-rgb, so switching it here switches all four at once.
   The pair arrives as -d/-l rather than as --ai-rgb directly because an inline custom property
   beats a stylesheet rule, and a light-scheme override would never have won against it. */
.ai-grid > li{ --ai-rgb:var(--ai-rgb-d); --ai-a:var(--ai-a-d); }
:root[data-scheme="light"] .ai-grid > li{ --ai-rgb:var(--ai-rgb-l); --ai-a:var(--ai-a-l); }
.ai-grid{
  list-style:none; margin:0; padding:0;
  display:grid; gap:clamp(.6rem, 1vw, .85rem);
  grid-template-columns:repeat(auto-fit, minmax(min(100%, 150px), 1fr));
}
.ai-t{
  position:relative; display:grid; gap:.6rem; place-items:center; text-align:center;
  padding:clamp(1rem, 1.7vw, 1.3rem) .6rem;
  border-radius:var(--radius); border:1px solid var(--line);
  background:var(--bg-2); text-decoration:none;
  transition:border-color .28s var(--ease), background .28s var(--ease);
}
/* The disc, in the client cards' language: a brand wash inside, a brand gradient ring outside. */
.ai-d{
  position:relative; display:grid; place-items:center;
  width:clamp(68px, 7.4vw, 94px); aspect-ratio:1; border-radius:50%;
  background-color:var(--bg);
  background-image:radial-gradient(120% 110% at 50% 0%,
     rgb(var(--ai-rgb) / calc(.40 * var(--ai-a,1))) 0%,
     rgb(var(--ai-rgb) / calc(.14 * var(--ai-a,1))) 48%,
     rgb(var(--ai-rgb) / calc(.05 * var(--ai-a,1))) 100%);
  transition:transform .3s var(--ease);
}
.ai-d::after{
  content:""; position:absolute; inset:0; border-radius:50%; padding:1px; pointer-events:none;
  background:radial-gradient(130% 120% at 50% 0%,
     rgb(var(--ai-rgb) / calc(.85 * var(--ai-a,1))) 0%,
     rgb(var(--ai-rgb) / calc(.35 * var(--ai-a,1))) 45%, var(--line-2) 100%);
  -webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
          mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite:xor; mask-composite:exclude;
}
.ai-i{ width:50%; height:50%; fill:rgb(var(--ai-rgb)); position:relative; }
/* A wordmark is not a glyph. Zapier's is three times wider than it is tall, so at the square 50%
   it fits by width and sets the type at a sixth of the disc. More of the box, and it reads. */
.ai-i-wide{ width:70%; height:70%; }
.ai-i-stroke{
  fill:none; stroke:rgb(var(--ai-rgb)); stroke-width:2;
  stroke-linecap:round; stroke-linejoin:round;
}
.ai-n{ font-size:.78rem; font-weight:600; letter-spacing:-.01em; color:var(--fg); line-height:1.2; }

/* ── the orbit proper ────────────────────────────────────────
 * Only above 900px. A ring wide enough for sixteen tools and their names cannot fit a phone, and
 * below this the same list is a plain grid: not a degraded ring, the honest layout at that size.
 */
/* The ring runs on a phone too, at about a third of the size. It is skipped between 620 and 900
   only because that band has the width for the plain grid to be the better answer: a tablet can
   show sixteen labelled tiles at a readable size, and a ring there would shrink them for nothing.
 */
@media (min-width: 900px), (max-width: 620px){
  .orbit{
    position:relative; display:block; margin-inline:auto;
    width:min(100%, 860px); aspect-ratio:1;
    /* sixteen discs at 94px need roughly 1800px of circumference before they touch */
    --r:clamp(252px, 30vw, 366px);
  }
  .orbit-core{ position:absolute; inset:0; margin:auto; width:clamp(210px, 22vw, 280px); z-index:2; }
  /* z-index 1 against the core's 2. Both are absolutely positioned siblings at z-index auto
     otherwise, so the later one in the DOM wins and the particles painted OVER the number they
     are supposed to be feeding. */
  .ai-grid{ position:absolute; inset:0; display:block; padding:0; z-index:1; animation:orbit-spin 72s linear infinite; }
  .ai-grid > li{
    position:absolute; inset:0; margin:auto;
    width:clamp(100px, 11vw, 124px); height:clamp(100px, 11vw, 124px);
    /* NO counter-rotation here: the tile's local frame has to stay aligned with its spoke so the
       particle can travel down it. The upright correction happens on .ai-t instead. */
    transform:rotate(var(--a)) translateY(calc(-1 * var(--r)));
  }
  /* The particle: one per tile, released down its own spoke into the core, each on its own beat
     so the ring reads as being charged rather than as sixteen things blinking at once. */
  .ai-grid > li::after{
    content:""; position:absolute; left:50%; top:50%; z-index:-1;
    width:7px; height:7px; margin:-3.5px 0 0 -3.5px; border-radius:50%;
    background:rgb(var(--ai-rgb));
    box-shadow:0 0 10px 1px rgb(var(--ai-rgb) / .8);
    animation:orbit-feed 3.4s var(--ease-in-out) infinite;
    animation-delay:calc(var(--i) * -.21s);
  }
  /* Undoes the container's spin AND this tile's own angle, so the mark and its name stay
     upright the whole way round. The static offset lives in the keyframes because an animation
     on transform would otherwise discard it. */
  .ai-t{
    width:100%; height:100%; padding:.3rem; gap:.34rem;
    border:0; background:none; place-content:center;
    animation:orbit-upright 72s linear infinite;
  }
  .ai-n{ font-size:.66rem; font-weight:550; }
}
@keyframes orbit-spin{ to{ transform:rotate(360deg); } }
@keyframes orbit-upright{
  from{ transform:rotate(calc(-1 * var(--a))); }
  to  { transform:rotate(calc(-1 * var(--a) - 360deg)); }
}
@keyframes orbit-feed{
  0%      { transform:translateY(0);              opacity:0; }
  12%     { opacity:1; }
  88%     { opacity:.9; }
  100%    { transform:translateY(var(--r));        opacity:0; }
}

/* Everything still, and the core showing the answer rather than the journey to it. */
@media (prefers-reduced-motion: reduce){
  .orbit-fill{ animation:none; stroke-dashoffset:0; stroke:var(--pos); }
  .orbit-num{ animation:none; --count:160; color:var(--pos); }
  .ai-grid, .ai-t{ animation:none; }
  .ai-grid > li::after{ animation:none; opacity:0; }
  .ai-t{ transform:rotate(calc(-1 * var(--a))); }
}

/* ── apps ────────────────────────────────────────────────────
 * The register here is the home screen, not the card. Every other section already owns a shape:
 * the clients are brand-washed bento cells, the skills are a ruled grid, the AI tools are a ring.
 * An app is none of those. What is true of an app and nothing else on this page is that it has an
 * icon you would tap and a shipping record, so the icon leads and the record follows.
 *
 * Colour is confined to the icon and the status dot. A wash across the whole card would repeat
 * the client cards' language and cost both of them their distinctness.
 */
.apps{
  list-style:none; margin:calc(var(--u) * 2.4) 0 0; padding:0;
  display:grid; gap:clamp(.9rem, 1.4vw, 1.15rem);
  grid-template-columns:repeat(3, 1fr);
}
.app{
  display:grid; align-content:start; gap:.9rem;
  padding:clamp(1.1rem, 1.7vw, 1.5rem);
  background:var(--surface); border:1px solid var(--line);
  border-radius:calc(var(--radius) * 1.6);
  transition:border-color .25s var(--ease), transform .25s var(--ease);
}
.app:hover{ border-color:var(--line-2); transform:translateY(-2px); }

.app-h{ display:flex; align-items:center; gap:.85rem; }
/* A real app icon is a squircle with the light coming from above, so it gets the inset highlight
   along its top edge and drops a little of its own colour underneath. The radius is a proportion,
   not a length: that is what keeps it a squircle instead of a rounded box as the tile scales. */
.app-i{
  flex:none; width:clamp(56px, 5.4vw, 68px); aspect-ratio:1; border-radius:26%;
  display:grid; place-items:center; background:var(--app);
  box-shadow:inset 0 1px 0 hsl(0 0% 100% / .28), 0 10px 22px -14px var(--app);
}
.app-i svg{ width:54%; height:54%; fill:#fff; }
.app-t{ display:grid; gap:.2rem; min-width:0; }
.app-n{ font-size:1.12rem; font-weight:700; letter-spacing:-.025em; color:var(--fg); line-height:1.15; }
/* Deliberately NOT the .mono utility: that utility uppercases, and it turns iOS into IOS, which
   is a brand name spelled wrong. Same face and tracking, casing left alone. */
.app-p{
  font-family:var(--mono); font-size:.65rem; letter-spacing:.08em; font-weight:500;
  color:var(--fg-3);
}
.app-d{ margin:0; color:var(--fg-2); font-size:.895rem; line-height:1.5; }

/* Status, date and destination on one line. The date sat on its own row underneath at first and
   read as an orphan: it is a fact about the app, not a caption for the card. */
.app-f{
  display:flex; align-items:center; gap:.6rem;
  padding-top:.85rem; border-top:1px solid var(--line);
}
.app-s{
  display:inline-flex; align-items:center; gap:.45rem;
  font-family:var(--mono); font-size:.66rem; letter-spacing:.09em; text-transform:uppercase;
  color:var(--fg-2);
}
.app-s i{ width:7px; height:7px; border-radius:50%; background:var(--st); flex:none; }
.app-l{
  display:inline-flex; align-items:center; gap:.3rem;
  font-size:.78rem; font-weight:600; color:var(--accent-text); text-decoration:none;
  transition:opacity .2s var(--ease);
}
.app-l svg{ width:13px; height:13px; fill:none; stroke:currentColor; stroke-width:2;
  stroke-linecap:round; stroke-linejoin:round; }
.app-l:hover{ opacity:.72; }
/* The date is the only reason "recent" means anything, so it is on the card rather than implied
   by the order alone. Last line, quietest thing on it. */
.app-w{
  font-family:var(--mono); font-size:.63rem; letter-spacing:.08em; text-transform:uppercase;
  color:var(--fg-3);
}
.app-w::before{ content:"·"; margin-right:.6rem; opacity:.5; }
.app-l{ margin-left:auto; }

/* The door to the full page. A fourth card would have read as a missing app; a bare text link
   would have been too small a target for what is the section's actual next step. */
.apps-all{
  margin-top:clamp(.9rem, 1.4vw, 1.15rem);
  display:flex; align-items:center; justify-content:space-between; gap:1.2rem;
  padding:clamp(1rem, 1.5vw, 1.35rem) clamp(1.1rem, 1.7vw, 1.5rem);
  border:1px solid var(--line); border-radius:calc(var(--radius) * 1.6);
  text-decoration:none; color:inherit; background:var(--bg-2);
  transition:border-color .25s var(--ease), background .25s var(--ease);
}
.apps-all:hover{ border-color:var(--accent-dim); background:var(--surface); }
.apps-all-t{ display:grid; gap:.22rem; }
.apps-all-n{ font-size:1.05rem; font-weight:700; letter-spacing:-.02em; color:var(--fg); }
.apps-all-s{ font-size:.85rem; color:var(--fg-2); }
.apps-all-a{
  flex:none; width:38px; height:38px; border-radius:50%;
  display:grid; place-items:center;
  border:1px solid var(--line-2); color:var(--accent-text);
  transition:transform .25s var(--ease), border-color .25s var(--ease);
}
.apps-all-a svg{ width:16px; height:16px; fill:none; stroke:currentColor; stroke-width:2;
  stroke-linecap:round; stroke-linejoin:round; }
.apps-all:hover .apps-all-a{ transform:translateX(3px); border-color:var(--accent-dim); }

@media (max-width: 900px){
  .apps{ grid-template-columns:1fr; }
  .apps-all{ flex-direction:row; }
}

/* The phone ring. Sixteen tiles on a circle inside a 390 frame is a geometry problem with one
   answer: the ring has to clear 2r + tile <= the frame, and its circumference has to exceed
   16 x tile or the discs overlap. r=160 with a 62px tile is the largest pair that satisfies both.
   Everything else follows from those two numbers.
 *
   The names come off. At a 62px tile the label would set at about 7px, which is below the size
   this page treats as legible anywhere else, and a ring of sixteen unreadable captions is worse
   than a ring of sixteen recognisable marks. The name stays on the link for assistive tech. */
@media (max-width: 620px){
  /* 38vw, not 41: at 41 the ring sat one pixel off the screen edge on a 320 frame. And the tile
     tapers faster than the radius does, because the no-overlap condition is circumference against
     16 x tile: at 320 a 51px tile needed 816px of ring and only had 764. */
  .orbit{ --r:min(160px, 38vw); }
  .orbit-core{ width:min(148px, 36vw); }
  .ai-grid > li{ width:min(62px, 14.5vw); height:min(62px, 14.5vw); }
  .ai-d{ width:min(48px, 11vw); }
  .ai-n{ display:none; }
  /* .58rem here was 9.3px, under the line this page holds everything else to. */
  .orbit-cap{ font-size:.7rem; letter-spacing:.1em; }
  .orbit-num{ font-size:clamp(1.5rem, 9vw, 2.1rem); margin-top:.15rem; }
  .ai-grid > li::after{ width:5px; height:5px; margin:-2.5px 0 0 -2.5px; }
}

/* ── stack / the three jobs ──────────────────────────────────
 * Horizontal bands, not columns. Three tall narrow boxes side by side gave each job a 300px
 * column to fit eleven categories and forty pills into, so every row of pills wrapped two or
 * three deep and the section ran past 1200px. Turned on its side the same content gets the
 * whole width: the verb holds a column of its own on the left, and the categories flow across
 * the rest in as many columns as the viewport affords.
 */
.jobs{
  list-style:none; margin:calc(var(--u) * 2.6) 0 0; padding:0;
  display:grid; gap:clamp(.8rem, 1.3vw, 1.05rem);
  grid-template-columns:1fr;
}
/* The verb and its line sit OUTSIDE the box. The box holds the stack, not the job: with the
   heading inside the same border the surface was doing two jobs at once and the band read as one
   undifferentiated slab. Out here the verb belongs to the page and the box is plainly a
   container of technology. */
.job{
  display:grid; gap:clamp(.85rem, 1.5vw, 1.4rem);
  grid-template-columns:1fr;
}
/* The heading is a row of its own above the box, not a column beside it. Beside it, the verb ate
   a fifth of the width on every band and the box never got the full measure; stacked, the box
   spans the whole container and the categories gain a column at every breakpoint. */
.job-top{ display:grid; gap:.3rem; align-content:start; }
.job-n{ font-family:var(--mono); font-size:.66rem; letter-spacing:.2em; color:var(--accent-text); }
.job-v{
  margin:0; font-size:clamp(1.5rem, 2.3vw, 2rem); font-weight:700;
  letter-spacing:-.03em; line-height:1.05; color:var(--fg);
}
.job-def{ margin:.2rem 0 0; color:var(--fg-2); font-size:.9rem; line-height:1.45; max-width:56ch; }
/* Subordinate to the verb, and only where it needs to be. On a phone the verb drops to 24px,
   which puts a 14.4px line in --fg-2 close enough in both size and weight to read as a second
   line of the title rather than as a gloss on it. Smaller, dimmer and further down; --fg-3
   rather than a one-off colour, so it recedes in both schemes (dark 55% L, light 45% L)
   instead of only in the one I happened to be looking at. Clears AA at this size: 5.5:1 on
   dark, 4.6:1 on light. Size and colour only: the gap stays at the .2rem the wide layout uses,
   because pushing the line further down detached it from the verb it belongs to. */
@media (max-width: 940px){
  .job-def{ color:var(--fg-3); font-size:.82rem; line-height:1.5; }
}

/* Columns, not a grid. Grid was the obvious choice and the wrong one: its rows are as tall as
   the tallest cell in them, and these categories are wildly uneven (Styling has eight pills,
   Search has one), so every row was padded out to its biggest member and the Designing band ran
   to 694px of mostly empty space. Multi-column flow has no rows to align to, so it packs each
   column to whatever height it needs and balances them. Reading order becomes down-then-across,
   which for an unordered list of capabilities is no loss. */
/* A grid with spans, not balanced columns. Columns packed tightly but could not FILL: a column
   run is a single vertical thread, so wherever one category was short the space beside it stayed
   empty and the box ended ragged. A grid can span, and dense flow backfills every hole a wide
   item leaves, so the box reads as a composition rather than as leftovers. */
.job-cats{
  display:grid; grid-auto-flow:dense; align-content:start;
  gap:1px;
  /* Two tracks, not one. A single column gives every category the same width and the bento
     language the rest of the section is built on disappears exactly where there is least room
     for it. Four tracks were worse than useless: with spans clamped to min(sp,4) every cell
     landed on three or four of four, nothing ever paired, and it rendered as one column with
     extra gridlines behind it. Two tracks and a span of one or two actually pack. */
  grid-template-columns:repeat(2, 1fr);
  padding:0; overflow:hidden;
  border:1px solid var(--line); border-radius:var(--radius); background:var(--surface);
  transition:border-color .25s var(--ease), background .25s var(--ease);
}
/* Every group is ruled on all four sides, so the box reads as one grid rather than as loose
   clusters. The lines are an outset ring on each cell, not the usual trick of colouring the
   container and letting it show through a 1px gap: --line is 17% lightness against a 10.5%
   surface, so any cell the grid does not fill would have shown as a visibly lighter block, and
   the last row never fills. A ring leaves empty space at surface colour. Two neighbouring rings
   meet inside the 1px gap and read as a single continuous line. */
.cat{
  align-content:start; min-width:0;
  background:var(--surface); box-shadow:0 0 0 1px var(--line);
  padding:clamp(.85rem, 1.3vw, 1.1rem);
}
/* Twelve tracks, and each cell spans what its content needs. Below the wide breakpoints the
   track count drops and the spans are clamped, because a cell asking for five of twelve on a
   phone is asking for the whole row anyway. */
/* 19 of the 37 categories hold three pills or fewer and take half a row; the 18 bigger ones take
   the whole width. Dense flow pairs the halves up. */
/* The phone's span is decided in build/stack.py and arrives as --msp, rather than being derived
   from --sp here. CSS cannot see that a name is too long for a half-width cell, and it cannot
   check that the spans sum to an even number; both are content questions and both were wrong. */
.cat{ grid-column:span var(--msp, 1); }
@media (min-width: 640px){
  .job-cats{ grid-template-columns:repeat(6, 1fr); }
  .cat{ grid-column:span min(var(--sp, 3), 6); }
}
@media (min-width: 1020px){
  .job-cats{ grid-template-columns:repeat(12, 1fr); }
  .cat{ grid-column:span calc(var(--sp, 3) * 2); }
}
@media (min-width: 1240px){
  .cat{ grid-column:span var(--sp, 3); }
}
/* The label is the skill in as few words as it can be said, and the row under it is what that
   skill is actually done with. The label is mono so it never competes with the verb above it. */
.cat-l{
  margin:0 0 .45rem; font-family:var(--mono); font-size:.7rem; letter-spacing:.14em;
  text-transform:uppercase; color:var(--fg-3);
}
.cat-t{
  list-style:none; margin:0; padding:0;
  display:flex; flex-wrap:wrap; gap:.4rem;
}
/* Every item is a mark plus its name. The name is always there: a logo alone is a quiz, and a
   buyer scanning for "Postgres" should not have to recognise an elephant to find it. */
.tk{
  display:inline-flex; align-items:center; gap:.38em;
  padding:.3em .6em .3em .45em;
  border:1px solid var(--line-2); border-radius:99px;
  font-size:.755rem; font-weight:550; line-height:1; color:var(--fg);
  transition:border-color .2s var(--ease), background .2s var(--ease);
}
/* Not everything here is a technology. Leading engineers and holding a schedule are not tools,
   so those rows carry the name alone rather than being padded with a logo that would lie. */
.tk-bare{ padding-inline:.65em; color:var(--fg-2); }
.tk-i{ width:13px; height:13px; fill:var(--brand); flex:none; }
/* Lucide glyphs are drawn rather than filled, so they need the fill turned off and the stroke
   turned on, or every one of them renders as a solid blob. */
.tk-i-stroke{
  fill:none; stroke:var(--brand); stroke-width:2;
  stroke-linecap:round; stroke-linejoin:round;
}

@media (hover: hover) and (pointer: fine){
  .job:hover .job-cats{ border-color:var(--line-2); background:var(--raised); }
  /* the tile brightens rather than moves: it is a light source, so hover turns it up */
  .ai-t:hover .ai-d{ transform:scale(1.09); }
  .ai-t:hover .ai-n{ color:var(--fg); }
  .tk:hover{ border-color:var(--brand, var(--line-2)); background:var(--raised); }
}

/* ── contact ──────────────────────────────────────────────────
 * Every channel is a card you can hit, not a link in a paragraph: the mark says which service
 * before the text does, and takes its own brand colour under the pointer.
 *
 * There was a `.contact h2{ max-width:16ch }` here, held over from when this heading was longer
 * and left-aligned. It survived the rewrite and was the reason "Tell me what you are building."
 * broke across two lines at every width while every other heading sat on one: 450px of cap
 * against 1036px of container. The shared 34ch now governs it like the rest.
 */
/* ── reach ───────────────────────────────────────────────────
 * Nine cards, and each one leads with what you would be doing rather than with the name of a
 * website. "Medium" tells a buyer nothing; "Read my articles" tells them whether it is worth the
 * click. The platform and its mark sit underneath, answering "where does this go" second, which
 * is the order the eye takes them in anyway.
 *
 * Each card carries its platform's colour twice, resolved against each ground, for the same
 * reason the AI tiles do: Medium and TikTok both ship pure black and GitHub ships near-white, so
 * one value cannot serve both schemes. Nothing is coloured until hover -- nine brands all shouting
 * at once is a logo wall, and the point of the section is the sentence on each card.
 */
.reach{
  list-style:none; margin:calc(var(--u) * 2.4) 0 0; padding:0;
  display:grid; grid-template-columns:repeat(3, 1fr); gap:clamp(.55rem, .9vw, .8rem);
}

.ch{
  /* --b has to be resolved HERE, not on .reach. The pair it chooses between is set inline on this
     element, and a custom property is resolved against the element it is declared on: asking the
     parent for var(--bd) asks an element that has no --bd, which makes it invalid and inherits
     down as nothing. Every brand silently fell back to unset. */
  --b:var(--bd);
  display:grid; align-content:space-between; gap:1.1rem; height:100%;
  padding:clamp(.95rem, 1.5vw, 1.2rem);
  border:1px solid var(--line); border-radius:calc(var(--radius) * 1.2);
  background:var(--surface); text-decoration:none;
  transition:border-color .24s var(--ease), background .24s var(--ease),
             transform .24s var(--ease);
}
/* The mark belongs next to the sentence, not next to the platform name. It is the first thing the
   eye lands on and it should be identifying the card, which is the CTA -- parked down beside the
   handle it was labelling a label. */
.ch-h{ display:flex; align-items:center; gap:.6rem; min-width:0; }
.ch-cta{
  font-size:1.02rem; font-weight:650; letter-spacing:-.02em; line-height:1.25; color:var(--fg);
  min-width:0;
}
.ch-tx{ display:grid; gap:.14rem; min-width:0; }
/* Up from 17px: level with the CTA it now sits beside, it was reading as a bullet point. */
.ch-i{
  width:20px; height:20px; flex:none; fill:var(--fg-3);
  transition:fill .24s var(--ease);
}
.ch-k{
  font-family:var(--mono); font-size:.68rem; letter-spacing:.11em; text-transform:uppercase;
  color:var(--fg-3); transition:color .24s var(--ease);
}
/* The handle is an identifier, not prose: mono, because these get read character by character and
   copied. Casing is left alone -- uppercasing would corrupt every one of them. Clipped rather
   than wrapped, so a long value can never push a card taller than the two beside it. */
.ch-v{
  font-family:var(--mono); font-size:.7rem; letter-spacing:.01em; color:var(--fg-2);
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  transition:color .24s var(--ease);
}
.ch:hover .ch-v, .ch:focus-visible .ch-v{ color:var(--fg); }

/* Hover is where the brand arrives: the mark takes its own colour, the border and a wash of the
   card take a dilution of it. color-mix rather than an alpha on the brand itself, so a pale mark
   and a dark one land at the same weight against the surface instead of one of them disappearing. */
.ch:hover, .ch:focus-visible{
  border-color:color-mix(in srgb, var(--b) 55%, var(--line));
  background:color-mix(in srgb, var(--b) 9%, var(--surface));
  transform:translateY(-2px);
}
.ch:hover .ch-i, .ch:focus-visible .ch-i{ fill:var(--b); }
.ch:hover .ch-k, .ch:focus-visible .ch-k{ color:var(--fg-2); }
.ch:active{ transform:translateY(-2px) scale(.988); }

:root[data-scheme="light"] .ch{ --b:var(--bl); }

@media (max-width: 900px){ .reach{ grid-template-columns:repeat(2, 1fr); } }
/* Two columns, not one. Nine single-file cards run the contact section past 1900px on a phone,
   which is most of three screens of scrolling for what is meant to be the easy part. The cards
   keep every element they have; they just sit two abreast with a little less padding. */
@media (max-width: 560px){
  .reach{ grid-template-columns:repeat(2, 1fr); gap:.5rem; }
  .ch{ padding:.8rem .75rem; gap:.75rem; }
  .ch-cta{ font-size:.92rem; letter-spacing:-.025em; }
  .ch-i{ width:17px; height:17px; }
  .ch-h{ gap:.45rem; }
  .ch-k{ font-size:.7rem; letter-spacing:.07em; }
  .ch-v{ font-size:.66rem; }
}
/* One per row below 620. Two abreast was the right call against nine cards running past 1900px
   of scrolling, but the card carries three lines -- a CTA, the platform and a handle -- and half
   a phone's width is not enough for the handle to sit under the platform rather than beside it. */
@media (max-width: 620px){ .reach{ grid-template-columns:1fr; } }

/* ── the three standing facts, given a shape instead of a run of sentences ── */
.facts{
  margin-top:calc(var(--u) * 2.6);
  display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr));
  gap:1px; background:var(--line); border:1px solid var(--line);
  border-radius:calc(var(--radius) * .9); overflow:hidden;
}
.fact{ background:var(--bg); padding:clamp(1.1rem, 2.4vh, 1.6rem) clamp(1.1rem, 2vw, 1.5rem); }
.fact-k{
  margin:0 0 .6rem; font-family:var(--mono); font-size:.7rem; letter-spacing:.15em;
  text-transform:uppercase; color:var(--fg-3);
}
.fact-v{ margin:0; font-size:1.14rem; line-height:1.2; color:var(--fg); }
.fact-s{ margin:.5rem 0 0; font-size:.82rem; line-height:1.45; color:var(--fg-3); }
.fact-deg{ font-weight:500; }
.fact-yr{ margin:.55rem 0 0; font-family:var(--mono); font-size:.7rem; letter-spacing:.08em; color:var(--fg-2); }

/* the clock is live, so it says so: a pulse, and digits that do not reflow as they tick */
.fact-clock .fact-v{ display:flex; align-items:center; gap:.55rem; font-variant-numeric:tabular-nums; letter-spacing:.01em; }
.clock-dot{
  width:7px; height:7px; border-radius:50%; background:var(--accent); flex:none;
  animation:clock-pulse 2s var(--ease) infinite;
}
@keyframes clock-pulse{
  0%,100%{ box-shadow:0 0 0 0 hsl(var(--accent-h) var(--accent-s) var(--accent-l) / .5); }
  70%    { box-shadow:0 0 0 7px hsl(var(--accent-h) var(--accent-s) var(--accent-l) / 0); }
}

.langs{ list-style:none; margin:0; padding:0; display:grid; gap:.7rem; }
.langs li{ display:grid; grid-template-columns:auto 1fr auto; align-items:center; gap:.7rem; }
.lg-l{ font-size:.92rem; color:var(--fg); }
.lg-b{ display:flex; gap:3px; }
.lg-b i{ width:100%; height:4px; border-radius:2px; background:var(--line-2); flex:1; }
.lg-b[style*="--n:1"] i:nth-child(-n+1),
.lg-b[style*="--n:2"] i:nth-child(-n+2),
.lg-b[style*="--n:3"] i:nth-child(-n+3),
.lg-b[style*="--n:4"] i:nth-child(-n+4),
.lg-b[style*="--n:5"] i:nth-child(-n+5){ background:var(--accent); }
.lg-r{ font-family:var(--mono); font-size:.64rem; letter-spacing:.08em; color:var(--fg-3); white-space:nowrap; }

footer{
  border-top:1px solid var(--line); padding-block:calc(var(--u) * 2.2);
  display:flex; flex-wrap:wrap; gap:.75rem 1.5rem; justify-content:space-between; align-items:center;
  font-family:var(--mono); font-size:.68rem; letter-spacing:.08em; color:var(--fg-3);
}

/* Touch devices fire :hover on tap and then latch it, so every hover state on this page
   lives behind a real pointer. */
@media (hover: hover) and (pointer: fine){
  .ptr{ display:inline; }
  .client-hero .t:hover, .client-md .t:hover{ border-color:var(--brand); background:var(--raised); }
  .client:hover{ background-color:var(--raised); }
  .client:hover::after{ opacity:.85; filter:brightness(1.35); }
  .brand:hover .brand-mark{ filter:brightness(1.12); }
  .nav-links a:hover{ color:var(--fg); background:var(--raised); }
  /* it is already the brightest thing in the header, so hover turns it up rather than recolouring
     it: any other yellow would read as a second brand colour */
  .nav-cta:hover, .menu-panel .menu-cv:hover{ filter:brightness(1.08); }
  .menu > summary:hover{ border-color:var(--accent-text); color:var(--accent-text); }
  .menu-panel a:hover{ color:var(--fg); background:var(--raised); }
  .hero-next:hover{ border-color:var(--accent); background:var(--accent-wash); animation-play-state:paused; }
  .hero-next:hover svg{ stroke:var(--accent-text); transform:translateY(2px); }
  .inline-link:hover{ text-decoration-color:var(--accent); }
  .cl-site:hover{ color:var(--accent-text); }
  .cl-site:hover .cl-out{ opacity:1; }
  .t:hover svg{ fill:var(--brand); transform:translateY(-2px); }
  /* The delay lives on :hover only, so a sweep across a client's six logos produces nothing
     and a pause produces one. Deliberate in, instant out. */
  .t:hover::after{ opacity:1; transform:translateX(-50%) translateY(0) scale(1); transition-delay:.32s; }
  /* The reach cards carry their own hover with them, up where the component is defined. What used
     to be here was written for the old rows and referenced --brand and an arrow that no longer
     exist -- and being later in the file at the same specificity, its background quietly beat the
     brand tint. */
}

/* ── motion ─────────────────────────────────────────────────── */
/* The section as a whole moves less and faster than it did — 700ms of whole-slab fade is the
   template move, and it was the entire motion budget below the fold. */
/* The section fades; only its rows travel. Both layers translating meant every list arrived
   twice — 14px of slab plus 10px of row on top of it. */
/* Scoped to .js, which an inline script in the head sets. Unscoped, a browser with JavaScript
   off holds every section between the hero and the footer at opacity 0 permanently, and a
   crawler that reads the rendered page rather than the source sees a blank document with a
   footer under it. */
.js .rv{ opacity:0; transition:opacity .5s var(--ease-out); }
.js .rv.in{ opacity:1; }

/* The rows arrive one after another instead of as one slab. transition-delay rather than a
   setTimeout ladder, so it survives a scroll-away and costs no JavaScript. 55ms apart: long
   enough to read as a cascade, short enough that the tenth row is not a wait. */
.js .stagger > *{
  opacity:0; transform:translateY(10px);
  transition:opacity .45s var(--ease-out), transform .45s var(--ease-out);
}
.js .rv.in .stagger > *{ opacity:1; transform:none; }
.js .rv.in .stagger > *:nth-child(1){ transition-delay:60ms }
.js .rv.in .stagger > *:nth-child(2){ transition-delay:115ms }
.js .rv.in .stagger > *:nth-child(3){ transition-delay:170ms }
.js .rv.in .stagger > *:nth-child(4){ transition-delay:225ms }
.js .rv.in .stagger > *:nth-child(5){ transition-delay:280ms }
.js .rv.in .stagger > *:nth-child(6){ transition-delay:335ms }
.js .rv.in .stagger > *:nth-child(7){ transition-delay:390ms }
.js .rv.in .stagger > *:nth-child(8){ transition-delay:445ms }
.js .rv.in .stagger > *:nth-child(9){ transition-delay:500ms }
.js .rv.in .stagger > *:nth-child(n+10){ transition-delay:555ms }
/* `transition` is one property, so `.stagger > *` silently replaced the transitions of any
   child that had its own — the ledger row stopped easing its hover background and the contact
   links stopped easing their padding. Children that own transitions re-declare them here,
   including the two the stagger needs. */
.stagger > .client{
  transition:opacity .45s var(--ease-out), transform .45s var(--ease-out),
             background .25s var(--ease), border-color .25s var(--ease);
}
.stagger > .ch{
  transition:opacity .45s var(--ease-out), transform var(--press),
             background .22s var(--ease), color .22s var(--ease);
}
.js .lift{ opacity:0; transform:translateY(26px); animation:lift .9s var(--ease) forwards; }
@keyframes lift{ to{ opacity:1; transform:none; } }

@media (prefers-reduced-motion: reduce){
  .edge .e-dot{ animation:none; }
  .brand-mark{ animation:none; clip-path:none; }
  .hero-next{ animation:none; }
  html{ scroll-behavior:auto; }
  *,*::before,*::after{ animation-duration:.001ms !important; animation-delay:0ms !important; transition-duration:.001ms !important; }
  .rv{ opacity:1; transform:none; }
  .stagger > *{ opacity:1; transform:none; transition:none; }
  .lift{ opacity:1; transform:none; }
}

/* ── responsive ─────────────────────────────────────────────── */

/* Stacked, the panel stops being a two-column component and becomes a section header with a
   badge in it, so it takes the section header's rules: the label leads, centred, ruled on both
   sides, and the copy is centred under it. The label lives inside .vet-copy for the wide layout,
   where it belongs to the copy column; `display:contents` dissolves that wrapper here so all
   four parts are items of the panel itself and `order` can put the label first. The h2 and lede
   margins go with it, because the panel's own gap now does that spacing. */
@media (max-width: 760px){
  .vet{ grid-template-columns:1fr; justify-items:center; gap:1.4rem; text-align:center; }
  .vet-copy{ display:contents; }
  .vet-copy .sec-label{ order:-1; display:flex; justify-content:center; }
  .vet-copy .sec-label::after{
    content:""; width:22px; height:1px; background:currentColor; display:block;
  }
  .vet-badge{ order:0; }
  .vet-copy h2{ order:1; margin:0; }
  .vet-copy .sec-lede{ order:2; margin:0; }
}
/* Optical tracking, done where it can be true rather than in an inert clamp: display type
   tightens only once it is genuinely large. */
@media (min-width: 1100px){
  .end-name{ letter-spacing:-.038em; }
  .acts{ letter-spacing:-.034em; }
  .sec h2{ letter-spacing:-.03em; }
}
@media (max-width: 940px){
  .lab{ grid-template-columns:1fr; }
  /* The slot moves up on a narrow frame, and it moves ONCE. This block used to spell the same
     clamp out three times -- on .acts, inside --banner, and on .narr -- which is why setting the
     variable higher up appeared to do nothing: three literal copies of the old value, later in
     the file, quietly won. */
  .hero{ --slot:clamp(4.5rem, 8vh, 6.5rem); }
  .acts{ font-size:clamp(1.05rem, 5.2vw, 1.8rem); top:var(--slot); }
  .stage{ --banner:calc(var(--slot) + clamp(1.3rem, 5.9vw, 2.1rem)); }

  /* The caption is a gloss on the word on a phone, where the two are close enough in size to be
     read as one thing. Three changes, all of them narrow-screen only:

     TOP. It used to carry its own copy of the wide --slot clamp, so below this breakpoint it
     positioned against a number the banner no longer used. Measured at 390 its box started
     4.4px ABOVE the word it belongs to. The runtime measures the banner already, so it writes
     the bottom into --sub-top and the caption hangs off that; only the gap stays here. The
     fallback covers the frame before the first layout.

     SIZE. The old clamp's vw term stopped biting at 1157px, so from there down the caption held
     15px while the word shrank to 20px. This one keeps tracking it.

     COLOUR. The runtime drives opacity to 1, so the stylesheet's .74 never applied and the
     caption rendered as the same white as the word above it. --fg-2 is what .sec-lede uses for
     the same job. 7.2:1 on dark, 6.2:1 on light. */
  .act-sub{
    top:calc(var(--sub-top, calc(var(--slot) + clamp(2.1rem, 5vw, 4.6rem))) + clamp(.5rem, 1vw, .95rem));
    font-size:clamp(.8rem, 1.7vw, .95rem);
    color:var(--fg-2);
  }


  /* The two screens stack. place-items alone would spread them to the far ends of a
     full-height grid, so align-content groups them, and the padding clears the banner. */
  .sc-design{
    grid-auto-flow:row; align-content:center; justify-items:center;
    gap:clamp(.7rem, 2vh, 1.3rem);
  }
  /* Stacked, so both heights come out of the same viewport. 48vh of width is 30vh of window
     height (16:10) and 17.5vh of width is 38vh of phone height (9:19.5): 68vh for the pair,
     which clears the padding and the gap. Without the vh terms an 900x800 tablet overflows
     the hero and the phone is clipped. */
  .win{ width:min(92vw, 44vh); }
  .sc-design{ padding-top:var(--banner); }

  /* Browser on its own row, then the phone and the tablet sharing a second one. Two explicit
     rows rather than auto-flow, because the pair on row two has to be a row of its own: with
     grid-auto-flow:row the tablet would take a third row and the act would run past the foot.
     The devices are sized so the two rows come to about 64vh together, which clears the banner,
     the foot and the gap between them. */
  .tablet{ display:block; width:min(46vw, 27vh); }
  .phone{ width:min(26vw, 132px, 27vh); }
  .sc-design{
    grid-template-columns:auto auto;
    grid-template-rows:auto auto;
    align-content:center; justify-content:center;
    column-gap:clamp(.5rem, 3vw, 1.1rem);
  }
  .win{ grid-column:1 / -1; grid-row:1; justify-self:center; }
  .phone{ grid-column:1; grid-row:2; }
  .tablet{ grid-column:2; grid-row:2; }

  .deploy{ padding:1.15rem 1.2rem 1.3rem; }
  .steps li{ font-size:.72rem; }
  /* the plan is wide by nature; on a phone it needs the whole frame and smaller labels */
  /* the label lives in a span now, and .node no longer sets a font-size for it to inherit */
  .node span{ font-size:.56rem; letter-spacing:.08em; }

  /* The side panel turns and runs along the bottom: a column of five edge rows is taller than
     the log on a phone, which inverts the whole point of the layout. */
  /* Sized to the room the act actually has, not to a cautious fraction of the viewport. At
     88vw and 60vh it sat with about 90px of slack above it and as much below, which reads as a
     small box adrift in a big frame rather than a console filling a screen. The vh term still
     governs on a short phone, where 62vh is what keeps it clear of the banner and the foot. */
  .term{ width:min(470px, 94vw); aspect-ratio:auto; height:min(62vh, 460px); }
  .term-body{ grid-template-columns:1fr; grid-template-rows:minmax(0,1fr) auto; }
  .term-side{
    border-left:0; border-top:1px solid var(--line);
    grid-template-columns:auto 1fr; align-items:center; gap:.8rem;
    padding:.7rem clamp(.9rem, 4vw, 1.4rem);
  }
  .edges{ grid-auto-flow:column; grid-auto-columns:1fr; gap:.4rem; }
  .edge{ grid-template-columns:auto; justify-items:center; gap:.35em; font-size:.56rem; }
  .edge .e-ms{ display:none; }
  .side-f{ display:none; }
  /* the sub-lines are detail, and detail at 10px on a 330px console is just overflow */
  .log-sub{ display:none; }
  .term-side{ grid-template-rows:auto; }
  .term-foot{ gap:.7rem; }
}

/* A LANDSCAPE PHONE. 844x390 is a viewport the stacked layout was never written for: every cap
   in it is a vh, and those were sized against a phone three times taller. The worst of them is
   the handset's `min(26vw, 132px, 27vh)`, which is a cap on WIDTH -- at 9/19.5 that is 58vh of
   height -- so the two rows came to 346px inside the 164px this act actually has, and
   align-content:center pushed the browser 75px up through the caption. Measured, not guessed.

   Height is the scarce axis here, so everything is capped by height and the three devices go
   back into one row, which is what there is room for when there are only 164px of it. Landscape
   is part of the condition on purpose: a 390-wide portrait phone is short in dev tools long
   before it is short in a hand, and it has a working layout already. So is the 940 width, which
   keeps a short desktop window out of a rule written for a handset. */
@media (max-width: 940px) and (max-height: 520px) and (orientation: landscape){
  .sc-design{
    grid-auto-flow:column; grid-template-columns:none; grid-template-rows:none;
    align-content:center; justify-content:center;
    column-gap:clamp(.5rem, 1.8vw, 1.3rem); row-gap:0;
  }
  .win, .phone, .tablet{ grid-column:auto; grid-row:auto; }
  /* one height budget, three aspect ratios: 36vh tall each, so no device sets the row's height
     alone and none of them can grow into the banner */
  .win{ width:min(34vw, 57.6vh); }
  .phone{ width:min(12vw, 16.6vh); }
  .tablet{ display:block; width:min(26vw, 48vh); }
}


/* Portrait drops the line further from the nav, and has to come after the 940 block to do it.
   On a phone the ring round the narration reaches above the text it circles, which left about
   twelve pixels between it and the nav bar; at 13vh there is room for the mark to be a mark
   rather than a collision. Every act follows it down, because --banner derives from the same
   value and .acts reads it directly. */
@media (orientation: portrait) and (max-width: 620px){
  .hero{ --slot:clamp(5.5rem, 13vh, 9rem); }
}

@media (max-width: 760px){
  .nav-links{ display:none; }
  .nav-cta{ display:none; }
  .menu{ display:block; }
  .nav{ grid-template-columns:1fr auto; padding-top:1.15rem; }
  .hero{ padding-top:calc(var(--u) * 5.5); }
  .row-head{ grid-template-columns:1fr; gap:.5rem; padding-bottom:calc(var(--u) * .7); }
  .row-track{ height:20px; }
    .row-detail{ padding-left:0; }
  .link{ grid-template-columns:1fr auto; }
  .link .lk{ grid-column:1 / -1; }
}
/* The scatter holds down to here: below it the notes are a third of the width, the right-hand
   column clamps into one stack and half the wall stops being readable. So a phone gets its own
   wall, two columns and ten notes, from the `m` layout in the story data. */
@media (max-width: 620px){
  .note{
    /* The rows are 14.5% of the stage apart but the note is sized from WIDTH, so on a short
       phone (667, 640, 560) the square grows taller than its own row and the wall collides
       with itself. 13.5vh is the tallest that always leaves a gap. */
    /* Smaller than it was: the wall now has to clear a band through the middle for the sentence
       it surrounds, so two rows have to fit above it and two below. */
    --nw:min(clamp(92px, 26vw, 112px), 12vh); --fold:20px;
    font-size:.8rem; line-height:1.24; padding:.6rem .65rem .8rem;
    left:min(var(--mx, 0%), calc(100% - var(--nw) - 10px));
    top:var(--my, 0%);
    box-shadow:0 12px 20px -12px rgb(0 0 0 / .78), 0 2px 5px -2px rgb(0 0 0 / .5);
  }
  .note-off-m{ display:none; }
  /* the phone wall sits below the text, so the narration gets its width back */
  .narr{ width:min(560px, 88vw); }
  .bridge{ width:min(560px, 88vw); }
}
@media (max-width: 420px){
}
