/* ---------- Variables ---------- */
/* Dark is the default theme. [data-theme="light"] below overrides it.

   The accent is split across two tokens rather than one, because a single
   value cannot serve both roles. The brand red is fine as text on the dark
   background but only 3.48:1 on the light one, and white sitting ON it is
   3.90:1 in either theme -- so solid fills carrying white text fail
   everywhere, while accent-as-text fails only in the light theme.

     --accent         text, borders, icons, the logo. #E74E33 on dark;
                      #BF3117 on light, where the brand red is only 3.48:1.
     --accent-strong  fills behind white text -- .hero-cta, .submit-btn,
                      .modal-btn. Darker in both themes.

   The upshot is that the brand red keeps full strength on dark everywhere
   except those three buttons. Status colours stay separate again: --error
   and --success are not the accent, for the same contrast reason. */
:root {
  color-scheme: dark;
  --bg: #121212;
  --surface-alt: #1a1a1d;
  --text: #f5f5f5;
  --text-body: #d6d6d6;
  --text-light: #a0a6ad;
  --text-faint: #8b9199;
  /* #E64A2E nudged up by 5/255 -- visually the same red, but it was 4.45:1
     on --surface-alt, a hair under AA, which showed up on the contact card's
     links. Now 4.94:1 on --bg and 4.58:1 on --surface-alt. */
  --accent: #E74E33;
  /* Solid fills that carry white text need to be darker than --accent: white
     on the brand red is only 3.90:1, which fails AA in BOTH themes. --accent
     itself barely moves here because it already passes as text, so the red
     keeps its full strength everywhere except those fills. The -rgb triplets
     exist so the glows below can use rgba() -- same approach as --dot-rgb. */
  --accent-strong: #C83318;        /* white on this: 5.33:1 */
  --accent-rgb: 231, 78, 51;
  --accent-strong-rgb: 200, 51, 24;
  --dark: #242428;
  --border: #2a2a2e;
  --header-bg: rgba(18, 18, 18, 0.85);
  /* Hero dot mesh: rgb triplet + resting/peak alpha, so the canvas can read
     the palette instead of hard-coding a colour per theme. */
  --dot-rgb: 245, 245, 245;
  --dot-alpha: 0.075;
  --dot-alpha-hi: 0.42;
  --shadow-resting: 0 1px 3px rgba(0, 0, 0, 0.45), 0 1px 2px rgba(0, 0, 0, 0.35);
  --shadow-hover: 0 30px 55px rgba(0, 0, 0, 0.55), 0 12px 24px rgba(0, 0, 0, 0.4);
  --font: 'Inter', 'Helvetica Neue', Arial, sans-serif;
  --radius-sm: 8px;
  --radius-md: 18px;
  --radius-lg: 28px;
  /* Form surfaces and status colours. --error is NOT --accent: the accent is
     only 3.48:1 on the light background, below AA for body text. These two
     clear 4.8:1 in both themes. */
  --field-bg: #17171a;
  --field-border: #34343a;
  --error: #FF7A62;
  --success: #4ADE80;
  --container: 1280px;
  --container-pad: 64px;
  /* The fixed header's height. Used by the header itself, by the hero's top
     padding, by the mobile nav's offset, and by scroll-padding-top below --
     they have to agree, so they read from here. */
  --header-h: 88px;
  --transition: 0.25s ease;
}
:root[data-theme="light"] {
  color-scheme: light;
  --bg: #f2f2f2;
  --surface-alt: #fafafa;
  --text: #141414;
  --text-body: #2B2B2B;
  /* Both were below AA in this theme -- #7E858B measured 3.34:1 on --bg and
     #9b9b9b just 2.48:1, and they carry body copy, card descriptions and
     project paragraphs, so the 4.5:1 threshold applies rather than the 3:1
     one for non-text. Darkened the least needed to clear it, keeping each
     one's hue and the light > faint ordering. */
  --text-light: #61666B;   /* 5.18:1 on --bg, 5.56:1 on --surface-alt */
  --text-faint: #6B6B6B;   /* 4.76:1 on --bg, 5.11:1 on --surface-alt */
  /* Here the accent itself has to move: at #E64A2E it is 3.48:1 on --bg, and
     it carries body-size text (.project-back, .about-links a, .aside-link,
     .modal-body a). One value covers both roles in this theme. */
  --accent: #BF3117;               /* 5.12:1 on --bg; white on it: 5.73:1 */
  --accent-strong: #BF3117;
  --accent-rgb: 191, 49, 23;
  --accent-strong-rgb: 191, 49, 23;
  --dark: #2B2B2B;
  --border: #e6e6e6;
  --header-bg: rgba(242, 242, 242, 0.9);
  --dot-rgb: 20, 20, 20;
  --dot-alpha: 0.085;
  --dot-alpha-hi: 0.40;
  --shadow-resting: 0 1px 3px rgba(20, 20, 30, 0.10), 0 1px 2px rgba(20, 20, 30, 0.06);
  --shadow-hover: 0 30px 55px rgba(20, 20, 30, 0.18), 0 12px 24px rgba(20, 20, 30, 0.12);
  --field-bg: #ffffff;
  --field-border: #d9d9dd;
  --error: #C6301A;
  --success: #1E7A3E;
}

