/* =========================================================
   delgado spinola — portfolio site
   ========================================================= */

/* =========================================================
   EASY TUNING — safe to edit yourself, no CSS knowledge needed.
   Change a value, save the file, refresh the browser.

   --diptych-ratio: shape of the two main gallery images per
     project (width / height). Examples:
       3 / 4   → current, portrait, slightly tall
       4 / 5   → portrait, closer to square
       1 / 1   → perfect square
       4 / 3   → landscape (wider than tall)
     Larger second number = taller image.

   --diptych-gap: the thin line between the two images in a
     pair. In px, e.g. 1px, 4px, 12px.

   --croquis-height: how tall each lineup row is at the bottom of
     a Development page — applies to every kind (pencil croquis,
     per-figure renders, or a single wide composited strip alike).
     Every row scrolls horizontally at this fixed height rather
     than shrinking to fit the screen, so a wider lineup just means
     more to scroll through, not a smaller image.

   Cover length: how long you scroll before the cover releases into
     the site isn't a variable here — it's just one viewport-height
     per photo in ENTRANCE_SLIDES (js/data.js). Add or remove a slide
     there to make the opening sequence longer or shorter.

   For the Development/Other Works photo layout, see the "EDITORIAL
   LAYOUT SYSTEM" comment in js/data.js above PROJECTS — each entry in
   a `photos` array is now a composition block (solo/pair/trio/full)
   with a `size`, optional `pause`/`offset`, documented there with
   examples. No CSS knowledge needed to use it.

   --texture-ticker-height: how tall the sticky auto-scrolling
     texture filmstrip is (currently only Firenze's project page,
     driven by `textureScroll` in js/data.js).

   --texture-ticker-speed: how long one full loop of the filmstrip
     takes — bigger number = slower drift.
   ========================================================= */
:root {
  --diptych-ratio: 3 / 4;
  --diptych-gap: 1px;
  --croquis-height: 260px;
  --texture-ticker-height: 150px;
  --texture-ticker-speed: 100s;
}

/* =========================================================
   SHARED GRID SYSTEM — the site's underlying geometry.
   One set of numbers driving every page's page margins, column
   gutters, and content-width cap, instead of each page defining its
   own one-off equivalent. The look this produces (28px quiet edge
   margins, a loose 12-column bed under the Development/Other Works
   photo grids, one shared width ceiling for Contact/About) already
   matched what had been hand-tuned per page before this pass existed —
   this just names it once so it's a single source of truth, and so a
   future page starts from the same geometry automatically instead of
   picking new numbers.

   Layouts can and should still be asymmetric/editorial (the scattered
   Development photos, the diptych pairing, croquis lineup rows) — this
   grid is the shared envelope/rhythm they sit inside, not a visible
   column overlay everything must snap to.

     --grid-margin / --grid-margin-mobile: the quiet horizontal edge
       every page respects — sidebar padding, the gallery/development/
       contact/about content, the croquis lineup row. One number, one
       edge, site-wide.
     --grid-gutter / --grid-gutter-mobile: space between columns in the
       Development/Other Works photo grid (.dev-grid / .dev-photos) —
       deliberately tight (a contact-sheet/scrapbook density: many small
       images in one view), not the same generous spacing as the site's
       other margins.
     --grid-row-gap / --grid-row-gap-mobile: vertical space between rows
       in that same grid — same tight, dense intent as --grid-gutter.
     --grid-columns: how many columns .dev-grid/.dev-photos are divided
       into — .dev-text and every editorial layout block (see the block
       system further down, sizes sm/md/lg/xl) is a column-span out of
       this total.
     --grid-max-w: the outer width cap Contact/About stop growing past
       on very large screens, so line length/photo scale stay sane
       instead of stretching edge to edge.
     --grid-pause / --grid-pause-mobile: the extra breathing room before
       an editorial "pause" beat in the Development/Other Works photo
       layout — see the block system documented in js/data.js — a
       deliberate isolation gap, bigger than the grid's own row-gap
       above, not a separate ad hoc spacing scale.

   Deliberately NOT part of this system: --diptych-gap above (a 1px
   seam between a paired project's two hero-style images — an
   editorial "seam" motif, not a structural gutter) and --sidebar-w
   below (a fixed first column that sits outside the content grid's own
   column count, not one of its 12). */
:root {
  --grid-margin: 28px;
  --grid-margin-mobile: 20px;
  --grid-gutter: 14px;
  --grid-gutter-mobile: 8px;
  --grid-row-gap: 16px;
  --grid-row-gap-mobile: 10px;
  --grid-columns: 12;
  --grid-max-w: 1800px;
  --grid-pause: 40px;
  --grid-pause-mobile: 24px;
}

:root {
  --black: #0a0a0a;
  --white: #ffffff;
  --gray-text: #8a8a86;
  --gray-line: #e4e3df;
  --sidebar-w: 260px;
  --ease: cubic-bezier(0.65, 0, 0.35, 1);

  /* Three-role type system:
       TITLE  → --font-display  (Space Grotesk) — wordmark, big bleed labels
       HEADER → --font-nav      (Inter 600, small caps) — sidebar/nav, section
                labels (INDEX, ABOUT ME, LANGUAGES, credits roles...)
       BODY   → --font-body     (Inter 400) — paragraphs, bios, descriptions
     --font-pixel (Silkscreen) is a separate decorative accent used sparingly
     (the "Portfolio" subtitle, croquis "Fig." tags) — not one of the 3 roles. */
  --font-display: 'Space Grotesk', ui-sans-serif, system-ui, sans-serif;
  --font-pixel: 'Silkscreen', monospace;
  --font-nav: 'Inter', ui-sans-serif, system-ui, sans-serif;
  --font-body: 'Inter', ui-sans-serif, system-ui, sans-serif;
}

* { box-sizing: border-box; }

html, body {
  margin: 0;
  padding: 0;
  background: var(--black);
  color: var(--black);
  font-family: var(--font-body);
  /* clip, not hidden: hidden forces overflow-y to auto (a well-known CSS
     quirk), which silently breaks position:sticky for the cover section. */
  overflow-x: clip;
}

html {
  /* "proximity" (not "mandatory") only nudges the scroll to rest once it's
     already naturally close to a snap target — it never fights or jumps
     during normal scrolling. Paired with #end-frame's scroll-snap-align
     below, this is what gives the very bottom of the page a brief,
     subtle "hold" instead of stopping wherever momentum happened to run
     out. No JS/wheel-hijacking involved, so it can't feel locked. */
  scroll-snap-type: y proximity;
}

body { background: var(--white); }

img, .ph { display: block; }

button {
  font-family: inherit;
  cursor: pointer;
  background: none;
  border: none;
  padding: 0;
  color: inherit;
  appearance: none;
  -webkit-appearance: none;
}

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

