/* ============================================================
   Gastón Porte — Résumé + Selected Work deck
   shared shell: fonts, reset, stage/rail, reveal system, pill nav, menu
   ============================================================ */
@font-face{font-family:'Canopee';src:url('fonts/Canopee.woff2') format('woff2');font-weight:400;font-style:normal;font-display:swap}
@font-face{font-family:'Rama Gothic C';src:url('fonts/RamaGothicC-Bold.woff2') format('woff2');font-weight:700;font-style:normal;font-display:swap}

:root{
  --green:#6E7A52; --green-ink:#454E33; --cream:#F3E6C7; --ink:#15130E;
  --ink-soft:rgba(21,19,14,.68); --cream-soft:rgba(243,230,199,.7);
  --font:"Inter","Helvetica Neue",Helvetica,Arial,sans-serif;
  --canopee:'Canopee',"Times New Roman",serif;
  --rama:'Rama Gothic C',"Helvetica Neue",Arial,sans-serif;
  --ease:cubic-bezier(.19,1,.22,1);
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%}
body{font-family:var(--font);background:var(--ink);color:var(--ink);
  -webkit-font-smoothing:antialiased;overflow:hidden}

/* ---------- cross-document View Transitions: real page loads (one per slide) that still
   feel like one continuous cover deck. Both the leaving and arriving page must opt in for the
   browser to animate the swap instead of an instant cut — this block is copied verbatim from
   the Malacara deck's shell.css (mc- renamed to gp-), the standing recipe for every deck. ---- */
@view-transition{navigation:auto}
::view-transition-group(root){animation-duration:.9s;animation-timing-function:cubic-bezier(.76,0,.24,1)}
::view-transition-old(root){animation:none}
::view-transition-new(root){animation:gp-cover-in .9s cubic-bezier(.76,0,.24,1);box-shadow:var(--gp-curtain-shadow)}
@keyframes gp-cover-in{from{transform:translateY(100%)}to{transform:translateY(0)}}
/* going BACKWARD flips which side moves: the page you're LEAVING stays on top and slides away
   (down, off-screen) instead of the incoming page sliding up to cover it — revealing the
   previous page sitting static underneath. gp-back is set by the destination's own blocking
   <head> script (before it paints) — see navigateTo() in shell.js for how the direction is
   decided. Both pseudo-elements need an explicit z-index override here since by default the
   browser always paints the incoming snapshot over the outgoing one, which is backwards for
   this case (the outgoing one needs to stay on top while IT moves). */
html.gp-back::view-transition-old(root){animation:gp-cover-out .9s cubic-bezier(.76,0,.24,1);z-index:2;box-shadow:var(--gp-curtain-shadow)}
html.gp-back::view-transition-new(root){animation:none;z-index:1;box-shadow:none}
@keyframes gp-cover-out{from{transform:translateY(0)}to{transform:translateY(100%)}}
.pillnav{view-transition-name:pillnav}
/* the moving sheet (new on forward nav, old on back nav — whichever one is sliding over the
   other, per the z-index rules above) casts this onto the static page beneath it. Its top
   edge is the only boundary that's ever actually on-screen while sliding (the sheet itself is
   a full viewport-height snapshot, so the rest of its border sits off-screen) — a negative
   y-offset throws the shadow upward from that edge, onto whatever is still showing above it. */
:root{--gp-curtain-shadow:0 -60px 90px -20px rgba(21,19,14,.55)}
@media(prefers-reduced-motion:reduce){
  ::view-transition-group(root),::view-transition-old(root),::view-transition-new(root){animation:none!important;box-shadow:none!important}
}

/* ---------- deck / stage: each page hosts exactly one slide, always at rest — the page
   transition itself is handled above by the View Transition, not by animating this element */
.deck{position:fixed;inset:0;overflow:hidden;background:var(--ink)}
/* the accent color lives on .slide itself — same as Malacara (.g-cream etc. paint the slide
   section directly, not a nested child). .slide is the element inset:0 to the viewport, i.e.
   the thing whose painted pixels the View Transition actually snapshots; a color applied one
   level deeper (on .stage) left a gap wherever .stage's own box didn't land pixel-exact on the
   viewport at the instant of capture, which is what was reading as a white flash on forward
   navigation specifically (the direction whose snapshot is the one sliding/settling into place). */
.slide{position:absolute;inset:0;display:grid;place-items:center;overflow:hidden}
.g-green{background:var(--green)}
.g-cream{background:var(--cream)}
.g-ink{background:var(--ink)}

/* stage always fills the window exactly — no fixed 16:9 box. A locked aspect ratio meant any
   window shape other than exactly 16:9 left a margin around the stage (pillarboxed or
   letterboxed depending on which dimension was the constraint). Recoloring that margin to a
   neutral ink still reads as a stray bar to a viewer scaling their own browser window; the only
   way to guarantee edge-to-edge with zero margin on any window shape is to not reserve one.
   cqw/cqh below are tuned against 16:9 — very off-ratio windows will stretch slightly, but
   ordinary laptop/desktop windows (16:10 to ~1.5:1) read as intended. */
.stage{position:relative;width:100vw;height:100svh;container-type:size;overflow:hidden}

/* ---------- header rail: single row, imitates the Malacara rail —
   brand / mark / varying section label / year / region, all on one line,
   mark sits INLINE with the text (small lift, not a stacked row above) ---------- */
.rail{position:absolute;top:3.6cqh;left:0;right:0;height:2cqh;z-index:8;
  font-size:.78cqw;font-weight:600;letter-spacing:.01em;text-transform:uppercase}
.rail .ri{position:absolute;top:0;white-space:nowrap}
.rail .ri--brand{left:2.2cqw}
.rail .ri--tagline{left:14.5cqw}
.rail .ri--label{left:58cqw}
.rail .ri--year{left:82cqw;font-variant-numeric:tabular-nums}
.rail .ri--region{right:2.5cqw}
.rail .ri-resume{left:33cqw;text-decoration:none;color:inherit;opacity:.8;transition:opacity .2s}
.rail .ri-resume::after{content:'';position:absolute;left:0;top:.58em;width:100%;height:1px;
  background:currentColor;transform:scaleX(0);transform-origin:left center;
  transition:transform .4s cubic-bezier(.65,0,.35,1)}
.rail .ri-resume:hover{opacity:1}
.rail .ri-resume:hover::after{transform:scaleX(1)}
/* now a real button (data-act="home", wired up generically in shell.js alongside the pillnav's
   own home button) instead of a plain img — jumps back to the cover via the same cross-document
   View Transition every other nav action uses, not a separate/special-cased jump. */
