@font-face {
  font-family: 'Stack Sans Text';
  src: url('./fonts/StackSansText-Variable.ttf') format('truetype');
  font-weight: 200 700;
  font-style: normal;
  font-display: swap;
}

:root {
  --color-bg: #FFFFFF;
  --color-text: #7A7C7F;
  --color-text-soft: #9A9C9F;
  --color-text-faint: #B5B7B9;
  --color-accent: #8AC63F;
  /* The one radius every photo/photo-frame on the site rounds to — cards,
     hero shots, galleries, placeholders, loading skeletons. Deliberately
     NOT applied to: mosaics (dense flush-tiled composites meant to read
     as one continuous surface, not discrete cards), full-bleed edge-to-
     edge images (nothing to round against at the viewport edge), or
     irregular cutout/schematic PNGs with no rectangular silhouette. */
  --radius-photo: 4px;
}

* { box-sizing: border-box; }

html, body {
  margin: 0;
  background: var(--color-bg);
  font-family: 'Stack Sans Text', sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

a {
  color: var(--color-text-soft);
  text-decoration: none;
}

/* Photo loading — a shared wrapper for every real <img> on the site.
   The photo stays invisible (but its box is already reserved by the
   parent's aspect-ratio/height, so nothing shifts) until it has actually
   decoded, with a small looping "loading" indicator underneath — same
   per-letter rise animation used elsewhere, just looping instead of
   playing once. Wire-up lives in image-loader.js. */

.photo-frame {
  position: relative;
  width: 100%;
  height: 100%;
}

.photo-frame img,
.photo-frame video {
  opacity: 0;
  transition: opacity .5s ease;
}

.photo-frame img.loaded,
.photo-frame video.loaded {
  opacity: 1;
}

.photo-frame-loading {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: var(--radius-photo);
  background: rgba(122, 124, 127, .06);
  transition: opacity .4s ease, visibility 0s linear .4s;
}

.photo-frame img.loaded ~ .photo-frame-loading {
  opacity: 0;
  visibility: hidden;
}

.loading-word {
  font-family: 'Overpass Mono', monospace;
  font-size: 11px;
  letter-spacing: .04em;
  color: var(--color-text-soft);
}

.rot-char-loop {
  display: inline-block;
  animation: rot-char-loop-bounce 1.1s ease-in-out infinite;
  animation-delay: calc(var(--i) * 80ms);
}

@keyframes rot-char-loop-bounce {
  0%, 100% { transform: translateY(0); opacity: .45; }
  50% { transform: translateY(-3px); opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  .rot-char-loop { animation: none; opacity: .7; }
}

/* Homepage: single fixed viewport, no page scroll */

html:has(body.home), body.home {
  height: 100%;
  overflow: hidden;
}

body.home {
  position: fixed;
  inset: 0;
  display: flex;
  flex-direction: column;
}

/* Header (homepage) */

.topbar {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: clamp(18px, 4vw, 36px) clamp(18px, 5vw, 48px);
  /* Notch/Dynamic Island devices reserve real space at the top of the
     screen — without this, the logo/nav sit right up against it instead
     of clearing it like the hero image below does (see .mobile-carousel,
     which already adds this same inset). */
  padding-top: calc(clamp(18px, 4vw, 36px) + env(safe-area-inset-top, 0px));
  z-index: 5;
}

.logo {
  display: block;
}

.logo-img {
  display: block;
  height: clamp(26px, 6vw, 36px);
  width: auto;
}

.topnav {
  display: flex;
  gap: clamp(14px, 3vw, 28px);
}

.topnav a {
  font-size: clamp(11px, 1.8vw, 13px);
  color: #A7A9AC;
  font-weight: 300;
  display: inline-block;
  transition: color .25s ease, transform .25s cubic-bezier(.22, .8, .22, 1);
}

@media (hover: hover) and (pointer: fine) {
  .topnav a:hover {
    color: #000;
    transform: scale(1.12);
  }
}

.topnav a:active {
  transform: scale(.97);
}

/* Mobile nav — horizontal text links are cramped and hard to tap at phone
   width, so it stacks vertically, right-aligned, at a larger touch-friendly
   size. Reordered (about, linkedin, instagram) via `order` only — DOM order
   (and the desktop layout) is untouched. Shared by the homepage topbar and
   the About/project page-nav, since both reuse .topnav. */
@media (max-width: 680px) {
  .topnav {
    flex-direction: column;
    align-items: flex-end;
    gap: 0;
  }

  .topnav a {
    font-size: 15px;
    /* Fixed line-height (not padding + flex gap) so line-to-line spacing
       is exact and predictable — top-to-top spacing between links is
       just this value, nothing else contributing. The logo's height (see
       .logo-img) is set to span from this row's top to "linkedin"'s
       bottom, so a change here should be re-measured against that too. */
    line-height: 25px;
    padding: 0;
  }

  .topnav a:nth-child(2) { order: 3; }
  .topnav a:nth-child(3) { order: 2; }
}

/* Mobile logo — the square mark reads as a placeholder at phone width with
   nothing else on the page to anchor it to a brand, so it's replaced with
   the real wordmark (logo_Aug.png). Applies on every page (homepage topbar
   + About/project page-nav both reuse .logo/.logo-img). */
@media (max-width: 680px) {
  /* Match the hero image/caption's own horizontal margin (24px, capped —
     see .mobile-stack) exactly, via the same formula, instead of the
     independent vw-based clamp() the topbar uses elsewhere. Those two
     systems only agree by coincidence at any given width, which is why the
     logo/nav were sitting outside the image's left/right edges. */
  body.home .topbar {
    padding-left: max(24px, calc((100vw - 380px) / 2));
    padding-right: max(24px, calc((100vw - 380px) / 2));
  }

  /* Top-align logo with "about" instead of centering the two blocks
     against each other. */
  .topbar {
    align-items: flex-start;
  }

  /* Height set so the logo spans from "about"'s box-top to "linkedin"'s
     visible text-bottom — not "linkedin"'s own CSS box-bottom (68px),
     which sits 7.26px below its actual baseline (25px line-height adds
     leading below the text too). 18px to 60.74px = 42.74px. Width is
     auto, so the image's native ~2.595:1 aspect ratio (1230x474 source)
     sets it proportionally. */
  .logo-img {
    display: block;
    height: 42.74px;
    width: auto;
  }
}

/* Carousel */

.carousel {
  flex: 1;
  display: flex;
  align-items: center;
  overflow: hidden;
  padding: clamp(60px, 10vh, 120px) 0;
  touch-action: none;
  cursor: grab;
}

.carousel.dragging {
  cursor: grabbing;
}

.track {
  display: flex;
  align-items: flex-start;
  gap: clamp(20px, 4vw, 32px);
  padding-left: clamp(16px, 4vw, 48px);
  will-change: transform;
}

.card {
  display: flex;
  flex-direction: column;
  width: clamp(170px, 44vw, 320px);
  flex: none;
  color: inherit;
  transition: transform .35s cubic-bezier(.22, .8, .22, 1);
}

@media (hover: hover) and (pointer: fine) {
  .card:hover {
    transform: scale(1.045);
  }
}

/* After the hover rule, not before — equal specificity means source order
   decides, and a press must win over a simultaneous hover or the .97
   feedback never shows on a real desktop click (mousedown fires while
   :hover is still true the whole time). */
.card:active {
  transform: scale(.97);
}

.card-image-wrap {
  aspect-ratio: 1 / 1;
  display: flex;
  align-items: flex-end;
  justify-content: center;
  overflow: hidden;
}

.card-image {
  position: relative;
  width: 100%;
  height: 100%;
  border-radius: var(--radius-photo);
  background: rgba(122, 124, 127, .06);
  border: 1.5px dashed rgba(122, 124, 127, .35);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
  color: var(--color-text-soft);
  overflow: hidden;
  user-select: none;
}

.card-image svg {
  opacity: .45;
}

.card-image-caption {
  font-size: 11px;
  padding: 0 10px;
  text-align: center;
  opacity: .75;
}

.card-photo {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: var(--radius-photo);
  display: block;
}

.card-meta {
  margin-top: 14px;
  display: flex;
  align-items: baseline;
  gap: 12px;
}

.card-name {
  display: inline-block;
  overflow: hidden;
  font-size: 15px;
  font-weight: 400;
  color: #000;
}

.card-name .rot-char {
  display: inline-block;
}

.card-desc {
  margin: 5px 0 0;
  color: #A7A9AC;
  font-size: 12.5px;
  line-height: 1.4;
  max-width: 300px;
  font-weight: 300;
}

/* Mobile carousel — the drag-row carousel above doesn't work well as a
   touch interaction at phone widths, so under the breakpoint it's swapped
   for a centered stack: one sharp active card, faded/blurred neighbors
   peeking at the sides, swipe/chevron navigation, and a caption tucked
   directly under the card (not floating loose lower on the screen) that
   always reflects whichever card is centered. */

.mobile-carousel {
  display: none;
}

@media (max-width: 680px) {
  .carousel { display: none; }

  .mobile-carousel {
    display: flex;
    flex-direction: column;
    align-items: center;
    /* Anchored from the top (not centered) so the gap to the nav above and
       the gap to the email below are each controlled by a single value
       (padding-top, and content height + this padding) instead of fighting
       over how justify-content:center splits leftover space between them. */
    justify-content: flex-start;
    flex: 1;
    min-height: 0;
    /* -25px net (was -35px, lowered 10px): shifts the image/name/
       description block up as a unit. */
    padding: calc(clamp(150px, 24vh, 200px) + env(safe-area-inset-top, 0px) - 25px) 0 clamp(24px, 4vh, 48px);
  }
}

/* The visible stack: same footprint as the caption below it (24px side
   margin, capped at 380px) — the active card renders at exactly this
   size, full width, matching the text column. Neighbors don't get their
   own separate lane; they're smaller copies layered behind/beside it
   (see .mobile-stack-cards/.mobile-card below), not laid out next to it. */
.mobile-stack {
  position: relative;
  width: calc(100% - 48px);
  max-width: 380px;
  aspect-ratio: 1 / 1;
  flex: none;
}

/* The real touch surface — invisible, native scroll-snap, one item per
   project at the full stack width. Momentum and snapping are the
   browser's own physics here, not hand-rolled JS drag math, which is
   what actually reads as "smooth" on a phone. scroll-snap-stop:always
   keeps a hard flick landing on just the next project, never skipping
   past it into a second or third. It sits under the visual cards in
   paint order but still receives every touch, since the cards above it
   are pointer-events:none (see .mobile-card). */
.mobile-scroll-spacer {
  position: absolute;
  inset: 0;
  display: flex;
  overflow-x: auto;
  overflow-y: hidden;
  scroll-snap-type: x mandatory;
  -webkit-overflow-scrolling: touch;
  /* The homepage never scrolls (body.home is a fixed, overflow:hidden
     single viewport), so pan-x costs nothing here — it just hands the
     horizontal drag straight to native scroll instead of holding the
     gesture open deciding what it is. A purely-vertical touch (for the
     swipe-to-navigate JS below) isn't covered by pan-x, so it still
     reaches JS untouched. */
  touch-action: pan-x;
  scrollbar-width: none;
}

.mobile-scroll-spacer::-webkit-scrollbar {
  display: none;
}

.mobile-scroll-spacer-item {
  flex: 0 0 100%;
  width: 100%;
  height: 100%;
  scroll-snap-align: center;
  scroll-snap-stop: always;
}

/* Purely decorative — centered on top of each other by default (this
   flex alignment is what every card's JS-applied translateX offset is
   measured from), never touch-interactive themselves. */
.mobile-stack-cards {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
}

.mobile-card {
  position: absolute;
  width: 100%;
  height: 100%;
  border-radius: var(--radius-photo);
  overflow: hidden;
  color: inherit;
  text-decoration: none;
  -webkit-touch-callout: none;
  user-select: none;
  /* No CSS transition here — transform/opacity are recalculated every
     scroll frame from the spacer's live position (see script.js), which
     is already animating smoothly via native momentum. A transition on
     top of that would fight the per-frame updates and read as smeared
     or laggy rather than crisp. */
}

.mobile-card img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  -webkit-user-drag: none;
  user-select: none;
}

.mobile-card .img-slot {
  width: 100%;
  height: 100%;
  border-radius: var(--radius-photo);
}

.mobile-caption {
  width: calc(100% - 48px);
  max-width: 380px;
  margin-top: 18px;
  text-align: left;
  /* Not part of the scrollable track (see script.js) — its own swipe
     handling is fully custom (both axes), so it shouldn't wait around
     for the browser to decide whether a touch here is a pan/zoom/etc. */
  touch-action: none;
}

.mobile-caption-meta {
  display: flex;
  align-items: baseline;
  gap: 10px;
}

.mobile-caption-name {
  display: inline-block;
  overflow: hidden;
  font-size: 16px;
  font-weight: 400;
  color: #000;
}

/* Each word rises up from below with a fade, staggered by its own index
   (--i, set inline per word) — a vanilla take on a word-by-word text
   rotate rather than the caption just snapping to new words. */
.rot-word {
  display: inline-block;
  animation: rot-word-in .5s cubic-bezier(.22, .8, .22, 1) both;
  animation-delay: calc(var(--i) * 35ms);
}

@keyframes rot-word-in {
  from { transform: translateY(65%); opacity: 0; }
  to { transform: translateY(0); opacity: 1; }
}

.mobile-caption-desc {
  /* 6.4px box margin, not 15px — the visible gap also includes ~8.6px of
     invisible font leading (title's own leading below its ink, plus this
     element's leading above its first line's ink), so the box-to-box
     margin has to undershoot the intended 15px visual gap to compensate. */
  margin: 6.4px 0 0;
  color: #A7A9AC;
  font-size: 13px;
  line-height: 1.4;
  font-weight: 300;
  overflow: hidden;
}

/* Email */

.email-row {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  display: flex;
  justify-content: center;
  padding: clamp(24px, 5vh, 40px) 0;
}

@media (max-width: 680px) {
  /* +5px down: .email-row is bottom-anchored (bottom:0), so its own
     position only moves via padding-bottom, not padding-top — reducing
     padding-bottom brings the button closer to (i.e. down toward) the
     screen's bottom edge. */
  .email-row {
    padding-bottom: calc(clamp(24px, 5vh, 40px) - 5px);
  }
}

.email-btn {
  appearance: none;
  border: 0;
  background: transparent;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  font-family: 'Stack Sans Text', sans-serif;
  font-weight: 300;
  font-size: 14px;
  color: #A7A9AC;
  letter-spacing: .01em;
  padding: 0;
  transition: color .3s ease, transform 120ms cubic-bezier(.22, .8, .22, 1);
}

.email-btn:active {
  transform: scale(.97);
}

.email-btn.copied {
  color: var(--color-accent);
}

/* Icon + label breathe as a single rigid unit — scaling/fading them
   separately reads as two things pulsing near each other instead of one. */
.email-content {
  display: inline-flex;
  align-items: center;
  gap: 6px;
}

.email-icon {
  display: none;
  flex: none;
}

.email-btn.copied .email-icon {
  display: inline-block;
}

@media (hover: hover) and (pointer: fine) {
  .email-btn:hover .email-icon {
    display: inline-block;
  }

  .email-btn:hover:not(.copied) .email-content {
    animation: email-breathe 2.2s cubic-bezier(.45, 0, .55, 1) infinite;
  }
}

/* Loosely mimics a natural breath: a quicker rise, a brief hold at the
   top, then a slower settle back down — not a symmetric mechanical pulse. */
@keyframes email-breathe {
  0%   { transform: scale(1);    opacity: .6; }
  40%  { transform: scale(1.06); opacity: 1; }
  55%  { transform: scale(1.06); opacity: 1; }
  100% { transform: scale(1);    opacity: .6; }
}

@media (prefers-reduced-motion: reduce) {
  .email-btn:hover:not(.copied) .email-content {
    animation: none !important;
  }
}

/* Subpages (about / project detail) */

@keyframes fadeInUp {
  from { opacity: 0; transform: translateY(16px); }
  to { opacity: 1; transform: translateY(0); }
}

.page-nav {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 10;
  will-change: transform;
}

.page-nav-inner {
  max-width: 1080px;
  margin: 0 auto;
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: clamp(24px, 5vw, 44px) clamp(20px, 6vw, 64px);
  /* Same notch/Dynamic Island clearance as .topbar on the homepage. */
  padding-top: calc(clamp(24px, 5vw, 44px) + env(safe-area-inset-top, 0px));
}

.page-nav-inner .topnav a.active {
  color: #000;
}

.page {
  max-width: 1080px;
  margin: 0 auto;
  padding: 0 clamp(20px, 6vw, 64px) 120px;
}

@media (max-width: 680px) {
  /* Match the homepage's .topbar/hero-image margin (24px, capped — see
     .mobile-stack) exactly, so every page's nav sits at the same inset as
     its own content, and all pages share one consistent side margin. */
  .page-nav-inner {
    padding-left: max(24px, calc((100vw - 380px) / 2));
    padding-right: max(24px, calc((100vw - 380px) / 2));
    /* Was clamp(24px,5vw,44px) — taller than the homepage topbar's
       clamp(18px,4vw,36px) — so logo/nav sat at different heights
       depending on the page. Matched here for a consistent look. */
    padding-top: calc(clamp(18px, 4vw, 36px) + env(safe-area-inset-top, 0px));
    /* Top-align logo with "about" instead of centering the two blocks
       against each other — the logo is much taller than the nav now. */
    align-items: flex-start;
  }

  .page {
    padding-left: max(24px, calc((100vw - 380px) / 2));
    padding-right: max(24px, calc((100vw - 380px) / 2));
  }
}

.page--narrow {
  max-width: 900px;
}

.page-nav-spacer {
  height: clamp(100px, 16vw, 160px);
}

.fade-in {
  animation: fadeInUp .6s cubic-bezier(.22, .8, .22, 1) both;
}

@media (prefers-reduced-motion: reduce) {
  .fade-in {
    animation: fadeIn .3s ease both;
  }
}

@keyframes fadeIn {
  from { opacity: 0; }
  to { opacity: 1; }
}

.img-slot {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
  border-radius: var(--radius-photo);
  background: rgba(122, 124, 127, .06);
  border: 1.5px dashed rgba(122, 124, 127, .35);
  color: var(--color-text-soft);
  overflow: hidden;
  user-select: none;
}

.img-slot svg {
  opacity: .45;
}

.img-slot span {
  font-size: 12px;
  padding: 0 10px;
  text-align: center;
  opacity: .75;
}

.project-hero {
  width: 100%;
  aspect-ratio: 16 / 10;
  overflow: hidden;
}

.project-hero img,
.detail-image img,
.about-portrait img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: var(--radius-photo);
  display: block;
}

