/* loupe — the engine.
 *
 * No appearance decisions live here. Every colour, size, easing and duration
 * is a custom property supplied by a theme file. The fallbacks after each
 * var() are only there so the page is not invisible if a theme fails to load;
 * they are not a house style.
 *
 * Timings and the fly-in geometry are read back out of these same properties
 * by loupe.js, so a theme stays the single source of truth for motion as
 * well as colour.
 */

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

/* The ground goes on html alone, never on body. A section page's backdrop is
   a negative-z-index child of body, and the painting order puts such a child
   above the root background but below body's own background box — so an
   opaque body background would hide it completely. */
html {
  height: 100%;
  background: var(--ground, #07060a);
}

body {
  height: 100%;
  overflow: hidden;
  color: var(--ink, #cfc6bd);
  font-family: var(--font-label, monospace);
}

/* ⚠⚠ A GALLERY THAT RENDERS #grid MUST SCROLL, AND FOR A WHILE NONE DID.
 *
 * `overflow: hidden` above is right for a SEQUENCE: that page is a fixed
 * viewport — plates behind, a rail in front — and nothing is ever below the
 * fold, so a scrollbar there would be a bug. It is wrong for every other
 * family. A grid is as tall as its rows: darkroom is seventeen works at four
 * columns with a hero, and `#grid` carries `min-height: 100vh` plus 14vh/22vh
 * of padding, so it always exceeds the viewport. Everything past the fold was
 * unreachable on all six grid presets.
 *
 * ⚠ IT WAS INVISIBLE BECAUSE THE SAME LAYOUT SCROLLED NEXT DOOR. `section.html`
 * carries `<body class="section">` and `body.section { overflow: auto }` below
 * releases it, so identical markup behaved correctly one page over — which is
 * exactly the shape of bug that survives testing. `gallery.html` has a bare
 * `<body>` and never gets that class.
 *
 * ⚠ Keyed off the FAMILY, which is already on <html>, rather than adding a
 * class to gallery.html — ARCHITECTURE §2.4 says the page type is decided by
 * markup and the engine stays ignorant of family, so the stylesheet is the
 * right place for a family-wide layout fact. Sheet is included because its
 * stage is `aspect-ratio: 800/550` and overflows a short viewport; `auto`
 * costs nothing when it fits. */
.fam-grid body,
.fam-sheet body { overflow: auto; }

/* ---------- the full-bleed plates ---------- */

#stage {
  position: fixed;
  inset: 0;
  overflow: hidden;
  transition: opacity var(--fade, 700ms) ease, filter var(--fade, 700ms) ease;
}

body.lit #stage {
  opacity: var(--stage-lit-opacity, 0.07);
  filter: saturate(var(--stage-lit-saturate, 0.5));
}

.plate {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity var(--fade, 700ms) ease;
}

.plate.on  { opacity: 1; }
.plate.out { opacity: 0; }

/* Bleed or mat — the biggest single decision a theme makes, and two tokens.
 *
 *   bleed  --plate-base: 100vmax; --plate-inset: 0;  --plate-fit: cover;
 *          a square the size of the viewport's long edge; at scale 1 it
 *          covers the screen exactly.
 *
 *   mat    --plate-base: 100vmin; --plate-inset: 9;  --plate-fit: contain;
 *          a square inset from the shorter edge, the photograph shown whole
 *          inside it, --ground visible all the way round as a wall.
 *
 * --plate-inset is unitless and read as vmin, so a miniature elsewhere can
 * reproduce the same mat as a percentage without knowing the viewport.
 *
 * Matted themes should also set --drift-scale: 1. The resting drift scales
 * the image, which on a bleed is invisible and on a mat pushes the picture
 * out past its own margin. */
.plate img {
  position: absolute;
  top: 50%;
  left: 50%;
  width:  calc(var(--plate-base, 100vmax) - 2 * var(--plate-inset, 0) * 1vmin);
  height: calc(var(--plate-base, 100vmax) - 2 * var(--plate-inset, 0) * 1vmin);
  margin: calc((var(--plate-base, 100vmax) - 2 * var(--plate-inset, 0) * 1vmin) / -2)
          0 0
          calc((var(--plate-base, 100vmax) - 2 * var(--plate-inset, 0) * 1vmin) / -2);
  object-fit: var(--plate-fit, cover);
  display: block;
  transform: scale(var(--plate-start-scale, 0.13));
  transition: transform var(--grow, 1500ms) var(--grow-ease, ease-out);
}

.plate.on img  { transform: scale(1); }
.plate.out img { transform: scale(var(--plate-exit-scale, 1.16)); }

.plate.resting img {
  transition: none;
  animation: drift var(--drift, 54s) ease-in-out infinite alternate;
}

@keyframes drift {
  from { transform: scale(1) translate3d(0, 0, 0); }
  to   { transform: scale(var(--drift-scale, 1.07)) translate3d(-1.2%, 1%, 0); }
}

/* ---------- environment ---------- */

#grain {
  position: fixed;
  inset: -50%;
  pointer-events: none;
  opacity: 0;
  mix-blend-mode: var(--grain-blend, overlay);
  transition: opacity 1.4s ease;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3'/></filter><rect width='160' height='160' filter='url(%23n)' opacity='0.5'/></svg>");
}

body.ready #grain { opacity: var(--grain, 0.16); }
body.lit   #grain { opacity: 0; }