.rail .ri-mark{position:absolute;left:50%;top:-.6cqh;transform:translateX(-50%);
  width:.7cqw;z-index:9;display:block;line-height:0;background:none;border:0;padding:0;
  cursor:pointer}
.rail .ri-mark img{display:block;width:100%;height:auto}
.rail--oncream{color:var(--ink)}.rail--oncream .ri--year,.rail--oncream .ri--region{color:var(--ink-soft)}
.rail--ongreen{color:var(--cream)}.rail--ongreen .ri--year,.rail--ongreen .ri--region{color:var(--cream-soft)}
.rail--onink{color:var(--cream)}.rail--onink .ri--year,.rail--onink .ri--region{color:var(--cream-soft)}

/* ---------- footnote / small caps utility ---------- */
.smallcaps{font-size:.85cqw;font-weight:600;letter-spacing:.08em;text-transform:uppercase}
.eyebrow{font-size:.95cqw;font-weight:600;letter-spacing:.1em;text-transform:uppercase}

/* ---------- reveal tiers ---------- */
[data-reveal]{will-change:transform,opacity,clip-path}
[data-reveal="large"]{clip-path:inset(0 0 105% 0);transform:translateY(.6cqh);
  transition:clip-path 1.05s var(--ease),transform 1.05s var(--ease)}
[data-reveal="info"]{opacity:0;transform:translateY(2.2cqh);
  transition:opacity .9s var(--ease),transform .9s var(--ease)}
[data-reveal="rule"]{transform:scaleX(0);transform-origin:left;transition:transform 1s var(--ease)}
[data-reveal="img"]{opacity:0;transform:scale(1.04);
  transition:opacity 1.1s var(--ease),transform 1.4s var(--ease)}
[data-reveal]{transition-delay:calc(var(--i,0)*90ms + var(--d,0ms))}
.is-in [data-reveal="large"]{clip-path:inset(0 0 -4% 0);transform:none}
.is-in [data-reveal="info"]{opacity:1;transform:none}
.is-in [data-reveal="rule"]{transform:scaleX(1)}
.is-in [data-reveal="img"]{opacity:1;transform:none}
@media(prefers-reduced-motion:reduce){
  [data-reveal]{transition:none!important}
}

/* =========================================================
   COVER
========================================================= */
.cv-wm{position:absolute;left:14.9cqw;top:9.5cqh;width:96cqw;z-index:2;color:var(--cream)}
/* each line is its own fit-content box so the rule below it (width:100%) measures the WORD,
   not the shared headline container — GASTÓN's rule and PORTE's rule are different lengths. */
.cv-wm .cv-line{display:block;width:fit-content}
.cv-wm .cv-line--g{transform:translateX(-.35cqw)}
.cv-wm .ln{position:relative;display:block;font-family:var(--canopee);font-weight:400;
  font-size:20.5cqw;line-height:.92;letter-spacing:0}
