/* The portfolio: framed project media scattered over the cloth, each opening
   a description card tethered to its frame by a drawn line.

   Everything resolves from the site tokens, so the page follows the theme
   sandbox in dev along with the rest of the site. Layout comes from
   portfolio.yaml: each frame carries --x/--y/--w inline, percentages of the
   canvas. Under the breakpoint the positions are ignored and the frames
   stack (see the media query at the bottom). */

/* Nothing on this page ever scrolls — the root included, or a pan can
   flash the browser's scrollbars. */
html:has(> .portfolio-body) { overflow: hidden; }
.portfolio-body { overflow: hidden; }

.portfolio-shell .back-link {
  position: fixed;
  top: 46px;
  z-index: 5;
  transition: opacity .3s cubic-bezier(.2, 0, 0, 1);
}
/* While a project is open the way out is the card's close (or Escape, or a
   click on the scene); the back arrow would only crowd the focus. */
.project-open .back-link { opacity: 0; pointer-events: none; }

/* The shell is the window, the canvas the plane behind it: panning moves the
   plane by transform (portfolio.js), the page itself never scrolls.
   `touch-action: none` hands the whole gesture to the pointer events, so a
   finger pans instead of fighting the browser for a scroll that goes
   nowhere. */
.portfolio-shell {
  position: fixed;
  inset: 0;
  overflow: hidden;
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
  cursor: grab;
}
.portfolio-shell.is-panning { cursor: grabbing; }

.portfolio-canvas {
  position: absolute;
  left: 0;
  top: 0;
  width: var(--canvas-width, 180vw);
  height: var(--canvas-height, 170vh);
  will-change: transform;
}

/* Story text pinned to the plane by --x/--y (percent of the canvas), panning
   with it. Not interactive, so a drag over the words still moves the plane; the
   scatter keeps the frames clear of it (portfolio.js). */
.canvas-block {
  position: absolute;
  left: var(--x, 0);
  top: var(--y, 0);
  width: var(--w, 26%);
  margin: 0;
  color: var(--color-text);
  font-family: var(--font-display, inherit);
  white-space: pre-line;
  text-wrap: pretty;
  text-shadow: 0 1px 16px rgba(0, 0, 0, .22);
  pointer-events: none;
  transition:
    opacity .45s cubic-bezier(.2, 0, 0, 1),
    filter .45s cubic-bezier(.2, 0, 0, 1);
}
.canvas-block--lead {
  font-size: clamp(1.5rem, 2.2vw, 2.5rem);
  line-height: 1.18;
  font-weight: 700;
  letter-spacing: -.01em;
}
.canvas-block--note {
  font-size: clamp(1rem, 1.3vw, 1.28rem);
  line-height: 1.5;
  opacity: .82;
}
/* An optional heading inside a block: the big greeting sits above the body copy
   in the same block, so "Hey, I'm Victor" and the intro read as one thing. */
.canvas-block-heading {
  display: block;
  margin-bottom: .35em;
  font-size: clamp(1.6rem, 2.3vw, 2.6rem);
  font-weight: 700;
  line-height: 1.15;
  letter-spacing: -.01em;
  opacity: 1;
}
/* Recede with the rest of the field while a card is open. */
.project-open .canvas-block { opacity: .22; filter: blur(3px); }

/* A frame is the media itself, softly rounded, sitting flat on the cloth.
   The script scatters the frames over the plane (a yaml `x`/`y` arrives as
   --x/--y and pins one); until a frame is placed it hides, then arrives on
   its own beat. */
.project-frame {
  position: absolute;
  left: var(--x, 0);
  top: var(--y, 0);
  width: var(--w, 18%);
  margin: 0;
  border-radius: 6px;
  cursor: pointer;
  /* The zoom shares the pan tween's clock and curve (--zoom-ms and
     --zoom-ease, set per move), so plane and frame move as one camera —
     the long quintic glide on open, a quick soft release on close. */
  transition:
    transform var(--zoom-ms, .55s) var(--zoom-ease, cubic-bezier(.83, 0, .17, 1)),
    filter .45s cubic-bezier(.2, 0, 0, 1),
    opacity .45s cubic-bezier(.2, 0, 0, 1);

  img, video {
    display: block;
    width: 100%;
    height: auto;
    border-radius: 6px;
    /* Counter-scaled while zoomed (below) on the same clock as the zoom,
       so the visible rounding never changes size. */
    transition: border-radius var(--zoom-ms, .55s) var(--zoom-ease, cubic-bezier(.83, 0, .17, 1));
  }

  &:focus-visible { outline: 2px solid var(--color-marker); outline-offset: 4px; }
}
/* The name is plain bold text tucked behind the frame's lower edge (z-index
   under the cover, so the part over the image stays hidden), and on hover — or
   keyboard focus — it slides down to sit just beneath the card, left-aligned to
   its edge, as if it had been hiding under it. Hidden while a card is open: the
   field is dimmed then and nothing there is meant to be hovered. */
