/* ==========================================================================
   The site's only stylesheet. Pairs with index.html + js/app.js.

   Each slide centres its image in the viewport. Fixed chrome (identity
   top-left, WORK top-right, caption bottom-left, pagination bottom-right)
   sits on top, in the same place on every slide, and is cheap to repaint
   (fixed position, no per-slide layout work) — kept deliberately thin so
   scrolling stays smooth.

   The `simple-` class prefix is a leftover from when this was one of three
   parallel design directions; it's just the namespace now.
   ========================================================================== */

:root {
  --bg: #fff;
  --grid-bg: #c4d1d4;
  --fg: #111;
  --image-radius: 12px;
  --grid-image-radius: 8px;

  --grid-columns: 6;
  --grid-columns-mobile: 2;
  --font-mono: "DM Mono", ui-monospace, monospace;
  --slide-pad: clamp(1rem, 4vw, 3rem);
  --sliver-w: clamp(0.75rem, 2.5vw, 2rem);

  /* The box every artwork is scaled to fit. Every plate — centered or
     peeking at an edge — is the same kind of element under this same cap,
     so a neighbour is already at the exact size it will have once it
     becomes the centre image. Nothing to keep in sync by hand. */
  --img-max-w: calc(100vw - 4 * var(--slide-pad));
  --img-max-h: calc(100vh - 2 * var(--slide-pad));

  /* Per-piece size cap, from `maxWidth:`/`maxHeight:` in pieces.md —
     js/app.js sets these on the individual slide, so they only ever
     override for that piece. Both defaults are deliberately huge rather
     than `none`: min() picks whichever is smaller, so an unset piece just
     falls through to the viewport-fit limit above, and a piece that asks
     for more than the screen still can't have it. */
  --piece-max-w: 100000px;
  --piece-max-h: 100000px;
  --plate-move: 380ms cubic-bezier(0.4, 0, 0.2, 1);

  /* The camera pull-back. Long and heavily eased-out so it decelerates
     into place like a real lens settling, rather than stopping dead. */
  --camera: 620ms cubic-bezier(0.22, 1, 0.36, 1);

  /* Where the grid's content is allowed to start, clear of ALLEN / Y LAU.
     Used TWICE and must match: as the grid's own top padding, and as the
     scroll container's scroll-padding-top. The padding alone isn't enough —
     opening the grid centres your piece's tile, and without the matching
     scroll-padding the browser centres against the full viewport and pulls
     the first row straight back up under the identity, cancelling it out. */
  --grid-top: calc(var(--slide-pad) + 5rem);
}

@supports (height: 100svh) {
  :root { --img-max-h: calc(100svh - 2 * var(--slide-pad)); }
}

* { box-sizing: border-box; }

html { scroll-behavior: auto; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--fg);
  font-family: var(--font-mono);
  font-weight: 300;
  font-size: clamp(0.75rem, 1vw, 0.95rem);
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

/* Chrome (identity/nav/caption/pagination) is fixed over whatever piece is
   active, so a dark `background:` (pieces.md, see scripts/build-projects.js)
   needs its text flipped light to stay readable. Overriding --fg here
   rather than adding color rules to every chrome selector works because
   everything already reads var(--fg) for its color — the underline
   (.simple-link::after's background) included — so one variable flip
   cascades to all of it for free, same single-source-of-truth --fg
   already was.

   :not(.is-gridding):not(.is-about) matters: is-dark-bg is set from the
   ACTIVE PIECE (js/app.js), but the grid and About have their own
   backgrounds (--grid-bg, --bg) unrelated to any one piece — without this
   guard, leaving a dark-background piece for the grid or About would carry
   its white-text override somewhere it doesn't belong. */
body.is-dark-bg:not(.is-gridding):not(.is-about) {
  --fg: #fff;
}

a { color: inherit; }

/* --- Slides ----------------------------------------------------------- */
.simple-gallery {
  height: 100vh;
  height: 100svh;
  overflow-y: scroll;
  scroll-snap-type: y mandatory;
  scrollbar-width: none;
}
.simple-gallery::-webkit-scrollbar { display: none; }

