:root {
  /* color — light theme, sourced from Figma "Color - Semantic" collection.
     Three text colors all carry meaning:
     --text       titles + inline bold spans
     --text-mid   body paragraph base
     --text-muted nav meta lines + breaker callout text                            */
  --bg:           #FFFFFF;  /* neutral/surface/surface — page background */
  --bg-warm:      #FAF9FB;  /* neutral/surface/ground — alt off-white */
  --bg-inset:     #F2EFF3;  /* neutral/surface/inset — image placeholder fill */
  --text:         #211F26;  /* neutral/surface/high-foreground */
  --text-mid:     #65636D;  /* neutral/surface/foreground */
  --text-muted:   #8E8C99;  /* neutral/solid/background */
  --border:       #DBD8E0;  /* neutral/border/default */

  /* type — only the 4 styles found in home + case studies */
  --font-family: 'Helvetica Neue', 'Inter', 'Helvetica', 'Arial', sans-serif;

  /* label/lg/bold — name, project title, section headings */
  --type-title-size:    16px;
  --type-title-lh:      24px;
  --type-title-weight:  700;
  /* body/lg/regular — paragraphs */
  --type-body-size:     16px;
  --type-body-lh:       28px;
  --type-body-weight:   400;
  /* label/md/regular — meta lines (years, industry) */
  --type-meta-size:     14px;
  --type-meta-lh:       20px;
  --type-meta-weight:   400;
  /* label/md/medium — eyebrow / inline subhead, nav section labels */
  --type-eyebrow-size:    14px;
  --type-eyebrow-lh:      20px;
  --type-eyebrow-weight:  500;

  /* spacing — Figma size/* values used in home + case studies */
  --space-2:  2px;
  --space-4:  4px;
  --space-12: 12px;
  --space-16: 16px;
  --space-24: 24px;
  --space-32: 32px;
  --space-64: 64px;

  /* layout */
  --page-width:           1728px;
  --col-text:              640px;  /* case study text column */
  --col-content:           974px;  /* case study pageContent outer container */
  --rail-width:            202px;  /* rail width (and locked work-item anchor width) */
  --rail-offset:           165px;  /* rail left edge from page frame */
  --plsr-inset:             24px;  /* home: PLSR mark inset from frame edges */
  --case-content-x:        589px;  /* case: pageContent left edge from frame edge */
  --case-content-top:      172px;  /* case: pageContent + side nav top */
  --back-arrow-offset:      28px;  /* case: distance from rail toggle to rail left edge */

  /* borders / radii. `--radius-xl 12px` on image + video containers,
     `--radius-sm 4px` on the passgate input. The video frame's 4px
     dark band is the FRAME_INSET in makeImagePlaceholder — it's the
     padding inside .video-frame's bg, set as inline percentages.
     The page header's 1px divider uses `var(--border)` from the
     color block above. */
  --radius-sm: 4px;
  --radius-xl: 12px;
}

* {
  box-sizing: border-box;
  /* iOS tap-highlight: the grey overlay that flashes on tap. We've
     disabled hover-driven visuals on touch (CSS @media + JS gates),
     so the tap-highlight was the last source of visual noise on
     gap-routing taps. Universal selector + !important because the
     property doesn't reliably inherit on WebKit and some user-agent
     stylesheet rules set it on specific elements. */
  -webkit-tap-highlight-color: transparent !important;
}

html {
  scroll-padding-top: 72px;  /* nav-link jumps land 72px below viewport top */
}

html, body {
  margin: 0;
  padding: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-family);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
  /* Defensive safety net against horizontal scrollbar at narrow
     viewports. Image placeholders now size fluidly (see
     makeImagePlaceholder in script.js + .case-block--image's
     align-self: stretch), so this shouldn't trigger in normal use —
     kept as insurance against any future fixed-width descendant
     accidentally overflowing. Vertical scroll unaffected. */
  overflow-x: hidden;
}

.app {
  position: relative;
  min-height: 100vh;
}

/* page visibility — driven by [data-page] on <html> (set inline before paint) */
[data-page="home"] .page-case { display: none; }
html:not([data-page="home"]) .page-home { display: none; }

/* shared text styles */
.title, .meta {
  margin: 0;
  font-family: var(--font-family);
}
.title {
  font-size: var(--type-title-size);
  line-height: var(--type-title-lh);
  font-weight: var(--type-title-weight);
  color: var(--text);
}
.meta {
  font-size: var(--type-meta-size);
  line-height: var(--type-meta-lh);
  font-weight: var(--type-meta-weight);
  color: var(--text-muted);
}

/* body paragraph — used inside case content + breaker text */
.body {
  margin: 0;
  font-size: var(--type-body-size);
  line-height: var(--type-body-lh);
  font-weight: var(--type-body-weight);
  color: var(--text-mid);
}
.body strong {
  font-weight: 500;
  color: var(--text);
}

/* page stage */
.page-stage {
  position: relative;
  z-index: 1;
}
.page {
  position: relative;
}

/* ───────── sidebar item hover (work + Me, in both sidebars) ─────────
   Coordinated hover: when an item is hovered, it scales up + gets a dot
   indicator on the left; siblings above translate up, siblings below
   translate down (push to "make room" for the scaled-up hovered item);
   non-hovered siblings dim. JS (wireRailItemHover in script.js) toggles
   the .is-hovered / .is-above-hovered / .is-below-hovered classes based
   on cursor y position — so hover is continuous across the gap between
   items (no dead zone). The transition's spring easing
   (cubic-bezier with y-peak > 1) gives a slightly springy
   "draw" feel on the hover-in scale and underline reveal.

   Live-tunable knobs (set via dev panel, with CSS fallbacks here):
   - --hover-scale                 (1.04)  hovered item's grow factor
   - --hover-push                  (20px)  sibling translateY on push
   - --hover-dim                   (0.3)   non-hovered opacity
   - --hover-underline-thickness   (1px)   title underline thickness
   - --hover-underline-offset      (2px)   distance from baseline
   - --hover-duration              (300ms) transition duration */
.rail .item {
  position: relative;
  transform-origin: left center;
  transition:
    transform var(--hover-duration, 300ms) cubic-bezier(0.34, 1.3, 0.64, 1),
    opacity var(--hover-duration, 300ms) ease-out;
}

/* Hovered item: scale up + title underline reveals */
.rail .item.is-hovered {
  transform: scale(var(--hover-scale, 1.04));
  opacity: 1;
}
/* Underline on the title only (not the meta). Drawn via a pseudo-element
   that scales from 0 to 1 on the x-axis with `transform-origin: left` —
   reads as a left-to-right draw animation rather than a color fade. The
   .title gets `align-self: flex-start` so its box is content-width (not
   stretched to parent), and the pseudo's `left: 0; right: 0` gives the
   underline the same width as the rendered text. The hover-in transition
   (defined on the pseudo below) uses a spring with overshoot for a
   slightly springy "draw" feel; the click-triggered retract uses a
   slower ease-out via the .is-retracting-underline override (further
   below). */
.rail .item .title {
  position: relative;
  align-self: flex-start;
}
.rail .item .title::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: calc(var(--hover-underline-offset, 2px) * -1);
  height: var(--hover-underline-thickness, 1px);
  background: var(--text-muted);
  transform: scaleX(0);
  transform-origin: left center;
  transition: transform var(--hover-duration, 300ms) cubic-bezier(0.34, 1.3, 0.64, 1);
}
.rail .item.is-hovered .title::after {
  transform: scaleX(1);
}
/* Decoupled retract duration. clearHoverState (script.js) adds
   .is-retracting-underline to the click source RIGHT BEFORE
   stripping .is-hovered, so the CSS-resolved transform change
   (scaleX(1) → scaleX(0)) animates with this transition rule
   (overriding the hover-in spring above). The class is removed
   ~retract-duration later by a setTimeout, so subsequent hover-in
   uses the original spring transition. ease-out (not the spring
   easing) gives a smooth deceleration without the overshoot — the
   spring is great for hover-in's "draw" feel but reads as snap
   on retract. */