.frame-name {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  z-index: -1;
  margin: 0;
  padding: .5em .1em 0 2px;
  color: var(--color-text);
  opacity: 0;
  transform: translateY(-100%);
  transition:
    transform .28s cubic-bezier(.2, 0, 0, 1),
    opacity .28s cubic-bezier(.2, 0, 0, 1);
  pointer-events: none;
}
.frame-name-title {
  display: block;
  font-size: 1.05rem;
  font-weight: 700;
  letter-spacing: .005em;
  line-height: 1.2;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* The optional one-liner: smaller, lighter, and free to wrap to a second line
   under the name. */
.frame-name-line {
  display: block;
  margin-top: .12em;
  font-size: .82rem;
  font-weight: 400;
  line-height: 1.3;
  opacity: .82;
}
.project-frame:hover .frame-name,
.project-frame:focus-visible .frame-name { transform: translateY(0); opacity: 1; }
/* While a card is open the plate stays hidden even on the active/focused frame
   (a tap focuses it, which would otherwise reveal the plate under the card). The
   hover/focus selectors are repeated under .project-open so they outrank the
   reveal above. */
.project-open .frame-name,
.project-open .project-frame:hover .frame-name,
.project-open .project-frame:focus-visible .frame-name { transform: translateY(-100%); opacity: 0; }
/* The chosen frame grows about its center while the plane glides it into
   position (portfolio.js sets --zoom), riding above its neighbours. */
.project-frame.is-active {
  z-index: 2;
  transform: scale(var(--zoom, 1));

  img, video, iframe { border-radius: calc(6px / var(--zoom, 1)); }
}

/* A YouTube pick has no size of its own, so the frame takes a fixed 16:9 box
   and the embed fills it. */
.project-frame.is-embedded { aspect-ratio: 16 / 9; }
.project-frame.is-embedded iframe {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  border: 0;
  border-radius: 6px;
}

/* A gallery pick with a different aspect morphs the frame instead of
   snapping it: the box glides to the newcomer's size (portfolio.js sets
   width/left/height and MORPH_MS matches these clocks) while both shots
   cover the moving box, the newcomer fading in over the old. When the
   glide settles the box goes back to sizing itself from its media. */
.project-frame.is-morphing {
  transition:
    width .32s cubic-bezier(.2, 0, 0, 1),
    height .32s cubic-bezier(.2, 0, 0, 1),
    left .32s cubic-bezier(.2, 0, 0, 1),
    top .32s cubic-bezier(.2, 0, 0, 1);
}
.project-frame .is-morphing-media {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: opacity .32s cubic-bezier(.2, 0, 0, 1);
}
.project-frame .is-entering { opacity: 0; }

.project-frame:not(.is-placed) { opacity: 0; pointer-events: none; }
.project-frame.is-placed {
  animation: project-frame-in .5s cubic-bezier(0, 0, .58, 1) backwards;
  animation-delay: var(--arrive, 0ms);
}
@keyframes project-frame-in { from { opacity: 0; } to { opacity: 1; } }

/* While a card is open everything behind the chosen frame falls out of
   focus: the other frames soften into the mockup's blobs, and the cloth
   itself — grid, life, effects — blurs with them. Only the active frame,
   the card and its tether stay sharp. */
.project-open .project-frame:not(.is-active) {
  /* Dimmed, not brightened: the out-of-focus field recedes into shadow so
     the active frame and the description read against it — a light image
     brightened under blur becomes a white fog that swallows the text. */
  filter: blur(16px) brightness(.75);
  opacity: .8;
  pointer-events: none;
}
.portfolio-body .stitch-bg { transition: filter .45s cubic-bezier(.2, 0, 0, 1); }
.project-open .stitch-bg { filter: blur(10px) brightness(.85); }

/* The open description lives in a fixed layer, positioned by the script
   from the frame's on-screen box (panning is held while open, so the box
   holds still). */
.project-layer {
  position: fixed;
  inset: 0;
  z-index: 4;
}

/* No box, no border, no wash: the description simply lies on the blurred
   scene beside the frame, held together by its own typography. The soft
   shadow keeps the text readable wherever the blur lands bright. */
.project-card {
  position: absolute;
  box-sizing: border-box;
  width: min(400px, 38vw);
  max-height: calc(100vh - 48px);
  overflow-y: auto;
  padding: 26px 30px 28px;
  color: var(--color-text);
  text-shadow: 0 1px 18px color-mix(in srgb, var(--color-background) 65%, transparent);
  animation: project-card-in .35s cubic-bezier(0, 0, .58, 1) .1s both;
  /* Rides along when a morphing frame re-shapes the room (the first
     placement sets left/top from nothing, which does not interpolate). */
  transition: left .32s cubic-bezier(.2, 0, 0, 1), top .32s cubic-bezier(.2, 0, 0, 1);
}
.project-layer[data-closing] .project-card {
  animation: project-card-in .16s cubic-bezier(.42, 0, 1, 1) reverse forwards;
}
@keyframes project-card-in { from { opacity: 0; } to { opacity: 1; } }

.project-card-header {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 10px;
}
.project-card-title {
  margin: 0;
  font-size: 1.3rem;
  font-weight: 600;
  line-height: 1.25;
}
.project-card-dates {
  font-size: .8rem;
  opacity: .7;
  white-space: nowrap;
}
.project-card-description {
  margin: 0;
  font-size: 1.1rem;
  line-height: 1.6;
}
/* Space between stacked paragraphs of a longer description. */
.project-card-description + .project-card-description { margin-top: .8em; }
/* The gallery: the project's other shots as small frames laid just under
   the focused frame (placeStrip), the hero leading. The strip cycles on
   its own until a click hands the visitor control; a pick swaps into the
   frame itself — the frame is the viewer. One overflowing row scrolls
   sideways rather than wrapping down into the card's room. */
.project-gallery {
  position: fixed;
  z-index: 4;
  display: flex;
  gap: 10px;
  max-width: calc(100vw - 48px);
  overflow-x: auto;
  scrollbar-width: none;
  /* The scroll box clips at its edges, so the current thumb's outline
     (2px, offset 2px) needs this much air on every side. */
  padding: 4px;
  animation: project-card-in .35s cubic-bezier(0, 0, .58, 1) .1s both;
  /* Same ride-along as the card when the frame morphs. */
  transition: left .32s cubic-bezier(.2, 0, 0, 1), top .32s cubic-bezier(.2, 0, 0, 1);
}
.project-layer[data-closing] .project-gallery {
  animation: project-card-in .16s cubic-bezier(.42, 0, 1, 1) reverse forwards;
}
.project-gallery-thumb {
  flex: none;
  width: 64px;
  height: 46px;
  padding: 0;
  border: 0;
  border-radius: 4px;
  overflow: hidden;
  background: none;
  cursor: pointer;
  opacity: .7;
  transition: opacity .16s cubic-bezier(.2, 0, 0, 1);

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

  &:hover, &:focus-visible { opacity: 1; }
  &:focus-visible { outline: 2px solid var(--color-marker); outline-offset: 2px; }
}
.project-gallery-thumb.is-current {
  opacity: 1;
  outline: 2px solid var(--color-marker);
  outline-offset: 2px;
}
/* A YouTube thumb wears a play triangle so it reads as a clip, not a still. */
.project-gallery-thumb.is-youtube { position: relative; }
.project-gallery-thumb.is-youtube::after {
  content: "";
  position: absolute;
  inset: 0;
  margin: auto;
  width: 0;
  height: 0;
  border-style: solid;
  border-width: 5px 0 5px 9px;
  border-color: transparent transparent transparent #fff;
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .55));
  pointer-events: none;
}

