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

/* ══════════════════════════════════════════════════════════
   Abdou Yaackoubi — portfolio
   Every colour, size and space comes from assets/tokens.css,
   which build.py generates from tokens.json. Nothing is
   hardcoded here except structural values (borders, ratios).
   Mobile first; breakpoints widen the layout.
   ══════════════════════════════════════════════════════════ */

*, *::before, *::after { box-sizing: border-box; }
/* The palette is dark-only, so say so. Without this the browser still assumes
   a light page and paints the UI it controls for white: scrollbar tracks,
   text-selection, spinners and form controls. There is no light theme to
   switch to — this declares the one that exists. */
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; color-scheme: dark; }

/* Anchor targets must clear the sticky header. Every TOC link and the skip
   link land on an id, and .site-head is position:sticky at 5.5rem, so
   without this the heading you just jumped to sits behind it — measured 48px
   from the top against a 76px header. */
:target,
.project__section,
#main {
  scroll-margin-top: calc(var(--header-height) + var(--space-4)
                          + var(--inset-top));
}

body {
  margin: 0;
  /* clip, NOT hidden. The hook's cursor glides in from beyond the right
     edge and must not open a scrollbar. overflow:hidden on <body> makes
     it a scroll container, which takes position:sticky away from the
     header; clip crops without creating one. */
  overflow-x: clip;
  background: var(--color-bg);
  color: var(--color-text);
  font-family: var(--font-sans);
  font-size: var(--font-size-base);
  line-height: var(--line-height-normal);
  -webkit-font-smoothing: antialiased;
}

img { display: block; max-width: 100%; height: auto; }
a { color: inherit; }

:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 3px;
  border-radius: var(--radius-sm);
}

/* <main> carries tabindex="-1" so the skip link genuinely moves focus —
   Chrome and Firefox set the sequential-focus point without it, Safari and
   several screen readers do not. It is not a control, though, and ringing
   the entire page content reads as a rendering bug, so the ring is
   suppressed here only. The visitor's confirmation is that the next Tab
   lands inside the content rather than back in the header. */
#main:focus, #main:focus-visible { outline: none; }

.skip-link {
  position: absolute; left: var(--space-4); top: -4rem;
  z-index: 50; padding: var(--space-3) var(--space-4);
  background: var(--color-accent); color: var(--color-accent-text);
  border-radius: var(--radius-sm); text-decoration: none;
  transition: top .2s;
}
.skip-link:focus { top: var(--space-4); }

/* ── device safe areas ───────────────────────────────────
   Reported on a Dynamic Island iPhone: the page scrolled under the status
   bar while the sticky header anchored below it, so content stayed visible
   in the strip above the header. Measured off the screenshot -- 692px wide
   at a 2.168 ratio is a 393pt iPhone Pro, and the header's top edge sat at
   59.6 CSS px, which is exactly that device's safe-area inset.

   The site had no safe-area handling at all and no viewport-fit, so it got
   whatever Safari decided. viewport-fit=cover in the meta takes that back:
   the page owns the whole screen, sticky top:0 means the real top, and the
   header's own padding holds its contents clear of the status bar while
   its blurred background covers the strip.

   Naming them as properties rather than repeating env() five times is what
   makes this testable -- override these four on :root and every dependent
   calc moves, which is how it was verified without an iPhone to hand.

   Everywhere else -- desktop, Android, any browser that reports no insets
   -- they resolve to the 0px fallback and every calc below reduces to
   exactly what it was. */
:root {
  --inset-top: env(safe-area-inset-top, 0px);
  --inset-right: env(safe-area-inset-right, 0px);
  --inset-bottom: env(safe-area-inset-bottom, 0px);
  --inset-left: env(safe-area-inset-left, 0px);
}

/* ── container tints ──────────────────────────────────────────────────────
   Measured at their borders, every screenshot in this portfolio is COOLER
   than the paper it sits on. Warmth here is R-B: paper is +12, and the
   covers run from 0 (Fissa3, pure white) through -16 (InstaDeep) and -24
   (Groupado) to -143 (Fixerloop's saturated blue). Not one is warm. That is
   why an untinted image reads as pasted on rather than placed -- it is a
   systematic mismatch, not a bad screenshot here and there.

   The screenshots cannot be recoloured; they are product UI and repainting
   them would misrepresent the work. So the container does the reconciling.
   Each project names a tint close to its own image's hue, the image floats
   INSET inside that tint, and the eye crosses two short steps -- paper to
   tint, tint to image -- instead of one long one.

   The tint is NOT the matching hero-card colour, and that was the first
   attempt. The cool tint sat as far from paper as the cover it was meant
   to reconcile, so the frame still read as a cool panel dropped on warm
   paper.

   What actually governs this is direction, not distance. A tone that is
   FARTHER from paper but warmer reads as the same paper under more ink;
   the identical distance travelled cool reads as a different material. So
   every media tint keeps warmth (R-B) above zero and simply leans:

       mediaSand  #EDE9E1  warmth +12   luma 233.3
       mediaClay  #EEE8E8  warmth  +6   luma 233.8
       mediaAsh   #EAE9E7  warmth  +3   luma 233.1   (paper: +1, luma 250.9)

   The family was sand/teal/sky until the accent moved from teal to ember.
   A cool tint under a warm accent is precisely the failure described
   above, so teal became clay and sky became ash: the same three slots at
   the same luma, with the hues re-solved into the warm half of the wheel.

   Luma is held flat across the family on purpose -- 0.7 of spread -- so
   hue is the only thing that changes between two projects and the work
   page does not visibly brighten and dim as you scroll it.

   The hero cards keep the saturated card* tokens: those are meant to read
   as distinct objects, which is the opposite of what a frame has to do. */
[data-tint="sand"] { --tint: var(--color-media-sand); }
[data-tint="clay"] { --tint: var(--color-media-clay); }
[data-tint="ash"]  { --tint: var(--color-media-ash);  }

/* The inline insets are not optional once viewport-fit=cover is set: in
   landscape the notch eats into the content column, and .shell is the one
   place that column's edges are defined. */
.shell {
  width: 100%;
  max-width: var(--max-width);
  margin-inline: auto;
  padding-inline: calc(var(--gutter) + var(--inset-left))
                  calc(var(--gutter) + var(--inset-right));
}
.prose { max-width: var(--prose-width); }

/* ── header / footer ─────────────────────────────── */
.site-head {
  position: sticky; top: 0; z-index: 20;
  /* Padding, not margin: the blurred background has to extend up behind
     the status bar while the name and nav stay below it. */
  padding-top: var(--inset-top);
  background: color-mix(in srgb, var(--color-bg) 88%, transparent);
  backdrop-filter: blur(10px);
}
.site-head__inner {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--space-4);
  padding-block: var(--space-4);
}
.site-head__name {
  font-weight: var(--font-weight-bold);
  text-decoration: none;
  letter-spacing: -0.01em;
}
.site-head nav { display: flex; gap: var(--space-6); }
/* The nav was 15px text at 6.47:1 against the page -- legible, and past
   WCAG AA -- so "make it brighter" was never the fix. Two real problems:

   Nothing marked the current page. `aria-current` appeared nowhere on the
   site, so a visitor on /about saw a nav identical to the homepage's; the
   only is-current rule in the stylesheet was the project-page TOC. It is
   marked with the accent, the colour this site already uses for "here".

   And the hit area was the text box -- 24px tall with zero padding, the
   bare WCAG 2.5.8 minimum and well under the 44px platform norm. The
   padding grows the target to 48px and the negative margin cancels it in
   layout, so the header stays 76px and nothing moves. */
.site-head nav a {
  color: var(--color-text-muted);
  text-decoration: none;
  font-size: var(--font-size-sm);
  padding: var(--space-2) var(--space-4);
  /* The negative margin is GONE, and deliberately. It existed to cancel
     the padding in layout so the flat header bar stayed 76px tall while
     the hit area grew to 48. The links sit inside a pill now, and a
     negative margin there pulls each link into its neighbour and under
     the pill's own padding -- the target the margin was protecting ends
     up overlapping the thing drawn around it. The pill supplies its own
     spacing, so the padding can just be padding. */
  border-radius: var(--radius-full);
  transition: color .25s var(--ease-out), background-color .25s var(--ease-out);
}
.site-head nav a:hover { color: var(--color-text); }
/* A filled pill rather than a colour change: the current page reads at a
   glance instead of on comparison with its neighbour. */
.site-head nav a[aria-current="page"] {
  background: var(--color-accent-soft);
  /* accent-BRIGHT, not accent. Both sit on the accent-soft wash, and on
     the dark theme the plain accent measures 4.44:1 against it -- under
     AA, and it passed 227 tests without anyone noticing, because the
     contrast sweep never pairs the accent with its own wash. The bright
     variant is 6.24 on dark and 7.47 on light. See the test. */
  color: var(--color-accent-bright);
  font-weight: var(--font-weight-medium);
}

/* ── contextual cursor label ──────────────────────────────────────────────
   What replaced the trail, and deliberately not the same kind of thing.

   The trail was always on, drew under the pointer wherever it went, and at
   6% of the ink under a 9px blur it left grey smudges across the hero. This
   renders NOTHING at rest. It appears only over an element whose click is
   not self-evident -- a screenshot, a row in the work rail -- and says what
   the click does.

   Offset down-right of the pointer, never under it, so it cannot sit on top
   of the thing you are reading. The native cursor is untouched: this adds a
   label beside it rather than replacing it, so a failure here costs a hint,
   not the ability to see where you are pointing.

   Fixed and pointer-events:none, so it can never take a click or change what
   is underneath. */
.cursor-label {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 60;
  pointer-events: none;
  /* The pill is drawn down-right of the hotspot; translate3d carries the
     pointer position and this carries the offset, so the two never fight. */
  margin: 14px 0 0 16px;
  padding: var(--space-2) var(--space-4);
  border-radius: var(--radius-full);
  background: var(--color-bg-inverse);
  color: var(--color-text-inverse);
  font-family: var(--font-sans);
  font-size: var(--font-size-xs);
  font-weight: var(--font-weight-medium);
  letter-spacing: 0.01em;
  white-space: nowrap;
  /* Hidden state. visibility (not display) so the transform keeps updating
     while it is away and it does not fly in from the last place it was. */
  opacity: 0;
  visibility: hidden;
  transform-origin: 0 0;
  scale: 0.9;
  transition: opacity .18s var(--ease-out), scale .18s var(--ease-out),
              visibility 0s linear .18s;
}
.cursor-label.is-on {
  opacity: 1;
  visibility: visible;
  scale: 1;
  transition: opacity .18s var(--ease-out), scale .18s var(--ease-out),
              visibility 0s;
}
/* The label is information, so it still appears -- it just stops easing in.
   Following the pointer is the pointer's own movement, not an animation. */
@media (prefers-reduced-motion: reduce) {
  .cursor-label,
  .cursor-label.is-on { transition: none; scale: 1; }
}

/* ══════════════════════════════════════════════════════════
   SCROLL-DRIVEN MOTION
   ══════════════════════════════════════════════════════════
   MOTION_INTENSITY 8, on the work rather than on the chrome. Every block
   below answers §5's question -- what does this animation communicate --
   in one line, or it would not be here.

   Native `animation-timeline`, not JavaScript. Scroll handlers were the
   reason scroll effects used to cost frames; this runs off the main thread
   and nothing has to be cleaned up. It is also why there is no library.

   Two guards wrap all of it, and both matter:

     @supports (animation-timeline: view())
         A browser without it must not be left holding an element at
         opacity 0 forever. Outside the guard every element is in its
         FINAL state, so an unsupporting browser simply shows the page.
     prefers-reduced-motion: no-preference
         Scroll-linked movement is movement the visitor did not ask for.

   The order is deliberate: the rule sets the resting state, and only then
   does the guarded block animate toward it.
   ══════════════════════════════════════════════════════════ */

/* The spine, drawn segment by segment.
   Communicates: chronology. The rail IS a timeline, and a line that builds
   as you descend says so better than a column of loose dots. Per entry
   rather than one long line, because view() on a 5500px element has no
   useful range -- a segment is one row tall and reads naturally. */
@media (min-width: 900px) {
  .entry { position: relative; }
  .entry::before {
    content: "";
    position: absolute;
    /* the dot is 12px wide at the column's left edge, so its centre is 6;
       the line is 1px, so 5 puts it dead centre under the dot */
    left: 5px;
    top: 0;
    bottom: 0;
    width: 1px;
    background: var(--color-border-strong);
    transform-origin: top center;
  }
}

/* Covers arrive rather than being there already.
   Communicates: sequence. Five projects down one column read as a list;
   arriving one at a time reads as a walk through the work. */
@keyframes rail-media-in {
  from { opacity: 0; transform: translateY(20px) scale(0.985); }
}
/* Case-study screens, same reason, tighter range: there are up to nine in
   a section and a long range would leave the last ones still arriving. */
@keyframes shot-in {
  from { opacity: 0; transform: translateY(14px); }
}
@keyframes spine-draw {
  from { transform: scaleY(0); }
}

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    @media (min-width: 900px) {
      .entry::before {
        animation: spine-draw linear both;
        animation-timeline: view();
        /* drawn while the row enters, finished before it is being read */
        animation-range: entry 0% entry 85%;
      }
    }
    .entry__media {
      animation: rail-media-in linear both;
      animation-timeline: view();
      animation-range: entry 5% entry 75%;
    }
    .shot {
      animation: shot-in linear both;
      animation-timeline: view();
      animation-range: entry 0% entry 55%;
    }
  }
}

/* The hero, on load rather than on scroll: there is nothing to scroll yet.
   Communicates: hierarchy. It walks the eye down in reading order instead
   of dropping five elements at once, which is the difference between a
   page that arrives and a page that is just suddenly there.

   `both` holds the end state, so nothing snaps back. The cascade is short
   on purpose: a hero a visitor has to WAIT through is a hero they resent. */
@keyframes hero-step-in {
  from { opacity: 0; transform: translateY(14px); }
}
/* THESE FOUR SELECTORS USED TO NAME NOTHING. .opening__status,
   __support, __actions and __facts were the old hero's status row,
   supporting paragraph, button row and four stat blocks -- every one
   of them deleted when the hero was cut back to a greeting, a headline
   and an ask. The keyframes and the four delays stayed behind, so the
   stylesheet described a hero cascade that animated zero elements and
   the hero arrived with no motion at all. Checked with a grep for each
   class against index.html: 0, 0, 0, 0.

   That is most of the reason the first impression read as flat. The
   fix is not new motion, it is pointing the motion that was already
   written at the elements that are actually there. A test below now
   fails if any name in this block leaves the markup again. */
/* :not(.hook-on):not(.hook-done) -- the first visit of a session plays
   the hook instead, and the hook owns this cascade from the moment the
   claim is built. Without the guard both would run: these would arrive
   on load, while the claim was still a set of outlines waiting to be
   filled, which is the one order the sequence cannot be in. .hook-done
   keeps them off afterwards, so removing .hook-on does not play the
   whole cascade a second time. */
@media (prefers-reduced-motion: no-preference) {
  html:not(.hook-on):not(.hook-done) .opening__portrait,
  html:not(.hook-on):not(.hook-done) .opening__greeting,
  html:not(.hook-on):not(.hook-done) .opening__meta,
  html:not(.hook-on):not(.hook-done) .ask,
  html:not(.hook-on):not(.hook-done) .opening__worked {
    animation: hero-step-in .55s var(--ease-out) both;
  }
  .opening__portrait { animation-delay: 0s; }
  .opening__greeting { animation-delay: .06s; }
  .opening__meta     { animation-delay: .34s; }
  .ask               { animation-delay: .42s; }
  .opening__worked   { animation-delay: .48s; }

}

/* ── cross-document view transitions ─────────────────────────────────────
   MOTION_INTENSITY 6, and motivated rather than decorative: it communicates
   a state transition. Clicking a case study currently blanks the page and
   repaints it, which is the one moment a static site feels like a stack of
   documents instead of a product. This crossfades the old page into the new
   one instead.

   Native and declarative. No JavaScript, no library, and nothing to clean
   up. Browsers without cross-document view transitions ignore the at-rule
   entirely and navigate exactly as they do today, so there is no fallback
   to write.

   The wordmark is given a transition name so it is treated as the SAME
   element across the navigation: it holds still while everything around it
   changes, which is what makes the two pages read as one site rather than
   two documents. Only one element gets this. Naming several is how view
   transitions start to feel like a slideshow.

   Durations are short on purpose. A page transition the visitor has to wait
   through is a page transition they will resent by the fourth click. */
@view-transition { navigation: auto; }

::view-transition-old(root) {
  animation: vt-out .18s var(--ease-out) both;
}
::view-transition-new(root) {
  animation: vt-in .26s var(--ease-out) both;
}
@keyframes vt-out { to   { opacity: 0; } }
@keyframes vt-in  { from { opacity: 0; transform: translateY(6px); } }

.site-head__name { view-transition-name: wordmark; }

/* WCAG 2.3.3: a transition driven by navigation is motion the visitor did
   not ask for. Under reduced motion the pages still swap, just instantly.
   `navigation: none` would also disable it, but keeping the transition and
   zeroing the animation leaves the named element working, which avoids a
   layout jump on the wordmark. */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(root),
  ::view-transition-new(root) { animation: none; }
}

/* ── theme toggle ─────────────────────────────────────────────────────────
   The page follows the system. This is for the visitor whose machine says
   dark but who wants to see the paper, which is most of what this site's
   identity is, and for the reverse.

   The mark is a circle whose right half fills, which is the shape every
   operating system uses for this. No icon library ships here, and one
   control is not worth hand-drawing SVG paths for. */
.theme-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* 44px is the platform touch minimum; the dial inside is 18. */
  width: 44px; height: 44px;
  padding: 0;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-full);
  background: color-mix(in srgb, var(--color-bg-raised) 72%, transparent);
  backdrop-filter: blur(10px);
  color: var(--color-text);
  cursor: pointer;
  flex: none;
  transition: border-color .25s var(--ease-out), background .25s var(--ease-out);
}
.theme-toggle:hover { border-color: var(--color-border-strong); }
.theme-toggle:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
}
.theme-toggle__dial {
  width: 18px; height: 18px;
  border-radius: var(--radius-full);
  border: 2px solid currentColor;
  /* The fill is an inset shadow rather than a second element: one box, and
     the half-moon is just a very large horizontal spread. */
  box-shadow: inset 9px 0 0 0 currentColor;
  transition: box-shadow .3s var(--ease-out), transform .3s var(--ease-out);
}
/* Pressed means "you have overridden the system", so the dial flips to show
   the other half. The state is on the button, not the html element, because
   the button is what the visitor is operating. */
.theme-toggle[aria-pressed="true"] .theme-toggle__dial {
  box-shadow: inset -9px 0 0 0 currentColor;
  transform: rotate(180deg);
}
@media (prefers-reduced-motion: reduce) {
  .theme-toggle, .theme-toggle__dial { transition: none; }
}

/* No margin. 128px here, on top of the closing band's own 48px of
   bottom padding, put 176px of empty page between the last thing anyone
   reads and the rule under it. The border is what separates the footer;
   it does not need a screen of air as well. */
.site-foot {
  margin-top: 0;
  border-top: 1px solid var(--color-border);
}
.site-foot__inner {
  display: flex; flex-wrap: wrap; align-items: center;
  justify-content: space-between; gap: var(--space-4);
  /* The bottom inset is the home indicator -- also not optional once the
     page covers the full screen. */
  padding-block: var(--space-8) calc(var(--space-8) + var(--inset-bottom));
}
.site-foot__note { margin: 0; color: var(--color-text-muted); font-size: var(--font-size-sm); }
.site-foot__links { display: flex; flex-wrap: wrap; gap: var(--space-6); }
/* min-width so the short ones are not fiddly. "CV" is two characters and
   came out 17x24, the smallest target on the site. It already CONFORMS to
   WCAG 2.5.8: the criterion exempts an undersized target whose 24px
   diameter circle does not reach another target, and tested against every
   other target's box on the page, this one's does not -- the nearest
   footer link is more than 48px away centre to centre. So this is not a
   conformance fix, it is just a kinder target for anyone with an unsteady
   pointer, at the cost of 3.5px of padding either side. */
.site-foot__links a {
  color: var(--color-text-muted);
  text-decoration: none;
  font-size: var(--font-size-sm);
  display: inline-block;
  min-width: 24px;
  text-align: center;
}
.site-foot__links a:hover { color: var(--color-text); }

/* ── hero ────────────────────────────────────────── */

/* ── bands + grids ───────────────────────────────── */
/* 48px, down from 64. At 64 the gap between two sections was 128px --
   a full phone screen of nothing between the end of one list and the
   title of the next, which is most of what "a lot of lost space" was
   pointing at. 96px still separates them; it just no longer takes a
   scroll to cross. */
.band { padding-block: var(--space-12); }
.band__head { margin-bottom: var(--space-12); }
.band__head h2 {
  font-family: var(--font-display);
  font-weight: var(--font-weight-regular);
  margin: 0;
  font-size: var(--font-size-2xl);
  line-height: var(--line-height-tight);
  letter-spacing: -0.02em;
}
.band__head p { margin: var(--space-2) 0 0; color: var(--color-text-muted); }

/* The card is a container, not a link -- see project_card in build.py for
   why. .card__title a carries the href and an ::after that covers the whole
   card, so the entire surface is still clickable while only the title names
   the destination. position: relative is what that overlay resolves
   against. */


/* Keyboard focus has to show on the card, not on the few words of the
   title: the overlay is what a pointer hits, so it is what an outline
   should describe. */


/* Covers ship untreated, on request. A grayscale-and-scrim treatment and
   then a blue duotone were both tried here and both removed -- see the two
   commits before this one for the measurements, if the covers ever need to
   be pulled back toward the page again. The bare numbers: they run 87x to
   587x the background's luminance, and the eight of them span 7.2x between
   lightest and darkest. */


/* Year, domain and role. Deliberately quieter than the tagline: it is
   reference information a visitor scans, not copy they read. */


/* ── buttons ─────────────────────────────────────── */
.btn-row { display: flex; flex-wrap: wrap; gap: var(--space-3); margin-top: var(--space-6); }
/* inline-flex, not inline-block: the buttons carry a mark beside the label
   now, and inline-block would sit the image on the text baseline with a
   descender gap under it rather than centred against the word. */
