/* ==========================================================================
   Mohamed Elhayyany — Portfolio (v2)

   1. Variables      every colour and spacing value, declared once
   2. Reset and base
   3. Shared pieces  container, labels, buttons, skip link
   4. Nav
   5. Mobile menu
   6. Hero
   7. Larger screens
   8. Reduced motion
   ========================================================================== */

/* --------------------------------------------------------------------------
   1. Variables — change a value here and it updates everywhere
   -------------------------------------------------------------------------- */

:root {
  /* Colour */
  /* Warm-beige base, light theme. Violet is the primary accent. */
  --bg:          #d6d0c1;
  --surface:     #e3dfd3;
  --border:      rgba(0, 0, 0, 0.08);
  --text:        #111111;
  --body:        #4a463d;
  /* Was #7a7568, which measured 2.99:1 on --bg — under the 4.5:1 floor for
     the 0.75rem labels this colours. #5c574c clears it at 4.74:1. Nothing
     on the page is lighter than this. */
  --muted:       #5c574c;
  /* Violet at display sizes: large headings, bars, dots, arrows, icons */
  --accent:      #7c3aed;
  /* Violet for small text. #7c3aed is 3.70:1 on --bg — fine for large type,
     short of 4.5:1 for anything set small, which is most of the violet on
     this page. This darker cut reaches 5.85:1 and carries all of it. */
  --accent-text: #5b21b6;
  /* Not "softer" any more — on a light background the hover state is a
     DARKER violet, and this variable is the hover colour at every one of
     its uses (filled buttons, the sidebar CTA, link hovers, cursor ring). */
  --accent-soft: #6d28d9;
  /* Kept at the requested value, but it cannot carry text on this
     background: #e8d900 against --bg measures 1.05:1, because a bright
     yellow and a light beige sit at almost the same luminance. Every
     text use of it is therefore on --accent instead, per the brief's own
     fallback. What is left using this is the ME-band collage tint, where
     it is an image wash rather than a foreground. */
  --accent-2:    #f5e500;

  /* The page colour with alpha, for the nav card and the mobile menu.
     Tracks --bg: it is meant to read as the page seen through blur, so
     if --bg moves this has to move with it. */
  --bg-blur: rgba(214, 208, 193, 0.82);

  /* Solid, and a shade darker than --bg, for the oversized name behind
     the hero — a subtle stamp rather than a headline. */
  --wordmark: #c9c2b0;

  /* The one bloom. Still a gradient — three elements use it as a
     background-image and a flat colour would break all of them — now a
     warm grey-brown, so on a light page it reads as a soft shadow cloud
     behind the content rather than a light source. */
  --glow: radial-gradient(circle at 50% 50%,
          rgba(120, 105, 90, 0.35) 0%,
          rgba(120, 105, 90, 0.10) 45%,
          transparent 70%);

  /* The second bloom, behind the Contact heading only. Same grey-brown,
     fainter — there is no warm accent left for it to echo. */
  --glow-warm: radial-gradient(circle at 50% 50%,
          rgba(120, 105, 90, 0.22) 0%,
          rgba(120, 105, 90, 0.06) 45%,
          transparent 70%);

  /* ---- STEP 10: the rest of the light palette -------------------------
     Everything below was a hard-coded value in a component until the dark
     theme arrived. There are no colour literals left outside this block,
     the STEP 2 block further down and the [data-theme="dark"] override.

     Tokens come in three kinds and it matters which is which:

     1. THEME tokens flip. --text, --surface, --border and the rest.
     2. FIXED tokens do not, because they are a colour ON something whose
        own colour never changes: white on violet, near-black on yellow,
        white on a scrim over a photograph. Aliasing those to --text is
        exactly how a dark theme ends up with yellow text on a yellow pill.
     3. INVERTED tokens flip the other way. The --vid-* set is the UGC
        band, which is the page's one reversed-out section: dark on a
        light page, and therefore light on a dark one.
     --------------------------------------------------------------- */

  /* Surfaces */
  --surface-strong: #d9d4c5;   /* a step up from --surface: glide, cards  */
  --panel-soft:  rgba(227, 223, 211, 0.72);   /* hero stat and trait boxes */
  --sink:        rgba(0, 0, 0, 0.05);         /* icon wells, chips         */
  --sink-hi:     rgba(0, 0, 0, 0.10);         /* their hover               */

  /* Ink */
  --faint:       #6b665b;      /* one step below --muted; 4.54:1 on --bg   */

  /* Lines. Three weights, because the page uses three. */
  --hairline:    rgba(0, 0, 0, 0.07);
  --hairline-2:  rgba(0, 0, 0, 0.12);
  --rule:        rgba(0, 0, 0, 0.22);
  --rule-2:      rgba(0, 0, 0, 0.18);

  /* Shadows. They do not invert: a dark page needs a DEEPER shadow, not a
     light one, or every card floats with no edge at all. */
  --shadow-1:    rgba(0, 0, 0, 0.05);
  --shadow-2:    rgba(0, 0, 0, 0.10);
  --shadow-3:    rgba(0, 0, 0, 0.18);
  --shadow-4:    rgba(0, 0, 0, 0.32);

  /* Accent, at alpha */
  --accent-ring: rgba(124, 58, 237, 0.4);
  --accent-glow: rgba(124, 58, 237, 0.55);
  --accent-wash: rgba(124, 58, 237, 0.10);

  /* The violet a FILLED control is painted in, as opposed to the violet
     text and icons are drawn in. They are the same here and differ in
     dark mode: the brief's #8b5cf6 is right for icons and large type but
     measures 4.23:1 under white text, just under the 4.5:1 floor, and
     most of the white text on this site is small. Filled controls keep a
     darker violet so the label on them stays legible. */
  --accent-fill:    var(--accent);
  --accent-fill-hi: var(--accent-soft);


  /* FIXED: these two are the whole reason the palette needs a category
     for "does not flip". White always sits on violet; near-black always
     sits on yellow. */
  --on-accent:   #FFFFFF;
  --on-accent-2: #111111;

  /* The big name, wherever it appears at display size: the hero
     wordmark, the copy of it that flies into the sidebar, and the
     divider before Contact. Its own token because it is the ONE use of
     the yellow that flips. The stat chips, the section numbers, the
     markers and the logo pill's BACKGROUND all stay yellow in both
     themes and must not follow this.

     Yellow in BOTH themes. On the dark page it carries itself; on the
     beige page it does not (#f5e500 on #d6d0c1 is 1.05:1, because a
     bright yellow and a light beige sit at nearly the same luminance),
     so in light it gets a hairline near-black outline and reads as a
     drawn letter rather than as a wash. */
  --name: #f5e500;

  /* The outline, two tokens. One colour and one width, so every surface
     that wears the name is tuned from one place and flips with the theme
     on its own.

     A HAIRLINE, deliberately. 0.006em is about 1.3px at the hero size
     and a little under 1px at the divider, which is enough to separate
     the yellow from the beige at every edge and not enough to read as a
     border. The earlier 0.030em plus a hard offset shadow was a sticker;
     this is a drawn letter.

     At this width it no longer matters whether the stroke is centred on
     the outline or painted behind it: half of 0.006em is a third of a
     pixel, far below the point where Inter 800 looks thinned. The stroke
     still goes behind, because it costs nothing and keeps the glyph
     exact, but nothing depends on it any more.

     There is NO shadow token. No offset copy, no glow, no second copy of
     the text anywhere. */
  --name-ink: #111111;
  --name-sw:  0.006em;

  /* FIXED: the stat chip is a dark plate carrying yellow numerals in both
     themes. The brief's #0b0b0b in dark is a shade deeper than the page,
     so the chip still reads as a plate rather than a hole. */
  --chip-bg:     #111111;
  --chip-border: transparent;

  /* FIXED: circular avatar badges, dark plate with light initials */
  --avatar-bg:   #111111;
  --avatar-ink:  #f7f5f0;

  /* FIXED: the hero heading sits on the portrait, not on the page */
  --hx-head:     #f7f5f0;

  /* The blurred page colour, at three strengths. Tracks --bg. */
  --bg-blur-solid: rgba(214, 208, 193, 0.97);
  --bg-blur-bar:   rgba(214, 208, 193, 0.92);

  /* FIXED: overlays and scrims. A lightbox is dark in both themes, and
     the scrim over a video thumbnail is dark because the thumbnail is a
     photograph, not because the page is light. */
  --overlay:        rgba(8, 8, 10, 0.92);
  --overlay-btn:    rgba(8, 8, 10, 0.6);
  --scrim:          rgba(0, 0, 0, 0.62);
  --lightbox-frame: #000000;

  /* FIXED: the availability dot */
  --live:        #22c55e;
  --live-ring:   rgba(34, 197, 94, 0.5);
  --live-ring-0: rgba(34, 197, 94, 0);

  /* The opened timeline card, which turns dark in place. In dark mode it
     cannot go darker than the page, so it goes one step LIGHTER and takes
     a hairline, which keeps "this card changed" legible either way. */
  --jr-open-bg:     #1f1f1f;
  --jr-open-ink:    #f7f5f0;
  --jr-open-body:   #c9c4b8;
  --jr-open-mute:   #9a958a;
  --jr-open-year:   #a78bfa;
  --jr-open-line:   rgba(255, 255, 255, 0.2);
  --jr-open-btn:    rgba(255, 255, 255, 0.12);
  --jr-open-border: transparent;

  /* INVERTED: the UGC band and the sidebar panels while they pass over
     it. Dark on a light page, light on a dark one, so the section stays
     the one place on the site that reverses out of its surroundings. */
  --vid-bg:       #111111;
  --vid-panel:    #1f1f1f;
  --vid-card:     #1a1a1a;
  --vid-ink:      #eae8e3;
  --vid-mute:     #b8b4aa;
  --vid-soft:     #8a8578;
  --vid-chip:     #2a2a2a;
  --vid-chip-hi:  #3a3a3a;
  --vid-track:    #333333;
  --vid-line:     rgba(255, 255, 255, 0.08);
  --vid-line-2:   rgba(255, 255, 255, 0.20);
  --vid-line-3:   rgba(255, 255, 255, 0.24);

  /* The Contact conversation (STEP 20). Four tokens, and they exist
     because a chat panel is a surface stack the palette did not have: a
     sheet, bubbles sitting ON the sheet, and a hover state above those.
     --surface and --panel are the same colour here, so a bubble painted
     --panel on a --panel sheet would be invisible.

     --cv-live is the availability dot and the line beside it. Green, not
     violet, because it means "available" rather than "brand".

     It has to carry its own text, and that text is 0.78rem, which is not
     large text, so the floor is 4.5:1 and not 3:1. Measured: #12a150 came
     out at 2.85:1 on the sheet and failed. #0f7a3a is 4.53:1 and still
     reads as a status light rather than as ink. */
  --cv-sheet: #efece4;
  --cv-in:    #e0dcd2;
  --cv-hover: #d8d3c7;
  --cv-live:  #0f7a3a;

  /* Spacing (STEP 10's dark override sits just below the close of this
     block; everything from here down is theme-independent) */
  --space-2xs: 0.375rem;
  --space-xs:  0.625rem;
  --space-sm:  1rem;
  --space-md:  1.5rem;
  --space-lg:  2.25rem;
  --space-xl:  3.5rem;
  --space-2xl: 5rem;

  /* Type */
  --font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
          "Helvetica Neue", Arial, sans-serif;

  --text-name:    clamp(3rem, 12vw, 8rem);
  --text-heading: clamp(2rem, 5vw, 3.5rem);
  --text-stat:    clamp(2rem, 6vw, 2.75rem);
  --text-lg:      1.25rem;
  --text-body:    1.05rem;
  --text-marquee: 0.875rem;
  --text-label:   0.75rem;

  --leading-name: 0.9;
  --leading-body: 1.75;

  --tracking-name:  -0.03em;
  --tracking-label: 0.16em;

  /* Layout */
  --wrap-max:   1200px;
  --measure:    65ch;
  --nav-height: 4.5rem;
  --section-padding: clamp(5rem, 12vh, 9rem);

  /* Shape and motion */
  --radius:    4px;
  --radius-sm: 8px;
  --radius-md: 12px;
  --radius-lg: 999px;

  --ease:       cubic-bezier(0.16, 1, 0.3, 1);
  --fast:       200ms;
  --slow:       400ms;
  --rise-time:  600ms;
  /* Shorter and on its own — the hero's load-in stagger, separate from
     --rise-time above (which the scroll-triggered section reveals keep
     using unchanged), so cutting the hero's worst-case blank window on
     a slow device doesn't also touch scroll-reveal timing */
  --rise-time-hero: 450ms;

  /* The nav sits above the menu on purpose: the burger lives inside it,
     and it has to stay reachable as the close button while the overlay
     is open. A child cannot escape its parent's stacking context, so
     lifting the button alone would not work. */
  --z-dots:     55;
  --z-menu:     60;
  --z-nav:      70;
  --z-lightbox: 80;
  --z-noise:    90;
  --z-cursor:   95;
  /* The atmosphere layer, behind every normal page element. -1 sits
     between the two — "above background, beneath content" for the
     cursor glow specifically. */
  --z-bg:          -2;
  --z-cursor-glow: -1;
}

/* --------------------------------------------------------------------------
   1b. STEP 10 - the dark palette

   One block, overriding colour tokens only. Nothing here touches spacing,
   type or z-index, and nothing in a component knows the theme exists: the
   whole switch happens in these declarations.

   The attribute goes on <html>, written by an inline script in <head>
   before this stylesheet is even requested, so the first paint is already
   in the right theme and there is no flash.
   -------------------------------------------------------------------------- */

[data-theme="dark"] {
  /* Warm near-black, not grey: the light theme is a warm beige and a
     neutral #121212 under it reads as a different site rather than the
     same one at night. */
  --bg:          #141312;
  --surface:     #1f1d1a;
  --surface-strong: #2a2825;
  --panel-soft:  rgba(42, 40, 37, 0.72);
  --sink:        rgba(255, 255, 255, 0.06);
  --sink-hi:     rgba(255, 255, 255, 0.11);

  --text:        #eae8e3;
  --body:        #c9c4b8;
  --muted:       #b8b4aa;
  --faint:       #8a8578;

  --border:      rgba(255, 255, 255, 0.08);
  --hairline:    rgba(255, 255, 255, 0.08);
  --hairline-2:  rgba(255, 255, 255, 0.13);
  --rule:        rgba(255, 255, 255, 0.22);
  --rule-2:      rgba(255, 255, 255, 0.18);

  /* Violet lifts. #7c3aed is 3.0:1 on this background and carries a lot
     of small text; #8b5cf6 reaches 4.6:1 and the text cut #a78bfa 7.4:1. */
  --accent:      #8b5cf6;
  --accent-text: #a78bfa;
  /* On a dark page the hover state is LIGHTER, the opposite of the light
     theme, which is why this is a token and not a darken() somewhere. */
  --accent-soft: #a78bfa;
  --accent-ring: rgba(139, 92, 246, 0.5);
  --accent-glow: rgba(167, 139, 250, 0.55);
  --accent-wash: rgba(139, 92, 246, 0.14);

  /* White on #8b5cf6 is 4.23:1, and a filled button's label is small
     text. #6d28d9 takes it to 6.6:1, and the hover goes UP to #7c3aed at
     5.3:1 rather than down, so the control still brightens on hover. */
  --accent-fill:    #6d28d9;
  --accent-fill-hi: #7c3aed;

  /* Yellow does not move. It is the one constant across both themes, and
     it still never carries text: #111111 sits on it in both. The name is
     the single exception, and it goes the other way: yellow here.

     No outline on the dark page. #f5e500 on #141312 is 14.2:1 with
     nothing helping it, and a near-black rim on a near-black background
     would only chew the letters back. Both tokens are neutralised rather
     than the rules being duplicated per theme. */
  --name: #f5e500;

  --name-ink: transparent;
  /* 0px, not a bare 0. The phone floors this with max(), and max() with a
     unitless zero inside it is invalid, so the whole declaration would be
     thrown away and the stroke width would fall back to the SVG initial
     value of 1. It paints nothing either way because the ink is
     transparent here, but an invalid declaration is a trap left lying
     about for whoever gives this colour next. */
  --name-sw:  0px;

  /* A stamp a shade lighter than the page, the mirror of the light
     theme's shade darker */
  --wordmark:    #211f1c;

  --bg-blur:        rgba(20, 19, 18, 0.86);
  --bg-blur-solid:  rgba(20, 19, 18, 0.97);
  --bg-blur-bar:    rgba(20, 19, 18, 0.94);

  /* Deeper, not lighter. A light shadow on a dark page is a glow, and a
     glow on every card is how a dark theme starts to look radioactive. */
  --shadow-1:    rgba(0, 0, 0, 0.30);
  --shadow-2:    rgba(0, 0, 0, 0.45);
  --shadow-3:    rgba(0, 0, 0, 0.55);
  --shadow-4:    rgba(0, 0, 0, 0.70);

  /* The warm bloom, at the tint the brief asks for */
  --glow: radial-gradient(circle at 50% 50%,
          rgba(120, 105, 90, 0.18) 0%,
          rgba(120, 105, 90, 0.06) 45%,
          transparent 70%);
  --glow-warm: radial-gradient(circle at 50% 50%,
          rgba(120, 105, 90, 0.12) 0%,
          rgba(120, 105, 90, 0.04) 45%,
          transparent 70%);

  /* The stat chip goes a shade below the page and takes a hairline, so it
     is still a plate rather than a hole punched in the panel */
  --chip-bg:     #0b0b0b;
  --chip-border: rgba(255, 255, 255, 0.14);

  --avatar-bg:   #0b0b0b;
  --avatar-ink:  #eae8e3;

  /* The opened timeline card goes one step LIGHTER than the page here,
     since it cannot go darker, and takes an edge to say it changed */
  --jr-open-bg:     #2a2825;
  --jr-open-ink:    #eae8e3;
  --jr-open-body:   #c9c4b8;
  --jr-open-mute:   #b8b4aa;
  --jr-open-border: rgba(255, 255, 255, 0.10);

  /* INVERTED. The UGC band is the page's one reversed-out section, so on
     a dark page it is the light one. The sidebar's switch code is
     untouched: .is-dark still means "over the band", it is only these
     values underneath it that swap. */
  --vid-bg:       #e3dfd3;
  --vid-panel:    #e3dfd3;
  --vid-card:     #d9d4c5;
  --vid-ink:      #111111;
  --vid-mute:     #5c574c;
  --vid-soft:     #6b665b;
  --vid-chip:     rgba(0, 0, 0, 0.05);
  --vid-chip-hi:  rgba(0, 0, 0, 0.10);
  --vid-track:    #c2bcac;
  --vid-line:     rgba(0, 0, 0, 0.07);
  --vid-line-2:   rgba(0, 0, 0, 0.14);
  --vid-line-3:   rgba(0, 0, 0, 0.16);

  /* The conversation. The sheet lifts off the page rather than sinking
     into it, and the bubbles lift again off the sheet, so the stack reads
     the same way round as it does in light. The dot goes lighter, because
     #12a150 on a near-black sheet is 2.6:1 and would disappear. */
  --cv-sheet:     #232120;
  --cv-in:        #2f2c29;
  --cv-hover:     #3a3733;
  --cv-live:      #46d07f;
}

/* --------------------------------------------------------------------------
   2. Reset and base
   -------------------------------------------------------------------------- */

*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  background-color: var(--bg);
  scroll-behavior: smooth;
  /* Stops a linked section landing underneath the fixed nav */
  scroll-padding-top: calc(var(--nav-height) + var(--space-sm));
  /* clip, never hidden. overflow-x: hidden here would make html a scroll
     container and stop body's overflow propagating to the viewport, so
     body would become a second scroll container and the page would carry
     two vertical scrollbars. `clip` creates no scroll container at all,
     so sticky and the pinned sections are untouched.

     It has to be on html as well as on body because the sidebar is
     position: fixed: its containing block is the viewport, so body's clip
     never reaches it, and the sidebar rows fly out past the right edge
     while the hero morph is scaling them. */
  overflow-x: clip;
}

/* Belt and braces alongside html's scroll-padding-top above: this puts
   the same offset directly on every section, the actual target elements
   rather than the scroll container. scroll-padding-top alone should be
   enough in modern browsers, but some scroll paths (a fragment link on
   first load before scroll-padding is fully honoured, older Safari)
   respect scroll-margin-top on the target more reliably. */
section {
  scroll-margin-top: var(--nav-height);
}

body {
  margin: 0;
  color: var(--body);
  font-family: var(--font);
  font-size: var(--text-body);
  line-height: var(--leading-body);
  -webkit-font-smoothing: antialiased;
  /* clip, not hidden: `hidden` on one axis forces the other axis to
     `auto`, which turns this into a scroll container. `clip` clips
     without ever becoming one, so the document stays the only thing
     that scrolls. */
  overflow-x: clip;
}

/* Set while the mobile menu is open, so the page behind can't scroll */
body.is-locked {
  overflow: hidden;
}

h1,
h2,
h3,
p,
dl,
dd {
  margin: 0;
}

img {
  display: block;
  max-width: 100%;
  height: auto;
}

a {
  color: inherit;
  text-decoration: none;
}

ul,
ol {
  margin: 0;
  padding: 0;
  list-style: none;
}

button {
  font: inherit;
  color: inherit;
  background: none;
  border: none;
  cursor: pointer;
}

strong {
  /* The emphasis pattern used throughout: a phrase lifted out of the
     sentence around it, brighter and heavier, never a different colour */
  font-weight: 600;
  color: var(--text);
}

em {
  /* The occasional italic accent word, for rhythm in a heading */
  font-style: italic;
  color: var(--accent);
}

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: var(--radius);
}

/* --------------------------------------------------------------------------
   2b. Atmosphere — the fixed background layer behind every section:
   one canvas (particles + sparkles, drawn by script.js), two static
   corner glows, a cursor-following glow, a grain overlay, and the
   custom cursor. All decorative and aria-hidden; script.js gates all of
   it behind prefers-reduced-motion and, for the cursor pieces, hover
   capability.
   -------------------------------------------------------------------------- */

.bg-canvas {
  position: fixed;
  inset: 0;
  z-index: var(--z-bg);
  pointer-events: none;
  /* iOS Safari treats a full-viewport fixed canvas as ordinary painted
     content, so it re-composites the whole thing against the page on
     every scroll frame. translateZ(0) promotes it to its own GPU layer,
     which takes it out of the scroll repaint path entirely — the single
     biggest win available on iPhone. The -webkit- prefix is harmless on
     current Safari and covers the older builds that need it. */
  -webkit-transform: translateZ(0);
  transform: translateZ(0);
  will-change: transform;
}

.bg-glow {
  position: fixed;
  z-index: var(--z-bg);
  width: min(56vw, 620px);
  aspect-ratio: 1;
  filter: blur(90px);
  opacity: 0.45;
  pointer-events: none;
}

.bg-glow--top {
  top: -12%;
  right: -12%;
  background: var(--glow-warm);
}

.bg-glow--bottom {
  bottom: -12%;
  left: -12%;
  background: var(--glow);
}

/* Follows the cursor on desktop only — script.js only ever moves this
   under finePointer, so on touch it just sits at 0,0 with opacity: 0 */
.cursor-glow {
  position: fixed;
  top: 0;
  left: 0;
  z-index: var(--z-cursor-glow);
  width: 400px;
  height: 400px;
  margin: -200px 0 0 -200px;
  border-radius: 50%;
  background: radial-gradient(circle, var(--accent-wash) 0%, transparent 70%);
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--fast) ease;
  will-change: transform;
}

.cursor-glow.is-active {
  opacity: 1;
}

/* A tiled feTurbulence SVG, not an external image — stays inside the
   "no CDN except fonts" rule. 3% opacity is deliberately barely there. */
.noise {
  position: fixed;
  inset: 0;
  z-index: var(--z-noise);
  opacity: 0.03;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  pointer-events: none;
}

/* ---- Custom cursor — desktop only ---- */

@media (hover: hover) {
  html {
    cursor: none;
  }

  a,
  button {
    cursor: none;
  }
}

.cursor-dot,
.cursor-ring {
  display: none;
  position: fixed;
  top: 0;
  left: 0;
  z-index: var(--z-cursor);
  border-radius: 50%;
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--fast) ease,
              width var(--fast) ease,
              height var(--fast) ease,
              margin var(--fast) ease,
              border-color var(--fast) ease;
}

@media (hover: hover) {
  .cursor-dot,
  .cursor-ring {
    display: block;
  }
}

.cursor-dot {
  width: 8px;
  height: 8px;
  margin: -4px 0 0 -4px;
  background-color: var(--accent);
}

.cursor-ring {
  width: 36px;
  height: 36px;
  margin: -18px 0 0 -18px;
  border: 1px solid var(--accent-soft);
}

/* Set once JavaScript has a real cursor position to draw, so nothing
   sits pinned at the top-left corner before the first mousemove */
.cursor-dot.is-active,
.cursor-ring.is-active {
  opacity: 1;
}

/* Set by script.js while the cursor is over a link, button, video card
   or work card */
.cursor-ring.is-hovering {
  width: 56px;
  height: 56px;
  margin: -28px 0 0 -28px;
  border-color: var(--accent);
}

/* --------------------------------------------------------------------------
   3. Shared pieces — the nav and hero use these, later sections will too
   -------------------------------------------------------------------------- */

.wrap {
  width: 100%;
  max-width: var(--wrap-max);
  margin-inline: auto;
  padding-inline: var(--space-md);
}

/* The small uppercase label: eyebrows, stat labels, section markers */
.label {
  font-size: var(--text-label);
  font-weight: 500;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--muted);
}

/* Hidden until a keyboard user tabs to it, then it drops into view */
.skip-link {
  position: absolute;
  top: var(--space-xs);
  left: var(--space-xs);
  z-index: 100;
  padding: var(--space-2xs) var(--space-sm);
  background-color: var(--accent-fill);
  color: var(--on-accent);
  font-size: var(--text-label);
  font-weight: 500;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  border-radius: var(--radius);
  transform: translateY(-200%);
  transition: transform var(--fast) ease;
}

.skip-link:focus {
  transform: translateY(0);
}

/* Buttons */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-xs) var(--space-lg);
  min-height: 3rem;
  font-size: var(--text-label);
  font-weight: 500;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  text-align: center;
  border: 1px solid transparent;
  border-radius: var(--radius-lg);
  transition: background-color var(--fast) ease,
              border-color var(--fast) ease,
              color var(--fast) ease,
              transform 200ms ease-out;
}

.btn--filled {
  background-color: var(--accent-fill);
  border-color: var(--accent-fill);
  color: var(--on-accent);
}

.btn--outline {
  border-color: var(--border);
  color: var(--text);
}

.btn--ghost {
  color: var(--muted);
}

/* Every hover is gated, so a tap on a phone can never leave a control
   stuck in its hovered state */
@media (hover: hover) {
  .btn--filled:hover {
    background-color: var(--accent-fill-hi);
    border-color: var(--accent-soft);
  }

  .btn--outline:hover {
    border-color: var(--accent);
    color: var(--accent-text);
  }

  .btn--ghost:hover {
    color: var(--text);
  }
}

/* --------------------------------------------------------------------------
   4. Nav — fixed, transparent over the hero, blurred once scrolled
   -------------------------------------------------------------------------- */

.nav {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: var(--z-nav);
  background-color: transparent;
  border-bottom: 1px solid transparent;
  transition: background-color var(--fast) ease,
              border-color var(--fast) ease;
}

/* Added by script.js once the page has been scrolled */
.nav.is-scrolled {
  background-color: var(--bg-blur);
  border-bottom-color: var(--border);
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
}

/* While the menu is open the nav is floating over the overlay, which has
   a background of its own — a second bar on top of it would only add a
   seam. Just the brand and the close button are left. */
body.is-locked .nav.is-scrolled {
  background-color: transparent;
  border-bottom-color: transparent;
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
}

.nav__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-md);
  min-height: var(--nav-height);
}

.nav__brand {
  font-size: var(--text-label);
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text);
  transition: color var(--fast) ease;
}

/* The inline links are desktop only — the overlay takes over below 900px */
.nav__links {
  display: none;
}

.nav__list {
  display: flex;
  align-items: center;
  gap: var(--space-md);
}

.nav__link {
  position: relative;
  display: inline-block;
  padding-block: var(--space-2xs);
  font-size: var(--text-label);
  font-weight: 500;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--muted);
  transition: color var(--fast) ease;
}

/* The hover underline — grown from the left, same technique as the
   Contact email's own hover underline further down */
.nav__link::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: -2px;
  width: 100%;
  height: 1px;
  background-color: var(--accent);
  transform: scaleX(0);
  transform-origin: left center;
  transition: transform var(--fast) ease;
}

/* "You are here" — script.js keeps this in sync with the section
   currently centred in the viewport, alongside the reading highlight. */
.nav__link.is-active {
  color: var(--accent-text);
}

.nav__resume {
  display: inline-block;
  padding: var(--space-2xs) var(--space-md);
  font-size: var(--text-label);
  font-weight: 500;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  transition: border-color var(--fast) ease,
              color var(--fast) ease;
}

@media (hover: hover) {
  .nav__brand:hover {
    color: var(--accent-text);
  }

  .nav__link:hover {
    color: var(--accent-text);
  }

  .nav__link:hover::after {
    transform: scaleX(1);
  }

  .nav__resume:hover {
    border-color: var(--accent);
    color: var(--accent-text);
  }
}

/* Menu button */
.burger {
  display: inline-flex;
  align-items: center;
  gap: var(--space-xs);
  padding-block: var(--space-2xs);
}

.burger__label {
  font-size: var(--text-label);
  font-weight: 500;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--muted);
  transition: color var(--fast) ease;
}

/* Three rules that fold into a cross. The middle one is the element's own
   background, so it can be wiped out while the outer two rotate — one
   continuous morph rather than two icons swapped over. */
.burger__icon {
  position: relative;
  width: 20px;
  height: 13px;
  background-image: linear-gradient(var(--body), var(--body));
  background-repeat: no-repeat;
  background-position: center;
  background-size: 100% 1px;
  transition: background-size var(--fast) ease;
}

.burger__icon::before,
.burger__icon::after {
  content: "";
  position: absolute;
  left: 0;
  width: 100%;
  height: 1px;
  background-color: var(--body);
  transition: top 300ms var(--ease),
              transform 300ms var(--ease);
}