/* The site's clickable underline: the wobble mask drawn in marker under the
   label, boiling at the flipbook cadence on hover — same recipe as the
   homepage contact links (site.css owns the mask and the keyframes). */
.project-card-visit {
  display: inline-flex;
  align-items: baseline;
  gap: 8px;
  margin-top: 16px;
  font-size: .9rem;
  font-weight: 500;
  color: var(--color-text);
  text-decoration: none;
}
.visit-label { position: relative; }
.visit-label::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: -5px;
  height: 6px;
  background-color: var(--color-marker);
  mask-image: var(--rule-mask);
  mask-repeat: repeat-x;
  mask-size: 240px 6px;
  mask-position: 0 50%;
}
.project-card-visit:hover .visit-label::after,
.project-card-visit:focus-visible .visit-label::after {
  animation: contact-squiggle .375s steps(3) infinite;
}
.project-card-close {
  align-self: start;
  flex: none;
  display: inline-flex;
  margin: 2px -8px 0 0;
  padding: 4px;
  border: 0;
  background: none;
  color: inherit;
  opacity: .6;
  cursor: pointer;
  transition: opacity .16s cubic-bezier(.2, 0, 0, 1);

  &:hover, &:focus-visible { opacity: 1; }
}

/* The migration note, ruled and set under the back arrow: the same flat
   modernist voice as the rest of the page, fading out of the way while a
   project is open like the back arrow does. */