.project-header {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  flex-wrap: wrap;
  gap: 24px;
  margin-top: 32px;
}

.project-title-row {
  display: flex;
  align-items: baseline;
  gap: 14px;
}

.project-title {
  font-size: clamp(26px, 4vw, 38px);
  font-weight: 600;
  color: var(--color-text);
  /* Same reasoning as .story-section-heading — the largest text on the
     site is the one place tracking was left at the browser default. */
  letter-spacing: -.01em;
}

.project-year {
  font-family: 'Overpass Mono', monospace;
  font-size: 14px;
  color: var(--color-text-soft);
}

.project-summary {
  margin: 0;
  max-width: 420px;
  color: var(--color-text-soft);
  font-size: 15px;
  line-height: 1.5;
}

.divider {
  height: 1px;
  background: #D0D2D1;
  margin: 44px 0;
}

.project-copy {
  max-width: 640px;
  color: var(--color-text-soft);
  font-size: 15px;
  line-height: 1.7;
  margin: 0 0 56px;
  font-weight: 300;
}

.detail-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: clamp(16px, 3vw, 24px);
}

.detail-image {
  width: 100%;
  aspect-ratio: 4 / 5;
  overflow: hidden;
}

.page-email-row {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 70px;
  margin-top: 70px;
}

.page-back-link {
  font-size: 13px;
  color: #A7A9AC;
  font-weight: 300;
  transition: color .25s ease, transform .25s cubic-bezier(.22, .8, .22, 1);
}

@media (hover: hover) and (pointer: fine) {
  .page-back-link:hover {
    color: #000;
    transform: scale(1.08);
  }
}

.page-back-link:active {
  transform: scale(.97);
}

/* About page — same building blocks as the project story pages
   (.story-section-heading, .story-text), just centered instead of left
   aligned, since this is the only page that reuses them that way. */

.about-story {
  text-align: center;
}

/* Tilts toward the cursor on hover — see the script below. The whole
   frame rotates as one rigid plane (not just the image inside it), so
   the clip boundary and its content always move together and nothing
   is ever revealed at the edges. Same transition drives both the
   continuous cursor-tracking and the settle back to flat on mouseleave
   — one easing curve for both keeps the motion reading as one
   consistent "held on a point behind it" object, not an instant snap
   that only eases on the way back. */
.about-portrait {
  width: 310px;
  max-width: 100%;
  aspect-ratio: 1 / 1;
  margin: 0 auto 48px;
  overflow: hidden;
  transition: transform .4s cubic-bezier(.22, .8, .22, 1);
  will-change: transform;
}

/* Sits right under the heading, in the heading's own black/regular
   treatment but italicized and colored like the body copy below it —
   a quiet aside rather than a second heading. */
.about-subtitle {
  max-width: 480px;
  margin: 0 auto 40px;
  padding: 0 24px;
  color: #86868B;
  font-size: 15px;
  line-height: 1.5;
  font-weight: 300;
  font-style: italic;
}

/* Story layout (SOUNDS case study) — bespoke to this one page, not the
   shared project template. Three independent widths, each centered on its
   own rather than nested inside the site's usual 1080px page column:
   full-bleed breakouts (100vw), a photo column (~800px), and body text
   narrower still (~480px) for a comfortable reading line length. */

.story {
  padding-bottom: 120px;
}

.story-full {
  display: block;
  width: 100%;
  margin: 0 0 48px;
  overflow: hidden;
}

/* A real photo gets a sane default banner ratio + cover-fit, so a
   portrait-oriented source photo doesn't stretch to full viewport width
   and end up nearly two screens tall — same object-fit:cover pattern the
   rest of the site already uses for hero images. Placeholder slots size
   themselves instead (they set their own aspect-ratio inline per instance,
   e.g. the closing shot's wider 21:9), so this only targets real photos. */
.story-full:has(.photo-frame) {
  aspect-ratio: 2 / 1;
}

/* GAZE-only: the exploded-view render is near-square (4520x4552) — the
   generic 2/1 banner crop above keeps only a ~9%-wide sliver of it
   ("barely visible"). Overridden by id so the shared rule above is
   untouched for every other project page. */
#gazeExploded.story-full:has(.photo-frame) {
  aspect-ratio: 1 / 1;
  width: 85%;
  margin-left: auto;
  margin-right: auto;
}

.story-full .img-slot {
  width: 100%;
}

.story-full img,
.story-full video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* GAZE-only: most of its full-bleed shots get the same rounded-corner
   treatment as every other photo on the page (hero, compare, pair,
   cable-split) — a plain modifier rather than changing .story-full
   itself, since that class is shared with SOUNDS and V1-Rotate, whose
   own full-bleed shots stay sharp-cornered on purpose. Deliberately not
   applied to the mosaic or the construction-detail sequence, which read
   better flush and unbounded. */
.story-full--radius {
  border-radius: var(--radius-photo);
}

.story-full--radius .photo-frame,
.story-full--radius img,
.story-full--radius video {
  border-radius: inherit;
}

/* GAZE-only: full-bleed breakout to the actual viewport edges, matching
   V1-Rotate's own .story-hero-wide look (edge-to-edge with a 25px
   gutter, not true zero-gutter like the detail slideshow). V1-Rotate
   reaches this for free because its page wrapper (.story) has no narrow-
   column cap; GAZE's .page--narrow does cap at 900px, so this applies
   the same 100vw + recentering margin trick. Used on gaze_pic_1-4 only —
   scenario.jpg, the animation video, and the exploded view stay in the
   normal column. */
.story-full--edge {
  width: 100vw;
  margin-left: calc(50% - 50vw);
  margin-right: calc(50% - 50vw);
  padding: 0 25px;
  box-sizing: border-box;
}

/* Same reasoning, scoped to GAZE via .page--narrow so V1-Rotate's own
   .story-hero-wide (already full width through its own wrapper) is
   untouched. */
.page--narrow .story-hero-wide {
  width: 100vw;
  max-width: 100vw;
  margin-left: calc(50% - 50vw);
  margin-right: calc(50% - 50vw);
}

.story-header {
  max-width: 800px;
  margin: 48px auto 0;
  padding: 0 24px;
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 24px;
  flex-wrap: wrap;
}

/* Matches .story-section-heading's treatment (black, regular weight,
   27px, same 1.2 line-height) rather than the site's usual soft gray
   body copy — this is the one pull-quote on the page, so it gets the
   same "heading" emphasis and the same breathing room between lines. */
.story-quote {
  max-width: 540px;
  margin: 0 auto 40px;
  padding: 0 24px;
  text-align: left;
  color: #000;
  font-size: 27px;
  line-height: 1.2;
  font-weight: 400;
}

/* The hero photo is followed directly by the quote in the reference,
   with a much tighter gap (~27px) than the page's normal ~96px rhythm
   between sections. */
.story-col + .story-quote {
  margin-top: -56px;
}

