/* ══════════════════════════════════════════════════════════════
   Dijitally: shared design tokens for static pages.
   Values below are lifted from index.html so generated pages match
   Dave's Figma system exactly. Do not drift from these.
   ══════════════════════════════════════════════════════════════ */

/* ── NeuSans Trial: same faces the prototype loads ── */
@font-face{font-family:"NeuSans Trial";src:url("/Fonts/NeuSansTrial-Light.otf") format("opentype");font-weight:300;font-style:normal;font-display:swap}
@font-face{font-family:"NeuSans Trial";src:url("/Fonts/NeuSansTrial-Book.otf") format("opentype");font-weight:350;font-style:normal;font-display:swap}
@font-face{font-family:"NeuSans Trial";src:url("/Fonts/NeuSansTrial-Regular.otf") format("opentype");font-weight:400;font-style:normal;font-display:swap}
@font-face{font-family:"NeuSans Trial";src:url("/Fonts/NeuSansTrial-RegularItalic.otf") format("opentype");font-weight:400;font-style:italic;font-display:swap}
@font-face{font-family:"NeuSans Trial";src:url("/Fonts/NeuSansTrial-Medium.otf") format("opentype");font-weight:500;font-style:normal;font-display:swap}
@font-face{font-family:"NeuSans Trial";src:url("/Fonts/NeuSansTrial-Bold.otf") format("opentype");font-weight:700;font-style:normal;font-display:swap}

:root{
  /* The header uses var(--s42) for its inset, but the token only ever existed
     inside the app shell, so static pages resolved it to nothing and the
     wordmark sat flush against the viewport edge. Matches the app: 42px. */
  --s42:42px; --s64:64px;

  /* THE LIGHT PALETTE IS NAMED TWICE ON PURPOSE, and the second name is the
     only reason `@media print` can restore it. Paper is white, so a printed
     page has to be the light rendering whatever the reader's theme, and the
     only way to undo `.dark`'s overrides in CSS is to set those tokens again.
     Setting them again by hand means a palette that drifts the first time Dave
     moves one: one of the two copies would be his and the other would be a
     transcription of his from some night in September. So the values live
     under `--lt-*`, the live tokens are `var()` of them, and the print block
     at the bottom of this file points `.dark` back at the same source. The
     computed values here are identical to the literals they replaced.
     `--tech` is not in the set because the accent does not flip with theme. */
  --lt-primary:#F5F3EE; --lt-secondary:#121010; --lt-static:#686868;
  --lt-grey:#DEDEDE;    --lt-bg:#F5F3EE;

  --primary:var(--lt-primary); --secondary:var(--lt-secondary);
  --static:var(--lt-static);   --grey:var(--lt-grey);
  --tech:#B6F4C9;              --bg:var(--lt-bg);
  --sans:"NeuSans Trial","Helvetica Neue",Arial,sans-serif;
  --mono:"JetBrains Mono",monospace;
  /* Typography: Figma exact, matching index.html */
  --h1:48px; --h1-w:500; --h1-lh:100%; --h1-ls:-0.96px;
  --h2:32px; --h2-w:500; --h2-lh:110%; --h2-ls:-0.64px;
  --nav-size:19px; --nav-w:600; --nav-ls:-0.38px;
  --eyebrow:12px; --eyebrow-ls:-0.24px;
  --card:24px; --card-lh:120%;
  --body:17px; --body-lh:150%;
  --ease:cubic-bezier(0.76,0,0.24,1);   /* Dave's signature easing */

  /* Header height, COMPOSED rather than copied. The header measures 99px and
     nothing outside its own two rules knew that, so two separate things were
     positioned under it: the author rail pinned at top:32px, and every
     scroll-into-view landing a focused control at y=0. A hand-typed 99 here
     would be wrong the first time either number moves, which is the same
     failure as the cached pixel nav widths recorded in CLAUDE.md. The two
     parts are the tokens, the total is derived, and the rules below consume
     the tokens so the three cannot drift apart.
     32*2 padding + 34 row + 1 border-bottom = 99px. */
  --header-pad:32px; --header-row:34px;
  --header-h:calc(var(--header-pad) * 2 + var(--header-row) + 1px);
}

/* WCAG 2.2 SC 2.4.11 Focus Not Obscured (Minimum), AA. The header is
   position:sticky at top:0 and opaque, so any scroll-into-view that lands its
   target at y=0 puts it behind the header: Shift+Tab, an in-page anchor, the
   skip link, scrollIntoView(). Reserving the header's own height at the top of
   the scrollport is the one-line fix for all four, and it is measured: without
   it, 12 of 36 routes failed MODE=obscured. */
html{scroll-padding-top:var(--header-h)}
/* --static is the one token that cannot be shared across both themes. #686868
   was darkened to clear AA on the bone ground (5.02:1), which is the right
   direction on light and the wrong one on dark: on #121010 the same grey is
   3.40:1, failing every meta line, eyebrow and standfirst on the site once the
   toggle is flipped. Dark needs it lighter, not darker. #7E7E7E is 4.67:1 here
   and stays in the same neutral family. #848484 rather than #7E7E7E because the
   zebra band (#1E1C1C) is a lighter ground than the page: #7E7E7E cleared the
   page at 4.67:1 but only reached 4.29:1 in a banded table cell. Dave's token,
   so worth him seeing. */
.dark{ --primary:#1a1a1a; --secondary:#FDFEFF; --bg:#121010; --grey:#2A2A2A;
       --static:#848484;
       /* .brand img asks for --logo-filter and nothing ever defined it, so our
          own wordmark rendered black on #121010, i.e. not at all. Inverting is
          fine here: this is the Dijitally mark, never a client's. */
       --logo-filter:invert(1); }

*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--secondary);font-family:var(--sans);
  font-size:var(--body);line-height:var(--body-lh);font-weight:350;
  -webkit-font-smoothing:antialiased;
  transition:background .3s ease,color .3s ease}
a{color:inherit}

.wrap{max-width:760px;margin:0 auto;padding:0 24px}