.portfolio-note {
  position: fixed;
  left: 3.5vw;
  top: 96px;
  z-index: 5;
  width: min(280px, 44vw);
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding-top: 10px;
  border-top: 2px solid var(--color-text);
  transition: opacity .3s cubic-bezier(.2, 0, 0, 1);
}
.portfolio-note-kicker {
  font-size: .7rem;
  letter-spacing: .12em;
  text-transform: uppercase;
  font-weight: 700;
  opacity: .8;
}
.portfolio-note-body { margin: 0; font-size: .82rem; line-height: 1.5; opacity: .85; }
.portfolio-note-body a { color: var(--color-text); text-underline-offset: 3px; }
.project-open .portfolio-note { opacity: 0; pointer-events: none; }

/* The filter shelf, bottom-left: a readout, the All / Live / Archived
   segmented control, and one chip per tag. Chips fill with the marker when
   active, the same yellow that draws the site's underlines. */
.portfolio-filters {
  position: fixed;
  left: 3.5vw;
  bottom: 30px;
  right: 110px;
  z-index: 5;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 10px;
  transition: opacity .3s cubic-bezier(.2, 0, 0, 1);
}
.project-open .portfolio-filters { opacity: 0; pointer-events: none; }
/* The section nav recedes with the rest of the chrome while a project is open,
   so a card can't collide with it. */