/* ---------- Reset ---------- */
* , *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html {
  scroll-behavior: smooth;
  /* Without this, an anchor scrolls its target flush to the top of the
     viewport and the fixed header sits on top of it: section kickers landed
     9px under the header, and the logo's #home link scrolled past the hero's
     own top padding so the hero jammed against the nav. scroll-padding-top
     shifts the resting position of every anchor jump and of
     scrollIntoView({block:'start'}) by the header's height. */
  scroll-padding-top: var(--header-h);
}
body {
  font-family: var(--font);
  color: var(--text);
  background: var(--bg);
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
  transition: background-color 0.3s ease, color 0.3s ease;
}
img { max-width: 100%; display: block; }
a { color: inherit; text-decoration: none; }
ul { list-style: none; }
button { font: inherit; cursor: pointer; border: none; background: none; color: inherit; }
h1, h2, h3, h4 { font-family: var(--font); line-height: 1.15; font-weight: 700; letter-spacing: -0.02em; }
.accent { color: var(--accent); }
.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
.container {
  width: 100%;
  max-width: var(--container);
  margin-inline: auto;
  padding-inline: var(--container-pad);
}
section { padding: 128px 0; }
.kicker { display: block; font-weight: 700; font-size: 1rem; margin-bottom: 20px; }
.section-head { max-width: 640px; margin-bottom: 48px; }
.section-head h2 { font-size: clamp(1.7rem, 3vw, 2.3rem); }
.section-head p { color: var(--text-light); font-size: 1.05rem; margin-top: 14px; }

/* ---------- Focus (keyboard accessibility) ---------- */
a:focus-visible, button:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* ---------- Scroll reveal (staggered cascade) ---------- */
/* Elements are visible by default; only once JS confirms IntersectionObserver
   support (and the visitor hasn't asked for reduced motion) does body get
   .reveal-armed, which is what actually hides them pre-reveal. That way a
   slow/broken/absent script, or reduced-motion preference, never leaves
   content stuck invisible. */
.reveal {
  transition: opacity 0.6s ease, transform 0.6s ease;
  transition-delay: calc(var(--reveal-i, 0) * 90ms);
}
body.reveal-armed .reveal:not(.in-view) {
  opacity: 0;
  transform: translateY(28px);
}
.reveal.in-view { opacity: 1; transform: translateY(0); }
@media (prefers-reduced-motion: reduce) {
  .reveal { transition: none; }
  body { transition: none; }
}
@media print {
  .reveal { opacity: 1 !important; transform: none !important; }
}

/* ---------- Header ---------- */
.site-header {
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: 100;
  background: var(--header-bg);
  backdrop-filter: blur(10px);
  border-bottom: 1px solid transparent;
  transition: border-color var(--transition), background-color var(--transition);
}
.site-header.scrolled { border-color: var(--border); }
.site-header .container {
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: var(--header-h);
}
.logo { font-size: 24px; font-weight: 700; letter-spacing: -0.25px; line-height: 1; }
.logo span { color: var(--accent); }
nav { position: relative; }
/* align-items: center (not the default stretch) is what lets a bordered
   control sit on the same optical line as the plain text links: the
   links centre on their line box, the button is an inline-flex box, and
   without this they resolve to different vertical centres. */
.nav-list { display: flex; align-items: center; gap: 32px; }
.nav-list a {
  font-weight: 700;
  font-size: 0.95rem;
  color: var(--text-body);
  position: relative;
  padding: 6px 0;
  transition: color var(--transition);
}
.nav-list a:hover { color: var(--accent); }

/* Header call-to-action. Deliberately shares the hero "Explore Work"
   button's visual language (accent pill, white bold label, lift on hover)
   so the two primary actions on the page read as the same control, just
   scaled for the header.

   Sized in em rather than px: the label is expected to become a full
   email address, which is a far longer string than "Contact", so the
   padding scales with whatever font-size the pill is given and the
   proportions hold. white-space: nowrap keeps an address from breaking
   mid-string. */
/* Contact nav item — ghost button. Transparent until interaction, with a
   hairline in currentColor so the border always tracks the text colour;
   change `color` and the border follows.

   Vertical padding is deliberately small so the control's height lands
   close to the 29px text links; `align-items: center` on .nav-list then
   resolves the remainder. Padding is in em, so the shape holds when the
   label becomes a full email address.

   Selectors are doubled with `.nav-list a.nav-cta` because `.nav-list a`
   (0,1,1) out-specifies a bare `.nav-cta` (0,1,0) and would otherwise win
   on colour and padding. */
.nav-cta,
.nav-list a.nav-cta {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5em;
  background: transparent;
  border: 1.5px solid currentColor;
  color: var(--accent);
  font-weight: 600;
  font-size: 0.88rem;
  line-height: 1;
  padding: 0.5em 1.15em;
  border-radius: 999px;
  white-space: nowrap;
  max-width: 100%;
  transition: background-color var(--transition), color var(--transition),
              border-color var(--transition);
}
/* Fills only on hover/focus, inverting to solid the same way the Work
   section's filter pills do. */
.nav-cta:hover,
.nav-list a.nav-cta:hover,
.nav-cta:focus-visible,
.nav-list a.nav-cta:focus-visible {
  background-color: var(--text);
  border-color: var(--text);
  color: var(--bg);
}
.nav-cta:focus-visible,
.nav-list a.nav-cta:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.nav-cta::after,
.nav-list a.nav-cta::after { content: none; }
.nav-list a.active::after {
  content: "";
  position: absolute;
  left: 0; bottom: 0;
  width: 100%; height: 2px;
  background: var(--accent);
  border-radius: 2px;
}
/* Shared sliding indicator: replaces the per-link underline above on wide
   screens (JS positions it over whichever link is active), so the active
   state eases smoothly between nav items instead of jump-cutting. */