.story-quote-mark {
  display: block;
  font-size: 56px;
  line-height: 12.2px;
  margin-bottom: -5px;
}

.story-divider {
  max-width: 800px;
  margin: 40px auto;
  padding: 0 24px;
}

.story-divider::after {
  content: '';
  display: block;
  height: 1px;
  background: #D0D2D1;
}

.story-col {
  max-width: 800px;
  margin: 0 auto 96px;
  padding: 0 24px;
}

.story-col img {
  width: 100%;
  height: auto;
  display: block;
  border-radius: var(--radius-photo);
}

.story-col .img-slot {
  width: 100%;
  border-radius: var(--radius-photo);
}

/* A single flattened composite (built from the source design file's own
   PDF coordinates, scaled to 1440px) rather than 15 separately
   positioned images — one continuous picture means the magnifier lens
   never has a "boundary" where it could show the wrong picture's
   content bleeding into a neighboring one. */
.story-mosaic {
  position: relative;
  width: 1440px;
  margin: 0 auto 48px;
  cursor: none;
}

.story-mosaic img {
  display: block;
  width: 100%;
  height: auto;
}

/* Desktop has no tap-to-open gallery (see the mobile media query below) —
   this hint only makes sense where that interaction exists. */
.story-mosaic-hint {
  display: none;
}

/* A circular "loupe" that follows the cursor over whichever mosaic image
   is hovered — its background is the same image, scaled up by ZOOM and
   offset so the zoomed content under the cursor stays centered in the
   lens (the classic product-zoom background-position technique). */
.mosaic-lens {
  position: absolute;
  left: 0;
  top: 0;
  /* Positioned via transform (see the script's move()), not left/top
     directly — left/top are layout properties, so writing them on every
     single mousemove forces a reflow + repaint each time with no
     compositing layer of its own. On Chromium that's merely wasteful;
     on Safari specifically it's a known source of stale/ghosted paint
     during fast pointer movement — the previous frame's content isn't
     always fully invalidated before the next one lands, which reads as
     the zoomed view and the page underneath both being visible at once.
     will-change:transform promotes the lens to its own layer so the
     browser only ever needs to reposition a layer, not repaint one. */
  will-change: transform;
  border-radius: 50%;
  pointer-events: none;
  z-index: 60;
  /* The actual root cause of the "duplicate" glitch: V1_Rotate_sketch.webp
     is RGBA with its paper background fully transparent (alpha 0), not
     painted white — invisible as a plain <img> since the white page
     shows through it, but the lens itself had no background-color of
     its own, so that same transparency let the real, unzoomed sketch
     sitting directly underneath show straight through the zoomed one,
     the two layered together. An opaque backdrop here fixes it
     regardless of which source image the lens is currently showing. */
  background-color: var(--color-bg);
  background-repeat: no-repeat;
  /* A 1px, 60%-opacity ring read as basically invisible against the
     sketch's own light pencil-on-paper background — with nothing
     visually marking where the glass ends, the zoomed content read as
     unclear against it. A near-solid white rim plus a thin dark
     hairline just outside it gives the lens an actual edge against any
     background, light or dark. */
  box-shadow:
    0 8px 32px rgba(0, 0, 0, .35),
    0 0 0 4px rgba(255, 255, 255, .95) inset,
    0 0 0 1px rgba(0, 0, 0, .12);
  opacity: 0;
  transition: opacity .15s ease;
}

.mosaic-lens.active {
  opacity: 1;
}

/* GAZE-only: no ring/hairline border on the lens — the shared rule
   above earns its border against SOUNDS' pale pencil-on-paper sketch,
   where an edgeless glass read as unclear; GAZE's mosaic is high-
   contrast studio photography, so the same border reads as a visible
   seam rather than a necessary edge. Scoped to #gazeMosaic's own lens
   instance so SOUNDS/V1-Rotate keep their border. */
#gazeMosaic .mosaic-lens {
  box-shadow: 0 8px 32px rgba(0, 0, 0, .35);
}

/* Positioned ancestor for a .mosaic-lens that isn't SOUNDS' own
   .story-mosaic (V1-Rotate's sketch sheet and balsa-model row reuse the
   same lens, see v1-rotate.html) — cursor:none since the lens itself is
   the pointer here, same as .story-mosaic. Plain visible: the script's
   move() clamps the lens's own center so the full circle always stays
   inside this container, which is what actually keeps it from spilling
   past an edge. overflow:hidden was tried first, but clipping the
   circle at the container's edge cuts it (and its box-shadow) into a
   flat-edged shape — reads as the photo having a hard rectangular
   boundary right where the "glass" should stay seamless and round. */
.story-lens-container {
  position: relative;
  cursor: none;
}

/* #sketchLens carries .story-full too, which sets its own overflow:
   hidden (for cover-fit photos that can overflow their box — not the
   case here, this one's img has no forced height to crop against).
   That still clipped the lens the same way removing the property above
   was meant to fix, since it's a second, independent declaration on the
   same element. Beats .story-full alone on specificity. */
.story-full.story-lens-container {
  overflow: visible;
}

.story-text {
  max-width: 540px;
  margin: 0 auto 96px;
  padding: 0 24px;
  color: #86868B;
  font-size: 16px;
  line-height: 1.5;
  font-weight: 300;
}

/* A section divider — deliberately true black rather than the site's
   usual soft gray body text, so it reads as a heading, not more copy. */
.story-section-heading {
  max-width: 540px;
  margin: 0 auto 24px;
  padding: 0 24px;
  color: #000;
  font-size: 27px;
  line-height: 1.2;
  font-weight: 400;
  /* Small text on the site already tracks slightly positive for
     legibility (see .story-mosaic-hint etc.) — at this size the
     relationship inverts: letters read too far apart without pulling
     them back in slightly. Subtle enough to not affect wrapping. */
  letter-spacing: -.01em;
}

/* V1-Rotate hero heading's "..." info trigger — back on the heading
   itself (not the photo — sitting on unpredictable photo content read
   as an odd, disconnected spot for a button that opens something
   between the photo and the heading, not on the photo). The wrapper
   carries the same box .story-section-heading normally sizes itself
   (moved here so the trigger can position against that exact box),
   scoped to .story-hero-heading specifically — .story-section-heading
   is shared by headings across the whole site and stays untouched
   everywhere else. */
.story-hero-heading {
  position: relative;
  max-width: 540px;
  margin: 0 auto 24px;
  padding: 0 24px;
}

.story-hero-heading .story-section-heading {
  max-width: none;
  margin: 0;
  padding: 0;
}

.story-info-trigger {
  position: absolute;
  top: 2px;
  right: 24px;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: rgba(122, 124, 127, .08);
  cursor: pointer;
  transition: background .2s ease;
  /* transform is owned by JS alone (see the spring by #v1InfoTrigger) —
     it counter-lifts this button as the panel's opening reflow pushes
     .story-hero-heading (and this button along with it) down, so the
     button ends up back under the cursor once it settles and a second
     click to close doesn't need the mouse to move at all. A CSS
     transition on transform here would race the spring's own per-frame
     values and read as laggy/rubber-banded, so there isn't one — the
     press-scale below lives on a separate inner element instead, so it
     can still transition normally without touching this property. */
}

.story-info-trigger:hover {
  background: rgba(122, 124, 127, .16);
}

.story-info-dots {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 3px;
  transition: transform 120ms cubic-bezier(.22, .8, .22, 1);
}

.story-info-trigger:active .story-info-dots {
  transform: scale(.92);
}

.story-info-dot {
  width: 3px;
  height: 3px;
  border-radius: 50%;
  background: #000;
}

/* Sits between the hero photo and .story-hero-heading, pushing the
   heading (and everything after it) down as it opens — see the script
   by #v1InfoTrigger for why. Height is driven frame-by-frame from a JS
   spring, not a CSS transition — no `transition: height` here on
   purpose, since a lingering CSS transition on top of the spring's own
   per-frame writes would race it and read as laggy. Sits on plain page
   background (nothing else is ever behind it), so — unlike the
   trigger, which sits on the photo above it — this needs no
   blur/translucency to stay legible; a plain white card reads clearly
   no matter what.
   Left/right padding (24px, in .story-info-panel-inner) is the same
   token the heading and body text around it already pad with, lining
   its text up flush with them instead of drifting from their shared
   column. Bottom padding matches (24px, pacing panel-content→heading
   the same as .story-hero-wide's own 48px margin-bottom paces
   photo→heading elsewhere). Top padding is intentionally not part of
   that same rhythm — see .story-info-panel-inner for why. */
.story-info-panel {
  height: 0;
  overflow: hidden;
}

.story-info-panel-inner {
  max-width: 540px;
  margin: 0 auto;
  /* Top padding is 22px shorter than the other three sides — the
     trigger button holds a fixed screen position whether the panel is
     open or closed (see the script by #v1InfoTrigger), so instead of
     moving the button to meet "Client", the content is pulled up to
     meet the button: measured directly as the gap between the button's
     resting top and .story-info-label's top (22px, identical on mobile
     and desktop, since neither is viewport-dependent), then subtracted
     from the shared 24px padding token everything else here still uses. */
  padding: 2px 24px 24px;
  opacity: 0;
  transition: opacity .4s cubic-bezier(.22, .8, .22, 1);
}

.story-info-row + .story-info-row {
  margin-top: 10px;
}

/* Sentence case, not caps — text-transform: uppercase was the only
   use of it anywhere on the site (checked), so it never actually
   matched an established convention here; letter-spacing comes down
   with it, since .06em was sized for the wider rhythm of all-caps
   glyphs and reads as over-tracked on lowercase letters at 10px. */
.story-info-label {
  display: block;
  font-size: 10px;
  letter-spacing: .02em;
  color: #A7A9AC;
  margin-bottom: 2px;
}

.story-info-value {
  display: block;
  font-size: 13px;
  line-height: 1.45;
  color: #000;
  font-weight: 300;
}

@media (prefers-reduced-motion: reduce) {
  .story-info-panel {
    transition: height .2s ease;
  }
  .story-info-panel-inner {
    transition: opacity .2s ease;
  }
}

.story-text-caption + .story-section-heading {
  margin-top: 34px;
}

/* Sits right under the picture it's captioning, instead of the standard
   48px rhythm gap used between narrative text and images elsewhere. */
/* Recomputed for the new 96px base rhythm above it — net gap lands
   around 19px, matching the two caption instances in the reference
   (20.6px after the charging photo, 17.3px after the side profile). */
.story-text-caption {
  max-width: 420px;
  margin-top: -77px;
  font-size: 12.5px;
  color: #A7A9AC;
  text-align: center;
}

.story-text p + p {
  margin-top: 9px;
}

.story-strip {
  max-width: 800px;
  margin: 0 auto 48px;
  padding: 0 24px;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 12px;
}

.story-strip .img-slot {
  aspect-ratio: 1 / 1;
  border-radius: var(--radius-photo);
}

.story-strip .img-slot span {
  font-size: 10px;
}

/* Height is set by JS (frame height + one viewport of scrub room) once
   the images load, since it depends on the device photo's rendered
   height at the current viewport width. */
.story-scroll {
  position: relative;
  width: 100%;
  margin: -24px 0 24px;
}

/* Height is set by JS to match the device photo's natural rendered
   height exactly — nothing needs cropping, the frame just sticks at
   top:0 for one viewport-height's worth of scroll (the phase-1 scrub),
   then releases on its own once that scroll room runs out, and the rest
   of the (already fully laid out) photo scrolls past normally. */
.story-scroll-frame {
  position: sticky;
  top: 0;
  width: 100%;
}

.story-scroll-cards {
  position: absolute;
  left: 50%;
  top: 2%;
  width: 82%;
  height: auto;
  transform: translateX(-50%);
  display: block;
}

.story-scroll-device {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: auto;
  display: block;
  will-change: transform;
}

.story-scroll-caption {
  max-width: 470px;
  margin: 24px auto 96px;
  padding: 0 24px;
  text-align: center;
  color: #A7A9AC;
  font-size: 12.5px;
  line-height: 1.5;
  font-weight: 300;
}

.story-gallery-track {
  position: relative;
  width: 100%;
  height: 260vh;
  margin: 0 0 48px;
}

.story-gallery-frame {
  position: sticky;
  top: 50vh;
  transform: translateY(-50%);
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* The stack itself is the true viewing window — cards peeking above or
   exiting below must be clipped to it, or mid-transition they spill past
   their box and overlap whatever text/photos sit around the section. */
.story-gallery-stack {
  position: relative;
  width: min(100%, 560px);
  aspect-ratio: 1 / 1.3;
  margin: 0 24px;
  overflow: hidden;
}

/* Anchored to the stack's bottom, not stretched to fill it — the extra
   30% of height above is legroom for the peeking cards behind the front
   one, so they show above the square photo instead of being clipped by
   the same overflow:hidden that (correctly) hides the downward exit. */
.story-gallery-card {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  aspect-ratio: 1 / 1;
  border-radius: var(--radius-photo);
  overflow: hidden;
  will-change: transform, opacity;
}

.story-gallery-card img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  -webkit-user-drag: none;
  user-select: none;
}