.burger__icon::before { top: 0; }
.burger__icon::after  { top: 12px; }

.burger[aria-expanded="true"] .burger__icon {
  background-size: 0% 1px;
}

.burger[aria-expanded="true"] .burger__icon::before {
  top: 6px;
  transform: rotate(45deg);
}

.burger[aria-expanded="true"] .burger__icon::after {
  top: 6px;
  transform: rotate(-45deg);
}

/* --------------------------------------------------------------------------
   5. Mobile menu — wipes in from the right

   visibility rather than the hidden attribute, because display: none
   cannot be transitioned. It switches instantly, but only once the wipe
   has finished on the way out.
   -------------------------------------------------------------------------- */

.menu {
  position: fixed;
  inset: 0;
  z-index: var(--z-menu);
  display: flex;
  flex-direction: column;
  /* Links and the Resume button sit together at the top; the contact
     block is pushed to the foot by its own auto margin, so there is no
     dead space between the links and the button */
  justify-content: flex-start;
  gap: var(--space-lg);
  padding: calc(var(--nav-height) + var(--space-lg)) var(--space-md) var(--space-xl);
  overflow: hidden;
  background-color: var(--bg-blur-solid);
  -webkit-backdrop-filter: blur(20px);
  backdrop-filter: blur(20px);
  transform: translateX(100%);
  visibility: hidden;
  transition: transform var(--slow) var(--ease),
              visibility 0s linear var(--slow);
}

.menu.is-open {
  transform: translateX(0);
  visibility: visible;
  transition: transform var(--slow) var(--ease),
              visibility 0s linear 0s;
}

/* The one glow permitted on this screen, in the corner the panel
   arrives from */
.menu__glow {
  position: absolute;
  top: -20%;
  right: -30%;
  width: 90%;
  aspect-ratio: 1;
  background: var(--glow);
  filter: blur(70px);
  opacity: 0.5;
  pointer-events: none;
}

.menu__nav {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-md);
}

/* Hairline between each, never above the first or below the last */
.menu__item + .menu__item {
  border-top: 1px solid var(--border);
}

/* The closing state lives here: short and undelayed, so the links clear
   out quickly. The open rules below slow it down and stagger it. */
.menu__item,
.menu__resume,
.menu__foot {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 150ms ease,
              transform 150ms ease;
}

.menu.is-open .menu__item,
.menu.is-open .menu__resume,
.menu.is-open .menu__foot {
  opacity: 1;
  transform: none;
  transition: opacity var(--slow) ease,
              transform var(--slow) var(--ease);
}

/* One behind the next, starting once the panel is most of the way in */
.menu.is-open .menu__item:nth-child(1) { transition-delay: 140ms; }
.menu.is-open .menu__item:nth-child(2) { transition-delay: 200ms; }
.menu.is-open .menu__item:nth-child(3) { transition-delay: 260ms; }
.menu.is-open .menu__item:nth-child(4) { transition-delay: 320ms; }
.menu.is-open .menu__item:nth-child(5) { transition-delay: 380ms; }
.menu.is-open .menu__item:nth-child(6) { transition-delay: 440ms; }
.menu.is-open .menu__resume { transition-delay: 500ms; }
.menu.is-open .menu__foot { transition-delay: 560ms; }

.menu__link {
  display: flex;
  align-items: baseline;
  gap: var(--space-sm);
  padding-block: var(--space-sm);
  color: var(--text);
  transition: color var(--fast) ease;
}

.menu__num {
  flex-shrink: 0;
}

.menu__text {
  font-size: 1.75rem;
  font-weight: 700;
  line-height: 1.1;
  letter-spacing: -0.02em;
}

.menu__link.is-active,
.menu__link.is-active .menu__num {
  color: var(--accent-text);
}

@media (hover: hover) {
  .menu__link:hover {
    color: var(--accent-text);
  }
}

/* Pushed to the foot of the panel by its own margin, rather than by
   spacing the whole column out */
.menu__foot {
  position: relative;
  margin-top: auto;
  display: flex;
  flex-direction: column;
  gap: var(--space-2xs);
  padding-top: var(--space-md);
  border-top: 1px solid var(--border);
  color: var(--muted);
}

.menu__contact {
  color: var(--muted);
  transition: color var(--fast) ease;
}

.menu__place {
  color: var(--muted);
}

@media (hover: hover) {
  .menu__contact:hover {
    color: var(--text);
  }
}

/* --------------------------------------------------------------------------
   6. Hero
   -------------------------------------------------------------------------- */

.hero {
  position: relative;
  /* A minimum rather than a fixed height, so a small phone grows instead
     of clipping. svh tracks the visible viewport, which avoids the mobile
     browser bug where 100vh tucks content under the address bar. */
  min-height: 100svh;
  display: flex;
  align-items: center;
  /* Tight on a phone so the name, pitch, portrait and buttons all clear
     the first screen. The desktop breakpoint opens both back up. */
  padding-top: calc(var(--nav-height) + var(--space-md));
  padding-bottom: var(--space-xl);
  /* clip, not hidden. overflow-x: hidden forces overflow-y to compute to
     auto, which made the hero its own scroll container — and in the
     migrating layout the hero is also height: 100vh, so any content a
     pixel taller than the viewport gave it an inner scrollbar and ate
     the first wheel gesture before the page moved. `clip` clips the
     wordmark's overhang exactly the same way without any of that. */
  overflow-x: clip;
}

/* Flat on purpose: the portrait sits between the pitch and the buttons on
   a phone, and it can only do that by being their sibling. The desktop
   grid further down puts it back beside the copy. */
.hero__inner {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-md);
  width: 100%;
}

.hero__intro {
  display: flex;
  flex-direction: column;
  /* stretch, not flex-start: .hero__inner's own align-items: flex-start
     means a flex item's cross-size defaults to fit-content rather than
     the container's full width — fine for .hero__media (deliberately
     narrower), wrong for this column, where it let the pitch paragraph
     size itself wider than the viewport instead of wrapping within it */
  align-items: stretch;
  gap: var(--space-sm);
  width: 100%;
  min-width: 0;
}

.hero__name {
  font-size: var(--text-name);
  font-weight: 800;
  line-height: var(--leading-name);
  letter-spacing: var(--tracking-name);
  color: var(--text);
}

/* The full stop after the name — the second, warm accent */
.hero__stop {
  color: var(--accent-text);
}

.hero__pitch {
  max-width: var(--measure);
  color: var(--body);
}

/* No bottom margins on any of these — the gap on .hero__inner sets the
   spacing, and a margin on top of it would double up */
.hero__actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-xs);
}

/* Stats — one per line on a phone, value then label, so a label like
   "Policy accuracy at scale" reads across instead of wrapping to three
   lines in a narrow column. They go back to three columns on desktop. */
.stats {
  display: flex;
  flex-direction: column;
  gap: var(--space-sm);
  width: 100%;
  padding-top: var(--space-md);
  border-top: 1px solid var(--border);
}

.stat {
  display: flex;
  align-items: baseline;
  gap: var(--space-sm);
}

.stat__value {
  font-size: var(--text-stat);
  font-weight: 800;
  line-height: 1;
  letter-spacing: -0.02em;
  color: var(--accent-text);
  /* A fixed column for the number, so the labels beside them line up
     however many digits each value has */
  flex-shrink: 0;
  min-width: 3.5ch;
  font-variant-numeric: tabular-nums;
}

.stat__label {
  display: block;
}

.hero__availability {
  display: flex;
  align-items: center;
  gap: var(--space-xs);
}

.hero__dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background-color: var(--accent);
  flex-shrink: 0;
}

/* Portrait */
.hero__media {
  position: relative;
  width: 100%;
  /* Narrow enough that a 30vh-tall crop stays close to square rather
     than becoming a wide letterbox band across the face */
  max-width: 230px;
  align-self: flex-start;
}

.hero__glow {
  position: absolute;
  inset: -10% -4%;
  z-index: -1;
  background: var(--glow);
  filter: blur(48px);
  pointer-events: none;
}

.portrait {
  width: 100%;
  /* Height-led on a phone, so the portrait can never grow tall enough to
     push the buttons off the first screen. svh with a vh fallback for
     older browsers. */
  height: 30vh;
  height: 30svh;
  /* Weighted well above centre, so the crop always lands on the face */
  object-fit: cover;
  object-position: 50% 12%;
  border-radius: var(--radius);
  /* The photo was shot on a light backdrop, which reads as a bright slab
     against a near-black page. Fading the edges to transparent lets it
     sit into the background instead of ending at a hard rectangle — and
     the violet .hero__glow behind it shows through the softened edge. */
  -webkit-mask-image: radial-gradient(ellipse at center, black 50%, transparent 95%);
  mask-image: radial-gradient(ellipse at center, black 50%, transparent 95%);
}

/* Greyscale lives entirely inside the hover query, so it only exists on
   devices that have a hover to undo it with. A phone has none — leaving
   the filter on the base rule there would strand the photo permanently
   grey with no way back to colour, so on mobile it simply stays in
   colour and only the edge mask applies. */
@media (hover: hover) {
  .portrait {
    filter: grayscale(1);
    transition: filter 400ms ease;
  }

  .portrait:hover {
    filter: grayscale(0);
  }
}

/* Scroll cue — desktop only. It is absolutely positioned at the foot of
   the hero, which on a phone is well below the fold and lands on top of
   the availability line. It also says little to someone who is already
   holding the page. */
.scroll-cue {
  display: none;
  position: absolute;
  left: 50%;
  bottom: var(--space-md);
  transform: translateX(-50%);
  flex-direction: column;
  align-items: center;
  gap: var(--space-xs);
  transition: color var(--fast) ease;
}

.scroll-cue__line {
  width: 1px;
  height: 2.5rem;
  background: linear-gradient(var(--muted), transparent);
}

@media (hover: hover) {
  .scroll-cue:hover .label {
    color: var(--text);
  }
}

/* ---- Hero split on scroll -------------------------------------------------
   script.js drives transform and opacity on these five blocks straight
   from scroll position: on desktop the left column slides left and the
   portrait slides right as the first viewport scrolls away; below 900px
   there is no room to send anything sideways, so they only fade.

   The transition has to come off first. Every one of these either is, or
   contains, a [data-rise] element carrying a 450ms transform/opacity
   transition for the load-in — leave that in place and a value written
   every frame from the scroll position would arrive 450ms late and smear
   instead of tracking. script.js adds .is-split-ready once the load rise
   has finished, which is also what tells it that it may start writing.
   ------------------------------------------------------------------------ */

.hero.is-split-ready .hero__intro,
.hero.is-split-ready .hero__actions,
.hero.is-split-ready .stats,
.hero.is-split-ready .hero__availability,
.hero.is-split-ready .hero__media,
.hero.is-split-ready [data-rise] {
  transition: none;
}

/* Hero content rises into place on load, one piece behind the next.
   Scoped to .js, same as [data-reveal] below — without it (JS off, or
   script.js failing before it reaches the riser code) the hero is
   simply there, visible, rather than permanently hidden with nothing
   left to ever show it again. */
.js [data-rise] {
  opacity: 0;
  transform: translateY(24px);
  transition: opacity var(--rise-time-hero) var(--ease),
              transform var(--rise-time-hero) var(--ease);
}

.js [data-rise].is-risen {
  opacity: 1;
  transform: none;
}

/* The four blocks script.js drives directly from scrollY (the hero
   split — see writeSplit). These get a new transform and opacity on
   every scroll frame for as long as the hero is on screen, which is
   exactly the case will-change exists for.
   Note this list is deliberately short. It is tempting to put
   will-change on every [data-reveal] too, but those transition once
   and then never move again — and a permanent layer for each of the
   ~60 revealed elements would cost far more GPU memory on a phone than
   the one-off transition saves. Browsers already promote an element
   automatically for the duration of a transform/opacity transition,
   so the reveals are handled without the hint. */
.hero__intro,
.hero__actions,
.hero__availability,
.hero__media {
  will-change: transform, opacity;
}

/* --------------------------------------------------------------------------
   7. Marquee — an endless strip of capability keywords

   Driven entirely by a CSS transform, so the browser can run it on the
   compositor. script.js only stops and starts it.
   -------------------------------------------------------------------------- */

.marquee {
  /* The track is deliberately wider than the screen. This is what stops
     that width reaching the document and adding a sideways scrollbar.
     `clip` rather than `hidden` so it is purely a clip and never a
     scroll container — nothing on this page scrolls but the document. */
  overflow: clip;
  padding-block: var(--space-md);
  border-block: 1px solid var(--border);
}

.marquee__track {
  display: flex;
  /* Sized by its contents rather than the screen, which is what makes
     the 50% below equal exactly one copy of the keywords */
  width: max-content;
  animation: marquee-slide 45s linear infinite;
  /* This one never stops moving while it is on screen, so a permanent
     compositor layer is what it actually wants — the hint is paid for
     once and saves a repaint of a very wide element every frame */
  will-change: transform;
}

.marquee__group {
  display: flex;
  align-items: center;
  gap: var(--space-lg);
  /* Matches the gap, so the space at the join is the same as every other
     gap and the seam is invisible */
  padding-right: var(--space-lg);
  flex-shrink: 0;
}

.marquee__item {
  display: flex;
  align-items: center;
  gap: var(--space-lg);
  white-space: nowrap;
  font-size: var(--text-marquee);
  font-weight: 500;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--muted);
}

/* Every keyword carries its own separator, including the last one, so
   the pattern stays even across the join */
.marquee__item::after {
  content: "·";
  color: var(--accent-text);
}

@keyframes marquee-slide {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}

/* Added by script.js when the strip scrolls out of view — no reason to
   animate something nobody is looking at */
.marquee.is-paused .marquee__track {
  animation-play-state: paused;
}

@media (hover: hover) {
  .marquee:hover .marquee__track {
    animation-play-state: paused;
  }
}

/* ---- Marquee CTA ---------------------------------------------------------
   The page's second marquee. Every mechanic above is reused unchanged —
   the seamless two-copy track, the hover pause, the off-screen pause —
   and only the type and colour differ. Being a .marquee, it is already
   covered by the reduced-motion rule and the observer in script.js.
   ------------------------------------------------------------------------ */

.marquee--cta {
  /* Tight on a phone — the display-size band below is tall enough to
     take over the whole screen otherwise. The 900px block restores the
     full padding. */
  padding-block: var(--space-sm);
}

.marquee--cta .marquee__track {
  /* One copy of three long phrases at display size is much wider than
     one copy of the keyword strip, so the same 45s would run this at a
     very different pixel speed. This keeps the two moving comparably. */
  animation-duration: 30s;
}

.marquee--cta .marquee__group {
  gap: var(--space-xl);
  /* Still matched to the gap, so the seam stays invisible */
  padding-right: var(--space-xl);
}

.marquee--cta .marquee__item {
  gap: var(--space-xl);
  /* Readable rather than overwhelming at phone widths; the 900px block
     puts the full display scale back */
  font-size: clamp(1.2rem, 5vw, 2rem);
  font-weight: 800;
  line-height: 1.1;
  /* The label tracking inherited from .marquee__item is far too wide at
     display size — this pulls it back to the tight setting the hero name
     and section headings use */
  letter-spacing: -0.02em;
  color: var(--accent);
}

/* Quiet punctuation between loud phrases, rather than a third violet
   shape competing with them */
.marquee--cta .marquee__item::after {
  color: var(--muted);
}

/* --------------------------------------------------------------------------
   8. Sections — the shared shell every content section from here on uses
   -------------------------------------------------------------------------- */

/* Every full-width band on the page is explicitly transparent, so the
   fixed particle canvas behind them (.bg-canvas, z-index -2) shows
   through the whole document rather than only wherever a section
   happened not to have a background. Declared rather than just left
   unset, so a background added to any of these later is an obvious
   deliberate choice instead of a silent hole punched in the canvas. */
.hero,
.section,
.marquee,
.contact,
.footer,
main,
body {
  background-color: transparent;
}

.section {
  padding-block: var(--section-padding);
}

/* Reading highlight — script.js toggles .is-active on whichever section
   an IntersectionObserver finds centred in the viewport. Scoped to .js
   so a page with JavaScript off (or too old for IntersectionObserver)
   never leaves every section permanently dimmed. */
.js .section {
  opacity: 0.35;
  transition: opacity 500ms var(--ease);
}

/* The reading highlight is a desktop-only effect. On a phone the whole
   screen is roughly one section, so dimming the neighbours buys nothing
   and costs legibility — especially outdoors. Under 768px every section
   sits at full opacity at all times. !important so nothing further down
   the file, and no inline style, can reintroduce the dim.
   This deliberately does NOT touch [data-reveal] / [data-rise]: those
   animate opacity 0 -> 1 to fade content in, and pinning them to 1 would
   disable every entrance animation on mobile. */
@media (max-width: 768px) {
  .js .section,
  section {
    opacity: 1 !important;
  }
}

.js .section.is-active {
  opacity: 1;
}

/* On top of the opacity fade, an inactive section's own heading is
   forced to --muted outright — otherwise an amber section number or
   marker would still read as "lit" at 0.35 opacity.
   This is the second half of the reading highlight, so it is gated to
   the same widths as the first half. Without the gate, mobile sections
   would sit at full opacity but still have grey headings — the effect
   half-applied, which looks like a bug rather than a choice. */
@media (min-width: 769px) {
  .js .section:not(.is-active) .section__marker,
  .js .section:not(.is-active) .section__num,
  .js .section:not(.is-active) .work__heading,
  .js .section:not(.is-active) .about__lead {
    color: var(--muted);
    transition: color 500ms var(--ease);
  }
}

/* The two-part opener: number in accent, title in muted */
.section__marker {
  font-size: var(--text-label);
  font-weight: 500;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--muted);
  margin-bottom: var(--space-xl);
}

.section__num {
  color: var(--accent-text);
}

/* Rises into view as the section is reached. script.js works out the
   stagger from each child's position, so a section can hold any number
   of them without a matching rule here. */
.js [data-reveal] {
  opacity: 0;
  transform: translateY(24px);
  transition: opacity var(--rise-time) var(--ease),
              transform var(--rise-time) var(--ease);
}

.js [data-reveal].is-revealed {
  opacity: 1;
  transform: none;
}

/* Section headings — script.js wraps each character in .char-clip
   (the overflow: hidden mask) around .char (the piece that actually
   rises), with a 25ms-per-character transitionDelay written inline.
   Only [data-split-reveal] elements get split at all; everything else
   keeps its plain text. */
.char-clip {
  display: inline-block;
  overflow: hidden;
  vertical-align: bottom;
}

.char {
  display: inline-block;
  transform: translateY(110%);
  transition: transform 500ms var(--ease);
}

/* Italic glyphs lean out past their own advance width, and .char-clip's
   overflow: hidden — needed to mask the rise — was cutting that overhang
   off: measured at 11% of the word's ink, all on the right-hand edge.
   The padding gives the lean somewhere to paint inside the mask, and the
   matching negative margin takes exactly the same amount back out of the
   layout, so letter spacing and the centring are both untouched. The
   mask still clips vertically, which is the part the reveal relies on. */
[data-split-reveal].is-split-revealed .char {
  transform: none;
}

/* ---- About -------------------------------------------------------------- */

.about {
  display: flex;
  flex-direction: column;
  gap: var(--space-xl);
}

.about__copy {
  display: flex;
  flex-direction: column;
  gap: var(--space-md);
  max-width: var(--measure);
}

.about__lead {
  font-size: var(--text-heading);
  font-weight: 700;
  line-height: 1.1;
  letter-spacing: -0.02em;
  color: var(--text);
  margin-bottom: var(--space-sm);
}

/* Definition list beside the copy */
.facts {
  display: flex;
  flex-direction: column;
}

.fact {
  display: flex;
  flex-direction: column;
  gap: var(--space-2xs);
  padding-block: var(--space-sm);
  border-top: 1px solid var(--border);
}

.fact:last-child {
  border-bottom: 1px solid var(--border);
}

.fact__value {
  color: var(--text);
  font-size: var(--text-body);
}

/* ---- Counters ----------------------------------------------------------- */

.counters {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--space-lg);
  margin-top: var(--space-2xl);
  padding-top: var(--space-lg);
  border-top: 1px solid var(--border);
}

.counter__value {
  font-size: var(--text-stat);
  font-weight: 800;
  line-height: 1;
  letter-spacing: -0.02em;
  color: var(--text);
  margin-bottom: var(--space-2xs);
  /* Digits keep a fixed width, so the number doesn't jitter sideways
     while it counts up */
  font-variant-numeric: tabular-nums;
}

.counter__label {
  display: block;
}

/* ---- Selected Work ------------------------------------------------------
   Stacked on a phone, heading first, cards below at their own natural
   height. From 900px this becomes two columns: a sticky intro on the
   left, and the card row on the right, scrolling sideways on its own —
   see .work__intro / .work__viewport. Nothing pins the page's own
   vertical scroll; .work__intro is a plain CSS position: sticky element,
   nothing more.
   ------------------------------------------------------------------------ */

/* Mobile: a plain column, intro block first, viewport (holding the
   stacked cards) beneath it — the same order as the markup, no CSS
   reordering needed. The two-column sticky/scrolling treatment only
   exists from 900px (see that block below); every property it needs is
   declared explicitly here at its normal, unremarkable value rather
   than just being left unset, so nothing about that later, narrower
   block can ever affect layout at this width. */

/* The whole scroll-control cluster — arrows and progress bar — is a
   desktop affordance for the horizontal row. On mobile the cards stack
   vertically and there's nothing for it to control. */

/* The oversized index number behind the content. z-index: 0 plus
   position: relative on every real content element (below) is what
   keeps it a backdrop rather than something sitting on top of the text. */

/* The small amber dots */

/* Muted at rest — .card:hover switches these to the violet accent
   further down, so hovering a card is still what "interactive" looks
   like everywhere else on the page */
.chip {
  padding: 0.3rem var(--space-xs);
  font-size: var(--text-label);
  font-weight: 500;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  transition: border-color var(--slow) var(--ease),
              color var(--slow) var(--ease);
}

/* A chip on its own — not inside a hovered card — still gets a quiet
   response when pointed at directly. Gated so a tap can't leave one
   stuck in its hovered colour. */
@media (hover: hover) {
  .chip:hover {
    border-color: var(--accent);
    color: var(--accent-soft);
  }
}

/* ---- Selected Work: cards that open on a phone ---------------------------
   Below 900px a card shows its header only — date, role, company, place —
   until it is tapped. Scoped to .js throughout, so with JavaScript off
   nothing is ever hidden and every bullet stays readable.

   The open/close animates grid-template-rows from 0fr to 1fr rather than
   max-height. It resolves to the content's real height, so there is no
   guessed maximum to overshoot and the easing stays even however long a
   card happens to be — card 05 has six bullets, card 02 has four.
   ------------------------------------------------------------------------ */

/* A 0fr row only collapses if its one child can actually be clipped */

/* Closed, the place line's own margin would leave a stretch of empty
   card under it — this takes it back, and does so in step with the rest */

/* ---- Capabilities --------------------------------------------------------
   Six groups, each a small uppercase label over a wrapped row of chips.
   One column on a phone; a two-column grid from 900px, matching the
   density of six categories without forcing them into a single long
   scroll of chip rows.
   ------------------------------------------------------------------------ */

.capabilities {
  display: grid;
  gap: var(--space-xl);
}

.capability {
  padding-top: var(--space-lg);
  border-top: 1px solid var(--border);
}

/* Lifted off the base .label's muted grey — the same warm accent the
   section number and each card's index already use, so the six titles
   read as a matching set of markers rather than as plain body text */
.capability__label {
  color: var(--accent-text);
  font-weight: 700;
  margin-bottom: var(--space-md);
}

.chip-row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2xs);
}

/* ---- Lightbox -------------------------------------------------------- */

/* Close, prev, next and the dots are all position: absolute, floating
   over the backdrop around the frame rather than sitting beside it in
   flex flow. A flex row of 44px arrows either side of a near-full-width
   frame does not fit at 375px — absolute positioning sidesteps the
   width budget entirely, since none of the controls consume space the
   frame would otherwise get; the frame just stays centred by the
   container regardless of what floats on top of it. */
.lightbox {
  position: fixed;
  inset: 0;
  z-index: var(--z-lightbox);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-xl) var(--space-md);
  background-color: var(--overlay);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
}

.lightbox[hidden] {
  display: none;
}

.lightbox__frame {
  position: relative;
  /* Kept narrow enough on a phone that the side-mounted arrows below
     always clear it with room to spare */
  width: min(72vw, calc(65vh * 9 / 16));
  aspect-ratio: 9 / 16;
  overflow: hidden;
  background-color: var(--lightbox-frame);
  border-radius: var(--radius-md);
  /* The dip-and-return when switching between videos */
  transition: opacity 200ms ease;
}

.lightbox__frame.is-switching {
  opacity: 0;
}

.lightbox__frame iframe {
  display: block;
  width: 100%;
  height: 100%;
  border: 0;
}

.lightbox__close,
.lightbox__nav {
  position: absolute;
  display: grid;
  place-items: center;
  width: 36px;
  height: 36px;
  color: var(--body);
  border: 1px solid var(--border);
  border-radius: 50%;
  background-color: var(--overlay-btn);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  transition: color var(--fast) ease,
              border-color var(--fast) ease;
}

.lightbox__close {
  top: var(--space-md);
  right: var(--space-md);
}

.lightbox__nav {
  top: 50%;
  transform: translateY(-50%);
}

.lightbox__nav--prev {
  left: var(--space-2xs);
}

.lightbox__nav--next {
  right: var(--space-2xs);
}

@media (hover: hover) {
  .lightbox__close:hover,
  .lightbox__nav:hover {
    color: var(--text);
    border-color: var(--accent);
  }
}

.lightbox__dots {
  position: absolute;
  bottom: var(--space-md);
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  gap: var(--space-2xs);
}

/* Larger than the dot it draws, so there is something big enough to
   tap on a phone */
/* Shared with .videos__dot below the video deck — same job, same look:
   a button bigger than the dot it draws, so there's something a thumb
   can actually hit */
.lightbox__dot,
.videos__dot {
  position: relative;
  width: 32px;
  height: 32px;
  border-radius: 50%;
}

.lightbox__dot::before,
.videos__dot::before {
  content: "";
  position: absolute;
  inset: 12px;
  border-radius: 50%;
  background-color: var(--border);
  transition: background-color var(--fast) ease,
              transform var(--fast) ease;
}

.lightbox__dot.is-current::before,
.videos__dot.is-current::before {
  background-color: var(--accent);
  transform: scale(1.3);
}

@media (hover: hover) {
  .lightbox__dot:hover::before,
  .videos__dot:hover::before {
    background-color: var(--muted);
  }
}

/* The deck's own dot row, centred beneath the reserved peek space */
.videos__dots {
  display: flex;
  justify-content: center;
  gap: var(--space-2xs);
  margin-top: var(--space-sm);
}

/* ---- Background: education, languages ------------------------------------
   Same grouped-with-amber-label shape as Capabilities above, so the two
   sections read as one visual family rather than two different systems.
   ------------------------------------------------------------------------ */

.background {
  display: grid;
  gap: var(--space-xl);
}

.background__group {
  padding-top: var(--space-lg);
  border-top: 1px solid var(--border);
}

.background__label {
  color: var(--accent-text);
  font-weight: 700;
  margin-bottom: var(--space-md);
}

.education {
  display: flex;
  flex-direction: column;
  gap: var(--space-md);
}

.education__title {
  color: var(--text);
  font-weight: 600;
}

.education__meta {
  color: var(--muted);
  font-size: var(--text-label);
  margin-top: var(--space-2xs);
}

/* ---- Certificates --------------------------------------------------------
   Four scans sitting under the certifications list. Stacked on a phone,
   spreading to two columns from 700px, then four on one row at 900px —
   earlier than the page's usual 900px break, because these are wide
   landscape images and four of them fit long before the rest of the
   layout wants a second column.
   ------------------------------------------------------------------------ */

.certs {
  display: grid;
  gap: var(--space-md);
  margin-top: var(--space-lg);
}

@media (min-width: 700px) {
  .certs {
    grid-template-columns: 1fr 1fr;
  }
}

@media (min-width: 1100px) {
  .certs {
    grid-template-columns: 1fr 1fr 1fr 1fr;
  }
}

.cert__link {
  display: block;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  /* Clips the image's own square corners to the rounded ones */
  overflow: hidden;
  /* The soft glow beneath. Neutral by default so it reads as depth
     rather than as colour, and only picks up the accent on hover. */
  box-shadow: 0 12px 30px var(--shadow-3);
  transition: transform var(--slow) var(--ease),
              border-color var(--slow) var(--ease),
              box-shadow var(--slow) var(--ease);
}

.cert__img {
  display: block;
  width: 100%;
  height: auto;
  /* The four files are 1280x905, 1241x857, 1280x905 and (EF SET)
     1280x905 — close but not identical, which would leave the cards
     different heights side by side. Pinning all four to the same ratio
     and covering trims a few pixels off the shorter one instead. */
  aspect-ratio: 1280 / 905;
  object-fit: cover;
}

/* Greyscale-until-hover is a pointer behaviour, so it is gated the same
   way the portrait is — a touch device would otherwise be stuck with
   grey certificates it can never bring into colour */
@media (hover: hover) {
  .cert__img {
    filter: grayscale(1);
    transition: filter var(--slow) var(--ease);
  }

  .cert__link:hover,
  .cert__link:focus-visible {
    transform: translateY(-8px);
    border-color: var(--accent);
    box-shadow: 0 22px 44px var(--accent-glow);
  }

  .cert__link:hover .cert__img,
  .cert__link:focus-visible .cert__img {
    filter: grayscale(0);
  }
}

.cert__caption {
  margin-top: var(--space-sm);
  font-size: var(--text-label);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--muted);
}

/* ---- Certificate lightbox ------------------------------------------------
   Deliberately much simpler than the video lightbox: one still image,
   no stepping between items, so there is nothing to build or tear down.
   ------------------------------------------------------------------------ */

.certbox {
  position: fixed;
  inset: 0;
  z-index: var(--z-lightbox);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-xl) var(--space-md);
  background-color: var(--overlay);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
}

.certbox[hidden] {
  display: none;
}

.certbox__img {
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
}

.certbox__close {
  position: absolute;
  top: var(--space-md);
  right: var(--space-md);
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  border: 1px solid var(--border);
  border-radius: 50%;
  background-color: var(--overlay-btn);
  color: var(--muted);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  -webkit-tap-highlight-color: transparent;
  transition: color var(--fast) ease,
              border-color var(--fast) ease;
}