.cv-wm .rule{display:block;height:1.26cqh;background:var(--cream);margin:-1.5cqh 0 1.3cqh;width:100%}
.cv-deskbar{position:absolute;left:0;right:0;bottom:0;height:13cqh;z-index:1;background:#333131}
/* sized by WIDTH (cqw), not height (cqh). The stage no longer holds a fixed 16:9 ratio (see
   .stage), so cqw and cqh aren't a fixed multiple of each other any more — a height-based photo
   next to a width-based headline would drift out of alignment on any window shape other than
   the one it was tuned against. Driving both from cqw keeps their relative alignment identical
   at every window shape; bottom:0 still anchors it to the desk-bar.
   Wrapper (not the <img> directly) carries the box now — same proj-imgwrap/proj-img split used
   on every case slide, so the cover portrait can run the identical pixel-to-clear canvas reveal
   (see .has-pixel above and shell.js). aspect-ratio matches the source PNG (1279x1010) since a
   plain div, unlike an <img>, has no intrinsic size to derive height:auto from. drop-shadow
   moves to the wrapper — the source is a cutout (real alpha, not a rectangle), and filter reads
   the combined canvas+img subtree the same way it read the plain <img> before. */
.cv-photowrap{position:absolute;right:14.9cqw;bottom:0;width:51cqw;aspect-ratio:1279/1010;
  z-index:3;overflow:hidden;filter:drop-shadow(0 1.4cqh 2.6cqh rgba(0,0,0,.35))}
/* two short lines in the column that's genuinely clear on every window shape: left-aligned to
   the wordmark's own edge, capped at 18cqw so it never reaches into the portrait's left edge
   (34.1cqw from stage-left) regardless of viewport, sitting low enough to clear the wordmark's
   two display lines above it. */
.cv-lines{position:absolute;left:14.9cqw;bottom:3.6cqh;z-index:2;width:18cqw;
  font-family:var(--font);color:var(--cream)}
.cv-lines p{margin:0;font-weight:600;font-size:1.02cqw;line-height:1.5;letter-spacing:.005em}
.cv-lines .cv-line2{margin-top:.6cqh;font-weight:500;opacity:.72}

/* =========================================================
   COPY SLIDES — Profile / Positioning
   layout + type from the Figma "profile" reference (node 15:2): Rama Gothic C Bold
   headline in title case (not the cover's all-caps Canopee treatment), a bold all-caps
   sans "subhead" sentence, then a regular-weight sans body paragraph, bracketed by a
   thick rule under the headline and a thin rule at the very bottom.
========================================================= */
/* the content block is centered as ONE unit, both axes, inside a safe zone that clears the
   rail (top) and the pillnav (bottom) — not the full stage, so centering doesn't crowd either.
   .cs-inner carries the 25% scale-down AND is the thing that gets centered; its children stack
   in normal flow (margin gaps) rather than independent absolute `top`s, which also fixed the
   old brittleness where a 2-line vs 1-line headline needed a different fixed top for the rule
   below it. Internal columns keep their own widths (head narrower than rule/body) so the
   headline still left-aligns to the same edge as the rule instead of independently centering. */
.copyslide .cs-content{position:absolute;top:9cqh;bottom:11cqh;left:0;right:0;
  display:flex;align-items:center;justify-content:center}
.copyslide .cs-inner{width:71cqw;transform:scale(.75)}
.copyslide .cs-eyebrow{display:block;margin-bottom:3cqh;font-variant-numeric:tabular-nums}
.copyslide .cs-head{display:block;width:60cqw;margin-bottom:4cqh;
  font-family:var(--rama);font-weight:700;font-size:7.6cqw;line-height:1.02;letter-spacing:0}
.copyslide .cs-head .ln{display:block}
.copyslide .cs-rule-top{display:block;width:71cqw;height:.42cqh;margin-bottom:12.2cqh}
.copyslide .cs-body{width:71cqw}
.copyslide .cs-subhead{width:58cqw;
  font-family:var(--font);font-weight:700;font-size:2.05cqw;line-height:1.28;
  letter-spacing:.005em;text-transform:uppercase}
.copyslide .cs-copy{margin-top:2.6cqh;font-size:1.35cqw;line-height:1.5}
.copyslide .cs-rule-bottom{display:block;margin-top:2.6cqh;width:71cqw;height:.12cqh}
.copyslide.g-cream{color:var(--ink)}
.copyslide.g-cream .cs-eyebrow{color:var(--green-ink)}
.copyslide.g-cream .cs-rule-top,.copyslide.g-cream .cs-rule-bottom{background:var(--ink)}
.copyslide.g-cream .cs-copy{color:var(--ink-soft)}
.copyslide.g-ink{color:var(--cream)}
.copyslide.g-ink .cs-eyebrow{color:var(--cream-soft)}
.copyslide.g-ink .cs-rule-top,.copyslide.g-ink .cs-rule-bottom{background:var(--cream)}
.copyslide.g-ink .cs-copy{color:var(--cream-soft)}
.copyslide.g-green{color:var(--cream)}
.copyslide.g-green .cs-eyebrow{color:var(--cream-soft)}
.divider{color:var(--cream)}
/* the case assets' own baked-in canvas is #fdf9f1, not the deck's --cream (#f3e6c7) — with
   the image height-contained (not edge-to-bleed), the gap above/below it shows the slide's OWN
   background, and a token mismatch there reads as a visible seam right at the image edge. */
.proj.g-cream{color:var(--ink);background:#FDF9F1}
.proj.g-cream .cs-eyebrow{color:var(--ink-soft)}
.proj.g-ink{color:var(--cream)}
.proj.g-ink .cs-eyebrow{color:var(--cream-soft)}
.copyslide.g-green .cs-rule-top,.copyslide.g-green .cs-rule-bottom{background:var(--cream)}
.copyslide.g-green .cs-copy{color:var(--cream-soft)}

/* ---------- pill nav ---------- */
/* width:max-content — with left:50% + transform:translateX(-50%) centering an auto-width fixed
   element, the browser's shrink-to-fit sizing computes "available width" against the LEFT
   OFFSET (50vw), not the full viewport, so on any narrow phone that available half-viewport can
   come in under the pill's true content width. Flexbox then has nowhere else to claw the space
   back from except .count's min-width floor, shrinking it down there and forcing "18 / 23" to
   wrap onto two lines. max-content forces the pill to size to its real content width regardless
   of that half-viewport ceiling. */
.pillnav{position:fixed;left:50%;bottom:3.2vh;transform:translateX(-50%);z-index:60;
  width:max-content;display:flex;align-items:center;gap:14px;background:rgba(21,19,14,.78);
  backdrop-filter:blur(14px);border:1px solid rgba(243,230,199,.18);border-radius:100px;
  padding:9px 12px;color:var(--cream);box-shadow:0 10px 40px rgba(0,0,0,.4)}
.pillnav button{background:none;border:0;color:var(--cream);cursor:pointer;width:36px;height:36px;
  border-radius:50%;display:grid;place-items:center;transition:background .3s,transform .3s}
.pillnav button:hover{background:rgba(243,230,199,.14)}
.pillnav button:active{transform:scale(.9)}
.pillnav .nav-arrow svg{width:15px;height:15px;stroke:currentColor;stroke-width:2.4;fill:none}
.pillnav .home svg{width:16px;height:16px;stroke:currentColor;stroke-width:2.2;fill:none}
.pillnav .count{font-size:12px;font-weight:600;letter-spacing:.06em;padding:0 4px;
  font-variant-numeric:tabular-nums;min-width:56px;text-align:center;color:var(--cream-soft);
  white-space:nowrap}
.pillnav .count b{color:var(--cream)}
.pillnav .progress{position:absolute;left:12px;right:12px;bottom:2px;height:2px;
  background:rgba(243,230,199,.16);border-radius:2px;overflow:hidden}
.pillnav .progress i{display:block;height:100%;background:var(--cream);
  transition:width .5s var(--ease);width:25%}
.burger{width:18px;height:13px;position:relative}
.burger span{position:absolute;left:0;width:100%;height:2px;background:currentColor;border-radius:2px;
  transition:transform .4s var(--ease),opacity .3s,top .3s}
.burger span:nth-child(1){top:0}.burger span:nth-child(2){top:5.5px}.burger span:nth-child(3){top:11px}
.menu-open .burger span:nth-child(1){top:5.5px;transform:rotate(45deg)}
.menu-open .burger span:nth-child(2){opacity:0}
.menu-open .burger span:nth-child(3){top:5.5px;transform:rotate(-45deg)}

/* ---------- menu overlay: thumbnail matrix ----------
   ported from the Malacara deck's own menu (same grid/card mechanics, same click behavior) —
   light cream background (close to Malacara's own #EFEFEF) rather than GASTÓN's usual dark ink,
   because the resting/hover card shadows are dark and need a light surface to actually read;
   everything else (grid, radius, shadow, stagger-in, hover lift) is 1:1. */
.menu{position:fixed;inset:0;z-index:55;background:var(--cream);color:var(--ink);
  padding:9vh 6vw;opacity:0;visibility:hidden;transition:opacity .5s var(--ease),visibility .5s;overflow-y:auto}
.menu.open{opacity:1;visibility:visible}
.menu-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:1.4vw}
.menu-grid .card{position:relative;cursor:pointer;overflow:hidden;border-radius:14px;aspect-ratio:16/9;
  background:#000;opacity:0;transform:translateY(28px);
  box-shadow:0 8px 16px -2px rgba(0,0,0,.45);
  transition:opacity .6s var(--ease),transform .5s var(--ease),box-shadow .35s var(--ease);
  transition-delay:calc(min(var(--i,0),26)*16ms)}
.menu-grid.animate-in .card{opacity:1;transform:translateY(0)}
.menu-grid .card:hover,.menu-grid .card.active{transition-delay:0s}
.menu-grid .card:hover{transform:translateY(-6px) scale(1.02);
  box-shadow:0 26px 50px -10px rgba(0,0,0,.7),0 6px 18px rgba(0,0,0,.5)}
.menu-grid .card.active{box-shadow:0 0 0 2px var(--green),0 20px 40px -8px rgba(0,0,0,.6)}
.menu-grid .card img{width:100%;height:100%;object-fit:cover;display:block}

/* =========================================================
   SECTION DIVIDER — Figma node 15:37. Index number + short subtitle up top,
   a massive Canopee section name bracketed by a rule above AND below (unlike
   the cover's single rule per line — this one bookends the whole word).
   Every position below is pixel-measured off a 1920×1080 render of the actual
   Figma frame (not eyeballed) — a prior pass estimated these and got the
   rule-to-word gaps roughly 3cqh too tight, which is what read as "wrong". */
/* the whole index/subtitle/rule/word group is ONE centered block, not a left-margin-anchored
   stack — the reference's block only reads as "left aligned" by coincidence (its rule/word left
   margin and right margin come out equal, 413px vs 414px of 1920, because "BRANDS" happens to be
   that wide). Anchoring to a fixed left cqw instead of centering is what read as pinned toward
   the right edge once a wider real word pushed the right edge close to the frame boundary. */
.divider .dv-block{position:absolute;left:50%;top:25.6cqh;transform:translateX(-50%);width:fit-content}
.divider .dv-toprow{display:flex;justify-content:space-between;align-items:baseline;width:100%;
  margin-bottom:1.6cqh}
.divider .dv-index{position:relative;top:-1.85cqh;font-variant-numeric:tabular-nums;white-space:nowrap}
.divider .dv-index .dash{opacity:.6;margin-left:.35em}
.divider .dv-subtitle{font-family:var(--rama);font-weight:700;font-size:4.5cqw;
  letter-spacing:.005em;white-space:nowrap}
/* dv-word carries the font-size (not .ln) so the rule gaps below can be set in em — proportional
   to THIS word's own size, not a fixed cqh borrowed from a different --wsize on the other divider */
.divider .dv-word{width:fit-content;font-size:var(--wsize,10cqw)}
.divider .dv-rule{display:block;height:.09em;background:var(--cream);width:100%}
/* NOT equal em values — Canopee's own line-box reserves visibly more leading below the
   baseline than above cap-height (measured: ~.072em "free" above, ~.167em "free" below, on an
   all-caps word with zero descenders, consistent across both --wsize instances). Equal margins
   here would still render as a bigger visual gap underneath; these are picked so margin+leading
   comes out equal on both sides. */
.divider .dv-rule--top{margin-bottom:.1em}
.divider .dv-rule--bottom{margin-top:.01em}
.divider .dv-word .ln{display:block;width:fit-content;padding-left:.7cqw;
  font-family:var(--canopee);font-weight:400;
  /* massive per the reference, but the reference's own word ("BRANDS") is 6 characters —
     real section names run longer, so each slide sets --wsize to whatever's the largest this
     specific word can run without crossing the right margin, instead of one shared value
     bottlenecked by the longest name in the deck. */
  font-size:1em;line-height:.94;letter-spacing:0;white-space:nowrap}
/* the portfolio-transition divider needs one more line than the section dividers do — a full
   descriptive sentence under the headline, not just the short nowrap dv-subtitle phrase — so it
   gets its own wrapping paragraph instead of forcing that sentence into dv-subtitle's one-line box. */
.divider .dv-copy{margin-top:2.2cqh;width:46cqw;font-family:var(--font);font-weight:500;
  font-size:1.15cqw;line-height:1.5;opacity:.85}

/* =========================================================
   HORIZONTAL GALLERY — one manifest entry (the Brand Design page) holds several cases as
   panels floating by on a shared track instead of each being its own top-level slide, ported
   from the same .track/.panel pattern proven on the Malacara deck (see shell.js's top comment
   for the full navigation model). Each .panel is sized exactly like .stage itself and hosts a
   full PROJECT SLIDE block (below) unchanged — position:relative here is what lets that
   content's existing position:absolute/cqw math keep working without modification, same as if
   it were sitting directly in .stage.
   NO css transition on .track: the scroll is driven by a per-frame JS lerp (shell.js's gx/gxT/
   loop(), ported 1:1 from Malacara's own gallery engine) so it reads as one continuous drag/
   scrub instead of a discrete slide-to-slide snap — a CSS transition here would fight that,
   re-animating every single one of the loop's per-frame assignments. will-change is a plain
   perf hint for a transform that updates every frame.
   Inactive panels stay fully painted (not visibility:hidden) because during a continuous scroll
   the NEXT/PREV panel is genuinely visible mid-drag, sliding in from the .stage{overflow:hidden}
   edge — hiding it would pop it in/out instead of sliding. `inert` (JS-toggled in shell.js) does
   the accessibility job visibility used to: pulls the panel out of tab order/AT exposure without
   touching paint. */
.track{position:absolute;inset:0;display:flex;width:100%;height:100%;will-change:transform}
.panel{position:relative;flex:0 0 100%;width:100%;height:100%;overflow:hidden}

/* per-image counter-drift, ported 1:1 from Malacara's ".mockbox img{translate:...var(--sxx)}" —
   as the track scrolls, each panel's own image also nudges slightly against its frame (--sxx,
   set every frame in shell.js's applyGX()), so the image reads as floating a beat independently
   of the box/track around it instead of being rigidly glued to it. Oversized + inset negative so
   the drift never uncovers an edge (112%/-6% is Malacara's exact figure: at the largest real
   offset — one full panel-width away, mid-scroll — the 0.06 multiplier caps the drift at ~6% of
   the box, precisely the margin this gives it on either side). */
.panel .proj-img,.panel .proj-canvas{inset:-6%;width:112%;height:112%;
  translate:var(--sxx,0px) 0}

/* =========================================================
   PROJECT SLIDE — a selected-work case image, dropped in full per the
   Order.pdf sequence. The source artboards carry their own baked-in
   "Project / Category / Year" header band; that band is cropped out of the
   asset itself (it collided with this deck's own rail) and rebuilt here as
   a small label matching the house eyebrow style, so wayfinding stays
   consistent with the rest of the deck instead of trusting per-asset text.
========================================================= */
/* full-bleed background, edge to edge. The corrected exports (assets/work/*.jpg, replaced
   2026-08-02) are the full 3840×2160 artboard at exactly the stage's own 16:9 ratio — no crop,
   no letterbox needed, cover just fills the frame 1:1 against that aspect match.
   Two layers, same box: a real <img> (the structural source of truth — always shows the
   correct photo natively, no JS required) with a <canvas> on top driving the pixel-to-clear
   reveal (see shell.js). The canvas fades out once it finishes, handing off to the plain
   image underneath.
   data-reveal="img" is kept on the wrap for markup consistency with every other reveal-tiered
   element, but its opacity is overridden to stay at 1 the whole time — the house [data-reveal
   ="img"] rule fades opacity 0->1 over 1.1s, and that fade was completely swallowing the
   pixelation: the canvas cycles through its ~0.6s of blocky steps while the wrap is still
   mostly transparent, so by the time it's visible enough to see, the image had already
   resolved — reading as a plain fade-in, not a sharpen-in. The canvas itself IS the reveal
   here, so the wrap stays fully hidden (opacity 0, no transition) until shell.js's
   setupWrap() has already painted the FIRST blocky frame and flips on .px-ready — matching
   the reference's own gsap.set(canvasWrap,{opacity:1}) done in the same tick as its first
   render(). Revealing any earlier (e.g. opacity:1 from the start) let the real <img>
   underneath show through the still-blank canvas, so the visible sequence was crisp -> blocky
   -> crisp instead of blocky -> crisp — the exact inversion of the intended effect.
   Only slides with a canvas (.has-pixel — every project slide, plus the cover portrait via
   .cv-photowrap below) get that override; a slide with no canvas keeps the plain house
   fade+scale entrance from the generic [data-reveal="img"] rule. */
.proj-imgwrap{position:absolute;inset:0;z-index:2;overflow:hidden}
.proj-imgwrap.has-pixel[data-reveal="img"],.cv-photowrap.has-pixel[data-reveal="img"]{
  opacity:0;transform:none;transition:none}
.proj-imgwrap.has-pixel.px-ready[data-reveal="img"],.cv-photowrap.has-pixel.px-ready[data-reveal="img"]{
  opacity:1}
.proj-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;z-index:1}
.proj-canvas{position:absolute;inset:0;display:block;width:100%;height:100%;z-index:2}