/* TERSECT's two galleries (.natural-ratio) — fixed-width, no crop. Each
   card's height comes from its own photo's natural ratio (img is
   width:100%/height:auto, not a forced box+cover), so nothing is ever
   cropped and — since every card is sized by its real content instead of
   squeezed into one shared box — nothing reads as "moving" inside a frame
   either. The stack's own height is driven by aspect-ratio (scaled off
   the tallest card, using SOUNDS' 1.3x headroom factor) so the peeking
   cards behind the front one still show above it, and the whole thing
   stays responsive since aspect-ratio (not a fixed px height) scales
   with the stack's own min(100%,382.5px) width on narrow screens.
   382.5px = 340px × 1.125 — once the 450px-too-big and 340px-too-tight
   ends of this were both tried and dismissed, 340 was left with more
   headroom than needed (real photos, real margins), so a further +12.5%
   still keeps every stack under 730px, comfortably inside a typical
   viewport like SOUNDS' own 560×728 stack is. */
/* SOUNDS pins its frame at 50vh (screen-center). TERSECT's stacks carry
   more headroom above the front card than below it (peek room only goes
   up), so centering the whole frame at 50vh leaves the actual photo
   sitting visibly below screen-center. Pinning higher, at 38vh, moves
   the photo itself close to center instead — the JS's own centerOffset
   is kept in sync with this same 38% figure. */
.natural-ratio .story-gallery-frame {
  top: 38vh;
}

/* SOUNDS' 260vh track holds 4 cards = 3 transitions, so each transition
   gets ~86.7vh of scroll. That 260vh is a flat constant in the base
   .story-gallery-track rule, though, not scaled to card count — fine for
   PROTO (also 4 cards, so it already paces identically to SOUNDS), but
   INTERIOR's 2 cards = 1 transition spread over that same 260vh made its
   single transition ~3x slower/more gradual than SOUNDS' own pace,
   reading as smoother while PROTO read as comparatively snappy.
   Scaling INTERIOR's track down to a flat 86.7vh (matching that one
   transition's target pace) broke the effect outright: the sticky frame
   itself is up to 589px tall, and a container barely bigger than the
   frame it's meant to hold leaves the sticky mechanism no room to
   actually stay pinned — past a certain scroll point it detaches and
   scrolls away with the page instead of staying stuck, so the whole
   gallery appears to vanish. 662px is the frame's own max height (its
   stack, at the full 382.5px card width) — added as a fixed floor on top
   of the 86.7vh scroll budget, so the container is always at least
   large enough to contain the frame with real room to spare, at any
   viewport width. */
.gallery-interior {
  height: calc(662px + 86.7vh);
}

.natural-ratio .story-gallery-stack {
  width: min(100%, 382.5px);
}

.gallery-interior .story-gallery-stack {
  aspect-ratio: 382.5 / 662;
}

.gallery-proto .story-gallery-stack {
  aspect-ratio: 382.5 / 726;
}

/* Scaling from the default center origin means each card's effective
   anchor point depends on its OWN height — fine for SOUNDS (every card
   identical), but for these mixed-height cards it breaks the stacking
   order: a shorter peek card behind a taller front one can end up with
   its scaled-down top sitting BELOW the front card's top, instead of
   peeking above it. Anchoring the scale to the bottom edge instead keeps
   every card's bottom (already the shared position:absolute anchor)
   exactly where the JS puts it regardless of that card's own height, so
   the peek order stays correct no matter which photo is in which slot. */
.natural-ratio .story-gallery-card {
  aspect-ratio: auto;
  transform-origin: 50% 100%;
}

.natural-ratio .story-gallery-card img {
  height: auto;
  object-fit: contain;
}

/* A plain filmstrip, not a pinned/scroll-jacked section — the page scrolls
   completely normally the whole time. As this normal-height block passes
   through the viewport, the reel inside just slides left in step with
   that scroll; there's no trapping and nothing to "get through" first. */
.story-detail-track {
  position: relative;
  width: 100%;
  overflow: hidden;
  margin: 0 0 48px;
  padding: 0 24px;
}

.story-detail-reel {
  display: flex;
  gap: 16px;
  will-change: transform;
}

.story-detail-card {
  flex: none;
  width: 280px;
  aspect-ratio: 540 / 373;
  border-radius: var(--radius-photo);
  overflow: hidden;
  /* Safari drops border-radius clipping on an overflow:hidden element
     when an ANCESTOR has an active transform — exactly the situation
     here, since .story-detail-reel above gets a JS-driven translateX
     every scroll frame for the marquee. The corners silently vanish and
     each photo renders as a plain sharp-edged rectangle; forcing this
     element onto its own compositing layer keeps Safari clipping it
     correctly regardless of what the parent's transform is doing. */
  transform: translateZ(0);
}

.story-detail-card img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  -webkit-user-drag: none;
  user-select: none;
}

/* Controls guide: a fixed schematic on the left (crossfades between which
   button is highlighted) and a swipeable explanation panel on the right,
   with dots marking which control you're on. Not scroll-jacked — it's a
   normal-height block; swiping/dragging or tapping a dot moves between
   the 5 controls entirely on its own. */
.story-controls {
  display: flex;
  align-items: flex-start;
  justify-content: center;
  gap: 64.3px;
  max-width: 600px;
  margin: 0 auto 96px;
  padding: 0 24px;
  touch-action: pan-y;
  cursor: grab;
}

.story-controls.dragging {
  cursor: grabbing;
}

/* The schematic PNGs are exported at 2x for sharpness — 634px native for
   a 315.6px display size — so width is set explicitly rather than as a
   percentage, or they'd render twice as large as intended. */
.story-controls-image {
  position: relative;
  flex: none;
  width: 257.4px;
  aspect-ratio: 634 / 656;
}

/* A single, permanent base image (the grey device with nothing
   highlighted) that never swaps and never fades — the source control_N
   PNGs turned out to differ slightly frame-to-frame outside the intended
   highlight too (subtle re-render noise across the whole device, not just
   the active button), so crossfading between full images made the entire
   schematic look like it was re-rendering on every switch, not just the
   highlight. Only the small highlight patch below swaps now. */
.story-controls-base-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
  display: block;
  -webkit-user-drag: none;
  user-select: none;
}

/* Two identical small patches (just the highlighted button/knob/slider,
   cropped from its source image with a little grey margin for a seamless
   blend against the base) stacked directly on top of each other, exactly
   as the old full-image layers worked: both always in the DOM, only
   which one is at opacity:1 changes, position/size set inline per
   control by JS so each patch lands exactly over its element on the
   static base beneath it. */
.story-controls-highlight-layer {
  position: absolute;
  object-fit: contain;
  display: block;
  -webkit-user-drag: none;
  user-select: none;
  opacity: 0;
  transition: opacity .5s ease;
}

.story-controls-highlight-layer.active {
  opacity: 1;
}

.story-controls-panel {
  flex: none;
  width: 278.4px;
  padding-top: 49.8px;
}

.story-controls-rows {
  display: flex;
  flex-direction: column;
  gap: 20px;
  min-height: 127px;
  transition: opacity .2s ease;
}

.story-controls-rows-img {
  display: block;
  max-width: 100%;
  height: auto;
}

.story-controls-row {
  display: flex;
  align-items: center;
  gap: 12px;
}

.story-controls-row-icon {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  min-width: 22px;
  height: 22px;
  color: #5B8FD1;
}

.story-controls-row p {
  margin: 0;
  color: #58595B;
  font-size: 15px;
  line-height: 1.5;
  font-weight: 300;
}

.story-controls-dots {
  display: flex;
  gap: 10px;
  margin-top: 37.2px;
}

.story-controls-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  border: 0;
  padding: 0;
  background: rgba(122, 124, 127, .3);
  cursor: pointer;
  transition: background .2s ease, transform .2s ease;
}

.story-controls-dot.active {
  background: var(--color-text);
  transform: scale(1.3);
}

.story-controls-dot:active {
  transform: scale(.85);
}

/* A gentle, settled nudge on the caption text — same "hint there's more
   to swipe through" idea as the prescribed-freshness poster/lightbox
   jiggle, just toned down: this widget has no peeking neighbor to hint
   at the interaction visually, so the text itself carries the cue. */
@keyframes controlsSwipeHint {
  0%, 100% { transform: translateX(0); }
  15% { transform: translateX(-9px); }
  35% { transform: translateX(7px); }
  55% { transform: translateX(-4px); }
  75% { transform: translateX(2px); }
  90% { transform: translateX(-1px); }
}

.story-controls-rows.hint-jiggle { animation: controlsSwipeHint .9s ease-in-out; }

/* Hidden by default at every width — the rest of its styling (position,
   sizing, the .open state) lives entirely inside the mobile media query
   below, since it's only ever opened on mobile. Without this base rule,
   the plain <div> has no display rule outside that query, so on desktop
   it sits in normal document flow instead of being invisible — a stray
   close button in the middle of the page. */
.mosaic-lightbox {
  display: none;
}

/* Story page mobile — everything above targets the 1440px desktop canvas
   directly (fixed widths, negative offsets tuned to that canvas). Under
   the breakpoint, the handful of elements that don't already shrink on
   their own (percentage/max-width based) get overridden here instead of
   rebuilt, so the same fixes apply cleanly to future project pages that
   reuse these same story-* classes. */
@media (max-width: 680px) {
  .story-mosaic {
    width: 100%;
    margin-bottom: 10px;
    cursor: pointer;
    transition: transform 120ms cubic-bezier(.22, .8, .22, 1);
  }

  .story-mosaic:active {
    transform: scale(.98);
  }

  .story-mosaic-hint {
    display: block;
    margin: 0 0 48px;
    text-align: center;
    font-size: 12px;
    font-weight: 300;
    color: #B3B3B3;
  }

  /* TERSECT-only: sits under .story-process-preview specifically (SOUNDS'
     own hint, under .story-mosaic, is untouched) — pushed down a bit
     further from the thumbnail than the shared default above. */
  .story-process-preview + .story-mosaic-hint {
    margin-top: 14px;
  }

  /* Hover-only zoom loupe — hidden outright on touch rather than left
     dormant, since some mobile browsers fire a synthetic mouseenter on
     tap that would otherwise pop it up with nothing to dismiss it. */
  .mosaic-lens {
    display: none !important;
  }

  /* Tap-to-open gallery, replacing the desktop hover-lens (which never
     fires on touch anyway). Native horizontal scroll-snap instead of
     custom drag physics — one swipe gesture per slide, no JS tracking
     needed. */
  body.mosaic-lightbox-open {
    overflow: hidden;
  }

  .mosaic-lightbox {
    position: fixed;
    inset: 0;
    z-index: 1000;
    background: rgba(20, 20, 20, .55);
    backdrop-filter: blur(24px);
    -webkit-backdrop-filter: blur(24px);
    display: none;
    opacity: 0;
    transition: opacity .25s ease;
  }

  @media (prefers-reduced-transparency: reduce) {
    .mosaic-lightbox {
      background: rgba(20, 20, 20, .92);
      backdrop-filter: none;
      -webkit-backdrop-filter: none;
    }
  }

  .mosaic-lightbox.open {
    display: block;
    opacity: 1;
  }

  .mosaic-lightbox-track {
    display: flex;
    width: 100%;
    height: 100%;
    overflow-x: auto;
    overflow-y: hidden;
    scroll-snap-type: x mandatory;
    -webkit-overflow-scrolling: touch;
  }

  .mosaic-lightbox-slide {
    flex: 0 0 100%;
    width: 100%;
    height: 100%;
    scroll-snap-align: start;
    scroll-snap-stop: always;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 40px 20px;
    box-sizing: border-box;
  }

  .mosaic-lightbox-slide img {
    max-width: 100%;
    max-height: 100%;
    object-fit: contain;
    display: block;
  }

  /* TERSECT-only: a caption under each photo in the mobile gallery. Scoped
     to .with-caption so SOUNDS' own lightbox (no caption markup) is
     untouched — flex-direction:column stacks the image above the caption
     instead of just centering the lone image. */
  .mosaic-lightbox.with-caption .mosaic-lightbox-slide {
    flex-direction: column;
    gap: 14px;
  }

  .mosaic-lightbox.with-caption .mosaic-lightbox-slide img {
    max-height: calc(100% - 40px);
  }

  .mosaic-lightbox-caption {
    max-width: 320px;
    text-align: left;
    font-size: 13px;
    line-height: 1.5;
    font-weight: 300;
    color: #fff;
  }

  .mosaic-lightbox-close {
    position: fixed;
    top: 16px;
    right: 16px;
    z-index: 1001;
    width: 40px;
    height: 40px;
    border: 0;
    border-radius: 50%;
    background: rgba(255, 255, 255, .12);
    color: #fff;
    font-size: 22px;
    line-height: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    appearance: none;
    cursor: pointer;
    transition: transform 120ms cubic-bezier(.22, .8, .22, 1);
  }

  .mosaic-lightbox-close:active {
    transform: scale(.9);
  }

  /* Circular icon buttons read the press best at the low end of the
     subtle range (.9), one step stronger than the .97 used everywhere
     else on the site for larger tap targets. */

  /* The side-by-side schematic + swipeable panel (~736px combined) is
     wider than a phone screen — stacked instead, image on top. */
  .story-controls {
    flex-direction: column;
    align-items: center;
    gap: 24px;
  }

  .story-controls-image {
    width: min(100%, 315.6px);
  }

  .story-controls-panel {
    width: 100%;
    max-width: 341.4px;
    padding-top: 0;
  }

  /* This photo's -60px margin matches its exact off-center position in
     the reference file at desktop scale — at phone width it just crops
     the image against the screen edge, so it reverts to centered. */
  .story-col-img-offset {
    margin-left: 0 !important;
  }

  /* The side-profile shot is a small detail photo in the reference, not a
     hero image — at phone width, max-width:100% alone still let it fill
     almost the entire column, reading as oversized. */
  .story-col-img-narrow {
    width: 200px !important;
  }

  /* The "explanation text" under the schematic is actually a pre-rendered
     PNG per control (control_N_txt.png, exported at 2x — native ~666px
     for a 333px desktop display size), not live HTML text, so there's no
     font-size to target. At its full inline width it read almost as wide
     as the 315.6px schematic above it, oversized relative to it. Capped
     down and centered — well within the asset's native resolution, so it
     stays crisp. */
  .story-controls-rows-img {
    max-width: 230px;
    margin: 0 auto;
  }

  .story-controls-dots {
    justify-content: center;
  }

  /* Same pinned "device rises from below" effect as desktop, pinned at
     top:0 exactly like desktop — no repositioning. (A centered version of
     this was tried and reverted: the gallery-stack section above uses
     that same top:50vh/translateY(-50%) trick, but only works because it
     has matching JS — syncMargin()/syncTopMargin() — that compensates the
     track's own margins for the extra half-frame of space centering
     introduces. Copying the CSS alone without that JS left this section's
     spacing wrong and made it overlap the text after it.) overflow:hidden
     contains the JS's constant crop (scale) so it can't spill past the
     frame and reintroduce horizontal scroll. */
  .story-scroll-frame {
    overflow: hidden;
  }

  /* Nudged down from the base 2% so the cards aren't sitting right at the
     literal top pixel of the screen once the frame pins — purely a
     position within the frame's own (now full-viewport-height) box, not a
     change to where the frame itself pins, so it doesn't touch the
     sticky-release math at all. */
  .story-scroll-cards {
    top: 8%;
  }
}