.simple-slide {
  height: 100vh;
  height: 100svh;
  scroll-snap-align: start;
  scroll-snap-stop: always;
  position: relative;
  overflow: hidden;
  /* --piece-bg comes from `background:` in pieces.md (js/app.js sets it as
     an inline custom property, per slide) — var()'s fallback IS the
     "default to white" behavior, so an unset piece needs no special-casing
     anywhere: it just falls through to --bg. */
  background: var(--piece-bg, var(--bg));
  /* Clicking the space around the artwork zooms out to the grid — the
     cursor is the only advertisement that invisible surface gets. The
     centred plate resets to default below (clicking IT does nothing). */
  cursor: zoom-out;
}

/* --- Plates ---------------------------------------------------------------
   Every image of a piece is a real plate in its own slide — there is no
   separate preview/duplicate element anywhere. Which one is centred and
   which peek at the screen edges is purely a matter of WHICH TRANSFORM each
   plate currently carries (see applySlots in js/app.js); PREV/NEXT just
   moves the classes along, and the real neighbour travels into the centre.

   All plates are absolutely centred at the same origin and share one size
   cap, so the only thing separating them is translateX — which means
   animating between slots is compositor-only (no layout, no paint) and the
   neighbour is already at its final size before it arrives.

   .simple-plate is a one-cell CSS Grid, not a bare <img> — .simple-plate__img
   and an optional .simple-plate__texture both sit in that SAME cell
   (grid-area: 1/1 below), so the texture's box always matches the image's
   rendered size with no JS measuring: the img's own intrinsic width/height
   (set by js/app.js from the build script's header read) are what the grid
   sizes the cell to. The wrapper keeps everything this section was already
   responsible for — position, the slot transform/opacity, border-radius +
   overflow (clips img and texture together, so rounded corners crop the
   grain too) — so the camera-transform code in the GRID VIEW section below
   still measures/positions this same element exactly as before.

   The size cap lives on the IMG, not the wrapper — measured, not assumed:
   a max-height expressed as a PERCENTAGE on a grid item whose track is
   itself auto-sized resolves as "none" per spec (percentages against an
   indefinite size are ignored), so max-height:100% here silently failed to
   constrain anything and the image rendered taller than the wrapper's own
   (correctly capped) box — overflow:hidden then quietly cropped the
   bottom of the artwork. Exactly the bug this codebase's own architecture
   notes already warn about for a different reason (bare-% max-height on an
   auto-height parent) — same root cause, reached via CSS Grid instead of
   block flow this time. The real absolute-unit caps directly on the img
   are what the grid's auto-track then sizes itself to, and the wrapper's
   own max-width/max-height became redundant once the img carries the real
   values, so they're gone from here. */
.simple-plate {
  position: absolute;
  top: 50%;
  left: 50%;
  border-radius: var(--image-radius);
  overflow: hidden;
  transform: translate(-50%, -50%);
  opacity: 0;
  pointer-events: none;
  transition: transform var(--plate-move), opacity var(--plate-move);
  touch-action: pan-y;
}

.simple-plate__img {
  display: block;
  width: auto;
  height: auto;
  max-width: min(var(--img-max-w), var(--piece-max-w));
  max-height: min(var(--img-max-h), var(--piece-max-h));
  -webkit-user-drag: none;
  user-select: none;

  /* Invisible until decoded, no transition — the instant setupLazyReveal
     (js/app.js) adds is-loaded, it just appears in one frame. No
     placeholder tint, no blur: this is deliberately the SAME thing an
     ordinary <img> with no JS involved at all would look like, just
     without the blank hole a still-pending lazy image would otherwise
     leave. The grid camera code (openGrid/closeGrid) still gates the
     flight on this class, so a still-loading tile/plate can never be
     shown mid-decode — that part of the earlier flicker fix has nothing
     to do with what the reveal looks like, only when it happens. */
  opacity: 0;
  /* Only the zoom feature (below) ever sets transform on this element —
     opacity above is deliberately untransitioned (see the comment), this
     is a separate property so that stays true. */
  transition: transform 200ms ease-out;
}
.simple-plate__img.is-loaded {
  opacity: 1;
}