/* Shading that keeps the chrome legible over any photograph. The colour is a
   token because a light theme has to lighten here, not darken. */
#shade {
  position: fixed;
  left: 0; right: 0; bottom: 0;
  height: 46vh;
  pointer-events: none;
  opacity: 0;
  transition: opacity 1.2s ease;
  background:
    /* the rail's lobe — scaled by --scrim-rail, which a light ground sets to 0 */
    radial-gradient(ellipse 44% 62% at 92% 90%,
      rgba(var(--scrim-rgb, 0 0 0) / calc(var(--scrim, 0.68) * var(--scrim-rail, 1))), transparent 100%),
    /* the credit's lobe — the one that makes the title readable.
       ⚠ Three stops, not two: the middle one is what stops a small ellipse from
       washing a large area. See --scrim-falloff in base.css. */
    radial-gradient(ellipse var(--scrim-size, 40% 52%) at var(--scrim-at, 4% 94%),
      rgba(var(--scrim-rgb, 0 0 0) / calc(var(--scrim, 0.68) * 0.9)) 0%,
      rgba(var(--scrim-rgb, 0 0 0) / calc(var(--scrim, 0.68) * 0.9 * var(--scrim-falloff, 0.5))) 50%,
      transparent 100%);
}

body.ready #shade { opacity: 1; }
body.lit   #shade { opacity: 0; }

/* A frame, band or spine at the edge of the viewport. The one chrome surface
 * with nowhere else to live: it is always visible, never covers the middle
 * of a photograph, and carries colour at rest rather than only on hover.
 *
 *   --edge-width  any border-width, so 12px 0 0 0 is a band along the top,
 *                 0 0 0 10px a spine down the left, 1px a full frame
 *   --edge-inset  pulls it in off the edge — a hairline at 18px reads as a
 *                 plate mark, flush at 0 reads as a bound edge
 *
 * Sits above the enlargement deliberately: a frame that vanished when you
 * opened a photograph would be a viewer, not a frame.
 */
#edge {
  position: fixed;
  inset: var(--edge-inset, 0);
  z-index: 6;
  pointer-events: none;
  border-style: solid;
  border-width: var(--edge-width, 0);
  border-color: var(--edge-color, transparent);
  opacity: 0;
  transition: opacity 1.2s ease;
}

body.ready #edge { opacity: 1; }

/* ---------- the rail ---------- */

#rail {
  position: fixed;
  right: var(--rail-inset-x, max(3vw, 22px));
  bottom: var(--rail-inset-y, max(3vh, 20px));
  display: flex;
  gap: var(--thumb-gap, 13px);
  z-index: 3;
  transition: opacity 500ms ease;
}

body.lit #rail { opacity: 0; pointer-events: none; }

/* ---------- the rail in the middle ----------
 *
 * ⚠ Where the rail STANDS and how the frames ARRIVE are one decision, not two.
 * A flight suits thumbnails travelling to a corner; a fall suits thumbnails
 * landing in the middle, where the eye already is. Choosing the centre without
 * changing the entrance gives you frames swinging round a point they are
 * already sitting on. settleRail() reads this class for exactly that reason.
 *
 * The five sit at a size worth looking at rather than filed in a corner, so
 * the sizes are viewport-relative here instead of the rail's own tokens. */
/* ⚠⚠ ENTRY PAGES ONLY. On a section page #rail is not the work — it is the
   cross-navigation to the other sections, and #grid holds the works. Centring
   it there stacks the navigation strip on top of the grid, which renders as
   two offset rows of thumbnails overlapping each other. Seen, not theorised.
   body.section is the marker the engine already sets. */
.rail-centre body:not(.section) #rail {
  right: auto;
  bottom: auto;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  gap: clamp(9px, 1.9vw, 30px);
}
.rail-centre body:not(.section) .thumb {
  width:  clamp(54px, 11.4vw, 176px);
  height: clamp(54px, 11.4vw, 176px);
}
/* the number in the corner of a frame is sized for a 78px tile; at 176px it
   reads as a caption, so it steps back out of the way */
.rail-centre body:not(.section) .thumb .n { opacity: 0; }
.rail-centre body:not(.section) #rail.live .thumb:hover .n,
.rail-centre body:not(.section) #rail.live .thumb:focus-visible .n { opacity: 1; }

.thumb {
  position: relative;
  width: var(--thumb-size, 78px);
  height: var(--thumb-size, 78px);
  padding: 0;
  border: var(--thumb-border, 1px solid rgba(255, 255, 255, 0.16));
  border-radius: var(--thumb-radius, 2px);
  background: var(--thumb-bg, #0a0809);
  cursor: pointer;
  touch-action: manipulation;
  opacity: 0;
  overflow: hidden;
  box-shadow: var(--thumb-shadow, 0 8px 30px rgba(0, 0, 0, 0.65));
  transform: translate(var(--dx, 0px), var(--dy, 0px))
             scale(var(--thumb-start-scale, 0.42))
             rotate(var(--rot, 0deg));
}

.thumb.animate {
  transition:
    transform var(--settle, 1250ms) var(--settle-ease, ease-out),
    opacity 700ms ease,
    border-color 260ms ease,
    box-shadow 260ms ease;
}

.thumb.settled {
  opacity: 1;
  transform: translate(0, 0) scale(1) rotate(0deg);
}

.thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  filter: var(--thumb-filter, brightness(0.86) saturate(0.94));
  transition: filter 260ms ease, transform 400ms ease;
}

