/* ============================================
   Eric Singley — Portfolio
   Design system
   ============================================ */

/* Native page transitions (2026-08-13, prototype). One line, no JS, no
   framework — the browser cross-fades between same-origin page loads
   instead of a hard cut. Purely progressive enhancement: browsers that
   don't support it just navigate normally, nothing to fall back to. */
@view-transition {
  navigation: auto;
}

:root {
  /* Real tokens pulled via getComputedStyle from the live site 2026-08-01 — not guessed */
  --bg: #ffffff;
  --bg-alt: #f4f3ee;
  --ink: #000000;
  --ink-soft: #57534a;
  --ink-hover: #333333; /* 80% grey, print-convention sense (80% black ink) — the .btn-primary hover darken, distinct from --ink-soft (that's for secondary text, not hover states) */
  --line: #e2e0d8;
  --accent: #ec5c39; /* rgb(236,92,57) — real button/link color from live site */
  --accent-ink: #ffffff;
  --white: #ffffff;

  /* Derived, not hand-picked (2026-08-13): these two used to be separately
     chosen hex values that happened to look right next to --accent. Now
     they're computed FROM --accent, so changing the one line above is a
     genuine one-token swap — no stale tint left behind elsewhere. */
  --accent-hover: color-mix(in srgb, var(--accent) 85%, white);
  --accent-tint: color-mix(in srgb, var(--accent) 65%, white); /* lighter accent for small text on dark/photo backgrounds */

  --font-display: "Space Grotesk", "Helvetica Neue", Arial, sans-serif; /* confirmed real H1 font */
  --font-body: "Raleway", "Helvetica Neue", Arial, sans-serif; /* confirmed real body/nav font */

  /* Site margin system (2026-08-13): ONE rationale for left/right spacing,
     used everywhere — header, footer, every section, full-bleed photo
     blocks included. No element gets its own special-case margin.
     --content-max bounds the overall box so it doesn't stretch forever on
     ultra-wide displays; --gutter is the fluid side padding that grows
     smoothly with the viewport up to a sensible cap. Together: content
     expands with the window, then settles into a centered, bounded
     column past a reasonable point, with the same margin value on every
     element at any given width. */
  --content-max: 1680px;
  --gutter: clamp(24px, 5vw, 96px);
  --max: var(--content-max); /* alias kept for existing references */

  --ease: cubic-bezier(0.16, 1, 0.3, 1);
}

* { box-sizing: border-box; }
html { scroll-behavior: smooth; }

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  * { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; }
  /* Belt-and-suspenders for the scroll-driven parallax specifically —
     duration:0.01ms technically still applies since these are explicit,
     not just relying on the blanket rule above. */
  .parallax-drift, .parallax-zoom { animation: none !important; transform: none !important; }
  /* View Transitions (see @view-transition at the top of this file)
     respect prefers-reduced-motion automatically per spec — browsers
     skip the cross-fade and jump straight to the new page. No extra
     rule needed for that one. */
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font-body);
  font-size: 17px;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}

img { max-width: 100%; display: block; }

a {
  color: inherit;
  text-decoration: none;
}

.link-underline {
  color: var(--accent);
  text-decoration: underline;
  text-underline-offset: 3px;
  text-decoration-thickness: 1.5px;
}
.link-underline:hover { color: var(--ink); }

h1, h2, h3, h4 {
  font-family: var(--font-display);
  font-weight: 500; /* real value: 500, not 600 */
  line-height: 1.05;
  letter-spacing: -0.05em; /* real value: -3.824px at 76.48px = ~-0.05em */
  margin: 0;
}

p { margin: 0 0 1em; }
p:last-child { margin-bottom: 0; }

.wrap {
  max-width: var(--max);
  margin: 0 auto;
  padding-left: var(--gutter);
  padding-right: var(--gutter);
}

.eyebrow {
  font-family: var(--font-display);
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--accent);
  display: inline-block;
  margin-bottom: 14px;
}

/* ---------- Header ---------- */

.site-header {
  /* 2026-08-17: header/footer are now <site-header>/<site-footer> custom
     elements (see js/nav.js) instead of native <header>/<footer> tags —
     custom elements have no default display value (they render inline,
     like a <span>) unless CSS says otherwise, so this "display: block"
     isn't decorative, it's load-bearing. Without it, position:sticky
     above does nothing useful on an inline box and the header collapses/
     overlaps page content on scroll. */
  display: block;
  position: sticky;
  top: 0;
  z-index: 50;
  background: rgba(0, 0, 0, 0.92);
  backdrop-filter: saturate(140%) blur(10px);
}

.site-header .wrap {
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 76px;
}

.logo {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 19px;
  letter-spacing: -0.01em;
  color: var(--white);
}

.site-nav {
  display: flex;
  align-items: center;
  gap: clamp(16px, 3vw, 34px);
  font-size: 15px;
  font-weight: 500;
}

.site-nav a {
  position: relative;
  padding: 4px 0;
  color: rgba(255, 255, 255, 0.62);
  white-space: nowrap;
  transition: color 0.2s var(--ease);
}
.site-nav a:hover { color: var(--white); }

/* Current-page indicator: accent-colored text, no underline bar
   (changed 2026-08-14 — Eric didn't want the bar, and it had also been
   the mechanism behind the "orange lines" dropdown bug, since it
   applied to every .site-nav a indiscriminately). Top-level nav items
   (direct children of .site-nav, plus dropdown triggers like "UX
   Portfolio") go bold too so the state still reads clearly without a
   bar; rows inside an open dropdown panel stay regular weight so the
   list itself stays calm/uniform. */
.site-nav a[aria-current="page"] { color: var(--accent); }
.site-nav > a[aria-current="page"],
.nav-dropdown-trigger[aria-current="page"] {
  font-weight: 700;
}

/* ---------- Nav dropdown (prototype 2026-08-13) ----------
   Single column only, by design — Eric doesn't want a multi-column
   mega-nav for a list this size. Sized to fit the worst-case section
   (12 items, Visual Design) fully above the fold: each row is ~41px
   (11px vertical padding + 15px/500 text), 12 rows + panel padding
   lands around 516px tall, which clears a header + ~600px of vertical
   space on any laptop-class display. Comfortable line spacing, but
   deliberately tight — no dividers, no extra chrome between rows. */

.nav-item { position: relative; }

/* Invisible hover bridge across the gap between the trigger and the
   dropdown panel below it. .nav-dropdown sits 14px below .nav-item
   (its margin-top) so the panel visually floats — but .nav-item's own
   hoverable box stops at the trigger text, so moving the mouse straight
   down through that 14px gap briefly leaves ALL hoverable area, CSS
   :hover drops, and the dropdown closes before the cursor arrives.
   This pseudo-element extends .nav-item's hover target through the gap
   (hovering a pseudo-element counts as hovering its host element) with
   zero visual change — found via Eric reporting the dropdown as
   unusable when rolling down into it, 2026-08-14.

   Corrected 2026-08-17: the bridge was only as wide as .nav-item itself
   (i.e. the trigger text — measured ~98px for "UX Portfolio"), but
   .nav-dropdown is centered and 264px min-width, extending ~83px past
   the trigger on both sides. Any diagonal mouse path toward an item on
   either side of the panel (the normal way people move a mouse, not
   straight down) crossed outside the bridge's narrow horizontal range
   while still in the vertical gap, dropped :hover, and closed the menu
   before the cursor arrived — which is exactly what Eric reported as
   "have to click". Widened the bridge to match the dropdown's own
   min-width, centered the same way, so the whole gap under the panel is
   hoverable, not just the sliver under the trigger word.

   Corrected again 2026-08-17 (same day, next message): centering the
   264px-min-width panel under narrow triggers (e.g. "On Design", ~80px)
   made it bleed roughly evenly onto BOTH sides — including leftward,
   under the neighboring nav item to the left. Eric reported this made it
   easy to accidentally drag onto the wrong dropdown while aiming for one
   further along the row. Left-aligning the panel (and this bridge) to
   the trigger's own left edge removes the leftward bleed entirely — the
   panel can still extend rightward past a short trigger, but that's
   bleed in the direction the mouse is already travelling toward, not
   backward into where it came from. */
.nav-item::after {
  content: "";
  position: absolute;
  top: 100%;
  left: 0;
  width: max(100%, 264px);
  height: 14px;
}

.nav-dropdown-trigger {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  white-space: nowrap;
}
.nav-dropdown-trigger::after {
  content: "";
  width: 7px;
  height: 7px;
  border-right: 1.5px solid currentColor;
  border-bottom: 1.5px solid currentColor;
  transform: rotate(45deg) translateY(-2px);
  transition: transform 0.2s var(--ease);
  opacity: 0.7;
}
.nav-item:hover .nav-dropdown-trigger::after,
.nav-item:focus-within .nav-dropdown-trigger::after {
  transform: rotate(225deg) translateY(1px);
}

.nav-dropdown {
  position: absolute;
  top: 100%;
  left: 0;
  transform: translateY(-6px);
  margin-top: 14px;
  min-width: 264px;
  background: var(--ink);
  border-radius: 14px;
  box-shadow: 0 20px 50px -12px rgba(0, 0, 0, 0.55), 0 4px 16px rgba(0, 0, 0, 0.35);
  padding: 10px 0;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity 0.18s var(--ease), transform 0.18s var(--ease), visibility 0.18s;
  z-index: 60;
}

.nav-item:hover .nav-dropdown,
.nav-item:focus-within .nav-dropdown {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
  pointer-events: auto;
}

.nav-dropdown a {
  display: block;
  padding: 11px 22px;
  font-family: var(--font-body);
  font-size: 15px;
  font-weight: 500;
  color: rgba(255, 255, 255, 0.62);
  white-space: nowrap;
  transition: background 0.15s var(--ease), color 0.15s var(--ease);
}
.nav-dropdown a:hover {
  background: rgba(255, 255, 255, 0.08);
  color: var(--white);
}

@media (max-width: 640px) {
  .nav-dropdown { position: static; transform: none; box-shadow: none; margin-top: 8px; padding-left: 12px; }
  .nav-item:hover .nav-dropdown, .nav-item:focus-within .nav-dropdown { display: none; } /* mobile pattern still TBD — hidden for now rather than broken */
}

/* ---------- Hero ---------- */

.hero {
  padding: clamp(64px, 12vw, 130px) 0 clamp(48px, 8vw, 90px);
}

.hero h1 {
  font-size: clamp(40px, 7vw, 84px);
  max-width: 16ch;
}

.hero .lede {
  margin-top: 26px;
  max-width: 46ch;
  /* 2026-08-18: bumped per Eric ("Make the subheads in the hero blocks
     larger. Body text style should do") -- old ceiling (22px) was
     smaller than .prose's own body-copy size (21px, but at a much
     narrower measure/higher weight here), reading closer to a caption
     than a subhead. New ceiling roughly matches .prose's size class,
     scaled up proportionally at every breakpoint rather than just
     raising the max. */
  font-size: clamp(20px, 2.4vw, 26px);
  color: var(--ink-soft);
}

.hero-actions {
  margin-top: 38px;
  display: flex;
  gap: 16px;
  flex-wrap: wrap;
}