.rail .item.is-retracting-underline .title::after {
  transition: transform var(--hover-retract-duration, 600ms) ease-out;
}

/* Siblings above / below the hovered item: dim + push to make room */
.rail .item.is-above-hovered {
  transform: translateY(calc(var(--hover-push, 20px) * -1));
  opacity: var(--hover-dim, 0.3);
}
.rail .item.is-below-hovered {
  transform: translateY(var(--hover-push, 20px));
  opacity: var(--hover-dim, 0.3);
}

/* Cursor: when the JS hover system is tracking a target, show the pointer
   cursor across the whole group so gap-area cursors match what the JS
   already routes for clicks. */
.rail .rail-nav.has-hover-active {
  cursor: pointer;
}

/* During a GSAP-driven rail transition, kill the .rail .item base CSS
   transition so the browser doesn't interpolate alongside GSAP's
   frame-by-frame inline-style updates. The .rail .item base rule has
   `transition: transform ..., opacity ...` for hover effects; without
   this override, GSAP's tweens fight the CSS transition (notably
   visible on Me's toggle expand where dy is largest — the morph
   "jumps" because the CSS transition is interpolating between GSAP's
   frame-by-frame inline transform updates). The class is added at the
   start of every runGsap* function and removed in cleanup. */
html.is-gsap-active .rail .item {
  transition: none;
}

/* Toggle-hover dim: when the user hovers the .nav-toggle (X / arrow),
   the dimmable elements in the rail fade to focus visual attention
   on the toggle — mirrors the item-hover dim model (.is-above-hovered
   / .is-below-hovered) and reuses the same transition path on each
   target. JS-driven via wireToggleHover, which adds .is-toggle-hovered
   to .rail on mousemove (gated on !transitionInFlight) and removes on
   mouseleave. The class is also cleared at the start of toggleRailSafari
   / handleRouteClickSafari so the dim doesn't carry into the morph
   (otherwise the rail would render dimmed during the transition).
   We dim the LEAF elements directly (each .item, each .nav-section,
   the .name-block) rather than their containers (.rail-nav, .sections-
   list). Container-level opacity creates a stacking context that
   composites children as a group, which interacts oddly with the
   children's own opacity transitions and reads as a "2-step" dim.
   Per-leaf opacity uses each leaf's existing transition rule (items
   already transition opacity for .is-above-hovered, sections likewise)
   so it's a single smooth transition per element.
   .nav-mark-link (PLSR home button) is NOT dimmed — it's a primary
   nav action, peer to the toggle, not a passive sibling. */
/* .name-block has only an opacity transition (used for hover dim
   and GSAP-controlled fade in/out during T2/T7). Transform is no
   longer animated by CSS here — hover-push lives on the wrapping
   .name-block-pusher, so when GSAP morphs .name-block's transform
   (password unlock, T1, T2, T7), there's no CSS-side transform
   transition to fight. */
.rail .name-block {
  transition: opacity var(--hover-duration, 300ms) ease-out;
}
html.is-gsap-active .rail .name-block {
  transition: none;
}
/* Pusher wrapper carries the hover-push transform. Lives on a
   different element than the morph target (.name-block) so the
   two transforms compose visually: hover during a morph applies
   the push to the pusher while GSAP keeps animating the inner
   name-block, and they stack via DOM ancestry. No more shared-
   property fight, no more snap, no more staggered effect. */
.rail .name-block-pusher {
  transition: transform var(--hover-duration, 300ms) cubic-bezier(0.34, 1.3, 0.64, 1);
}
.rail.is-toggle-hovered .name-block,
.rail.is-toggle-hovered .item,
.rail.is-toggle-hovered .nav-section {
  opacity: var(--hover-dim, 0.3);
}

/* Header push when a rail-nav item is hovered. The nav-mark (case
   states) and name-block (home state) are mutually exclusive (only
   one shown at a time per rail state), but the rule targets both —
   the hidden one's transform is harmless. Translate matches the
   work-item siblings' push amount via --hover-push so the entire
   "above the hover target" column slides up as one. No dim — the
   logo's job is to be seen, and the toggle-hover cascade above
   already covers the dim case for the nav-mark/name-block when the
   user is engaging with the back-arrow. Section-nav hover does NOT
   trigger this — sections are an independent component, the rail
   header doesn't track their hover state. */
.rail .nav-mark-link {
  /* No is-gsap-active override here even though .name-block has one
     a few rules above. Reason: GSAP only animates nav-mark's OPACITY
     (T1 home→case + T2 case→home fade in/out); never its transform.
     Killing the transform transition during is-gsap-active was over-
     cautious and made the hover-push release SNAP instead of animate
     when clearHoverState strips has-hover-active at click time —
     visible as the logo "jumping" between routes. The CSS opacity
     transitions aren't defined on this rule anyway, so GSAP's
     opacity tweens are unaffected. */
  transition: transform var(--hover-duration, 300ms) cubic-bezier(0.34, 1.3, 0.64, 1);
}
.rail:has(.rail-nav.has-hover-active) .nav-mark-link,
.rail:has(.rail-nav.has-hover-active) .name-block-pusher {
  transform: translateY(calc(var(--hover-push, 20px) * -1));
}

/* ───────── custom cursor ─────────
   Hides the native cursor everywhere and renders a custom hand-drawn
   cursor (assets/cursor/*) instead. The element is fixed-positioned and
   translated via JS to follow the mouse 1:1. Module: cursor.js — see
   that file for state machine, hotspot interpolation, and the recheck
   backstop after VT navigation.

   `* { cursor: none !important }` is the natural way to suppress the
   native cursor, but Chromium and Safari each have their own quirks
   around interaction states (Chromium: app-focus handoff briefly
   shows system cursor; Safari: `:active`/click momentarily reveals
   default arrow). Including the state pseudo-classes (`:active`,
   `:focus`, `:hover`) explicitly catches WebKit's edge cases where
   the universal `*` doesn't fully apply during click / focus.

   Both quirks reduce to "the OS/browser sometimes shows the system
   cursor for accessibility / UI-feedback reasons regardless of what
   CSS says." There's no airtight CSS-only fix; the only complete
   solution is the Pointer Lock API which has trade-offs.

   --cursor-size is set live by cursor.js (mirrored from its config so
   the dev panel can tune it). Defaults to 100px to match cursor.js. */
*,
*:active,
*:focus,
*:hover {
  cursor: none !important;
}

/* Reading mode: revert to the OS cursor when over the article body
   (.case-content). The custom hand cursor reads as "navigation tool"
   and doesn't match the reading mode.
   `cursor: default` (not `auto`): forces the OS arrow consistently
   across all elements in the article. `auto` would let the OS pick
   per element — I-beam over text, arrow over images/breakers — which
   creates a distracting back-and-forth flip as the cursor scans
   paragraphs. Text selection still works (cursor type is visual only,
   not behavioral), it just isn't advertised by an I-beam.
   The descendant rule has specificity (0,2,0), winning over
   `*:hover`'s (0,1,0). The custom cursor element itself is hidden
   via the :has() rule below — the existing 80ms opacity transition
   on .custom-cursor makes the swap smooth.
   Anchor exception: the Me page's case-header has a mailto link in
   the role slot (the only <a> the renderCase pipeline produces).
   Restore a pointer cursor over it so it advertises as clickable
   instead of inheriting the article's default arrow. */
.case-content,
.case-content *:hover {
  cursor: default !important;
}
.case-content a,
.case-content a:hover {
  cursor: pointer !important;
}
html:has(.case-content:hover) .custom-cursor {
  /* !important needed because cursor.js sets opacity inline (style.opacity
     = '1') on first mousemove. Inline styles win over CSS rules without
     !important; with it, this rule wins back and the custom cursor
     snaps to invisible while the native cursor takes over. When :hover
     no longer matches (cursor leaves the article), this rule stops
     applying and cursor.js's inline opacity 1 takes over again — custom
     cursor reappears. No transition (.custom-cursor has none) — both
     directions snap instantly for a decisive swap. */
  opacity: 0 !important;
}

