/* ============================================================
   crispy — portfolio
   ============================================================ */

@property --cut {
  syntax: "<length>";
  inherits: true;
  initial-value: 0px;
}

/* light-sweep tracking (js/glare.js writes these on :root, at most once per
   frame). Registered so they're typed lengths — cheaper for the engine to
   parse than re-tokenising a string every frame, and they interpolate
   properly if ever transitioned. */
@property --scroll-y {
  syntax: "<length>";
  inherits: true;
  initial-value: 0px;
}
@property --glare-x {
  syntax: "<length>";
  inherits: true;
  initial-value: 0px;
}
@property --glare-y {
  syntax: "<length>";
  inherits: true;
  initial-value: 0px;
}

/* the opened folder's own top edge, in viewport px. Registered as a real
   length so it can TRANSITION — the project-open clip and the content/bar
   rise are all derived from it, so they move at exactly the folder's speed
   (js/open.js sets its start/end to the card top and the docked top). */
@property --po-folder-top {
  syntax: "<length>";
  inherits: true;
  initial-value: 0px;
}

@property --po-shape-progress {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}

@property --po-paper-angle {
  syntax: "<angle>";
  inherits: true;
  initial-value: 0deg;
}

@property --po-collage-angle {
  syntax: "<angle>";
  inherits: true;
  initial-value: 0deg;
}

@property --po-current-height {
  syntax: "<length>";
  inherits: true;
  initial-value: 0px;
}

/* The dock's own top edge, animated in the SAME transition declaration as
   its `top` (js/open.js writes both together in positionDock). That makes it
   lockstep by construction — unlike --po-folder-top, which lives on
   .project-open and flips on a different beat, so it lags mid-flight. The
   light sweep rides this so it stays screen-anchored through the flight. */
@property --po-dock-top {
  syntax: "<length>";
  inherits: true;
  initial-value: 0px;
}

@property --po-slot-y {
  syntax: "<length>";
  inherits: true;
  initial-value: 0px;
}

@property --po-slot-h {
  syntax: "<length>";
  inherits: true;
  initial-value: 0px;
}

@property --metal-stop-start {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 0%;
}

@property --metal-stop-end {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 100%;
}

@property --metal-angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: -4deg;
}

@property --metal-light {
  syntax: "<color>";
  inherits: false;
  initial-value: #e1e3e5;
}

@property --metal-mid {
  syntax: "<color>";
  inherits: false;
  initial-value: #819195;
}

@property --metal-dark {
  syntax: "<color>";
  inherits: false;
  initial-value: #202429;
}

:root {
  --page-bg: #e5e3e6;
  --ink: #2b2730;
  --ink-soft: #9d97a1;

  /* GLOBAL WHITE CARD COLOURS
     Shared by every 2D and 3D project. The first pair is the face fill,
     the second is the fine paper-like rim, and the third is the broad
     inset plastic rim used by 3D cards. Project JSON does not override
     these values. */
  --card-paper-a: #e7e3e7;
  --card-paper-b: #e6e5e6;

    /* 3D FACE FILL
     Independent from the 2D paper gradient. These two stops affect only
     rigid 3D cards; both rim gradients remain shared below. */
  --card-3d-paper-a: #e5e1e5;
  --card-3d-paper-b: #e9e8ea;

  /* 3D GRADIENT ANGLES
     0 = left→right, 90 = top→bottom, -90 = bottom→top. folder.js reads
     these when it builds the SVG, so reload after editing the file. */
  --card-3d-paper-angle: 53.13deg;
  --card-3d-inner-edge-angle: 30deg;
  --card-3d-fine-edge-angle: 45deg;
  --card-3d-back-fill-angle: -90deg;
  --card-3d-back-inner-edge-angle: 90deg;
  --card-3d-back-outer-edge-angle: -90deg;

  --card-edge-a: #f3f2f3;
  --card-edge-b: #d7d3d8;
  --card-plastic-edge-a: #e9e7ea;
  --card-plastic-edge-b: #dfdde0;

  /* Paper score and emboss belong to the same shared material. These are
     referenced directly from the generated SVG, so DevTools edits update
     the already-rendered cards without rebuilding them. */
  --card-crease-a: #e7e3e700;
  --card-crease-b: #d8d3d9;
  --card-crease-c: #efedf0;
  --card-crease-d: #e8e5e800;
  --card-emboss-fill: #eceaec;
  --card-3d-emboss-fill: #e5e3e5;
  --card-year-badge-stroke: 3px;

  --serif: "Brawler", Georgia, serif;
  --script: "Brawler", Georgia, serif;
  --sans: "Host Grotesk", -apple-system, "Segoe UI", sans-serif;
  --footer-serif: "PT Serif", Georgia, serif;

  /* superellipse corners everywhere a corner exists */
  --squircle: superellipse(2.4);

  --ease-out: cubic-bezier(0.26, -0.02, 0, 1.01);

  --card-radius: 22px;
  /* Decorative cardOverlay.<ext> can deliberately bleed beyond the paper.
     One value controls all four sides in both closed and project-open states. */
  --card-overlay-bleed: 16px;

  /* Light sweep strength. The paper reads BOTH layers (under + over), the
     photos only the "over" one, so the photos stay the weaker of the two by
     construction. Peak white works out to roughly 0.35 on paper and 0.18 on
     a photo — the photo figure has to stay this high or the sweep simply
     disappears into a busy image. Tune these two, not the gradient. */
  --glare-paper: 0.65;
  /* 2D only — the 3D case overrides both layers with its own pair below.
     Held at ~10%: a hint of movement across the paper rather than a light
     source. Because the band is anchored to the SCREEN, its strength is also
     what makes two identical cards look unequal depending on where each one
     sits in it, so a low value keeps that difference from reading as a
     colour difference between projects. */
  --glare-photo: 0.1;
  --glare-paper-2d: 0.1;
  --glare-photo-3d: 0.4;
  
   /* one duration + one curve for the whole project open/close
     choreography, so the folder, shredder, content and pushed-out
     interface all move together as a single motion (see js/open.js, which
     paces its cleanup to match). The curve eases IN gently (slow start) so
     the whole thing unfurls instead of snapping — the plain --ease-out
     starts too fast and read as abrupt. */
  --po-dur: 0.8s;
  --po-ease: cubic-bezier(0.45, 0, 0.15, 1.01);

  /* Secondary choreography. The page furniture should clear the stage more
     calmly than the physical folder; project media gets its own rise curve
     after it emerges from the moving folder around mid-flight. */
  --po-interface-dur: 0.8s;
  --po-interface-ease: cubic-bezier(0.68, 0, 0.3, 0.97);
  --po-interface-open-delay: 0ms;
  --po-interface-active-dur: var(--po-interface-dur);
  --po-interface-active-ease: var(--po-interface-ease);
  --po-interface-active-delay: 0ms;
  /* PROJECT OPEN TUNING
     Folder begins first; media + shredder begin `open-overlap` before the
     folder finishes. The shredder grows from its folder-fit width to 100%. */
  /* Mirror the smooth closing rhythm: the folder and emerging paper share
     the same one-second physical easing. Emergence starts only 80ms after
     the folder, so both motions read as one overlapping action. */
  --po-open-folder-dur: 1s;
  --po-open-folder-ease: cubic-bezier(0.37, 0, 0, 1.01);
  --po-emerge-dur: 1s;
  --po-emerge-ease: cubic-bezier(0.44, 0, 0, 1.01);
  --po-open-overlap: 920ms;
  /* Final expanded-folder placement. Categories now live inside its lower
     edge, so the old outside-tag clearance is no longer needed. */
  --po-dock-bottom: 16px;
  /* At maximum scroll, the lowest media block stops this far above the
     expanded folder instead of being pulled completely through the slot. */
  --po-last-media-gap: 18px;
  /* PROJECT CLOSE TUNING
     1) reverse-scroll returns the project to its first frame;
     2) stow pulls the first frame + shredder into the closing folder;
     3) overlap starts stow this many milliseconds before scroll is finished. */
  --po-return-scroll-dur: 520ms;
  --po-return-scroll-ease: cubic-bezier(0.33, 0.02, 0.35, 0.99);
  --po-stow-dur: 1s;
  --po-stow-ease: cubic-bezier(0.76, 0, 0, 1.01);
  --po-close-overlap: 320ms;
  --po-shred-final-inset: 4px;
  --po-suck-dur: var(--po-stow-dur);
  --po-suck-ease: var(--po-stow-ease);
  /* END-OF-PROJECT PULL TO CLOSE
     distance: extra wheel delta needed to reach the hover pose and close;
     reset delay/duration: pause and spring-back after the gesture stops. */
  --po-end-pull-distance: 850;
  --po-end-pull-reset-delay: 180ms;
  --po-end-pull-reset-dur: 420ms;
  /* 3D end-of-project pull: rigid layers close toward one another. */
  --po-3d-hover-x: 4px;
  --po-3d-end-pull-x: 10px;
  /* Keep the close gesture equally deliberate on both card types. */
  --po-3d-end-pull-distance: 850;
}

* { margin: 0; padding: 0; box-sizing: border-box; }

html {
  scroll-behavior: smooth;
  /* Scroll remains functional, but its native chrome never participates in
     layout and cannot jump during project open/close. */
  scrollbar-width: none;
  -ms-overflow-style: none;
}
html::-webkit-scrollbar,
body::-webkit-scrollbar,
.po-scroll::-webkit-scrollbar {
  display: none;
  width: 0;
  height: 0;
}

body {
  background: var(--page-bg);
  color: var(--ink);
  font-family: var(--serif);
  min-height: 100vh;
  min-height: 100dvh;
  overflow-x: hidden;
  /* sticky footer: pushed to the bottom of the viewport by margin-top:
     auto on .site-footer, not a guessed padding number */
  display: flex;
  flex-direction: column;
}

.svg-defs { position: absolute; width: 0; height: 0; }

/* ------------------------------------------------------------
   Header
   ------------------------------------------------------------ */

.site-header {
  width: 100%;
  max-width: 1144px;
  margin: 0 auto;
  padding: 128px 44px 40px;
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 32px;
}

/* page-load intro: movement only. The interface never dissolves in. */
@keyframes rise {
  from { transform: translateY(18px); }
  to   { transform: translateY(0); }
}

.logo    { animation: rise 0.8s var(--ease-out) backwards; }
.filters { animation: rise 0.8s var(--ease-out) 0.12s both; }
.site-footer p { animation: rise 1s var(--ease-out) 0.2s both; }

.logo { display: block; }
.logo svg {
  width: clamp(240px, 30vw, 420px);
  height: auto;
  display: block;
  /* Keep the resting logo as vector geometry; permanent layer promotion
     can make diagonal contours look stepped at fractional viewport sizes.
     transition — the browser re-rasterizes onto a stable layer once,
     rather than snapping resolution each frame */
  shape-rendering: geometricPrecision;
}

/* hover: morph the wordmark from Regular to Light + a slight skew.
   Both "d" values come from res/typoRegular.svg and res/typoLight.svg —
   same path structure (same command sequence), so the browser can
   interpolate between them natively via the animatable `d` property,
   no JS/manual point-matching needed. */
/* The skew lives HERE, on the same element as the morph, not on the <svg>
   box above. `transform` is a property the compositor can own; `d` is not —
   it must be re-interpolated and re-rasterised on the main thread every
   frame. Split across the two elements they advanced on different clocks, so
   any main-thread hitch let the letterforms lag their own skew for a frame,
   which read as the logo shivering. On an inner SVG element the transform
   goes through the same rendering path as the morph, so the two can no
   longer disagree.

   Units follow the element: with transform-box: view-box the translate is in
   viewBox units, so 6.45 here matches the old 6px at the logo's full size —
   and now scales with the logo instead of staying a fixed pixel amount. */
.logo-path {
  shape-rendering: geometricPrecision;
  transform-box: view-box;
  transform-origin: left bottom;
  transform: skewX(0deg) translate(0px, 0px);
  d: path("m104.454,82.756c-3.527,29.47-27.145,43.313-50.905,43.313C23.978,126.069,0,97.909,0,63.179S23.978.297,53.55.297c29.907,0,47.248,25.957,50.507,41.924l-24.329,14.045-3.121-5.415,6.133-6.133c-3.589-16.788-13.108-28.129-29.19-28.129-18.266,0-31.772,21.387-31.772,46.59s13.389,46.59,34.183,46.59c14.942,0,29.114-9.708,33.676-27.013h14.817ZM451.543,3.536v5.516c-32.186,23.868,16.091,117.721-63.733,117.721-17.181,0-36.763-9.428-39.934-23.626l-6.773-2.89v-5.516h31.445v5.516l-6.016,2.445c1.826,6.203,7.639,16.252,22.003,16.252,27.526,0,28.152-37.024,28.152-65.404-14.412,27.97-34.019,30.049-59.01,30.049-4.364,0-8.892-.063-13.585-.063v-7.159c.302,0,26.794-.486,26.794-30.699,0-11.977-6.18-26.053-17.65-26.053-15.761,0-25.125,23.104-25.125,66.393v26.474l8.591,4.963v5.516h-39.349v-5.516l8.591-4.963V14.007l-8.591-4.955V3.536h39.349v5.516l-8.591,4.955v20.604h.587c3.034-20.945,17.527-32.176,31.57-32.176,14.701,0,29.475,12.299,29.475,38.616,0,15.184-10.734,30.716-21.699,35.002,51.4-4.48,43.414-53.711,56.46-59.768-3.139-1.794-9.393-5.425-12.523-7.233V3.536h39.561ZM134.299,86.017v26.474l8.591,4.963v5.516h-39.341v-5.516l8.591-4.963V14.007l-8.591-4.955V3.536h39.341v5.516l-8.591,4.955v27.612h.898c3.393-14.187,16.791-39.176,45.949-39.176h14.019l-7.723,29.884-5.598,1.541-2.813-9.437c-29.954.585-44.733,25.92-44.733,61.586Zm67.001-57.053h-6.297l-2.754,10.276-21.699,5.821v5.516l8.591,4.955v56.959l-8.591,4.963v5.516h39.341v-5.516l-8.591-4.963V28.964Zm73.253,17.033l22.378-12.921c-3.145-17.681-18.891-33.076-41.933-33.076-26.802,0-42.76,16.354-42.76,33.996,0,41.898,62.276,29.187,62.276,60.004,0,10.224-6.258,20.196-18.659,20.196-13.346,0-25.82-9.987-25.965-31.44h-23.525v5.516l5.855,4.767c3.124,19.417,20.807,34.526,41.155,34.526,25.015,0,43.051-14.877,43.051-33.838,0-39.801-62.092-29.945-62.092-59.545,0-12.398,7.606-21.52,20.579-21.52,14.439,0,21.403,11.947,21.528,23.199l-4.908,4.908,3.02,5.228Z");
  /* One declaration, so both halves share a single timing origin. */
  transition:
    d 0.5s var(--ease-out),
    transform 0.5s var(--ease-out);
}

/* Hover stops at 70% of the way to Light, not the full weight — and
   half the skew. The 70% path isn't hand-tuned: it's the literal output
   of the browser's own path interpolation (Web Animations API, sampled
   at t=0.7 between the Regular and Light "d" values below), so it's as
   correct as the live morph itself would be at that point. */