.nav-indicator {
  position: absolute;
  bottom: 0;
  height: 2px;
  background: var(--accent);
  border-radius: 2px;
  opacity: 0;
  pointer-events: none;
  transition: left 0.35s ease, width 0.35s ease, opacity 0.2s ease;
}
@media (min-width: 861px) {
  .nav-list a.active::after { content: none; }
}
@media (max-width: 860px) {
  .nav-indicator { display: none; }
}
.nav-wrap { display: flex; align-items: center; gap: 24px; }
.theme-toggle {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  border: 1.5px solid var(--border);
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text);
  flex-shrink: 0;
  transition: border-color var(--transition), color var(--transition);
}
.theme-toggle:hover { border-color: var(--accent); color: var(--accent); }
.theme-toggle svg { width: 18px; height: 18px; }
.theme-toggle .icon-moon { display: none; }
:root[data-theme="light"] .theme-toggle .icon-sun { display: none; }
:root[data-theme="light"] .theme-toggle .icon-moon { display: block; }
.nav-toggle {
  display: none;
  flex-direction: column;
  justify-content: center;
  gap: 5px;
  width: 26px;
  /* The three bars are only 16px tall together, well under the 24px minimum
     tap target (WCAG 2.2 AA, 2.5.8). Vertical padding grows the hit area to
     26x32 without moving the bars: .nav-wrap centres its children, so the
     extra height is split evenly above and below. Padding, not height, so
     box-sizing does not eat into the 26px the bars need. */
  padding: 8px 0;
}
.nav-toggle span { height: 2px; width: 100%; background: var(--text); border-radius: 2px; transition: transform var(--transition), opacity var(--transition), background var(--transition); }
.nav-toggle:hover span { background: var(--accent); }

/* ---------- Hero ---------- */
/* Full-viewport hero: the hero content, scroll cue, and tech marquee
   together fill one screen on load, so Work starts below the fold.

   svh, NOT dvh. dvh is the *dynamic* viewport height, so it tracks the
   mobile address bar as it collapses -- the hero grew 78px the instant you
   started scrolling and every section below it lurched down by the same
   amount. svh is the height with the browser chrome fully expanded: a
   constant, so the hero never resizes and nothing below it moves. The cost
   is that a sliver of the next section shows once the bar retracts, which is
   much cheaper than a reflow on the first scroll. 100vh stays as the
   fallback for browsers predating svh. */
.hero-viewport {
  position: relative; /* positioning context for the dot-mesh canvas */
  min-height: 100vh;
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  padding-top: var(--header-h); /* clears the fixed header */
}
/* Faint dotted mesh behind the hero. Purely decorative: aria-hidden in the
   markup and pointer-events:none here, so it never intercepts the CTA or
   shows up for assistive tech. Sits at inset:0 so it can't overflow. */
#dotMesh {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  pointer-events: none;
  z-index: 0;
}
/* Siblings are static, and a positioned element paints above static ones
   regardless of source order, so the content needs its own stacking level. */
.hero-viewport > .hero,
.hero-viewport > .scroll-cue,
.hero-viewport > .tech-marquee { position: relative; z-index: 1; }
.hero { flex: 1; display: flex; align-items: center; }
.hero h1 {
  font-size: clamp(2.4rem, 5vw, 3.4rem);
  margin-bottom: 24px;
  max-width: 544px;
  /* Reserves room for the tallest of the rotating headlines so the lead
     paragraph below doesn't jump as shorter/longer sentences cycle in. */
  min-height: 3.45em;
}
.hero p.lead { font-size: 1.1rem; color: var(--text-body); max-width: 544px; }

.hero-cta {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  background: var(--accent-strong);
  color: #fff;
  font-weight: 700;
  font-size: 0.98rem;
  padding: 16px 30px;
  border-radius: 999px;
  margin-top: 40px;
  box-shadow: 0 8px 24px rgba(var(--accent-strong-rgb), 0.25);
  transition: transform var(--transition), box-shadow var(--transition);
}
.hero-cta:hover { transform: translateY(-2px); box-shadow: 0 12px 32px rgba(var(--accent-strong-rgb), 0.35); }
.hero-cta svg { transition: transform var(--transition); }
.hero-cta:hover svg { transform: translateX(3px); }

/* Sits in normal document flow between the hero and the tech marquee
   (not absolutely positioned), so it always has its own breathing room
   and can never overlap the marquee below it. Purely decorative — a
   visual "more below" cue, not a second CTA — so it takes no pointer
   events and isn't focusable/clickable; the "Explore Work" button is
   the one actual way to trigger the scroll. */