/* Dims the space around the zoomed plate — see index.html for why this is a
   real fixed element rather than a pseudo-element, and why z-index:1 (above
   default-stacked slide backgrounds/off-screen plates, below the current
   plate's z-index:2 and the chrome's z-index:50). Opacity via
   background-color's alpha, not a separate opacity property, so it never
   needs pointer-events juggling beyond the permanent none below. */
.simple-zoom-scrim {
  position: fixed;
  inset: 0;
  z-index: 1;
  background: rgba(0, 0, 0, 0);
  pointer-events: none;
  transition: background-color 200ms ease-out;
}
body.is-zooming .simple-zoom-scrim {
  background: rgba(0, 0, 0, 0.65);
}

/* Experimental push-in zoom (js/app.js openZoom/closeZoom) — scales the img
   toward its real pixel size; .simple-plate's own overflow:hidden clips it
   to the plate's resting (unscaled, since transform doesn't affect layout
   size) box, which is what makes this read as looking through a fixed
   porthole rather than the plate itself growing. transform-origin is
   updated continuously from the pointer position (js/app.js trackZoomPan)
   to pan; only the scale transition (above) eases, panning itself is
   instant so it tracks the cursor directly. */
.simple-plate__img.is-zoomed {
  transform: scale(var(--zoom-scale, 1));
}

/* Ink-grain overlay, screen-blended over the artwork only (not the whole
   page — the old version of this lived fixed over the entire viewport).
   Tiled at a fixed px size rather than stretched, so the grain reads at a
   consistent scale whether a piece is drawn small or fills the screen.

   position:absolute against .simple-plate (already position:absolute, so
   it's already a valid containing block — no extra rule needed), NOT
   inset:0 — inset:0.5px is deliberate. .simple-plate__img is an intrinsic-
   ratio replaced element, capped by max-width/max-height; .simple-plate
   shrink-wraps to it in normal block flow. Those are still two SEPARATE
   layout computations even without CSS Grid in between (an earlier version
   of this used a grid-area:1/1 stack, which had the same issue) — measured
   a persistent ~0.016px disagreement between them, exactly 1/64px, which
   is Chromium's LayoutUnit rounding granularity: two nominally-identical
   boxes computed via different code paths can each independently round to
   the nearest 1/64px and land on different values. No CSS sizing trick
   makes that agree exactly. inset:0.5px is a deliberate, comfortably-
   oversized safety margin (>30x the actual measured gap) so the texture is
   always very slightly SMALLER than its container instead of trying to
   match it exactly — eliminates the overhang that showed as a hairline of
   unblended grain along the artwork's edge (worst on light/white images,
   where that stray sliver against a plain background actually shows) —
   invisible at 0.5px on a blended, textural effect like this one. js/app.js
   only renders this element at all when a piece hasn't set `texture: off`
   in pieces.md — see CONTENT.md. */
.simple-plate__texture {
  position: absolute;
  inset: 0.5px;
  pointer-events: none;
  background-image: url("../assets/ink-fade.jpg");
  background-repeat: repeat;
  background-size: 240px 240px;
  mix-blend-mode: screen;
  /* Zoom (js/app.js openZoom/trackZoomPan) applies the SAME scale +
     transform-origin here as it does to .simple-plate__img — same
     --zoom-scale variable, same is-zoomed class, set together on both
     elements every time (see zoomTargets) — so the grain stays registered
     to the artwork as it scales and pans instead of sitting static while
     the image moves underneath it. */
  transition: transform 200ms ease-out;
}
.simple-plate__texture.is-zoomed {
  transform: scale(var(--zoom-scale, 1));
}

/* Centred. Takes pointer events (unlike every other slot, which stays
   click-through) specifically so it can absorb a click — that's what makes
   "click outside the image" a distinct gesture from "click the image", see
   .simple-slide's own click handling in js/app.js. */