/* big play button — an unmistakable "this is a video" affordance overlaid on the poster frame,
   same floating-dark-glass language as the pillnav. shell.js adds/removes .is-hidden on play/
   pause/ended so it reads as a real transport control, not decoration: gone the instant playback
   starts, back the moment it stops. Sits at z-index 3, above .proj-img (1) and any .proj-canvas
   (2) but still under the .proj-tag caption chip (5). */
.video-play-btn{position:absolute;z-index:3;top:50%;left:50%;transform:translate(-50%,-50%);
  width:6.2cqw;height:6.2cqw;min-width:64px;min-height:64px;border-radius:50%;
  background:rgba(21,19,14,.72);backdrop-filter:blur(6px);border:2px solid var(--cream);
  display:grid;place-items:center;cursor:pointer;transition:transform .3s var(--ease),background .25s,opacity .3s}
.video-play-btn svg{width:36%;height:36%;fill:var(--cream);margin-left:.28em}
.video-play-btn:hover{transform:translate(-50%,-50%) scale(1.08);background:rgba(21,19,14,.88)}
.video-play-btn:active{transform:translate(-50%,-50%) scale(.94)}
.video-play-btn.is-hidden{opacity:0;pointer-events:none;transform:translate(-50%,-50%) scale(.82)}

/* case caption chip — same "floating dark glass" language as the pillnav/menu (translucent ink,
   blur, hairline cream border) rather than Malacara's mint sticker, so it reads as part of THIS
   deck's system instead of a borrowed color. Docked flush to the bottom edge (left-anchored,
   clear of the pillnav which is bottom-center) rather than floating above it — a closed drawer
   that only shows name + category at rest. Hovering (or focusing — tabindex below makes this
   reachable by keyboard AND by tap on touch, no JS required) expands .pt-more upward: since the
   chip is bottom-anchored, growing its height pushes the TOP edge up while bottom stays pinned,
   which is the "expands toward the top" the drawer needs, for free, from absolute positioning.
   grid-template-rows 0fr->1fr (not max-height) animates to the paragraph's exact natural height
   instead of a guessed cap, so short and long blurbs both animate at a consistent, correct speed. */