/* The first six declarations undo the browser, not the design.
   .btn dresses an <a> and a <button> as the same control, and only the
   <a> was ever getting dressed: a <button> with no reset keeps the UA's
   own chrome, so "Copy email" was shipping as a SYSTEM button -- Arial
   instead of Instrument Sans, a flat #6B6B6B fill from the dark
   color-scheme, appearance:auto and the arrow cursor -- sitting next to
   an ember pill that shared none of it.

   It is invisible in a screenshot of the light theme, where the system
   grey is close enough to a designed grey to pass, which is how it
   survived this long. `font: inherit` is spelled out property by
   property because the shorthand would reset the font-size set below
   it. */
.btn {
  appearance: none;
  -webkit-appearance: none;
  background: none;
  color: inherit;
  font-family: inherit;
  font-weight: inherit;
  letter-spacing: inherit;
  text-align: center;
  cursor: pointer;
  display: inline-flex; align-items: center; gap: var(--space-2);
  padding: var(--space-3) var(--space-6);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-full);
  font-size: var(--font-size-sm);
  text-decoration: none;
  transition: border-color .2s, background .2s, color .2s;
}
/* The mark is decorative -- every button already says what it is in words,
   so the <img> carries alt="" and the label does the announcing. Height is
   fixed and width is not: the CV mark is a portrait sheet, and forcing it
   square would squash it. `flex: none` keeps a long label from compressing
   it. */
.btn__icon {
  height: 18px; width: auto; max-width: 18px; flex: none;
  object-fit: contain;
}
/* Same rule as the toolkit chips, and the same reason: build.py measures
   each mark's luminance against the page and adds this only when the mark
   would otherwise disappear. None of the four currently qualifies -- this
   is here so that dropping in a dark mark later still works. */
.btn__icon--plate {
  padding: 2px;
  border-radius: var(--radius-sm);
  background: var(--color-bg-inverse);
}
.btn:hover { border-color: var(--color-text); }
.btn--solid {
  background: var(--color-accent);
  border-color: var(--color-accent);
  color: var(--color-accent-text);
}
.btn--solid:hover { filter: brightness(1.08); }

.band--contact h2 {
  font-family: var(--font-display);
  font-weight: var(--font-weight-regular);
  margin: 0;
  font-size: var(--font-size-3xl);
  line-height: var(--line-height-tight);
  letter-spacing: -0.03em;
}
.lede { margin: var(--space-3) 0 0; color: var(--color-text-muted); max-width: var(--prose-width); }

/* ── about ───────────────────────────────────────── */
.about { padding-block: var(--space-16); }
.about h1 {
  font-family: var(--font-display);
  font-weight: var(--font-weight-regular);
  margin: 0 0 var(--space-12);
  font-size: var(--font-size-3xl);
  letter-spacing: -0.03em;
  line-height: var(--line-height-tight);
}
.about__grid { display: grid; gap: var(--space-12); }
@media (min-width: 900px) {
.about__grid { grid-template-columns: 320px 1fr; gap: var(--space-16); align-items: start; }
}
.about__portrait { margin: 0; }
/* The prose runs ~1150px against a 426px photo, and with align-items:start
   that left 720px of empty column. Sticky spends it instead of leaving it:
   the portrait travels with the reader down the page. */
@media (min-width: 900px) {
  .about__portrait {
    position: sticky;
    top: calc(var(--header-height) + var(--space-6));
  }
}
.about__portrait img {
  width: 100%; border-radius: var(--radius-lg);
  /* No border. The card is the surface now, and a hairline around a photo
     inside a tinted card is the same hard edge the media frames dropped. */
  border: 0;
}
.about .prose p { margin: 0 0 var(--space-6); }
.about__cta { margin-top: var(--space-8); }

/* ── experience, on the about page ────────────────
   The scannable counterpart to the about prose: the prose says what the
   work was like, this says what the work was. It reuses the spec-sheet
   vocabulary of the project facts tables — mono micro-labels, hairline
   rules, no boxes — so the history reads as part of the system rather
   than a résumé bolted onto the end of it.

   The gap above comes from .about's own padding-block, which is why .cv
   adds none at the top; doubling them pushed Experience most of a screen
   away from the paragraph it continues. */
.cv { padding-block: 0 var(--space-24); }
.cv__head {
  margin: 0 0 var(--space-6);
  font-family: var(--font-mono);
  font-weight: var(--font-weight-regular);
  font-size: var(--font-size-xs);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--color-text-faint);
}
.cv__list + .cv__head { margin-top: var(--space-16); }
.cv__list {
  list-style: none; margin: 0; padding: 0;
  border-top: 1px solid var(--color-border);
}
.cv__row {
  display: grid; gap: var(--space-2);
  padding-block: var(--space-6);
  border-bottom: 1px solid var(--color-border);
}
@media (min-width: 700px) {
/* Fixed years column so the dates stack into one readable spine. 13rem
     holds the longest string — "February 2021 — September 2021" — on one
     line at this size; below the breakpoint the row goes single-column and
     the date sits above the company instead. */
  .cv__row { grid-template-columns: 13rem 1fr; gap: var(--space-8); }
  .cv__kit { grid-template-columns: 13rem 1fr; gap: var(--space-4) var(--space-8); }
}
.cv__years {
  margin: 0;
  padding-top: 0.35em;        /* optical: mono x-height to display cap-height */
  font-family: var(--font-mono);
  font-size: var(--font-size-xs);
  letter-spacing: 0.03em;
  line-height: var(--line-height-normal);
  color: var(--color-text-faint);
}
.cv__body { min-width: 0; }
.cv__company {
  margin: 0;
  font-family: var(--font-display);
  font-weight: var(--font-weight-regular);
  font-size: var(--font-size-lg);
  letter-spacing: -0.01em;
  line-height: var(--line-height-tight);
}
.cv__role { margin: var(--space-1) 0 0; }
.cv__place {
  margin: var(--space-2) 0 0;
  font-family: var(--font-mono);
  font-size: var(--font-size-xs);
  letter-spacing: 0.03em;
  color: var(--color-text-faint);
}
.cv__note {
  margin: var(--space-2) 0 0;
  max-width: var(--prose-width);
  font-size: var(--font-size-sm);
  line-height: var(--line-height-normal);
  color: var(--color-text-muted);
}

/* Links back into the case studies — the thing a plain CV page cannot do:
   every row is a door into the reasoning. Chips rather than buttons,
   because at eight rows a button per project would out-shout the history
   it is there to annotate.

   The trailing arrow is load-bearing, not decoration. A pill with a
   hairline border on a raised surface is exactly what .tags looks like
   elsewhere on the site, and .tags is inert — same appearance, opposite
   behaviour. The arrow is what tells you this one goes somewhere. */
.cv__work { display: flex; flex-wrap: wrap; gap: var(--space-2); margin: var(--space-4) 0 0; }
.cv__work a::after { content: " \2192"; color: var(--color-accent); }
.cv__work a {
  padding: var(--space-1) var(--space-3);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-full);
  background: var(--color-bg-raised);
  font-family: var(--font-mono);
  font-size: var(--font-size-xs);
  letter-spacing: 0.03em;
  color: var(--color-text-muted);
  text-decoration: none;
  transition: color 160ms var(--ease-out), border-color 160ms var(--ease-out);
}
.cv__work a:hover { color: var(--color-text); border-color: var(--color-border-strong); }

.cv__kit { margin: 0; display: grid; gap: var(--space-4); }
.cv__kit dt {
  font-family: var(--font-mono);
  font-size: var(--font-size-xs);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--color-text-faint);
}
.cv__kit dd { margin: 0; color: var(--color-text-muted); }

/* ── project page ────────────────────────────────── */
.project__head { padding-block: var(--space-16) var(--space-12); }
.eyebrow {
  margin: 0 0 var(--space-3);
  font-family: var(--font-mono);
  font-size: var(--font-size-xs);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--color-text-muted);
}
.project__head h1 {
  font-family: var(--font-display);
  font-weight: var(--font-weight-regular);
  margin: 0;
  font-size: var(--font-size-3xl);
  line-height: var(--line-height-tight);
  letter-spacing: -0.03em;
}
.project__tagline {
  margin: var(--space-3) 0 0;
  max-width: var(--prose-width);
  font-size: var(--font-size-lg);
  line-height: var(--line-height-snug);
  color: var(--color-text-muted);
}
.project__summary { margin: var(--space-6) 0 0; }

.tags { list-style: none; display: flex; flex-wrap: wrap; gap: var(--space-2); margin: var(--space-8) 0 0; padding: 0; }
.tags li {
  padding: var(--space-1) var(--space-3);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-full);
  font-size: var(--font-size-xs);
  color: var(--color-text-muted);
}

.facts {
  margin: var(--space-12) 0 0; padding: 0;
  border-top: 1px solid var(--color-border);
}
.facts__row {
  display: grid; gap: var(--space-1);
  padding-block: var(--space-4);
  border-bottom: 1px solid var(--color-border);
}
@media (min-width: 640px) {
.facts__row { grid-template-columns: 160px 1fr; gap: var(--space-6); }
}
.facts dt { color: var(--color-text-muted); font-size: var(--font-size-sm); }
.facts dd { margin: 0; }


.project__section { padding-block: var(--space-12) var(--space-6); }
.project__section h2 {
  margin: 0 0 var(--space-4);
  font-size: var(--font-size-xl);
  letter-spacing: -0.02em;
  line-height: var(--line-height-snug);
}
.project__section .prose p { margin: 0 0 var(--space-4); }

/* ── images ──────────────────────────────────────── */
.shots {
  display: grid; gap: var(--space-8);
  width: 100%; max-width: var(--max-width);
  margin-inline: auto; padding-inline: var(--gutter);
  margin-block: var(--space-8) var(--space-16);
}
.shot { margin: 0; }
.shot__btn {
  display: block; width: 100%; cursor: zoom-in;
  /* Was white with a 1px border and no padding -- which meant the white
     never showed at all (the image covered it) and the eye went straight
     from warm paper, across a hard rule, onto a cool screenshot. The tint
     and the inset are what replace that rule: see container-tints above. */
  background: var(--tint, var(--color-bg-alt));
  border: 0;
  padding: var(--space-3);
  border-radius: var(--radius-lg);
  overflow: hidden;
  transition: box-shadow .35s var(--ease-out), transform .35s var(--ease-out);
}
/* The border used to carry the hover. With it gone the figure lifts
   instead, using the same tinted shadow as the hero cards -- a neutral
   shadow on warm paper reads as grey haze. */
.shot__btn:hover {
  transform: translateY(-2px);
  box-shadow: 0 18px 40px -24px var(--color-shadow-card);
}
/* The frame is the container the height cap is measured against, so the cap
   scales with the column instead of with the window. There is exactly ONE
   rule for a shot image now, further down; this used to declare a second
   one with the same selector, which is how two sabotages once passed a
   green test suite. */
.shot__btn { container-type: inline-size; }
.shot figcaption {
  margin-top: var(--space-3);
  color: var(--color-text-muted);
  font-size: var(--font-size-sm);
  max-width: var(--prose-width);
}

/* ── pager ───────────────────────────────────────── */
.pager {
  display: flex; flex-wrap: wrap; gap: var(--space-4);
  justify-content: space-between;
  padding-block: var(--space-12);
  border-top: 1px solid var(--color-border);
}
.pager__link { text-decoration: none; display: grid; gap: var(--space-1); }
.pager__link--next { margin-left: auto; text-align: right; }
.pager__link span { color: var(--color-text-muted); font-size: var(--font-size-sm); }
.pager__link strong { font-size: var(--font-size-lg); font-weight: var(--font-weight-medium); letter-spacing: -0.01em; }
.pager__link:hover strong { color: var(--color-accent); }

/* ── lightbox ────────────────────────────────────── */
.lb {
  position: fixed; inset: 0; z-index: 100;
  display: flex; align-items: center; justify-content: center;
  padding: var(--space-4);
  background: color-mix(in srgb, var(--color-bg-inverse) 92%, transparent);
  overflow: auto;
}
.lb[hidden] { display: none; }
.lb__img {
  max-width: min(100%, 1200px);
  margin: auto;
  border-radius: var(--radius-md);
}
.lb__close {
  position: fixed; top: var(--space-4); right: var(--space-4);
  padding: var(--space-2) var(--space-4);
  background: var(--color-bg); color: var(--color-text);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-full);
  font: inherit; font-size: var(--font-size-sm); cursor: pointer;
}

/* ── wider screens ───────────────────────────────── */
@media (min-width: 900px) {
  .band--contact h2, .project__head h1, .about h1 {
  font-family: var(--font-display);
  font-weight: var(--font-weight-regular); font-size: var(--font-size-4xl); }
}

@media (prefers-reduced-motion: reduce) {
  * { animation: none !important; transition: none !important; }
  html { scroll-behavior: auto; }
}

/* ══════════════════════════════════════════════════════════
   Asymmetric project header — narrative left, facts pinned right.
   Reference sites in this genre lean on uneven column ratios
   rather than even halves; it stops the page feeling like a form.
   ══════════════════════════════════════════════════════════ */
.project__meta { min-width: 0; }

/* facts read as a spec sheet: mono labels, tight rows */
.facts dt {
  font-family: var(--font-mono);
  font-size: var(--font-size-xs);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--color-text-faint);
}
@media (min-width: 900px) {
.facts__row { grid-template-columns: 1fr; gap: var(--space-1); }
}

/* metrics sit on a raised surface so numbers feel deliberate */

/* section headings pick up the display face too */
.project__section h2 {
  font-family: var(--font-display);
  font-weight: var(--font-weight-regular);
  font-size: var(--font-size-2xl);
}
.project__section .prose p { color: var(--color-text-muted); }
.project__section .prose p:first-child { color: var(--color-text); }

/* tags as faint chips on a tinted surface */
.tags li {
  background: var(--color-bg-raised);
  color: var(--color-text-muted);
  font-family: var(--font-mono);
  letter-spacing: 0.03em;
}

/* pager gains the display face and an accent arrow */
.pager__link strong { font-family: var(--font-display); font-weight: var(--font-weight-regular); }
.pager__link span { font-family: var(--font-mono); font-size: var(--font-size-xs); letter-spacing: 0.06em; text-transform: uppercase; }
.pager__link--prev strong::before { content: "\2190\00a0"; color: var(--color-accent); }
.pager__link--next strong::after  { content: "\00a0\2192"; color: var(--color-accent); }

/* eyebrow gets the accent dot treatment */
.eyebrow { color: var(--color-accent); }

/* subtle grain so large dark areas don't band */
body::after {
  content: ""; position: fixed; inset: 0; z-index: 90;
  pointer-events: none; opacity: 0.03;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E");
}

/* ══════════════════════════════════════════════════════════
   ENHANCEMENTS
   Two classes, set by enhance.js as its first act:

     html.js     the script is running — JS-dependent UI may be shown
     html.anim   reveals are wanted — not set when the visitor has
                 asked for reduced motion

   Every hiding rule is scoped to one of them. Without JS nothing is
   hidden; without .anim nothing is translated or faded out of view.
   Learned the hard way, twice: never let a reveal be the only thing
   standing between a visitor and the content — and never let
   "prefers reduced motion" mean "pretend JavaScript is off", which
   is what a single combined class quietly did.
   ══════════════════════════════════════════════════════════ */
/* The reveal, in the register mariavasilyeva.com works in: slow and
   deliberate rather than quick and springy. Her page runs its transforms
   at 1.4s; this is 1.2s, because hers are page-level transitions that
   fire once while these fire per section on scroll, and 1.4s on a section
   you are already reading arrives late.

   The blur is gone, and that is a performance change as much as a visual
   one. `filter: blur(8px)` animating to 0 across a whole band -- four
   cards and their covers -- is the most expensive property in this
   transition by a wide margin, and it was running on every section of
   every page. None of the four reference sites blurs on reveal. Opacity
   and transform are both compositor-only.

   Travel dropped 26px -> 18px to match: over a longer duration the same
   distance reads as drift rather than arrival. */
.anim .rise {
  opacity: 0;
  transform: translateY(18px);
  transition:
    opacity .9s var(--ease-out),
    transform 1.2s var(--ease-out);
}
.anim .rise.is-in { opacity: 1; transform: none; }

/* Staggered entry for the cards inside a band, so a row assembles instead
   of appearing whole. Pure CSS off the parent's .is-in -- no per-element
   JS, no observer per card. The delays stop at the sixth child: beyond
   that a visitor is waiting on an animation rather than reading, and the
   largest band on the site holds four. */
.anim .rise .entry,
.anim .rise .value {
  opacity: 0;
  transform: translateY(14px);
  transition:
    opacity .8s var(--ease-out),
    transform 1s var(--ease-out);
}
.anim .rise.is-in .entry,
.anim .rise.is-in .value { opacity: 1; transform: none; }
.anim .rise.is-in .entry:nth-child(2),
.anim .rise.is-in .value:nth-child(2) { transition-delay: .08s; }
.anim .rise.is-in .entry:nth-child(3),
.anim .rise.is-in .value:nth-child(3) { transition-delay: .16s; }
.anim .rise.is-in .entry:nth-child(4) { transition-delay: .24s; }
.anim .rise.is-in .entry:nth-child(n+5) { transition-delay: .3s; }

/* ── index numbers on cards ──────────────────────── */


/* ── in-page section index ───────────────────────── */
.toc { margin-bottom: var(--space-8); }
.toc__label {
  margin: 0 0 var(--space-3);
  font-family: var(--font-mono);
  font-size: var(--font-size-xs);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--color-text-faint);
}
.toc__list {
  list-style: none; margin: 0; padding: 0;
  counter-reset: toc;
  border-left: 1px solid var(--color-border);
}
.toc__list li { counter-increment: toc; }
.toc__list a {
  display: block;
  padding: var(--space-2) var(--space-4);
  margin-left: -1px;
  border-left: 1px solid transparent;
  font-size: var(--font-size-sm);
  color: var(--color-text-faint);
  text-decoration: none;
  transition: color .25s var(--ease-out), border-color .25s var(--ease-out);
}
.toc__list a::before {
  content: counter(toc, decimal-leading-zero);
  margin-right: var(--space-3);
  font-family: var(--font-mono);
  font-size: var(--font-size-xs);
  /* no extra opacity: it multiplied the already-translucent faint colour
     (0.62 x 0.6) and dropped these counters to about 2.8:1. The smaller
     mono size is enough to rank them below the label. */
}
.toc__list a:hover { color: var(--color-text); }
.toc__list a.is-current {
  color: var(--color-text);
  border-left-color: var(--color-accent);
}
/* the index only makes sense once it can sit beside the prose */
@media (max-width: 899px) { .toc { display: none; } }

/* ── reading progress ────────────────────────────── */
.progress {
  position: sticky; top: 0; z-index: 15;
  height: 2px; margin-bottom: calc(var(--space-2) * -1);
  background: var(--color-border);
}
/* Driven by the scroll position itself, not by a scroll handler.
   §5.D bans window scroll listeners for animation: they run on every
   scroll frame and cannot be batched off the main thread. A scroll
   timeline is the same effect computed by the compositor.

   scroll(root block) tracks the whole document rather than the article.
   On a case study the article IS nearly the whole document, and counting
   the pager and footer is arguably more honest: the bar fills when the
   page is finished, not when the prose is.

   The resting state is scaleX(0) and that is correct unguarded too: a
   browser without scroll timelines shows an empty track, which is what
   the bar looked like before any JavaScript ran. It reads as a bar at
   zero, not as a broken element. */
.progress__bar {
  display: block; height: 100%;
  background: var(--color-accent);
  transform: scaleX(0);
  transform-origin: left center;
}
@keyframes progress-fill { to { transform: scaleX(1); } }
@supports (animation-timeline: scroll()) {
  .progress__bar {
    animation: progress-fill linear both;
    animation-timeline: scroll(root block);
  }
}

/* ── hero: let the name breathe, and add a hairline close ── */

/* ══════════════════════════════════════════════════════════
   OVERSIZED DISPLAY + MASK REVEALS
   Big headings slide up from behind a clipping mask as one
   solid line. At this scale a whole-line move reads far more
   confident than a per-character stagger.
   ══════════════════════════════════════════════════════════ */
.mask {
  display: block;
  overflow: hidden;
  /* pad then pull back so descenders aren't clipped by the mask */
  padding-bottom: 0.09em;
  margin-bottom: -0.09em;
}
.mask__in { display: block; }
.anim .mask__in {
  transform: translateY(105%);
  transition: transform .95s var(--ease-reveal) .05s;
}
/* The revealed state. This was a THREE-selector group -- the third was
   `.anim .hero .mask__in`, which fired the marquee band's heading on load
   instead of on scroll. Removing the marquee meant removing that selector,
   and deleting it by matching its text took the shared declaration block
   with it: the two selectors above were left dangling with no body, so
   every masked heading on the site stayed translated 105% down inside its
   own clip. Invisible, on every page, with the whole suite green -- the
   tests assert markup and tokens, and this was rendered geometry. */
.anim .is-in .mask__in,
.anim .mask.is-in .mask__in { transform: translateY(0); }


@keyframes maskUp { from { transform: translateY(105%); } to { transform: none; } }

/* ── scale ─────────────────────────────────────── */
.band--contact h2 { font-size: var(--font-size-4xl); letter-spacing: -0.035em; }
.project__head h1 { font-size: var(--font-size-4xl); letter-spacing: -0.035em; }
.about h1 { font-size: var(--font-size-3xl); }
.band__head h2 { font-size: var(--font-size-3xl); }

/* ── Selected case studies: image beside the words ─────────────────────
   Manager feedback, three notes that turn out to be one change:
   "Repenser les cartes en image + texte côte à côte dans selected case
   studies", "Donner plus d'espace aux images", and the half of
   "Renforcer la hiérarchie typographique" that type size cannot reach.

   A 2x2 grid of stacked cards gave every cover about 550px and made four
   case studies read as four tiles in a wall of eight. One full-width row
   each gives the cover 1.6 of 2.6 columns -- roughly 709px on the 1200px
   measure, a third more pixels -- and, more usefully, distinguishes the
   two bands by SHAPE. Four wide rows followed by four small tiles is a
   hierarchy a visitor reads before reading a single word; four tiles
   followed by four tiles is not, however the type is sized. */