.logo:hover .logo-path {
  transform: skewX(-2deg) translate(6.45px, 0px);
  d: path("M100.771,82.756C96.3224,109.45,77.9859,126.069,51.5288,126.069C21.9571,126.069,0,97.909,0,63.179S21.9571,0.297,51.5291,0.297C81.4361,0.297,96.7814,27.4069,100.04,43.3732L79.7504,52.339L77.1061,48.3303L83.0109,42.4255C79.4219,25.6375,70.1332,11.4433,51.5284,11.4433C30.5443,11.4433,17.1384,37.976,17.1384,63.179S31.2799,114.92,53.9394,114.92C71.7472,114.92,81.9761,100.061,86.5381,82.756H100.771ZM436.469,3.536V7.652C404.283,31.52,452.162,126.773,377.112,126.773C359.931,126.773,340.603,116.322,338.408,102.447L332.086,100.257V96.141H359.468V100.257L353.853,102.002C355.269,108.241,359.368,118.954,377.837,118.954C410.334,118.954,405.626,81.1054,406.784,52.1969C393.468,81.0097,366.864,81.0419,346.073,81.0419C341.709,81.0419,337.387,80.9789,332.694,80.9789V76.3763C333.008,76.3763,362.681,75.3569,362.681,43.9399C362.681,30.8842,355.987,16.1978,343.395,16.1978C327.634,16.1978,316.714,42.7276,316.714,86.0166V114.591L325.305,118.854V122.97H291.561V118.854L300.152,114.591V11.907L291.561,7.652V3.536H325.305V7.652L316.714,11.907V34.611H317.301C321.219,13.6646,335.042,2.5309,349.085,2.5309C361.917,2.5309,378.362,14.734,378.362,41.051C378.362,56.235,365.727,72.4978,354.712,75.71C412.229,74.366,402.364,20.4863,415.14,13.485C412.001,11.691,404.414,9.46,401.284,7.652V3.536H436.467ZM127.037,86.017V114.591L135.628,118.854V122.97H101.887V118.854L110.478,114.591V11.907L101.887,7.652V3.536H135.628V7.652L127.037,11.907V41.619H127.935C131.328,27.432,149.643,2.443,175.822,2.443H187.904L180.881,25.327L176.44,26.868L174.64,19.1831C144.686,19.7681,127.037,50.3517,127.037,86.0177ZM194.371,24.7465H189.474L185.32,35.0225L167.693,40.8435V44.9595L177.684,49.2145V114.591L169.093,118.854V122.97H202.962V118.854L194.371,114.591V24.7465ZM269.125,42.1449L287.976,33.6983C284.831,16.0173,270.907,-9.05991e-7,246.888,-9.05991e-7C221.275,-9.05991e-7,206.46,16.725,206.46,33.996C206.46,75.5083,270.836,60.1352,270.836,94C270.836,105.74,262.74,117.696,248.003,117.696C232.59,117.696,221.223,104.209,221.078,82.756H201.288V86.872L207.143,90.939C209.028,110.289,224.39,127.565,247.257,127.565C271.099,127.565,287.149,113.133,287.149,93.727C287.149,51.0385,222.957,66.7878,222.957,33.7459C222.957,21.8477,231.488,9.162,247.036,9.162C262.677,9.162,270.619,22.4285,270.744,33.6805L266.112,38.3127L269.126,42.1442Z");
}

.filters {
  display: flex;
  align-items: center;
  gap: 12px;
  padding-bottom: 0px;
}

/* Contacts + filters as one right-hand cluster. The wrapper carries the
   spacing between the two groups; the header's own 32px gap would push the
   icons too far from the Tools button. */
.header-tools {
  display: flex;
  align-items: center;
  gap: 18px;
}

.social {
  display: flex;
  align-items: center;
  gap: 12px;
  /* same entrance as .filters, a beat later so the row reads left to right */
  animation: rise 0.8s var(--ease-out) 0.18s both;
}

/* Quiet by default: these are a footnote next to the controls, not a call to
   action. Stroked marks (not the official brand badges) so the pair reads as
   one drawn set in the same weight as the rest of the interface. */
.social-link {
  display: grid;
  place-items: center;
  width: 26px;
  height: 26px;
  color: #aca8b0;
  transition:
    color 0.28s var(--ease-out),
    transform 0.28s var(--ease-out);
}

.social-link svg {
  width: 20px;
  height: 20px;
  display: block;
  shape-rendering: geometricPrecision;
}

@media (hover: hover) {
  .social-link:hover {
    color: #605761;
    transform: translateY(-1px);
  }
}

.social-link:focus-visible {
  outline: 2px solid #9d97a1;
  outline-offset: 3px;
  border-radius: 6px;
}

/* Gradient-ring buttons. A CSS gradient can't be a real border, so each
   ring is a nested box: the box's padding is the ring thickness, and its
   own border-box background shows only in that padding strip. This renders
   a clean, uniform, correctly-rounded ring in every engine — the
   mask-composite version produced an uneven band and a bad corner. Radii
   step down 8 → 7 → 6 so each layer's corner stays concentric.
     Tools      = one ring around the metal fill.
     Everything = two rings (two tones, opposite diagonals) around a light
                  fill — the two-tone bevel. */
.filter-btn {
  font-family: var(--sans);
  font-size: 18px;
  font-weight: 500;
  border: none;
  cursor: pointer;
  display: inline-flex;
  border-radius: 8px;
  padding: 1px; /* ring thickness (Everything: its outer ring) */
  touch-action: manipulation;
}

.btn-fill {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 2px 14px;
  line-height: 26px; /* fixed so the 1px font gap below doesn't shift height */
}

/* "Tools" — metallic: one grey-blue ring, metal-sheen fill (light top,
   dark bottom, from the Photoshop export). */