#rail.live .thumb:hover img,
#rail.live .thumb:focus-visible img {
  filter: var(--thumb-filter-hover, brightness(1.12) saturate(1.06));
  transform: scale(var(--thumb-hover-scale, 1.07));
}

#rail.live .thumb:hover,
#rail.live .thumb:focus-visible {
  border-color: var(--accent, rgba(255, 214, 160, 0.75));
  box-shadow: var(--thumb-shadow-hover, 0 10px 34px rgba(0, 0, 0, 0.7)),
              0 0 22px var(--accent-glow, rgba(255, 176, 90, 0.28));
  outline: none;
}

/* ---------- duotone: chrome applied to the content itself ----------
 *
 * The answer to identity vanishing when the filler images are replaced. The
 * rail is mapped into two theme colours — shadows to --duo-dark, highlights
 * to --duo-light — so a stranger's photographs still arrive looking like the
 * theme. Enlargements are never touched; the work is shown as it is.
 *
 * Grayscale, screened over the dark colour, then multiplied by the light one.
 * A hue-rotate would only tint; this actually remaps the ends of the ramp.
 * A theme opts in with --rail-duotone: 1, which loupe.js turns into a class,
 * because blend modes cannot be switched off by a custom property alone. */
.duotone .thumb,
.duotone .cell {
  background: var(--duo-dark, #101014);
  isolation: isolate;
}

.duotone .thumb img,
.duotone .cell img {
  filter: grayscale(1) contrast(var(--duo-contrast, 1.08));
  mix-blend-mode: screen;
}

.duotone .thumb::after,
.duotone .cell::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background: var(--duo-light, #f4efe6);
  mix-blend-mode: multiply;
}

/* hover lifts back toward the real photograph */
#rail.live .duotone-off:hover img { filter: none; }

.duotone #rail.live .thumb:hover img,
.duotone #rail.live .thumb:focus-visible img,
.duotone #grid.live .cell:hover img,
.duotone #grid.live .cell:focus-visible img {
  filter: grayscale(0) contrast(1);
  mix-blend-mode: normal;
}

.duotone #rail.live .thumb:hover::after,
.duotone #rail.live .thumb:focus-visible::after,
.duotone #grid.live .cell:hover::after,
.duotone #grid.live .cell:focus-visible::after { opacity: 0; }

.thumb .n {
  position: absolute;
  inset: auto 0 0 0;
  z-index: 2;
  font-size: var(--n-size, 9px);
  letter-spacing: 0.16em;
  color: var(--n-ink, rgba(255, 255, 255, 0.72));
  background: linear-gradient(transparent,
    rgba(var(--scrim-rgb, 0 0 0) / 0.72));
  padding: 10px 0 2px;
  text-align: center;
  opacity: 0;
  transition: opacity 240ms ease;
}

#rail.live .thumb:hover .n,
#rail.live .thumb:focus-visible .n { opacity: 1; }

/* ---------- axis one: the layout families ---------- */

/* Sequence is this engine's default and needs no class: #stage + #rail.
   Grid and Sheet are the other two, and both render into #grid. */

/* ===== GRID — all at once, uniform =====================================
 *
 * Modelled on the tribute product, which has run this for real. Its dials, as tokens:
 *   --grid-columns   4 | auto-fit      how many across
 *   --cell-size      the tile          how big
 *   --cell-gap       the gutter        how far apart
 *
 * ⚠⚠ A WIDE WORK IS NOT A GRID SPAN. the sibling product lifts a panorama out as its own
 * full-width band, and TEMPLATES.md flags reading its note before copying
 * anything. The reason is that a spanning cell is still a CELL — fixed square,
 * object-fit: cover — so widening one CROPS the panorama, which is the one
 * thing a panorama cannot survive. The band below is `height: auto` instead:
 * as deep as the photograph needs, nothing cropped.
 *
 * ⚠ And no stored dimensions are needed, deliberately. The browser already
 * knows the ratio from the file. Anything reading it server-side would work for
 * new uploads and fail silently for everything already stored — the sibling product' own
 * note, and it applies to loupe's manifest exactly as written.
 */
.fam-grid #grid {
  grid-template-columns: repeat(var(--grid-columns, auto-fit), var(--cell-size, 154px));
}

/* the lead — one work larger, with the rest filling in around it */
.fam-grid.grid-hero #grid > .cell:first-child {
  grid-column: span 2;
  grid-row: span 2;
  width:  calc(var(--cell-size, 154px) * 2 + var(--cell-gap, 18px));
  height: calc(var(--cell-size, 154px) * 2 + var(--cell-gap, 18px));
}

/* a widened work: its own band, full width of the grid, never cropped */
.fam-grid #grid > .band {
  grid-column: 1 / -1;
  display: flex;
  justify-content: center;
  width: auto;
  height: auto;
  border: 0;
  background: none;
  box-shadow: none;
  overflow: visible;
}
.fam-grid #grid > .band img {
  width: 100%;
  height: auto;
  display: block;
  border: var(--thumb-border, 1px solid rgba(255, 255, 255, 0.16));
  border-radius: var(--thumb-radius, 2px);
  box-shadow: var(--thumb-shadow, none);
  box-sizing: border-box;
  object-fit: unset;
}