/* ── skip link ──────────────────────────────────────────────────────────────
   The first Tab on every page walked the whole header before reaching a word of
   content: wordmark, three nav links, Get in touch, theme toggle. Six stops on
   desktop, on every page, every time.

   position:fixed rather than absolute because the homepage runs a GSAP
   ScrollSmoother, and anything inside #smooth-wrapper inherits a transform that
   turns fixed into absolute. The link is a direct child of <body> there, outside
   the wrapper, for the same reason the header is portalled out.

   Colour is the literal #121010, not var(--secondary): the accent stays #B6F4C9
   in both themes, so a token that flips to near-white on dark would put white on
   mint. Same reasoning as .cta-btn.

   The target carries tabindex="-1". An in-page anchor scrolls but does not move
   focus, so without it the next Tab returns to the nav and the link has achieved
   nothing but a scroll: the half-built version of this control that is worse
   than none, because it claims to have done something.
   ── */
.skip-link{position:fixed;left:var(--s42);top:0;z-index:300;
  transform:translateY(-140%);
  display:inline-block;padding:12px 20px;
  background:var(--tech);color:#121010;
  font-family:var(--mono);font-size:var(--eyebrow);letter-spacing:var(--eyebrow-ls);
  text-transform:uppercase;text-decoration:none;border-radius:0;
  transition:transform .2s var(--ease)}
.skip-link:focus{transform:translateY(12px)}
.skip-link:focus-visible{outline:2px solid var(--secondary);outline-offset:2px}
@media (prefers-reduced-motion:reduce){.skip-link{transition:none}}
/* The header drops to a 16px gutter at this width in both the static pages and
   the app shell, so the link lines up with the nav rather than the desktop
   42px inset. */
@media (max-width:768px){.skip-link{left:16px}}
/* Focus lands here from the skip link and nowhere else, so the ring would only
   ever be noise around the whole page. */
main:focus{outline:none}

/* ── header / footer ── */
/* Full-bleed header, matching the homepage: 42px gutters, no 1200px box. */
header.site{border-bottom:1px solid var(--grey);padding:0;margin-bottom:56px;
  background:var(--bg);position:sticky;top:0;z-index:100}
header.site .wrap{max-width:none;width:100%;padding:0 var(--s42);min-height:var(--header-row);
  display:flex;align-items:center;justify-content:space-between;gap:24px}
.brand{font-family:var(--sans);font-weight:700;letter-spacing:-.02em;font-size:20px;text-decoration:none}
nav.site{display:flex;align-items:center;gap:42px}
nav.site a{font-family:var(--sans);font-size:var(--nav-size);font-weight:var(--nav-w);
  letter-spacing:var(--nav-ls);text-decoration:none;color:var(--secondary);
  display:flex;align-items:center;gap:4px;padding:6px;line-height:100%;
  transition:opacity .2s ease}
nav.site a::before{content:"";width:3px;height:3px;background:var(--secondary);flex-shrink:0;display:inline-block}
nav.site a:hover{opacity:.55}
/* ── footer ──
   Mirrors the app footer exactly: two rows inside the same 42px gutters, mono
   12px uppercase with Dave's 3px square before each link, nav left and legal
   right, LinkedIn on the second row. The static footer used to be one line of
   "Home / Terms / Privacy" with no nav, no icon and none of the spacing. */
footer.site{border-top:1px solid var(--grey);margin-top:80px;padding:0;background:var(--bg)}
footer.site .wrap{max-width:none;width:100%;padding:0 var(--s42);
  display:flex;flex-direction:column;align-items:stretch;gap:0}
.f-row{display:flex;align-items:center;justify-content:space-between;
  gap:24px;flex-wrap:wrap;padding-top:32px}