.site-nav { transition: opacity .3s cubic-bezier(.2, 0, 0, 1); }
.project-open .site-nav { opacity: 0; pointer-events: none; }
.portfolio-readout {
  display: flex;
  gap: 20px;
  align-items: baseline;
  font-size: .7rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  font-weight: 600;
  opacity: .9;
}
.portfolio-clear {
  border: 0;
  background: none;
  padding: 0;
  color: var(--color-text);
  font: inherit;
  letter-spacing: inherit;
  text-transform: inherit;
  cursor: pointer;
  text-decoration: underline;
  text-underline-offset: 3px;
}
.portfolio-chips { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.portfolio-status {
  display: inline-flex;
  margin-right: 10px;
  border: 1px solid color-mix(in srgb, var(--color-text) 55%, transparent);
}
/* With no archived work the control gets no buttons; without this its border
   and margin would show as a stray box before the tags. */
.portfolio-status:empty { display: none; }
.portfolio-status button,
.portfolio-tag {
  font: inherit;
  font-size: .72rem;
  letter-spacing: .05em;
  text-transform: uppercase;
  font-weight: 600;
  color: var(--color-text);
  background: none;
  cursor: pointer;
}
.portfolio-status button { border: 0; padding: 7px 13px; }
.portfolio-status button + button {
  border-left: 1px solid color-mix(in srgb, var(--color-text) 55%, transparent);
}
.portfolio-tag {
  display: inline-flex;
  gap: 7px;
  align-items: baseline;
  border: 1px solid color-mix(in srgb, var(--color-text) 55%, transparent);
  padding: 7px 11px;
}
.portfolio-tag-count { opacity: .55; font-weight: 400; }
.portfolio-tag.is-empty { opacity: .45; }
.portfolio-status button:hover:not(.is-active),
.portfolio-tag:hover:not(.is-active) {
  background: color-mix(in srgb, var(--color-text) 12%, transparent);
}
.portfolio-status button.is-active,
.portfolio-tag.is-active {
  background: var(--color-marker);
  color: var(--color-background);
}
.portfolio-tag.is-active .portfolio-tag-count { opacity: .7; }
.portfolio-status button:focus-visible,
.portfolio-tag:focus-visible,
.portfolio-clear:focus-visible { outline: 2px solid var(--color-marker); outline-offset: 2px; }

/* Key hints while a project is open: a muted legend centered at the foot of
   the screen, naming the arrows and Escape. Desktop only (the reveal lives in
   the min-width query below) — a phone has no keyboard and stacks the card
   over this spot anyway. */
.portfolio-keyhints {
  position: fixed;
  left: 50%;
  bottom: calc(26px + env(safe-area-inset-bottom, 0px));
  transform: translateX(-50%);
  z-index: 5;
  display: flex;
  gap: 28px;
  align-items: baseline;
  color: var(--color-text);
  opacity: 0;
  pointer-events: none;
  font-size: .72rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  font-weight: 700;
  white-space: nowrap;
  transition: opacity .3s cubic-bezier(.2, 0, 0, 1);
}
.keyhint { display: inline-flex; align-items: baseline; gap: 8px; }
.keyhint-keys { font-weight: 600; letter-spacing: .04em; opacity: .8; }
/* The media line only makes sense when the open project has a gallery to
   step; without one the arrows browse projects, so it hides itself. */
.portfolio-keyhints .keyhint-media { display: none; }
body.project-has-media .portfolio-keyhints .keyhint-media { display: inline-flex; }
/* Desktop only, and only with a project open — the base opacity 0 keeps it off
   on phones and while panning the field. */
@media (min-width: 701px) {
  .project-open .portfolio-keyhints { opacity: .55; }
}

/* Filtered-out frames leave the plane entirely (no ghost, no gap); the rest
   glide to their new spots when the field re-flows. */
.project-frame.is-hidden { display: none; }
.project-frame.is-reflowing {
  transition:
    left .6s cubic-bezier(.2, .8, .2, 1),
    top .6s cubic-bezier(.2, .8, .2, 1),
    width .6s cubic-bezier(.2, .8, .2, 1),
    filter .45s cubic-bezier(.2, 0, 0, 1),
    opacity .45s cubic-bezier(.2, 0, 0, 1);
}

/* An NDA project keeps a project's footprint but shows a hatched outline
   card with only the facts that can travel: descriptor, client type, role,
   year. No image, so it takes a fixed shape instead of an image's aspect. */
.project-frame.is-nda {
  aspect-ratio: 4 / 3;
  border-radius: 0;
  cursor: pointer;
}
.nda-card {
  position: absolute;
  inset: 0;
  border: 2px solid var(--color-text);
  background-image: repeating-linear-gradient(135deg,
    color-mix(in srgb, var(--color-text) 22%, transparent) 0 1px,
    transparent 1px 10px);
}
.nda-panel {
  position: absolute;
  inset: 12px;
  padding: 13px 15px;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  gap: 12px;
  background: var(--color-background);
}
.nda-card-top {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  font-size: .66rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  font-weight: 700;
}
.nda-mark { display: inline-flex; align-items: center; gap: 6px; }
.nda-year { font-weight: 400; opacity: .75; }
.nda-card-body { display: flex; flex-direction: column; gap: 5px; }
.nda-descriptor {
  font-family: var(--font-display, inherit);
  font-size: 1.1rem;
  line-height: 1.15;
  font-weight: 600;
  text-wrap: pretty;
}
.nda-meta { font-size: .74rem; opacity: .8; line-height: 1.4; }
.nda-ask {
  margin-top: 4px;
  font-size: .66rem;
  letter-spacing: .06em;
  text-transform: uppercase;
  font-weight: 600;
  text-decoration: underline;
  text-underline-offset: 3px;
}

/* NDA, hatch treatment: the real media stays visible but wears a diagonal
   hatch and a small badge, so the work reads as under wraps without hiding
   what kind of thing it is. The hatch sits above the media (z-index) so a
   gallery pick swapped into the frame stays veiled too. */
/* An even scrim over the whole frame, so it reads as deliberately redacted
   regardless of what is behind it. The scrim sits above the media (z-index)
   so a gallery pick swapped in stays veiled too. */
.nda-hatch {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  border-radius: 6px;
  background-color: rgba(12, 16, 46, .32);
}
.project-frame.is-active .nda-hatch { border-radius: calc(6px / var(--zoom, 1)); }
.nda-badge {
  position: absolute;
  top: 8px;
  left: 8px;
  z-index: 2;
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 3px 7px;
  background: var(--color-background);
  color: var(--color-text);
  font-size: .58rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  font-weight: 700;
  pointer-events: none;
}
/* In the detail view the "Under NDA" mark moves up next to the card title
   (built by portfolio.js), so the frame's own badge steps aside there. */
.project-open .nda-badge { display: none; }
/* The card title and its NDA chip share a row. */
.project-card-titlerow {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 10px;
}
.project-card-nda {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 3px 8px;
  border: 1px solid color-mix(in srgb, var(--color-text) 40%, transparent);
  border-radius: 3px;
  font-size: .62rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  font-weight: 700;
  opacity: .8;
  white-space: nowrap;
}
/* The gallery of a hatched project carries the same veil on its thumbs, so the
   screens stay redacted everywhere they show. */
.project-gallery.is-nda-hatch .project-gallery-thumb { position: relative; overflow: hidden; }
.project-gallery.is-nda-hatch .project-gallery-thumb::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background-color: rgba(12, 16, 46, .32);
}