/* Universal button set — one typography/sizing spec, applied everywhere.
   Text style (Space Grotesk, 500, 16px, uppercase, 0.04em tracking, pill,
   24px/35.2px padding) is the real spec measured off the live site's own
   buttons. Color is the deliberate deviation Eric asked for: solid black
   (.btn-primary) is now the universal standard, not just a hero-only
   treatment — replaces the earlier coral .btn-accent everywhere. */

.btn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-family: var(--font-display);
  font-weight: 500;
  font-size: 16px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  padding: 24px 35.2px;
  border-radius: 100px;
  border: 1px solid transparent;
  transition: transform 0.25s var(--ease), background 0.25s var(--ease), color 0.25s var(--ease), border-color 0.25s var(--ease);
}

.btn-primary {
  background: var(--ink);
  color: var(--white);
  border-color: var(--ink);
}
.btn-primary:hover { background: var(--ink-hover); border-color: var(--ink-hover); transform: translateY(-2px); }

.btn-ghost {
  background: transparent;
  color: var(--ink);
  border-color: var(--line);
}
.btn-ghost:hover { border-color: var(--ink); transform: translateY(-2px); }

/* Ghost needs light text/border when it sits on a dark full-bleed photo section */
.section-photo .btn-ghost { color: var(--white); border-color: rgba(255,255,255,0.45); }
.section-photo .btn-ghost:hover { border-color: var(--white); }

/* .section-flat = a .section-photo with no actual photo, just its solid
   black fallback background. A solid black .btn-primary would disappear
   against that, so invert to white here — still the same monochrome
   button system, just flipped for contrast. */
.section-flat .btn-primary {
  background: var(--white);
  color: var(--ink);
  border-color: var(--white);
}
.section-flat .btn-primary:hover {
  background: rgba(255,255,255,0.85);
  border-color: rgba(255,255,255,0.85);
}

/* ---------- Section scaffolding ---------- */

section { padding: clamp(56px, 9vw, 110px) 0; }
.section-bordered { border-top: 1px solid var(--line); }

.section-head {
  display: flex;
  justify-content: space-between;
  align-items: flex-end;
  gap: 24px;
  margin-bottom: clamp(32px, 5vw, 56px);
  flex-wrap: wrap;
}

.section-head h2 {
  font-size: clamp(28px, 4vw, 42px);
}

.section-head .sub {
  max-width: 42ch;
  color: var(--ink-soft);
  font-size: 16px;
}

/* ---------- Work grid ---------- */

.work-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: clamp(20px, 3vw, 40px);
}

@media (max-width: 760px) {
  .work-grid { grid-template-columns: 1fr; }
}

.work-card {
  display: block;
  border-radius: 18px;
  overflow: hidden;
  background: var(--white);
  border: 1px solid var(--line);
  opacity: 0;
  transform: translateY(24px);
  transition: opacity 0.6s var(--ease), transform 0.6s var(--ease), box-shadow 0.3s var(--ease), border-color 0.3s var(--ease);
}
.work-card.in-view { opacity: 1; transform: translateY(0); }
.work-card:hover {
  border-color: var(--ink);
  box-shadow: 0 18px 40px -20px rgba(22,20,15,0.25);
}

.work-card .thumb {
  aspect-ratio: 16/10;
  position: relative;
  display: flex;
  align-items: flex-end;
  padding: 20px;
  color: var(--white);
  overflow: hidden;
}
.work-card .thumb::after {
  content: attr(data-caption);
  position: absolute;
  top: 16px; left: 20px;
  font-family: var(--font-display);
  font-size: 12px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  opacity: 0.85;
}

.work-card .thumb-label {
  font-family: var(--font-display);
  font-size: 13px;
  font-weight: 600;
  background: rgba(0,0,0,0.28);
  padding: 6px 12px;
  border-radius: 100px;
  backdrop-filter: blur(6px);
}

.work-card .body { padding: 24px 26px 28px; }

.work-card .tags {
  font-size: 12.5px;
  letter-spacing: 0.03em;
  color: var(--ink-soft);
  margin-bottom: 10px;
  text-transform: uppercase;
  font-weight: 600;
}

.work-card h3 {
  font-size: 22px;
  margin-bottom: 10px;
}

.work-card .excerpt {
  color: var(--ink-soft);
  font-size: 15px;
}

.work-card .arrow {
  display: inline-flex;
  margin-top: 16px;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 14px;
  color: var(--accent);
  align-items: center;
  gap: 6px;
}
.work-card:hover .arrow { gap: 10px; }
.work-card .arrow span { transition: transform 0.25s var(--ease); }

/* Tools & skills line — reused for non-clickable engagement cards
   (Independent Practice page) that have no case-study link target */
.work-card .tools {
  margin-top: 16px;
  font-size: 13px;
  color: var(--ink-soft);
}
.work-card .tools strong { color: var(--ink); font-weight: 600; }