/* ⚠⚠ THREE COLUMNS IS calc(75% - 2px), AND THE 2px IS NOT A FUDGE. Carried
 * from the sibling product with its arithmetic: with four columns and a gap g, a column is
 * (W - 3g)/4, so three columns plus the two gaps between them is
 * 3(W - 3g)/4 + 2g = 0.75W - g/4. A flat 75% overhangs and the band stops
 * lining up with the grid above it. it is stated there for g = 8px, where the
 * correction is 2px; expressed here against the live gutter so it stays exact
 * if the gutter is turned.
 *
 * ⚠ It also cannot be a three-column grid span: centring three of four columns
 * needs half a column of margin each side, which no grid placement expresses. */
.fam-grid #grid > .band.span-three img {
  width: calc(75% - var(--cell-gap, 18px) / 4);
}

/* ===== SHEET — all at once, placed ====================================
 *
 * ⚠⚠ NOT A GRID WITH SPANS. That was the first attempt here and it was the
 * wrong shape: the archive product that has run this for real places ABSOLUTE
 * rectangles on a stage. That is what makes a sheet a sheet rather than a
 * grid with uneven cells — a frame can sit anywhere, including overlapping
 * the rhythm a grid would impose, and nothing snaps to a column.
 *
 * Frames are percentages of the stage, so an arrangement is resolution-free
 * and the same numbers drive the gallery and its miniature.
 *
 * ⚠ the archive product's other two findings, carried:
 *   THE SHAPES THEMSELVES, NOT THEIR NAMES. "Relaxed" and "Masonry" mean
 *   nothing to someone who has not seen either, and a dropdown of words is a
 *   choice nobody can actually make. The picker draws each arrangement small
 *   and the person picks the one that looks like what they want.
 *   A SHAPE IS NOT ITS COLOURS. In a builder the frames come out EMPTY — a
 *   sheet wearing seeded swatches looks like photographs that failed to load,
 *   and an empty slot is what invites a picture.
 */
.fam-sheet #grid {
  display: block;
  position: relative;
  width: min(94vw, 1200px);
  margin: 0 auto;
  aspect-ratio: 800 / 550;
  min-height: 0;
  padding: 0;
  align-content: initial;
  justify-content: initial;
}
/* ⚠ Every var carries a fallback. Without one, calc(var(--fx) * 1%) is
   invalid at computed-value time when the manifest names no frame, so left
   and width fall back to auto and an absolutely-positioned cell renders at
   the image's NATURAL size — a 2000px photograph across the viewport. Seen,
   not theorised: it is what a mis-ordered demo produced. A missing frame now
   degrades to a modest placed square instead, which is the sibling product' rule for a
   bad span applied to a bad frame. */
.fam-sheet #grid > .cell {
  position: absolute;
  left:   calc(var(--fx, 4) * 1%);
  top:    calc(var(--fy, 6) * 1%);
  width:  calc(var(--fw, 22) * 1%);
  height: calc(var(--fh, 30) * 1%);
}
/* a widened work has no meaning on a sheet — every frame is already its own
   size — so a band falls back to being one more placed frame. */
.fam-sheet #grid > .band { position: absolute; display: block; }

/* ---------- the artist ---------- */

/* ⚠⚠ NOT one of the works, and the layout has to say so. The reference gallery —
   the design this engine was generalised from — got this right and said why:
   "The five are lights-NN; the portrait keeps its own name" and "Arrows walk
   the five; the portrait sits outside that sequence."

   ⚠ The signal that does the work is the ASPECT RATIO. The works are square;
   the portrait keeps the slide's own 5:4 landscape rather than being cropped
   to match. That is what makes it read as a different KIND of object rather
   than a sixth picture. The demo asset is 2000x1600 — exactly 1.25.

   ⚠ Same chrome as the rail otherwise — border, shadow, filter all come from
   the same tokens — so it still belongs to the page it sits on. Different
   object, same room. */
/* ⚠ An author `display` beats the [hidden] attribute — the UA stylesheet sets
   display:none at the lowest possible precedence, so #portrait { display:block }
   silently un-hides it. The element ships hidden and is only shown when a
   manifest names a portrait; without this rule a gallery with no portrait drew
   an empty frame. Classic, and invisible until a manifest lacks the field. */
#portrait[hidden] { display: none; }

#portrait {
  position: fixed;
  left: var(--credit-inset-x, max(3vw, 22px));
  top:  var(--credit-inset-y, max(3vh, 22px));
  z-index: 4;
  display: block;
  width: clamp(48px, 5vw, 75px);
  aspect-ratio: 1.25;
  border: var(--thumb-border, 1px solid rgba(255, 255, 255, 0.16));
  border-radius: var(--thumb-radius, 2px);
  background: var(--thumb-bg, #0a0809);
  box-shadow: var(--thumb-shadow, none);
  overflow: hidden;
  opacity: 0;
  transition: opacity 1.2s ease 0.4s, border-color 260ms ease, box-shadow 260ms ease;
}

body.ready #portrait { opacity: 1; }
body.lit   #portrait { opacity: 0; pointer-events: none; transition-delay: 0s; }

#portrait img {
  width: 100%; height: 100%;
  object-fit: cover;
  display: block;
  filter: var(--thumb-filter, none);
  transition: filter 260ms ease, transform 400ms ease;
}

#portrait:hover img,
#portrait:focus-visible img {
  filter: var(--thumb-filter-hover, none);
  transform: scale(var(--thumb-hover-scale, 1.05));
}

#portrait:hover,
#portrait:focus-visible {
  border-color: var(--accent, #ffd6a0);
  box-shadow: var(--thumb-shadow-hover, none), 0 0 22px var(--accent-glow, transparent);
  outline: none;
}

