/* ============================================================
   THE SOLANA LABS — v6 "STUDIO"
   Light-first, editorial-technical. No teal, no gold, no Deep Field.
   ============================================================ */
@font-face{font-family:'Bricolage Grotesque';src:url('/assets/fonts/bricolage-grotesque.woff2') format('woff2');font-weight:200 800;font-display:swap}
@font-face{font-family:'Outfit';src:url('/assets/fonts/outfit.woff2') format('woff2');font-weight:100 900;font-display:swap}

:root{
  --paper:#F5F5F2; --card:#FFFFFF; --card-2:#FAFAF8;
  --ink:#111113; --ink-2:#2A2A30; --muted:#6E6E78;
  --line:rgba(17,17,19,.10); --line-2:rgba(17,17,19,.055);
  --accent:#2E5BFF; --accent-ink:#1F42CC; --accent-wash:rgba(46,91,255,.08);
  --accent-line:rgba(46,91,255,.30); --chip:rgba(17,17,19,.06);
  --on-accent:#FFFFFF; --blueprint:#2E5BFF;
  /* legacy names the pre-v6 pages still reference. --panel meant a card
     surface in the old system; keeping that meaning is what stops those
     cards rendering as a blue slab with dark text on it. */
  --panel:#FFFFFF; --line-soft:rgba(17,17,19,.055);
  --teal:#2E5BFF; --brand-deep:#1F42CC; --gold:#8A5F0B;
  --disp:'Bricolage Grotesque',system-ui,sans-serif;
  --body:'Outfit',system-ui,sans-serif;
  --mono:ui-monospace,'SF Mono',Menlo,Consolas,monospace;
  --e:cubic-bezier(.2,.7,.2,1);
  --r-s:10px; --r-m:16px; --r-l:24px; --r-xl:32px;
  --col:1240px;
  --shadow:0 1px 2px rgba(17,17,19,.04),0 8px 24px -12px rgba(17,17,19,.10);
  --shadow-lift:0 2px 4px rgba(17,17,19,.05),0 18px 44px -18px rgba(17,17,19,.18);
  color-scheme:light;
}
:root[data-theme="dark"]{
  --paper:#0B0B0D; --card:#141418; --card-2:#17171C;
  --ink:#F3F3F6; --ink-2:#D6D6DE; --muted:#9A9AA6;
  --line:rgba(243,243,246,.12); --line-2:rgba(243,243,246,.065);
  --accent:#7D9BFF; --accent-ink:#9DB3FF; --accent-wash:rgba(125,155,255,.12);
  --accent-line:rgba(125,155,255,.34); --chip:rgba(243,243,246,.09);
  --on-accent:#0B0B0D; --blueprint:#2746C9;
  --panel:#141418; --line-soft:rgba(243,243,246,.065);
  --teal:#7D9BFF; --brand-deep:#9DB3FF; --gold:#E0A93F;
  --shadow:0 1px 2px rgba(0,0,0,.4),0 8px 24px -12px rgba(0,0,0,.6);
  --shadow-lift:0 2px 4px rgba(0,0,0,.45),0 18px 44px -18px rgba(0,0,0,.7);
  color-scheme:dark;
}
*{margin:0;padding:0;box-sizing:border-box}
html{-webkit-text-size-adjust:100%;scroll-behavior:smooth}
@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}}
body{background:var(--paper);color:var(--ink);font-family:var(--body);font-size:17px;
  line-height:1.6;-webkit-font-smoothing:antialiased;overflow-x:clip}
::selection{background:var(--accent);color:var(--on-accent)}
a{color:inherit;text-decoration:none}
img{max-width:100%;display:block}
.wrap{width:min(100% - 44px,var(--col));margin-inline:auto}

/* ---------- type ---------- */
h1,h2,h3,h4{font-family:var(--disp);font-weight:600;letter-spacing:-.025em;line-height:1.05}
h1{font-size:clamp(2.9rem,7vw,5.4rem);letter-spacing:-.04em}
h2{font-size:clamp(2rem,4.2vw,3.1rem);letter-spacing:-.032em}
h3{font-size:1.18rem;letter-spacing:-.018em}
.lede{font-size:clamp(1.06rem,1.6vw,1.26rem);color:var(--muted);max-width:60ch;line-height:1.62}
.eyebrow{font-family:var(--mono);font-size:.7rem;letter-spacing:.17em;text-transform:uppercase;
  color:var(--muted);display:inline-flex;align-items:center;gap:9px}
.eyebrow::before{content:"";width:22px;height:1px;background:currentColor;opacity:.5}
.fig{font-family:var(--mono);font-size:.66rem;letter-spacing:.1em;color:var(--muted)}

/* ---------- top bar ---------- */
.bar{position:sticky;top:0;z-index:80;background:color-mix(in srgb,var(--paper) 86%,transparent);
  backdrop-filter:saturate(1.6) blur(14px);border-bottom:1px solid var(--line-2)}
.bar .wrap{display:flex;align-items:center;justify-content:space-between;gap:20px;height:72px}
.brand{display:flex;align-items:center;gap:11px;font-family:var(--disp);font-weight:650;
  font-size:1.02rem;letter-spacing:-.02em}
.brand .mark{width:34px;height:34px;color:var(--ink);flex:0 0 auto}
.nav{display:flex;align-items:center;gap:2px}
.nav a{font-size:.9rem;color:var(--muted);padding:9px 13px;border-radius:99px;
  transition:color .16s var(--e)}
/* The pill is gone. The rail (appended below) becomes the single mechanism and
   the only one that can also express "current" without reading as a pressed
   button — its resting state IS the current state: full width, accent,
   opacity 1. --line-2 is a 5.5%/6.5% wash, so in dark the pill was very
   nearly invisible and was already carrying almost nothing; it also competed
   with the two other pill shapes in the same 72px row (.tgl, .btn-ink).
   Gated on hover:hover so a tap on a tablet above the 720px breakpoint cannot
   stick it — the rail half of that gate is in the appended nav block. */
@media (hover:hover){
  .nav a:hover{color:var(--ink)}
}
.bar-cta{display:flex;align-items:center;gap:9px}
.tgl{width:36px;height:36px;border-radius:99px;border:1px solid var(--line);background:transparent;
  display:grid;place-items:center;cursor:pointer;color:var(--muted);transition:.18s;flex:0 0 auto}
.tgl:hover{color:var(--ink);border-color:var(--ink)}
.tgl svg{width:16px;height:16px}
:root[data-theme="dark"] .tgl .sun{display:none}
:root:not([data-theme="dark"]) .tgl .moon{display:none}

/* ---------- buttons ---------- */
.btn{display:inline-flex;align-items:center;gap:9px;font-family:var(--body);font-weight:500;
  font-size:.95rem;padding:13px 22px;border-radius:99px;border:1px solid transparent;
  letter-spacing:normal;cursor:pointer;transition:.18s;white-space:nowrap}

/* Primary. NO border-color here: .btn-pri declares none at rest, so it stays
   transparent from the .btn base. Declaring it on :hover would be (0,2,0) and
   land AFTER .hero .btn-pri:hover (line 120), growing a blue 1px ring around
   the WHITE hero pill on index.html:71 — the homepage's main CTA. */