.simple-plate.is-current {
  opacity: 1;
  z-index: 2;
  pointer-events: auto;
  cursor: default; /* don't inherit .simple-slide's zoom-out */
}
/* Only advertise the zoom affordance where it's real — is-zoomable is set
   per image (js/app.js markZoomable) from actual headroom, so a piece
   already at its native resolution keeps the plain cursor above instead of
   promising a zoom that would just show a blurry upscale. Lives on the IMG,
   not this wrapper (see markZoomable's comment — applySlots overwrites the
   wrapper's className on every page), so :has() reaches into the child to
   style the wrapper's own cursor. */
.simple-plate.is-current:has(.simple-plate__img.is-zoomable) {
  cursor: zoom-in;
}
.simple-plate.is-current:has(.simple-plate__img.is-zoomed) {
  cursor: zoom-out;
}

/* Feedback for "that press did nothing" — PREV/NEXT at either end of a
   piece, or ArrowUp/Down at the first/last piece (see shakeBoundary in
   js/app.js). A single directional bump-and-recoil, not an oscillating
   shake: pushes toward whichever direction was attempted (--bump-x/y, set
   inline per call so one animation covers all four directions) like it
   hit something solid there, recoils a little past center, settles. Every
   keyframe re-states the base translate(-50%, -50%) centering
   .simple-plate.is-current already has, rather than animating from an
   origin of 0 — this owns the SAME transform property the slot system
   does, so it has to fully own the centering itself for the duration, not
   just add an offset on top of it. No `forwards`: once it ends, the
   property just falls back to the plain .is-current rule above, already
   the right value. */
@keyframes plate-bump {
  0%, 100% { transform: translate(-50%, -50%); }
  30% { transform: translate(calc(-50% + var(--bump-x, 0px)), calc(-50% + var(--bump-y, 0px))); }
  60% { transform: translate(calc(-50% + var(--bump-x, 0px) * -0.25), calc(-50% + var(--bump-y, 0px) * -0.25)); }
}
.simple-plate.is-current.is-shaking {
  animation: plate-bump 320ms cubic-bezier(0.34, 1.56, 0.64, 1);
}

/* Parked just past the screen edge, invisible — where every plate that
   isn't centred or peeking waits. translateX(-100%) is the plate's own
   width, so `-100% - 50vw` lands its RIGHT edge exactly on x=0 (off the
   left); `50vw` lands the LEFT edge exactly on x=100vw (off the right).
   Plates behind the current one park left, ahead of it park right, so
   whichever way you page, the real plate arrives from the side it
   logically came from. */
.simple-plate.is-parked-prev {
  transform: translateX(calc(-100% - 50vw)) translateY(-50%);
}
.simple-plate.is-next,
.simple-plate.is-parked-next {
  transform: translateX(50vw) translateY(-50%);
}

/* The single sliver: only the ACTIVE piece pulls its NEXT plate in, and
   only on the right. That's also the whole animate-in/out between
   projects — scroll to a piece and its next edge peeks in, scroll away and
   it slides back out. Nothing peeks on the left by design; PREV still
   works and still slides the real plate in from that side. */
.simple-slide.is-active .simple-plate.is-next {
  transform: translateX(calc(50vw - var(--sliver-w))) translateY(-50%);
  opacity: 1;
  pointer-events: auto;
  cursor: pointer;
}

@media (prefers-reduced-motion: reduce) {
  .simple-plate,
  .simple-plate__img,
  .simple-plate__texture,
  .simple-zoom-scrim { transition: none; }
  /* transition:none above doesn't touch this — animation is a separate
     mechanism, needs its own opt-out. */
  .simple-plate.is-shaking { animation: none; }
}

/* --- Fixed corners: identity (top-left) + nav (top-right) ---------------
   Both sit above the grid/about views (z-index 50 vs their 40) so they
   stay put while either one covers the gallery underneath — both open
   *behind* the chrome rather than replacing it. */
.simple-identity,
.simple-nav {
  position: fixed;
  top: var(--slide-pad);
  z-index: 50;
  display: flex;
  flex-direction: column;
  line-height: 1.25;
}
/* A real link now (back to the first piece), so it takes pointer events —
   the whole two-line name is one hover target, and both lines underline
   together. */