@media (prefers-reduced-motion: reduce) {
  #portrait, #portrait img { transition: none; }
}

/* ---------- the way home ---------- */

/* ⚠ A separate element from #plate-text on purpose. That block is the
   ARTIST'S — .t is their gallery's title and .s its credit — and on a
   customer's gallery handing their byline to our navigation would be wrong.
   a sibling product reached the same conclusion in its own sign-off study: the
   sign-off is mark plus wordmark, distinct from the content, and the wordmark
   is what links home.

   ⚠⚠ The mark is deliberately NOT tokenised. DESIGN.md: "the mark does not
   follow [the theme], because a mark that changed with the theme would not be
   a mark." That is also what makes this legible with no scrim tuning — an
   opaque tile reads over any photograph, on any ground, which is the property
   a sibling product chose its own mark tile for.

   ⚠ Upper RIGHT, not left. The upper left is the first corner read, and on a
   customer's gallery it belongs to the artist — it holds their portrait. Our
   mark takes the quieter corner. Swap the two properties below to reverse it. */
#home {
  position: fixed;
  right: var(--credit-inset-x, max(3vw, 22px));
  top:   var(--credit-inset-y, max(3vh, 22px));
  z-index: 4;
  display: flex;
  align-items: center;
  gap: 10px;
  text-decoration: none;
  opacity: 0;
  transform: translateY(-6px);
  transition: opacity 1.2s ease 0.4s, transform 1.2s ease 0.4s;
}

/* ⚠⚠ THE WORDMARK'S SCRIM — the second of exactly two places allowed to touch
   this photograph's opacity, the credit's corner being the first. It rides on
   #home rather than on #shade because #shade is anchored to the bottom of the
   viewport and 46vh tall, so it can never reach this corner.
   ⚠⚠ ON THE WORD, NOT ON THE WHOLE MARK. The glyph tile beside it is the LOGO,
   and it already carries its own ground — the figure/ground swap is the whole
   design of it. A lobe behind the tile lifts a surface that was never
   transparent, so it reads as a smudge sitting behind the logo and competes
   with the one element on the page that should be crisp. Only the word is live
   text over a photograph, so only the word needs the lift.
   Owner's call 2026-08-31.
   ⚠ Inset NEGATIVELY so the lift extends past the mark and fades out before it
   reaches anything else; radius stops at 70% so there is a clean edge of
   nothing rather than a wash that keeps going.
   ⚠ --scrim-mark is 1 in base, so every ground gets one, boardwalk included.
   It scales with --scrim, so the lobe darkens under the word on a dark ground
   and lifts it on a light one — the same direction that ground's scrim always
   works in. Set --scrim-mark to 0 on any ground that should go without. */
#home .word::before {
  content: '';
  position: absolute;
  /* ⚠⚠ SYMMETRIC, so the lobe is CENTRED ON THE WORD. An asymmetric inset
     (-7 left, -17 right, to clear the logo) moved the box's centre 5px right of
     the word's, which put the visible lift 2px INSIDE the first letter and 8px
     past the last — the word was lit off-centre and its start was left dark.
     ⚠ -10px is the largest symmetric inset available: the gap between the glyph
     tile and the word measures exactly 10px, so the box now meets the tile
     without crossing it. Nothing shows there anyway — see the explicit sizing
     below, which puts alpha 0 at the box edge. */
  inset: -11px -10px;
  z-index: -1;
  pointer-events: none;
  border-radius: 50%;
  /* ⚠ SIZED EXPLICITLY at 50% 50% — the ellipse inscribed in the box. The
     default is farthest-corner, which reaches PAST the box's edges at their
     midpoints, so the lobe was still faintly lifting where it looked finished.
     At 50% the alpha reaches 0 exactly on the box edge, which is where the
     glyph tile begins. */
  background: radial-gradient(ellipse 50% 50% at center,
    rgba(var(--scrim-rgb, 0 0 0) / calc(var(--scrim, 0.68) * var(--scrim-mark, 0))) 0%,
    rgba(var(--scrim-rgb, 0 0 0) / 0) 100%);
}

body.ready #home { opacity: 1; transform: none; }
body.lit   #home { opacity: 0; transition-delay: 0s; transition-duration: 300ms; }

/* ⚠⚠ The mark is the OUTLINES, not live text. make-favicon.py is explicit:
   "The letterform is outlines, not live text. Share Tech Mono is a webfont
   ... converted once with fontTools, and it and the four constants under it
   are the mark." An earlier version of this rule set a text "L" in Share Tech
   Mono, which makes the letterform depend on a webfont arriving — fall back to
   Courier and the mark is a different shape. The path below is the one
   favicon.svg ships.

   ⚠ And it is favicon.svg ITSELF, loaded as an <img>, not a copy of its path
   inlined here. A copy would be a fourth place the glyph lives and a fourth
   place it can drift from make-favicon.py, which is the single generator. The
   tab icon and this mark are now the same file by construction — regenerate
   with make-favicon.py and both change together. */
#home .tile {
  width: 22px; height: 22px;
  display: block;
  border-radius: 3px;
  overflow: hidden;
  flex: none;
}