.scroll-cue {
  display: flex;
  justify-content: center;
  width: fit-content;
  margin: 0 auto 28px;
  color: var(--text-faint);
  pointer-events: none;
}
.scroll-cue svg { animation: scroll-cue-bounce 1.8s ease-in-out infinite; }
@keyframes scroll-cue-bounce {
  0%, 100% { transform: translateY(0); opacity: 0.6; }
  50% { transform: translateY(6px); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .scroll-cue svg { animation: none; }
}

/* Blinking cursor for the hero's typewriter effect. Solid (no blink)
   while actively typing/deleting via .typing; blinks during pauses. */
.hero-cursor {
  display: inline-block;
  width: 3px;
  height: 0.85em;
  margin-left: 4px;
  background: var(--accent);
  vertical-align: text-bottom;
  animation: hero-cursor-blink 1s step-end infinite;
}
.hero-cursor.typing { animation: none; opacity: 1; }
@keyframes hero-cursor-blink {
  0%, 49% { opacity: 1; }
  50%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .hero-cursor { animation: none; opacity: 1; }
}

/* ---------- Tech Stack Marquee ---------- */
/* Deliberately quiet: muted color, small type, no accent — a background
   detail sitting between the hero and Work, not competing with either. */
.tech-marquee {
  padding: 36px 0;
}
.tech-marquee-viewport {
  overflow: hidden;
  mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
}
.tech-marquee-track {
  display: flex;
  width: max-content;
  animation: tech-marquee-scroll 36s linear infinite;
  will-change: transform;
}
.tech-marquee-viewport:hover .tech-marquee-track { animation-play-state: paused; }
.tech-marquee-set {
  display: flex;
  align-items: center;
  gap: 40px;
  padding-right: 40px;
  flex-shrink: 0;
}
.tech-item {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-size: 0.8rem;
  font-weight: 500;
  color: var(--text-faint);
  white-space: nowrap;
  opacity: 0.75;
}
.tech-icon { width: 18px; height: 18px; flex-shrink: 0; }
/* The shift is exactly one set's width, so the track lands on an identical
   frame and the restart is invisible. JS sets --marquee-shift in px once the
   webfont has settled; the 50% fallback is the correct value for the two
   copies that ship in the HTML, so the marquee still loops without JS (it
   just needs a viewport no wider than one set, as it did before). */
@keyframes tech-marquee-scroll {
  from { transform: translateX(0); }
  to { transform: translateX(calc(-1 * var(--marquee-shift, 50%))); }
}
@media (prefers-reduced-motion: reduce) {
  .tech-marquee-track { animation: none; }
}

/* ---------- Work ---------- */
.work-filters { display: flex; gap: 12px; margin-bottom: 44px; flex-wrap: wrap; }
.filter-btn {
  padding: 10px 22px;
  border-radius: 999px;
  border: 1.5px solid var(--accent);
  font-weight: 600;
  font-size: 0.88rem;
  color: var(--accent);
  transition: all var(--transition);
}
.filter-btn.active { background: var(--text); color: var(--bg); border-color: var(--text); }
.filter-btn:hover:not(.active) { background: var(--text); color: var(--bg); border-color: var(--text); }
.work-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 28px; }
.work-card { display: block; }
.thumb-block {
  aspect-ratio: 4 / 3;
  background: var(--dark);
  overflow: hidden;
  transition: transform 0.4s ease;
}
.thumb-block.has-image, .project-cover.has-image {
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
}
/* Cards get a resting shadow that deepens and spreads on hover, as if the
   card were physically lifting toward the light rather than just scaling. */
.work-card .thumb-block, .case-card .thumb-block {
  border-radius: 8px;
  box-shadow: var(--shadow-resting);
  transition: transform 0.4s ease, box-shadow 0.4s ease;
}
.work-card:hover .thumb-block, .case-card:hover .thumb-block {
  transform: scale(1.04) translateY(-6px);
  box-shadow: var(--shadow-hover);
}
.work-card .info { padding-top: 18px; }
.work-card .info span { color: var(--text-faint); font-size: 0.78rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
.work-card .info h3 { font-size: 1.1rem; margin-top: 8px; font-weight: 700; }
.work-card .info p { color: var(--text-light); font-size: 0.92rem; margin-top: 8px; }
.work-card.hidden { display: none; }

/* ---------- Case Studies ---------- */
.case-studies { background: var(--surface-alt); }
.case-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 24px; }
.case-card { display: block; }
.case-card .thumb-block { aspect-ratio: 4 / 3; }
.case-card .info { padding-top: 16px; }
.case-card .info span { color: var(--text-faint); font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; }
.case-card .info h3 { font-size: 1rem; margin-top: 6px; font-weight: 700; }
.case-card .info p { color: var(--text-light); font-size: 0.88rem; margin-top: 6px; }

/* ---------- Services ---------- */
/* Horizontal columns (icon above text) on larger screens; the mobile media
   query below reverts to the original stacked rows (icon left of text). */
.service-list { display: flex; flex-direction: row; gap: 40px; }
.service-item { display: flex; flex-direction: column; gap: 20px; flex: 1; }
/* Holds a service icon in a fixed 40x40 box. The box size is unchanged from
   the solid placeholder it replaces, so surrounding layout and spacing are
   untouched. The svg fills the box and its own viewBox handles scaling, so
   icons with different viewBoxes (96, 128 and 512) all land at the same
   rendered size without distortion — preserveAspectRatio defaults to
   xMidYMid meet, which fits and centres rather than cropping or stretching.
   color (not background) drives the artwork, since every icon now paints in
   currentColor. */
.service-bullet {
  width: 40px;
  height: 40px;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text);
}
.service-bullet svg { width: 100%; height: 100%; display: block; }
.service-item h3 { font-size: 1.15rem; font-weight: 700; margin-bottom: 8px; }
.service-item p { color: var(--text-light); font-size: 0.95rem; }