.custom-cursor {
  position: fixed;
  top: 0;
  left: 0;
  width: var(--cursor-size, 100px);
  height: var(--cursor-size, 100px);
  pointer-events: none;
  z-index: 9999;
  opacity: 0;
  /* No opacity transition — cursor visibility changes (off-window
     mouseleave/enter, the article :has() rule) snap immediately so
     the cursor swap feels decisive. The previous 80ms ease-out fade
     read as "laggy" when entering/leaving the article zone. */
  will-change: transform;
  user-select: none;
  -webkit-user-select: none;
}
.custom-cursor__img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: none;
  user-select: none;
  -webkit-user-select: none;
  -webkit-user-drag: none;
  pointer-events: none;
}
/* Show only the img whose data-frame matches the cursor's data-frame.
   This is a pure CSS toggle — all 9 frames stay loaded in the DOM, no
   src reassignment, no fetch races. Frames 0–4 are the fist + point
   sequence (hover-driven). Frames 5–8 are the thumbs-up sequence used
   by the unlock-delight scripted animation only. */
.custom-cursor[data-frame="0"] .custom-cursor__img[data-frame="0"],
.custom-cursor[data-frame="1"] .custom-cursor__img[data-frame="1"],
.custom-cursor[data-frame="2"] .custom-cursor__img[data-frame="2"],
.custom-cursor[data-frame="3"] .custom-cursor__img[data-frame="3"],
.custom-cursor[data-frame="4"] .custom-cursor__img[data-frame="4"],
.custom-cursor[data-frame="5"] .custom-cursor__img[data-frame="5"],
.custom-cursor[data-frame="6"] .custom-cursor__img[data-frame="6"],
.custom-cursor[data-frame="7"] .custom-cursor__img[data-frame="7"],
.custom-cursor[data-frame="8"] .custom-cursor__img[data-frame="8"] {
  display: block;
}

/* Debug dot: 6px red marker pinned to the real mouse coordinate. Used
   ONLY for hotspot tuning — toggle from the dev panel checkbox. The dot
   is positioned via translate3d to its top-left at (mouseX, mouseY) and
   then -50%/-50% via translate to recenter, so the dot's visual center
   sits exactly on the click coord. z-index above the cursor so it's
   always visible "through" the hand. */
.cursor-debug-dot {
  position: fixed;
  top: 0;
  left: 0;
  width: 6px;
  height: 6px;
  margin: -3px 0 0 -3px;  /* recenter on the coord */
  background: #ff2d55;
  border-radius: 50%;
  pointer-events: none;
  z-index: 10000;
  opacity: 0;
  will-change: transform;
}
.cursor-debug-dot.is-visible {
  opacity: 1;
}


/* ───────── home page ───────── */
.page-home {
  height: 100vh;
  overflow: hidden;
  /* Clip the logo's left bleed at the page's 24px margin line so all
     four sides have matching breathing room. Without this the left
     side is flush to the viewport edge while top/right/bottom each
     show 24px of white. */
  clip-path: inset(0 0 0 var(--plsr-inset));
}
.page-home .mark {
  position: absolute;
  top: var(--plsr-inset);
  right: var(--plsr-inset);
  /* Right-anchored, full-viewport-height square. Height fills the
     viewport (minus 24px top + bottom inset); width follows via
     aspect-ratio: 1. On tall-narrow viewports the square is wider
     than the remaining horizontal space and bleeds off the LEFT,
     clipped by .page-home's clip-path. The rail's white background
     card masks the bleed area where the rail sits, so the
     composition reads as "logo emerges from behind the rail and
     extends right." */
  height: calc(100vh - 2 * var(--plsr-inset));
  aspect-ratio: 1;
}
.page-home .mark img {
  display: block;
  width: 100%;
  height: 100%;
}

/* ───────── case study page (mynd, growlink, trt) ───────── */
.page-case {
  max-width: var(--page-width);
  margin: 0 auto;
  position: relative;
  padding-bottom: 200px;
}
.case-content {
  width: var(--col-content);
  margin-left: var(--case-content-x);
  padding-top: var(--case-content-top);
  display: flex;
  flex-direction: column;
  gap: var(--space-32);
}

/* header (project title + role + intro paragraphs) — 640 text column, centered in 974 */
.case-header {
  width: var(--col-text);
  align-self: center;
  display: flex;
  flex-direction: column;
  gap: var(--space-12);
}
.case-header .header-title {
  margin: 0;
  font-size: var(--type-title-size);
  line-height: var(--type-title-lh);
  font-weight: var(--type-title-weight);
  color: var(--text);
}
.case-header .header-role {
  margin: 0;
  font-size: var(--type-meta-size);    /* 14px */
  line-height: var(--type-meta-lh);    /* 20px */
  font-weight: 500;
  color: var(--text);
}
/* If role is an email (Me page), the renderer wraps it in a mailto
   link. Inherit the role line's color + weight, drop the default
   browser underline. Resting state reads as plain text so drag-
   selecting the email for copy isn't disrupted by hover effects.
   Hover reveals a left-to-right scaleX underline (same draw pattern
   as rail items + section nav), keyed off `--hover-*` tokens for
   live tunability via the dev panel. No scale on hover — scale
   transforms shift text mid-selection, which makes precise copy
   harder. */
.case-header .header-role a {
  position: relative;
  color: inherit;
  text-decoration: none;
}
.case-header .header-role a::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: calc(var(--hover-underline-offset, 2px) * -1);
  height: var(--hover-underline-thickness, 1px);
  background: var(--text-muted);
  transform: scaleX(0);
  transform-origin: left center;
  transition: transform var(--hover-duration, 300ms) cubic-bezier(0.34, 1.3, 0.64, 1);
}
.case-header .header-role a:hover::after {
  transform: scaleX(1);
}
/* First intro paragraph (the company description) gets italic + muted
   treatment, distinguishing it from the role intro that follows. */
.case-header .header-intro--description {
  font-size: var(--type-meta-size);    /* 14px — overrides body's 16 */
  line-height: var(--type-meta-lh);    /* 20px — overrides body's 28 */
  font-style: italic;
  color: var(--text-muted);
}
/* Divider between the company description and the role intro. Width
   stretches to col-text via flex's default align-self: stretch.
   Margin: 20px 0 plus the parent's 12px gap = 32px total spacing
   above + below the rule, matching the design call. */
.case-header .header-divider {
  height: 1px;
  background: var(--border);    /* #DBD8E0 — design-system token, lighter and more muted than #cccccc */
  border: 0;
  margin: 20px 0;
}

/* sections */
.case-section {
  display: flex;
  flex-direction: column;
  gap: var(--space-32);
}
.case-section .section-title {
  margin: 0;
  font-size: var(--type-title-size);
  line-height: var(--type-title-lh);
  font-weight: var(--type-title-weight);
  color: var(--text);
}

/* paragraph cluster — width 640, centered, internal gap 12 */
.case-block {
  align-self: center;
}
.case-block--p {
  width: var(--col-text);
  display: flex;
  flex-direction: column;
  gap: var(--space-12);
}