#home .word {
  /* the containing block for the scrim above — without it that lobe would
     resolve against #home and sit behind the logo as well as the word */
  position: relative;
  font-family: var(--font-display, monospace);
  font-size: var(--label-size, 11px);
  letter-spacing: var(--label-tracking, 0.2em);
  text-transform: var(--label-case, lowercase);
  color: var(--ink, #e8dcc9);
  text-shadow: var(--chrome-shadow, none);
  /* stepped back, the way the sibling product steps "Our" back in its wordmark */
  opacity: 0.82;
}

#home:hover .word,
#home:focus-visible .word { opacity: 1; }
#home:focus-visible { outline: 2px solid var(--accent, #ffb05a); outline-offset: 4px; }

@media (prefers-reduced-motion: reduce) {
  #home { transition: none; }
}

/* ---------- the standing credit ---------- */

#plate-text {
  position: fixed;
  left: var(--credit-inset-x, max(3vw, 22px));
  bottom: var(--credit-inset-y, max(3vh, 22px));
  z-index: 3;
  font-size: var(--label-size, 11px);
  letter-spacing: var(--label-tracking, 0.2em);
  line-height: 1.9;
  text-transform: var(--label-case, lowercase);
  color: var(--ink-dim, #b8ada2);
  text-shadow: var(--chrome-shadow, 0 1px 14px rgba(0, 0, 0, 0.9));
  opacity: 0;
  transform: translateY(8px);
  transition: opacity 1.5s ease 0.5s, transform 1.5s ease 0.5s;
  pointer-events: none;
  max-width: var(--credit-max, calc(100vw - 500px));
}

body.ready #plate-text { opacity: 1; transform: none; }
body.lit   #plate-text { opacity: 0; transition-delay: 0s; transition-duration: 400ms; }

#plate-text .t {
  font-family: var(--font-display, monospace);
  font-size: var(--title-size, 11px);
  letter-spacing: var(--title-tracking, 0.34em);
  text-transform: var(--title-case, lowercase);
  color: var(--ink, #e8dcc9);
}

/* ⚠ #plate-text sets pointer-events: none so the standing credit never eats a
   click meant for the work behind it. The artist's name is the one thing in
   there that IS a target, so it takes them back. */
#plate-text .who {
  pointer-events: auto;
  color: inherit;
  text-decoration: none;
  border-bottom: 1px solid var(--accent, currentColor);
  padding-bottom: 1px;
}
#plate-text .who:hover,
#plate-text .who:focus-visible { color: var(--ink, #e8dcc9); }

#plate-text .s {
  /* ⚠ Was --ink-faint. That is the tertiary ink, sized for text sitting on a
     GROUND; this line sits on a photograph, where the corner scrim can only do
     so much and 10px at 0.2em tracking mushes. --ink-dim is the secondary and
     is the right weight for a line that is meant to be read. Carried from
     the reference gallery unchanged until now, so this is a decision being made
     rather than a regression being fixed. */
  color: var(--ink-dim, #b8ada2);
  font-size: var(--sub-size, 10px);
}

/* The title line doubles as the breadcrumb on a section page — the only way
   up, put where the eye already goes rather than behind new chrome. */
#plate-text .t a {
  color: var(--ink-dim, #b8ada2);
  text-decoration: none;
  border-bottom: 1px solid transparent;
  transition: color 200ms ease, border-color 200ms ease;
}

#plate-text .t a:hover { color: var(--accent, #e8dcc9); border-bottom-color: currentColor; }
#plate-text .t i { font-style: normal; opacity: 0.4; margin: 0 0.55em; }
#plate-text { pointer-events: none; }
#plate-text .t a { pointer-events: auto; }

/* ---------- section pages: fade only, no motion ---------- */

body.section { overflow: auto; }

/* A section taller than the viewport scrolls its cells underneath the fixed
   credit, so it needs the same shading the entry page gets. The cells are
   position: relative, so they paint in the same layer as a fixed element
   with z-index auto and would win on DOM order — hence the explicit 2, above
   the grid and below #plate-text's 3. */
body.section #shade {
  top: auto;
  bottom: 0;
  height: 30vh;
  z-index: 2;
  background:
    radial-gradient(ellipse 62% 100% at 2% 98%,
      rgba(var(--scrim-rgb, 0 0 0) / var(--scrim, 0.68)) 0%,
      rgba(var(--scrim-rgb, 0 0 0) / var(--scrim, 0.68)) 26%,
      rgba(var(--scrim-rgb, 0 0 0) / 0) 78%),
    radial-gradient(ellipse 52% 100% at 99% 98%,
      rgba(var(--scrim-rgb, 0 0 0) / var(--scrim, 0.68)) 0%,
      rgba(var(--scrim-rgb, 0 0 0) / var(--scrim, 0.68)) 30%,
      rgba(var(--scrim-rgb, 0 0 0) / 0) 80%);
}

/* The rail persists on section pages so you can cross to another section
   rather than only back up. It fades in place — no fly-in, because motion
   belongs to the entry page. */
body.section #rail .thumb {
  transform: none;
  transition: opacity var(--fade, 700ms) ease,
              border-color 260ms ease,
              box-shadow 260ms ease;
}

body.section #rail .thumb.settled { opacity: 1; }

/* Where you already are, so the rail reads as position and not just links. */
#rail .thumb.current {
  border-color: var(--accent, rgba(255, 214, 160, 0.75));
  box-shadow: var(--thumb-shadow, 0 8px 30px rgba(0, 0, 0, 0.65)),
              0 0 18px var(--accent-glow, rgba(255, 176, 90, 0.28));
}