/* ---------- UX Evangelizing ---------- */
.evangelizing-track {
  position: relative;
  aspect-ratio: 16 / 8;
  overflow: hidden;
  /* Same corner as .project-cover and the gallery figures, so the carousel
     reads as part of the same set of framed media. overflow:hidden above is
     what carries the radius down onto the slides. */
  border-radius: var(--radius-md);
}
.evangelizing-arrow {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  z-index: 2;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(255, 255, 255, 0.15);
  color: #fff;
  transition: background var(--transition);
}
.evangelizing-arrow:hover { background: rgba(255, 255, 255, 0.3); }
.evangelizing-arrow:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
.evangelizing-arrow.prev { left: 16px; }
.evangelizing-arrow.next { right: 16px; }
.evangelizing-arrow svg { width: 20px; height: 20px; }
.evangelizing-slide {
  position: absolute; inset: 0;
  background-color: var(--dark);
  opacity: 0;
  transition: opacity 0.5s ease;
  display: flex;
  align-items: flex-end;
  padding: 40px 44px;
}
/* The photo sits on its own element rather than on the slide itself. That is
   what lets the narrow layout below lift the caption out from over the
   picture without having to fight the inline background-image. */
.evangelizing-slide .slide-media {
  position: absolute; inset: 0; z-index: 0;
  background-color: var(--dark);
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
}
/* Readability scrim over the photo, kept as a plain stylesheet gradient
   (no url()) so it can't be affected by the base-URL quirk below. */
.evangelizing-slide::before {
  content: "";
  position: absolute; inset: 0; z-index: 1;
  background: linear-gradient(180deg, rgba(0, 0, 0, 0.05) 0%, rgba(0, 0, 0, 0.2) 45%, rgba(0, 0, 0, 0.8) 100%);
}
.evangelizing-slide.active { opacity: 1; }
.evangelizing-slide .slide-text { position: relative; z-index: 2; max-width: 560px; }
.evangelizing-slide h3 { color: #fff; font-size: 1.3rem; margin-bottom: 10px; }
/* Photo position within its event, so a repeated caption reads as one set of
   pictures rather than as the same entry rendered twice. */
.slide-count { font-size: 0.72rem; font-weight: 600; letter-spacing: 0.06em;
  text-transform: uppercase; opacity: 0.75; margin-left: 8px; vertical-align: middle; }
.evangelizing-slide p { color: #cfcfcf; font-size: 0.92rem; }
/* The dot a person sees is 9px; the button they have to hit is 24x24 (WCAG
   2.2 AA, 2.5.8). The circle moves to ::before so the padded box stays
   invisible, and the row's gap drops to 0 because each button now carries
   its own spacing -- dot centres end up 24px apart instead of 19px, which is
   also what the criterion's spacing exception would have required anyway.
   The focus ring moves to ::before too, so it keeps hugging the dot rather
   than tracing the larger hit box. */
.evangelizing-dots { display: flex; justify-content: center; gap: 0; margin-top: 28px; }
.evangelizing-dots button {
  width: 24px;
  height: 24px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: none;
}
.evangelizing-dots button::before {
  content: '';
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: var(--text-faint);
  transition: background var(--transition), transform var(--transition);
}
.evangelizing-dots button:hover:not(.active)::before { background: var(--text-light); }
.evangelizing-dots button:focus-visible { outline: none; }
.evangelizing-dots button:focus-visible::before { outline: 2px solid var(--accent); outline-offset: 3px; }
.evangelizing-dots button.active::before { background: var(--accent); transform: scale(1.25); }

/* ---------- Contact ---------- */
.contact-grid { display: grid; grid-template-columns: 1.15fr 0.85fr; gap: 64px; align-items: start; }
.contact-form { max-width: 620px; }

.field { margin-bottom: 22px; }
.field label { display: block; font-weight: 600; font-size: 0.92rem; margin-bottom: 8px; color: var(--text); }
.field .optional { font-weight: 400; color: var(--text-body); }
.field input,
.field select,
.field textarea {
  width: 100%;
  font: inherit;
  font-size: 0.98rem;
  color: var(--text);
  background: var(--field-bg);
  border: 1px solid var(--field-border);
  border-radius: var(--radius-sm);
  padding: 13px 15px;
  transition: border-color var(--transition), box-shadow var(--transition);
}
.field textarea { resize: vertical; min-height: 150px; line-height: 1.6; }
.field select {
  appearance: none;
  cursor: pointer;
  padding-right: 40px;
  background-image: linear-gradient(45deg, transparent 50%, currentColor 50%), linear-gradient(135deg, currentColor 50%, transparent 50%);
  background-position: right 20px center, right 14px center;
  background-size: 6px 6px, 6px 6px;
  background-repeat: no-repeat;
}
.field input::placeholder, .field textarea::placeholder { color: var(--text-faint); }
.field input:focus, .field select:focus, .field textarea:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.22);
}
.field input:focus-visible, .field select:focus-visible, .field textarea:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
.field input[aria-invalid="true"],
.field select[aria-invalid="true"],
.field textarea[aria-invalid="true"] { border-color: var(--error); }
.field-error { display: none; margin-top: 7px; font-size: 0.86rem; font-weight: 500; color: var(--error); }
/* Never colour alone: the glyph carries the same signal. */
.field-error::before { content: "\25B2 "; font-size: 0.78em; }
.field.has-error .field-error { display: block; }