/* Story layout (TERSECT case study) */

/* Continuous right-to-left marquee of small hardware/component photos
   (flange, plug, LED, socket, switch) — same mechanic as SOUNDS'
   .story-detail-track (constant drift + scroll-kick + drag, paused
   off-screen), not reused directly since that track forces every card
   into a uniform 280px/540:373 box — these five need to stay at their
   own real relative size instead (a flange plate really is bigger than
   an LED bulb). Heights are each source PNG's native height ÷ 4 (these
   five assets are exported at 4x — .story-mosaic images below are 3x,
   everything else on this page is 2x). The reel's markup duplicates the
   five images back-to-back so the loop point is seamless. */
.story-components-track {
  position: relative;
  width: 100%;
  overflow: hidden;
  margin: 0 0 96px;
  padding: 0 24px;
  cursor: grab;
}

.story-components-track.dragging {
  cursor: grabbing;
}

.story-components-reel {
  display: flex;
  align-items: center;
  gap: 96px;
  will-change: transform;
}

.story-components-reel img {
  flex: none;
  display: block;
  width: auto;
  -webkit-user-drag: none;
  user-select: none;
}

/* Freeform collage of development photos, absolutely positioned to match
   the reference layout — positions and sizes both measured directly off
   tersect_process_mosaic.png (a 2x reference of this section specifically,
   provided after the first pass left no real gaps between tiles and some
   overlapping), then scaled uniformly so the collage's own natural width
   fills the usual 1440px canvas. Each tile's base position/size comes in
   via --l/--t/--w/--h custom properties (set inline per instance) rather
   than JS reading/writing left/top/width/height directly, so the :hover
   state below can override them at normal CSS specificity instead of
   needing !important over an inline style. */
.story-process-mosaic {
  position: relative;
  width: 1000px;
  height: 773px;
  margin: 0 auto 48px;
}

/* Compact composite standing in for the collage on mobile — see the
   max-width:680px rules below for where it's actually shown. */
.story-process-preview {
  display: none;
}

.story-process-tile {
  position: absolute;
  left: var(--l);
  top: var(--t);
  width: var(--w);
  height: var(--h);
  cursor: pointer;
  z-index: 1;
  transform: scale(1);
  transform-origin: center;
  /* transform + box-shadow only — no left/top/width/height in the
     transition. Animating those forces a layout reflow every frame
     (expensive with ~11 overlapping absolutely-positioned tiles, and the
     likely cause of this feeling glitchy); transform is composited on the
     GPU and never triggers layout, so the same "grow on hover" reads as
     smooth instead. */
  transition: transform .25s ease, box-shadow .25s ease, z-index 0s .25s;
}

/* The image's own clipping now lives on this inner wrapper, not the tile
   itself — the tile needs to stay unclipped so the caption beside it
   (a child of the tile, so it inherits the same hover state) isn't cut
   off by the photo's own overflow:hidden. */
.story-process-tile-media {
  width: 100%;
  height: 100%;
  overflow: hidden;
}

.story-process-tile-media img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  filter: blur(0) opacity(1);
  transition: filter .25s ease;
}

/* CAD dimension drawings (V1-Rotate's process1-3) — cover-fit crops to
   whatever aspect ratio the tile box happens to be, which was clipping
   into these drawings' own dimension lines and labels since a technical
   drawing has no "safe to crop" margin the way a photo does. contain
   keeps the whole drawing visible always, letterboxed on a white ground
   that matches the drawing's own background instead of showing a gap. */
.story-process-tile--drawing .story-process-tile-media img {
  object-fit: contain;
  background: #fff;
}

/* Hover state is driven by JS toggling .is-active/.is-hovered (see the
   mosaic's own script), not CSS :hover/:has(). Two reasons: (1) :has()
   is a known WebKit perf sink — it re-evaluates the relational match
   across every sibling on every hover change, and Safari's
   implementation is markedly slower at this than Chromium's, which is
   also why the effect behaved differently there; (2) with :has(), the
   dim+blur filter was animating independently across 10+ overlapping
   tiles on every mouse move, which is exactly the kind of relational-
   selector churn WebKit struggles with and was visibly janky even in
   Chrome. Plain class toggles are a single controlled DOM mutation per
   real state change, not a selector re-match on every pointer move, and
   behave identically across browsers. will-change is added only while
   the mosaic is actually being interacted with (via .is-active), not
   permanently, so the 12 tiles don't sit as idle GPU layers while the
   page is just being scrolled past.
   Hover stays bound to the SAME element that scales (.story-process-
   tile.is-hovered) so its hit-test area grows with its visual size —
   splitting hover-detection from the scaled element would leave a
   stale, unscaled hit-box, flickering the instant the mouse crossed it. */
.story-process-mosaic.is-active .story-process-tile-media img {
  will-change: filter;
}

.story-process-tile.is-hovered {
  transform: scale(1.28);
  z-index: 100;
  box-shadow: 0 16px 40px rgba(0, 0, 0, .3);
  transition: transform .25s ease, box-shadow .25s ease, z-index 0s;
}

.story-process-mosaic.is-active .story-process-tile:not(.is-hovered) .story-process-tile-media img {
  filter: blur(3px) opacity(.28);
}

/* Sits beside the photo, not on top of it — plain text, no chip/scrim,
   since it no longer needs to read against a photo behind it. Aligned to
   the tile's top edge (not vertically centered) so it reads as a caption
   anchored to the photo, consistent regardless of how tall that specific
   tile is. Anchored at the edge closest to the tile (transform-origin)
   and counter-scaled on hover, since it's a child of the tile that
   itself scales 1.28x — without the counter-scale the caption would grow
   right along with it. */
.story-process-caption {
  position: absolute;
  top: 0;
  width: 168px;
  font-size: 12px;
  line-height: 1.5;
  font-weight: 200;
  color: #000;
  opacity: 0;
  pointer-events: none;
  transition: opacity .2s ease;
}

.story-process-tile--right .story-process-caption {
  left: 100%;
  margin-left: 16px;
  transform-origin: top left;
}

.story-process-tile--left .story-process-caption {
  right: 100%;
  margin-right: 16px;
  text-align: right;
  transform-origin: top right;
}

.story-process-caption-label {
  font-weight: 400;
}

.story-process-tile.is-hovered .story-process-caption {
  opacity: 1;
  transform: scale(.78125);
  transition: opacity .2s ease .05s, transform .25s ease;
}

@media (max-width: 680px) {
  .story-components-reel {
    gap: 56px;
  }

  .story-components-reel img {
    max-height: 100px;
    width: auto;
  }

  /* Same tap-to-open gallery as SOUNDS' .story-mosaic on mobile — the
     fixed 1440px absolute-positioned collage doesn't translate to phone
     width, and there's no hover to drive the grow+caption effect above
     on a touch screen anyway. The preview below stands in as the compact
     thumbnail; tapping it opens the same .mosaic-lightbox used on
     SOUNDS, scrolling through the real individual photos. */
  .story-process-mosaic {
    display: none;
  }

  /* Built from TILES (see the script below) as a percentage-positioned
     mini version of the desktop collage, not a single flattened image —
     that's what lets it drop process_11/12 cleanly while everything else
     keeps the desktop mosaic's exact layout, just scaled to fit. */
  .story-process-preview {
    display: block;
    position: relative;
    width: 100%;
    aspect-ratio: 1000 / 773;
    margin: 0 auto 10px;
    border-radius: var(--radius-photo);
    overflow: hidden;
    cursor: pointer;
    transition: transform 120ms cubic-bezier(.22, .8, .22, 1);
  }

  .story-process-preview:active {
    transform: scale(.98);
  }

  .story-process-preview-tile {
    position: absolute;
  }

  .story-process-preview-tile img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
  }

  /* V1-Rotate-only: sits edge-to-edge at the shared width:100% above,
     reading as cramped against the screen — the same 24px side inset
     used everywhere else on mobile (.mobile-stack, .story-detail-track)
     instead, scoped by id so TERSECT's own preview is untouched. */
  #v1ProcessPreview {
    width: calc(100% - 48px);
    margin: 0 auto 10px;
  }
}

/* PRESCRIBED FRESHNESS — the prescription+logo scroll reveal. A sticky
   frame pins vertically centered in the viewport (same top:50% +
   translateY(-50%) pinning trick as TERSECT's gallery frame) while the
   track's extra scroll room plays the reveal: prescription.png rises
   from below, scales up from small to its full PDF-measured size, and
   settles at 20% opacity exactly where the (always-visible, never
   moving) logo is already centered — so the logo reads as printed on
   top of it once it arrives, not overlaid after the fact. */
.story-rx-scroll {
  position: relative;
  width: 100%;
  height: calc(360px + 80vh);
  margin: 0 0 24px;
}

.story-rx-frame {
  position: sticky;
  top: 50%;
  transform: translateY(-50%);
  width: 100%;
  height: 360px;
}

/* Stays hidden through most of the reveal — the script only fades/scales
   it in once the card is nearly at rest (see LOGO_START in the script),
   not visible for the whole rise from small-and-low. opacity:0 here is
   just the pre-JS default, so there's no flash of the logo before the
   script's first render() runs; the script owns it after that. */
.story-rx-logo {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: min(335px, 63vw);
  height: auto;
  z-index: 2;
  opacity: 0;
  pointer-events: none;
}

/* Base position is the FINAL resting state (centered, full size) — the
   script only ever adds an extra translateY/scale on top of this
   transform, ramping to 0/1 as the reveal completes, rather than
   flipping between two different positioning schemes. */
.story-rx-prescription {
  position: absolute;
  top: 50%;
  left: 50%;
  width: min(415px, 78vw);
  height: auto;
  z-index: 1;
  transform: translate(-50%, -50%);
  will-change: transform, opacity;
}

@media (max-width: 680px) {
  .story-rx-scroll {
    height: calc(260px + 70vh);
  }

  .story-rx-frame {
    height: 260px;
  }
}