@media (hover: hover) {
  .certbox__close:hover {
    color: var(--text);
    border-color: var(--accent);
  }
}

.languages {
  display: flex;
  flex-direction: column;
  gap: var(--space-sm);
}

.language {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-sm);
  padding-bottom: var(--space-sm);
  border-bottom: 1px solid var(--border);
}

.language:last-child {
  border-bottom: none;
  padding-bottom: 0;
}

.language__name {
  color: var(--text);
  font-weight: 600;
}

.language__level {
  color: var(--muted);
  font-size: var(--text-label);
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

/* ---- Contact: closing statement, no form ----------------------------------
   The one deliberate exception to "glows are violet": this one warm-amber
   bloom, behind the heading only, echoing the accent-2 word inside it.
   ------------------------------------------------------------------------ */

.contact {
  position: relative;
  /* Deliberately not clipped. The glow below is capped to the viewport
     width instead, which stops it widening the page without putting a
     boundary around the section's own contents. */
  overflow: visible;
  /* Left-aligned, like every other section opener on the page. It was
     centred while the section was three lines of text and nothing else;
     it now carries a heading, a scene and a card column, and centring a
     layout that wide only makes the eye hunt for where each line starts. */
  text-align: left;
  padding-block: clamp(5rem, 14vh, 9rem);
}

.contact__glow {
  position: absolute;
  top: 50%;
  left: 50%;
  z-index: 0;
  /* Centred, so capping at the viewport width is what guarantees it can
     never reach past either edge and add a horizontal scrollbar */
  width: min(760px, 100vw);
  aspect-ratio: 1;
  transform: translate(-50%, -55%);
  background: var(--glow-warm);
  filter: blur(70px);
  pointer-events: none;
}

.contact__eyebrow {
  position: relative;
  color: var(--accent-text);
  margin-bottom: var(--space-lg);
}


/* ---- Section dot navigation ----------------------------------------------
   Fixed to the right edge, centred vertically. One dot per section, in
   the order they actually appear down the page (Content sits before
   Background in the markup, so it does here too — a progress indicator
   that disagreed with the scroll order would be worse than useless).
   The active dot is kept in sync by the same IntersectionObserver that
   drives the nav highlight; the fill on the hairline is written from
   scroll position by script.js.
   ------------------------------------------------------------------------ */

.dots {
  position: fixed;
  top: 50%;
  right: var(--space-xs);
  z-index: var(--z-dots);
  transform: translateY(-50%);
  transition: opacity var(--fast) ease;
}

/* The mobile menu is a full-screen overlay below these in the stack —
   they would otherwise float on top of it */
body.is-locked .dots {
  opacity: 0;
  pointer-events: none;
}

.dots__list {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--space-2xs);
}

/* The hairline runs the full height of the dot column, behind the dots */
.dots__line {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 50%;
  width: 1px;
  transform: translateX(-50%);
  background-color: var(--border);
  overflow: hidden;
  pointer-events: none;
}

.dots__fill {
  display: block;
  width: 100%;
  height: 100%;
  background-color: var(--accent);
  transform: scaleY(0);
  transform-origin: top center;
  /* No transition — script.js writes this from the page's own scroll
     position, so it has to track exactly rather than ease behind it */
}

/* The link is deliberately bigger than the dot it draws, so there is
   something a thumb can actually hit */
.dots__dot {
  position: relative;
  display: block;
  width: 22px;
  height: 22px;
}

.dots__dot::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 6px;
  height: 6px;
  margin: -3px 0 0 -3px;
  border-radius: 50%;
  background-color: var(--muted);
  transition: width var(--fast) var(--ease),
              height var(--fast) var(--ease),
              margin var(--fast) var(--ease),
              background-color var(--fast) ease,
              box-shadow var(--fast) ease;
}

.dots__dot.is-active::after {
  width: 11px;
  height: 11px;
  margin: -5.5px 0 0 -5.5px;
  background-color: var(--accent);
  box-shadow: 0 0 10px var(--accent-glow);
}

/* Sits to the left of the dot (the dots are already at the right edge)
   and slides in from the right on hover */
.dots__label {
  position: absolute;
  right: calc(100% + 8px);
  top: 50%;
  display: none;
  padding: 0.3rem var(--space-xs);
  white-space: nowrap;
  font-size: var(--text-label);
  font-weight: 500;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--text);
  background-color: var(--bg-blur);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  -webkit-backdrop-filter: blur(10px);
  backdrop-filter: blur(10px);
  opacity: 0;
  pointer-events: none;
  transform: translateY(-50%) translateX(10px);
  transition: opacity 200ms var(--ease),
              transform 200ms var(--ease);
}

/* Labels are a pointer affordance only — no hover on touch, and no room
   for them beside a phone-width viewport either */
@media (hover: hover) and (min-width: 900px) {
  .dots__label {
    display: block;
  }

  .dots__dot:hover .dots__label,
  .dots__dot:focus-visible .dots__label {
    opacity: 1;
    transform: translateY(-50%) translateX(0);
  }
}

/* ---- Footer ---------------------------------------------------------- */

.footer {
  border-top: 1px solid var(--border);
  padding-block: var(--space-lg);
}

.footer__inner {
  display: flex;
  flex-direction: column;
  gap: var(--space-2xs);
}

.footer__name {
  font-weight: 700;
  color: var(--text);
}

.footer__meta {
  color: var(--muted);
  font-size: var(--text-label);
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

/* --------------------------------------------------------------------------
   9. Larger screens
   -------------------------------------------------------------------------- */

@media (min-width: 600px) {
  .wrap {
    padding-inline: var(--space-lg);
  }

  .hero__actions {
    gap: var(--space-sm);
  }
}

@media (min-width: 900px) {
  /* Links come back inline, menu button goes away */
  .nav__links {
    display: block;
  }

  .burger {
    display: none;
  }

  .hero {
    padding-top: calc(var(--nav-height) + var(--space-xl));
    padding-bottom: var(--space-2xl);
  }

  /* The five flattened children regroup into the original two columns:
     everything in the left, the portrait spanning the right. Grid rather
     than flex, because the copy has to stack in a column beside a
     portrait that spans all of it. */
  .hero__inner {
    display: grid;
    grid-template-columns: 1fr clamp(260px, 28vw, 380px);
    grid-template-areas:
      "intro   media"
      "actions media"
      "stats   media"
      "avail   media";
    align-items: start;
    column-gap: var(--space-2xl);
    row-gap: var(--space-lg);
  }

  .hero__intro   { grid-area: intro; gap: var(--space-md); min-width: 0; }
  .hero__actions { grid-area: actions; }
  /* Back to three columns, value stacked above its label */
  .stats {
    grid-area: stats;
    flex-direction: row;
    /* Four columns share this row now rather than three, so the gutter
       comes down to give each one usable width. No flex-wrap: the four
       stay on a single line and shrink together instead of one of them
       dropping to a second row. */
    gap: var(--space-md);
    padding-top: var(--space-lg);
  }

  .stat {
    flex: 1;
    /* Lets a column shrink below the natural width of its label, so a
       long one wraps inside its own column instead of forcing the row
       wider than the space it has */
    min-width: 0;
    flex-direction: column;
    gap: var(--space-2xs);
  }

  .stat__value {
    min-width: 0;
    /* Scales with the room actually available. At the 900px end of this
       breakpoint the left column is narrow enough that four numbers at
       the full --text-stat size would leave the labels nothing to sit
       in; this keeps them large without crowding the row. */
    font-size: clamp(1.75rem, 2.4vw, 2.5rem);
  }
  .hero__availability { grid-area: avail; }

  .hero__media {
    grid-area: media;
    width: 100%;
    max-width: none;
    align-self: center;
  }

  /* Back to a ratio-led crop with room to breathe */
  .portrait {
    height: auto;
    aspect-ratio: 4 / 5;
    object-position: 50% 18%;
  }

  /* The hero is one screen tall here, so the cue sits where it belongs */
  .scroll-cue {
    display: flex;
  }

  /* Copy on the left, the facts list beside it */
  .about {
    flex-direction: row;
    align-items: flex-start;
    justify-content: space-between;
    gap: var(--space-2xl);
  }

  .about__copy {
    flex: 1;
    min-width: 0;
  }

  .facts {
    flex-shrink: 0;
    width: clamp(240px, 26vw, 320px);
  }

  .counters {
    grid-template-columns: repeat(4, minmax(0, 1fr));
    gap: var(--space-xl);
  }

  /* Back to the full display scale — unchanged from what it has always
     been on desktop, it is only the phone that steps down */
  .marquee--cta {
    padding-block: clamp(var(--space-xl), 8vh, var(--space-2xl));
  }

  .marquee--cta .marquee__item {
    font-size: clamp(2.5rem, 9vw, 6rem);
  }

  /* Two up — six groups read better as a short, wide block than as one
     long column of chip rows */
  .capabilities {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    column-gap: var(--space-2xl);
    row-gap: var(--space-lg);
  }

  /* Four across, rather than the mobile deck. Every property the deck
     needed — position, height, touch-action — is reset here back to
     the original floating row, so this is the layout exactly as it was
     before the deck existed. */

  /* The deck's own indicator — nothing to show once all four cards are
     visible side by side */
  .videos__dots {
    display: none;
  }

  /* More room to work with, so the controls move further from the frame
     and grow back to their full size */
  .lightbox__frame {
    width: min(32vw, calc(78vh * 9 / 16));
  }

  .lightbox__close,
  .lightbox__nav {
    width: 44px;
    height: 44px;
  }

  .lightbox__close {
    top: var(--space-lg);
    right: var(--space-lg);
  }

  .lightbox__nav--prev {
    left: var(--space-lg);
  }

  .lightbox__nav--next {
    right: var(--space-lg);
  }

  /* Set once here and read by the row and the progress bar, so the last
     card's right edge and the bar both line up with the page's own
     right-hand container edge without repeating the calc twice */

  /* Two columns, on a CSS grid rather than flex. Grid tracks are fixed,
     explicit boundaries — unlike a flex item, a grid column can't be
     pushed wider by a child's content (the row's five 560px cards, far
     wider than the column, were doing exactly that to the old flex
     layout, which is why card 01 was rendering under the intro text).
     column-gap is the actual mechanism for "a clear gap after the
     heading column" — a real gutter between the two tracks, not
     padding borrowed from one side or the other. */

  /* A plain sticky sidebar — nothing more. It stays in view only for as
     long as the section (which is roughly as tall as the row of cards)
     is taller than this column itself; once the section's bottom edge
     catches up, it scrolls away with everything else, same as any other
     sticky sidebar. top matches where content already clears the fixed
     nav elsewhere on the page. */

  /* The permanent clip: this is the column boundary itself — cards can
     scroll sideways inside it, but nothing ever renders outside this
     grid cell, so nothing can ever reach back under .work__intro. */

  /* The row turns sideways and fills its column exactly. The gap from
     the intro column is now explicit padding-left on the row itself —
     not just the grid's own column-gap — so "a clear gap after the
     heading column" is a real, literal space on the track's own box,
     not something inferred from the layout mechanism around it. The
     right side keeps the full wrap-aligned pad, so the last card still
     lines up with the page's own edge. */

  /* script.js adds this only while a wheel or drag gesture is actively
     under way. Mandatory snap fights direct scrollLeft writes — the
     wheel handler's scrollLeft += and the drag handler's scrollLeft =
     both happen in many small steps rather than one native scroll
     gesture, and the browser was treating each step as a finished
     scroll and snapping straight back to the current card, which is
     what made wheel and drag look dead. Suspending snap for the
     gesture's duration and letting it re-engage once it ends keeps
     both: full manual control while scrolling, still settling on a
     card boundary the moment you let go. */

  /* Side by side — the same "two up" treatment as .capabilities above */
  .background {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    column-gap: var(--space-2xl);
  }

  .footer__inner {
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
  }

  /* Roomier than the phone version, which stays deliberately small so it
     never competes with the content beside it */
  .dots {
    right: var(--space-md);
  }

  .dots__list {
    gap: var(--space-xs);
  }

  .dots__dot {
    width: 26px;
    height: 26px;
  }

  .dots__dot::after {
    width: 7px;
    height: 7px;
    margin: -3.5px 0 0 -3.5px;
  }

  .dots__dot.is-active::after {
    width: 13px;
    height: 13px;
    margin: -6.5px 0 0 -6.5px;
  }

  /* The toggle and the two wrappers exist only for the phone's collapse.
     display: contents takes the wrappers' boxes out of the layout
     altogether, so the card goes back to being the exact flex column it
     was before they were added — which is what keeps .card__tags'
     margin-top: auto pinning the tags to the bottom of the card. */

  /* Nothing occupies the corner here, so the numeral goes back to it
     and the date needs no reserved space beside it */

  /* Nothing is collapsed here, so the header keeps its normal spacing */

  /* Grab-to-drag, set by script.js only while a drag is actually under way */

  /* Arrows either side of the progress bar, all one row. Left padding
     matches .work__row above exactly, so this lines up with the first
     card's own left edge; the right side keeps the wrap-aligned pad to
     match the row. */

  /* Set by script.js at either end of the row's scroll range */
}

/* --------------------------------------------------------------------------
   9. Reduced motion — respects the "less movement" setting on the device
   -------------------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }

  /* STEP 11 replaced what used to be here.

     This block used to read `animation-duration: 0.01ms !important` on
     the universal selector: everything off, with a list of exceptions
     clawed back one at a time. On iOS, where Reduce Motion is switched on
     far more often than people realise, that meant most of the site did
     not animate at all, and every new piece of motion was silent by
     default until somebody remembered to add it to the exception list.

     The policy now is SOFTEN, NOT REMOVE. Animations keep their durations
     and their easing. What is taken away is the part of the motion that
     actually causes trouble: distance, scale, rotation, blur, parallax,
     pinning and momentum. A reveal that rose 24px and faded still fades,
     and rises 6px instead. The trigger is large movement across the
     viewport, not the existence of a transition.

     scroll-behavior is the one thing still forced off, because a
     smooth-scrolled jump across several screens IS large movement. */
  *,
  *::before,
  *::after {
    scroll-behavior: auto !important;
  }

  /* ---- the movement cap ---------------------------------------------
     --rm-shift is how far anything may travel. Every rule below that had
     a bigger number is pulled back to it and the opacity in the same rule
     is left alone to carry the reveal. */
  :root {
    --rm-shift: 6px;
  }

  .js [data-reveal],
  .js [data-rise],
  .jr__item,
  .hlp__card,
  .say__card,
  .st__item {
    transform: translateY(var(--rm-shift)) !important;
  }
.js [data-reveal].is-revealed,
.js [data-rise].is-risen,
.jr__item.is-in,
.hlp.is-in .hlp__card,
.say__card.is-in,
.st__item.is-in {
    transform: none !important;
  }


  /* No blur transitions. A blur that resolves is a focus pull, which is
     one of the strongest triggers on the list, and the most expensive
     thing on this page to composite besides. */
  .hx__nav,
  .hx__lead,
  .hx__stats,
  .hx__traits,
  .hx__tag,
  .hx__desc,
  .wib__w {
    filter: none !important;
  }

  /* The portrait is the exception: `filter: none` here would take its
     drop-shadow with the blur, and the shadow is not motion. It keeps the
     shadow and loses the blur. */
  .hx__portrait {
    filter: var(--hx-shadow) !important;
  }

  /* ---- Functional motion, deliberately kept ---------------------------
     Each selector here beats the blanket above on specificity, which is
     what lets it override a declaration that is already !important — a
     class (0,1,0) outranks the universal selector (0,0,0).

     The line being drawn: this setting exists to stop motion that is
     applied *to* content, not to stop content that happens to move.
     A marquee frozen mid-strip is not a calmer marquee, it is three of
     nine keywords and a layout that looks broken. A tap with no
     response is not a calmer tap, it is a dead link. Neither is what
     someone asking for less movement is asking for.
     -------------------------------------------------------------------- */

  /* Both marquees. The travel is the only way the rest of the keywords
     are ever seen, so removing it removes content. */
  .marquee__track {
    animation: marquee-slide 45s linear infinite !important;
  }

  .marquee--cta .marquee__track {
    animation-duration: 30s !important;
  }

  /* The off-screen pause is a performance measure, not motion, so it
     still applies */
  .marquee.is-paused .marquee__track {
    animation-play-state: paused !important;
  }

  /* Card expand and collapse. Without the row transition the card snaps
     open and the chevron stops explaining what it just did. */

  /* Nav background arriving on scroll, and the active-section colour */
  .nav {
    transition: background-color var(--fast) ease,
                border-color var(--fast) ease !important;
  }

  .nav__link,
  .menu__link {
    transition: color var(--fast) ease !important;
  }

  /* The mobile menu panel keeps its wipe. This was previously forced
     instant, but a full-screen panel that teleports into place is more
     disorienting than one that arrives in a fifth of a second — the
     opposite of what the setting is asking for. The visibility delay
     has to be restored with it, or the panel vanishes before it has
     finished wiping back out. */
  .menu {
    transition: transform var(--slow) var(--ease),
                visibility 0s linear var(--slow) !important;
  }

  .menu.is-open {
    transition: transform var(--slow) var(--ease),
                visibility 0s linear 0s !important;
  }

  /* The per-link stagger inside the menu is decoration on top of the
     panel wipe above, so the links are simply in place when it opens */
  .menu__item,
  .menu__resume,
  .menu__foot {
    transition-delay: 0s !important;
  }

  .menu.is-open .menu__item,
  .menu.is-open .menu__resume,
  .menu.is-open .menu__foot {
    opacity: 1;
    transform: none;
  }

  /* Hero content is simply there, with no rise */
  [data-rise] {
    opacity: 1;
    transform: none;
  }

  /* Section content is simply there, with no rise */
  .js [data-reveal] {
    opacity: 1;
    transform: none;
  }

  /* script.js never initialises the particle canvas, the sparkle trail
     or the cursor dot/ring/glow under reduced motion, so the native
     cursor has to come back — otherwise the page would be left with no
     visible pointer at all */
  html,
  a,
  button {
    cursor: auto;
  }

  a,
  button {
    cursor: pointer;
  }

  .cursor-dot,
  .cursor-ring,
  .cursor-glow {
    display: none;
  }

  /* No characters to catch mid-clip if script.js never split them */
  [data-split-reveal] .char {
    transform: none;
  }

  /* The reading highlight never runs, so nothing should sit dimmed */
  .section {
    opacity: 1;
  }

  /* The dot column is a scroll-progress indicator with a travelling
     fill — there is nothing left of it once the motion is removed, and
     the nav still covers every one of these sections */
  .dots {
    display: none;
  }

  /* Neither the hover transition nor the scroll-driven version runs
     here, so the portrait simply stays in colour. The edge mask is not
     motion and stays put. */
  .portrait {
    filter: none;
  }

  /* script.js never drives the hero split, so nothing should start
     shifted or faded */
  .hero__intro,
  .hero__actions,
  .stats,
  .hero__availability,
  .hero__media {
    transform: none;
    opacity: 1;
  }
}

/* --------------------------------------------------------------------------
   10. Hero to sidebar — content migration

   An experiment, kept in one block at the end of the file so it can be
   lifted out whole. Nothing above this line was edited for it.

   The mechanic: the name, the four stat numbers and the nav links are
   not duplicated and crossfaded. Each one is a single element that
   starts the page sitting exactly where the hero puts it and is carried,
   by transform alone, into its place in the sidebar. At the end of the
   run every transform is the identity, so the finished sidebar is plain
   CSS flow with nothing held in place by script.

   Three things make that possible:

   a. The hero is PINNED for the length of the run (body.is-migrating,
      below). If it scrolled normally, the positions the content is
      leaving would slide out from under it mid-flight.

   b. The elements that change size are rendered at their LARGE size and
      scaled DOWN, never the reverse — upscaled text is blurred text.

   c. The sidebar surface is its own element (.rail__bg) rather than a
      background on .nav, so it can reach full opacity while the hero is
      still on its way out without taking the migrating content with it.

   Everything here needs script. body.is-migrating is added by script.js
   only above 900px and only when motion is allowed; without it this
   whole block is inert and the page behaves as it always has.
   -------------------------------------------------------------------------- */

/* Hidden at every width until the sidebar exists */
.rail__stats,
.rail__foot,
.rail__bg {
  display: none;
}

/* The zone is a plain wrapper until it is asked to be taller */
.hero-zone {
  position: relative;
}

/* ---- Oversized wordmark ------------------------------------------------
   Wider than the viewport on purpose, so it runs off both edges. Behind
   everything, and far too faint to compete with the name in front of it.
   Not shown on a phone, where the hero column is stacked and there is no
   spare depth for it to sit in. */

.hero__wordmark {
  display: none;
}

@media (min-width: 900px) {
  .hero__wordmark {
    display: block;
    position: absolute;
    top: 50%;
    left: 50%;
    z-index: 0;
    transform: translate(-50%, -50%);
    font-size: 26vw;
    font-weight: 800;
    line-height: 0.8;
    letter-spacing: -0.04em;
    white-space: nowrap;
    /* Solid and darker than the page, rather than near-white at 3%. On
       the old near-black background the only way to be visible was to be
       lighter than it; on this one there is room underneath. */
    color: var(--wordmark);
    opacity: 1;
    pointer-events: none;
    -webkit-user-select: none;
    user-select: none;
  }

  /* Keeps the real hero content in front of it */
  .hero__inner {
    position: relative;
    z-index: 1;
  }
}

/* 900px, not a breakpoint of this section's own. It is the same one the
   page already uses for "desktop" — where the nav links come back inline,
   the burger disappears and the hero becomes two columns — so the bar the
   migration starts from and the layout it ends in switch together. Below
   it, none of this exists: burger, overlay menu, stacked hero, no
   sidebar, no reserved gutter. */
@media (min-width: 900px) {
  :root {
    /* Scales with the window rather than sitting at one fixed width. At
       900px it is 216px and the content column keeps ~590px; by 1360px it
       has reached its 272px ceiling and stops growing, because a sidebar
       this sparse looks thin and stretched if it keeps widening. */
    --rail-w: clamp(13.5rem, 20vw, 17rem);
    --rail-inset: var(--space-lg);
    --rail-pad: var(--space-md);
    /* Left gutter reserved down the page for the sidebar to sit in */
    --rail-gutter: calc(var(--rail-w) + var(--space-lg) + var(--space-xl));
  }

  /* ---- The reserved gutter -------------------------------------------
     Permanent, present on first paint, never animated. Adding it when
     the sidebar formed would reflow the whole document on one frame and
     visibly jolt.

     Scoped to .js, not to a scroll state: it has to be reserved before
     anything forms, but with JavaScript off no sidebar ever appears and
     an empty 360px gutter would just be a hole in the page. */
  .js main > *:not(.hero-zone):not(*),
  .js .footer {
    padding-left: var(--rail-gutter);
    /* The column is narrower than --wrap-max once the gutter is taken
       out, so .wrap's own centring has nothing left to do and the text
       would otherwise run to within a --space-md of the right edge */
    padding-right: var(--space-xl);
  }

  .js main > *:not(.hero-zone):not(*) > .wrap,
  .js .footer > .wrap {
    margin-inline: 0 auto;
  }

  /* ---- (a) The pinned zone -------------------------------------------
     One screen of hero plus 1.2 screens of transition. The hero sticks
     to the top for exactly that 1.2 screens, then releases. */
  /* One screen of hero plus 0.7 of a screen of pinning. This is only how
     long the hero is held still — the migration's own length is measured
     in script.js and usually runs a little past the end of this, so the
     tail of it happens while the hero slides away and the next section
     comes up. That overlap is what closes the gap between them. */
  body.is-migrating .hero-zone {
    height: 170vh;
  }

  /* The marquee strip between the hero and Profile is only ~75px tall, so
     Profile's usual top padding on top of it put its heading most of a
     screen below the fold with nothing in between. The marquee is the
     separation here; this is the rest of the gap, removed. */
  body.is-migrating .marquee + .section {
    padding-top: var(--space-lg);
  }

  body.is-migrating .hero-zone > .hero {
    position: -webkit-sticky;
    position: sticky;
    top: 0;
    height: 100vh;
    min-height: 0;
  }

  /* script.js drives both. The blur is the brief rack-focus moment; the
     fade is the hero handing over to the sidebar. */
  body.is-migrating .hero {
    opacity: var(--hero-fade, 1);
    filter: blur(var(--hero-blur, 0px));
  }

  /* Once it has faded out completely it stops being painted at all —
     a full-viewport blurred layer is not worth compositing for the rest
     of the page. Reversed the moment it starts coming back. */
  body.is-migrating .hero.is-spent {
    visibility: hidden;
  }

  /* The originals of the three migrating pieces. They keep their place
     in the document (and in the accessibility tree — the h1 is still the
     h1) but what you see is the element travelling to the sidebar.

     Scoped to .hero on purpose: the travelling copies carry the same
     .stat__value class (that is how they render identically), and an
     unscoped rule here hides the very things it is meant to stand in
     for. */
  body.is-migrating .hero .hero__name,
  body.is-migrating .hero .stat__value {
    opacity: 0;
  }

  /* ---- The sidebar ----------------------------------------------------
     .is-rail is the card layout. Under migration it is on from the
     first frame — the content is simply transformed back out to the
     hero, and released as you scroll. */

  .nav.is-rail {
    /* Ceiling on how tall the finished brand may be. It is read by
       script.js, which scales the name to the narrower of "fits the
       column" and "fits this height" — the name breaks into two lines at
       most desktop widths, and two lines at full column width is a lot of
       the card's height to spend on a logo when the screen is short. */
    --rail-brand-max: 7rem;
    top: var(--rail-inset);
    bottom: var(--rail-inset);
    left: var(--rail-inset);
    right: auto;
    width: var(--rail-w);
    background-color: transparent;
    border-bottom: 0;
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
  }

  /* (c) The surface, on its own element */
  .nav.is-rail .rail__bg {
    display: block;
    position: absolute;
    inset: 0;
    z-index: 0;
    background-color: var(--bg-blur);
    border: 1px solid var(--border);
    border-radius: var(--radius-md);
    -webkit-backdrop-filter: blur(12px);
    backdrop-filter: blur(12px);
    opacity: var(--rail-bg-o, 0);
    pointer-events: none;
  }

  .nav.is-rail .nav__inner {
    position: relative;
    z-index: 1;
    flex-direction: column;
    align-items: stretch;
    justify-content: flex-start;
    gap: var(--space-md);
    height: 100%;
    max-width: none;
    min-height: 0;
    padding: var(--space-lg) var(--rail-pad);
    /* Room for the brand, which is taken out of flow below. script.js
       overwrites this with the brand's measured finished height. */
    padding-top: calc(var(--space-lg) + 3.25rem);
  }

  /* The CTA at the bottom has to be reachable on a short screen, where
     the column is taller than the card. It cannot simply scroll from the
     start: for most of the run the links and the brand are outside the
     card's box on their way in from the hero, and a scroll container
     would clip every one of them. So the card only becomes scrollable
     once everything has landed — .is-settled, set at the end of the run
     and whenever the sidebar is static. */
  .nav.is-rail.is-settled .nav__inner {
    overflow-y: auto;
    overscroll-behavior: contain;
    scrollbar-width: thin;
    scrollbar-color: var(--border) transparent;
  }

  /* Before that, though, it is worth not needing to scroll at all. A
     laptop screen is the common case and the column fits it with room to
     spare once the spacing is tightened. */
  @media (max-height: 820px) {
    .nav.is-rail {
      --rail-brand-max: 4.75rem;
      --rail-inset: var(--space-md);
    }

    .nav.is-rail .nav__inner {
      gap: var(--space-sm);
      padding-bottom: var(--space-md);
    }

    .nav.is-rail .rail__stats {
      padding-block: var(--space-xs);
      gap: var(--space-xs);
    }

    .rail__stat-value {
      font-size: 1.15rem;
    }

    .nav.is-rail .nav__link {
      padding-block: 2px;
    }

    .nav.is-rail .rail__foot {
      padding-top: var(--space-sm);
    }

    .rail__cta {
      margin-top: var(--space-2xs);
      padding-block: 0.55rem;
    }
  }

  /* A second, tighter tier — 780px covers the 1366x768 laptop, which is
     the short screen that actually turns up. Below roughly this the
     column cannot hold everything at a readable size and .is-settled's
     overflow takes over, which is the point of having both. */
  @media (max-height: 780px) {
    .nav.is-rail {
      --rail-brand-max: 3.25rem;
      --rail-inset: var(--space-xs);
    }

    .nav.is-rail .nav__brand {
      top: var(--space-md);
    }

    .nav.is-rail .nav__inner {
      gap: var(--space-xs);
      padding-top: var(--space-md);
      padding-bottom: var(--space-sm);
    }

    .nav.is-rail .rail__stats {
      gap: var(--space-2xs);
    }

    .rail__stat-value {
      font-size: 1rem;
    }

    .nav.is-rail .rail__foot {
      padding-top: var(--space-xs);
    }
  }

  /* ---- Brand ----------------------------------------------------------
     (b): rendered at the hero's own display size and scaled down, so it
     is sharp at both ends of the run. Out of flow, because at full size
     it is several times wider than the card. script.js sets its width to
     the h1's measured width so the two wrap identically, then transforms
     it from one to the other. */
  body.is-migrating .nav.is-rail .nav__brand {
    position: absolute;
    left: var(--rail-pad);
    top: var(--space-lg);
    z-index: 2;
    font-size: var(--text-name);
    font-weight: 800;
    line-height: var(--leading-name);
    letter-spacing: var(--tracking-name);
    text-transform: none;
    color: var(--text);
    transform-origin: 0 0;
    will-change: transform;
  }

  /* Matches the hero's own full stop — the warm accent, not the violet */
  .nav__stop {
    color: var(--accent-text);
  }

  /* ---- Stats ---- */

  .nav.is-rail .rail__stats {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--space-sm) var(--space-xs);
    padding-block: var(--space-sm);
    border-block: 1px solid var(--border);
  }

  .rail__stat {
    display: flex;
    flex-direction: column;
    gap: 2px;
    min-width: 0;
  }

  .rail__stat-value {
    display: block;
    font-size: 1.35rem;
    font-weight: 800;
    line-height: 1;
    letter-spacing: -0.02em;
    color: var(--accent-text);
    font-variant-numeric: tabular-nums;
  }

  /* Under migration this is only a slot: it holds the space, and the
     number you actually see is a copy flying in from the hero. */
  body.is-migrating .rail__stat-value {
    visibility: hidden;
  }

  /* The copies. Same .stat__value class as the hero's, so they render
     identically, plus absolute positioning and a top-left origin. */
  .mig-stat {
    position: absolute;
    left: 0;
    top: 0;
    z-index: 2;
    display: block;
    transform-origin: 0 0;
    white-space: nowrap;
    pointer-events: none;
    will-change: transform;
  }

  .rail__stat-label {
    font-size: 0.6rem;
    font-weight: 500;
    letter-spacing: var(--tracking-label);
    text-transform: uppercase;
    color: var(--muted);
    /* Arrives late, once the number it belongs to has landed */
    opacity: var(--rail-late, 1);
  }

  /* ---- Links ---- */

  .nav.is-rail .nav__list {
    flex-direction: column;
    align-items: stretch;
    gap: var(--space-2xs);
  }

  .nav.is-rail .nav__link {
    display: block;
    padding-block: var(--space-2xs);
    transform-origin: 0 0;
  }

  body.is-migrating .nav__link {
    will-change: transform;
  }

  /* A left marker reads better on a vertical list than an underline
     swept across a full-width block */
  .nav.is-rail .nav__link::after {
    left: calc(var(--space-xs) * -1);
    top: 50%;
    bottom: auto;
    width: 2px;
    height: 12px;
    transform: translateY(-50%) scaleY(0);
    transform-origin: center;
    transition: transform var(--fast) ease;
  }

  .nav.is-rail .nav__link.is-active::after {
    transform: translateY(-50%) scaleY(1);
  }

  /* The bar's Resume button has no place to migrate to — the sidebar has
     a CTA of its own at the bottom. The hero keeps its Resume button, so
     nothing is lost at the top of the page. */
  .js .nav__resume-item {
    display: none;
  }

  /* ---- Foot: divider, contact, CTA ---- */

  .nav.is-rail .rail__foot {
    display: flex;
    flex-direction: column;
    gap: var(--space-2xs);
    /* Pins the block to the bottom of the card however tall the rest is */
    margin-top: auto;
    padding-top: var(--space-md);
    border-top: 1px solid var(--border);
    opacity: var(--rail-late, 1);
  }

  /* Scales with the card. At the 900px end the column inside the sidebar
     is only ~168px, and the email address at a fixed 0.7rem broke across
     two lines mid-word. */
  .rail__contact {
    font-size: clamp(0.6rem, 0.78vw, 0.7rem);
    line-height: 1.6;
    color: var(--body);
    word-break: break-word;
    transition: color var(--fast) ease;
  }

  .rail__place {
    font-size: clamp(0.6rem, 0.78vw, 0.7rem);
    color: var(--muted);
  }

  .rail__cta {
    display: block;
    margin-top: var(--space-xs);
    padding: var(--space-xs) var(--space-sm);
    background-color: var(--accent-fill);
    border-radius: var(--radius-lg);
    color: var(--on-accent);
    font-size: var(--text-label);
    font-weight: 600;
    letter-spacing: var(--tracking-label);
    text-transform: uppercase;
    text-align: center;
    transition: background-color var(--fast) ease;
  }

  /* ---- The blur ------------------------------------------------------
     One custom property, read by everything that travels, so the whole
     rack focus is a single write per frame rather than eleven. */
  body.is-migrating .nav__brand,
  body.is-migrating .nav__link,
  .mig-stat {
    filter: blur(var(--mig-blur, 0px));
  }

  @media (hover: hover) {
    .rail__contact:hover {
      color: var(--accent-text);
    }

    .rail__cta:hover {
      background-color: var(--accent-fill-hi);
    }
  }

  /* ---- Things the sidebar makes redundant ---- */

  /* A vertical list of section names is already on screen; the dot
     column on the right edge was saying the same thing twice. */
  body.is-rail .dots {
    opacity: 0;
    pointer-events: none;
  }

  /* Selected Work used to need overrides here — it had a sticky column of
     its own that fought the page-level gutter. It is a vertical timeline
     now (section 11) with no column and no horizontal scroller, so there
     is nothing left to override. */
}
/* --------------------------------------------------------------------------
   11. Selected Work — connected timeline

   Replaces the horizontal card row. The cards are an ordinary vertical
   column in the main content area, off-centre and alternating sides, with
   an SVG thread drawn between their markers.

   The thread's geometry is not written here: script.js measures where each
   marker actually ends up and builds the path from that, so the curve is
   right whatever height the cards take. This file only styles it. With
   JavaScript off no path is ever built, the svg stays empty, and what is
   left is a plain readable column of cards — which is why none of the
   layout below depends on the thread existing.
   -------------------------------------------------------------------------- */