.simple-identity {
  left: var(--slide-pad);
  pointer-events: auto;
  cursor: pointer;
}
.simple-identity:focus-visible { outline: none; }
.simple-identity__line {
  color: var(--fg);
}

/* WORK and ABOUT are two UNRELATED actions, not one name like ALLEN/Y LAU
   above — so each is its own .simple-link with its own independent hover,
   not a shared trigger. Both are plain flow children of THIS fixed
   wrapper, which is also what keeps .simple-link's own `position:relative`
   (set for its underline anchor) from ever fighting a child's corner
   position again — a bug that happened here once already when WORK was
   individually `position:fixed` and .simple-link's later-declared
   `position:relative` silently won on specificity, dropping WORK off the
   bottom of the screen. Nothing below is individually positioned any
   more, so that collision can't recur. */
.simple-nav {
  right: var(--slide-pad);
  align-items: flex-end;
  gap: 0.35em;
  text-align: right;
  color: var(--fg);
}

/* --- Caption (title/year, bottom-left) + pagination (bottom-right) ------- */
.simple-caption {
  position: fixed;
  bottom: var(--slide-pad);
  left: var(--slide-pad);
  z-index: 50;
  display: flex;
  flex-direction: column;
  line-height: 1.4;
  pointer-events: none;
}
/* Room for the title's hover underline (::after sits at bottom: -0.2em) —
   without this gap the year sits close enough that the drawn line reads as
   touching or crossing out the year below it instead of underlining the
   title above it. */
.simple-caption__title { margin-bottom: 0.35em; }
.simple-caption__client { margin-bottom: 0.35em; }
.simple-caption__year { color: var(--fg); }

/* Counter stacked above PREV/NEXT, both right-aligned in the same corner. */
.simple-pagination {
  position: fixed;
  bottom: var(--slide-pad);
  right: var(--slide-pad);
  z-index: 50;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.4rem;
}
.simple-pagination__counter { color: var(--fg); }
.simple-pagination__nav {
  display: flex;
  gap: 1.5ch;
}

/* PREV at the first image / NEXT at the last: paging clamps (no wrap), so
   the dead direction must LOOK dead — a full-strength button that silently
   no-ops reads as broken. Struck through with the same hand-drawn ink line
   used for the hover underline below (see .simple-link::after) — just
   repositioned through the middle and permanently drawn instead of
   animating in on hover — rather than dimmed: "crossed off" reads more
   clearly as "not available here" than a faded button, which can look like
   it's mid-load or just less important. Kept in the layout (not
   display:none) so PREV/NEXT don't jump around as they toggle; text stays
   full-strength.

   Deliberately still clickable (no pointer-events:none) — step() in
   js/app.js already treats this exact boundary as a real case, not an
   unreachable one: pressing it triggers shakeBoundary()'s bump instead of
   nothing. A control a user can press but that visibly does nothing reads
   as broken; one that gives feedback back reads as intentional. aria-
   disabled (set in renderPagination), not the native disabled attribute,
   is what keeps this the correct ARIA pattern for "looks disabled, stays
   operable" rather than truly inert. */
.simple-link.is-disabled {
  cursor: pointer;
}
.simple-link.is-disabled::after {
  top: 50%;
  bottom: auto;
  transform: translateY(-50%) scaleX(1);
  animation: none; /* always on — nothing to trigger it, unlike the hover reveal */
}

