/* ============ PORTFOLIO DESIGN LANGUAGE (v2) ============
   Neutral, calm, premium. Project branding (Everpure palette,
   Familjen Grotesk, Pure Orange) lives ONLY inside .pi scope. */

:root {
  --bg: #FFFFFF;
  --ink: #111827;
  --soft: #4B5563;
  --line: #E5E7EB;
  --accent: #2563EB;
  --success: #10B981;
  --surface: #F9FAFB;
  /* Added for theme support — each captures an EXISTING hardcoded shade used repeatedly
     across style.css (card backgrounds, three distinct secondary-text grays, the repeated
     hover-border tint, and the primary button's hover shade) so light mode's rendered
     output is byte-for-byte identical to before this token existed; only the dark-theme
     override block below gives these a second value. --maxw/--radius are layout tokens,
     deliberately left out of the dark override — theme changes color, not geometry. */
  --card: #FFFFFF;
  --muted: #9CA3AF;
  --muted2: #6B7280;
  --text2: #374151;
  --border-hover: #d6dae0;
  --btn-hover-bg: #000;
  --nav-bg: rgba(255,255,255,0.9);
  --grid-line: rgba(17,24,39,0.035);
  --hnm-color: rgba(17,24,39,0.05);
  --hnm-stroke: rgba(17,24,39,0.07);
  /* Deliberately the SAME value in both themes (no dark-theme override below) — a small
     number of components (the "dark" card in About's alternating dark/accent/light grid,
     the current-step badge in .journey) are styled dark specifically to contrast against
     their own surface-colored siblings, not against the page background. If they instead
     tracked --ink (which correctly flips light in dark mode for everything that DOES need
     to invert, like body text and .btn), they'd flip to light exactly when their siblings
     flip dark, inverting the visual hierarchy they're meant to create. */
  --fixed-dark: #111827;
  --fixed-dark-text: #fff;
  --fixed-dark-muted: #9CA3AF;
  --maxw: 1120px;
  --radius: 12px;
}
/* ---------- Dark theme ---------- */
/* Applied via `data-theme="dark"` on <html> (set by script.js, persisted to localStorage,
   and re-applied by a tiny inline script in each page's <head> before first paint — see
   the comment there — so there is no light-flash on reload). Every value here is a
   deliberate dark-mode counterpart of the token immediately above; nothing else in the
   stylesheet needed to change; --accent and --success are intentionally omitted (repeated
   here would be redundant) since the existing blue/green already have enough contrast
   against a near-black page and the brief explicitly asks to retain them as-is. */
:root[data-theme="dark"] {
  --bg: #0B0F17;
  --ink: #EDEFF2;
  --soft: #9AA4B2;
  --line: #232B3A;
  --surface: #10151F;
  --card: #131A29;
  --muted: #7C8794;
  --muted2: #8890A0;
  --text2: #C7CCD4;
  --border-hover: #2E3849;
  --btn-hover-bg: var(--ink);
  --nav-bg: rgba(11,15,23,0.85);
  --grid-line: rgba(255,255,255,0.045);
  /* The decorative "BHASKAR BHANJA" background text was invisible in dark mode: it was
     ink-colored (near-black) at 5%/7% opacity, which reads as a very faint dark mark on
     white but disappears entirely against a near-black page. Flipped to white-based at a
     similar (slightly higher, to compensate for how much less white-on-black registers at
     very low alpha than dark-on-white does) opacity — still clearly secondary to the real
     H1, not a second heading. Text, size, position, animation and speed are untouched;
     only these two color values change. */
  --hnm-color: rgba(255,255,255,0.045);
  --hnm-stroke: rgba(255,255,255,0.065);
}
/* Scoped, short, and only on the properties that actually repaint on toggle — never
   `all`, which would also animate layout-affecting properties and risk exactly the kind
   of reflow/shift the brief explicitly rules out. Applied broadly (not enumerated
   per-selector) because the set of elements that read a theme token is large and already
   fully captured by these four properties; nothing here can move geometry since neither
   `background-color`, `color`, `border-color` nor `fill` participates in layout. */
body, header, .nav, .btn, .card, .cs-card, .showcase-item, .panel, .cap, .user-card,
.decision, .resume-block, .hid-card, .stat, .logo-tile, .tool, .testi, footer,
.theme-toggle, .nav ul, a {
  transition: background-color 0.25s ease, color 0.25s ease, border-color 0.25s ease, fill 0.25s ease;
}
@media (prefers-reduced-motion: reduce) {
  body, header, .nav, .btn, .card, .cs-card, .showcase-item, .panel, .cap, .user-card,
  .decision, .resume-block, .hid-card, .stat, .logo-tile, .tool, .testi, footer,
  .theme-toggle, .nav ul, a {
    transition: none;
  }
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; overflow-y: scroll; }
/* Disables the browser's own scroll-anchoring — its automatic scroll-position adjustment
   whenever content changes size. Off the shelf, this fights the internal navigation swap
   directly: replacing a tall page's <main> with a much shorter one while scrolled deep
   shrinks the scrollable height out from under the current position, and the browser's own
   anchoring logic (silently, without a script-visible cause) intervenes as part of that
   same reflow — a moving target that landOnTarget's own scroll-to-top correction (see
   script.js) can end up racing rather than reliably winning against. Scroll position after
   every internal navigation is already fully owned and explicitly set by that function
   (top of page, or a specific in-page anchor) — the browser guessing at a "preserve what
   the user was looking at" position was never a want here, on any page, so disabling it
   site-wide removes the actual competing mechanism instead of only reacting to it. */
html { overflow-anchor: none; }
/* Set only for the initial paint of a cross-page arrival at #work/#contact (see the
   inline script in index.html's <head>) — makes the browser's native "scroll to
   fragment on load" instant instead of animating smoothly from the page top. Removed
   by script.js once that initial landing has settled, restoring normal smooth
   scrolling for every later interaction (in-page nav clicks, manual scrolling). */
html.hash-instant { scroll-behavior: auto !important; }

/* Internal page-to-page navigation transitions. Two things were tried and removed before
   this: the native cross-document View Transitions API (genuinely activated, per the
   pageswap event, but no perceptible improvement in the user's own browser), and a
   body-wide exit-fade-then-real-navigate (still a hard document reload underneath, so the
   browser's own unload/reload mechanics — full re-parse, a fresh reveal-on-load cycle,
   momentary loss of scroll/DOM state — were still what the user actually felt, no matter
   how the moment right at the click was dressed up). This is a same-document content swap
   instead (see script.js's "Smooth internal page-to-page navigation" block): the
   destination's HTML is fetched and ready in memory BEFORE any visual change begins, then
   only <main> is replaced — header/footer, already identical markup on every page, are
   never touched at all, so nothing about them can jump regardless of destination. `main`,
   not `body`, is what fades, so the persistent header stays completely stable through
   every transition. */
/* Cross-page swap entrance (see script.js swapTo/finishSwap): the incoming <main> is
   inserted with this class applied, then it's removed a frame later so the content settles
   into place. Deliberately transform-only, never opacity — <main> sits directly over
   `body`'s own background (see body{background:var(--bg)} below), so animating its opacity
   exposes that background as a full-viewport flash the instant main isn't fully opaque. A
   previous version of this mechanism used opacity and produced exactly that flash; this
   replacement can't reproduce it because opacity is never touched. */
main.main-enter { transform: translateY(6px); }
main { transition: transform 180ms cubic-bezier(0.16,1,0.3,1); }
@media (prefers-reduced-motion: reduce) { main { transition: none; } main.main-enter { transform: none; } }

body {
  font-family: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
  background: var(--bg); color: var(--ink);
  line-height: 1.65; font-size: 16px;
  -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
}

h1, h2, h3 { font-weight: 600; letter-spacing: -0.022em; line-height: 1.15; }
a { color: var(--accent); text-decoration: none; transition: color 0.15s ease; }
a:hover { text-decoration: none; text-underline-offset: 3px; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }

/* Modern arrow-CTA treatment for standalone body/content links (e.g. "More about me →").
   Deliberately a new class, not a change to the bare `a`/`a:hover` rule above — that base
   rule is what styles footer link hover across every page (no dedicated `footer a`
   selector exists), so changing it would touch the footer, which stays locked. No
   underline anywhere, including on hover — `text-decoration:none` is repeated on the
   :hover rule specifically to override the base `a:hover{text-decoration:underline}`,
   which is what caused the earlier double-line bug (the custom underline layered on top
   of the browser's own). Negative margin matching the padding keeps the resting text in
   its normal inline position — the padding only becomes visible as the hover background
   chip, never as a layout shift. The translateX(3px) motion matches the site's own
   existing pattern for this exact kind of link (.card .read, .showcase-cta). */
.body-link {
  color: var(--accent); text-decoration: none; display: inline-flex; align-items: center;
  padding: 3px 7px; margin: -3px -7px; border-radius: 6px; background: transparent;
  transition: transform 0.2s cubic-bezier(0.16,1,0.3,1), background-color 0.2s ease;
}
.body-link:hover { transform: translateX(3px); text-decoration: none; background-color: rgba(37,99,235,0.08); }
img { max-width: 100%; display: block; }
.wrap { max-width: var(--maxw); margin: 0 auto; padding: 0 28px; }
.wrap.wide { max-width: 1320px; }