/* Spans the whole timeline, sits behind the cards, never takes a click */

/* The whole route, faint — so the shape of what is coming is legible
   before the violet line has reached it */

/* The drawn line. script.js sets stroke-dasharray to the path's own
   length and moves stroke-dashoffset with scroll position, which is what
   makes it appear to be drawn from one marker to the next. */

/* ---- The card ---------------------------------------------------------- */

/* The marker the thread connects to. Sits on the card's edge, straddling
   the border, on whichever side faces the middle of the column. */

/* The large index, same treatment as the ghost numbers used elsewhere */

/* ---- The rest of the case study ---------------------------------------
   Every point from the original card is still here; the first two are the
   lead above and these are the remainder. 0fr to 1fr on a grid row is the
   one way to transition to a content-derived height. */

/* ---- Byline ------------------------------------------------------------ */

/* The organisation's initial rather than an empty circle — a blank
   placeholder shape on a finished page is just a hole */

/* ---- Reading focus -----------------------------------------------------
   One card at a time carries the section. script.js puts .is-current on
   whichever card the thread has most recently reached; the rest step back
   rather than disappear. Scoped to .js so a page without it shows every
   card at full strength instead of a column of dimmed ones. */

/* ---- Off-centre, alternating ------------------------------------------
   Only from 900px: below that a card needs the whole width it can get,
   and an alternating offset would make every one of them narrower for no
   gain. The dot stays on the left there and the thread runs straight
   down, which is the same code path with the curve flattened out. */

/* Taller gaps once there is room, so one card at a time sits in the
   viewport and the previous one has properly left before the next
   arrives — the "one at a time" reading the section is built around. */

/* ---- Reduced motion ----------------------------------------------------
   The thread is still drawn — it is the section's structure, not an
   effect — but script.js fills it in completely rather than tying it to
   scroll, and nothing here transitions. */
/* --------------------------------------------------------------------------
   12. Masked initials, and Capabilities as a statement
   -------------------------------------------------------------------------- */

/* ---- The "ME" band ------------------------------------------------------
   A visual beat, nothing more. The letters are filled with a collage of
   the site's own pictures — the four Shorts thumbnails and the portrait —
   clipped to the glyphs, so the page's own background is what shows
   everywhere outside them.

   background-clip: text rather than a mask image: it needs no extra
   asset, it scales with the type, and it degrades to a solid fill on
   anything that does not support it (see the @supports below, which is
   what actually turns the text transparent — without that rule the
   letters stay filled and visible rather than vanishing).

   The gradient is the bottom layer, so if a remote thumbnail fails to
   load the letters are still filled, on-palette, rather than empty. */

/* ---- Capabilities as one statement --------------------------------------
   Replaces the seven groups of tag chips. The skills are still named —
   they are the pills — but they sit inside a sentence rather than in a
   grid, so the section reads as a claim rather than an inventory. */

/* The icon definitions. Not display: none, which some browsers refuse to
   pull <use> references out of; taken out of flow and given no size
   instead, which every browser is happy with. */
.icon-defs {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
}

.statement {
  display: flex;
  flex-direction: column;
  gap: var(--space-lg);
  margin-top: var(--space-lg);
}

.statement__lead {
  max-width: 46ch;
  font-size: clamp(1.3rem, 2.7vw, 2.3rem);
  font-weight: 700;
  line-height: 1.5;
  letter-spacing: -0.015em;
  /* Off-white rather than pure white: at this size and weight, full white
     on this background is harsh over several lines */
  color: var(--text);
}

.statement__tools {
  max-width: 46ch;
  font-size: clamp(0.95rem, 1.5vw, 1.2rem);
  font-weight: 500;
  line-height: 1.9;
  color: var(--body);
}

/* ---- The pills ----------------------------------------------------------
   Sized in em, so they track whichever line of the statement they sit in
   and stay smaller than the words around them at every breakpoint. */

.pill {
  display: inline-flex;
  align-items: center;
  gap: 0.4em;
  margin-inline: 0.1em;
  padding: 0.2em 0.62em;
  vertical-align: middle;
  background-color: var(--surface);
  border: 1px solid var(--accent);
  border-radius: var(--radius-lg);
  font-size: 0.46em;
  font-weight: 600;
  letter-spacing: 0.02em;
  /* Kept tight so a pill does not push its line away from its neighbours */
  line-height: 1.5;
  color: var(--text);
  white-space: nowrap;
}

/* The tools line is already small, so its pills need proportionally more
   of it or the words inside stop being readable */
.statement__tools .pill {
  font-size: 0.8em;
}

.pill__icon {
  width: 1.05em;
  height: 1.05em;
  flex-shrink: 0;
  fill: none;
  stroke: var(--accent);
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}

@media (min-width: 900px) {
  .statement {
    gap: var(--space-xl);
    margin-top: var(--space-xl);
  }
}
/* --------------------------------------------------------------------------
   13. The pitch, standing on its own

   Moved out of the hero, where it sat under the name, and given a screen
   to itself between the hero and Profile. Not a .section: that class
   carries the reading-highlight dim, which needs an id and an observer
   entry to ever brighten again — the same reason .me-band sets its
   own padding rather than taking it.
   -------------------------------------------------------------------------- */

.pitch-band {
  /* The breathing room is the point — this is one line on an otherwise
     empty screen, and it stops reading as a pause if it is crowded */
  padding-block: clamp(5rem, 16vh, 10rem);
}

/* Two weights doing the work: the sentence sits at 400, which at heading
   scale reads as a spoken line rather than a headline, and the one
   emphasised clause is 700. The contrast is the whole design — there is
   no second colour, no size change, nothing else. */
.pitch-band__line {
  max-width: min(100%, 46rem);
  font-size: var(--text-heading);
  font-weight: 400;
  line-height: 1.3;
  letter-spacing: -0.02em;
  /* Off-white rather than pure white: at this size, over four lines,
     full white is harsh and leaves the accent nothing to lift against */
  color: var(--text);
}

/* Already <strong> in the original copy — this gives that existing
   emphasis the accent and the heavier weight, rather than adding
   emphasis of its own anywhere else in the line */
.pitch-band__line strong {
  color: var(--accent);
  font-weight: 700;
}

/* ---- The row beneath ---------------------------------------------------
   Face, status, action. Wraps rather than shrinks, so on a phone the
   button drops to its own line at full size instead of being squeezed. */

.pitch-band__row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  /* Tightens as the column narrows. At 900px the content column is only
     ~476px wide and the three items at the full gap came to just over
     that, dropping the button onto its own line for the sake of a few
     pixels. */
  gap: clamp(var(--space-xs), 1.1vw, var(--space-sm));
  margin-top: var(--space-xl);
}

.pitch-band__avatar {
  flex-shrink: 0;
  width: 46px;
  height: 46px;
  border-radius: 50%;
  border: 1px solid var(--border);
  object-fit: cover;
  /* Same crop the hero portrait uses, so the face is centred rather than
     the top of the head */
  object-position: 50% 18%;
}

.pitch-band__status {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2xs);
  padding: var(--space-2xs) clamp(var(--space-xs), 1.1vw, var(--space-sm));
  background-color: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  font-size: var(--text-label);
  font-weight: 500;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--body);
  white-space: nowrap;
}

.pitch-band__dot {
  flex-shrink: 0;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background-color: var(--accent);
}
/* --------------------------------------------------------------------------
   14. Card rail — a reusable horizontal scroller

   Capabilities uses it; anything else can, by carrying the same data
   attributes. Named .cardrail rather than .rail because .rail__* already
   means the sidebar elsewhere in this file.

   The mechanics are the ones the old Selected Work row used before that
   section became a vertical timeline: native overflow scrolling with
   mandatory snap, snap suspended while a wheel or drag gesture is under
   way, and a progress bar plus arrows driven from scrollLeft.
   -------------------------------------------------------------------------- */

.cardrail {
  margin-top: var(--space-lg);
}

.cardrail__row {
  display: flex;
  flex-direction: row;
  align-items: stretch;
  gap: var(--space-md);
  width: 100%;
  overflow-x: auto;
  overflow-y: hidden;
  padding-block: var(--space-2xs);
  scrollbar-width: thin;
  scrollbar-color: var(--border) transparent;
  /* Settles on a card boundary after any scroll — wheel, drag, touch or
     the arrows — rather than a fast flick landing halfway between two
     cards and reading as "skipped" */
  scroll-snap-type: x mandatory;
  -webkit-overflow-scrolling: touch;
}

/* script.js adds this only while a wheel or drag gesture is actually under
   way. Mandatory snap fights direct scrollLeft writes: those happen in many
   small steps rather than one native gesture, and the browser treats each
   step as a finished scroll and snaps straight back — which is what makes
   wheel and drag feel dead. Suspending snap for the gesture and letting it
   re-engage on release keeps both. */
.cardrail__row.is-snap-suspended {
  scroll-snap-type: none;
}

.cardrail__row::-webkit-scrollbar {
  height: 6px;
}

.cardrail__row::-webkit-scrollbar-thumb {
  background-color: var(--border);
  border-radius: var(--radius-lg);
}

.cardrail__row:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 4px;
}

/* ---- The card ----------------------------------------------------------
   Same surface, border, radius and hover lift as the Selected Work cards. */

/* ---- Controls ---------------------------------------------------------- */

.cardrail__controls {
  display: flex;
  align-items: center;
  gap: var(--space-md);
  margin-top: var(--space-md);
}

.cardrail__arrow {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: 40px;
  height: 40px;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: 50%;
  background-color: var(--surface);
  color: var(--text);
  font-size: 1rem;
  line-height: 1;
  transition: border-color var(--fast) ease,
              color var(--fast) ease,
              opacity var(--fast) ease;
}

.cardrail__arrow:disabled {
  opacity: 0.35;
  cursor: default;
}

@media (hover: hover) {
  .cardrail__arrow:not(:disabled):hover {
    border-color: var(--accent);
    color: var(--accent);
  }
}

.cardrail__progress {
  position: relative;
  flex: 1;
  height: 2px;
  background-color: var(--border);
  border-radius: var(--radius-lg);
  overflow: hidden;
}

.cardrail__bar {
  position: absolute;
  inset: 0;
  transform-origin: left center;
  /* script.js writes scaleX from 0 to 1 as the row scrolls */
  transform: scaleX(0);
  background-color: var(--accent);
}

/* ---- Drag affordance, desktop only ------------------------------------- */

@media (hover: hover) {
  .cardrail__row.is-draggable {
    cursor: grab;
  }

  .cardrail__row.is-dragging {
    cursor: grabbing;
    /* Stops the browser turning a drag into a text selection */
    user-select: none;
  }
}

@media (min-width: 900px) {

  .cardrail__row {
    gap: var(--space-lg);
  }
}

@media (prefers-reduced-motion: reduce) {

  .cardrail__row {
    scroll-behavior: auto;
  }
}

/* --------------------------------------------------------------------------
   15. Yellow — backgrounds only

   #f5e500 measures 1.18:1 against --bg, so it can never carry text. Behind
   near-black text it is the opposite: roughly 15:1, and the most legible
   pairing available on this page. Both uses below therefore put --text on
   the yellow, never violet — violet and yellow never share an element.
   -------------------------------------------------------------------------- */

/* ---- Highlighter behind the key phrase ---------------------------------
   A gradient rather than a flat fill, so it reads as a marker stroke laid
   over the words rather than a solid label. box-decoration-break makes a
   phrase that wraps get the stroke on each line instead of one long bar
   spanning the gap between them. */

.pitch-band__line strong,
.about__lead em {
  color: var(--text);
  background-image: linear-gradient(to top,
                    var(--accent-2) 0%,
                    var(--accent-2) 78%,
                    transparent 78%);
  padding-inline: 0.1em;
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
}

/* ---- Numbers on a yellow block ----------------------------------------- */

.stat__value,
.rail__stat-value,
.section__num {
  color: var(--on-accent-2);
  background-color: var(--accent-2);
  border-radius: var(--radius);
}

.stat__value,
.rail__stat-value {
  padding-inline: 0.18em;
}

/* From 900px these sit in a flex COLUMN, where a child stretches to the
   full width by default and the yellow would run the width of the whole
   stat rather than hugging the number. Shrinking the item to its own
   content fixes that without moving the number.

   Deliberately not below 900px: there .stat is a flex ROW aligned on the
   baseline, the item already sizes to its content, and align-self would
   override that baseline and knock the number out of line with its
   label. */
@media (min-width: 900px) {
  .stat__value,
  .rail__stat-value {
    align-self: flex-start;
  }
}

.section__num {
  padding-inline: 0.35em;
}

/* The reading highlight dims an inactive section's number to --muted. On
   yellow that is still 5.6:1, so it stays legible while dimmed. */
/* ==========================================================================
   STEP 2 — Hero, hero-to-sidebar morph, sidebar, mobile

   Self-contained, with its own prefixes (hx- hero, sb- sidebar), so it does
   not collide with the older hero, nav and rail rules further up the file —
   those have no markup left and are inert.
   ========================================================================== */

:root {
  --sb-w: clamp(240px, 20vw, 300px);
  --sb-gap: 1rem;
  /* STEP 2 gave these their own names before the palette had them. They
     are aliases now rather than second copies, so the theme switch reaches
     them too: a literal here would have left the whole sidebar and hero on
     the light palette while the rest of the page turned. */
  --yellow: var(--accent-2);
  --panel: var(--surface);
  --ink: var(--text);
  --violet: var(--accent);
  --violet-dark: var(--accent-soft);
  --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
  /* For something travelling ACROSS the screen rather than arriving on it
     or leaving it: the cursor gliding to the Send button in Contact. It
     accelerates away and settles, which is what a movement with a start
     and a finish both on screen actually looks like. */
  --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
}

/* The old gutter rule matched the new pin zone through :not(.hero-zone) and
   pushed the hero half a sidebar off centre. Superseded here. */
.js main > *:not(.hz),
.js .footer {
  padding-left: 0;
  padding-right: 0;
}

/* --------------------------------------------------------------------------
   Pin zone — 100vh of hero plus 50vh of transition, and nothing after it.
   script.js divides scrollY by that 50vh, so the two must agree.
   -------------------------------------------------------------------------- */

.hz { position: relative; }

@media (min-width: 900px) {
  .js .hz { height: 150vh; }

  .js .hz > .hx {
    position: -webkit-sticky;
    position: sticky;
    top: 0;
    height: 100vh;
  }

  /* The section after the zone starts immediately — no spacer, no extra
     top padding, so it is on screen the moment the morph finishes. */
  .js .hz + .section,
  .js .hz + * { margin-top: 0; }
}

/* --------------------------------------------------------------------------
   Hero
   -------------------------------------------------------------------------- */

.hx {
  position: relative;
  min-height: 100svh;
  padding: clamp(4.5rem, 9vh, 7rem) clamp(1rem, 3vw, 2.5rem) clamp(1rem, 3vh, 2rem);
  overflow: clip;
}

/* ---- Wordmark: spans ~92% of the viewport ----
   Centred by offset, never by transform — the morph owns this element's
   transform outright and a translateX(-50%) here would be wiped the first
   frame it writes. */
.hx__word {
  position: absolute;
  left: 4%;
  top: clamp(3rem, 8vh, 6rem);
  z-index: 0;
  width: 92%;
  font-size: 14.6vw;
  font-weight: 800;
  line-height: 0.8;
  letter-spacing: -0.045em;
  text-align: center;
  color: var(--name);
  white-space: nowrap;
  pointer-events: none;
  -webkit-user-select: none;
  user-select: none;
  transform-origin: 0 0;
}

/* ---- Portrait: tall, bottom-aligned, IN FRONT of the letters ---- */

.hx__portrait {
  position: absolute;
  left: 50%;
  bottom: 0;
  /* Above the wordmark: the head reads in front of the lower half of the
     letterforms rather than behind them. */
  z-index: 2;
  width: auto;
  height: 85vh;
  transform: translateX(-50%);
  object-fit: contain;
  object-position: bottom center;
  pointer-events: none;
  /* ---- the mask -----------------------------------------------------
     The PNG is only half a cutout. Measured on portrait-cutout.png
     (448x557): at face level the alpha row reads 0 0 0 0 255 255 255 0 0
     0 0, so the head is properly cut out, but at shoulder level it reads
     255 all the way across, 181 of the 557 pixels on the left edge are
     fully opaque, 192 on the right, and all 448 along the bottom. The
     lower third is a hard crop. That straight cut is the image, not the
     mask, and short of a new asset the mask is what has to hide it.

     Three layers, INTERSECTED. The default composite is `add`, which
     unions them: every layer would only ever make the picture MORE
     visible and the whole thing would do nothing. `intersect` means a
     pixel survives only where all three agree, so each layer can take
     something away.

     The colours here are the only ones in this file outside the palette,
     and deliberately: in a mask the colour is an opacity stop, not a
     colour. Tokenising them would let the theme change how much of the
     portrait is visible. It blends into whichever --bg is behind it
     either way, because what fades is alpha, not ink. */
  --hx-mask-x: linear-gradient(to right,
      transparent 0%, #000 18%, #000 82%, transparent 100%);
  /* The shoulders dissolve into the section below rather than stopping
     on the image's own bottom edge */
  --hx-mask-y: linear-gradient(to bottom,
      #000 0%, #000 75%, transparent 100%);
  /* Rounds the corners off. Centred at 38% down, which is the face, so
     the falloff is symmetrical about the head rather than about the
     middle of a box whose bottom third is shoulders. */
  --hx-mask-r: radial-gradient(ellipse 80% 86% at 50% 38%,
      #000 52%, rgba(0, 0, 0, 0.6) 76%, transparent 100%);

  -webkit-mask-image: var(--hx-mask-x), var(--hx-mask-y), var(--hx-mask-r);
  -webkit-mask-composite: source-in;
  -webkit-mask-repeat: no-repeat;
  -webkit-mask-size: 100% 100%;

  mask-image: var(--hx-mask-x), var(--hx-mask-y), var(--hx-mask-r);
  mask-composite: intersect;
  mask-repeat: no-repeat;
  mask-size: 100% 100%;

  /* Depth rather than a cut. drop-shadow reads the MASKED alpha, so the
     shadow follows the feathered silhouette instead of the image's
     rectangle. --shadow-2 flips with the theme, so this deepens on the
     dark page instead of glowing. Kept in a variable because the mobile
     intro and the scroll animation both write `filter` and would
     otherwise drop it. */
  --hx-shadow: drop-shadow(0 12px 28px var(--shadow-2));
  filter: var(--hx-shadow);
}

/* ---- Nav row, on the wordmark's baseline, split around the portrait ---- */

.hx__nav {
  position: absolute;
  /* Just under the wordmark's baseline at every width rather than cutting
     through the letterforms. */
  top: clamp(10rem, 29vh, 18rem);
  z-index: 4;
  display: flex;
  align-items: center;
  gap: clamp(0.5rem, 1vw, 1rem);
}

.hx__nav--left  { left: clamp(1rem, 3vw, 2.5rem); }
.hx__nav--right { right: clamp(1rem, 3vw, 2.5rem); }

.hx__navlink {
  font-size: clamp(0.62rem, 0.78vw, 0.78rem);
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink);
  white-space: nowrap;
  transform-origin: 0 0;
  transition: color 160ms var(--ease-out);
}

.hx__div {
  width: 1px;
  height: 0.85em;
  background-color: var(--rule);
}

/* ---- Headline over the chest ---- */

.hx__lead {
  position: absolute;
  left: 50%;
  top: 62%;
  z-index: 5;
  transform: translateX(-50%);
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: clamp(0.8rem, 2vh, 1.4rem);
  width: max-content;
  max-width: min(90vw, 30vw + 8rem);
}

.hx__head {
  margin: 0;
  font-size: clamp(1.75rem, 3.6vw, 3.75rem);
  font-weight: 800;
  line-height: 1.02;
  letter-spacing: -0.03em;
  color: var(--hx-head);
  text-shadow: 0 2px 18px var(--shadow-4),
               0 1px 3px var(--shadow-4);
}

.hx__btns { display: flex; flex-wrap: wrap; gap: 0.6rem; }

.hx__btn {
  display: inline-flex;
  align-items: center;
  padding: 0.7em 1.4em;
  border-radius: 999px;
  font-size: clamp(0.72rem, 0.85vw, 0.85rem);
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  white-space: nowrap;
  transform-origin: 0 0;
  transition: background-color 160ms var(--ease-out);
}

.hx__btn--go  { background-color: var(--accent-fill); color: var(--on-accent); }
.hx__btn--alt { background-color: var(--panel); color: var(--ink); }

/* ---- Stat cards, left of the portrait ---- */

.hx__stats {
  position: absolute;
  left: clamp(1rem, 3vw, 2.5rem);
  top: 46%;
  z-index: 4;
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  width: clamp(150px, 15vw, 205px);
}

.hx__stat {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  padding: 0.9rem 1rem;
  background-color: var(--panel-soft);
  border: 1px solid var(--hairline);
  border-radius: 14px;
  transform-origin: 0 0;
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
}

/* Yellow on beige is 1.18:1 and unreadable, so the number sits on a dark
   chip instead — near-black behind yellow is about 15:1. */
.hx__num {
  align-self: flex-start;
  padding: 0.1em 0.45em;
  border-radius: 7px;
  background-color: var(--chip-bg);
  color: var(--yellow);
  font-size: clamp(1.1rem, 1.7vw, 1.5rem);
  font-weight: 800;
  line-height: 1.18;
}

.hx__lab {
  font-size: clamp(0.6rem, 0.72vw, 0.72rem);
  font-weight: 600;
  letter-spacing: 0.05em;
  color: var(--body);
}

/* ---- Traits panel, right of the portrait ---- */

.hx__traits {
  position: absolute;
  right: clamp(1rem, 3vw, 2.5rem);
  top: 42%;
  z-index: 4;
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  margin: 0;
  padding: 1rem 1.1rem;
  list-style: none;
  width: clamp(160px, 16vw, 215px);
  background-color: var(--panel-soft);
  border: 1px solid var(--hairline);
  border-radius: 16px;
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
}

.hx__trait {
  display: flex;
  align-items: center;
  gap: 0.55rem;
  font-size: clamp(0.66rem, 0.8vw, 0.8rem);
  font-weight: 600;
  color: var(--ink);
}

.hx__badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: 22px;
  height: 22px;
  border-radius: 7px;
  background-color: var(--yellow);
  color: var(--on-accent-2);
}

/* ---- Bottom rail ---- */

.hx__tag,
.hx__desc {
  position: absolute;
  bottom: clamp(1rem, 3vh, 2rem);
  z-index: 4;
  margin: 0;
  font-size: clamp(0.7rem, 0.86vw, 0.88rem);
  line-height: 1.5;
}

.hx__tag {
  left: clamp(1rem, 3vw, 2.5rem);
  display: flex;
  flex-direction: column;
  font-weight: 700;
  color: var(--ink);
}

.hx__desc {
  right: clamp(1rem, 3vw, 2.5rem);
  max-width: min(30ch, 28vw);
  text-align: right;
  color: var(--body);
}

@media (hover: hover) and (pointer: fine) {
  .hx__navlink:hover { color: var(--violet); }
  .hx__btn--go:hover { background-color: var(--violet-dark); }
  .hx__btn--alt:hover { background-color: var(--surface-strong); }
}

/* --------------------------------------------------------------------------
   Intro — CSS keyframes, not JS. This plays during load when the main
   thread is busiest; CSS animation runs off it, rAF does not.
   -------------------------------------------------------------------------- */

/* The drop-shadow rides along in every keyframe that touches `filter`.
   A filled animation owns the property outright, so leaving it out here
   would silently delete the portrait's shadow for the length of the
   intro and, with fill-mode both, for good afterwards. */
@keyframes hx-rise {
  from { opacity: 0; transform: translateX(-50%) translateY(40px); filter: var(--hx-shadow) blur(20px); }
  to   { opacity: 1; transform: translateX(-50%) translateY(0);    filter: var(--hx-shadow) blur(0); }
}

@keyframes hx-in {
  from { opacity: 0; transform: translateY(14px); filter: blur(6px); }
  to   { opacity: 1; transform: translateY(0);    filter: blur(0); }
}

@keyframes hx-in-x {
  from { opacity: 0; transform: translateX(-50%) translateY(14px); filter: blur(6px); }
  to   { opacity: 1; transform: translateX(-50%) translateY(0);    filter: blur(0); }
}

/* STEP 11: scoped to the desktop hero, which is the only layout these
   keyframes were ever written for.

   hx-rise and hx-in-x carry `translateX(-50%)`, because on desktop the
   portrait and the headline are absolutely positioned at left: 50% and
   that offset IS their centring. On a phone both are ordinary flex items
   centred by their container, so the same keyframe dragged them half
   their own width to the left and held them there for the whole 1.9s of
   the intro, then dropped them back when .is-ready killed the animation.
   Measured at 390px: the portrait sat at x=48 and the headline at x=16
   against a viewport centre of 195, then both jumped. That was the
   side-to-centre jump. The phone has its own intro further down. */
@media (min-width: 900px) {
  .js .hx__portrait   { animation: hx-rise 700ms var(--ease-out) 600ms both; }
  .js .hx__nav--left  { animation: hx-in 450ms var(--ease-out) 1000ms both; }
  .js .hx__nav--right { animation: hx-in 450ms var(--ease-out) 1080ms both; }
  .js .hx__lead       { animation: hx-in-x 450ms var(--ease-out) 1160ms both; }
  .js .hx__stats      { animation: hx-in 450ms var(--ease-out) 1240ms both; }
  .js .hx__traits     { animation: hx-in 450ms var(--ease-out) 1320ms both; }
  .js .hx__tag        { animation: hx-in 450ms var(--ease-out) 1400ms both; }
  .js .hx__desc       { animation: hx-in 450ms var(--ease-out) 1480ms both; }
}

/* animation-fill-mode: both keeps the keyframes owning opacity, filter and
   transform for good, and a filled animation outranks an inline style — so
   every write the morph makes would be silently ignored. Dropping them once
   the intro has played hands those properties back. */
.hx.is-ready .hx__portrait,
.hx.is-ready .hx__nav--left,
.hx.is-ready .hx__nav--right,
.hx.is-ready .hx__lead,
.hx.is-ready .hx__stats,
.hx.is-ready .hx__traits,
.hx.is-ready .hx__tag,
.hx.is-ready .hx__desc { animation: none; }

.hx.is-ready .hx__lead { transform: translateX(-50%); }

/* At p = 1 every moving hero piece is hidden in the same frame the real
   sidebar is shown, so the swap has nothing to see. */
.hx.is-gone .hx__word,
.hx.is-gone .hx__nav,
.hx.is-gone .hx__stats,
.hx.is-gone .hx__btn--go { visibility: hidden; }

/* --------------------------------------------------------------------------
   Sidebar — the morph destination.
   visibility: hidden, NOT display: none: it has to be laid out so its boxes
   can be measured, but must not be seen until the morph lands on it.
   -------------------------------------------------------------------------- */

.sb { display: none; }

@media (min-width: 900px) {
  .js .sb {
    display: flex;
    position: fixed;
    left: var(--sb-gap);
    top: var(--sb-gap);
    bottom: var(--sb-gap);
    z-index: 60;
    flex-direction: column;
    gap: 0.6rem;
    width: var(--sb-w);
    visibility: hidden;
  }

  .js .sb.is-live { visibility: visible; }

  .js main > *:not(.hz),
  .js .footer {
    padding-left: calc(var(--sb-w) + var(--sb-gap) * 2);
    padding-right: clamp(1rem, 3vw, 2.5rem);
  }
}

.sb__panel {
  padding: 0.85rem 0.95rem;
  background-color: var(--panel);
  border: 1px solid var(--hairline);
  border-radius: 16px;
}

.sb__brandrow { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }

.sb__logo {
  display: inline-block;
  padding: 0.3em 0.7em;
  border-radius: 999px;
  background-color: var(--yellow);
  color: var(--on-accent-2);
  font-size: 0.72rem;
  font-weight: 800;
  letter-spacing: 0.06em;
}

.sb__icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 30px;
  height: 30px;
  border-radius: 9px;
  background-color: var(--sink);
  color: var(--ink);
  transition: background-color 160ms var(--ease-out);
}