@media (min-width: 900px) {
  /* space-16, not space-20. The scale is 1,2,3,4,6,8,12,16,24,32 -- there
     IS no --space-20, and an undefined var() makes the whole declaration
     invalid at computed-value time, which resets gap to its initial value
     rather than falling back to the .grid rule above. So this read gap: 0
     and the rows sat flush against each other, one project's meta line
     touching the next one's title. Caught from a screenshot, not from the
     stylesheet. See the test that now checks every var() resolves. */
  /* The cover takes its width from the column and its height from the
     ratio -- it must NOT stretch. align-items: stretch applies to both
     columns, and a stretched cover has a definite height, so aspect-ratio
     then computes its WIDTH from that height: Fixerloop's text is the
     tallest of the four, its row grew to 299px, and its cover came out
     717px wide against everyone else's 679, overflowing its column. Pinning
     it to start keeps all four covers identical and lets the row grow under
     the body alone if a summary ever needs more height. */
    /* Both columns now share a top AND a bottom edge: the title sits level
     with the cover's top corner and the index badge on it, and the meta is
     pushed down to meet the cover's bottom. The space that is left falls
     between the description and the meta, where it reads as a deliberate
     gap rather than as a centred blob. */
  }

/* Three tiers that cannot be mistaken for one another, built from faces
   the site already uses rather than from new sizes. The title takes the
   display face so it reads as a heading; the tagline is prose a full step
   up from where it was; the meta is mono and faint, so it reads as data.
   Before this the tagline (sm) and the meta (xs) sat 1.15x apart in the
   same muted colour, which is why the bottom of the page looked flat. */


/* ONE rule for the row tagline. This selector had accumulated three
   separate declarations as the design moved -- a size with a margin, then
   a colour, then another size -- which is the same duplicate-selector
   pattern that made a test collect the wrong rule earlier today. The
   cascade sorted it out correctly; reading it did not.

   Four tiers on a row, and the two middle ones need real separation or they
   blur: the tagline is the hook at full-strength ink, the description is
   the detail, muted and a step down. At 24/16 the size step now does its
   own work, where 20/17 was leaving it all to the colour. */


/* ── hero: name left, portrait right ───────────── */
/* ── oversized next-project teaser ─────────────── */
.pager { align-items: end; gap: var(--space-8); }
.pager__link strong { font-size: var(--font-size-2xl); letter-spacing: -0.025em; line-height: var(--line-height-tight); }
@media (min-width: 900px) {
.pager__link--next strong { font-size: var(--font-size-3xl); }
}
.pager__link strong { transition: color .3s var(--ease-out); }

/* ── stacked wordmark ────────────────────────────────
   Two lines, surname indented. It was set in the condensed poster face;
   that face left with the marquee, and an ultra-condensed grotesk would
   be the one hard edge on a page built out of soft rounded cards. The
   display face at 600 reads as a mark at this size without fighting it. */
.site-head__name {
  display: block;
  /* Set in the display face, not the poster face. For two years this was
     Anton: ultra-condensed caps, and the argument for it was that the
     contrast against the rounded display face made it read as a mark
     rather than as the first line of the page.

     The contrast was real and so was the cost. Anton at 24px over 0.82
     leading puts nine condensed caps and five more directly above them
     into 159x57, and the result is a dark blob -- the surname is the part
     that loses, which is the half of the name that matters. The stacking,
     the indent, the caps and the rule below do the work the condensed
     face was being asked to do, and they do it legibly.

     Deleting it also deletes a font: Anton was 12KB on every page for
     this one element, and the display face was already downloaded. */
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(0.95rem, 1.65vw, 1.3125rem);
  line-height: 1.02;
  letter-spacing: -0.012em;
  text-transform: uppercase;
  color: var(--color-text);
}
.wordmark__line { display: block; }
/* The indent is the whole idea, and something sits in it: the same
   hand-drawn stroke the share card puts under "trust.", which is the
   only handwriting in the identity. It used to be a 2px rectangle --
   a rule, in a mark whose other half is a marker stroke -- and the two
   did not read as one hand.

   It carries the FIRST line now, not the second. The stroke lands on
   the given name, and the surname, which is the longer word, gets the
   full width instead of starting a third of the way across.

   Everything sized in em so it tracks the clamp above, and stroked
   rather than filled so the one accent in the header stays a token. */
.wordmark__line--in { padding-left: 1.5em; position: relative; }
.wordmark__mark {
  position: absolute;
  left: 0;
  /* Centred on the cap height of the line it marks, not on the line
     box: these are caps with nothing below the baseline, so the optical
     middle sits above the box's middle. The stroke's own ink sits in the
     lower half of its viewBox before the flick, which is the other half
     of why this is not simply 50%. */
  top: 0.3em;
  width: 1.2em;
  height: auto;
  overflow: visible;
  fill: none;
  stroke: var(--color-accent);
  /* 5 units of a 48-unit viewBox rendered 20px wide is about 2.1px on
     screen -- the weight the ruled bar had, which is what this has to
     match to stay legible at logo size. Drawn to the card's proportions
     it would come out at 0.8px and disappear: the card's stroke is 200px
     long and this one is 20, and a gesture does not scale linearly into
     a mark. What carries the handwriting at this size is the curve and
     the round ends, not the taper. */
  stroke-width: 5;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* the header sits directly on the hero now, so give the row some air */
.site-head__inner { align-items: start; padding-block: var(--space-6); }
.site-head nav { padding-top: 0.2em; }

/* ══════════════════════════════════════════════════════════
   IMAGE GRID
   Screens were previously full-bleed and stacked, one per row at
   70vh, which turned a 600-word case study into twelve screens of
   scrolling. They now sit two-up and shorter, so a section reads as
   one block of evidence instead of a slideshow. Nothing is lost:
   every tile still opens full size in the lightbox, which is where
   detail belongs.
   ══════════════════════════════════════════════════════════ */
.shots {
  display: grid;
  gap: var(--space-4);
  grid-template-columns: 1fr;
  max-width: var(--max-width);
  margin-inline: auto;
  padding-inline: var(--gutter);
  margin-block: var(--space-8) var(--space-12);
}
@media (min-width: 700px) {
.shots { grid-template-columns: repeat(2, 1fr); gap: var(--space-4); }
  /* a lone image has no pair to sit beside, so let it run the full width */
  .shots .shot:only-child { grid-column: 1 / -1; }
}

/* ── one cap for every figure ───────────────────────────────────────────
   Cropped again, as production has it -- uncropping made InstaDeep 26,955px
   tall -- but through a single rule instead of production's four.

   Production had FOUR caps, and which one a figure got was close to
   arbitrary: 38vh by default, 56vh if it happened to be the only figure in
   its section, 70vh and then 38vh again for anything over 2200px, 48vh for
   phones. Two figures of identical proportions could render at different
   heights because of how many siblings they had. That is the
   inconsistency; one cap is the fix.

   The cap is 5/6 of the FRAME WIDTH, not a slice of the window. That
   matters: production's cap was in vh while the column was 406px wide, so
   widening the column to 828px would have halved the fraction visible and
   cropped harder than before. Measured against the 53 non-phone figures,
   5/6 of the width reproduces production's dominant geometry almost
   exactly -- 21 show in full and the median figure shows 77% of itself,
   where production's paired treatment showed 21 and 77%. Same amount of
   picture, on an image twice the size.

   cqw rather than a fixed ratio on the frame: 14 figures are WIDER than
   3/2, up to 3.57, and an aspect-ratio frame with object-fit cover would
   have cropped those sideways -- trading a vertical crop nobody asked
   about for a horizontal one on a component strip. A max-height only ever
   trims the bottom, and a wide figure stays untouched because its natural
   height is already under the cap. Where cqw is unsupported the
   declaration is dropped and the figure renders uncropped, which is the
   safe direction to fail in. */
.shot__btn img {
  display: block;
  width: 100%;
  height: auto;
  max-height: 83.333cqw;      /* 5/6 of the frame: see above */
  object-fit: cover;
  object-position: top;
  /* outer 28px - 12px padding = 16px. Concentric with the frame. */
  border-radius: var(--radius-md);
}
.shot[data-tall="true"] .shot__btn::after {
  content: "Full screen";
  position: absolute;
  /* One gutter clears the padding, the second insets it into the picture. */
  right: calc(var(--space-3) * 2); bottom: calc(var(--space-3) * 2);
  padding: var(--space-1) var(--space-3);
  font-family: var(--font-mono); font-size: var(--font-size-xs);
  letter-spacing: 0.04em;
  color: var(--color-text);
  background: color-mix(in srgb, var(--color-bg) 72%, transparent);
  backdrop-filter: blur(6px);
  border-radius: var(--radius-sm);
}
.shot__btn { position: relative; }

.shot figcaption {
  margin-top: var(--space-2);
  font-size: var(--font-size-xs);
  color: var(--color-text-faint);
  max-width: none;
  padding-inline: 0;
  text-align: left;
}

/* ══════════════════════════════════════════════════════════
   CASE STUDY STRUCTURE
   Devices borrowed from a Webflow case study worth studying: open
   on the work, number the phases, and set research figures large
   enough to read as findings. All fed by fields already in the JSON
   — nothing here invents content.
   ══════════════════════════════════════════════════════════ */

/* ── cover band: the page opens on the work, not on prose ── */
/* The gutter moves from padding to width so that padding is free to be the
   INSET -- same treatment as .entry__media and .shot__btn. The arithmetic
   is what .shell resolves to (max-width capped, one gutter each side), just
   expressed as width rather than as padding. */
.cover-band {
  margin: 0 auto var(--space-16);
  width: calc(100% - var(--gutter) * 2);
  max-width: calc(var(--max-width) - var(--gutter) * 2);
  background: var(--tint, var(--color-bg-alt));
  padding: var(--space-3);
  border-radius: var(--radius-lg);
}
.cover-band img {
  width: 100%;
  max-height: 62vh;
  object-fit: cover;
  object-position: top;
  /* Was a 1px border over a WHITE fill -- on a cover the fill never showed,
     so the page went paper, rule, cool image with nothing between. The
     tinted frame above replaces both. */
  border: 0;
  border-radius: var(--radius-md);
  background: transparent;
}

/* ── insight band: research numbers as findings ── */
.insights {
  padding-block: var(--space-12);
  border-block: 1px solid var(--color-border);
  margin-bottom: var(--space-16);
}
.insights__grid {
  display: grid; gap: var(--space-12);
  grid-template-columns: 1fr;
}
@media (min-width: 700px) {
.insights__grid { grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--space-16); }
}
.insight__value {
  display: block;
  font-family: var(--font-display);
  font-weight: var(--font-weight-regular);
  font-size: clamp(2.75rem, 6vw, 4.25rem);
  line-height: 1;
  letter-spacing: -0.03em;
  color: var(--color-accent);
}
.insight__label {
  display: block;
  margin-top: var(--space-3);
  max-width: 26ch;
  font-size: var(--font-size-sm);
  line-height: var(--line-height-snug);
  color: var(--color-text-muted);
}

/* ── numbered phases ── */
.project__section h2 {
  display: flex; align-items: baseline; gap: var(--space-4);
}
.project__num {
  flex: none;
  font-family: var(--font-mono);
  font-size: var(--font-size-xs);
  letter-spacing: 0.06em;
  color: var(--color-accent);
  /* nudge down so the small mono digit sits on the cap line */
  transform: translateY(-0.15em);
}

/* ── labelled fact groups ── */
.facts-group + .facts-group { margin-top: var(--space-8); }
.facts-group__label {
  margin: 0 0 var(--space-3);
  font-family: var(--font-mono);
  font-size: var(--font-size-xs);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--color-text-faint);
}
.facts-group .facts { margin-top: 0; }
.facts-group__value { margin: 0; }

/* ══════════════════════════════════════════════════════════
   CASE STUDY LAYOUT
   The sidebar has to stay put for the length of the article, so it
   lives in a grid spanning the whole reading section. position:
   sticky is relative to the nearest scrolling ancestor but bounded
   by its containing block — inside the header it ran out after a
   couple of hundred pixels. Here the block is the full article.
   ══════════════════════════════════════════════════════════ */
.project__head { padding-block: var(--space-16) var(--space-12); }
.project__layout { display: block; }
.project__main { min-width: 0; }

@media (min-width: 1000px) {
.project__layout {
    display: grid;
    grid-template-columns: minmax(0, 1fr) 260px;
    gap: var(--space-16);
    align-items: start;
  }
  /* sidebar second in the DOM so the prose is read first, placed right */
  .project__meta { grid-column: 2; grid-row: 1; }
  .project__main { grid-column: 1; grid-row: 1; }

  .project__meta {
    position: sticky;
    /* clears the sticky site header */
    top: var(--space-16);
    max-height: calc(100dvh - var(--space-24));
    overflow-y: auto;
    overscroll-behavior: contain;
    /* hide the scrollbar; the panel is short and a bar reads as chrome */
    scrollbar-width: none;
  }
  .project__meta::-webkit-scrollbar { display: none; }

  /* sections and images are inside the column now, so they must not
     re-apply the page shell or they would double the gutters */
  .project__main .shots {
    max-width: none;
    padding-inline: 0;
    margin-inline: 0;
  }

  /* "Donner plus d'espace aux images", the case-study half of it.
     Measured on Steer at 1366: the prose column is 828px and the grid was
     406px + 406px, so a paired screenshot got 406px -- while a section
     holding ONE figure already went full width at 828px, through the
     :only-child rule below. The site therefore already decided 828px is
     the right size for a screenshot; pairing was halving it. A landscape
     screenshot of a web page at 406px is a thumbnail of a layout, which is
     the one thing a case study needs to be legible.

     Phone screens are excluded deliberately. They are portrait and narrow,
     they are tagged from their intrinsic dimensions by build.py rather than
     by hand, and a 375-wide screen blown up to 828px is just soft -- their
     denser grid is correct. Hence :not(), rather than a blanket rule that
     would have caught them too. */
  .project__main .shots:not(.shots--phone) { grid-template-columns: 1fr; }
}

/* below the breakpoint the sidebar drops under the prose */
@media (max-width: 999px) {
  .project__meta { margin-top: var(--space-16); }
}

/* ── phone screens ───────────────────────────────────
   A 375x812 screen cover-fitted into a wide short tile shows only its
   top third. Phone screens are therefore contained, not cropped, and
   given a denser grid since each one is narrow. build.py tags them
   from their intrinsic dimensions, so this needs no per-image config. */
.shots--phone { grid-template-columns: repeat(2, 1fr); gap: var(--space-4); }
@media (min-width: 700px) {
.shots--phone { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
}
.shot[data-phone="true"] .shot__btn {
  /* This variant pioneered the inset treatment the base rule now shares.
     It no longer needs to declare either: the tint and the 12px inset come
     from the base rule, and matching them keeps the inner radius
     concentric, which a roomier inset here would not. */
  background: var(--tint, var(--color-bg-alt));
}
.shot[data-phone="true"] .shot__btn img {
  /* Exempt from the HEIGHT cap. A phone screen is ~0.46 wide-to-tall, so
     5/6 of its column would cut more than half of it off, and a phone
     screenshot sliced at the waist is the one case everybody agrees is
     wrong.

     Capped on WIDTH instead, which is what actually keeps them consistent.
     f04-settings.png sits in a MIXED section -- one phone beside one
     landscape shot -- and build.py only tags a grid .shots--phone when
     EVERY image in it is a phone, so that grid stayed single-column, the
     phone inherited the full 828px, and it rendered 1754px tall next to
     siblings of 383-735px. Constraining the width fixes it wherever it
     lands, which is better than teaching the grid about mixed sections.
     Inside a real phone grid the columns are already 195-265px, so this
     changes nothing there. */
  max-height: none;
  max-width: 300px;
  margin-inline: auto;
  border-radius: var(--radius-sm);
}
/* the phone tiles are small, so a corner hint would crowd them */
.shot[data-phone="true"] .shot__btn::after { content: none; }

/* ── tool chips ──────────────────────────────────────
   Each tool is a chip with room for an 18px mark. build.py only emits an
   <img> when assets/tools/<slug>.svg exists, so the monogram fallback is
   what shows until real brand files are dropped in. */
.tools {
  list-style: none; margin: 0; padding: 0;
  display: flex; flex-wrap: wrap; gap: var(--space-2);
}
.tool {
  display: inline-flex; align-items: center; gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-full);
  background: var(--color-bg-raised);
  font-size: var(--font-size-sm);
  transition: border-color .25s var(--ease-out), background .25s var(--ease-out);
}
.tool:hover { border-color: var(--color-border-strong); background: var(--color-bg-raised-hover); }
/* A light plate, because most brand marks are drawn for light backgrounds.
   Measured against this page's #02050C: MCP 1.00:1 and Framer 1.03:1 (both
   invisible), Figma's app tile 2.10:1, VWO 2.48:1. Recolouring them would fix the contrast and
   break the vendors' usage terms, which generally forbid altering the mark
   — so the mark is left exactly as shipped and the surface behind it
   changes instead. Same move as a press page putting logos on white.
   box-sizing is border-box globally, so the padding eats into the 18px
   rather than growing the chip. */
.tool__icon {
  width: 18px; height: 18px; flex: none;
  object-fit: contain;
}
/* Only for marks that would otherwise vanish. build.py measures each icon's
   luminance and adds this class when its contrast against the page falls
   below 3:1 -- MCP, Framer, Figma's black app tile and the VWO wordmark
   today. Applying it to everything was wrong in the other direction:
   Creative Cloud, Notion, Miro and Claude Code are light marks that LOSE
   contrast on white, Creative Cloud going from 10.33:1 on the page to
   1.79:1 on a plate. The mark itself is never recoloured; vendor terms
   forbid altering it. */
.tool__icon--plate {
  padding: 2px;
  border-radius: var(--radius-sm);
  background: var(--color-bg-inverse);
}
/* the fallback deliberately reads as this site's type, not as a brand mark */
.tool__mono {
  display: grid; place-items: center;
  width: 18px; height: 18px; flex: none;
  border-radius: var(--radius-sm);
  background: var(--color-accent-soft);
  color: var(--color-accent-bright);
  font-family: var(--font-mono);
  font-size: 0.68rem; line-height: 1;
}
.tool__name { color: var(--color-text); }

/* ── screen-in-screen viewer ─────────────────────────────
   A framed browser for stepping through component views. Every panel is in
   the DOM and visible without JS; only .js hides the inactive ones, so the
   content can never be trapped behind a script that failed to load. */
.viewer { margin-top: var(--space-8); }
.viewer__frame {
  border: 1px solid var(--color-border-strong);
  border-radius: var(--radius-lg);
  background: var(--color-bg-alt);
  overflow: hidden;
}
.viewer__bar {
  display: flex; align-items: center; gap: var(--space-2);
  padding: var(--space-3) var(--space-4);
  border-bottom: 1px solid var(--color-border);
  background: var(--color-bg-raised);
}
.viewer__bar span {
  width: 10px; height: 10px; border-radius: var(--radius-full);
  background: var(--color-border-strong);
}
.viewer__stage { display: block; }
.viewer__panel img {
  display: block; width: 100%; height: auto;
}
.viewer__cap {
  margin: 0; padding: var(--space-3) var(--space-4);
  border-top: 1px solid var(--color-border);
  font-size: var(--font-size-sm); color: var(--color-text-muted);
}
.js .viewer__panel { display: none; }
.js .viewer__panel.is-on { display: block; }
.viewer__tabs {
  display: flex; flex-wrap: wrap; gap: var(--space-2);
  margin-top: var(--space-4);
}
.viewer__tab {
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-full);
  background: transparent;
  color: var(--color-text-muted);
  font: inherit; font-size: var(--font-size-sm);
  cursor: pointer;
  transition: border-color .2s var(--ease-out), color .2s var(--ease-out),
              background .2s var(--ease-out);
}
.viewer__tab:hover { border-color: var(--color-border-strong); color: var(--color-text); }
.viewer__tab.is-on {
  background: var(--color-accent-soft);
  border-color: var(--color-accent);
  color: var(--color-accent-bright);
}
/* without JS the tabs cannot do anything, so they are not offered */
html:not(.js) .viewer__tabs { display: none; }

/* viewer images open full size — documentation boards are unreadable otherwise */
.viewer__zoom {
  display: block; width: 100%; padding: 0; border: 0; background: none;
  cursor: zoom-in;
  margin-inline: auto;   /* a view capped below full width centres in the frame */
}
/* This used to undo .shot__btn img's crop. That crop is gone -- every shot
   renders at its own ratio now -- so the reset has nothing left to undo and
   the declarations below are what a plain image wants anyway. Kept explicit
   rather than deleted: this button reuses .shot__btn, so if a cap is ever
   reintroduced there, a documentation board is the first thing it would
   quietly slice, and that already happened once (a 900x1915 board shown
   285px tall, about a quarter of it). */
.viewer__zoom img {
  display: block;
  width: 100%;
  height: auto;
  max-height: none;
  object-fit: contain;
}

/* ── the contact address, as text ─────────────────────────
   The email existed on every page only inside href="mailto:". A recruiter
   who wants to paste it into an ATS, or who has no mail client configured,
   could not read it at all. Mono because it is a machine string, and sized
   to sit under the lede without competing with it. */
.contact__addr {
  margin: var(--space-4) 0 0;
  font-family: var(--font-mono);
  font-size: var(--font-size-sm);
  color: var(--color-text-muted);
}
.contact__addr a { color: inherit; text-decoration: none; border-bottom: 1px solid var(--color-border-strong); }
.contact__addr a:hover { color: var(--color-accent); border-bottom-color: var(--color-accent); }

/* ── motion clips in a shots grid ─────────────────────────
   The first motion on the site. Sized like any other shot so the grid does
   not have to know the difference, but it is never cropped: a still can be
   object-fit: cover into a tile because the subject survives, whereas
   cropping an animation throws away the part that moves. So the frame is
   let to the clip's own ratio and the poster carries the dimensions. */