/* top corners only (bottom stays flush/square against the viewport edge it's docked to) +
   the same box-shadow as .pillnav, so this reads as the same family of floating UI chrome
   instead of a flat content block. */
.proj-tag{position:absolute;left:2.6cqw;bottom:0;z-index:5;width:26cqw;
  background:rgba(21,19,14,.86);backdrop-filter:blur(14px);
  border:1px solid rgba(243,230,199,.18);
  border-radius:clamp(6px,1.4cqw,20px) clamp(6px,1.4cqw,20px) 0 0;color:var(--cream);
  padding:2.2cqh 1.7cqw;cursor:default;box-shadow:0 10px 40px rgba(0,0,0,.4)}
.proj-tag:focus-visible{outline:1px solid var(--cream);outline-offset:-1px}
/* head row — category top-left, name top-right (per the portttt Figma reference, node 144:2).
   The name is the brand-identifying element (bigger, Rama Gothic) and must never wrap — it gets
   white-space:nowrap and first claim on the row's width. The category is capped at max-width:42%
   so it can never eat into the name's space; if a category is too long for that column it wraps
   WITHIN it instead ("Colegio de Notarios de la CDMX" / "Consumer Brand for Capsule · Packaging"
   both appear on real slides). A handful of names are still too long for their remaining ~58% at
   the house 3.8cqw size — those get a per-instance --pt-name-size override on the .pt-name element
   itself (same pattern as the divider slides' --wsize), sized down just enough to hold one line. */
.proj-tag .pt-head{display:flex;align-items:flex-start;justify-content:space-between;gap:1.2cqw}
/* subtitle styling matches the house "numerals" (.eyebrow / .cs-eyebrow, used for the 01/03
   slide index on the profile slides): tracking + tabular-nums copied straight over, but the
   SIZE has to be .eyebrow's raw .95cqw multiplied by .75 — .cs-eyebrow only ever renders
   inside .cs-inner, which carries transform:scale(.75) (see the copyslide rules below), so
   .95cqw is not what the numerals actually look like on screen. Matching the raw value here
   (no scale applied to .proj-tag) rendered visibly larger than the thing it was meant to match. */
.proj-tag .pt-cat{flex:0 1 auto;min-width:0;max-width:42%;display:block;font-size:.71cqw;
  font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:var(--cream-soft);
  text-wrap:balance;font-variant-numeric:tabular-nums}
.proj-tag .pt-name{flex:0 0 auto;display:block;font-family:var(--rama);font-weight:700;
  font-size:var(--pt-name-size,3.8cqw);line-height:1.1;letter-spacing:0;text-align:right;
  white-space:nowrap}
/* the bold bar from the Figma reference — a permanent divider under the head row, not just a
   hairline; stays visible in both the resting and hover-expanded states */