::selection { background: #DBEAFE; }

/* ---------- Motion system ---------- */
.reveal { opacity: 0; transform: translateY(18px); transition: opacity 0.7s cubic-bezier(0.16,1,0.3,1), transform 0.7s cubic-bezier(0.16,1,0.3,1); }
.reveal.in { opacity: 1; transform: none; }
.reveal.d1 { transition-delay: 0.08s; } .reveal.d2 { transition-delay: 0.16s; }
.reveal.d3 { transition-delay: 0.24s; } .reveal.d4 { transition-delay: 0.32s; }
@media (prefers-reduced-motion: reduce) {
  .reveal { opacity: 1; transform: none; transition: none; }
  html { scroll-behavior: auto; }
}

/* ---------- Header: floating pill navigation ---------- */
header {
  position: sticky; top: 0; z-index: 50;
  background: transparent; border-bottom: none;
  padding: 18px 0;
  pointer-events: none; /* let clicks pass through the transparent header band outside the pill */
}
.nav {
  display: flex; align-items: center; gap: 10px; width: fit-content;
  margin: 0 auto; padding: 6px; padding-right: 8px;
  background: var(--nav-bg); backdrop-filter: blur(14px) saturate(1.4); -webkit-backdrop-filter: blur(14px) saturate(1.4);
  border: 1px solid var(--line); border-radius: 999px;
  box-shadow: 0 14px 34px -18px rgba(17,24,39,0.18), 0 2px 10px -4px rgba(17,24,39,0.06);
  pointer-events: auto;
}
/* Dark mode only: --line (#232B3A) sits too close in brightness to the dark page background
   (#0B0F17) for the pill's own outline to read against page content behind/around it —
   scoped to just this one border, not the shared --line token itself, since --line is also
   the border color for many other components (cards, dividers, the brand avatar) that
   aren't part of this request and stay as they are. */
:root[data-theme="dark"] .nav { border-color: rgba(255,255,255,0.16); }
/* Brand mark: a small circular avatar only — the name lives in the hero marquee, not the capsule */
.nav .brand { flex: 0 0 auto; width: 34px; height: 34px; border-radius: 50%; overflow: hidden; border: 1px solid var(--line); }
.nav .brand:hover { text-decoration: none; }
.nav .brand img { width: 100%; height: 100%; object-fit: cover; object-position: 50% 15%; display: block; }
.nav ul { position: relative; display: flex; gap: 2px; list-style: none; align-items: center; }
.nav ul a { display: inline-flex; align-items: center; position: relative; z-index: 1; color: var(--soft); font-size: 13.5px; font-weight: 500; padding: 8px 14px; border-radius: 999px; transition: color 0.18s ease, background-color 0.18s ease; white-space: nowrap; }
.nav ul a:not(.active):hover { color: var(--ink); background: var(--surface); text-decoration: none; }
.nav ul a.active { color: #fff; font-weight: 600; }
/* One shared sliding highlight — links stay transparent; this single element travels
   underneath the active link via transform/width, never removed/recreated on change. */
.nav-active-pill {
  position: absolute; top: 0; left: 0; height: 100%; width: 0;
  background: var(--accent); border-radius: 999px; z-index: 0; pointer-events: none;
  transform: translate3d(0,0,0);
  transition: transform 0.5s cubic-bezier(.22,1,.36,1), width 0.5s cubic-bezier(.22,1,.36,1);
}
/* Decorative cursor-trailing orb — positioned via JS translate3d each frame, never affects layout. */
.cursor-orb {
  position: fixed; top: 0; left: 0; width: 16px; height: 16px;
  margin-left: -8px; margin-top: -8px;
  border-radius: 99px; background: var(--accent);
  pointer-events: none; z-index: 9999; will-change: transform;
}
@media (hover: none), (pointer: coarse) { .cursor-orb { display: none; } }
.nav .btn { padding: 8px 16px; border-radius: 999px; }
/* ---------- Theme toggle ---------- */
/* Two instances of the same control share this styling (see markup comment in each
   page's <head>): `.theme-toggle-desktop`, a direct child of `.wrap.nav` visible only
   above the mobile breakpoint, and `.theme-toggle-mobile` inside the mobile dropdown's
   own <ul> — the closed mobile PILL itself is untouched, so this never changes its
   locked width/height/content. Both share `.theme-toggle` for the icon-swap logic and a
   shared JS handler (script.js queries `.theme-toggle` generically). Mapping is deliberately
   the reverse of the usual convention: Moon shows while the theme is Light (a tap switches
   to Dark), Sun shows while the theme is Dark (a tap switches to Light) — both icons live in
   the DOM at all times, click just flips which one is opacity:1 via the [data-theme]
   selector below, instant, same as always.
   Two earlier rounds tried to make the SWITCH itself animate (a transition on the attribute
   change, then a one-shot class-triggered animation on click) — both solved the wrong
   problem. What's actually wanted is an always-on idle animation: whichever icon is
   currently showing should visibly, continuously move on its own, the way the footer's
   location/phone/email icons already do, with the click still just an instant swap of which
   icon that is. So the moon's body/accent animate continuously whenever [data-theme] is NOT
   "dark" (moon showing, Light theme), and the sun's rays/body animate continuously whenever
   it IS "dark" (sun showing, Dark theme) — pure CSS, gated purely on that attribute, nothing
   added or removed by JS, nothing to start/stop on click. Motion is deliberately restrained
   (a few degrees of rotation, a small scale breathe, a slow twinkle on the moon's accent
   dot) — recognizable sun/moon shapes with a bit of life in them, not a spinner and not a
   bounce. */
.theme-toggle {
  display: inline-flex; align-items: center; justify-content: center;
  border: none; background: transparent; color: var(--soft); cursor: pointer;
  padding: 0; flex: none; position: relative;
}
.theme-toggle:hover { color: var(--ink); }
.theme-toggle .icon-sun, .theme-toggle .icon-moon {
  position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
  /* overflow:hidden (the SVG default) is deliberate here, not an oversight: it clips any
     animated frame to this element's own viewBox-mapped 16x16 box, which is the icon's
     existing visible footprint — nothing about the button size, position, or icon dimensions
     changes. This is the hard containment boundary for the idle sun/moon animation below: no
     rotation/scale amplitude tuning can ever push a frame past the icon's own edges. */
  overflow: hidden;
}
.theme-toggle .moon-body { transform-origin: 12px 12px; }
.theme-toggle .moon-accent { transform-origin: 6.2px 7.5px; }
.theme-toggle .sun-rays {
  transform-origin: 12px 12px; opacity: 0; transform: scale(0.3) rotate(-20deg);
}
.theme-toggle .sun-body {
  transform-origin: 12px 12px; opacity: 0; transform: scale(0.5);
}
:root[data-theme="dark"] .theme-toggle .moon-body { opacity: 0; transform: scale(0.4); }
:root[data-theme="dark"] .theme-toggle .moon-accent { opacity: 0; transform: scale(0.3); }
:root[data-theme="dark"] .theme-toggle .sun-rays { opacity: 1; transform: scale(1) rotate(0deg); }
:root[data-theme="dark"] .theme-toggle .sun-body { opacity: 1; transform: scale(1); }

/* Always-on idle animation: a continuous SUN <-> MOON crossfade/morph loop, inspired by
   icons8-sun-24.apng's own sun-fades/rays-retract -> crescent-grows-in -> holds ->
   reverses cycle, slowed down to a premium ~3s. This replaced two earlier, wrong-shaped
   attempts: a click-triggered swap (the icon never moved on its own) and a small
   independent sway/pulse on whichever icon was showing (technically live but not a
   Sun<->Moon transformation — just a wiggling static icon). What's actually wanted is the
   transformation itself, continuously, forever, regardless of theme or clicks.
   Mechanism: every part (sun-rays+sun-body as one unit, moon-body, moon-accent) runs the
   SAME shared timeline — rest (0-15%), recede to a small rotated/scaled-down "gone" state
   (by 35%), hold gone (35-65%), grow back to rest (by 85%), hold rest (85-100%) — but each
   element is assigned either the "Dominant" keyframes (rest most of the time, briefly
   recedes to let the other icon take over) or the "Recessive" keyframes (the exact
   phase-inverted mirror: gone most of the time, briefly appears during that same 35-65%
   window). Sun gets Dominant and Moon gets Recessive in Dark theme (so it reads as
   Sun -> Moon -> Sun); the assignment flips in Light theme (Moon -> Sun -> Moon) — this is
   what keeps "Light shows Moon / Dark shows Sun" true at any glance while the icon is
   always mid-cycle. Because every "gone" extreme is a SHRINK (scale down toward 0), not a
   grow, every keyframe here has strictly less reach from centre than the already-safe rest
   state — so this is, if anything, more conservative for the overflow:hidden containment
   above than the previous version, never less. 3s cycle, ease-in-out, infinite. */
:root[data-theme="dark"] .theme-toggle .sun-rays,
:root[data-theme="dark"] .theme-toggle .sun-body { animation: sunDominant 3s ease-in-out infinite; }
:root[data-theme="dark"] .theme-toggle .moon-body { animation: moonRecessive 3s ease-in-out infinite; }
:root[data-theme="dark"] .theme-toggle .moon-accent { animation: moonAccentRecessive 3s ease-in-out infinite; }
:root:not([data-theme="dark"]) .theme-toggle .moon-body { animation: moonDominant 3s ease-in-out infinite; }
:root:not([data-theme="dark"]) .theme-toggle .moon-accent { animation: moonAccentDominant 3s ease-in-out infinite; }
:root:not([data-theme="dark"]) .theme-toggle .sun-rays,
:root:not([data-theme="dark"]) .theme-toggle .sun-body { animation: sunRecessive 3s ease-in-out infinite; }
@keyframes sunDominant {
  0%, 15% { opacity: 1; transform: scale(1) rotate(0deg); }
  35%, 65% { opacity: 0; transform: scale(0.25) rotate(-25deg); }
  85%, 100% { opacity: 1; transform: scale(1) rotate(0deg); }
}
@keyframes sunRecessive {
  0%, 15% { opacity: 0; transform: scale(0.25) rotate(-25deg); }
  35%, 65% { opacity: 1; transform: scale(1) rotate(0deg); }
  85%, 100% { opacity: 0; transform: scale(0.25) rotate(-25deg); }
}
@keyframes moonDominant {
  0%, 15% { opacity: 1; transform: scale(1) rotate(0deg); }
  35%, 65% { opacity: 0; transform: scale(0.3) rotate(15deg); }
  85%, 100% { opacity: 1; transform: scale(1) rotate(0deg); }
}
@keyframes moonRecessive {
  0%, 15% { opacity: 0; transform: scale(0.3) rotate(15deg); }
  35%, 65% { opacity: 1; transform: scale(1) rotate(0deg); }
  85%, 100% { opacity: 0; transform: scale(0.3) rotate(15deg); }
}
@keyframes moonAccentDominant {
  0%, 15% { opacity: 1; transform: scale(1); }
  35%, 65% { opacity: 0; transform: scale(0.2); }
  85%, 100% { opacity: 1; transform: scale(1); }
}
@keyframes moonAccentRecessive {
  0%, 15% { opacity: 0; transform: scale(0.2); }
  35%, 65% { opacity: 1; transform: scale(1); }
  85%, 100% { opacity: 0; transform: scale(0.2); }
}
@media (prefers-reduced-motion: reduce) {
  .theme-toggle .sun-rays, .theme-toggle .sun-body,
  .theme-toggle .moon-body, .theme-toggle .moon-accent { animation: none; }
}
.theme-toggle-desktop {
  width: 34px; height: 34px; border-radius: 50%;
  transition: background-color 0.15s ease, color 0.15s ease;
}
.theme-toggle-desktop:hover { background: var(--surface); }
.theme-toggle-standalone { display: none; }
.theme-toggle-standalone:hover { background: var(--surface); }
@media (max-width: 480px) {
  .nav { padding: 5px; padding-right: 6px; gap: 6px; }
  .nav .brand { width: 30px; height: 30px; }
  .nav ul a { padding: 7px 10px; font-size: 13px; }
}

/* Mobile hamburger nav — zero-JS checkbox toggle. Inert on desktop (display:none) so it
   can never affect the locked desktop nav; every interactive/panel rule for it lives
   inside the existing 880px mobile breakpoint below. Kept out of script.js entirely: the
   panel is shown/hidden via CSS transform/opacity (never display:none) specifically so
   the existing scrollspy pill logic's offsetLeft/offsetWidth reads on the real <ul> stay
   valid at all times, regardless of open/closed state. */
.nav-toggle-input, .nav-toggle-label, .nav-backdrop, .nav-download, .nav-status { display: none; }
.btn {
  display: inline-flex; align-items: center; gap: 8px;
  /* `color: var(--bg)` instead of a hardcoded `#fff` — in light mode --bg is #FFFFFF,
     so this renders byte-identical to before; the point is that --ink and --bg are
     always each other's contrast pair in both themes (that's the whole definition of
     the theme), so a button whose fill is "the ink color" correctly gets "the bg color"
     for its text in EITHER theme — light mode: dark button, white text (unchanged);
     dark mode: --ink is now light, so the button becomes a light pill with dark text,
     a standard inverted-primary-button treatment rather than a light-on-light button
     that would otherwise be invisible. */
  background: var(--ink); color: var(--bg) !important;
  padding: 10px 18px; border-radius: 9px; font-weight: 500; font-size: 14px;
  transition: transform 0.16s ease, box-shadow 0.16s ease, background-color 0.16s ease, border-color 0.16s ease;
  border: 1px solid var(--ink);
}
/* `--btn-hover-bg` instead of hardcoded `#000` — in light mode it IS `#000` (identical to
   before). The reason this needed a token at all: with the button's base now inverted in
   dark mode (see above), a literal `#000` on hover would flash the button to solid black
   while its text is `var(--bg)` (also near-black there), making the label briefly
   unreadable mid-hover. In dark mode `--btn-hover-bg` resolves to `var(--ink)` — the same
   as the button's own resting background — so the hover state relies on the already-
   approved blue flood-fill (`.btn::before`, untouched) for its visual cue instead of a
   flat color swap that only made sense against a dark base. */
.btn:hover { background: var(--btn-hover-bg); border-color: #2563EB; text-decoration: none !important; transform: translateY(-1px); }
.btn:active { transform: translateY(0); box-shadow: 0 1px 4px rgba(17,24,39,0.16); transition-duration: 0.06s; }
.btn.ghost { background: transparent; color: var(--ink) !important; border: 1px solid var(--line); }
.btn.ghost:hover { border-color: var(--accent); color: #fff !important; transform: translateY(-1px); }
.nav .btn { padding: 7px 14px; }

/* Primary-CTA hover fill, reproducing the reference site's corner-anchored
   circle-reveal: a small circle pinned to the bottom-left corner scales up (transform
   only, GPU-friendly) and is clipped by the button's own rounded overflow, so the blue
   appears to flood in from that corner rather than the background flatly swapping.
   Applies to every .btn (including .ghost) — never to nav links, which are a
   different component entirely. */
.btn { position: relative; overflow: hidden; z-index: 0; }
.btn::before {
  content: "";
  position: absolute;
  left: 0; bottom: 0;
  width: 24px; height: 24px;
  margin-left: -12px; margin-bottom: -12px;
  border-radius: 50%;
  background: #2563EB;
  transform: scale(0);
  transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
  z-index: -1;
  pointer-events: none;
}
.btn:hover::before { transform: scale(15); }

/* ---------- Hero ---------- */
.hero { padding: 48px 0 64px; position: relative; }
/* Light-theme-only atmospheric mesh wash, full-bleed edge-to-edge across the viewport — NOT
   painted on .hero itself, because .hero also carries .wrap's max-width/centering, which
   would clip the gradient to the ~1120px content column and produce visible hard edges on
   wider screens. .hero-mesh is a dedicated, empty, aria-hidden decorative element (a real
   element rather than a third pseudo-element, since .hero::before/::after already exist and
   are untouched) that breaks out of that constraint with the standard left:50%/100vw/
   translateX(-50%) full-bleed technique, sized to the same height as .hero and sitting
   behind .hero::before/::after and all real content. A large ellipse with a very gradual
   fade (transparent at 85%) avoids any visible boundary/ring. Dark theme is untouched: gated
   to the default (non-dark) theme only, same as the codebase's existing dark-mode-override
   convention, just inverted. */
.hero-mesh { display: none; }
:root:not([data-theme="dark"]) .hero-mesh {
  display: block;
  position: absolute; top: 0; left: 50%; width: 100vw; height: 100%;
  transform: translateX(-50%);
  z-index: -3; pointer-events: none;
  /* Two-tone wash — a bluish-indigo glow on the left, the portfolio's own accent blue on the
     right (matching the reference's left/right color split, per explicit request) — each an
     independent radial gradient at the same ry(58%)/center-y(50%)/stop(85%) tuning as before,
     so both fade to nothing at ~96% of this element's own height with no hard edge. Indigo
     (#6366F1) reads as "bluish-purple" without crossing into true violet/magenta. Alpha raised
     substantially (0.16/0.18 -> 0.34/0.38) per explicit feedback that the wash was not visible
     enough against white at the lower values. */
  background:
    radial-gradient(ellipse 58% 58% at 22% 50%, rgba(99,102,241,0.34), transparent 85%),
    radial-gradient(ellipse 58% 58% at 78% 50%, rgba(37,99,235,0.38), transparent 85%);
}
/* Perspective grid-line texture sitting directly on top of .hero-mesh (same z-index, -3, but
   later in DOM order so it paints above the mesh per the requested stacking) and below
   .hero::after's z-index:-2 dot-grid, .hero::before's z-index:-1 blob, and all real content.
   Light-theme-only, same full-bleed breakout as .hero-mesh. The source asset is a wide
   transparent-PNG/AVIF strip (a receding floor-grid graphic) anchored to the hero's bottom
   edge, sized by its own aspect ratio so it never distorts. */
.hero-texture { display: none; }
:root:not([data-theme="dark"]) .hero-texture {
  display: block;
  position: absolute; left: 50%; bottom: 150px; width: 100vw; aspect-ratio: 2048 / 321;
  transform: translateX(-50%);
  z-index: -3; pointer-events: none;
  background-image: url("assets/hero-grid-texture.avif");
  background-repeat: no-repeat;
  background-size: 100% 100%;
  background-position: bottom center;
}
.hero::before {
  content: ""; position: absolute; top: -140px; right: -140px; width: 700px; height: 700px;
  border-radius: 50%; pointer-events: none; z-index: -1; filter: blur(10px);
  background: radial-gradient(closest-side, rgba(37,99,235,0.10), transparent 70%);
  animation: heroAmbient 15s ease-in-out infinite alternate;
}
.hero::after {
  content: ""; position: absolute; inset: -60px 0 0 0; pointer-events: none; z-index: -2; opacity: 0.6;
  background-image: linear-gradient(var(--grid-line) 1px, transparent 1px), linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
  background-size: 60px 60px;
  mask-image: radial-gradient(ellipse 75% 65% at 78% 12%, black 0%, transparent 70%);
  -webkit-mask-image: radial-gradient(ellipse 75% 65% at 78% 12%, black 0%, transparent 70%);
  animation: heroGrid 70s linear infinite;
}
@keyframes heroAmbient { from { transform: translate(0,0) scale(1); opacity: 0.7; } to { transform: translate(-20px, 10px) scale(1.06); opacity: 1; } }
@keyframes heroGrid { from { transform: translateY(0); } to { transform: translateY(60px); } }
@media (prefers-reduced-motion: reduce) { .hero::before, .hero::after { animation: none; } }
.badge {
  display: inline-flex; align-items: center; gap: 8px;
  font-size: 13px; font-weight: 500; color: var(--soft);
  background: var(--surface); border: 1px solid var(--line);
  padding: 6px 14px 6px 10px; border-radius: 999px; margin-top: 20px; margin-bottom: 28px;
}
.badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 3px rgba(16,185,129,0.15); animation: statusPulse 2.5s ease-in-out infinite; }
@keyframes statusPulse { 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(0.86); opacity: 0.45; } }
@media (prefers-reduced-motion: reduce) { .badge::before { animation: none; } }
.hero-grid { display: grid; grid-template-columns: 1.55fr 1fr; gap: 64px; align-items: center; position: relative; z-index: 1; }

/* Oversized scrolling name — a decorative background layer behind the hero content.
   Reuses the same GPU-transform marquee mechanic as .marquee/.track below, scaled up
   and made purely decorative (aria-hidden, no real content — the name already exists
   as real text in the brand link and is not duplicated for screen readers). */
.hero-name-marquee {
  position: absolute; left: 0; right: 0; top: 50%; transform: translateY(-50%);
  height: clamp(90px, 14vw, 220px); overflow: hidden; z-index: 0; pointer-events: none;
  mask-image: linear-gradient(90deg, transparent, black 6%, black 94%, transparent);
  -webkit-mask-image: linear-gradient(90deg, transparent, black 6%, black 94%, transparent);
}
.hnm-track { display: flex; width: max-content; align-items: center; animation: hnmScroll 70s linear infinite; will-change: transform; }
.hnm-track span {
  font-size: clamp(64px, 12vw, 220px); font-weight: 800; letter-spacing: -0.03em; line-height: 1;
  text-transform: uppercase; color: var(--hnm-color); white-space: nowrap; padding-right: 0.5em;
  -webkit-text-stroke: 1px var(--hnm-stroke);
}
@keyframes hnmScroll { from { transform: translateX(0); } to { transform: translateX(-50%); } }
@media (prefers-reduced-motion: reduce) { .hnm-track { animation: none; transform: translateX(-25%); } }
@media (max-width: 880px) { .hero-name-marquee { height: clamp(60px, 20vw, 120px); } }
.hero h1 { font-size: clamp(34px, 4.6vw, 56px); font-weight: 650; letter-spacing: -0.032em; margin-bottom: 20px; }
.hero h1 em { font-style: normal; color: var(--accent); }
.hero .sub { font-size: 17px; color: var(--soft); max-width: 600px; margin-bottom: 27px; }
.hero .cta-row { display: flex; gap: 12px; flex-wrap: wrap; }
.portrait {
  width: 100%; max-width: 300px; aspect-ratio: 1/1.08;
  object-fit: cover; object-position: 50% 18%;
  border-radius: 16px;
  box-shadow: 0 1px 2px rgba(17,24,39,0.06), 0 24px 48px -16px rgba(17,24,39,0.22);
  position: relative; z-index: 1;
}
.portrait-wrap { position: relative; justify-self: end; width: 100%; max-width: 300px; z-index: 1; }
.portrait-wrap .p-dot {
  position: absolute; top: 38%; right: -34px; width: 22px; height: 22px; border-radius: 50%;
  background: var(--accent); box-shadow: 0 8px 18px -6px rgba(37,99,235,0.55); z-index: 2; pointer-events: none;
  animation: pDotFloat 6s ease-in-out infinite alternate;
}
@keyframes pDotFloat { from { transform: translateY(0); } to { transform: translateY(-14px); } }
@media (prefers-reduced-motion: reduce) { .p-dot { animation: none; } }
@media (max-width: 700px) { .p-dot { display: none; } }
.portrait-wrap .p-glow {
  position: absolute; top: -15%; right: -22%; width: 300px; height: 300px; border-radius: 50%;
  background: radial-gradient(closest-side, rgba(37,99,235,0.16), transparent 70%);
  filter: blur(24px); pointer-events: none; z-index: 0;
  animation: pGlow 9s ease-in-out infinite; transition: opacity 0.4s;
}
.portrait-wrap .p-rect {
  position: absolute; border: 1.5px solid var(--line); border-radius: 18px; pointer-events: none; z-index: 0;
}
.portrait-wrap .p-rect.r1 { top: -26px; right: -26px; width: 120px; height: 120px; animation: pDriftA 8s ease-in-out infinite alternate; }
.portrait-wrap .p-rect.r2 { bottom: -22px; left: -30px; width: 90px; height: 90px; border-radius: 14px; animation: pDriftB 11s ease-in-out infinite alternate; }
.portrait-wrap .p-glass {
  position: absolute; bottom: 26px; left: -44px; width: 92px; height: 62px; z-index: 2; pointer-events: none;
  background: linear-gradient(150deg, rgba(255,255,255,0.65), rgba(255,255,255,0.25));
  border: 1px solid rgba(255,255,255,0.8); border-radius: 12px;
  backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
  box-shadow: 0 12px 28px -12px rgba(17,24,39,0.22);
  animation: pDriftB 13s ease-in-out infinite alternate-reverse;
}
@keyframes pGlow { 0% { transform: scale(1); opacity: 0.55; } 50% { transform: scale(1.08); opacity: 1; } 100% { transform: scale(1); opacity: 0.55; } }
@keyframes pDriftA { from { transform: translateY(0) rotate(0deg); } to { transform: translateY(-16px) rotate(2deg); } }
@keyframes pDriftB { from { transform: translateY(0) rotate(0deg); } to { transform: translateY(13px) rotate(-1.5deg); } }
.portrait { transition: transform 0.4s cubic-bezier(0.16,1,0.3,1), box-shadow 0.4s; }
.portrait-wrap:hover .portrait { transform: scale(1.02); box-shadow: 0 1px 2px rgba(17,24,39,0.06), 0 28px 56px -16px rgba(17,24,39,0.28); }
.portrait-wrap:hover .p-glow { opacity: 1; }
@media (prefers-reduced-motion: reduce) { .p-glow, .p-rect, .p-glass { animation: none !important; } }
@media (max-width: 880px) { .portrait-wrap { justify-self: start; } .portrait-wrap .p-glass { left: auto; right: -18px; } }

/* ---------- Profile photo flip (reference-inspired hover reveal) ----------
   Two independently rotated, backface-hidden faces stacked in the same box: the
   existing photo (front) rotates away on hover while a frosted reveal panel (back,
   new) rotates into view — a two-layer counter-rotation flip, not a single 3D
   container with backface-visibility. Gated to fine-pointer/hover-capable devices
   only, so touch never lands in a stuck mid-flip state. Does not touch .portrait's
   own existing scale-on-hover rule above — that still applies to the image inside
   the front face and simply rides along with it. */
.portrait-flip { position: relative; width: 100%; max-width: 300px; aspect-ratio: 1/1.08; border-radius: 16px; }
.portrait-flip-front, .portrait-flip-back {
  position: absolute; inset: 0; width: 100%; height: 100%;
  border-radius: inherit; backface-visibility: hidden; -webkit-backface-visibility: hidden;
  transition: transform 0.6s cubic-bezier(0.65, 0, 0.35, 1);
}
.portrait-flip-front { transform: perspective(1000px) rotateY(10deg); z-index: 2; }
.portrait-flip-front .portrait { position: absolute; inset: 0; margin: 0; width: 100%; height: 100%; }
.portrait-flip-back {
  transform: perspective(1000px) rotateY(180deg);
  background: rgba(255,255,255,0.72);
  backdrop-filter: blur(15px); -webkit-backdrop-filter: blur(15px);
  box-shadow: 0 1px 2px rgba(17,24,39,0.06), 0 24px 48px -16px rgba(17,24,39,0.22);
  overflow: hidden;
}
.pf-ring { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); border: 1px solid #000; border-radius: 999px; pointer-events: none; }
.pf-ring-big { width: 59%; aspect-ratio: 1/1; }
.pf-ring-small { width: 34%; aspect-ratio: 1/1; }
/* Sized to 50% of the panel (matching the reference's own text-ring-to-panel ratio) so
   the circular text traces a path that sits inside .pf-ring-big with clearance, instead
   of spanning the full panel and reading as "outside the circle". */
.pf-ring-text { position: absolute; top: 50%; left: 50%; width: 50%; height: 50%; transform: translate(-50%,-50%); animation: pfSpin 20s linear infinite; }
.pf-ring-text text { font-size: 12px; font-weight: 400; letter-spacing: 0; word-spacing: 7px; fill: #000; text-transform: uppercase; }
.pf-arrow { position: absolute; top: 50%; left: 50%; width: 20%; height: 20%; color: #000; pointer-events: none; animation: pfArrowBounce 1.5s ease-in-out infinite; }
@keyframes pfSpin { from { transform: translate(-50%,-50%) rotate(0deg); } to { transform: translate(-50%,-50%) rotate(360deg); } }
@keyframes pfArrowBounce { 0%, 100% { transform: translate(-50%,-50%) translateY(0); } 50% { transform: translate(-50%,-50%) translateY(12px); } }
@media (hover: hover) and (pointer: fine) {
  .portrait-wrap:hover .portrait-flip-front { transform: perspective(1000px) rotateY(-180deg); }
  .portrait-wrap:hover .portrait-flip-back { transform: perspective(1000px) rotateY(-10deg); }
}
@media (prefers-reduced-motion: reduce) {
  .portrait-flip-front, .portrait-flip-back { transition: none; }
  .portrait-wrap:hover .portrait-flip-front, .portrait-wrap:hover .portrait-flip-back { transform: none; }
  .pf-ring-text, .pf-arrow { animation: none; }
}
.company-strip { margin-top: 100px; }
.company-strip .label { font-size: 12px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); margin-bottom: 12px; }
.company-strip .names { display: flex; flex-wrap: wrap; gap: 12px 34px; font-size: 15.5px; font-weight: 600; color: var(--soft); letter-spacing: -0.01em; }
.logo-wall { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; }
.logo-tile {
  display: flex; align-items: center; justify-content: center; text-align: center;
  padding: 18px 12px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface);
  font-size: 15px; font-weight: 600; letter-spacing: -0.01em; color: var(--soft);
  transition: color 0.2s, border-color 0.2s, background 0.2s, transform 0.2s;
}
.logo-tile:hover { color: var(--ink); border-color: var(--border-hover); background: var(--card); transform: translateY(-2px); }
@media (max-width: 880px) { .logo-wall { grid-template-columns: repeat(2, 1fr); } }

/* ---------- Impact Highlights ---------- */
#impact { padding: 64px 0 72px; border-bottom: 1px solid var(--line); }
.stats { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; margin-top: 30px; align-items: stretch; }

/* One card component, two named variants (.metric / .capability). Every card is a
   top-anchored flex column with an explicit height floor, so visual weight comes
   from the shared component, never from how much text a given card happens to hold. */
.stat {
  display: flex; flex-direction: column; align-items: flex-start;
  min-height: 210px; box-sizing: border-box;
  border: 1px solid var(--line); border-radius: 14px; padding: 28px 26px 26px; background: var(--surface);
  transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
}
.stat:hover { border-color: var(--border-hover); background: var(--card); box-shadow: 0 10px 28px -16px rgba(17,24,39,0.12); transform: translateY(-2px); }

/* Primary zone (the "b" element): reserved height sized to the tallest case — a
   3-line capability headline at 5-column card width — so the number variant (always
   1 line) sits inside the *same* box, top-aligned. Because the zone height is fixed
   regardless of variant, the supporting text below it starts at one Y-offset for all
   five cards. This targets `.stat b` directly, not `.stat > b`, because it's the only
   `<b>` in the component in either variant — no descendant-selector ambiguity here. */
.stat b {
  display: flex; align-items: flex-start; width: 100%;
  min-height: 82px; margin-bottom: 10px;
  color: var(--ink);
}
.stat.metric b {
  font-size: 36px; font-weight: 700; letter-spacing: -0.03em;
  font-variant-numeric: tabular-nums; line-height: 1.1;
}
.stat.capability b {
  font-size: 19px; font-weight: 650; letter-spacing: -0.015em; line-height: 1.35;
}

/* IMPORTANT: this must be a direct-child combinator, not a descendant selector.
   The metric variant nests its animated counter as `<b><span data-count>…</span></b>` —
   a bare `.stat span` rule matches that inner span too and silently overrides the
   52px/36px number styling with the supporting-text styling (this was the actual bug:
   numbers rendered small and gray because they were being styled as if they were the
   caption). Scoping to `.stat > span` targets only the supporting-text span, which is
   a direct child of `.stat` in every card, in both variants. */
.stat > span { font-size: 14px; color: var(--muted2); line-height: 1.45; display: block; }
@media (max-width: 1080px) { .stats { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 880px) { .stats { grid-template-columns: 1fr 1fr; } .stat.metric b { font-size: 34px; } }

/* ---------- Sections ---------- */
section { padding: 71px 0; }
/* Case Studies listing page only (#work is shared with the homepage's Selected Work
   section, so this is scoped to .cs-listing, not #work itself). The generic 86px section
   padding put far more air above .cs-back than the case-study detail pages' .cs-hero
   (padding: 32px 0 32px) — matching that exact reference value here, top only. */
#work.cs-listing { padding-top: 32px; }
.eyebrow { font-size: 12px; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent); margin-bottom: 10px; }
/* Hero-only name treatment — scoped so the shared .eyebrow class (used elsewhere, e.g. the
   Sneak Peek "Playground" label) is untouched. Reuses the same "Caveat" handwriting font
   already loaded for .connect-cursive/.hid-caption, just at a larger, name-signature size. */
.hero-signature { font-family: "Caveat", cursive; font-size: 25px; font-weight: 600; text-transform: none; letter-spacing: 0; margin-bottom: 4px; }
/* Compact "Open to work" status indicator, sat beside the handwritten greeting — replaces
   the old large availability pill that used to sit below the CTA row. Reuses the same
   pulsing-dot language as that former pill (var(--success), same statusPulse keyframe)
   so the status cue stays visually consistent with the rest of the site, just smaller. */
.hero-greeting-row { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin-bottom: 4px; }
.hero-greeting-row .hero-signature { margin-bottom: 0; }
.hero-open-badge {
  display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;
  font-size: 12px; font-weight: 500; color: var(--soft);
  background: var(--surface); border: 1px solid var(--line);
  padding: 4px 10px; border-radius: 999px;
}
.hero-open-dot { width: 10px; height: 10px; border-radius: 50%; flex: none; background: var(--success); box-shadow: 0 0 0 3px rgba(16,185,129,0.15); animation: statusPulse 2.5s ease-in-out infinite; }
.hero-open-sep { color: var(--soft); opacity: 0.6; }
@media (prefers-reduced-motion: reduce) { .hero-open-dot { animation: none; } }
section h2 { font-size: clamp(26px, 3vw, 34px); margin-bottom: 12px; letter-spacing: -0.025em; }
.lead { color: var(--soft); max-width: 680px; font-size: 16.5px; }

/* ---------- Work cards ---------- */
.cards { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; margin-top: 40px; }
/* Case-studies archive: hold 2 columns past the shared 880px reset, down to 700px */
@media (max-width: 880px) { .cards.archive { grid-template-columns: 1fr 1fr; } }
@media (max-width: 700px) { .cards.archive { grid-template-columns: 1fr; } }
.card {
  background: var(--card); border: 1px solid var(--line); border-radius: 14px; overflow: hidden;
  transition: transform 0.25s cubic-bezier(0.16,1,0.3,1), box-shadow 0.25s, border-color 0.25s;
  color: var(--ink); display: block;
}
.card:hover { transform: translateY(-4px); box-shadow: 0 1px 2px rgba(17,24,39,0.04), 0 16px 40px -20px rgba(17,24,39,0.18); border-color: var(--border-hover); text-decoration: none; }
.card .thumb { overflow: hidden; aspect-ratio: 16/9; background: var(--surface); }
.card .thumb img { width: 100%; height: 100%; object-fit: cover; object-position: top; transition: transform 0.5s cubic-bezier(0.16,1,0.3,1); }
.card:hover .thumb img { transform: scale(1.035); }
.card .card-body { padding: 24px 26px 26px; }
.card .tags { font-size: 11.5px; font-weight: 600; color: var(--soft); letter-spacing: 0.07em; margin-bottom: 10px; text-transform: uppercase; }
.card h3 { font-size: 20px; margin-bottom: 8px; letter-spacing: -0.015em; }
.card p { font-size: 14.5px; color: var(--soft); }
.card .read { display: inline-block; margin-top: 14px; font-weight: 500; color: var(--accent); font-size: 14px; transition: transform 0.2s cubic-bezier(0.16,1,0.3,1); }
.card:hover .read { transform: translateX(3px); }

/* Six-track grid: a plain multiple-of-3 card count (e.g. 6) needs nothing extra.
   The .five modifier is only for a 5-card grid, where it fills the dangling last row evenly. */
.cards.three { grid-template-columns: repeat(6, 1fr); }
.cards.three .card { grid-column: span 2; }
.cards.three.five .card:nth-child(4), .cards.three.five .card:nth-child(5) { grid-column: span 3; }
.cards.three .card h3 { font-size: 18px; }
@media (max-width: 1100px) {
  .cards.three .card { grid-column: span 3; }
  .cards.three.five .card:nth-child(5) { grid-column: span 6; }
}

/* ---------- Selected Work: sticky cover-stack, reproducing the mechanism measured
   directly on portavia.framer.website's Featured Projects section (getComputedStyle
   read at 20+ scroll positions, every DOM depth: transform:none and opacity:1 on every
   card at every position — there is no JS-driven transform, scale, or fade there at
   all). Each card sits in its own `position: sticky` wrapper, one after another in
   normal document flow. As the user scrolls, each new card's wrapper rises from below
   the viewport up to the same pinned top offset as the one before it; being later in
   the DOM, it paints on top and progressively covers the previous card from the bottom
   up as it rises. No element here ever has its transform/opacity/scale written by JS —
   case-study-showcase.js only tracks which card currently reads as "active" for the
   01/05 counter and for keyboard/screen-reader focus. Isolated entirely from the nav
   system: its own JS module, its own scroll listener, no shared state with script.js. */
.cs-section-head { text-align: center; margin-bottom: 56px; }
.cs-view-all { text-align: center; margin-top: 28px; }
.cs-track { position: relative; }
.cs-index {
  display: none;
  position: sticky; top: 40px; z-index: 5; text-align: center;
  font-size: 13px; font-weight: 600; color: var(--soft); letter-spacing: 0.02em;
  font-variant-numeric: tabular-nums;
}
.cs-index-current { color: var(--ink); }
.cs-index-sep { margin: 0 4px; color: #C7CBD2; }

/* Reference's actual outer mechanism (framer-lyues5): display:flex, flex-direction:
   column, gap:120px, with each sticky card as a DIRECT flex child — verified via
   getComputedStyle on the live reference (a display:contents responsive-variant wrapper
   around each card is transparent to layout, so the effective parent is this flex
   container). This replaces the previous non-sticky height:100vh "slot" wrapper per
   card — the reference does not use a separate slot element at all; the gap between
   flex children is what creates the scroll slack between one card sticking and the
   next covering it. */
.cs-card-stack { display: flex; flex-direction: column; gap: 120px; }
/* top:80px is the reference's own measured sticky offset (getComputedStyle on
   framer-gonwtm), not an approximation — reproduced exactly for this diagnostic phase.
   z-index:1 matches the reference's own explicit value on framer-gonwtm (confirmed via
   getComputedStyle); all four cards share this same value on the reference too, so
   stacking is still resolved by DOM order — this doesn't change that, it just matches
   the literal declaration. */
.cs-card-sticky { position: sticky; top: 80px; z-index: 1; }
.cs-card {
  /* width:1120px and aspect-ratio:1.5/1 are the reference's own measured desktop values
     (getComputedStyle on framer-gonwtm, confirmed identical at both 1280px and 1920px
     viewports) — not approximated. Height is not set directly; it falls out of
     width ÷ aspect-ratio exactly as it does on the reference (1120 / 1.5 = 746.67px). */
  position: relative; display: block; width: 1120px; aspect-ratio: 1.5 / 1;
  max-width: 100%;
  margin: 0 auto; border-radius: 20px; overflow: hidden;
  background: var(--surface); cursor: pointer;
  box-shadow: 0 1px 2px rgba(17,24,39,0.04), 0 26px 52px -24px rgba(17,24,39,0.32);
  /* Reference evidence showed a per-card inline scale (~0.926) tied to its custom hover
     cursor component (data-framer-cursor), not to scroll position — confirmed by polling
     transform on a fresh page load and at 40+ scroll offsets, which stayed transform:none
     throughout. Reproduced here as a plain CSS hover state; safe to transition because
     nothing else ever writes to this element's own transform (the scroll mechanism only
     ever touches position via native sticky, never this card's transform/opacity). */
  transition: transform 0.35s cubic-bezier(0.16,1,0.3,1);
}
.cs-card:hover { transform: scale(0.965); }
.cs-card:focus-visible { outline: 2px solid #fff; outline-offset: -3px; box-shadow: 0 0 0 5px var(--accent); }
/* Reference wrapper hierarchy inside the card link (framer-ensd48), reproduced level for
   level from its extracted markup: a cursor wrapper (framer-11gwpp8) containing an image
   wrapper, the overlay, and the info block — rather than flattening these into siblings
   of the card itself. */
.cs-card-cursor-wrap { position: relative; width: 100%; height: 100%; overflow: hidden; }
.cs-card-image-wrap { position: absolute; inset: 0; border-radius: inherit; }
.cs-card-img { display: block; width: 100%; height: 100%; object-fit: cover; object-position: center center; border-radius: inherit; }
/* Reference overlay gradient stops (framer-17rrz4o) kept exactly as measured; only the
   opacity multiplier is raised (0.6 → 0.74) so foreground text reads more clearly
   against busy project screenshots, without crushing the image to black. */
.cs-card-overlay { position: absolute; inset: 0; background: linear-gradient(rgba(12,12,13,0.65) 0%, rgba(12,12,13,1) 100%); opacity: 1; }
.cs-card-info {
  position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center;
  justify-content: center; text-align: center; padding: 40px; gap: 14px;
}
/* Reference splits the badge into two elements: framer-12o7i4v (the pill background)
   wrapping framer-1rmvwl7 (the text) — reproduced as .cs-badge wrapping .cs-category
   instead of one element carrying both the pill and the text styling. */
.cs-badge { display: inline-block; background: var(--accent); padding: 6px 14px; border-radius: 99px; }
.cs-category { font-size: 12px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: #fff; }
.cs-text-wrap { display: flex; flex-direction: column; gap: 8px; }
.cs-title { display: block; margin: 0; font-size: clamp(34px, 3.8vw, 58px); font-weight: 650; letter-spacing: -0.025em; color: #fff; text-wrap: balance; }
.cs-desc { display: block; margin: 0; font-size: 16px; color: rgba(255,255,255,0.85); max-width: 46ch; }

@media (max-width: 1023px) {
  .cs-card-sticky { top: 68px; }
  .cs-card { width: min(90%, 720px); aspect-ratio: 1.5 / 1; }
  .cs-card-info { padding: 32px; gap: 10px; }
}
@media (max-width: 700px) {
  .cs-card-sticky { top: 60px; }
  .cs-card { width: 92%; aspect-ratio: 4 / 5; }
  .cs-index { top: 32px; }
  .cs-title { font-size: clamp(28px, 8vw, 38px); }
  .cs-desc { max-width: none; font-size: 14.5px; }
  .cs-card-info { padding: 28px; gap: 8px; }
}

/* Personal & Systems Work — lighter, subordinate secondary section (not equal-weight with Selected Work) */
#personal-work { padding: 56px 0 80px; }
#personal-work .pw-heading { font-size: 20px; font-weight: 650; letter-spacing: -0.015em; margin-bottom: 10px; color: var(--ink); }
#personal-work .lead { font-size: 15px; }
/* .showcase reuses the exact .cards/.card/.thumb/.card-body/.tags system above — same grid,
   same 16:9 crop, same body padding — so these two cards read as the same reusable card
   component as the Case Studies grid, not a separate layout. */
.showcase { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; margin-top: 26px; }
@media (max-width: 700px) { .showcase { grid-template-columns: 1fr; } }
.showcase-item {
  background: var(--card); border: 1px solid var(--line); border-radius: 14px; overflow: hidden;
  transition: transform 0.25s cubic-bezier(0.16,1,0.3,1), box-shadow 0.25s, border-color 0.25s;
  color: var(--ink); display: block;
}
.showcase-item:hover { transform: translateY(-4px); box-shadow: 0 1px 2px rgba(17,24,39,0.04), 0 16px 40px -20px rgba(17,24,39,0.18); border-color: var(--border-hover); }
.showcase-media { display: block; overflow: hidden; aspect-ratio: 16/9; background: var(--surface); }
.showcase-media img { width: 100%; height: 100%; object-fit: cover; object-position: top; transition: transform 0.5s cubic-bezier(0.16,1,0.3,1); }
.showcase-item:hover .showcase-media img { transform: scale(1.035); }
.showcase-caption { padding: 24px 26px 26px; }
.showcase-label { font-size: 11.5px; font-weight: 600; color: var(--soft); letter-spacing: 0.07em; margin-bottom: 10px; text-transform: uppercase; }
.showcase-text h3 { font-size: 20px; margin-bottom: 8px; letter-spacing: -0.015em; }
.showcase-text h3 a { color: inherit; text-decoration: none; }
.showcase-text p { font-size: 14.5px; color: var(--soft); }
.showcase-cta { display: inline-block; margin-top: 14px; font-weight: 500; color: var(--accent); font-size: 14px; transition: transform 0.2s cubic-bezier(0.16,1,0.3,1); text-decoration: none; }
.showcase-item:hover .showcase-cta { transform: translateX(3px); }
.mini-cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; margin-top: 26px; }
.mini-card {
  display: block; border: 1px solid var(--line); border-radius: 12px; padding: 20px 22px; background: var(--surface);
  color: var(--ink); transition: border-color 0.2s, transform 0.2s;
}
.mini-card:hover { border-color: var(--border-hover); transform: translateY(-2px); text-decoration: none; }
.mini-card .mini-tag { display: block; font-size: 11px; font-weight: 600; color: var(--soft); letter-spacing: 0.06em; text-transform: uppercase; margin-bottom: 8px; }
.mini-card h4 { font-size: 16px; font-weight: 650; letter-spacing: -0.01em; margin-bottom: 4px; }
.mini-card p { font-size: 13.5px; color: var(--soft); margin-bottom: 10px; }
.mini-card .mini-read { font-size: 13px; font-weight: 500; color: var(--accent); }
@media (max-width: 700px) { .mini-cards { grid-template-columns: 1fr; } }

.support {
  display: flex; justify-content: space-between; align-items: center; gap: 24px; flex-wrap: wrap;
  border: 1px solid var(--line); border-radius: 14px; background: var(--surface);
  padding: 28px 32px; margin-top: 24px;
}

/* ---------- Journey (compact accordion, replaces the old six-stacked-block timeline).
   Reuses the same interaction mechanism as the Capabilities accordion (.cap-toggle) —
   aria-expanded + max-height transition — via a shared JS selector, so no new JS
   controller was written; only the markup/CSS here is new to this section. */
/* Two-column layout: items stay in a single DOM sequence (1-6, chronological), split into
   two independent .jrny-col containers (1-3 left, 4-6 right) rather than a single CSS Grid
   with shared row tracks — this is deliberate, not just convenient. A shared-row grid would
   couple the two columns' heights (Grid sizes each row to its tallest item and, by default,
   stretches shorter items to match), so expanding one accordion item would visibly push/
   stretch its neighbor in the other column. Two independent block-level columns behave
   exactly like the original single-list stacking, just duplicated side by side, so opening
   an item only ever affects its own column. This also means mobile (which collapses back to
   one column) naturally stacks column 1 then column 2 in the correct chronological order
   with no extra reordering CSS needed. */
.jrny-list { margin-top: 30px; display: grid; grid-template-columns: 1fr 1fr; column-gap: 24px; align-items: start; }
.jrny-col { display: flex; flex-direction: column; gap: 10px; }
/* Each row is now its own bordered, rounded card (matches the approved reference) instead
   of a continuous divider-line list — .jrny-col's own border-top and each row's
   border-bottom (a shared line between adjacent rows) are replaced by a full border per
   row plus the column's own gap, so cards read as separate rather than touching. */
.jrny-row { border: 1px solid #e0e7ff; border-radius: 14px; overflow: hidden; }
:root[data-theme="dark"] .jrny-row { border-color: #232b3a; }
@media (max-width: 880px) { .jrny-list { grid-template-columns: 1fr; column-gap: 0; } }
.jrny-toggle {
  display: flex; align-items: center; gap: 16px; width: 100%;
  background: none; border: none; padding: 16px 14px; margin: 0;
  font: inherit; text-align: left; color: var(--ink); cursor: pointer;
  transition: color 0.15s ease;
  /* Left/right column dividers are two independent stacks (see .jrny-list comment above),
     so any row whose own content is even fractionally taller than its counterpart makes
     every divider below it drift out of alignment between columns. min-height pins every
     row to the same closed-state height regardless of small content differences (the
     Mindteck status chip vs. plain-text dates elsewhere) — the structural fix, not a
     per-row offset hack. */
  min-height: 76px;
}
.jrny-toggle:hover { color: var(--accent); }
.jrny-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
.jrny-logo {
  width: 34px; height: 34px; border-radius: 8px; flex: none;
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--surface); border: 1px solid var(--line); overflow: hidden;
}
.jrny-logo img { width: 100%; height: 100%; object-fit: contain; display: block; }
.jrny-id { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.jrny-role { font-size: 15.5px; font-weight: 600; letter-spacing: -0.01em; transition: color 0.15s ease; }
/* Company + date sit on one metadata line, wrapping independently of the row's own flex
   layout if a long company name + date can't both fit (e.g. narrow mobile widths) — this
   is what lets the date live "beside the company name" instead of pinned to the row's far
   edge, without ever colliding with the plus icon (which sits outside .jrny-id entirely). */
.jrny-meta { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; row-gap: 2px; }
.jrny-co { font-size: 14px; color: var(--soft); }
/* Past-role dates: quiet metadata, deliberately a step further muted than the company name
   (tertiary tone already used site-wide for de-emphasized labels) so hierarchy reads as
   title > company > date at a glance. The current role (Mindteck) overrides this via
   .is-current into a small accent status chip instead — see below. */
.jrny-when { flex: none; font-size: 11px; font-weight: 400; line-height: 1.2; color: #6581B0; font-variant-numeric: tabular-nums; }
.jrny-when.is-current {
  flex: none; font-size: 11px; font-weight: 400; line-height: 1.2;
  color: var(--accent); background: rgba(37,99,235,0.08);
  padding: 3px 6px; border-radius: 8px;
}
/* Plus/minus toggle — two thin bars forming "+" (slim, regular-weight glyph, no bold/heavy
   stroke); the vertical bar rotates flat onto the horizontal one on expand, becoming "−".
   No rotation of the whole glyph, no chevron, no circular background. #7a9cff in both
   states — a specific lighter blue for this icon only, not the shared --accent token (which
   stays #2563EB everywhere else, e.g. the current-role date chip just above). */
.jrny-toggle-icon { position: relative; flex: none; width: 18px; height: 18px; }
.jrny-toggle-icon::before, .jrny-toggle-icon::after {
  content: ""; position: absolute; top: 50%; left: 50%; width: 13px; height: 1.5px;
  background: #7a9cff; border-radius: 1px; transform: translate(-50%, -50%);
  transition: transform 0.25s cubic-bezier(0.16,1,0.3,1);
}
.jrny-toggle-icon::after { transform: translate(-50%, -50%) rotate(90deg); }
.jrny-toggle[aria-expanded="true"] .jrny-toggle-icon::after { transform: translate(-50%, -50%) rotate(0deg); }
.jrny-toggle[aria-expanded="true"] .jrny-role { color: var(--accent); }
.jrny-desc-wrap { max-height: 0; overflow: hidden; transition: max-height 0.3s cubic-bezier(0.16,1,0.3,1); }
.jrny-desc-inner { padding: 6px 14px 24px; }
.jrny-desc-inner .jrny-scope { font-size: 14.5px; color: var(--ink); font-weight: 500; margin-bottom: 10px; max-width: 640px; }
.jrny-desc-inner ul { display: grid; gap: 6px; padding-left: 18px; max-width: 640px; }
.jrny-desc-inner li { font-size: 14px; color: var(--soft); line-height: 1.5; }
@media (prefers-reduced-motion: reduce) {
  .jrny-desc-wrap, .jrny-toggle-icon::before, .jrny-toggle-icon::after, .jrny-toggle { transition: none; }
}

/* ---------- Capabilities ---------- */
.caps { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-top: 36px; }
.cap { border: 1px solid var(--line); border-radius: var(--radius); padding: 26px 28px; background: var(--card); transition: border-color 0.2s, box-shadow 0.2s; }
.cap:hover { border-color: var(--border-hover); box-shadow: 0 8px 24px -12px rgba(17,24,39,0.12); }
.cap h3 { font-size: 16.5px; margin-bottom: 8px; }
.cap p { font-size: 14px; color: var(--soft); }

/* Numbered editorial accordion — replaces the .caps card grid for "What I bring to a team" */
.cap-list { margin-top: 30px; border-top: 1px solid var(--line); }
.cap-row { border-bottom: 1px solid var(--line); }
.cap-toggle {
  display: flex; align-items: center; gap: 20px; width: 100%;
  background: none; border: none; padding: 20px 0; margin: 0;
  font: inherit; text-align: left; color: var(--ink); cursor: pointer;
  transition: color 0.15s ease;
}
.cap-toggle:hover { color: var(--accent); }
.cap-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
.cap-toggle .cap-num { font-size: 13px; font-weight: 650; color: var(--muted); font-variant-numeric: tabular-nums; flex: none; }
.cap-toggle .cap-title { flex: 1; font-size: 17px; font-weight: 600; letter-spacing: -0.012em; }
.cap-toggle .cap-chevron { flex: none; width: 18px; height: 18px; color: var(--muted); transition: transform 0.25s cubic-bezier(0.16,1,0.3,1); }
.cap-toggle[aria-expanded="true"] .cap-chevron { transform: rotate(180deg); color: var(--accent); }
.cap-toggle[aria-expanded="true"] .cap-num { color: var(--accent); }
.cap-row:has(.cap-toggle[aria-expanded="true"]) .cap-toggle { padding-left: 14px; padding-right: 14px; }
.cap-desc-wrap { max-height: 0; overflow: hidden; transition: max-height 0.3s cubic-bezier(0.16,1,0.3,1); }
.cap-desc-inner { padding: 6px 14px 22px; }
.cap-desc-inner p { font-size: 14.5px; color: var(--soft); max-width: 640px; }
@media (max-width: 700px) {
  .cap-toggle { gap: 14px; padding: 16px 0; }
  .cap-toggle .cap-title { font-size: 15.5px; }
}

/* Editorial proof matrix — "Why Teams Hire Me": a structured index, not another
   card wall, so it reads distinctly from the .stats "Impact Highlights" above it. */
.proof-matrix { margin-top: 26px; border-top: 1px solid var(--line); }
.proof-row { display: grid; grid-template-columns: 40px 1fr auto; align-items: center; gap: 20px; padding: 16px 0; border-bottom: 1px solid var(--line); transition: padding-left 0.2s ease; }
.proof-row:hover { padding-left: 6px; }
.proof-row:hover .proof-num { color: var(--accent); }
.proof-row:hover .proof-link { transform: translateX(3px); }
.proof-num { font-size: 22px; font-weight: 650; letter-spacing: -0.02em; color: #D1D5DB; font-variant-numeric: tabular-nums; line-height: 1; }
.proof-row h3 { font-size: 12px; font-weight: 650; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink); margin-bottom: 3px; }
.proof-row p { font-size: 14.5px; color: var(--soft); }
.proof-link { display: inline-block; font-size: 13.5px; font-weight: 500; color: var(--accent); white-space: nowrap; transition: transform 0.2s cubic-bezier(0.16,1,0.3,1); }
@media (max-width: 700px) {
  .proof-row { grid-template-columns: 1fr; gap: 4px; padding: 16px 0; align-items: start; }
  .proof-link { justify-self: start; margin-top: 6px; }
}

/* ---------- How I Design: editorial image + overlapping principle cards,
   replacing the "Why Teams Hire Me" proof matrix above. Overlap/z-order is plain
   document flow (later card in the DOM paints over the one before it) — no JS,
   no absolute positioning, fully independent of the Featured Projects mechanism. */
.hid-head { text-align: center; max-width: 640px; margin: 0 auto 64px; }
.hid-grid { display: grid; grid-template-columns: minmax(0, 38%) 1fr; gap: 56px; align-items: stretch; }
.hid-image { border-radius: 20px; overflow: hidden; background: var(--surface); transform: translateY(-100px) rotate(-3deg) scale(0.617); }
/* .hid-image also carries the site-wide .reveal class; once IntersectionObserver adds
   .in on scroll-into-view, .reveal.in's own `transform: none` (two classes) otherwise
   outranks .hid-image's single-class rule and wipes the tilt/scale right as the section
   becomes visible. Re-assert it here at higher specificity so it survives the reveal. */
.hid-image.reveal.in { transform: translateY(-100px) rotate(-3deg) scale(0.617); }
/* Desktop-only gentle float, matching the slow idle motion of the .ai3d-* 3D shapes
   elsewhere on the page, but deliberately vertical-only — no rotation — per explicit
   request ("not dancing, just moving slowly upwards and downs"). The keyframes repeat
   .hid-image's own static tilt/scale transform and add an oscillating translateY on top
   of it (rather than replacing the transform outright, which would snap the photo out of
   its tilted/scaled position). The extra translateY is itself scaled down by the existing
   scale(0.617) since it comes later in the transform chain, so 22px here reads as ~14px
   of actual on-screen movement, matching the amplitude of the other floating shapes.
   Scoped to the same >700px range where the static tilt/scale itself is active (see the
   max-width:700px reset below) — mobile stays fully static, untouched. */
@keyframes hidImageFloat {
  0%, 100% { transform: translateY(-100px) rotate(-3deg) scale(0.617) translateY(0); }
  50% { transform: translateY(-100px) rotate(-3deg) scale(0.617) translateY(-22px); }
}
@media (min-width: 701px) {
  .hid-image, .hid-image.reveal.in { animation: hidImageFloat 8s ease-in-out infinite; }
}
@media (prefers-reduced-motion: reduce) {
  .hid-image, .hid-image.reveal.in { animation: none; }
}
.hid-image img { display: block; width: 100%; height: 100%; object-fit: cover; object-position: center 70%; }
/* Small handwritten annotation above the office photo. .hid-image-col is the new grid
   item (replacing bare .hid-image) so .hid-image itself — and its permanent tilt/scale
   transform above — is completely untouched; the caption is positioned against this
   wrapper's own (untransformed) box, not against the photo's transformed paint position. */
.hid-image-col { position: relative; }
.hid-image-col .hid-image { height: 100%; }
.hid-caption-wrap {
  position: absolute; top: -34px; left: 8px; z-index: 2;
  display: flex; align-items: flex-start; gap: 2px;
  transform: rotate(-2deg); pointer-events: none;
}
/* Same fix as .hid-image.reveal.in above: .reveal.in's own `transform: none` (two
   classes) otherwise outranks the single-class rule right above and wipes the tilt
   the moment this element reveals on scroll. */
.hid-caption-wrap.reveal.in { transform: rotate(-2deg); }
.hid-caption { font-family: "Caveat", cursive; font-weight: 500; font-size: 20px; color: var(--soft); margin: 0; line-height: 1; white-space: nowrap; }
.hid-caption-arrow { width: 28px; height: 24px; color: var(--soft); flex: none; margin-top: 10px; }
.hid-cards { display: flex; flex-direction: column; }
.hid-card {
  display: grid; grid-template-columns: 40px 1fr; gap: 18px;
  background: var(--card); border: 1px solid var(--line); border-radius: 14px;
  padding: 24px 26px 26px;
  box-shadow: 0 1px 2px rgba(17,24,39,0.04), 0 20px 40px -24px rgba(17,24,39,0.22);
  transition: transform 0.25s cubic-bezier(0.16,1,0.3,1), box-shadow 0.25s;
}
.hid-card:not(:first-child) { margin-top: -26px; }
.hid-card:hover {
  transform: translateY(-3px); box-shadow: 0 1px 2px rgba(17,24,39,0.05), 0 24px 44px -20px rgba(17,24,39,0.28);
  background-color: var(--accent); border-color: var(--accent);
}
.hid-card:nth-child(1) { width: 92%; margin-left: 0; }
.hid-card:nth-child(2) { width: 92%; margin-left: auto; }
.hid-card:nth-child(3) { width: 92%; margin-left: 0; }
.hid-card:nth-child(4) { width: 92%; margin-left: auto; }
.hid-card:nth-child(5) { width: 92%; margin-left: 0; }
.hid-num { font-size: 20px; font-weight: 650; letter-spacing: -0.02em; color: var(--accent); font-variant-numeric: tabular-nums; line-height: 1.2; }
.hid-card:hover .hid-num { color: #fff; }
.hid-card h3 { font-size: 16px; font-weight: 650; letter-spacing: -0.012em; color: var(--ink); margin-bottom: 6px; }
.hid-card:hover h3 { color: #fff; }
.hid-card p { font-size: 14px; color: var(--soft); line-height: 1.55; }
.hid-card:hover p { color: rgba(255,255,255,0.85); }
@media (max-width: 880px) {
  .hid-grid { grid-template-columns: minmax(0, 34%) 1fr; gap: 36px; }
}
@media (max-width: 700px) {
  .hid-head { margin-bottom: 40px; }
  .hid-grid { grid-template-columns: 1fr; gap: 28px; }
  /* .hid-image's own desktop tilt/offset/scale transform (translateY(-100px) rotate(-3deg)
     scale(0.617), asserted a second time at higher specificity via .hid-image.reveal.in —
     see the comment above that rule) was never reset here, so the image kept rendering
     shifted up and rotated over the paragraph text once the grid stacked to one column.
     Repeating both selectors at matching specificity, later in the cascade, is what
     actually overrides it. */
  .hid-image, .hid-image.reveal.in { transform: none; }
  .hid-image img { height: auto; aspect-ratio: 16 / 10; }
  .hid-card:not(:first-child) { margin-top: 14px; }
  .hid-card:nth-child(1), .hid-card:nth-child(2), .hid-card:nth-child(3),
  .hid-card:nth-child(4), .hid-card:nth-child(5) { width: 100%; margin-left: 0; }
}

/* How I Design decorative objects (v6 — green/pyramid/cube source assets replaced with
   newly supplied, natively-transparent PNGs (3D_icon_1/2/3_transparent.png in chat), fully
   resolving the v5 AVIF-pipeline noise/brightness issue for these three the same way star
   was resolved (real clean asset, not a filter/reprocessing workaround). Confirmed clean
   before use: real alpha channel already present (corner alpha=0, interior alpha=255, no
   reconstruction needed), already trimmed to content, and an 8x zoomed edge check showed no
   speckle/block noise. Widths stay the approved caps (green/cube 150px, pyramid 172.5px
   +15%); heights recomputed from each new asset's own real content ratio to avoid
   distortion: green 277:280 (1.0108), pyramid 371:299 (0.8059), cube 335:326 (0.9731).
   hid-3d-star.png (the old noisy asset) is left in place, unreferenced, per no-unrelated-
   cleanup — star already points at assets/about-3d-star.png since v5.
   v7: pair gap increased +25% (left 27.4px->34.25px, right 27.25px->34.06px) by moving
   ONLY the top icon of each pair upward (green top 411->404.2px, star top 406->399.2px);
   pyramid/cube's top/height/width/left/right are untouched per "don't touch the bottom
   icons" — the extra gap comes entirely out of the space above the pair, not below.
   v8: green/star (top icons only) moved to top:250px; pyramid/cube untouched again. */
.hid-decor-green {
  top: 250px; right: 4px;
  width: min(150px, calc((100vw - 1120px) * 0.62));
  height: min(151.6px, calc((100vw - 1120px) * 0.6267));
}
.hid-decor-pyramid {
  top: 590px; left: 16px;
  width: min(172.5px, calc((100vw - 1120px) * 0.713));
  height: min(139px, calc((100vw - 1120px) * 0.5746));
}
.hid-decor-star {
  top: 250px; left: 0px;
  width: min(172.5px, calc((100vw - 1120px) * 0.713));
  height: min(189.75px, calc((100vw - 1120px) * 0.7843));
}
.hid-decor-cube {
  top: 623px; right: 16px;
  width: min(150px, calc((100vw - 1120px) * 0.62));
  height: min(146px, calc((100vw - 1120px) * 0.6033));
}
@media (max-width: 1279px) {
  .hid-decor-pyramid, .hid-decor-cube { display: none; }
  .hid-decor-green { top: 20px; right: 10px; width: 60px; height: 61px; }
  .hid-decor-star { top: 20px; left: 10px; width: 60px; height: 66px; }
}
@media (max-width: 900px) {
  .hid-decor-star { display: none; }
  .hid-decor-green { top: 20px; right: 12px; width: 44px; height: 48px; opacity: 0.7; }
}
/* padding-bottom overridden from the generic `section` rule (now 71px, see above) — this
   section was flagged as the worst offender for excess whitespace before Experience, so
   it gets a further cut below the new site-wide baseline (55px, ~25% below its own prior
   73px) — scoped to this section only, top padding untouched so the heading's position
   relative to Featured Projects above is unaffected.
   margin-bottom: -126px added on top — a 200px reduction was requested, but the visible
   gap to Experience's heading (measured live) was only 126px total; -126px is the maximum
   that closes it to exactly 0 without the two sections' content actually overlapping.
   Uses margin (not touching Experience's own padding/rules at all) so this stays fully
   scoped to #how-i-design.
   padding-bottom cut further to 26px: the visible last-card-to-heading gap (measured live,
   115px) was asked to shrink by 25%. Since margin-bottom pulls Experience up relative to
   this section's own border-box (which padding-bottom is part of), trimming padding-bottom
   here shifts Experience's heading up by the same amount, closing the gap directly —
   margin-bottom stays untouched and the two sections' boxes still meet at exactly 0. */
#how-i-design { position: relative; overflow: hidden; padding-bottom: 26px; margin-bottom: -126px; }
#how-i-design .wrap { position: relative; z-index: 1; }

/* "12+ Years of product and visual design experience" impact card: its resting/hover
   styling actually lives in index.html's own <style> block (.impact-card, .impact-card:
   hover — background: var(--accent) on hover, white at rest), not in this file. Scoped
   here at higher specificity so it overrides that inline block without touching
   index.html, and targeted to only the 2nd card (this one) so the other 3 impact cards
   keep their existing flat --accent hover untouched. */
#about-impact .impact-cards .impact-card:nth-child(2):hover { background: #1D4ED8; border-color: #1D4ED8; }

/* ---------- Two-column balance blocks ---------- */
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 56px; align-items: start; }
.split > div:first-child > .lead:first-child { margin-top: 0; }
.split > .panel { margin-top: 5px; }
/* About: both columns share one optical center; panel carries equal visual mass */
.about-split { grid-template-columns: 1.1fr 0.9fr; gap: 72px; align-items: center; }
.about-split > .panel { margin-top: 0; background: var(--surface); padding: 28px 32px 30px; }
.about-split > .panel ul { gap: 13px; }
.panel { border: 1px solid var(--line); border-radius: 14px; background: var(--card); padding: 24px 28px 26px; }
.panel h3 { font-size: 13px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--soft); margin-bottom: 14px; line-height: 1.4; }
.panel ul { list-style: none; display: grid; gap: 12px; }
.panel li { display: flex; gap: 12px; align-items: baseline; font-size: 14.5px; color: var(--text2); line-height: 1.55; }
.panel li::before { content: ""; width: 6px; height: 6px; border-radius: 2px; background: var(--accent); flex: none; transform: translateY(-1px); }
.panel li b { color: var(--ink); font-weight: 600; }
.snapshot { margin-top: 20px; }
/* Label/value columns instead of flex space-between: the old layout pushed each value to
   the far right edge and right-aligned it, so any value long enough to wrap (Domains,
   Location) read as ragged-left, flush-right text — hard to scan. A fixed label column
   with the value left-aligned in its own column reads as a clean two-column list instead,
   and the value simply wraps left-aligned like normal text. */
.snapshot .row { display: grid; grid-template-columns: 150px 1fr; gap: 5.2px; padding: 10px 0; font-size: 14px; align-items: baseline; }
.snapshot .row span { color: var(--soft); }
.snapshot .row b { font-weight: 600; color: var(--ink); text-align: left; font-variant-numeric: tabular-nums; }
@media (max-width: 480px) { .snapshot .row { grid-template-columns: 1fr; gap: 4px; } }

/* ---------- Contact ---------- */
.contact {
  background:
    radial-gradient(ellipse 120% 90% at 78% -10%, rgba(37,99,235,0.14), transparent 55%),
    radial-gradient(ellipse 90% 70% at 8% 110%, rgba(16,185,129,0.06), transparent 60%),
    linear-gradient(180deg, #0E1628 0%, #0B1120 55%, #0A0F1D 100%);
  color: #cbd5e1; border-radius: 20px; padding: 94px 64px 72px; margin: 20px 0 0;
  position: relative; overflow: hidden; isolation: isolate;
  box-shadow: inset 0 1px 0 rgba(255,255,255,0.07), inset 0 -1px 0 rgba(0,0,0,0.4), 0 24px 60px -30px rgba(11,17,32,0.5);
}
/* The old ::before/::after ambient blob glows, and the grid-lines/beam/orb/glass/tile/
   rrect decorative elements below, were removed from the markup — the particle-field
   canvas (footer-arc-background.js) is now the section's only background decoration, and
   having both competed visually / muddied the intended blue-only palette (the removed
   blobs included a green tint). Their CSS rules are left in place below (unused, since
   nothing in the markup references those classes anymore) rather than hunted down and
   deleted throughout the file — dead CSS is harmless; re-adding the markup later would
   still work correctly if ever wanted back. */
.footer-arc-canvas {
  position: absolute; inset: 0; width: 100%; height: 100%;
  z-index: -1; pointer-events: none; display: block;
}
.contact .grid-lines {
  position: absolute; inset: -56px 0 0 0; pointer-events: none; z-index: -1; opacity: 0.65;
  background-image: linear-gradient(rgba(148,163,184,0.07) 1px, transparent 1px), linear-gradient(90deg, rgba(148,163,184,0.07) 1px, transparent 1px);
  background-size: 56px 56px;
  mask-image: radial-gradient(ellipse 90% 80% at 70% 20%, black 0%, transparent 75%);
  -webkit-mask-image: radial-gradient(ellipse 90% 80% at 70% 20%, black 0%, transparent 75%);
  animation: gridPan 60s linear infinite;
}
@keyframes gridPan { from { transform: translateY(0); } to { transform: translateY(56px); } }
/* Soft glowing orbs */
.contact .orb { position: absolute; border-radius: 50%; pointer-events: none; z-index: -1; filter: blur(36px); }
.contact .orb.o1 {
  top: 2%; right: 3%; width: 280px; height: 280px;
  background: radial-gradient(closest-side, rgba(37,99,235,0.36), transparent 70%);
  animation: orbFloatA 7s ease-in-out infinite alternate;
}
.contact .orb.o2 {
  bottom: -6%; right: 28%; width: 220px; height: 220px;
  background: radial-gradient(closest-side, rgba(16,185,129,0.20), transparent 70%);
  animation: orbFloatB 8.5s ease-in-out infinite alternate;
}
@keyframes orbFloatA { from { transform: translate(0,0) scale(1); opacity: 0.65; } to { transform: translate(-40px, 28px) scale(1.12); opacity: 1; } }
@keyframes orbFloatB { from { transform: translate(0,0) scale(1); opacity: 0.6; } to { transform: translate(32px, -24px) scale(1.08); opacity: 1; } }
/* Floating glass rectangles — layered behind the tile */
.contact .glass {
  position: absolute; pointer-events: none; z-index: -1; border-radius: 18px;
  background: linear-gradient(150deg, rgba(255,255,255,0.06), rgba(255,255,255,0.015));
  border: 1px solid rgba(255,255,255,0.10);
  backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
  box-shadow: 0 20px 44px -22px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.08);
}
.contact .glass.g1 { top: 10%; right: 22%; width: 150px; height: 96px; transform: rotate(-7deg); animation: glassFloatA 9s ease-in-out infinite alternate; }
.contact .glass.g2 { top: 54%; right: 5%; width: 110px; height: 130px; transform: rotate(8deg); animation: glassFloatB 12s ease-in-out infinite alternate; }
.contact .rrect {
  position: absolute; top: 16%; right: 8%; width: 210px; height: 210px; z-index: -1; pointer-events: none;
  border: 1.5px solid rgba(148,163,184,0.14); border-radius: 44px;
  animation: rrectSpin 26s linear infinite;
}
@keyframes rrectSpin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.contact .orb.o1 { animation: orbFloatA 7s ease-in-out infinite alternate, glowPulse 5s ease-in-out infinite; }
@keyframes glowPulse { 0%, 100% { opacity: 0.7; } 50% { opacity: 1; } }
@keyframes glassFloatA { from { transform: rotate(-8deg) translateY(0); opacity: 0.7; } to { transform: rotate(-3deg) translateY(-24px); opacity: 1; } }
@keyframes glassFloatB { from { transform: rotate(9deg) translateY(0); opacity: 0.6; } to { transform: rotate(4deg) translateY(20px); opacity: 1; } }
/* Glass dashboard tile — abstract, decorative, right side */
.contact .tile {
  position: absolute; top: 24%; right: 10%; width: 190px; z-index: -1; pointer-events: none;
  backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
  background: linear-gradient(160deg, rgba(148,163,184,0.10), rgba(148,163,184,0.03));
  border: 1px solid rgba(148,163,184,0.16); border-radius: 14px; padding: 16px;
  box-shadow: 0 18px 40px -18px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.08);
  animation: tileFloat 30s ease-in-out infinite alternate;
}
.contact .tile .t-dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(37,99,235,0.55); margin-bottom: 12px; }
.contact .tile .t-bar { height: 6px; border-radius: 3px; background: rgba(148,163,184,0.22); margin-bottom: 8px; }
.contact .tile .t-bar.b1 { width: 82%; }
.contact .tile .t-bar.b2 { width: 60%; background: rgba(37,99,235,0.30); }
.contact .tile .t-bar.b3 { width: 70%; }
.contact .tile .t-spark { display: flex; gap: 5px; align-items: flex-end; height: 34px; margin-top: 14px; }
.contact .tile .t-spark i { flex: 1; border-radius: 2px 2px 0 0; background: rgba(148,163,184,0.18); }
.contact .tile .t-spark i:nth-child(3) { background: rgba(37,99,235,0.35); }
.contact .tile .t-spark i:nth-child(1) { height: 40%; } .contact .tile .t-spark i:nth-child(2) { height: 65%; }
.contact .tile .t-spark i:nth-child(3) { height: 90%; } .contact .tile .t-spark i:nth-child(4) { height: 55%; }
.contact .tile .t-spark i:nth-child(5) { height: 75%; }
@keyframes tileFloat {
  0% { transform: translateY(0) rotate(-4deg); opacity: 0.72; }
  50% { opacity: 1; }
  100% { transform: translateY(-24px) rotate(0deg); opacity: 0.88; }
}
@media (max-width: 880px) { .contact .tile, .contact .glass, .contact .orb, .contact .rrect { display: none; } }
.contact .beam {
  position: absolute; top: 0; bottom: 0; left: 62%; width: 1px; z-index: -1; pointer-events: none;
  background: linear-gradient(180deg, transparent, rgba(148,163,184,0.16) 40%, rgba(148,163,184,0.16) 60%, transparent);
  animation: beamShift 38s ease-in-out infinite alternate;
}
@keyframes ambientA { 0% { transform: translate(0, 0); opacity: 0.7; } 50% { opacity: 1; } 100% { transform: translate(-40px, 26px); opacity: 0.85; } }
@keyframes ambientB { from { transform: translate(0, 0); opacity: 0.8; } to { transform: translate(34px, -20px); opacity: 1; } }
@keyframes ringDrift { from { transform: rotate(10deg) translateY(0); opacity: 0.7; } to { transform: rotate(19deg) translateY(-22px); opacity: 1; } }
@keyframes beamShift { from { transform: translateX(0); opacity: 0.6; } to { transform: translateX(26px); opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .contact::before, .contact::after, .contact .beam, .contact .grid-lines, .contact .tile, .contact .orb, .contact .glass, .contact .rrect, .marquee .track { animation: none; } }
.contact h2 { color: #fff; max-width: 640px; }
.contact p { color: #94a3b8; max-width: 560px; margin-top: 10px; }
.contact .links { margin: 32px 0 36px; display: grid; gap: 18px; font-size: 14.5px; }
.contact .links a { color: #cbd5e1; display: inline-flex; align-items: center; gap: 14px; width: fit-content; line-height: 1.4; transition: color 0.15s; }
.contact .links a:hover { color: #fff; text-decoration: none; }
.contact .links svg { width: 18px; height: 18px; flex: none; color: #64748b; transition: color 0.15s; }
.contact .links a:hover svg { color: #93c5fd; }
.contact .links .link-static { color: #94a3b8; display: inline-flex; align-items: center; gap: 14px; line-height: 1.4; }
.contact .links .link-static svg { width: 18px; height: 18px; flex: none; color: #64748b; }
/* Location icon: always-on, continuously looping — no hover trigger, runs regardless of
   pointer position, cloned frame-by-frame from icons8-place-marker.gif. In the reference the
   pin body/circle never move at all; only the ground ellipse animates, as a continuous
   stroke draw/erase cycle — full ellipse shrinks away to a small arc near the pin's base,
   then redraws back out to the full ellipse, looping forever. Reproduced here with
   stroke-dasharray/stroke-dashoffset (dash length 23 = the ellipse's own circumference at
   rx:5/ry:2, so dash and gap exactly tile the path): animating dashoffset from 0 to 2x that
   length sweeps the on/off boundary all the way around, which is what shrinks the visible
   arc to nothing at the halfway point and regrows it by the end. Cycle length is deliberately
   slower than the raw reference (2.8s, not the reference's 1.17s) so the draw/erase motion is
   easier to follow at rest; same dash mechanism, same proportions, just stretched in time.
   overflow:visible kept so the ring (see below) can render past the icon's own 18x18 drawing
   box without being clipped.
   The ring is scaled 2.2x from an origin at its own top edge (12px 19px, not its center) so
   it grows mostly downward/outward past the icon's box instead of symmetrically inflating in
   place — this is what actually fixes the "ring trapped in a tiny box" issue: at the icon's
   normal size the ring is only ~7.5x3px, visually fused against the pin's point, so enlarging
   it in place wouldn't give it the clear "separate ring beneath the pin" read being asked
   for. This changes nothing about the icon's own layout box (still 18x18, same row height/
   spacing) — only the animated ring layer paints outside it, into the existing gap between
   rows. The pin body gets a very small, slow, continuous vertical drift (translateY, 2px,
   soft ease, no bounce/overshoot) on its own 1.17s timeline — independent of the ring's
   2.8s cycle, left untouched here since it was separately approved as correct. */
.icon-pin { overflow: visible; }
.icon-pin .pin-body {
  transform-origin: 12px 21px;
  animation: pinBodyDrift 1.17s ease-in-out infinite;
}
.icon-pin .pin-ring {
  opacity: 1;
  stroke-dasharray: 23 23;
  transform-origin: 12px 19px;
  transform: scale(2.2);
  animation: pinRingDraw 2.8s linear infinite;
}
@keyframes pinRingDraw {
  from { stroke-dashoffset: 0; }
  to { stroke-dashoffset: 46; }
}
@keyframes pinBodyDrift {
  0%, 15% { transform: translateY(0); }
  50%, 65% { transform: translateY(-2px); }
  100% { transform: translateY(0); }
}

.icon-phone .phone-body { animation: phoneRing 3.2s ease-in-out infinite; animation-delay: 0.4s; transform-origin: 12px 12px; }
.icon-phone .phone-wave { animation: phoneWave 3.2s ease-in-out infinite; animation-delay: 0.4s; transform-origin: 15px 6px; opacity: 0; }
.icon-phone .phone-wave-2 { animation-delay: 0.55s; }
@keyframes phoneRing {
  0%, 8%, 100% { transform: rotate(0deg); }
  1% { transform: rotate(-15deg); }
  2% { transform: rotate(13deg); }
  3% { transform: rotate(-10deg); }
  4% { transform: rotate(8deg); }
  5% { transform: rotate(-4deg); }
  6% { transform: rotate(0deg); }
}
@keyframes phoneWave {
  0%, 8%, 100% { opacity: 0; transform: scale(0.6); }
  2%, 6% { opacity: 0.9; }
  7% { opacity: 0; transform: scale(1.15); }
}

/* Email icon: always-on, continuously looping — no hover trigger, cloned frame-by-frame from
   icons8-email.gif. Reference cycle: closed envelope holds at rest, the flap opens (its
   downward "V" seam flips to point upward past the top edge, like a lid folding back), a
   letter card rises up out of the envelope and pokes out between the open flap's wings,
   holds, then retracts back down as the flap folds shut again, holds at rest, loops. The
   flap's closed shape (`m2.5 6 9.5 7 9.5-7`) is a V with its vertex below the hinge line
   (y:6) — reflecting it with scaleY(-1) around that same hinge turns it into a ^ whose
   vertex sits the same distance above the hinge, which is exactly the open-lid shape in the
   reference; the letter is a plain opacity+translateY move from hidden-and-low to
   visible-and-raised and back. Both run on the same timeline so they stay in phase. Cycle
   length is deliberately slower than the raw reference (2.5s, not 1.17s) purely so the
   open/hold/close sequence is comfortably readable rather than a quick blink — same motion,
   same keyframe proportions, just stretched in time. */
.icon-mail { overflow: visible; }
.icon-mail .mail-flap {
  transform-origin: 12px 6px;
  animation: mailFlapOpen 2.5s ease-in-out infinite;
}
.icon-mail .mail-letter {
  transform-origin: 12px 10.5px;
  animation: mailLetterPop 2.5s ease-in-out infinite;
}
@keyframes mailFlapOpen {
  0%, 15% { transform: scaleY(1); }
  30%, 55% { transform: scaleY(-1); }
  70%, 100% { transform: scaleY(1); }
}
@keyframes mailLetterPop {
  0%, 25% { opacity: 0; transform: translateY(4px); }
  35%, 55% { opacity: 1; transform: translateY(-9px); }
  68%, 100% { opacity: 0; transform: translateY(4px); }
}
@media (prefers-reduced-motion: reduce) {
  /* Static rest state for each: pin ring fully drawn (no motion), mail flap closed and
     letter hidden — matching how these icons looked before any animation existed.
     Phone is untouched — its own continuous animation is a separate, unrelated approval. */
  .icon-pin .pin-ring { animation: none; stroke-dashoffset: 0; }
  .icon-pin .pin-body { animation: none; transform: none; }
  .icon-mail .mail-flap { animation: none; transform: scaleY(1); }
  .icon-mail .mail-letter { animation: none; opacity: 0; transform: translateY(4px); }
  .icon-phone .phone-body, .icon-phone .phone-wave { animation: none; }
}
.contact .links .link-row { display: inline-flex; align-items: center; gap: 8px; }
.contact .links .copy-email-btn { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; flex: none; border: none; border-radius: 6px; background: transparent; color: #64748b; cursor: pointer; padding: 0; transition: color 0.15s, background 0.15s; }
.contact .links .copy-email-btn:hover { color: #93c5fd; background: rgba(255,255,255,0.06); }
.contact .links .copy-email-btn svg { width: 15px; height: 15px; }
.contact .links .copy-email-btn .icon-check { display: none; }
.contact .links .copy-email-btn.is-copied { color: #34d399; }
.contact .links .copy-email-btn.is-copied .icon-copy { display: none; }
.contact .links .copy-email-btn.is-copied .icon-check { display: block; }
.contact .cta-btns { display: flex; gap: 10px; }
.contact .btn { background: #fff; color: #0B1120 !important; border-color: #fff; }
.contact .btn:hover { background: #e6eefc; border-color: #2563EB; color: #fff !important; }
.contact .btn.ghost { background: transparent; color: #fff !important; border-color: rgba(255,255,255,0.3); }
.contact .btn.ghost:hover { border-color: #2563EB; box-shadow: none; }
/* Social-connect block — sits in the genuinely empty pocket of the decorative cluster
   (below .glass.g2's bottom edge, right of .orb.o2's right edge), so it never overlaps
   .orb/.glass/.rrect/.tile. `bottom: 0` on the absolutely-positioned block lands its last
   child (.connect-btns) exactly on the container's content-bottom edge — the same
   coordinate the left-side Email me/Download resume buttons' own bottom sits on (both
   measured live at 590.71875px from .contact's top), so the two button rows share one
   bottom line without hardcoding either value. Desktop only via position:absolute; mobile
   (≤880px, where the decorative cluster itself is already hidden) drops it into normal
   document flow instead. */
.contact .connect-social { position: absolute; right: 20px; bottom: 72px; width: auto; text-align: left; z-index: 1; }
.contact .connect-cursive {
  font-family: "Caveat", cursive; font-size: 21px; font-weight: 500;
  color: #fff; margin: 0 80px 24px 2px; line-height: 1;
}
.contact .connect-btns { display: flex; gap: 10px; justify-content: flex-start; }
.contact .social-btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 45px; height: 45px; flex: none;
  border-radius: 9px; border: 1px solid rgba(255,255,255,0.18);
  background: rgba(255,255,255,0.04); color: #cbd5e1;
  position: relative; overflow: hidden; z-index: 0;
  transition: border-color 0.15s, color 0.15s, background-color 0.15s;
}
/* Same corner-anchored flood-fill as the primary CTA (.btn::before above) — a small
   circle pinned to the bottom-left corner scales up and is clipped by the button's own
   rounded overflow, so the accent blue floods in from the corner instead of the
   background flatly swapping, matching the CTA buttons' hover language exactly. Scale
   factor is smaller than .btn's (6 vs 15) because this button is a fixed 45px square
   rather than a variable-width label button — 6 is the smallest factor that still fully
   covers the box's corner-to-corner diagonal, so the flood reaches the far corner at the
   same visual pace as the CTA buttons' own flood. */
.contact .social-btn::before {
  content: "";
  position: absolute;
  left: 0; bottom: 0;
  width: 24px; height: 24px;
  margin-left: -12px; margin-bottom: -12px;
  border-radius: 50%;
  background: #2563EB;
  transform: scale(0);
  transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
  z-index: -1;
  pointer-events: none;
}
.contact .social-btn:hover::before { transform: scale(6); }
.contact .social-btn svg { width: 20px; height: 20px; flex: none; }
.contact .social-btn:hover { border-color: #2563EB; color: #fff; background: rgba(37,99,235,0.12); text-decoration: none; }
@media (prefers-reduced-motion: reduce) { .contact .social-btn { transition: none; } }
@media (max-width: 880px) {
  .contact .connect-social { position: static; width: auto; text-align: left; margin-top: 28px; }
  .contact .connect-btns { justify-content: flex-start; }
  /* The desktop-only 210px right margin on .connect-cursive is sized against that fixed
     absolute-positioned layout; at mobile width it has nothing to do with and just eats
     into the column's own width, forcing "Let's connect" to wrap onto two lines. Reset it
     here so mobile keeps the same single-line heading it already had. */
  .contact .connect-cursive { margin-right: 0; }
}

/* ---------- Case study shared ---------- */
.cs-back {
  display: flex; align-items: center; justify-content: center;
  width: 32px; height: 32px; margin-bottom: 28px;
  color: var(--muted); border-radius: 8px;
  transition: color 0.15s ease, background-color 0.15s ease;
}
.cs-back:hover { color: var(--ink); background: var(--surface); text-decoration: none; }
.cs-back:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.cs-back svg { width: 18px; height: 18px; }
.cs-hero { padding: 32px 0 32px; }
.cs-hero h1 { font-size: clamp(30px, 4vw, 46px); margin-bottom: 20px; letter-spacing: -0.03em; }
.cs-statement { font-size: 19px; font-weight: 500; letter-spacing: -0.012em; border-left: 3px solid var(--accent); padding: 4px 0 4px 22px; margin: 26px 0; width: 100%; max-width: none; box-sizing: border-box; color: var(--ink); }
/* Weighted grid, not equal-width and not pure content-flow: long fields (Role,
   Result) get flexible, larger tracks; short fields (Period, Website) get
   intrinsic (max-content) tracks so they never take more room than they need.
   Scoped by item count via :has(), since this project's field order is always
   Role, Company, Period, [Result], [Website] — never a different sequence. */
.meta {
  /* Auto-Layout-style: every column is sized to its own content (capped at max-content,
     free to shrink to 0 + wrap if the row is tight), never a proportional (fr) share of
     the container. Previously the first columns used Nfr tracks, which stretch to fill
     whatever width is left over — so a short "Role" value still claimed 1.7 shares of
     the full row, shoving later fields out and producing gaps that grew with container
     width instead of staying fixed. column-gap is the one, single spacing value between
     adjacent fields now — nothing else expands to fill space. */
  display: grid; align-items: start; column-gap: 28px; row-gap: 8px;
  grid-template-columns: minmax(0, max-content) minmax(0, max-content) minmax(0, max-content);
  font-size: 13.5px; color: var(--soft);
  border-top: 1px solid var(--line); border-bottom: 1px solid var(--line);
  padding: 16px 0; margin: 30px 0;
}
.meta span { min-width: 0; overflow-wrap: break-word; }
.meta:where(:has(span:nth-child(4)):not(:has(span:nth-child(5)))) {
  grid-template-columns: minmax(0, max-content) minmax(0, max-content) minmax(0, max-content) minmax(0, max-content);
}
/* 5-item (InfraCloud): intentional 2-row layout at every width — Role/Company/
   Period on row 1, Result/Website on row 2 — rather than forcing all 5 onto one
   row, which forced Role/Company/Result into unnecessary internal multi-line wraps. */
.meta:where(:has(span:nth-child(5))) {
  grid-template-columns: minmax(0, max-content) minmax(0, max-content) minmax(0, max-content);
}
@media (max-width: 1199px) { .meta { column-gap: 18px; } }
@media (max-width: 1023px) {
  .meta:where(:has(span:nth-child(4)):not(:has(span:nth-child(5)))) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 767px) {
  .meta { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .meta span:last-child:nth-child(odd) { grid-column: 1 / -1; }
}
@media (max-width: 560px) { .meta { grid-template-columns: 1fr; } }
.meta b { color: var(--ink); font-weight: 600; }
.meta-icons span { display: inline-flex; align-items: center; gap: 9px; }
.meta-icons svg { flex: none; color: var(--muted); }
article section { padding: 58px 0; border-bottom: 1px solid var(--line); }
article section:last-of-type { border-bottom: none; }
/* About page only: the boundary between the Career Snapshot section and "How I got
   here" — the snapshot panel already has its own visible border, so the section
   divider directly beneath it read as a redundant second line. Scoped to this one
   section pair (not the shared `article section` rule above, which still applies
   everywhere else) so no other section boundary on this or any other page is affected.
   Removing the border without touching padding would leave an oversized ~116px gap with
   nothing to justify it, so both sides' padding are tightened to match. */
.about-sticky-content > section:first-of-type { border-bottom: none; padding-bottom: 20px; }
.about-sticky-content > section:first-of-type + section { padding-top: 20px; }
article h2 { font-size: 25px; margin-bottom: 6px; }
article h3 { font-size: 18px; margin: 28px 0 8px; }
article p, article li { color: #4B5563; font-size: 16px; max-width: 920px; }
article ul, article ol { padding-left: 22px; margin: 14px 0; display: grid; gap: 9px; }
/* Product-showcase screenshot frame */
.shot {
  border: 1px solid var(--line); border-radius: 16px; overflow: hidden;
  background: linear-gradient(180deg, #FCFCFD 0%, #F4F5F7 100%);
  padding: 14px; margin: 32px 0 0;
  box-shadow: 0 1px 2px rgba(17,24,39,0.04), 0 12px 32px -18px rgba(17,24,39,0.12);
  transition: box-shadow 0.3s ease, transform 0.3s ease;
}
.shot:hover { box-shadow: 0 1px 2px rgba(17,24,39,0.04), 0 20px 44px -20px rgba(17,24,39,0.16); transform: translateY(-2px); }
.shot img { width: 100%; border-radius: 9px; border: 1px solid rgba(17,24,39,0.08); transition: transform 0.45s cubic-bezier(0.16,1,0.3,1); }
.shot:hover img { transform: scale(1.008); }
.shot.crop { aspect-ratio: 16/10; }
.shot.crop img { height: 100%; object-fit: cover; object-position: top; }
.shot.crop.tall { aspect-ratio: 4/3; }
figure figcaption {
  font-size: 13px; color: var(--muted2); margin: 12px 4px 28px; line-height: 1.55;
  padding-left: 12px; border-left: 2px solid var(--line);
}
.insight { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 20px 24px; font-size: 15px; margin: 22px 0; max-width: 740px; color: var(--ink); }
.insight b { color: var(--accent); }
.disclosure { background: #FFFBEB; border: 1px solid #FDE68A; border-radius: var(--radius); padding: 18px 22px; font-size: 15px; margin: 18px 0; max-width: 740px; color: #78350F; }
table { border-collapse: collapse; margin: 20px 0; width: 100%; max-width: 700px; font-size: 15px; }
th, td { border: 1px solid var(--line); padding: 10px 14px; text-align: left; }
th { background: var(--surface); font-weight: 600; }
.duo { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin: 8px 0 0; align-items: start; }
.duo figure { margin: 0; }
.duo .shot { margin-top: 24px; }
/* Phone-sized screenshots: never stretch a mobile screen to desktop width */
.duo.phones { grid-template-columns: repeat(2, minmax(0, 400px)); justify-content: start; gap: 32px; }

/* Editorial mobile-screen gallery — scoped to the HomeLink "Selected App Screens" section only */
.screen-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 40px 28px; align-items: start; margin: 8px 0 0; }
.screen-grid figure { margin: 0; }
.screen-grid .shot.screen-frame { width: min(100%, 230px); aspect-ratio: 9 / 19.5; margin: 0 auto; display: flex; align-items: flex-start; justify-content: center; }
.screen-grid .shot.screen-frame img { width: 100%; height: 100%; object-fit: contain; }
.screen-grid figcaption { text-align: center; margin-left: auto; margin-right: auto; max-width: 230px; }
@media (max-width: 1000px) { .screen-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 700px) { .screen-grid { grid-template-columns: 1fr; gap: 32px; } }

.next-cta { display: flex; justify-content: space-between; align-items: center; gap: 20px; background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: 28px 32px; margin: 48px 0; flex-wrap: wrap; }

/* Standardized case-study ending. New class rather than reusing .next-cta directly:
   .next-cta is also used by about.html's own (differently-shaped, text-block + 2-button)
   CTA, and this needed its own two-area structure — same outer card visual language
   (border/radius/background/padding/margin copied from .next-cta verbatim), just its own
   selector so about.html's usage is completely unaffected.
   Two areas, matching the case study's own information hierarchy rather than three equal
   buttons: Area A (.cs-final-utility) is the pair of global actions for this case study —
   Back to the collection, and the primary "go see the real thing" CTA. Area B
   (.cs-final-next), separated by a rule, is contextual: it names the actual next case
   study (preserving each page's existing descriptive copy) before its own action, so
   "Next Project" reads as a continuation of the story rather than a fourth generic button.
   Pages with no live URL simply omit the second child of .cs-final-utility; the page that
   is last in sequence (Design System) omits .cs-final-next and the divider entirely. */
.cs-final-cta { background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: 28px 32px; margin: 48px 0; }
.cs-final-utility { display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap; }
.cs-final-divider { border: none; border-top: 1px solid var(--line); margin: 24px 0; }
.cs-final-next { display: flex; align-items: center; justify-content: space-between; gap: 20px; flex-wrap: wrap; }
.cs-final-next .btn { flex: none; }

/* Key experiences (Pure Insights) */
.exp-block { display: grid; grid-template-columns: 64px 1fr; gap: 24px; margin: 40px 0; }
.exp-block .num { font-size: 15px; font-weight: 650; color: var(--muted); border: 1px solid var(--line); border-radius: 10px; width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; font-variant-numeric: tabular-nums; }
.exp-block h3 { margin-top: 8px; }

/* ---------- Case study product-thinking components ---------- */
.ch-label { font-size: 12px; font-weight: 650; letter-spacing: 0.09em; text-transform: uppercase; color: var(--accent); margin: 30px 0 6px; }
.ch-label:first-of-type { margin-top: 20px; }

.users { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin: 30px 0 24px; }
.user-card { border: 1px solid var(--line); border-radius: 14px; background: var(--card); padding: 24px 26px; box-shadow: 0 1px 2px rgba(17,24,39,0.04); }
/* A card count that leaves exactly one card alone in the last row (e.g. 4, 7) spans it full-width instead of a dangling gap */
.user-card:last-child:nth-child(3n+1) { grid-column: 1 / -1; }
.user-card > b { display: block; font-size: 16.5px; letter-spacing: -0.01em; color: var(--ink); margin-bottom: 4px; }
.user-card .u-label { display: block; font-size: 11px; font-weight: 650; letter-spacing: 0.09em; text-transform: uppercase; color: var(--muted); margin: 16px 0 6px; }
.user-card ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 5px; }
.user-card li { font-size: 14px; color: var(--soft); padding-left: 14px; position: relative; max-width: none; }
.user-card li::before { content: ""; position: absolute; left: 0; top: 8px; width: 5px; height: 5px; border-radius: 2px; background: var(--accent); }

.journey { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 26px 0 24px; }
.journey .j-step {
  padding: 10px 16px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface);
  font-size: 13.5px; font-weight: 500; color: var(--text2); white-space: nowrap;
}
/* Fixed (not theme-inverting) on purpose: siblings use `var(--surface)`, which correctly
   darkens in dark mode, so `.j-end` needs to stay the one consistently DARK/filled step
   to still read as "current" against them — inverting via var(--ink) would make it turn
   light in dark mode while its surface-colored siblings turn dark, the opposite of the
   intended contrast. Same reasoning as .dwsc-card.dark on the About page. */
.journey .j-step.j-end { background: var(--fixed-dark); color: var(--fixed-dark-text); border-color: var(--fixed-dark); }
.journey .j-arrow { color: var(--muted); font-size: 14px; flex: none; }

.decisions { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 28px; }
/* An odd decision count leaves a lone card in the last row — span it full-width instead of a half-empty row */
.decision:last-child:nth-child(odd) { grid-column: 1 / -1; }
.decision { border: 1px solid var(--line); border-radius: 14px; background: var(--card); padding: 24px 26px 26px; box-shadow: 0 1px 2px rgba(17,24,39,0.04); transition: border-color 0.2s, box-shadow 0.2s; }
.decision:hover { border-color: var(--border-hover); box-shadow: 0 10px 26px -16px rgba(17,24,39,0.14); }
.decision > span { display: block; font-size: 11px; font-weight: 650; letter-spacing: 0.09em; text-transform: uppercase; color: var(--muted); margin: 16px 0 4px; }
.decision > span:first-child { margin-top: 0; }
.decision > b { display: block; font-size: 16px; letter-spacing: -0.012em; color: var(--ink); line-height: 1.35; }
.decision > p { font-size: 14px; color: var(--soft); margin: 0; max-width: none; }

@media (max-width: 880px) {
  .users, .decisions { grid-template-columns: 1fr; }
  .journey { gap: 8px; }
  .journey .j-step { white-space: normal; }
}

/* ---------- Pure Insights project scope (Everpure brand lives here ONLY) ---------- */
.pi article h1, .pi article h2, .pi article h3, .pi .cs-hero h1 { font-family: "Familjen Grotesk", "Inter", sans-serif; }
.pi .eyebrow { color: var(--accent); }
.pi .cs-statement { border-left-color: var(--accent); }
.pi .insight b { color: var(--accent); }
.pi .exp-block .num { background: #FFF5E3; border-color: #ecdfc4; color: var(--accent); }
.pi .ch-label { color: var(--accent); }
.pi .user-card li::before { background: var(--accent); }
.pi .decision > b { font-family: "Familjen Grotesk", "Inter", sans-serif; }
.pi .journey .j-step.j-end { background: var(--accent); border-color: var(--accent); }
.pi .shot { background: #FFF5E3; border-color: #ecdfc4; }

/* ---------- TestSigma project scope: 4-stat results row (page has no 5th stat) ---------- */
.ts .stats { grid-template-columns: repeat(4, 1fr); }

.swatches { display: flex; flex-wrap: wrap; gap: 10px; margin: 18px 0; }
.sw { width: 90px; border-radius: 9px; overflow: hidden; border: 1px solid var(--line); font-size: 10.5px; text-align: center; background: var(--card); }
.sw i { display: block; height: 48px; }
.sw span { display: block; padding: 5px 2px; color: var(--soft); }

/* ---------- Tools marquee ---------- */
#tools { padding-bottom: 60px; }
.marquee {
  margin-top: 40px; overflow: hidden; position: relative;
  mask-image: linear-gradient(90deg, transparent, black 8%, black 92%, transparent);
  -webkit-mask-image: linear-gradient(90deg, transparent, black 8%, black 92%, transparent);
}
.marquee .track { display: flex; gap: 16px; width: max-content; animation: marq 36s linear infinite; padding: 6px 0; }
.marquee:hover .track { animation-play-state: paused; }
.tool {
  flex: none; display: inline-flex; align-items: center; gap: 10px;
  padding: 10px 20px 10px 12px; border: 1px solid var(--line); border-radius: 999px;
  background: var(--card); font-size: 15px; font-weight: 500; color: #4B5563; white-space: nowrap;
  box-shadow: 0 1px 2px rgba(17,24,39,0.04);
}
.tico {
  width: 28px; height: 28px; border-radius: 8px; flex: none; font-style: normal;
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 12px; font-weight: 700; letter-spacing: 0;
  background: var(--surface); border: 1px solid var(--line);
}
.tico.tps { background: #001E36; color: #31A8FF; border: none; }
.tico.tai { background: #330000; color: #FF9A00; border: none; }
.tico.txd { background: #470137; color: #FF61F6; border: none; }
.tico.tin { background: #FF3366; color: #fff; border: none; }
.tico.tmi { background: #FFD02F; color: #050038; border: none; }
/* Dark-mode-only icon fixes. These four tool icons (Figma Make, Framer, ChatGPT, HTML/CSS)
   are the ones that sit on `.tico`'s own default background — every OTHER icon either
   carries its own fixed brand-color circle (.tps/.tai/.txd/.tmi above) or is itself
   multi-colored/saturated enough (Figma, FigJam, Sketch, InVision, Google Analytics,
   Claude, Gemini, Lovable, the AI-Assisted sparkle) to stay visible against a dark
   surface without help. These four use plain black/near-black fill or stroke, which is
   legible on the light default surface but disappears once that surface goes dark. Only
   the icon's own color changes here — background, border, radius, size, spacing,
   typography and the marquee's animation are all untouched, and light mode is completely
   unaffected since these rules only exist inside the dark-theme selector. Notion (also
   near-black, `fill:#111827`) is deliberately NOT touched: its mark sits on its own white
   rect drawn inside the same icon, so it already has guaranteed contrast in either theme —
   recoloring it here would make it disappear into its own background instead. */
:root[data-theme="dark"] .tico.tfigmake svg { stroke: #EDEFF2; }
:root[data-theme="dark"] .tico.tframer svg path { fill: #EDEFF2; }
:root[data-theme="dark"] .tico.tchatgpt svg path { fill: #EDEFF2; }
:root[data-theme="dark"] .tico.thc svg { stroke: var(--muted); }
@keyframes marq { from { transform: translateX(0); } to { transform: translateX(-50%); } }
/* Root cause of "tags visibly repeat": `#tool-track` holds 18 tools in the HTML source,
   doubled to 36 at runtime by the inline script at the end of the #tools section
   (`innerHTML += innerHTML`), purely so the CSS animation above has a second copy to
   scroll into place for a seamless loop. That duplication is invisible during the normal
   scrolling animation, but `prefers-reduced-motion: reduce` (confirmed active on a real
   device via a screen recording — this is not a theoretical setting) stops the animation
   and switches `.track` to `flex-wrap: wrap`, which lays out ALL 36 items as a plain
   static grid — both copies, back to back, so the visitor sees the full 18-tag sequence
   render twice in a row. `:nth-child(n+19)` hides items 19–36 (the duplicate half) only
   in this fallback state; the animated case is completely untouched, so the normal
   scrolling appearance, its speed, and this project's own seamless-loop distance fix
   (tools-marquee.js) are unaffected. */
@media (prefers-reduced-motion: reduce) {
  .marquee .track { animation: none; flex-wrap: wrap; width: auto; }
  .marquee { mask-image: none; -webkit-mask-image: none; }
  #tool-track .tool:nth-child(n+19) { display: none; }
}

/* ---------- Sneak Peek (new, independent component — no shared state with #tools'
   .marquee/.track above; its own class names throughout so #tools stays untouched) ---------- */
/* Same light-theme-only atmospheric wash as the hero (see that comment) — one broad,
   low-alpha blue ellipse on the section's own background layer (the section itself has no
   max-width, so this is already full-bleed edge-to-edge — no breakout needed here), with a
   very gradual fade so there's no visible ring/edge, just enough to separate this section
   from the plain white sections around it. Dark theme untouched. */
:root:not([data-theme="dark"]) #sneak-peek {
  /* Same ry/center-y tuning as the hero mesh — the "transparent" stop is fully reached
     before the section's own bottom edge, so the wash fades away cleanly instead of being
     cut off mid-color where the section ends. Alpha raised (0.15 -> 0.30) to match the hero's
     visibility boost. */
  background: radial-gradient(ellipse 90% 55% at 50% 45%, rgba(37,99,235,0.30), transparent 85%);
}
#sneak-peek .lead { margin-left: auto; margin-right: auto; max-width: 700px; }
.sneak-marquee { margin-top: 8px; overflow: hidden; position: relative; }
.sneak-track {
  display: flex; gap: var(--sneak-gap); width: max-content; padding: 4px 0;
  animation: sneak-marq 216s linear infinite;
  --sneak-w: 432px; --sneak-gap: 34px; --sneak-n: 28;
}
.sneak-marquee:hover .sneak-track { animation-play-state: paused; }
/* Fixed item width + fixed gap makes the loop distance exact (n item-pitches, not a
   measured -50%, which is off by half a gap once you sum an odd item count's flex gaps —
   see tools-marquee.js's own comment for why that shortcut doesn't work here either). */
@keyframes sneak-marq {
  from { transform: translateX(0); }
  to { transform: translateX(calc(-1 * var(--sneak-n) * (var(--sneak-w) + var(--sneak-gap)))); }
}
.sneak-item { flex: none; width: var(--sneak-w); display: block; color: inherit; }
a.sneak-item:hover { text-decoration: none; }
/* Frame carries the border + shadow; the image sits inset inside it with real
   breathing room (padding, not a box-shadow drawn against the image's own edge) — an
   image mounted in a card, not a shadow glued to the artwork. */
.sneak-frame {
  display: block; box-sizing: border-box; aspect-ratio: 4 / 3;
  padding: 14px; border-radius: 18px;
  border: 1px solid var(--line); background: var(--card);
  box-shadow: 0 1px 2px rgba(17,24,39,0.04), 0 14px 32px -18px rgba(17,24,39,0.16);
}
:root[data-theme="dark"] .sneak-frame { border-color: #445066; }
.sneak-thumb {
  display: block; width: 100%; height: 100%; object-fit: cover; object-position: top; border-radius: 10px; background: var(--surface);
}
.sneak-cap { display: block; margin-top: 12px; overflow: hidden; }
.sneak-title { display: block; font-size: 14px; font-weight: 500; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sneak-category { display: block; font-size: 12px; color: var(--soft); margin-top: 2px; }
/* Reduced-motion fallback: was `flex-wrap: wrap`, which — since each card is a fixed
   --sneak-w (228-432px depending on breakpoint) — only ever fits ONE card per row on a
   phone-width viewport, turning the gallery into a very long vertical stack (confirmed on
   a real device, 2026-09-05). A horizontally swipeable strip is the standard touch-device
   equivalent of the desktop marquee: no auto-scroll (motion preference respected), but the
   row stays a row — the visitor swipes through it instead of watching it move. */
@media (prefers-reduced-motion: reduce) {
  .sneak-marquee { overflow-x: auto; -webkit-overflow-scrolling: touch; scroll-snap-type: x proximity; }
  .sneak-track { animation: none; }
  .sneak-item { scroll-snap-align: start; }
  .sneak-item[aria-hidden="true"] { display: none; }
}
@media (max-width: 880px) {
  .sneak-marquee { padding-left: 28px; padding-right: 28px; contain: paint; }
  .sneak-track { --sneak-w: 288px; --sneak-gap: 22px; animation-duration: 154s; }
}
@media (max-width: 480px) {
  .sneak-track { --sneak-w: 228px; --sneak-gap: 17px; animation-duration: 128s; }
  .sneak-title { font-size: 13px; }
  .sneak-category { font-size: 11px; }
}

/* ---------- Recommendations ---------- */
.testis { display: grid; grid-template-columns: repeat(3, 1fr); gap: 22px; margin-top: 40px; }
.testis { align-items: stretch; }
.testi {
  position: relative; border: 1px solid var(--line); border-radius: 16px; background: var(--card); padding: 30px 28px 26px;
  display: flex; flex-direction: column; gap: 22px; overflow: hidden; height: 100%;
  box-shadow: 0 1px 2px rgba(17,24,39,0.04);
  transition: border-color 0.2s, box-shadow 0.25s, transform 0.25s cubic-bezier(0.16,1,0.3,1);
}
.testi:hover { border-color: var(--border-hover); box-shadow: 0 18px 40px -20px rgba(17,24,39,0.18); transform: translateY(-4px); }
.testi .qmark { position: absolute; top: 2px; right: 18px; font-size: 96px; line-height: 1; font-weight: 700; color: rgba(37,99,235,0.08); pointer-events: none; font-family: Georgia, serif; }
.testi .quote { font-size: 14.5px; color: var(--soft); flex: 1; position: relative; }
.testi .who { margin-top: auto; }
.testi .who span { display: block; line-height: 1.45; }
.avatar-img { width: 46px; height: 46px; border-radius: 50%; object-fit: cover; object-position: 50% 25%; flex: none; border: 2px solid #fff; box-shadow: 0 0 0 1px var(--line), 0 4px 10px -4px rgba(17,24,39,0.2); }
.testi .who { display: flex; gap: 14px; align-items: center; }
.testi .avatar {
  width: 44px; height: 44px; border-radius: 50%; flex: none;
  background: linear-gradient(140deg, #E8EDF6, #DBEAFE); color: var(--accent);
  display: flex; align-items: center; justify-content: center; font-weight: 650; font-size: 14px;
}
.testi .who b { display: block; font-size: 14.5px; letter-spacing: -0.01em; }
.testi .who span { font-size: 12.5px; color: var(--soft); }

/* ---------- Design System card cover (CSS art, no fake screenshot) ---------- */
.thumb.ds-cover {
  display: flex; flex-direction: column; justify-content: space-between;
  background: linear-gradient(150deg, #F4F6FA 0%, #E9EEF7 100%);
  padding: 26px 28px;
}
.thumb.ds-cover .ds-type { font-size: 64px; font-weight: 650; letter-spacing: -0.04em; color: var(--ink); line-height: 1; }
.thumb.ds-cover .ds-meta { display: flex; gap: 8px; }
.ds-swatch { width: 26px; height: 26px; border-radius: 8px; border: 1px solid rgba(17,24,39,0.08); }
.ds-swatch.s1 { background: #111827; } .ds-swatch.s2 { background: #2563EB; }
.ds-swatch.s3 { background: #9CA3AF; } .ds-swatch.s4 { background: #E5E7EB; }
.thumb.ds-cover .ds-bars { display: grid; gap: 7px; }
.thumb.ds-cover .ds-bars i { height: 8px; border-radius: 4px; background: rgba(17,24,39,0.10); display: block; transition: width 0.5s cubic-bezier(0.16,1,0.3,1); }
.card:hover .thumb.ds-cover .ds-bars i { background: rgba(37,99,235,0.22); }

/* ---------- Certifications (About page) — curated top 9, 3x3 grid on desktop,
   reusing .cards.archive's own 2-col/1-col responsive breakpoints below. Card visual
   language (border/radius/hover lift) matches .card; background/muted colors use the
   existing --card/--muted tokens (not hardcoded) so dark mode resolves correctly. */
.cert-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 32px; }
.cert-card {
  display: block; border: 1px solid var(--line); border-radius: var(--radius); background: var(--card);
  padding: 22px 24px 20px; color: var(--ink); text-decoration: none;
  transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
}
.cert-card:hover { border-color: var(--border-hover); box-shadow: 0 10px 26px -16px rgba(17,24,39,0.14); transform: translateY(-2px); text-decoration: none; }
.cert-card h3 { font-size: 15.5px; font-weight: 600; letter-spacing: -0.012em; line-height: 1.35; margin-bottom: 8px; }
.cert-card .cert-issuer { font-size: 13px; font-weight: 500; color: var(--soft); margin-bottom: 2px; }
.cert-card .cert-meta { font-size: 12.5px; color: var(--muted); margin-bottom: 14px; }
.cert-card .cert-cta { display: inline-block; font-size: 13.5px; font-weight: 500; color: var(--accent); transition: transform 0.2s cubic-bezier(0.16,1,0.3,1); }
.cert-card:hover .cert-cta { transform: translateX(3px); }
@media (max-width: 880px) { .cert-grid { grid-template-columns: 1fr 1fr; } }
@media (max-width: 700px) { .cert-grid { grid-template-columns: 1fr; } }

/* ---------- Footer ---------- */
footer { border-top: 1px solid var(--line); padding: 44px 0 40px; font-size: 14px; color: var(--soft); }
/* Right column sized to `auto` (its own max-content) rather than a fixed/fr ratio, so
   .foot-meta gets exactly the width its widest line needs and never wraps unexpectedly at
   desktop widths; the left column is minmax(0,1fr) so it simply absorbs whatever space
   remains and wraps its own tagline normally if that space is tight. This is a structural
   sizing fix, not a content-length workaround — it holds regardless of how long .foot-meta's
   text is, as long as it fits the .wrap's own 1120px cap.
   .foot-meta is deliberately two explicit lines (nav + location, then copyright alone) via
   two block-level .foot-line children — not width-triggered wrapping — so it always matches
   the left column's own two lines (identity, then tagline) regardless of viewport width.
   Plain block stacking with no extra margin/gap, same as the left column's <br>, so the
   vertical gap between both columns' two lines matches exactly. */
footer .frow { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 48px; align-items: start; }
footer .foot-line { white-space: nowrap; }
@media (max-width: 1000px) { footer .frow { grid-template-columns: minmax(240px, 1fr) minmax(240px, 1fr); gap: 32px; } footer .foot-line { white-space: normal; } }
@media (max-width: 700px) { footer .frow { grid-template-columns: 1fr; gap: 24px; } }
footer a { color: var(--soft); }
footer a:hover { color: var(--ink); }

/* ---------- Resume page ---------- */
.resume-block { border: 1px solid var(--line); border-radius: 16px; padding: 32px 48px 44px; margin: 56px 0 64px; background: var(--card); }
/* Selected Projects / Personal & Systems Work links only (the only <a> tags inside
   .resume-block's <ul> lists) — scoped so the base a/a:hover rule (still governing
   footer links etc. elsewhere) is untouched. Color-only hover, no background chip:
   these are inline links inside running list text, so a padded background container
   would visually disrupt the line. Keyboard focus stays visible via the existing
   global :focus-visible rule, unaffected by this change. */
.resume-block ul a { color: var(--accent); text-decoration: none; transition: color 180ms ease; }
.resume-block ul a:hover, .resume-block ul a:focus-visible { color: #1D4ED8; text-decoration: none; }
.resume-block h2 { font-size: 20px; letter-spacing: -0.015em; }
.resume-block h3 { margin: 28px 0 4px; font-size: 16.5px; }
.resume-block .role-meta { font-size: 13px; color: var(--soft); font-weight: 500; margin-bottom: 8px; font-variant-numeric: tabular-nums; }
.resume-block p, .resume-block li { font-size: 15px; color: var(--text2); }
.resume-block ul { padding-left: 20px; display: grid; gap: 7px; margin: 10px 0; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0; }
.chip {
  background: var(--surface); border: 1px solid var(--line); border-radius: 999px; padding: 5px 13px; font-size: 13px; color: var(--soft);
  transition: background-color 180ms ease, border-color 180ms ease, transform 180ms ease, box-shadow 180ms ease;
}
/* hover-capable pointers only, so touch devices never get a stuck/forced hover state */
@media (hover: hover) and (pointer: fine) {
  .chip:hover { background: rgba(37,99,235,0.08); border-color: var(--accent); transform: translateY(-1.5px); box-shadow: 0 3px 8px -4px rgba(37,99,235,0.28); }
}

@media (max-width: 880px) {
  section { padding: 60px 0; }
  /* #how-i-design's own -126px margin-bottom (see the rule itself, further up this file)
     is calibrated to compensate for the desktop card stack's compact negative-margin
     overlap (cards overlapping upward by -26px each, in .hid-card:not(:first-child)) — it
     pulls the next section up to sit at the correct visual distance despite that overlap
     making the section's content shorter than its cards' combined height would suggest.
     The ≤700px query resets that overlap to a plain +14px gap, but never touched this
     margin, so on mobile that same -126px instead eats into real content, pulling
     #experience up over the last card and the "My Journey" heading beneath it (confirmed
     via measured getBoundingClientRect: the two sections overlapped by exactly 126px).
     Resetting it to 0 here lets the section's height follow its actual, now
     non-overlapping, mobile content. Placed in this later (880px) block deliberately —
     placing it in the ≤700px block above lost the specificity tie against the original
     rule, since that block appears earlier in the file than the original declaration. */
  #how-i-design { margin-bottom: 0; }
  .split { grid-template-columns: 1fr; gap: 32px; }
  /* The BASE (unconditional, desktop) .hero rule further up this file is
     `padding: 48px 0 64px` — a 3-value shorthand, so its middle 0 sets BOTH left and right
     padding, always, at every width. .hero is the one place on the site where the `.wrap`
     class and a section-specific class land on the very same element
     (<div class="wrap hero">, not a nested .wrap inside a section as everywhere else), so
     that 0 silently overrides .wrap's own `padding: 0 28px` — same specificity, .hero's
     rule later in the file, so it wins, at every width, not just mobile. Since .wrap's
     max-width already caps and centers the content at desktop widths, the zeroed side
     padding is invisible there regardless (nothing to fix, nothing changed); at mobile
     widths, where .wrap actually fills the viewport, that same 0 left the hero content
     genuinely flush against the screen edges — confirmed via getComputedStyle showing
     paddingLeft/Right: 0px through the whole ancestor chain down to the hero text, while
     every other section's .wrap (a separate nested div there) correctly read 28px.
     Explicitly restoring 28px here (matching .wrap's own value, not a new number) is
     necessary, not optional — simply omitting left/right from this override still leaves
     the base rule's unconditional 0 in effect, since that base rule isn't gated by any
     media query and nothing here removes it, only this mobile block's own declarations. */
  .hero { padding-top: 72px; padding-bottom: 48px; padding-left: 28px; padding-right: 28px; }
  .hero-grid { grid-template-columns: 1fr; gap: 36px; }
  /* Root cause of the squeezed-left/empty-right-gap portrait on mobile: this rule used to
     cap `.portrait` (the `<img>`) at `max-width:240px`, narrower than its own wrapper
     (`.portrait-wrap`/`.portrait-flip`, both capped at 300px). `.portrait-flip-front .portrait`
     is `position:absolute; inset:0` (a later, more specific rule) — but `inset:0` only makes
     an absolutely positioned box stretch to fill its container when `width` is `auto`; once
     `max-width` gives it a definite (smaller) used width, `left:0` wins and `right:0` is
     silently overridden, so the image renders 240px wide, pinned to the left edge of its own
     box, with the rest of that box's width sitting empty on the right — exactly the reported
     symptom. `.portrait-wrap` also capped itself at 300px regardless of how much wider the
     mobile content column actually was, leaving a further gap on wider phones (390–430px,
     where the gutter-to-gutter column is 334–374px). Removing both caps and using the full
     available width (the base rules' own `width:100%` then does the work) makes the image
     fill the same gutter-to-gutter column as the hero text above it, at its own aspect ratio
     — not a fixed size disconnected from the viewport. */
  .portrait-wrap { justify-self: start; max-width: none; }
  .portrait, .portrait-flip { max-width: none; }
  /* Decorative background scaffolding (glow, the two floating outlined rectangles, the
     frosted-glass chip) is tuned for the desktop composition — sized and offset (some with
     negative insets) to sit around a fixed 300px desktop portrait. On mobile, now that the
     portrait fills the full content width, the same offsets read as stray pale shapes behind
     and beside the photo rather than a deliberate accent, which is what the screenshot showed.
     `.p-dot` already has its own `display:none` at 700px above; these three get the same
     treatment here. Desktop (>880px) is untouched — this is scoped entirely inside this
     mobile block. */
  .portrait-wrap .p-glow, .portrait-wrap .p-rect, .portrait-wrap .p-glass { display: none; }
  /* Purely decorative background watermark (aria-hidden), tuned for the desktop grid's
     proportions. Once the grid stacks to one column its containing block's height grows
     to the full stacked-content height, so its own top:50% formula lands the (faint but
     visible, stroked) giant text over real content — e.g. the portrait — instead of
     staying a subtle backdrop. Hiding it on mobile removes the overlap outright rather
     than chasing a repositioning formula for a non-essential flourish. */
  .hero-name-marquee { display: none; }
  .stats { grid-template-columns: 1fr 1fr; }
  .cards, .cards.three, .caps, .duo, .duo.phones, .testis { grid-template-columns: 1fr; }
  .cards.three .card, .cards.three.five .card:nth-child(4), .cards.three.five .card:nth-child(5) { grid-column: auto; }
  .jrny-toggle { gap: 12px; padding: 14px 10px; flex-wrap: wrap; }
  .jrny-role { font-size: 14.5px; }
  .jrny-desc-inner { padding: 6px 10px 22px; }
  .contact { padding: 40px 28px; }

  /* ---------- Mobile hamburger nav panel ---------- */
  /* Architecture change: the menu is now a true DROPDOWN anchored to the pill itself
     (`position: absolute` inside `.nav`, which gets `position: relative` below), not a
     viewport-fixed overlay. Earlier rounds fixed a real fixed-position-with-backdrop-filter
     containing-block bug and, separately, a real off-center-paint bug in that fixed-overlay
     model — but the model itself (a full-viewport-anchored card behind a full-screen
     backdrop) was still the wrong shape for what this nav is supposed to be: a small dropdown
     that unfolds directly under the pill, not a modal overlay. Anchoring to `.nav` directly
     removes the entire class of "is it centered on the viewport" question — the dropdown is
     positioned relative to the pill, and the pill is already reliably centered, so the
     dropdown inherits that centering for free instead of re-deriving it against the viewport.
     `backdrop-filter` stays off on mobile: with `.nav` now `position: relative` that's no
     longer needed to fix a containing-block bug (relative already establishes one on its
     own), but the blur is still an unnecessary visual on the enlarged mobile pill. */
  .nav { backdrop-filter: none; -webkit-backdrop-filter: none; position: relative; }
  /* Closed pill enlarged to match the reference's proportions — the compact icon-only pill
     (34px avatar + 34px hamburger, tight padding) measured visibly smaller than the
     reference's own closed pill (avatar/control ~40px, ~56px tall, with a short status
     label filling the width between them) when compared directly against it. `.nav-status`
     is that label (own wording — "Open to work", not the reference's own copy — reusing the
     same pulsing-dot treatment as the hero's `.badge`), shown only here so desktop's
     icon-only pill is untouched. */
  .nav { padding: 8px; gap: 10px; }
  .nav .brand { width: 40px; height: 40px; }
  .nav-status {
    display: inline-flex; align-items: center; gap: 6px;
    font-size: 13px; font-weight: 500; color: var(--soft); white-space: nowrap;
  }
  .nav-status::before {
    content: ""; width: 7px; height: 7px; border-radius: 50%; flex: none;
    background: var(--success); box-shadow: 0 0 0 3px rgba(16,185,129,0.15);
  }
  .nav-toggle-input {
    display: inline-block; position: absolute; opacity: 0; width: 1px; height: 1px; pointer-events: none;
  }
  /* `position: relative` + a z-index above the panel (60) and backdrop (55) below is
     required, not decorative: this label is a plain in-flow flex item, and per CSS painting
     order a `position: fixed` descendant of the same stacking context always paints above an
     in-flow non-positioned one regardless of DOM order — confirmed live via
     `document.elementFromPoint` on the icon's own center, which returned `.nav-backdrop`, not
     this label, while the menu was open. That's why the hamburger/× control looked and acted
     completely missing: the open panel was both visually covering it and intercepting its
     taps. */
  .nav-toggle-label {
    display: flex; flex-direction: column; align-items: center; justify-content: center;
    gap: 5px; width: 40px; height: 40px; border-radius: 50%; flex: 0 0 auto; cursor: pointer;
    position: relative; z-index: 61;
  }
  .nav-toggle-label span {
    display: block; width: 20px; height: 2px; border-radius: 2px; background: var(--ink);
  }
  /* The open/closed VISUAL state is driven by the `html.nav-open` class (toggled by
     script.js on the checkbox's `change` event), not by `:checked ~` sibling selectors.
     This was a real, confirmed finding, not a stylistic preference: with every property
     that matters here (position, right/transform, background) driven off
     `.nav-toggle-input:checked ~ ...`, the DECLARED/computed values were verified correct
     (getComputedStyle matched exactly what the CSS says) in every configuration tried —
     position:fixed, position:absolute, transform-based, right-based — yet the actual
     painted pixels did not: the panel rendered as a narrow, misplaced, see-through strip
     with page content bleeding through it, in this project's own tooling AND independently
     in a real screen recording of Chrome's device toolbar, persisting for seconds (not a
     mid-transition frame). That points to the `:checked` general-sibling-combinator
     mechanism itself failing to reliably repaint under Chrome's mobile device-emulation,
     not to any specific position/offset property. Driving the same properties off a plain
     class on <html> — set synchronously in JS the moment the checkbox's state changes —
     sidesteps that failure mode entirely. The checkbox itself is kept only for its
     semantics/keyboard operability (Tab + Space still toggles it, firing `change` the same
     way a click does); it no longer drives any visual state directly. */
  /* Travel distance is bar-height + gap (2px + 5px = 7px) — kept in sync with
     .nav-toggle-label's gap above so the × forms cleanly centered, not the old 6px tuned
     for the previous 4px gap. */
  html.nav-open .nav-toggle-label span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
  html.nav-open .nav-toggle-label span:nth-child(2) { opacity: 0; }
  html.nav-open .nav-toggle-label span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
  /* `.nav-backdrop` is intentionally NOT shown here (it stays `display:none` from the base
     rule at every breakpoint). A dropdown attached to the pill doesn't get a full-screen
     dark scrim in the reference interaction this is modeled on, and a fixed full-viewport
     element — dark or fully transparent — is exactly the kind of layer that has repeatedly
     been the source of this project's horizontal-overflow/off-center bugs, so removing it
     entirely (rather than making it invisible but still present) removes that whole risk
     category outright. The label element stays in the HTML across all 13 pages (harmless,
     permanently hidden) so no markup had to change; tap-outside-to-close is now handled by a
     document-level click listener in script.js instead (see `setMenuOpen` there). */
  /* The dropdown is `position: absolute` against `.nav` (the pill, now `position: relative`
     above) — not `position: fixed` against the viewport. This is a deliberate change, not a
     revert of the earlier fixed-vs-absolute finding: that finding was about an off-canvas
     drawer whose CLOSED state sat far outside its own box at `right:-400px`, and an absolute
     descendant that renders outside its containing block DOES count toward the document's
     scrollable area even while invisible — that failure mode doesn't apply here because this
     dropdown is never positioned off-canvas in either state. Closed and open share the exact
     same footprint (directly under the pill, horizontally centered on it); only opacity and a
     small scale/translateY change between the two, so there is nothing for this element to
     contribute to `scrollWidth` beyond its own on-screen footprint, which — like the previous
     fixed version — is still capped at `100vw - 40px` by `width: min(340px, calc(100vw -
     40px))` and centered on the pill's own horizontal center via `left:50%` +
     `transform: translateX(-50%)` (resolved against `.nav`'s box, not the viewport, but `.nav`
     itself is reliably centered, so the dropdown is too). `top: calc(100% + 10px)` places it
     a small gap below the pill's own bottom edge — following the pill wherever it is,
     including under header's `position: sticky`, rather than a hard-coded distance from the
     top of the viewport. */
  .nav ul {
    position: absolute; top: calc(100% + 10px); left: 50%; width: min(340px, calc(100vw - 40px));
    height: auto; max-height: min(520px, calc(100vh - 140px));
    flex-direction: column; align-items: center; justify-content: flex-start;
    gap: 7px; padding: 20px 24px 24px;
    background: var(--card); border-radius: 24px;
    box-shadow: 0 20px 48px -14px rgba(17,24,39,0.28), 0 2px 14px -4px rgba(17,24,39,0.08);
    pointer-events: none; overflow-y: auto;
    /* Opacity + scale/translateY from the top, not a side slide — the dropdown unfolds
       downward from the pill, matching how the pill itself sits (it never moves). Solid
       `#fff` (not a translucent rgba) plus `will-change` promoting this to its own
       compositing layer before the transition starts — a real, repeated symptom in this
       project's own testing tooling was the panel painting partially see-through with page
       content bleeding through for up to a second after opacity/transform were already
       correct by every computed-style check, self-correcting the instant anything forced a
       repaint. That's a stale-composite artifact, not a layout bug, but `will-change` is the
       standard, low-cost mitigation for exactly this class of opacity+transform compositing
       lag, so it's applied here rather than left to chance. */
    opacity: 0; transform: translateX(-50%) translateY(-6px) scale(0.96); transform-origin: top center;
    will-change: opacity, transform;
    z-index: 60;
  }
  html.nav-open .nav ul { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); pointer-events: auto; }
  .nav ul li { width: 100%; }
  .nav ul a { width: 100%; padding: 15px 14px; font-size: 16px; border-radius: 10px; text-align: center; }
  /* The sliding pill is a link-width highlight tuned for a horizontal row; in the vertical
     panel a flat fill on the active row reads correctly instead. */
  .nav-active-pill { display: none; }
  .nav ul a.active { background: var(--accent); color: #fff; }
  /* Download Resume: the compact mobile header only has room for avatar + hamburger, so the
     desktop-style pill button (a direct child of .wrap.nav, the last item in the row) is
     hidden here — `.nav > .btn` targets only that direct child, not the one below, which
     lives nested inside a dedicated <li> at the bottom of the panel instead, reusing the
     site's existing .btn look rather than the plain link row styling above. Centered and
     sized to its own content (not full-width) to read as the same kind of centered pill CTA
     as the reference's own bottom action, not a full-bleed bar. */
  .nav > .btn { display: none; }
  .nav-download { display: flex; justify-content: center; width: 100%; margin-top: 12px; padding-top: 16px; border-top: 1px solid var(--line); }
  .nav-download .btn {
    display: inline-flex; justify-content: center;
    padding: 13px 30px; border-radius: 999px; font-size: 15px;
  }
  .theme-toggle-desktop { display: none; }
  /* Theme toggle's mobile home. Previously lived as its own row inside the dropdown —
     moved out per explicit feedback: a control the user reaches for constantly (theme)
     shouldn't require opening the menu first. Now a standalone circle positioned
     `left: 100%` against `.wrap.nav` itself (already `position: relative`, for the
     dropdown) — NOT a viewport-relative offset. That distinction matters: the pill is
     horizontally centered and its own rendered width is effectively constant across
     every required mobile width (avatar + status text + hamburger never changes), so
     anchoring to the pill's own right edge keeps this button "immediately to its right"
     at every width without hardcoding a viewport-dependent position, and it automatically
     stays correct if the pill's content (and therefore width) ever changes. The pill's
     own box, avatar, "Open to work" label, and hamburger are completely untouched — this
     adds a sibling, not a modification. */
  .theme-toggle-standalone {
    display: flex; position: absolute; top: 50%; left: 100%;
    transform: translateY(-50%); margin-left: 8px;
    width: 36px; height: 36px; border-radius: 50%;
    background: var(--nav-bg); backdrop-filter: blur(14px) saturate(1.4); -webkit-backdrop-filter: blur(14px) saturate(1.4);
    border: 1px solid var(--line); box-shadow: 0 14px 34px -18px rgba(17,24,39,0.18), 0 2px 10px -4px rgba(17,24,39,0.06);
    z-index: 40;
  }
  /* Locks the underlying page's scroll while the panel is open — set/cleared by
     script.js's `change` listener on the checkbox. */
  html.nav-open { overflow: hidden; }
  /* Chrome's mobile device-emulation (DevTools device toolbar, and any CDP-driven viewport
     override) has a real, reproducible quirk: on the very first paint under an emulated
     viewport, a `transition` on a property whose value differs between the base rule and a
     media-query override freezes that property at its pre-media-query value instead of
     applying the media query's value — confirmed independently in a real screen recording of
     Chrome's device toolbar. Fix: no element in this mechanism carries a `transition` until
     this class is present, and script.js adds it a moment after the DOM parses — long before
     any human can tap the hamburger, but late enough to never be present for that very first
     emulated paint. */
  html.nav-ready .nav ul { transition: opacity 0.26s ease, transform 0.3s cubic-bezier(.22,1,.36,1); }
  html.nav-ready .nav-backdrop { transition: opacity 0.28s ease; }
  html.nav-ready .nav-toggle-label span { transition: transform 0.25s ease, opacity 0.25s ease; }
  .resume-block { padding: 30px 24px; }
  .exp-block { grid-template-columns: 1fr; gap: 8px; }

  /* ---------- Tools marquee: mobile left/right gutter ---------- */
  /* `.marquee` is a full-bleed sibling of `#tools .wrap` by design (an infinite horizontal
     ticker has to run edge-to-edge to loop) — confirmed live: `.marquee` has 0 left/right
     padding at every width, so the tag pills scroll flush against the true screen edge while
     the "My Design Toolkit" heading/paragraph above them sit inside the standard 28px `.wrap`
     gutter. Insetting only the masked viewport by that same 28px lines the visible tags up
     with the heading without touching the marquee's own component: the track's width,
     animation, gap, tag styling and infinite-loop mechanism are all untouched — this only
     moves where the `overflow:hidden` window (and its edge mask-fade) starts. */
  .marquee { padding-left: 28px; padding-right: 28px; }
  /* `contain: paint` gives `.marquee` its own, more rigorously enforced clipping/paint
     boundary for the animated 5700px+ `.track` inside it — plain `overflow: hidden` on an
     ancestor of a `transform`-animated descendant is a known category of mobile-browser
     compositing edge case (the descendant can end up promoted to its own compositing
     layer and, on some engines, briefly paint outside that ancestor's clip during the
     animation, even though every computed style says it shouldn't). `contain: paint` is
     the standard, purpose-built containment for exactly this. */
  .marquee { contain: paint; }
  /* A real screen recording from an actual phone (not this project's own testing tooling)
     showed a genuine horizontal scrollbar and the whole page shifted sideways — repeated,
     careful DOM sweeps at every required width (every element's rendered rect checked
     against the viewport, both at rest and mid-animation) turned up nothing beyond the
     marquee's own off-screen ticker items, which `overflow: hidden` already excludes from
     `scrollWidth`. That gap between "nothing measurable is wrong" and "the rendered result
     scrolls sideways" points at a real browser-level rendering quirk this project's own
     tooling cannot reproduce (most plausibly tied to the marquee's continuously-animated,
     very wide track, possibly interacting with `prefers-reduced-motion` state on that
     device), not a fixable single CSS property. This is the outer viewport clip for that:
     `.marquee` is the one element on this page that is deliberately wider than the
     viewport, and this contains it at the document root — the page itself can never
     become horizontally scrollable regardless of what the marquee's internal layout does,
     while the marquee's own visible animation inside its already-clipped box is completely
     unaffected. Mobile-only, inside this same breakpoint — desktop is untouched. */
  html, body { overflow-x: hidden; }
}