.shot--video { grid-column: 1 / -1; }
/* A video is a media container like any other, so it takes the same tinted
   frame as .shot__btn rather than the white-plus-border it had.

   The frame is a spread-only ring, not padding. Padding on a replaced
   element does show the background around the content, but border-radius
   clips at the BORDER box -- so the video would sit square-cornered inside
   a rounded frame. A ring follows the radius instead, which makes both
   edges round and concentric by construction rather than by arithmetic.
   It takes no layout space, so the margin gives it room and the width
   gives that margin back. */
.shot__video {
  display: block;
  width: calc(100% - var(--space-3) * 2);
  margin: var(--space-3);
  height: auto;
  border: 0;
  border-radius: var(--radius-md);
  background: transparent;
  box-shadow: 0 0 0 var(--space-3) var(--tint, var(--color-bg-alt));
}
/* A visitor who has asked for calm gets no autoplay at all -- enhance.js
   never adds it -- so the controls are the whole interface here and they
   should not be the quiet option. */
@media (prefers-reduced-motion: reduce) {
  .shot__video { outline: 1px solid var(--color-border-strong); outline-offset: -1px; }
}

/* ══════════════════════════════════════════════════════════
   THE OPENING — the first screen
   ══════════════════════════════════════════════════════════
   The clarity block that replaced the marquee as the top of the page.
   New class rather than a sixth `.hero` override: `.hero` is already
   redefined five times in this file, and editing it by text match is how
   the :root block got destroyed once. This owns its own namespace.

   Type sizes come from the four sites this was briefed on. The claim sits
   at --font-size-3xl (44-72px) against sandeep.design's 76px, at weight
   500 -- not 700. Every reference uses medium for display type; bold is
   what makes a grotesk look like a default. Tracking is -0.035em, the
   middle of the -0.03/-0.04em the four of them occupy.
   ══════════════════════════════════════════════════════════ */

/* ── the opening panel ────────────────────────────────────────────────────
   The greeting and the three cards under it are one object now, in the
   same surface language as .aboutblock__card and .about__card. They used
   to be two sections of loose content with the page showing through
   between them, which left the fan reading as something that had been put
   below the hero rather than as part of it.

   The padding is not decoration here. The fan cards are rotated 5deg, and
   a rotated box overhangs its layout box: at roughly 300x310 that is 13px
   sideways and 12px vertically beyond where the grid thinks the card
   stops. Anything less than that at the panel edge and the corners of the
   outer two cards touch it. */
/* The panel needs the clearance every other section already has. While the
   opening was loose content it carried its own clamp(space-16, 11vh,
   space-32) of top padding, and the text cleared the header even though
   the section started flush against it. Wrapping it in a card made the
   section's top edge VISIBLE, and a visible edge at a measured gap of 0
   reads as the menu sitting on the panel.

   --space-16 is not a new number: .band and .about both open on it, which
   is why the about card already cleared the header by 64px and this did
   not. Bottom stays 0 -- the next band brings its own. */
.hero-band { padding-block: var(--space-16) 0; }

.hero-card {
  /* Same two-step padding as the other cards, and it has to stay the same:
     a card's padding is what insets its content, so a card padded
     differently from its siblings puts their contents on different left
     edges. 24px still clears the 13px rotation overhang, and the fan is
     square below 900px anyway. */
  padding: var(--space-6);
  border-radius: var(--radius-xl);
  background: var(--color-bg-alt);
  box-shadow: 0 18px 40px -24px var(--color-shadow-card);
}
@media (min-width: 900px) {
  .hero-card { padding: var(--space-8); }
}

.opening {
  /* Was clamp(space-16, 11vh, space-32) / space-16 -- the breathing room a
     section needs when the page is its backdrop. The panel's own padding
     is that room now, so this only has to separate the greeting from the
     fan beneath it. */
  padding-block: var(--space-8) var(--space-12);
  display: grid;
  gap: var(--space-8);
  justify-items: start;
}

/* ── status row ── */
.opening__status {
  display: flex; align-items: center; flex-wrap: wrap;
  gap: var(--space-3);
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--font-size-xs);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--color-text-muted);
}
.opening__avail { display: inline-flex; align-items: center; gap: var(--space-2); }
/* The dot is the only thing on the first screen in the accent colour, so
   "available" is the one idea the palette points at. */
.opening__dot {
  width: 7px; height: 7px; border-radius: var(--radius-full);
  background: var(--color-accent);
  box-shadow: 0 0 0 3px var(--color-accent-soft);
  flex: none;
}
.opening__sep { width: 1px; height: 11px; background: var(--color-border-strong); flex: none; }
.opening__portfolio { color: var(--color-text-faint); }

/* ── the claim assembles ─────────────────────────────────────────────────
   MOTION_INTENSITY 6, and the one piece of motion on this site that is
   about the work rather than about the page.

   The last line of the h1 is "I build what I design." On load it arrives
   already selected: a dashed frame, four corner handles, and a label
   naming the thing. The frame holds for about a second and then releases,
   leaving the sentence with nothing drawn on it.

   The label says `h1` rather than a pixel size. A size would have to be a
   number written into CSS, and the real number changes with every
   viewport -- a measurement badge that is wrong at most widths is worse
   than no badge. `h1` is true at every width, and it is the better joke:
   a BUILD-side name inside a DESIGN-side selection, which is the sentence
   it is drawn around.

   Both layers rest at opacity 0 and are only ever shown BY the animation.
   That is what makes this safe: no JavaScript, no reduced-motion
   fallback to write, and no state to clean up. A browser that ignores the
   animation, or a visitor who asked for less motion, gets the clean type
   that is the end state anyway.

   Drawn entirely on pseudo-elements, so the heading's text content is
   still exactly the sentence -- nothing here is in the accessibility
   tree, and nothing here is selectable. */
.claim__line { display: block; }
.claim__line--built {
  position: relative;
  /* A block so the lines stack, but shrunk to its text so the frame is
     drawn around the SENTENCE and not around the column. fit-content
     rather than max-content: this line still has to be allowed to wrap
     at 375px. */
  width: fit-content;
}
.claim__line--built::before,
.claim__line--built::after {
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.22s var(--ease-out);
}
/* The frame, and its four handles in one layer: four 7px gradient squares
   sitting on the corners, which is cheaper and steadier than four
   absolutely positioned elements that each have to be kept in the corner
   as the type reflows. */
.claim__line--built::before {
  content: "";
  position: absolute;
  /* The box is the selection PLUS 4px of margin on every side, because a
     background cannot paint outside the box that owns it and the handles
     straddle the corners. Drawing the frame with `border` and the handles
     with `background` clipped every handle to the 1.5px border box, which
     is a lot of CSS for four invisible squares.

     So the whole selection is one background: four edges and four
     handles, eight layers, painted 4px inside this box. The edges land
     flush to the line box vertically and clear of the side bearings
     horizontally, which is the bounding box a design tool would draw.

     Handles come first in the list, so they sit above the edges where
     they meet. Solid, not outlined: Figma fills them white against blue,
     and the equivalent here is the accent against the page. */
  inset: -4px calc(-0.08em - 4px);
  background-image:
    linear-gradient(var(--color-accent), var(--color-accent)),
    linear-gradient(var(--color-accent), var(--color-accent)),
    linear-gradient(var(--color-accent), var(--color-accent)),
    linear-gradient(var(--color-accent), var(--color-accent)),
    linear-gradient(var(--color-accent), var(--color-accent)),
    linear-gradient(var(--color-accent), var(--color-accent)),
    linear-gradient(var(--color-accent), var(--color-accent)),
    linear-gradient(var(--color-accent), var(--color-accent));
  background-size:
    8px 8px, 8px 8px, 8px 8px, 8px 8px,
    calc(100% - 8px) 1.5px, calc(100% - 8px) 1.5px,
    1.5px calc(100% - 8px), 1.5px calc(100% - 8px);
  background-repeat: no-repeat;
  background-position:
    left 0 top 0, right 0 top 0, left 0 bottom 0, right 0 bottom 0,
    left 4px top 4px, left 4px bottom 4px,
    left 4px top 4px, right 4px top 4px;
}
.claim__line--built::after {
  opacity: 0;
  pointer-events: none;
}
/* The frame, and its four handles in one layer: four 7px gradient squares
   sitting on the corners, which is cheaper and steadier than four
   absolutely positioned elements that each have to be kept in the corner
   as the type reflows. */
.claim__line--built::before {
  content: "";
  position: absolute;
  /* Flush to the line box vertically, because that IS the bounding box a
     selection draws. The earlier -0.1em pushed the top edge up through
     the descenders of the line above. Horizontally it clears the glyphs'
     side bearings so the frame does not sit on the's stem. */
  inset: 0 -0.08em;
  /* Solid, not dashed. A 1px dash of accent on near-black is legible in
     principle -- 6.44:1 -- and unreadable in practice at this size in the
     second it is on screen. Solid is also what the tool draws: dashed is
     a hint, solid is a selection, and this is a selection. */
  border: 1.5px solid var(--color-accent);
  border-radius: 1px;
  background-image:
    linear-gradient(var(--color-accent), var(--color-accent)),
    linear-gradient(var(--color-accent), var(--color-accent)),
    linear-gradient(var(--color-accent), var(--color-accent)),
    linear-gradient(var(--color-accent), var(--color-accent));
  background-size: 8px 8px;
  background-repeat: no-repeat;
  background-position:
    left -4.75px top -4.75px,    right -4.75px top -4.75px,
    left -4.75px bottom -4.75px, right -4.75px bottom -4.75px;
}
.claim__line--built::after {
  content: "h1";
  position: absolute;
  left: -0.14em;
  top: calc(100% + 10px);
  padding: 2px 7px;
  border-radius: 3px;
  background: var(--color-accent);
  color: var(--color-accent-text);
  font: 600 11px/1.4 var(--font-mono);
  letter-spacing: 0.04em;
  white-space: nowrap;
}
@media (prefers-reduced-motion: no-preference) {
  /* Delayed past the hero cascade (.38s on the last of it) so the frame
     reads as a beat after the page settles, not as part of its arrival.

     `backwards`, NOT `both`. This is the fix for the thing that made the
     first version feel broken: a filled animation keeps its end state and
     an animation beats a normal declaration in the cascade, so with
     `both` the :hover rule below could never win and the frame was gone
     for good the moment the beat ended. `backwards` holds the first
     keyframe through the delay and then lets go, which returns the
     decorations to their resting opacity: 0 and puts hover back in
     charge. */
  /* THE LOAD BEAT IS NOW SIZE-GATED, and the gate is the point. The
     frame is drawn for an 88px line: 1.5px edges, 7px corner handles,
     4px outside the box. At the 136px desktop setting those proportions
     invert -- the edges go hairline, the handles stop reading as
     handles, and what a visitor meets on load is a thin accent
     rectangle around the headline that looks like a validation error
     rather than a Figma selection. Seen in a screenshot at 1440, not
     reasoned about.

     Below 1100px the type is at or under the 88px this was built for,
     so the beat plays there exactly as before. Above it, hover still
     brings the whole thing back for anyone who points at the sentence
     -- the decoration is intact, it just stops introducing itself at a
     size where it does not read.

     The delays wait for the reveal: the last line lands at .41 + .95 =
     1.36s, and a frame drawn around a line that is still moving reads
     as a glitch. */
  @media (max-width: 1099px) {
    .claim__line--built::before { animation: claim-frame 2.6s var(--ease-out) 1.5s backwards; }
    .claim__line--built::after  { animation: claim-tag   2.4s var(--ease-out) 1.67s backwards; }
  }
}

/* And it comes back. A one-shot that plays once per page load is a thing
   a visitor can miss permanently -- arrive, look at the nav, and the only
   piece of motion on the site has already happened. Pointing at the
   sentence selects it again, for as long as you keep pointing.

   Hover only, because the hover query is what tells a touch device not to
   emulate this on tap. A phone gets the load beat and nothing sticky. */
@media (hover: hover) {
  .claim__line--built:hover::before,
  .claim__line--built:hover::after { opacity: 1; }
}
@keyframes claim-frame {
  0%   { opacity: 0; transform: scale(0.985); }
  7%   { opacity: 1; transform: scale(1); }
  76%  { opacity: 1; transform: scale(1); }
  100% { opacity: 0; transform: scale(1.006); }
}
@keyframes claim-tag {
  0%   { opacity: 0; transform: translateY(3px); }
  8%   { opacity: 1; transform: translateY(0); }
  74%  { opacity: 1; transform: translateY(0); }
  100% { opacity: 0; transform: translateY(-2px); }
}

/* ── the claim: the page's h1 ── */
.opening__claim {
  margin: 0;
  font-family: var(--font-display);
  /* --font-size-3xl with a lower FLOOR, and only the floor: the vw track
     and the ceiling are the token's, so this matches every other 3xl
     heading from 470px up.

     The floor had to move because the claim is three authored lines now.
     At 375px the panel leaves 279px of content width and the longest
     line, "I build what I design.", needs about 32px of type to hold --
     at the token's 40px floor it broke, and the hero rendered as five
     ragged lines instead of three. The token itself is shared by eight
     rules and could not follow. */
  font-size: clamp(2rem, 8.5vw, 5.5rem);
  font-weight: var(--font-weight-medium);
  line-height: var(--line-height-tight);
  letter-spacing: var(--tracking-display);
  /* A cap, not the thing that breaks the lines -- the breaks are authored
     as newlines in heroClaim. It keeps the measure sane if that copy ever
     grows a long line, and `balance` stops an orphan word. */
  max-width: 26ch;
  text-wrap: balance;
}

/* ── the support line ── */
.opening__support {
  margin: 0;
  font-size: var(--font-size-base);
  line-height: var(--line-height-normal);
  color: var(--color-text-muted);
  max-width: 54ch;
  text-wrap: pretty;
}

/* ── actions ── */
.opening__actions {
  display: flex; align-items: center; flex-wrap: wrap;
  gap: var(--space-4);
  margin: 0;
}
.opening__note {
  font-size: var(--font-size-sm);
  color: var(--color-text-faint);
}

/* ── the facts strip ──
   Pleurat runs ROLE / TOOLS / YEAR / LIVE across the top of every case
   study. This is the homepage equivalent, and it is where the roles list
   kept its meaning once the h1 became a sentence instead of a list. */
.opening__facts {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--space-6) var(--space-8);
  margin: var(--space-4) 0 0;
  padding-top: var(--space-6);
  border-top: 1px solid var(--color-border);
  width: 100%;
}
.opening__fact dt {
  font-family: var(--font-mono);
  font-size: var(--font-size-xs);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--color-text-faint);
}
.opening__fact dd {
  margin: var(--space-2) 0 0;
  font-size: var(--font-size-sm);
  color: var(--color-text);
}

@media (min-width: 640px) {
.opening__facts { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
@media (min-width: 900px) {
.opening { gap: var(--space-8); }
  .opening__support { font-size: var(--font-size-lg); line-height: var(--line-height-snug); }
}

/* ══════════════════════════════════════════════════════════
   THE SIGNATURE BAND — the marquee, demoted
   ══════════════════════════════════════════════════════════
   What `.hero` is now. It no longer has to be the whole first screen and
   it no longer holds the statement, which removes the problem the scrim
   was built to solve: the wall used to overlap the paragraph on a short
   window, and no scrim height could fix it because the two were stacked
   in the same box. The paragraph left, so the overlap cannot happen.

   Height drops from a full viewport to a band. At 100dvh it was pushing
   the work two screens down on a laptop.
   ══════════════════════════════════════════════════════════ */
/* ── the pause control (WCAG 2.2.2, Level A) ── */


/* Paused state: every row stops, both copies, with no JS per frame. */


/* ══════════════════════════════════════════════════════════
   HOW I WORK — three named principles
   ══════════════════════════════════════════════════════════
   Deliberately NOT three equal cards with a border and a shadow, which is
   the single most generic shape this section could take. No card at all:
   a lettered index, a claim, a paragraph, separated by a hairline. The
   hierarchy is type and space, which is how all four reference sites do
   theirs.
   ══════════════════════════════════════════════════════════ */


/* ── category chips on a case-study card ──────────────────
   The `tags` list has been in every content.json since the schema was
   written and nothing rendered it until the redesign. petradesigns.io
   leads each project with the same device, and it is the cheapest
   clarity on the page: what KIND of work this was, before any prose.

   Outlined boxes at radius-sm, not pills. A pill badge is the most
   generic shape this could take and it would collide with the solid
   pill the primary action already uses -- two unrelated things reading
   as the same component. */


/* The chips follow the card's own hover, so the whole block reads as one
   target rather than a title that lights up beside inert labels. */

/* ── the NN · PHASE eyebrow above a case-study section ────
   pleurat.com's device, and the reason one of its case studies reads as
   "classic and easy to explore": the eyebrow carries the stage name, so
   the heading under it is free to be a sentence instead of a label.

   The number was inside the h2 until the redesign, which is why
   .project__num carried a translateY nudge to sit on the cap line of
   type three steps larger. Out here it is on its own baseline with the
   phase, so the nudge is gone rather than inherited. */
.project__eyebrow {
  display: flex; align-items: center;
  gap: var(--space-3);
  margin: 0 0 var(--space-3);
  font-family: var(--font-mono);
  font-size: var(--font-size-xs);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
}
.project__eyebrow .project__num {
  color: var(--color-accent);
  transform: none;
}
.project__phase { color: var(--color-text-faint); }
/* A hairline rule rather than a middot: at this size a "·" between two
   uppercase mono strings reads as a stray character. */
.project__eyebrow-sep {
  width: var(--space-6); height: 1px;
  background: var(--color-border-strong);
  flex: none;
}

/* The heading can now take the display face and a real size, because it no
   longer has a small mono number sitting inside it fighting the baseline. */
.project__section h2 {
  font-family: var(--font-display);
  font-weight: var(--font-weight-medium);
  letter-spacing: var(--tracking-heading);
  text-wrap: balance;
  max-width: 24ch;
}

/* ── insight cells keep their labels on one line ──────────
   The values are set at up to 68px, so one that wraps to a second or
   third line pushes its own label down while its neighbours' stay put.
   Measured on Steer before the fix: two labels at y=10 and the third at
   y=133, because "Investor-backed, Versailles" ran to three lines.

   The content fix was to make every value a figure again -- the longest
   is now ten characters. This is the structural half, so a future long
   value misaligns nothing.

   `margin-top: auto` was the first attempt and it is the wrong axis: it
   pins each label to the BOTTOM of its cell, so three cells whose labels
   wrap to different line counts end up with their tops at 1318, 1318 and
   1298. Bottoms aligned, tops ragged, which is the half nobody looks at.

   Subgrid is the tool for this. The cells share the grid's two rows, so
   every value is measured against the tallest value and every label
   starts on the same line. The fallback is plain flow, which is already
   correct while all eight values on the site are one line -- it is a
   future long value this protects against, not today's. */
@supports (grid-template-rows: subgrid) {
  .insights__grid { grid-template-rows: auto auto; }
  .insight {
    display: grid;
    grid-row: span 2;
    grid-template-rows: subgrid;
  }
}

/* The status row wraps on a phone -- "OPEN TO CONVERSATIONS" and
   "PORTFOLIO '26" do not share a 375px line -- and a flex separator
   stays put when that happens, so the hairline was left stranded at the
   end of the first line pointing at nothing. It is a divider between two
   things on one line; with the line broken, the break IS the divider. */
@media (max-width: 639px) {
  .opening__sep { display: none; }
  .opening__status { gap: var(--space-2) var(--space-4); }
}

/* ══════════════════════════════════════════════════════════
   SCROLL SNAPPING — controlled stops
   ══════════════════════════════════════════════════════════
   Free scrolling leaves a section half on screen and half off, with the
   stop wherever the flick happened to die. These give the scroll defined
   resting places at the section boundaries.

   `proximity`, NOT `mandatory`, and that is the whole safety argument.
   Mandatory forces the scroll onto the nearest snap point every time it
   settles, which is fine while every section is shorter than the viewport
   and hostile the moment one is not: the bands on this homepage run from
   353px to 3591px, so on a 768px screen mandatory snapping would fight a
   visitor trying to read the middle of a case-study band and could leave
   parts of it unreachable. Proximity snaps when the scroll ENDS near a
   boundary and otherwise stays out of the way, which is the behaviour
   that was wanted and the only one that is safe at these heights.

   Gated on no-preference: a scroll position the browser adjusts after the
   visitor stopped is exactly the kind of unrequested movement
   prefers-reduced-motion is for. Gated on height too -- under 480px the
   snap points are closer together than the viewport and snapping starts
   to feel like resistance.
   ══════════════════════════════════════════════════════════ */

/* Not only for snapping: this is also the fix for an anchor jump landing
   under the sticky header. The site set scroll-margin-top on #main and
   .project__section individually; on the container it covers every target,
   including the snap points below and the table-of-contents links. */
html {
  scroll-padding-top: calc(var(--header-height) + var(--inset-top) + var(--space-4));
}

@media (prefers-reduced-motion: no-preference) and (min-height: 480px) {
html { scroll-snap-type: y proximity; }

  /* A band is NOT a snap point while its own rows are. A band and its
     first row sit ~141px apart, so listing both put two candidates inside
     a sixth of a viewport of each other and the resolved position came out
     13 to 50px off a target rather than on one -- jitter, not snapping.
     The set below is one non-overlapping sequence: a band's own stop is
     its first row.

     Gaps are kept near a viewport so that wherever a scroll settles there
     is usually a stop close enough for proximity to catch. The facts strip
     is in this list for that reason and no other: without it the first gap
     ran 652px from the top of the page, and a scroll settling in the
     middle was 228px from either end -- too far, so it did not snap. */
  /* NOT .opening. It was the first entry here, and a snap target in the
     first screen means the browser snaps PAST the top of the page on
     load: the page came to rest at scrollY 126 and would not return to 0,
     which put the hero panel's top edge 24px behind the floating nav.

     It was always wrong and was merely small -- .opening used to start at
     134, snapping to 30, which nobody noticed. Wrapping the opening in a
     panel moved its border box to 230 and turned 30 into 126.

     The top of the page is already a resting position. It does not need
     a snap point, and giving it one takes the real top away. */
  .opening__facts,
  .fan,
  .entry,
  .value,
  .band--contact,
  .project__head,
  .project__section,
  .pager {
    scroll-snap-align: start;
  }

  /* Gaps are kept near a viewport so that wherever a scroll settles
     there is usually a stop close enough for proximity to catch. The
     facts strip is in this list for that reason and no other: without it
     the first gap was 652px, from the top of the page to the signature
     band, and a scroll that settled in the middle of it was 228px from
     either end -- too far, so it did not snap, which was the original
     complaint reproduced exactly.

     ONE run is deliberately longer than a viewport: the case-study band,
     1149px from its top to the gallery cards below it. That one is left
     alone on purpose. The only stops that could go inside it are partway
     down the cards themselves, and snapping a reader to the middle of a
     card they are reading is worse than not snapping at all. A single
     coherent block you are meant to linger in is exactly the case
     proximity exists to stay out of. */
}

/* ══════════════════════════════════════════════════════════
   THE CASE-STUDY RAIL
   ══════════════════════════════════════════════════════════
   A horizontal scroller, not a carousel, and the distinction is the
   reason this is safe to use for the most important content on the site.

   The evidence against carousels is specific: slides that advance on
   their own, content hidden behind controls, and engagement falling off
   a cliff after the first slide. None of the three applies here. Nothing
   moves unless the visitor moves it. The next card is always partly on
   screen -- the peek IS the affordance -- so nothing is hidden. And it
   is a real scroll container, so a trackpad swipe, a touch drag, Tab,
   and the arrow keys all work with no script at all; the buttons below
   are a convenience for a mouse without a horizontal wheel, not the
   mechanism.

   `x mandatory` here against `y proximity` on the page is not an
   inconsistency, it is the one condition that makes mandatory safe:
   every card is NARROWER than the rail, so no card can be scrolled past
   or left half-reachable. The vertical axis fails that test badly -- a
   band runs 3591px against a 768px viewport -- which is why the page
   itself gets proximity.
   ══════════════════════════════════════════════════════════ */

.band--rail { padding-block: var(--space-16); }

/* The head keeps the content column; the rail itself runs full-bleed so
   cards can scroll out past the gutter instead of stopping inside it. */
.band__head--rail {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: var(--space-8);
  flex-wrap: wrap;
}

/* A card in the rail is a vertical card: cover on top, words under it.
   The two-column case-study card is a shape for a full-width row, and
   inside a 540px track it would leave a 200px text column. */


/* Three lines of the summary, not five. The full text is on the project
   page, which is where someone who wants it is going; on the card its
   job is to say enough to decide. Clamping also makes every card the
   same height, so the rail's cards share a baseline. */


/* A thin scrollbar rather than none. Hiding it removes the one piece of
   permanent evidence that the rail scrolls at all, which is the single
   most common way this pattern goes wrong. */


/* ── the nav: counter and two buttons ── */


@media (max-width: 639px) {
/* The counter alone on a phone: two 44px targets plus the head's own
     wrapping left the buttons on a line of their own under the heading,
     where they read as page navigation rather than as this rail's. Touch
     has the gesture anyway. */
}

/* ══════════════════════════════════════════════════════════
   COLLAPSIBLE CASE-STUDY SECTIONS
   ══════════════════════════════════════════════════════════
   Fixerloop rendered at 26,747px -- about 35 screens for one project.
   Every section is a <details> now, so the page opens as a readable
   index of itself and the depth is one click away per section.

   <details> and not a scripted accordion: <summary> is already a button
   to the keyboard and to assistive tech, it already exposes expanded
   state, and it still works with JavaScript off. Nothing here recreates
   any of that in script.
   ══════════════════════════════════════════════════════════ */

.section__summary {
  display: grid;
  grid-template-columns: 1fr auto auto;
  align-items: center;
  gap: var(--space-2) var(--space-6);
  padding-block: var(--space-4);
  cursor: pointer;
  list-style: none;
  /* The default disclosure triangle, removed in both dialects. The
     marker sits at the start of the box and would push a two-line
     summary off the content column's left edge. */
  -webkit-tap-highlight-color: transparent;
}
.section__summary::-webkit-details-marker { display: none; }
.section__summary::marker { content: ""; }

.section__summary > .project__eyebrow { grid-column: 1; grid-row: 1; margin: 0; }
.section__summary > h2 { grid-column: 1; grid-row: 2; margin: 0; }
.section__count {
  grid-column: 2; grid-row: 1 / span 2;
  font-family: var(--font-mono);
  font-size: var(--font-size-xs);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--color-text-faint);
  white-space: nowrap;
}

/* The chevron, on the summary itself so there is no extra element and
   nothing for a screen reader to announce twice -- <summary> already
   says "expanded" or "collapsed". */
.section__summary::after {
  content: "";
  grid-column: 3; grid-row: 1 / span 2;
  width: 32px; height: 32px;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-full);
  background:
    linear-gradient(currentColor, currentColor) center / 11px 1px no-repeat,
    linear-gradient(currentColor, currentColor) center / 1px 11px no-repeat;
  color: var(--color-text-muted);
  transition: transform .35s var(--ease-out), border-color .25s var(--ease-out),
              color .25s var(--ease-out);
}
/* Open: the vertical stroke rotates away, so a + becomes a -. One
   element, two states, no icon swap. */