/* Honeypot: off-screen rather than display:none, which some bots skip. */
.hp { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; opacity: 0; }

.privacy-note { font-size: 0.86rem; color: var(--text-body); margin: 0 0 24px; max-width: 52ch; }
.submit-btn {
  display: inline-flex; align-items: center; gap: 10px;
  background: var(--accent-strong); color: #fff;
  font-weight: 700; font-size: 0.98rem;
  padding: 16px 34px; border-radius: 999px;
  box-shadow: 0 8px 24px rgba(var(--accent-strong-rgb), 0.25);
  transition: transform var(--transition), box-shadow var(--transition), opacity var(--transition);
}
.submit-btn:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 12px 32px rgba(var(--accent-strong-rgb), 0.35); }
.submit-btn:disabled { opacity: 0.65; cursor: progress; transform: none; box-shadow: none; }
.spinner {
  width: 15px; height: 15px; border-radius: 50%;
  border: 2px solid rgba(255, 255, 255, 0.4); border-top-color: #fff;
  animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

.contact-aside {
  background: var(--surface-alt);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  padding: 32px;
  box-shadow: var(--shadow-resting);
}
.contact-aside h3 { font-size: 1.05rem; margin-bottom: 8px; }
.contact-aside p { color: var(--text-body); font-size: 0.94rem; margin: 0 0 16px; }
.aside-block + .aside-block { margin-top: 26px; padding-top: 26px; border-top: 1px solid var(--border); }
.aside-link {
  display: flex; align-items: center; gap: 9px;
  font-weight: 700; color: var(--accent);
  word-break: break-word;
}
.aside-link + .aside-link { margin-top: 10px; }
.aside-link:hover { text-decoration: underline; text-underline-offset: 3px; }
.aside-link svg { width: 16px; height: 16px; flex-shrink: 0; }
.response-time { display: inline-flex; align-items: center; gap: 8px; font-size: 0.88rem; color: var(--text-body); font-weight: 500; }
.response-time .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--success); flex-shrink: 0; }

/* ---------- Contact result dialogs ---------- */
.modal {
  width: min(460px, calc(100vw - 40px));
  /* The global `* { margin: 0 }` reset above kills the UA stylesheet's
     `dialog { margin: auto }`, which is what centres a modal dialog. Without
     this it pins to the top-left corner and the backdrop click target is
     wrong. */
  margin: auto;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--surface-alt);
  color: var(--text);
  box-shadow: var(--shadow-hover);
  /* Closed dialogs are display:none, so animate only the open state. */
  opacity: 0;
  transform: scale(0.96);
  transition: opacity var(--transition), transform var(--transition);
}
.modal[open] { opacity: 1; transform: scale(1); }
.modal::backdrop { background: rgba(0, 0, 0, 0.85); }
.modal-inner { position: relative; padding: 36px 32px 32px; }
.modal-close {
  position: absolute; top: 14px; right: 14px;
  width: 34px; height: 34px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  color: var(--text-body);
  transition: background var(--transition), color var(--transition);
}
.modal-close:hover { background: var(--dark); color: var(--text); }
.modal-close svg { width: 17px; height: 17px; }
.modal-badge {
  width: 44px; height: 44px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  margin-bottom: 18px;
}
.modal-badge svg { width: 22px; height: 22px; }
.modal-badge.is-ok  { background: var(--success); color: var(--bg); }
.modal-badge.is-bad { background: var(--error);   color: var(--bg); }
.modal-title { font-size: 1.3rem; margin-bottom: 12px; }
.modal-body { color: var(--text-body); font-size: 0.95rem; margin: 0 0 12px; }
.modal-body a { color: var(--accent); font-weight: 600; text-decoration: underline; text-underline-offset: 3px; }
.modal-btn {
  margin-top: 14px;
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--accent-strong); color: #fff;
  font-weight: 700; font-size: 0.94rem;
  padding: 13px 28px; border-radius: 999px;
  transition: transform var(--transition);
}
.modal-btn:hover { transform: translateY(-2px); }
body.modal-open { overflow: hidden; }

/* ---------- About ---------- */
.about .container { display: grid; grid-template-columns: 1.15fr 0.85fr; gap: 64px; align-items: center; }
.about-body h2 { font-size: clamp(1.8rem, 3vw, 2.3rem); margin-bottom: 24px; max-width: 544px; }
.about-body p { color: var(--text-body); margin-bottom: 16px; max-width: 544px; }
/* Square, because the sources are 2048x2048 -- the old 379/365 placeholder
   box would have cropped 3.7% off them for nothing. */
.about-photo {
  position: relative;
  aspect-ratio: 1 / 1;
  /* Both images are absolutely positioned, so there is nothing in flow to size
     this from -- and margin-left:auto opts the item out of grid stretch. Width
     has to be stated or the box collapses to zero. */
  width: 100%;
  max-width: 379px;
  margin-left: auto;
  background: var(--dark);
  overflow: hidden;
  border-radius: var(--radius-md);
}
.about-photo-img {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  /* Frame and source are both 1:1, so cover neither crops nor stretches; it is
     here so an off-square replacement degrades to a crop, never a squash. */
  object-fit: cover;
}
/* Hover swap. Gated on a real pointer: on a touchscreen :hover latches after a
   tap and the illustration would just stick. */