.sb__blurb { margin: 0.6rem 0 0; font-size: 0.66rem; line-height: 1.5; color: var(--body); }

.sb__stats { display: flex; align-items: stretch; gap: 0.6rem; }
.sb__stat { display: flex; flex-direction: column; gap: 0.15rem; flex: 1; min-width: 0; }
.sb__stat + .sb__stat { padding-left: 0.6rem; border-left: 1px solid var(--hairline); }

.sb__num {
  align-self: flex-start;
  padding: 0.08em 0.4em;
  border-radius: 6px;
  background-color: var(--chip-bg);
  color: var(--yellow);
  font-size: 0.95rem;
  font-weight: 800;
  line-height: 1.2;
}

.sb__lab { font-size: 0.56rem; font-weight: 600; letter-spacing: 0.05em; color: var(--body); }

.sb__nav { display: flex; flex-direction: column; gap: 0.1rem; padding: 0.5rem; }

.sb__link {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.42rem 0.55rem;
  border-radius: 9px;
  font-size: 0.7rem;
  font-weight: 600;
  color: var(--ink);
  transition: background-color 160ms var(--ease-out), color 160ms var(--ease-out);
}

.sb__ico { flex-shrink: 0; width: 14px; height: 14px; color: currentColor; }

/* Only lit once the morph has landed — a violet row appearing mid-flight
   would compete with the item still flying toward it. */
.sb.is-live .sb__link.is-active { background-color: var(--accent-fill); color: var(--on-accent); }

.sb__ticker { overflow: clip; padding: 0.6rem 0; }
.sb__track { display: flex; width: max-content; }
.sb__chips { display: flex; gap: 0.35rem; margin: 0; padding: 0 0.2rem; list-style: none; }

.sb__chip {
  padding: 0.25em 0.6em;
  border-radius: 999px;
  background-color: var(--sink);
  font-size: 0.56rem;
  font-weight: 600;
  white-space: nowrap;
  color: var(--body);
}

.sb__mail { display: flex; align-items: center; gap: 0.4rem; }
.sb__addr { flex: 1; min-width: 0; font-size: 0.6rem; color: var(--body); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.sb__copy {
  flex-shrink: 0;
  padding: 0.3em 0.6em;
  border: 1px solid var(--hairline-2);
  border-radius: 7px;
  background: none;
  font-size: 0.56rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink);
  cursor: pointer;
}

.sb__resume {
  display: block;
  margin-top: auto;
  padding: 0.75rem;
  border-radius: 12px;
  background-color: var(--accent-fill);
  color: var(--on-accent);
  font-size: 0.7rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  text-align: center;
  transition: background-color 160ms var(--ease-out);
}

@media (hover: hover) and (pointer: fine) {
  .sb__icon:hover { background-color: var(--sink-hi); }
  .sb.is-live .sb__link:not(.is-active):hover { background-color: var(--sink); }
  .sb__copy:hover { border-color: var(--violet); color: var(--violet); }
  .sb__resume:hover { background-color: var(--violet-dark); }
}

@media (min-width: 900px) and (max-height: 820px) {
  .js .sb { gap: 0.4rem; }
  .sb__panel { padding: 0.6rem 0.75rem; }
  .sb__blurb { margin-top: 0.4rem; font-size: 0.62rem; }
  .sb__link { padding: 0.3rem 0.5rem; font-size: 0.66rem; }
  .sb__resume { padding: 0.55rem; }
}

@media (min-width: 900px) and (max-height: 680px) {
  .sb__blurb, .sb__ticker { display: none; }
}

.hx__word, .hx__navlink, .hx__stat, .hx__btn { will-change: transform; }

/* --------------------------------------------------------------------------
   Mobile — no pin, no sidebar, stacked hero
   -------------------------------------------------------------------------- */

.topbar { display: none; }

@media (max-width: 899px) {
  .topbar {
    display: flex;
    position: sticky;
    top: 0;
    z-index: 60;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    padding: 0.6rem 1rem;
    background-color: var(--bg-blur-bar);
    -webkit-backdrop-filter: blur(10px);
    backdrop-filter: blur(10px);
    border-bottom: 1px solid var(--hairline);
  }

  .logo-pill {
    padding: 0.3em 0.7em;
    border-radius: 999px;
    background-color: var(--yellow);
    color: var(--on-accent-2);
    font-size: 0.7rem;
    font-weight: 800;
    letter-spacing: 0.06em;
  }

  .hx {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 1.1rem;
    min-height: 0;
    padding: 1.5rem 1rem 2.5rem;
  }

  .hx__word, .hx__portrait, .hx__nav, .hx__lead,
  .hx__stats, .hx__traits, .hx__tag, .hx__desc {
    position: static;
    transform: none;
    inset: auto;
    width: 100%;
    max-width: none;
  }

  .hx__word { order: 1; font-size: 15.5vw; text-align: center; }
  .hx__portrait { order: 2; height: auto; max-width: 82%; margin-top: -10%; }
  .hx__lead { order: 3; align-items: center; }
  .hx__head { text-align: center; font-size: clamp(1.75rem, 8.5vw, 2.5rem); }
  .hx__btns { justify-content: center; }
  .hx__stats { order: 4; flex-direction: row; gap: 0.6rem; }
  .hx__stat { flex: 1; }

  .hx__traits {
    order: 5; flex-direction: row; flex-wrap: wrap; justify-content: center;
    gap: 0.4rem; padding: 0; background: none; border: 0;
    -webkit-backdrop-filter: none; backdrop-filter: none;
  }

  .hx__trait {
    padding: 0.35em 0.7em;
    background-color: var(--panel);
    border: 1px solid var(--hairline);
    border-radius: 999px;
    font-size: 0.66rem;
  }

  .hx__tag  { order: 6; align-items: center; text-align: center; }
  .hx__desc { order: 7; max-width: 38ch; text-align: center; }
  .hx__nav  { display: none; }
}

/* --------------------------------------------------------------------------
   Reduced motion — final state, no intro, no pin, no morph
   -------------------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  .js .hx__portrait, .js .hx__nav--left, .js .hx__nav--right, .js .hx__lead,
  .js .hx__stats, .js .hx__traits, .js .hx__tag, .js .hx__desc { animation: none; }

  .js .hz { height: auto; }
  .js .hz > .hx { position: relative; height: auto; }
  .js .sb { visibility: visible; }
  .hx__word, .hx__navlink, .hx__stat, .hx__btn { will-change: auto; }
}

/* ==========================================================================
   STEP 2c — four stats, a bigger sidebar, and a crossfaded landing
   Appended last, so these win over the section above by source order.
   ========================================================================== */

:root { --sb-w: clamp(290px, 22vw, 360px); }

/* ---- Hero: four cards in a 2x2 grid, tighter so they still fit ---- */

.hx__stats {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0.5rem;
  width: clamp(230px, 23vw, 310px);
  top: 42%;
}

.hx__stat {
  gap: 0.2rem;
  padding: 0.6rem 0.65rem;
  border-radius: 12px;
}

.hx__num { font-size: clamp(0.95rem, 1.35vw, 1.2rem); padding: 0.08em 0.38em; }
.hx__lab { font-size: clamp(0.53rem, 0.62vw, 0.63rem); line-height: 1.3; }

/* --------------------------------------------------------------------------
   Sidebar — full height, no empty space
   -------------------------------------------------------------------------- */

@media (min-width: 900px) {
  .js .sb {
    top: 0;
    bottom: 0;
    left: 20px;
    padding-block: 20px;
    gap: 12px;
    width: var(--sb-w);
  }

  .js main > *:not(.hz),
  .js .footer {
    padding-left: calc(var(--sb-w) + 40px);
  }
}

.sb__panel { padding: 14px 16px; border-radius: 18px; }

.sb__logo { font-size: 20px; padding: 0.32em 0.75em; }
.sb__icon { width: 36px; height: 36px; border-radius: 11px; }
.sb__blurb { margin-top: 10px; font-size: 14px; line-height: 1.45; }

/* Stats: 2x2, every number on its own dark chip with the label beneath */
.sb__stats {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 12px 10px;
}

.sb__stat { display: flex; flex-direction: column; gap: 3px; }
.sb__stat + .sb__stat { padding-left: 0; border-left: 0; }

.sb__num { font-size: 26px; padding: 0.06em 0.34em; border-radius: 8px; line-height: 1.15; }
.sb__lab { font-size: 12px; line-height: 1.25; }

/* The nav takes the free height and spreads its items through it, so the
   column has no slack left over at the bottom */
.sb__nav {
  flex: 1;
  justify-content: space-evenly;
  gap: 0;
  padding: 10px;
}

.sb__link { font-size: 17px; gap: 12px; padding: 12px 12px; border-radius: 11px; }
.sb__ico { width: 20px; height: 20px; }

.sb__chip { font-size: 13px; padding: 0.3em 0.7em; }
.sb__addr { font-size: 14px; }
.sb__copy { font-size: 11px; padding: 0.4em 0.7em; }

.sb__resume {
  min-height: 52px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 16px;
  border-radius: 14px;
  margin-top: 0;
}

/* Short viewports: step everything down so the column still fits without
   the sidebar needing to scroll */
@media (min-width: 900px) and (max-height: 920px) {
  .js .sb { padding-block: 12px; gap: 8px; }
  .sb__panel { padding: 10px 12px; border-radius: 14px; }
  .sb__logo { font-size: 16px; }
  .sb__icon { width: 30px; height: 30px; }
  .sb__blurb { margin-top: 7px; font-size: 12px; line-height: 1.4; }
  .sb__num { font-size: 19px; }
  .sb__lab { font-size: 10px; }
  .sb__stats { gap: 8px; }
  .sb__link { font-size: 14px; padding: 8px 10px; gap: 9px; }
  .sb__ico { width: 16px; height: 16px; }
  .sb__nav { padding: 6px; }
  .sb__chip { font-size: 11px; }
  .sb__addr { font-size: 12px; }
  .sb__resume { min-height: 42px; font-size: 13px; }
}

@media (min-width: 900px) and (max-height: 660px) {
  .sb__blurb, .sb__ticker { display: none; }
  .sb__num { font-size: 16px; }
  .sb__link { font-size: 12.5px; padding: 6px 8px; }
}

/* ==========================================================================
   STEP 2e — the inverse FLIP

   The real sidebar elements are the ones that move, so everything here is
   written so that none of them can be hidden, clipped or faded by something
   they happen to sit inside.

   Two rules carry most of the weight:

   1. A panel's background is painted on a pseudo-element, not on the panel.
      Fading the panel itself would multiply straight through to every item
      travelling inside it — which is exactly how items used to vanish.
   2. Nothing in the sidebar clips. An item at the start of the run is
      scaled up several times and sitting out in the middle of the screen,
      which is the whole point.
   ========================================================================== */

/* --------------------------------------------------------------------------
   The pin zone

   Only a short pin now. The sidebar is position:fixed, so its items go on
   animating long after the hero has scrolled away — holding the hero on
   screen for the whole run is what left the right-hand column empty at the
   end. 110vh pins for 10vh, which comfortably covers the 4vh dissolve at
   the start, and then lets Profile rise into view while the sidebar
   finishes assembling itself.
   -------------------------------------------------------------------------- */

@media (min-width: 900px) {
  .js .hz { height: 110vh; }
}

/* --------------------------------------------------------------------------
   Sidebar visibility and hit-testing
   -------------------------------------------------------------------------- */

@media (min-width: 900px) {
  /* Visible from the first frame of the morph, because from that frame on
     the real elements ARE the animation. */
  .js .sb.is-morphing,
  .js .sb.is-live { visibility: visible; }

  /* Mid-flight a nav row is a huge transformed box lying across the hero.
     It must not eat clicks until it has arrived and become a nav row. */
  .js .sb.is-morphing:not(.is-live) { pointer-events: none; }

  /* Overflow of any kind would crop the items on their way in */
  .js .sb,
  .js .sb .sb__panel { overflow: visible; }

  /* Before the morph starts they are invisible AND unmeasured-for-paint;
     script.js drives the opacity from the first scroll frame onwards. */
  .js .sb .sb__logo,
  .js .sb .sb__stat,
  .js .sb .sb__link,
  .js .sb .sb__blurb,
  .js .sb .sb__icon,
  .js .sb .sb__track,
  .js .sb .sb__addr,
  .js .sb .sb__copy { opacity: 0; }
}

/* --------------------------------------------------------------------------
   Panel backgrounds on a pseudo-element

   --bgo and --bgs are written by script.js. Their defaults are the finished
   state, so with no JS, on mobile, or under reduced motion the panel simply
   looks like a panel.
   -------------------------------------------------------------------------- */

.sb__panel {
  position: relative;
  z-index: 0;
  background-color: transparent;
  border-color: transparent;
}

.sb__panel::before {
  content: "";
  position: absolute;
  z-index: -1;
  inset: 0;
  box-sizing: border-box;
  border: 1px solid var(--hairline);
  border-radius: inherit;
  background-color: var(--panel);
  opacity: var(--bgo, 1);
  transform: scale(var(--bgs, 1));
  transform-origin: 50% 0;
}

/* The ticker is the one panel that has to clip — its track is twice as wide
   as the panel by design. Clip the track, not the panel. */
.sb__ticker { overflow: visible; }
.sb__ticker .marquee__track { will-change: transform; }

@media (min-width: 900px) {
  .js .sb .sb__ticker { overflow: clip; }
}

/* --------------------------------------------------------------------------
   The logo pill

   The name itself is the travelling item and stays legible all the way; the
   yellow only arrives under it at the end, so the wordmark does not turn
   into a pill until it is nearly home.
   -------------------------------------------------------------------------- */

.sb__logo {
  position: relative;
  z-index: 0;
  background-color: transparent;
  transform-origin: 0 0;
}

.sb__logo::before {
  content: "";
  position: absolute;
  z-index: -1;
  inset: 0;
  border-radius: 999px;
  background-color: var(--yellow);
  opacity: var(--pill, 1);
}

.sb__logoin { display: inline-block; }

/* --------------------------------------------------------------------------
   Resume — violet from the first frame, two labels in one box
   -------------------------------------------------------------------------- */

.sb__resume { transform-origin: 0 0; }
.sb__rin { position: relative; display: inline-block; }

/* Resume is the real label and the one that sizes the button, so it is the
   one in normal flow and visible by default. "Get in touch" is borrowed
   from the hero for the first half of the journey only — it rides on top,
   centred, and is invisible until script.js fades it in at p = 0. */
.sb__r2 { display: block; white-space: nowrap; }

.sb__r1 {
  position: absolute;
  top: 0;
  left: 50%;
  display: block;
  white-space: nowrap;
  transform: translateX(-50%);
  opacity: 0;
}

/* --------------------------------------------------------------------------
   Everything that travels scales about its own top-left, which is the
   corner the measurements in script.js are taken from.
   -------------------------------------------------------------------------- */

.sb__stat,
.sb__link { transform-origin: 0 0; }

/* Only hidden where the morph is actually going to run and bring them in */
@media (min-width: 900px) {
  .js .sb__ico { opacity: 0; }
}

/* The hero's icons only ever existed to be carried across. The sidebar owns
   its icons now, so the hero's stay out of sight for good. */
.hx__navico { display: none; }

/* Once the hero items are at zero they leave hit-testing too, so the
   invisible hero nav cannot swallow a click meant for the sidebar. */
.hx.is-gone .hx__word,
.hx.is-gone .hx__nav,
.hx.is-gone .hx__stats,
.hx.is-gone .hx__btn--go { visibility: hidden; }

/* --------------------------------------------------------------------------
   No morph: mobile, no JS, or reduced motion. Every element above is simply
   itself, with nothing driving it.
   -------------------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  .js .sb .sb__logo,
  .js .sb .sb__stat,
  .js .sb .sb__link,
  .js .sb .sb__blurb,
  .js .sb .sb__icon,
  .js .sb .sb__track,
  .js .sb .sb__addr,
  .js .sb .sb__copy,
  .js .sb .sb__ico,
  .js .sb .sb__resume { opacity: 1; }

  .js .hz { height: auto; }
  .js .hz > .hx { position: relative; }
}

.no-js .sb__ico,
.no-js .sb__logo,
.no-js .sb__stat,
.no-js .sb__link { opacity: 1; }

/* --------------------------------------------------------------------------
   Reveal fallback — a section whose observer never fires must still show
   -------------------------------------------------------------------------- */

.no-js [data-reveal],
[data-reveal].is-revealed { opacity: 1; transform: none; }

/* ==========================================================================
   STEP 2f — the wordmark becomes the full name

   Two lines instead of one. ELHAYYANY is the wider word and the one that
   sets the size: the font-size below is tuned so it spans ~92% of the hero,
   and because that size is in vw while the box is a percentage, the fill
   ratio holds at every width rather than only at the one it was eyeballed
   at.

   The sidebar pill is given the same two-line structure, the same tracking
   and the same line-height on purpose. The morph scales the real pill by
   the ratio of the two font sizes, so the two blocks only overlay each
   other exactly while every measurement that is expressed in em matches —
   a different letter-spacing on the pill would make its ELHAYYANY a
   different width once scaled up, and the swap at the start would show it.
   ========================================================================== */

/* The wordmark's own measurements, as properties, because three other
   elements now have to be positioned from its bottom edge rather than from
   a percentage of the viewport. A percentage would not hold: the block's
   height scales with the WIDTH of the window and everything else was
   placed against its height, so a wide short screen dropped the nav row
   straight through the middle of ELHAYYANY. */
.hx {
  --word-top: clamp(1.5rem, 4vh, 3.5rem);
  --word-size: 14.95vw;
  /* two lines at line-height 0.85 */
  --word-bottom: calc(var(--word-top) + var(--word-size) * 1.7);
}

.hx__word {
  /* ELHAYYANY at ~92% of the hero; MOHAMED is the same size, centred over it */
  top: var(--word-top);
  font-size: var(--word-size);
  line-height: 0.85;
  letter-spacing: -0.02em;
  text-transform: uppercase;
}

/* ---- Everything the taller wordmark pushed down ---- */

.hx__nav { top: calc(var(--word-bottom) + 0.7rem); }

.hx__stats,
.hx__traits { top: calc(var(--word-bottom) + 3.2rem); }

/* Shrink-wrapped to the longer of the two lines, so the block the morph
   measures is the letters themselves and not the 92% box around them. */
.hx__wordin { display: inline-block; }
.hx__ln { display: block; }

/* --------------------------------------------------------------------------
   The sidebar pill — same name, same two lines, same proportions
   -------------------------------------------------------------------------- */

.sb__logo {
  /* A 999px radius on a two-line block is a lozenge, not a pill */
  border-radius: 12px;
  padding: 0.34em 0.6em;
  line-height: 0.85;
  letter-spacing: -0.02em;
  text-transform: uppercase;
  text-align: center;
}

.sb__logo::before { border-radius: 12px; }
.sb__logoin { display: inline-block; }
.sb__ln { display: block; }

.logo-pill { text-transform: uppercase; }

/* --------------------------------------------------------------------------
   Mobile — the name is nine characters longer than the old wordmark, and the
   stacked hero has 1rem of padding either side that the desktop 92% box does
   not, so it needs its own size or ELHAYYANY runs off the screen.
   -------------------------------------------------------------------------- */

@media (max-width: 899px) {
  .hx__word { font-size: 14vw; }
  .hx__nav, .hx__stats, .hx__traits { top: auto; }
  /* One line, whatever happens. The pill had no nowrap, so before Inter
     arrives the wider fallback face could push "ELHAYYANY" onto a second
     line and the bar would jump when the font landed. It also had
     POSITIVE tracking, which is what made a name this long feel stretched
     and cramped at the same time.

     11.5px and -0.02em: bigger than the 9.92px it was, and still ~50px
     clear of the theme toggle at 360px. width auto is the default for an
     inline-block and is written out because this pill is the one element
     the hero-to-sidebar morph does not touch, so nothing else guarantees
     it. */
  .logo-pill {
    width: auto;
    padding: 6px 12px;
    font-size: 11.5px;
    letter-spacing: -0.02em;
    white-space: nowrap;
  }

  /* Below 380px the name is the widest thing in the bar, so it gives up a
     little more size rather than wrapping */
  @media (max-width: 379px) {
    .logo-pill { font-size: 10.5px; padding-inline: 10px; }
  }

  /* `.hx.is-ready .hx__lead` restores the desktop centring offset when the
     intro finishes, and at two class names it outranks the stacked-hero
     reset above, which is only one. On a phone the headline is a normal
     block in a flex column, so half its own width to the left puts it off
     the screen. It has to be beaten at its own specificity.

     This used to be the only defence against the desktop keyframes, and
     it only covered the END state: it put the headline back once the
     intro had finished, having spent the whole intro off to the left.
     The keyframes themselves are scoped to 900px and up now, so this is
     belt and braces rather than the fix. */
  .hx.is-ready .hx__lead { transform: none; }
}

/* ==========================================================================
   STEP 3 — the Journey timeline

   Five cards down a drawn curve, with the old Background section folded in:
   the degree and the languages now sit in the year they happened rather
   than in a list at the bottom of the page.

   Violet carries this section: the years, the dots and the tool badges.
   Yellow has exactly one job left - a marker behind a key fact, always
   with the page's own ink on top of it, which is the only way a colour
   this light can carry meaning here at all. Two marks per card at most,
   or they stop meaning anything.

   Each tint is picked for the surface it sits on: #7c3aed reaches 6.5:1
   on the beige panel but only 1.2:1 on the opened card's near-black, so
   the year steps up to var(--jr-open-year) there, which reaches 6:1.
   ========================================================================== */

/* ---- the shortened intro ---- */

.jr__lead {
  margin: 2.5rem 0 0;
  max-width: 18ch;
  font-size: clamp(2rem, 5vw, 3.5rem);
  font-weight: 700;
  line-height: 1.05;
  letter-spacing: -0.03em;
  color: var(--text);
}

/* Dark on the yellow marker, not violet: the marker is now the one job
   yellow does in this section, and it does it the same way everywhere —
   a key fact with the page's own ink on top of it. */
.jr__lead em {
  font-style: italic;
  color: var(--on-accent-2);
  background-color: var(--accent-2);
  box-decoration-break: clone;
  -webkit-box-decoration-break: clone;
  padding: 0 0.12em;
}

.jr__intro {
  margin: 1.6rem 0 0;
  max-width: 560px;
  font-size: 1.05rem;
  line-height: 1.75;
  color: var(--body);
}

/* --------------------------------------------------------------------------
   The timeline area
   -------------------------------------------------------------------------- */

.jr {
  position: relative;
  margin-top: 4rem;
  padding-inline: clamp(1rem, 3vw, 2.5rem);
  /* room for the dashed tail to run on past the last card */
  padding-bottom: 22vh;
}

/* Sized and pathed by script.js from where the cards actually landed */
/* Three layers inside .jr: the line at 0, the cards at 1, the dots at 3.

   The line runs BEHIND the cards, so each card hides the stretch passing
   under it and the curve reappears on the other side. That is the whole
   effect, and it is why the line is at full opacity: the cards do the
   hiding, so there is no fading and no mask to maintain. A mask would
   also have had to be rebuilt every time a card opened or the column
   reflowed; a card that is simply painted on top never goes out of date.

   overflow: visible lets a sweep paint outside the SVG's own box, but
   nothing relies on it: the handles are CLAMPED to the box in script.js,
   so the widest sweep cannot reach the page edge. Clipping would have
   hidden the problem; clamping means there is no problem to hide. */
.jr__svg {
  position: absolute;
  inset: 0;
  z-index: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
}