/* thumb color variants (placeholders until real screenshots are dropped in) */
.thumb-neara { background: linear-gradient(135deg, #7b3fe4, #e0602f); }
.thumb-bluecrew { background: linear-gradient(135deg, #1f2a5c, #6f5be0); }
.thumb-amazon { background: linear-gradient(135deg, #16140f, #3a3527); }
.thumb-venbrook { background: linear-gradient(135deg, #234b3f, #4f8f6d); }
.thumb-intuit { background: linear-gradient(135deg, #0f6e5c, #17a385); }
.thumb-kode { background: linear-gradient(135deg, #7a1f1f, #c96b3d); }
.thumb-apple { background: linear-gradient(135deg, #333, #7d7d7d); }
.thumb-walmart { background: linear-gradient(135deg, #0f4fae, #4fa8e0); }

/* ---------- Placeholder image blocks (case study body) ---------- */

.img-placeholder {
  border-radius: 14px;
  border: 1.5px dashed var(--line);
  background: var(--bg-alt);
  color: var(--ink-soft);
  aspect-ratio: 16/10;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  font-size: 13.5px;
  padding: 20px;
  gap: 6px;
}
.img-placeholder .tag {
  font-family: var(--font-display);
  font-weight: 600;
  color: var(--ink);
  display: block;
  margin-bottom: 4px;
  font-size: 14px;
}

/* ---------- About / philosophy ---------- */

.pull-quote {
  font-family: var(--font-display);
  /* 2026-08-18: bumped per Eric ("Heading can be bigger"). Only used on
     About (confirmed via grep -- no other page references .pull-quote),
     so this is a direct edit, not a scoped override. */
  font-size: clamp(30px, 4.6vw, 46px);
  font-weight: 500;
  max-width: 24ch;
  line-height: 1.2;
  margin-bottom: 12px;
}
.pull-quote cite {
  display: block;
  font-family: var(--font-body);
  font-style: normal;
  font-size: 15px;
  color: var(--ink-soft);
  margin-top: 16px;
}

/* 2026-08-17: bumped 20% (17.5px -> 21px) per Eric — body copy read too
   small on the Visual Design pages. .prose is the single shared body-text
   class (case studies, Visual Design split/block/full-center sections,
   hero deks via split-intro/on-dark), so this one change reflows every
   page that uses it. max-width is in ch, which is already relative to
   font-size, so the 62-character line length is preserved automatically
   at the new size — no separate adjustment needed there. p's
   margin-bottom is in em for the same reason (scales with the element's
   own font-size). The paired heading sizes below were bumped the same
   20% alongside this so the heading/body ratio each pattern was tuned at
   doesn't shift. */
.prose {
  max-width: 62ch;
  font-size: 21px;
}
.prose p { margin-bottom: 1.3em; }

.two-col {
  display: grid;
  grid-template-columns: 0.9fr 1.4fr;
  gap: clamp(32px, 6vw, 80px);
  align-items: start;
}
@media (max-width: 820px) {
  .two-col { grid-template-columns: 1fr; }
}

/* ---------- Case study template ---------- */

.cs-hero {
  padding: clamp(56px, 10vw, 100px) 0 clamp(40px, 6vw, 64px);
}

.cs-hero .eyebrow-row {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
  margin-bottom: 22px;
}
.cs-tag {
  font-family: var(--font-display);
  font-size: 12.5px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  border: 1px solid var(--line);
  border-radius: 100px;
  padding: 6px 14px;
  color: var(--ink-soft);
}

.cs-hero h1 { font-size: clamp(34px, 6vw, 62px); max-width: 20ch; }
.cs-hero .dek {
  margin-top: 20px;
  font-size: clamp(17px, 2vw, 20px);
  color: var(--ink-soft);
  max-width: 60ch;
}

/* Two-column text intro (2026-08-17, several Visual Design pages) --
   a handful of these project pages pair their heading with their intro
   prose side by side rather than stacking them the way most .cs-hero
   sections do -- confirmed via getBoundingClientRect on the live pages
   (Two Tone logo, Print wallpaper, the second half of Political
   statement's intro): heading and first paragraph share the same top
   edge, in two roughly-equal columns. Reuses .cs-hero's own h1+.prose
   markup untouched -- just a modifier that turns .wrap into a 2-column
   grid instead of the default stacked flow, so it isn't a new
   text/image component, only a new arrangement of the same two
   elements every .cs-hero already has. align-items:start keeps a
   short heading from being vertically centered against a much taller
   paragraph column. */
.cs-hero.split-intro .wrap {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: clamp(24px, 4vw, 64px);
  align-items: start;
}
.cs-hero.split-intro h1 { max-width: none; }
.cs-hero.split-intro .prose { margin-top: 0; }

/* Solid black full-bleed hero, no photo (2026-08-17, Self promotion) --
   live's version of this page's hero isn't a photo-hero like Hangar 18
   or Political statement, it's a plain solid-black section (confirmed:
   the live section's own class list includes Squarespace's "black"
   theme class, and getComputedStyle found no background-image on it
   anywhere in the section) with the headline in white. Distinct from
   .section-photo (which is built around an absolutely-positioned <img>
   layer) -- this is just .cs-hero with an inverted color scheme, no
   image markup needed at all. */
.cs-hero.on-dark {
  background: var(--ink);
  color: var(--bg);
}
.cs-hero.on-dark h1 { color: var(--bg); }
.cs-hero.on-dark .prose { color: color-mix(in srgb, var(--white) 86%, transparent); }

.cs-block {
  padding: clamp(40px, 7vw, 76px) 0;
  border-top: 1px solid var(--line);
}

/* 2026-08-17: bumped 20% alongside .prose (see comment there) — same
   clamp() scaled up (24/3.4vw/34 -> 29/4.1vw/41), margin-bottom scaled
   with it (20px -> 24px) to keep the same visual gap-to-size ratio.
   .cs-split h2 and .cs-full-center h2 below carry the identical values
   and were moved together so every heading in this pattern stays
   consistent, not just the one Eric happened to flag. */
.cs-block h2 {
  font-size: clamp(29px, 4.1vw, 41px);
  margin-bottom: 24px;
  max-width: 22ch;
}

.cs-block .prose { color: var(--ink); }

.cs-block.on-dark {
  background: var(--ink);
  color: var(--bg);
  border-top: none;
}
.cs-block.on-dark .prose { color: color-mix(in srgb, var(--white) 86%, transparent); }
.cs-block.on-dark h2 { color: var(--bg); }
.cs-block.on-dark .eyebrow { color: var(--accent-tint); }

.cs-list {
  margin: 18px 0 0;
  padding-left: 22px;
}
.cs-list li { margin-bottom: 8px; }

.stat-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
  gap: 24px;
  margin-top: 8px;
}
.stat {
  border-left: 2px solid var(--accent);
  padding-left: 16px;
}
.stat .num {
  font-family: var(--font-display);
  font-size: clamp(30px, 4vw, 44px);
  font-weight: 700;
  display: block;
  line-height: 1;
  margin-bottom: 6px;
}
.stat .label {
  color: var(--ink-soft);
  font-size: 14px;
}

/* ---------- Case study: alternating image/text split (2026-08-15) ----------
   Added while rebuilding neara.html to match the live site's actual layout.
   The live page alternates: white bg + image-right, then #f4f4f3 bg +
   image-left, repeating section to section, verified via direct DOM
   position + computed-background-color extraction against the live page
   (not eyeballed) — see CONVENTIONS.md for the verification method.
   .cs-split replaces .cs-block for any case-study body section that pairs
   prose with an image; .cs-block itself is untouched for anything that
   doesn't need the 2-column treatment. */

.cs-split {
  padding: clamp(56px, 9vw, 96px) 0;
}
/* .bg-tint generalized to .cs-block too (2026-08-17, Intuit TurboCard's
   reskin-compare section) -- same flat #f4f4f3 tint, just no longer
   assumed to only ever sit on a .cs-split. Shared base class, thin
   variant, per §5 -- didn't invent a second tint class for the same
   color. */
.cs-split.bg-tint, .cs-block.bg-tint { background: #f4f4f3; }
/* Accent-tinted variant (2026-08-17, Erotic Museum's Print magazine
   callout) -- a soft wash of the site's one accent color, derived the
   same way --accent-tint already is (§3: color-mix off --accent, never a
   separate hand-picked hex), not a literal "red" class -- keeps the
   token-driven naming rule intact even though the visual result reads as
   a warm red/coral. Light enough (12%) to read as a section-level
   background tint, not a loud color block, consistent with how .bg-tint
   itself is used to differentiate a section without shouting. */
.cs-block.bg-accent-tint { background: color-mix(in srgb, var(--accent) 12%, var(--white)); }
/* Opt-in tighter column gap (2026-08-17, Venbrook's intro block) --
   Eric flagged the spacing there as "weird" and asked for the text
   moved a unit/grid to the left. Most of that weirdness was really
   the source image's own huge built-in transparent padding (fixed
   separately by cropping the asset itself -- see the comment on
   intro-laptop.webp's crop below), but the shared 96px gap plus that
   padding combined to a very wide gap even after the crop. Rather
   than shrinking `.cs-split .wrap`'s gap globally (used by every
   split section on the site), added this as a scoped opt-in, same
   pattern as `.split-30-70`/`.bg-tint` -- roughly half the default
   clamp range. */
.cs-split.tight-gap .wrap { gap: clamp(20px, 3vw, 48px); }

/* Ratio modifiers — opt-in on a section, override the --text-w/--media-w
   defaults set below. Named for what they do (30% text / 70% media),
   same naming spirit as every other opt-in modifier in this file
   (.rounded, .cap-ai, etc.) rather than a raw inline style, so a future
   section that wants the same ratio reuses the class instead of
   retyping the values. Add more steps here as sections need them —
   nothing about the mechanism below is limited to these two.
   Values are `fr` tracks (3fr/7fr), with the unit baked directly into
   the custom property — see the comment on .cs-split .wrap below for
   why that matters, it's not just a style choice. */
.cs-split.split-30-70 .wrap { --text-w: 3fr; --media-w: 7fr; }
.cs-split.split-70-30 .wrap { --text-w: 7fr; --media-w: 3fr; }

/* Column split is variable per-section, not a fixed 50/50 — see
   --text-w/--media-w below (2026-08-17). Most sections don't set them and
   get the 1fr/1fr default; a section whose image needs more (or less)
   room than its text overrides the two custom properties directly. These
   are matched to the *semantic* column (text vs. media), not a raw grid
   track index, so the override gives the same result on an .img-left
   section as an .img-right one — see the grid-template-areas rules below
   for why that works.

   Two real bugs here, worth remembering, both caught by Eric looking at
   the actual rendered page rather than trusting a computed-style check:

   1) First attempt used percentages (30%/70%). Measured correctly in
   isolation, but overflowed the grid container by exactly one gap-width
   once actually rendered — percentage tracks and `gap` don't share a
   sizing basis: 30%+70% of the *full* content width, plus the gap on
   top, added up to more than 100%, so the media column bled off the
   right edge (Eric: "still looks 50/50" — it wasn't the wrong ratio, the
   overflow was pushing the *visible* portion back toward even).

   2) Second attempt kept --text-w/--media-w as bare numbers (3, 7) and
   tried `calc(var(--text-w) * 1fr)` in grid-template-columns, assuming
   fr behaves like a normal calc()-multipliable unit. It doesn't — tested
   directly (a throwaway grid with `grid-template-columns: calc(3 * 1fr)
   calc(7 * 1fr)`), and Chrome collapsed both tracks into one, sizing
   both children to the full container width, stacked on top of each
   other. calc() doesn't support `fr` as an operand in the way it does
   px/%/etc.

   The fix that actually works: bake the unit into the custom property
   itself (`--text-w: 3fr`, not `--text-w: 3`), so `grid-template-columns:
   var(--text-w) var(--media-w)` substitutes a complete, valid `3fr`
   token — no calc() involved. `fr` tracks are defined to divide the
   space *left over after* gaps, which is what avoids bug (1), and a bare
   var() substitution (not a calc() expression) is what avoids bug (2).
   Confirmed via getBoundingClientRect after this fix: media column right
   edge lands exactly at the wrap's own right edge (no overflow), and the
   text:media pixel ratio matches 3:7 exactly, not ~47:53. */
.cs-split .wrap {
  --text-w: 1fr;
  --media-w: 1fr;
  display: grid;
  gap: clamp(40px, 6vw, 96px);
  align-items: center;
}

.cs-split h2 {
  /* 2026-08-17: bumped 20% with .prose/.cs-block h2/.cs-full-center h2 —
     see the comment on .prose above. */
  font-size: clamp(29px, 4.1vw, 41px);
  margin-bottom: 24px;
  /* 2026-08-16: raised from 22ch so "Working with Cursor (and learning
     fast)" reads as one line per Eric's request — measured its actual
     rendered width (708px in a 708px-wide text column at 34px/Space
     Grotesk) rather than guessing a ch value. The other headings in this
     pattern are all either shorter or force their own line break with an
     explicit <br>, so this wider cap doesn't affect them. */
  max-width: 60ch;
}
.cs-split .prose { color: var(--ink); }

/* Rounded corners are the default for every case-study image/text block
   now, not an opt-in (2026-08-17, Eric: "for all image/text blocks...
   the image gets those rounded corners"). This used to be the separate
   `.rounded` modifier, added per-image as Eric flagged each one on
   Bluecrew/Venbrook — now that the pattern's proven out across every
   case study, it's simpler and more consistent to make it the shared
   base treatment and carve out exceptions for the few cases where a
   rounded rect is actually wrong, rather than opting each new image in
   by hand. Value unchanged (50px, see the removed `.rounded` comment's
   history below `.laptop-frame` for how that number was originally set). */
.cs-split-media img,
.cs-split-media video { width: 100%; display: block; border-radius: 50px; }
.cs-split-media figure { margin: 0; }
.cs-split-media figcaption {
  margin-top: 14px;
  font-size: 14px;
  color: var(--ink-soft);
}

/* Text always paints above media — matters for .bleed-left below, where
   the media column intentionally overlaps the text column; harmless
   everywhere else since there's nothing to stack against normally. */
.cs-split-text { position: relative; z-index: 2; grid-area: text; }
.cs-split-media { grid-area: media; }

/* Opt-in (2026-08-17, Illustrations & Infographics' "Kitch with
   character"): when a wide ratio like .split-70-30 pairs a short block of
   copy with a small image, the text's own 62ch cap (see .prose above)
   leaves a big block of dead space between where the text actually ends
   and where its grid column ends -- that's on top of the normal .wrap
   gap, not instead of it, so text and media can end up looking far
   apart even though the ratio/gap system itself is working correctly.
   Shrinking --text-w to close that gap isn't the fix -- it would also
   hand more of the row to --media-w and grow the image back, undoing the
   whole reason a section reaches for split-70-30 in the first place.
   Instead, let the text block's own box hug the column's far edge
   (nearest the media column) rather than stretching to fill the full
   track -- grid's default justify-self:stretch is what causes the box to
   fill the column and leave the copy stranded on the near/outer edge. */
.cs-split-text.hug-media { justify-self: end; }

/* Placement uses named grid areas, not the `order` property (switched
   2026-08-17) — this is what lets --text-w/--media-w above target "the
   text column" and "the media column" as fixed semantic roles regardless
   of which side they render on. With `order`-based placement, swapping
   visual sides also swaps which raw grid track (1 or 2) each element
   lands in, so a column-width override would silently apply to the wrong
   side on .img-left vs .img-right. Named areas sidestep that entirely. */
.cs-split.img-right .wrap {
  grid-template-areas: "text media";
  grid-template-columns: var(--text-w) var(--media-w);
}
.cs-split.img-left .wrap {
  grid-template-areas: "media text";
  grid-template-columns: var(--media-w) var(--text-w);
}

/* three-phone cluster (intro section) — real gap between phones, not
   touching (2026-08-16 correction: was a negative margin overlap before,
   Eric flagged them as too big and touching). */
.cs-split-media.cluster-3 {
  display: flex;
  align-items: flex-end;
  justify-content: center;
  gap: 5%;
}
.cs-split-media.cluster-3 img {
  width: 28%;
}

/* two-up captioned pair (UX big move section) */
.cs-split-media.pair {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 20px;
}
.cs-split-media.pair figcaption { text-align: center; }

/* Intuit TurboCard's before/after screens — corrected same day (first
   pass used a side-by-side .img-pair grid, replaced entirely). Eric's
   actual intent: the two screens are pixel-identical layouts, only
   re-skinned, and putting them side by side makes the eye do the
   comparison work instead of the page proving it. Overlaying them at
   full size and crossfading makes the *alignment itself* the proof —
   nothing on screen moves or shifts, only the color/branding layer
   changes. This only works because the two source images really are
   the same pixel dimensions (2500×1313 each, confirmed before
   building this) — a true crossfade with a mismatched pair would pop/
   jump at every transition instead of reading as one steady image.

   Structure: two images stacked via position:absolute in one box
   sized to their shared real aspect ratio. Walmart is the static base
   layer (always opacity:1); Intuit is the layer that fades in/out on
   top of it — animating just the one top layer is enough to produce
   a two-way crossfade, no JS needed.

   Corrected same day: the images + tags live in a separate
   .reskin-compare-frame div, not directly inside the <figure> itself.
   First version put them straight in <figure class="reskin-compare">
   alongside the <figcaption> -- since both images are position:absolute
   (removed from normal flow), the figcaption ended up as the *only*
   in-flow child, so it rendered at the very top of the figure's box
   instead of pushed below the images, and then got visually painted
   over by the absolutely-positioned images stacking above it. Confirmed
   via getBoundingClientRect before touching the CSS, not guessed: the
   figcaption's rect had the identical top as the images' own rect.
   Moving position:relative/aspect-ratio to an inner wrapper gives the
   figcaption a normal preceding sibling with real flow height, so it
   lands below the frame the way any figcaption normally would. */
.reskin-compare-frame {
  position: relative;
  width: 100%;
  aspect-ratio: 2500 / 1313;
}
.reskin-compare-frame img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.reskin-compare-frame .reskin-overlay {
  animation: reskin-crossfade 7s ease-in-out infinite;
}
@keyframes reskin-crossfade {
  0%, 40% { opacity: 1; }
  50%, 90% { opacity: 0; }
  100% { opacity: 1; }
}
/* Corrected same day, third pass: the corner tag was a dark pill
   sitting on top of the images -- Eric didn't mind the idea, but
   flagged the alignment as "awkward" (it was covering real screen
   content) and asked for plain text above the images instead, with
   the existing static caption pulled up to sit as its subhead, so the
   two read as one unified text block instead of a headline overlaying
   the art and a separate caption below it.

   .reskin-compare-heading holds the two cycling brand-name spans as
   plain in-flow text now, not an absolutely-positioned overlay. They
   still need to occupy the exact same spot and cross-fade, so this
   uses CSS Grid stacking instead of position:absolute: both spans are
   explicitly placed in the same cell (grid-row:1; grid-column:1;),
   which layers them on top of each other the same way absolute
   positioning would, but WITHOUT removing them from the parent's flow
   -- the grid container's row height is still set by its tallest
   stacked child, so there's no repeat of the §25 figcaption-hidden-
   behind-the-frame bug above. (Could have used a fixed-height wrapper
   the same way .reskin-compare-frame does, but grid-stacking needs no
   aspect-ratio/height guess at all, since both lines of text are
   naturally the same height -- simpler and more robust for this
   case.) */
.reskin-compare-heading {
  display: grid;
  justify-items: center;
  text-align: center;
}
.reskin-compare-heading .reskin-tag {
  grid-row: 1;
  grid-column: 1;
  font-family: var(--font-display);
  font-size: clamp(22px, 3vw, 30px);
  font-weight: 600;
  color: var(--ink);
}
.reskin-tag-it { animation: reskin-crossfade 7s ease-in-out infinite; }
.reskin-tag-wm { animation: reskin-crossfade-inverse 7s ease-in-out infinite; }
@keyframes reskin-crossfade-inverse {
  0%, 40% { opacity: 0; }
  50%, 90% { opacity: 1; }
  100% { opacity: 0; }
}
/* The subhead (the old caption text) sits directly under the cycling
   headline, tight spacing between them like any heading+dek pair on
   this site (.cs-hero h1 + .lede, homepage h2 + .dek) -- then a
   bigger gap before the image frame itself, since Eric specifically
   asked for more breathing room there once the text moved above the
   art instead of sitting on top of it. */
.reskin-compare figcaption {
  margin: 6px 0 32px;
  font-size: 15px;
  color: var(--ink-soft);
  text-align: center;
}
/* prefers-reduced-motion is already handled by the blanket rule near
   the top of this file (animation-duration:0.01ms + iteration-
   count:1) -- these keyframes end on a defined, static frame
   (opacity:1 for the Intuit layer/text, opacity:0 for the Walmart
   text) so reduced-motion users land on one settled state, not a
   flicker. */

/* Amazon Flex Debit's theme-color gallery (2026-08-17) -- the live page
   has a native Squarespace full-bleed slideshow cycling through 4 theme
   variants (Prime blue, Orange, Squid Ink light, Squid Ink dark), each a
   single flat 2560x1080 image (4 phone screens montaged together per
   theme, not 4 separate assets to lay out ourselves). Same crossfade
   technique as .reskin-compare-frame above (stacked position:absolute
   layers, animate opacity, no JS), extended from a 2-way to a 4-way
   cycle: one shared @keyframes definition, each image staggered with a
   negative animation-delay one quarter of the cycle apart, so at any
   moment exactly one is fully opaque and the other three are fading in/
   out around it. Full-bleed (edge-to-edge, breaks out of .wrap) since
   that's how it actually runs live -- a section with no .wrap wrapper,
   same way .section-photo's image ignores .wrap too. */
/* Background matched to the image's own grey (2026-08-17), not the
   site's usual #f4f4f3 bg-tint — sampled directly from the padding
   pixels of the source PNGs (canvas getImageData, all 4 theme images:
   rgb(230,230,230)/#e6e6e6, consistently, not a guess) rather than
   editing the images to strip their baked-in background. Matching the
   CSS to the asset was the simpler, lower-risk fix of the two options:
   a one-line color change with zero chance of visible artifacts, versus
   masking/cropping the grey out of 4 separate PNGs (real risk of
   uneven edges or clipped shadow/content, and it'd need redoing if any
   of the 4 assets are ever swapped). */
.theme-gallery {
  background: #e6e6e6;
  padding: clamp(32px, 5vw, 56px) 0;
}
.theme-gallery-frame {
  position: relative;
  width: 100%;
  aspect-ratio: 2560 / 1080;
}
.theme-gallery-frame img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.theme-gallery-frame img {
  animation: theme-crossfade 12s ease-in-out infinite;
}
.theme-gallery-frame img:nth-child(1) { animation-delay: 0s; }
.theme-gallery-frame img:nth-child(2) { animation-delay: -3s; }
.theme-gallery-frame img:nth-child(3) { animation-delay: -6s; }
.theme-gallery-frame img:nth-child(4) { animation-delay: -9s; }
@keyframes theme-crossfade {
  0%, 17%   { opacity: 1; }
  25%       { opacity: 0; }
  92%       { opacity: 0; }
  100%      { opacity: 1; }
}

/* .rounded used to carry `border-radius: 50px` here as an opt-in
   modifier — 2026-08-16: raised from 14px to 50px, unified across every
   use of the class (not just the stat-graphic Eric flagged) by querying
   the live site directly rather than guessing: both Bluecrew images that
   carry a live rounded-corner treatment (the "Timesheets 25%" stat
   graphic and the Design System calendar graphic) use `border-radius:
   50px` on their `.fluid-image-container` wrapper (confirmed via
   getComputedStyle, not CSS source-reading). Folded into the base
   `.cs-split-media img` rule above 2026-08-17 now that rounding is the
   default everywhere, not a per-image opt-in — the `.rounded` class
   itself has been removed from every page's markup since it's now a
   no-op, kept only as dead history here rather than deleted outright
   (same "leave the trail" call as every other superseded rule in this
   file). */

/* Opt-out counterpart to the site-wide default above (2026-08-17):
   Walmart MoneyCard's before/after comparison pair (real full-page
   screenshots of the 2012 vs. 2016 marketing site) reads better with
   square corners -- these are document screenshots, not device/UI
   mockups, and rounding them made the pair look like two separate
   phone-style cards rather than one continuous side-by-side
   comparison. Scoped to `.pair` specifically rather than a blanket
   `.no-round` class with no context, since a future page might want
   this same opt-out on a different `.cs-split-media` variant too. */
.cs-split-media.pair.no-round img { border-radius: 0; }

/* Same opt-out, now used on a plain (non-.pair) .cs-split-media
   (2026-08-17, Mosaic Lizard Theater) -- Eric wants the Othello poster
   square-cornered since it's meant to read as the actual poster
   artwork (the kind of rectangular print you'd frame outside a venue
   or put on a marquee), not a rounded UI-style graphic. This is the
   "a future page might want this same opt-out on a different
   .cs-split-media variant" case the comment above anticipated --
   generalized .no-round to work with or without .pair rather than
   adding a near-duplicate rule. */
.cs-split-media.no-round img,
.cs-split-media.no-round video { border-radius: 0; }
/* video added 2026-08-17 (Kōde Collective's TikTok-ad clip) -- its poster
   frame is a phone-mockup graphic with its own bezel already baked into
   the pixels (transparent PNG background, rounded corners drawn as part
   of the image itself). Rounding the <video> element's own bounding box
   on top of that clips the phone's corners unevenly instead of framing
   it -- same "don't round what's already got its own shape" logic as
   the Othello poster case above, just extended to a video element
   instead of an img. Superseded on Kōde Collective itself by
   `.device-frame` below (2026-08-17, next message) -- left here as the
   general mechanism for any future page that reuses a real phone-mockup
   PNG the way this one originally did. */

/* Solid-color device frame (2026-08-17, Kōde Collective's TikTok-ad video)
   -- the original approach used a real phone-mockup PNG (with the TikTok
   app chrome, icons, and bezel baked into the pixels) as the <video>'s
   poster. That looked good paused, but the mockup was only ever a static
   image: the instant playback starts, all of that chrome vanishes and
   the raw rectangular clip is left in a bare box -- a jarring switch
   Eric flagged after the poster-transparency fix. Rather than keep
   chasing a mockup that can only ever be half-right (present before
   play, gone during it), this drops the fake-UI approach entirely and
   wraps the real, always-consistent <video> element in a plain CSS
   frame instead -- solid dark grey, rounded corners, same look whether
   paused or playing. `--ink-hover` reused rather than a new hand-picked
   hex, since it's already documented as this site's "80% grey" token. */
.device-frame {
  background: var(--ink-hover);
  /* 2026-08-17: reworked per Eric ("Mobile phones are often taller on
     the top & bottom... rounded corners are encroaching on the video").
     Two separate fixes bundled into one pass:
     1) Padding is asymmetric now (46px top/bottom, 16px sides) instead
        of a uniform 14px -- real phones carry noticeably more bezel
        above/below the screen (camera/speaker, chin) than on the sides,
        the first version read as a generic rounded box, not a phone.
     2) The corner-radius math actually matters here: with a padded
        frame, the outer curve reaches `radius` px inward from the
        corner in both directions. The old 34px outer radius against
        only 14px of padding meant the curve reached past the padding
        and cut into the video's own (still-square-ish) corner -- the
        "encroaching" Eric flagged. Fixed by keeping outer-radius <=
        side-padding (30px outer against 16px sides would still overlap
        slightly on its own, so the video's own corner radius makes up
        the rest -- see the concentric-radius comment on the video rule
        below) so the two curves nest cleanly instead of colliding. */
  padding: 46px 16px;
  border-radius: 30px;
  display: inline-block;
}
.device-frame video {
  width: 100%;
  display: block;
  /* Concentric with the frame's own 30px/16px-padding corner
     (30 - 16 = 14) so the two curves share a center instead of the
     outer one overlapping the video's corner. */
  border-radius: 14px;
}

/* photo/gradient-background variant (2026-08-16, added for Bluecrew):
   Neara's tinted sections are a flat color; Bluecrew's live page instead
   uses full-bleed gradient JPGs behind two of its sections. Set the real
   image via inline style="background-image:url(...)" per section rather
   than approximating the gradient in CSS — same "use the real pixels"
   lesson as the Walmart MoneyCard image saga. White text, matching the
   live page's actual contrast treatment on these sections. */
.cs-split.on-photo {
  background-size: cover;
  background-position: center;
  color: var(--bg);
}
.cs-split.on-photo h2 { color: var(--bg); }
.cs-split.on-photo .prose { color: color-mix(in srgb, var(--white) 90%, transparent); }

/* full-width, centered section (2026-08-16, added for Bluecrew's "Selling
   the vision") — not every case-study section is a 2-column split; this
   one is centered prose above a full-width video, no image column at
   all. Kept separate from .cs-split rather than forcing it into the grid. */
.cs-full-center {
  padding: clamp(56px, 9vw, 96px) 0;
  text-align: center;
}
.cs-full-center h2 { font-size: clamp(29px, 4.1vw, 41px); margin-bottom: 24px; }
.cs-full-center .prose { max-width: 62ch; margin: 0 auto; color: var(--ink); }
.cs-full-center .video-frame {
  margin-top: 40px;
  max-width: 900px;
  margin-left: auto;
  margin-right: auto;
  border-radius: 14px;
  overflow: hidden;
  background: var(--ink);
}
.cs-full-center .video-frame img,
.cs-full-center .video-frame video { width: 100%; display: block; }

/* "Design as product development" laptop mockup (2026-08-16): Eric wants
   it bigger and deliberately overlapping the text column on the z-axis —
   the laptop image's own shadow/negative space needs to read close to
   the text, not sit in its own boxed-off column. Pulls left with a
   negative margin and widens past 100% of the grid column to compensate;
   .cs-split-text's z-index:2 above keeps the actual text readable on top
   of the overlap. Left this as an opt-in modifier rather than the
   default — most sections don't want their image invading the text
   column, this one specifically does. */
.cs-split-media.bleed-left {
  position: relative;
  z-index: 1;
  margin-left: clamp(-160px, -14vw, -70px);
  width: calc(100% + clamp(70px, 14vw, 160px));
}

/* Text bleeding toward media (2026-08-17) — the mirror-image case of
   .bleed-left above, applied to text instead of media. Eric's framing:
   "a lot of my images have shadows or negative space that could be
   encroached upon" — text doesn't need to stay strictly inside its own
   grid track if the neighboring image has real transparent margin around
   the actual subject to absorb the overlap. First use: the "Leading the
   full-scale platform redesign" laptop section, where the text column
   (narrowed by .split-30-70) was wrapping tall — widening the box lets
   it reflow to fewer/shorter lines and sit visibly closer to the laptop,
   without moving the laptop itself. Checked the actual image first, not
   just applied by eye: laptop-frame-blank.webp is a transparent-
   background PNG-style webp, and the laptop object's own pixels don't
   start until ~11.6% into the image from its left edge — real negative
   space, not a guess, so text landing in that zone reveals page/section
   background, never overlaps actual laptop imagery. Same clamp values as
   .bleed-left (70-160px) since they're tuned for the same kind of
   image-negative-space budget. `.cs-split-text` already carries
   `z-index: 2` from its base rule, so it paints above `.cs-split-media`
   in the overlap zone automatically — no extra stacking rule needed.
   Two variants, matching .img-left/.img-right, since which way text
   should bleed depends on which side its media column is on. Opt-in,
   not the default — most sections want a clean gap. */
.cs-split-text.bleed-right {
  margin-right: clamp(-160px, -14vw, -70px);
  width: calc(100% + clamp(70px, 14vw, 160px));
}
.cs-split-text.bleed-left {
  margin-left: clamp(-160px, -14vw, -70px);
  width: calc(100% + clamp(70px, 14vw, 160px));
}

/* "Simplifying without losing power" phone screenshot (2026-08-16): full
   column width read as much too large — the live page runs this image
   at roughly half its column's width, not full-bleed like the laptop
   mockup above. */
.cs-split-media.cap-phone {
  max-width: 46%;
  margin: 0 auto;
}

/* Bluecrew intro block composite (2026-08-16): live site crops this image
   into a fixed box (measured 952x566, ~1.682:1) via object-fit:cover; our
   full-bleed source asset is natively 2:1, so rendering it uncropped at
   width:100% read flatter/wider than the live page. Match live's crop. */
/* Hangar 18 final-product photo (2026-08-17): the source image is a
   portrait shot (1500x2000) that includes the cutting mat/ruler below
   the actual map -- rendered at full column width it ran ~944px tall
   against a ~539px text column, way taller than its paired text. Eric:
   "crop it a little shorter so it more closely matches the height of
   the text." The map content itself (paper edge to "BACK DOOR" label)
   only occupies the top ~68% of the frame; the rest is cutting-mat
   desk surface. Cropping to 11:10 with object-position:top keeps the
   entire hand-drawn map intact (nothing useful is lost) while trimming
   the empty mat below, landing much closer to the text column's
   height without being a tight/awkward crop. */
/* 2026-08-18 correction, per Eric on the live page: "crop my finished
   product image so it shows the whole poster... height is ok, maybe
   narrower." The 11/10 ratio above only ever showed the top 68% of the
   1500x2000 source (measured: src_ratio 0.75 / target_ratio 1.1 = 0.68
   fraction of height visible under object-fit:cover) -- but a fresh
   pixel scan down the source's center column shows the map's own
   content (including "BACK DOOR"/"THE CAVE", the labels lowest on the
   poster) actually runs to ~78% of the frame, not 68%. That extra ~10%
   was being cut off the bottom the whole time, not just empty desk
   surface as originally assumed. New aspect-ratio (8/9 ≈ 0.889) shows
   the top 84% of the source height -- past the real content line with
   margin -- and the box is capped narrower (80% width) so the
   resulting height comes out close to the old one instead of growing,
   satisfying "narrower" and "height is ok" as two effects of one
   change rather than two separate knobs. */
.cs-split-media.crop-tight {
  max-width: 80%;
}
.cs-split-media.crop-tight img {
  aspect-ratio: 8 / 9;
  object-fit: cover;
  object-position: top;
}

.cs-split-media.crop-wide img {
  aspect-ratio: 1.682 / 1;
  object-fit: cover;
  /* 2026-08-16 correction: default object-position (50% 50%, dead center)
     cropped straight into the phone's left edge. Live site doesn't use a
     centered crop here — it has a custom Squarespace focal point set on
     this image (measured directly via getComputedStyle().objectPosition
     on the live page: 27.0492% 48.3607%), which crops mostly from the
     empty right-hand side of the laptop instead. Matching that exact
     focal point keeps the phone fully visible while still cropping to
     the same live proportions from task #71 — no longer a contradiction
     between "match live's proportions" and "don't cut off the phone". */
  object-position: 27.0492% 48.3607%;
}

/* "Representing for AI in UX" section (2026-08-16): flat white read as
   too much white-on-white next to its neighbors — reuse the same light
   tint as Neara's .bg-tint sections. Image also ran larger than its live
   counterpart relative to the text column, so cap and center it same as
   .cap-phone above but less aggressively (source image isn't as
   oversized as the phone screenshot was). */
.cs-split-media.cap-ai {
  max-width: 78%;
  margin: 0 auto;
}

/* "Leading the full-scale platform redesign" gif (2026-08-16): Eric asked
   for a laptop behind the browser-chrome animation without touching the
   original gif file.
   Superseded 2026-08-16: first pass used a flat SVG laptop shell — Eric
   flagged it as "kinda weak" and asked for the same real photographic
   MacBook mockup already used on Neara's "Design as product development"
   section (assets/images/neara/laptop-landing.webp) instead. Copied that
   file as-is to assets/images/bluecrew/laptop-frame-real.webp (it still
   has Neara's own landing-page screenshot baked into its screen — that's
   fine, the gif overlay below fully covers it, no cutout/masking needed).
   Screen-region percentages were measured directly from the source image
   (2500x1532px) via a numpy brightness/alpha scan to find the true
   bezel-to-content boundary (866,45)-(2308,998) — NOT eyeballed — then
   confirmed with a Pillow composite before touching the live page: pasting
   a scaled gif frame into that exact box left no sliver of the old Neara
   screenshot visible and no black bezel gap.

   Corrected 2026-08-17: Eric flagged the gif as not quite aligning with
   the laptop. Root cause, found by compositing the actual overlay (not
   just eyeballing the live page): the laptop photo has slight real-world
   perspective/skew — it's not a perfect rectangle — so a flat rectangular
   gif overlay sized to the tightest measured bounding box sat unevenly
   against the bezel: a comfortable black margin on the top/right, but the
   content ran almost edge-to-edge on the left/bottom, visibly encroaching
   on the bezel's rounded corners there. A pixel-level corner scan at all
   four corners (not just one) confirmed the asymmetry — top-left reached
   real content just 6px in, bottom corners had ~18px of natural bezel
   margin in the original screenshot.

   True perspective correction (warping the gif to the photographed
   quadrilateral) isn't practical for an animated gif in CSS. Took Eric's
   two suggestions instead: (1) `laptop-frame-blank.webp` is a new shell
   asset — the same photo with the entire measured screen region flood-
   filled to the bezel's own sampled black (0,0,0), so any residual
   mismatch reads as "more bezel," not a seam against real screenshot
   content; (2) the overlay box itself is pulled in ~2.5% from the
   original tight measurement on every side, clearing the bezel at all
   four corners instead of touching it at the tightest one. Verified with
   a fresh Pillow composite before touching the page — even black margin
   on all four sides, no encroachment — not just assumed from the math.

   Corrected again same day: (1) Eric pointed out real MacBook Pro
   displays don't have rounded corners — the `border-radius: 3%/4%` added
   above was wrong on the facts, not just a style call; removed. (2) He
   also spotted a thin white vertical line on the display's left edge.
   Traced it with a per-column brightness scan of `laptop-frame-blank.webp`
   (not eyeballed): a 2-3px anti-aliased seam at x≈863-865, right at the
   *old* flood-fill boundary (866) — brightness spiked to 134 against a
   ~0-2 black background, a sliver of the original baked-in screenshot
   peeking through just outside the painted rectangle. Fixed by widening
   `laptop-frame-blank.webp`'s flood-filled region 16px further left
   (other three edges unchanged — they didn't show the same boundary-
   adjacent spike when scanned the same way) and regenerating the asset.
   Re-verified with the same column-brightness scan after the fix
   (nothing above the black floor in the 820-900 range near the new
   boundary) and a fresh composite before touching the page again. */
/* Exception to the site-wide rounded-corner default above (2026-08-17):
   this is a photograph of a real MacBook, not a UI screenshot — Eric
   already corrected an earlier version of this exact section for
   applying `border-radius` to a real laptop bezel ("real MacBook Pro
   displays don't have rounded corners," see the corrected-again note
   above), so the new blanket default would reintroduce that same
   mistake if it weren't explicitly turned back off here. Both the shell
   photo and the gif inset it frames stay square. */
.cs-split-media.laptop-frame {
  position: relative;
}
.cs-split-media.laptop-frame .laptop-shell {
  width: 100%;
  display: block;
  border-radius: 0;
}
.cs-split-media.laptop-frame .laptop-screen {
  position: absolute;
  top: 4.49%;
  /* 2026-08-17: left nudged 35.76% (was 36.08%) -- Eric spotted the
     overlay sitting a couple px right of center. Checked precisely
     against the black-filled bezel region in laptop-frame-blank.webp
     rather than eyeballing a correction: left/right margins were 52px vs
     36px (8px off-center), top/bottom were already equal at ~23.8px each
     so only the horizontal position needed adjusting. New value centers
     both margins at 44px. */
  left: 35.76%;
  width: 54.8%;
  height: 59.1%;
  object-fit: cover;
  border-radius: 0;
}

@media (max-width: 820px) {
  /* Single column, image always first — the --text-w/--media-w ratio is
     a desktop-only concern (both stack to full width here regardless of
     what a section set them to). */
  .cs-split.img-right .wrap,
  .cs-split.img-left .wrap {
    grid-template-columns: 1fr;
    grid-template-areas: "media" "text";
  }
}

/* ---------- Project nav (prev/next) ---------- */

.project-nav {
  border-top: 1px solid var(--line);
  padding: 28px 0;
}
.project-nav .wrap {
  display: flex;
  justify-content: space-between;
  gap: 20px;
}
.project-nav a {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(16px, 2.4vw, 22px);
  display: flex;
  align-items: center;
  gap: 10px;
  color: var(--ink-soft);
  transition: color 0.2s var(--ease);
}
.project-nav a:hover { color: var(--accent); }
.project-nav .next { text-align: right; flex-direction: row-reverse; }
/* Sized up substantially 2026-08-17, per Eric's request, after the
   "Previous"/"Next" text was dropped in favor of a bare arrow (see the
   note above `.project-nav .wrap` history) -- the arrow was still
   carrying the old 12px/uppercase/letter-spacing styling tuned for a
   short word, which read as tiny and easy to miss once it was the
   *only* directional indicator instead of a label. text-transform/
   letter-spacing dropped entirely (irrelevant for a single glyph);
   font-family switched from --font-body to --font-display to match
   the arrow's new role as a real visual element, not a small label. */
.project-nav .dir {
  font-family: var(--font-display);
  font-size: clamp(28px, 4vw, 44px);
  font-weight: 600;
  color: var(--ink-soft);
  line-height: 1;
  /* 2026-08-18: corrected again, per Eric on the live deployed page --
     the 6px nudge from 2026-08-17 overshot the other way and read as
     too low ("looks like you moved them down"). Re-tuned directly
     against the live Vercel deployment this time (not localhost) by
     rendering the real arrow+label markup at translateY values from
     -4px to +6px side by side, scaled 3x, and comparing each against
     "Walmart MoneyCard"'s actual x-height (top of the "a"/"m") rather
     than eyeballing a single value in isolation. -2px is the one that
     lines the arrow's shaft up with that x-height line without reading
     high (like -4px) or low (like the old +6px). Still eyeballed, not
     computed from font metrics -- there's no DOM-measurable "x-height"
     for a rendered glyph -- but this pass at least compared multiple
     candidates against the real label text directly instead of tuning
     one value against a mental estimate. */
  transform: translateY(-2px);
}

/* ---------- Footer ---------- */

.site-footer {
  /* display: block for the same reason as .site-header above — <site-footer>
     is a custom element, not a native block-level <footer>. */
  display: block;
  background: var(--ink);
  color: var(--white);
  padding: 56px 0 40px;
}
.site-footer .wrap {
  display: flex;
  justify-content: space-between;
  align-items: flex-end;
  gap: 24px;
  flex-wrap: wrap;
}
.site-footer h2 {
  font-size: clamp(26px, 4vw, 40px);
  max-width: 16ch;
  color: var(--white);
}
.footer-contact { text-align: right; }
.footer-contact a { display: block; margin-top: 6px; }
.footer-contact .email { color: var(--accent); font-weight: 600; }
.copyright {
  margin-top: 40px;
  padding-top: 24px;
  font-size: 13px;
  color: rgba(255, 255, 255, 0.5);
  display: flex;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 8px;
}

/* ---------- reveal on scroll ---------- */

.reveal {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 0.7s var(--ease), transform 0.7s var(--ease);
}
.reveal.in-view { opacity: 1; transform: translateY(0); }

@media (max-width: 640px) {
  .site-nav { gap: 14px; font-size: 14px; }
  .footer-contact { text-align: left; }
  .site-footer .wrap { flex-direction: column; align-items: flex-start; }
}

/* ---------- Full-bleed dark sections (shared base) ----------
   .section-photo is the base: black bg, full-bleed absolutely-positioned
   image, left-anchored text block. .hero-photo and .section-block are
   both instances of it — they only override sizing/type-scale, never
   the structural bits. Use both classes together in markup, e.g.
   class="section-photo hero-photo" or class="section-photo section-block". */

.section-photo {
  position: relative;
  display: flex;
  align-items: center;
  background: #000;
  /* overflow: clip, not hidden — hidden establishes its own CSS "scroll
     container" per spec (even though nothing here is interactively
     scrollable), which hijacks animation-timeline:view() on descendant
     images so it tracks this box's own [nonexistent] scroll instead of
     the real page scroll. clip still visually crops the oversized
     parallax image the same way, without that side effect. */
  overflow: clip;
}
.section-photo img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 50% 50%; /* per-photo override: .focal-custom below */
}
/* 2026-08-17: Neara's hero photo (pexels-kindelmedia-7148409.jpg) has
   people's faces positioned high in the frame — a centered crop pushed
   the visible window down past their faces. Checked the live page's
   actual object-position via getComputedStyle rather than eyeballing a
   fix: 49.0976% 22.4014% (near-centered horizontally, anchored much
   closer to the top vertically). Other case-study heroes (Bluecrew,
   homepage) were checked the same way and are genuinely centered on
   live — 50/50 stays the shared default, this is a per-image opt-in,
   not a change to every hero photo on the site. */
.section-photo img.focal-custom {
  object-position: 49.0976% 22.4014%;
}
/* Intuit TurboCard's hero (intuit-on-tablet-2.jpg) — checked live
   rather than assumed centered, per the same rule as .focal-custom
   above. Live value: 18.4703% 26.4016%. Own class since it's a
   different per-image value than Neara's. */
.section-photo img.focal-intuit {
  object-position: 18.4703% 26.4016%;
}
.section-photo .wrap {
  /* Left/right margin and max-width are NOT overridden here anymore —
     .section-photo .wrap inherits the same base .wrap rule as every
     other section, header, and footer (see the site margin system in
     :root). Only what's unique to sitting on top of a photo lives here:
     stacking above the image, white text, and width:100% — .section-photo
     is a flex container, and flex items shrink to their content's width
     by default instead of filling the row, which would silently break
     .wrap's max-width/auto-margin centering without this. */
  position: relative;
  z-index: 2;
  width: 100%;
  color: var(--white);
}
.section-photo h1,
.section-photo h2 {
  color: var(--white);
  font-size: clamp(40px, 6vw, 76.48px); /* real measured value at desktop: 76.48px */
  max-width: 18ch;
}
.section-photo .btn { margin-top: 32px; }

/* Tinted variant: for a source graphic that's already a complete,
   self-contained composition (its own background, framing, negative space
   baked in — e.g. a device mockup) rather than a croppable photograph.
   First (only, so far) use: Walmart MoneyCard, whose one available asset
   is a laptop mockup with no standalone photography to use full-bleed
   instead — see CONVENTIONS.md.

   Three earlier attempts got this wrong (all 2026-08-15, each caught by
   Eric looking at the real rendered result, not a description of it):
   1) object-fit: cover on the raw image cropped the bottom off the
      laptop.
   2) object-fit: contain, sized to fill the full section box, made the
      rendered size depend on the section's aspect ratio vs. the image's —
      wrong regime at the tested width: tiny laptop, visible top/bottom
      bars. Widening the image's canvas to fix an unrelated seam issue
      made this worse.
   3) A fixed-height, auto-width boxed image (own aspect ratio, no
      stretching) fixed the sizing, but reintroduced a visible rectangular
      seam on all four sides where the image's own blue met the section's
      separate --tint-bg CSS color — a hand-approximated gradient can
      never match real pixels exactly (Eric: "remove the existing blue
      background... compose the whole background as 1 gradient").

   The actual fix: back to object-fit: cover filling the full section
   (same treatment as every other case-study image — no special
   positioning), but on a source image with real, generous padding added
   above and below the laptop first. Padding was added the same way the
   left-edge seam got fixed earlier: sample the image's own top/bottom
   edge pixels and tile them outward with PIL, blurring the seam, so the
   extra canvas is real image content continuing the actual vignette —
   not a separate CSS color that has to match it. Because there's no
   separate background color involved anywhere in the render, there's
   nothing for a seam to form against. The padding amount (300px top,
   450px bottom, chosen because the source image's original margins were
   uneven — 495px bottom vs. 639px top out of 3000px — so bottom needed
   more to end up balanced) is generous enough that a cover crop at
   realistic desktop widths still keeps the whole laptop in frame; only
   an unusually wide monitor window would crop a sliver off the bottom
   edge, versus the whole laptop being at risk before. --tint-bg stays as
   an inert fallback (the image covers 100%/100% always, so it never
   actually shows) rather than load-bearing. */
.section-tinted {
  background: var(--tint-bg, #000);
}
.section-tinted img {
  object-position: 100% 50%;
}
/* Walmart MoneyCard case-study hero (2026-08-17): the case-study hero box
   is a much wider/shorter banner than the homepage tile this same source
   image (thumb-walmart.webp) was originally padded for, so the shared
   50% vertical centering above crops into the laptop itself at wide
   desktop viewports -- Eric: "I was showing the bottom of the laptop
   (the keyboard & body), but you've cropped both the top & bottom."
   This case-study page uses its own re-cropped copy (hero.webp, cropped
   tighter to the laptop than the homepage source) with vertical bias
   toward the bottom, so the keyboard/body stays in frame even in the
   worst-case wide/short box, at the cost of cropping more off the top of
   the screen (least important part -- it's blank canvas above the
   browser chrome) rather than the bottom. Own class, not a change to
   the shared rule above, since the homepage tile still needs 50%. */
.section-tinted img.focal-walmart-cs {
  object-position: 100% 85%;
}

/* Hero variant: fixed viewport-relative height, since it's the first thing seen */
.hero-photo {
  height: clamp(560px, 82vh, 880px);
}

/* Case-study hero standard (2026-08-16): about half the homepage hero's
   height. Eric asked for this on Neara specifically, then to make it the
   standard for "the hero block on these pages" — apply to every
   case-study hero (Bluecrew, Amazon Flex Debit, and any future one),
   never to the homepage/UX-portfolio hero, which keeps the taller
   .hero-photo height on its own. */
.hero-photo.cs-hero {
  height: clamp(280px, 41vh, 440px);
}
.hero-photo .wrap {
  padding-top: clamp(48px, 8vw, 80px);
  padding-bottom: clamp(48px, 8vw, 80px);
}
.hero-photo .eyebrow { color: var(--accent-tint); }
.hero-photo .lede {
  color: rgba(255,255,255,0.82);
  font-family: var(--font-body);
  font-weight: 500;
}

/* Content-block variant: consulting intro / project modules. Padding still
   drives spacing, but min-height now pins every .section-block to the
   same floor so the row of case-study modules reads as one consistent
   height instead of each one being exactly as tall as its own content
   (checked against the live site 2026-08-15: its modules run ~477-482px
   at 1680px viewport, except Walmart MoneyCard's, which measures 564px
   because its headline wraps to 2 lines there — that gap, combined with
   the Walmart block's object-fit: contain image, is what made its laptop
   mockup look small and adrift. Eric asked for uniform height across all
   of them, taller is fine — 620px covers the live site's tallest case
   comfortably). Content still vertically centers within it via
   .section-photo's flex/align-items: center. */
.section-block {
  padding: clamp(56px, 9vw, 100px) 0;
  min-height: 620px;
}
.section-block h2 { max-width: 20ch; }
.section-block .dek {
  color: var(--white);
  font-family: var(--font-display);
  font-weight: 500;
  letter-spacing: -0.03em;
  font-size: clamp(20px, 2.8vw, 32.8px);
  margin-top: 8px;
  /* 45ch: raised from an original 30ch, then 40ch, as longer slash-
     separated tag lists were added and kept wrapping with a single
     orphan word on their own line (Bluecrew, Amazon Flex Debit, then
     Venbrook's "UX Design / Marketing Design / Design System
     Management" — the longest one yet, needing ~42ch). 45ch covers all
     current tag lists on one line at desktop widths with some headroom,
     while Neara/Kōde/Apple/Walmart's much shorter deks stay well short
     of full container width regardless. */
  max-width: 45ch;
}

.work-card .thumb { position: relative; }
.work-card .thumb img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.work-card .thumb::before {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(0deg, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.05) 45%);
  z-index: 1;
}
.work-card .thumb-label { position: relative; z-index: 2; }
.work-card .thumb::after { z-index: 2; }

.cs-img {
  border-radius: 14px;
  overflow: hidden;
  border: 1px solid var(--line);
  background: var(--bg-alt);
}
.cs-img img { width: 100%; display: block; }
.cs-img figcaption {
  padding: 12px 16px;
  font-size: 13px;
  color: var(--ink-soft);
  border-top: 1px solid var(--line);
}
figure.cs-img { margin: 0; }

.cs-block.on-dark .cs-img { border-color: color-mix(in srgb, var(--white) 20%, transparent); background: color-mix(in srgb, var(--white) 5%, transparent); }
.cs-block.on-dark .cs-img figcaption { color: color-mix(in srgb, var(--white) 65%, transparent); border-color: color-mix(in srgb, var(--white) 20%, transparent); }

.img-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 20px;
}
@media (max-width: 640px) { .img-row { grid-template-columns: 1fr; } }

.swatch-grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 14px;
  margin-top: 24px;
}
@media (max-width: 640px) { .swatch-grid { grid-template-columns: repeat(2, 1fr); } }
.swatch-grid .cs-img { margin: 0; }

/* ---------- Motion prototypes (2026-08-13) ----------
   Blur-up image loading + two native scroll-driven parallax variants.
   All progressive enhancement — nothing here breaks or needs a fallback
   if a browser doesn't support animation-timeline:view(). */

.img-blur-load {
  filter: blur(18px);
  transform: scale(1.04);
  opacity: 0.55;
  transition: filter 0.9s var(--ease), transform 0.9s var(--ease), opacity 0.9s var(--ease);
}
.img-blur-load.is-loaded {
  filter: blur(0);
  transform: scale(1);
  opacity: 1;
}

@supports (animation-timeline: view()) {
  @keyframes parallax-drift {
    from { transform: translateY(-8%) scale(1.18); }
    to   { transform: translateY(8%) scale(1.18); }
  }
  @keyframes parallax-zoom {
    from { transform: scale(1.28); }
    to   { transform: scale(1); }
  }
  /* Variant A: image drifts opposite scroll direction — classic depth cue */
  .parallax-drift {
    animation: parallax-drift linear;
    animation-timeline: view();
    animation-range: cover 0% cover 100%;
  }
  /* Variant B: image settles from a slow zoom-in as the section scrolls
     through view — no drift, a "coming into focus" feel instead */
  .parallax-zoom {
    animation: parallax-zoom linear;
    animation-timeline: view();
    animation-range: cover 0% cover 100%;
  }
  /* .img-blur-load already sets a transform via its own transition;
     parallax needs to own transform once loaded, so hand off cleanly */
  .img-blur-load.parallax-drift, .img-blur-load.parallax-zoom { transition-property: filter, opacity; }
}

/* ---------- Visual Design grid (2026-08-17) ---------- */
/* New component, not a reuse of .cs-split-media -- the live
   /visual-design landing page presents its 12 projects as a dense,
   edge-to-edge 2-column grid of square cover images (checked directly:
   no gap between cells, no rounded corners, no visible caption text on
   the grid itself -- the project name is only the link's accessible
   text). Square corners here are a deliberate match to the live page,
   not an oversight -- same reasoning as the Walmart before/after pair's
   .no-round (§35): rounding a tessellated, edge-to-edge grid leaves
   visible slivers at every seam, and reads wrong for what's meant to
   look like one continuous wall of work. */
.visual-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
}
.visual-grid a {
  display: block;
  aspect-ratio: 1 / 1;
  overflow: hidden;
}
.visual-grid img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  transition: transform 0.4s ease;
}
.visual-grid a:hover img {
  transform: scale(1.03);
}

/* Visual Design project pages: a simple vertically-stacked photo gallery
   inside the content-width .wrap (not full-bleed -- checked directly on
   the live pages, e.g. /visual-design/erotic-museum: every gallery image
   sits within the same content column as the intro text above it, just
   full-width within that column, not edge-to-edge browser width like
   the case-study hero photos). Rounded corners per the site-wide default
   (§32) since these are real photos/graphics, not the document-style
   screenshots that earned the Walmart pair's .no-round opt-out. */
.vd-gallery {
  display: flex;
  flex-direction: column;
  gap: clamp(24px, 4vw, 48px);
}
.vd-gallery img,
.vd-gallery video {
  width: 100%;
  display: block;
  border-radius: 50px;
}

/* 2-column square-crop grid, for project pages whose own live gallery is
   a genuine 2-up grid rather than a single stacked column (2026-08-17,
   Whittier Museum) -- confirmed via getBoundingClientRect on the live
   page's own gallery images: two fixed columns, ~830x831 square cells
   (object-fit:cover), a small ~6-8px gap between cells. The live page
   itself has zero border-radius here, but Eric asked for rounded
   corners on this rebuild's version anyway (2026-08-17, next message)
   -- a deliberate departure from live-fidelity, not a missed check;
   §32's site-wide 50px default applies instead. Distinct from
   .visual-grid (which wraps <a> links for the landing page's 12-project
   index and has zero gap) and .vd-gallery (single-column stack, the
   default for most other project pages) -- reuse whichever of the three
   actually matches a given page's live layout rather than defaulting to
   one for every project. */
.vd-grid-2col {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 8px;
}
.vd-grid-2col img {
  width: 100%;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  display: block;
  border-radius: 50px;
}

/* Aligned 2-column grid for project pages whose images are a natural,
   uneven mix of portrait/landscape -- images keep their own proportions
   (no forced square crop) but sit in a real CSS grid so rows line up
   left-to-right in a "neat grid" rather than interlocking (2026-08-17,
   Hangar 18 -- replaces an earlier offset-masonry version built as two
   independent flex columns of differing total height; Eric flagged that
   as an undiscussed design liberty and asked for a neat grid instead).
   `align-items: start` is the key property: without it, CSS grid
   stretches every cell to match the tallest image in its row, which
   would distort the shorter image's aspect ratio. With it, each image
   keeps its natural height and the grid still aligns both columns row
   by row. Same 8px gap and 50px rounding as .vd-grid-2col (Eric asked
   for the two galleries to match on those two properties) -- the
   difference is purely uniform-crop-grid vs. natural-aspect grid, not
   gap/rounding. */
.vd-grid-natural {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 8px;
  align-items: start;
}
.vd-grid-natural img {
  width: 100%;
  height: auto;
  display: block;
  border-radius: 50px;
}

/* Click-to-fullscreen lightbox (2026-08-17, Mosaic Lizard Theater's
   4-poster grid) -- Eric: "make those click to full screen. Add a
   little UI to that row so people can really see them. Maybe a
   back/next kind of thing?" Built as a generic, reusable pattern
   rather than a one-off: any gallery container just needs the
   `.lightbox-gallery` class added (see js/main.js) and every `img`
   inside it is auto-wired -- no per-image markup, no per-page JS.
   Scoped to Mosaic Lizard Theater's `.vd-grid-4col` for now since
   that's what was asked for, but it'll work unchanged on any other
   gallery component (`.vd-grid-2col`, `.vd-grid-3col`,
   `.vd-grid-natural`, `.vd-masonry-offset`, `.vd-gallery`) the moment
   the class is added there too.

   Two pieces: (1) `.lightbox-trigger`, the button main.js wraps around
   each gallery `<img>` at runtime -- gives every thumbnail a hover
   affordance (dim overlay + a 4-corner "expand" mark built from CSS
   gradients, not an emoji/icon-font glyph, to stay consistent with the
   site's plain typographic style) and makes it a real, keyboard-
   focusable button rather than a bare click handler on a non-interactive
   img. (2) `.lightbox-overlay`, the single fullscreen viewer instance
   main.js injects once per page (reused across every gallery on that
   page, not duplicated per gallery) -- large centered image, prev/next
   arrows, a close button, and a "2 / 4" counter. Kept in the DOM
   permanently and toggled via opacity + pointer-events (not
   display:none) so the fade transition actually runs. */
.lightbox-trigger {
  position: relative;
  display: block;
  width: 100%;
  margin: 0;
  padding: 0;
  border: 0;
  background: transparent;
  cursor: zoom-in;
  text-align: left;
}
.lightbox-trigger::before {
  content: "";
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0);
  border-radius: inherit;
  transition: background 0.2s ease;
  pointer-events: none;
}
.lightbox-trigger:hover::before,
.lightbox-trigger:focus-visible::before {
  background: rgba(0, 0, 0, 0.32);
}
.lightbox-trigger::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 34px;
  height: 34px;
  transform: translate(-50%, -50%);
  opacity: 0;
  transition: opacity 0.2s ease;
  pointer-events: none;
  /* four corner brackets, drawn with layered gradients -- a plain
     "expand to fullscreen" mark with no external asset or font glyph */
  background:
    linear-gradient(to right, #fff 2px, transparent 2px) 0 0 / 11px 11px no-repeat,
    linear-gradient(to bottom, #fff 2px, transparent 2px) 0 0 / 11px 11px no-repeat,
    linear-gradient(to left, #fff 2px, transparent 2px) 100% 0 / 11px 11px no-repeat,
    linear-gradient(to bottom, #fff 2px, transparent 2px) 100% 0 / 11px 11px no-repeat,
    linear-gradient(to right, #fff 2px, transparent 2px) 0 100% / 11px 11px no-repeat,
    linear-gradient(to top, #fff 2px, transparent 2px) 0 100% / 11px 11px no-repeat,
    linear-gradient(to left, #fff 2px, transparent 2px) 100% 100% / 11px 11px no-repeat,
    linear-gradient(to top, #fff 2px, transparent 2px) 100% 100% / 11px 11px no-repeat;
}
.lightbox-trigger:hover::after,
.lightbox-trigger:focus-visible::after {
  opacity: 1;
}

.lightbox-overlay {
  position: fixed;
  inset: 0;
  z-index: 999;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(8, 8, 8, 0.95);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s ease;
}
.lightbox-overlay.is-open {
  opacity: 1;
  pointer-events: auto;
}
.lightbox-frame {
  max-width: min(92vw, 1200px);
  max-height: 88vh;
  display: flex;
  align-items: center;
  justify-content: center;
}
.lightbox-frame img {
  display: block;
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: 88vh;
  border-radius: 12px;
  box-shadow: 0 24px 64px rgba(0, 0, 0, 0.55);
}
.lightbox-close,
.lightbox-nav {
  position: absolute;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  border: 1px solid rgba(255, 255, 255, 0.25);
  background: rgba(255, 255, 255, 0.06);
  color: var(--white);
  cursor: pointer;
  transition: background 0.2s ease, border-color 0.2s ease;
}
.lightbox-close:hover,
.lightbox-nav:hover {
  background: rgba(255, 255, 255, 0.16);
  border-color: rgba(255, 255, 255, 0.5);
}
.lightbox-close {
  top: 24px;
  right: 24px;
  width: 44px;
  height: 44px;
  font-size: 18px;
}
.lightbox-nav {
  top: 50%;
  transform: translateY(-50%);
  width: 52px;
  height: 52px;
  font-size: 26px;
  line-height: 1;
}
.lightbox-prev { left: 24px; }
.lightbox-next { right: 24px; }
.lightbox-count {
  position: absolute;
  bottom: 24px;
  left: 50%;
  transform: translateX(-50%);
  font-family: var(--font-display);
  font-size: 13px;
  letter-spacing: 0.04em;
  color: rgba(255, 255, 255, 0.7);
}
@media (max-width: 640px) {
  .lightbox-nav { width: 42px; height: 42px; font-size: 20px; }
  .lightbox-prev { left: 10px; }
  .lightbox-next { right: 10px; }
  .lightbox-close { top: 14px; right: 14px; }
}

/* Tight (zero-gap) variant of .vd-grid-2col, for pages whose live grid
   cells actually touch (2026-08-17, Political statement) -- measured
   directly on the live page: two 700x701 square columns with left
   edges at 140/840 and no gap between them (840 - (140+700) = 0),
   unlike Whittier's ~8px gap on the same 2-column-square pattern.
   Keeps the base .vd-grid-2col rounding/crop rules, just zeroes the
   gap -- not a different crop or corner treatment, only spacing. */
.vd-grid-2col.no-gap { gap: 0; }

/* Square-corner opt-out for .vd-grid-2col (2026-08-17, Kōde Collective) --
   measured on the live page: 700x701 square cells, zero gap, zero
   border-radius -- unlike Political statement's version of this same
   2-col-square pattern (§ above), which kept the site's rounding by
   Eric's own request. No override requested here, so this rebuild
   follows live fidelity by default: square corners. Same
   base-class-plus-modifier approach as every other opt-out in this
   file rather than a new one-off grid class. */
.vd-grid-2col.no-round img { border-radius: 0; }

/* Square-corner opt-out for .vd-gallery (2026-08-17, Kōde Collective's
   naming/typography walkthrough video) -- it's a screen recording, not
   product photography, and live shows it with zero rounding (same
   document-style-content-doesn't-get-rounded call as the Walmart
   reskin screenshots, §32's carve-out). */
.vd-gallery.no-round img,
.vd-gallery.no-round video { border-radius: 0; }

/* 3-across square row, edge-to-edge (2026-08-17, Two Tone logo) --
   live's 3 logo images are natively square (261x261) and sit with
   effectively zero gap between them (measured left edges 67/582/1098
   against a 516px cell width -- a 1-2px rounding artifact, not a real
   gap). No rounding here deliberately: with cells touching edge to
   edge, rounded corners on adjoining cells would read as odd notches
   rather than a clean grid -- live itself has none either. */
.vd-grid-3col {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 0;
}
.vd-grid-3col img {
  width: 100%;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  display: block;
}

/* 4-across square grid with real gap, for the two poster-collection
   pages (2026-08-17, National Forest Posters & Mosaic Lizard Theater)
   -- measured on both live pages: 4 equal square columns (~327px and
   ~320px respectively) with a noticeably wider gap than the tight
   product-photo grids elsewhere on the site, ~30-39px rather than 8px.
   Splitting the difference at a round 32px rather than hand-tuning two
   near-identical values per page. Rounded corners per the site-wide
   default (§32) -- unlike the edge-to-edge 3-col grid above, there's
   real breathing room here for the curve to read cleanly. */
.vd-grid-4col {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 32px;
}
.vd-grid-4col img {
  width: 100%;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  display: block;
  border-radius: 50px;
}

/* Genuine offset 2-column masonry -- two independent flex columns of
   differing total height, images at their own natural proportions
   (2026-08-17, Self promotion). This is the same technique built and
   then reverted for Hangar 18 (renamed .vd-grid-natural there once it
   turned out to be an undiscussed liberty) -- but for Self promotion
   the live page's own gallery genuinely *is* this interlocking,
   uneven-column layout: confirmed via getBoundingClientRect, left
   column (evolution/roots/bambi) and right column (explosion/van) end
   at different total heights. Reusing the flex-column construction
   here is deliberate, not a relapse into the earlier mistake --
   matching what's actually live, not defaulting to it. Gap measured
   at ~77-78px both directions, wider than the tight product-photo
   galleries -- this page reads more like a loose gallery wall. */
.vd-masonry-offset {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 64px;
}
.vd-masonry-offset-col {
  display: flex;
  flex-direction: column;
  gap: 64px;
}
.vd-masonry-offset img {
  width: 100%;
  display: block;
  border-radius: 50px;
}

/* ---------- On Design (2026-08-17) ----------
   Eric signed off and asked me to build this section autonomously,
   favoring existing conventions/live layout when in doubt. Live's
   "Design Thinking" landing page (/design-thinking -- confirmed via the
   homepage nav href, same discovery pattern as Kōde Collective's real
   URL) is nothing like the Visual Design index: no cards, no thumbnails,
   just 5 links stacked and centered, each set at the exact same type
   size the site already uses for its biggest headline (measured via
   getComputedStyle on the live page: 76.48px/500/-0.05em -- identical
   to .section-photo h1's clamp ceiling, §already in :root type scale).
   Reusing that value instead of inventing a new heading size. */
.essay-index {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  padding: clamp(48px, 9vw, 108px) 0 clamp(72px, 11vw, 132px);
}
.essay-index a {
  display: block;
  font-family: var(--font-display);
  font-weight: 500;
  font-size: clamp(32px, 6vw, 76.48px);
  line-height: 1.35;
  letter-spacing: -0.05em;
  color: var(--ink);
  transition: color 0.2s var(--ease);
}
.essay-index a:hover { color: var(--accent); }

/* Grey-tint hero for a text-only essay intro (2026-08-17, NASA Style
   Guide) -- live's version of this page's .cs-hero sits on the site's
   usual #f4f4f3 .bg-tint flat grey rather than white/photo, confirmed
   via getComputedStyle on the live section background. Generalizing
   .bg-tint onto .cs-hero the same way it was already generalized from
   .cs-split to .cs-block (§ "bg-tint generalized to .cs-block too")
   rather than inventing a third near-identical tint class. */
.cs-hero.bg-tint { background: #f4f4f3; }

/* Long-form essay body text (2026-08-17, the 4 text-only On Design
   posts -- Dark mode, AI & gratitude, Desire paths, Voice interfaces).
   Widened from .prose's default 62ch to 74ch, matching About's own
   long-form prose column (the only other place on the site with this
   much unbroken running text) rather than the tighter measure used in
   two-column case-study splits. */
.essay-prose { max-width: 74ch; }

/* Inline pull-quote inside a running essay (2026-08-17, Dark mode's
   "That type reads best which is read most.") -- distinct from the
   site's existing .pull-quote, which is a large standalone intro quote
   (About's Eames line) sitting above the body copy. This one is a
   quote *within* the essay, so live sets it smaller and indented
   rather than full display size: measured via getComputedStyle on the
   live page (42.88px/500, indented ~82px from the body copy's own left
   edge). Kept as its own class instead of reusing .pull-quote since
   the live values genuinely differ (About's is ~64px, unindented). */
.essay-prose blockquote {
  margin: 1.3em 0 1.3em 40px;
  font-family: var(--font-display);
  font-size: clamp(24px, 3.2vw, 42.88px);
  font-weight: 500;
  line-height: 1.15;
  letter-spacing: -0.03em;
}

/* 4-column natural-aspect gallery for NASA Style Guide's 14 scanned
   manual pages -- same natural-aspect-ratio idea as .vd-grid-natural
   (Hangar 18) but 4 columns wide, matching live's own row width
   (measured: 4 columns of ~370px each at desktop, confirmed via
   getBoundingClientRect). Own class rather than a .vd-grid-natural
   modifier since this page isn't part of the Visual Design section and
   shouldn't inherit its later opt-in modifiers (.no-round etc.) by
   accident. */
/* 2026-08-18 correction, per Eric on the live page ("look at my live
   page and match the layout"): a uniform grid puts every image at the
   same width per row, but live's actual gallery is a flowing masonry
   (confirmed earlier via getBoundingClientRect -- the last 2 images
   rendered at 459px wide vs. 370px for the rest, which a strict
   grid-template-columns can't reproduce since every cell in a given
   row shares one column width). Switched to CSS multi-column flow
   (column-count/break-inside:avoid), which lets each image keep its
   own natural size within a column instead of being forced into
   uniform grid cells -- much closer to how Squarespace's own stacked
   gallery block actually lays these out. */
.essay-grid-4col {
  column-count: 4;
  column-gap: 8px;
}
.essay-grid-4col img {
  width: 100%;
  height: auto;
  display: block;
  border-radius: 50px;
  break-inside: avoid;
  margin-bottom: 8px;
}
@media (max-width: 820px) {
  .essay-grid-4col { column-count: 2; }
}

/* 2-column masonry gallery, same multi-column technique as
   .essay-grid-4col above but 2 columns instead of 4 (2026-08-18,
   Woodworking). Live's actual gallery (Squarespace's stacked/fluid-
   engine gallery block, images at their native aspect ratio, a mix of
   solo full-width and 2-up rows depending on each photo's own
   proportions) isn't reproducible with a fixed grid-template-columns
   for the same reason documented above for NASA Style Guide -- multi-
   column flow is the right tool whenever a live gallery's row widths
   genuinely vary photo-to-photo rather than being uniform. */
.vd-grid-masonry-2col {
  column-count: 2;
  column-gap: 8px;
}
.vd-grid-masonry-2col img {
  width: 100%;
  height: auto;
  display: block;
  border-radius: 50px;
  break-inside: avoid;
  margin-bottom: 8px;
}