@media (hover: hover) and (pointer: fine) {
  .about-photo-alt { opacity: 0; transition: opacity 0.45s ease; }
  .about-photo:hover .about-photo-alt { opacity: 1; }
}
@media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: reduce) {
  .about-photo-alt { transition: none; }
}
/* Anywhere without a fine pointer the illustration never shows, so keep it out
   of the layout and off the network. */
@media not all and (hover: hover) and (pointer: fine) {
  .about-photo-alt { display: none; }
}

/* ---------- Contact ---------- */
/* Social links, sitting under the About copy now that the dedicated
   Contact section is gone. Wraps rather than overflowing on narrow screens. */
.about-links { display: flex; flex-wrap: wrap; gap: 10px 24px; margin-top: 32px; font-weight: 700; }
.about-links a { color: var(--accent); transition: opacity var(--transition); }
.about-links a:hover { opacity: 0.7; }

/* ---------- Footer ---------- */
.site-footer { padding: 16px 0 64px; }
.footer-row {
  display: flex;
  justify-content: space-between;
  align-items: center;
  flex-wrap: wrap;
  gap: 12px;
  font-size: 0.9rem;
  color: var(--text-light);
}
.footer-row .logo { font-size: 24px; }
/* 23px of line box, one short of the 24px minimum. Padding buys the hit area
   and negative margins hand the space back to the flex row, so the footer
   keeps its original height and the label does not move. */
.back-to-top {
  display: flex;
  align-items: center;
  gap: 8px;
  font-weight: 400;
  color: var(--text-light);
  padding: 4px 0;
  margin-block: -4px;
}
.back-to-top svg { width: 15px; height: 15px; }

/* ---------- Project detail page ---------- */
.project-hero { padding: 168px 0 56px; }
/* Same one-pixel-short line box as .back-to-top. Here nothing else sets the
   height, so the padding is cancelled by the margins: -4px on top and 24-4 on
   the bottom leave the link and the h1 below it exactly where they were. */
.project-back {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  color: var(--accent);
  font-weight: 600;
  font-size: 0.9rem;
  padding: 4px 0;
  margin-top: -4px;
  margin-bottom: 20px;
}
.project-hero h1 { font-size: clamp(2rem, 4vw, 2.8rem); margin-bottom: 18px; max-width: 700px; }
.project-hero p { color: var(--text-light); font-size: 1.05rem; max-width: 640px; }
.project-cover { aspect-ratio: 16/9; background: var(--dark); border-radius: var(--radius-md); margin-bottom: 64px; }
.project-meta {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, max-content));
  justify-content: space-between;
  gap: 32px 40px;
  background: var(--surface-alt);
  border-radius: var(--radius-md);
  padding: 32px;
  margin-bottom: 64px;
}
.project-meta > div { min-width: 0; }
.project-meta div span { display: block; color: var(--text-light); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; }
.project-meta div strong { font-size: 1rem; }
.project-meta div strong a:hover { color: var(--accent); text-decoration: underline; }
.project-sourcing {
  max-width: 760px;
  margin: -32px 0 56px;
  padding-left: 20px;
  border-left: 3px solid var(--border);
  color: var(--text-light);
  font-size: 0.92rem;
}
.project-source-links { list-style: none; margin: 0 0 16px; }
.project-source-links li { margin-bottom: 8px; }
.project-source-links a { color: var(--accent); }
.project-source-links a:hover { text-decoration: underline; }
.project-content { margin-bottom: 64px; }
.project-content h2 { font-size: 1.4rem; margin: 40px 0 16px; }
.project-content h2:first-child { margin-top: 0; }
.project-content p { color: var(--text-light); margin-bottom: 16px; }
.project-content ul { margin: 0 0 16px 20px; list-style: disc; color: var(--text-light); }
.project-content ul li { margin-bottom: 8px; }
.project-gallery { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 64px; }
.project-gallery .thumb-block { aspect-ratio: 4/3; }
.gallery-caption { color: var(--text-light); font-size: 0.85rem; margin-top: 10px; }

/* Inline figures dropped mid-narrative (research diagrams, journey maps,
   etc). The image scales to the full width of the content column and keeps
   its own intrinsic aspect ratio at any viewport size — width is the only
   dimension set, height is left to `auto` rather than fixed or cropped. */
.project-inline-figure { margin: 32px 0; }
.project-inline-image-btn {
  display: block;
  width: 100%;
  border: 0;
  padding: 0;
  background: none;
  font: inherit;
  text-align: inherit;
  border-radius: var(--radius-md);
  overflow: hidden;
  box-shadow: var(--shadow-resting);
  cursor: pointer;
  transition: box-shadow var(--transition);
}
.project-inline-image { display: block; width: 100%; height: auto; }
.project-inline-caption { color: var(--text-light); font-size: 0.85rem; margin-top: 10px; }

/* ---------- Clickable gallery / inline images ---------- */
.lightbox-trigger:hover { box-shadow: var(--shadow-hover); }
button.thumb-block.lightbox-trigger {
  display: block;
  width: 100%;
  border: 0;
  padding: 0;
  font: inherit;
  text-align: inherit;
  cursor: pointer;
}