/* ---------- placeholder image blocks ---------- */
.ph {
  position: relative;
  width: 100%;
  height: 100%;
  background-size: cover;
  background-position: center;
  overflow: hidden;
}
.ph::after {
  content: attr(data-label);
  position: absolute;
  left: 14px;
  bottom: 12px;
  font-family: var(--font-pixel);
  font-size: 10px;
  letter-spacing: 0.05em;
  color: rgba(255,255,255,0.75);
  text-transform: uppercase;
}
.ph-bw1 { background: linear-gradient(160deg, #3a3a3a, #0c0c0c 60%, #1c1c1c); }
.ph-bw2 { background: linear-gradient(200deg, #4a4a4a, #101010 55%, #262626); }
.ph-bw3 { background: linear-gradient(120deg, #2c2c2c, #050505 65%); }
.ph-magenta { background: linear-gradient(150deg, #d63b8f, #7d1e63 55%, #2b0a24); }
.ph-red { background: linear-gradient(140deg, #b3311f, #591409 60%, #200502); }
.ph-blush { background: linear-gradient(160deg, #e8b4c8, #b5647f 55%, #4d1f2c); }
.ph-teal { background: linear-gradient(150deg, #3f7f7a, #123a38 60%); }
.ph-olive { background: linear-gradient(150deg, #6f6a3f, #2c290f 60%); }
.ph-sand { background: linear-gradient(150deg, #cdbfa0, #8f8060 55%, #3a331f); }
.ph-stone { background: linear-gradient(150deg, #c98e63, #8a4f30 55%, #3c1f11); }
.ph-video::before {
  content: '▶';
  position: absolute;
  top: 12px;
  right: 14px;
  color: rgba(255,255,255,0.85);
  font-size: 11px;
}

/* =========================================================
   ENTRANCE
   ========================================================= */
/* Cover: the single, permanent top of the whole site. #entrance-slides
   holds the entrance photos as plain, normal-flow, full-height blocks —
   they scroll exactly like any other content, no pinning or transforms
   on them at all. .entrance-overlay (the wordmark) is layered on top via
   grid overlap and is itself the only thing that's position:sticky —
   it sticks centered in the viewport for as long as #entrance-slides is
   still scrolling past underneath it, then releases naturally into the
   page below once the photos run out. There is exactly one of these;
   scrolling to the very top from anywhere on the site always reaches it. */
/* RESPONSIVE CANDIDATE (desktop pinned/scroll-driven — see RESPONSIVE
   section below): this sticky-overlay-over-scrolling-photos pin is listed
   in the site's responsive plan as something to reconsider for mobile if
   it reads as too long/uncomfortable on a small screen. Leave as-is until
   that pass; do not preemptively change it. */
.cover-section {
  position: relative;
  display: grid;
  background: var(--black);
}
/* Both children below sit in the same (implicit) grid cell, so they
   overlap instead of stacking — that's what lets the sticky overlay span
   the whole photo scroll instead of just its own slice of it. */
#entrance-slides,
.entrance-overlay {
  grid-row: 1;
  grid-column: 1;
}

/* Pacing gap before the Projects area, kept INSIDE the cover on purpose
   (explicit grid-row: 2, its own row below the photo stack — not
   overlapping the sticky overlay above). This used to live as margin-top
   on .page-label-bar, which meant it re-appeared as blank header space
   every time you jumped straight to a project (applyRoute() scrolls to
   cover.offsetHeight, which now already includes this spacer) — living
   here means a direct nav lands exactly on "contact" at the top of the
   screen, and the gap is only ever seen while actually scrolling down
   from the cover. */
.cover-spacer {
  grid-row: 2;
  grid-column: 1;
  height: clamp(140px, 18vw, 320px);
  background: var(--white);
}

#entrance-slides { min-width: 0; }
.cover-slide {
  width: 100%;
  height: 100vh;
  overflow: hidden;
}
.cover-slide .ph { width: 100%; height: 100%; }
.entrance-media {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* The Intro page's own crossfading reel (see #intro-slides) reuses this
   markup/opacity pattern independently of the cover above. */
.entrance-slide {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 1.4s var(--ease);
}
.entrance-slide.is-active { opacity: 1; }
.entrance-slide .ph { width: 100%; height: 100%; }

.entrance-overlay {
  align-self: start;
  position: sticky;
  top: 0;
  height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  z-index: 5;
  pointer-events: none;
}

.entrance-click {
  pointer-events: auto;
  background: none;
  border: none;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  padding: 24px 40px;
}

.wordmark {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(32px, 6vw, 64px);
  letter-spacing: -0.01em;
  color: var(--white);
  text-transform: lowercase;
  line-height: 1;
  /* No scrim behind the text — it sits directly on whichever photo is
     passing underneath, so this shadow alone is what keeps it legible. */
  text-shadow: 0 1px 3px rgba(0,0,0,0.35), 0 8px 28px rgba(0,0,0,0.3);
  transition: opacity 0.3s var(--ease), transform 0.3s var(--ease);
}
/* Hover feedback is gated behind (hover: hover) + (pointer: fine) site-wide
   (see the convention note in the RESPONSIVE section below) so a tap on
   touch devices can never leave an element stuck in its dimmed/shifted
   state — this is pure affordance, not a reveal, so touch loses nothing
   functionally, it just skips the animation. */
@media (hover: hover) and (pointer: fine) {
  .entrance-click:hover .wordmark { opacity: 0.75; transform: translateY(-2px); }
}

.entrance-sub {
  font-family: var(--font-pixel);
  font-size: clamp(10px, 1.4vw, 14px);
  color: rgba(255,255,255,0.85);
  letter-spacing: 0.05em;
  text-shadow: 0 1px 3px rgba(0,0,0,0.35);
}


/* =========================================================
   PORTFOLIO SHELL
   ========================================================= */
#portfolio {
  position: relative;
  min-height: 100vh;
}

/* Toggled by setPageVisibility() in app.js — whichever top-level block
   (cover+main-area vs. contact-page vs. about-page) isn't the current route
   gets this, so it takes no space in the document and can never be reached
   by scrolling. Exactly one of the three is ever missing this class. */
.is-hidden-page { display: none; }

/* Positioning root for #brand-reveal below, which anchors to its top-right
   corner (independent of the sidebar's own width). */
#main-area { position: relative; }

/* Persistent, sticky "delgado spinola" bleed-label — stays reachable no
   matter how far down the page you've scrolled, and is the one way back
   to the intro/landing state from anywhere (reused as-is on the About
   page too). Contact itself now lives in the sidebar. */
/* RESPONSIVE CANDIDATE: another desktop pinned element (see note on
   .cover-section above and the RESPONSIVE section below). Currently stays
   sticky at every width — fine for tablet, worth revisiting for mobile if
   it crowds a small viewport. */
.page-label-bar {
  position: sticky;
  top: 0;
  z-index: 10;
  height: clamp(38px, 5.8vw, 88px);
  overflow: visible;
  background: var(--white);
  pointer-events: none;
}
.page-label-crop {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: clamp(41px, 6.3vw, 96px);
  overflow: hidden;
  /* This crop box is intentionally a touch taller than .page-label-bar's
     own height (above), so its own white fill — not just the bar's — is
     what needs to fully cover the label; otherwise the bottom sliver of
     the crop area (below the bar's shorter box) shows the images behind
     it instead of white. */
  background: var(--white);
}
.page-label {
  position: absolute;
  left: 24px;
  top: 0;
  z-index: 1;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(56px, 8.5vw, 130px);
  letter-spacing: -0.02em;
  color: var(--black);
  -webkit-text-stroke: 2px var(--black);
  text-stroke: 2px var(--black);
  line-height: 1;
  text-transform: lowercase;
  user-select: none;
  white-space: nowrap;
  pointer-events: auto;
  cursor: pointer;
  transition: opacity 0.3s var(--ease), transform 0.3s var(--ease);
}
@media (hover: hover) and (pointer: fine) {
  .page-label:hover { opacity: 0.7; transform: translateY(-2px); }
}
.page-label.is-static { cursor: default; pointer-events: none; }
/* "delgado spinola" now uses .page-label at its full, original size/crop
   treatment unmodified — same bleed size and bottom-crop-by-.page-label-
   crop that "contact" had. It's a longer phrase (~2x "contact"'s width at
   the same size), so on narrow viewports where the label's clamped size
   hits its 56px floor, the tail end can run past .page-label-crop's
   clipped edge and get invisibly cut off — a real tradeoff of keeping the
   size uniform across such different word lengths, not yet addressed
   (the site's mobile pass is still a functional floor generally, see the
   RESPONSIVE section below). */

/* Sticky, auto-scrolling texture filmstrip pinned at the very top of a
   project's Development view (currently Firenze's `textureScroll` only) —
   sits right below .page-label-bar and stays pinned there while the rest
   of the page scrolls past underneath it. The track is rendered twice in
   the DOM (see textureScrollHTML() in app.js) and the keyframe below moves
   exactly -50%, so the loop point between the two copies is invisible and
   it reads as an infinite drift. RESPONSIVE CANDIDATE: unstuck + shortened
   on mobile below, since two stacked sticky bars is a lot for a small
   screen — revisit if it feels crowded there. */
.texture-ticker {
  position: sticky;
  top: clamp(38px, 5.8vw, 88px); /* directly under .page-label-bar */
  z-index: 8;
  height: var(--texture-ticker-height);
  overflow: hidden;
  background: var(--white);
  border-bottom: 1px solid var(--gray-line);
}
.texture-ticker-track {
  display: flex;
  width: max-content;
  height: 100%;
  animation: texture-ticker-scroll var(--texture-ticker-speed) linear infinite;
  will-change: transform;
}
.texture-ticker-track img {
  flex: none;
  height: 100%;
  width: auto;
  object-fit: cover;
  display: block;
}
@keyframes texture-ticker-scroll {
  from { transform: translateX(0); }
  to { transform: translateX(-50%); }
}
@media (hover: hover) and (pointer: fine) {
  .texture-ticker:hover .texture-ticker-track { animation-play-state: paused; }
}

/* ---------- contact + about: full-bleed pages (no sidebar), completely
   separate from the main scrollable site — see .is-hidden-page above ---------- */
#contact-page,
#about-page {
  position: relative;
  min-height: 100vh;
  background: var(--white);
  padding-top: 72px;
  display: flex;
  flex-direction: column;
}
#contact-page.is-hidden-page,
#about-page.is-hidden-page {
  display: none;
}
#contact-body,
#about-body {
  display: flex;
  flex-direction: column;
  flex: 1;
}

/* Return link — the only way back to the main portfolio from Contact/About
   now that they're separate pages, not scroll-reachable. Fixed so it stays
   in the top-left corner even if the page's own content scrolls. */
/* RESPONSIVE CANDIDATE: fine at every width tested so far; revisit only if
   it ever crowds a small screen once the mobile pass happens. */
.page-return {
  position: fixed;
  top: 24px;
  left: var(--grid-margin);
  z-index: 20;
  font-family: var(--font-nav);
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0.02em;
  color: var(--black);
  transition: opacity 0.2s var(--ease), transform 0.2s var(--ease);
}
@media (hover: hover) and (pointer: fine) {
  .page-return:hover { opacity: 0.55; transform: translateX(-3px); }
}


.contact-grid {
  display: grid;
  grid-template-columns: 140px 1fr;
  gap: calc(54vw - 140px);
  padding: 8px var(--grid-margin) 40px;
  max-width: var(--grid-max-w);
}
.contact-photo .ph,
.contact-photo img {
  aspect-ratio: 4 / 5;
  width: 100%;
  object-fit: cover;
  display: block;
}
.contact-index-label {
  font-family: var(--font-nav);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--gray-text);
  margin-bottom: 38px;
}
.contact-bio {
  font-family: var(--font-body);
  font-size: 16px;
  line-height: 2;
  color: var(--gray-text);
  max-width: 420px;
  margin: 0 0 44px;
}
.contact-details {
  display: flex;
  flex-direction: column;
  gap: 12px;
  margin-bottom: 48px;
}
.contact-details a {
  font-family: var(--font-nav);
  font-size: 17px;
  font-weight: 500;
  color: var(--black);
  width: fit-content;
  transition: opacity 0.2s var(--ease), transform 0.2s var(--ease);
}
@media (hover: hover) and (pointer: fine) {
  .contact-details a:hover { opacity: 0.55; transform: translateX(3px); }
}

.cross-link {
  display: inline-block;
  margin-top: 8px;
  font-family: var(--font-nav);
  font-size: 13px;
  font-weight: 600;
  color: var(--black);
  transition: opacity 0.2s var(--ease), transform 0.2s var(--ease);
}
@media (hover: hover) and (pointer: fine) {
  .cross-link:hover { opacity: 0.55; transform: translateX(3px); }
}


.contact-footer {
  margin-top: auto;
  padding: 40px var(--grid-margin) 28px;
  font-family: var(--font-nav);
  font-size: 11px;
  color: var(--gray-text);
}

/* ---------- end frame: the deliberate close of the whole page ----------
   Short and understated by design: a thin hairline, then one row with the
   closing identifier flush to the far left and a small amount of
   supporting text to its right. The name is prominent through position/
   alignment alone, not through oversized type — kept at nav-label scale. */
#end-frame {
  display: flex;
  justify-content: center;
  padding: 32px var(--grid-margin) 44px;
  background: var(--white);
  /* Thin line immediately before the end frame, matching the site's other
     hairline dividers. */
  border-top: 1px solid var(--gray-line);
  /* Snap target for the proximity scroll-snap set on html above — gives
     the page a brief, subtle hold as it settles into its final position. */
  scroll-snap-align: end;
}
.end-frame-row {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 40px;
  width: 100%;
  max-width: var(--grid-max-w);
}
#end-frame .end-name {
  font-family: var(--font-nav);
  font-weight: 600;
  font-size: 14px;
  letter-spacing: 0.04em;
  color: var(--black);
  white-space: nowrap;
}
.end-text {
  display: flex;
  gap: 40px;
}
.end-text p {
  font-family: var(--font-body);
  font-size: 12px;
  line-height: 1.7;
  color: var(--gray-text);
  max-width: 220px;
  margin: 0;
}

/* ---------- about page ---------- */
.about-grid {
  display: grid;
  grid-template-columns: minmax(0, 54vw) minmax(280px, 560px);
  padding: 8px var(--grid-margin) 40px;
  max-width: var(--grid-max-w);
}
.about-content { grid-column: 2; }
.about-index-label {
  font-family: var(--font-nav);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--gray-text);
  margin-bottom: 38px;
}
.about-status {
  margin-bottom: 44px;
}
.about-status p {
  font-family: var(--font-body);
  font-size: 16px;
  line-height: 1.8;
  color: var(--gray-text);
  max-width: 460px;
  margin: 0 0 6px;
}
.about-section {
  margin-bottom: 40px;
}
.about-section-label {
  font-family: var(--font-nav);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--gray-text);
  margin-bottom: 16px;
}
.about-row {
  display: flex;
  justify-content: space-between;
  gap: 24px;
  max-width: 460px;
  padding: 10px 0;
  border-bottom: 1px solid var(--gray-line);
  font-family: var(--font-nav);
  font-size: 15px;
  color: var(--black);
}
.about-row-meta {
  color: var(--gray-text);
  text-align: right;
}
.about-edu-item { margin-bottom: 16px; }
.about-edu-item:last-child { margin-bottom: 0; }
.about-edu-detail {
  font-family: var(--font-body);
  font-size: 14px;
  line-height: 1.7;
  color: var(--gray-text);
  max-width: 460px;
  margin: 6px 0 0;
}
.about-skills {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0 24px;
}
.about-skills li {
  font-family: var(--font-nav);
  font-size: 14px;
  color: var(--black);
  padding: 7px 0;
  border-bottom: 1px solid var(--gray-line);
}

.site-header-mobile {
  display: none;
}

.layout {
  display: grid;
  grid-template-columns: var(--sidebar-w) 1fr;
  min-height: 100vh;
}

/* ---------- sidebar ---------- */
/* Desktop pinned element — already de-pinned to position:static below the
   mobile breakpoint (see RESPONSIVE section). That override is the model
   to follow for the other RESPONSIVE CANDIDATE elements above whenever
   they get their own tablet/mobile treatment. */
.sidebar {
  position: sticky;
  top: clamp(38px, 5.8vw, 88px);
  height: calc(100vh - clamp(38px, 5.8vw, 88px));
  padding: 40px var(--grid-margin) 32px;
  display: flex;
  flex-direction: column;
}


/* ---------- "delgado spinola" erase-on-hover reveal ----------
   The old sidebar brand link + its decorative divider were removed (the
   top-left .page-label bleed-label now serves as the one way back to
   intro from anywhere) — the erasing itself happens directly on
   #brand-reveal-canvas below, driven by initBrandRevealCanvas() in app.js,
   and never depended on that sidebar element. */

/* The "paint" being erased — a <canvas>, not a styled div, since the erase
   trail is drawn pixel-by-pixel along the cursor's actual path (see
   initBrandRevealCanvas() in app.js), not a simple geometric transform.
   Appended as a plain child of #intro-slides (see the DOMContentLoaded
   handler at the bottom of app.js), which already has position:relative —
   this sits directly on the intro page's own slideshow, in its actual
   existing position, rather than a separate copy placed elsewhere.
   pointer-events is *on* here (unlike a purely decorative overlay) because
   the canvas needs real cursor coordinates — safe because #intro-slides is
   a small, self-contained, non-essential-to-click region. */
.brand-reveal-canvas {
  position: absolute;
  inset: 0;
  /* canvas is a replaced element — without an explicit width/height here,
     inset:0 alone doesn't stretch it; it falls back to rendering at its
     intrinsic pixel-buffer size (the width/height *attributes* JS sets for
     DPR sharpness), which is not the same thing as its CSS display size. */
  width: 100%;
  height: 100%;
  display: block;
  cursor: default;
}

.nav-projects {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.nav-item button {
  background: none;
  border: none;
  padding: 0;
  font-family: var(--font-nav);
  font-size: 15px;
  font-weight: 400;
  color: var(--black);
  transition: opacity 0.2s var(--ease), transform 0.2s var(--ease);
}
@media (hover: hover) and (pointer: fine) {
  .nav-item button:hover { opacity: 0.55; transform: translateX(3px); }
}
.nav-item.is-active > button {
  font-weight: 700;
}

.nav-sub {
  list-style: none;
  margin: 6px 0 0;
  padding: 0 0 0 14px;
  max-height: 0;
  overflow: hidden;
  transition: max-height 0.35s var(--ease);
}
.nav-item.is-active .nav-sub {
  /* Generous cap, not a tight fit to today's item count — a fixed 60px
     here used to clip Other Works' sub-list once it grew past ~3 items
     (each new discipline pushes real content height up). This animates
     the same way (expand transition finishes as soon as content height
     is reached) but never clips future additions. */
  max-height: 500px;
}
.nav-sub button {
  font-size: 12px;
  font-weight: 400;
  color: var(--gray-text);
}
.nav-sub .is-active-sub button {
  color: var(--black);
  font-weight: 600;
}

.nav-gap { height: 28px; }

/* Contact is a primary nav option, not just another project/page link —
   noticeably larger and bolder than the rest of .nav-item so it stands
   out, while still using the same button/hover mechanics as every other
   item (only size/weight/margin differ here). */
.nav-contact { margin-top: 6px; }
.nav-contact button {
  font-size: 22px;
  font-weight: 700;
  letter-spacing: -0.01em;
}

.nav-playground { margin-top: 0; }

.sidebar-footer {
  margin-top: auto;
  padding-top: 24px;
  font-family: var(--font-nav);
  font-size: 11px;
  color: var(--gray-text);
}
.sidebar-footer a { display: block; margin-top: 4px; color: var(--black); }
@media (hover: hover) and (pointer: fine) {
  .sidebar-footer a:hover { opacity: 0.6; }
}

/* ---------- content area ---------- */
.content {
  position: relative;
  padding-top: 48px;
  /* Without this, a wide-content grid item (like .croquis-row below) has
     no bounded width to overflow *within* — CSS Grid's 1fr track defaults
     to auto min-width, i.e. it grows to fit its widest child instead of
     letting that child scroll internally. min-width: 0 caps the track at
     the grid's available space so overflow-x: auto further down actually
     triggers a contained horizontal scrollbar instead of silently
     stretching (and clipping) the whole page layout. */
  min-width: 0;
}

.view {
  display: none;
  opacity: 0;
  transform: translateY(10px);
  transition: opacity 0.55s var(--ease), transform 0.55s var(--ease);
}
.view.is-current {
  display: block;
}
.view.is-current.is-shown {
  opacity: 1;
  transform: translateY(0);
}

/* intro page — sidebar visible, single video panel, no project selected */
.intro-video {
  padding: 0 var(--grid-margin) 40px;
}
#intro-slides {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 9;
  overflow: hidden;
}
#intro-slides .entrance-media {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* diptych gallery */
.diptych {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--diptych-gap);
  background: var(--gray-line);
  margin-bottom: var(--diptych-gap);
}
/* A pair with just one image (already a pre-composited two-up shot) fills
   the full width instead of leaving an empty second column. `aspect-ratio`
   here is just a sensible fallback height for a single-image pair anywhere
   else in a gallery; the hero position overrides it below since its real
   height comes from the viewport-fit calc instead. Position/inset (rather
   than the regular width/height: 100% every other .diptych image uses) is
   deliberate: a real <img>'s own intrinsic size wins out over a percentage
   height in this single-column grid case — Chrome treats the lone auto
   row's cross-size as indefinite for that percentage to resolve against,
   even though the row visually stretches to the container's height, so
   the image quietly reverts to its natural aspect ratio and overflows the
   box instead of being cropped/contained by it. Absolute positioning with
   inset: 0 sidesteps that percentage-resolution question entirely — it
   sizes directly against the (definite) containing block instead. */
.diptych-single {
  grid-template-columns: 1fr;
  position: relative;
  aspect-ratio: var(--diptych-ratio);
}
.gallery-strip .diptych:first-child.diptych-single {
  aspect-ratio: auto;
}
.diptych-single img,
.diptych-single .ph,
.diptych-single video {
  position: absolute;
  inset: 0;
}
.diptych .ph,
.diptych img,
.diptych video {
  aspect-ratio: var(--diptych-ratio);
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* Hero pair: the very first image pair at the top of a project's gallery
   — the one visible immediately on landing (see .cover-spacer above for
   why that landing point is now exactly here). Capped to fit inside the
   remaining viewport below the sticky "contact" bar and .content's own
   top padding, so it never pushes the initial view past one screen; the
   margin-bottom below is the white frame the crop leaves before the next
   image. Every other diptych in the gallery keeps its normal
   aspect-ratio-driven height and scrolls as before — this treatment is
   deliberately just for the first pair. If .page-label-bar's height or
   .content's padding-top ever change, update the two matching numbers
   below to keep this fitting exactly. */
.gallery-strip .diptych:first-child {
  height: calc(100vh - clamp(38px, 5.8vw, 88px) - 48px - 64px);
  margin-bottom: 64px;
}
.gallery-strip .diptych:first-child .ph,
.gallery-strip .diptych:first-child img,
.gallery-strip .diptych:first-child video {
  aspect-ratio: auto;
  height: 100%;
}

.reveal {
  opacity: 0;
  transform: translateY(18px) scale(0.99);
  transition: opacity 0.8s var(--ease), transform 0.8s var(--ease);
}
.reveal.is-in {
  opacity: 1;
  transform: translateY(0) scale(1);
}

/* ---------- development template ---------- */
/* One flat 12-column grid holding the text box AND every editorial layout
   block as direct siblings — this (not two separate containers) is what
   makes the two read as one continuous composition instead of separate
   galleries. The text box is pinned to row 1 / columns 1-4 (any
   .other-works-links live inside it, not in this grid — see its own
   comment below); every block (see the EDITORIAL LAYOUT SYSTEM
   further down — solo/pair/trio/full) is left on plain auto-placement
   with only a column *span* (no explicit start unless it uses `offset`),
   so the grid's own placement algorithm does the "wrap, then break out"
   work for us: it fills row 1's remaining columns 5-12 beside the text
   first (this is the opening "Context" beat sitting next to the intro
   paragraphs), and the moment a block no longer fits in that leftover
   space it drops to a fresh row with the full 12 columns free — i.e.
   below the text box, blocks automatically span their own designated
   width, full-bleed pairs/trios/full images included. No JS/manual split
   of the photos array needed, and it degrades on its own: if a block
   doesn't fit beside the text it simply free-falls to its own row below,
   which is also the plain-white-space gap the text ends into (governed
   by row-gap plus row 1's own height, since row 1's height is set by the
   taller of the text or whatever sits beside it). */
.dev-grid {
  display: grid;
  grid-template-columns: repeat(var(--grid-columns), 1fr);
  gap: var(--grid-row-gap) var(--grid-gutter);
  align-items: start;
  padding: 0 var(--grid-margin) 40px;
}

.dev-text {
  grid-column: 1 / span 4;
  grid-row: 1;
}

.dev-text p {
  font-family: var(--font-body);
  font-size: 14.5px;
  line-height: 1.85;
  color: var(--gray-text);
  margin: 0 0 22px;
}

/* Minimal typographic link bridging a project's Development page
   straight into its own titled section of a cross-project Other Works
   collection (Textile & Material Studies, Knit Only — see
   otherWorksSectionCtaHTML in app.js). A plain bulleted text line, not
   an image button — deliberately no background, border, or container,
   per her reference. Rendered as the last child inside .dev-text
   itself, so it flows directly under the paragraphs at the text box's
   own width, independent of whatever photo happens to land beside the
   text box in the shared grid row. */
.other-works-links {
  display: flex;
  flex-direction: column;
  gap: 18px;
  margin-top: 32px;
}
.other-works-link {
  display: flex;
  align-items: center;
  gap: 14px;
  font-family: var(--font-nav);
  font-weight: 400;
  font-size: 15px;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--black);
  text-decoration: none;
  transition: opacity 0.2s var(--ease), transform 0.2s var(--ease);
}
.other-works-link-bullet {
  flex: none;
  width: 8px;
  height: 8px;
  background: var(--black);
}
@media (hover: hover) and (pointer: fine) {
  .other-works-link:hover { opacity: 0.55; transform: translateX(3px); }
}

/* Development photo grid: loose asymmetric layout, natural proportions,
   generous white space — figures keep their own aspect ratio instead of
   being force-cropped, matching a scattered process-book layout. Used for
   both real photography and placeholder gradients alike. On a project's
   Development page these figures are direct children of .dev-grid itself
   (see above); .dev-photos only wraps them as its own standalone grid for
   Other Works, which has no text box to weave around. */
.dev-photos {
  display: grid;
  grid-template-columns: repeat(var(--grid-columns), 1fr);
  gap: var(--grid-row-gap) var(--grid-gutter);
  align-items: start;
}
/* Other Works: the same scattered .dev-photos grid, but standing on its
   own below a discipline's hero image instead of living inside .dev-grid's
   text+photos pair — so it needs its own horizontal padding to line up
   with .gallery-strip above it. */
.other-works-photos {
  padding: 0 var(--grid-margin) 90px;
}
/* A cross-project Other Works collection (Textile & Material Studies,
   Knit Only) is one continuous page divided into titled sections, one per
   source project (see the `sections` shape in data.js and
   otherWorksSectionHTML in app.js) — not a single mixed grid. Each
   section carries its own id for deep-linking (see otherWorksSectionCtaHTML
   + applyRoute's landing logic in app.js). */
.other-works-section-label {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(22px, 2.6vw, 32px);
  letter-spacing: -0.01em;
  color: var(--black);
  text-transform: lowercase;
  padding: 56px var(--grid-margin) 28px;
}
/* Hairline divider between sections (not before the first one) so each
   project's group reads as clearly its own, matching the site's other
   hairline dividers (--gray-line). */
.other-works-section + .other-works-section {
  border-top: 1px solid var(--gray-line);
}
/* =========================================================
   EDITORIAL LAYOUT SYSTEM — Development / Other Works photos.
   Each entry in a `photos` array (js/data.js) is authored as one of
   four composition "blocks" (solo/pair/trio/full — see the full
   authoring guide in data.js above PROJECTS), rendered by devBlockHTML()
   in app.js. Every block is still a direct child of the same flat
   --grid-columns grid as before (.dev-grid or .dev-photos), so CSS
   auto-placement keeps doing the "fill in beside the text first, then
   wrap full-width below" trick documented on .dev-grid above — only
   the shape/size of what gets placed, and the pacing between beats, is
   now art-directed per image instead of one mechanical stagger formula
   applied uniformly down the whole page. */

/* ---- solo: one image, sized sm/md/lg/xl (span 2/3/4/6 of 12) —
   deliberately small (a contact-sheet/scrapbook density: many images
   readable together in one view, not a few large ones), 'xl' included
   mainly for the rare beat that should stand out from that density. ---- */
.dev-figure {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.dev-figure img {
  width: 100%;
  height: auto;
  display: block;
}
.dev-figure .ph {
  width: 100%;
  aspect-ratio: 3/4;
  display: block;
}
.dev-figure .ph.tall { aspect-ratio: 3/5; }
.dev-figure.size-sm { grid-column: span 2; }
.dev-figure.size-md { grid-column: span 3; }
.dev-figure.size-lg { grid-column: span 4; }
.dev-figure.size-xl { grid-column: span 6; }
/* full: breaks out to the entire row instead of sharing it with
   anything else — a single strong beat (a resolution shot, a full
   look) rather than one more figure among others. */
.dev-figure.is-full { grid-column: 1 / -1; }

/* offset: nudges a solo figure off the default left-packed position so
   it reads as a deliberate asymmetric placement rather than always
   filling the next available slot — used sparingly, for one genuine
   beat, not every image. Right edge = column 13 minus the figure's own
   span, so it always sits flush with the grid's right edge regardless
   of size. */
.dev-figure.size-sm.offset-right { grid-column: 11 / span 2; }
.dev-figure.size-md.offset-right { grid-column: 10 / span 3; }
.dev-figure.size-lg.offset-right { grid-column: 9 / span 4; }
.dev-figure.size-xl.offset-right { grid-column: 7 / span 6; }

/* ---- pair / trio: two or three related images as ONE explicit grid
   item (a sketch next to its result, a detail next to the garment it
   belongs to), each a small nested grid so the group reads as a single
   deliberate composition instead of figures that just happened to land
   side by side. Same --grid-gutter as the outer grid — one shared
   rhythm, not a separate spacing scale. ---- */
.dev-pair,
.dev-trio {
  display: grid;
  gap: var(--grid-gutter);
  align-items: start;
}
.dev-pair img,
.dev-trio img {
  width: 100%;
  height: auto;
  display: block;
}
/* A .ph placeholder (gradient or numbered slot) has no intrinsic size of
   its own the way a real <img> does, so `height: auto` alone would
   collapse it to 0 — give it the same aspect-ratio fallback .dev-figure
   .ph already uses outside a pair/trio. */
.dev-pair .ph,
.dev-trio .ph {
  width: 100%;
  aspect-ratio: 3/4;
  display: block;
}
.dev-pair .ph.tall,
.dev-trio .ph.tall { aspect-ratio: 3/5; }
.dev-pair { grid-template-columns: 1fr 1fr; }
/* ratio: weight the two images unevenly (a small detail beside its
   larger primary shot, either order) instead of always splitting even. */
.dev-pair.ratio-primary-detail { grid-template-columns: 2fr 1fr; }
.dev-pair.ratio-detail-primary { grid-template-columns: 1fr 2fr; }
.dev-pair.span-md { grid-column: span 4; }
.dev-pair.span-lg { grid-column: span 6; }
.dev-pair.span-full { grid-column: 1 / -1; }
.dev-trio {
  grid-template-columns: repeat(3, 1fr);
  grid-column: 1 / -1;
}

/* ---- numbered placeholder slot (see devPlaceholderSlots() in
   data.js) — a flat neutral box with its own two-digit number shown
   large and centered, clearly distinct from the colored .ph-xxx
   gradient placeholders used elsewhere, so each of the 30 slots is
   identifiable at a glance for later photo replacement. Overrides the
   base .ph::after corner tag (position/font/color/size) rather than
   adding a second label. ---- */
.ph.dev-slot {
  background: var(--gray-line);
  display: flex;
  align-items: center;
  justify-content: center;
}
.ph.dev-slot::after {
  content: attr(data-slot);
  position: static;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(15px, 5vw, 32px);
  letter-spacing: -0.01em;
  color: var(--black);
  text-transform: none;
}

/* ---- horizontal image sequence — a scrollable filmstrip used instead
   of the block grid above for a section's photos (currently Textile &
   Material Studies / Knit Only, see otherWorksSectionHTML in app.js;
   any discipline using the `sections` shape in data.js gets it
   automatically). Deliberately the opposite of the croquis lineup rows
   further down: generous whitespace between images instead of flush
   edges, varied height/proportion per image instead of one uniform
   height, and images sitting at different vertical positions (via
   align-self) instead of all bottom-aligned — a loose editorial rhythm,
   not a tight contact strip. Reused per section through
   devSequenceHTML()/devSequenceSlots() in app.js/data.js. ---- */
.dev-sequence {
  display: flex;
  align-items: flex-end;
  gap: 56px;
  overflow-x: auto;
  padding: 40px var(--grid-margin) 64px;
  scrollbar-width: none;
  -ms-overflow-style: none;
}
.dev-sequence::-webkit-scrollbar { display: none; }
.dev-sequence .ph.dev-slot,
.dev-sequence .dev-seq-item {
  flex: 0 0 auto;
}
/* A .ph placeholder has no intrinsic size, so its height comes from
   --seq-h (set per seq-h-* class below) with aspect-ratio (seq-ratio-*)
   faking varied proportions; a real <img> already has an intrinsic
   ratio, so it only takes the fixed height and lets width follow
   naturally — same "never force-crop a real photo" rule as everywhere
   else on the site. */
.dev-sequence .ph.dev-slot {
  width: auto;
  height: var(--seq-h, 320px);
}
.dev-sequence .dev-seq-item { height: var(--seq-h, 320px); }
.dev-sequence .dev-seq-item img {
  height: 100%;
  width: auto;
  display: block;
}
.dev-sequence .seq-h-sm { --seq-h: 220px; }
.dev-sequence .seq-h-md { --seq-h: 320px; }
.dev-sequence .seq-h-lg { --seq-h: 440px; }
.dev-sequence .seq-ratio-portrait { aspect-ratio: 3/4; }
.dev-sequence .seq-ratio-tall { aspect-ratio: 2/3; }
.dev-sequence .seq-ratio-square { aspect-ratio: 1/1; }
.dev-sequence .seq-ratio-landscape { aspect-ratio: 4/3; }
.dev-sequence .seq-v-top { align-self: flex-start; }
.dev-sequence .seq-v-center { align-self: center; }

/* ---- pause: a visual "breath" before a beat — deliberately more than
   the grid's own row-gap (--grid-row-gap) above, so it reads as a real
   pause in the sequence (an isolated image, or space before/after an
   important moment) rather than just a slightly bigger gutter. ---- */
.dev-figure.is-paused,
.dev-pair.is-paused,
.dev-trio.is-paused {
  margin-top: var(--grid-pause);
}

.lineup-group:first-child .croquis-row { padding-top: 40px; }
.lineup-label {
  padding: 0 var(--grid-margin);
  font-family: var(--font-pixel);
  font-size: 10px;
  letter-spacing: 0.05em;
  color: var(--gray-text);
  text-transform: uppercase;
}
.croquis-row {
  display: flex;
  gap: 0;
  overflow-x: auto;
  padding: 20px var(--grid-margin) 40px;
  align-items: flex-end;
  /* Scroll still works exactly as before (mouse wheel, trackpad, drag,
     touch) — only the visible scrollbar track/thumb is hidden, so the row
     reads as a seamless bleed off-screen rather than a UI widget. */
  scrollbar-width: none;      /* Firefox */
  -ms-overflow-style: none;   /* old Edge/IE */
}
.croquis-row::-webkit-scrollbar {
  display: none;              /* Chrome, Safari */
}
/* Every lineup row — pencil croquis, per-figure renders, or a single wide
   composited strip alike — shares this: fixed height, natural width,
   horizontal scroll. That's what lets a strip wider than the screen (the
   common case) be scrolled start-to-end instead of shrunk to fit. */
.croquis-row svg,
.croquis-row img {
  flex: 0 0 auto;
  height: var(--croquis-height);
  width: auto;
  display: block;
}

.gallery-strip {
  padding: 0 var(--grid-margin) 40px;
}

/* Per-image credit line — a small gray one-line caption under just the
   specific images a project's data.js entry sets `credit` on (see
   diptychHTML in app.js). Deliberately minimal/editorial: no bullets, no
   columns, no per-role line breaks — the whole "Creative Direction- X,
   Photographer- Y, ..." string is one continuous run of text. Mirrors
   `.diptych`'s own grid-template-columns so a credit lines up directly
   under its image in a two-image pair; `.diptych-credits--single` matches
   `.diptych-single` for a full-width hero image instead. */
.diptych-credits {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--diptych-gap);
  /* Generous breathing room below the credit line before the next image/
     content starts — deliberately NOT --diptych-gap (that's the thin
     image-seam hairline, 1px, way too tight for a text caption). */
  margin-bottom: 40px;
}
.diptych-credits--single {
  grid-template-columns: 1fr;
}
.diptych-credit {
  font-family: var(--font-body);
  font-size: 11px;
  line-height: 1.5;
  letter-spacing: 0.01em;
  color: var(--gray-text);
  padding-top: 8px;
}
/* The image row's own gap-to-next-pair margin moves onto .diptych-credits
   above when a credit is present, so the caption sits snug under the image
   instead of doubling the gap. The second rule matches specificity with
   the hero (:first-child) sizing rule below so a credited hero image gets
   the same treatment. */
.diptych.diptych--with-credits {
  margin-bottom: 0;
}
.gallery-strip .diptych:first-child.diptych--with-credits {
  margin-bottom: 0;
}
/* The hero image itself is sized to exactly fill the first viewport (see
   .gallery-strip .diptych:first-child above), so its credit line would
   otherwise sit right at the fold and peek into view on landing. A top
   margin here pushes it well clear of the fold — it only appears once she
   scrolls. Deliberately more than the bare minimum (100vh can render a
   little short of the visible viewport on some browsers/devices, e.g.
   mobile Safari's collapsing URL bar), so there's a real safety buffer,
   not just a 1px clearance. */
.gallery-strip .diptych:first-child.diptych--with-credits + .diptych-credits {
  margin-top: 120px;
}

/* =========================================================
   RESPONSIVE
   =========================================================
   Breakpoint tiers (documented here since plain CSS has no custom-media
   variables to reference from an @media condition itself):

     Desktop  — no query, the styles above. Full editorial layout, hover
                interactions, larger type, generous horizontal spacing,
                the pinned/scroll-driven sections flagged as
                "RESPONSIVE CANDIDATE" above.
     Tablet   — @media (max-width: 1024px). Stub below, currently empty:
                intent is to adjust proportions/spacing while preserving
                the same editorial composition (sidebar + content grid
                stays two-column), simplify interactions only where a
                touch target genuinely needs it, keep pinned/scroll
                sections as-is unless testing shows otherwise.
     Mobile   — @media (max-width: 860px) below. Intent per the site's
                responsive plan: recompose rather than shrink (this bucket
                currently just collapses grids to 1 column as a functional
                floor, not a final mobile design), prioritize readability/
                navigation, replace :hover feedback with touch equivalents
                (see the `(hover: hover) and (pointer: fine)` gates used
                throughout the file above — mobile already falls back to
                no-animation taps for free), and revisit the "RESPONSIVE
                CANDIDATE" pinned sections if they feel excessive here.

   Content and markup are already decoupled from this file: js/app.js
   renders the same HTML structure/classes at every width, and js/data.js
   holds all copy/images independent of layout — so tablet/mobile passes
   only ever need new CSS (and, if an interaction truly changes shape, a
   small touch-specific branch in app.js) rather than a rebuild. */

/* ---- Tablet (max-width: 1024px) ----
   Intentionally empty for now — add proportion/spacing adjustments here
   when the tablet pass happens. Keeping the tier declared (rather than
   introducing it later) means new rules have an obvious home and won't
   get bolted onto the mobile bucket below by mistake. */
@media (max-width: 1024px) {
}

/* ---- Mobile (max-width: 860px) ----
   Functional collapse only (single-column grids, static sidebar) — treat
   as a floor to build a real recomposed mobile layout on top of, not as
   the finished mobile design. */
@media (max-width: 860px) {
  /* Hover-dependent by design (see the "delgado spinola" erase-on-hover
     reveal block above) — rather than leave the intro video stuck in its
     idle almost-hidden state with no way to reveal it, just hide the mask
     outright so the video shows normally, same as before this feature
     existed. */
  .brand-reveal-canvas { display: none; }
  /* Un-stick and shrink the texture ticker — two stacked sticky bars is a
     lot of permanent screen real estate on a small viewport. It still
     auto-scrolls, it just scrolls away with the rest of the page now. */
  .texture-ticker { position: static; height: 72px; }
  #end-frame { padding: 28px var(--grid-margin-mobile) 36px; }
  .end-frame-row { flex-direction: column; gap: 20px; }
  .end-text { flex-direction: column; gap: 10px; }
  .dev-sequence { gap: 28px; padding: 24px var(--grid-margin-mobile) 40px; }
  .dev-sequence .seq-h-sm { --seq-h: 160px; }
  .dev-sequence .seq-h-md { --seq-h: 220px; }
  .dev-sequence .seq-h-lg { --seq-h: 300px; }
  .layout { grid-template-columns: 1fr; }
  .sidebar {
    position: static;
    height: auto;
    /* Horizontal padding matches --grid-margin-mobile like every other
       mobile block below (was a stray 24px, off by 4px from the rest of
       the site's 20px mobile margin — folded into the shared token). */
    padding: 100px var(--grid-margin-mobile) 24px;
    border-right: none;
    border-bottom: 1px solid var(--gray-line);
  }
  .content { padding-top: 32px; }
  /* Text pinned across both mobile columns leaves nothing free beside it,
     so every photo auto-places below it on its own — same "text box, then
     a plain 2-up photo grid" stack as before, just now arrived at through
     the same auto-placement rule as desktop instead of a separate
     mobile-only structure. .other-works-links lives inside .dev-text
     (see the comment above .other-works-link) so it just follows the
     text box's own full-width span here with no extra rule needed. */
  .dev-grid { grid-template-columns: repeat(3, 1fr); gap: var(--grid-row-gap-mobile) var(--grid-gutter-mobile); padding: 0 var(--grid-margin-mobile) 30px; }
  .dev-text { grid-column: 1 / -1; }
  .other-works-photos { padding: 0 var(--grid-margin-mobile) 60px; }
  .other-works-section-label { padding: 40px var(--grid-margin-mobile) 20px; font-size: 22px; }
  /* 28px literal (not --grid-row-gap-mobile, now much tighter for the
     dense photo mosaic below) — Contact's own stacked-column gap is a
     separate, unrelated spacing decision that happened to share a value
     with the old photo grid by coincidence. */
  .contact-grid { grid-template-columns: 1fr; gap: 28px; padding: 0 var(--grid-margin-mobile) 30px; max-width: none; }
  .about-grid { grid-template-columns: 1fr; padding: 0 var(--grid-margin-mobile) 30px; max-width: none; }
  .about-content { grid-column: 1; }
  .about-skills { grid-template-columns: 1fr; }
  .contact-photo .ph { max-width: 200px; }
  .diptych { grid-template-columns: 1fr; }
  .diptych .ph,
  .diptych img,
  .diptych video { aspect-ratio: 4/3; }
  /* Credit cells stack the same way as the images above them do. */
  .diptych-credits { grid-template-columns: 1fr; }
  /* The desktop-only hero-pair viewport cap doesn't translate to a
     single-column mobile stack (two images each trying to be 100% of a
     fixed-height parent) — revert the first pair back to the same plain
     aspect-ratio-driven sizing as every other pair at this width. */
  .gallery-strip .diptych:first-child { height: auto; margin-bottom: var(--diptych-gap); }
  .gallery-strip .diptych:first-child.diptych--with-credits { margin-bottom: 0; }
  .gallery-strip .diptych:first-child .ph,
  .gallery-strip .diptych:first-child img,
  .gallery-strip .diptych:first-child video { aspect-ratio: 4/3; height: 100%; }
  /* A single-image hero (.diptych-single, e.g. Rincones) has no normal-flow
     content of its own to size the container from — its image is
     position: absolute (see .diptych-single above). Restore a real
     aspect-ratio here so the container has a definite height for that
     absolutely-positioned image to fill; without this the container would
     collapse to 0 height at this breakpoint. */
  .gallery-strip .diptych:first-child.diptych-single { aspect-ratio: 4/3; }
  .dev-photos { grid-template-columns: repeat(3, 1fr); gap: var(--grid-row-gap-mobile) var(--grid-gutter-mobile); }
  .dev-figure.size-sm,
  .dev-figure.size-md,
  .dev-figure.size-lg,
  .dev-figure.size-xl,
  .dev-figure.offset-right { grid-column: span 1; }
  .dev-figure.is-full { grid-column: 1 / -1; }
  /* Pair/trio groups stack to a single column at this width (a two- or
     three-across nested grid has no room to breathe in a 2-column mobile
     bed) and take the full row rather than sharing it. */
  .dev-pair,
  .dev-trio {
    grid-column: 1 / -1;
    grid-template-columns: 1fr;
    gap: var(--grid-row-gap-mobile);
  }
  .dev-figure.is-paused,
  .dev-pair.is-paused,
  .dev-trio.is-paused { margin-top: var(--grid-pause-mobile); }
  .croquis-row { padding: 40px var(--grid-margin-mobile) 60px; }
  .entrance-slide { grid-template-columns: 1fr; }
}