.f-row-b{padding:12px 0 32px}
.f-nav{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
footer.site a{font-family:var(--mono);font-size:12px;font-weight:400;line-height:100%;
  letter-spacing:var(--eyebrow-ls);text-transform:uppercase;text-decoration:none;
  color:var(--secondary);display:inline-flex;align-items:center;gap:12px;
  transition:opacity .2s ease}
footer.site a:hover{opacity:.55}
.f-dot{width:3px;height:3px;background:var(--secondary);flex-shrink:0;display:inline-block}
.f-legal{display:inline-flex;align-items:center;gap:12px}
.f-social{width:24px;height:24px;flex-shrink:0;display:inline-flex;
  align-items:center;justify-content:center;gap:0}
.f-social img{width:24px;height:24px;display:block;filter:var(--logo-filter,none)}
@media(max-width:640px){
  .f-row{padding-top:24px;gap:16px}
  .f-row-b{padding:8px 0 24px}
  .f-nav{gap:16px;width:100%;justify-content:space-between}
}

/* ── type ── */
.eyebrow{font-family:var(--mono);font-size:var(--eyebrow);letter-spacing:var(--eyebrow-ls);
  line-height:100%;text-transform:uppercase;color:var(--static);margin:0 0 16px}
h1{font-size:var(--h1);font-weight:var(--h1-w);line-height:var(--h1-lh);
  letter-spacing:var(--h1-ls);margin:0 0 20px}
.standfirst{font-size:20px;line-height:140%;letter-spacing:-.2px;color:var(--static);margin:0 0 44px}
article h2{font-size:var(--h2);font-weight:var(--h2-w);line-height:var(--h2-lh);
  letter-spacing:var(--h2-ls);margin:52px 0 16px}
article h3{font-size:var(--card);font-weight:500;line-height:var(--card-lh);margin:36px 0 12px}
article p{margin:0 0 20px}
article ul,article ol{margin:0 0 20px;padding-left:22px}
article li{margin-bottom:9px}
article a{text-decoration:underline;text-underline-offset:3px}
article img{width:100%;height:auto;display:block;margin:32px 0;border-radius:4px;background:var(--grey)}
article blockquote{margin:32px 0;padding:4px 0 4px 20px;border-left:2px solid var(--tech);
  font-size:20px;line-height:130%;letter-spacing:-.2px}
article hr{border:none;border-top:1px solid var(--grey);margin:44px 0}
article code{font-family:var(--mono);font-size:.9em;background:var(--grey);padding:2px 6px}
/* Two column reference tables, set the way the Figma treats them: a mono label
   row over a hairline, no cell borders, and alternating grey bands instead of a
   boxed grid. The term column carries the weight, the explanation stays muted. */
article table{width:100%;border-collapse:collapse;margin:8px 0 40px;table-layout:fixed}
article thead th{
  font-family:var(--mono);font-size:var(--eyebrow);letter-spacing:var(--eyebrow-ls);
  text-transform:uppercase;color:var(--static);font-weight:400;
  padding:0 24px 10px;text-align:left;border-bottom:1px solid var(--grey)}
article tbody td{padding:22px 24px;text-align:left;vertical-align:top;border:0}
/* A step down from the bone ground, not the ground itself, or the banding is
   invisible. */
article tbody tr:nth-child(even){background:#EDEAE2}
/* Without this the light band paints across the dark page and the term in it
   becomes near invisible. Same step, in the other direction. */
.dark article tbody tr:nth-child(even){background:#1E1C1C}
.dark article tbody td:first-child{color:var(--secondary)}
article tbody td:first-child{width:34%;font-weight:500;color:var(--secondary);padding-right:32px}
article tbody td:last-child{color:var(--static)}
.dark article tbody tr:nth-child(even){background:#1A1A1A}
@media(max-width:760px){
  article table,article thead,article tbody,article tr,article td,article th{display:block;width:auto}
  article thead{display:none}
  article tbody td:first-child{width:auto;padding-bottom:4px}
  article tbody td{padding:6px 16px}
  article tbody td:last-child{padding-bottom:22px}
}

/* ── stat row ── */
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:28px;
  margin:40px 0 48px;padding:28px 0;border-top:1px solid var(--grey);border-bottom:1px solid var(--grey)}
.stat{display:flex;flex-direction:column;gap:6px}
.stat-n{font-size:38px;line-height:100%;font-weight:500;letter-spacing:-.76px}
.stat-l{font-family:var(--mono);font-size:var(--eyebrow);line-height:135%;
  text-transform:uppercase;letter-spacing:var(--eyebrow-ls);color:var(--static)}

/* ── CTA: Dave's inversion, square corners, no transform ── */
.cta{margin:44px 0}
.btn{display:inline-flex;align-items:center;gap:10px;background:var(--tech);color:#121010;
  text-decoration:none;padding:18px 24px;border:none;border-radius:0;
  font-family:var(--sans);font-size:16px;font-weight:500;letter-spacing:0;
  transition:background .2s ease,color .2s ease}
.btn:hover{background:#121010;color:#B6F4C9}
.dark .btn:hover{background:#FDFEFF;color:#121010}

/* ── work grid: staggered, not a uniform grid ── */
.work-filters{margin:0 auto 40px}
.tiles.stagger{max-width:1200px;margin:0 auto;padding:0 24px;
  display:grid;grid-template-columns:repeat(2,1fr);column-gap:110px;row-gap:0;align-items:start}
.tiles.stagger .tile:nth-child(even){margin-top:180px}
.tiles.stagger .tile{margin-bottom:120px}
.tile{text-decoration:none;color:inherit;display:flex;flex-direction:column}
.tile[hidden]{display:none}
.tile-img{display:block;overflow:hidden;background:var(--grey)}
.tile-img img{width:100%;height:100%;object-fit:cover;display:block;
  transition:transform .7s var(--ease)}
.tile:hover .tile-img img{transform:scale(1.03)}
.tile-t{display:block;font-size:26px;font-weight:350;line-height:120%;
  letter-spacing:-.52px;padding:18px 0 6px;transition:opacity .2s ease}
.tile:hover .tile-t{opacity:.55}
.tile-m{display:block;font-family:var(--mono);font-size:11px;letter-spacing:-.22px;
  text-transform:uppercase;color:var(--static)}
main.wide{max-width:none;padding:0}
main.wide > .eyebrow,main.wide > h1,main.wide > .standfirst{max-width:1200px;margin-left:auto;
  margin-right:auto;padding-left:24px;padding-right:24px}
@media(max-width:820px){
  .tiles.stagger{grid-template-columns:1fr;column-gap:0}
  .tiles.stagger .tile:nth-child(even){margin-top:0}
  .tiles.stagger .tile{margin-bottom:64px}
  .tile-t{font-size:22px}
}

/* ── header: identical width and logo scale on every page ── */
/* Measured against the app header: 32px vertical padding over a 34px row
   gives the same 98px it does, instead of 129px. The nav jumped 31px
   vertically and the wordmark 65px wider on every click between them. */
header.site{padding:var(--header-pad) 0}
/* The static header capped its wrap at 1200px while the app header runs
   full-bleed at var(--s42), so the wordmark jumped 114px sideways on every
   click between a case study and /work. Both are full-bleed now. */
header.site .wrap,footer.site .wrap{max-width:none}
.brand{display:flex;align-items:center;line-height:0}
.brand img{height:19px;width:auto;display:block;
  filter:var(--logo-filter,none);transition:filter .3s ease}
@media(max-width:640px){.brand img{height:16px}}

/* ── visual pull-outs ── */
.fig{margin:48px -80px;max-width:none}
.fig img{width:100%;height:auto;display:block;border-radius:4px;background:var(--grey)}
.fig figcaption{font-family:var(--mono);font-size:11px;letter-spacing:var(--eyebrow-ls);
  text-transform:uppercase;color:var(--static);padding-top:12px}
.pull{font-size:clamp(24px,3vw,34px);font-weight:500;line-height:115%;letter-spacing:-.68px;
  margin:48px 0;padding:0 0 0 22px;border-left:3px solid var(--tech);max-width:20ch}
.bq{margin:44px 0;padding:28px 32px;background:var(--primary);border:1px solid var(--grey);
  font-size:22px;line-height:135%;letter-spacing:-.44px}
.dark .bq{background:#1a1a1a}
.bq cite{display:block;font-family:var(--mono);font-size:11px;font-style:normal;
  letter-spacing:var(--eyebrow-ls);text-transform:uppercase;color:var(--static);padding-top:16px}
@media(max-width:1000px){.fig{margin:36px 0}}

/* ══ case study components ══ */
.cs-logo{height:34px;width:auto;display:block;margin:0 0 24px auto}
.cs-hero{position:relative;margin:0 -80px 56px;max-width:none}
.cs-hero img{width:100%;height:auto;display:block;border-radius:4px}
.hstats{position:absolute;left:50%;bottom:8%;transform:translateX(-50%);
  display:flex;gap:2px}
.hstat{background:rgba(18,16,16,.72);backdrop-filter:blur(6px);color:#FDFEFF;
  padding:22px 26px;min-width:132px;display:flex;flex-direction:column;gap:6px;text-align:center}
.hstat b{font-size:34px;font-weight:500;line-height:1;letter-spacing:-.68px}
.hstat i{font-family:var(--mono);font-size:11px;font-style:normal;line-height:1.3;
  letter-spacing:var(--eyebrow-ls);text-transform:uppercase;color:#D6D6D6}

/* scope accordion */
.scope{display:grid;grid-template-columns:minmax(180px,260px) minmax(0,1fr);gap:56px;margin:64px 0}
.scope h2{margin:0;font-size:var(--h2);font-weight:500;line-height:var(--h2-lh);letter-spacing:var(--h2-ls)}
.acc{border-top:1px solid var(--grey);padding:18px 0}
.acc:last-child{border-bottom:1px solid var(--grey)}
.acc summary{list-style:none;cursor:pointer;font-size:18px;font-weight:500;
  display:flex;align-items:center;justify-content:space-between;gap:16px}
.acc summary::-webkit-details-marker{display:none}
.acc summary::after{content:"+";font-family:var(--mono);font-size:20px;color:var(--static)}
.acc[open] summary::after{content:"\2013"}
.acc img{width:100%;height:auto;display:block;margin:18px 0 14px;border-radius:2px;background:var(--grey)}
.acc p{color:var(--static);margin:0}

/* how-we-work table: alternating rows */
/* Zebra striping now lives with the rest of the table treatment above. */
article th{font-family:var(--mono);font-size:10px;letter-spacing:var(--eyebrow-ls);
  text-transform:uppercase;color:var(--static);font-weight:400}
article td,article th{border:none;border-bottom:1px solid var(--grey);padding:18px 16px;vertical-align:top}
article td:first-child{font-weight:500;width:32%}

/* black closing panel */
.closing{background:#0d0d0d;color:#FDFEFF;padding:64px;margin:72px -80px 0;border-radius:4px}
.closing-lead{font-size:22px;line-height:135%;letter-spacing:-.44px;margin:0 0 18px;color:var(--tech)}
.closing p{color:#D6D6D6;max-width:56ch}
.closing .btn{margin-top:18px}

/* project shape: gallery left, ask / process / results rail right */
/* Without art the rail becomes the section: full width, two columns of labels
   so it does not read as a narrow strip beside an empty space. */
.project-norail{grid-template-columns:1fr !important}
.project-norail .pj-rail{position:static;display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:32px}
.project{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,340px);gap:56px;
  align-items:start;margin:56px 0}
.pj-rail{position:sticky;top:96px;align-self:start;display:flex;flex-direction:column;gap:28px}
.pj-sec{border-top:1px solid var(--grey);padding-top:14px}
.pj-sec .eyebrow{margin:0 0 8px}
.pj-sec p:last-child{margin:0;color:var(--static)}

/* project gallery */
.gallery{display:flex;flex-direction:column;gap:14px;margin:0}
/* The grey frame sits under the image and is not clipped by the reveal, so the
   gallery reads as grey blocks while the images wipe in rather than as a white
   hole the height of the whole section. The inline aspect-ratio holds the box
   before the file loads. */
/* The frame hugs its image exactly. As a block span it was inheriting line box
   height and standing ~64px taller than the picture, which is the grey band. */
.ph{display:grid;background:var(--grey);overflow:hidden;border-radius:2px;
  line-height:0;font-size:0;cursor:zoom-in}
/* article img carries margin:32px 0, which inside the frame shows as a grey
   band above and below the picture. The frame supplies its own spacing. */
.ph img{grid-area:1/1;margin:0;border-radius:0}
.ph img{width:100%;height:auto;display:block}
.gallery > .ph, .g-pair > .ph{width:100%}
.g-pair{display:grid;grid-template-columns:1fr 1fr;gap:14px;align-items:start}

/* up next + lets chat */
.upnext-row{display:grid;grid-template-columns:minmax(240px,320px) minmax(0,1fr);
  gap:56px;align-items:start;margin:88px -80px 0}
.upnext{background:#0d0d0d;color:#FDFEFF;text-decoration:none;padding:18px;display:block}
.upnext-img{display:block;aspect-ratio:1;overflow:hidden;background:#1a1a1a}
.upnext-img img{width:100%;height:100%;object-fit:cover;display:block;
  transition:transform .6s var(--ease)}
.upnext:hover .upnext-img img{transform:scale(1.04)}
.upnext-lab{display:block;font-family:var(--mono);font-size:10px;letter-spacing:var(--eyebrow-ls);
  text-transform:uppercase;color:#9A9A9A;padding:22px 0 6px}
.upnext-t{display:block;font-size:26px;font-weight:500;line-height:118%;letter-spacing:-.52px}
/* The reference gives the lead card a bordered panel of its own, sitting a step
   off the page rather than blending into it, and the up next card is the smaller
   of the two. */
.upnext-row .lc-card{background:var(--primary);padding:48px;max-width:none;margin:0;
  border:1px solid var(--grey);border-radius:4px;align-self:start}
.dark .upnext-row .lc-card{border-color:#2A2A2A}
.upnext{border-radius:4px;overflow:hidden}
.upnext-row .lead-form{gap:10px}
.upnext-row .lead-form input{min-height:52px;background:transparent}
.dark .upnext-row .lead-form input{border-color:#3A3A3A}
.upnext-row .lc-card h2{font-size:28px;letter-spacing:-.56px;margin:0 0 22px;max-width:16ch}

@media(max-width:1000px){
  .cs-hero,.closing,.upnext-row{margin-left:0;margin-right:0}
  .closing{padding:34px}
  /* minmax(0,1fr) rather than 1fr, and the 0 is the entire point. `1fr` is
     `minmax(auto,1fr)`, so the track can never go below its widest item's
     automatic minimum size, and the lead card's is 320px. In a 272px content
     box that track stayed 320px and every case study scrolled sideways at a
     320px viewport, which is WCAG 2.2 SC 1.4.10 Reflow. Measured, not
     inferred: with the track forced to minmax(0,1fr) the card goes 320 -> 272
     and scrollWidth 344 -> 320. The rest of this file already writes
     minmax(0,1fr) in three places for the same reason. MODE=reflow is the
     gate that found it. */
  .scope,.upnext-row{grid-template-columns:minmax(0,1fr);gap:28px}
  .project{grid-template-columns:1fr;gap:32px}
  .pj-rail{position:static}
  .hstats{position:static;transform:none;margin-top:2px;flex-wrap:wrap}
  .hstat{flex:1 1 120px;background:#121010}
  .g-pair{grid-template-columns:1fr}
}

/* ══ interaction language ══ */
.work-hero{position:relative;background:#0d0d0d;overflow:hidden;margin-bottom:64px;
  min-height:min(74vh,660px);display:flex;align-items:center;justify-content:center}
.work-hero .wh-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;opacity:.62}
.work-hero canvas.glyphs{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}
.work-hero .wh-mark{position:relative;z-index:2;font-size:clamp(56px,13vw,168px);font-weight:700;
  line-height:.82;letter-spacing:-.045em;color:var(--tech);margin:0;text-align:center;width:100%;
  mix-blend-mode:screen;pointer-events:none}
.work-hero .wh-mark sup{font-size:.15em;vertical-align:super;letter-spacing:0}
.work-hero .wh-line{position:absolute;left:0;bottom:0;z-index:2;color:#FDFEFF;font-size:15px;
  line-height:1.35;max-width:34ch;padding:0 24px 32px;
  margin:0 auto;width:100%;max-width:1200px;left:50%;transform:translateX(-50%)}

/* pixel reveal */
/* Content is never hidden by base CSS pending JavaScript. The armed state is
   added by site.js only once it has an observer running, so a lazy image that
   never trips the observer, or a JS failure, still shows the picture. */
.reveal{transition:clip-path .9s var(--ease),filter .9s ease}
.reveal.armed{clip-path:inset(0 100% 0 0);filter:contrast(1.4) saturate(.6)}
.reveal.armed.is-in{clip-path:inset(0 0 0 0);filter:none}
@media(prefers-reduced-motion:reduce){
  .reveal,.reveal.armed{clip-path:none;filter:none;transition:none}
  .work-hero canvas.glyphs{display:none}
}

/* ── lead capture ──────────────────────────────────────────────────────────
   Restored: these rules were lost when style.css was rewritten to match Dave's
   tokens, which left every <label class="sr-only"> rendering visibly next to
   its own placeholder on every form on the site. */
.sr-only{position:absolute!important;width:1px;height:1px;padding:0;margin:-1px;
  overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}
.lead-form{display:flex;flex-wrap:wrap;gap:12px;align-items:stretch;max-width:640px}
/* min-width:0 is not the reflow fix, and it was reverted once for pretending to
   be: with the up-next track corrected the page does not scroll sideways
   whether this is here or not. It fixes the thing next door. A flex item keeps
   min-width:auto, which floors it at the control's own intrinsic width, 222px
   here. Inside the up-next card at a 320px viewport the flex line is 174px, so
   the field ran from x=73 to x=295 and finished 1px inside the card's border,
   having eaten all 48px of its right padding while every other element in the
   card respected it. flex:1 1 240px still governs wherever there is room, so
   nothing moves above 320. */
.lead-form input{flex:1 1 240px;min-width:0;min-height:56px;padding:16px 18px;
  border:1px solid var(--grey);border-radius:0;background:var(--bg);color:var(--secondary);
  font-family:var(--sans);font-size:16px;font-weight:350}
.lead-form input:focus{outline:2px solid var(--tech);outline-offset:-2px;border-color:var(--secondary)}
.lead-form input::placeholder{color:var(--static)}
.lead-form .btn{min-height:56px;flex:0 0 auto;cursor:pointer;justify-content:center}
.lead-note{flex:1 0 100%;margin:4px 0 0;font-family:var(--mono);font-size:var(--eyebrow);
  letter-spacing:var(--eyebrow-ls);color:var(--static);min-height:16px}
.lead-note.err{color:#C2352E}
.lead-sub{margin:0 0 24px;color:var(--static);font-size:17px}
@media(max-width:640px){.lead-form .btn{width:100%}}

/* ── nav current-page state ── */
/* Every item carries the padding, not just the current one, so the filled box
   fills in place instead of widening that item and nudging the row. */
nav.site a{padding:6px}
/* nav.site a (0,1,2) outranks .nav-cta (0,1,0), so the CTA's own padding
   never applied and it sat 17px narrower than the app's. */
nav.site a.nav-cta{padding:6px 14.5px}
nav.site a.is-current{background:var(--secondary);color:var(--bg)}
nav.site a.is-current::before{background:var(--bg)}
.dark nav.site a.is-current{background:var(--bg);color:var(--secondary)}
.dark nav.site a.is-current::before{background:var(--secondary)}


/* ── header extras, matching the homepage ── */
.nav-cta{background:var(--tech);color:#121010;text-decoration:none;
  display:inline-flex;align-items:center;gap:4px;padding:6px 12px;line-height:100%;
  font-family:var(--sans);font-size:var(--nav-size);font-weight:var(--nav-w);
  letter-spacing:var(--nav-ls);border:none;border-radius:0;cursor:pointer;
  transition:background .2s ease,color .2s ease}
/* nav.site a sets color:var(--secondary) at (0,1,1), which outranks .nav-cta's
   own (0,1,0) colour above. In light mode --secondary is #121010, the same ink
   the button already wanted, so nothing looked wrong. In dark it becomes
   #FDFEFF: white on the mint chip, 1.24:1, on every static page's header. The
   accent never flips with the theme, so its ink must not either. */
nav.site a.nav-cta{color:#121010}
.nav-cta::before{content:"";width:3px;height:3px;background:currentColor;flex-shrink:0}
.nav-cta:hover{background:#121010;color:#B6F4C9}
.dark .nav-cta:hover{background:#FDFEFF;color:#121010}
.theme-toggle{width:29px;height:16px;border-radius:999px;border:1px solid var(--secondary);
  background:var(--bg);position:relative;cursor:pointer;flex-shrink:0;padding:0}
.theme-toggle span{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;
  background:var(--secondary);transition:transform .25s var(--ease)}
.dark .theme-toggle span{transform:translateX(18px)}
@media(max-width:768px){
  header.site .wrap,footer.site .wrap{padding:0 16px}
  /* The row grows here, so --header-row has to grow with it: scroll-padding-top
     is derived from it and would otherwise reserve 99px under a 129px header,
     which is a 30px shortfall that looks exactly like no fix at all. */
  :root{--header-row:64px}
  header.site .wrap{min-height:var(--header-row)}
  /* nav.site a sets display:flex at (0,1,1), which outranks a bare .nav-cta at
     (0,1,0) whatever the source order, so this never hid anything. The CTA
     stayed in the nav at every width: the header measured 520px inside a 390px
     viewport and every static page scrolled sideways by 330px on a phone. Same
     specificity trap as the colour override above, same fix. */
  nav.site a.nav-cta{display:none}
  /* Even with the CTA gone the header does not fit a phone: the wordmark is
     158px and the links plus toggle another 403px inside a 390px viewport.
     Dave's own app header already answers this at exactly this breakpoint,
     dropping the wordmark and running the nav right-aligned at gap 12, so the
     static pages take the same shape rather than a new one. Keeping the mark
     would mean shrinking the nav type, and the type scale is not ours to
     change. All three links stay reachable, which the app header does not do. */
  /* Superseded: this used to drop the wordmark and cram all three links plus
     the toggle into 390px. The app header solves the same problem with a
     burger, and having two different mobile headers on one site is the actual
     bug. Static pages now take the app's shape: mark left, toggle and burger
     right, links in a panel. */
  header.site .wrap{justify-content:space-between}
  header.site .brand{display:flex}
  .hdr-right{display:flex;align-items:center;gap:16px}
  /* header.site .burger beats the base .burger{display:none} below, which
     would otherwise win on source order and leave a 0x0 burger. */
  header.site .burger{display:flex;flex-direction:column;justify-content:center;gap:6px;
    width:24px;height:16px;padding:0;background:none;border:none;cursor:pointer}
  .burger span{display:block;width:24px;height:2px;background:var(--secondary);
    transition:transform .3s ease,opacity .2s ease}
  .burger[aria-expanded="true"] span:first-child{transform:translateY(4px) rotate(45deg)}
  .burger[aria-expanded="true"] span:last-child{transform:translateY(-4px) rotate(-45deg)}

  /* The links leave the bar and become a panel under it. */
  nav.site{position:fixed;left:0;right:0;top:var(--header-row,64px);
    flex-direction:column;align-items:flex-start;gap:0;
    background:var(--bg);border-bottom:1px solid var(--grey);
    padding:8px var(--s42) 24px;
    transform:translateY(-8px);opacity:0;pointer-events:none;
    transition:opacity .22s ease,transform .22s ease;z-index:99}
  nav.site.open{transform:translateY(0);opacity:1;pointer-events:auto}
  nav.site a{padding:14px 0;font-size:22px;width:100%}
  nav.site a.nav-cta{margin-top:12px;padding:14px 18px;width:auto;font-size:18px}
}

/* The burger is mobile-only; the desktop header keeps its inline links. */
.burger{display:none}
.hdr-right{display:flex;align-items:center;gap:16px}

/* ── filter chips ──────────────────────────────────────────────────────────
   These lived only in blog.css, which /work does not load, so the work page
   filters rendered as unstyled native buttons. Shared stylesheet now owns them. */
.chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}
.chip{font-family:var(--mono);font-size:11px;letter-spacing:var(--eyebrow-ls);
  text-transform:uppercase;padding:9px 16px;border:1px solid var(--grey);border-radius:100px;
  background:transparent;color:var(--secondary);cursor:pointer;
  transition:background .2s ease,color .2s ease,border-color .2s ease}
.chip:hover{border-color:var(--secondary)}
.chip.is-on{background:var(--secondary);color:var(--bg);border-color:var(--secondary)}

/* ── touch target sizing ───────────────────────────────────────────────────
   Nav links, chips and footer links are visually small by design. On touch
   devices expand the hit area with a pseudo-element so the visual box is
   untouched but the target clears the 44px minimum. */
@media (pointer: coarse) {
  nav.site a, footer.site a, .chip, .nav-cta, .theme-toggle,
  .art-next a, .post-meta a { position: relative; }
  nav.site a::after, footer.site a::after, .chip::after, .nav-cta::after,
  .theme-toggle::after, .art-next a::after {
    content: ""; position: absolute; top: 50%; left: 50%;
    transform: translate(-50%, -50%);
    width: 100%; height: 100%; min-width: 44px; min-height: 44px;
  }
  /* the dot pseudo-element on nav links must not be swallowed by the hit area */
  nav.site a::before { z-index: 1; }
}


/* ── no orphans ──
   Declan's standing rule. A lone word on the last line of a heading, a stat
   label or a card title reads as unfinished layout, so short display strings
   wrap as a unit rather than dropping one word. */
h1, h2, h3, .standfirst, .stat-l, .pj-sec .eyebrow, .tile h3, article table td:first-child{
  text-wrap: balance;
}

/* ── headings that hold a client's name ──
   A last resort, not a style: overflow-wrap:break-word does nothing at all
   unless a single word cannot fit its line, so it cannot change any heading
   that already fits. It is here because --h1 is a flat 48px with no clamp,
   and at a 320px viewport the content box is 272px: "Constantinople's" needs
   352px and "Crystalbrook's" 327px, so those two case studies, and only those
   two, scrolled sideways. WCAG 2.2 SC 1.4.10 Reflow.

   The alternative was clamping --h1, and the type scale is Dave's rather than
   ours. This breaks the word instead of shrinking every heading on the site.
   Client names are the risk here and every one of them lands in a heading, so
   the rule sits on the headings rather than on body copy. MODE=reflow gates it. */
h1, h2, h3{ overflow-wrap: break-word; }

/* ── breadcrumb ── */
.crumb{display:flex;align-items:center;gap:8px;margin:0 0 18px;
  font-family:var(--mono);font-size:var(--eyebrow);letter-spacing:var(--eyebrow-ls);
  text-transform:uppercase;color:var(--static)}
.crumb a{color:var(--static);text-decoration:none}
.crumb a:hover{color:var(--secondary)}
/* --grey is a hairline token (#DEDEDE light, #2A2A2A dark). It suits the "/"
   separator, which is decoration, but it was also colouring the current page
   name, which is the one word in the breadcrumb that carries meaning: 1.21:1 on
   light and 1.67:1 on dark, on every work page. The name takes the muted text
   token instead. Listed for both themes so it outranks .dark .crumb span. */
.crumb span{color:var(--grey)}
.dark .crumb span{color:#3A3A3A}
.crumb [aria-current="page"],
.dark .crumb [aria-current="page"]{color:var(--static)}
/* The page name is the only variable-length item here, and a full article title
   wrapped the breadcrumb onto a second line at 375px. It is allowed to shrink and
   truncate visually; the complete title stays in the DOM, so assistive tech still
   reads the whole thing. Home and Blog never shrink, so the trail stays readable.
   Beats .crumb>* on specificity (0,2,0 over 0,1,0), which is why the flex
   shorthand is repeated here rather than left to the shrink default. */
.crumb>*{flex:none}
.crumb [aria-current="page"]{flex:0 1 auto;min-width:0;
  overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* A work page centres its breadcrumb through <main class="wrap">, so .crumb is
   bare there and this never fires. A blog page has no wrap on <main>, so the
   breadcrumb carries .wrap itself, and there the two rules collide: .wrap sets
   margin:0 auto, .crumb above sets margin:0 0 18px, both single-class, and
   .crumb is 400 lines later in the cascade. The max-width survived and the
   centring did not, so every one of the 16 blog pages hung its trail off the
   left edge of the window, 96px clear of the H1 underneath it. Two classes
   outrank one. Sides only, so the 18px below the trail stays. */
.crumb.wrap{margin-left:auto;margin-right:auto}


/* ── case study gallery ──
   The project section was sitting inside the 760px prose measure, so the gallery
   column resolved to 316px and the work showed as thumbnails. It breaks out to
   the full width of the page instead, which is what the images deserve. */
.project{max-width:1520px;width:min(1520px,calc(100vw - 48px));
  margin-left:50%;transform:translateX(-50%);padding:0;box-sizing:border-box}
/* The breakout used to stand down under 1560px, which is every laptop in the
   building, so the gallery fell back to the 760px prose measure and the work
   rendered at 151px wide. calc() keeps it clear of the scrollbar instead. */
@media(max-width:999px){ .project{width:auto;margin-left:0;transform:none} }

/* Stacking every image in one column made the gallery 1346px against a 799px
   rail, which is 548px of dead space beside it: the hole in the middle of the
   Procore page. Two columns brings the two sides to roughly the same height and
   lets each image be larger than the old paired thumbnails. */
/* Two even columns were a fix for dead space measured in a static full-page
   capture, where a sticky rail cannot stick. It cost the images half their
   width for a gap that does not exist while scrolling. One column, full width,
   and g-pair stays a deliberate pair rather than the default. */

/* ── lightbox ── */
.lb-view{position:fixed;inset:0;z-index:300;background:rgba(18,16,16,.94);
  display:flex;align-items:center;justify-content:center;padding:32px;
  opacity:0;pointer-events:none;transition:opacity .25s ease;cursor:zoom-out}
.lb-view.open{opacity:1;pointer-events:auto}
.lb-view img{max-width:100%;max-height:100%;width:auto;height:auto;
  display:block;border-radius:2px}
.lb-view .lb-close{position:absolute;top:20px;right:24px;background:none;border:0;
  color:#F5F3EE;font-family:var(--mono);font-size:12px;letter-spacing:.12em;
  text-transform:uppercase;cursor:pointer;padding:8px}
@media(prefers-reduced-motion:reduce){ .lb-view{transition:none} }

/* ── forced colours: Windows High Contrast Mode ─────────────────────────────
   A third rendering of this site, on top of light and dark, and the first
   sweep of it on 20 Sep 2026 found two defects. What the mode does is the
   reason for both: every colour the CSS sets is REPLACED by one the reader
   chose, while `<img>` pixels and `filter` are left alone. So any rule that
   recolours artwork to suit a palette is now aimed at a palette that is not on
   screen.

   1. THE WORDMARK INVERTED AGAINST A GROUND IT NO LONGER KNEW.
   `--logo-filter:invert(1)` is right on #121010 and catastrophic on a white
   Canvas: measured at 1.15:1, near-white ink on white, so our own mark was
   invisible. The footer's LinkedIn glyph, which takes the same token, at
   2.17:1.

   The combination is not contrived, which is the part worth keeping: THE
   THEME HERE IS THE CLOCK, NOT THE READER. `getDefaultDark()` returns true
   between 18:00 and 06:00 with no stored preference, so someone running High
   Contrast White who arrives in the evening is put into `.dark` without ever
   asking for it, and this filter is applied over their white ground. A stored
   preference from an earlier visit does the same at any hour.

   So under forced colours the filter follows THE READER'S PALETTE and stops
   following the site's theme, which is what `prefers-color-scheme` reports
   once High Contrast is on. `.dark` is named in both blocks because it is the
   selector that sets the token, and equal specificity means the later rule
   wins.

   Note this is the inverse of the usual advice about `forced-color-adjust`.
   Nothing here is being opted OUT of the reader's palette: the filter is being
   made to agree with it. */
@media (forced-colors: active) {
  :root, .dark { --logo-filter: none; }
}
@media (forced-colors: active) and (prefers-color-scheme: dark) {
  :root, .dark { --logo-filter: invert(1); }
}

/* 2. THE PRIMARY BUTTON LOST ITS ONLY BOUNDARY.
   `.btn` is `background: var(--tech); border: none`, so the fill was the whole
   affordance. Under forced colours the fill becomes Canvas, which is exactly
   the ground it sits on, and "Get in touch" rendered as a run of plain text in
   the middle of the page on every route that carries it.

   `outline` rather than `border`, and `-1px` offset, so the box is not one
   pixel wider than it is in every other rendering: this has to be invisible to
   the reflow and tap gates, which measure geometry. `ButtonText` rather than a
   colour of ours, because a hex here would simply be replaced again, and
   because the whole point is to use the palette the reader picked. Corners
   stay square, per Dave.

   `:focus-visible` sets its own outline and therefore replaces this one, which
   is correct: a focus ring should be distinguishable from a resting edge. */
@media (forced-colors: active) {
  .btn, .cta-btn { outline: 1px solid ButtonText; outline-offset: -1px; }
}


/* ── print: the fourth rendering ────────────────────────────────────────────
   Light, dark and forced colours are three renderings of this site and each
   one took a sweep to find something nobody had seen. This is the fourth, and
   it is the one a client keeps: a case study saved to PDF and sent on, a blog
   post printed to read away from a screen. Measured with `MODE=print`, and for
   the pixels by printing the real thing through `Page.printToPDF` and
   rasterising the sheet, because those are two different questions and the
   second is what catches a probe modelling the wrong paper.

   THREE THINGS CHANGE ON THE WAY TO PAPER, and every rule below is one of
   them. The third is at the top of the block rather than here, because it is
   the one that had to be measured last and it is the one that made the other
   two look small.

   1. THE GROUND IS NOT PAINTED. Chrome's Print dialog ships with background
   graphics OFF and the protocol default matches it, so a box whose contrast
   came from its fill hands its text to white paper. Nothing in the cascade
   says so, which is why no stylesheet reads as wrong and why this sat under
   thirty runs of green gates. Measured on /work/afterpay: the up-next project
   name 19.25:1 on #0d0d0d and 1.01:1 on paper, its label 6.91 and 2.81, the
   current-page nav chip 17.1 and 1.11.

   ON THE SHEET IT IS 2.32:1, NOT 1.01:1, and the gap is the part worth
   keeping. Chrome darkens light text a little when it drops the ground beneath
   it: #FDFEFF lands as rgb(170,170,171), which is why the printed up-next name
   reads as pale grey rather than as nothing at all. Both numbers are honest
   and they answer different questions. The probe reads the cascade, which is
   what a stylesheet can change; the raster reads the sheet, which is what a
   reader gets. Neither is near 4.5, so the verdict is the same either way, and
   nobody should "fix" the probe to agree with the PDF.

   2. FIXED IS PER PAGE BOX, not per viewport. A `position: fixed` element in a
   paged medium is laid out against the page and paints ONCE PER SHEET. The
   skip link is parked off screen with `transform: translateY(-140%)`, which is
   off the top of a 950px viewport and NOT off the top of a 1123px sheet: it
   printed SKIP TO CONTENT on all twelve sheets of /work/afterpay, over body
   copy on three of them and over the LET'S CHAT heading on a fourth. A
   transform is not a way to hide something from a printer, and neither is the
   lightbox's `opacity: 0`.

   WHICH FILLS COME BACK, because the two halves below are a rule and not a
   preference. Background graphics off is a DELIBERATE INK CHOICE by the
   reader, and `print-color-adjust: exact` overrides it, so it is spent only
   where the alternative is losing content:

   - THE FILL STANDS IN FOR A PHOTOGRAPH, or carries a state nothing else
     carries. It is content, content prints, and it opts back in. The
     current-page nav chip is the only one on this side; the work cards with no
     artwork are the same case and live in index.html.
   - THE FILL IS DECORATION BEHIND TEXT. The reader asked not to spend ink on
     it and that must not cost them the words, so the text becomes ink and the
     panel keeps a hairline. The up-next card and the closing panel are both
     this, and both are UI rather than artwork: a solid black rectangle on
     sheet eleven of twelve tells a reader nothing they cannot see from the
     type.

   The ink is `--secondary` and the hairline `--static`, so this follows the
   tokens the page already sets rather than introducing a print palette. The
   hairline is there because THE FILL WAS THE BOUNDARY: dropping it without
   replacing it leaves a card that is no longer a card, which is the same
   argument the forced-colours block above makes about `.btn`. The flat buttons
   take the same treatment here, by the same hook list, so the next flat button
   added is named by two gates rather than one.

   `outline` rather than `border`, and `-1px` offset, so the box is not a pixel
   wider than it is on screen. Print geometry is not swept, but a rule that
   changes layout to add an edge is one that will be copied into a medium that
   is.

   WHAT IS DELIBERATELY NOT HERE. Pagination: whether a heading is orphaned,
   whether the gallery is worth six sheets, whether a contact form belongs on
   paper. Those are design questions, the answers are Dave's, and the gate does
   not grade them either. */
@media print {
  /* PAPER IS WHITE, SO PAPER IS THE LIGHT RENDERING. This is the third thing
     that changes on the way to paper and it is the one that made the other two
     look small. `getDefaultDark()` turns the site dark between 18:00 and 06:00
     with no stored preference, so an evening reader prints a page whose ink is
     #FDFEFF over a #121010 ground that the printer then drops. Measured with
     MODE=print THEME=dark before this rule existed: the /privacy h1 1.01:1 on
     paper, "Shared outcomes" on /work/afterpay 1.01:1, body copy 3.74:1.

     ON THE SHEET IT IS NOT BLANK, IT IS WORSE THAN THAT, and the difference is
     the probe-versus-raster gap this file warns about one paragraph up. Chrome
     darkens the dropped-ground ink, so #F5F3EE lands as rgb(170,170,171), and
     what prints is legible-looking grey. But `--static` in dark is #848484,
     which is already mid and is NOT darkened, so it prints at 13.20:1 while
     the h1 above it prints at 2.32:1. THE HIERARCHY INVERTS: the heading comes
     out five times fainter than the sentence underneath it, on a sheet that
     looks deliberate. Measured on /privacy, sheet one, and the whole page went
     from 466 ink pixels to 4657 once the palette was restored.

     Restated from `--lt-*` rather than from literals so the two copies cannot
     drift; see the note beside them. */
  :root, .dark {
    --primary: var(--lt-primary); --secondary: var(--lt-secondary);
    --static:  var(--lt-static);  --grey:      var(--lt-grey);
    --bg:      var(--lt-bg);      --logo-filter: none;
  }

  /* Screen-only affordances. Each one paints on every sheet otherwise. */
  .skip-link, .lb-view, [data-theme-toggle] { display: none !important; }

  /* Decoration behind text: the words become ink, the panel keeps an edge. */
  .upnext, .closing {
    background: transparent; color: var(--secondary);
    outline: 1px solid var(--static); outline-offset: -1px;
  }
  .upnext-lab, .closing p, .closing-lead { color: var(--secondary); }

  /* State nothing else carries: the fill comes back. Written as a property
     rather than as a colour so that blog.css, which sets this chip again at
     the same specificity and later in source order, cannot quietly win. */
  nav.site a.is-current {
    -webkit-print-color-adjust: exact; print-color-adjust: exact;
  }

  /* Flat buttons, whose fill was their only boundary. Same hook list the
     forced-colours block uses, on purpose. */
  .btn, .cta-btn, .btn-flat, .nav-cta {
    outline: 1px solid var(--secondary); outline-offset: -1px;
  }
}

/* ── touch targets ──
   Audited on an emulated iPhone 14 Pro: the menu glyph is drawn 24x10, the
   theme switch 29x19, the lightbox close 28x19 and the footer links 28x26.
   All well under the ~44px a thumb needs. A centred pseudo-element expands
   the hit area without moving a single pixel of the design. */
@media (pointer: coarse) {
  button[aria-label="Open menu"], button[aria-label="Close menu"],
  [data-theme-toggle], .theme-toggle, button[aria-label*="Toggle"],
  button[aria-label*="Close"], .f-social, footer.site a, nav.site a, .crumb a {
    position: relative;
  }
  button[aria-label="Open menu"]::after, button[aria-label="Close menu"]::after,
  [data-theme-toggle]::after, .theme-toggle::after, button[aria-label*="Toggle"]::after,
  button[aria-label*="Close"]::after, .f-social::after {
    content: ""; position: absolute; left: 50%; top: 50%;
    width: 44px; height: 44px; transform: translate(-50%, -50%);
  }
  footer.site a::after, nav.site a::after, .crumb a::after {
    content: ""; position: absolute; left: 0; right: 0; top: 50%;
    height: 44px; transform: translateY(-50%);
  }
}