/* ---------- Lightbox ---------- */
.lightbox {
  position: fixed;
  inset: 0;
  z-index: 1000;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 64px 24px;
}
.lightbox[hidden] { display: none; }
.lightbox-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.85);
  opacity: 0;
  transition: opacity var(--transition);
}
.lightbox.is-open .lightbox-backdrop { opacity: 1; }
.lightbox-content {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  max-width: min(1200px, 100%);
  max-height: 100%;
  opacity: 0;
  transform: scale(0.96);
  /* Transitioned only once .is-ready is added (the image has actually
     finished loading) — see js/main.js. Fading it in as soon as the
     backdrop opens, before the image has a real size to lay out at, is
     what produced the visible jerk/snap this replaces. */
  transition: opacity var(--transition), transform var(--transition);
}
.lightbox-content.is-ready { opacity: 1; transform: scale(1); }
.lightbox-image {
  display: block;
  max-width: 100%;
  max-height: 78vh;
  width: auto;
  height: auto;
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-hover);
}
.lightbox-caption {
  color: var(--text);
  font-size: 0.9rem;
  text-align: center;
  max-width: 640px;
  margin-top: 16px;
}
.lightbox-close {
  position: absolute;
  top: -48px;
  right: 0;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  border: 1px solid var(--border);
  background: var(--surface-alt);
  color: var(--text);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: background var(--transition), transform var(--transition);
}
.lightbox-close:hover { background: var(--dark); transform: scale(1.06); }
body.lightbox-open { overflow: hidden; }
@media (prefers-reduced-motion: reduce) {
  .lightbox-backdrop, .lightbox-content { transition: none; }
}
@media (max-width: 640px) {
  .lightbox { padding: 56px 16px 24px; }
  .lightbox-close { top: -44px; right: 0; }
}
.project-nav {
  border-top: 1px solid var(--border);
  padding-top: 40px;
  padding-bottom: 64px;
  display: flex;
  justify-content: space-between;
  gap: 20px;
}
.project-nav a { display: flex; flex-direction: column; gap: 6px; max-width: 260px; }
.project-nav a:last-child { text-align: right; align-items: flex-end; }
.project-nav span { color: var(--text-light); font-size: 0.8rem; }
.project-nav strong { font-weight: 700; }

/* ---------- Responsive ---------- */
@media (max-width: 1024px) {
  .case-grid { grid-template-columns: repeat(2, 1fr); }
  .project-meta { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 860px) {
  :root { --container-pad: 24px; }
  .nav-list { display: none; }
  .nav-toggle { display: flex; }
  .work-grid { grid-template-columns: repeat(2, 1fr); }
  .about .container { grid-template-columns: 1fr; }
  .about-photo { order: -1; max-width: 420px; margin-inline: auto; width: 100%; }
  .service-list { flex-direction: column; gap: 40px; max-width: 720px; }
  .service-item { flex-direction: row; gap: 24px; align-items: flex-start; }
  .service-item p { max-width: 560px; }
  /* Stack the photo and its caption instead of overlaying them. At this width
     the scrim and text covered roughly the lower half of the picture. Every
     slide shares one grid cell, so the crossfade still works and the track
     takes the height of the tallest caption. */
  .contact-grid { grid-template-columns: 1fr; gap: 40px; }
  .contact-form { max-width: none; }
  .evangelizing-track {
    aspect-ratio: auto;
    display: grid;
    container-type: inline-size;
  }
  .evangelizing-slide {
    position: relative; inset: auto;
    grid-area: 1 / 1;
    flex-direction: column;
    align-items: stretch;
    padding: 0;
    background-color: var(--surface-alt);
  }
  .evangelizing-slide .slide-media {
    position: relative; inset: auto;
    aspect-ratio: 4 / 3;
  }
  /* No photo underneath the words any more, so the scrim has nothing to do. */
  .evangelizing-slide::before { display: none; }
  .evangelizing-slide .slide-text {
    max-width: none;
    flex: 1 1 auto;
    padding: 18px 20px 22px;
  }
  /* The caption is on the surface colour now, not on a darkened photo, so it
     has to take its colours from the palette or it vanishes in light mode. */
  .evangelizing-slide h3 { color: var(--text); }
  .evangelizing-slide p { color: var(--text-body); }
  /* Keep the arrows on the picture rather than over the caption: the media box
     is 75% of the track's width tall, so its centre sits 37.5cqw down. */
  .evangelizing-arrow { top: 37.5cqw; }
  section { padding: 80px 0; }
  .hero { padding: 0; }
}
@media (max-width: 640px) {
  /* Fill the content column rather than sitting in a 420px box inside it. */
  .about-photo { max-width: none; }
  .contact-aside, .modal-inner { padding: 24px; }
  .submit-btn { width: 100%; justify-content: center; }
  .work-grid, .case-grid { grid-template-columns: 1fr; }
  .project-gallery { grid-template-columns: 1fr; }
  .project-meta { grid-template-columns: 1fr 1fr; }
  .project-nav { flex-direction: column; }
  .project-nav a:last-child { text-align: left; align-items: flex-start; }
  .footer-row { flex-direction: column; text-align: center; }
}

/* ---------- Mobile nav open state ---------- */
body.nav-open .nav-list {
  display: flex;
  position: fixed;
  top: var(--header-h); left: 0; right: 0;
  background: var(--bg);
  flex-direction: column;
  padding: 24px;
  gap: 20px;
  border-bottom: 1px solid var(--border);
}
body.nav-open .nav-cta { justify-content: center; width: 100%; }
body.nav-open .nav-toggle span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
body.nav-open .nav-toggle span:nth-child(2) { opacity: 0; }
body.nav-open .nav-toggle span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