@media (prefers-reduced-motion: reduce) {
  .project-frame, .project-frame.is-morphing, .project-frame.is-reflowing,
  .project-frame .is-morphing-media,
  .project-card, .project-gallery { transition: none; }
  .project-card, .project-gallery { animation: none; }
  .project-card-visit:hover .visit-label::after,
  .project-card-visit:focus-visible .visit-label::after { animation: none; }
}

/* Small screens pan the same plane and dim the same way — per element,
   never with a scrim over the whole view, which would blur the chosen
   frame along with the rest. The description changes shape: it stacks
   right under the frame and its strip (placeStacked sets top and
   max-height), a long text scrolling inside the room that remains. */
@media (max-width: 700px) {
  .project-card {
    width: min(420px, calc(100vw - 32px));
    padding: 14px 16px 16px;
  }
  .project-gallery { max-width: calc(100vw - 32px); }
  .project-gallery-thumb { width: 52px; height: 38px; }

  /* The shelf keeps a wider berth on a phone; chips pack tighter and the
     status control may drop onto its own line rather than shove the tags. */
  /* The filter shelf is hidden on a phone: the chips wrap into a block that
     swallows the small screen, and the plane reads fine without them there. */
  .portfolio-filters { display: none; }
  /* With the shelf gone, the migration note drops to the bottom-left, out of
     the way of the back arrow up top. */
  .portfolio-note {
    top: auto;
    bottom: 20px;
    width: min(280px, 68vw);
  }

  /* On a phone the canvas badge is just the lock; the words would crowd a
     small frame. The full "Under NDA" still reads next to the title in the
     detail view. */
  .nda-badge-label { display: none; }
  .nda-badge { padding: 4px; }

  /* The per-block percent widths are tuned for the desktop plane and read too
     narrow on a phone, so the blocks widen to the viewport here. The 22px left
     inset keeps the copy off the viewport border when a block opens near it. */
  .canvas-block {
    width: 68vw;
    padding-left: 22px;
    box-sizing: border-box;
  }
  /* The greeting is pinned at 30% across the plane. On the narrow mobile plane
     that leaves too thin a strip on its left for a frame to fit, so the scatter
     only ever fills above, right, and below it. Centering the block across the
     plane gives an equal column on each side, so frames flank it. */
  .canvas-block:has(.canvas-block-heading) { left: 36%; }

  /* A roomier plane on a phone. Frames are sized to the plane WIDTH, so a wider
     plane both enlarges the previews and opens up the columns on either side of
     the (fixed-width) greeting, so the scatter fills the sides instead of only
     stacking frames above and below it. A little extra height keeps the gaps
     clear. */
  .portfolio-canvas { width: 300vw; height: 250vh; }
}