/* image block — variable width per content, 32 padding top/bottom, 16 gap, centered */
.case-block--image {
  /* Override .case-block's `align-self: center` (which sizes to natural
     content width). Children use `flex: w w 0` for proportional fluid
     sizing — basis 0 means the row's natural width is just the gap, so
     without `stretch` here the block would collapse to ~16px wide and
     visually disappear. Stretching to col-content lets the children
     distribute correctly inside, with `justify-content: center` doing
     the visual centering on wide viewports where the children hit
     their max-width caps. */
  align-self: stretch;
  display: flex;
  flex-direction: row;
  gap: var(--space-16);
  padding: var(--space-32) 0;
  justify-content: center;
  align-items: center;
}
.image-placeholder {
  background: var(--bg-inset);
  /* Sizing (width / height / aspect-ratio / flex) is set inline by
     makeImagePlaceholder in script.js using each image's authored
     w + h. See that function for why. flex-shrink: 0 was removed
     when sizing went fluid — placeholders need to shrink with the
     article column on narrow viewports.
     position: relative so the inner <img> can be absolutely sized
     to fill the placeholder while preserving the placeholder's
     aspect-ratio behavior (the img's width/height attributes are
     set for CLS prevention but the visual size comes from this
     fill).
     border-radius matches the design's 12px for image/video
     containers. overflow: hidden clips the inner media to the
     rounded corners — no separate radius needed on img/video. */
  position: relative;
  overflow: hidden;
  border-radius: var(--radius-xl);
}
.image-placeholder img {
  /* Image fills the entire placeholder. Absolute positioning anchors
     to the placeholder's padding-box; with no padding on image
     placeholders, that's the full container. object-fit: cover crops
     if the source's actual aspect ratio doesn't match the JSON-
     declared w/h. */
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.image-placeholder video {
  /* Sizing + positioning lives on .image-placeholder--video below.
     Every video in this codebase is wrapped in that variant (the
     renderer adds the class for any video file extension), so this
     base rule only carries cross-cutting defaults. */
  display: block;
  object-fit: cover;
}

/* Video frame — applied via .image-placeholder--video on placeholders
   whose `src` is a video file. Visual: grey backdrop, dark frame
   around the video, 12px radius (26px on Bloom for the phone-screen
   read), dual drop shadows. Reads as a TV / device frame.

   Structure (set by makeImagePlaceholder):
     .image-placeholder--video           grey backdrop (#E9E9E9)
       └── .video-frame                  dark frame (#1A1A1A),
            |                            outer radius, drop shadows
            └── .video-clip              inner radius, overflow:hidden
                 └── <video>             no styling, fills clip via inset:0

   Why two wrappers (frame + clip) instead of one:
   - Putting border-radius on a <video> directly renders jagged on
     Safari at the rounded corner — replaced elements use a
     different clipping path than divs do. Both the outer curve
     (frame) and the inner curve (clip) need to be on plain divs
     to come out smooth. The video itself has no border-radius.
   - The clip's overflow:hidden + the frame's dark bg surrounding
     the clip means any sub-pixel gap at any edge shows the dark
     frame color, not the placeholder's grey. The hairline class
     of bug is structurally impossible.
   - Inner radius = outer radius − FRAME_INSET so the dark band
     reads as a uniform ring around the video at any size.

   Geometry stack at authored width (per side):
     GREY_H/V px grey padding + FRAME_INSET px dark frame visible +
     video content + FRAME_INSET dark + GREY grey.
   GREY_H/V and FRAME_INSET are set in makeImagePlaceholder; default
   is 32 grey + 4 dark. Bloom uses 74 grey horizontally for the
   phone-frame proportion. Frame and clip positions are inline
   percentages so the inner area's aspect stays locked across the
   responsive range. */
.image-placeholder--video {
  background: #E9E9E9;
}
.image-placeholder--video .video-frame {
  position: absolute;
  /* top, left, width, height set inline by setFrameAndClip */
  background: #1A1A1A;
  border-radius: var(--radius-xl);
  box-shadow:
    -10px 4px 40px rgba(0, 0, 0, 0.15),
     10px 4px 40px rgba(0, 0, 0, 0.15);
}
.image-placeholder--video .video-clip {
  position: absolute;
  /* top, left, width, height set inline by setFrameAndClip */
  border-radius: calc(var(--radius-xl) - 4px);   /* concentric inner curve */
  overflow: hidden;
}
.image-placeholder--video video {
  /* Fills the clip exactly. No border-radius (the clip handles
     rounding via overflow:hidden), no background, no outline.
     Anything sub-pixel about the video element's geometry is
     clipped or filled by the wrappers. */
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}
#bloom .image-placeholder--video .video-frame {
  border-radius: 26px;
}
#bloom .image-placeholder--video .video-clip {
  border-radius: 22px;        /* 26 outer − 4 inset */
}

/* breaker — 640 text column, italic muted callout, 32 padding */
.case-block--breaker {
  width: var(--col-text);
  display: flex;
  flex-direction: column;
  gap: var(--space-24);
  padding: var(--space-32) 0;
  align-items: stretch;
}
.case-block--breaker .breaker-text {
  margin: 0;
  font-size: var(--type-body-size);
  line-height: var(--type-body-lh);
  font-weight: var(--type-body-weight);
  font-style: italic;
  color: var(--text-muted);
}


/* ═════════════════════════════════════════════════════════════════
   rail
   ═════════════════════════════════════════════════════════════════
   The single primary navigation element. Three states (home,
   case-collapsed, case-expanded) driven by data-rail-state. State
   transitions animate via GSAP — see script.js's "Rail transitions"
   section. Layout below is purely CSS; transitions reach in via
   inline transform / opacity tweens.

   Y-alignment invariant: the .rail-nav (work list) sits at y=128
   from rail top in ALL three states. Achieved by reserving a 64px-
   tall slot for whichever of {nav-mark, name-block} is visible
   (both end at y=64) plus a 64px gap absorbed into rail-nav's
   padding-top. */

/* Rail container. Same anchor as case sidebar (case-style top) so the
   work-list row 1 lands at the same y as Growlink in home or
   nav-project in case. left edge matches the case sidebar's.
   Note: gap is 0 here (not space-64) because the 64px spacing between
   the visible header (name-block in home / nav-mark-link in case
   states) and the rail-nav, AND between rail-nav and sections-list
   in case-collapsed, lives on .rail-nav as padding-top/padding-bottom
   instead. This is so the rail-nav's bounding box covers those 64px
   visual gaps — making them part of the magnetic-hover + cursor hit
   zone (matching the user's perception of "still in the navigation
   area"). See the .rail .rail-nav rule below for the padding side. */
.rail {
  position: fixed;
  z-index: 2;
  left: calc((100vw - var(--page-width)) / 2 + var(--rail-offset));
  top: calc((100vh - 440px) / 2);
  width: var(--rail-width);
  display: flex;
  flex-direction: column;
  gap: 0;
}

/* Rail background card. Renders a white rectangle 48px outside the
   rail's bounding box on every side, so the rail content stays
   readable when the page below has high-contrast art behind it
   (the giant PLSR mark on the home page bleeds across the rail at
   narrow viewports). On case pages this card is white-on-white and
   visually invisible — a no-op there.
   Implementation: position: absolute on a pseudo with inset: -48px
   extends the card outward without changing the rail's layout box
   (children, hit zones, GSAP geometry all unchanged). z-index: -1
   inside the rail's stacking context (rail has z-index: 2) places
   the card behind the rail's text/items but above page content —
   exactly the layering we want. pointer-events: none so the
   extended white area doesn't capture clicks meant for the page. */
.rail::before {
  content: '';
  position: absolute;
  inset: -48px;
  background: var(--bg);
  z-index: -1;
  pointer-events: none;
}

/* nav-toggle. Sticky in negative-left margin, vertically pinned at y=130
   from rail top — same as the case sidebar's existing toggle. Hidden in
   home state (no toggle when no case is loaded). */
.rail .nav-toggle {
  position: absolute;
  left: calc(var(--back-arrow-offset) * -1 - 8px);
  top: 130px;
  width: 20px;
  height: 20px;
  padding: 0;
  background: none;
  border: 0;
  color: var(--text-muted);
  cursor: pointer;
}
.rail .nav-toggle::before {
  content: '';
  position: absolute;
  top: -24px;
  bottom: -24px;
  left: -32px;
  right: -8px;
}
.rail .nav-toggle:hover { color: var(--text); }
/* Guard period: while a GSAP animation is in flight (is-gsap-active
   on root), the toggle is non-clickable for the duration. Suppress
   hover color so the icon stays muted as a visual cue that it's
   not interactive right now. Normal hover resumes when the class
   is removed in the cleanup callback. */