.jr__line,
.jr__tail {
  stroke: var(--ink);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* Only the TAIL is dotted. The line through the cards stays solid, and
   keeps stroke-dasharray for its own scroll reveal, which is why the dot
   pattern can only live down here: the tail fades in on opacity instead,
   so its dasharray is free.

   Dots, not dashes. With a 2px stroke and round caps, a dash LENGTH of
   zero paints exactly one round cap per dash position, which is a 2px
   circle. A 2px dash would paint a 4px pill once the two caps are added,
   and read as a short dash. 0.01 rather than a bare 0 because some
   engines drop a zero-length dash entirely. 10px between them, so the gap
   is what the eye measures rather than the dot. */
.jr__tail {
  stroke-dasharray: 0.01 10;
}

/* The dots stay above everything, at the card edge where the line meets
   the card. They need a layer of their own: a dot left inside .jr__list
   could never paint above a sibling of the list, however high its own
   z-index, because the list has a z-index and therefore a stacking
   context of its own. Same coordinate space as .jr, which is what the
   anchors are measured in, so script.js writes left and top straight from
   the anchor with no offset arithmetic. */
.jr__dots {
  position: absolute;
  inset: 0;
  z-index: 3;
  pointer-events: none;
}

/* The dash pattern is set further up, with the dotted line it belongs
   beside. What is left here is the tail's own reveal, which is opacity
   rather than a dash offset: it fades in over the last tenth of the
   scroll, so it never needed the dasharray the main line used and is
   unaffected by that moving to a mask. */
.jr__tail {
  opacity: 0;
}

.jr__list {
  position: relative;
  z-index: 1;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* Centres ~60vh apart: the card is centred inside a 60vh row, so the
   spacing is the row and does not depend on how tall a card turns out. */
.jr__item {
  position: relative;
  display: flex;
  align-items: center;
  min-height: 60vh;
}

.jr__item--r { justify-content: flex-end; }
.jr__item--l { justify-content: flex-start; }

/* These used to be five different indents, so the column read as a wander
   rather than a zigzag. They are one value now, mirrored left and right.

   This is where "precise, not hand-drawn" actually gets decided. The
   curve's horizontal reach between two dots IS the distance between those
   dots, so five different indents meant five different sweeps however
   uniformly the bezier itself was built. One indent, mirrored, and every
   segment is the same shape as every other by construction.

   The 4% pulls each card off its edge so the dot on the outer corner sits
   inside the column rather than half over the boundary. */
.jr__item--o1,
.jr__item--o2,
.jr__item--o3,
.jr__item--o4,
.jr__item--o5 { padding: 0; }

.jr__item--r { padding-right: 4%; }
.jr__item--l { padding-left: 4%; }

/* --------------------------------------------------------------------------
   The dot on the line

   Placed by script.js at the exact point the path was built from, so it is
   on the line by construction rather than by eye.
   -------------------------------------------------------------------------- */

.jr__dot {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 3;
  width: 18px;
  height: 18px;
  margin: -9px 0 0 -9px;
  border: 2px solid var(--ink);
  border-radius: 50%;
  background-color: var(--accent);
  transform: scale(0);
  transition: transform 320ms var(--ease-out);
}

.jr__dot.is-on { transform: scale(1); }

/* --------------------------------------------------------------------------
   The card
   -------------------------------------------------------------------------- */

.jr__card {
  position: relative;
  width: min(540px, 100%);
  padding: 36px;
  border-radius: 20px;
  background-color: var(--panel);
  box-shadow: 0 18px 50px var(--shadow-2),
              0 2px 6px var(--sink);
  opacity: 0;
  transform: translateY(40px);
  transition: opacity 600ms var(--ease-out),
              transform 600ms var(--ease-out),
              background-color 420ms var(--ease-out),
              box-shadow 420ms var(--ease-out);
}

.jr__item.is-in .jr__card {
  opacity: 1;
  transform: none;
}

/* The thin rule beside the card, on whichever side the line runs past */
/* STEP 12: gone on desktop. This hairline existed to join the card to a
   line that used to run straight down beside it, so the two read as
   connected. The ribbon arrives at an angle now and crosses the card
   outright, which leaves the rule as a stray vertical stub beside a
   curve that is nowhere near it, and the brief's shape has no straight
   vertical runs in it at all. It stays below 900px, where the line is
   still a rail down the left. */
.jr__rule {
  display: none;
  position: absolute;
  top: 26px;
  bottom: 26px;
  width: 1px;
  background-color: var(--rule);
}

.jr__item--r .jr__rule { left: -22px; }
.jr__item--l .jr__rule { right: -22px; }

.jr__year {
  margin: 0;
  font-size: clamp(3.2rem, 6vw, 5.6rem);
  font-weight: 800;
  line-height: 0.92;
  letter-spacing: -0.045em;
  /* Violet, so it no longer needs the outline that made yellow readable
     on this panel: #7c3aed on #e3dfd3 is 6.5:1 on its own. */
  color: var(--accent);
  transition: color 420ms var(--ease-out), font-size 420ms var(--ease-out);
}

/* The yellow marker — the only yellow left in this section. Dark text on
   it at every size, on the light card and on the opened dark one alike. */
.jr__hl {
  padding: 0 0.16em;
  border-radius: 3px;
  background-color: var(--accent-2);
  /* Not --text. The yellow does not flip, so the ink on it must not
     either: following --text put near-white on near-yellow, 1.07:1. */
  color: var(--on-accent-2);
  box-decoration-break: clone;
  -webkit-box-decoration-break: clone;
}

.jr__title {
  margin: 0.35rem 0 0;
  font-size: clamp(1.3rem, 2vw, 1.75rem);
  font-weight: 700;
  line-height: 1.15;
  letter-spacing: -0.02em;
  color: var(--text);
  transition: color 420ms var(--ease-out), font-size 420ms var(--ease-out);
}

/* The summary collapses as the long text opens, so the expanded card runs
   year, title, badges, full text — with no leftover two-line précis of the
   paragraph directly underneath it. Same 0fr/1fr row as the panel, and the
   spacing is padding rather than margin so it is inside the height being
   animated and collapses with it. */
.jr__sum {
  display: grid;
  grid-template-rows: 1fr;
  transition: grid-template-rows 460ms var(--ease-out);
}

.jr__card.is-open .jr__sum { grid-template-rows: 0fr; }

/* The grid item carries no spacing of its own. Padding on the item itself
   is part of its border box, so min-height: 0 cannot take it below that and
   the row stalls at the height of the padding instead of closing — which is
   why the spacing lives on the paragraph inside, where it gets clipped. */
.jr__sumin { overflow: hidden; min-height: 0; }

.jr__short {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  margin: 0;
  padding-top: 0.7rem;
  font-size: clamp(0.95rem, 1.2vw, 1.125rem);
  line-height: 1.5;
  color: var(--body);
  transition: color 420ms var(--ease-out);
}

/* ---- footer row ---- */

.jr__foot {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin-top: 1.75rem;
}

.jr__badges { display: inline-flex; flex-shrink: 0; }

.jr__badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 38px;
  height: 38px;
  border-radius: 50%;
  font-size: 0.72rem;
  font-weight: 800;
  letter-spacing: 0.02em;
  /* the ring is the panel colour, so the two discs read as overlapping
     rather than as one blob */
  box-shadow: 0 0 0 3px var(--panel);
  transition: box-shadow 420ms var(--ease-out),
              background-color 420ms var(--ease-out),
              color 420ms var(--ease-out);
}

/* The company disc sits ON TOP of the tool disc. The other way round, the
   tool disc covers the right half of the initials and T3 reads as T. */
.jr__badge--co {
  position: relative;
  z-index: 1;
  background-color: var(--avatar-bg);
  color: var(--avatar-ink);
}

/* Panel colour with a violet icon. A plain panel disc on a panel card
   would be invisible, so a faint violet edge gives it a shape. */
.jr__badge--tool {
  position: relative;
  z-index: 0;
  margin-left: -10px;
  box-sizing: border-box;
  border: 1.5px solid var(--accent-ring);
  background-color: var(--panel);
  color: var(--accent);
}
.jr__badge--3    { font-size: 0.5rem; letter-spacing: -0.02em; }
.jr__badge--live { position: relative; }

/* Still there, still running */
.jr__live {
  position: absolute;
  top: -1px;
  right: -1px;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background-color: var(--live);
  box-shadow: 0 0 0 2px var(--panel), 0 0 0 0 var(--live-ring);
  animation: jr-pulse 2.6s ease-in-out infinite;
}

@keyframes jr-pulse {
  0%, 100% { box-shadow: 0 0 0 2px var(--panel), 0 0 0 0 var(--live-ring); }
  50%      { box-shadow: 0 0 0 2px var(--panel), 0 0 0 7px var(--live-ring-0); }
}

.jr__label {
  flex: 1;
  min-width: 0;
  margin: 0;
  font-size: 0.78rem;
  font-weight: 600;
  line-height: 1.3;
  color: var(--muted);
  transition: color 420ms var(--ease-out);
}

.jr__more {
  flex-shrink: 0;
  padding: 0.6em 1.1em;
  border: 1px solid var(--rule-2);
  border-radius: 999px;
  background: none;
  font: inherit;
  font-size: 0.7rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text);
  cursor: pointer;
  transition: background-color 160ms var(--ease-out),
              border-color 160ms var(--ease-out),
              color 160ms var(--ease-out),
              opacity 200ms var(--ease-out);
}

@media (hover: hover) and (pointer: fine) {
  .jr__more:hover {
    border-color: var(--accent-fill);
    background-color: var(--accent-fill);
    color: var(--on-accent);
  }
}

/* --------------------------------------------------------------------------
   Read more — the card becomes a dark card in place

   The height is animated with grid-template-rows 0fr -> 1fr rather than a
   measured max-height: the row resolves to the content's real height, so
   there is no magic number to get wrong when the copy changes.
   -------------------------------------------------------------------------- */

.jr__panel {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 460ms var(--ease-out);
}

.jr__panelin { overflow: hidden; min-height: 0; }

.jr__card.is-open .jr__panel { grid-template-rows: 1fr; }

.jr__close {
  position: absolute;
  top: 18px;
  right: 18px;
  z-index: 2;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background-color: var(--jr-open-btn);
  color: var(--jr-open-ink);
  cursor: pointer;
  opacity: 0;
  pointer-events: none;
  transition: opacity 260ms var(--ease-out), background-color 160ms var(--ease-out);
}

.jr__card.is-open .jr__close { opacity: 1; pointer-events: auto; }

@media (hover: hover) and (pointer: fine) {
  .jr__close:hover { background-color: var(--vid-line-3); }
}

.jr__text {
  margin: 1.4rem 0 0;
  font-size: 1rem;
  line-height: 1.7;
  color: var(--jr-open-body);
}

/* ---- the open, dark state ---- */

.jr__card.is-open {
  background-color: var(--jr-open-bg);
  /* inset hairline, not a border: a real border would add 2px and shift
     everything inside the card the moment it opened. Transparent in light
     mode, where the card is far darker than the page and needs no edge. */
  box-shadow: 0 26px 70px var(--shadow-4),
              inset 0 0 0 1px var(--jr-open-border);
}

/* "A bigger title" — the card's own title grows into it rather than a
   second copy appearing underneath, which read as the same words twice. */
.jr__card.is-open .jr__title {
  color: var(--jr-open-ink);
  font-size: clamp(1.6rem, 2.6vw, 2.2rem);
}
.jr__card.is-open .jr__short { color: var(--jr-open-body); }
.jr__card.is-open .jr__label { color: var(--jr-open-mute); }
.jr__card.is-open .jr__more  { opacity: 0; pointer-events: none; }
.jr__card.is-open .jr__rule  { background-color: var(--jr-open-line); }

/* Violet at full strength disappears into near-black — 1.2:1 — so the
   opened card steps the year up to a lighter violet, which reaches 6:1
   on var(--jr-open-bg). Same size and weight; only the tint changes. */
.jr__card.is-open .jr__year { color: var(--jr-open-year); }

.jr__card.is-open .jr__badge { box-shadow: 0 0 0 3px var(--jr-open-bg); }
.jr__card.is-open .jr__badge--co { background-color: var(--avatar-ink); color: var(--jr-open-bg); }

.jr__card.is-open .jr__live {
  box-shadow: 0 0 0 2px var(--jr-open-bg), 0 0 0 0 var(--live-ring);
  animation-name: jr-pulse-dark;
}

@keyframes jr-pulse-dark {
  0%, 100% { box-shadow: 0 0 0 2px var(--jr-open-bg), 0 0 0 0 var(--live-ring); }
  50%      { box-shadow: 0 0 0 2px var(--jr-open-bg), 0 0 0 7px var(--live-ring-0); }
}

/* --------------------------------------------------------------------------
   Mobile — one column, and the curve straightens into a rail on the left
   -------------------------------------------------------------------------- */

@media (max-width: 899px) {
  .jr {
    margin-top: 2.5rem;
    /* Room on the left for the rail and its dots; the right gutter matches
       .wrap so the cards line up with the intro text above them. */
    padding-left: 44px;
    padding-right: var(--space-md);
    /* This used to be 12vh, which on a 390x844 phone is 101px of nothing.
       The desktop version needs that room because the dashed tail runs on
       past the last card; on mobile the tail is only 90px and it finishes
       ABOVE the last card's bottom edge, so the padding was buying space
       for something that was never there. What is left is breathing room,
       not structure. */
    padding-bottom: 1.5rem;
  }

  .jr__item {
    min-height: 0;
    justify-content: flex-start;
    padding-left: 0;
    padding-right: 0;
  }

  .jr__item + .jr__item { margin-top: 2rem; }

  .jr__card { width: 100%; padding: 22px; border-radius: 16px; }
  .jr__rule { display: none; }
  .jr__year { font-size: clamp(2.6rem, 13vw, 3.6rem); }
  .jr__foot { flex-wrap: wrap; gap: 0.6rem; margin-top: 1.25rem; }
  .jr__label { flex: 1 1 100%; order: 3; }
  .jr__more { margin-left: auto; }
  .jr__lead { max-width: none; }
}

/* --------------------------------------------------------------------------
   Mobile spacing between Work, the quote, the text band and What I Bring

   Every one of these gaps was written in vh, which is generous on a laptop
   and enormous on a phone: 12vh and 16vh on a 844px screen come out at
   101px and 135px, and they stack at every boundary. Measured on a 390px
   screen the run from the last Work card to the quote was 337px of empty
   page. These four rules put one 80px gap at each boundary instead.

   The band's own 24px padding sits INSIDE its two hairlines, so it is part
   of the band rather than part of the gap - the 80px is measured hairline
   to text.

   Measured at 390x844 with the reveals at rest:
     last Work card -> quote        24 + 80
     quote button   -> band hairline     80
     band hairline  -> "02 / What I Bring"  80
   -------------------------------------------------------------------------- */

@media (max-width: 899px) {
  /* The section now ends where the timeline ends. The gap below it belongs
     to the quote, which is the thing that needs the air. */
  #work { padding-bottom: 0; }

  .pitch-band { padding-block: 5rem; }

  #capabilities { padding-top: 5rem; }
}

/* --------------------------------------------------------------------------
   No motion: everything is simply there, and script.js draws the whole line
   in one go instead of tying it to the scroll.
   -------------------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  .jr__card { opacity: 1; transform: none; }
  .jr__dot { transform: scale(1); }
  .jr__tail { opacity: 1; }
  .jr__live { animation: none; }
}

.no-js .jr__card { opacity: 1; transform: none; }
.no-js .jr__dot { transform: scale(1); }


/* ==========================================================================
   STEP 4 - Journey and Work merged

   The timeline IS the Work section now. What is new here is the evidence
   around it: a row of four strengths under the intro, tags on each card
   saying which strength that role built, and a short "what I brought"
   list inside each expanded card.

   Colour follows the rule the timeline already set: violet does the
   pointing, yellow is only ever a marker with dark text on it.
   ========================================================================== */

/* ---- the strengths row ---- */

.st {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: clamp(1.25rem, 2.4vw, 2.25rem);
  margin: 3rem 0 0;
  padding: 2rem 0 0;
  border-top: 1px solid var(--border);
  list-style: none;
}

.st__item {
  min-width: 0;
  opacity: 0;
  transform: translateY(24px);
  transition: opacity 600ms var(--ease-out), transform 600ms var(--ease-out);
}

/* One after another, not all at once */
.st.is-in .st__item { opacity: 1; transform: none; }
.st.is-in .st__item:nth-child(2) { transition-delay: 90ms; }
.st.is-in .st__item:nth-child(3) { transition-delay: 180ms; }
.st.is-in .st__item:nth-child(4) { transition-delay: 270ms; }

.st__ico {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  border-radius: 12px;
  border: 1.5px solid var(--accent-ring);
  background-color: var(--panel);
  color: var(--accent);
}

.st__name {
  margin: 0.85rem 0 0;
  font-size: clamp(1.15rem, 1.5vw, 1.375rem);
  font-weight: 700;
  line-height: 1.2;
  letter-spacing: -0.02em;
  color: var(--text);
}

.st__proof {
  margin: 0.5rem 0 0;
  font-size: clamp(0.875rem, 1.05vw, 0.9375rem);
  line-height: 1.55;
  color: var(--body);
}

@media (max-width: 1200px) {
  .st { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .st.is-in .st__item:nth-child(3) { transition-delay: 90ms; }
  .st.is-in .st__item:nth-child(4) { transition-delay: 180ms; }
}

@media (max-width: 620px) {
  .st { grid-template-columns: minmax(0, 1fr); gap: 1.5rem; margin-top: 2.25rem; }
  .st.is-in .st__item:nth-child(n) { transition-delay: 0ms; }
}

/* ---- strength tags on each card ----
   They live inside the collapsing summary, so the expanded card keeps the
   order it was asked for: year, title, badges, text, what I brought. */

.jr__tags {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin: 0.85rem 0 0;
  padding: 0;
  list-style: none;
}

.jr__tag {
  padding: 0.32em 0.7em;
  border-radius: 999px;
  background-color: var(--sink);
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.01em;
  white-space: nowrap;
  color: var(--text);
}

/* ---- "What I brought", inside the opened card ---- */

.jr__broughtlab {
  margin: 1.5rem 0 0;
  font-size: 0.68rem;
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--jr-open-year);
}

.jr__brought {
  margin: 0.7rem 0 0.2rem;
  padding: 0;
  list-style: none;
}

.jr__brought li {
  position: relative;
  padding-left: 1.1rem;
  font-size: 0.95rem;
  line-height: 1.6;
  color: var(--jr-open-body);
}

.jr__brought li + li { margin-top: 0.3rem; }

.jr__brought li::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0.62em;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background-color: var(--jr-open-year);
}

@media (prefers-reduced-motion: reduce) {
  .st__item { opacity: 1; transform: none; }
}

.no-js .st__item { opacity: 1; transform: none; }

/* ==========================================================================
   STEP 5 - What I Bring

   One sentence carries the whole section. Each capability sits on a chip
   inside it, and each chip is a real button: a card you can only reach
   with a mouse is a card half the visitors never read.

   The reveal is scroll position, not a timer. script.js writes each word's
   opacity and blur from how far through the pin zone you are, so scrolling
   back up puts the words out of focus again exactly as they came in - no
   transitions on the words, or the scroll and the transition would be
   fighting over the same two numbers.
   ========================================================================== */

/* ---- heading ---- */

.wib__head {
  margin: clamp(2rem, 5vh, 3.5rem) 0 0;
  font-size: clamp(3.25rem, 13vw, 11.25rem);
  font-weight: 800;
  line-height: 0.88;
  letter-spacing: -0.045em;
  text-align: center;
  /* #111 at the top of the words to --body at the bottom */
  background-image: linear-gradient(180deg, var(--text) 0%, var(--body) 100%);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  /* background-clip: text paints only inside the background box, and a
     line-height under 1 lets the glyphs hang outside it - without this the
     descender of the g in "Bring?" simply is not painted. */
  padding-block: 0.1em 0.24em;
}

.wib__headln { display: block; }

/* Older engines paint nothing through a transparent colour, so they get
   the flat ink instead of an invisible heading. */
@supports not ((-webkit-background-clip: text) or (background-clip: text)) {
  .wib__head { color: var(--text); background-image: none; }
}

.wib__kicker {
  width: max-content;
  max-width: 100%;
  margin: clamp(1.25rem, 3vh, 2rem) auto 0;
  padding: 0.55em 1.2em;
  border: 1px solid var(--border);
  border-radius: 999px;
  background-color: var(--panel);
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  text-align: center;
  color: var(--muted);
}

/* --------------------------------------------------------------------------
   The pin zone. The extra height is the reveal: how far you have scrolled
   through it is how many words have come into focus.
   -------------------------------------------------------------------------- */

.wib {
  position: relative;
  margin-top: clamp(2rem, 6vh, 4rem);
}

.wib__sticky { padding-inline: clamp(1rem, 4vw, 3rem); }

/* min(1100px, 100%) rather than a flat 1100px: the container here is the
   content area to the RIGHT of the fixed sidebar, which is a few hundred
   pixels narrower than the viewport, and a flat cap would let the line ask
   for more room than it has. overflow stays visible so a word's blur halo
   is never cut off at the edge while it is still out of focus. */
.wib__line {
  max-width: min(1100px, 100%);
  margin: 0 auto;
  padding-inline: 24px;
  overflow: visible;
  font-size: clamp(36px, 4.2vw, 64px);
  font-weight: 800;
  line-height: 1.15;
  letter-spacing: -0.03em;
  text-align: center;
  text-wrap: pretty;
  overflow-wrap: break-word;
  color: var(--text);
}

/* A chip is an atom, so it must never be the thing that makes a line wider
   than the box it sits in */
.wib__slot { max-width: 100%; }

/* Each word is its own unit so the blur can be per-word - filter does not
   apply to a plain inline box, hence inline-block. */
/* The out-of-focus starting state belongs only where the reveal actually
   runs. Below 900px there is no pin to drive it, so the words would simply
   sit at 15% and 4px of blur for ever. It also matters that neither filter
   nor will-change reaches the phone: both make the word a containing block
   for fixed-position descendants, and the card below is fixed there. */
.wib__w {
  position: relative;
  z-index: 0;
  display: inline-block;
}

@media (min-width: 900px) {
  .wib__w {
    opacity: 0.15;
    filter: blur(4px);
    will-change: opacity, filter;
  }
}

/* An opacity below 1 makes each word its own stacking context, so a card's
   z-index cannot reach past the word it lives in and every later word
   paints over it. The word holding an open card is lifted above its
   neighbours for as long as it is open. */
.wib__w.is-lift { z-index: 50; }

@media (min-width: 900px) {
  .wib { height: 200vh; }

  .wib__sticky {
    position: sticky;
    top: 0;
    display: grid;
    place-items: center;
    height: 100vh;
  }
}

/* --------------------------------------------------------------------------
   The chips
   -------------------------------------------------------------------------- */

.wib__slot {
  position: relative;
  display: inline-flex;
  vertical-align: baseline;
  margin-left: 0.18em;
}

.wib__chip {
  display: inline-flex;
  align-items: center;
  gap: 0.14em;
  height: 0.9em;
  padding: 0 0.3em;
  border: 1px solid var(--border);
  border-radius: 0.24em;
  background-color: var(--panel);
  color: var(--accent);
  font: inherit;
  line-height: 1;
  cursor: pointer;
  /* lines it up with the text beside it rather than with its own baseline */
  vertical-align: middle;
  transition: background-color 160ms var(--ease-out),
              border-color 160ms var(--ease-out);
}

/* Sized in em so the chip stays in proportion at every clamp() step */
.wib__chipico { width: 0.44em; height: 0.44em; flex-shrink: 0; }
.wib__caret   { width: 0.3em;  height: 0.3em;  flex-shrink: 0;
                transition: transform 240ms var(--ease-out); }

.wib__slot.is-open .wib__caret { transform: rotate(180deg); }

.wib__slot.is-open .wib__chip { border-color: var(--accent); }

/* The page's global :focus-visible sets border-radius: var(--radius),
   which would square off the chip the moment it takes focus. It keeps its
   own corners, and the ring follows them. */
.wib__chip:focus-visible {
  border-color: var(--accent);
  border-radius: 0.24em;
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

@media (hover: hover) and (pointer: fine) {
  .wib__chip:hover { border-color: var(--accent); }
}

/* The chip's accessible name. Visible to a screen reader, not on screen. */
.wib__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  white-space: nowrap;
  border: 0;
  clip-path: inset(50%);
}

/* --------------------------------------------------------------------------
   The card, anchored to its own chip

   --wib-shift is written by script.js: the card is centred on the chip and
   then pushed back inside the viewport if that would hang it off an edge.
   It is a margin rather than a transform so it does not fight the one the
   entrance uses.
   -------------------------------------------------------------------------- */

.wib__card {
  position: absolute;
  top: calc(100% + 0.5rem);
  left: 50%;
  z-index: 30;
  display: block;
  width: 340px;
  margin-left: calc(-170px + var(--wib-shift, 0px));
  padding: 24px;
  border: 1px solid var(--border);
  border-radius: 20px;
  background-color: var(--panel);
  box-shadow: 0 20px 55px var(--shadow-3), 0 2px 6px var(--shadow-1);
  text-align: left;
  opacity: 0;
  visibility: hidden;
  transform: translateY(-6px) scale(0.97);
  transform-origin: 50% 0;
  transition: opacity 200ms var(--ease-out),
              transform 200ms var(--ease-out),
              visibility 200ms;
}

/* Flipped above the chip when there is no room below */
.wib__slot.is-up .wib__card {
  top: auto;
  bottom: calc(100% + 0.5rem);
  transform: translateY(6px) scale(0.97);
  transform-origin: 50% 100%;
}

.wib__slot.is-open .wib__card {
  opacity: 1;
  visibility: visible;
  transform: none;
}

.wib__cardico {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 46px;
  height: 46px;
  border-radius: 14px;
  border: 1.5px solid var(--accent-ring);
  background-color: var(--bg);
  color: var(--accent);
}

.wib__cardtitle {
  display: block;
  margin-top: 0.85rem;
  font-size: 1.125rem;
  font-weight: 700;
  line-height: 1.25;
  letter-spacing: -0.015em;
  color: var(--text);
}

.wib__cardtext {
  display: block;
  margin-top: 0.5rem;
  font-size: 0.9375rem;
  font-weight: 400;
  line-height: 1.55;
  letter-spacing: 0;
  color: var(--body);
}

/* --------------------------------------------------------------------------
   Mobile - no pin, smaller type, and the card goes full width under the
   sentence rather than trying to point at a chip on a 375px screen.
   -------------------------------------------------------------------------- */

@media (max-width: 899px) {
  .wib { height: auto; margin-top: 2rem; }
  .wib__sticky { position: static; height: auto; padding-block: 1rem 0; }

  /* The heading keeps the size it had. Only the sentence grows. */
  .wib__head { font-size: clamp(2.75rem, 16vw, 5rem); }

  /* The sentence was set at 6.4vw against 24px of side padding, which put
     it at 25px in a 310px column on a 390px screen and left it filling
     81% of the line: a paragraph in the middle of the page rather than
     the thing the section is built around. 7.2vw in a 16px gutter is
     28px in a 326px column, and the floor of 26px is what keeps a 360px
     screen from dropping below readable.

     1.2 rather than 1.15, because at this size the tighter leading made
     the descenders of one line sit into the caps of the next. */
  .wib__line {
    font-size: clamp(26px, 7.2vw, 40px);
    line-height: 1.2;
    padding-inline: 16px;
    /* text-wrap stays `pretty` from the base rule. Switching it to greedy
       wrapping here was tried and measured no different at 360, 390 or
       430: the rag is set by the words, not by the wrap strategy. The
       sentence carries "development", "combined" and "languages", and at
       28px in a 326px column any two of those overflow, so a line ends
       early whatever the browser does with the rest. 78% average fill is
       the ceiling at this size, and a larger font lowers it rather than
       raising it. */
  }

  /* The chips are icon-only buttons (their names are in a visually hidden
     span for screen readers), so they are about 39px wide whatever the
     text does and cannot be what pushes a line over. They still have to
     shrink slightly against the bigger type or they start to look like
     buttons dropped into a sentence rather than part of it. */
  .wib__chip { height: 0.85em; }

  /* 133px sat between the last line and the end of the section: 32px of
     the sticky wrapper's own bottom padding and about 100px of the shared
     section padding, on a section whose content had already finished.
     The wrapper's is now zero and this is the whole gap. */
  #capabilities { padding-bottom: 60px; }

  .wib__card {
    position: fixed;
    top: auto;
    bottom: 12px;
    left: 12px;
    right: 12px;
    width: auto;
    margin-left: 0;
    transform: translateY(10px);
  }

  .wib__slot.is-up .wib__card {
    top: auto;
    bottom: 12px;
    transform: translateY(10px);
  }

  .wib__slot.is-open .wib__card { transform: none; }
}

/* --------------------------------------------------------------------------
   No motion: every word is simply in focus, and nothing pins.
   -------------------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  .wib { height: auto; }
  .wib__sticky { position: static; height: auto; padding-block: 1rem 3rem; }
  .wib__w { opacity: 1; filter: none; will-change: auto; }
  .wib__card { transition: opacity 120ms linear, visibility 120ms; transform: none; }
  .wib__slot.is-up .wib__card { transform: none; }
}

.no-js .wib__w { opacity: 1; filter: none; }
.no-js .wib { height: auto; }
.no-js .wib__sticky { position: static; height: auto; }

/* ==========================================================================
   STEP 6 - How I Can Help

   Three cards in one panel, with a single highlight that slides between
   them. Three separate backgrounds fading in and out would read as a
   flicker as the cursor crosses; one element moving reads as one element
   moving, and it is a transform, so it costs nothing per frame.

   The highlight is decorative, so it sits behind the cards and is hidden
   from assistive technology entirely. Below 900px it is dropped and every
   card simply wears the highlight colour - there is no cursor to follow.
   ========================================================================== */

.hlp__head {
  margin: clamp(1.5rem, 4vh, 2.5rem) 0 0;
  font-size: clamp(2.5rem, 7vw, 5.625rem);
  font-weight: 800;
  line-height: 0.98;
  letter-spacing: -0.04em;
  color: var(--text);
}

.hlp__headln { display: block; }
.hlp__headln--soft { color: var(--body); }

.hlp__intro {
  margin: 1.5rem 0 0;
  max-width: 560px;
  font-size: 1.125rem;
  line-height: 1.6;
  color: var(--body);
}

/* ---- the panel ---- */

.hlp {
  position: relative;
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 0;
  margin-top: clamp(2.5rem, 6vh, 4rem);
  padding: 16px;
  border-radius: 24px;
  background-color: var(--panel);
}

/* Moved and sized by script.js from where the cards actually are, so it
   stays right whatever the copy does to their widths. */
.hlp__glide {
  position: absolute;
  top: 16px;
  bottom: 16px;
  left: 0;
  z-index: 0;
  width: 0;
  border-radius: 18px;
  background-color: var(--surface-strong);
  box-shadow: 0 12px 34px var(--shadow-2), 0 1px 3px var(--shadow-1);
  transform: translateX(16px);
  transition: transform 380ms var(--ease-out), width 380ms var(--ease-out);
  pointer-events: none;
}

.hlp__card {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  padding: 36px;
  border-radius: 18px;
  background-color: transparent;
  opacity: 0;
  transform: translateY(26px);
  transition: opacity 600ms var(--ease-out), transform 600ms var(--ease-out);
}

.hlp.is-in .hlp__card { opacity: 1; transform: none; }
.hlp.is-in .hlp__card:nth-child(3) { transition-delay: 100ms; }
.hlp.is-in .hlp__card:nth-child(4) { transition-delay: 200ms; }

/* ---- card contents ---- */

.hlp__top {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin: 0;
}

.hlp__badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: 44px;
  height: 44px;
  border-radius: 10px;
  background-color: var(--accent-fill);
  color: var(--on-accent);
}

/* Two of the three titles wrap to a second line and one does not. Every
   title reserves both lines and centres its text inside them, so the three
   badges sit at one height and the result lines start together. The
   min-height has to live HERE, not on the row: an em on the row resolves
   against its inherited 16px, not against the title's own size. */
.hlp__title {
  display: flex;
  align-items: center;
  min-height: 2.3em;
  font-size: clamp(1.15rem, 1.7vw, 1.625rem);
  font-weight: 700;
  line-height: 1.15;
  letter-spacing: -0.02em;
  color: var(--text);
}

.hlp__result {
  margin: 1.5rem 0 0;
  font-size: clamp(1.3rem, 2.1vw, 1.875rem);
  font-weight: 800;
  line-height: 1.25;
  letter-spacing: -0.025em;
  color: var(--text);
}

.hlp__text {
  margin: 0.85rem 0 0;
  font-size: 1.0625rem;
  line-height: 1.6;
  color: var(--body);
}


/* The grid stretches all three cards to the tallest one, and the copy above
   this point wraps to a different number of lines in each. `margin-top: auto`
   pushes this block and its list to the bottom of whatever height the card
   ends up with, so the three checklists finish flush on the card's padding
   and none of them has dead space beneath it. The per-card "Let's talk" link
   used to do this job; taking it out is what made the bottoms ragged, so the
   pin moves up one element rather than disappearing. padding-top is the
   minimum breathing room for the card that has the most copy and therefore
   gets no `auto` space at all. */
.hlp__getlab {
  margin: 0;
  margin-top: auto;
  padding-top: 1.75rem;
  font-size: 0.68rem;
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--muted);
}

.hlp__get {
  margin: 0.85rem 0 0;
  padding: 0;
  list-style: none;
}

.hlp__get li {
  display: flex;
  align-items: flex-start;
  gap: 0.6rem;
  font-size: 0.9375rem;
  line-height: 1.5;
  color: var(--body);
}

.hlp__get li + li { margin-top: 0.5rem; }

.hlp__get svg {
  flex-shrink: 0;
  margin-top: 0.2em;
  color: var(--accent);
}

/* ---- the one call to action, centred under the panel ---- */

.hlp__cta {
  margin: clamp(2rem, 4vh, 2.75rem) 0 0;
  text-align: center;
}

.hlp__btn {
  gap: 0.5rem;
  transition: background-color var(--fast) ease,
              border-color var(--fast) ease,
              gap 200ms var(--ease-out);
}

@media (hover: hover) and (pointer: fine) {
  .hlp__btn:hover { gap: 0.8rem; }
}

/* --------------------------------------------------------------------------
   Mobile - one column, no cursor to follow, so every card wears the colour
   -------------------------------------------------------------------------- */