.project__section[open] > .section__summary::after {
  transform: rotate(90deg);
  background:
    linear-gradient(currentColor, currentColor) center / 11px 1px no-repeat;
}

.section__summary:hover::after,
.project__section[open] > .section__summary:hover::after {
  color: var(--color-text);
  border-color: var(--color-border-strong);
}
.section__summary:hover > h2 { color: var(--color-accent); }
.section__summary > h2 { transition: color .3s var(--ease-out); }

/* The focus ring belongs on the row, not on the text node inside it. */
.section__summary:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 4px;
  border-radius: var(--radius-sm);
}

/* A hairline between sections, so a page of closed ones reads as a list
   rather than as a stack of floating headings. */
.project__section { border-top: 1px solid var(--color-border); }
.project__section:last-of-type { border-bottom: 1px solid var(--color-border); }

.section__body { padding-bottom: var(--space-8); }

/* ── expand / collapse all ── */
.sections__control {
  display: flex;
  justify-content: flex-end;
  padding-block: var(--space-4);
}
.sections__control[hidden] { display: none; }
.sections__toggle {
  min-height: 32px;
  padding: 0 var(--space-4);
  font-family: var(--font-mono);
  font-size: var(--font-size-xs);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--color-text-muted);
  background: var(--color-bg-raised);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-full);
  cursor: pointer;
  transition: color .25s var(--ease-out), border-color .25s var(--ease-out);
}
.sections__toggle:hover { color: var(--color-text); border-color: var(--color-border-strong); }

/* ══════════════════════════════════════════════════════════
   SELECTED WORK — one column, at a size you can actually read
   ══════════════════════════════════════════════════════════
   This band was four columns, which put each cover at 218x136. Its own
   description promises "the screens, not the write-up" and then showed
   the screens at a size where no screen is legible -- a UI screenshot at
   218px is a smudge, so the band was texture rather than evidence.

   One column at 520px is 2.4x the width. The reference for this
   (haythem.cv/gallery) runs a 540px single column with 207px between
   items, and the thing that makes it read as clean is that every image
   is the SAME width -- no grid, no competing aspect ratios.

   520 and not wider: the covers are 900px from the image pipeline, and
   the case-study cards in the rail are 540. Tier 2 staying just inside
   Tier 1 keeps the hierarchy the two bands exist to express, and keeps
   every cover rendering under its natural width.
   ══════════════════════════════════════════════════════════ */


/* The title carried the small-card size because it used to sit in a
   218px column. At 520 it is the same step as a case-study card. */


/* ══════════════════════════════════════════════════════════
   THE FANNED STACK
   ══════════════════════════════════════════════════════════
   Three cards at opposing rotations, overlapping like a hand held in
   one fist. It replaced a marquee of ultra-condensed poster type that
   belonged to a near-black page.

   The rotations are small and ALTERNATE in sign. One shared angle reads
   as a mistake -- a grid that slipped -- where +/- reads as deliberate.
   Nothing animates on its own, so WCAG 2.2.2 does not apply here rather
   than being satisfied: the only motion is the lift on hover and focus.

   Negative horizontal margins are what makes it a fan rather than a
   row: each card pulls into its neighbour so the edges tuck under.
   ══════════════════════════════════════════════════════════ */
/* Bottom padding only, and small: the panel closes the section now, where
   this used to have to hold space against the band below it. */
.fan { padding-block: 0 var(--space-2); }

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

.fan__card {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  padding: var(--space-8);
  border-radius: var(--radius-xl);
  background: var(--color-card-sand);
  color: var(--color-text);
  /* Tinted rather than black: a neutral shadow on a warm paper ground
     reads as grey haze. This one carries the ink's own hue. */
  box-shadow: 0 18px 40px -24px var(--color-shadow-card);
  transition: transform .45s var(--ease-out), box-shadow .45s var(--ease-out);
}
.fan__card--sand { background: var(--color-card-sand); }
.fan__card--clay { background: var(--color-card-clay); }
.fan__card--ash  { background: var(--color-card-ash); }

.fan__label {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--font-size-lg);
  font-weight: var(--font-weight-semibold);
  letter-spacing: var(--tracking-heading);
}
.fan__body {
  margin: 0;
  flex: 1;
  color: var(--color-text-muted);
  line-height: var(--line-height-snug);
  text-wrap: pretty;
}
/* The pill, in the reference's register: solid ink on a pastel card. */
.fan__cta {
  align-self: flex-start;
  margin-top: var(--space-2);
  padding: var(--space-3) var(--space-6);
  border-radius: var(--radius-full);
  background: var(--color-bg-inverse);
  color: var(--color-text-inverse);
  font-size: var(--font-size-sm);
  font-weight: var(--font-weight-medium);
  text-decoration: none;
  transition: transform .25s var(--ease-out), background-color .25s var(--ease-out);
}
.fan__cta:hover { transform: translateX(3px); }

/* The fan only exists where there is room for cards to overlap. Below
   this they stack square, which is the honest mobile form -- a rotated
   card at 325px wide clips its own corners against the gutter. */
@media (min-width: 900px) {
  .fan__stack {
    grid-template-columns: repeat(3, 1fr);
    gap: 0;
    padding-inline: var(--space-8);
  }
  .fan__card { min-height: 310px; }
  /* The overlap is translateX, NOT a negative margin. A margin changes
     the layout box, so the two outer cards came out 336px against the
     middle one's 304 -- a 32px difference that reads as a mistake rather
     than as a fan. Transform moves the painted card without touching the
     grid, so all three stay one column wide and only their positions
     differ. (Their rotated bounding boxes still differ, which is
     geometry, not layout: measure offsetWidth, not getBoundingClientRect.) */
  .fan__card:nth-child(1) { transform: rotate(-5deg) translate(var(--space-8), 6px); }
  .fan__card:nth-child(2) { transform: rotate(1.5deg); z-index: 2; }
  .fan__card:nth-child(3) { transform: rotate(5deg) translate(calc(var(--space-8) * -1), 6px); }

  /* A hand of two, for when the card list is one short.
     Not currently reached -- there are three cards -- but the list comes
     from site.json and has been two before.

     The three rotations above are -5 / +1.5 / +5: the middle card is the
     near-upright one the outer two lean against, and it is also the only
     one with a z-index. Drop it and the remaining rules still match by
     position, so two cards came out at -5 and +1.5 with the track still
     repeat(3, 1fr): one tilted, one almost straight, sitting in columns 1
     and 2 with the third empty and the group left of centre.

     The first fix for that overlapped and clipped the second card's text,
     and the reason is worth keeping: a ROTATED card is wider than its
     layout box. At 380x310 and 3.5deg the painted box measures
     380*cos(3.5) + 310*sin(3.5) = 398px, 18px past the column it was
     given, while the two translate values pulled another 48px of overlap
     on top. So: no translate here, and a gap wider than the 18px of
     overhang. The lean is the whole effect; the convergence was never
     needed with only two. */
  .fan__stack:has(> :nth-child(2):last-child) {
    grid-template-columns: repeat(2, minmax(0, 380px));
    gap: var(--space-6);
    justify-content: center;
  }
  .fan__card:first-child:nth-last-child(2) { transform: rotate(-3.5deg); }
  .fan__card:first-child:nth-last-child(2) ~ .fan__card { transform: rotate(3.5deg); }

  /* Hover lifts the card out of the fan and squares it up, so the one
     you are reading is the one in front and the only one straight. */
  .fan__card:hover,
  .fan__card:focus-within {
    transform: rotate(0deg) translateY(-10px);
    z-index: 3;
    box-shadow: 0 30px 60px -28px var(--color-shadow-lift);
  }
}

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

/* ── the portrait, in How I work ──────────────────────────
   It used to sit inside the marquee with poster type sliding across it.
   That band is gone, and this is the section that makes a claim about
   how its author works, so this is where the face belongs. */


/* ══════════════════════════════════════════════════════════
   THE YEAR RAIL
   ══════════════════════════════════════════════════════════
   A dated spine: a dot and a year on the left, the project on the
   right, newest first. Both work bands render it, which is the point --
   they used to be two unrelated components, a horizontal scroller and a
   stack of covers, so the page had two different ways of showing the
   same kind of thing.

   The tiers differ by what each row SAYS, not by being different
   components: a case-study row carries chips, a summary and a
   full-width cover; a gallery row carries one line and a thumbnail.
   ══════════════════════════════════════════════════════════ */

.band--work { padding-block: var(--space-12) var(--space-8); }
@media (min-width: 900px) {
  .band--work { padding-block: var(--space-16) var(--space-12); }
}

.rail-years {
  list-style: none;
  margin: 0;
  padding: 0;
}

.entry {
  display: grid;
  gap: var(--space-4);
  padding-block: var(--space-12);
  border-top: 1px solid var(--color-border);
  position: relative;
}
.entry:first-child { border-top: 0; padding-top: var(--space-4); }

/* ── the spine ── */
/* The year is set in the display face, not the mono label face it used to
   share with the eyebrows. At 13px mono it was a caption on a row whose
   title runs 32px; it read as metadata rather than as the thing the rail
   is ordered by.

   The column had to grow with it. Measure the string as it RENDERS, not
   as it is authored: text-transform makes "2024-now" set as "2024-NOW",
   and caps are wider -- 159px against 141 at 32/700. Measuring the
   authored text put the column at 176 and the date silently wrapped to
   two lines, which is the one thing a fixed spine column cannot do. With
   the dot and its gap the column needs 183; it is 192, leaving 9px.
   The next longest string, "2020-21", takes 110.

   Colour stays --color-text-faint. The reference this came from sets its
   numerals in a very light grey, which on this paper computes to about
   1.4:1 -- fine for a decorative numeral, not for the date on a job. At
   32px/700 this is large text by WCAG, so 3:1 would have been the bar and
   4.55:1 clears it comfortably; the size is what makes these visible, not
   the weight of the ink. */
.entry__when {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--font-size-xl);
  font-weight: var(--font-weight-bold);
  line-height: var(--line-height-tight);
  letter-spacing: var(--tracking-display);
  text-transform: uppercase;
  /* So a future size change overflows visibly instead of quietly wrapping
     the spine into two ragged lines, which is how this went wrong once. */
  white-space: nowrap;
  color: var(--color-text-faint);
}
/* Scaled with the type it sits beside: a 9px dot next to 32px numerals
   reads as a speck. Still the accent ring that fills on hover. */
.entry__dot {
  width: 12px; height: 12px;
  border-radius: var(--radius-full);
  border: 2px solid var(--color-accent);
  flex: none;
}
/* Filled on hover, so the spine responds to the row you are reading. */
.entry:hover .entry__dot,
.entry:focus-within .entry__dot { background: var(--color-accent); }

.entry__body { display: grid; gap: var(--space-6); }
.entry__text { display: grid; gap: var(--space-3); justify-items: start; }

.entry__tags {
  list-style: none; margin: 0; padding: 0;
  display: flex; flex-wrap: wrap; gap: var(--space-2);
}
.entry__tags li {
  padding: 3px var(--space-3);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-full);
  font-family: var(--font-mono);
  font-size: var(--font-size-xs);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--color-text-faint);
  white-space: nowrap;
}

.entry__title {
  margin: 0;
  font-family: var(--font-display);
  font-weight: var(--font-weight-semibold);
  letter-spacing: var(--tracking-heading);
  line-height: var(--line-height-tight);
}
.entry--lg .entry__title { font-size: var(--font-size-2xl); }
.entry--sm .entry__title { font-size: var(--font-size-xl); }

/* The block link: one control per row, named by the project, with the
   prose read as its siblings rather than crammed into its accessible
   name. Same pattern the cards used before this. */
.entry__title a {
  color: inherit; text-decoration: none;
  transition: color .3s var(--ease-out);
}
.entry__title a::after {
  content: ""; position: absolute; inset: 0; z-index: 1;
}
.entry:hover .entry__title a { color: var(--color-accent); }
.entry__title a:focus-visible { outline: none; }
.entry__title a:focus-visible::after {
  outline: 2px solid var(--color-accent);
  outline-offset: 6px;
  border-radius: var(--radius-lg);
}

.entry__lede {
  margin: 0;
  font-size: var(--font-size-lg);
  line-height: var(--line-height-snug);
  color: var(--color-text);
  text-wrap: pretty;
}
.entry--sm .entry__lede { font-size: var(--font-size-base); color: var(--color-text-muted); }
.entry__note {
  margin: 0;
  color: var(--color-text-muted);
  line-height: var(--line-height-normal);
  max-width: 54ch;
  text-wrap: pretty;
}
.entry__cta {
  font-size: var(--font-size-sm);
  font-weight: var(--font-weight-medium);
  color: var(--color-accent);
}

/* ── the media ── */
.entry__media {
  margin: 0;
  overflow: hidden;
  border-radius: var(--radius-lg);
  /* The fallback is the warm paper-alt, so a project that never declares a
     tint still lands on something warmer than the image rather than on
     white. See the container-tints block at the top of this file. */
  background: var(--tint, var(--color-bg-alt));
  padding: var(--space-3);
}
.entry__media img {
  display: block; width: 100%;
  aspect-ratio: 16 / 10;
  object-fit: cover;
  object-position: 50% 0;
  /* Concentric, not eyeballed: outer 28px minus 12px of padding is 16px,
     which is exactly --radius-md. Change the padding and this has to move
     with it or the corners go visibly out of true. */
  border-radius: var(--radius-md);
  transition: transform .6s var(--ease-out);
}
.entry:hover .entry__media img { transform: scale(1.02); }

@media (min-width: 900px) {
  /* Spine left, project right. The year column is fixed so every dot
     lines up vertically -- a ragged spine is the one thing that would
     make this read as a list of unrelated rows again. */
  .entry {
    /* 192, not 160: the year is 32px display type now, and "2024-NOW"
       renders 159px wide -- 183 with the dot and its gap. */
    grid-template-columns: 192px minmax(0, 1fr);
    gap: var(--space-12);
    align-items: start;
  }
  .entry--lg { padding-block: var(--space-16); }
  .entry--lg .entry__body { gap: var(--space-8); }

  /* Tier 2 is a row, not a stack: thumbnail beside the line rather than
     a cover under it. */
  .entry--sm .entry__body {
    grid-template-columns: minmax(0, 1fr) 240px;
    align-items: center;
    gap: var(--space-8);
  }
  .entry--sm .entry__media img { aspect-ratio: 3 / 2; }
}

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

/* ══════════════════════════════════════════════════════════
   THE FLOATING NAV
   ══════════════════════════════════════════════════════════
   The header was a full-bleed sticky bar with a blurred backdrop. On a
   warm paper page built out of rounded cards, a hard-edged bar is the
   one rectangle left, and it reads as chrome bolted on top rather than
   as part of the layout.

   It floats now: the links sit in their own pill, and the contact link
   comes out of that row as a solid button, because a contact link
   between two page links reads as a third page rather than as the ask.
   ══════════════════════════════════════════════════════════ */
.site-head {
  /* The backdrop is BACK, and the comment above explains why it left:
     the blur moved onto the nav pill and the wordmark's own pill, so
     the bar itself did not need one.

     Then the wordmark's pill was removed, and nothing was left between
     a sticky header and the page scrolling under it -- the name sat
     directly on top of the h1, two sets of ink with nothing in between.
     That is the exact failure the note further down this file warned
     about, reintroduced by deleting the thing that was fixing it.

     A mask rather than a flat fill: opaque down to just short of the
     bottom so text passing underneath is not readable through it, then
     a short fade so the bar has no hard edge against the page. The
     pills keep their own blur and now sit ON this instead of standing
     in for it.

     THE FADE IS A FIXED LENGTH, NOT A PERCENTAGE, and that is the
     whole fix. It was 74%, which is a distance that grows with the
     header -- and the header is a different height at every breakpoint
     because the nav wraps to its own row on a phone. Measured at 375:
     header 193px, so the fade began at 143px and ran for 50. The nav's
     own bottom edge is at 145. The bar started going transparent TWO
     PIXELS ABOVE THE NAV IT EXISTS TO BACK, and 50px of the page
     scrolled visibly through the header on every phone.

     An absolute length cannot do that: the fade is 32px wherever the
     bottom edge happens to be. Clearance below the nav, measured
     after: 28px at 1280, 16px at 375. */
  background: linear-gradient(
    to bottom,
    var(--color-bg) 0%,
    var(--color-bg) calc(100% - var(--space-8)),
    transparent 100%);
  backdrop-filter: blur(8px);
  padding-top: calc(var(--inset-top) + var(--space-4));
  padding-bottom: var(--space-6);
}
/* The blur moves onto the pill, where it has an edge to belong to. */
.site-nav {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  /* Wraps on a phone, never on desktop. Adding the theme toggle put the row
     at 420px of content inside a 375px viewport, 21px of horizontal page
     scroll. A tighter gap only buys 8 of those, so the row has to be
     allowed to break; the media query below keeps it to one line from the
     width where it actually fits. */
  flex-wrap: wrap;
  justify-content: flex-end;
}
@media (min-width: 560px) {
  .site-nav { flex-wrap: nowrap; }
}
@media (max-width: 400px) {
  .site-nav { gap: var(--space-2); }
}
.site-nav__pill {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2);
  border-radius: var(--radius-full);
  background: color-mix(in srgb, var(--color-bg-raised) 72%, transparent);
  backdrop-filter: blur(12px);
  border: 1px solid var(--color-border);
}
/* `.site-head nav a.site-nav__cta`, not `.site-nav__cta`. The button is an
   <a> inside .site-head nav, so the generic nav-link rule scores (0,1,2)
   against this one's (0,1,0) and wins on specificity no matter which comes
   later in the file. The symptom was the muted ink colour landing on the
   dark fill: 2.24:1, an unreadable button, found by the contrast sweep
   rather than by looking -- at a glance it just looks like a dark pill. */