html.is-gsap-active .rail .nav-toggle,
html.is-gsap-active .rail .nav-toggle:hover {
  color: var(--text-muted);
}
.rail .nav-toggle .icon {
  position: absolute;
  inset: 0;
  display: block;
  transition: opacity 220ms ease, transform 280ms cubic-bezier(0.34, 1.3, 0.64, 1);
}
.rail .nav-toggle .icon-arrow { opacity: 1; transform: rotate(0); }
.rail .nav-toggle .icon-close { opacity: 0; transform: rotate(-90deg); }
/* Hamburger is mobile-only — see the @media (max-width: 767px), ...
   block at the bottom of this file. Hidden on desktop entirely. */
.rail .nav-toggle .icon-hamburger { display: none; }
/* Icon visibility is keyed off data-toggle-icon, NOT data-rail-state.
   Reason: leave-fade preamble in toggleRailSafari delays setRailState
   by ~80ms; if icons were keyed off data-rail-state, they'd lag the
   click by 80ms. Decoupling lets the icon begin its swap immediately
   on click while the layout state flips after the preamble. The icon
   attribute is set early in toggleRailSafari (synchronous to click),
   then defensively re-asserted in setRailState. */
.rail[data-toggle-icon="x"] .nav-toggle .icon-arrow {
  opacity: 0; transform: rotate(90deg);
}
.rail[data-toggle-icon="x"] .nav-toggle .icon-close {
  opacity: 1; transform: rotate(0);
}

/* nav-mark. PLSR symbol that links back to home. First flex child after
   the absolute toggle, so it sits at y=0 — height 64. Visible in case
   states only. Hit-area extender pseudo same as the original case
   sidebar's nav-mark-link. */
.rail .nav-mark-link {
  display: block;
  width: 64px;
  height: 64px;
  cursor: pointer;
  position: relative;
}
.rail .nav-mark-link::before {
  content: '';
  position: absolute;
  inset: calc(var(--nav-mark-hit-extension, 24px) * -1);
}
.rail .nav-mark {
  display: block;
  width: 100%;
  height: 100%;
}

/* name-block. Visible in home only. Box height matches nav-mark (64px)
   so when this is the first visible flex child, the y-alignment of the
   work-list (which always starts at y=128 = 64 + space-64) is preserved.
   Content (title + meta = 48px) is top-padded by 16px to match where
   "Burak Varol" sits in the existing home sidebar. */
.rail .name-block {
  height: 64px;
  padding-top: 16px;
  display: flex;
  flex-direction: column;
  /* gap-2 matches work-list items so the name-block's rendered text
     content height (46px) equals an item's content height. Slot is
     still 64px tall (the y-alignment invariant) — content sits
     top-aligned with 2px empty at slot bottom. */
  gap: var(--space-2);
  margin: 0;
}
/* Typography for .title / .meta children comes from the global .title /
   .meta rules at the top of this file. We just need to reset margins on
   the heading + paragraph elements (they default to non-zero margin). */
.rail .name-block .title,
.rail .name-block .meta {
  margin: 0;
}

/* rail-nav. The single, persistent work list. Inner structure:
   .work-group (Growlink, Mynd, TRT) and .me-group (Me) as siblings,
   with gap-64 between them — preserves the visual block-level
   separation that the original home sidebar achieved with separate
   .block.work and .block.me. Inside .work-group, gap-24 between items.
   Me is structurally and visually a work item; the only reason it's in
   its own group is to render the 64px gap above it (vs. the 24px gap
   between the others) without per-item styling. */
.rail .rail-nav {
  display: flex;
  flex-direction: column;
  gap: var(--space-64);
  /* padding-top/padding-bottom replace the equivalent flex gaps that
     used to live on .rail (between header and rail-nav, and between
     rail-nav and sections-list). With .rail's gap now 0, the 64px
     visual spacing comes entirely from these paddings — and as a
     bonus, the rail-nav's bounding box now covers those zones, so
     mousemove (hover magnetization) and elementFromPoint (cursor.js
     clickable detection) both align with the rail's visual extent.
     Y-alignment invariant: work-list still starts at y=128 from rail
     top in all states. In home: name-block(64) + .rail-gap(0) +
     padding-top(64) = 128. In case states: nav-mark-link(64) +
     .rail-gap(0) + padding-top(64) = 128. Verified preserved. */
  padding-top: var(--space-64);
  padding-bottom: var(--space-64);
}
.rail .work-group {
  display: flex;
  flex-direction: column;
  gap: var(--space-24);
}
.rail .me-group {
  display: flex;
  flex-direction: column;
}

/* ───────── password gate ─────────
   Soft gate: SHA-256 hash check (see wirePassgate in script.js).
   Whole portfolio is gated until first unlock, then localStorage flag
   skips the gate on return visits. Form sits in .rail at the same
   flex slot as .rail-nav — when html.is-gated, .rail-nav is hidden
   and .rail-passgate is shown; reverses on unlock. The slot starts
   at the same y as Growlink would (rail-nav padding-top of 64 makes
   the work-list begin at y=128; passgate's padding-top: 64 matches
   so the input lands in Growlink's row). */
.rail-passgate {
  display: none;            /* shown only when html.is-gated */
  width: var(--rail-width);
  margin: 0;
  padding: 0;
  padding-top: var(--space-64);
}
.passgate-input {
  display: block;
  width: 100%;
  padding: 14px 16px;
  background: var(--bg-inset);
  border: 0;
  border-radius: var(--radius-sm);
  font-family: inherit;
  font-size: var(--type-body-size);
  line-height: 1;
  color: var(--text);
  outline: none;
  -webkit-appearance: none;
  appearance: none;
}
.passgate-input::placeholder {
  color: var(--text-muted);
}
.passgate-input:focus {
  background: var(--bg-warm);
}

/* Gated-state visibility flip. .is-gated on <html> hides everything
   navigation-y in the rail (work list, me, sections list) and reveals
   the passgate. Rail's nav-mark + name-block stay visible — those are
   the page identity (logo + name, the only things showing while
   gated, per design). */
html.is-gated .rail .rail-nav {
  display: none;
}
html.is-gated .rail .sections-list {
  display: none;
}
html.is-gated .rail .rail-passgate {
  display: block;
}
/* Article content is hidden too — gating CSS overrides the
   data-page-driven visibility, since data-page is forced to 'home'
   by the inline script anyway, but belt + suspenders. */
html.is-gated .page-case {
  display: none;
}
/* Items invisible while gated — guarantees no flash on the
   gated→unlocked transition. Without this, when the gate class is
   removed and rail-nav goes from display:none to display:flex,
   there's a window (browser-dependent timing) where items render
   at their default opacity:1 before passgateUnlock's gsap.set has
   a chance to apply opacity:0. CSS-level invisibility eliminates
   that window entirely. The unlock animation transitions items from
   opacity:0 (CSS or inline GSAP) to opacity:1 (inline GSAP) — once
   GSAP writes inline opacity:1, the .is-gated rule no longer applies
   anyway (class has been removed), so this only matters during the
   moment of transition. Mobile already had this for home/expanded
   items via a different rule; this extends the guarantee to desktop
   while gated. */
html.is-gated .rail .item {
  opacity: 0;
}
/* Gated state: rail spans the full viewport with vertically-centered
   content. Gated content (logo + name + passgate) is short (~170px),
   so the desktop default `top: calc((100vh - 440px) / 2)` would tuck
   it in the upper third. justify-content: center on a full-viewport
   rail recenters whatever's visible regardless of content height.
   Scoped to gated only — UNLOCKED home uses the desktop default top
   calc so its items align with case-collapsed's items at y=128
   from rail top, which keeps Growlink's T1 in-place bounce working
   (zero-distance morph requires identical y in home + case-collapsed).
   The "tall white slab" effect on unlocked home comes from extending
   the rail's ::before pseudo vertically (rule below) — without
   moving the rail itself. */
html.is-gated .rail {
  top: 0;
  bottom: 0;
  justify-content: center;
}