/* PRESCRIBED FRESHNESS — the paragraph right after the scroll reveal
   gets its own one-shot scroll-triggered entrance (JS adds .is-visible
   via IntersectionObserver) instead of the page-load-only .fade-in used
   elsewhere, so it reads as arriving right as the reveal settles rather
   than just sitting there, already-visible, through a long scroll. */
.story-rx-text-reveal {
  opacity: 0;
  transform: translateY(16px);
  transition: opacity .6s ease, transform .6s ease;
}

.story-rx-text-reveal.is-visible {
  opacity: 1;
  transform: translateY(0);
}

/* PRESCRIBED FRESHNESS — the topdown object collage. Same architecture
   as TERSECT's process mosaic (see .story-process-mosaic above): tiles
   positioned as percentages of the reference photo's own pixel canvas
   (here 1250×1628 — topdown_flat_pieces.png's native size, since that
   composite is what the layout PDF's positions were measured against),
   JS-driven .is-active/.is-hovered classes instead of CSS :hover/:has()
   for the same cross-browser performance reasons, and a percentage-based
   mobile preview + tap-to-open lightbox in place of the fixed desktop
   canvas. The one difference: each tile here is a standalone, tightly-
   cropped object cutout (fruit or packaging) rather than a rectangular
   photo crop, so object-fit is "contain" (preserve the object's own
   shape) rather than "cover", and there's no hover scale-zoom — the
   layout is closely packed enough that growing a tile risks overlapping
   its neighbors, so the hover treatment is dim/blur + caption only. */
.story-rx-mosaic {
  position: relative;
  width: min(650px, 100%);
  aspect-ratio: 1250 / 1628;
  margin: 0 auto 48px;
}

.story-rx-tile {
  position: absolute;
  left: var(--l);
  top: var(--t);
  width: var(--w);
  height: var(--h);
  z-index: 1;
}

/* Without this, stacking falls back to DOM order — a tile that happens
   to come later in TILES (like blueberry, after syringe) would render
   on top of an earlier-listed hovered tile and its caption wherever
   their boxes overlap, showing through as a blurred smudge across the
   caption text. The hovered tile (and its caption, a child of it) needs
   to always be the topmost element regardless of list order. */
.story-rx-tile.is-hovered {
  z-index: 10;
}

/* Clips the blur filter to this tile's own box. A few tiles sit right
   against their neighbor (syringe/blueberry, for instance) with no gap
   between them — filter:blur() isn't clipped by an element's own bounds
   by default, so the blurred neighbor's soft edge bled a couple of
   pixels past its box into the tile next to it, visible as a faint
   line/smudge along that shared edge. overflow:hidden alone didn't
   fully stop it (still visible, worst on the syringe — the tallest,
   thinnest tile, right beside blueberry), so this also adds paint
   containment and its own stacking/compositing context: contain:paint
   is a stronger, explicit guarantee ("nothing inside this box ever
   paints outside it") than overflow:hidden's layout-level clipping,
   and isolation:isolate forces this tile's filtered content onto its
   own layer instead of one that can get merged with a neighbor's during
   compositing. */
.story-rx-tile-media {
  width: 100%;
  height: 100%;
  overflow: hidden;
  contain: paint;
  isolation: isolate;
}

.story-rx-tile-media img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
  filter: blur(0) opacity(1);
  transition: filter .25s ease;
}

/* No will-change:filter here (deliberately) — a couple of tiles sit
   right against their neighbor with no gap (syringe/blueberry), and
   will-change promoting the blurred image to its own compositing layer
   is a known way for that layer's filter output to escape the parent's
   overflow:hidden clip in WebKit, which is exactly the smudge that kept
   showing up along that shared edge even after the clip was added.
   Without it, the blur composites in the normal paint order and stays
   inside its own box. */
.story-rx-mosaic.is-active .story-rx-tile:not(.is-hovered) .story-rx-tile-media img {
  filter: blur(2px) opacity(.28);
}

/* A "psst, look closer" nudge for anyone who hovers an object and just
   sits there for a couple of seconds — the same rocking motion as the
   mobile lightbox's swipe hint below (rxSwipeHint), reused here as a
   small sign of life rather than a swipe cue, since desktop has nothing
   to swipe. Targets the media wrapper, not the tile itself, so the
   caption beside it stays put instead of jiggling along with the photo.
   Re-arms every 5s for as long as the same tile stays hovered — see
   playHoverNudge/setHovered in the script. */
.story-rx-tile.story-rx-nudge .story-rx-tile-media {
  animation: rxSwipeHint 1.1s ease-in-out;
}

/* A rocking motion that decays and settles back to rest, not a full
   spin or bounce — reads as "this moves". Sized in real px rather than
   a percentage so it reads clearly on both the tiny mosaic tiles here
   and the much larger mobile lightbox image below that shares it. */
@keyframes rxSwipeHint {
  0%, 100% { transform: translateX(0) rotate(0deg); }
  10% { transform: translateX(-22px) rotate(-8deg); }
  25% { transform: translateX(16px) rotate(6deg); }
  40% { transform: translateX(-11px) rotate(-4deg); }
  55% { transform: translateX(7px) rotate(2.5deg); }
  70% { transform: translateX(-4px) rotate(-1.3deg); }
  85% { transform: translateX(2px) rotate(.6deg); }
}

.story-rx-caption {
  position: absolute;
  top: 0;
  width: 220px;
  opacity: 0;
  pointer-events: none;
  transition: opacity .2s ease;
}

.story-rx-tile--right .story-rx-caption {
  left: 100%;
  margin-left: 20px;
}

.story-rx-tile--left .story-rx-caption {
  right: 100%;
  margin-right: 20px;
  text-align: right;
}

.story-rx-tile.is-hovered .story-rx-caption {
  opacity: 1;
  transition: opacity .2s ease .05s;
}

.story-rx-caption-title {
  display: block;
  font-size: 17px;
  line-height: 1.3;
  font-weight: 400;
  color: #000;
  margin-bottom: 6px;
}

.story-rx-caption-desc {
  display: block;
  font-size: 12px;
  line-height: 1.5;
  font-weight: 200;
  color: #000;
  margin-bottom: 10px;
}

.story-rx-caption-compounds-label {
  display: block;
  font-size: 10px;
  line-height: 1.5;
  font-weight: 400;
  letter-spacing: .04em;
  color: #86868B;
  margin-bottom: 2px;
}

.story-rx-caption-compounds {
  display: block;
  font-size: 12px;
  line-height: 1.5;
  font-weight: 300;
  color: #86868B;
}

.story-rx-preview {
  display: none;
}

@media (max-width: 680px) {
  .story-rx-mosaic {
    display: none;
  }

  .story-rx-preview {
    display: block;
    position: relative;
    width: 100%;
    aspect-ratio: 1250 / 1628;
    margin: 0 auto 10px;
    border-radius: var(--radius-photo);
    overflow: hidden;
    cursor: pointer;
    transition: transform 120ms cubic-bezier(.22, .8, .22, 1);
  }

  .story-rx-preview:active {
    transform: scale(.98);
  }

  .story-rx-preview-tile {
    position: absolute;
  }

  .story-rx-preview-tile img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: contain;
  }

  /* PRESCRIBED FRESHNESS-only: sits under .story-rx-preview specifically
     (see the equivalent TERSECT rule above for .story-process-preview —
     same reasoning, scoped so SOUNDS' own hint is untouched). */
  .story-rx-preview + .story-mosaic-hint {
    margin-top: 14px;
  }

  /* PRESCRIBED FRESHNESS's own lightbox instance (id-scoped, so SOUNDS'
     and TERSECT's shared .mosaic-lightbox keep their dark blurred-glass
     treatment) — a plain white backdrop, no boxed "card" around each
     object (a white box on an already-white background just reads as a
     faint floating rectangle, not a real card). The desktop hover
     caption's black-on-white/gray palette (.story-rx-caption-title/desc/
     compounds) already suits a white background as-is, so no color
     overrides are needed here — that's the whole reason this reuses
     those classes instead of inventing lightbox-only ones. */
  #rxLightbox {
    background: #fff;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }

  /* The slide itself still centers its content by default (the shared
     .mosaic-lightbox-slide rule) — fine when there's one fixed block to
     center, but the CARD's total height varies with each caption's own
     length (a fruit's title+desc+compounds runs taller than a medical
     item's title+desc alone), so centering the whole card shifted the
     caption's start position along with it, even once the image itself
     was pinned to a fixed-height slot. Anchoring the slide from the top
     instead — a fixed 7vh clearing the close button, then the fixed-
     height image slot below it — makes the slot's own position, and
     everything that flows after it, independent of however long that
     particular caption happens to be: the top ~70% of the viewport is
     always the image zone, the remaining ~30% always the text zone. */
  #rxLightbox .mosaic-lightbox-slide {
    justify-content: flex-start;
    padding-top: 7vh;
  }

  #rxLightbox .story-rx-lightbox-card {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 14px;
    max-width: 100%;
    max-height: 100%;
    overflow: auto;
  }

  /* Fixed-height slot, same for every slide, that the (variably-sized)
     image sits inside — centered both ways, so a small object (the
     vesicles) sits in the middle of its slot rather than pinned to one
     edge — so the caption right after it lands at the same screen
     position on every slide instead of following each image's own
     height. Matches LIGHTBOX_MAX_VH in the script, the tallest object's
     (the syringe's) own height, since nothing is ever taller than that.
     63vh here + 7vh of padding-top above it = the ~70vh image zone. */
  #rxLightbox .story-rx-lightbox-image-slot {
    display: flex;
    align-items: center;
    justify-content: center;
    height: 63vh;
    flex-shrink: 0;
  }

  /* Each slide's img gets an explicit height (in vh) set inline by the
     script — proportional to that object's real size relative to the
     tallest one (the syringe), with a floor so the very smallest items
     (the capsule, the pomegranate seed) don't shrink past being
     recognizable, rather than every object independently maxing out to
     the same height regardless of its actual scale, which is what made
     the vesicles read as oversized. */
  #rxLightbox .mosaic-lightbox-slide img {
    width: auto;
    max-width: 78vw;
  }

  /* A "psst, swipe" nudge — see scheduleSwipeHint in the script, which
     repeats this every 5s until the user actually swipes. rxSwipeHint
     itself is defined at the top level now (see .story-rx-tile.story-
     rx-nudge above), shared with the desktop hover-nudge. */
  #rxLightbox .story-rx-swipe-hint {
    animation: rxSwipeHint 1.1s ease-in-out;
  }

  #rxLightbox .mosaic-lightbox-close {
    background: rgba(0, 0, 0, .06);
    color: #000;
  }
}

/* PRESCRIBED FRESHNESS — the five-poster carousel. Native CSS scroll-snap
   (same mechanism as the mobile lightbox track elsewhere on this page)
   rather than a hand-rolled drag/physics loop: scroll-snap-align:center
   plus side padding that leaves each card's neighbors peeking at the
   edges gets "one large, centered, with slivers of the next/previous
   visible" for free, including native touch-swipe momentum on mobile.
   pointerdown/move below only adds click-and-drag for desktop mouse
   users without a trackpad — the snap-to-center settle itself is native,
   not something that script computes. */
.story-poster-wrap {
  width: min(900px, 100%);
  margin: 0 auto 12px;
}

.story-poster-track {
  display: flex;
  gap: 20px;
  overflow-x: auto;
  overflow-y: hidden;
  scroll-snap-type: x mandatory;
  -webkit-overflow-scrolling: touch;
  /* Top/bottom padding isn't decorative spacing here — overflow-y:hidden
     (needed so this stays a horizontal-only scroller) was clipping each
     card's own box-shadow flat at the track's edge, since a 24px blur
     radius needs roughly that much clearance above and below the card
     to fade out fully instead of getting cut off partway through. */
  padding: 28px calc((100% - min(380px, 78vw)) / 2) 32px;
  cursor: grab;
  scrollbar-width: none;
  perspective: 1400px;
  /* Feathers the left/right edges to transparent instead of a hard clip
     as a poster scrolls out of view — fixed to the track's own box, not
     the scrolled content, so it always fades right at the visible
     boundary regardless of scroll position. */
  mask-image: linear-gradient(to right, transparent, #000 70px, #000 calc(100% - 70px), transparent);
  -webkit-mask-image: linear-gradient(to right, transparent, #000 70px, #000 calc(100% - 70px), transparent);
}

.story-poster-track::-webkit-scrollbar {
  display: none;
}

.story-poster-track.dragging {
  cursor: grabbing;
  scroll-snap-type: none;
}

.story-poster-card {
  flex: 0 0 auto;
  width: min(380px, 78vw);
  aspect-ratio: 2121 / 3000;
  scroll-snap-align: center;
  scroll-snap-stop: always;
  border-radius: var(--radius-photo);
  overflow: hidden;
  box-shadow: 0 4px 24px rgba(0, 0, 0, .08);
  opacity: 1;
  transition: opacity .3s ease;
}

/* .active is the card currently snapped/centered (see updateCarousel in
   the script). Blur used to mark the difference here too, but stacked
   on top of the 3D tilt it just read as broken — a sharp card tilting
   away already carries its own "not the focus" cue (it recedes and
   shrinks with the perspective), so this only needs a light opacity
   dip on top, not a second, competing effect. */
.story-poster-card:not(.active) {
  opacity: .82;
}

.story-poster-card img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  pointer-events: none;
}

.story-poster-dots {
  display: flex;
  justify-content: center;
  gap: 8px;
  padding: 16px 0 0;
}

.story-poster-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: rgba(122, 124, 127, .3);
  border: 0;
  padding: 0;
  cursor: pointer;
  transition: background .2s ease, transform .2s ease;
}