.site-head nav a.site-nav__cta {
  padding: var(--space-3) var(--space-6);
  border-radius: var(--radius-full);
  background: var(--color-bg-inverse);
  color: var(--color-text-inverse);
  text-decoration: none;
  font-size: var(--font-size-sm);
  font-weight: var(--font-weight-medium);
  white-space: nowrap;
  transition: transform .25s var(--ease-out);
}
.site-head nav a.site-nav__cta:hover { transform: translateY(-2px); }

/* THE HEADER'S ASK WAITS. At the top of the homepage this button used
   to sit 300px above the hero's own "Book a call", four identical words
   twice on one screen. It is collapsed until the hero's control has
   scrolled off the top -- see enhance.js -- which is the point where
   the page has finished introducing itself and an ask is a service
   rather than an interruption.

   max-width and visibility, not opacity: opacity alone would leave a
   gap in the header where the button is about to be, and a button that
   is invisible but still in the tab order. Scoped to .js, so with this
   file missing the button is simply always there. The visibility delay
   is what lets it finish collapsing before it stops being focusable. */
.js .site-head nav a.site-nav__cta {
  /* Positioned so the overflow below actually clips what is inside it.
     The label carries a visually-hidden "(opens in a new tab)" span,
     which is position:absolute -- so without a containing block here it
     belongs to the sticky header instead, is laid out at its static
     position past the end of a label the button is no longer showing,
     and escapes the clip. Measured at 768: one 1px span at x=816,
     49px of document overflow that body's overflow-x only hides. */
  position: relative;
  max-width: 0;
  padding-inline: 0;
  margin-left: calc(var(--space-2) * -1);
  opacity: 0;
  visibility: hidden;
  overflow: hidden;
  transition: max-width .45s var(--ease-out), padding .45s var(--ease-out),
              margin .45s var(--ease-out), opacity .3s var(--ease-out),
              transform .25s var(--ease-out), visibility 0s linear .45s;
}
.js .site-head nav a.site-nav__cta.is-shown {
  max-width: 12rem;
  padding-inline: var(--space-6);
  margin-left: 0;
  opacity: 1;
  visibility: visible;
  transition: max-width .45s var(--ease-out), padding .45s var(--ease-out),
              margin .45s var(--ease-out), opacity .3s var(--ease-out) .1s,
              transform .25s var(--ease-out), visibility 0s;
}
@media (prefers-reduced-motion: reduce) {
  .js .site-head nav a.site-nav__cta,
  .js .site-head nav a.site-nav__cta.is-shown { transition: none; }
}

@media (max-width: 639px) {
  /* Three rows was the old answer and it cost 249px of a 720px phone --
     a third of the screen spent on a header before anything it is a
     header FOR. The wordmark took row one, the pill and the toggle row
     two, and the call to action was left stranded on row three.

     Two rows now: the wordmark, then everything else across one line.
     The nav stops wrapping and spreads instead, and the two padded
     elements give up horizontal padding -- not vertical, because the
     44px touch targets are not negotiable and the padding is what makes
     them 44px. Measured at 320, 360, 375 and 414: no overflow at any of
     them, which is the whole reason the paddings are what they are. */
  /* ONE row now, not two. The two-row version measured 193px tall on a
     375x812 phone -- 24% of the screen, 29% on an SE -- spent on a
     header before anything it is a header FOR, and it was reported as
     exactly that. The wordmark had a whole row to itself and the nav
     had another.

     What made one row possible was taking the booking button out of it
     (see below): the row carried 280px of content in a 327px line, so
     there was nothing to give. Measured after: 123px, and the index
     rows clear the fold. */
  .site-head__inner { flex-wrap: nowrap; row-gap: 0; gap: var(--space-2); }

  /* SOLID on a phone. No gradient, no blur.
     
     The bar fades out over its last 32px so it has no hard edge against
     the page, and on a desktop that fade sits in empty space below the
     nav and is invisible. On a phone it is not invisible, for two
     reasons that compound:
     
       the nav wraps to its own row, so the header is ~250px tall with
         the safe-area inset -- a much bigger object to see through
       the page is 375px wide, so whatever is behind the fade is large
         text rather than the middle of a line
     
     Reported twice from a real handset, and reproduced here with the
     inset forced to 59px: the fade clears the nav by 30px and is STILL
     legible, because a 32px band of half-transparent page is simply
     visible at this scale. Moving the stop again would be tuning the
     wrong variable. The fade is a refinement the phone cannot afford,
     so the phone does not get it.
     
     backdrop-filter goes with it. Behind an opaque fill it draws
     nothing, and it costs a GPU layer on the device least able to
     spare one -- on a bar that is sticky, which is where iOS is
     fussiest about compositing. */
  .site-head {
    background: var(--color-bg);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }
  .site-head nav {
    width: auto;
    flex: none;
    flex-wrap: nowrap;
    justify-content: flex-end;
    gap: var(--space-1);
  }
  .site-head nav a { padding-inline: var(--space-3); }

  /* THE BOOKING BUTTON LEAVES THE PHONE HEADER. It is not lost: the
     same link is under Ways and in the closing band, and this page was
     shipping FOUR of them at once -- header, hero, ways, close -- which
     is what "too many calls to action" measured out to. The one in a
     sticky bar is the one asking hardest and earning it least, because
     it is on screen before the visitor has seen a single thing that was
     designed.

     Desktop keeps it. There the header is one 76px row with room to
     spare, and the ask costs nothing it is taking from the work. */
  .site-head nav a.site-nav__cta { display: none; }

  /* The pill gives up its chrome -- 16px of padding and 8px of internal
     gap around 113px of text, on the row with the least width to spare.
     The marker stays and loses its inset with it, so the current page is
     still a filled pill; it just no longer sits inside a second one. */
  .site-nav__pill {
    padding: 0;
    gap: 0;
    background: none;
    border-color: transparent;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }
  .site-nav__marker { inset-block: 0; }
}

/* ── the wordmark, and the backdrop it used to have ──────────────────
   The header used to be a full-bleed bar with a blurred background, so
   everything in it had something behind it. Moving that backdrop onto
   the nav pill left the wordmark bare, and because the header is still
   sticky, prose scrolled straight under the name. The answer then was to
   give the wordmark the same pill. */
/* No pill. The wordmark is already a shape -- two stacked lines with a
   rule holding the indent -- and wrapping a shape in a second shape was
   what made the old mark look cramped: the descender-free caps sat 8px
   from a border on all four sides and the surname ran to the edge. The
   nav pills stay; they are buttons and need a hit area. A logo is not a
   button. */

/* ── the case-study sidebar, as a card ────────────────────
   Contents and facts were loose text in a column, which is what they
   were on a near-black page where the column edge was the only surface
   available. On paper a soft card is the honest way to say "this is
   reference, not narrative", and it is the same object the rest of the
   layout is built from. */
@media (min-width: 900px) {
  .project__meta {
    padding: var(--space-8);
    border-radius: var(--radius-lg);
    background: var(--color-bg-raised);
    border: 1px solid var(--color-border);
  }
}

/* The cover takes the layout's radius -- a square-cornered full-bleed image
   is the one hard edge left on a page of rounded surfaces. The frame now
   carries --radius-lg, so the image inside takes the concentric inner one
   (28px frame minus 12px of inset = 16px). */
.cover-band img {
  border-radius: var(--radius-md);
}

/* ══════════════════════════════════════════════════════════
   THE JOURNEY — detail on hover
   ══════════════════════════════════════════════════════════
   Each role shows years, company and title; the place, the note and the
   case-study links open underneath when you hover the row.

   THE DETAIL IS NOT HIDDEN. It collapses with max-height and opacity,
   never display or visibility, so it stays in the accessibility tree and
   is read in order by a screen reader whether or not anyone can hover.
   And the collapse itself is scoped to `hover: hover and pointer: fine`:
   on a touch screen there is no hover to give, so everything is simply
   open rather than unreachable. `:focus-within` covers the keyboard,
   which works because every row that carries a note also carries a link
   into its case study.
   ══════════════════════════════════════════════════════════ */
.cv__row {
  border-radius: var(--radius-lg);
  padding: var(--space-4);
  margin-inline: calc(var(--space-4) * -1);
  transition: background-color .3s var(--ease-out);
}

@media (hover: hover) and (pointer: fine) {
  .cv__detail {
    max-height: 0;
    opacity: 0;
    overflow: hidden;
    margin-top: 0;
    transition: max-height .45s var(--ease-out),
                opacity .3s var(--ease-out),
                margin-top .45s var(--ease-out);
  }
  .cv__row:hover,
  .cv__row:focus-within { background: var(--color-bg-raised); }
  .cv__row:hover .cv__detail,
  .cv__row:focus-within .cv__detail {
    /* A generous ceiling rather than a measured height: max-height cannot
       animate to `auto`, and the tallest row here (T-ledger: a place, a
       note and two project links) measures well under this. Too low and
       the row clips its own last line, which is the failure mode worth
       over-shooting. */
    max-height: 24rem;
    opacity: 1;
    margin-top: var(--space-3);
  }
}

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

/* ══════════════════════════════════════════════════════════
   VALUES
   ══════════════════════════════════════════════════════════
   Numbered, because a value with an ordinal in front of it reads as a
   considered list rather than as three adjectives. Separate from the
   homepage's craft principles: that section is what he designs, this is
   how he works with the people around him.
   ══════════════════════════════════════════════════════════ */
.band--values { padding-block: 0 var(--space-12); }

.values {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--space-8);
  counter-reset: value;
}
.value {
  display: grid;
  gap: var(--space-3);
  padding: var(--space-8);
  border-radius: var(--radius-xl);
  background: var(--color-bg-raised);
  border: 1px solid var(--color-border);
}
.value__num {
  font-family: var(--font-mono);
  font-size: var(--font-size-xs);
  letter-spacing: var(--tracking-label);
  color: var(--color-accent);
}
.value__label {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--font-size-lg);
  font-weight: var(--font-weight-semibold);
  line-height: var(--line-height-snug);
  letter-spacing: var(--tracking-heading);
  text-wrap: balance;
}
.value__body {
  margin: 0;
  color: var(--color-text-muted);
  line-height: var(--line-height-normal);
  text-wrap: pretty;
}

@media (min-width: 900px) {
  .values { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-6); }
}

/* ── the nav marker ───────────────────────────────────────
   A single filled pill that slides to whichever item you are pointing
   at, and returns to the current page when you leave. The reference
   does this with an absolutely-positioned div whose left and width are
   set per item; so does this, in about twenty lines of vanilla JS.

   Without script the marker never appears and the current page keeps
   its tinted background, so the nav still says where you are. `is-live`
   is what JS adds to hand that job over -- the two never paint at once.
   ══════════════════════════════════════════════════════════ */
.site-nav__pill { position: relative; }
.site-nav__marker {
  position: absolute;
  inset-block: var(--space-2);
  left: 0; width: 0;
  border-radius: var(--radius-full);
  background: var(--color-bg-inverse);
  opacity: 0;
  pointer-events: none;
  transition: left .42s var(--ease-spring), width .42s var(--ease-spring),
              opacity .25s var(--ease-out);
}
.site-nav__pill.is-live .site-nav__marker { opacity: 1; }

/* The tinted current-page background is the no-JS fallback. Once the
   marker is live it would paint underneath it, so it goes. */
.site-nav__pill.is-live a[aria-current="page"] { background: none; }

/* Whichever link the marker is under reads inverted. The class is set in
   JS rather than inferred from :hover, because the marker also rests on
   the current page when the pointer is nowhere near the nav. */
.site-head nav .site-nav__pill a { position: relative; z-index: 1; }
.site-head nav .site-nav__pill a.is-marked { color: var(--color-text-inverse); }

@media (prefers-reduced-motion: reduce) {
  .site-nav__marker { transition: opacity .2s linear; }
}

/* ══════════════════════════════════════════════════════════
   A BIT ABOUT ME
   ══════════════════════════════════════════════════════════
   Statement, portrait, a paragraph and a door to the about page. It
   replaced a band of three craft principles whose content moved to the
   about page, where it sits beside the journey and the values instead
   of competing with the work for the homepage's attention.
   ══════════════════════════════════════════════════════════ */
.band--aboutblock { padding-block: var(--space-16) var(--space-8); }

/* ── a bit about me ───────────────────────────────────────────────────────
   This block sits directly under .fan -- three generous tinted cards -- and
   used to be loose content on bare paper, which is what made it read as
   inconsistent rather than merely quiet. It now takes the same geometry as
   those cards (--radius-xl, --space-8, the same tinted shadow) with a
   paper-family fill, so it is recognisably a sibling without competing
   with them for attention.

   The card also gives the leftover space a job. Measured before: the text
   column ran 279px of content inside a 375px row, centred, so 96px of it
   was void split above and below, and the statement's 20ch cap left 212px
   empty to its right. Inside a padded card the same air reads as margin. */
.aboutblock__card {
  /* 32px of padding costs a phone 64px of a 375px screen, which is most of
     a word per line. The roomier inset starts where there is room for it. */
  padding: var(--space-6);
  border-radius: var(--radius-xl);
  background: var(--color-bg-alt);
  box-shadow: 0 18px 40px -24px var(--color-shadow-card);
}

.aboutblock__label {
  margin: 0 0 var(--space-6);
  font-family: var(--font-mono);
  font-size: var(--font-size-xs);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--color-text-faint);
}
/* stretch, not center: centring is what produced two equal voids above and
   below the text. Stretched, the portrait takes the row's height and the
   two columns end on the same line. */
.aboutblock__grid { display: grid; gap: var(--space-8); align-items: stretch; }

.aboutblock__portrait { margin: 0; }
.aboutblock__portrait img {
  display: block;
  width: 100%;
  /* The 4/5 ratio pinned this to 375px tall whatever sat beside it, and the
     text only ever needed 279. Height now follows the row, with a floor so
     a short paragraph can never squash a portrait into a letterbox. */
  height: 100%;
  min-height: 320px;
  object-fit: cover;
  /* Biased DOWN, and load-bearing rather than taste: portrait.jpg is 0.56
     aspect going into a taller frame, so `cover` discards height, and at
     the default 50% the crop centres on the buildings behind and cuts the
     subject off at the frame edge -- the one thing it exists to show. */
  object-position: 50% 38%;
  border-radius: var(--radius-lg);
}

.aboutblock__text {
  display: grid;
  gap: var(--space-4);
  justify-items: start;
  /* Centred, having tried the alternatives against the real content.
     space-between pinned the statement and the button to the portrait's
     edges, but it did so by opening 77px between the three of them, and a
     heading, a paragraph and its button stop reading as one thing at that
     distance. `start` grouped them correctly and left the hole under the
     button instead. Centred, the group stays a group and the remaining air
     sits where the card is already padded, which is the one place on this
     block that empty space looks deliberate. */
  align-content: center;
}
.aboutblock__statement {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--font-size-2xl);
  font-weight: var(--font-weight-semibold);
  line-height: var(--line-height-tight);
  letter-spacing: var(--tracking-display);
  /* 20ch measured 400px inside a 612px column. 26ch uses the column without
     running past the comfortable reading measure for display type. */
  max-width: 26ch;
  text-wrap: balance;
}
.aboutblock__body {
  margin: 0;
  color: var(--color-text-muted);
  line-height: var(--line-height-normal);
  max-width: 48ch;
  text-wrap: pretty;
}
.aboutblock__cta {
  padding: var(--space-3) var(--space-6);
  border-radius: var(--radius-full);
  background: var(--color-bg-inverse);
  color: var(--color-text-inverse);
  text-decoration: none;
  font-size: var(--font-size-sm);
  font-weight: var(--font-weight-medium);
  transition: transform .25s var(--ease-out);
}
.aboutblock__cta:hover { transform: translateX(3px); }

@media (min-width: 900px) {
  .aboutblock__card { padding: var(--space-8); }
  /* The gap was --space-16. Inside a padded card the card's own inset
     already separates the columns from everything else, so the gap only has
     to separate them from each other. */
  .aboutblock__grid { grid-template-columns: 300px 1fr; gap: var(--space-8); }
}

/* The about page opens on the statement rather than on the word
   "About" -- a label for a nav item is not a thing to say to someone who
   has just arrived. */
/* ── the about page opens on a card ───────────────────────────────────────
   Same move as .aboutblock__card on the homepage, and for the same reason:
   this section was loose content on bare paper while the rest of the page
   is built from surfaces. --color-bg-alt, not white -- #FFFFFF is the one
   value that cannot merge with this paper. */
.about__card {
  padding: var(--space-6);
  border-radius: var(--radius-xl);
  background: var(--color-bg-alt);
  box-shadow: 0 18px 40px -24px var(--color-shadow-card);
}

/* THE SAME HEADLINE AS THE HOMEPAGE, which is the whole point of it.
   This was 88px at weight 400 in --color-accent-deep, next to a
   homepage claim at 136px, weight 700, in the page's own ink. Two
   pages, two typographic systems, one site. The accent was chosen for
   its contrast on paper and it does clear AAA -- but a pink headline
   is a different brand from a white one, and nothing else on either
   page is set in it.

   What is kept: the measure cap, because this sentence is longer than
   the claim and needs to break where it was written to break. */
/* `.about .about__statement`, not `.about__statement`. Three rules in
   this file set `.about h1` -- the legacy element styling, the 900px
   step, and the scale block -- and at (0,1,1) every one of them beats a
   bare class at (0,1,0). The symptom was silent and total: this rule
   set a size and a weight and the heading kept rendering at
   --font-size-3xl, weight 400, because the loser of a specificity
   contest does not report anything. Same trap, same shape, as the
   `.site-head nav a.site-nav__cta` note further up. */
.about .about__statement {
  font-family: var(--font-display);
  letter-spacing: var(--tracking-display);
  line-height: var(--line-height-tight);
  /* 18ch measured 846px. "Building is how I prove it." needs 880 and had
     1024 of room, so the cap -- not the type size -- was what broke the
     sentence over two lines. 21ch is 987: clear of the sentence, still
     short of the column, and still a cap at narrower viewports because ch
     scales with the clamped font size. */
  max-width: 21ch;
  text-wrap: balance;
  color: var(--color-text);
}
/* The homepage's desktop treatment, to the letter: same weight, same
   tracking, same 0.96 leading, measured against the same descender
   clearance. The size track is this page's own, because the sentence
   is six words longer and at 136px it would run to four lines. */
@media (min-width: 1100px) {
  .about .about__statement {
    font-size: min(clamp(4rem, 8vw, 7.5rem),
                   max(3rem, calc(26vh - 60px)));
    line-height: 0.96;
    letter-spacing: -0.05em;
    font-weight: var(--font-weight-bold);
    max-width: 24ch;
  }
}

/* ══════════════════════════════════════════════════════════
   THE SIMPLE PAGES
   The homepage said 557 words before its footer: an availability
   badge, a supporting paragraph, four stat blocks, a fanned card
   stack, an "A bit about me" band duplicating /about, two project
   rails carrying chips and summaries, and a "Let's talk" band
   repeating the links the footer repeated again. It says 38 now.

   What follows styles what replaced all of it. Rules above this
   banner that target .fan, .aboutblock, .opening__facts, .entry
   or .band--contact no longer match anything on any page; they are
   left in place rather than cut blind, because several are shared
   with the case studies.
   ══════════════════════════════════════════════════════════ */

/* ── the two actions, the site's only ask ─────────────────── */
.actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3);
  margin-top: var(--space-8);
}
/* The mailto twin of the copy button. Hidden here, shown by the
   <noscript> rule in head() or by enhance.js when the Clipboard
   API is missing -- see actions_row() in build.py. */
.js-copy-fallback { display: none; }

/* ── the hero's ask, as a sentence ─────────────────────────── */
/* Two solid pills above the fold ask for the meeting before the
   visitor has seen anything. Set as text, the same two controls read
   as an offer rather than a demand, and the loud pair still waits in
   the closing band where the work has argued for them.

   Both controls are .ask__act, one an <a> and one a <button>, so the
   button has to have its chrome taken off (appearance, background,
   border, padding, font) before the two can look like one sentence. */
.ask {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2) var(--space-3);
  margin: var(--space-8) 0 0;
  font-family: var(--font-display);
  font-size: var(--font-size-lg);
  font-weight: var(--font-weight-semibold);
  line-height: 1.3;
}
.ask__act {
  appearance: none;
  -webkit-appearance: none;
  background: none;
  border: 0;
  padding: 0;
  font: inherit;
  color: var(--color-text);
  cursor: pointer;
  touch-action: manipulation;
  /* Dotted, not solid: a solid underline on a word this size reads as
     a link in body copy, and these are controls. The offset keeps it
     clear of the descenders in "Copy" and "a call". */
  text-decoration-line: underline;
  text-decoration-style: dotted;
  text-decoration-thickness: 2px;
  text-underline-offset: 6px;
  text-decoration-color: var(--color-text-faint);
  transition: color .2s var(--ease-out),
              text-decoration-color .2s var(--ease-out);
}
.ask__act:hover,
.ask__act:focus-visible {
  color: var(--color-accent);
  text-decoration-color: var(--color-accent);
}
/* The focus ring is drawn properly rather than left to the underline
   colour: a colour change alone is not a focus indicator. */
.ask__act:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 4px;
  border-radius: var(--radius-sm);
}
/* .ask__or is GONE with the sentence it joined. The hero read
   "Book a call  or  Copy email" and now reads "See the work", because
   a page with four booking links on it was asking for the meeting
   before it had shown one thing that was designed. The copy-email
   control moved out with it; the closing band and /about still carry
   both, and both still carry their own live region. */

/* The hero's one action. inline-flex with a 44px floor because this was
   31px tall -- under the 44px touch minimum, on the single most
   important control above the fold. Two more were 31 and 22; see the
   rules below. */
.ask__act--see {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  min-height: 44px;
}
/* The arrow leans toward what it points at when you reach for it. The
   link already scrolls there; this says so before the click. */