/* --- Link hover: a quick, fine hand-drawn underline ---------------------- */
.simple-link {
  position: relative;
  display: inline-block;
  cursor: pointer;
  pointer-events: auto;
  text-decoration: none;
}
.simple-link::after {
  content: "";
  position: absolute;
  right: -0.08em;
  bottom: -0.2em;
  left: -0.08em;
  height: 2px;
  background: var(--fg);
  clip-path: polygon(0 54%, 12% 18%, 25% 52%, 39% 22%, 53% 60%, 67% 28%, 81% 55%, 100% 25%, 100% 75%, 81% 100%, 67% 70%, 53% 100%, 39% 66%, 25% 100%, 12% 70%, 0 100%);
  transform: scaleX(0);
  transform-origin: left center;
  pointer-events: none;
}
.simple-link:hover,
.simple-link:focus-visible {
  outline: none;
}
/* What draws the underline. The pseudo always lives on .simple-link, but the
   thing you HOVER isn't always the link itself: "ALLEN / Y LAU" is one name
   across two lines (hovering either draws both), and a grid tile's title
   belongs to the whole tile. Those ancestors own the hover and reach in —
   which is why the triggers are gathered here rather than each owner
   re-declaring the animation. */
.simple-link:hover:not(.is-disabled)::after,
.simple-link:focus-visible:not(.is-disabled)::after,
.simple-identity:hover .simple-link::after,
.simple-identity:focus-visible .simple-link::after,
.simple-grid__cell:hover .simple-link::after,
.simple-grid__cell:focus-visible .simple-link::after {
  animation: ink-underline 220ms steps(2, end) forwards;
}
.simple-link:active {
  opacity: 0.6;
}

@keyframes ink-underline {
  to { transform: scaleX(1); }
}

@media (prefers-reduced-motion: reduce) {
  .simple-link:hover:not(.is-disabled)::after,
  .simple-link:focus-visible:not(.is-disabled)::after,
  .simple-identity:hover .simple-link::after,
  .simple-identity:focus-visible .simple-link::after,
  .simple-grid__cell:hover .simple-link::after,
  .simple-grid__cell:focus-visible .simple-link::after {
    animation: none;
    transform: scaleX(1);
  }
}

/* --- Grid view: the camera ------------------------------------------------
   .simple-gridview is the fixed frame you look through; __scene is the one
   element that moves. Opening scales the WHOLE scene so the active piece's
   tile lands exactly on top of the fullscreen image, then releases it to
   identity — so every other tile rushes in from off-screen as a
   consequence of the same motion, which is what makes it read as a camera
   pulling back rather than a tile flying to a slot.

  The frame begins white to match the gallery, then its background fades to
  grey in sync with the camera pull-back and fades home again on close. */
.simple-gridview {
  position: fixed;
  inset: 0;
  z-index: 40;
  /* --gridview-from is set by openGrid/closeGrid to the actual piece being
     left or landed on (read via getComputedStyle, not assumed to be --bg)
     — a piece can have its own `background:` (pieces.md), so the honest
     starting point for this crossfade isn't always white. Falls back to
     --bg when unset (nothing has flown yet, or the no-camera fallback
     paths that skip setting it). */
  background: var(--gridview-from, var(--bg));
  display: none;
  overflow: hidden; /* locked while the camera moves; see .is-idle */
  transition: background-color var(--camera);
  /* Keeps scrollIntoView (and the user's own scrolling) from parking tiles
     underneath the identity — see --grid-top. */
  scroll-padding-top: var(--grid-top);
}
.simple-gridview.is-open { display: block; }
.is-gridding .simple-gridview { background-color: var(--grid-bg); }

/* Laid out but not yet painted — the frame where we measure the tile and
   pre-apply the zoomed transform. Without this you'd see one frame of the
   grid at full size before the camera snaps in. */
.simple-gridview.is-measuring { visibility: hidden; }

/* Only scrollable once the camera has stopped. Scrolling mid-flight would
   fight the transform (and the scroll offset is baked into the maths). */
.simple-gridview.is-idle { overflow-y: auto; }

.simple-gridview__scene {
  transform-origin: 0 0; /* the maths below assumes the scene's own corner */
  transition: transform var(--camera);
}
/* Promote to its own layer only while moving — leaving will-change on
   permanently would pin a layer for a grid that's usually closed. */
.simple-gridview.is-animating .simple-gridview__scene {
  will-change: transform;
}