#rail .thumb.current img { filter: var(--thumb-filter-hover, brightness(1.12) saturate(1.06)); }

/* ...but not at the cost of the duotone. That rule wins on specificity and
   left the current thumbnail as the only colour frame in a two-tone rail,
   which reads as a fault rather than as a marker. The border, glow and
   number already say where you are. */
.duotone #rail .thumb.current img {
  filter: grayscale(1) contrast(var(--duo-contrast, 1.08));
}
#rail .thumb.current .n   { opacity: 1; }

body.section #backdrop {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  z-index: -1;
  transition: opacity var(--fade, 700ms) ease;
}

body.section.ready #backdrop { opacity: var(--backdrop, 0.2); }
body.section.lit   #backdrop { opacity: 0; }

#grid {
  min-height: 100vh;
  display: grid;
  align-content: center;
  justify-content: center;
  grid-template-columns: repeat(auto-fit, var(--cell-size, 154px));
  gap: var(--cell-gap, 18px);
  padding: 14vh var(--credit-inset-x, 5vw) 22vh;
}

.cell {
  position: relative;
  width: var(--cell-size, 154px);
  height: var(--cell-size, 154px);
  padding: 0;
  border: var(--thumb-border, 1px solid rgba(255, 255, 255, 0.16));
  border-radius: var(--thumb-radius, 2px);
  background: var(--thumb-bg, #0a0809);
  cursor: pointer;
  touch-action: manipulation;
  overflow: hidden;
  box-shadow: var(--thumb-shadow, 0 8px 30px rgba(0, 0, 0, 0.65));
  display: block;
  opacity: 0;
  /* Fade only. Nothing here moves — that is what makes a real page load
     between levels viable instead of an in-page router. */
  transition: opacity var(--fade, 700ms) ease,
              border-color 260ms ease,
              box-shadow 260ms ease;
}

/* ---------- an opening that fades rather than bursts ----------
 *
 * ⚠ The default opening GROWS: each plate starts at --plate-start-scale and
 * swells to fill the screen, which is the older choreography and the one the
 * corner rail's flight was built to match. This one does not move at all —
 * three photographs at full size and full strength, each fading into the next.
 * The movement is saved for the row, so there is exactly one gesture on the
 * page at a time.
 *
 * ⚠⚠ And the last one STEPS BACK rather than staying. body.ready is added at
 * the same instant the row is released, so the dim and the glide are one
 * gesture rather than two events — that simultaneity is the whole effect. A
 * plate that dims before or after the thumbs arrive reads as two things
 * happening; together it reads as the room settling behind them.
 */
.opening-fade .plate img {
  transform: none;
  transition: none;
}
.opening-fade .plate {
  transition: opacity 1100ms ease;
}
.opening-fade body.ready .plate.on {
  opacity: var(--rest, 0.26);
}

/* ⚠⚠ A CENTRED RAIL MAKES THE ROW THE SUBJECT, so the work behind it recedes
 * to a BACKDROP rather than merely stepping back. That is a different
 * relationship from a corner rail's, where the plate IS the page and the row is
 * filed beside it — and it is the same relationship a section page's #backdrop
 * has to its grid, which is why it takes the same token rather than a new one.
 * Owner's call 2026-08-31, against a section page as the reference.
 * ⚠ Specificity, not a token override: writing `--rest` here would collide with
 * the light grounds in dials.css, which set it to 0.92 for a reason that still
 * holds. This wins on its own without disturbing them.
 * ⚠ It fades toward the GROUND, so on a light ground it would wash out rather
 * than darken — the finding recorded on .g-wall and .g-paper in dials.css. No
 * centred-rail preset uses a light ground today; one that did would need the
 * recession expressed as saturation or scale instead. */
.rail-centre.opening-fade body.ready .plate.on {
  opacity: var(--backdrop, 0.15);
}

/* ⚠ A work standing BEHIND a grid, not in it. It is dimmed hard on purpose:
   at full strength the frames read as debris on top of a photograph rather
   than as the subject, and the thing being shown becomes the wallpaper. This
   is the same relationship the enlargement has to its stage — the ground is
   lit by the work without competing with it. */
.plate.backdrop {
  opacity: 0.34;
  filter: saturate(0.85);
}
.plate.backdrop img {
  /* ⚠ The whole geometry, not just the size. .plate img centres itself with
     top/left 50% and a NEGATIVE MARGIN of half --plate-base — which is -50vmax
     on a bleed. Overriding width and height alone left that margin in place,
     so the picture sat far up and left and appeared to stop two thirds down
     the page. Any rule that re-sizes a plate image has to neutralise the
     centring trick with it. */
  position: absolute;
  top: 0;
  left: 0;
  margin: 0;
  object-fit: cover;
  width: 100%;
  height: 100%;
  /* ⚠ NO DRIFT. The resting plate drifts slowly because it is the subject and
     the movement is the point. A backdrop that drifts translates itself out of
     frame — measured at -344px vertically, which left the bottom half of the
     page as flat ground and read as the image being cut off. A ground holds
     still. */
  animation: none;
  transform: none;
}

/* ---------- a grid that arrives ----------
 *
 * ⚠ The plain .cell above is FADE ONLY, and its comment says why: nothing
 * moving is what makes a real page load between levels viable instead of an
 * in-page router. That reasoning is about SECTION pages, which are arrived at
 * by navigation. An entry page is arrived at cold, and there the same glide
 * the rail uses reads far better than a fade.
 *
 * ⚠⚠ Opt-in, and never on a section page. body:not(.section) keeps the
 * original guarantee intact: crossing between levels still costs nothing.
 */
.entrance-glide body:not(.section) .cell {
  transform: translate(var(--dx, 0px), var(--dy, 0px))
             scale(var(--thumb-start-scale, 0.42))
             rotate(var(--rot, 0deg));
  transition: transform var(--settle, 1250ms) var(--settle-ease, ease-out),
              opacity var(--fade, 700ms) ease,
              border-color 260ms ease,
              box-shadow 260ms ease;
}

.entrance-glide body:not(.section) .cell.in {
  transform: translate(0, 0) scale(1) rotate(0deg);
}

.cell.in { opacity: 1; }

.cell img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  filter: var(--thumb-filter, brightness(0.86) saturate(0.94));
  transition: filter 260ms ease;
}