/* Tall white slab on home (gated + unlocked). The rail's white
   background card lives in the ::before pseudo with `inset: -48px`
   default — a 48px halo around the rail's bounding box. On home we
   want it to extend the full viewport height, matching the visual
   density of the gated state's full-viewport rail. Extending the
   pseudo's vertical inset to -100vh sends its top/bottom edges far
   above + below the viewport; the browser clips at viewport edges,
   visible result = full-height white strip. The pseudo stays
   absolutely positioned relative to .rail, so its horizontal
   position (left:-48 right:-48) tracks the rail. Critically, this
   does NOT shift the rail itself — items stay at their original y
   from case-collapsed, so the in-place bounce on T1 works.
   Mobile is unaffected: the .rail::before rule has display: none in
   the mobile media query (the rail itself is the chrome on mobile,
   no card pseudo needed). */
.rail[data-rail-state="home"]::before {
  inset: -100vh -48px;
}

/* Wrong password: subtle horizontal shake on the form. Class added
   transiently in JS (300ms) and removed; re-adding restarts the
   animation if user enters wrong password again rapidly. */
@keyframes passgate-shake {
  0%, 100% { transform: translateX(0); }
  20%      { transform: translateX(-6px); }
  40%      { transform: translateX(6px); }
  60%      { transform: translateX(-4px); }
  80%      { transform: translateX(4px); }
}
.rail-passgate.is-shaking {
  animation: passgate-shake 300ms ease-in-out;
}

/* Unlock reveal animations are GSAP-driven now (see passgateUnlock
   in script.js) — they use the rail's `gsapConfig.enter*` params
   (same as T3/T4 item entries), sequencing name-block morph first
   then items fade+slide. CSS-only approach was abandoned because
   the entrance animations need to share params with the rest of
   the rail's animation language. */

/* Work-item anchor. Identical visual regardless of group, route, or
   state — width locked to --rail-width so all anchors share
   one rect width (the GSAP morph captures rect deltas; same width
   means no horizontal flex during travel). Typography for child
   .title / .meta spans comes from the global .title / .meta rules.
   Hover state (transform, opacity, underline) is in the unified
   hover-system blocks earlier in this file. */
.rail .item {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  text-decoration: none;
  color: inherit;
  width: var(--rail-width);
}

/* sections-list. Populated by JS for the active route. Visible in
   case-collapsed only. */
.rail .sections-list {
  display: flex;
  flex-direction: column;
  gap: var(--space-12);
}

/* Section nav items. Default: full text color (not muted). The active
   section gets a 4px filled-circle dot indicator on the left
   (.is-active::before). Hover state: underlined via ::after pseudo
   that draws left-to-right via scaleX. Non-hovered siblings dim
   (.has-hover-active) when any item in the list is hovered. */
.rail .nav-section {
  position: relative;
  align-self: flex-start;
  font-size: var(--type-eyebrow-size);
  line-height: var(--type-eyebrow-lh);
  font-weight: var(--type-eyebrow-weight);
  color: var(--text);
  text-decoration: none;
  opacity: 1;
  /* opacity for hover-dim, transform for sibling push (added together
     so they animate in sync). */
  transition:
    opacity   var(--section-hover-duration, 300ms) ease-out,
    transform var(--section-hover-duration, 300ms) cubic-bezier(0.34, 1.3, 0.64, 1);
}
/* Active dot: small filled circle to the left of the text, vertically
   centered with the text. Uses currentColor to follow text color. */
.rail .nav-section.is-active::before {
  content: '';
  position: absolute;
  top: 50%;
  left: calc((var(--section-active-dot-size, 4px) + var(--section-active-dot-offset, 12px)) * -1);
  width: var(--section-active-dot-size, 4px);
  height: var(--section-active-dot-size, 4px);
  margin-top: calc(var(--section-active-dot-size, 4px) * -0.5);
  background: currentColor;
  border-radius: 50%;
}
/* Hover underline: drawn left-to-right via scaleX. */
.rail .nav-section::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: calc(var(--section-hover-underline-offset, 2px) * -1);
  height: var(--section-hover-underline-thickness, 1px);
  background: currentColor;
  transform: scaleX(0);
  transform-origin: left center;
  transition: transform var(--section-hover-duration, 300ms) cubic-bezier(0.34, 1.3, 0.64, 1);
}
.rail .nav-section.is-hovered::after {
  transform: scaleX(1);
}
/* Dim non-hovered siblings when the group has a hover active. The
   .has-hover-active class is set by JS (wireRailSectionHover) on the
   sections-list when any item is hovered (including via gap-
   magnetization). */
.rail .sections-list.has-hover-active .nav-section:not(.is-hovered) {
  opacity: var(--section-hover-dim, 0.3);
}
/* Push siblings up/down to make room — same coordinated-hover pattern
   as the rail items. JS adds the is-above-hovered / is-below-hovered
   classes; CSS drives the translate. Transition is set on the base
   .rail .nav-section rule above (transform alongside opacity). */
.rail .nav-section.is-above-hovered {
  transform: translateY(calc(var(--section-hover-push, 8px) * -1));
}
.rail .nav-section.is-below-hovered {
  transform: translateY(var(--section-hover-push, 8px));
}

/* ─── state-driven visibility ───
   Three states on the rail: `home`, `case-collapsed`, `case-expanded`.
   Each rule below hides what doesn't belong in that state.

   In case-collapsed, only the active anchor (.is-active) is visible in
   the work-list, and it sits at row 1 because flex auto-flow puts the
   only visible child at the top. The active anchor's parent group
   (.work-group OR .me-group) stays visible; the other group is hidden
   via :has() so its empty layout doesn't push the active item off row 1
   (gap-64 between empty group and visible group would shift y by 64). */

/* HOME: no toggle, no mark, no sections. Name + full work list visible. */
.rail[data-rail-state="home"] .nav-toggle,
.rail[data-rail-state="home"] .nav-mark-link,
.rail[data-rail-state="home"] .sections-list {
  display: none;
}

/* CASE-COLLAPSED: no name. Toggle + mark + sections visible. Only the
   active item is visible in the work list. */
.rail[data-rail-state="case-collapsed"] .name-block {
  display: none;
}
.rail[data-rail-state="case-collapsed"] .item:not(.is-active) {
  display: none;
}
.rail[data-rail-state="case-collapsed"] .work-group:not(:has(.is-active)),
.rail[data-rail-state="case-collapsed"] .me-group:not(:has(.is-active)) {
  display: none;
}
/* The active item-as-title-at-top is non-interactive (per design): no
   click, no cursor pointer. Achieved by killing pointer events for
   that element in this state. The hover-styling override (no scale,
   no underline) is in the cross-state rule below — it has to handle
   case-expanded too, where the active anchor IS clickable (T5) but
   we still don't want hover affordance for consistency. */
.rail[data-rail-state="case-collapsed"] .item.is-active {
  pointer-events: none;
  cursor: default;
}
/* Sections-list hit-zone fix (case-collapsed only — sections-list is
   hidden in other states). The 64px visual gap between the active
   item (in rail-nav) and the first section is split: half goes to
   rail-nav's padding-bottom as "dead air" (closer to the active item,
   not magnetized to anything since active is non-interactive), and
   half goes to sections-list's padding-top as the section magnetic
   zone. Total visual spacing stays 64. Why split rather than give
   it all to sections-list: 64px of section magnetism is too generous
   given that sections themselves are spaced by 12px gaps — the user
   could be quite far from any section text and still magnetize to
   the first one. The 32px split lets section magnetism kick in only
   for the bottom half of the gap, which reads as "near the sections"
   visually.
   The padding-bottom on sections-list is a pure addition for the
   bottom dead zone — the rail's effective hit area extends 64px past
   the last section, since there's no flex sibling beyond. (Less
   tradeoff there since there's nothing else competing for the zone.) */
.rail[data-rail-state="case-collapsed"] .sections-list {
  padding-top: var(--space-32);
  padding-bottom: var(--space-64);
}
.rail[data-rail-state="case-collapsed"] .rail-nav {
  padding-bottom: var(--space-32);
}