/* Tiles keep each artwork's own aspect ratio: height:auto off a natural
   image, no fixed cell height, no object-fit. That's load-bearing, not
   styling — these pieces run from 0.69 to 1.0 aspect, so a cropped square
   thumbnail would NOT match the uncropped fullscreen image, and the moment
   the camera hands over would visibly pop. Same image, same proportions,
   both ends. align-items:start lets rows stay ragged rather than stretch. */
.simple-grid {
  display: grid;
  grid-template-columns: repeat(var(--grid-columns), minmax(0, 1fr));
  gap: clamp(0.75rem, 2vw, 1.75rem);
  align-items: start;
  padding: var(--grid-top) var(--slide-pad) var(--slide-pad);
}
/* The other works fade in as the camera backs off, and back out when you
   pick one — they don't just blink on and off at the ends of the flight.
   Default is transparent: when the camera is zoomed in on a piece, the rest
   of the grid isn't there yet, it arrives.

   .is-origin is the piece you came from (or are flying into). It's exempt,
   because it isn't "another work" — at full zoom it IS the artwork on
   screen, so fading it would fade the very thing being handed over. */
.simple-grid__cell {
  display: block;
  cursor: pointer;
  opacity: 0;
  transition: opacity var(--camera);
}
.is-gridding .simple-grid__cell { opacity: 1; }
.simple-grid__cell.is-origin { opacity: 1; }

/* The origin's caption is not exempt though: at full zoom it would be a
   giant title slapped under the artwork, duplicating the real caption in
   the corner. It fades on the same curve as everything else. */
.simple-grid__cell.is-origin .simple-grid__title {
  opacity: 0;
  transition: opacity var(--camera);
}
.is-gridding .simple-grid__cell.is-origin .simple-grid__title { opacity: 1; }

/* .simple-grid__img is a normal in-flow child — the frame just wraps it
   (block flow, no CSS Grid stacking). position:relative is what makes the
   frame a valid containing block for .simple-grid__texture's
   position:absolute below — see .simple-plate for why that texture uses
   inset:0.5px rather than inset:0 (a real, measured ~1/64px Chromium
   layout-precision gap between the frame's shrink-wrapped box and the
   img's own intrinsic-ratio box, not something any CSS sizing approach
   makes agree exactly, grid or otherwise) — same fix here, same reasoning.
   The frame, not the bare img, is what openGrid/closeGrid
   fly/measure/radius-match (see `tile` vs `tileImg` there) — border-radius
   + overflow live here so rounded corners clip the grain too. */
.simple-grid__frame {
  position: relative;
  display: block;
  border-radius: var(--image-radius);
  overflow: hidden;
  transition: border-radius var(--camera);
}
.is-gridding .simple-grid__frame { border-radius: var(--grid-image-radius); }

.simple-grid__img {
  display: block;
  width: 100%;
  height: auto;
  -webkit-user-drag: none;
  user-select: none;

  /* Same "invisible until decoded, no transition" reveal as
     .simple-plate__img — see the comment there. */
  opacity: 0;
}
.simple-grid__img.is-loaded {
  opacity: 1;
}

/* Same ink-grain overlay as .simple-plate__texture, scaled down to suit a
   much smaller tile. js/app.js only renders this element when a piece
   hasn't set `texture: off` in pieces.md.

   transition on background-size (not just border-radius above) is what
   makes the grid ↔ single-image handoff not "jump" — see
   textureSizeMatching in js/app.js for why a size mismatch is the actual
   cause, not something a crossfade would be needed to paper over. Once
   openGrid/closeGrid snap this to the plate-matching size, it eases back
   to (or in from) this 120px resting value in lockstep with --camera,
   exactly like border-radius already does. */
.simple-grid__texture {
  position: absolute;
  inset: 0.5px;
  pointer-events: none;
  background-image: url("../assets/ink-fade.jpg");
  background-repeat: repeat;
  background-size: 120px 120px;
  transition: background-size var(--camera), inset var(--camera);
  mix-blend-mode: screen;
}
/* The tile's title underlines with everything else — the strikethrough that
   used to live here was the last survivor of the old hover style. The
   trigger is up with the other underline triggers; hovering anywhere on the
   tile draws it. */