.btn-pri{background:var(--accent);color:var(--on-accent)}
.btn-pri:hover,
.btn-pri:focus-visible{background:var(--accent-ink);transform:translateY(-1px)}
.btn-pri:active{background:color-mix(in srgb,var(--accent-ink) 92%,#000)}

/* Secondary. The border was the whole affordance and the legacy .btn.ghost had
   no :hover and no :focus at all — that is the "no feedback" report. No
   transform: it sits beside a primary that lifts, and two moving neighbours
   read as noise. */
.btn-ghost{border-color:var(--line);color:var(--ink);background:transparent}
.btn-ghost:hover,
.btn-ghost:focus-visible{border-color:var(--ink);background:var(--card)}
.btn-ghost:active{background:var(--card-2);border-color:var(--ink)}

/* Ink. opacity:.9 is GONE — it faded label and fill together and left the pair
   unmeasurable. --ink-2 is a real token with a known ratio. */
.btn-ink{background:var(--ink);color:var(--paper)}
.btn-ink:hover,
.btn-ink:focus-visible{background:var(--ink-2);transform:translateY(-1px)}
.btn-ink:active{background:var(--ink-2)}
.btn-sm{padding:9px 16px;font-size:.86rem}
.btn svg{width:15px;height:15px;flex:0 0 auto}

/* ---------- blueprint panel ---------- */
.blueprint{position:relative;border-radius:var(--r-xl);overflow:hidden;
  background:var(--blueprint);color:#fff;isolation:isolate}
.blueprint::before{content:"";position:absolute;inset:0;pointer-events:none;
  background-image:linear-gradient(rgba(255,255,255,.14) 1px,transparent 1px),
                   linear-gradient(90deg,rgba(255,255,255,.14) 1px,transparent 1px);
  background-size:46px 46px;mask-image:radial-gradient(ellipse at 50% 40%,#000 55%,transparent 100%)}
.tick{position:absolute;font-family:var(--mono);font-size:.6rem;letter-spacing:.14em;
  color:rgba(255,255,255,.92);text-transform:uppercase}
.tick.tl{top:18px;left:22px}.tick.tr{top:18px;right:22px}
.tick.bl{bottom:18px;left:22px}.tick.br{bottom:18px;right:22px}

/* ---------- sections ---------- */
.sec{padding:clamp(72px,9vw,124px) 0}
.sec-head{display:flex;flex-direction:column;gap:16px;margin-bottom:46px;max-width:68ch}
.sec-head .row{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;flex-wrap:wrap}

/* ---------- hero ---------- */
.hero{padding:clamp(28px,4vw,52px) 0 clamp(56px,7vw,92px)}
.hero-panel{padding:clamp(40px,6vw,86px) clamp(26px,5vw,72px) clamp(44px,6vw,78px)}
.hero h1{color:#fff;max-width:17ch;margin:22px 0 0}
.hero .lede{color:rgba(255,255,255,.95);margin-top:24px;max-width:52ch}
.hero .eyebrow,.blueprint .eyebrow{color:rgba(255,255,255,.93)}
.hero-ctas{display:flex;gap:12px;margin-top:38px;flex-wrap:wrap}
.hero .btn-pri{background:#fff;color:#1F42CC}
.hero .btn-pri:hover{background:#EEF2FF}
/* Same fixed --blueprint field as .cta-panel, so the same recipe. A WHITE
   wash lightens the field to #476FFF and drops the white label to 4.21:1 — a
   measured failure at 15.2px/500. An INK wash darkens it and raises the label
   to 6.39:1, so hover becomes the most legible state rather than the least.
   The resting border goes .4 -> .70 for the 3:1 non-text bar (2.03:1 -> 3.33:1
   light, 4.53:1 dark); the blue hero and the blue panels now behave
   identically instead of carrying two recipes for one surface. */
.hero .btn-ghost{border-color:rgba(255,255,255,.70);color:#fff}
.hero .btn-ghost:hover,
.hero .btn-ghost:focus-visible{background:rgba(11,11,13,.14);border-color:#fff;color:#fff}
.hero .btn-ghost:active{background:rgba(11,11,13,.22);border-color:#fff;color:#fff}

/* ---------- stat strip ---------- */
.stat-wrap{width:min(100% - 100px,calc(var(--col) - 56px));margin-inline:auto}
.stat-wrap .stats{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--line);
  border:1px solid var(--line);border-radius:var(--r-m);overflow:hidden;margin-top:-34px;
  position:relative;z-index:2;box-shadow:var(--shadow)}
.stat-wrap .stat{background:var(--card);padding:24px 22px}
.stat-wrap .stat b{display:block;font-family:var(--disp);font-size:2rem;font-weight:600;letter-spacing:-.03em}
.stat-wrap .stat span{font-family:var(--mono);font-size:.66rem;letter-spacing:.13em;text-transform:uppercase;color:var(--muted)}

/* ---------- app grid (the box thing) ---------- */
.apps{display:grid;grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:14px}
.app{aspect-ratio:1;border-radius:22px;overflow:hidden;background:var(--card);
  border:1px solid var(--line);box-shadow:var(--shadow);transition:.22s;position:relative}
.app img{width:100%;height:100%;object-fit:cover}
.app:hover{transform:translateY(-4px) scale(1.02);box-shadow:var(--shadow-lift);border-color:var(--accent)}

/* ---------- cards ---------- */
.grid-3{display:grid;grid-template-columns:repeat(auto-fit,minmax(282px,1fr));gap:18px}
.grid-2{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:18px}
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--r-l);
  padding:30px 28px;box-shadow:var(--shadow);transition:.22s}
.card:hover{box-shadow:var(--shadow-lift)}
.card h3{margin-bottom:10px}
.card p{color:var(--muted);font-size:.97rem}
.card .ic{width:40px;height:40px;border-radius:12px;background:var(--accent-wash);
  display:grid;place-items:center;margin-bottom:18px;color:var(--accent)}
.card .ic svg{width:19px;height:19px;stroke:currentColor;fill:none;stroke-width:1.7}

/* ---------- steps ---------- */
.steps{counter-reset:s;display:grid;gap:0;border-top:1px solid var(--line)}
.step{counter-increment:s;display:grid;grid-template-columns:72px 1fr 1fr;gap:28px;
  padding:30px 4px;border-bottom:1px solid var(--line);align-items:start}
.step::before{content:"0" counter(s);font-family:var(--mono);font-size:.78rem;color:var(--accent);
  letter-spacing:.1em;padding-top:4px}
.step h3{margin:0}
.step p{color:var(--muted);font-size:.97rem}
@media(max-width:720px){.step{grid-template-columns:48px 1fr;gap:16px}.step p{grid-column:2}}

/* ---------- pricing ---------- */
.tiers{display:grid;grid-template-columns:repeat(auto-fit,minmax(286px,1fr));gap:18px;align-items:start}
.tier{background:var(--card);border:1px solid var(--line);border-radius:var(--r-l);
  padding:32px 28px;box-shadow:var(--shadow);display:flex;flex-direction:column;gap:18px}
.tier.feat{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent),var(--shadow-lift);position:relative}
.tier.feat::after{content:"MOST CHOSEN";position:absolute;top:-10px;left:28px;background:var(--accent);
  color:var(--on-accent);font-family:var(--mono);font-size:.6rem;letter-spacing:.13em;
  padding:4px 11px;border-radius:99px}
.tier .name{font-family:var(--disp);font-size:1.12rem;font-weight:600}
.tier .price{font-family:var(--disp);font-size:2.3rem;font-weight:600;letter-spacing:-.035em;line-height:1}
.tier .price small{font-family:var(--body);font-size:.84rem;font-weight:400;color:var(--muted);letter-spacing:0}
.tier .blurb{color:var(--muted);font-size:.94rem}
.tier ul{list-style:none;display:grid;gap:10px;margin-top:2px}
.tier li{display:flex;gap:10px;font-size:.93rem;color:var(--ink-2);line-height:1.45}
.tier li svg{width:15px;height:15px;flex:0 0 auto;margin-top:4px;color:var(--accent);
  stroke:currentColor;fill:none;stroke-width:2.4}
.tier .btn{width:100%;justify-content:center;margin-top:auto}

/* ---------- book a call ---------- */
.book{padding:clamp(40px,5vw,66px) clamp(26px,4vw,56px);display:grid;
  grid-template-columns:1.15fr .85fr;gap:40px;align-items:center}
@media(max-width:860px){.book{grid-template-columns:1fr}}
.book h2{color:#fff;max-width:16ch}
.book p{color:rgba(255,255,255,.95);margin-top:16px;max-width:46ch}
.book-list{list-style:none;display:grid;gap:12px;margin-top:26px}
.book-list li{display:flex;gap:10px;color:rgba(255,255,255,.96);font-size:.95rem}
.book-list svg{width:16px;height:16px;flex:0 0 auto;margin-top:4px;stroke:#fff;fill:none;stroke-width:2.2}
.book-card{background:#fff;border-radius:var(--r-l);padding:28px 26px;color:var(--ink);
  box-shadow:0 20px 60px -20px rgba(0,0,0,.4)}
:root[data-theme="dark"] .book-card{background:var(--card)}
.book-card .fig{margin-bottom:14px;display:block}
.book-card h3{margin-bottom:8px}
.book-card p{color:var(--muted);font-size:.93rem;margin:0 0 20px}
.book-card .btn{width:100%;justify-content:center}
.book-card .alt{text-align:center;font-size:.84rem;color:var(--muted);margin-top:14px}
.book-card .alt a{color:var(--accent);text-decoration:underline;text-underline-offset:3px}

/* ---------- faq ---------- */
.faq{border-top:1px solid var(--line)}
.faq details{border-bottom:1px solid var(--line)}
.faq summary{list-style:none;cursor:pointer;padding:22px 2px;display:flex;align-items:center;
  justify-content:space-between;gap:20px;font-family:var(--disp);font-weight:550;font-size:1.04rem;
  letter-spacing:-.015em}
.faq summary::-webkit-details-marker{display:none}
.faq summary::after{content:"+";font-family:var(--mono);color:var(--accent);font-size:1.2rem;flex:0 0 auto}
/* no glyph swap: the "+" rotates into an "x" instead (see the motion block) */
.faq p{padding:0 2px 24px;color:var(--muted);font-size:.97rem;max-width:72ch}

/* ---------- footer ---------- */
.foot{background:var(--card);border-top:1px solid var(--line);padding:64px 0 34px;margin-top:0}
.foot-grid{display:grid;grid-template-columns:1.6fr 1fr 1fr 1fr;gap:38px}
@media(max-width:860px){.foot-grid{grid-template-columns:1fr 1fr}}
.foot h5{font-family:var(--mono);font-size:.64rem;letter-spacing:.16em;text-transform:uppercase;
  color:var(--muted);margin-bottom:16px;font-weight:400}
.foot ul{list-style:none;display:grid;gap:9px}
.foot a{color:var(--muted);font-size:.93rem;transition:.16s}
.foot a:hover{color:var(--ink)}
.foot .blurb{color:var(--muted);font-size:.93rem;max-width:34ch;margin-top:14px}
.foot-note{border-top:1px solid var(--line);margin-top:46px;padding-top:24px;
  font-family:var(--mono);font-size:.66rem;line-height:1.95;color:var(--muted)}

/* ---------- reveal ---------- */
.rv{opacity:0;transform:translateY(8px)}
html.js .rv.in{opacity:1;transform:none;transition:opacity .6s cubic-bezier(.2,.7,.3,1),transform .6s cubic-bezier(.2,.7,.3,1)}
html:not(.js) .rv{opacity:1;transform:none}
@media(prefers-reduced-motion:reduce){.rv,.rv.in{opacity:1!important;transform:none!important;transition:none!important}}

@media(max-width:720px){
  .stat-wrap .stats{grid-template-columns:1fr 1fr;margin-top:-20px}
  .nav{display:none}
  .bar .wrap{height:64px}
}

/* ============================================================
   SHOWCASE — App Store artwork marquee
   ============================================================ */
/* The row must stay clipped HORIZONTALLY — the track is 3 copies wide — but a
   hovered phone has to be able to leave the row vertically. overflow:hidden is
   NOT the only clip here: mask-clip's initial value is border-box and the
   Masking spec restricts painted content to the mask painting area, so the
   mask is a second, independent 2-D clip on the SAME rectangle. Switching to
   overflow-x:clip / overflow-y:visible would therefore change nothing visible,
   and overflow-clip-margin applies to the CLIPPED axis (x), where it would
   leak the 3x track past the edge fade. So stop unclipping and give that one
   box room: 10px -> 24px of block padding, taken straight back as -14px of
   block margin. Every phone, the second row and the <p class="fig"> below land
   on exactly the pixel they do today (see .mq-row + .mq-row for the other
   half). padding-inline stays 0, so the border-box WIDTH is untouched and the
   6%/94% mask stops land on the same pixels; the gradient is 90deg, i.e.
   uniform in Y, so a taller box cannot alter the fade.
   24px is sized to contain the OLD -10px/1.035 hover (17.5px above, ~19.5px
   below once --shadow-lift's ~22px blur reach is counted), so the clipping is
   fixed even if the gesture is left alone. */
/* The band the mounts sit on. In LIGHT the mount is pure white and --paper is
   #F5F5F2 — a 10-value difference, so the frames float with nothing to sit
   against. A soft accent tint gives them a field, the way the reference uses
   blue. The tint goes on the ROWS, not on #showcase: the section also holds
   .lede and .fig, both --muted, which sit at 4.83:1 on paper and would drop to
   4.10:1 over this tint — a real failure. Nothing but the mounts sits on the
   rows, and the mount captions are on the mount, not on the band.
   The two rows' boxes overlap by 14px (their -14px margins collapse), so the
   two tinted boxes paint as one continuous band with no seam.
   In DARK the mount (#141418) is already lighter than the page (#0B0B0D) and
   reads fine; an accent tint there computes to (19,21,30), within two values
   of the mount, which would make it worse. So dark keeps --paper. */
.showcase{overflow:hidden;padding:24px 0;margin-block:-14px;
  /* The band the mounts sit on. Character comes from two soft pools of accent
     at the left and right plus a fine dot weave — all HORIZONTAL in effect.
     A vertical gradient is not an option: the two rows are separate boxes
     that overlap by 14px, so a top-to-bottom ramp restarts at the second row
     and draws a visible seam straight across the section. Radial pools centred
     on the mid-line and a tiling dot grid are identical in both rows, so the
     band reads as one continuous surface. */
  background:
    radial-gradient(color-mix(in srgb,var(--accent) 20%,transparent) 1px,transparent 1.5px)
      0 0/26px 26px,
    radial-gradient(72% 130% at 16% 50%,color-mix(in srgb,var(--accent) 15%,var(--paper)),transparent 64%),
    radial-gradient(64% 120% at 84% 50%,color-mix(in srgb,var(--accent) 12%,var(--paper)),transparent 62%),
    color-mix(in srgb,var(--accent) 5%,var(--paper));
  --mq-copies:3;--mq-gap:16px;
  -webkit-mask-image:linear-gradient(90deg,transparent,#000 6%,#000 94%,transparent);
          mask-image:linear-gradient(90deg,transparent,#000 6%,#000 94%,transparent)}
/* Dark gets the same treatment at lower strength. The mount is --card
   (#141418) against --paper (#0B0B0D) and already separates, so this is for
   character only and must not close that gap: the pools top out at 9%. */
:root[data-theme="dark"] .showcase{
  background:
    radial-gradient(color-mix(in srgb,var(--accent) 14%,transparent) 1px,transparent 1.5px)
      0 0/26px 26px,
    radial-gradient(72% 130% at 16% 50%,color-mix(in srgb,var(--accent) 9%,var(--paper)),transparent 64%),
    radial-gradient(64% 120% at 84% 50%,color-mix(in srgb,var(--accent) 7%,var(--paper)),transparent 62%),
    var(--paper)}
.mq{display:flex;gap:var(--mq-gap);width:max-content;will-change:transform}
html.motion .mq{animation:mq 112s linear infinite}
html.motion .mq.rev{animation-direction:reverse;animation-duration:148s}
.showcase:hover .mq,.showcase:focus-within .mq{animation-play-state:paused}
/* Each row is 3 identical copies of a 9-shot run, so one loop period is a
   third of the track. The old keyframe travelled -50%, which lands 4.5 shots
   into a 9-shot period and visibly jumped on every cycle. For C copies of a
   run of n, track = C*n items + (C*n - 1) gaps, so C*period = track + gap and
   period = track/C + gap/C. A margin applied to every n-th child cancels out
   of that identity, which is what lets the per-app grouping below exist. */
@keyframes mq{from{transform:translateX(0)}
  to{transform:translateX(calc(-100% / var(--mq-copies) - var(--mq-gap) / var(--mq-copies)))}}
@media(prefers-reduced-motion:reduce){html.motion .mq{animation:none}
  .showcase{overflow-x:auto;scrollbar-width:thin;
    -webkit-mask-image:none;mask-image:none}}
/* A shot is a device, not a rectangle. The frame is the phone body and the
   label sits in its chin on solid metal rather than floating over the artwork
   — which is what lets the app name stay visible at all times and still pass
   AA, since text over an arbitrary screenshot never can. background-COLOR is
   set as a longhand on purpose: the shorthand would reset it to transparent
   and leave the chin label with no resolvable backdrop to measure against. */
/* Geometry measured off the reference image, not estimated.
   Both reference cards fit an outer radius of 80.2px on a 435px-wide card
   (rms 3.2px) with a 6px white stroke, and their 435x941 box is aspect 0.4623
   against our screenshots' 1284/2778 = 0.4622 — the same card, so the ratios
   transfer directly:
       radius = 0.1844 x width        stroke = 0.0138 x width
   At 196px that is 36px and 2.7px; at 168px, 31px and 2.3px. The stroke is
   held at 3px on both rows because it is a stroke, not a scaled mount.
   The inner radius is always outer - stroke, so the curves stay concentric.
   The 1px border is TRANSPARENT and exists only to carry the accent on hover;
   background-clip defaults to border-box, so the white paints under it and the
   visible band is the 2px padding plus that 1px = 3px. */
/* The mount is WHITE IN BOTH THEMES, not var(--card). The mount is the print's
   frame — a property of the object, like the four deliberately fixed surfaces
   elsewhere in this file — not a theme surface. At var(--card) it went #141418
   in dark and the frame simply vanished against the page, which is the whole
   reason the cards stopped reading as mounted prints there. The captions are
   off on both rows, so nothing sits on this white and no contrast pair
   depends on it. */
.shot{flex:0 0 auto;width:min(196px,46vw);display:flex;flex-direction:column;
  padding:2px;border-radius:36px;position:relative;
  background-color:#FFFFFF;
  border:1px solid transparent;box-shadow:var(--shadow)}
.shot img{width:100%;height:auto;display:block;aspect-ratio:1284/2778;
  object-fit:cover;border-radius:33px}
/* No captions on either row. */
.shot-tag{display:none}
/* 6px, no scale. An integer translate keeps the 8.96px uppercase mono chin
   label crisp instead of resampling it — that label is the only text in the
   row. Dropping scale() also removes the horizontal growth the 6%/94% mask
   fade was clipping on shots near the row edges. In DARK, --shadow-lift is
   pure black (rgba(0,0,0,.45)/.7) under a near-white phone on a near-black
   page, so it carries almost no signal there — the accent hairline is what
   actually announces the state. The 1px border already exists at var(--line),
   so only its colour changes: no reflow. */
@media (hover:hover) and (pointer:fine){
  .shot:hover{transform:translateY(-6px);box-shadow:var(--shadow-lift);
    border-color:var(--accent);z-index:2}
}
.shot:focus-visible{transform:translateY(-6px);box-shadow:var(--shadow-lift);
  border-color:var(--accent);z-index:2}
/* The shots of one app travel as a group. This was :nth-child(3n), which only
   worked while every app contributed exactly 3 shots; Elements is now 2, so the
   positional rule would put the gutter mid-app and drift further every group.
   The boundary is marked in the markup instead. The loop math is unaffected:
   each of the 3 copies carries the same number of these margins, so they cancel
   out of period = track/copies + gap/copies exactly as the gaps do. */
.mq > .shot.gend{margin-right:20px}
/* the reversed row reads as the row behind it: smaller, slower. Same measured
   ratio at the smaller width: 0.1844 x 168 = 31, inner 31 - 3 = 28. */
.mq.rev > .shot{width:min(168px,40vw);border-radius:31px}
.mq.rev > .shot img{border-radius:28px}
/* The 14px that used to separate the rows now lives inside the two 24px
   paddings: row 1's -14px bottom margin and row 2's 0 top margin collapse to
   -14px (no adjoining positive margins, so the most negative wins), landing
   row 2's phones at row1Bottom + (24 - 14 + 24) = +34px — the same 34px as
   today's 10 + 14 + 10.
   DO NOT "restore" this to 14px: with 24px of padding on each row that
   re-opens the visual gap to 48px. The identity is gap = 10px + padding. */
.mq-row + .mq-row{margin-top:0}

/* ============================================================
   LEGACY VOCABULARY — restyles the markup the existing pages
   already use, so every <main> stays byte-identical.
   ============================================================ */
main.wrap.legal,main.legal{width:min(100% - 44px,920px);margin-inline:auto;
  padding-top:clamp(42px,6vw,72px);padding-bottom:clamp(56px,7vw,96px)}
main > .sec:first-child,main > section:first-child{padding-top:clamp(42px,6vw,72px)}
.page-hero{margin-bottom:34px}
.page-hero h1{font-size:clamp(2.2rem,4.6vw,3.3rem);margin:16px 0 0}
.kicker{font-family:var(--mono);font-size:.68rem;letter-spacing:.17em;text-transform:uppercase;
  color:var(--muted);display:inline-flex;align-items:center;gap:9px}
.kicker::before{content:"";width:22px;height:1px;background:currentColor;opacity:.5}
.dates{display:flex;flex-wrap:wrap;gap:8px;margin-top:18px}
.tag,.mono-tag,.chip,.plat,.store-pill{font-family:var(--mono);font-size:.66rem;letter-spacing:.11em;
  text-transform:uppercase;color:var(--muted);border:1px solid var(--line);
  border-radius:99px;padding:6px 13px;display:inline-flex;align-items:center;gap:7px;background:var(--card)}
.store-pill{text-transform:none;letter-spacing:.02em;font-family:var(--body);font-size:.86rem;
  padding:9px 16px;color:var(--ink)}
/* deleted — all 18 store-pills on the site are inside .grid-cat (verified by
   grep: projects/index.html only), where .grid-cat .store-pill:hover (0,3,0)
   owns colour, border and background. This global (0,1,1) rule was already
   unreachable; the CONTROL role in the appended block supplies the travel. */
.panel,.panel.still{background:var(--card);border:1px solid var(--line);border-radius:var(--r-l);
  padding:clamp(26px,3.6vw,46px);box-shadow:var(--shadow)}
.legal-body{font-size:1rem;line-height:1.72;color:var(--ink-2)}
.legal-body h2{font-size:1.5rem;margin:40px 0 14px;letter-spacing:-.022em}
.legal-body h2:first-child{margin-top:0}
.legal-body h3{font-size:1.08rem;margin:26px 0 10px;color:var(--ink)}
.legal-body p{margin-bottom:14px}
.legal-body ul,.legal-body ol{margin:0 0 16px 20px;display:grid;gap:9px}
.legal-body li{color:var(--muted)}
.legal-body li b,.legal-body li strong{color:var(--ink)}
/* :not(.btn) is load-bearing. This rule is (0,1,1) and .btn-pri is (0,1,0),
   so inside a .legal-body it won the colour and painted an accent label on an
   accent fill -- 1.00:1, invisible -- on the three delete-account pages whose
   CTA lives in the legal body. The old .btn.brand shim was (0,2,0) and had
   been masking it. */
.legal-body a:not(.btn){color:var(--accent);text-decoration:underline;text-underline-offset:3px}
.legal-body strong{color:var(--ink)}
.legal-body code{font-family:var(--mono);font-size:.86em;color:var(--accent-ink);
  background:var(--accent-wash);border:1px solid var(--line);border-radius:5px;
  padding:1px 6px;white-space:nowrap}
table{width:100%;border-collapse:collapse;font-size:.92rem;margin:4px 0 18px}
th{font-family:var(--mono);font-size:.62rem;letter-spacing:.13em;text-transform:uppercase;
  color:var(--muted);text-align:left;font-weight:400;padding:10px 14px 10px 0;
  border-bottom:1px solid var(--line)}
td{padding:13px 14px 13px 0;border-bottom:1px solid var(--line-2);color:var(--muted);vertical-align:top}
td strong,td b{color:var(--ink)}
.table-scroll{overflow-x:auto;margin-bottom:8px}
.callout,.note,.disclaim,.tldr,.scope{border:1px solid var(--line);border-left:3px solid var(--accent);
  border-radius:var(--r-s);padding:20px 24px;margin:22px 0;background:var(--accent-wash)}
.callout p:last-child,.note p:last-child,.disclaim p:last-child,.tldr p:last-child{margin-bottom:0}
.note h3,.disclaim h3{margin-top:0;color:var(--accent-ink)}
.tldr h2{font-size:1.06rem;margin:0 0 12px}
.tldr ul{margin-left:18px}
.scope{font-family:var(--mono);font-size:.74rem;line-height:1.8;background:transparent;
  border-left-color:var(--line);color:var(--muted)}
.warn{border-left-color:#D7263D;background:rgba(215,38,61,.06)}
.warn h3{color:#D7263D}
.steps{counter-reset:s;display:grid;gap:14px;margin:22px 0;list-style:none}
.steps li{counter-increment:s;position:relative;padding-left:50px;color:var(--muted)}
.steps li::before{content:"0" counter(s);position:absolute;left:0;top:1px;font-family:var(--mono);
  font-size:.72rem;color:var(--accent);letter-spacing:.08em}
.steps li b{color:var(--ink)}
.steps li .sub{display:block;margin-top:7px;font-size:.9rem;padding-left:14px;
  border-left:1px solid var(--line)}
.nodo,.gone,.kept{list-style:none;padding-left:0;display:grid;gap:10px;margin:0 0 18px}
.nodo li,.gone li,.kept li{position:relative;padding-left:28px;color:var(--muted)}
.nodo li::before,.gone li::before{content:"\2715";position:absolute;left:0;top:1px;
  color:#D7263D;font-family:var(--mono);font-size:.8rem}
.kept li::before{content:"\2192";position:absolute;left:0;top:0;color:var(--accent);font-family:var(--mono)}
.nodo li b,.gone li b,.kept li b{color:var(--ink)}
.faq-item{background:var(--card);border:1px solid var(--line);border-radius:var(--r-m);
  padding:22px 24px;margin-bottom:12px}
.faq-item h3{margin:0 0 8px;font-size:1.02rem}
.faq-item p{color:var(--muted);font-size:.95rem;margin:0}
.policy-foot{margin-top:42px;padding-top:24px;border-top:1px solid var(--line);
  font-family:var(--mono);font-size:.68rem;letter-spacing:.03em;color:var(--muted);line-height:1.9}
.policy-foot a{color:var(--accent)}
/* ---- the three legacy shims are DELETED, not repaired --------------------
   .btn.brand / .btn.brand:hover / .btn.ghost lived here, and .btn.gold never
   existed at all. The markup on the eight v6 pages is rewritten to
   .btn-pri / .btn-ghost (see htmlEdits), so nothing references them.

   WHY DELETED: .btn.brand (0,2,0) LOSES to the later .cta-panel .btn (line
   378, also (0,2,0)), which sets color:#1F42CC — then .btn.brand:hover (0,3,0)
   punches back through and replaces only the background with --accent-ink,
   which in light IS #1F42CC. The label did not merely lose contrast, it
   reached 1.00:1 and vanished, on the exact button the user named
   (contact-us/index.html:58) and again at services/index.html:214. Deleting
   the shim removes the collision at its source; adding a colour to its hover
   would only balance it.

   DO NOT rewrite fisch/index.html: it is the only page on /assets/css/page.css
   (and it loads /assets/js/page.js), where .btn.gold / .btn.gold:hover /
   .btn.brand / .btn.ghost already exist and work in that page's gold dialect
   (page.css:124-126, 278-279).                                            */
.btn .ic,.btn svg.ic{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.8}
.sec-head h2{margin-top:12px}
.hr,.rule{height:1px;background:var(--line);border:0;margin:40px 0}
.svc,.mini,.pcard,.card,.web{background:var(--card);border:1px solid var(--line);
  border-radius:var(--r-l);padding:28px 26px;box-shadow:var(--shadow)}
.svc h3,.mini h3,.pcard h3{margin-bottom:9px}
.svc p,.mini p,.pcard p{color:var(--muted);font-size:.96rem}
.appicon{border-radius:20px;overflow:hidden;border:1px solid var(--line);box-shadow:var(--shadow)}
.glyph{color:var(--accent)}
.links{display:flex;flex-wrap:wrap;gap:9px;margin-top:16px}
.oops{min-height:62svh;display:grid;place-items:center;text-align:center}
.mark404{width:clamp(76px,11vw,112px);height:auto;margin:0 auto 26px;color:var(--ink)}
.big-mail{font-family:var(--disp);font-size:clamp(1.5rem,3.6vw,2.4rem);letter-spacing:-.03em;
  color:var(--accent);word-break:break-word}
.panel.cta-panel,.panel.still.cta-panel,.cta-panel{background:var(--blueprint);color:#fff;border-radius:var(--r-xl);
  padding:clamp(34px,4.4vw,56px);margin-top:40px}
.cta-panel h2,.cta-panel h3{color:#fff}
.cta-panel p,.cta-panel li{color:rgba(255,255,255,.95)}
/* ---------- buttons on a blue panel --------------------------------------
   The one line this replaces painted the primary and the secondary
   identically (two white slabs), gave neither a hover, and — being (0,2,0)
   like .btn.brand — set the colour that .btn.brand:hover then kept while
   replacing the background, for 1.00:1 in light.

   A .cta-panel is a fixed --blueprint field in both themes, which is the same
   problem .hero .btn-pri / .hero .btn-ghost already solve at lines 119-122.
   This reuses that recipe (#EEF2FF is the hero's own hover tint) so the blue
   panels and the blue hero finally behave identically. Compound selectors give
   (0,3,0)/(0,4,0), so the outcome does not depend on where this lands.      */
.cta-panel .btn{background:#fff;color:#1F42CC;border-color:#fff}

.cta-panel .btn.btn-pri{background:#fff;color:#1F42CC;border-color:#fff}
.cta-panel .btn.btn-pri:hover,
.cta-panel .btn.btn-pri:focus-visible{background:#EEF2FF;border-color:#EEF2FF;
  color:#1F42CC;transform:translateY(-1px)}
.cta-panel .btn.btn-pri:active{background:#DDE4FF;border-color:#DDE4FF;color:#1F42CC}

/* Secondary: outlined on the blue, so the two buttons read as a pair with a
   hierarchy. The hover wash is INK, not white: rgba(255,255,255,.12) — the
   hero's instinct — lightens the field to #476FFF and drops the white label to
   4.21:1, a real fail at 15.2px/500. Ink darkens it and RAISES the label to
   6.39:1. One declaration, no theme fork, hover is the most legible state.  */
.cta-panel .btn.btn-ghost{background:transparent;color:#fff;
  border-color:rgba(255,255,255,.70)}
.cta-panel .btn.btn-ghost:hover,
.cta-panel .btn.btn-ghost:focus-visible{background:rgba(11,11,13,.14);color:#fff;
  border-color:#fff}
.cta-panel .btn.btn-ghost:active{background:rgba(11,11,13,.22);border-color:#fff;color:#fff}
.back{font-family:var(--mono);font-size:.7rem;letter-spacing:.13em;text-transform:uppercase;
  color:var(--muted);display:inline-flex;gap:8px;margin-top:30px}
.back:hover{color:var(--ink)}
.grid-cat,.svc-grid,.mini-grid,.web-grid,.contact-grid,.plat-grid,.method-grid{
  display:grid;grid-template-columns:repeat(auto-fit,minmax(282px,1fr));gap:18px}
/* Scoped to the no-motion case on purpose. As `.reveal{...!important}` this beat
   the scroll-driven animations below in the cascade and silently killed them on
   projects/, services/ and contact-us/. Nothing loaded sets .reveal to opacity 0,
   so plain `opacity:1` is the correct unsupported state. */
html:not(.motion) .reveal{opacity:1;transform:none}

/* --- icon sizing carried over from page.css (these marks carry no width attr) --- */
.ic{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:1.7;
  stroke-linecap:round;stroke-linejoin:round;flex:0 0 auto}
.ic-fill{fill:currentColor;stroke:none}
.glyph{width:44px;height:44px;border-radius:12px;display:grid;place-items:center;
  margin-bottom:18px;background:var(--accent-wash);color:var(--accent);flex:0 0 auto}
.glyph .ic{width:22px;height:22px}
.store-pill .ic{width:15px;height:15px}
.appicon{width:64px;height:64px;border-radius:16px;border:1px solid var(--line);flex:none}
.links{display:flex;gap:10px;flex-wrap:wrap;margin-top:18px}
/* safety net: no inline svg inside page content may size itself from its container */
main svg:not(.mark):not([width]){width:20px;height:20px;flex:0 0 auto}
main .glyph svg,main .appicon svg{width:22px;height:22px}

/* ---------- web platform cards (/projects/#web) ----------
   These reuse .web and .web-grid, which already existed in the card and grid
   vocabulary but were never used by any page. The whole card is the link, so
   the hover affordance lives on the <a> itself. .web is NOT in the
   scroll-animated subject list as an individual element -- .web-grid>* is --
   so the card itself carries the animation and must not use transform on
   hover; it lifts with shadow and border instead. */
.web-grid .web{display:flex;flex-direction:column;padding:24px 22px;
  border-radius:var(--r-l);text-decoration:none;color:inherit}
/* BOTH LINES DELETED. They are the same idea at two different strengths, and
   --accent-line measures only 1.55:1 (light) / 1.87:1 (dark) against --card —
   in dark that hairline was the card's ENTIRE hover response, because
   --shadow-lift is pure black on a #0B0B0D page and renders nothing. The
   appended SURFACE role now handles .panel.mini, .svc, .pcard, .card, .tier
   and .web-grid .web with one measured edge (accent 75%, >=3:1 on card,
   card-2 and paper in both themes) plus a :focus-within twin they never had.
   Lines 422-423 (.web-grid .web:hover .host and its arrow nudge) STAY — those
   are child effects, the same shape as the medallion rule. */
.webicon{width:46px;height:46px;border-radius:12px;border:1px solid var(--line);
  object-fit:cover;background:var(--card);margin-bottom:15px}
.web-grid .web h4{font-family:var(--disp);font-size:1.04rem;font-weight:600;
  letter-spacing:-.02em;line-height:1.3;margin:0}
.web-grid .web .host{font-family:var(--mono);font-size:.7rem;letter-spacing:.04em;
  color:var(--muted);margin-top:5px;display:inline-flex;align-items:center;gap:6px}
.web-grid .web .host::after{content:"\2197";font-family:var(--body);font-size:.92em;
  transition:transform .18s var(--e)}
.web-grid .web:hover .host{color:var(--accent-ink)}
.web-grid .web:hover .host::after{transform:translate(1px,-1px)}
.web-grid .web .desc{color:var(--muted);font-size:.92rem;line-height:1.62;margin-top:13px}

/* ============================================================
   PROJECT CARDS — scoped to .grid-cat throughout, because
   .panel.mini / .links / .store-pill / .appicon / .score are shared
   with 17 other pages whose metrics must not move. Everything below
   sits after the `main svg:not(.mark):not([width])` safety net above
   so it can win the (0,2,1) specificity tie on source order.
   ============================================================ */
.grid-cat > .panel.mini{display:flex;flex-direction:column;padding:26px 24px 22px}
.grid-cat .top{display:flex;align-items:flex-start;gap:14px;margin-bottom:15px}
.grid-cat .top h4{flex:1;min-width:0;font-family:var(--disp);font-size:1.06rem;
  font-weight:600;letter-spacing:-.02em;line-height:1.3;margin:0;padding-top:2px}
.grid-cat .appicon{width:56px;height:56px;border-radius:14px}
/* the rating reads as a badge rather than two characters adrift */
.grid-cat .score{flex:0 0 auto;display:inline-flex;align-items:center;gap:5px;
  font-family:var(--mono);font-size:.72rem;color:var(--ink);background:var(--card-2);
  border:1px solid var(--line);border-radius:99px;padding:4px 9px 4px 8px}
.grid-cat .score .ic{width:11px;height:11px;color:var(--ink-2)}
.grid-cat .desc{flex:1;color:var(--muted);font-size:.93rem;line-height:1.62}
/* links become a card footer: one hairline bled out to the card edges, and
   `margin-top:auto` puts every card's actions on the same baseline across
   the grid however long the description runs. */
.grid-cat .links{margin:20px -24px -6px;padding:15px 24px 0;gap:8px;
  border-top:1px solid var(--line-2)}
.grid-cat .store-pill{font-family:var(--body);font-size:.84rem;font-weight:500;
  letter-spacing:.01em;text-transform:none;color:var(--ink-2);
  background:var(--card-2);border:1px solid var(--line);padding:6px 13px 6px 6px;gap:9px}
/* the glyph becomes its own chip with no extra markup: an inline <svg> takes
   padding and a background like any other box, so the existing icons are the
   chips. content-box keeps the 14px drawing at 14px inside the 5px pad. */
/* `:not(.mark)` is not decoration: the safety net above is
   `main svg:not(.mark):not([width])` at (0,2,2), and these store glyphs carry
   neither a class nor a width attribute, so a plain `.grid-cat .store-pill svg`
   at (0,2,1) loses to it and the icons render at 20px inside a 30px chip. */
.grid-cat .store-pill svg:not(.mark),.grid-cat .store-pill .ic{width:14px;height:14px;
  box-sizing:content-box;padding:5px;border-radius:8px;
  background:var(--chip);color:var(--ink);
  transition:background-color .18s var(--e),color .18s var(--e)}
/* background:var(--card) brightens in light (#FAFAF8 -> #FFFFFF) but DARKENS
   in dark (#17171C -> #141418, a 1.03:1 change), so one rule pulled in
   opposite directions per theme and dark-mode hover made the pill
   imperceptibly recede. A 10% accent wash over the resting surface lifts in
   both. The :focus-visible twin is new — there was none, so a keyboard user
   tabbing the 18 store links got only the global outline. */
.grid-cat .store-pill:hover,
.grid-cat .store-pill:focus-visible{color:var(--accent-ink);
  border-color:color-mix(in srgb,var(--accent) 75%,transparent);
  background:color-mix(in srgb,var(--accent) 10%,var(--card-2))}
/* THIS is the near-white square the user reported: in dark the chip flipped to
   --ink #F3F3F6, a 16.59:1 jump against the #141418 card, two or three times
   per card. In LIGHT the same rule flipped it to near-black and took the mark
   to 1.11:1 — it disappeared exactly when you pointed at it. With the fill bug
   fixed (see the addition below) the inversion carries no legibility at all,
   so it becomes an accent tint that stays inside the card's own luminance
   register and reads as one gesture with the label going to --accent-ink.
   :not(.mark) keeps the header logo out; color-mix is already a dependency of
   this file (.bar, line 65). */
@media (hover:hover){
  .grid-cat .store-pill:hover svg:not(.mark){
    background:color-mix(in srgb,var(--accent) 18%,transparent);
    color:var(--accent-ink)}
}
.grid-cat .store-pill:focus-visible svg:not(.mark){
  background:color-mix(in srgb,var(--accent) 18%,transparent);
  color:var(--accent-ink)}
/* the third link is a website, not a store: dashed edge, outbound arrow */
.grid-cat .store-pill.web{border-style:dashed}
.grid-cat .store-pill.web::after{content:"\2197";font-family:var(--body);font-size:.86em;
  color:var(--muted);margin-left:-2px;transition:transform .18s var(--e),color .18s var(--e)}
.grid-cat .store-pill.web:hover::after{color:var(--accent-ink);transform:translate(1px,-1px)}

/* ============================================================
   INTERACTIONS
   ============================================================ */
/* ---------- the pool, seen from directly above -----------------------------
   REPLACES site/assets/css/v6.css lines 681-813 WHOLESALE (the comment at 681
   down to the closing brace of the reduce-motion block at 813, immediately
   above `/* cursor-lit blueprint *''/`). Replacing the whole block is what
   removes the dead rules: .pool-water, its ::before and ::after, the
   --pool-shallow/mid/deep/grout/lane custom properties and the
   `.pool-water::after{display:none}` line inside the old reduce block.

   Four concentric real objects, not "a box full of water":
     .throw        the tiled DECK (follows the theme — it is the building)
     .pool-shell   the COPING, a proud stone ring with a wet halo
     .pool-wall    the dark tiled WALL BAND seen down the inside wall
     .pool-floor   the WATER, and the only overflow:hidden element
   Everything painted on the floor (tiles, slope break, steps, caustics, the
   float shadows) is therefore clipped by the real waterline, while the rail
   and the ladder live on the shell and may legitimately cross the coping.
   The pool itself is FIXED in both appearances, like .cta-panel: a pool is an
   object, not a theme surface. Only the deck and the coping follow the theme.
   ------------------------------------------------------------------------- */
.throw{position:relative;height:clamp(340px,44vw,480px);border-radius:var(--r-xl);
  overflow:hidden;isolation:isolate;
  container-type:inline-size;               /* so cqw below tracks the BOX, not the viewport */
  touch-action:pan-y;                       /* never `none` — it traps mobile scroll */
  /* The three architecture widths. Every nested radius and the JS playable
     rectangle derive from these. px first as the fallback, then cqw: a bare
     percentage would resolve per-axis inside `inset` and give a deck 24px at
     the sides and 16px top and bottom. */
  --deck:22px;  --deck:clamp(10px,3.6cqw,26px);
  --coping:8px; --coping:clamp(6px,1.2cqw,9px);
  --band:12px;  --band:clamp(7px,2cqw,14px);
  --break:40%;                              /* the slope break. JS depth01() must match. */
  /* The deck is cool wet concrete, NOT #F0F0F0: against the page --paper
     (#F5F5F2) that measured 1.04:1 and the hero's most striking element
     dissolved into the page, held only by a hairline. These separate by hue
     as well as value. */
  --deck-tile:#DDE3E6; --deck-tile-2:#CDD5D9; --deck-grout:#B9C2C7;
  --coping-hi:#FFFFFF; --coping-lo:#D8D8D8;
  --wall:#0B6186; --wall-2:#0A5878; --wall-grout:rgba(255,255,255,.26);
  --floor-grout:rgba(255,255,255,.17);
  /* step treads, shallowest first; the same tokens the night block overrides */
  --step-1:#BDF4F9; --step-2:#95EAF4; --step-3:#6FDDEE;
  --step-grout:rgba(6,58,86,.26); --step-riser:rgba(5,48,74,.36);
  background:
    repeating-linear-gradient(90deg,var(--deck-grout) 0 1.5px,transparent 1.5px 48px),
    repeating-linear-gradient(180deg,var(--deck-grout) 0 1.5px,transparent 1.5px 48px),
    /* a SHEEN at .45, not solid #FFFFFF — at full opacity this painted the
       top-left 55% pure white and erased the tile colour there */
    radial-gradient(140% 120% at 12% 0%,rgba(255,255,255,.45) 0%,transparent 55%),
    linear-gradient(118deg,var(--deck-tile) 0%,var(--deck-tile-2) 100%);
  /* offset so the deck grid never aligns with the pool's own tiling */
  background-position:9px 7px,9px 7px,0 0,0 0;
  border:1px solid rgba(17,17,19,.14);
  box-shadow:var(--shadow)}


/* ---------- the pool on a phone -------------------------------------------
   At 375px the box was 331 wide x 340 tall: 44vw (165px) falls under the 340px
   floor, so it was square-ish already, but twenty bodies in that area is
   cramped and it reads small. This widens it past the page gutter and squares
   it up — about 351 x 405 at 375px, a third more water. The deck, coping and
   band are clamp(cqw) and .throw is container-type:inline-size, so they all
   re-proportion to the new box rather than to the screen.
   ------------------------------------------------------------------------- */
@media(max-width:720px){
  .throw{height:min(108vw,460px);width:calc(100% + 20px);margin-inline:-10px}
}
:root[data-theme="dark"] .throw{
  --deck-tile:#23272C; --deck-tile-2:#1A1D21; --deck-grout:#333A41;
  --coping-hi:#8E9AA3; --coping-lo:#5E6872;
  background:
    repeating-linear-gradient(90deg,var(--deck-grout) 0 1.5px,transparent 1.5px 48px),
    repeating-linear-gradient(180deg,var(--deck-grout) 0 1.5px,transparent 1.5px 48px),
    radial-gradient(140% 120% at 12% 0%,rgba(255,255,255,.07) 0%,transparent 55%),
    linear-gradient(118deg,var(--deck-tile) 0%,var(--deck-tile-2) 100%);
  background-position:9px 7px,9px 7px,0 0,0 0;
  border-color:rgba(243,243,246,.14)}

/* THE COPING. NO z-index: a z-index here would make .pool-shell a stacking
   context and bury .pool-rail / .pool-ladder (z-index:3) under all eighteen
   icons (z-index:1). overflow:visible so the rail may cross onto the deck. */
.pool-shell{position:absolute;inset:var(--deck);pointer-events:none;overflow:visible;
  border-radius:max(8px,calc(var(--r-xl) - var(--deck)));
  background:linear-gradient(160deg,var(--coping-hi) 0%,var(--coping-lo) 100%);
  box-shadow:0 0 0 10px rgba(120,140,152,.09),      /* wet deck halo */
             0 1px 0 rgba(255,255,255,.85),         /* the raised lip */
             0 10px 18px -10px rgba(8,58,92,.45)}   /* the coping's own shadow */

/* THE WALL BAND — the ring of .pool-wall left visible around .pool-floor.
   Smaller tiles (18px) than the floor, so it reads as a different surface.
   EVERY derived radius is wrapped in max(): --r-xl is 32px and deck+coping
   alone reach 35px at desktop, so the bare subtraction goes NEGATIVE, the
   declaration is DROPPED, and the pool renders square inside a 32px card. */
.pool-wall{position:absolute;inset:var(--coping);
  border-radius:max(8px,calc(var(--r-xl) - var(--deck) - var(--coping)));
  background:
    repeating-linear-gradient(90deg,var(--wall-grout) 0 1px,transparent 1px 18px),
    repeating-linear-gradient(180deg,var(--wall-grout) 0 1px,transparent 1px 18px),
    linear-gradient(180deg,var(--wall) 0%,var(--wall-2) 100%);
  box-shadow:inset 0 0 0 1px rgba(255,255,255,.16),
             inset 0 2px 5px rgba(4,36,56,.55)}     /* the coping's shadow on the wall */

/* THE WATER, and the only clipping element. Palette off the sampled vector
   reference (#00A8D8 #18C0D8 #30C0D8) against the old #2A95CE — made for the
   references and for the dark fraction, NOT to feed the caustics: the web
   measures inside the reference band on the old blues too. Depth is expressed
   twice so it cannot read as a lighting bug: this tonal ramp along the long
   axis plus a deep-end corner, and the painted slope break below. */
.pool-floor{position:absolute;inset:var(--band);overflow:hidden;
  border-radius:max(10px,calc(var(--r-xl) - var(--deck) - var(--coping) - var(--band)));
  background:
    /* THE SKY REFLECTION. A real pool seen from above is not just lit water —
       it is a mirror, and the brightest thing in it is the sky. This is a
       broad soft sheen entering from the top-left, the same quarter the sun
       lights the floats from, laid OVER the depth gradient so the shallow end
       reads as doubly bright. It sits under the caustics, so the two
       compound rather than fight. */
    linear-gradient(118deg,rgba(255,255,255,.34) 0%,rgba(255,255,255,.12) 26%,
                    transparent 52%),
    radial-gradient(80% 60% at 22% 8%,rgba(226,250,255,.30) 0%,transparent 70%),
    radial-gradient(118% 150% at 94% 98%,rgba(16,74,102,.34) 0%,transparent 62%),
    linear-gradient(96deg,#2ED2E8 0%,#0FBEE2 20%,#06A9D8 42%,#0094CB 68%,#0E7FB4 100%);
  box-shadow:inset 0 0 16px rgba(8,58,92,.32),      /* the wall's shadow on the water */
             inset 0 3px 8px rgba(8,58,92,.30)}

/* The floor tiling. The grout FADES WITH DEPTH — a dead-uniform white lattice
   at a fixed 42px pitch was the other half of the "very basic" read, and the
   most machine-made thing in the box. Mask only; zero per-frame cost. */
/* THE POOL FLOOR — real glazed tile seen from directly above, replacing the
   1px wireframe grid. Replaces the whole `.pool-tiles` rule (and its two
   mask-image lines) in site/assets/css/v6.css.

   Stack bond, square, 22px pitch — the pitch the deck's background-position
   offset (9px 7px on a 48px grid) is already detuned against, and 14-27 tiles
   across a 300-600px floor. Running bond reads as a brick path from overhead;
   a large format reads as a bare panel at this size.

   FOUR static background layers, one element, no per-tile DOM, no data URI
   (so neither the `#`-as-fragment nor the url("...") quote trap can bite),
   nothing animated, no filter and no blend mode. It rasterises once under the
   live caustic web.
     1,2  THE GROUT CHANNEL, 2px and BEVELLED, not a line: 1px recess shadow
          then 1px lit chamfer, in that order on +x and +y, so every tile face
          carries a bright top-left edge and a dark bottom-right one. Same sun
          as the float shadows (+11x +15y) — highlights up and to the LEFT.
          Measured over the shallow floor (#2ED2E8): luma 143 dark / 180 lit
          against a 163 face, a 37-level step across 2px.
     3,4  THE GLAZE: two radials at background-size:22px give each tile its own
          slight dome (a ~10-12 level fall, top-left to bottom-right) with no
          extra node — background-size is what makes a non-repeating gradient
          per-tile. Deliberately half the amplitude it was first drawn at: at
          .115/.075 the dome swung 20 levels per tile and, stacked on the
          grout bevel, started to read as a grid stamped over the water
          instead of tile under it.
   The darks are the water's own blue-black, never neutral grey. This surface
   sits BELOW the depth gradient and below an 18.8%-bright caustic web.

   THE MASK IS UNCHANGED on purpose. Easing its tail (.18 -> .30) was tried
   and rejected: over deep-end water the lit chamfer already measures a
   29-level step on a darker base, and the deep end is exactly where the
   caustics are thinnest (12.9% bright vs 27.5% shallow), so raising the mask
   there removes the only thing attenuating the grid. If the deep end ever
   looks dead, move the 46% midpoint, never the 100% tail.

   Fixed in both appearances, like the rest of the pool: no var() from the
   theme, no prefers-color-scheme branch. The two tokens are declared on the
   rule itself so a partial merge cannot leave them pointing at the old white
   grout. --floor-grout at :root/.throw is now unused — retire it there. */
.pool-tiles{position:absolute;inset:0;
  --floor-grout:rgba(6,52,78,.17);
  --floor-sheen:rgba(226,252,255,.20);
  background-image:
    repeating-linear-gradient(90deg,var(--floor-grout) 0 1px,var(--floor-sheen) 1px 2px,transparent 2px 22px),
    repeating-linear-gradient(180deg,var(--floor-grout) 0 1px,var(--floor-sheen) 1px 2px,transparent 2px 22px),
    radial-gradient(130% 130% at 22% 16%,rgba(255,255,255,.07) 0%,rgba(255,255,255,.025) 36%,transparent 66%),
    radial-gradient(100% 100% at 102% 104%,rgba(2,40,62,.05) 0%,rgba(2,40,62,.015) 34%,transparent 60%);
  background-size:auto,auto,22px 22px,22px 22px;
  -webkit-mask-image:linear-gradient(96deg,#000 0%,rgba(0,0,0,.55) 46%,rgba(0,0,0,.18) 100%);
  mask-image:linear-gradient(96deg,#000 0%,rgba(0,0,0,.55) 46%,rgba(0,0,0,.18) 100%)}

/* THE SLOPE BREAK — what stops the depth change reading as a lighting bug.
   Real pools mark exactly this, at the same --break the physics uses, so what
   the eye sees and what the water does agree. Kept DELIBERATELY faint: at the
   strength it needs to punch through the screen-blended caustics it stops
   being a pool feature and becomes a full-height rendering seam splitting the
   box into two panels. Measured and backed off twice to get here. */

/* CORNER STEPS, from directly above: three quarter-round treads, each LIGHTER
   than the one below it (shallower water returns more light), each with its
   own soft shadow cast onto the deeper tread outside it.
   NO NOSING. There were three 2px --step-nose arcs along the tread lips; from
   directly overhead three concentric white bars in a corner read as a handrail
   or a drain grille, not as step edges, and at the clamp's 68px floor the 2px
   stroke rendered at 1.1px anyway. Removed on purpose (owner, 20 Oct 2026) —
   do not re-add it. Separation now comes from the three tread fills plus the
   offset --step-riser crescent under each one.
   Drawn deepest-first, so each lighter tread covers the inner half of the
   shadow below it and leaves only the outside crescent — what a real cast
   shadow does. The treads are #6FDDEE/#95EAF4/#BDF4F9, NOT the #39CCE0 family:
   they sit in the SHALLOW corner where the floor is already #2ED2E8, and at
   that value they were invisible. If they ever need more separation, deepen
   --step-riser; never put white back on the lips. */
.pool-steps{position:absolute;left:-1px;top:-1px;width:clamp(68px,30%,122px);
  aspect-ratio:1;height:auto}
/* At night the treads were the brightest thing in the pool: their fills are
   baked into the SVG as daylight #BDF4F9/#95EAF4/#6FDDEE, so with the caustics
   gone they glowed like a light box in the shallow corner. A filter dims them
   in place without touching the markup, and it rasterises once because the
   element never animates. */
/* The treads are TILED now and themed through tokens, not dimmed with a
   blanket filter. brightness(.40) collapsed three distinct tread colours into
   one flat grey slab — which is exactly why the corner read as a blob rather
   than as steps. Each tread carries the same 13px tile pattern as the floor,
   so it reads as a tiled structure sitting in the water. --step-1 is the top
   (shallowest) tread. */
:root[data-theme="dark"] .throw{
  --step-1:#3E93B4; --step-2:#357F9C; --step-3:#2C6B85;
  --step-grout:rgba(8,40,60,.42); --step-riser:rgba(3,22,36,.55)}

/* ---------- caustics: an iso-contour web, not soft blobs -------------------
   Real top-down pool light is a NET — thin bright lines closing into cells, at
   several scales at once, covering about a fifth of the surface. The eight
   soft radial blobs this replaces measure 0.0% bright; the vector reference
   measures 21%. MEASURED HERE: 18.8% bright / 61.6% mid / 19.6% dark, with a
   real grade (27.5% bright shallow, 12.9% deep).

   HOW THE BAND IS CUT, and why not in the transfer table. These noise fields
   are tight gaussians — mean 0.504 sd 0.103 (#cw-a), 0.499 sd 0.124 (#cw-b) —
   so an iso-band covering ~10% of the area is only about 0.03 wide in v.
   tableValues cannot resolve that: 9 entries step by 0.125, 21 by 0.05. So the
   band is cut in feColorMatrix instead. Its alpha row `S 0 0 0 I` computes
   A = S*R + I and clamps to 0..1, which stretches a band of half-width 1/(2S)
   around v = -I/S across the whole output range and flattens everything else
   to solid 0 or 1. S is a smooth thinning knob with no quantisation.
   feComponentTransfer is then left with only the line's CROSS-SECTION, which
   five or seven entries handle easily.

   Do NOT reach for feFuncA type="gamma" to thin these instead: exponent 11 and
   15 return bit-identical coverage (6.2%/6.2%), as do 20 and 28 (3.0%/3.0%) —
   the transfer function is baked to a 256-entry LUT and high exponents
   quantise away. The colour matrix never touches that LUT.
   ------------------------------------------------------------------------- */
.pool-defs{position:absolute;width:0;height:0;overflow:hidden}

/* inset:-8%, not -14%: the drift never travels further than the margin, so no
   layer edge can enter the box, and the inflated layer is 1.35x the floor area
   instead of 1.64x. Four full-area composited layers IS the real cost of this
   approach — see the budget query below. */
.cw{position:absolute;inset:-8%;pointer-events:none;z-index:0;
  backface-visibility:hidden;
  /* CAUSTIC BRIGHTNESS RESPONDS TO DEPTH: in every reference the web is
     strongest in the shallow end and washes out in the deep end. Aligned to
     the floor's own 96deg ramp. This is a GRADE, not an on/off — at .40 in the
     deep end it measured 51.8% dark there and the pool read as two unrelated
     panels. Static; zero per frame. */
  -webkit-mask-image:linear-gradient(96deg,#000 0%,rgba(0,0,0,.94) 34%,rgba(0,0,0,.84) 64%,rgba(0,0,0,.74) 100%);
  mask-image:linear-gradient(96deg,#000 0%,rgba(0,0,0,.94) 34%,rgba(0,0,0,.84) 64%,rgba(0,0,0,.74) 100%)}
html.motion .cw{will-change:transform}      /* never promoted when it cannot move */
.cw svg{display:block;width:100%;height:100%}
.cw-trough rect{filter:url(#cw-trough)}
.cw-a rect{filter:url(#cw-a)}
.cw-b rect{filter:url(#cw-b)}
.cw-c rect{filter:url(#cw-c)}

/* Opacities are measured, not taste. Dropping the web layers to .70/.85/.40
   falls to 12.2% bright — back under the reference band and reading as
   smudges again. If this ever needs to be calmer, take it out of .cw-c first:
   that layer carries detail, not structure. */
.cw-trough{mix-blend-mode:multiply;opacity:.40}
.cw-a{mix-blend-mode:screen;opacity:1}
.cw-b{mix-blend-mode:screen;opacity:1}
.cw-c{mix-blend-mode:screen;opacity:.74}
/* No dark override. The pool is FIXED in both appearances, so the night water
   IS the day water — measured identical in both themes, which is why the
   old "the pool is already dark, bring the trough down" correction is gone. */

/* Drift. TRANSLATE ONLY, never scale — the whole point is that the filter runs
   once, and a scaled promoted layer can be re-rastered at the settled scale.
   Periods 41/53/67s, all prime, so the phases do not re-align for 41*53*67 =
   145,591s — about 40 hours. Nothing beats.
   Closed loops on `linear`, not `alternate` on an ease: an eased alternate
   stops dead at both ends of every cycle, and layers that all pause together
   is exactly the pulse that gives a loop away (the old 13s/19s did this).
   .cw-trough shares .cw-a's animation EXACTLY — same name, same duration, no
   delay. It is the dark between the same cells .cw-a lights, so on its own
   clock the dark would slide out from under the bright and the water would
   look doubled. DO NOT give it its own timing: it will look like a filter bug
   rather than a timing bug, and this is the line most likely to be broken. */
html.motion .cw-trough,
html.motion .cw-a{animation:cwDeep 41s linear infinite}
html.motion .cw-b{animation:cwMid 53s linear infinite}
html.motion .cw-c{animation:cwFine 67s linear infinite}
@keyframes cwDeep{0%,100%{transform:translate3d(-26px,-14px,0)}50%{transform:translate3d(22px,16px,0)}}
@keyframes cwMid {0%,100%{transform:translate3d(18px,-20px,0)} 50%{transform:translate3d(-20px,14px,0)}}
@keyframes cwFine{0%,100%{transform:translate3d(-12px,18px,0)} 50%{transform:translate3d(16px,-16px,0)}}

/* ---------- the floor: every float casts a shadow ---------------------------
   The sun is high and off to the UPPER LEFT, as in the reference photographs,
   so a shadow sits down-and-right of the thing that casts it, never under it.
   Built in JS into .pool-floor, so it is clipped at the waterline and so
   reduce-motion — where the physics never starts — gets no stranded shadows.
   WHY a separate layer, and not ::after on .orb or a box-shadow:
     * .orb has overflow:hidden, so an offset ::after would be clipped off;
     * .orb ROTATES, and a box-shadow rotates with it — the sun would visibly
       swing around every time an icon spun;
     * .orb is z-index:1, so a shadow painted inside it would sit ON TOP of
       the neighbouring icons instead of on the pool floor.
   (Note the first reason applies to ::after, not to box-shadow: overflow
   clips descendants, not the element's own shadow. The other two kill both.)
   ------------------------------------------------------------------------- */
.pool-fx{position:absolute;inset:0;pointer-events:none}

/* The element is only a transform handle; the child carries the shape, so the
   soft edge is a box-shadow blur rasterised ONCE into its own layer and from
   then on the frame only moves it. The child also carries the breath, because
   `scale` on THIS element would scale its POSITION: it is left:0/top:0, so its
   transform-origin is near the floor's top-left and 5% of a 600px vector is
   30px of travel — the shadow would visibly slide out from under its icon.
   No ::after meniscus here: it would be frozen at the last throw's heading
   and sits off the hull anyway. The waterline lives on .orb::after instead. */
.orb-shade{position:absolute;left:0;top:0;display:block}
.orb-shade i{position:absolute;inset:21%;border-radius:50%;
  background:rgba(7,50,80,.40);
  box-shadow:0 0 16px 7px rgba(7,50,80,.26),0 0 34px 12px rgba(7,50,80,.12)}
:root[data-theme="dark"] .orb-shade i{background:rgba(2,26,46,.50);
  box-shadow:0 0 16px 7px rgba(2,26,46,.32),0 0 34px 12px rgba(2,26,46,.16)}

/* A pooled expanding ring: one per push, drag, throw, wall hit and hard knock.
   Driven from JS by the Web Animations API on transform+opacity ONLY, so once
   a ring is started the main thread does nothing for the rest of its life —
   and no `void el.offsetWidth` forced reflow is needed to replay one. The box
   is a full 128px and the keyframes scale UP from .16, so the border is kept
   thin at 1.5px: it reads as a ring at every size it passes through. */
.pool-ripple{position:absolute;left:0;top:0;width:128px;height:128px;border-radius:50%;
  border:1.5px solid rgba(228,250,255,.82);opacity:0;will-change:transform,opacity;
  box-shadow:0 0 12px rgba(214,244,255,.40),inset 0 0 18px rgba(228,250,255,.20)}
:root[data-theme="dark"] .pool-ripple{border-color:rgba(186,230,248,.60);
  box-shadow:0 0 12px rgba(160,216,240,.26),inset 0 0 18px rgba(186,230,248,.14)}

/* ---------- the pool at night ---------------------------------------------
   Caustics are SUNLIGHT refracted through the surface — they are the one thing
   a pool cannot have after dark, and leaving them on is what made dark mode
   read as "the same pool, dimmed" rather than as night. So the bright layers
   go entirely, the sky reflection goes with them (there is no sky to mirror),
   and the water drops to a deep night blue. What is left is the trough layer,
   which is the DARK half of the caustic pair, kept at low strength as the
   gentle surface relief you still see under pool lighting — and a single cool
   pool-lamp glow from the shallow end, which is what actually lights a pool at
   night. Removing four screen-blended filter layers also makes dark mode
   strictly cheaper than light. */
/* NIGHT LIGHT. Killing all four caustic layers made it night but it also made
   it STILL, and still water reads as a painted slab. A pool at night is not
   unlit — it is lit from BELOW by its own lamps, and that light still moves.
   So the finest and broadest webs come back at a fraction of their daylight
   strength: enough that the surface glints and travels, not enough to pretend
   the sun is up. .cw-c (the finest) stays off — at night the small-scale
   detail is the first thing you stop being able to see. */
:root[data-theme="dark"] .cw-a{opacity:.20}
:root[data-theme="dark"] .cw-b{opacity:.13}
:root[data-theme="dark"] .cw-c{display:none}
:root[data-theme="dark"] .cw-trough{opacity:.30}

/* the lamp itself: a cool pool-light in the shallow corner, breathing slowly
   so the night pool is never completely static even before anything moves.
   One element, one transform-free opacity animation, no layout. */
.pool-lamp{position:absolute;inset:0;pointer-events:none;z-index:0;opacity:0;
  background:
    radial-gradient(42% 34% at 15% 12%,rgba(150,222,250,.30) 0%,transparent 70%),
    radial-gradient(60% 48% at 78% 86%,rgba(110,190,226,.16) 0%,transparent 72%)}
:root[data-theme="dark"] .pool-lamp{opacity:1}
html.motion:root[data-theme="dark"] .pool-lamp{animation:lampBreathe 9s ease-in-out infinite alternate}
@keyframes lampBreathe{from{opacity:.78}to{opacity:1}}
:root[data-theme="dark"] .pool-floor{
  background:
    radial-gradient(66% 52% at 16% 10%,rgba(126,196,228,.16) 0%,transparent 72%),
    radial-gradient(118% 150% at 94% 98%,rgba(2,18,34,.52) 0%,transparent 64%),
    linear-gradient(96deg,#10506E 0%,#0C4463 20%,#093A57 42%,#07304A 68%,#05253A 100%);
  box-shadow:inset 0 0 22px rgba(0,8,18,.55),inset 0 3px 10px rgba(0,8,18,.50)}

/* ---------- fittings ------------------------------------------------------
   The rail crosses the coping onto the deck — the detail that proves the
   coping is a real object with a thickness. The overshoot is DERIVED from the
   deck so it can never exceed it and be sliced off by .throw's overflow:hidden
   (at --deck:10px a flat top:-22px loses the top finial entirely).
   Both are z-index:3: fixed metalwork belongs in front of the floats, and this
   only resolves correctly because .pool-shell has no z-index of its own.
   The duck and the ring go to z-index:0 — they are decor, and the PRODUCTS
   should be in front of them, not behind (they are z-index:2 today, painting
   on top of the app icons). */

/* Decor floats. Same sun as the eighteen icons — the old offsets were straight
   down, as if it were overhead, which now contradicts them. A drop-shadow on
   an element whose only animation is a transform rasterises once and is then
   carried, so these stay free; none of the three enters the rAF loop. */
/* The duck and the tube are no longer decor: they are bodies in the same
   physics loop as the eighteen icons, so they are positioned by transform from
   0,0 like an .orb and are grabbable. Their drop-shadow is dropped with the
   CSS drift — the loop gives them a real floor shadow like everything else. */
.pool-duck,.pool-ring{position:absolute;top:0;left:0;z-index:1;
  cursor:grab;touch-action:none;will-change:transform}
.pool-duck:active,.pool-ring:active{cursor:grabbing}
.pool-duck{width:56px;height:56px}
.pool-ring{width:72px;height:72px}
@keyframes drift1{
  from{transform:translate3d(0,0,0) rotate(-7deg)}
  to  {transform:translate3d(38px,26px,0) rotate(9deg)}}
@keyframes drift2{
  from{transform:translate3d(0,0,0) rotate(6deg)}
  to  {transform:translate3d(-34px,-22px,0) rotate(-8deg)}}

/* From above, a bubble breaking the surface spreads and fades rather than
   rising, so these are expanding rings, not travelling dots. */
.bubble{position:absolute;width:14px;height:14px;border-radius:50%;
  border:2px solid rgba(255,255,255,.55);pointer-events:none;opacity:0;z-index:0}
html.motion .bubble{animation:surface var(--d,9s) ease-out var(--delay,0s) infinite}
@keyframes surface{
  0%{opacity:0;transform:scale(.3)}
  18%{opacity:.85}
  100%{opacity:0;transform:scale(3.4)}}

/* ---------- floating, not swimming -----------------------------------------
   The resting motion is CSS, NOT a force in the physics loop. That split is
   the whole point: the rAF loop can stop dead on a still pool and the floats
   still move, and because the loop applies NO ambient force at all the raft
   cannot migrate into a corner the way it did under the old swirl field.
   JS writes `transform`; these write `translate`/`rotate`/`scale`, which are
   separate properties applied BEFORE `transform` and so compose with it — no
   wrapper element, nothing clipped by .orb{overflow:hidden}, no fight.

   A pure `translate` is origin-independent and simply adds its px wherever the
   tile is. `rotate` is NOT: .orb is left:0/top:0, so its transform-origin sits
   near the box's top-left and a 1deg CSS rotate becomes a ~7px ORBITAL SWING
   for a tile 680px away. That is why the roll lives on .orb img, whose origin
   is its own centre, and why scale:1.05 is there — a 1.1deg rotation of a
   square exposes ~0.9px of var(--card) inside overflow:hidden.

   The user asked for "floating in very slow", so the beat that matters is the
   55.2s DRIFT (+-8px/+-6px), not a bob: the raft slowly rearranges itself and
   nothing jiggles. A fast bob was tried and dropped — at 2.4px it needs a ~14%
   icon crop to avoid exposing var(--card), which is a real tax on eighteen
   brand icons to buy motion the user did not ask for.
   55.2 = 4 x 13.8, so the drift and the roll close together. JS hands each
   tile a negative animation-delay, so no two of the eighteen share a phase.
   The JS playable rect is inset by this same 8px, so a drifting tile can never
   cross the waterline. Per-frame cost: ZERO JS, transform-class properties
   only, so it stays on the compositor and never touches layout.
   ------------------------------------------------------------------------- */
html.motion .orb{animation:orb-drift 55.2s linear infinite}
html.motion .orb.grab{animation-play-state:paused}   /* pinned under a finger is not floating */
/* --dr scales the orbit per body (set in JS). Eighteen identical 8px circles
   on one 55.2s clock read as a carousel; varying the radius and the period is
   what makes it look like water. Default 1 keeps the original geometry. */
@keyframes orb-drift{
  0%{translate:0px 0px}
  12.5%{translate:calc(5.66px*var(--dr,1)) calc(-4.24px*var(--dr,1))}
  25%{translate:calc(8px*var(--dr,1)) 0px}
  37.5%{translate:calc(5.66px*var(--dr,1)) calc(4.24px*var(--dr,1))}
  50%{translate:0px calc(6px*var(--dr,1))}
  62.5%{translate:calc(-5.66px*var(--dr,1)) calc(4.24px*var(--dr,1))}
  75%{translate:calc(-8px*var(--dr,1)) 0px}
  87.5%{translate:calc(-5.66px*var(--dr,1)) calc(-4.24px*var(--dr,1))}
  100%{translate:0px 0px}}
html.motion .orb img{animation:orb-roll 13.8s linear infinite}
@keyframes orb-roll{
  0%{rotate:0deg;scale:1.05}
  25%{rotate:1.1deg;scale:1.05}
  50%{rotate:0deg;scale:1.05}
  75%{rotate:-1.1deg;scale:1.05}
  100%{rotate:0deg;scale:1.05}}
/* The shadow rides the same clocks and the same per-tile phase at 1.5x the
   drift — the parallax of a thing on the surface casting onto the floor, and
   the reason a resting pool still reads as floating even though the rAF loop
   is asleep and writing nothing. Translate on the handle, scale on the child. */
html.motion .orb-shade{animation:shade-drift 55.2s linear infinite}
@keyframes shade-drift{
  0%{translate:0px 0px}
  12.5%{translate:8.49px -6.36px}
  25%{translate:12px 0px}
  37.5%{translate:8.49px 6.36px}
  50%{translate:0px 9px}
  62.5%{translate:-8.49px 6.36px}
  75%{translate:-12px 0px}
  87.5%{translate:-8.49px -6.36px}
  100%{translate:0px 0px}}
html.motion .orb-shade i{animation:shade-breathe 13.8s linear infinite}
@keyframes shade-breathe{
  0%{scale:1}25%{scale:1.05}50%{scale:1}75%{scale:.95}100%{scale:1}}

/* The hint sits on open water, whose luminance changes across the gradient and
   under the caustics, so a bare text-shadow is not a contrast argument — white
   on the shallow end measures about 2.3:1. It gets its own deep plaque, which
   both fixes the ratio and reads as a sign on the pool edge.
   z-index:1, the SAME as .orb, and before the orbs in the DOM: equal z-index
   falls back to tree order, so this is deterministically above the pool
   (z-auto) and below every icon, without relying on source order against
   .pool-shell. It also FADES on the first touch — with rest now meaning rest,
   a tile that parks on the plaque would otherwise sit there permanently
   half-covering it, where the old swirl kept sweeping them off. */
/* ON THE DECK, not in the water. Centred in the pool it sat under twenty
   bodies that cluster centrally, and all a reader saw was fragments of it
   between icons — it read as broken text rather than as a hint. The deck
   strip at the bottom is the one area nothing can float over, so the plaque
   is legible at all times and reads as poolside signage. It keeps the .80
   plaque: it is now over the deck tile, not the water, but the value is set
   by the worst realised backdrop and the deck sheen is near-white too. */
.throw-hint{position:absolute;left:50%;bottom:4px;top:auto;transform:translateX(-50%);
  font-family:var(--mono);font-size:.66rem;letter-spacing:.15em;text-transform:uppercase;
  /* .80, not .62. The backdrop is a GRADIENT, so it has no resolvable
     background-color — an auditor composites the plaque against the page paper
     and reads 4.48:1, and the real backdrop also shifts under the caustics. At
     .80 it passes against the worst case there is, pure white, at 7.46:1
     measured. The caustic web now genuinely puts near-white pixels back there,
     so that worst case is REALISED rather than hypothetical: DO NOT lower it —
     it was partly safe before only because nothing bright was ever behind it. */
  color:#FFFFFF;background:rgba(8,46,74,.80);padding:8px 16px;border-radius:99px;
  box-shadow:inset 0 0 0 1px rgba(255,255,255,.22);
  pointer-events:none;text-align:center;z-index:1;white-space:nowrap;
  transition:opacity .5s var(--e)}
.throw.touched .throw-hint{opacity:0}

/* ---------- the icons look wet --------------------------------------------
   All of this is rasterised once with the icon's own layer and costs nothing
   per frame. A real refraction would mean backdrop-filter on eighteen moving
   elements — a backdrop read plus a blur over each one every frame — which is
   precisely the thing that would wreck the 119fps, so the refraction is faked:
   a 1.5px COOL edge on one side and a warm one on the other, which at 44-64px
   is indistinguishable from the real thing.
   var(--shadow) is gone: a float's shadow is on the floor now, and a page-card
   shadow hanging off an icon in water was the giveaway. .orb.grab loses
   var(--shadow-lift) for the same reason — the lift is expressed by the floor
   shadow pushing further out and growing, which the JS already does.
   The bottom darkening is .18 starting at 58%, NOT .30 at 44%: several of
   these icons carry wordmarks in the lower half and this is the company's own
   product shelf.
   NOTE FOR WHOEVER EDITS THIS: these rules MUST stay ABOVE the
   prefers-reduced-motion block below. Moved after it, .orb{position:absolute}
   wins the specificity tie on source order and breaks the static grid. */
/* 22.37% is Apple's icon corner ratio, and it must be a PERCENTAGE: the width
   is a clamp(), so a fixed 18px was 41% of the tile at the 44px end and 28% at
   the 64px end — at small sizes the icons read as circles, not app icons. */
.orb{position:absolute;top:0;left:0;width:clamp(44px,6vw,64px);height:clamp(44px,6vw,64px);
  border-radius:22.37%;overflow:hidden;cursor:grab;will-change:transform;z-index:1;
  border:1px solid rgba(255,255,255,.42);background:var(--card);
  box-shadow:0 1px 1px rgba(255,255,255,.26),0 3px 7px rgba(6,44,72,.34);
  touch-action:none}                        /* the tile itself may capture the drag */
.orb::after{content:"";position:absolute;inset:0;border-radius:inherit;
  pointer-events:none;
  background:
    linear-gradient(203deg,rgba(255,255,255,.34) 0 15%,rgba(255,255,255,.07) 34%,transparent 52%),
    linear-gradient(180deg,transparent 58%,rgba(11,76,116,.10) 80%,rgba(8,56,90,.18) 100%);
  box-shadow:
    inset 0 1.5px 0 rgba(255,255,255,.52),      /* the dry rim catching the sun */
    inset 0 -1.5px 0 rgba(152,228,255,.36),     /* the waterline at the submerged edge */
    inset 1.5px 0 0 rgba(176,236,255,.22),      /* refraction, cool side */
    inset -1.5px 0 0 rgba(255,214,170,.14)}     /* refraction, warm side */
:root[data-theme="dark"] .orb{border-color:rgba(206,232,246,.26);
  box-shadow:0 1px 1px rgba(206,232,246,.14),0 3px 7px rgba(0,0,0,.42)}
.orb img{width:100%;height:100%;object-fit:cover;pointer-events:none;-webkit-user-drag:none}
.orb.grab{cursor:grabbing;box-shadow:0 2px 2px rgba(255,255,255,.3),0 10px 22px rgba(6,44,72,.42);z-index:3}
.orb:active{cursor:grabbing}
/* The icons are links, so they must be reachable without a pointer (they are
   bare div[data-href] today: no role, no tabindex, no key handler). The ring
   is INSET because .throw is overflow:hidden and an outline on an icon at the
   edge would be clipped; white core on a dark surround so it is visible over
   any icon artwork. */
.orb:focus-visible{outline:none;
  box-shadow:inset 0 0 0 2px #FFFFFF,inset 0 0 0 4px rgba(8,46,74,.92),
             0 3px 7px rgba(6,44,72,.34)}

@media(max-width:480px){
  /* Cut the DECK first, never the band: the band is what sells the wall.
     A 343px box leaves a 299px floor — ~40% fill with eighteen 44px icons,
     tight but no tighter than today. */
  .throw{--deck:8px; --coping:6px; --band:7px}
}
/* Narrow or slow devices lose the FINE layer only: measured at ~6% bright, so
   dropping it lands around 14-15% — still inside the reference band — and it
   takes a quarter of the caustic backing store with it. This is the budget,
   SHIPPED, not a note for later. */
@media(max-width:560px),(update:slow){
  .cw-c{display:none}
}
/* baseFrequency is in USER UNITS, i.e. CSS px, so a cell is the same PHYSICAL
   size at 375px as at 700px — which means the big layer's ~118px cell is 6
   cells across a desktop box but only 3 across a phone one, where it stops
   being a net and becomes two giant blobs. This is STRUCTURAL, not statistical:
   the 375px composite still measures fine, so no coverage metric catches it.
   baseFrequency is an SVG attribute and cannot be set from CSS, so the narrow
   case gets its own defs and the `filter` PROPERTY (which is CSS, and
   overrides the presentation attribute) is swapped to them. Frequencies are
   raised to preserve cell COUNT, not cell size. .cw-c is display:none here, so
   it needs no -s twin. */
@media(max-width:560px){
  .cw-trough rect{filter:url(#cw-trough-s)}
  .cw-a rect{filter:url(#cw-a-s)}
  .cw-b rect{filter:url(#cw-b-s)}
  /* The pool is too small for fittings: at a ~300px floor the ladder alone
     covers a whole icon, and the rail's finial is the first thing .throw's
     overflow:hidden would slice. The steps still say "entry". */
  .pool-rail{display:none}
}

@media(prefers-reduced-motion:reduce){
  /* The ARCHITECTURE stays — a static pool is exactly what this mode should
     show — and the padding is DERIVED from the three widths, so the icon grid
     can never land on the deck. Only the things that would be stranded on top
     of the collapsed grid are removed.
     The physics never starts here (init() returns before throwBox), so
     .pool-fx, .orb-shade and .pool-ripple are never built at all; the rules
     below are belt-and-braces for a live flip, since html.motion is set once
     at page load and never re-evaluated. */
  .throw{height:auto;display:flex;flex-wrap:wrap;gap:12px;justify-content:center;
    padding:calc(var(--deck) + var(--coping) + var(--band) + 12px)}
  .orb{position:static;transform:none!important;translate:none!important;
    rotate:none!important;animation:none!important}
  .orb img{animation:none!important;rotate:none!important;scale:none!important}
  .throw-hint{position:static;transform:none;width:100%;margin-bottom:10px;
    align-self:center;justify-self:center;opacity:1}
  .pool-fx{display:none}
  .pool-steps,.pool-rail,.pool-duck,.pool-ring,.bubble{display:none}
}

/* cursor-lit blueprint */
.blueprint{--mx:50%;--my:40%}
.blueprint::before{mask-image:radial-gradient(420px circle at var(--mx) var(--my),#000 0%,rgba(0,0,0,.45) 45%,transparent 78%);
  transition:none}
[data-magnet]{transition:transform .32s cubic-bezier(.2,.8,.3,1)}
[data-tilt]{transition:transform .35s cubic-bezier(.2,.8,.3,1)}


/* --- contrast corrections found by the WCAG audit (20 Oct 2026) --- */
/* Pages that set their own --danger picked a red that fails AA on light paper. */
:root{--danger:#B3122A}
:root[data-theme="dark"]{--danger:#FF7B74}
.warn,.warn h3{--danger:#B3122A}
:root[data-theme="dark"] .warn,:root[data-theme="dark"] .warn h3{--danger:#FF7B74}
.warn h3{color:var(--danger)}
.nodo li::before,.gone li::before{color:var(--danger)}
/* Any legacy micro-label sitting on a blue panel must take panel ink, not muted grey. */
.blueprint .kicker,.cta-panel .kicker,.book .kicker,
.blueprint .fig,.cta-panel .fig{color:rgba(255,255,255,.93)}
.blueprint .kicker::before,.cta-panel .kicker::before{background:rgba(255,255,255,.7)}
/* ...but the booking card sits ON the blue panel with its own light surface,
   so it must keep normal ink rather than inheriting the panel's white. */
.book-card .fig,.book-card .kicker{color:var(--muted)}
.book-card .kicker::before{background:currentColor}

/* Leftovers from the Deep Field build. They were position:fixed there, so they
   took no space; unstyled here they became normal blocks and the canvas inside
   .field pushed every page down by its default 300x150. Removed from the markup,
   and neutralised here in case any page is ever restored from an old backup. */
.field,.vignette,.progress,.cursor-dot,.cursor-ring{display:none!important}


/* ============================================================
   MOTION — scroll-driven, native, no library
   ------------------------------------------------------------
   One primitive: things rise ~10px into place as they enter, scrubbed by
   scroll position rather than fired by a threshold. Nothing is carried by
   motion: the worst failure anywhere below is content sitting 10px low and
   perfectly legible.

   Three rules this file depends on, each of which silently breaks it:
     1. LONGHANDS ONLY. The `animation` shorthand resets animation-timeline
        to auto, which disconnects the timeline and plays it on the clock.
     2. NO animation-duration. On a progress timeline the RANGE is the
        duration; leaving duration at auto maps 0->100% across the range.
     3. The range ends at `entry 100%`. Anything reaching past entry can
        strand the last screenful of a document at a partial state forever.
     4. body must keep `overflow-x:clip`, never `hidden` — `hidden` makes
        body a scroll container and breaks every view() timeline here.
   ============================================================ */
@supports (animation-timeline: view()){
@media (prefers-reduced-motion: no-preference){

  /* Three travel distances, so a section has a subject. 34px for the thing
     being introduced, 20px for the blocks around it, 12px for running prose.
     Opacity always resolves well before the end of the range, so no text is
     ever parked mid-fade where a contrast audit would catch it. */
  @keyframes tsl-rise{
    from{opacity:0;transform:translateY(20px) scale(.988)}
    50% {opacity:1}
    to  {opacity:1;transform:none}
  }
  @keyframes tsl-drift{
    from{transform:translateY(12px)}
    to  {transform:none}
  }
  @keyframes tsl-lift{
    from{opacity:0;transform:translateY(34px) scale(.972)}
    48% {opacity:1}
    to  {opacity:1;transform:none}
  }
  @keyframes tsl-rule{ from{transform:scaleX(0)} to{transform:none} }

  /* discrete blocks: rise. .tier / .grid-2 / .grid-3 / .tx-grid children were
     missing from this list, which is why pricing and the two-up rows sat
     perfectly still while everything around them moved. */
  html.motion:not(.no-sda) :is(.rv,.reveal,.sec-head,.faq-item,
    .panel.mini,.svc,.pcard,.tier,.legal-body>h2,.legal-body>h3,
    .grid-cat>*,.grid-2>*,.grid-3>*,.tx-grid>*,.method-grid>*,.plat-grid>*,
    .web-grid>*){
    animation-name:tsl-rise;
    animation-fill-mode:both;
    animation-timing-function:var(--e);
    animation-timeline:view();
    animation-range:entry 0% entry 100%;
  }

  /* the page hero, the blue panels and the showcase get the larger entrance.
     .hero-panel is excluded: it is in view at load, so its entry range is
     already resolved to 100% and it would never animate — its children get a
     real, time-based arrival further down instead. */
  html.motion:not(.no-sda) :is(.page-hero,.blueprint:not(.hero-panel),
    .cta-panel,.showcase,.book-card){
    animation-name:tsl-lift;
    animation-fill-mode:both;
    animation-timing-function:var(--e);
    animation-timeline:view();
    animation-range:entry 0% entry 92%;
  }

  /* running prose: moves, never fades — so body copy is never mid-opacity */
  html.motion:not(.no-sda) :is(.lede,.legal-body>p,.legal-body>ul,.legal-body>ol,
    .callout,.note,.tldr,.scope,.fig){
    animation-name:tsl-drift;
    animation-fill-mode:both;
    animation-timing-function:var(--e);
    animation-timeline:view();
    animation-range:entry 0% entry 100%;
  }
  /* never two clocks on one element */
  html.motion:not(.no-sda) :is(.rv,.reveal,.svc,.pcard,.panel.mini,.tier) :is(p,ul,ol){
    animation-name:none;
  }

  /* Siblings arrive in sequence rather than as a slab. The stagger shifts the
     range START and pulls the END back with it, so every child still completes
     inside `entry` and rule 3 above holds — nothing can strand past entry 100%.
     Shifting the start alone would only compress the range and make the later
     cards faster, not later. These tie the base rule above at (0,4,1) and win
     on source order, which is why they must stay below it. */
  html.motion:not(.no-sda) :is(.grid-cat,.grid-2,.grid-3,.tx-grid,
    .method-grid,.plat-grid,.svc-grid,.mini-grid,.web-grid) > :nth-child(3n+1){
    animation-range:entry 0% entry 74%;
  }
  html.motion:not(.no-sda) :is(.grid-cat,.grid-2,.grid-3,.tx-grid,
    .method-grid,.plat-grid,.svc-grid,.mini-grid,.web-grid) > :nth-child(3n+2){
    animation-range:entry 9% entry 87%;
  }
  html.motion:not(.no-sda) :is(.grid-cat,.grid-2,.grid-3,.tx-grid,
    .method-grid,.plat-grid,.svc-grid,.mini-grid,.web-grid) > :nth-child(3n){
    animation-range:entry 18% entry 100%;
  }
  /* the second marquee row trails the first */
  html.motion:not(.no-sda) .mq-row + .mq-row{animation-range:entry 11% entry 96%}

  /* section rules draw across as they arrive */
  html.motion:not(.no-sda) :is(.hr,.rule){
    transform-origin:left center;
    animation-name:tsl-rule;
    animation-fill-mode:both;
    animation-timing-function:var(--e);
    animation-timeline:view();
    animation-range:entry 0% entry 90%;
  }

  /* the blueprint grid settles as the panel arrives */
  html.motion:not(.no-sda) .blueprint::before{
    animation-name:tsl-drift;
    animation-fill-mode:both;
    animation-timing-function:var(--e);
    animation-timeline:view();
    animation-range:entry 0% entry 100%;
  }
}}

/* ---------- page arrival ------------------------------------------------
   The one time-based run in the file. Everything above is scrubbed by
   scroll, which means the first screenful — already in view when the
   document loads — resolves straight to its end state and never moves.
   That is most of why the page read as static. These use the `animation`
   SHORTHAND deliberately: it resets animation-timeline to auto, which is
   exactly right for a clock-driven run, and it is the one place in this
   file where that reset is wanted rather than a bug. The selectors are
   weighted to (0,4,1) so they beat the scroll-driven rules above for the
   hero's own .lede rather than fighting it for animation-name.

   TRAVEL ONLY — no opacity, and this is load-bearing. A scroll-driven
   fade is safe because its timeline comes from layout: an element in
   view resolves to its end state whether or not a frame is ever painted.
   A CLOCK-driven fade with fill:both has no such floor. If the clock
   never advances — a tab restored in the background, a throttled or
   unrendered page — the animation sits at 0% forever and `from{opacity:0}`
   leaves the entire hero blank. Measured here: playState "running",
   currentTime 0, h1 at opacity 0. Travel fails to "18px low and perfectly
   legible", which is the same floor every other rule in this file holds to.
   ----------------------------------------------------------------------- */
@media (prefers-reduced-motion: no-preference){
  @keyframes tsl-enter{
    from{transform:translateY(22px)}
    to  {transform:none}
  }
  html.motion:not(.no-sda) .hero .hero-panel > *{
    animation:tsl-enter .74s var(--e) both}
  html.motion:not(.no-sda) .hero .hero-panel > .eyebrow{animation-delay:.04s}
  html.motion:not(.no-sda) .hero .hero-panel > h1{animation-delay:.1s}
  html.motion:not(.no-sda) .hero .hero-panel > .lede{animation-delay:.18s}
  html.motion:not(.no-sda) .hero .hero-panel > .hero-ctas{animation-delay:.26s}
  html.motion:not(.no-sda) .hero .stat-wrap{animation:tsl-enter .74s var(--e) .34s both}
  html.motion:not(.no-sda) .bar{animation:tsl-enter .45s var(--e) both}
}


/* ---------- interaction motion: not scroll-driven, survives reduce-motion ---------- */
.btn,.tgl,.shot,.app,.card,.tier,.store-pill{transition:
  transform .18s var(--e), box-shadow .18s var(--e),
  background-color .18s var(--e), border-color .18s var(--e), color .18s var(--e)}
.btn:active,.tgl:active,.store-pill:active{transform:translateY(1px) scale(.985);
  transition-duration:.09s}
[data-magnet]:active{transform:translateY(1px) scale(.985)!important;transition-duration:.09s}

/* focus arrives rather than snapping — and stays visible under reduce-motion */
:where(a,button,summary,input,[tabindex]):focus-visible{
  outline:2px solid var(--accent);outline-offset:3px;border-radius:6px;
  transition:outline-offset .14s var(--e)}
@media (prefers-reduced-motion: no-preference){
  :where(a,button,summary,input,[tabindex]):focus-visible{outline-offset:4px}
}

/* the toggle turns */
.tgl svg{transition:transform .32s var(--e),opacity .18s var(--e)}
.tgl:hover svg{transform:rotate(18deg)}
:root[data-theme="dark"] .tgl .moon{transform:rotate(-12deg)}

/* nav + footer links grow an underline from the left */
.nav a,.foot a{position:relative}
.nav a::after,.foot a::after{content:"";position:absolute;left:0;right:0;bottom:-2px;
  height:1px;background:currentColor;transform:scaleX(0);transform-origin:left center;
  transition:transform .22s var(--e);opacity:.5}
.nav a:hover::after,.foot a:hover::after{transform:none}

/* the FAQ marker rotates instead of swapping glyph */
.faq summary::after{transition:transform .26s var(--e)}
.faq details[open] summary::after{transform:rotate(45deg)}

@media (prefers-reduced-motion: reduce){
  .tgl:hover svg{transform:none}
  .nav a::after,.foot a::after{transition:none}
}

/* ============================================================
   APPENDED BLOCK — 2026-10-20. Everything below goes at the END
   of v6.css, in this order. Several rules win source-order ties
   against earlier declarations without raising specificity, and
   the reduce-motion block at the bottom must be last.
   ============================================================ */

/* ---------- 1. focus ring on the fixed blue surfaces ----------------------
   The global ring (line 701) is outline:2px solid var(--accent). On --paper
   that is fine (4.74:1 light / 7.50:1 dark), but a .cta-panel and a .blueprint
   ARE --blueprint: #2E5BFF light, #2746C9 dark. In LIGHT the ring colour
   equals the field exactly — 1.00:1, no visible focus at all, on every
   focusable element in every blue panel sitewide. In dark it is 2.85:1, also
   under the 3:1 bar of WCAG 1.4.11. These panels are fixed in both
   appearances, so the ring is white in both. :where() contributes zero, so
   this is (0,2,0) against the global (0,1,0).
   The ring sits at outline-offset 3-4px, i.e. ON the panel field rather than
   on the button — so it is correct for the white .hero .btn-pri pill too
   (white ring on blue, 5.18:1). Do not "fix" that by reverting it.        */
.cta-panel :where(a,button,summary,input,[tabindex]):focus-visible,
.blueprint :where(a,button,summary,input,[tabindex]):focus-visible{
  outline-color:#fff}

/* ---------- 2. the hover system: one language, three roles ----------------
     SURFACE (a card)   -> edge to accent 75%, shadow steps to lift. NEVER moves.
     CONTROL (a link)   -> 1px travel plus accent ink. Moves.
     CHIP    (metadata) -> ink firms, a --chip wash fills. NEVER moves.
   Every surface class is on a view() animation whose keyframes end at
   transform:none, so NO surface is ever transformed — box-shadow and
   border-color only. That is option (a) of the transform trap, and it means
   there is no !important anywhere in this file.

   Carrier only. Colour, shadow and transform — deliberately NO filter and no
   backdrop-filter, so none of this costs a frame until the cursor arrives.
   .shot is deliberately ABSENT: 54 marquee cards already have their own
   transition at line 693, and so do .btn/.tgl/.app/.card/.tier/.store-pill. */
:is(.tag,.mono-tag),
:is(.panel,.faq-item),
.glyph,.card > .ic,.back,
.grid-cat .appicon,.web-grid .webicon{
  transition:background-color .16s var(--e),border-color .16s var(--e),
             color .16s var(--e),box-shadow .18s var(--e),
             transform .16s var(--e)}

/* ---------- 3. SURFACE: 34 cards. Edge firms, shadow steps. Never moves. --
   Generalising the one card hover that was already built deliberately rather
   than inventing a second idea. accent 75% measures >=3:1 against --card,
   --card-2 AND --paper in both themes; --accent-line (1.55:1 / 1.87:1) does
   not, which is why the two rules it replaces were deleted.
   Counts: .panel.mini 18, .svc 5, .pcard 2, .card 6, .tier 3, .web 6.      */
@media (hover:hover){
  :is(.panel.mini,.svc,.pcard,.card,.tier,.web-grid .web):hover{
    border-color:color-mix(in srgb,var(--accent) 75%,transparent);
    box-shadow:var(--shadow-lift)}
}
/* keyboard parity, deliberately OUTSIDE the hover query: tabbing into any of
   the 18 catalogue cards' store links now lights the card it belongs to,
   which doubles as a "you are here" cue in a grid of 18. */
:is(.panel.mini,.svc,.pcard,.card,.tier,.web-grid .web):focus-within{
  border-color:color-mix(in srgb,var(--accent) 75%,transparent);
  box-shadow:var(--shadow-lift)}

/* .tier.feat already carries border-color:var(--accent) and
   box-shadow:0 0 0 1px var(--accent),var(--shadow-lift) AT REST (line 166).
   Repeating those values would give the single most commercially important
   card on the site zero hover feedback while the two tiers beside it lit up.
   The ring STEPS 1px -> 2px instead. A spread shadow is not layout.        */
.tier.feat:hover,
.tier.feat:focus-within{border-color:var(--accent);
  box-shadow:0 0 0 2px var(--accent),var(--shadow-lift)}

/* the quiet tier of the same role: 12 .faq-item blocks. Edge only, no shadow
   lift — these are static Q/A, not cards you can act on, but a reader working
   down twelve of them benefits from knowing which one the cursor is in. No
   :focus-within twin: a .faq-item contains only h3 + p, nothing focusable. */
@media (hover:hover){
  .faq-item:hover{border-color:color-mix(in srgb,var(--accent) 75%,transparent)}
}

/* ---------- 4. the medallion. 11 elements (.glyph x5, .card > .ic x6) -----
   The only place the system runs two effects at once, and the second is on a
   CHILD of the hovered element rather than a competing effect on it. This
   pair gets BETTER on hover, which is the point: 4.63 -> 6.36:1 light,
   5.86 -> 6.77:1 dark. Deepening the wash while KEEPING --accent as the ink
   measures only 4.24:1 in light — switching to --accent-ink is what buys the
   margin.
   The `>` child combinator is load-bearing: .ic is also the generic inline-svg
   class, and on /projects/ the .ic svgs live inside .score and inside
   .store-pill, never as a direct child — a descendant selector would repaint
   the rating badge and all 18 store chips. color-mix toward transparent
   premultiplies, so the hue is preserved rather than muddied.
   No transform, although these children are free to take one: a growing icon
   inside a card that cannot move is exactly the incoherence to avoid.      */
@media (hover:hover){
  :is(.svc,.pcard,.card):hover > :is(.glyph,.ic){
    background:color-mix(in srgb,var(--accent) 14%,transparent);
    color:var(--accent-ink)}
}
:is(.svc,.pcard,.card):focus-within > :is(.glyph,.ic){
  background:color-mix(in srgb,var(--accent) 14%,transparent);
  color:var(--accent-ink)}
:root[data-theme="dark"] :is(.svc,.pcard,.card):hover > :is(.glyph,.ic),
:root[data-theme="dark"] :is(.svc,.pcard,.card):focus-within > :is(.glyph,.ic){
  background:color-mix(in srgb,var(--accent) 18%,transparent)}

/* ---------- 5. CHIP: 42 inert capsules that currently do nothing ----------
   .tag x41, .mono-tag x1. The ink firms from --muted to --ink-2 and a --chip
   wash fills the capsule. No transform, no cursor change, no border change —
   because 41 of these 42 are NOT clickable and must not learn to look
   clickable. They are dates, platform labels and publisher names. The honest
   response for an inert capsule is a LEGIBILITY GAIN, which reads as a
   highlight rather than a button: every pair more than doubles its resting
   ratio, so this can only move the audit further from failure.
   The one capsule that DOES wrap a link (civipass/support/index.html:78, a
   mailto inside a .tag) gets the control edge through :has(a), so the
   exception is explicit rather than accidental.
   .chip is NOT in the selector: it has zero uses anywhere on the site.
   .plat is NOT in the selector either: both its uses are on
   fisch/delete-account/index.html, which overrides .plat and .plat.ios in a
   page <style> AFTER the stylesheet link, in its own gold/teal dialect — a
   (0,2,0) tie that page-local source order wins. Excluding it costs 2
   capsules and avoids restyling another page's local palette.             */
@media (hover:hover){
  :is(.tag,.mono-tag):hover{background:var(--chip);color:var(--ink-2)}
  .tag:has(a):hover{border-color:color-mix(in srgb,var(--accent) 75%,transparent)}
  .tag a:hover{color:var(--accent-ink)}
}
.tag:focus-within{background:var(--chip);color:var(--ink-2);
  border-color:color-mix(in srgb,var(--accent) 75%,transparent)}

/* ---------- 6. CONTROL: the only role allowed to move, and it moves 1px ---
   TRAVEL ONLY. The colour lives in .grid-cat .store-pill:hover (0,3,0), which
   is where it can actually win — a global (0,2,0) colour rule would be dead
   inside .grid-cat and alive nowhere else, which is precisely how mouse and
   keyboard came to diverge.
   :not(:active) is mandatory, not defensive. Line 696 is
   .btn:active,.tgl:active,.store-pill:active{transform:translateY(1px)
   scale(.985)} at (0,2,0); a bare .store-pill:hover{transform:…} is also
   (0,2,0) and appended LATER, so on a mouse press — which is always hover AND
   active — it would win and destroy the existing press feedback.
   .back slides 2px toward where it is taking you. 18 .store-pill + 6 .back.  */
@media (hover:hover){
  .store-pill:hover:not(:active){transform:translateY(-1px)}
  .back:hover{transform:translateX(-2px)}
}
.store-pill:focus-visible:not(:active){transform:translateY(-1px)}
.back:focus-visible{transform:translateX(-2px)}

/* ---------- 7. the store marks have no fill, so `color:` never reached them
   Verified in the markup: 16 <svg viewBox="0 0 384 512"> (App Store) and 7
   <svg viewBox="0 0 512 512"> (Google Play) on projects/index.html, all with
   no class and no fill attribute, and their <path> children declare no fill
   either — so they render at the SVG initial value fill:rgb(0,0,0) and every
   `color:` in the chip rules has always been inert.
   Consequence, both opposite-theme failures: in DARK the mark is black on a
   (43,43,48) chip at REST = 1.49:1, i.e. the App Store and Google Play logos
   are invisible on that page today. In LIGHT the hover rule flipped the chip
   to --ink and took the mark to 1.11:1 — it disappeared exactly when you
   pointed at it. A resting-state audit cannot see either, because it measures
   text, not undeclared SVG fills.
   `fill` is an inherited SVG property, so setting it on the <svg> reaches the
   <path> children.
   :not([fill]) is NOT decoration: the website pill's globe is stroke-only and
   declares fill="none" as a PRESENTATION attribute, and author CSS beats a
   presentation attribute — unguarded, this would flood 6 globes into solid
   blobs. The guard also keeps any future stroke-only mark safe, provided it
   declares fill="none" as these do.                                        */
.grid-cat .store-pill svg:not(.mark):not([fill]){fill:currentColor}

/* ---------- 8. app + web icons: a measured frame ---------------------------
   16 of the 18 App Store icons have a median 1px edge luminance of
   0.000-0.039, i.e. 1.00-1.56:1 against --card #141418, and the only
   separation — border:1px solid var(--line) — composites to (47,47,51) =
   1.37:1. A dark icon on a dark card behind a 12%-white hairline is a smudge,
   exactly as reported. Light mode is NOT fine either: adopt-me-values.jpg and
   cos-values.jpg are pure white at the edge = 1.00:1 against the white card.
   ONE theme-agnostic ring serves both populations and both themes, because it
   contrasts with the CARD — so no icon has to contrast with anything.

   Why border-color and not a plate or an inset ring:
     * the artwork is full-bleed and the radius is supplied by CSS, so with
       box-sizing:border-box the 1px border sits just OUTSIDE the content box —
       it frames the artwork instead of painting over it;
     * an inset keyline lands on the artwork's own outermost pixel row: it
       works on the dark icons and is invisible on the white ones;
     * an outset "plate" measures 1.19:1 (white @.07) to 1.38:1 (@.12) —
       effectively invisible — while eating 3-4px of the 14px .top gap;
     * a filter or backdrop on 24 elements alters the client's artwork and
       breaks constraint 5.
   ONLY border-COLOR moves. Width stays 1px. (0,2,0) beats both bare .appicon
   rules (lines 367, 398) and .webicon (line 414).                          */
.grid-cat .appicon,
.web-grid .webicon{border-color:color-mix(in srgb,var(--ink) 45%,transparent)}

/* the frame firms up with the card — strictly ABOVE the resting ring in BOTH
   themes, which an rgba(17,17,19,.28) light-mode value would not have been
   (1.89:1, i.e. weaker than the 3.03:1 resting ring it would have beaten on
   specificity). No transform, nothing to composite across 24 icons. */
@media (hover:hover){
  .grid-cat > .panel.mini:hover .appicon,
  .web-grid .web:hover .webicon{
    border-color:color-mix(in srgb,var(--ink) 62%,transparent)}
}
.grid-cat > .panel.mini:focus-within .appicon,
.web-grid .web:focus-within .webicon{
  border-color:color-mix(in srgb,var(--ink) 62%,transparent)}

/* ---------- 9. the copy-to-clipboard control (contact-us, services) -------
   The control exists only where the async Clipboard API does AND the origin is
   secure. html.clip is set in the existing <head> snippet before first paint,
   so a no-JS, plain-HTTP or hardened reader never sees a dead <button>, and
   nothing swaps after paint. Exactly one of the button and the mailto <a> is
   ever rendered, so the flex row never holds both.
   .copy{display:none} must beat .btn{display:inline-flex} (line 83) and both
   are (0,1,0) — source order decides, so this block must stay after it.    */
.copy{display:none}
html.clip .copy{display:inline-flex}
html.clip .copy-fallback{display:none}

/* three glyphs in one 16px box, three labels in ONE grid cell: the button's
   intrinsic width is set by its longest label, so a state change can never
   move the row. This is why the label is not replaced with textContent the
   way the orphaned home.js did it.
   visibility, not opacity alone: two opacity:0 text nodes are permanent DOM
   text, and an auditor that folds opacity in computes 1:1 on them. The 0s
   visibility transition is DELAYED on the way out so the 160ms crossfade is
   unchanged, and the delay is zeroed on the active state so the incoming
   glyph is visible immediately.                                            */
.copy-ics{position:relative;width:16px;height:16px;flex:0 0 auto}
.copy-txt{display:inline-grid;align-items:center}
.copy-txt > .copy-t{grid-area:1/1}
.copy-ics .ic,
.copy-txt > .copy-t{opacity:0;visibility:hidden;
  transition:opacity .16s var(--e),visibility 0s linear .16s}
.copy-ics .ic{position:absolute;inset:0}
.copy .copy-i-idle,.copy .copy-t-idle{opacity:1;visibility:visible;transition-delay:0s}
.copy.is-ok  .copy-i-idle,.copy.is-ok  .copy-t-idle,
.copy.is-err .copy-i-idle,.copy.is-err .copy-t-idle{opacity:0;visibility:hidden}
.copy.is-ok  .copy-i-ok, .copy.is-ok  .copy-t-ok,
.copy.is-err .copy-i-err,.copy.is-err .copy-t-err{opacity:1;visibility:visible;
  transition-delay:0s}

/* the role="status" span. position:absolute takes it out of the .hero-ctas
   flex flow entirely, so it cannot open a gap or create a scroll artefact.
   Scoped to .copy-live rather than a bare .vh utility: the pattern is used
   exactly once, in a stylesheet shared by 21 pages. */
.copy-live{position:absolute;width:1px;height:1px;margin:-1px;padding:0;
  overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);
  white-space:nowrap;border:0}

/* Both live copy buttons are the SECONDARY in a blue .cta-panel
   (contact-us/index.html:53-59, services/index.html:209-217), so they inherit
   .cta-panel .btn.btn-ghost — transparent, white ink, .70 white border — and
   their hover/focus/active come from there. Only the RESULT states are new,
   and they must not read as a deeper version of hover or active: success is a
   solid white pill, failure a darker ink wash. (0,5,0) so the result beats
   .cta-panel .btn.btn-ghost:hover (0,4,0) while the pointer is still on the
   button after the click. */
.cta-panel .btn.btn-ghost.copy.is-ok{background:#fff;color:#1F42CC;border-color:#fff}
.cta-panel .btn.btn-ghost.copy.is-err{background:rgba(11,11,13,.28);color:#fff;
  border-color:#fff}

/* forward-looking: a .copy outside a blue panel. Hover and focus come from
   .btn-ghost, so only the results are declared. Measured over --paper, which
   is the real backdrop of a .btn-ghost (it declares no background). The ring
   uses --accent-ink, not --accent-line: --accent-line composited over the
   wash is only 1.39:1, far under the 3:1 bar. */
.btn.copy.is-ok{background:color-mix(in srgb,var(--accent) 14%,transparent);
  color:var(--accent-ink);box-shadow:inset 0 0 0 1.5px var(--accent-ink)}
.btn.copy.is-err{background:transparent;color:var(--gold);
  box-shadow:inset 0 0 0 1.5px var(--gold)}

/* this control's entire feedback is a colour plus a box-shadow, and
   forced-colors drops the shadow — the one place on the site where that
   matters. One line closes it. */
@media (forced-colors:active){
  .copy.is-ok,.copy.is-err{outline:2px solid CanvasText;outline-offset:-3px}
}

/* ---------- 10. the nav: ONE idea, the rail -------------------------------
   The base .nav a::after,.foot a::after rule (lines 715-717) is SHARED with
   the footer, so every change here is .nav-scoped and the 21 footers keep
   their 1px underline untouched. Anyone later editing line 715 to "fix the
   nav" will silently restyle every footer.
   Fixed geometry in EVERY state — inset to the 13px padding so the rail sits
   under the word rather than the whole hit area, 2px, rounded — so hover and
   current differ only in colour and opacity and the rail never resizes. The
   pseudo-element is position:absolute, so 1px -> 2px cannot shift anything,
   and the link box is ~33px inside a 72px bar, leaving ~19px of room for
   bottom:-4px plus 2px. */
.nav a::after{left:13px;right:13px;bottom:-4px;height:2px;border-radius:2px;
  transition:transform .22s var(--e),background-color .16s var(--e),
             opacity .16s var(--e)}

/* the rail retracts in its direction of travel instead of snapping back —
   the one motion refinement, and it costs a single declaration. */
.nav a:not(:hover):not([aria-current])::after{transform-origin:right center}

/* the other half of the touch gate. Line 718's .nav a:hover::after is shared
   with the footer and ungated, so on a touch tablet above the 720px
   breakpoint a tap would leave the rail stuck on — and the rail is now the
   ONLY affordance, so that stuck state is more visible than the pill was. */
@media (hover:none){
  .nav a:hover::after{transform:scaleX(0)}
}

/* keyboard parity with hover (constraint 8) */
.nav a:focus-visible{color:var(--ink)}
.nav a:focus-visible::after{transform:none}

/* CURRENT. Matches both aria-current="location" (the homepage scroll-spy) and
   aria-current="true" (the static, hand-authored pages), so one rule covers
   both. NO font-weight change: that would reflow the nav row. Distinct from
   hover by HUE and by transient-vs-resting, and legible to someone who cannot
   perceive the blue, because rail-present-vs-absent is a second, non-colour
   channel. MUST stay after the :focus-visible rule above — both are (0,2,1),
   so moving it earlier would demote the current link to ink on focus.      */
.nav a[aria-current]{color:var(--accent-ink)}
.nav a[aria-current]::after{transform:none;opacity:1;background:var(--accent)}
@media (hover:hover){
  .nav a[aria-current]:hover{color:var(--accent-ink)}
}

/* anchors must land clear of the 72px sticky bar (73px with its border), or a
   spy that says "Services" points at a heading hidden behind the header.
   scroll-margin only moves the scroll destination — it is not a box-model
   property and shifts no layout. There is no scroll-margin anywhere in this
   file today, so this is also a pre-existing bug fix independent of the nav,
   and it changes where subpage anchors land on all 21 pages. */
section[id],.sec[id]{scroll-margin-top:84px}
@media(max-width:720px){section[id],.sec[id]{scroll-margin-top:76px}}

/* ---------- 11. reduce-motion. MUST BE THE LAST BLOCK IN THE FILE --------
   POSITION, stated once for every rule above: a hover transition that only
   changes background-color, border-color, color or box-shadow over 120-180ms
   is user-initiated, brief and moves nothing, so it SURVIVES reduce-motion —
   which is the convention line 692's own comment already establishes. What is
   dropped is travel. Everything damped here is a transform.                */
@media (prefers-reduced-motion: reduce){

  /* buttons: the 1px lifts go, the colour change carries the whole
     affordance. Matches the file's existing position at line 725
     (.tgl:hover svg{transform:none}) rather than leaving two philosophies.
     The :active press at line 696 STAYS — direct-manipulation feedback, not
     decoration. */
  .btn-pri:hover,.btn-pri:focus-visible,
  .btn-ink:hover,.btn-ink:focus-visible,
  .cta-panel .btn.btn-pri:hover,
  .cta-panel .btn.btn-pri:focus-visible{transform:none}

  /* the marquee rows become overflow-x:auto scrollers at line 253-255, and
     unset overflow-y computes to auto with them — so the -14px margins would
     put row 2's box over the band holding row 1's horizontal scrollbar, for
     exactly the readers who have no marquee and must drag it. The extra
     padding exists only to contain a lift that no longer happens here, so
     give the original geometry back. */
  .showcase{padding:10px 0;margin-block:0}
  .mq-row + .mq-row{margin-top:14px}

  /* the two largest hover transforms on the site. .shot is a ~429px object
     moving 10px on 54 elements — the "moves a large element" case. Both now
     express as border-color plus box-shadow only. */
  .shot:hover,.shot:focus-visible,.app:hover{transform:none}

  /* CONTROL travel. The :not(:active) must be REPEATED here: the rules being
     damped are .store-pill:hover:not(:active) at (0,3,0), and a bare
     .store-pill:hover is only (0,2,0) — it would lose the cascade and the
     travel would survive reduce-motion silently. */
  .store-pill:hover:not(:active),.store-pill:focus-visible:not(:active),
  .back:hover,.back:focus-visible{transform:none}

  /* the two outbound-arrow nudges. 1px each, but they are travel like any
     other, and leaving them was the only inconsistency in this block. */
  .grid-cat .store-pill.web:hover::after,
  .web-grid .web:hover .host::after{transform:none}

  /* re-assert the rail's stillness: the appended .nav a::after rule above
     re-declares `transition`, so it beats the transition:none at line 726,
     which appears earlier in the file. The rail then appears and disappears
     instantly instead of wiping in — correct, because the state is also
     carried by colour, so the wipe is pure decoration. */
  .nav a::after{transition:none}
}

/* ---------- the footer mark cycles through the icon set --------------------
   A 16-frame sprite stepped with steps(16), NOT a <video>: one 40KB request,
   no decoder, no autoplay policy to fight, and it stops dead under
   reduce-motion on frame one. The sheet is 1920x120 (16 x 120px, 3x for
   retina) shown at 640x40, so background-size and the keyframe are in PIXELS
   — a percentage background-position resolves against (box - image) and does
   not step cleanly.
   Only the FOOTER mark cycles; the header keeps the still logo so the page
   does not have two competing animations in the chrome. */
/* The footer brand is a centred vertical lockup: the cycling mark at 80px
   with the name beneath it. .brand is a flex ROW everywhere else (the header),
   so the direction, alignment and gap all have to be restated here rather than
   inherited. The sprite is 2560x160 (16 x 160px, 2x for the 80px box) shown at
   1280x80 — the frame pitch and the keyframe distance must both be in pixels
   and must both equal 16 x the box, or the steps land between frames. */
.foot .brand-cycle{flex-direction:column;align-items:center;gap:14px;
  text-align:center;font-size:1.12rem}
.foot .brand-cycle .mark{width:80px;height:80px;flex:0 0 auto;
  border-radius:22.37%;background-image:url("/assets/img/mark-cycle.webp");
  background-size:1280px 80px;background-position:0 0;background-repeat:no-repeat;
  box-shadow:0 2px 4px rgba(8,20,32,.20),0 12px 26px -12px rgba(8,20,32,.40)}
html.motion .foot .brand-cycle .mark{animation:markCycle 3.2s steps(16) infinite}
@keyframes markCycle{from{background-position:0 0}to{background-position:-1280px 0}}

/* the rest of the column follows the lockup, otherwise a centred brand sits
   over left-aligned prose and reads as a mistake rather than a choice */
.foot-grid > div:first-child{text-align:center}
.foot-grid > div:first-child .blurb{margin-inline:auto;max-width:34ch}
@media(max-width:720px){
  .foot-grid > div:first-child{text-align:left}
  .foot .brand-cycle{flex-direction:row;align-items:center;gap:11px}
  .foot .brand-cycle .mark{width:52px;height:52px;background-size:832px 52px}
  html.motion .foot .brand-cycle .mark{animation:markCycleSm 3.2s steps(16) infinite}
  @keyframes markCycleSm{from{background-position:0 0}to{background-position:-832px 0}}
  .foot-grid > div:first-child .blurb{margin-inline:0}
}

/* ---------- the header, made a little more playful -------------------------
   The nav rail already carries hover and the current section. These add the
   small amounts of life the header was missing: the links lift a hair and
   their rail overshoots before settling, and the whole lockup gets a local
   chip. Everything is transform/colour only and dies under reduce-motion. */
@media (hover:hover){
  .nav a:hover{transform:translateY(-1px)}
}
.nav a{transition:color .16s var(--e),transform .18s var(--e)}
.nav a::after{transition:transform .34s cubic-bezier(.34,1.56,.64,1),
                         background-color .16s var(--e),opacity .16s var(--e)}
.nav a:active{transform:translateY(0)}

/* THE LOCAL CHIP — the visitor's own city, their local clock and, when it
   resolves, their weather. The city comes from Intl's timezone, NOT from the
   Geolocation API: no permission prompt, no precise position, and it is
   already in the browser. [hidden] until JS fills it, so a no-JS reader never
   sees an em-dash placeholder. */
.localchip{display:inline-flex;align-items:center;gap:8px;
  font-family:var(--mono);font-size:.68rem;letter-spacing:.04em;
  color:var(--muted);padding:7px 12px;border-radius:99px;
  border:1px solid var(--line);background:var(--card-2);
  white-space:nowrap;transition:border-color .18s var(--e),color .18s var(--e)}
.localchip[hidden]{display:none}
.localchip .ic{width:13px;height:13px;stroke-width:1.9;color:var(--accent)}
.localchip .lc-place{color:var(--ink-2)}
.localchip .lc-time{color:var(--ink);font-variant-numeric:tabular-nums}
.localchip .lc-sep{width:3px;height:3px;border-radius:50%;background:currentColor;opacity:.5}
.localchip .lc-wx{display:inline-flex;align-items:center;gap:5px;color:var(--ink-2)}
.localchip .lc-wx[hidden]{display:none}
/* holds a 14px stroke icon, never a glyph: emoji render in the vendor's own
   colour and style, which belongs to nobody's brand */
.localchip .lc-ic{display:inline-flex;align-items:center;color:var(--ink-2)}
.localchip .lc-ic svg{width:14px;height:14px;display:block}
@media (hover:hover){ .localchip:hover{border-color:var(--accent-line);color:var(--ink-2)} }
/* the clock's colon blinks on the second, the one bit of life it needs */
html.motion .localchip .lc-time::after{content:"";display:inline-block;width:0}
@media(max-width:980px){ .localchip .lc-wx{display:none} }
@media(max-width:860px){ .localchip{display:none} }

/* ---------- "where you are", folded into the booking panel ----------------
   This was its own blue section sitting immediately above the blue booking
   panel — two full-bleed --blueprint fields stacked with nothing but page
   background between them, which read as one panel that had been cut in half.
   The map is now a full-width row INSIDE .book, so there is a single taller
   panel. .book is a 1.15fr/.85fr grid, so the row has to span both columns
   explicitly or it would sit in the first one.
   ------------------------------------------------------------------------- */
.book-atlas{grid-column:1 / -1;margin-top:8px;padding-top:26px;
  border-top:1px solid rgba(255,255,255,.26)}
.book-atlas-lede{color:rgba(255,255,255,.95);max-width:52ch;margin-bottom:20px}
.atlas-head{display:flex;align-items:center;justify-content:space-between;gap:16px;
  font-family:var(--mono);font-size:.62rem;letter-spacing:.13em;text-transform:uppercase;
  color:rgba(255,255,255,.95);margin-bottom:12px}
.atlas-scale{color:rgba(255,255,255,.95)}
.atlas-canvas{display:block;width:100%;height:auto;aspect-ratio:900/396}

.atlas-facts{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px;
  margin-top:22px;padding-top:18px;border-top:1px solid rgba(255,255,255,.26)}
@media(max-width:560px){.atlas-facts{grid-template-columns:1fr 1fr}}
.atlas-facts dt{font-family:var(--mono);font-size:.58rem;letter-spacing:.13em;
  text-transform:uppercase;color:rgba(255,255,255,.95)}
.atlas-facts dd{margin-top:6px;font-family:var(--disp);font-size:1.12rem;font-weight:600;
  letter-spacing:-.01em;color:#fff;font-variant-numeric:tabular-nums}
.atlas-note{margin-top:16px;font-family:var(--mono);font-size:.6rem;letter-spacing:.06em;
  color:rgba(255,255,255,.95);max-width:56ch}

/* ---------- the drafting rule down the edge of a section -------------------
   Decoration, and deliberately decoration only: aria-hidden, pointer-events
   none, and it never reflows anything. It is drawn with repeating-linear-
   gradients so there is no DOM per tick. Hidden below 1180px, where there is
   no margin left to put it in. */
.ruled{position:relative}
.ruled::before{content:"";position:absolute;left:-34px;top:0;bottom:0;width:16px;
  pointer-events:none;opacity:.5;
  background:
    linear-gradient(var(--line),var(--line)) right/1px 100% no-repeat,
    repeating-linear-gradient(180deg,var(--line) 0 1px,transparent 1px 10px) right/7px 100% no-repeat,
    repeating-linear-gradient(180deg,var(--line) 0 1px,transparent 1px 50px) right/14px 100% no-repeat}
@media(max-width:1180px){.ruled::before{display:none}}