.ask__down {
  transition: transform .25s var(--ease-out);
}
@media (hover: hover) {
  .ask__act--see:hover .ask__down,
  .ask__act--see:focus-visible .ask__down { transform: translateY(3px); }
}

/* ── the hero ─────────────────────────────────────────────── */
.hero-band { padding-block: var(--space-16) var(--space-12); }
.opening {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  /* The hero's older rule is still in the file above and still applies:
     it set `display: grid`, a 32px `gap` and its own padding-block. The
     display is overridden by being later, but gap and padding are not
     mentioned here unless they are reset, and they were adding 32px of
     padding plus 32px between every child -- which is why the margins
     below appeared to do nothing. */
  padding: 0;
  gap: 0;
}
/* Explicit margins rather than one flex `gap`, because the four things
   in here do not want equal spacing: the meta line belongs to the
   headline above it and the buttons are a separate beat. */
.opening__portrait {
  margin: 0 0 var(--space-6);
  width: clamp(56px, 7vw, 76px);
  aspect-ratio: 1;
  border-radius: var(--radius-full);
  overflow: hidden;
  background: var(--color-bg-alt);
}
/* Small and round. It was a 340px square filling a third of the
   first screen; at 72px it says "a person made this" without
   taking the room the headline needs to fit above the fold. */
/* A plain cover fit, which is all a square head-and-shoulders needs.

   What was here instead: a 472.8% width with -67.2% / -125.8% margins,
   because the old portrait was a 1702x2062 street scene with the face
   occupying about a tenth of the frame -- a cover-fit circle of it was
   a dark alley with a small person in it, so the image had to be scaled
   up inside the circle and dragged into place by hand. Three magic
   numbers that had to be re-measured together if the photograph ever
   changed.

   The photograph changed. It is cropped to 280x280 on the way in now
   (sips --cropOffset, see site/_src for the original), so the framing
   lives in the file where it belongs and the CSS has nothing to say. */
.opening__portrait img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
/* The greeting, above the claim and well under it in weight.
   It was line one of the h1 and spent a third of the largest type on
   the site saying hello. Demoted, not deleted: it is the only warm
   thing above the fold, and at --font-size-sm in muted ink it reads as
   someone introducing themselves before making the point, which is
   what it always was. */
.opening__greeting {
  margin: 0 0 var(--space-3);
  color: var(--color-text-muted);
  font-size: var(--font-size-sm);
}
.opening__claim { margin: 0; }
.opening__meta {
  margin: var(--space-4) 0 0;
  color: var(--color-text-muted);
  font-size: var(--font-size-sm);
}
/* Four names under the ask, set as a quiet sentence.
   --color-text-faint, not muted: this is corroboration, and it should
   be legible to a reader who goes looking for it without competing
   with the two actions directly above it. */
.opening__worked {
  margin: var(--space-4) 0 0;
  max-width: 48ch;
  color: var(--color-text-faint);
  font-size: var(--font-size-sm);
  line-height: var(--line-height-normal);
}

/* ── the hero as one composition, not a stack ───────────────────────
   Reference the owner brought: dakshgajjar.com, "especially the hero
   section with the animation and the spacement between everything".
   Measured rather than admired, at a 1024 viewport:

     headline   150px / weight 800 / line-height 0.78 / tracking -0.085em
     ours       87px  / weight 500 / line-height 1.04 / tracking -0.038em

   So the thing being reacted to is mostly scale: 1.7x the size, 300
   weight units heavier, negative leading, tracking three times tighter.
   The second half is structure -- his hero is exactly one viewport with
   the headline at the top and a small support row sitting ON the fold,
   so the first screen reads as a finished composition. Ours stacked
   five things from the top and stopped, which is why there was a hole
   under it.

   WHAT IS NOT TAKEN: the all-caps setting, because the claim is a
   sentence with a period and a voice, and caps flatten both; and the
   full-bleed SVG thread behind his type, which is his signature --
   copying it would make this read as his site rather than as one that
   learned from it. The work stack stays in the hero too, where his has
   none: a CEO specifically flagged that the first screen showed no
   work, and that note has not stopped being true.

   150px, not var(--header-height). The token says 5.5rem and the bar
   has measured 150px at every desktop width since the wordmark became
   two lines. The token is load-bearing for four scroll offsets that
   currently land correctly, so it is left alone and the real number is
   written here, where being wrong would put the foot row 62px below
   the fold instead of on it. */
@media (min-width: 1100px) {
  .hero-band {
    /* The inset is subtracted as well as the bar. 100dvh counts the
       strip under a notch or a rounded corner, so on any device with
       one -- an iPad in landscape is over 1100px -- the hero comes out
       exactly --inset-top too tall and pushes its own foot row off the
       fold it is measured against. There is a test for this.

       AND A CEILING, because the headline cannot grow past 136px: the
       column is capped at 1152 and the longest line fills 1044 of it at
       that size, so on a screen taller than about 890px the extra fold
       has nowhere to go and comes out as air between the headline and
       the strip. Measured at 1512x982: 262px of it, which is the same
       hole this hero was built to close, turned on its side.

       48rem is the first value that caps the air without giving up the
       fold fit at 1440x900, the commonest desktop here: there the fold
       term is 750 and still wins, so the strip lands exactly on 900.
       The ceiling only bites above an 886px fold, and holds the gap at
       about 198px from there up. Past that the hero stops growing and
       the first project row starts showing under it, which is the
       better answer anyway -- on a big screen you get the claim, the
       strip AND the work. */
    min-height: min(calc(100dvh - 150px - var(--inset-top)), 48rem);
    display: flex;
    flex-direction: column;
  }
  .opening {
    flex: 1;
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    gap: var(--space-12);
    padding: 0;
    /* STRETCH. The base rule sets align-items: flex-start, which is
       right for a single column of left-aligned text and wrong for two
       blocks that are meant to span the measure: it shrink-wrapped the
       foot row to 1001px of 1152, so the cover row came out 165px a
       card instead of 220 and stopped sharing an edge with the
       headline above it. Measured before this line: foot 1001. */
    align-items: stretch;
  }
  /* The face leaves the desktop hero. At 76px beside a 136px headline
     it is a thumbnail competing with the loudest thing on the page, and
     the reference has no portrait in its hero at all. It costs 100px of
     a first screen that was 148px over budget, it is still the first
     thing on /about, and the phone keeps it -- there the headline is
     32px and the two sit together at the same scale rather than one
     shouting over the other. */
  .opening__portrait { display: none; }
  /* 64/48 was the padding for a hero that ended whenever its text did.
     This one has to fit the fold with a foot row on it.
     Asymmetric, and the bottom number is the one that was measured: at
     32px the band came out 815px against a 800px fold on a 1280x800
     laptop, even though the foot row itself was already home at 783 --
     the overshoot was entirely padding sitting under it. 16px takes the
     band to 799. Above 900px of height none of this binds, because
     min-height wins and space-between pins the foot to the fold
     regardless. */
  .hero-band { padding-block: var(--space-8) var(--space-4); }
}

/* ── the headline, at the size the reference is actually using ──────
   The mobile track is untouched. A single clamp cannot grow faster on
   desktop than on a phone -- it is linear in vw -- and raising the
   shared track to reach 136px up here would put the phone at 43px,
   where "I design AI products" needs 364px of a 327px column and
   wraps. So small screens keep clamp(2rem, 8.5vw, 5.5rem) exactly as
   measured, and the jump is a desktop override.

   Fit, computed from the 681px the line measures at 88px and then
   verified in the browser: 128px at a 1280 viewport is 947px against
   1152 available, 136px at 1440 is 1006 against 1200. The tighter
   tracking is doing about 45px of that on its own.

   0.92, not 0.78. His setting is all caps, which has no descenders
   between lines to collide; "design" and "people" both have one, and
   below about 0.9 the g and the p start meeting the cap line under
   them. */
@media (min-width: 1100px) {
  .opening__claim {
    /* WIDTH AND HEIGHT, because this hero is measured against the fold.
       A width-only clamp gave 128px at 1280x800 -- a very ordinary
       laptop -- and 128px of headline plus the foot row needs 875px of
       the 800 available, so the foot fell 43px BELOW the fold it is
       supposed to sit on. Measured at 1100x800 and 1280x800 before
       this.

       The second term is the height budget: the fold less everything
       that is not the headline -- 150 bar, 64 padding, 36 greeting, 48
       gap, 94 foot -- divided over three lines at a 0.96 line box.

       The foot was 191px while it carried three project covers, and the
       constant here was 140px to match. The covers are gone and the
       strip measures 94, so 97px of fold came back; over three lines at
       0.96 that is 34px of type, and the constant comes down with it.
       Leave it at 140 and a 720px-tall laptop renders the headline 34px
       smaller than it has room for, with the slack showing as a hole
       above the strip.

       `min` takes whichever of the two constraints bites first: width
       on a wide short window, height on a narrow tall one.

       The `max` is a floor. Below about 700px of height the height term
       goes under the mobile size and, without it, a wide short window
       would render the desktop headline SMALLER than a phone's. At that
       point the hero overflows a little instead, which is the better
       failure. */
    font-size: min(clamp(5.5rem, 10vw, 8.5rem),
                   max(3.5rem, calc(31vh - 106px)));
    /* 0.96, arrived at by measurement rather than taste. The face's
       natural line box at 136px is 163px: ascent ~129, descent ~34, cap
       height ~98. At a 0.92 line box (125px) the cap top of each line
       sits 27px under the baseline above it while the descender of that
       line reaches 34px -- so "design" and "people" put their g and p
       7px INTO the line below. 0.96 gives a 131px box and 33px of
       clearance against a 34px descender, which is as tight as this
       setting goes before it collides. The reference reaches 0.78
       because all caps have nothing below the baseline to collide. */
    line-height: 0.96;
    letter-spacing: -0.05em;
    font-weight: var(--font-weight-bold);
    max-width: none;
  }
  /* The small type gets smaller and opens up, so the contrast between
     the headline and everything qualifying it is a decision rather than
     an accident. The reference runs 150px against a 12px support line;
     this is 136 against 13. */
  .opening__meta,
  .opening__worked {
    font-size: var(--font-size-xs);
    letter-spacing: 0.015em;
  }
}

/* ── the line reveal ────────────────────────────────────────────────
   The mechanism the reference uses, and the one this stylesheet
   already had: a clip one line tall with the line sliding up through
   it. The type arrives crisp instead of fading in, which at this size
   is the whole difference -- a 136px letterform at 40% opacity reads
   as a rendering artifact.

   clip-path, NOT overflow: hidden. The last line carries the selection
   frame, painted on pseudo-elements at `inset: -4px calc(-0.08em - 4px)`
   -- 4px outside the line box on all four sides, with handles on the
   corners. overflow:hidden clips to the padding box and would shear
   every edge and handle off it. clip-path clips to the border box and
   accepts negative insets, so -100% left and right disables horizontal
   clipping completely while the padding below gives the frame vertical
   room. Both devices survive.

   The padding is inside the clip and therefore visible, so each line
   appears about 0.07em before it is fully home. That is the same trade
   `.mask` already makes for descenders, at the same magnitude. */
.claim__reveal {
  display: block;
  /* Shrunk to its line, not the column. The clip below disables
     horizontal clipping entirely (-100% both sides), so this costs the
     reveal nothing and keeps the hook's fill sweep on the words rather
     than running it across the width of the column. */
  width: fit-content;
  padding-block: 0.07em;
  margin-block: -0.07em;
  clip-path: inset(0 -100% 0 -100%);
}

/* ── one left edge ──────────────────────────────────────────────────
   Every line starts at the column's left edge.

   The middle line was briefly set against the RIGHT margin, on the
   argument that a left column of three lines leaves the right third of
   the largest element on the page empty. It does -- but at 136px the
   staggered line reads as a line that got away rather than as a
   composition: it starts 259px in from the left and overhangs the line
   above it by 108px, which is the shape of a mistake. A ragged right on
   a left-aligned block is a setting; an indent that matches nothing else
   on the page is not.

   The space it was answering is answered by the foot row instead, which
   puts the three covers where the claim's right-hand margin used to be.

   Still a flex column, and that part is not cosmetic: the hook hangs a
   -0.14em margin off each line to keep the descenders of "design" and
   "people" inside background-clip: text, and in block layout those
   margins would collapse into each other and shift the headline while
   somebody was reading it. */
@media (min-width: 1100px) {
  .opening__claim {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
  }
}
@media (prefers-reduced-motion: no-preference) {
  html:not(.hook-on):not(.hook-done) .opening__claim .claim__line {
    animation: maskUp .95s var(--ease-reveal) both;
  }
  .claim__reveal:nth-child(1) .claim__line { animation-delay: .08s; }
  .claim__reveal:nth-child(2) .claim__line { animation-delay: .19s; }
  .claim__reveal:nth-child(3) .claim__line { animation-delay: .30s; }
  .claim__reveal:nth-child(4) .claim__line { animation-delay: .41s; }
}

/* ── the foot row, sitting on the fold ─────────────────────────────
   What qualifies the claim -- who, where, and the way in -- plus three
   covers. align-items: end so both halves share a baseline at the
   bottom of the screen rather than floating at their own heights. */
.opening__foot {
  display: grid;
  gap: var(--space-8);
}
@media (min-width: 1100px) {
  .opening__foot {
    /* Facts left, the one way in right, both sitting on the fold. It was
       a 420px column beside three project covers; with the covers gone
       that column would have left the right two thirds of the strip
       empty -- which is the hole the covers were put there to fill, back
       again. The content spans it instead. */
    grid-template-columns: minmax(0, 1fr) auto;
    gap: var(--space-12);
    align-items: end;
  }
  .opening__foot .ask { margin: 0; justify-content: flex-end; }
}

/* ── selected work, as an index ───────────────────────────
   Three 1152x720 cover cards filled two and a half screens and said
   four words each. Five rows fit in one screen and each says what the
   project was, which is the trade every reference the owner sent makes:
   none of the four shows large cover cards, and the two that show work
   on the homepage at all show a dated list.

   The cover is not gone. It follows the cursor -- see .peek below --
   which is the only place an image here earns its weight, because it
   appears when someone has already shown interest in a particular row. */
.band__title {
  font-size: var(--font-size-sm);
  font-weight: var(--font-weight-medium);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--color-text-faint);
  margin: 0 0 var(--space-6);
}
/* The title with a count opposite it. The line from the title to the
   right margin was empty on every section; the numeral fills it and
   tells a reader how long the list is before they start it.

   __bar, not __head: `.band__head` already names the title block of a
   chapter on the case-study pages, and one rule reaching both would be
   an accident waiting for whoever edits it next. */
.band__bar {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-4);
  margin: 0 0 var(--space-6);
}
.band__bar .band__title { margin: 0; }
.band__count {
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--font-size-xs);
  font-weight: var(--font-weight-medium);
  line-height: 1;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--color-text-faint);
  font-variant-numeric: tabular-nums;
}
/* Removes the 300ms wait a touch browser spends deciding whether a tap
   is the start of a double-tap zoom. It applies to the controls, not to
   the page, so pinch-zoom is untouched -- disabling that is an
   accessibility failure, this is just the tap delay. */
.btn,
.index__link,
.site-nav a,
.site-nav button,
.theme-toggle,
.site-foot a { touch-action: manipulation; }

.index { list-style: none; margin: 0; padding: 0; }
.index__row {
  border-top: 1px solid var(--color-border);
  transition: border-color .25s var(--ease-out);
}
.index__row:last-child { border-bottom: 1px solid var(--color-border); }
/* The hovered row's own hairline takes the accent. The page is made of
   hairlines, so part of the feedback is the page's own material getting
   brighter, under the wash set up below. */
.index__row:hover { border-top-color: var(--color-accent); }
.index__link {
  position: relative;
  display: grid;
  /* num | name | line | domain | year | arrow. The line takes the slack,
     so the three right-hand columns stay in a true vertical rule down
     the page -- which is what makes a list of five read as an index
     rather than as five paragraphs. */
  /* The name column grew with the type below: at clamp(1.5rem, 2.4vw,
     2.125rem) "Automotive retail" no longer fits beside a 7rem name
     without the line column collapsing. */
  grid-template-columns:
    2rem minmax(9rem, 13rem) minmax(0, 1fr) minmax(0, 10rem) 5rem 1rem;
  align-items: baseline;
  gap: var(--space-6);
  padding-block: var(--space-6);
  text-decoration: none;
  color: inherit;
}
.index__num {
  font-size: var(--font-size-xs);
  color: var(--color-text-faint);
  font-variant-numeric: tabular-nums;
}
/* The name carries the row. It used to be set at --font-size-lg, the
   same size as the ask under the cards two sections down -- so the four
   things the page is actually about were typographically level with a
   link. Up to 34px, the row's own headline. */
.index__name {
  font-family: var(--font-display);
  font-size: clamp(1.5rem, 2.4vw, 2.125rem);
  line-height: 1.1;
  font-weight: var(--font-weight-medium);
  letter-spacing: var(--tracking-heading);
  transition: transform .3s var(--ease-out);
}
.index__line,
.index__domain,
.index__year {
  font-size: var(--font-size-sm);
  color: var(--color-text-muted);
}
.index__line { transition: color .25s var(--ease-out); }
.index__domain, .index__year { color: var(--color-text-faint); }
.index__year { font-variant-numeric: tabular-nums; }
.index__arrow {
  color: var(--color-text-faint);
  transition: transform .3s var(--ease-out), color .25s var(--ease-out);
}
/* Everything below is paired :hover / :focus-visible on purpose. A row
   that only responds to a pointer is a row a keyboard cannot tell it
   has landed on. */

/* The highlight, rebuilt so it is not a box.

   The first version was `background: var(--color-bg-alt)` across the
   whole row: a hard-edged grey rectangle, the only filled shape
   anywhere near a section built from hairlines and page colour. It read
   as a selection box in a file manager following the cursor down the
   list.

   Three things fix that while keeping a real highlight:

     warm, not grey   -- the accent at 7%, so it reads as the row being
                         lit rather than a different surface appearing.
     rounded          -- radius-md corners, so it is a shape the rest of
                         this layout already uses.
     wider than the text -- it bleeds a space-4 past the content on both
                         sides, so the fill belongs to the ROW rather
                         than being clipped to where the words stop,
                         which is what made the edges read as a border.

   The spans are lifted to z-index 1 rather than the fill pushed to -1:
   a negative z-index escapes behind the page background the moment any
   ancestor makes a stacking context, and the band above this one has a
   scroll-driven animation that would do exactly that. */
.index__link::before {
  content: "";
  position: absolute;
  inset: 0 calc(var(--space-4) * -1);
  border-radius: var(--radius-md);
  background: color-mix(in srgb, var(--color-accent) 7%, transparent);
  opacity: 0;
  transition: opacity .22s var(--ease-out);
}
.index__link:hover::before,
.index__link:focus-visible::before { opacity: 1; }
.index__link > span { position: relative; z-index: 1; }

/* The cover, in the layout, for anyone who cannot hover.
   
   Hidden here on purpose. Where there is a cursor there is the peek
   preview, which shows the cover larger and on demand, and putting the
   same image in the row as well would be saying it twice.
   
   Where there is NO cursor there was nothing: the homepage rendered one
   image, the portrait, so a phone visitor saw a product designer's
   portfolio with no work in it. The media query below is the exact
   inverse of the peek's own gate, so the two can never both be on or
   both be off. */
.index__cover { display: none; }

/* The wash never travels alone. Three small movements ride with it, all
   made of things already on the page: the row's own hairline takes the
   accent, the line of description comes up from muted to full strength,
   and the name steps right as the arrow leaves. The fill is then the
   quietest part of the response rather than the whole of it, which is
   what keeps it from reading as a slab.

   The focus ring goes because focus-visible already gets all of it. */
.index__link:focus-visible { outline: none; }
.index__link:hover .index__name,
.index__link:focus-visible .index__name { transform: translateX(6px); }
.index__link:hover .index__line,
.index__link:focus-visible .index__line { color: var(--color-text); }
.index__link:hover .index__arrow,
.index__link:focus-visible .index__arrow {
  transform: translate(3px, -3px);
  color: var(--color-accent);
}
/* Focus still has to be VISIBLE, not just tinted: a background change
   alone is the kind of focus style that disappears against a busy
   screenshot or for anyone who cannot distinguish the two surfaces. */
.index__link:focus-visible {
  box-shadow: inset 2px 0 0 0 var(--color-accent);
}

/* Below this, the six columns stop being a table and start being six
   words stacked in a 40px gutter: at 375px the tagline rendered one
   word per line and the row stood 322px tall. Two lines instead --
   number, name and year across the top, the line underneath.

   The domain goes. It is the one fact of the five that the tagline
   already implies ("One of the first digital wallets in Tunisia" is
   not going to be mistaken for anything but fintech), and it is on the
   case study either way. The arrow goes too: there is no hover on a
   phone, so it was pointing at nothing. */
@media (max-width: 760px) {
  .index__link {
    grid-template-columns: 1.75rem minmax(0, 1fr) auto;
    grid-template-areas:
      "num  name line-end"
      "line line line"
      "pic  pic  pic";
    gap: var(--space-2) var(--space-3);
    padding-block: var(--space-4);
    align-items: baseline;
  }
  .index__num  { grid-area: num; }
  .index__name { grid-area: name; }
  .index__year { grid-area: line-end; }
  .index__line { grid-area: line; }
  .index__domain,
  .index__arrow { display: none; }
  .index__link:hover .index__name,
  .index__link:focus-visible .index__name { transform: none; }
  .index__cover { grid-area: pic; }
}

/* ══════════════════════════════════════════════════════════
   WAYS TO WORK TOGETHER
   Between the work and the ask, because it answers the question the
   work raises -- "what would engaging look like" -- and the closing
   band is where the answer gets acted on.

   Same surface as .about__card and .aboutblock__card: --color-bg-alt
   on --radius-xl with the one shadow this site uses. Three of them in
   an auto-fit grid rather than a fixed three columns, so the row
   degrades to two-up and then one-up without a breakpoint of its own.
   ══════════════════════════════════════════════════════════ */
.band--ways { padding-block: 0 var(--space-12); }
.ways {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
  gap: var(--space-4);
  margin: 0;
  padding: 0;
  list-style: none;
}
/* A flex column, not a block: the three cards are a set and should end
   level. Grid stretch gives them equal height already; this makes the
   body copy sit under the title rather than in the middle of the box
   the stretch created. */