#grid.live .cell:hover img,
#grid.live .cell:focus-visible img {
  filter: var(--thumb-filter-hover, brightness(1.12) saturate(1.06));
}

#grid.live .cell:hover,
#grid.live .cell:focus-visible {
  border-color: var(--accent, rgba(255, 214, 160, 0.75));
  box-shadow: var(--thumb-shadow-hover, 0 10px 34px rgba(0, 0, 0, 0.7)),
              0 0 22px var(--accent-glow, rgba(255, 176, 90, 0.28));
  outline: none;
}

.cell .n {
  position: absolute;
  inset: auto 0 0 0;
  z-index: 2;
  font-size: var(--n-size, 9px);
  letter-spacing: 0.16em;
  color: var(--n-ink, rgba(255, 255, 255, 0.72));
  background: linear-gradient(transparent, rgba(var(--scrim-rgb, 0 0 0) / 0.72));
  padding: 12px 0 3px;
  text-align: center;
  opacity: 0;
  transition: opacity 240ms ease;
}

#grid.live .cell:hover .n,
#grid.live .cell:focus-visible .n { opacity: 1; }

/* ---------- the enlargement ---------- */

#light {
  position: fixed;
  inset: 0;
  z-index: 5;
  display: grid;
  place-items: center;
  background: var(--lit-ground, #0a090c);
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--fade, 700ms) ease;
  cursor: zoom-out;
}

body.lit #light { opacity: 1; pointer-events: auto; }

/* The mount is a theme's chance to frame the work — a white mat, a thin
   line, or nothing at all. */
#light .mount {
  padding: var(--mount-pad, 0);
  background: var(--mount-bg, transparent);
  border: var(--mount-border, 0);
  box-shadow: var(--enlarge-shadow, 0 30px 90px rgba(0, 0, 0, 0.8));
  opacity: 0;
  transform: scale(0.965);
  transition: opacity 800ms ease, transform var(--settle, 1100ms) var(--settle-ease, ease-out);
  line-height: 0;
}

#light .mount.shown { opacity: 1; transform: scale(1); }

#light img {
  max-width: var(--enlarge-max-w, 88vw);
  max-height: var(--enlarge-max-h, 86vh);
  object-fit: contain;
  display: block;
}

#light .meta {
  position: fixed;
  left: 0; right: 0;
  bottom: var(--meta-bottom, max(2.4vh, 18px));
  text-align: center;
  font-size: var(--sub-size, 10px);
  letter-spacing: 0.32em;
  text-transform: var(--label-case, lowercase);
  color: var(--ink-faint, #6e6660);
  opacity: 0;
  transition: opacity 700ms ease 400ms;
}

body.lit #light .meta { opacity: 1; }

/* ---------- phones held upright: the rail turns on its side ---------- */

@media (max-width: 720px) {
  #plate-text { max-width: 88vw; }
}

@media (max-width: 720px) and (orientation: portrait) {
  #rail {
    flex-direction: column;
    top: 50%;
    bottom: auto;
    right: calc(var(--rail-inset-x-sm, 13px) + env(safe-area-inset-right, 0px));
    transform: translateY(-50%);
    gap: var(--thumb-gap-sm, 10px);
  }

  .thumb { width: var(--thumb-size-sm, 54px); height: var(--thumb-size-sm, 54px); }

  #shade {
    top: 0;
    height: auto;
    background:
      linear-gradient(to left, rgba(var(--scrim-rgb, 0 0 0) / var(--scrim, 0.62)), transparent 32%),
      linear-gradient(to top,  rgba(var(--scrim-rgb, 0 0 0) / var(--scrim, 0.74)), transparent 27%);
  }

  #plate-text {
    left: calc(20px + env(safe-area-inset-left, 0px));
    bottom: calc(22px + env(safe-area-inset-bottom, 0px));
    max-width: calc(100vw - 42px);
  }

  #light img { max-width: 92vw; max-height: 76vh; }

  /* the rail is a column down the right here, so the grid has to clear it */
  body.section #grid {
    padding-right: calc(var(--thumb-size-sm, 54px) + 38px);
    padding-left: 20px;
  }
}

/* Motion is a theme's to spend. This only removes what the reader has
   already asked not to see. */
@media (prefers-reduced-motion: reduce) {
  .plate img { transform: none !important; transition: none !important; }
  .plate.resting img { animation: none; }
  .thumb { transform: none !important; }
  .thumb.animate { transition: opacity 500ms ease; }
  #light .mount { transition: opacity 400ms ease; transform: none !important; }
}