@media (max-width: 899px) {
  .hlp {
    grid-template-columns: minmax(0, 1fr);
    gap: 12px;
    padding: 12px;
    border-radius: 20px;
  }

  .hlp__glide { display: none; }

  .hlp__card {
    padding: 24px;
    background-color: var(--surface-strong);
    box-shadow: 0 10px 28px var(--shadow-2);
  }

  .hlp__head { font-size: clamp(2.25rem, 11vw, 3.5rem); }
  .hlp__intro { font-size: 1rem; }
}

/* --------------------------------------------------------------------------
   No motion: the cards are simply there, and the highlight jumps rather
   than travels - it still says which card is which, without the journey.
   -------------------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  .hlp__card { opacity: 1; transform: none; transition: none; }
  .hlp__glide { transition: none; }
  .hlp__btn { transition: background-color var(--fast) ease, border-color var(--fast) ease; }
}

.no-js .hlp__card { opacity: 1; transform: none; }
.no-js .hlp__glide { display: none; }
.no-js .hlp__card { background-color: var(--surface-strong); }

/* ==========================================================================
   STEP 7 - Content as the page's one dark band

   It runs the full width and under the sidebar, so it needs sharp edges
   and no gradient: a fade into the beige at a boundary the fixed sidebar
   crosses would put the sidebar's own switch out of step with what is
   behind it.
   ========================================================================== */

.vid {

  position: relative;
  background-color: var(--vid-bg);
  color: var(--vid-ink);
}

/* The section's left gutter normally clears the sidebar. Here the band is
   meant to pass behind it, so only the inner .wrap keeps the gutter. */
/* The band keeps the ordinary section gutter and the ordinary .wrap, so its
   pill, heading and paragraph land on exactly the same left edge as every
   section above it. Padding does not shrink a background box, so the dark
   fill still runs the full width and passes behind the sidebar. */

/* The reading-highlight dim would grey out a dark band into mud */
.js .vid,
.js .vid:not(.is-active) { opacity: 1; }

.vid__marker .section__num { background-color: var(--accent-2); color: var(--on-accent-2); }
/* The marker's own text node is the "/" between the two spans. It was
   inheriting --muted, which is a dark grey on the light page and a light
   grey on the dark one; either way it is the wrong one for a band that
   reverses out of its surroundings. */
.vid__marker { color: var(--vid-mute); }
.vid__marker > span:last-child { color: var(--vid-mute); }

/* The band keeps the sidebar's gutter on its inner .wrap, so the usable
   width here is about 370px narrower than the viewport. The heading has to
   be sized against THAT, not against vw, or "Behind the Work," breaks
   across three lines and the paragraph beside it lands on top of the word
   it was meant to sit next to. */
.vid__head {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 18rem);
  align-items: end;
  gap: clamp(1.5rem, 3vw, 3rem);
  margin-top: clamp(1.5rem, 4vh, 2.5rem);
}

.vid__title {
  margin: 0;
  font-size: clamp(2.25rem, 5vw, 4.5rem);
  font-weight: 800;
  line-height: 0.98;
  letter-spacing: -0.04em;
  color: var(--vid-ink);
}

/* Each line is one line. If a viewport ever gets too narrow for that the
   clamp has already shrunk the type, and the mobile rule below takes over. */
.vid__titleln { display: block; white-space: nowrap; }
.vid__titleln--soft { color: var(--vid-soft); }

.vid__intro {
  margin: 0 0 0.4rem;
  font-size: 1.0625rem;
  line-height: 1.6;
  color: var(--vid-mute);
}

/* ---- the row ---- */

/* The row is a sibling of .wrap, not a child, so it can run off the right
   edge. Its left edge is put back on the wrap's content edge by hand:
   (100% - wrap-max) / 2 is the auto margin a centred .wrap takes, and
   --space-lg is that wrap's own padding. max() covers the narrower
   viewports where the wrap fills the row and the auto margin is zero.
   The negative right margin cancels the section's right gutter so the
   cards carry on past the edge of the screen. */
.vid__rail {
  margin-top: clamp(2rem, 5vh, 3.5rem);
  margin-right: calc(-1 * clamp(1rem, 3vw, 2.5rem));
  padding-left: max(var(--space-lg),
                    calc((100% - var(--wrap-max)) / 2 + var(--space-lg)));
  padding-bottom: 0;
}

.vid__row {
  display: flex;
  gap: 20px;
  margin: 0;
  padding: 0;
  list-style: none;
  overflow-x: auto;
  scrollbar-width: none;
  -ms-overflow-style: none;
}

.vid__row::-webkit-scrollbar { display: none; }

.vid__item { flex-shrink: 0; }

.vid__card {
  position: relative;
  display: block;
  overflow: hidden;
  width: 292px;
  height: 520px;
  padding: 0;
  border: 0;
  border-radius: 20px;
  background-color: var(--vid-card);
  cursor: pointer;
  opacity: 0;
  transform: translateY(28px);
  transition: opacity 600ms var(--ease-out), transform 600ms var(--ease-out);
}

.vid__rail.is-in .vid__card { opacity: 1; transform: none; }
.vid__rail.is-in .vid__item:nth-child(2) .vid__card { transition-delay: 80ms; }
.vid__rail.is-in .vid__item:nth-child(3) .vid__card { transition-delay: 160ms; }
.vid__rail.is-in .vid__item:nth-child(4) .vid__card { transition-delay: 240ms; }
.vid__rail.is-in .vid__item:nth-child(5) .vid__card { transition-delay: 320ms; }
.vid__rail.is-in .vid__item:nth-child(6) .vid__card { transition-delay: 400ms; }

/* The hover lift is its own transform, and it only applies once the card
   has arrived - otherwise it would fight the entrance. */
@media (hover: hover) and (pointer: fine) {
  .vid__rail.is-in .vid__card:hover { transform: translateY(-6px); }
  .vid__rail.is-in .vid__card:hover .vid__play { transform: translate(-50%, -50%) scale(1.12); }
}

.vid__thumb {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.vid__num,
.vid__tag {
  position: absolute;
  top: 14px;
  padding: 0.38em 0.7em;
  border-radius: 999px;
  background-color: var(--scrim);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  font-size: 0.68rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  line-height: 1;
  color: var(--on-accent);
}

.vid__num { left: 14px; }

.vid__tags {
  position: absolute;
  top: 14px;
  right: 14px;
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 6px;
  max-width: 60%;
}

.vid__tag {
  position: static;
  letter-spacing: 0.02em;
  text-transform: none;
}

.vid__play {
  position: absolute;
  top: 50%;
  left: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 64px;
  height: 64px;
  border-radius: 50%;
  background-color: var(--accent-fill);
  color: var(--on-accent);
  transform: translate(-50%, -50%);
  transition: transform 260ms var(--ease-out), background-color 160ms var(--ease-out);
  /* the triangle sits a shade right of centre or it reads as off-centre */
  padding-left: 4px;
}

.vid__card:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 4px; border-radius: 20px; }

/* ---- progress bar ---- */

.vid__progress {
  height: 3px;
  margin: clamp(1.5rem, 3vh, 2rem) clamp(1rem, 3vw, 2.5rem) 0 0;
  border-radius: 999px;
  background-color: var(--vid-track);
  overflow: hidden;
}

.vid__bar {
  display: block;
  width: 0;
  height: 100%;
  border-radius: 999px;
  background-color: var(--accent);
}

@media (max-width: 899px) {
  .vid__head { grid-template-columns: minmax(0, 1fr); align-items: start; gap: 1rem; }
  .vid__title { font-size: clamp(1.9rem, 8.6vw, 3rem); }
  .vid__intro { margin-top: 0.25rem; }
  .vid__card { width: 70vw; height: calc(70vw * 16 / 9); max-height: 78vh; }
  .vid__row { gap: 14px; }
}

@media (prefers-reduced-motion: reduce) {
  .vid__card { opacity: 1; transform: none; transition: none; }
  .vid__play { transition: none; }
}

.no-js .vid__card { opacity: 1; transform: none; }

/* ==========================================================================
   The sidebar while the dark band is behind it

   script.js puts .is-dark on each panel independently, as the band's edge
   crosses that panel's middle. Only the colours live here.
   ========================================================================== */

.sb__panel,
.sb__resume,
.sb__panel::before,
.sb__link,
.sb__blurb,
.sb__lab,
.sb__addr,
.sb__chip,
.sb__icon,
.sb__copy,
.sb__num {
  transition: background-color 250ms var(--ease-out),
              color 250ms var(--ease-out),
              border-color 250ms var(--ease-out);
}

.sb__panel.is-dark::before {
  background-color: var(--vid-panel);
  border-color: var(--vid-line);
}

.sb__panel.is-dark .sb__blurb,
.sb__panel.is-dark .sb__lab,
.sb__panel.is-dark .sb__addr,
.sb__panel.is-dark .sb__chip { color: var(--vid-mute, var(--vid-mute)); }

.sb__panel.is-dark .sb__link { color: var(--vid-ink); }
.sb.is-live .sb__panel.is-dark .sb__link.is-active { background-color: var(--accent-fill); color: var(--on-accent); }

.sb__panel.is-dark .sb__chip { background-color: var(--vid-chip); }

.sb__panel.is-dark .sb__icon { background-color: var(--vid-chip); color: var(--vid-ink); }

/* The number chips are already near-black on yellow, which would vanish
   into a var(--jr-open-bg) panel - a hairline gives them an edge back. */
.sb__panel.is-dark .sb__num { border: 1px solid var(--vid-line-2); }

.sb__panel.is-dark .sb__addr { color: var(--vid-ink); }
.sb__panel.is-dark .sb__copy { border-color: var(--vid-line-3); color: var(--vid-ink); }
.sb__mail.is-dark::before { background-color: var(--vid-chip); }

/* The logo pill keeps its yellow and its dark text in both worlds */

@media (hover: hover) and (pointer: fine) {
  .sb__panel.is-dark .sb__icon:hover { background-color: var(--vid-chip-hi); }
  .sb.is-live .sb__panel.is-dark .sb__link:not(.is-active):hover {
    background-color: var(--vid-line);
  }
  .sb__panel.is-dark .sb__copy:hover { border-color: var(--accent-2); color: var(--accent-2); }
}

@media (prefers-reduced-motion: reduce) {
  .sb__panel, .sb__resume, .sb__panel::before, .sb__link,
  .sb__blurb, .sb__lab, .sb__addr, .sb__chip, .sb__icon, .sb__copy, .sb__num {
    transition: none;
  }
}

/* The three craft pills under the Content paragraph */
.vid__kinds {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin: 1rem 0 0;
  padding: 0;
  list-style: none;
}

.vid__kind {
  padding: 0.42em 0.9em;
  border: 1px solid var(--vid-line-2);
  border-radius: 999px;
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--vid-ink);
}

/* ==========================================================================
   STEP 8 / What People Say

   Three real lines, drag only. The wheel is deliberately left alone here
   (data-rail-nowheel): this row is a thing you pull, and turning a vertical
   wheel sideways over it would trap the page scroll for no reason.
   ========================================================================== */

.say__head {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: clamp(1.5rem, 4vw, 3rem);
  margin-top: clamp(1.5rem, 4vh, 2.5rem);
}

.say__title {
  margin: 0;
  font-size: clamp(2.25rem, 5vw, 5.625rem);
  font-weight: 800;
  line-height: 0.98;
  letter-spacing: -0.04em;
  color: var(--text);
}

.say__titleln { display: block; }
.say__titleln--soft { color: var(--body); }

/* ---- dash indicator ---- */

.say__dashes {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-shrink: 0;
  margin-bottom: 0.35rem;
  padding: 12px 14px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background-color: var(--panel);
}

.say__dash {
  width: 14px;
  height: 5px;
  padding: 0;
  border: 0;
  border-radius: 999px;
  background-color: var(--rule);
  cursor: pointer;
  transition: width 320ms var(--ease-out), background-color 320ms var(--ease-out);
}

.say__dash.is-on {
  width: 34px;
  background-color: var(--accent);
}

/* ---- the row ----
   Left edge on the wrap's content edge, right edge past the screen: the
   same arithmetic the UGC row uses. */
.say {
  margin-top: clamp(2rem, 5vh, 3.5rem);
  margin-right: calc(-1 * clamp(1rem, 3vw, 2.5rem));
  padding-left: max(var(--space-lg),
                    calc((100% - var(--wrap-max)) / 2 + var(--space-lg)));
}

.say__row {
  display: flex;
  gap: 20px;
  width: 100%;
  margin: 0;
  /* The trailing gutter is what lets the last card come fully into view
     instead of stopping flush against the edge of the screen, and it is
     TWO things added together, which is what it was missing.

     The clamp only cancels .say's negative margin-right: it buys back
     the page gutter that the row was deliberately pushed out through,
     so the right-hand end of the run lands on the wrap's content edge
     rather than past it. On its own that is not a gutter at all, it is
     a return to zero, and on a phone it left the last card finishing
     flush with the screen while the first one started 36px in.

     --space-lg is the gutter proper. It is the same token that sets
     .say's padding-left, so the space before the first card and the
     space after the last are one value and cannot drift apart.

     This is also the number the scroll run is built from: scrollWidth
     is cards + gaps + this padding, so widening it here widens the
     travel by exactly as much and the last card arrives with the
     gutter behind it. */
  padding: 4px calc(clamp(1rem, 3vw, 2.5rem) + var(--space-lg)) 24px 0;
  list-style: none;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  -ms-overflow-style: none;
}

.say__row::-webkit-scrollbar { display: none; }

.say__item {
  flex-shrink: 0;
  scroll-snap-align: start;
}

.say__card {
  position: relative;
  display: flex;
  flex-direction: column;
  width: min(620px, 82vw);
  min-height: 100%;
  padding: 36px;
  border-radius: 24px;
  background-color: var(--panel);
  box-shadow: 0 18px 50px var(--shadow-2), 0 2px 6px var(--shadow-1);
  opacity: 0;
  transform: translateY(28px);
  transition: opacity 600ms var(--ease-out), transform 600ms var(--ease-out);
}

.say.is-in .say__card { opacity: 1; transform: none; }
.say.is-in .say__item:nth-child(2) .say__card { transition-delay: 110ms; }
.say.is-in .say__item:nth-child(3) .say__card { transition-delay: 220ms; }

.say__badge {
  position: absolute;
  top: 28px;
  right: 28px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  border-radius: 10px;
  background-color: var(--accent-fill);
  color: var(--on-accent);
}

.say__cardtitle {
  margin: 0;
  max-width: 78%;
  font-size: clamp(1.5rem, 2.4vw, 2.25rem);
  font-weight: 700;
  line-height: 1.15;
  letter-spacing: -0.025em;
  color: var(--text);
}

.say__quote {
  margin: 1.25rem 0 0;
  padding: 0;
  border: 0;
}

.say__quote p {
  margin: 0;
  font-size: clamp(1rem, 1.35vw, 1.25rem);
  line-height: 1.6;
  color: var(--body);
}

.say__foot {
  display: flex;
  align-items: center;
  gap: 0.85rem;
  margin-top: auto;
  padding-top: 2rem;
}

.say__avatar {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background-color: var(--avatar-bg);
  color: var(--avatar-ink);
  font-size: 1rem;
  font-weight: 800;
}

.say__who { display: flex; flex-direction: column; gap: 2px; }
.say__role { font-size: 1rem; font-weight: 700; color: var(--text); }
.say__note { font-size: 0.8rem; color: var(--muted); }

/* Selecting the quote instead of dragging the row is never what was meant */
.say__row.is-dragging,
.say__row.is-dragging * { user-select: none; }

/* ---- the drag puck ---- */

.say__cursor {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 95;
  /* Off by default and switched on only for a real pointer. Stated the
     positive way round: a `not all and (hover) and (pointer: fine)` query
     is easy to get wrong and leaves the puck in the layout on a phone. */
  display: none;
  align-items: center;
  justify-content: center;
  gap: 4px;
  width: 90px;
  height: 90px;
  border-radius: 50%;
  background-color: var(--accent-fill);
  color: var(--on-accent);
  font-size: 0.72rem;
  font-weight: 800;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  opacity: 0;
  transform: translate(-50%, -50%) scale(0.4);
  transition: opacity 200ms var(--ease-out), scale 260ms var(--ease-out);
  pointer-events: none;
}

.say__cursor.is-on { opacity: 1; scale: 1; }
.say__cursor.is-held { scale: 0.9; }

@media (hover: hover) and (pointer: fine) {
  .say__cursor {
    display: flex;
    align-items: center;
    justify-content: center;
  }
}

/* One cursor at a time: the site's own is stood down while the puck is up */
body.is-say-drag .cursor-ring,
body.is-say-drag .cursor-dot,
body.is-say-drag .cursor-glow { opacity: 0 !important; }

@media (hover: hover) and (pointer: fine) {
  body.is-say-drag .say__row,
  body.is-say-drag .say__row * { cursor: none; }
}

@media (max-width: 899px) {
  .say__head { flex-direction: column; align-items: flex-start; gap: 1.25rem; }
  .say__title { font-size: clamp(2rem, 9.5vw, 3.25rem); }
  .say__dashes { margin-bottom: 0; }
  .say { margin-top: 2rem; }
  .say__card { width: 85vw; padding: 24px; border-radius: 20px; }
  .say__cardtitle { max-width: 72%; }
  .say__row { gap: 14px; }
  .say__badge { top: 20px; right: 20px; width: 38px; height: 38px; }
}

@media (prefers-reduced-motion: reduce) {
  .say__card { opacity: 1; transform: none; transition: none; }
  .say__dash { transition: none; }
  .say__cursor { transition: none; }
}

.no-js .say__card { opacity: 1; transform: none; }

/* ==========================================================================
   STEP 9 / the name divider

   The letters and the clip window are the same SVG <text> nodes, so the
   yellow and the photographs are cut from one shape. script.js sizes the
   type by measuring it, the way the hero does, rather than by a hand-tuned
   vw value that only holds at the width it was tuned at.
   ========================================================================== */

.mb {
  padding-block: clamp(2.5rem, 9vh, 6rem);
  overflow: hidden;
}

.mb__svg {
  display: block;
  width: 100%;
  height: auto;
}

.mb__t {
  font-family: inherit;
  font-weight: 800;
  letter-spacing: -0.045em;
  /* Measured, not guessed: the size that puts ELHAYYANY on 92% of the
     1000-unit viewBox in Inter. The viewBox is fixed, so this is right at
     every screen width with no script running at all, and right on the
     first paint. script.js only nudges it when a fallback face with other
     metrics is standing in for Inter. */
  font-size: 155.5px;
}

/* Each photograph fades up into the letters and back out again. opacity
   only: an SVG <image> inside a clip path is already doing enough work. */
/* No transition. The single rAF loop in script.js writes opacity every
   frame, and a transition on the same property would be a second animator
   fighting it for the same number: every write would start a fresh 400ms
   run from wherever the last one had got to, which is both slower and
   wrong. Same reason the What I Bring words have no transition.

   will-change and backface-visibility give each picture its own composited
   layer, so moving one is a compositor job and never repaints the letters
   underneath. No filter, no box-shadow and no blur anywhere near these:
   every one of them would force a paint on a moving element and is exactly
   what the layer is there to avoid.

   The pool is eight nodes and they exist for the whole visit, so the
   standing cost of eight layers is paid once rather than being churned. */
.mb__shot {
  opacity: 0;
  will-change: transform, opacity;
  backface-visibility: hidden;
}

/* ==========================================================================
   STEP 9 - Contact: the typing scene

   Replaces the magnetic email and phone outright. What is left of the old
   section is the shell (.contact), its warm glow and the eyebrow; the
   heading, the links, the magnetic response, the touch ripple and the
   breathing pulse are all gone, along with their reduced-motion blocks.

   ONE DEVICE, TWO SKINS. .ct__lid is the laptop lid above 900px and the
   whole phone body below it, and .ct__deck - which holds the keyboard -
   moves from "the base under the lid" to "an on-screen keyboard inside
   the phone". Two separate scenes would mean two copies of the message
   and two copies of the keys, and a screen reader would find whichever
   one it happened to reach first.

   EVERYTHING SCALES FROM THE DEVICE'S OWN WIDTH. .ct__dev is a size
   container and almost every length inside it is in cqw, so the scene is
   the same drawing at 720px and at 320px. A viewport unit would not do:
   from 900px up the sidebar takes about 370px out of the row, so the
   device is nowhere near a fixed fraction of the window.

   THE FINISHED STATE IS THE DEFAULT. Lid open, full sentence, cards
   visible. script.js winds that back to play it, and only after checking
   the motion setting, so no-JS and reduced-motion both land on a still
   picture of the ending rather than on an empty screen.
   ========================================================================== */

/* ---- header ---- */

.ct__head {
  margin: 0;
  font-size: clamp(56px, 6.5vw, 96px);
  font-weight: 800;
  line-height: 0.98;
  letter-spacing: -0.04em;
  color: var(--text);
}

.ct__headln { display: block; }
.ct__headln--soft { color: var(--body); }

.ct__line {
  margin: 1.5rem 0 0;
  max-width: 560px;
  font-size: 1.125rem;
  line-height: 1.6;
  color: var(--body);
}



@keyframes ct-pulse {
  0%, 100% { opacity: 0.25; transform: scale(0.8); }
  50%      { opacity: 1;    transform: scale(1); }
}



@keyframes ct-blink {
  0%, 49%   { opacity: 1; }
  50%, 100% { opacity: 0; }
}



/* Up and off the top-right corner. Scale falls away with distance so it
   reads as going somewhere rather than sliding off the edge. */
@keyframes ct-fly {
  0%   { opacity: 0; transform: translate(-50%, -50%) rotate(-8deg) scale(0.5); }
  12%  { opacity: 1; transform: translate(-50%, -50%) rotate(-8deg) scale(1); }
  55%  { opacity: 1; transform: translate(120%, -240%) rotate(6deg) scale(0.9); }
  100% { opacity: 0; transform: translate(560%, -560%) rotate(16deg) scale(0.55); }
}



@keyframes ct-wake {
  0%   { opacity: 0;    stroke-dashoffset: 300; }
  10%  { opacity: 0.75; }
  55%  { opacity: 0.75; stroke-dashoffset: 0; }
  100% { opacity: 0;    stroke-dashoffset: 0; }
}


@keyframes ct-fold {
  0%   { opacity: 1; transform: scale(1) rotate(0deg); }
  100% { opacity: 0; transform: scale(0.28) rotate(-14deg) translateY(-8%); }
}



/* The stagger that makes three rows of different lengths read as a
   keyboard rather than as three centred lists */
.ct__krow--b { padding-inline: 2.4cqw; }
.ct__krow--c { padding-inline: 5.6cqw; }


.ct__key--space { flex: 0 0 46%; }



/* The whole LinkedIn card is the target, reached by stretching this one
   anchor over it rather than wrapping the card in a link: the label and
   the name stay plain text in the accessibility tree and the link keeps
   a name of its own. Only this card can do it. The other two each have a
   control of their own, and a stretched link would sit on top of them.

   The pseudo-element is transparent and has no radius, so the card looks
   exactly as it did; the keyboard focus ring still lands on the button
   itself, which is where the eye expects it. */
.ct__act--stretch::after {
  content: "";
  position: absolute;
  inset: 0;
}



/* Announced to a screen reader, invisible on screen. script.js puts the
   whole sentence in one of these before it empties the visible copy to
   retype it, so the message is never half-read aloud. */
.ct__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  white-space: nowrap;
  border: 0;
  clip-path: inset(50%);
}


/* The cards used to run in a row UNDER the scene between 900 and 1399,
   two up below 1200 and three across above it, because the scene had the
   full width to itself and there was nowhere beside it to put them.
   STEP 18c gave them a column of their own at every width from 900 up, so
   both of those rules are gone: a two-up grid inside a 300px track would
   have given each card 140px and broken the email mid-word, which is the
   very thing the three-across rule was written to avoid at 1024. */

/* --------------------------------------------------------------------------
   Mobile - the laptop becomes a phone

   Same elements throughout. The lid stops rotating and becomes the phone
   body, the screen goes tall, the message window becomes a chat bubble
   with a tail, and the deck lifts out of the flow and sits at the foot of
   the screen as an on-screen keyboard.
   -------------------------------------------------------------------------- */

@media (max-width: 899px) {



  /* Up and settling, the way a sent message moves up the thread */
  @keyframes ct-slide {
    0%   { transform: translateY(0); }
    100% { transform: translateY(-6cqw); }
  }


  .ct__krow--b { padding-inline: 3cqw; }
  .ct__krow--c { padding-inline: 7cqw; }



  .ct__head { font-size: clamp(2.25rem, 11vw, 3.5rem); }
  .ct__line { font-size: 1rem; }

}


/* ==========================================================================
   STEP 10 - the theme toggle, and the few edges dark mode needs

   The palette itself is two blocks at the top of this file. What is left
   here is the button, the crossfade between themes, and the handful of
   places where a dark page needs an outline that a light one does not.
   ========================================================================== */

/* ---- the crossfade ----

   Only while switching. script.js puts .is-theming on <html>, waits out
   the 300ms and takes it off again, which gets three things at once:

   1. No transition on first load, by construction rather than by a flag.
      The class is not there yet, so the first paint cannot animate.
   2. No permanent transition sitting on every element on the page, which
      is what a bare `* { transition: background-color ... }` would be,
      and which would fight every component's own transitions for the
      rest of the visit.
   3. Nothing to undo. The rule stops applying the moment the class goes.

   !important is doing real work here: plenty of components declare their
   own `transition` shorthand, and the shorthand resets every longhand.
   Without it, a card with `transition: transform 420ms` would refuse to
   ease its background. The cost is that for those 300ms a transform
   caught mid-flight snaps instead of easing, which is a fair trade for
   one third of one second. */

html.is-theming,
html.is-theming *,
html.is-theming *::before,
html.is-theming *::after {
  transition: background-color 0.3s ease,
              border-color 0.3s ease,
              color 0.3s ease,
              fill 0.3s ease,
              stroke 0.3s ease,
              box-shadow 0.3s ease !important;
}

/* A theme change is a change of state, not a journey. Under this setting
   it simply happens. */
@media (prefers-reduced-motion: reduce) {
  html.is-theming,
  html.is-theming *,
  html.is-theming *::before,
  html.is-theming *::after {
    transition: none !important;
  }
}

/* ---- the button ---- */

.tt {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: 30px;
  height: 30px;
  padding: 0;
  border: 0;
  border-radius: 9px;
  background-color: var(--sink);
  color: var(--text);
  font-family: inherit;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: background-color 160ms var(--ease-out);
}

.tt:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

@media (hover: hover) and (pointer: fine) {
  .tt:hover { background-color: var(--sink-hi); }
}

/* Both icons are always in the box, stacked. Only one is at opacity 1, so
   the swap is a crossfade in place rather than one icon being removed and
   another inserted, which is what lets it rotate through the change. */
.tt__ico {
  position: absolute;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: opacity 260ms var(--ease-out), transform 260ms var(--ease-out);
}

.tt__ico svg { display: block; }

/* Light theme: the moon is showing, because the moon is what you would be
   switching TO. The sun waits, turned back and faded out. */
.tt__ico--moon { opacity: 1; transform: rotate(0deg) scale(1); }
.tt__ico--sun  { opacity: 0; transform: rotate(-90deg) scale(0.6); }

[data-theme="dark"] .tt__ico--moon { opacity: 0; transform: rotate(90deg) scale(0.6); }
[data-theme="dark"] .tt__ico--sun  { opacity: 1; transform: rotate(0deg) scale(1); }

@media (prefers-reduced-motion: reduce) {
  .tt__ico { transition: opacity 120ms ease; transform: none !important; }
}

/* ---- where the three copies sit ---- */