.story-poster-dot.active {
  background: #000;
  transform: scale(1.3);
}

.story-poster-dot:active {
  transform: scale(.85);
}

/* Placeholder project pages (not linked publicly yet) — just a centered,
   pulsing heading in place of the usual hero/copy/gallery content. */
.coming-soon {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 50vh;
  text-align: center;
}

.coming-soon-heading {
  color: #000;
  font-size: 27px;
  font-weight: 400;
  animation: comingSoonPulse 2.2s ease-in-out infinite;
}

@keyframes comingSoonPulse {
  0%, 100% { opacity: 1; }
  50% { opacity: .35; }
}

@media (prefers-reduced-motion: reduce) {
  .coming-soon-heading {
    animation: none;
    opacity: .7;
  }
}

/* V1-ROTATE */

/* Hero — wide like the reference layout's edge-to-edge banner, but with
   the site's usual side margin and rounded corners instead of true
   full-bleed. Capped at 1440px (the reference's own width) with 25px of
   padding, so on a 1440-wide viewport the image sits right up near full
   size with just a thin strip of white space at each edge; wider screens
   keep that same 25px gap plus whatever extra centers the capped box. */
.story-hero-wide {
  max-width: 1440px;
  margin: 0 auto 48px;
  padding: 0 25px;
}

/* Tighter gap specifically between consecutive wide shots (the angle
   sequence) — doesn't touch the hero's own 48px gap before the heading,
   since hero is never followed by another .story-hero-wide. */
.story-hero-wide:has(+ .story-hero-wide) {
  margin-bottom: 35px;
}

.story-hero-wide .photo-frame {
  border-radius: var(--radius-photo);
  overflow: hidden;
}

.story-hero-wide .photo-frame img {
  width: 100%;
  height: auto;
  display: block;
}

/* Hero specifically gets a fixed banner crop (its own photo has a lot of
   empty wall around the chair to trim); the angle shots reusing this
   same wide/rounded treatment keep their natural ratio via the plain
   .photo-frame rule above instead. */
.story-hero-wide .photo-frame.hero-photo {
  aspect-ratio: 1440 / 563;
}

.story-hero-wide .photo-frame.hero-photo img {
  height: 100%;
  object-fit: cover;
}

/* Mobile: the wide banner ratio flattens the chair into a sliver of a
   much wider frame. Tighten the crop instead of shrinking the whole
   image — since object-fit:cover only trims width when the box gets
   narrower than the source ratio, the full chair height stays in frame
   while the empty wall on the sides (and some of the tail end of the
   walking figure) gets cropped away, reading as "zoomed in" rather than
   "smaller." */
@media (max-width: 680px) {
  .story-hero-wide .photo-frame.hero-photo {
    aspect-ratio: 4 / 3;
  }
}

/* Closes the gap below the sketch sheet so it sits flush against the
   models row right after it — the lens effect spans both as one
   uninterrupted hover surface instead of stopping at a visible break.
   Desktop only: mobile shows a different sketch image and swaps the
   models grid for a swipeable slideshow, so there's no shared lens
   surface to keep seamless there. */
@media (min-width: 681px) {
  #sketchLens {
    margin-bottom: 0;
  }
}

@media (max-width: 680px) {
  #sketchLens {
    margin-bottom: 32px;
  }

  /* The paragraph right before the sketch, scoped by :has() so only that
     one instance shrinks — .story-text's shared 96px rhythm gap is tuned
     for the 1440px desktop canvas and reads as oversized crammed against
     a narrow mobile column right before a tall portrait image. */
  .story-text:has(+ #sketchLens) {
    margin-bottom: 32px;
  }
}

/* Two different sketch photos per breakpoint (see v1-rotate.html) — the
   mobile one is its own portrait-oriented composite, not a crop of the
   desktop landscape sheet, so swapping via CSS display keeps each at
   its own natural size instead of forcing one image into both shapes. */
#sketchLens .sketch-mobile {
  display: none;
}

@media (max-width: 680px) {
  #sketchLens .sketch-desktop {
    display: none;
  }

  #sketchLens .sketch-mobile {
    display: block;
    width: 100%;
    height: auto;
  }
}

/* Balsa scale models — a plain 4-up row (not a swipeable gallery; every
   model is meant to be visible at once, per the reference layout), with
   a small caption underneath in the same quiet, small-caps-ish register
   as .story-mosaic-hint elsewhere on the site. Mobile swaps this for
   .story-models-mobile below — a hover-grid has no touch equivalent. */
.story-models-grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 32px;
  max-width: 1180px;
  margin: 0 auto;
  padding: 0 24px;
}

.story-models-item img {
  width: 100%;
  height: auto;
  display: block;
  border-radius: var(--radius-photo);
}

.story-models-caption {
  max-width: 1180px;
  margin: 12px auto 96px;
  padding: 0 24px;
  text-align: center;
  color: #A7A9AC;
  font-size: 12px;
  font-weight: 300;
}

.story-models-mobile {
  display: none;
}

@media (max-width: 680px) {
  .story-models-grid {
    display: none;
  }

  .story-models-mobile {
    display: block;
    margin: 0 0 12px;
  }

  /* Padding (rather than centering each item) is what lets the FIRST
     and LAST models still snap to center — the padding itself supplies
     the empty scroll room on either end that a plain flex track
     wouldn't have past its first/last item. 10% each side pairs with
     each item's 80% flex-basis so the peek on both sides of the
     centered item stays even. */
  .story-models-mobile-track {
    display: flex;
    gap: 16px;
    overflow-x: auto;
    scroll-snap-type: x mandatory;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
    padding: 0 10%;
  }

  .story-models-mobile-track::-webkit-scrollbar {
    display: none;
  }

  .story-models-mobile-item {
    flex: 0 0 80%;
    scroll-snap-align: center;
    scroll-snap-stop: always;
    cursor: pointer;
    transition: transform 120ms cubic-bezier(.22, .8, .22, 1);
  }

  .story-models-mobile-item:active {
    transform: scale(.96);
  }

  .story-models-mobile-item img {
    width: 100%;
    height: auto;
    display: block;
    border-radius: var(--radius-photo);
    -webkit-user-drag: none;
    user-select: none;
  }
}

/* Pilot-seat / fuselage reference pair — two images side by side at their
   own natural proportions (not a forced equal split), matching the
   reference layout exactly. center, not flex-start: the fuselage panel
   is shorter than the pilot seat, and the reference sits it vertically
   centered against the seat rather than top-aligned. */
.story-pair {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 24px;
  max-width: 540px;
  margin: 0 auto 96px;
  padding: 0 24px;
}

.story-pair-item {
  min-width: 0;
}

.story-pair-item img {
  width: 100%;
  height: auto;
  display: block;
  border-radius: var(--radius-photo);
}

/* Detail gallery, desktop version — a row where hovering one photo grows
   it to dominate the row while its neighbors yield, per the reference
   effect. Resting widths come from the reference layout's own
   proportions (--grow, set inline per item), not an even split. Plain
   CSS :hover is enough here (unlike the process mosaic's JS-driven
   hover below) — there's no cross-tile dim/blur relational state to
   track, just the hovered item's own flex-grow changing, which :hover
   handles natively without any of :has()'s cross-browser cost. Hidden
   on mobile in favor of the swipeable stacked gallery (.gallery-detail)
   below — a hover effect has no equivalent on a touch screen. */
/* Height comes straight off the reference layout (1239 × 705) via
   aspect-ratio, not a fixed height — that keeps the same proportion at
   any viewport width instead of drifting with whatever a vw clamp
   happens to land on. All five items rest at an equal, deliberately
   narrow width (each just a teaser strip of its photo) — hovering one
   grows it to flex-grow:2, which against four resting items works out
   to almost exactly the photos' own ~0.6 source aspect ratio at this
   height, so it reveals the complete, uncropped image rather than an
   arbitrary wider crop. That's also why the jump is only 1→2, not some
   much larger number: going further would start covering a box wider
   than the photo's own proportions again, forcing the same kind of
   crop/zoom this was fixing. */
.story-hover-gallery {
  display: flex;
  align-items: stretch;
  gap: 0;
  max-width: 1239px;
  aspect-ratio: 1239 / 705;
  /* 96px, matching the .story-text rhythm used right above and below
     it (see "Two details carry the reference..." for the same 96/96
     spacing) — was 48px, which made the materials paragraph after this
     gallery sit noticeably tighter than that same paragraph pattern
     elsewhere on the page. */
  margin: 0 auto 96px;
  padding: 0 24px;
}

/* Plain flex-grow transition. This used to be faked with a FLIP
   transform (snap the layout instantly, animate a transform back from
   the old visual state) to keep the resize off the layout/reflow path
   — but that approach kept breaking in ways a simple transition can't:
   independent per-item transforms drifting into hairline seams, a
   z-index/position combo that left transitions stuck, an image
   counter-scale that fell out of sync with its container and exposed
   the background behind it. A plain flex-grow transition has none of
   those failure modes — the browser reflows the whole row together
   every frame, so every item's edges are exactly consistent by
   construction, always. Reflowing 5 flex children on hover is trivial
   cost for any modern browser. */
.story-hover-item {
  flex-grow: 1;
  flex-shrink: 1;
  flex-basis: 0;
  min-width: 40px;
  overflow: hidden;
  transition: flex-grow .25s ease-out;
}

@media (prefers-reduced-motion: reduce) {
  .story-hover-item {
    transition: none;
  }
}

/* Corners round only on the row's outer edges — the strips sit flush
   against each other, so every tile getting its own radius would carve
   little notches at each seam instead of the single unified pill shape
   this is going for. */
.story-hover-item:first-child {
  border-radius: var(--radius-photo) 0 0 var(--radius-photo);
}

.story-hover-item:last-child {
  border-radius: 0 var(--radius-photo) var(--radius-photo) 0;
}

.story-hover-item img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  -webkit-user-drag: none;
  user-select: none;
}

@media (min-width: 681px) {
  /* .gallery-detail is the mobile-only stacked-card twin of
     .story-hover-gallery above — same 5 photos, TERSECT's swipeable
     pinned-stack treatment instead of the hover effect. Scoped to this
     one modifier class specifically, not .story-gallery-track generally,
     so TERSECT's own interior/proto galleries (shown on every width)
     are unaffected. */
  .gallery-detail {
    display: none;
  }
}

@media (max-width: 680px) {
  .story-hover-gallery {
    display: none;
  }
}

/* TERSECT's per-transition pace is 86.7vh (SOUNDS' 260vh ÷ 3
   transitions for its 4-card gallery — see .gallery-interior/.gallery-
   proto above). Left at the base .story-gallery-track's flat 260vh, this
   5-card/4-transition gallery would pack the same 4 transitions into
   less scroll room (65vh each) — a noticeably snappier, rushed pace than
   every other pinned-stack gallery on the site. Scaling to the same
   86.7vh-per-transition constant (× 4 transitions here) keeps the feel
   identical to TERSECT's.

   The flat vh figure alone isn't enough, though — see TERSECT's own
   .gallery-interior comment above: without a fixed-px floor at least as
   tall as the frame itself, the container is barely bigger than the
   sticky frame it's meant to hold, so the sticky mechanism runs out of
   room to stay pinned partway through and detaches early. 693px below
   is this gallery's own frame max-height (344.25px stack width × the
   382.5/770 ratio below), added as that floor — the same fix INTERIOR
   needed, just sized to this gallery's own (10%-smaller, see below)
   cards. */
.gallery-detail {
  height: calc(693px + 346.8vh);
}