/* CASE-EXPANDED: no name, no sections. Toggle + mark + full work list. */
.rail[data-rail-state="case-expanded"] .name-block,
.rail[data-rail-state="case-expanded"] .sections-list {
  display: none;
}

/* Active anchor in case-collapsed: never show hover styling (no
   scale-up, no underline). Case-collapsed only — case-expanded's
   active anchor IS clickable (T5 click collapses the rail), so it
   keeps its hover affordance. The rule matters here even though
   pointer-events: none (above) and the wireRailItemHover skip both
   prevent NEW .is-hovered acquisition, because PERSISTENT hover
   leaks in from prior paths: clicking a hovered item in home keeps
   .is-hovered through T1's morph (the underline-carry mechanism —
   clearHoverScaleOnly intentionally doesn't strip the class so the
   pseudo stays drawn). When GSAP's clearProps fires post-bounce,
   CSS resolves .is-hovered's scale(1.04) → item snaps from 1 → 1.04
   → snaps back to 1 in cleanup. Visible as a "puff" on Growlink T1
   (zero-distance) without this rule.
   Setting transform: none on the .item kills the scale; setting
   scaleX(0) on the pseudo retracts the underline. Because the pseudo
   has its own CSS transition (300ms spring), the retract animates
   smoothly the moment data-rail-state flips to case-collapsed — a
   natural "you've arrived" cue without explicit JS scheduling. Also
   covers T5 collapse: state transitions case-expanded → case-collapsed,
   and the underline (drawn while hovering in case-expanded) retracts
   via the same pseudo-transition path on state flip. */
.rail[data-rail-state="case-collapsed"] .item.is-active.is-hovered {
  transform: none;
}
.rail[data-rail-state="case-collapsed"] .item.is-active.is-hovered .title::after {
  transform: scaleX(0);
}

/* ───────── touch-primary devices: disable hover styles ─────────
   On a touch device, tapping synthesizes a hover-then-tap sequence.
   The hover JS adds .is-hovered + sibling push/dim classes on the
   synthesized mousemove; the click handler later strips them. Between
   those two events the user sees a brief flash of hover-state styling.
   Cleanest fix: don't paint hover state at all on touch-primary
   devices. The @media query targets devices whose primary pointer
   is touch — `hover: none, pointer: coarse`. iPads with a Magic
   Keyboard trackpad report `pointer: fine, hover: hover` and continue
   to get hover styling, since they have a real pointing device. Both
   conditions must match (AND, not OR) so we don't disable hover for
   stylus-only devices that report `hover: none, pointer: fine`.
   Each rule resets a hover effect to its base (no transform, no
   dim, no underline scaleX) so even though JS still toggles the
   classes, CSS paints nothing for them. */
@media (hover: none) and (pointer: coarse) {
  /* Rail items */
  .rail .item.is-hovered {
    transform: none;
  }
  .rail .item.is-hovered .title::after {
    transform: scaleX(0);
  }
  .rail .item.is-above-hovered,
  .rail .item.is-below-hovered {
    transform: none;
    opacity: 1;
  }
  /* Toggle-hovered cascade */
  .rail.is-toggle-hovered .name-block,
  .rail.is-toggle-hovered .item,
  .rail.is-toggle-hovered .nav-section {
    opacity: 1;
  }
  /* Section nav */
  .rail .nav-section.is-hovered::after {
    transform: scaleX(0);
  }
  .rail .sections-list.has-hover-active .nav-section:not(.is-hovered) {
    opacity: 1;
  }
  .rail .nav-section.is-above-hovered,
  .rail .nav-section.is-below-hovered {
    transform: none;
  }
  /* Rail header push (nav-mark + name-block) when a rail-nav item is
     hovered. The JS gates the hover handlers off on touch so the
     .has-hover-active class never gets added — the :has() selector
     can't match without it — but reset here as defense-in-depth. */
  .rail:has(.rail-nav.has-hover-active) .nav-mark-link,
  .rail:has(.rail-nav.has-hover-active) .name-block-pusher {
    transform: none;
  }
}

/* ───────── responsive ─────────
   Three-tier system (>768px range; mobile is a separate arc):

   • Tier 1: ≥1728px — design canvas. The :root values at the top of
     this file define this tier directly, no media query needed.
     Rail uses canvas-edge math: left = (100vw-1728)/2 + rail-offset.

   • Tier 2: 1280–1727px — narrow desktop / wide tablet. Rail switches
     from canvas-anchored to viewport-anchored (left = rail-offset
     directly). Content area becomes fluid: --case-content-x derives
     from rail position + gutter; --col-content shrinks to fill the
     space between content-x and the right gutter, but caps at 974px
     so it never exceeds the design's intended width. --col-text caps
     at 640 for reading comfort, only shrinking when --col-content
     forces it.

   • Tier 3: ≤1279px — compact (small laptop / iPad landscape). Tighter
     scalars: rail-offset 165→64, side-gutter 80→64. The tier 2 calc()
     formulas pick up the new values automatically via cascade.

   Tier 3 is reached at exactly 1279px; tier 2 owns 1280px. Visible step
   at this boundary (rail jumps left by 101px, content reflows tighter)
   — that's an explicit tier transition, not a bug. Resizing across it
   is rare in real use; matters mainly for testing. */

@media (max-width: 1727px) {
  :root {
    --side-gutter: 80px;
    --case-content-x: calc(var(--rail-offset) + var(--rail-width) + var(--side-gutter));
    --col-content: min(974px, calc(100vw - var(--case-content-x) - var(--side-gutter)));
    --col-text: min(640px, calc(var(--col-content) - 64px));
  }
  .rail {
    left: var(--rail-offset);
  }
}

@media (max-width: 1439px) {
  :root {
    /* Below 1440 the rail's slab card sits 4px LEFT of the page's
       24px margin (slab visible left edge at x=20), giving a 4px
       sub-pixel buffer so the slab's edge can't accidentally render
       at x=23.5 and leave a hairline gap. The slab extends -48 past
       the rail on each side (default), so for the slab's visible
       left edge to land at x=20, the rail goes at rail-offset =
       20 + 48 = 68. The 24px page margin (logo / clip-path) stays
       unchanged. */
    --rail-offset: 68px;
    --side-gutter: 64px;
  }
}

/* ───────── mobile ─────────
   Trigger: width <768 OR height <600 (phone landscape catches via height).
   Mirrored in JS: `isMobile()` in script.js — the two MUST stay in sync
   so the GSAP-skip path matches the CSS-driven layout.

   Big idea: the desktop rail's three states (data-rail-state) drive
   completely different layouts on mobile, but the state machine is
   unchanged. Same DOM, same dispatcher, same toggle button — only the
   CSS rendering differs.

   • home → rail expands to fill the viewport. Vertical stack: nav-mark
     (PLSR, same 64px size as the rail's case-state mark, hence "leave
     our mark" on the home page) + name-block + work-list + me. No top
     bar, no chrome. The page IS the menu.

   • case-collapsed → rail collapses to a 48px top bar. nav-mark on the
     left (smaller, 24×24, links home), nav-toggle on the right (shows
     hamburger icon — replaces the desktop arrow at this breakpoint).
     Article content scrolls below.

   • case-expanded → top bar stays, rail extends down to fill viewport
     with the overlay content (name-block + work-list + me — the same
     content as mobile home, minus the top-of-stack mark since the bar
     already has one). Toggle's icon swaps to X. Tap a route or X to
     close.

   The rail's `::before` background card (a desktop-only contrast trick
   over the giant home logo) gets dropped — on mobile the rail itself
   has a white background as part of being the chrome.

   Sections-list always hidden on mobile per design call. In-page
   section navigation is "something entirely else" TBD. */