.proj-tag .pt-bar{display:block;height:.55cqh;margin-top:1.5cqh;background:var(--cream)}
.proj-tag .pt-more{display:grid;grid-template-rows:0fr;transition:grid-template-rows .5s var(--ease)}
/* .is-open is a JS-driven hook (see shell.js) — a one-time auto-peek plays this same open
   state shortly after the slide reveals, then removes it, so a first-time viewer sees the
   card fully expand and collapse once on its own and understands it's hoverable before ever
   touching it. Same visual state as :hover/:focus-within, just triggered by a class instead. */
.proj-tag:hover .pt-more,.proj-tag:focus-within .pt-more,.proj-tag.is-open .pt-more{grid-template-rows:1fr}
.proj-tag .pt-more-inner{overflow:hidden}
.proj-tag .pt-desc{margin-top:1.6cqh;padding-bottom:2.4cqh;font-size:.84cqw;line-height:1.55;
  color:var(--cream-soft);text-wrap:pretty}
/* three of the Identities cases (Chiltepino's, Colegio de Notarios, Rebel Wings) also exist as
   full strategy presentations in In-Depth Work — this jumps straight there. Real <a href> (works
   even if JS fails) whose click is intercepted for the same navigateTo() every other in-deck
   link uses, so the cross-document transition direction reads correctly instead of defaulting
   to "forward" regardless of which way the target actually sits in the manifest. */
.proj-tag .pt-desc:has(+ .pt-deck-link){padding-bottom:.6cqh}
.proj-tag .pt-deck-link{display:inline-block;position:relative;padding-bottom:2.2cqh;
  font-size:.78cqw;font-weight:700;letter-spacing:.02em;color:var(--cream);
  text-decoration:none;opacity:.8;transition:opacity .2s}
.proj-tag .pt-deck-link::after{content:'';position:absolute;left:0;top:.58em;width:100%;height:1px;
  background:currentColor;transform:scaleX(0);transform-origin:left center;
  transition:transform .4s cubic-bezier(.65,0,.35,1)}
.proj-tag .pt-deck-link:hover{opacity:1}
.proj-tag .pt-deck-link:hover::after{transform:scaleX(1)}

/* =========================================================
   WEBSITE SLIDE — a live, actually-navigable Sculpt build embedded in the deck (the
   "Websites" section), rather than a static case image. Unlike .proj-imgwrap, this floats as
   a bounded card INSET from every edge — visible cream margin on all sides is the point (the
   ask was "floating on top of the cream background", not full-bleed) — with a slim browser-
   chrome strip on top so it reads unambiguously as a real, live site rather than a screenshot.
   The iframe is unsandboxed on purpose: these are the user's own deployed builds, and the
   whole point is that a viewer can click/scroll/book inside it during the presentation. z-index
   2 (same tier as .proj-imgwrap) keeps it under .proj-tag (z-index 5) if the tag's hover-
   expand ever overlaps it. */
.web-frame{position:absolute;top:11cqh;left:8cqw;right:8cqw;bottom:12cqh;z-index:2;
  display:flex;flex-direction:column;border-radius:16px;overflow:hidden;background:#fff;
  box-shadow:0 30px 80px rgba(21,19,14,.35)}