/* 10% smaller than the shared .natural-ratio default — this gallery's
   own request, not a site-wide change, so it's scaled here rather than
   in the shared width:min(100%,382.5px) rule TERSECT's two galleries
   also use. On phone-width screens the "100%" in that shared rule
   already resolves below the 382.5px cap, so a percentage-based
   reduction (min(90%, ...)) was tried first and did nothing — this
   stack's flex-basis is 'auto', and a percentage 'width' on an auto-
   basis flex item doesn't shrink it the way it would a normal block box
   (measured in-browser: 90% and 100% both computed to the exact same
   327px here). calc() against vw sidesteps that entirely, resolving
   against the viewport directly instead of asking the flex algorithm to
   resolve a percentage: 100% here is empirically the viewport width
   minus this item's own 24px-per-side margin (.story-gallery-track runs
   full-bleed, no page-level horizontal padding), so 90vw - 43.2px (90%
   of that same 48px margin) is that basis scaled to 90%.
   .story-gallery-frame's justify-content:center keeps it centered as
   the extra room opens up beside it.

   Height ratio raised from 635 to 770 — at 635 the stack (the true
   viewing window, overflow:hidden) wasn't tall enough to hold its own
   legroom, so every peeking card behind the front one landed above the
   stack's own top edge and got clipped away entirely: measured live,
   all four peek layers sat between 39px and 79px above the stack's top
   at rest. 770 was found by sweeping the full scroll range and raising
   the ratio until the deepest peek (card 5, the one that peeks the
   least and sits highest) cleared the top with room to spare — about
   24px of clearance at its tightest point, at every scroll position,
   not just at rest. */
.gallery-detail .story-gallery-stack {
  width: min(calc(90vw - 43.2px), 344.25px);
  aspect-ratio: 382.5 / 770;
}

/* The shared .natural-ratio pin (38vh, see above) was calibrated for
   TERSECT's own, much shallower legroom-to-card-height ratio — with this
   gallery's much taller legroom (needed for the fix above), that same
   38vh left the actual front card sitting well above screen-center
   instead of on it. Overridden here specifically, rather than touching
   the shared rule TERSECT still relies on. 43.6vh was found the same way
   38vh originally was: measure the front card's own on-screen center as
   a percentage of viewport height, then move the pin by the difference
   needed to land it at 50vh. The JS's own centerOffset for this page is
   kept in sync with this same 43.6% figure. */
.gallery-detail .story-gallery-frame {
  top: 43.6vh;
}

/* GAZE */

/* Day/night compare: the night photo is the plain base layer (sized
   normally, sets the block's height via its own aspect ratio); the day
   photo sits in an absolutely-positioned overlay on top of it, always
   fully painted — the range input below just dials that top image's own
   opacity, so it reads as the light itself changing, not a wipe/reveal
   between two photos. */
.story-compare {
  max-width: 720px;
  margin: 0 auto 48px;
  padding: 0 24px;
}

.story-compare-frame {
  position: relative;
  aspect-ratio: 2640 / 1760;
  border-radius: var(--radius-photo);
  overflow: hidden;
}

.story-compare-frame img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.story-compare-base {
  position: absolute;
  inset: 0;
}

.story-compare-overlay {
  position: absolute;
  inset: 0;
}

/* Opacity is set on this img directly by the range input (see the
   script by #dayNightCompare), not on the wrapping div — will-change
   promotes it to its own compositing layer so Safari repaints it
   cleanly on every drag tick instead of occasionally leaving a stale
   frame behind (the same class of issue as the mosaic lens elsewhere
   on the site, fixed the same way there). */
#dayNightDayImg {
  will-change: opacity;
}

.story-compare-controls {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  margin-top: 18px;
}

.story-compare-hint {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 11px;
  letter-spacing: .04em;
  color: var(--color-text-soft);
}

.story-compare-icon {
  flex: none;
}

/* The input's own box is a generous 28px tall so it's actually easy to
   grab and drag — the thin 2px line it looked like before WAS its
   entire hit target, which made most real clicks/drags land outside it
   and silently do nothing. The visual track stays thin by drawing it on
   the ::-webkit-slider-runnable-track / ::-moz-range-track pseudo-
   element instead of the input's own background, with the thumb
   negative-margined back onto that thin line. */
.story-compare-range {
  width: 100%;
  -webkit-appearance: none;
  appearance: none;
  height: 28px;
  background: transparent;
  outline: none;
  cursor: pointer;
}

.story-compare-range::-webkit-slider-runnable-track {
  height: 2px;
  border-radius: 999px;
  background: #D0D2D1;
}

/* Hover/press grow with a touch of overshoot (a "back"-eased curve, not
   a flat ease) so the thumb reads as a physical object responding to
   the cursor rather than a plain CSS fade — bigger on hover, bigger
   still while actually pressed, both riding the same springy curve. */
.story-compare-range::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  margin-top: -7px;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 2px 8px rgba(0, 0, 0, .25), 0 0 0 1px rgba(0, 0, 0, .08);
  cursor: pointer;
  transition: transform .4s cubic-bezier(.34, 1.56, .64, 1), box-shadow .4s cubic-bezier(.34, 1.56, .64, 1);
}

.story-compare-range:hover::-webkit-slider-thumb {
  transform: scale(1.3);
  box-shadow: 0 4px 14px rgba(0, 0, 0, .3), 0 0 0 1px rgba(0, 0, 0, .08);
}

.story-compare-range::-webkit-slider-thumb:active {
  transform: scale(1.5);
}

.story-compare-range::-moz-range-track {
  height: 2px;
  border-radius: 999px;
  background: #D0D2D1;
}

.story-compare-range::-moz-range-thumb {
  width: 16px;
  height: 16px;
  border: 0;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 2px 8px rgba(0, 0, 0, .25), 0 0 0 1px rgba(0, 0, 0, .08);
  cursor: pointer;
  transition: transform .4s cubic-bezier(.34, 1.56, .64, 1), box-shadow .4s cubic-bezier(.34, 1.56, .64, 1);
}

.story-compare-range:hover::-moz-range-thumb {
  transform: scale(1.3);
  box-shadow: 0 4px 14px rgba(0, 0, 0, .3), 0 0 0 1px rgba(0, 0, 0, .08);
}

.story-compare-range:active::-moz-range-thumb {
  transform: scale(1.5);
}

/* Circular "spin" gallery — nine portrait frames orbiting an open
   centre, matching gallery_spin.svg exactly (see the script by
   #spinCollage for the geometry: radius 106 on a 365x365 viewBox, 40°
   apart, each frame counter-rotating against the ring so it stays
   upright the whole way round). The SVG carries its own aspect ratio
   (viewBox), so the container just caps how big the ring gets. */
.story-spin-ring {
  width: 100%;
  max-width: 529px;
  /* 96px bottom margin, not 48px — matches .story-text's own 96px
     rhythm gap, so the ring sits with equal whitespace above (the
     preceding .story-text's bottom margin) and below it, instead of
     the gap below reading half as tall as the gap above. */
  margin: 0 auto 96px;
}

.story-spin-ring svg {
  display: block;
  width: 100%;
  height: auto;
  /* SVG's default overflow is hidden — clips strictly to the 365x365
     viewBox. A frame popped to 2x on hover (see the script by
     #spinCollage) extends past that box, especially for frames whose
     orbit position sits near the ring's outer edge — cut off there,
     fine for frames closer to centre, which read as "sometimes cuts
     off" depending on which one you hover. overflow:visible lets the
     enlarged frame render past the nominal box instead of being
     clipped at its edge. */
  overflow: visible;
}

/* Mobile-only replacement for the spin ring — a small row of squircle
   thumbnails that periodically trade places with a neighbour, spring-
   driven with real velocity (not a CSS keyframe loop), with
   filter:blur climbing with |velocity| each frame and clearing at rest
   — the closest cheap approximation of the reference video's blur-
   trailed swap (see the script by #jiggleCluster; JS drives transform
   and filter directly every frame, so no CSS transition/animation
   competes with it). Desktop keeps the SVG ring; only one of the two
   is ever in the DOM's visible flow at a time. */
.story-spin-ring {
  display: block;
}

.story-jiggle-cluster {
  display: none;
}

@media (max-width: 680px) {
  .story-spin-ring {
    display: none;
  }

  .story-jiggle-cluster {
    display: block;
    position: relative;
    width: 324px;
    height: 112px;
    margin: 0 auto 48px;
  }

  .story-jiggle-card {
    position: absolute;
    top: 20px;
    left: 0;
    width: 72px;
    height: 72px;
    border-radius: 16px;
    overflow: hidden;
    will-change: transform, filter;
  }

  .story-jiggle-card .photo-frame,
  .story-jiggle-card img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    border-radius: inherit;
  }
}

/* Cable photo split into two halves that slide in from opposite edges
   on scroll (see the script by #cableSplit) — one image, shown twice
   via background-position, each half only revealing its own side. */
.story-cable-split {
  position: relative;
  max-width: 720px;
  aspect-ratio: 2652 / 915;
  margin: 0 auto 48px;
  padding: 0 24px;
  overflow: hidden;
}

.story-cable-half {
  position: absolute;
  top: 0;
  bottom: 0;
  /* calc(), not a plain 50% + a matching inset — percentage widths on
     an absolutely positioned element resolve against the containing
     block's padding box, so a plain 50% paired with left/right:24px
     insets doesn't land the two halves flush at the center (they end
     up short by however much padding throws off that percentage base).
     Subtracting the inset directly ties both halves to the same 24px
     figure explicitly, so they always meet exactly at 50% regardless
     of how the percentage itself resolves. */
  width: calc(50% - 24px);
  background-repeat: no-repeat;
  background-size: 200% 100%;
  border-radius: var(--radius-photo);
}

.story-cable-half--left {
  left: 24px;
  background-position: left center;
}

.story-cable-half--right {
  right: 24px;
  background-position: right center;
}

/* Component-detail slideshow — these five source photos are each an
   isolated small part (bracket clip, steel ring, two mesh diffusers, a
   screw), not a big product shot, so every card gets an explicit
   width+height in JS matching that part's real proportions relative to
   the others (the ring reads biggest, the screw a thin sliver) rather
   than being force-cropped into one uniform box. Full-bleed breakout
   (100vw + a negative-margin recenter) so it spans edge to edge of the
   viewport instead of stopping at .page--narrow's 900px column. Named
   story-gaze-detail-* rather than reusing SOUNDS/TERSECT's own
   .story-detail-track/-reel/-card, which already exist elsewhere in
   this file with a fixed 280px/540:373 card. Draggable — pointerdown
   grabs the reel and tracks 1:1 (see the script by #detailTrack), with
   the release velocity handed to the same drift physics used for the
   passive marquee and scroll-kick, so a flick keeps going and decays
   naturally instead of stopping dead. */
.story-gaze-detail-track {
  width: 100vw;
  margin-left: calc(50% - 50vw);
  margin-right: calc(50% - 50vw);
  overflow: hidden;
  margin-top: 0;
  margin-bottom: 48px;
  /* Vertical padding sized for the tallest card (the 439px-tall
     bracket) at its full popped-and-overshot size, not just its
     1.45x target — the spring's own damping (0.5) overshoots the
     target by ~16% before settling back, so the real peak is closer
     to 1.55x. overflow-x:hidden alone can't substitute for this:
     CSS computes a lone overflow-y:visible to auto (still clips)
     whenever overflow-x is anything else, so padding is the only
     reliable way to keep a popped card from being cut off top/bottom
     while the marquee still clips left/right. */
  padding: 130px 0;
  touch-action: pan-y;
  cursor: grab;
  /* Gives the hover pop's rotateY (see the script by #detailTrack) an
     actual vanishing point to rotate toward — without a perspective on
     an ancestor, rotateY just looks like a horizontal squash with no
     depth cue at all. */
  perspective: 900px;
}

.story-gaze-detail-track.dragging {
  cursor: grabbing;
}

.story-gaze-detail-reel {
  display: flex;
  align-items: center;
  gap: 88px;
  will-change: transform;
}

/* No overflow:hidden / border-radius here — this box is what the JS
   hover spring scales and shadows (see the script by #detailTrack), and
   a clip on the same element would cut the growing shadow off at its
   own edge. The clip lives one level down, on .photo-frame, instead. */
.story-gaze-detail-card {
  flex: none;
}

.story-gaze-detail-card .photo-frame {
  width: 100%;
  height: 100%;
  border-radius: var(--radius-photo);
  overflow: hidden;
}

.story-gaze-detail-card img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: inherit;
  -webkit-user-drag: none;
  user-select: none;
}

/* Making-the-Lamp mosaic — reuses SOUNDS' own .story-mosaic component
   exactly (one flattened composite image + magnifier lens on desktop,
   tap-to-open lightbox on mobile — see the script by #gazeMosaic) rather
   than a bespoke CSS grid. .story-mosaic's own width:1440px (sized for
   SOUNDS' composite) is overridden here by id, since GAZE's composite
   is a different resolution and this page's content column is narrower.
   width:100% + max-width:900px, not a bare width:900px — the column's
   actual content box is narrower than 900px once .page's own side
   padding is subtracted, so a flat 900px overflowed it and, with
   margin:auto degenerating to 0 against a container it's wider than,
   pinned flush left instead of centering (reading as "shifted right"
   relative to the narrower text above and below it). */
#gazeMosaic.story-mosaic {
  width: 100%;
  max-width: 900px;
}