@media (max-width: 767px), (max-height: 599px) {
  /* Reset desktop tier 2/3 variables for content (article uses full
     viewport width minus 24px page padding on each side; --case-content-top
     of 112 = 48 top bar + 64 breathing room — the 64 matches the design's
     base rhythm and keeps the article from feeling stacked under the bar). */
  :root {
    --case-content-x: 24px;
    --case-content-top: 112px;
    --col-content: calc(100vw - 48px);
    --col-text: min(640px, calc(100vw - 48px));
  }

  /* Rail switches from desktop's left-side fixed aside to a top-anchored
     full-width element. White background here makes the desktop ::before
     card redundant. Padding is uniform 24px on the sides everywhere
     (so nav-mark + nav-toggle land at the same 24px from the viewport
     edges in every state, no per-state margin compensation). Vertical
     padding is per-state since the rail's height differs (48 in collapsed,
     full viewport in home + expanded). */
  .rail {
    top: 0;
    left: 0;
    right: 0;
    bottom: auto;
    width: auto;
    height: auto;
    padding: 0 24px;
    background: var(--bg);
    z-index: 4;
  }
  .rail::before {
    display: none;
  }

  /* nav-toggle moves from desktop's negative-left margin to top-right
     of the bar. 48×48 hit zone via the ::before extender (so taps near
     the icon's visual area register reliably on touch). */
  .rail .nav-toggle {
    top: 12px;
    right: 24px;
    left: auto;
    width: 24px;
    height: 24px;
  }
  .rail .nav-toggle::before {
    top: -12px;
    bottom: -12px;
    left: -12px;
    right: -12px;
  }

  /* Hamburger replaces the desktop arrow at this breakpoint. Same
     220ms opacity + 280ms rotate transition (inherited from .rail
     .nav-toggle .icon's base rule). icon-arrow + icon-close logic
     stays untouched on desktop; here we just substitute the visible
     "closed" icon. */
  .rail .nav-toggle .icon-arrow {
    display: none;
  }
  .rail .nav-toggle .icon-hamburger {
    display: block;
    opacity: 1;
    transform: rotate(0);
  }
  .rail[data-toggle-icon="x"] .nav-toggle .icon-hamburger {
    opacity: 0;
    transform: rotate(90deg);
  }

  /* Sections-list: always hidden on mobile. In-page section nav is a
     separate design decision TBD. */
  .rail .sections-list {
    display: none;
  }

  /* Name-block is hidden in the case-expanded overlay (purely
     navigation there — no need to repeat the identity). Mobile home
     keeps its name-block as the page header below the logo. Desktop's
     existing case-collapsed rule already hides it in that state. */
  .rail[data-rail-state="case-expanded"] .name-block {
    display: none;
  }

  /* Work items + Me item fill the rail's content width on mobile (the
     desktop --rail-width: 202 is too narrow for tap targets at this
     scale and also smaller than the available space when rail is the
     full viewport). Passgate input matches — fills the rail's content
     width so it doesn't sit as a narrow desktop-style tile. */
  .rail .item {
    width: 100%;
  }
  .rail .rail-passgate {
    width: 100%;
  }

  /* ─────── HOME ─────── */
  /* Rail fills the viewport. Stack: nav-mark (revealed — desktop hides
     on home), 32px gap, name-block, rail-nav with 32px top padding.
     Vertically centered via justify-content. At extreme short heights
     (phone landscape) where content > viewport, overflow-y: auto kicks
     in and scrolls — content may clip at the top edge in that case
     (known justify-content + scroll quirk), but the case is rare and
     phone-landscape users get the menu via tap anyway. */
  .rail[data-rail-state="home"] {
    bottom: 0;
    padding-top: 48px;
    padding-bottom: 48px;
    justify-content: center;
    overflow-y: auto;
  }
  .rail[data-rail-state="home"] .nav-mark-link {
    display: block;
    margin-bottom: 32px;
  }
  .rail[data-rail-state="home"] .rail-nav {
    padding-top: 32px;
    padding-bottom: 0;
  }

  /* ─────── CASE-COLLAPSED ─────── */
  /* 48px top bar. nav-mark on the left (24×24), nav-toggle absolute on
     the right. Switched from desktop's flex-direction: column to row so
     the lone nav-mark child lands on the LEFT (main axis start) and
     align-items: center handles vertical centering. (In column flex,
     align-self: center is cross-axis = horizontal center — wrong axis
     for our needs here.) */
  .rail[data-rail-state="case-collapsed"] {
    flex-direction: row;
    align-items: center;
    height: 48px;
    overflow: visible;
  }
  .rail[data-rail-state="case-collapsed"] .nav-mark-link {
    width: 24px;
    height: 24px;
  }
  .rail[data-rail-state="case-collapsed"] .rail-nav {
    display: none;
  }

  /* ─────── CASE-EXPANDED ─────── */
  /* Top bar (mark + X) + overlay content centered below. nav-mark on
     the left + nav-toggle on the right, both pinned in the 48px bar
     zone via absolute positioning. rail-nav is the only flex child
     and it vertically centers in the remaining viewport (rail spans
     full viewport in this state via bottom: 0). Top + bottom padding
     of 48 keeps content from touching the bar or the bottom edge —
     justify-content: center handles the actual centering. */
  .rail[data-rail-state="case-expanded"] {
    bottom: 0;
    padding-top: 48px;
    padding-bottom: 48px;
    justify-content: center;
    overflow-y: auto;
  }
  .rail[data-rail-state="case-expanded"] .nav-mark-link {
    position: absolute;
    top: 12px;          /* (48 - 24) / 2 = 12, vertically centers in bar */
    left: 24px;
    width: 24px;
    height: 24px;
    margin-top: 0;
    align-self: auto;
  }
  .rail[data-rail-state="case-expanded"] .rail-nav {
    padding-top: 0;
    padding-bottom: 0;
  }

  /* Items default invisible on mobile in their entrance contexts
     (home + case-expanded overlay). GSAP fades them in via the
     runMobile* helpers in script.js — see init, the dispatcher
     mobile branch, toggleRail mobile branch, and passgateUnlock.
     After the animation, inline opacity:1 (left there by GSAP's
     clearProps configuration) takes precedence over this CSS rule
     so items don't snap back invisible between runs.
     Why CSS-default-invisible: prevents a flash of visible items
     at first paint, before init has a chance to run gsap.set(). */
  .rail[data-rail-state="home"] .item,
  .rail[data-rail-state="case-expanded"] .item {
    opacity: 0;
  }

  /* Body scroll lock while overlay is open. CSS-only via :has() —
     no JS class management needed. When data-rail-state="case-expanded"
     is set anywhere in the document, lock html overflow. Restored
     when state flips away. :has() is supported in all browsers we
     care about (Chrome 105+, Safari 15.4+, Firefox 121+). */
  html:has(.rail[data-rail-state="case-expanded"]) {
    overflow: hidden;
  }

  /* Mobile entrance animations (page-stage fade, top-bar slide,
     items stagger / fade) all live in script.js — see the
     runMobile* helpers. CSS keyframes were retired here because
     they conflicted with GSAP inline styles in the cascade (CSS
     animations beat inline values). Single animation system
     now: GSAP tunable knobs in gsapConfig + dev panel apply to
     mobile contexts too. */

  /* ─────── section indicator (mobile, case-collapsed only) ───────
     Read-only label showing the section currently in view. Centered
     in the bar via absolute positioning + translate (a flex-centering
     approach would be biased by the toggle's absolute claim on the
     right side). Single-line with ellipsis truncation for safety —
     longest current section name ("Escalations Workflow") fits at
     320px viewport with room to spare, but the cap protects against
     future long names. */
  .rail[data-rail-state="case-collapsed"] .section-indicator {
    display: block;
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    max-width: calc(100vw - 128px);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    font-family: inherit;
    font-size: var(--type-eyebrow-size);
    font-weight: var(--type-eyebrow-weight);
    /* Use the eyebrow line-height (20px) not 1 — line-height: 1 = font-size,
       so descenders (g, p, y) extend below the box and get cropped by
       overflow: hidden. The 20px line-height accommodates them naturally. */
    line-height: var(--type-eyebrow-lh);
    color: var(--text);
    pointer-events: none;
  }
}

/* Default (desktop) — section indicator hidden everywhere. The mobile
   media query above re-enables it ONLY in case-collapsed. */
.section-indicator {
  display: none;
}