.simple-grid__cell:focus-visible { outline: none; }
/* NOT display:block — .simple-link already set inline-block so the
   underline (an ::after sized off THIS element) hugs the text instead of
   stretching to the tile's full width. margin-top does the row-break job
   block display would otherwise be doing here. */
.simple-grid__title {
  margin-top: 0.5rem;
  font-size: 0.82em;
}

/* Caption and pagination belong to the canvas, not the grid — they fade as
   the camera pulls back. Identity and WORK deliberately stay. */
.is-gridding .simple-caption,
.is-gridding .simple-pagination,
.is-about .simple-caption,
.is-about .simple-pagination,
.is-zooming .simple-caption,
.is-zooming .simple-pagination {
  opacity: 0;
  pointer-events: none;
}
.simple-caption,
.simple-pagination {
  transition: opacity var(--camera);
}

/* --- About view -------------------------------------------------------
   A plain overlay, no animation, no relationship to the grid at all — it
   just covers whatever's currently showing (gallery OR grid, doesn't
   matter which) and reveals it again unchanged when closed. z-index sits
   ABOVE .simple-gridview's 40, because About can be opened on top of an
   already-open grid (WORK stays open underneath; About just covers it),
   still below the chrome's 50 so identity/WORK/ABOUT stay reachable. */
.simple-about-view {
  position: fixed;
  inset: 0;
  z-index: 42;
  display: none;
  overflow-y: auto;
  background: var(--bg);
}
.simple-about-view.is-open { display: block; }

.simple-about-view__col {
  max-width: 34rem;
  margin: 0 auto;
  padding: calc(var(--slide-pad) + 4rem) var(--slide-pad) var(--slide-pad);
  text-align: center;
}
.simple-about-view__col img {
  display: block;
  width: 75%;
  height: auto;
  margin: 0 auto 2rem;
  border-radius: var(--image-radius);
}
.simple-about-view__col p {
  margin: 0 0 1.2em;
  line-height: 1.6;
  text-transform: none; /* prose reads better sentence-case than the site's default caps */
}
/* The email is now inline in a sentence ("Reach me at …"), which inherits
   the paragraph's text-transform:none for readable prose — but the email
   itself gets uppercase pulled back explicitly, so it reads as a call to
   action the same way every other actionable word on this site does
   (WORK, ABOUT, PREV, NEXT). One capitalized word inside an otherwise
   sentence-case line is the whole point: it's what makes it look
   clickable at a glance instead of just being more prose. */
.simple-about-view__email { text-transform: uppercase; }

@media (prefers-reduced-motion: reduce) {
  .simple-gridview,
  .simple-gridview__scene { transition: none; }
  .simple-caption,
  .simple-pagination,
  .simple-grid__cell,
  .simple-grid__frame,
  .simple-grid__texture,
  .simple-grid__cell.is-origin .simple-grid__title { transition: none; }
}

/* --- Mobile: shrink caption slightly, no other layout change needed ------
   since the image is centered (not corner-pinned) the same flex-centering
   rule already works at any viewport size. */
@media (max-width: 640px) {
  .simple-caption {
    font-size: 0.8em;
  }
  :root {
    --sliver-w: clamp(0.5rem, 4vw, 1.25rem);
    --grid-columns: var(--grid-columns-mobile);
  }

  /* PREV/NEXT are just small text at the site's base font-size — a fine
     tap target on desktop with a mouse, well under the ~44px touch-target
     guideline on a phone. A ::before overlay pads the HIT AREA only: it's
     absolutely positioned off the link's own box (already position:relative
     for the hover-underline ::after), so the visible text and the
     underline's own anchor point don't move or grow with it — just what
     you can miss-tap around. gap grows to match so two enlarged zones
     sitting right next to each other don't overlap. is-disabled already
     carries pointer-events:none, which this pseudo-element inherits too —
     a struck-through PREV/NEXT doesn't gain a bigger inert hit area. */
  .simple-pagination__nav {
    gap: 3.5ch;
  }
  .simple-pagination__nav .simple-link::before {
    content: "";
    position: absolute;
    inset: -0.9em;
  }
}