.btn-metallic {
  background: linear-gradient(135deg, #c7d0d3, #5f727b);
  box-shadow: 1px 7px 5px -4px rgb(73 125 131 / 27%), -8px -7px 7px -4px rgb(242 253 253);
  transition: box-shadow 420ms cubic-bezier(0.22, 1, 0.36, 1);
}
.btn-metallic .btn-fill {
  --metal-stop-start: 0%;
  --metal-stop-end: 100%;
  --metal-angle: -4deg;
  --metal-light: #e1e3e5;
  --metal-mid: #819195;
  --metal-dark: #202429;
  border-radius: 7px;
  background: linear-gradient(
    var(--metal-angle),
    var(--metal-light) var(--metal-stop-start)
      calc(var(--metal-stop-start) + 3%),
    var(--metal-mid) 58%,
    var(--metal-dark) var(--metal-stop-end)
  );
  color: #364449;
  font-size: 19px;
  text-shadow: 0 1px 1px rgb(255 255 255 / 80%);
  box-shadow: inset -1px -2px 1px rgb(214 218 219 / 24%), inset 1px 1px 5px rgb(91 105 115 / 35%);
  transition:
    --metal-stop-start 420ms cubic-bezier(0.22, 1, 0.36, 1),
    --metal-stop-end 420ms cubic-bezier(0.22, 1, 0.36, 1),
    --metal-angle 420ms cubic-bezier(0.22, 1, 0.36, 1),
    --metal-light 420ms cubic-bezier(0.22, 1, 0.36, 1),
    --metal-mid 420ms cubic-bezier(0.22, 1, 0.36, 1),
    --metal-dark 420ms cubic-bezier(0.22, 1, 0.36, 1);
}

/* "Everything" — light toggle: outer ring (button bg) + inner ring
   (ring-inner bg) in two tones on opposite diagonals, around a light fill.
   The inner ring stays in an always-darker-than-fill grey so it doesn't
   fade out against the pale fill on the side its gradient runs light. */
.btn-toggle {
  background: linear-gradient(168deg, #ffffff, #c0bcc2);
  box-shadow: 0 3px 4px -2px rgb(45 31 40 / 28%), 0 7px 12px -2px rgb(83 45 95 / 15%);
  transition: box-shadow 420ms cubic-bezier(0.22, 1, 0.36, 1);
}
.btn-toggle .btn-ring-inner {
  display: flex;
  border-radius: 7px;
  padding: 1px; /* inner ring thickness */
  background: linear-gradient(-12deg, #fbfbfb, #cec6d3);
}
.btn-toggle .btn-fill {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  border-radius: 6px;
  background: #edeced;
}

.btn-toggle .btn-fill::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  opacity: 0;
  background: linear-gradient(
    325deg,
    rgb(242 239 243 / 78%) 24%,
    rgb(220 217 221) 100%
  );
  transition: opacity 380ms cubic-bezier(0.22, 1, 0.36, 1);
}

.btn-toggle-label {
  position: relative;
  z-index: 1;
  background-image: linear-gradient(180deg, #492236, #65575d);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

/* ------------------------------------------------------------
   Everything / 3D / 2D animated mode switch

   One fixed footprint prevents the header from reflowing. The material
   button shrinks and slides inside it while the mode label is revealed in
   the space it leaves behind. Lottie owns only the changing icon geometry.
   ------------------------------------------------------------ */
.mode-switch {
  --mode-wide: 119px;
  --mode-icon: 62px;
  --mode-height: 34px;
  --mode-ease: cubic-bezier(0.22, 1, 0.36, 1);
  position: relative;
  flex: 0 0 var(--mode-wide);
  width: var(--mode-wide);
  height: var(--mode-height);
}

.mode-button {
  position: absolute;
  inset: 0 auto auto 0;
  z-index: 1;
  width: var(--mode-wide);
  height: var(--mode-height);
  box-sizing: border-box;
  transition:
    left 520ms var(--mode-ease),
    width 520ms var(--mode-ease),
    box-shadow 420ms var(--mode-ease);
}

.mode-button .btn-ring-inner,
.mode-button .btn-fill {
  width: 100%;
  height: 100%;
  box-sizing: border-box;
}

.mode-button .btn-fill {
  padding: 0;
}

.mode-side-label {
  position: absolute;
  z-index: 0;
  top: 50%;
  width: 48px;
  translate: 0 -50%;
  font: 400 18px/1 var(--sans);
  text-align: center;
  color: #aca8b0;
  opacity: 1;
  pointer-events: none;
}

.mode-side-label--3d {
  left: 6px;
}

.mode-side-label--2d {
  right: 6px;
}

.mode-label {
  display: flex;
  justify-content: center;
  white-space: nowrap;
  background: none;
  -webkit-background-clip: border-box;
  background-clip: border-box;
  transition: opacity 120ms linear;
}

.mode-char {
  display: inline-block;
  background-image: linear-gradient(180deg, #492236, #65575d);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  opacity: 1;
  transform: translateX(0) scaleX(1);
  transition:
    opacity 110ms linear,
    transform 150ms var(--mode-ease);
  transition-delay: calc(var(--char-index) * 26ms);
}

.mode-lottie {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  opacity: 0;
  transform: scale(0.82);
  transition:
    opacity 180ms linear 70ms,
    transform 420ms var(--mode-ease);
  overflow: hidden;
  pointer-events: none;
}

.mode-lottie svg {
  position: absolute;
  top: 50%;
  left: 50%;
  display: block;
  width: 52px !important;
  height: 52px !important;
  overflow: hidden;
  transform: translate(-50%, -50%) translateY(1px) !important;
}

/* LOTTIE ICON STROKE TUNING
   The JSON's authored fill and zero-width masking paths stay untouched.
   Only genuinely visible exported strokes get one clean outline. */
.mode-lottie .mode-icon-stroke {
  stroke: #5d4b59 !important;
  stroke-width: 2px !important;
  stroke-linecap: round !important;
  stroke-linejoin: round !important;
  vector-effect: non-scaling-stroke;
  shape-rendering: geometricPrecision;
}

/* Solid technical face that hides the rear cube edge. Lottie coordinates
   are 500x500, so 8 units are roughly one rendered pixel here. */
.mode-lottie .mode-icon-occlusion-fill {
  transform: translateY(-12px) translateX(12px) scale(1.1);
}

.mode-switch[data-mode="3d"] .mode-button,
.mode-switch[data-mode="2d"] .mode-button {
  width: var(--mode-icon);
}

.mode-switch[data-mode="3d"] .mode-button {
  left: calc(var(--mode-wide) - var(--mode-icon));
}

.mode-switch[data-mode="2d"] .mode-button {
  left: 0;
}

.mode-switch[data-mode="3d"] .mode-char,
.mode-switch[data-mode="2d"] .mode-char {
  opacity: 0;
  transform: translateX(3px) scaleX(0.35);
}

.mode-switch[data-mode="3d"] .mode-lottie,
.mode-switch[data-mode="2d"] .mode-lottie {
  opacity: 1;
  transform: scale(1);
}

.mode-switch.is-returning .mode-lottie {
  opacity: 0;
  transform: scale(0.82);
  transition:
    opacity 180ms linear,
    transform 300ms var(--mode-ease);
}

.mode-switch.is-returning .mode-char {
  transition-delay: calc((9 - var(--char-index)) * 24ms);
}

.mode-switch.is-busy .mode-button {
  cursor: progress;
}

@media (prefers-reduced-motion: reduce) {
  .mode-button,
  .mode-side-label,
  .mode-char,
  .mode-lottie {
    transition-duration: 1ms !important;
    transition-delay: 0ms !important;
  }
}

@media (hover: hover) {
  /* BUTTON HOVER TUNING
     Tools: angle + first/middle/last gradient colors + first/last stops.
     Everything: overlay gradient is configured above; only its opacity is
     animated here so the double border keeps its original geometry. */
  .btn-metallic:hover .btn-fill {
    --metal-stop-start: 9%;
    --metal-stop-end: 91%;
    --metal-angle: -4deg;
    --metal-light: #f1f5f5;
    --metal-mid: #6e8081;
    --metal-dark: #13181b;
  }

  .btn-metallic:hover {
    box-shadow:
      2px 9px 8px -4px rgb(73 125 131 / 31%),
      -10px -9px 10px -4px rgb(242 253 253 / 92%);
  }

  .btn-toggle:hover .btn-fill::before {
    opacity: 1;
  }

  .btn-toggle:hover {
    box-shadow:
      0 4px 6px -2px rgb(45 31 40 / 32%),
      0 9px 16px -2px rgb(83 45 95 / 19%);
  }
}

/* Tools acknowledges a press with a reflection travelling across the metal,
   not with a state change. An inverted "pressed" pose was tried first and
   read as dirty rather than active: darkening a brushed-metal fill just looks
   like tarnish. A moving highlight is what real metal does when it catches
   the light, so the button stays the same object and simply reacts.

   The sweep passes OVER the label on purpose — a reflection crossing an
   engraved surface crosses the engraving too. */
.btn-metallic .btn-fill {
  position: relative;
  overflow: hidden;
}

.btn-metallic .btn-fill::after {
  content: "";
  position: absolute;
  top: -25%;
  bottom: -25%;
  left: 0;
  width: 45%;
  pointer-events: none;
  opacity: 0;
  background: linear-gradient(
    90deg,
    rgb(255 255 255 / 0%) 0%,
    rgb(255 255 255 / 42%) 42%,
    rgb(255 255 255 / 66%) 55%,
    rgb(255 255 255 / 0%) 100%
  );
  transform: translateX(-170%) skewX(-14deg);
}

/* Transform-only, so the pass stays on the compositor. main.js adds the class
   on click and drops it at animationend, which also lets a rapid second click
   restart the sweep instead of ignoring it. */
/* Fast off the mark, then settling — the interface's own --ease-out, so the
   reflection decelerates on the same curve as every other motion on the page
   instead of introducing a second timing language. */
.btn-metallic.is-sweeping .btn-fill::after {
  animation: metal-sweep 700ms var(--ease-out);
}

/* Because the travel decelerates, the fade-out is held late: the highlight
   spends its slow tail still visible, which is what makes it read as coming
   to rest rather than being cut off. */
@keyframes metal-sweep {
  from { transform: translateX(-170%) skewX(-14deg); opacity: 0; }
  14%  { opacity: 1; }
  80%  { opacity: 1; }
  to   { transform: translateX(330%) skewX(-14deg); opacity: 0; }
}

.filter-btn:focus-visible,
.card:focus-visible {
  outline: 2px solid #cc2113;
  outline-offset: 3px;
}

/* ------------------------------------------------------------
   Cards list
   ------------------------------------------------------------ */

.cards {
  --cards-gap: 36px;
  width: 100%;
  max-width: 1144px;
  margin: 0 auto;
  padding: 0 44px 40px;
  display: flex;
  flex-direction: column;
  gap: var(--cards-gap);
}

/* scroll-in reveal: movement only. `translate` is separate from
   `transform`, so it composes with the alternating offset above. */
.card-unit {
  position: relative;
  translate: 0 40px;
  opacity: 1;
  transition:
    translate 0.9s var(--ease-out),
    height 560ms var(--ease-out),
    margin-bottom 560ms var(--ease-out),
    opacity 260ms ease,
    clip-path 520ms var(--ease-out);
}

/* Optional 400x800 side art (`earLeft.*` / `earRight.*` in the project
   folder root, next to project.json — NOT in res/). The layer is behind
   both the card and its shadow; hover never transforms it. */
.card-ears {
  position: absolute;
  z-index: 0;
  top: 0;
  left: 0;
  width: 100%;
  aspect-ratio: 2 / 1;
  pointer-events: none;
  opacity: 1;
  transition: opacity 300ms ease;
}

.card-ear {
  position: absolute;
  /* Slightly taller than the paper and tucked 20px underneath it: the art
     then shows through the corner rounding instead of stopping flush at
     the straight side edge. The layer stays behind the card, so the tucked
     strip is invisible everywhere except inside the corner curves. */
  top: -10px;
  height: calc(100% + 20px);
  width: auto;
  display: block;
  user-select: none;
}

.card-ear-left {
  right: calc(100% - 20px);
}

.card-ear-right {
  left: calc(100% - 20px);
}

.card-unit > .card {
  z-index: 2;
}

body.project-mode .card-unit.po-source > .card-ears {
  opacity: 0;
}

.card-unit.in-view {
  translate: 0 0;
}

/* Deliberately NO `overflow: clip` here. It clips to the unit's own box,
   and both the side art and the card's drop shadow paint outside that box —
   so they stayed cut for the whole filter animation and snapped back the
   instant this class was removed, well after the card had settled. (Going
   out, the same cut lands under the opacity fade, which is why only the
   appearance read as a pop.) clip-path alone does the vertical wipe, with
   margins wide enough that ears and shadow are never touched, so removing
   the class at the end changes nothing on screen. */
.card-unit.is-filtering {
  clip-path: inset(-80px -400px -140px -400px);
  will-change: height, margin-bottom, opacity, clip-path;
}

.card-unit.is-filtering-out,
.card-unit.is-hidden {
  opacity: 0;
  /* Side margins match the expanded state, so the ears are never cut
     horizontally — the wipe is purely the bottom edge travelling. The top
     stays at 0 here (not -80px): it keeps the collapsed region empty, so no
     sliver of side art hangs above a card that has not arrived yet. */
  clip-path: inset(0 -400px 100% -400px);
}

.card-unit.is-hidden {
  height: 0;
  margin-bottom: calc(-1 * var(--cards-gap));
  overflow: hidden;
  visibility: hidden;
  pointer-events: none;
}

/* plain-text heading painted onto the folder's top-left, over the
   paper — separate from the embossed logo on the flap, which is
   decorative and often too subtle to read as a label on its own.
   layout.svg's blocks leave this corner clear. */
.card-title {
  position: absolute;
  top: 19px;
  left: 26px;
  margin: 0;
  font-family: var(--serif);
  font-size: 26x;
  font-weight: 400;
  line-height: 1.2;
  color: #605761;
  z-index: 1;
}

/* ------------------------------------------------------------
   Card (folder)
   ------------------------------------------------------------ */

.card {
  position: relative;
  border: none;
  background: none;
  font: inherit;
  color: inherit;
  text-align: left;
  display: block;
  width: 100%;
  cursor: pointer;
  touch-action: manipulation;
  /* --cut = leg length of the corner fold, in px.
     --skew-f / --skew-b open the two folder layers in
     OPPOSITE directions (bottom-anchored, no rotation).
     --scale-f / --scale-b add the perspective foreshortening:
     the front cover stretches UP (>1), the back compresses (<1),
     both from the shared bottom edge. */
  --cut: 132px;
  --skew-f: 0deg;
  --skew-b: 0deg;
  --scale-f: 1;
  --scale-b: 1;
  --layer-front-x: 0px;
  --layer-back-x: 0px;
  --layer-badge-x: 0px;
  --layer-media-x: 0px;
}

/* --- back card (folder body: gradient + year tab) ---
   Stays put. As the front cover skews open, its top-right
   corner fold uncovers this layer's year tab. Bottom edges
   of both layers share the same baseline, so nothing splays
   apart at the bottom — it reads as a folder opening. */

.card-back {
  position: absolute;
  inset: 0;
  /* No shadow here on purpose: the front folder cover sits almost
     exactly on top of this layer, so a second shadow stack here would
     just double up with .folder-shape's and read as one wide, soft
     blur. The single lift-off-the-page shadow lives on .folder-shape;
     this layer only needs to exist for the back-shape fill/skew. */
  transform-origin: bottom center;
  /* A literal `none` at rest is important here. Keeping an identity matrix
     (`skewX(0) scaleY(1)`) plus permanent will-change made Chromium retain a
     rasterized card layer after the first hover, softening all descendants. */
  transform: none;
  transition: transform 0.55s var(--ease-out);
}

.back-shape {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  overflow: visible;
}

/* The ordinary portfolio card gets one separate light bounce from its
   gradient back sheet. Keep it off the project-open clone: that composition
   has its own calibrated shadow stack. */
.card-unit .back-shape {
  filter: drop-shadow(-6px -6px 8px rgb(248 247 248 / 60%));
}

.card-year {
  position: absolute;
  top: 11px;
  right: 27px;
  font-family: var(--footer-serif);
  font-style: normal;
  font-size: 22px;
  color: rgba(255, 250, 245, 0.92);
  letter-spacing: 0.02em;
  display: inline-block;
  /* NEVER a stacking context (no z-index, no transform): the blending
     ::before/::after below must reach .back-shape's gradient as backdrop.
     The element lives at CARD level (after card-front, see main.js) — kept
     inside card-back, the hover transform there forms a stacking context
     that traps the year's z-index beneath card-front and the year vanishes
     for the whole hover. Its children carry the z-indices and the motion. */
}

/* The year used to inherit its hover ride from card-back. At card level
   that motion is restated here on the CHILDREN (never on .card-year itself,
   see above): the same skew about the card's bottom edge, plus the drop the
   back's scaleY used to cause — while the glyphs themselves stay unsquished,
   exactly like the old counter-scale left them. --card-h is published by
   folder.js from the live back height. */
.card-year-text {
  position: relative;
  z-index: 7;
  display: inline-block;
  transform-origin: center calc(var(--card-h, 250px) - 11px);
  transform: none;
  transition: transform 0.55s var(--ease-out);
}

/* the shadow lives on a ::before behind the actual glyphs (text stays
   crisp), so mix-blend-mode:overlay only affects the shadow, blending it
   into the tab's gradient underneath rather than the year's own fill */
.card-year::before {
  content: attr(data-year);
  position: absolute;
  inset: 0;
  color: transparent;
  text-shadow: 1px 1px 4px rgba(0, 0, 0, 0.9);
  mix-blend-mode: overlay;
  z-index: 6;
  pointer-events: none;
}

/* Opposing light bounce for both 2D tabs and 3D badges. It is kept on its
   own overlay layer so neither shadow muddies the actual year glyphs. */
.card-year::after {
  content: attr(data-year);
  position: absolute;
  inset: 0;
  color: transparent;
  text-shadow: -1px -1px 4px rgb(204 204 204 / 100%);
  mix-blend-mode: overlay;
  z-index: 6;
  pointer-events: none;
}

/* the shadow layers ride along with the glyphs (transforms on the pseudos
   themselves are safe for the blend — only an ANCESTOR stacking context
   would isolate it from .back-shape) */
.card--2d > .card-year::before,
.card--2d > .card-year::after {
  transform-origin: center calc(var(--card-h, 250px) - 11px);
  transition: transform 0.55s var(--ease-out);
}

/* --- front card (folder face) --- */

.card-front {
  position: relative;
  /* shape (paper + squircle corners + folded corner + fold shadow)
     is painted by the generated SVG in .folder-shape; this box stays
     transparent and just holds the content. No padding: the collage
     fills the face 1:1 with layout.svg's viewBox (the block margins
     are baked into the layout coordinates), so the fold/tab line up. */
  padding: 0;
  /* skew opens the cover from the bottom edge (origin bottom):
     the top tilts, the bottom stays glued to the back layer. */
  transform-origin: bottom center;
  transform: none;
  transition: transform 0.55s var(--ease-out);
}

/* generated folder outline, sits behind the content. Its stroke IS the
   paper's edge (visible bevel/highlight); this filter is the separate
   lift-off-the-page shadow — same 3-layer depth as --shadow-3, just
   expressed as drop-shadow() since this element (not a plain box) is
   what actually carries the visible paper silhouette. */
.folder-shape {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 0;
  pointer-events: none;
  overflow: visible;
  /* the ONE shadow stack for the whole card — see .card-back for why
     it isn't duplicated there. Kept tight (small blur radii relative
     to offset) so it reads as a crisp lift, not a soft haze. */
  filter:
    drop-shadow(0 1px 2px rgb(52 20 86 / 10%))
    drop-shadow(0 6px 5px rgb(92 52 84 / 4%))
    drop-shadow(0 12px 12px rgba(37, 26, 49, 0.06));
  transition: filter 0.55s var(--ease-out);
}

@media (hover: hover) {
  .card--2d:hover .folder-shape,
  .card--3d:hover .folder-shape {
    filter:
      drop-shadow(0 3px 3px rgba(46, 27, 66, 0.07))
      drop-shadow(0 9px 8px rgba(46, 29, 64, 0.08))
      drop-shadow(0 18px 18px rgba(37, 26, 49, 0.08));
  }
}

/* keep content above the shape */
.collage,
.card-details { position: relative; z-index: 1; }
.emboss { z-index: 1; }

/* --- hover / open: the two layers open in opposite directions,
       bottoms stay glued, shadow deepens. No rotation. --- */

@media (hover: hover) {
  .card--2d:hover {
    --skew-f: 2.5deg; --skew-b: -2.5deg;
    /* front stretches half as much as the back compresses */
    --scale-f: 1.00625; --scale-b: 0.9875;
  }

  /* Apply the matrices only while the interaction is active. On pointer
     leave the transition resolves all the way back to `transform:none`, so
     text, overlays and photos return to normal-resolution painting. */
  .card-unit > .card--2d:hover .card-front {
    transform: skewX(var(--skew-f)) scaleY(var(--scale-f));
  }

  .card-unit > .card--2d:hover .card-back {
    transform: skewX(var(--skew-b)) scaleY(var(--scale-b));
  }

  /* the year's old ride inside card-back, recreated: skew about the card's
     bottom edge (origin set in the base rules above) + the downward shift
     the back's scaleY produced at the year's height. No scale term — the
     glyphs were exactly counter-scaled before, so their net shape never
     squished. 25px ≈ the year text's centre below the card top. */
  .card-unit > .card--2d:hover .card-year-text,
  .card-unit > .card--2d:hover > .card-year::before,
  .card-unit > .card--2d:hover > .card-year::after {
    transform:
      translateY(calc((1 - var(--scale-b, 1)) * (var(--card-h, 250px) - 25px)))
      skewX(var(--skew-b, 0deg));
  }
}

/* ------------------------------------------------------------
   3D project card — a hard layered case, not a paper folder
   ------------------------------------------------------------ */

.card--3d .card-front,
.card--3d .card-back {
  transition: transform 0.55s var(--ease-out);
}

.card--3d .card-back { z-index: 1; }
.card--3d .card-front { z-index: 3; }

/* Closed 3D card only: clip the rear plastic sheet with the +1 design-unit
   radius calculated in folder.js. The dock removes this ancestor selector,
   so its custom morph remains completely unclipped. */
.card-unit .card--3d .back-shape {
  overflow: hidden;
}

.year-badge-shape {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 4;
  overflow: visible;
  pointer-events: none;
  transition: transform 0.55s var(--ease-out);
}

.card--3d > .card-year {
  top: 10px;
  right: 26px;
  /* Keep this wrapper out of its own stacking context. Its two overlay
     shadows must blend against the badge SVG; the visible text child owns
     the higher z-index instead. */
  z-index: auto;
  transition: right 0.55s var(--ease-out);
}

/* The Photoshop reference describes a recessed mark: three dark inner
   layers plus one light outer bounce. The SVG filter lives in index.html. */
.card--3d .emboss {
  --card-emboss-fill: var(--card-3d-emboss-fill);
  filter: url(#debossFx);
}

.card--3d .emboss-logo svg {
  mix-blend-mode: normal;
}

@media (hover: hover) {
  /* Separate the two rigid sheets. No skew and no photo counter-transform:
     the entire white face, its windows and the inset logo are one layer. */
  .card-unit > .card--3d:hover {
    --layer-front-x: -10px;
    --layer-back-x: 10px;
    --layer-badge-x: var(--layer-back-x);
    /* The window belongs to the moving white case, but the photograph is
       physically behind it. Cancel only the front sheet's X travel on the
       media wrapper so the crop moves while the pixels stay screen-fixed. */
    --layer-media-x: 10px;
  }

  .card-unit > .card--3d:hover .card-front {
    transform: translateX(var(--layer-front-x));
  }

  .card-unit > .card--3d:hover .card-back {
    transform: translateX(var(--layer-back-x));
  }

  .card-unit > .card--3d:hover > .year-badge-shape {
    transform: translateX(var(--layer-badge-x));
  }

  /* Move the text without transform/translate. A transformed year creates
     a stacking context and traps its overlay shadows, making them composite
     as normal dirty-grey shadows instead of blending with the badge. */
  .card-unit > .card--3d:hover > .card-year {
    right: calc(26px - var(--layer-badge-x));
  }

  .card-unit > .card--3d:hover .preview-media {
    translate: var(--layer-media-x) 0;
  }
}

/* Closed 3D stack: badge is physically attached to the gradient rear sheet.
   Its outline is clipped strictly INSIDE its own SVG silhouette, so it can
   stay above the white plate without producing an outside overlap. */
.card-unit .card--3d .card-back { z-index: 1; }
.card-unit .card--3d .card-front { z-index: 3; }
.card-unit .card--3d > .year-badge-shape { z-index: 4; }
.card-unit .card--3d > .card-year { z-index: auto; }


/* ------------------------------------------------------------
   Card content
   ------------------------------------------------------------ */


/* the card face. Preview blocks and the logo are absolutely placed
   inside here from layout.svg's block fractions. The ratio is FIXED
   at the reference card's own proportions (500:250, from
   res/2dCardReference.svg) — not derived from each project's layout.svg —
   because folder.js's fold/corner math assumes height = width/2. Every
   project's layout.svg should use a 2:1 viewBox (e.g. 1000x500) so its
   block rects map onto this without stretching. */
.collage {
  position: relative;
  width: 100%;
  aspect-ratio: 2 / 1;
}

/* ---- light sweep ----------------------------------------------------
   A soft white band anchored to the SCREEN: cards travel through it as you
   scroll, and it drifts slightly toward the cursor (js/glare.js).

   Two layers give the paper and the photos different strengths without
   measuring either one. In paint order:
       .folder-shape → .card-glare-under → .collage (photos) → .card-glare-over
   The photos sit on top of the "under" layer, so they mask it out and it
   only ever lights exposed paper. The "over" layer is above everything.
   Net: paper = under + over, photos = over alone.

   Each layer is a wrapper clipped to the paper's exact silhouette (folder.js
   points clip-path at a <clipPath> mirroring the folder path, so the sweep
   can't spill past the folded corner) around a viewport-sized inner that
   carries the gradient. Only the inner moves, and only by a transform — the
   gradient itself is never re-rasterised as you scroll. */
.card-glare {
  position: absolute;
  inset: 0;
  pointer-events: none;
  overflow: hidden; /* holds the sweep inside the card until folder.js
                       hands over the real silhouette clip */
}
.card-glare-under { opacity: var(--glare-paper); }
.card--2d .card-glare-under { opacity: var(--glare-paper-2d); }
.card-glare-over  { opacity: var(--glare-photo); z-index: 3; }
.card--3d .card-glare-over { opacity: var(--glare-photo-3d); }

.card-glare-inner {
  position: absolute;
  /* Overscan by the cursor's full reach on every side (--glare-reach is
     written by glare.js from the same constant it drifts by, so the two
     can't fall out of step). Without this the drift pulls the layer's own
     edge into frame and slices the band off with a hard straight line —
     the gradient is mid-band where it meets these edges, not transparent. */
  top: calc(-1 * var(--glare-overscan, 190px));
  left: calc(-1 * var(--card-left, 0px) - var(--glare-overscan, 190px));
  width: calc(100vw + 2 * var(--glare-overscan, 190px));
  height: calc(100vh + 2 * var(--glare-overscan, 190px));
  /* Angled along the card's own diagonal (a 2:1 card ≈ 155deg) and, more
     importantly, sized to the CARD rather than the viewport: the stops span a
     fraction of the gradient line, so a whole band with a defined core lands
     inside one card. Spread across the full viewport diagonal instead, any
     single card only ever showed a slice of one enormous ramp — visible as a
     vague brightening, never as a highlight. */
  /* Narrow core, long tails. Squeezing every stop toward the middle made the
     band half as wide but also twice as steep, and a steep ramp reads as a
     hard edge — which is not what a sheen on paper does. So only the BRIGHT
     part is narrow (47→53, against the original 42→58), while the fade keeps
     a wide envelope (38→62) and gets extra stops so it dissolves instead of
     stepping. Both glare layers share this gradient; 3D overrides it with its
     own deliberately crisp version below. */
  background: linear-gradient(
    155deg,
    rgba(255, 255, 255, 0) 38%,
    rgba(255, 255, 255, 0.05) 44%,
    rgba(255, 255, 255, 0.18) 47%,
    rgba(255, 255, 255, 0.35) 50%,
    rgba(255, 255, 255, 0.18) 53%,
    rgba(255, 255, 255, 0.05) 56%,
    rgba(255, 255, 255, 0) 62%
  );
  /* cancels the card's own document offset, so the band stays put on screen
     while the card scrolls through it. Deliberately no will-change: these are
     viewport-sized, and promoting two per card would cost far more memory
     than the small clipped repaint it would save. */
  transform: translate(
    var(--glare-x, 0px),
    calc(var(--scroll-y, 0px) - var(--card-top, 0px) + var(--glare-y, 0px))
  );
}

/* Promoted only while the card is near the viewport; js/glare.js toggles the
   class from an IntersectionObserver.

   Unpromoted, these repaint on every scroll frame, and that alone was the
   main page's scroll jank: forcing a compositor layer made it perfectly
   smooth, while writing each layer's transform directly — bypassing the
   inherited --scroll-y on :root and the whole-tree invalidation it causes —
   changed nothing at all. So it is paint, not style.

   Promoting them ALL permanently is still wrong, which is why this is scoped:
   they are viewport-sized and there are two per card, so the texture cost
   grows with the project list, most of it for cards nobody can see. The
   observer's rootMargin creates the layer well before the card scrolls in, so
   the promotion itself is never visible — a layer appearing under a card
   already on screen would re-round it onto a different device-pixel row and
   read as a jump. */
.card-front.is-glare-near .card-glare-inner {
  will-change: transform;
}

/* Plastic 3D case: the leading edge is crisp, but still purely light — no
   darkening pass. The 2D paper folders keep the symmetric soft sweep above. */
.card--3d .card-glare-inner {
  background: linear-gradient(
    155deg,
    rgba(255, 255, 255, 0) 42%,
    rgba(255, 255, 255, 0) 45.8%,
    rgb(255 255 255 / 9%) 46%,
    rgba(255, 255, 255, 0.04) 48%,
    rgba(255, 255, 255, 0) 55%
  );
}

@media (prefers-reduced-motion: reduce) {
  /* keep the sheen, drop the cursor drift */
  .card-glare-inner { transform: translateY(calc(var(--scroll-y, 0px) - var(--card-top, 0px))); }
}

/* whole-card overlay (see buildCard's cardOverlay resolveMedia call):
   already cut to the card's 2:1 shape, sits above every block + the logo,
   moves with the paper (no counter-skew — it's part of the face, not a
   photo behind a window). */
.card-overlay {
  position: absolute;
  inset: calc(-1 * var(--card-overlay-bleed));
  width: calc(100% + var(--card-overlay-bleed) + var(--card-overlay-bleed));
  height: calc(100% + var(--card-overlay-bleed) + var(--card-overlay-bleed));
  /* The overlay is authored against the card coordinates. A very small
     non-uniform expansion keeps all four artwork edges exactly 16px outside
     instead of cover-cropping the left/right decoration. */
  object-fit: fill;
  pointer-events: none;
  z-index: 2;
}

/* Previews are windows cut into the folder paper: recessed,
   with an inner shadow — never a card floating on top. The window
   (this box + its shadow below) moves with the paper like the fold and
   the embossed logo do; only the photo itself stays put — see the img/
   video rule below for how. Position + size come from the layout.svg
   block, set inline. */
.preview {
  position: absolute;
  border-radius: 16px;
  corner-shape: var(--squircle);
  overflow: hidden;
  background: var(--ph, #d9d5db);
  background-size: cover;
  background-position: center;
}

/* The photo counter-transforms .card-front's hover/open tilt AND stretch so
   it reads as real content behind a window, not part of the flexing paper.
   card-front applies skewX(a) scaleY(s); the exact inverse is
   scaleY(1/s) skewX(-a) (note the reversed order). Both the shear and the
   vertical scale pivot on card-front's bottom edge, so both are cancelled
   from the same Y-origin — expressed as a percentage of THIS element's own
   height via --origin-y (set per block in main.js from the layout.svg
   fraction: (1 - top) / h * 100). Cancels shear, scale AND the position
   shift each causes, at any card size. A live-animating transform
   (needed to compensate the parent's) does soften slightly during the
   hover transition — see the sharpen filter below, which counteracts it. */
.preview-media {
  width: 100%;
  height: 100%;
  transform: none;
  translate: 0 0;
  transform-origin: 50% var(--origin-y, 50%);
  transition:
    transform 0.55s var(--ease-out),
    translate 0.55s var(--ease-out);
}

/* Promote only for the active 3D transition. Permanent promotion leaves
   the settled card softer; the JS class disappears at transitionend. */
/* The 3D hover is deliberately NOT promoted.

   It used to carry `will-change: transform, translate` for the duration of
   the transition, and that was visibly worse: creating the layer re-rounded
   the card onto a different device-pixel row, so it dropped a fraction of a
   pixel as the hover began and stepped back as the class was removed at the
   end. Which cards showed it depended purely on where each one happened to
   land — changing the page zoom moved the flicker to a different card and
   pulled the title in with it, since the title rides inside the promoted
   card-front. That is a rounding artefact, not motion, and no easing hides it.

   Nothing is lost by dropping it: unlike the 2D card's skew plus inverse
   skew, this hover is a translateX of whole pixels, which the compositor
   gains nothing from. If a weak machine ever janks here, promote a WRAPPER
   that is not the element being rasterised — never these. */

/* Same promotion for the 2D year while its hover ride is animating. Its
   blending shadow layers now live OUTSIDE the animating card-back surface;
   keeping them composited for the transition prevents the cross-surface
   blend from flickering/dropping mid-animation on some GPUs. Transient by
   construction — the class is removed at transitionend. */
.card--2d.is-hover-transforming .card-year-text,
.card--2d.is-hover-transforming > .card-year::before,
.card--2d.is-hover-transforming > .card-year::after {
  will-change: transform;
}

@media (hover: hover) {
  .card--2d[aria-expanded="false"]:hover .preview-media {
    transform: scaleY(calc(1 / var(--scale-f))) skewX(calc(-1 * var(--skew-f)));
  }

  /* Sharpen only while the parent and its inverse are actually moving.
     main.js removes this class on the front layer's transform transitionend,
     so neither the stable hover pose nor the neutral pose keeps a filter.

     The softness this compensates is inherent to the flight: while
     card-front animates, its subtree is one composited texture — the photo
     is painted counter-skewed into it and the texture is skewed forward, so
     the pixels are resampled and only recover once the layer repaints at
     rest. Promoting .preview-media (with or without a plain-radius window
     clip) was tried instead of this filter and changed nothing, so the
     filter stays. Anything short of moving the photos out of the skewed
     subtree entirely is a compensation, not a cure. */
  .card--2d[aria-expanded="false"].is-hover-transforming .preview-media {
    filter: url(#sharpenFx);
  }
}

/* The source card stays hidden for the whole project view. Chromium can
   retain the last rasterized SVG-filter frame while that layer is hidden;
   this one-frame reset is applied by open.js before the card is revealed. */
.card.is-sharpen-reset .preview-media {
  filter: none !important;
  transition: none !important;
}

/* an upright rectangle inside what's now a parallelogram-shaped clip
   (the window inherits the paper's shear) doesn't quite reach the slanted
   corner — only the top-left, given the fold's skew direction — so it's
   scaled up from the OPPOSITE (bottom-right) corner: all the overflow
   growth goes toward the one corner that needs covering, instead of being
   split four ways from the centre, so a smaller scale does the job.
   (Separate element from the skew-cancel above, which pivots around a
   point far outside the box — different origin, different purpose.)
   .preview's overflow:hidden clips the excess. */
.preview-media img,
.preview-media video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  transform: scale(1.08);
  transform-origin: bottom right;
}

/* inner shadow painted above the image, so the window edges
   recede into the paper */
.preview::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  corner-shape: var(--squircle);
  pointer-events: none;
  /* two dark layers (top-left corner) + one light layer — the light one
     needs an OPPOSITE-signed offset (bottom-right corner), otherwise all
     three land in the same corner and the two dark ones, painted first,
     fully cover the light one underneath — it was invisible, not subtle.
     tune freely from here. */
  box-shadow:
    inset 1px 2px 3px -1px rgb(79 61 76 / 51%),
    inset 3px 11px 16px -5px rgb(33 25 34 / 70%),
    inset -3px -8px 16px -5px rgb(233 224 233 / 45%);
}

/* ------------------------------------------------------------
   Embossed logo — placed from the layout.svg "logo" block
   ------------------------------------------------------------ */

.emboss {
  position: absolute;
  filter: url(#embossFx);
  pointer-events: none;
  user-select: none;
}

/* logo art fills its layout block; the convex emboss comes from the
   .emboss filter, and multiply lets the fill react to the paper under it */
.emboss-logo {
  display: grid;
  place-items: center;
}
.emboss-layout {
  inset: 0;
  width: 100%;
  height: 100%;
}
.emboss-logo svg {
  width: 100%;
  height: 100%;
  display: block;
  /* clip to the block like any normal SVG (default UA behavior) —
     the previous "visible" was copied from .folder-shape, where it's
     needed for the drop-shadow's feather; here it just let the
     artwork bleed past its own bounding box */
  overflow: hidden;
  mix-blend-mode: multiply;
}

/* ------------------------------------------------------------
   Tags row under the card
   ------------------------------------------------------------ */

.card-tags {
  margin: 18px 0 0 6px;
  font-family: var(--serif);
  font-size: 18px;
  color: #aca8b0;
  letter-spacing: 0.01em;
}

.card-tags .sep {
  font-style: normal;
  padding: 0 8px;
  color: #aca8b0;
}

/* ------------------------------------------------------------
   Footer
   ------------------------------------------------------------ */

.site-footer {
  width: 100%;
  margin-top: auto;
  padding: 64px 48px 32px;
  text-align: center;
  font-family: var(--footer-serif);
  font-style: italic;
  font-size: 17px;
  color: #afa7ae;
}

/* ------------------------------------------------------------
   Project open view (js/open.js)
   ------------------------------------------------------------ */

/* the rest of the page slides fully off-screen — no fade. Header up, footer
   down; sibling cards get an inline `translate` toward their nearest edge
   from js/open.js (pushSiblings). The clicked card's own unit (.po-source)
   is excluded — it holds its place so the dock has an exact rect to morph
   from/back to. Everything shares --po-dur/--po-ease so it clears in step
   with the folder. */
.site-header,
.site-footer {
  transition:
    transform var(--po-interface-active-dur)
    var(--po-interface-active-ease)
    var(--po-interface-active-delay);
}
body.project-mode {
  --po-interface-active-dur: var(--po-open-folder-dur);
  --po-interface-active-ease: var(--po-open-folder-ease);
  --po-interface-active-delay: var(--po-interface-open-delay);
}
body.project-mode .site-header {
  transform: translateY(-160%);
  pointer-events: none;
}
body.project-mode .site-footer {
  transform: translateY(160%);
  pointer-events: none;
}
/* the source unit persists through cleanup, so :has keeps the faster
   timing active for the whole open→close cycle (base .card-unit uses the
   slower scroll-reveal timing, which would make siblings lag the folder) */
body:has(.po-source) .card-unit {
  transition:
    translate var(--po-interface-active-dur)
    var(--po-interface-active-ease)
    var(--po-interface-active-delay);
}
body:has(.po-source) .card-unit:not(.po-source) { pointer-events: none; }

/* fixed full-screen frame. --po-folder-top (the folder's live top edge)
   transitions from the card top to the docked top in lockstep with the
   dock; every emergence below is derived from it. */
.project-open {
  position: fixed;
  inset: 0;
  z-index: 50;
  pointer-events: none;
  --po-dur: var(--po-open-folder-dur);
  --po-ease: var(--po-open-folder-ease);
  --po-folder-top: var(--po-folder-start);
  --po-canvas-from: 0px;
  --po-bar-from: 0px;
  --po-shred-stow-scale: 1;
  --po-shred-origin-x: 50%;
  /* How far project media may continue below the folder's upper edge. The
     white front cover paints above it; only the back-gradient opening shows
     the overlap, matching the physical stack in the reference. */
  --po-media-overlap: 54px;
  transition: --po-folder-top var(--po-dur) var(--po-ease);
}
.project-open.is-in { --po-folder-top: var(--po-dock-final); }
.project-open.is-closing {
  --po-dur: var(--po-stow-dur);
  --po-ease: var(--po-stow-ease);
}

/* clip layer: its bottom edge is pinned to the folder's top edge, so
   NOTHING inside (the scrolling content, the slot bar) is ever visible
   below the folder. As the folder drops, this opens downward and reveals
   the content emerging at the folder's top — not sliding in from the
   screen bottom. */
.po-clip {
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  clip-path: inset(
    0 0
    calc(100% - var(--po-folder-top) - var(--po-media-overlap))
    0
  );
}

/* --- scrolling content column --- */

.po-scroll {
  position: absolute;
  inset: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  pointer-events: auto;
  scrollbar-width: none;
  -ms-overflow-style: none;
  -webkit-overflow-scrolling: touch;
  touch-action: pan-y;
  /* clip-path (top inset = the slot line, set inline by open.js):
     content is invisible above the slot — the strip clone owns that zone.
     The bottom is handled by the parent .po-clip (the folder edge). */
}

.po-pad {
  max-width: 1144px;
  margin: 0 auto;
  padding-left: 44px;
  padding-right: 44px;
}

/* The first pixel of the content starts exactly on the clicked folder's
   upper edge. It then travels to its fixed reading position while the
   folder travels to the dock. The moving .po-clip edge is the only thing
   that reveals/hides it; opacity is never animated. */
.po-canvas {
  position: relative;
  width: 100%;
  transform: translateY(var(--po-canvas-from));
  transition: none;
  will-change: transform;
}
.project-open.is-emerging .po-canvas {
  transform: translateY(0);
  transition: transform var(--po-emerge-dur) var(--po-emerge-ease);
}
.project-open.is-retracting .po-canvas {
  transform: translateY(var(--po-canvas-from));
  transition: transform var(--po-suck-dur) var(--po-suck-ease);
}

.po-block {
  position: absolute;
  border-radius: 7px;
  overflow: hidden;
  background: #d9d5db;
}
.po-block img,
.po-block video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* ------------------------------------------------------------
   Custom project video player
   ------------------------------------------------------------ */

.crispy-player {
  --cp-ink: rgb(255 255 255 / 75%);
  --cp-muted: rgb(255 255 255 / 58%);
  /* PLAYER ICON OPTICAL TUNING
     Adjust these independently: the three glyphs have different visual mass. */
  --cp-play-icon-size: 20px;
  --cp-sound-icon-size: 22px;
  --cp-expand-icon-size: 17px;
  --cp-fullscreen-icon-size: 17px;
  --cp-play-icon-x: 1px;
  --cp-sound-icon-x: 0px;
  --cp-expand-icon-x: 0px;
  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: #171419;
  isolation: isolate;
}

.crispy-player > .cp-video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  cursor: pointer;
}

.cp-ui {
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
}

.cp-ui::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 112px;
  z-index: 0;
  pointer-events: none;
  opacity: 0;
  background: linear-gradient(to top, rgb(10 8 11 / 64%), rgb(10 8 11 / 22%) 48%, transparent);
  transition: opacity 240ms ease;
}

.crispy-player:hover .cp-ui::after,
.crispy-player:focus-within .cp-ui::after,
.crispy-player.is-paused .cp-ui::after,
.crispy-player.is-controls-visible .cp-ui::after { opacity: 1; }

.cp-centre-play,
.cp-control {
  border: 0;
  color: var(--cp-ink);
  cursor: pointer;
  display: grid;
  place-items: center;
  padding: 0;
  pointer-events: auto;
}

.cp-centre-play {
  position: absolute;
  left: 50%;
  top: 50%;
  z-index: 1;
  width: 64px;
  height: 64px;
  translate: -50% -50%;
  border: 0;
  border-radius: 0;
  background: transparent;
  box-shadow: none;
  filter: none;
  opacity: 0;
  scale: 0.86;
  transition:
    opacity 240ms var(--ease-out),
    scale 360ms var(--ease-out),
    box-shadow 360ms var(--ease-out);
}

.cp-centre-play::before {
  content: "";
  position: absolute;
  inset: 0;
  background: rgb(255 255 255 / 32%);
  -webkit-backdrop-filter: blur(2px) saturate(90%);
  backdrop-filter: blur(2px) saturate(90%);
  -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cpath fill='white' d='M20 10C16.2 7.8 11.5 10.5 11.5 14.9v34.2c0 4.4 4.7 7.1 8.5 4.9l29.6-17.1c3.8-2.2 3.8-7.6 0-9.8L20 10Z'/%3E%3C/svg%3E") center / contain no-repeat;
  mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cpath fill='white' d='M20 10C16.2 7.8 11.5 10.5 11.5 14.9v34.2c0 4.4 4.7 7.1 8.5 4.9l29.6-17.1c3.8-2.2 3.8-7.6 0-9.8L20 10Z'/%3E%3C/svg%3E") center / contain no-repeat;
  filter: drop-shadow(0 4px 7px rgb(4 3 5 / 17%));
  transition: background-color 220ms ease, filter 220ms ease;
}

.cp-centre-play:hover::before {
  background: rgb(255 255 255 / 38%);
  filter: drop-shadow(0 5px 8px rgb(4 3 5 / 20%));
}

.is-paused .cp-centre-play {
  opacity: 1;
  scale: 1;
}

.cp-centre-play svg {
  opacity: 0;
}

.cp-controls {
  position: absolute;
  left: 14px;
  right: 14px;
  bottom: 14px;
  z-index: 1;
  min-height: 38px;
  padding: 0 5px;
  border: 0;
  border-radius: 0;
  background: transparent;
  box-shadow: none;
  display: grid;
  grid-template-columns: 28px max-content minmax(80px, 1fr) max-content 28px 28px;
  align-items: center;
  gap: 9px;
  pointer-events: auto;
  opacity: 0;
  translate: 0 6px;
  transition:
    opacity 220ms var(--ease-out),
    translate 320ms var(--ease-out);
}

.crispy-player:hover .cp-controls,
.crispy-player:focus-within .cp-controls,
.crispy-player.is-paused .cp-controls,
.crispy-player.is-controls-visible .cp-controls {
  opacity: 1;
  translate: 0 0;
}

.cp-control {
  width: 28px;
  height: 28px;
  border-radius: 7px;
  background: transparent;
  transition: background-color 180ms ease, color 180ms ease;
}

.cp-control:hover,
.cp-control:focus-visible {
  color: white;
  background: rgb(255 255 255 / 14%);
  outline: none;
}

.cp-control svg {
  width: 18px;
  height: 18px;
  fill: currentColor;
}

.cp-play svg {
  width: var(--cp-play-icon-size);
  height: var(--cp-play-icon-size);
  translate: var(--cp-play-icon-x) 0;
}

.cp-sound svg {
  width: var(--cp-sound-icon-size);
  height: var(--cp-sound-icon-size);
  translate: var(--cp-sound-icon-x) 0;
}

.cp-expand svg {
  width: var(--cp-expand-icon-size);
  height: var(--cp-expand-icon-size);
  translate: var(--cp-expand-icon-x) 0;
}

.cp-fullscreen {
  display: none;
}

.cp-fullscreen svg {
  width: var(--cp-fullscreen-icon-size);
  height: var(--cp-fullscreen-icon-size);
}

.cp-control .cp-play-shape { fill: currentColor; stroke: none; filter: none; }
.cp-control .cp-play-sheen { display: none; }

.cp-control .cp-stroke {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.cp-sound svg {
  fill: currentColor;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.cp-sound .cp-sound-wave { fill: none; }
.crispy-player.is-muted .cp-sound-wave { opacity: 0.22; }

.cp-time {
  min-width: 34px;
  color: var(--cp-ink);
  font-family: var(--sans);
  font-size: 12px;
  font-variant-numeric: tabular-nums;
  line-height: 1;
  text-align: center;
}

.cp-timeline {
  position: relative;
  height: 18px;
  display: flex;
  align-items: center;
}

.cp-timeline::before,
.cp-buffered,
.cp-played {
  content: "";
  position: absolute;
  left: 0;
  top: 50%;
  height: 4px;
  translate: 0 -50%;
  border-radius: 4px;
  pointer-events: none;
}

.cp-timeline::before {
  right: 0;
  background: rgb(255 255 255 / 22%);
  box-shadow: inset 0 1px 1px rgb(0 0 0 / 18%);
}

.cp-buffered {
  width: 0;
  background: rgb(255 255 255 / 36%);
}

.cp-played {
  width: 0;
  background: rgb(255 255 255 / 92%);
  box-shadow: 0 1px 3px rgb(0 0 0 / 22%);
}

.cp-seek {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  opacity: 0;
  cursor: pointer;
}

.cp-seek:focus-visible {
  opacity: 0;
  outline: none;
}

.cp-timeline:has(.cp-seek:focus-visible)::before {
  box-shadow: 0 0 0 2px rgb(255 255 255 / 36%);
}

/* Cinema mode keeps the project's physical frame visible: the shredder is
   the upper boundary, the compressed folder the lower one. */
.cp-cinema-backdrop {
  position: fixed;
  inset: 0;
  /* Between the project media and the portalled machine/dock: pictures and
     shards dim, while the physical interface stays bright. */
  z-index: 24;
  border: 0;
  padding: 0;
  opacity: 0;
  pointer-events: auto;
  cursor: default;
  background: rgb(22 18 24 / 68%);
  -webkit-backdrop-filter: blur(7px) saturate(72%);
  backdrop-filter: blur(7px) saturate(72%);
  transition: opacity 320ms ease;
}

.cp-cinema-backdrop.is-visible { opacity: 1; }

/* Photo focus. Same two layers and the same z-index band as the player's
   cinema mode above, so a focused picture and a focused video sit at exactly
   the same depth: the media and the shards dim, the machine and the folder
   stay bright and clickable over the top. */
.po-focus-backdrop {
  position: fixed;
  inset: 0;
  z-index: 24;
  border: 0;
  padding: 0;
  opacity: 0;
  cursor: default;
  /* .project-open is pointer-events:none so the page underneath stays live;
     every interactive layer inside it has to opt back in, or a real click
     falls straight through (a scripted .click() would still "work", which is
     what hid this). */
  pointer-events: auto;
  background: rgb(22 18 24 / 68%);
  -webkit-backdrop-filter: blur(7px) saturate(72%);
  backdrop-filter: blur(7px) saturate(72%);
  transition: opacity 320ms ease;
}

.po-focus-backdrop.is-visible { opacity: 1; }

.po-focus {
  position: fixed;
  z-index: 26;
  border-radius: 12px;
  overflow: hidden;
  cursor: zoom-out;
  pointer-events: auto;   /* see the backdrop above */
  box-shadow: 0 20px 60px rgb(12 8 14 / 28%);
  transition: opacity 300ms ease;
}

/* contain, not cover: the point of focusing is to see the whole frame, so
   the picture is never cropped here even though its block crops it. */
.po-focus img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  display: block;
}

.po-focus.is-leaving { opacity: 0; }

/* The machine and the folder stay above the dim and keep working, exactly as
   they do for a focused video — the media dims, the physical interface does
   not. Same numbers as the .has-video-cinema pair above so a focused picture
   and a focused video stack identically. The bar only reaches this layer once
   open.js has lifted it out of .po-clip. */
.project-open.has-image-focus .po-dock { z-index: 30; }
.project-open.has-image-focus .po-shred-bar { z-index: 31; }

/* A touch recessed while something is focused in front of them, matching the
   video's treatment. */
.project-open.has-image-focus .po-dock .card-front,
.project-open.has-image-focus .po-shred-bar {
  filter: brightness(0.9);
}

/* Only the blocks that actually open one advertise it. */
.po-block:not(.po-block-alpha):not(.po-block-logo):has(img) {
  cursor: zoom-in;
}

@media (prefers-reduced-motion: reduce) {
  .po-focus-backdrop { transition-duration: 1ms; }
}

.crispy-player.is-cinema {
  position: fixed;
  left: var(--cp-cinema-left, 24px);
  top: var(--cp-cinema-top, 80px);
  width: var(--cp-cinema-width, calc(100vw - 144px));
  height: var(--cp-cinema-height, calc(100vh - 200px));
  z-index: 26;
  border-radius: 12px;
  background: transparent;
  box-shadow: 0 20px 60px rgb(12 8 14 / 28%);
  pointer-events: auto;
}

.crispy-player.is-cinema > .cp-video { object-fit: cover; }
.crispy-player.is-cinema .cp-controls {
  left: 18px;
  right: 18px;
  bottom: 12px;
  grid-template-columns:
    28px max-content minmax(80px, 1fr) max-content 28px 28px 28px;
}
.crispy-player.is-cinema .cp-fullscreen { display: grid; }

/* Second-stage fullscreen. The player itself enters the browser top layer,
   so the project folder and shredder are intentionally absent. */
.crispy-player:fullscreen {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  border-radius: 0;
  background: #080709;
  box-shadow: none;
}

.crispy-player:fullscreen::backdrop { background: #080709; }
.crispy-player:fullscreen > .cp-video { object-fit: contain; }
.crispy-player:fullscreen .cp-controls {
  left: 28px;
  right: 28px;
  bottom: 20px;
  grid-template-columns:
    28px max-content minmax(80px, 1fr) max-content 28px 28px;
}
.crispy-player:fullscreen .cp-focus-toggle { display: none; }
.crispy-player:fullscreen .cp-ui::after { height: 150px; }

/* Native fullscreen owns its idle state: :hover is permanently true while
   the pointer is inside the top layer, so it cannot control visibility. */
.crispy-player:fullscreen.is-controls-idle {
  cursor: none;
}

.crispy-player:fullscreen.is-controls-idle .cp-controls,
.crispy-player:fullscreen.is-controls-idle .cp-centre-play {
  opacity: 0;
  pointer-events: none;
}

.crispy-player:fullscreen.is-controls-idle .cp-controls {
  translate: 0 6px;
}

.crispy-player:fullscreen.is-controls-idle .cp-ui::after {
  opacity: 0;
}

.project-open.has-video-cinema .po-dock { z-index: 30; }
.project-open.has-video-cinema .po-shred-bar { z-index: 31; }

.project-open:has(.crispy-player.is-cinema) .po-dock .card-front,
.project-open:has(.crispy-player.is-cinema) .po-shred-bar.is-cinema-bar {
  filter: brightness(0.9);
}

/* A true fullscreen exit is a hard state switch. Suppress the Focus filter
   return and every physical-layer transition during its two-frame handoff. */
.project-open.is-hard-cinema-exit .po-dock,
.project-open.is-hard-cinema-exit .po-dock *,
.project-open.is-hard-cinema-exit .po-shred-bar {
  transition: none !important;
  animation: none !important;
}

/* Dark cinema backdrop makes the calibrated white bounce read too loudly.
   Preserve every dark layer and only attenuate the light material response.
   A focused photo dims the page exactly as a focused video does, so it needs
   the same attenuation — hence both classes on each rule below. */
.project-open.has-video-cinema .po-dock.is-docked .card .folder-shape,
.project-open.has-image-focus .po-dock.is-docked .card .folder-shape {
  filter:
    drop-shadow(0 1px 1px rgb(46 27 66 / 10%))
    drop-shadow(0 12px 4px rgb(46 29 64 / 5%))
    drop-shadow(0 12px 8px rgb(37 26 49 / 3%))
    drop-shadow(-8px -2px 6px rgb(214 210 216 / 10%))
    drop-shadow(-6px -8px 16px rgb(246 246 246 / 14%));
}

.project-open--2d.has-video-cinema .po-shred-bar.is-cinema-bar,
.project-open--2d.has-image-focus .po-shred-bar {
  box-shadow:
    0 2px 2px rgb(46 27 66 / 5%),
    0 6px 5px rgb(46 29 64 / 6%),
    0 12px 12px rgb(37 26 49 / 6%),
    inset 0 1px 1px rgb(255 255 255 / 15%),
    inset 0 -1px 0 rgb(255 255 255 / 12%);
}

/* On a 2D project the bar carries no fill of its own: it normally sits over
   the bright page with the shredder layers behind it supplying the paper.
   Lifted above the dim for a focus it had nothing to hide the backdrop with
   and went see-through. Give it the 2D paper fill for exactly that state —
   3D paints its own gradient, which is why the bug only showed here. Both
   focus modes get it: a focused video on a 2D project had the same hole. */
.project-open--2d.has-image-focus .po-shred-bar,
.project-open--2d.has-video-cinema .po-shred-bar.is-cinema-bar {
  background: linear-gradient(
    180deg,
    var(--card-paper-a),
    var(--card-paper-b)
  );
}

.project-open--3d.has-video-cinema .po-shred-bar.is-cinema-bar,
.project-open--3d.has-image-focus .po-shred-bar {
  box-shadow:
    0 2px 2px rgb(46 27 66 / 5%),
    0 7px 8px rgb(46 29 64 / 7%),
    0 15px 18px rgb(37 26 49 / 7%),
    inset 0 1px 1px rgb(255 255 255 / 16%),
    inset 0 -1px 0 rgb(255 255 255 / 14%),
    inset 0 0 0 5px rgb(220 217 221 / 8%);
}

@media (max-width: 700px) {
  .cp-controls {
    left: 8px;
    right: 8px;
    bottom: 7px;
    grid-template-columns: 28px minmax(64px, 1fr) max-content 28px 28px;
    gap: 6px;
  }
  .cp-current { display: none; }
  .crispy-player.is-cinema .cp-controls {
    left: 10px;
    right: 10px;
    grid-template-columns: 28px minmax(64px, 1fr) max-content 28px 28px 28px;
  }
  .crispy-player:fullscreen .cp-controls {
    grid-template-columns: 28px minmax(64px, 1fr) max-content 28px 28px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .cp-centre-play,
  .cp-controls,
  .cp-ui::after,
  .cp-cinema-backdrop { transition-duration: 1ms; }
}

.crispy-player.is-buffering::after {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  z-index: 3;
  width: 24px;
  aspect-ratio: 1;
  margin: -12px;
  border: 2px solid rgb(255 255 255 / 25%);
  border-top-color: rgb(255 255 255 / 82%);
  border-radius: 50%;
  animation: cp-spin 0.8s linear infinite;
  pointer-events: none;
}

@keyframes cp-spin { to { rotate: 1turn; } }

/* Shredder clones contain only the synchronized picture. Controls belong to
   the live player below the slot and must never be cut into paper. */
.po-shred-clone .cp-ui { display: none; }
.po-shred-clone .crispy-player { pointer-events: none; }

/* PNG/WebP/AVIF/GIF may carry real alpha. The generic placeholder colour
   would turn every transparent block into a grey sheet. Keep its native
   alpha and force a softly sampled composited texture when it is downscaled. */
.po-block-alpha {
  background: transparent;
}
.po-block-alpha img {
  image-rendering: auto;
  backface-visibility: hidden;
  transform: translateZ(0);
  filter: blur(0.3px);
}

/* A real alpha-contour shadow for transparent raster artwork in the
   shredder. The generic strip shadow handles rectangular photos; this one
   follows the PNG silhouette and is explicitly cast down and right. */
.po-shred-view .po-block-alpha img,
.po-shred-underlay .po-block-alpha img {
  filter:
    blur(0.3px)
    drop-shadow(0.6px 1px 0.3px rgb(28 20 31 / 16%));
}
.po-photo-shadow-layer {
  position: fixed;
  top: 0;
  z-index: 4;
  pointer-events: none;
  mix-blend-mode: overlay;
  /* One screen-space gradient for the whole column. open.js masks this
     layer to actual photo rectangles, so layout gaps remain untouched. */
  background-position: 0 0;
  background-repeat: no-repeat;
  background-size: 100vw 100vh;
  background-image: linear-gradient(
    to bottom,
    transparent calc(var(--po-photo-shadow-screen-y, -100px) - 1px),
    var(--po-contact-dark) var(--po-photo-shadow-screen-y, -100px),
    var(--po-contact-mid) calc(
      var(--po-photo-shadow-screen-y, -100px) + var(--po-photo-contact-stop-mid)
    ),
    var(--po-contact-soft) calc(
      var(--po-photo-shadow-screen-y, -100px) + var(--po-photo-contact-stop-soft)
    ),
    transparent calc(
      var(--po-photo-shadow-screen-y, -100px) + var(--po-photo-contact-stop-end)
    )
  );
}
.po-block-logo {
  background: none;
  border-radius: 0;
  display: grid;
  place-items: center;
}
.po-block-logo svg {
  width: 100%;
  height: 100%;
  display: block;
  shape-rendering: geometricPrecision;
}

.po-label {
  position: absolute;
  font-family: var(--sans);
  font-size: 18px;
  font-weight: 500;
  color: #aca8b0;
  white-space: nowrap;
  translate: 0 14px;
}

/* A caption that is an address. It keeps the caption's size and weight — it
   belongs to that row, not to the interface — and earns a quiet underline so
   it is recognisable as clickable without shouting over the work. */
a.po-label-link {
  color: #8d8892;
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-decoration-color: rgb(141 136 146 / 38%);
  text-underline-offset: 3px;
  cursor: pointer;
  transition:
    color 0.28s var(--ease-out),
    text-decoration-color 0.28s var(--ease-out);
}

@media (hover: hover) {
  a.po-label-link:hover {
    color: #5f5866;
    text-decoration-color: currentColor;
  }
}

a.po-label-link:focus-visible {
  outline: 2px solid #9d97a1;
  outline-offset: 3px;
  border-radius: 3px;
}

/* --- shredder (fixed, top) --- */

/* strips zone: one clone of the content column, scroll-synced by
   open.js and clipped by a per-strip staircase polygon (also set
   from open.js every frame) */
.po-shred-underlay,
.po-shred-shadow,
.po-shred-view {
  position: fixed;
  top: 0;
  overflow: hidden;
  pointer-events: none;
  transform-origin: 50% 100%;
  transform:
    perspective(var(--po-warp-perspective))
    rotateX(var(--po-warp-angle));
  transition: none;
  will-change: transform;
  backface-visibility: hidden;
}
.po-shred-underlay { z-index: 1; }
.po-shred-shadow {
  z-index: 2;
  clip-path: inset(100% 0 0 0);
  --po-strip-shadow-color: rgb(28 20 31 / 34%);
  --po-strip-shadow-width: 2px;
  --po-strip-shadow-blur: 0.65px;
  --po-strip-shadow-offset-x: 1px;
  --po-strip-shadow-offset-y: 2px;
}
.po-shred-shadow-shape {
  position: absolute;
  inset: 0;
  /* A separate right-edge shadow per strip. Unlike a shadow derived from
     the union mask, these thirteen edges can never merge or disappear. */
  background: linear-gradient(
    to right,
    transparent calc(100% - var(--po-strip-shadow-width)),
    var(--po-strip-shadow-color) 100%
  );
  background-size: var(--po-strip-width, 80px) 100%;
  background-repeat: repeat-x;
  transform: translate(
    var(--po-strip-shadow-offset-x),
    var(--po-strip-shadow-offset-y)
  );
  filter: blur(var(--po-strip-shadow-blur));
}
.po-shred-view {
  z-index: 3;
  /* SHREDDER TOP FADE TUNING
     Colour, direction and stops for the page-coloured fade painted over
     every strip. This is intentionally colour, not transparency masking. */
  --po-strip-fade-angle: to bottom;
  --po-strip-fade-solid: rgb(236 235 236 / 94%);
  --po-strip-fade-mid: rgb(229 227 230 / 56%);
  --po-strip-fade-clear: rgb(229 227 230 / 0%);
  /* Absolute Y coordinates inside the fixed shredder layer. They no longer
     scale with the individual strip height. */
  --po-strip-fade-stop-solid: 40px;
  --po-strip-fade-stop-mid: 80px;
  --po-strip-fade-stop-clear: 130px;
  /* no entrance of its own: this zone is above the slot and starts empty
     (nothing has been fed through yet). Strips appear here only once content
     is scrolled up past the slot — the staircase clip is set every frame by
     open.js. */
}

/* ------------------------------------------------------------
   3D fracture shredder

   One textured canvas is clipped by a deterministic Voronoi field in JS.
   The plate is intentionally quiet and uses the same pale plastic material
   as the 3D card; the disconnected, diminishing shards carry the effect.
   ------------------------------------------------------------ */
.po-voronoi-defs {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
  pointer-events: none;
}

.project-open--3d {
  --po-warp-angle: 0deg;
  --po-voronoi-scroll: 0px;
  /* 3D SHARD COLOUR / GRADIENT TUNING
     Transparent PNG pixels and layout gaps expose this paper colour instead
     of leaving only the dark shadow visible. */
  --po-voronoi-paper-fill: var(--page-bg);
  --po-voronoi-bevel-light: rgb(255 255 255 / 65%);
  --po-voronoi-bevel-dark: rgb(229 227 230 / 49%);
  --po-voronoi-bevel-angle: 135deg;
  --po-voronoi-fade-solid: var(--page-bg);
  --po-voronoi-fade-mid-a: rgb(229 227 230 / 94%);
  --po-voronoi-fade-mid-b: rgb(229 227 230 / 58%);
  --po-voronoi-fade-stop-a: 8px;
  --po-voronoi-fade-stop-b: 18px;
  --po-voronoi-fade-stop-c: 42px;
  --po-voronoi-fade-stop-clear: 72px;
}

.project-open--3d .po-shred-underlay,
.project-open--3d .po-shred-shadow {
  display: none;
}

.project-open--3d .po-shred-view {
  transform: translateY(var(--po-voronoi-scroll));
  background: var(--po-voronoi-paper-fill);
  will-change: transform, clip-path;
}

.po-voronoi-shadow,
.po-voronoi-highlight {
  width: 100%;
  height: 100%;
  overflow: hidden;
  pointer-events: none;
}

.po-voronoi-shadow {
  position: fixed;
  top: 0;
  z-index: 3;
  opacity: 0;
  transition: opacity 140ms var(--ease-out);
}

.project-open--3d.is-voronoi-shadow-ready .po-voronoi-shadow {
  opacity: 1;
}

.po-voronoi-highlight {
  position: absolute;
  inset: 0;
  z-index: 4;
  mix-blend-mode: screen;
}

.project-open--3d.is-retracting .po-voronoi-shadow {
  transform: translateY(var(--po-shred-return));
  transition: transform var(--po-suck-dur) var(--po-suck-ease);
}

.project-open--3d .po-shred-bevel {
  background: linear-gradient(
    var(--po-voronoi-bevel-angle),
    var(--po-voronoi-bevel-light) 0%,
    transparent 38%,
    var(--po-voronoi-bevel-dark) 100%
  );
  mix-blend-mode: overlay;
}

.project-open--3d .po-shred-fade {
  background: linear-gradient(
    to bottom,
    var(--po-voronoi-fade-solid) 0 var(--po-voronoi-fade-stop-a),
    var(--po-voronoi-fade-mid-a) var(--po-voronoi-fade-stop-b),
    var(--po-voronoi-fade-mid-b) var(--po-voronoi-fade-stop-c),
    transparent var(--po-voronoi-fade-stop-clear)
  );
}

.project-open--3d .po-shred-contact {
  --po-contact-dark: rgb(26 22 29 / 52%);
  --po-contact-mid: rgb(35 29 39 / 22%);
  --po-contact-soft: rgb(38 31 42 / 4%);
}
/* Phase two starts only after reverse scroll has reached zero, so the strip
   masks are already empty. The machine, its smoothing layers and the upper
   sheet can now travel into the folder as one physical stack. */
.project-open.is-retracting .po-shred-underlay,
.project-open.is-retracting .po-shred-shadow,
.project-open.is-retracting .po-shred-view {
  transform:
    translateY(var(--po-shred-return))
    perspective(var(--po-warp-perspective))
    rotateX(var(--po-warp-angle));
  transition: transform var(--po-suck-dur) var(--po-suck-ease);
}
.project-open--3d.is-retracting .po-shred-view {
  transform: translateY(calc(
    var(--po-shred-return) + var(--po-voronoi-scroll)
  ));
}
.project-open.is-retracting .po-photo-shadow-layer {
  transform: translateY(var(--po-shred-return));
  transition: transform var(--po-suck-dur) var(--po-suck-ease);
}
.po-shred-underlay .po-shred-clone {
  filter: saturate(0.98) contrast(0.99);
}
.po-shred-clone {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 0;
  will-change: transform;
  transform-style: preserve-3d;
  backface-visibility: hidden;
}

.po-shred-effects {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
}

.po-shred-fade {
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  /* One shared screen-space gradient; the parent's thirteen hard masks cut
     it into strips without resetting its coordinate system. */
  background: linear-gradient(
    var(--po-strip-fade-angle),
    var(--po-strip-fade-solid) var(--po-strip-fade-stop-solid),
    var(--po-strip-fade-mid) var(--po-strip-fade-stop-mid),
    var(--po-strip-fade-clear) var(--po-strip-fade-stop-clear)
  );
}
/* each strip fades to dark toward its far end — paper disappearing
   into the machine */
/* the slot bar the paper feeds through; ✕ closes the project */
/* One-pixel inset bevel on every cut: white catches the left edge, black
   shades the right edge. The strip mask keeps both lines inside the paper. */
.po-shred-bevel {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background: repeating-linear-gradient(
    to right,
    rgb(255 255 255 / 56%) 0 1px,
    transparent 1px calc(var(--po-strip-width, 80px) - 1px),
    rgb(28 20 31 / 30%) calc(var(--po-strip-width, 80px) - 1px)
      var(--po-strip-width, 80px)
  );
}

/* SHREDDER CONTACT SHADOW TUNING
   These are the direction, colours and exact stops of the gradient laid over
   BOTH paper layers. Keep the alpha in the colours; blend mode is overlay. */
.project-open {
  /* PAPER WARP TUNING
     Larger angle = stronger inward bend above the slot.
     Smaller perspective = stronger depth compression. */
  --po-warp-angle: 12deg;
  --po-warp-perspective: 520px;
  --po-contact-angle: to top;
  --po-contact-dark: rgb(22 20 23 / 80%);
  --po-contact-mid: rgb(27 24 28 / 36%);
  --po-contact-soft: rgb(28 20 31 / 5%);
  --po-contact-stop-dark: 0px;
  --po-contact-stop-mid: 10px;
  --po-contact-stop-soft: 22px;
  --po-contact-stop-end: 38px;
  /* Same material below the slot, stretched slightly farther over photos. */
  --po-photo-contact-stop-mid: 16px;
  --po-photo-contact-stop-soft: 34px;
  --po-photo-contact-stop-end: 58px;
}

/* Contact shadow from the shredder, laid over both representations of the
   paper. The alpha-aware parent keeps it on actual image pixels only. */
.po-shred-contact {
  position: absolute;
  inset: 0;
  z-index: 3;
  pointer-events: none;
  mix-blend-mode: overlay;
  background: linear-gradient(
    var(--po-contact-angle),
    var(--po-contact-dark) var(--po-contact-stop-dark),
    var(--po-contact-mid) var(--po-contact-stop-mid),
    var(--po-contact-soft) var(--po-contact-stop-soft),
    transparent var(--po-contact-stop-end)
  );
}

.po-shred-bar {
  position: fixed;
  /* The sliced paper sits above the machine and is clipped at the slot.
     The body itself stays opaque, so the intact page cannot leak through it. */
  z-index: 0;
  height: 40px;
  display: flex;
  align-items: center;
  pointer-events: auto;
  overflow: hidden;
  border-radius: 24px;
  box-shadow:
    0 2px 2px rgb(46 27 66 / 5%),
    0 6px 5px rgb(46 29 64 / 6%),
    0 12px 12px rgb(37 26 49 / 6%),
    inset 0 1px 1px rgb(255 255 255 / 72%),
    inset 0 -1px 0 rgb(255 255 255 / 68%);
  /* Starts exactly behind the clicked folder's upper edge and rises into
     its final fixed position on the same timing curve as the content. */
  transform-origin: var(--po-shred-origin-x) 50%;
  transform:
    translateY(var(--po-bar-from))
    scaleX(var(--po-shred-stow-scale));
  /* Kept on the base state as well as the motion states so Focus can fade
     both into and out of its material filter without a one-frame snap. */
  transition:
    filter 320ms cubic-bezier(0.22, 1, 0.36, 1);
}

.project-open--2d .po-shred-bar {
  border-radius: 14px;
}

.project-open--3d .po-shred-bar {
  z-index: 5;
  background: linear-gradient(
    var(--card-3d-paper-angle),
    var(--card-3d-paper-a),
    var(--card-3d-paper-b)
  );
  box-shadow:
    0 2px 2px rgb(46 27 66 / 5%),
    0 7px 8px rgb(46 29 64 / 7%),
    0 15px 18px rgb(37 26 49 / 7%),
    inset 0 1px 1px rgb(255 255 255 / 76%),
    inset 0 -1px 0 rgb(255 255 255 / 72%),
    inset 0 0 0 5px rgb(220 217 221 / 28%);
}

.project-open--3d .po-slot {
  visibility: hidden;
}
.project-open.is-emerging .po-shred-bar {
  transform: translateY(0) scaleX(1);
  transition:
    transform var(--po-emerge-dur) var(--po-emerge-ease),
    filter 320ms cubic-bezier(0.22, 1, 0.36, 1);
}
.project-open.is-retracting .po-shred-bar {
  transform:
    translateY(var(--po-bar-from))
    scaleX(var(--po-shred-stow-scale));
  transition:
    transform var(--po-suck-dur) var(--po-suck-ease),
    filter 320ms cubic-bezier(0.22, 1, 0.36, 1);
}

.po-slot {
  flex: 1;
  height: 4px;
  margin-left: 22px;
  border-radius: 2px;
  background: linear-gradient(
    180deg,
    rgb(64 56 68 / 34%),
    rgb(255 255 255 / 22%)
  );
  box-shadow:
    inset 0 1px 1px rgb(52 42 60 / 20%);
}

.po-close {
  border: none;
  /* One continuous paper surface: the close control must not read as a
     separate coloured tile inside the shredder body. */
  background: transparent;
  cursor: pointer;
  font-family: var(--sans);
  font-size: 15px;
  line-height: 1;
  color: #7d7683;
  align-self: stretch;
  display: grid;
  place-items: center;
  padding: 0 14px 0 12px;
  margin-left: 8px;
}
.po-close:hover { color: #3f3a45; }

/* --- docked (compressed) folder (fixed, bottom) --- */

/* geometry (left/top/width/height) is set inline by open.js: it
   starts at the clicked card's rect and transitions to the dock
   position — folder.js's ResizeObserver re-generates the paper
   outline every frame, so the folder genuinely compresses */
.po-dock {
  /* Absolute inside the fixed .project-open frame behaves like fixed
     positioning but, unlike position:fixed + z-index, does not trap the
     whole folder in one stacking context. This lets project media sit
     between the back gradient and front paper. */
  position: absolute;
  z-index: auto;
  pointer-events: auto;
  cursor: pointer;
  visibility: hidden;
  --po-shape-progress: 0;
  --po-paper-angle: var(--po-entry-front-skew, 0deg);
  --po-collage-angle: var(--po-entry-front-skew, 0deg);
  --po-current-height: var(--po-start-height, 0px);
  /* Final-position controls. These are intentionally collected here so the
     dock composition can be tuned without touching the animation code. */
  --po-title-final-x: -32px;
  --po-title-final-y: -8px;
  --po-year-final-top: 6px;
  --po-year-final-right: -58px;
  --po-hover-front-skew: -3deg;
  --po-hover-back-skew: 3deg;
  --po-end-pull-front-skew: var(--po-entry-front-skew, 0deg);
  --po-end-pull-back-skew: var(--po-entry-back-skew, 0deg);
  transition:
    left var(--po-dur) var(--po-ease),
    top var(--po-dur) var(--po-ease),
    width var(--po-dur) var(--po-ease),
    height var(--po-dur) var(--po-ease),
    --po-shape-progress var(--po-dur) var(--po-ease),
    --po-paper-angle var(--po-dur) var(--po-ease),
    --po-collage-angle var(--po-dur) var(--po-ease),
    --po-current-height var(--po-dur) var(--po-ease),
    --po-dock-top var(--po-dur) var(--po-ease);
  will-change: top, height;
}
.po-dock.is-live { visibility: visible; }

/* Re-anchor the light sweep for the docked folder. A grid card cancels the
   page scroll to stay screen-fixed; the dock instead moves by its own top
   edge, which --po-dock-top tracks (animated alongside `top` in the very
   same transition, so the two cannot drift). Feeding that in as --card-top,
   with the scroll term zeroed, reuses the card formula unchanged — so the
   sweep holds one screen position right through the flight down and back,
   and the handover between card and dock is invisible.
   Costs nothing per frame: it rides a variable that already animates. */
.po-dock {
  --scroll-y: 0px;
  --card-top: var(--po-dock-top);
}

/* The sweep must sit in the SAME box as the paper it lights. During the morph
   the paper is drawn in a fixed --po-collage-h box pinned to the folder's
   bottom edge, while card-front shrinks under it — so an inset:0 sweep would
   part company with the silhouette the moment the height starts animating,
   showing up as a hard mis-clipped edge right at the start of the flight.
   Mirror .folder-shape's geometry and its preserved hover pose exactly. */
.po-dock .card-glare {
  top: auto;
  bottom: 0;
  height: var(--po-collage-h);
  transform-origin: center bottom;
  transform: skewX(var(--po-entry-front-skew, 0deg))
    scaleY(var(--po-entry-front-scale, 1));
  /* ...and it fades out as the folder docks, instead of cutting off the
     moment the card is handed over. Fading is fine here precisely because
     it's only a highlight — nothing about the paper's motion is faked.
     Reverses on close, so the sweep comes back up as the folder returns. */
  transition: opacity var(--po-dur) var(--po-ease);
}
.po-dock.is-docked .card-glare { opacity: 0; }
.po-dock.is-docked {
  --po-shape-progress: 1;
  --po-paper-angle: 25.64deg;
  --po-collage-angle: 15deg;
  --po-current-height: var(--po-final-height, 0px);
}

/* 3D cases remain rigid throughout the whole project transition: their SVG
   points compress into the dock, but neither sheet is sheared. */
.po-dock--3d {
  --po-hover-front-skew: 0deg;
  --po-hover-back-skew: 0deg;
  --po-end-pull-front-skew: 0deg;
  --po-end-pull-back-skew: 0deg;
  --po-3d-layer-dur: var(--po-dur);
  --po-3d-layer-ease: var(--po-ease);

  /* Final dock composition for the rigid 3D card. Keep these independent
     from the paper-folder coordinates above: the badge and the plastic rim
     give this version a different visual centre. */
  --po-title-final-x: -60px;
  --po-title-final-y: -2px;
  --po-year-final-top: 9px;
  --po-year-final-right: -38px;
}

.po-dock--3d.is-docked {
  --po-paper-angle: 0deg;
  --po-collage-angle: 0deg;
}

.po-dock--3d.is-ready {
  --po-3d-layer-dur: 0.42s;
  --po-3d-layer-ease: var(--ease-out);
}

.po-dock .card-year {
  top: var(--po-year-start-top, 11px);
  left: var(--po-year-start-left, auto);
  right: auto;
  /* Deliberately untransformed. --po-year-start-left/top are measured from
     the card's TRANSFORMED rect, so the hover shear is already baked into
     the start position; re-applying it as a transform double-counts and
     throws the year ~21px sideways. What's left is the glyphs sitting
     upright instead of sheared — about 1px on the text's box, and the
     element's position is exact. */
  transform: none;
  will-change: left, top;
  transition:
    left var(--po-dur) var(--po-ease),
    top var(--po-dur) var(--po-ease);
}

.po-dock.is-docked .card-year {
  top: var(--po-year-final-top);
  left: calc(
    100% - var(--po-year-width, 0px) - var(--po-year-final-right)
  );
  transform: none;
}

/* Opening starts at the captured hover position, but closing ends at the
   source card's untransformed layout coordinates. This makes the clone and
   real year occupy the exact same pixels at the cleanup hand-off. */
.po-dock.is-closing:not(.is-docked) .card-year {
  top: var(--po-year-return-top, 11px);
  left: var(--po-year-return-left, auto);
}

.po-dock .card { position: absolute; inset: 0; cursor: pointer; }
.po-dock .card-front {
  position: absolute;
  inset: 0;
  z-index: 4;
}
.po-dock .card-front,
.po-dock .card-back {
  transform: none !important;
  will-change: auto;
  backface-visibility: visible;
}

.po-dock .card-front {
  transition: filter 320ms cubic-bezier(0.22, 1, 0.36, 1);
}

.po-dock .card-back { transition: none; }

/* Preserve the exact horizontal hover pose during the grid -> dock handoff.
   open.js captures these values before :hover is lost, then animates both
   rigid sheets to zero while the SVG points morph. */
.po-dock.po-dock--3d .card-front {
  transform: translateX(var(--po-entry-front-x, 0px)) !important;
}

.po-dock.po-dock--3d .card-back {
  transform: translateX(var(--po-entry-back-x, 0px)) !important;
}

.po-dock.po-dock--3d .year-badge-shape {
  transform: translateX(var(--po-entry-badge-x, 0px));
}

.po-dock.po-dock--3d.is-live .card-front,
.po-dock.po-dock--3d.is-live .card-back,
.po-dock.po-dock--3d.is-live .year-badge-shape {
  transition: transform var(--po-3d-layer-dur) var(--po-3d-layer-ease);
}

.po-dock.po-dock--3d.is-live .card-front {
  transition:
    transform var(--po-3d-layer-dur) var(--po-3d-layer-ease),
    filter 320ms cubic-bezier(0.22, 1, 0.36, 1);
}

/* Do not replace the year's positional transitions when adding the rigid
   3D layer translation. Losing left/top here was the source of its snap. */
.po-dock.po-dock--3d.is-live .card-year {
  transition:
    left var(--po-dur) var(--po-ease),
    top var(--po-dur) var(--po-ease);
}

/* The layer timing follows the transform onto the year's children. */
.po-dock.po-dock--3d.is-live .card-year-text,
.po-dock.po-dock--3d.is-live .card-year::before,
.po-dock.po-dock--3d.is-live .card-year::after {
  transition: transform var(--po-3d-layer-dur) var(--po-3d-layer-ease);
}

/* Global project-open stack:
     1 back gradient, 2 project media, 4 front paper, 5 year/tags. */
.po-dock .back-shape { z-index: 1; }
.po-dock .year-badge-shape { z-index: 5; }
/* Do not put z-index on the wrapper: that would isolate its ::before and
   turn the overlay shadow back into an ordinary dark shadow. The two visual
   layers participate directly in the dock's stacking context instead. */
.po-dock .card-year { z-index: auto; }
.po-dock--3d .card-year { z-index: auto; }
.po-dock .card-year::before,
.po-dock .card-year::after { z-index: 6; }
.po-dock .card-year-text {
  position: relative;
  z-index: 7;
  /* On a grid card this counter-scales card-back's scaleY. The dock forces
     card-back to transform:none, so leaving the counter in place stretches
     the year by ~1.3% the moment the card is adopted. */
  transform: none;
}

/* The Illustrator morph uses a fixed 500x250 coordinate canvas. The canvas
   keeps the source card's physical height and stays anchored to the shared
   bottom edge; the path itself moves into its final lower strip. */
.po-dock .folder-shape,
.po-dock .back-shape,
.po-dock .year-badge-shape {
  top: auto;
  bottom: 0;
  height: var(--po-collage-h);
  transform-origin: center bottom;
  /* This transform is the preserved hover pose, not part of the morph.
     It must already be fully applied before the invisible clone is shown;
     transitioning it from 0deg would make the folder snap shut on click. */
  transition: none;
}

.po-dock .folder-shape {
  transform: skewX(var(--po-entry-front-skew, 0deg))
    scaleY(var(--po-entry-front-scale, 1));
}

/* During the project morph, two extra shadows are cast UPWARD directly by
   the white paper silhouette. Transparent copies in the start state keep
   the filter list interpolable, so opening and closing never snap. */
.po-dock .card .folder-shape {
  filter:
    drop-shadow(0 2px 2px rgba(46, 27, 66, 0.051))
    drop-shadow(0 6px 5px rgba(46, 29, 64, 0.059))
    drop-shadow(0 12px 12px rgba(37, 26, 49, 0.059))
    drop-shadow(0 -3px 4px rgb(63 49 62 / 0%))
    drop-shadow(0 -16px 22px rgb(78 64 77 / 0%));
}

.po-dock.is-docked .card .folder-shape {
  filter:
    drop-shadow(0 1px 1px rgb(46 27 66 / 10%))
    drop-shadow(0 12px 4px rgba(46, 29, 64, 5%))
    drop-shadow(0 12px 8px rgb(37 26 49 / 3%))
    drop-shadow(-8px -2px 6px rgb(214 210 216 / 50%))
    drop-shadow(-6px -8px 16px rgb(246 246 246 / 70%));
}

.po-dock .back-shape {
  transform: skewX(var(--po-entry-back-skew, 0deg))
    scaleY(var(--po-entry-back-scale, 1));
  /* The ordinary card has a light upper-left bounce. Keep an identical,
     transparent filter in the expanded state so closing can interpolate it
     instead of making the source card's shadow pop in during cleanup. */
  filter: drop-shadow(-6px -6px 8px rgb(248 247 248 / 0%));
}

.po-dock .year-badge-shape {
  transform: none;
}

/* Once the clone is visible, transform and shadow changes may animate.
   Keeping transform out of the pre-live transition is what preserves the
   source hover pose during the initial hand-off. */
.po-dock.is-live .folder-shape {
  transition:
    transform 0.42s var(--ease-out),
    filter 0.42s var(--ease-out);
}

.po-dock.is-live .back-shape {
  transition:
    transform 0.42s var(--ease-out),
    filter 0.42s var(--ease-out);
}

/* A small closing gesture on the fully docked folder: both layers move
   toward zero skew instead of opening farther. */
@media (hover: hover) {
  /* The docked 3D case closes laterally on hover: the white face travels
     right, while every coloured component travels left by the same amount. */
  .po-dock.po-dock--3d.is-ready:hover:not(.is-end-pulling):not(.is-end-releasing) .card-front {
    transform: translateX(var(--po-3d-hover-x)) !important;
  }

  .po-dock.po-dock--3d.is-ready:hover:not(.is-end-pulling):not(.is-end-releasing) .card-back,
  .po-dock.po-dock--3d.is-ready:hover:not(.is-end-pulling):not(.is-end-releasing) .year-badge-shape {
    transform: translateX(calc(-1 * var(--po-3d-hover-x))) !important;
  }

  /* The year rides on its CHILDREN. A transform on .card-year itself turns it
     into a stacking context, which drops the glyphs and their blended shadow
     pseudos behind card-front — the year vanishes for the whole hover. That is
     the trap its own declaration warns about, and the grid card avoids it the
     same way (see .card--2d:hover .card-year-text). */
  .po-dock.po-dock--3d.is-ready:hover:not(.is-end-pulling):not(.is-end-releasing) .card-year-text,
  .po-dock.po-dock--3d.is-ready:hover:not(.is-end-pulling):not(.is-end-releasing) .card-year::before,
  .po-dock.po-dock--3d.is-ready:hover:not(.is-end-pulling):not(.is-end-releasing) .card-year::after {
    transform: translateX(calc(-1 * var(--po-3d-hover-x))) !important;
  }

  /* Tags sit outside card-front, so they do not inherit the white layer's
     X movement. Move them by the same amount as the title/front sheet. */
  .po-dock.po-dock--3d.is-ready:hover:not(.is-end-pulling):not(.is-end-releasing) .po-dock-tags {
    translate: var(--po-3d-hover-x) 0;
  }

  .po-dock.is-ready:hover .folder-shape {
    transform: skewX(var(--po-hover-front-skew))
      scaleY(var(--po-entry-front-scale, 1));
    filter:
      drop-shadow(0 2px 2px rgb(46 27 66 / 12%))
      drop-shadow(0 14px 6px rgb(46 29 64 / 8%))
      drop-shadow(0 18px 12px rgb(37 26 49 / 7%))
      drop-shadow(-9px -3px 7px rgb(214 210 216 / 58%))
      drop-shadow(-8px -11px 20px rgb(246 246 246 / 78%));
  }

  .po-dock.is-ready:hover .back-shape {
    transform: skewX(var(--po-hover-back-skew))
      scaleY(var(--po-entry-back-scale, 1));
  }

  /* Pull follows the exact hover path, only progressively. During delayed
     release JS walks the same path backward. */
  .po-dock.is-ready.is-end-pulling .folder-shape,
  .po-dock.is-ready.is-end-releasing .folder-shape {
    transform: skewX(var(--po-end-pull-front-skew))
      scaleY(var(--po-entry-front-scale, 1));
  }
  .po-dock.is-ready.is-end-pulling .back-shape,
  .po-dock.is-ready.is-end-releasing .back-shape {
    transform: skewX(var(--po-end-pull-back-skew))
      scaleY(var(--po-entry-back-scale, 1));
  }

  /* While the wheel is moving, the paper follows it instead of chasing
     every new target with the full hover transition. Release is driven by
     the 420ms JS easing, so it needs no second CSS interpolation either. */
  .po-dock.is-ready.is-end-pulling .folder-shape,
  .po-dock.is-ready.is-end-pulling .back-shape {
    transition-duration: 90ms, 0.42s;
    transition-timing-function: linear, var(--ease-out);
  }
  .po-dock.is-ready.is-end-releasing .folder-shape,
  .po-dock.is-ready.is-end-releasing .back-shape {
    transition-duration: 0ms, 0.42s;
  }

  /* A rigid 3D case closes laterally instead of shearing. The white front
     travels right while the coloured rear sheet, badge and year travel the
     same distance left. JS writes both X values from the scroll progress. */
  .po-dock--3d.is-ready.is-end-pulling .card-front,
  .po-dock--3d.is-ready.is-end-releasing .card-front {
    transform: translateX(var(--po-end-pull-front-x, 0px)) !important;
  }
  .po-dock--3d.is-ready.is-end-pulling .po-dock-tags,
  .po-dock--3d.is-ready.is-end-releasing .po-dock-tags {
    translate: var(--po-end-pull-front-x, 0px) 0;
  }
  .po-dock--3d.is-ready.is-end-pulling .card-back,
  .po-dock--3d.is-ready.is-end-releasing .card-back,
  .po-dock--3d.is-ready.is-end-pulling .year-badge-shape,
  .po-dock--3d.is-ready.is-end-releasing .year-badge-shape {
    transform: translateX(var(--po-end-pull-back-x, 0px)) !important;
  }

  /* Children again, same stacking-context trap as the hover rule above. */
  .po-dock--3d.is-ready.is-end-pulling .card-year-text,
  .po-dock--3d.is-ready.is-end-releasing .card-year-text,
  .po-dock--3d.is-ready.is-end-pulling .card-year::before,
  .po-dock--3d.is-ready.is-end-releasing .card-year::before,
  .po-dock--3d.is-ready.is-end-pulling .card-year::after,
  .po-dock--3d.is-ready.is-end-releasing .card-year::after {
    transform: translateX(var(--po-end-pull-back-x, 0px)) !important;
  }
  .po-dock--3d.is-ready.is-end-pulling .folder-shape,
  .po-dock--3d.is-ready.is-end-releasing .folder-shape,
  .po-dock--3d.is-ready.is-end-pulling .back-shape,
  .po-dock--3d.is-ready.is-end-releasing .back-shape {
    transform: none;
  }
  .po-dock--3d.is-ready.is-end-pulling .card-front,
  .po-dock--3d.is-ready.is-end-pulling .card-back,
  .po-dock--3d.is-ready.is-end-pulling .year-badge-shape {
    transition-duration: 90ms;
    transition-timing-function: linear;
  }
  .po-dock--3d.is-ready.is-end-releasing .card-front,
  .po-dock--3d.is-ready.is-end-releasing .card-back,
  .po-dock--3d.is-ready.is-end-releasing .year-badge-shape {
    transition-duration: 0ms;
  }
  /* The year keeps its own left/top timing; the transform timing follows the
     children that now carry it. */
  .po-dock--3d.is-ready.is-end-pulling .card-year,
  .po-dock--3d.is-ready.is-end-releasing .card-year {
    transition:
      left var(--po-dur) var(--po-ease),
      top var(--po-dur) var(--po-ease);
  }
  .po-dock--3d.is-ready.is-end-pulling .card-year-text,
  .po-dock--3d.is-ready.is-end-pulling .card-year::before,
  .po-dock--3d.is-ready.is-end-pulling .card-year::after {
    transition: transform 90ms linear;
  }
  .po-dock--3d.is-ready.is-end-releasing .card-year-text,
  .po-dock--3d.is-ready.is-end-releasing .card-year::before,
  .po-dock--3d.is-ready.is-end-releasing .card-year::after {
    transition: transform 0ms linear;
  }
}

/* Closing has its own full-length transform hand-off: the compressed folder
   can begin in the preserved hover pose, but it must arrive at the source
   card already neutral instead of switching pose during clone cleanup. */
.po-dock.is-closing .folder-shape,
.po-dock.is-closing .back-shape {
  transition:
    transform var(--po-dur) var(--po-ease),
    filter var(--po-dur) var(--po-ease);
}

/* Finish with the exact shadow used by .card-unit .back-shape. The clone and
   real source card therefore have matching pixels at the final hand-off. */
.po-dock.is-closing:not(.is-docked) .back-shape {
  filter: drop-shadow(-6px -6px 8px rgb(248 247 248 / 60%));
}

/* This viewport follows the changing folder dimensions and clips anything
   outside the paper. The collage inside deliberately keeps the ORIGINAL
   card height, so percentage-positioned windows never crawl upward merely
   because their containing block is shrinking. */
.po-collage-clip {
  position: absolute;
  left: 0;
  right: 0;
  top: auto;
  bottom: 0;
  height: var(--po-collage-h);
  z-index: 1;
  overflow: hidden;
  /* Image windows may collapse toward the card centre, but they can never
     enter the title's reserved strip. This is a hard geometric clip, not a
     fade, and open.js derives the inset from the live title box. */
  clip-path: inset(var(--po-title-safe, 55px) 0 0 0);
  transform-origin: bottom center;
  /* scaleY is the vertical half of the preserved hover pose. card-front is
     forced to transform:none here, so anything it used to stretch must
     restate it or the contents drop by (cardH - y) * (scale - 1) the instant
     the card is adopted — a few px at the top, tapering to zero at the
     bottom edge. Same order as .folder-shape so the two stay identical. */
  transform: skewX(var(--po-collage-angle))
    scaleY(var(--po-entry-front-scale, 1));
  pointer-events: none;
}

/* The collage keeps its original coordinate system throughout the morph.
   The windows close geometrically below; raster media is never scaled. */
.po-dock .collage {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: var(--po-collage-h);
  aspect-ratio: auto;
  transform: none;
}

/* Hard block geometry: top and height animate in pixels toward the block's
   own centre. No transform is applied to the cutout itself, so its position
   cannot drift as its size changes. */
.po-dock .preview {
  --po-slot-y: var(--po-slot-top);
  --po-slot-h: var(--po-slot-height);
  clip-path: none;
  top: var(--po-slot-y) !important;
  height: var(--po-slot-h) !important;
  transform: none;
  border-radius: 16px;
  /* The clone is inserted before its pixel geometry is assigned. Keep that
     preparation state transitionless; otherwise --po-slot-h starts at the
     registered 0px value and is still opening when is-docked immediately
     asks it to close again, so the opening animation appears to vanish. */
  transition: none;
  will-change: top, height;
}

/* is-live is added only after open.js has frozen the source layout into
   --po-slot-top/--po-slot-height. From this point both directions use the
   same real start/end geometry. */
.po-dock.is-live .preview {
  transition:
    --po-slot-y var(--po-dur) var(--po-ease),
    --po-slot-h var(--po-dur) var(--po-ease),
    border-radius var(--po-dur) var(--po-ease);
}

/* Media keeps its original pixel height. Centering that rigid media inside
   the changing-height window exactly compensates the window's geometry:
   the paper cutout closes, while the image behind it stays in place. */
.po-dock .preview-media {
  position: absolute;
  left: 0;
  top: 50%;
  margin-top: var(--po-media-offset);
  width: 100%;
  height: var(--po-slot-height);
  /* The counter-skew pivot must follow both the shrinking card and the
     moving hard window. Keeping it at the old card bottom introduces an
     increasing horizontal offset as the angle grows. */
  transform-origin: 50% calc(
    var(--po-collage-h)
    - var(--po-slot-y)
    - var(--po-slot-h) / 2
    - var(--po-media-offset)
  );
  /* Exact inverse of the clip's skewX(a) scaleY(s) — reversed order, both
     terms — so the photo stays put while the paper window flexes, matching
     what .preview-media does on a grid card. */
  translate: var(--po-entry-media-x, 0px) 0;
  transform: scaleY(calc(1 / var(--po-entry-front-scale, 1)))
    skewX(calc(-1 * var(--po-collage-angle)));
  transition: filter 0.55s var(--ease-out);
}

/* is-sharpen-reset deliberately suppresses the grid hover transition, but
   the adopted 3D card still needs its captured +X media compensation to
   converge with the front sheet. Restore only that dock transition. */
.po-dock--3d .card.is-sharpen-reset .preview-media {
  transition:
    translate var(--po-3d-layer-dur) var(--po-3d-layer-ease),
    filter 0.55s var(--ease-out) !important;
}

.po-dock.is-docked .preview {
  --po-slot-y: var(--po-slot-mid);
  --po-slot-h: 0px;
  border-radius: 4px;
}

/* Remove the final zero-height antialiased hairline without a fade. */
.po-dock.is-ready .preview {
  visibility: hidden;
}

/* Emboss is positioned by the original percentage layout directly inside
   card-front, so it compresses/skews with the paper. Opacity is added on top
   of that physical transform. */
.po-dock-emboss-frame,
.po-dock-surface-frame {
  opacity: 1;
  transition: opacity var(--po-dur) var(--po-ease);
  will-change: opacity;
}
.po-dock-emboss-frame { z-index: 2; }
.po-dock-emboss-frame,
.po-dock-surface-frame {
  transform-origin: bottom center;
  /* Same restated hover stretch as .po-collage-clip — these were lifted out
     of the collage but are still printed on the paper. The transform lives on
     the FRAME, whose bottom edge is the card's: on the emboss's own box the
     pivot sits ~32px higher and skew would shift it sideways on adoption. */
  transform: skewX(var(--po-paper-angle))
    scaleY(var(--po-entry-front-scale, 1));
}

.po-dock.is-docked .po-dock-emboss-frame,
.po-dock.is-docked .po-dock-surface-frame {
  opacity: 0;
}

.po-dock-emboss-frame,
.po-dock-surface-frame {
  position: absolute;
  left: 0;
  right: 0;
  top: auto;
  bottom: 0;
  height: var(--po-collage-h);
  z-index: 1;
  pointer-events: none;
  overflow: visible;
}

/* Emboss uses the same full-card compression layer as the printed overlay.
   Keeping scale on this wrapper preserves the folder-bottom pivot and avoids
   multiplying the visible skew on the logo's much smaller own box. */
.po-dock-emboss-scale {
  position: absolute;
  inset: 0;
  transform-origin: bottom center;
  scale: 1 1;
  transition: scale var(--po-dur) var(--po-ease);
  will-change: scale;
}

.po-dock.is-docked .po-dock-emboss-scale {
  scale: 1 var(--po-card-scale);
}

/* Keep skew on the rigid outer frame, exactly like the emboss. Vertical
   compression belongs to the raster inside, so scale and skew cannot
   compound into a visually stronger shear. */
.po-dock-surface {
  position: absolute;
  inset: calc(-1 * var(--card-overlay-bleed));
  width: calc(100% + var(--card-overlay-bleed) + var(--card-overlay-bleed));
  height: calc(100% + var(--card-overlay-bleed) + var(--card-overlay-bleed));
  object-fit: fill;
  transform: none;
  transform-origin: bottom center;
  scale: 1 1;
  transition: scale var(--po-dur) var(--po-ease);
  will-change: scale;
}

.po-dock.is-docked .po-dock-surface {
  scale: 1 var(--po-card-scale);
}

/* The source title inherits the hovered front cover's transform. Recreate
   that exact pose around the same card-level pivot, then smoothly return the
   type to an upright final state while the SVG itself morphs. */
.po-dock .card-title {
  z-index: 3;
  transform-origin:
    var(--po-title-origin-x, center)
    var(--po-title-origin-y, bottom);
  transform:
    translate(0px, 0px)
    skewX(var(--po-entry-front-skew, 0deg))
    scaleY(var(--po-entry-front-scale, 1));
  transition: transform var(--po-dur) var(--po-ease);
}

.po-dock.is-docked .card-title {
  transform:
    translate(
      calc(var(--po-title-lock-x, 0px) + var(--po-title-final-x)),
      var(--po-title-final-y)
    )
    skewX(0deg)
    scaleY(1);
}

/* While the invisible clone is being prepared, its copied hover pose must
   land immediately. Transitions begin only after the hand-off is complete. */
.po-dock:not(.is-live) .card-title,
.po-dock:not(.is-live) .card-year,
.po-dock:not(.is-live) .card-year-text {
  transition: none;
}

/* tags sit just under the folder and simply ride up with it as it docks —
   no fade */
.po-dock-tags {
  position: absolute;
  top: 100%;
  left: 6px;
  margin: 16px 0 0;
  z-index: 5;
  transform: translateY(0);
  transition:
    left var(--po-dur) var(--po-ease),
    margin-top var(--po-dur) var(--po-ease),
    transform var(--po-dur) var(--po-ease);
}

.po-dock.is-docked .po-dock-tags {
  left: 30px;
  margin-top: 0;
  transform: translateY(calc(-100% - 10px));
}

.po-dock--3d.is-docked .po-dock-tags {
  left: -32px;
  transform: translateY(calc(-100% - 18px));
  translate: 0 0;
  transition:
    left var(--po-dur) var(--po-ease),
    margin-top var(--po-dur) var(--po-ease),
    transform var(--po-dur) var(--po-ease),
    translate 0.42s var(--ease-out);
}

/* ------------------------------------------------------------
   Responsive
   ------------------------------------------------------------ */

@media (max-width: 900px) {
  :root {
    --card-overlay-bleed: 11px;
    --po-last-media-gap: 14px;
  }
  .site-header {
    padding: clamp(56px, 8vw, 76px) 24px 36px;
    gap: 24px;
  }
  .logo svg { width: clamp(240px, 34vw, 310px); }
  .filters { gap: 10px; }
  .header-tools { gap: 14px; }
  .social { gap: 10px; }
  .social-link { width: 24px; height: 24px; }
  .social-link svg { width: 19px; height: 19px; }
  .mode-switch {
    --mode-wide: 110px;
    --mode-icon: 57px;
    --mode-height: 32px;
  }
  .mode-side-label { font-size: 16px; }
  .mode-side-label--3d { left: 4px; }
  .mode-side-label--2d { right: 4px; }
  .filter-btn { font-size: 16px; }
  .btn-fill { padding: 1px 12px; line-height: 26px; }
  .btn-metallic .btn-fill { font-size: 17px; }
  .mode-lottie svg { width: 48px !important; height: 48px !important; }
  .cards { --cards-gap: 52px; padding: 0 24px 24px; }
  .card-title {
    top: clamp(11px, 1.8vw, 16px);
    left: clamp(15px, 2.5vw, 22px);
    font-size: clamp(17px, 2.5vw, 23px);
  }
  .card-year {
    top: clamp(6px, 1vw, 9px);
    right: clamp(14px, 2.5vw, 23px);
    font-size: clamp(14px, 1.95vw, 18px);
  }
  .card--3d > .card-year {
    top: clamp(5px, 0.9vw, 8px);
    right: clamp(14px, 2.4vw, 22px);
  }
  .preview { border-radius: clamp(9px, 1.7vw, 16px); }
  .card-tags { margin-top: 14px; font-size: clamp(14px, 2vw, 18px); }
  .card-tags .sep { padding: 0 clamp(4px, 0.8vw, 8px); }
  .po-pad { padding-left: 24px; padding-right: 24px; }
  .po-label { font-size: clamp(15px, 2.2vw, 18px); }
  .po-block { border-radius: clamp(5px, 0.8vw, 7px); }
  .site-footer { padding: 52px 24px 28px; }
  /* the collage keeps its layout.svg proportions and simply scales
     down with the card — no per-breakpoint overrides needed */
}

@media (max-width: 640px) {
  :root {
    --card-overlay-bleed: 8px;
    --po-dock-bottom: max(8px, env(safe-area-inset-bottom));
    --po-last-media-gap: 10px;
  }

  .site-header {
    flex-direction: column;
    align-items: flex-start;
    padding: 38px 16px 28px;
    gap: 20px;
  }
  .logo svg { width: clamp(216px, 61vw, 240px); }
  .filters { gap: 9px; }
  /* the header stacks here, so the cluster keeps icons and filters on one row */
  .header-tools { gap: 12px; }
  .social { gap: 9px; }
  .social-link { width: 22px; height: 22px; }
  .social-link svg { width: 18px; height: 18px; }
  .mode-switch {
    --mode-wide: 104px;
    --mode-icon: 53px;
    --mode-height: 31px;
  }
  .mode-side-label { font-size: 15px; }
  .filter-btn { font-size: 15px; }
  .btn-fill { padding: 1px 10px; line-height: 25px; }
  .btn-metallic .btn-fill { font-size: 16px; }
  .mode-lottie svg { width: 44px !important; height: 44px !important; }

  .cards { --cards-gap: 40px; padding: 0 16px 20px; }

  /* Phone cards are tap targets, not hover canvases. Removing the sweep and
     any sticky hover composition also avoids retaining large GPU textures
     after a tap. */
  .card-glare { display: none !important; }
  .card-unit > .card--2d:hover .card-front,
  .card-unit > .card--2d:hover .card-back,
  .card-unit > .card--3d:hover .card-front,
  .card-unit > .card--3d:hover .card-back,
  .card-unit > .card--3d:hover > .year-badge-shape,
  .card-unit > .card--3d:hover > .card-year {
    transform: none !important;
  }
  .card-unit > .card--3d:hover .preview-media {
    translate: 0 0 !important;
  }
  /* Keep the original material depth even though phone hover motion is off.
     These are the same three front shadows and the same rear-sheet light
     bounce used by the desktop cards, frozen at their resting state. */
  .card-unit .folder-shape {
    filter:
      drop-shadow(0 1px 2px rgb(52 20 86 / 10%))
      drop-shadow(0 6px 5px rgb(92 52 84 / 4%))
      drop-shadow(0 12px 12px rgb(37 26 49 / 6%)) !important;
  }
  .card-unit .back-shape {
    filter: drop-shadow(-6px -6px 8px rgb(248 247 248 / 60%)) !important;
  }
  .card-title {
    top: clamp(6px, 1.8vw, 8px);
    left: clamp(9px, 2.5vw, 11px);
    font-size: clamp(10px, 2.7vw, 12px);
    line-height: 1.05;
  }
  .card-year {
    top: clamp(3px, 1vw, 4px);
    right: clamp(8px, 2.3vw, 10px);
    font-size: clamp(9px, 2.5vw, 11px);
    line-height: 1.05;
  }
  .card--3d > .card-year {
    top: clamp(3px, 0.9vw, 4px);
    right: clamp(8px, 2.2vw, 10px);
  }
  .preview { border-radius: clamp(6px, 2.1vw, 10px); }
  .card-tags {
    margin: 8px 0 0 3px;
    font-size: clamp(10.5px, 2.9vw, 12px);
    line-height: 1.2;
  }
  .card-tags .sep { padding: 0 clamp(3px, 1vw, 5px); }

  .po-pad { padding-left: 16px; padding-right: 16px; }
  .po-label {
    font-size: clamp(10px, 2.7vw, 11px);
    line-height: 1;
    translate: 0 4px;
  }
  .po-block { border-radius: 4px; }
  .po-dock {
    --po-title-final-x: -10px;
    --po-title-final-y: -1px;
    --po-year-final-top: 3px;
    --po-year-final-right: 8px;
  }
  .po-dock--3d {
    --po-title-final-x: -8px;
    --po-title-final-y: -1px;
    --po-year-final-top: 3px;
    --po-year-final-right: 8px;
  }
  .po-dock .card-title {
    font-size: clamp(11px, 3vw, 12px);
    line-height: 1.05;
  }
  .po-dock .card-year {
    font-size: clamp(9px, 2.5vw, 10px);
    line-height: 1.05;
  }
  .po-dock-tags {
    margin-top: 7px;
    font-size: clamp(8.5px, 2.35vw, 10px);
    line-height: 1;
    white-space: nowrap;
  }
  .po-dock.is-docked .po-dock-tags,
  .po-dock--3d.is-docked .po-dock-tags {
    left: 10px;
    margin-top: 0;
    transform: translateY(calc(-100% - 5px));
  }

  /* Mobile-lite project view: only the real media column passes behind a
     higher machine bar. No duplicate paper, strip masks, Voronoi polygons,
     bevels or per-frame shadow masks are painted. */
  .project-open.is-mobile-lite .po-shred-underlay,
  .project-open.is-mobile-lite .po-shred-shadow,
  .project-open.is-mobile-lite .po-shred-view,
  .project-open.is-mobile-lite .po-voronoi-shadow,
  .project-open.is-mobile-lite .po-voronoi-highlight,
  .project-open.is-mobile-lite .po-photo-shadow-layer {
    display: none !important;
  }
  .project-open.is-mobile-lite .po-shred-bar {
    box-shadow:
      0 2px 2px rgb(46 27 66 / 4%),
      0 7px 10px rgb(37 26 49 / 7%),
      inset 0 1px 1px rgb(255 255 255 / 66%);
  }
  .po-close {
    min-width: 42px;
    padding: 0 10px 0 8px;
    margin-left: 4px;
  }
  .po-slot { margin-left: 14px; }
  .cp-centre-play { width: 50px; height: 50px; }
  .site-footer {
    padding: 42px 16px max(22px, env(safe-area-inset-bottom));
    font-size: 15px;
  }
}

@media (max-width: 360px) {
  .site-header { padding-top: 30px; }
  .logo svg { width: 210px; }
  .cards { padding-inline: 12px; }
  .po-pad { padding-inline: 12px; }
  .card-tags { font-size: 11.5px; }
  .card-tags .sep { padding-inline: 2px; }
}

@media (max-height: 560px) and (orientation: landscape) {
  :root { --po-dock-bottom: 6px; }
  .site-header { padding-top: 28px; }
  .po-label { font-size: 13px; }
}

/* ------------------------------------------------------------
   Reduced motion
   ------------------------------------------------------------ */

@media (prefers-reduced-motion: reduce) {
  * {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
  }
}