.topbar__acts {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.sb__acts {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
}

.tt--sb {
  width: 30px;
  height: 30px;
  border-radius: 9px;
}

/* The morph fades the sidebar in by animating named children, not the
   whole column, so anything it does not know about is painted at full
   strength from the first frame. This button is new and not one of them,
   and without this it sat on top of the hero next to the hero's own copy.
   It now arrives with the rest of the sidebar. */
.sb:not(.is-live) .tt {
  opacity: 0;
  pointer-events: none;
}

.tt--sb { transition: background-color 160ms var(--ease-out), opacity 240ms var(--ease-out); }

/* The sidebar panel reverses while it is over the UGC band, and the
   toggle sits inside it. Without this it keeps the page's own --sink and
   --text and turns into a light icon on a light panel, which is exactly
   the failure the .is-dark block already handles for .sb__icon beside it. */
.sb__panel.is-dark .tt {
  background-color: var(--vid-chip);
  color: var(--vid-ink);
}

@media (hover: hover) and (pointer: fine) {
  .sb__panel.is-dark .tt:hover { background-color: var(--vid-chip-hi); }
}

/* The sidebar's icons grow with it above 1200px, and the toggle grows
   with them, or it would sit as a small square beside a large one */
@media (min-width: 1200px) {
  .tt--sb { width: 36px; height: 36px; border-radius: 11px; }
}

/* The hero's copy. Top right, above the wordmark, out of the way of both
   nav rows. z-index 4 puts it over the portrait, which is the only thing
   in the hero that reaches this corner. */
.tt--hx {
  position: absolute;
  top: clamp(1rem, 3vh, 2rem);
  right: clamp(1rem, 3vw, 2.5rem);
  z-index: 4;
  width: 38px;
  height: 38px;
  border-radius: 12px;
  background-color: var(--panel-soft);
  border: 1px solid var(--hairline);
  transition: background-color 160ms var(--ease-out),
              opacity 320ms var(--ease-out);
}

/* Once the sidebar is live it has a toggle of its own, so the hero's goes
   away rather than leaving two on screen. :has() is what lets a rule on
   the hero react to a class on the sidebar without caring where either
   sits in the document. Where :has() is not supported the hero button
   simply stays, which is untidy but not broken. */
html:has(.sb.is-live) .tt--hx {
  opacity: 0;
  pointer-events: none;
}

/* Below 900px there is no sidebar and no hero pin; the topbar carries the
   only toggle, so the hero's copy would be a second one on the same
   screen. */
@media (max-width: 899px) {
  .tt--hx { display: none; }
}

@media (min-width: 900px) {
  .topbar__acts { display: none; }
}

/* ---- the edges a dark page needs ----

   Each of these is transparent in light mode, so the light theme is
   exactly what it was before this step. */

.sb__num,
.hx__num {
  box-shadow: inset 0 0 0 1px var(--chip-border);
}


/* The sidebar panel over the UGC band. In dark mode the band is the light
   one, so this is the light panel, and the hairline has to come back the
   other way round. The switch code in script.js is untouched: .is-dark
   still only means "this panel is over the band". */
[data-theme="dark"] .sb__panel.is-dark::before {
  box-shadow: inset 0 0 0 1px var(--vid-line);
}

/* The reading highlight dims an inactive section's heading and its section
   number to --muted. That is a dark grey on the light page, which is fine
   on the number's yellow chip, and a light grey on the dark page, which is
   1.59:1 on the same chip. The number keeps its own ink while dimmed here;
   the chip is yellow in both themes, so the ink on it cannot follow the
   theme. Specificity has to beat .js .section:not(.is-active) .section__num,
   which the attribute selector supplies. */
[data-theme="dark"].js .section:not(.is-active) .section__num {
  color: var(--on-accent-2);
}

/* Same dimmer, same problem, the other section. The UGC band reverses out
   of the page, so --muted is the wrong grey on it in BOTH themes: 2.63:1
   on the dark band in light mode, 1.55:1 on the light band in dark mode.
   The band has its own grey and the dimmer has to use it. Matching the
   dimmer's specificity and sitting later is what takes it. */
.js .section:not(.is-active) .vid__marker,
.js .section:not(.is-active) .vid__marker > span:not(.section__num) {
  color: var(--vid-mute);
}

/* Except the number, which is not on the band at all: it is on its own
   yellow chip, and the chip is yellow in both themes */
.js .section:not(.is-active) .vid__marker .section__num {
  color: var(--on-accent-2);
}

/* ==========================================================================
   STEP 11 - the phone intro, and the phone in general

   Nothing in this block applies above 899px. The desktop hero, its
   keyframes and its morph are untouched.
   ========================================================================== */

/* ---- the hold ----

   .is-loading is written by the inline block in <head> before the first
   paint, and removed once the fonts and the portrait are in. Only the
   phone hero is covered by it: the desktop hero has its own intro and
   nothing to gain from waiting.

   visibility rather than display, so the layout is still built and
   measured underneath. The hero is the right size the instant it appears
   instead of reflowing into place in front of the visitor. */
@media (max-width: 899px) {
  html.is-loading .hx > *,
  html.is-loading .topbar {
    visibility: hidden;
  }

  /* The bloom is the one thing that must be visible while the hero is
     still covered: it is the first beat of the intro. */
  html.is-loading .mi__bloom { visibility: visible; }
}

@media (max-width: 899px) {

  /* ---- a. the bloom ---------------------------------------------------
     A soft yellow circle growing from the middle of the screen and fading
     out. Two properties, both compositor-only. */

  .mi__bloom {
    position: absolute;
    top: 38%;
    left: 50%;
    z-index: 0;
    width: 62vw;
    height: 62vw;
    margin: -31vw 0 0 -31vw;
    border-radius: 50%;
    background: radial-gradient(circle,
                rgba(245, 229, 0, 0.55) 0%,
                rgba(245, 229, 0, 0.18) 45%,
                transparent 70%);
    opacity: 0;
    pointer-events: none;
  }

  html.is-intro .mi__bloom {
    animation: mi-bloom 500ms var(--ease-out) both;
  }

  @keyframes mi-bloom {
    0%   { opacity: 0;    transform: scale(0.2); }
    35%  { opacity: 1;    transform: scale(0.85); }
    100% { opacity: 0;    transform: scale(1.25); }
  }

  /* ---- b. the name, letter by letter ----------------------------------
     Each letter is its own clip box with the glyph inside it, written by
     script.js. The clip is per letter rather than per line because a
     single overflow:hidden on the line clips sideways as well, and at
     -0.02em tracking the outer letters sit right on that edge.

     The 30ms stagger is written as an inline animation-delay, so the two
     lines carry on from one another rather than both starting at zero. */

  .mi__letter {
    display: inline-block;
    overflow: hidden;
    /* The glyph box, not the line box, so a clip cannot eat the cap height */
    vertical-align: bottom;
  }

  .mi__letter > i {
    display: inline-block;
    font-style: inherit;
    transform: translate3d(0, 0, 0);
  }

  /* Held below the clip until the intro runs. Only while .is-loading is
     on, so a page that never gets an intro (JS failed, class never added)
     still shows the name. */
  html.is-loading .mi__letter > i {
    transform: translate3d(0, 105%, 0);
  }

  /* animation-delay: inherit is the whole trick. script.js writes the
     per-letter stagger as an inline animation-delay on the CLIP, which is
     the element it created and can address; the glyph inside takes that
     computed value. animation-delay does not inherit on its own, so this
     has to ask for it. */
  html.is-intro .mi__letter > i {
    animation: mi-letter 520ms var(--ease-out) both;
    animation-delay: inherit;
  }

  @keyframes mi-letter {
    from { transform: translate3d(0, 105%, 0); }
    to   { transform: translate3d(0, 0, 0); }
  }

  /* ---- c. the portrait ------------------------------------------------ */

  html.is-loading .hx__portrait {
    opacity: 0;
  }

  html.is-intro .hx__portrait {
    animation: mi-portrait 760ms var(--ease-out) 620ms both;
  }

  @keyframes mi-portrait {
    from {
      opacity: 0;
      transform: translate3d(0, 60px, 0) scale(1.05);
      filter: var(--hx-shadow) blur(10px);
    }
    to {
      opacity: 1;
      transform: translate3d(0, 0, 0) scale(1);
      filter: var(--hx-shadow) blur(0);
    }
  }

  /* The intro's animations are filled, and a filled animation outranks an
     inline style. Until they are dropped, every transform, opacity and
     filter the scroll animation writes would be silently ignored.
     script.js adds .is-settled when the run is over, which is the same
     trick .hx.is-ready plays for the desktop morph. */
  html.is-intro.is-settled .hx__portrait,
  html.is-intro.is-settled .hx__word,
  html.is-intro.is-settled .hx__head,
  html.is-intro.is-settled .hx__btns,
  html.is-intro.is-settled .hx__stats,
  html.is-intro.is-settled .hx__traits,
  html.is-intro.is-settled .hx__tag,
  html.is-intro.is-settled .hx__desc {
    animation: none;
  }

  /* ---- d. headline, buttons, stats, traits ---------------------------- */

  html.is-loading .hx__head,
  html.is-loading .hx__btns,
  html.is-loading .hx__stats,
  html.is-loading .hx__traits,
  html.is-loading .hx__tag,
  html.is-loading .hx__desc {
    opacity: 0;
  }

  html.is-intro .hx__head    { animation: mi-up 420ms var(--ease-out) 1080ms both; }
  html.is-intro .hx__btns    { animation: mi-up 420ms var(--ease-out) 1200ms both; }
  html.is-intro .hx__stats   { animation: mi-up 420ms var(--ease-out) 1300ms both; }
  html.is-intro .hx__traits  { animation: mi-up 420ms var(--ease-out) 1400ms both; }
  html.is-intro .hx__tag     { animation: mi-up 420ms var(--ease-out) 1460ms both; }
  html.is-intro .hx__desc    { animation: mi-up 420ms var(--ease-out) 1520ms both; }

  @keyframes mi-up {
    from { opacity: 0; transform: translate3d(0, 18px, 0); }
    to   { opacity: 1; transform: translate3d(0, 0, 0); }
  }

  /* ---- e. the top bar, last ------------------------------------------- */

  html.is-intro .topbar {
    animation: mi-bar 380ms var(--ease-out) 1600ms both;
  }

  @keyframes mi-bar {
    from { opacity: 0; transform: translate3d(0, -100%, 0); }
    to   { opacity: 1; transform: translate3d(0, 0, 0); }
  }
}

/* ==========================================================================
   STEP 11 - mobile polish

   Every rule below fixes something that was measured at 390px, not
   something that looked off. The findings are named in each comment.
   Nothing here applies above 899px.
   ========================================================================== */

@media (max-width: 899px) {

  /* ---- side gutters ----
     Measured: every section's .wrap started at x=0, so all the body copy
     ran into both edges of the screen. The wrap had a gutter on desktop
     and none here. 20px, and it has to respect the notch in landscape,
     where the safe inset is horizontal rather than vertical. */
  .wrap {
    padding-inline: max(20px, env(safe-area-inset-left))
                    max(20px, env(safe-area-inset-right));
  }

  /* The timeline keeps its 44px rail gutter on the left and takes the
     standard one on the right */
  .jr { padding-right: max(20px, env(safe-area-inset-right)); }

  /* ---- the top bar ----
     Measured 53px; the brief asks for 60. The extra goes on as padding
     rather than a height, so the safe-area inset can add to it on a
     notched phone instead of eating the content. */
  .topbar {
    /* 60px of bar, plus whatever the notch needs on top of it rather than
       out of it. On a phone with no inset this computes to exactly 60. */
    min-height: 60px;
    padding-top: env(safe-area-inset-top, 0px);
    padding-inline: max(20px, env(safe-area-inset-left))
                    max(20px, env(safe-area-inset-right));
    transition: transform 280ms var(--ease-out);
  }

  /* Hides going down, comes back going up. script.js writes the class and
     holds it off until 80px of scroll, so it never tucks while the hero
     is still on screen. */
  .topbar.is-tucked {
    transform: translate3d(0, -100%, 0);
  }

  /* ---- 44px tap targets ----
     Measured under 44px: the theme toggle (30x30), the burger (86x33),
     the hero buttons (126x36), Read more (101x35), the Copy and WhatsApp
     buttons (57x33) and the What I Bring chips (39x22).

     The box grows, the ink does not: padding and min-height take each one
     to 44 without changing a single font size or border radius, so the
     design is untouched and only the hit area moves. */
  .tt,
  .burger {
    min-width: 44px;
    min-height: 44px;
  }
.hx__btn,
.jr__more,
.hlp__btn,
.btn {
    min-height: 44px;
  }

  /* A chip is too small to grow without redrawing the sentence it sits
     in, so it gets an invisible pad instead: the ::after covers 44px
     centred on the chip and takes the tap. */
  .wib__chip { position: relative; }

  .wib__chip::after {
    content: "";
    position: absolute;
    left: 50%;
    top: 50%;
    width: max(100%, 44px);
    height: 44px;
    transform: translate(-50%, -50%);
  }

  /* Same treatment for the dash indicator, measured at 34x5 */
  .say__dash { position: relative; }

  .say__dash::after {
    content: "";
    position: absolute;
    left: 50%;
    top: 50%;
    width: max(100%, 44px);
    height: 44px;
    transform: translate(-50%, -50%);
  }

  /* ---- body copy at 16px ----
     Measured below 16px: the hero strapline at 11.2px, the timeline
     summaries at 15.2px, the strength proof lines at 14px and the
     "what I brought" items at 15.2px. Line height 1.5 on all of them. */
  .hx__desc,
  .jr__short,
  .jr__text,
  .jr__brought li,
  .st__proof,
  .hlp__text,
  .vid__intro,
  .say__quote {
    font-size: 1rem;
    line-height: 1.5;
  }

  /* The label under a timeline card is a caption, not prose, so it stays
     smaller than the body but comes up off 12.5px to the 13px floor */
  .jr__label,
  .footer__meta { font-size: 0.8125rem; line-height: 1.5; }

  /* ---- carousels ----
     Measured: no scroll-snap anywhere (snap-type computed to none on all
     three rows), and the UGC cards at 70vw. 85vw shows a clear slice of
     the next card, which is what says the row scrolls. */
  .vid__card { width: 85vw; }

  /* .cardrail__row is the scroller, not .vid__row: the snap rule at the
     top of the rail section is inside a desktop media query, so on a
     phone these rows had no snap at all. proximity, not mandatory: the
     shared rail writes scrollLeft directly during a drag and mandatory
     fights those writes, which is the same reason .is-snap-suspended
     exists for the desktop rails. Its rule already covers this one. */
  .cardrail__row {
    scroll-snap-type: x proximity;
  }

  .vid__item,
  .say__card {
    scroll-snap-align: start;
  }

  /* ---- fixed things and the home indicator ---- */
  .menu {
    padding-bottom: max(var(--space-lg), env(safe-area-inset-bottom));
  }

  .footer {
    padding-bottom: max(var(--space-lg), env(safe-area-inset-bottom));
  }

  /* ---- the full-screen menu ----
     28px items as briefed, arriving one after another. */
  .menu__link { font-size: 1.75rem; }

  .menu__item,
  .menu__link { min-height: 44px; }
}

/* ---- the reduced-motion intro ----
   Same order, same timings, fades only. No letter rise, no blur, no
   scale, nothing travelling more than --rm-shift. */
@media (max-width: 899px) and (prefers-reduced-motion: reduce) {
  html.is-loading .mi__letter > i { transform: none; }

  html.is-intro .mi__letter > i {
    animation: mi-fade 420ms ease both;
    animation-delay: inherit;
  }

  html.is-intro .hx__portrait {
    animation: mi-fade 520ms ease 620ms both;
  }

  html.is-intro .hx__head    { animation: mi-fade 380ms ease 1080ms both; }
  html.is-intro .hx__btns    { animation: mi-fade 380ms ease 1200ms both; }
  html.is-intro .hx__stats   { animation: mi-fade 380ms ease 1300ms both; }
  html.is-intro .hx__traits  { animation: mi-fade 380ms ease 1400ms both; }
  html.is-intro .hx__tag     { animation: mi-fade 380ms ease 1460ms both; }
  html.is-intro .hx__desc    { animation: mi-fade 380ms ease 1520ms both; }
  html.is-intro .topbar      { animation: mi-fade 320ms ease 1600ms both; }

  /* The bloom is a light coming on, not something crossing the screen.
     It keeps its fade and loses its growth. */
  html.is-intro .mi__bloom {
    animation: mi-fade-out 500ms ease both;
  }

  @keyframes mi-fade-out {
    0%   { opacity: 0; }
    35%  { opacity: 0.8; }
    100% { opacity: 0; }
  }

  @keyframes mi-fade {
    from { opacity: 0; }
    to   { opacity: 1; }
  }
}


/* --------------------------------------------------------------------------
   STEP 15 - the role title at two lengths

   Full:  Technical Customer Success Manager & Business Developer
   Short: Customer Success & Business Development

   The full one is used everywhere there is room. The short one exists for
   the hero tagline on a phone, where the full title wrapped to three lines
   at 360px and pushed "That's Mohamed." away from the title it answers.
   -------------------------------------------------------------------------- */

.hx__tagshort { display: none; }

@media (max-width: 899px) {
  .hx__tagfull { display: none; }
  .hx__tagshort { display: block; }
}

/* ==========================================================================
   STEP 16
   ========================================================================== */

/* ==========================================================================
   STEP 18b - the name: yellow in both themes, a hairline rim in light

   Three surfaces wear it: the hero wordmark, the copy of the wordmark
   that flies into the sidebar, and the divider before Contact. The
   sidebar logo pill at rest is NOT one of them: it is dark text on a
   yellow lozenge and already has all the contrast it needs.

   One declaration each, one token pair behind all three, and no second
   copy of the text anywhere on the page. The offset shadow, the two
   layered <text> copies in the divider and the clipped <rect> they sat
   under are all gone.

   Appended last so it beats the earlier .hx__word blocks by source
   order without needing a heavier selector.
   ========================================================================== */

/* ---- the hero wordmark ----
   The floor is for phones only and it is not decoration. 0.006em against
   the desktop wordmark is 1.15px at 1280 and 1.72px at 1920, which is the
   hairline that was asked for. The same 0.006em against the phone's 14vw
   wordmark is 0.33px at 390, which Chrome draws as a third of an alpha
   value and the eye does not see at all, putting the name back to the
   1.18:1 yellow-on-beige it is outlined to escape. max() leaves every
   desktop width untouched and only engages below about a 100px font. */
.hx__word {
  color: var(--name);
  -webkit-text-stroke: max(0.6px, var(--name-sw)) var(--name-ink);
  /* Keeps the glyph exact by laying the black down first. At a hairline
     it is barely doing anything, and where it is not supported the
     stroke simply centres again, which at 0.006em is a third of a pixel
     either side. That is the degradation, not a second code path. */
  paint-order: stroke fill;
}

/* ---- the flying copy ----
   The pill's inner span stands in for the wordmark for the length of the
   morph, so it wears the same rim. script.js winds the width down to
   zero across the same window that turns the text to ink, so what lands
   in the sidebar is the ordinary pill. */
.sb__logoin { paint-order: stroke fill; }

/* ---- the divider ----
   The two <text> nodes are now DRAWN, not just used as a clip shape, so
   the yellow comes from their own fill and the rim from their own
   stroke. That removes the <rect> that used to be clipped to them and
   both layered ink copies at once: one set of letters, filled and
   stroked, exactly like the wordmark above.

   The clipPath still refers to these very nodes, through <use>, so the
   photographs are cut from the same shape that is painted. A reference,
   not a copy: they cannot drift apart when a web font lands or mbFit()
   resizes them, which is the whole reason the original built the clip
   this way.

   paint-order puts the stroke behind the fill, so the trail, which is
   clipped to the glyph outline and therefore stops at the inside edge of
   the stroke, leaves the rim standing around every photograph. */
.mb__t {
  fill: var(--name);
  stroke: var(--name-ink);
  stroke-width: var(--name-sw);
  stroke-linejoin: round;
  paint-order: stroke;
}

/* The same phone floor as the wordmark, and it has to be written in user
   units rather than px because in SVG a px IS a user unit: the viewBox is
   a fixed 1000 wide, so one user unit is 0.39 screen px at 390 and 1.44 at
   1440. 1.8 user units is the width that lands on the wordmark's 0.6px
   floor once the viewBox is scaled down to a phone. */
@media (max-width: 899px) {
  .mb__t { stroke-width: max(1.8px, var(--name-sw)); }
}

@media (max-width: 899px) {



  /* The <wbr> in the markup is a break opportunity, and nowrap makes it a
     no-op, which is what we want here. */
}

/* --------------------------------------------------------------------------
   STEP 16 - the phone frame goes

   What is left of the scene is the bubble and the keyboard, sized to their
   own content and sitting on the page background. Everything that drew a
   device is switched off rather than deleted, because the same elements
   still carry the typing, the send and the key presses; only their
   costume goes.

   The point is vertical: with the frame gone and the bubble at about 60%
   of its old height, the typing and the contact rows fit on one screen,
   so the reply is visible in the same glance as the message.
   -------------------------------------------------------------------------- */

@media (max-width: 899px) {


  .ct__krow--b { padding-inline: 10px; }
  .ct__krow--c { padding-inline: 24px; }


}


/* ==========================================================================
   STEP 18c - Contact on one screen

   The heading, the scene and the three cards used to run to 1.39 screens
   at 1440x900, with 989px between the eyebrow and the last card, so the
   end of the page was three separate views of one section. They are now
   one view: heading and availability line top left, the scene under them,
   the cards in a column on the right, the footer on the floor.

   The mechanism is `display: contents` on .ct. The eyebrow, the heading
   and the line are siblings of .ct inside .wrap, and the scene and the
   cards are its children, so no single existing element is the parent of
   all five. Dissolving .ct promotes the scene and the cards into .wrap's
   own grid and all five become placeable together, without moving a line
   of markup and without a wrapper that exists only to be a grid.

   Everything here is min-width: 900px. The phone keeps the frameless
   stacked scene from STEP 16 untouched; its own sizing is further down.
   ========================================================================== */

/* The two-column grid that held the laptop beside a card column is gone
   with the laptop. The heading block is back to being ordinary stacked
   content inside .wrap, and the line below it escapes the wrap entirely
   (see "The Line That Connects" at the end of this file). */
@media (min-width: 900px) {
  .contact { padding-block: clamp(2rem, 5vh, 4rem) clamp(2rem, 4vh, 3.5rem); }
  .contact__eyebrow { margin-bottom: 0.5rem; }
  .ct__line { margin-top: 0.75rem; font-size: 1rem; max-width: 46ch; }
}

/* ==========================================================================
   STEP 18c, part two - Contact on a phone

   The same goal within reason: 2.02 screens for the section and its
   footer, down to about one and a half. Nothing is restructured here, the
   stacked frameless scene from STEP 16 is exactly as it was; this only
   takes the air out of it.
   ========================================================================== */

@media (max-width: 899px) {

  /* 14vh of an 844px phone is 118px at the top AND at the bottom. That
     one declaration was 236px, more than a quarter of a screen, spent on
     nothing. */
  .contact { padding-block: clamp(2rem, 5vh, 3rem); }

  /* The heading keeps the full size here too. It is allowed to be the
     thing you land on: what has to fit together below it is the scene
     and the three rows. */
  .ct__head { line-height: 1; }
  .ct__line { margin-top: 0.75rem; font-size: 0.95rem; }


}

/* --ct-footer is gone with the one-screen Contact that reserved against
   it. The section is now as tall as its content and the footer follows,
   so there is no height to subtract from anything. */

/* ==========================================================================
   STEP 20 - Contact: the conversation

   A chat panel: a short exchange, then the three contact methods as the
   actions in the bar beneath it. It replaces "The Line That Connects",
   whose CSS, markup and JavaScript are all deleted, and the laptop typing
   scene before that.

   Everything paints from the ordinary palette, so both themes come free.
   The one new pair of tokens is the bubble colours, because a message
   bubble is a surface that needs to sit ON --panel rather than be it.
   ========================================================================== */

.cv { margin-top: clamp(1.5rem, 4vh, 2.75rem); }

.cv__panel {
  max-width: 900px;
  padding: clamp(20px, 2.4vw, 28px) clamp(20px, 2.4vw, 30px) clamp(18px, 2vw, 24px);
  border-radius: 28px;
  background-color: var(--cv-sheet);
  box-shadow: 0 26px 64px var(--shadow-2), 0 2px 6px var(--shadow-1);
}

/* ---- the header ---- */

.cv__head {
  display: flex;
  align-items: center;
  gap: 13px;
  padding-bottom: 15px;
  border-bottom: 1px solid var(--border);
}

.cv__av {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  flex: 0 0 auto;
  border-radius: 50%;
  background-color: var(--accent-fill);
  color: var(--on-accent);
  font-size: 15px;
  font-weight: 700;
  letter-spacing: 0.02em;
}

.cv__name { margin: 0; font-size: 1rem; font-weight: 700; letter-spacing: -0.015em; }

.cv__status {
  display: flex;
  align-items: center;
  gap: 6px;
  margin: 2px 0 0;
  font-size: 0.78rem;
  font-weight: 600;
  color: var(--cv-live);
}

.cv__live {
  width: 7px;
  height: 7px;
  flex: 0 0 auto;
  border-radius: 50%;
  background-color: var(--cv-live);
}

.cv__tools { display: flex; gap: 8px; margin: 0 0 0 auto; }

.cv__tool {
  display: grid;
  place-items: center;
  width: 36px;
  height: 36px;
  border-radius: 11px;
  background-color: var(--cv-in);
  color: var(--body);
  transition: background-color var(--fast) ease, color var(--fast) ease;
}

.cv__tool:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* ---- the thread ---- */

.cv__thread {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 8px;
  margin: 0;
  padding: 18px 0 2px;
  list-style: none;
}

.cv__msg {
  max-width: 68%;
  padding: 12px 17px;
  border-radius: 20px;
  font-size: 1rem;
  line-height: 1.45;
}

.cv__msg--in {
  border-bottom-left-radius: 7px;
  background-color: var(--cv-in);
  color: var(--text);
}

.cv__msg--out {
  align-self: flex-end;
  border-bottom-right-radius: 7px;
  background-color: var(--accent-fill);
  color: var(--on-accent);
  font-weight: 500;
}

.cv__meta {
  align-self: flex-end;
  font-size: 0.7rem;
  color: var(--muted);
  letter-spacing: 0.01em;
}

/* The separator is generated rather than typed, so a missing live time
   never leaves a dangling middle dot on the page. Non-breaking spaces:
   ordinary ones in generated content collapse against the text node
   beside them and the line set as "Delivered ·04:12". */
.cv__time:not(:empty)::before { content: "\00A0\00B7\00A0"; }

.cv__typing {
  display: inline-flex;
  gap: 5px;
  padding: 14px 18px;
  border-radius: 20px;
  border-bottom-left-radius: 7px;
  background-color: var(--cv-in);
}

.cv__typing s {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background-color: var(--muted);
  /* A CSS animation, not a rAF loop: it is predetermined, it repeats for
     as long as the section is on screen, and CSS keeps it off the main
     thread so it stays smooth while the rest of the page is still
     loading. transform and opacity only. */
  animation: cv-bounce 1.25s var(--ease-in-out) infinite;
}

.cv__typing s:nth-child(2) { animation-delay: 0.16s; }
.cv__typing s:nth-child(3) { animation-delay: 0.32s; }

@keyframes cv-bounce {
  0%, 60%, 100% { transform: translateY(0); opacity: 0.45; }
  30% { transform: translateY(-5px); opacity: 1; }
}

/* ---- the action bar ---- */

.cv__bar {
  display: grid;
  /* Not three equal columns. The address is one unbreakable 26 character
     run and the other two values are half that, so an even split gave the
     email 181px against the 190px it needs and broke it over two lines
     while the phone row sat half empty. */
  grid-template-columns: 1.25fr 1fr 1fr;
  gap: 10px;
  margin: 16px 0 0;
  padding-top: 16px;
  border-top: 1px solid var(--border);
  list-style: none;
}

.cv__act {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 13px 15px;
  border-radius: 15px;
  background-color: var(--cv-in);
  transition: background-color var(--fast) ease, transform var(--fast) var(--ease-out);
}

.cv__ico {
  display: grid;
  place-items: center;
  width: 32px;
  height: 32px;
  flex: 0 0 auto;
  border-radius: 10px;
  background-color: var(--accent-fill);
  color: var(--on-accent);
  transition: background-color var(--fast) ease;
}

.cv__txt { min-width: 0; }

.cv__val {
  display: block;
  font-size: 0.92rem;
  font-weight: 700;
  letter-spacing: -0.02em;
  color: var(--text);
  text-decoration: none;
  /* The address is one unbreakable 26 character run in a third of 900px */
  overflow-wrap: anywhere;
}

a.cv__val:hover { color: var(--accent-text, var(--accent)); }

.cv__sub {
  display: inline-block;
  margin-top: 2px;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  font-size: 0.78rem;
  font-weight: 600;
  color: var(--accent-text, var(--accent));
  text-decoration: none;
  cursor: pointer;
}

.cv__sub:hover { text-decoration: underline; }

.cv__val:focus-visible,
.cv__sub:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

/* ---- hover, desktop only ----
   Gated on a fine pointer so a tap never leaves a row stuck lifted. */
@media (hover: hover) and (pointer: fine) {
  .cv__act:hover { background-color: var(--cv-hover); transform: translateY(-2px); }
  .cv__act:hover .cv__ico { background-color: var(--accent-fill-hi); }
  .cv__tool:hover { background-color: var(--accent-fill); color: var(--on-accent); }
}

/* ==========================================================================
   The sequence

   THE MARKUP IS THE FINISHED STATE and script.js winds it back to play
   it, the same contract every scene in this section has had. `.is-live`
   is written only once the module is actually driving the thread, so no
   JavaScript, an exception before that point, or the frame before the
   observer fires all leave the finished conversation on screen rather
   than an empty panel. Hiding by default and revealing on init is the
   other way round, and it shows a flash of nothing on every load.
   ========================================================================== */

.cv__msg,
.cv__meta,
.cv__typing,
.cv__act {
  /* Named properties, never `all`: a transition on `all` here would also
     animate the background-color the hover rule above changes, and the
     two would fight over the same 200ms. */
  transition: opacity 200ms var(--ease-out), transform 200ms var(--ease-out);
}

/* 8px and 0.96, not scale(0). Nothing in the real world appears out of
   nothing, and a bubble that grows from a point reads as a balloon rather
   than as a message arriving. */
.cv.is-live .cv__msg,
.cv.is-live .cv__meta,
.cv.is-live .cv__typing {
  opacity: 0;
  transform: translateY(8px) scale(0.96);
}

/* Each bubble comes from its own side, which is the one piece of spatial
   information a thread carries: who said it. */
.cv.is-live .cv__msg--in { transform: translate(-10px, 8px) scale(0.96); }
.cv.is-live .cv__msg--out { transform: translate(10px, 8px) scale(0.96); }

.cv.is-live .cv__act { opacity: 0; transform: translateY(10px); }

.cv.is-live [data-cv-step].is-in,
.cv.is-live [data-cv-act].is-in {
  opacity: 1;
  transform: none;
}

/* The bar keeps its hover lift once it has arrived. Matched to the weight
   of the rule above, or hovering would lose to it on specificity and the
   row would sit still. */
@media (hover: hover) and (pointer: fine) {
  .cv.is-live [data-cv-act].is-in:hover { transform: translateY(-2px); }
}

/* ==========================================================================
   Phone
   ========================================================================== */

@media (max-width: 899px) {
  .cv__panel {
    padding: 18px 16px 16px;
    border-radius: 22px;
  }

  .cv__msg { max-width: 86%; font-size: 0.95rem; padding: 11px 15px; }
  .cv__av { width: 40px; height: 40px; font-size: 14px; }
  .cv__tools { display: none; }

  /* Three across would give each row about 110px on a 390px screen and
     break the address over four lines. They stack, and each one is a
     56px tap target with room to spare. */
  .cv__bar { grid-template-columns: minmax(0, 1fr); gap: 8px; }
  .cv__act { display: flex; }
  .cv__act { min-height: 56px; padding: 12px 14px; }
  .cv__val { font-size: 0.95rem; }
}

/* ==========================================================================
   Reduced motion

   Fewer and gentler, not none. The bubbles still arrive in order, because
   the ORDER is the content here and removing it would leave the panel
   telling no story at all. What goes is the travel and the scale: they
   fade in where they already are. The typing dots stop bouncing and
   simply sit there, so nothing loops forever in the corner of the eye.
   ========================================================================== */

@media (prefers-reduced-motion: reduce) {
  .cv.is-live .cv__msg,
  .cv.is-live .cv__msg--in,
  .cv.is-live .cv__msg--out,
  .cv.is-live .cv__meta,
  .cv.is-live .cv__typing,
  .cv.is-live .cv__act { transform: none; }

  .cv__msg,
  .cv__meta,
  .cv__typing,
  .cv__act { transition: opacity 200ms var(--ease-out); }

  .cv__typing s { animation: none; opacity: 0.55; }

  .cv__act:hover { transform: none; }
}