.web-chrome{flex:0 0 auto;display:flex;align-items:center;gap:.55cqw;height:3.6cqh;
  padding:0 1.4cqw;background:#e7e2d8;border-bottom:1px solid rgba(21,19,14,.08)}
.web-dot{width:.6cqw;height:.6cqw;min-width:6px;min-height:6px;border-radius:50%;
  background:rgba(21,19,14,.18)}
.web-url{margin-left:.8cqw;font-size:.8cqw;color:rgba(21,19,14,.55);letter-spacing:.02em;
  font-variant-numeric:tabular-nums}
.web-iframe{flex:1 1 auto;width:100%;border:0;display:block;background:#fff}

/* =========================================================
   IN-DEPTH WORK SLIDE — a full pitch deck (every page, not a curated few), browsable end to
   end inside a framed box — same inset/radius/shadow language as .web-frame (top/left/right/
   bottom insets, 16px radius, identical shadow) so the two "boxed content" slide families read
   as one system. The deck itself is an internal filmstrip carousel with its own prev/next +
   counter (driven by shell.js), NOT one main-deck manifest entry per page — Chiltepino's alone
   is 76 pages; giving each its own top-level slide would balloon the pillnav/menu from 36
   entries toward 200+. The outer deck's own pillnav/menu/swipe/arrow-keys still move between
   PROJECTS here, untouched; only clicking the arrows INSIDE the box turns its pages.
   The 5 source decks are NOT all 16:9 — Rebel Wings' pages are a perfect square (1600x1600),
   Chiltepino's/Colegio de Notarios are near-square (1600x1667), only Prism/3bird are the tall
   portrait shape (1600x2250) the box was originally sized for. A single fixed landscape box
   made object-fit:contain correctly (never distorted) but tiny and pillarboxed for the square
   ones — reading as "squished" even though nothing was actually stretched. Fix: size the frame
   itself to each deck's own --deck-ratio (set inline per page, width/height = source px ratio),
   fit to the available inset area with the same min()-clamp technique .stage uses for its
   mobile letterbox fit, then center what's left with margin:auto — so the frame becomes a
   square/portrait/landscape box matching its OWN content, filling it edge to edge instead of
   floating small in the middle of a shape it was never going to fill. */
.deck-frame{position:absolute;top:11cqh;left:8cqw;right:8cqw;bottom:12cqh;margin:auto;z-index:2;
  aspect-ratio:var(--deck-ratio,16/9);
  width:min(84cqw,calc(77cqh * var(--deck-ratio,1.778)));
  height:min(77cqh,calc(84cqw / var(--deck-ratio,1.778)));
  border-radius:16px;overflow:hidden;background:#fff;
  box-shadow:0 30px 80px rgba(21,19,14,.35)}
.deck-track{display:flex;width:100%;height:100%;transition:transform .5s var(--ease)}
.deck-page{flex:0 0 100%;width:100%;height:100%;object-fit:contain;background:#fff}
/* deck-arrows sit BEHIND the frame (z-index:1 vs the frame's 2) and straddle its real left/right
   edge — most of the circle hanging out past the edge in open space, a smaller slice geometrically
   under the frame's own opaque rectangle (so the frame paints over it and hides it). That's the
   resting "underlapped" look — see shell.js's positionArrows() ARROW_VISIBLE constant for the
   exact visible/hidden split. shell.js sets the actual left/right offset from a live
   getBoundingClientRect() measurement of the frame, because the frame's own edge is NOT a fixed
   cqw value — width:min(84cqw,77cqh*ratio) means its rendered edge moves depending on both the
   deck's own aspect ratio and the current viewport shape (see .deck-frame's own comment above).
   The 1cqw fallback below only ever shows for an instant before that JS measurement lands.
   Hovering slides the whole circle the rest of the way out (translateX, on top of the existing
   translateY centering) clear of the frame, the "comes out from behind" reveal. */
.deck-arrow{position:absolute;top:50%;transform:translateY(-50%);z-index:1;
  width:5.4cqw;height:5.4cqw;min-width:82px;min-height:82px;border-radius:50%;
  display:grid;place-items:center;background:rgba(21,19,14,.85);backdrop-filter:blur(6px);
  border:1px solid rgba(243,230,199,.3);color:#fff;cursor:pointer;padding:0;
  transition:transform .35s var(--ease),background .25s,opacity .25s}
.deck-arrow:hover{background:rgba(21,19,14,1)}
.deck-arrow:disabled{opacity:.2;pointer-events:none}
.deck-arrow--prev{left:1cqw}
.deck-arrow--next{right:1cqw}
.deck-arrow--prev:hover{transform:translateY(-50%) translateX(-40%)}
.deck-arrow--next:hover{transform:translateY(-50%) translateX(40%)}
.deck-arrow svg{width:34%;height:34%;stroke:currentColor;stroke-width:2.4;fill:none}
.deck-counter{position:absolute;left:50%;bottom:1cqw;transform:translateX(-50%);z-index:3;
  padding:.7cqh 1.1cqw;border-radius:100px;background:rgba(21,19,14,.65);
  backdrop-filter:blur(6px);color:#fff;font-size:.78cqw;font-weight:600;
  letter-spacing:.04em;font-variant-numeric:tabular-nums}
.deck-counter b{color:#fff}
/* .deck-subtitle only exists on the two Spanish-language decks (Chiltepino's, Colegio de
   Notarios) — an English gloss of whatever that page actually says, swapped by shell.js on
   every page turn (see setupDeck's optional caption-script lookup) so a non-Spanish-speaking
   viewer can still follow the deck live instead of just looking at pictures. Pinned to the top
   of the frame (not stacked with the bottom counter/arrows) so both stay legible at once. */
.deck-subtitle{position:absolute;top:0;left:0;right:0;z-index:3;padding:1.1cqh 1.6cqw;
  background:rgba(21,19,14,.72);backdrop-filter:blur(8px);color:#fff;
  font-size:.85cqw;line-height:1.4;text-align:center}

/* =========================================================
   RÉSUMÉ CONTENT — additional copyslide variants for the info/experience/
   capabilities/contact slides. All of them reuse .cs-content/.cs-inner/
   .cs-eyebrow/.cs-head/.cs-rule-top from the COPY SLIDES block above —
   only .cs-body's own children (subhead/copy/rule-bottom) get swapped out
   for a different shape per slide. Same reveal system, same two-tone
   g-ink/g-cream/g-green palette, same Rama Gothic + house sans pairing —
   nothing here introduces a new type family or a new accent color. */

/* a slide that needs more room for a dense body (a 9-row sequence, an
   8-row timeline, a headline running 3-4 lines) pulls the fixed 12.2cqh
   gap under the top rule down to something the content can actually fit
   under, instead of every copyslide being forced to the same gap tuned
   for a one-line headline + a short paragraph. */
.copyslide .cs-rule-top.cs-tight{margin-bottom:5cqh}
/* even snugger — the timeline (career) and capability grid (capabilities) sit right under
   the rule with almost no gap, since their own first row already reads as the block's edge. */
.copyslide .cs-rule-top.cs-snug{margin-bottom:1.6cqh}

/* a bold accent line AFTER the body — visually identical to .cs-subhead
   (same bold uppercase accent treatment) so it reads as the same family
   of statement, just placed as a closing line instead of an opener. */
.copyslide .cs-highlight{margin-top:2.6cqh;width:71cqw;font-size:1.82cqw;white-space:nowrap}

/* small kicker above the headline — "MOBII SYSTEMS / 2021–PRESENT" style
   role+dates line for the Experience slides that need one. */
.copyslide .cs-kicker{display:block;margin-bottom:1.8cqh;font-family:var(--font);
  font-weight:700;font-size:1.05cqw;letter-spacing:.08em;text-transform:uppercase;opacity:.72}

/* process sequence (How I Work) — a numbered, ruled list of bold statements,
   not cards: every row is a hairline rule + a tabular index + one line of
   Rama Gothic, the same "index + statement" language the deck's own divider
   slides already use for their 01— / 02— numerals. */
.copyslide .cs-seq{width:71cqw}
.copyslide .cs-seq-row{display:flex;align-items:baseline;gap:1.4cqw;
  padding:.95cqh 0;border-top:1px solid currentColor;opacity:.88}
.copyslide .cs-seq-row:first-child{border-top:none}
.copyslide .cs-seq-row:last-child{border-bottom:1px solid currentColor}
.copyslide .cs-seq-n{flex:0 0 auto;width:2.4cqw;font-variant-numeric:tabular-nums;
  font-family:var(--font);font-size:.82cqw;font-weight:600;opacity:.5}
.copyslide .cs-seq-t{flex:1 1 auto;font-family:var(--font);font-weight:400;
  font-size:1.35cqw;letter-spacing:0;line-height:1.35}
.copyslide .cs-seq-foot{width:71cqw;margin-top:2.6cqh;font-size:1.05cqw;line-height:1.45;opacity:.75}

/* editorial career timeline — a plain ruled list (year column + company/role
   column), deliberately NOT résumé cards or icons. */
.copyslide .cs-tl{width:71cqw}
.copyslide .cs-tl-row{display:grid;grid-template-columns:13.5cqw 1fr;gap:1.6cqw;
  align-items:baseline;padding:.78cqh 0;border-top:1px solid currentColor;opacity:.88}
.copyslide .cs-tl-row:first-child{border-top:none}
.copyslide .cs-tl-row:last-child{border-bottom:1px solid currentColor}
.copyslide .cs-tl-yr{font-variant-numeric:tabular-nums;font-family:var(--font);
  font-size:.86cqw;font-weight:600;letter-spacing:.03em;text-transform:uppercase;opacity:.6}
.copyslide .cs-tl-co{font-family:var(--font);font-weight:700;font-size:1.32cqw;
  letter-spacing:0}
.copyslide .cs-tl-role{display:block;margin-top:.3cqh;font-family:var(--font);
  font-size:.92cqw;opacity:.7}

/* name-wall (Selected Brand Experience) + tools list — a wrapped set of bold
   Rama Gothic wordmarks, set in the deck's own type rather than fetched or
   approximated logos. */
.copyslide .cs-namegrid{width:71cqw;display:flex;flex-wrap:wrap;gap:2cqh 2.8cqw}
.copyslide .cs-namegrid span{font-family:var(--font);font-weight:700;
  font-size:1.55cqw;text-transform:uppercase;letter-spacing:.01em;opacity:.92}
.copyslide .cs-namegrid-note{width:71cqw;margin-top:2.8cqh;font-family:var(--font);
  font-size:.95cqw;line-height:1.5;opacity:.62}

/* capabilities — a dense 3-column editorial grid, hairline rule above each
   term (same "ruled statement" language as the sequence list above), no
   category cards or skill bars. */
.copyslide .cs-capgrid{width:71cqw;display:grid;grid-template-columns:repeat(3,1fr);
  gap:.85cqh 2cqw}
.copyslide .cs-capgrid span{display:block;padding-top:.65cqh;border-top:1px solid currentColor;
  font-family:var(--font);font-weight:700;font-size:.94cqw;text-transform:uppercase;
  letter-spacing:.02em;line-height:1.3;opacity:.85}
.copyslide .cs-capgrid span:nth-child(-n+3){border-top:none;padding-top:0}
.copyslide .cs-capgrid-note{width:71cqw;margin-top:2.4cqh;font-family:var(--font);
  font-size:.88cqw;line-height:1.5;opacity:.6}

/* contact — info line (name/title/location/email/linkedin, wrapped) + pill
   actions. EMAIL/VIEW LINKEDIN/DOWNLOAD are real anchors (mailto:/https:/a
   file link), not buttons faking navigation. */
.copyslide .cs-contact-info{width:71cqw;display:flex;flex-wrap:wrap;gap:.5cqh 2.2cqw;
  margin-bottom:2.8cqh;font-family:var(--font);font-weight:700;font-size:1.05cqw;
  text-transform:uppercase;letter-spacing:.03em;opacity:.85}
.copyslide .cs-contact-info--spaced{margin-top:3.6cqh}
.copyslide .cs-contact-actions{width:71cqw;display:flex;flex-wrap:wrap;gap:1.1cqh 1.1cqw}
.copyslide .cs-contact-actions a{display:inline-flex;align-items:center;
  padding:1.3cqh 1.8cqw;border-radius:100px;border:1px solid currentColor;
  font-family:var(--font);font-weight:600;font-size:.95cqw;text-transform:uppercase;
  letter-spacing:.04em;text-decoration:none;transition:opacity .25s}
.copyslide .cs-contact-actions a:hover{opacity:.65}
.copyslide.g-ink .cs-contact-actions a{color:var(--cream)}
.copyslide.g-ink .cs-contact-actions a.is-primary{background:var(--cream);color:var(--ink)}
.copyslide.g-cream .cs-contact-actions a{color:var(--ink)}
.copyslide.g-cream .cs-contact-actions a.is-primary{background:var(--ink);color:var(--cream)}

/* ---------- mobile reflow ---------- */
/* Desktop .stage deliberately has NO fixed aspect-ratio (see the .stage rule above) — that's
   what keeps it edge-to-edge with zero margin on any ordinary browser window shape. But that
   same "just fill whatever box you're given" behavior is what was making mobile "all over the
   place": every cqw/cqh position on every slide was tuned against a 16:9 canvas, and a phone's
   actual viewport (portrait ~9:19.5, or a landscape phone still far off 16:9) is nothing like
   that ratio, so the whole composition was recomputing against the wrong shape instead of just
   shrinking. Malacara's fix (same deck engine, proven pattern) is this exact formula: lock
   .stage to 16:9 and size it as large as the viewport allows while staying that ratio — since
   every child is dimensioned in cqw/cqh (container-query units of .stage), the entire 1920x1080
   composition scales down as one proportional unit, with .slide's existing place-items:center
   (see above) automatically letterboxing/pillarboxing the leftover space in the slide's own
   ground color. Scoped to this media query only, not applied universally like Malacara does, so
   ordinary desktop windows keep the zero-margin behavior; `(max-height:820px)` is included
   alongside max-width so a landscape phone (e.g. 844x390 — wider than the 820px width check
   alone would catch) still gets the fit math, not just portrait ones. */
@media (max-width:820px),(max-height:820px){
  .stage{aspect-ratio:16/9;width:min(100vw,calc(100svh*16/9));height:min(100svh,calc(100vw*9/16))}
  .slide{box-shadow:none}
  .pillnav{bottom:max(2.4vh,env(safe-area-inset-bottom,0px))}
  /* letterbox/pillarbox margin is now visible real estate (it wasn't on desktop, where .stage
     always filled .slide exactly), and painting it in whichever accent the CURRENT slide
     happens to carry (g-green/g-cream/g-ink, normally on .slide itself — see the comment
     above) makes that margin blend into some slides and not others, so slide boundaries read
     inconsistently from one to the next. Fixed instead: .slide always frames in --ink, and the
     accent moves one level in, onto .stage, which is now the exact visible-content box on
     mobile — every slide gets the same dark frame, and the accent only ever paints the part
     that's actually the slide. */
  .slide{background:var(--ink)}
  .g-green .stage{background:var(--green)}
  .g-cream .stage{background:var(--cream)}
  .g-ink .stage{background:var(--ink)}
  /* .proj.g-cream (above, desktop) repaints the FRAME to the case images' own baked-in
     #FDF9F1 so a desktop .stage-filling gap around the image doesn't seam against the deck's
     --cream token. On mobile .stage IS the tight image-bounding box, not .slide, so that same
     seam-matching needs to move to .stage too — and .proj.g-cream's frame override has to be
     cancelled back to the universal --ink frame, at matching specificity + later in the
     cascade so it actually wins over the desktop rule at this viewport. */
  .proj.g-cream{background:var(--ink)}
  .proj.g-cream .stage{background:#FDF9F1}
  .menu{padding:6vh 4vw 6vh}
  .menu-grid{grid-template-columns:repeat(auto-fill,minmax(42vw,1fr));gap:2.6vw}
}
.rotate-hint{display:none}
@media (max-width:820px) and (orientation:portrait){
  .rotate-hint{display:flex;position:fixed;left:0;right:0;bottom:calc(12vh + env(safe-area-inset-bottom,0px));
    z-index:40;justify-content:center;pointer-events:none}
  .rotate-hint span{background:var(--ink);color:var(--cream);font-size:12px;font-weight:600;
    letter-spacing:.03em;padding:7px 14px;border-radius:100px}
}