.way {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  padding: var(--space-6);
  border-radius: var(--radius-xl);
  background: var(--color-bg-alt);
  box-shadow: 0 18px 40px -24px var(--color-shadow-card);
}
/* The numeral. Three cards in a row said "three of these" only by being
   three; this says it in the same mono the project rows count in. */
.way__num {
  position: absolute;
  top: var(--space-6);
  right: var(--space-6);
  font-family: var(--font-mono);
  font-size: var(--font-size-xs);
  font-weight: var(--font-weight-medium);
  line-height: 1;
  letter-spacing: 0.04em;
  color: var(--color-text-faint);
}
.way__title {
  margin: 0;
  /* Up one step. The title is what someone scans for; at --font-size-base
     it was the same size as its own body copy. The numeral sits in the
     top right, so the title has a right margin to clear it. */
  padding-right: var(--space-6);
  font-family: var(--font-display);
  font-size: var(--font-size-lg);
  font-weight: var(--font-weight-semibold);
  letter-spacing: var(--tracking-heading);
}
.way__body {
  margin: 0;
  color: var(--color-text-muted);
  font-size: var(--font-size-sm);
  line-height: var(--line-height-normal);
}

/* ── the cover, following the cursor ──────────────────────
   Built, positioned and animated by enhance.js. It rests off-screen
   rather than display:none so the first hover does not have to decode
   the image before it can show it. */
.peek {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 40;
  width: 300px;
  aspect-ratio: 16 / 10;
  border-radius: var(--radius-md);
  overflow: hidden;
  pointer-events: none;
  opacity: 0;
  transform: translate3d(-40vw, -40vh, 0);
  transition: opacity .2s var(--ease-out);
  box-shadow: 0 24px 60px var(--color-shadow-lift);
  background: var(--color-bg-alt);
}
.peek.is-on { opacity: 1; }
.peek img {
  width: 100%; height: 100%;
  object-fit: cover; object-position: center top;
  display: block;
}
/* No pointer, no hover: a touch device would only ever see this flash
   on tap, on its way to the page it is a preview of. */
@media (hover: none), (prefers-reduced-motion: reduce) {
  .peek { display: none; }
}

/* ── the closing ask, on three pages ──────────────────────── */
.band--close { padding-block: var(--space-16) var(--space-12); }
.band__close {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--font-size-3xl);
  font-weight: var(--font-weight-medium);
  letter-spacing: var(--tracking-heading);
  /* "Have something / in mind?", not "Have something in / mind?". The
     question is four words long and a ragged last line of one word
     reads as a mistake at this size. */
  text-wrap: balance;
}
.band--close .actions { margin-top: var(--space-6); }
/* THE CLOSING BAND IS A ROW on the homepage. It used to stack heading,
   proof, buttons and address down the left edge, which on a 1440px
   screen left the right half empty -- the same hole the hero had, at
   the other end of the page. The buttons go left, where the signal's
   trace lands; the two lines that qualify them share the buttons'
   baseline on the right.

   Scoped to .close__row, which only the homepage emits: /about and the
   case studies keep the stacked version, where there is no signal and
   the band is the whole section. */
.close__row {
  display: grid;
  gap: var(--space-6);
  margin-top: var(--space-8);
}
.close__row .actions { margin-top: 0; }
.close__row .close__proof { margin-top: 0; }
.close__row .reach { margin-top: var(--space-2); }
@media (min-width: 760px) {
  .close__row {
    grid-template-columns: auto minmax(0, 1fr);
    align-items: end;
    gap: var(--space-12);
  }
  .close__side {
    justify-self: end;
    max-width: 44ch;
    text-align: right;
  }
}
/* The sentence that cashes the h1's claim. Held to a readable measure
   rather than the band's full width -- it is prose, and the only prose
   on this page. */
.close__proof {
  margin: var(--space-4) 0 0;
  max-width: 54ch;
  color: var(--color-text-muted);
}
/* The address in plain sight, under the button that copies it. Muted,
   because it is here to be read and relayed rather than clicked -- the
   control above is the click. */
.reach {
  margin: var(--space-4) 0 0;
  font-size: var(--font-size-sm);
  color: var(--color-text-muted);
}
.reach__addr {
  color: inherit;
  text-underline-offset: 3px;
  text-decoration-color: var(--color-text-faint);
  transition: color .2s var(--ease-out);
}
.reach__addr:hover,
.reach__addr:focus-visible { color: var(--color-accent); }
.reach__sep { color: var(--color-text-faint); }

/* ── about: jobs, tools, how I work ───────────────────────── */
.about { padding-block: var(--space-16) var(--space-12); }
.about__lead { display: flex; flex-direction: column; gap: var(--space-12); }
/* The homepage's foot row, on this page. Prose on the left, something
   to look at on the right, one hairline across the top of both -- the
   same rule the hero draws under its claim. The columns are the other
   way round from the homepage's because what they hold is: there the
   left column is three short facts beside three covers, here it is two
   paragraphs beside seven marks. */
.about__foot {
  display: grid;
  gap: var(--space-8);
  padding-top: var(--space-8);
  border-top: 1px solid var(--color-border);
}
@media (min-width: 1100px) {
  .about__foot {
    grid-template-columns: minmax(0, 1fr) minmax(0, 360px);
    gap: var(--space-12);
    align-items: start;
  }
}
.about__intro { margin: 0; max-width: 58ch; }
.about__intro p:first-child { margin-top: 0; }
.about__intro p:last-child { margin-bottom: 0; }
/* The label sits on the marks the way a band bar sits on its list. */
.about__tools .band__title { margin: 0 0 var(--space-4); }
.jobs, .values { list-style: none; margin: 0; padding: 0; }
.job {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: var(--space-1) var(--space-6);
  /* The index's padding, not half of it: the rows beside these on the
     homepage breathe at space-6 and these were at space-4. */
  padding-block: var(--space-6);
  border-top: 1px solid var(--color-border);
  transition: border-color .25s var(--ease-out);
}
.job:hover { border-top-color: var(--color-accent); }
.job:last-child { border-bottom: 1px solid var(--color-border); }
/* The company carries the row, the way a project name carries one on
   the homepage, and at the same size. At --font-size-base these four
   rows were the smallest list on the site and the page they are the
   substance of read as a table. */
.job__company {
  font-family: var(--font-display);
  font-size: clamp(1.5rem, 2.4vw, 2.125rem);
  line-height: 1.1;
  font-weight: var(--font-weight-medium);
  letter-spacing: var(--tracking-heading);
}
.job__company a { text-decoration-color: var(--color-border-strong); }
.job__company a:hover { color: var(--color-accent); }
.job__role { grid-column: 1; color: var(--color-text-muted); font-size: var(--font-size-sm); }
.job__years {
  grid-column: 2; grid-row: 1 / span 2;
  align-self: center;
  color: var(--color-text-faint);
  font-size: var(--font-size-sm);
  font-variant-numeric: tabular-nums;
}
.values li { padding-block: var(--space-3); max-width: 62ch; }
/* The prose variant of the same section. Same measure as the list it
   replaces, and the muted ink the list gave its bodies -- this is the
   same content, so it should sit at the same weight on the page. */
.values__prose {
  margin: 0;
  max-width: 62ch;
  color: var(--color-text-muted);
}
.value__label { font-weight: var(--font-weight-bold); }
.value__body { color: var(--color-text-muted); }
.about__small { margin: var(--space-6) 0 0; font-size: var(--font-size-sm); }
.about__small a { color: var(--color-text-muted); margin-right: var(--space-4); }

.site-foot__sep { color: var(--color-text-faint); }

/* ── phones ───────────────────────────────────────────────── */
@media (max-width: 559px) {
  /* Full-width and stacked, because two pill buttons side by side at
     375px leaves each about 150px wide with the label compromising.

     `width: 100%` on the row itself is the part that actually does it:
     .actions sits in a flex column with align-items: flex-start, so it
     is shrink-to-fit, and stretching its CHILDREN to a shrink-wrapped
     parent just gave two 126px buttons in a column. */
  .actions {
    flex-direction: column;
    align-items: stretch;
    width: 100%;
    margin-top: var(--space-6);
  }
  .actions .btn { width: 100%; justify-content: center; }

  /* The hero has to fit the first screen here too, and at 375x760 it
     cleared the fold by three pixels -- which is to say it did not fit
     on any phone shorter than this one. The header already takes 177px
     of a phone, so the room comes out of the hero's own spacing. */
  .hero-band { padding-block: var(--space-6) var(--space-8); }
  .opening__portrait { margin-bottom: var(--space-4); }
  .opening__meta { margin-top: var(--space-4); }

  .card__row { gap: var(--space-1) var(--space-3); }
  .card__years { margin-left: 0; flex-basis: 100%; }
}

/* ── index covers, where there is no cursor ───────────────
   The inverse of the peek gate. A touch device gets the cover in the
   row; a pointer device gets it following the cursor. Never both.
   
   aspect-ratio with object-fit, not the image's own proportions: five
   covers at five different shapes would make the list lurch as it
   scrolls. The space is reserved by width/height on the <img> as well,
   so nothing reflows when a lazy image arrives. */
@media (hover: none), (pointer: coarse) {
  .index__cover {
    display: block;
    margin-top: var(--space-3);
    border-radius: var(--radius-md);
    overflow: hidden;
    background: var(--color-bg-alt);
  }
  .index__cover img {
    display: block;
    width: 100%;
    height: auto;
    aspect-ratio: 16 / 10;
    object-fit: cover;
  }
}

/* ── Signal: a forward pass through the homepage ──────────────────────
   See signal.js for the why. Two rules hold this file together:

   1. Every rule that touches page content is scoped to html.signal-on,
      which signal.js sets only after it has built the circuit. If the
      script fails, nothing here applies and the page is unchanged.
   2. Only tokens from tokens.css. The circuit re-themes with the page
      because it never names a colour of its own.
*/

/* The SVG is positioned against <main>. Set by the script, before it
   measures, so offsetTop chains end here. */
.signal-scope { position: relative; }

.signal {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
  z-index: 2;
  opacity: 0;
  transition: opacity .6s var(--ease-out);
}
.signal.is-ready { opacity: 1; }

/* The route before power reaches it. Faint enough to read as structure,
   not as a second line competing with the lit one. */
.signal__ghost {
  fill: none;
  stroke: var(--color-border-strong);
  stroke-width: 1;
  opacity: .55;
}

/* Lit wire. 1.5px, the same weight as the selection frame on
   "Then I build them.", so the trace reads as coming out of it. */
.signal__trunk,
.signal__wire {
  fill: none;
  stroke: var(--color-accent);
  stroke-width: 1.5;
  stroke-linejoin: miter;
  stroke-linecap: butt;
}

/* Branches are not scrubbed. They react when the trunk passes their
   fork, so a stub or a bus draws itself once instead of following the
   scroll wheel back and forth a pixel at a time. */
/* --len is set by signal.js on each path as it is created -- a branch
   never exists for a frame without one. Declared here anyway, because a
   custom property that is only ever written by a script looks exactly
   like a misspelling to anything reading the stylesheet. */
.signal__branch .signal__wire {
  --len: 0px;
  stroke-dasharray: var(--len) var(--len);
  stroke-dashoffset: var(--len);
  transition: stroke-dashoffset var(--dur, .45s) var(--ease, var(--ease-out));
}
.signal__branch.is-on .signal__wire {
  stroke-dashoffset: 0;
  transition-delay: var(--delay, 0s);
}

/* Nodes: the 7px dot with a 3px soft ring is .opening__dot, reused. */
.signal__dot {
  fill: var(--color-bg);
  stroke: var(--color-border-strong);
  stroke-width: 1.5;
  transition: fill .3s var(--ease-out), stroke .3s var(--ease-out);
}
.signal__halo {
  fill: var(--color-accent-soft);
  opacity: 0;
  transition: opacity .3s var(--ease-out);
}
.signal__pin.is-lit .signal__dot {
  fill: var(--color-accent);
  stroke: var(--color-accent);
  transition-delay: var(--delay, 0s);
}
.signal__pin.is-lit .signal__halo {
  opacity: 1;
  transition-delay: var(--delay, 0s);
}

/* Hover / keyboard focus on a row sends one ping out of its node. */
.signal__ping {
  fill: none;
  stroke: var(--color-accent);
  stroke-width: 1;
  opacity: 0;
  transform-box: fill-box;
  transform-origin: center;
}
.signal__pin.is-ping .signal__ping { animation: signal-ping .7s var(--ease-out); }

/* The input: a square handle, like the frame's corner handles. The
   circuit starts as a design-tool artefact and ends as a shipped button. */
.signal__handle {
  fill: var(--color-accent);
  opacity: 0;
  transition: opacity .35s var(--ease-out);
}
.signal__handle.is-lit { opacity: 1; }

/* The tip. Sits where the reader is. */
.signal__head { transition: opacity .2s var(--ease-out); }
.signal__head.is-off { opacity: 0; }
.signal__head-ring { fill: var(--color-accent-soft); }
.signal__head-dot  { fill: var(--color-accent); }

/* A resize rebuilds the SVG. Without this, every branch that was already
   on would draw itself again from zero. */
.signal.is-instant *,
.signal.is-still * { transition: none !important; animation: none !important; }
.signal.is-still .signal__head { display: none; }

/* ── what the signal does to the page ───────────────────── */

/* Project rows: the top rule sweeps in the accent as the signal arrives,
   then settles back. Same border, same colour as the existing hover. */
.signal-on .index__row { position: relative; }
.signal-on .index__row::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: -1px;
  height: 1px;
  background: var(--color-accent);
  transform: scaleX(0);
  transform-origin: 0 50%;
  opacity: 0;
  pointer-events: none;
}
.signal-on .index__row[data-signal="on"]::after { animation: signal-sweep 1.2s var(--ease-out) both; }
.signal-on .index__num { transition: color .3s var(--ease-out); }
.signal-on .index__row[data-signal="on"] .index__num { color: var(--color-accent); }

/* Ways cards: a hairline in the accent, delayed to match its tap on the bus. */
.signal-on .way { transition: box-shadow .45s var(--ease-out); }
.signal-on .way[data-signal="on"] {
  box-shadow:
    inset 0 0 0 1px color-mix(in srgb, var(--color-accent) 45%, transparent),
    0 18px 40px -24px var(--color-shadow-card);
  transition-delay: var(--signal-delay, 0s);
}

/* Output: one ring when the signal lands. Once, not a loop. */
.signal-on .band--close .btn--solid[data-signal="on"] { animation: signal-arrive 1.1s var(--ease-out); }

@keyframes signal-sweep {
  0%   { opacity: 1; transform: scaleX(0); }
  50%  { opacity: 1; transform: scaleX(1); }
  100% { opacity: 0; transform: scaleX(1); }
}
@keyframes signal-ping {
  0%   { opacity: .9; transform: scale(1); }
  100% { opacity: 0;  transform: scale(2.8); }
}
@keyframes signal-arrive {
  0%   { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-accent) 55%, transparent); }
  100% { box-shadow: 0 0 0 16px transparent; }
}

/* Reduced motion: the script draws the finished diagram and leaves the
   content alone; this makes sure nothing it does draw can move. */
@media (prefers-reduced-motion: reduce) {
  .signal, .signal * { transition: none !important; animation: none !important; }
  .signal-on .index__row::after { display: none; }
}

/* Decorative. Not for paper, not for a high-contrast theme that will
   flatten it into a stray black line through the margin. */
@media print { .signal { display: none; } }
@media (forced-colors: active) { .signal { display: none; } }

/* ── Hook: the claim, designed and then built ─────────────────────────
   Two seconds, once per session, and only for a visitor who has not
   asked for less motion. See hook.js for the sequence.

   Everything is scoped to html.hook-on, which the inline head script in
   build.py sets before first paint (so the solid headline never flashes)
   and which hook.js removes when it is done. A failsafe timer in that
   same head script removes it too, so a hook.js that never loads costs
   at most a few seconds of outlined, still-readable type.

   RETARGETED from the version this arrived as. That one assumed a hero
   whose claim lines were direct children of the h1 and whose supporting
   content sat in .opening__strip and .opening__cue. This hero wraps each
   line in its own .claim__reveal clip, and its supporting content is the
   portrait, the greeting and the foot row. Every selector below names
   what is actually in the markup; a test greps them to keep it that way. */

/* 1. Design: the claim as outlines. Still real text, still readable,
   still exactly what a screen reader gets. */
.hook-on .claim__line {
  color: transparent;
  -webkit-text-stroke: 1px var(--color-text-faint);
  background-image: linear-gradient(90deg, var(--color-text) 0 50%, transparent 50% 100%);
  background-size: 200% 100%;
  background-position: 100% 0;
  -webkit-background-clip: text;
  background-clip: text;
}
/* background-clip: text paints only inside the line box, and at a 0.96
   line box the descenders of "design" and "people" hang below it -- they
   would come out of the outline phase with their tails sliced off. Grow
   the box without moving anything.

   Not on the last line: it has no descenders, and both the selection
   frame and the signal's starting handle are measured from its box.

   The reveal clip has to open as well. .claim__reveal clips at the line
   box plus 0.07em; the 0.14em here would be cut by it, which would put
   the slice back one element further out. */
.hook-on .claim__line:not(.claim__line--built) {
  padding-block: .14em;
  margin-block: -.14em;
}
.hook-on .claim__reveal { clip-path: none; }

/* 2. Build: each line fills left to right, like a render pass. The
   delays hang off the wrapper, because each line is an only child of
   its own clip and :nth-child on the line itself is always 1.

   0.85s a line with a 0.16s stagger, up from 0.62 and 0.12. At the old
   pace the three lines overlapped so heavily that the sweep read as the
   whole headline brightening at once; at this one you can see it cross
   each line, which is the thing worth watching. */
.hook-on.hook-build .claim__line {
  animation: hook-build .85s var(--ease-out) forwards;
}
/* Explicitly 0, not inherited: the load cascade sets .08s on the first
   line and that rule is unguarded, so without this the build sweep would
   start 80ms late on line one and on time on the rest. */
.hook-on.hook-build .claim__reveal:nth-child(1) .claim__line { animation-delay: 0s; }
.hook-on.hook-build .claim__reveal:nth-child(2) .claim__line { animation-delay: .16s; }
.hook-on.hook-build .claim__reveal:nth-child(3) .claim__line { animation-delay: .32s; }
.hook-on.hook-build .claim__reveal:nth-child(4) .claim__line { animation-delay: .48s; }

@keyframes hook-build {
  to { background-position: 0 0; -webkit-text-stroke-color: transparent; }
}

/* The frame on the last line is shown by the cursor's click, not by the
   load animation. hook-done keeps the load animation off afterwards:
   removing hook-on would otherwise re-apply it and flash the frame a
   second time, a beat after the cursor already drew it. */
.hook-on .claim__line--built::before,
.hook-on .claim__line--built::after,
.hook-done .claim__line--built::before,
.hook-done .claim__line--built::after { animation: none; }
.hook-on .claim__line--built::after { -webkit-text-stroke: 0; }
.claim__line--built.is-selected::before,
.claim__line--built.is-selected::after { opacity: 1; transition-duration: .18s; }

/* The foot waits for the build, then rises. Named one by one rather
   than as .opening__top, because on this hero the h1 is INSIDE
   .opening__top and hiding that container would hide the very thing
   the hook exists to perform.

   THE GREETING AND THE PORTRAIT ARE NOT IN THIS LIST ANY MORE. They
   were, and the first screen of a first visit was an outlined headline
   alone on an empty page for the two seconds before the build -- which
   is not read as an intro, it is read as a page that failed to load.
   Reported as "it bugs for a second and then the animation starts".

   One line of greeting above the claim is enough to say the page is
   here and the headline is being drawn, rather than the page is
   broken. The foot still waits: it is the part that answers the claim,
   and it should not arrive before the claim is finished. */
.hook-on .opening__foot {
  opacity: 0;
  transform: translateY(12px);
}
.hook-on.hook-build .opening__foot {
  opacity: 1;
  transform: none;
  transition: opacity .6s var(--ease-out) var(--d, .2s), transform .8s var(--ease-out) var(--d, .2s);
}
/* After the last line has finished filling (.48 + .85), not during it. */
.hook-on.hook-build .opening__foot { --d: .7s; }

/* The cursor: a multiplayer pointer with a name tag. Decorative, built
   by hook.js, removed by hook.js. */
.hook-cursor {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 5;
  pointer-events: none;
  will-change: transform, opacity;
  /* Invisible until something is animating it. hook.js builds this
     element and then starts a delayed animation on it, and for the
     length of that delay the element renders at its own static style --
     which, with no rule here, was a fully opaque pointer parked in the
     top-left corner of the hero. The animation's backwards fill is the
     real fix; this is the belt, and it also covers the frame between
     appendChild and animate(). */
  opacity: 0;
}
.hook-cursor__arrow {
  display: block;
  width: 20px;
  height: 22px;
  /* The token, not a literal: this stylesheet has no colour values of
     its own, and a hardcoded black shadow is invisible in dark and
     wrong in light. --color-shadow-lift is the same one the peek
     preview casts. */
  filter: drop-shadow(0 3px 8px var(--color-shadow-lift));
}
.hook-cursor__arrow path { fill: var(--color-accent); stroke: var(--color-bg); stroke-width: 1.5; }
.hook-cursor__tag {
  position: absolute;
  top: 19px;
  left: 14px;
  padding: 3px 9px 4px;
  border-radius: 3px 10px 10px 10px;
  background: var(--color-accent);
  color: var(--color-accent-text);
  font: 600 11px/1.3 var(--font-mono);
  letter-spacing: .02em;
  white-space: nowrap;
}
.hook-cursor.is-flipped .hook-cursor__tag {
  left: auto;
  right: 6px;
  border-radius: 10px 3px 10px 10px;
}

@media (prefers-reduced-motion: reduce) {
  /* The head script never sets hook-on for this visitor. This is the
     belt to that pair of braces. */
  .hook-cursor { display: none; }
  .hook-on .claim__line { color: inherit; -webkit-text-stroke: 0; background: none; animation: none; }
  .hook-on .opening__foot { opacity: 1; transform: none; }
}
