/* 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;
  /* see the matted-sequence rule below — the stage ends above the bottom band
     there, and covers the screen everywhere else */
  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));
}

/* ⚠⚠ A MATTED SEQUENCE ENDS ITS STAGE ABOVE THE RAIL, so the work is centred in
   the room it actually has. Owner's report 2026-09-10, on `paper`: "the
   enlargement is sitting right now on top of the row of thumbs with plenty of
   available space above it." The plate centres on the STAGE, and the stage was
   the whole viewport — so the bottom band the rail and credit occupy was
   counted as space the work could use, and the work sat down on top of them.
   ⚠⚠ MATTED ONLY, AND THAT IS NOT A DETAIL. A bleed preset's plate is meant to
   cover the screen — `--plate-fit: cover` over the viewport's long edge — so
   shortening its stage would letterbox the one thing that family exists to do.
   A matted plate is already inset with the ground showing all round, so ending
   its stage early moves the work up and shows more of the same wall.
   ⚠ Not in section mode, which has its own chrome. */
body:not(.section).f-matted.fam-sequence #stage,
.f-matted.fam-sequence body:not(.section) #stage { bottom: var(--bottom-band); }

.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));
  display: flex;
  gap: var(--thumb-gap, 13px);
  z-index: 3;
  transition: opacity 500ms ease;
  /* ⚠⚠ THE RAIL RUNS OFF THE LEFT EDGE WITHOUT THIS, AND TAKES WORKS WITH IT.
     It is anchored RIGHT and grows leftward with no wrap and no scroll, so a
     body of work longer than the window puts its first thumbs off-screen where
     nothing can reach them — `gesso` on the paintings is 35 thumbs, about
     3000px of rail. ⚠ Found 2026-09-10, the same day and the same shape as the
     grid's centring overflow: both were unreachable code until the museum
     series grew past six works, and five sequences cross it now.
     ⚠ A SCROLL, NOT A WRAP. The rail is one line on purpose — it carries the
     ORDER — and a second line would say the order breaks there. Capping the
     count was the other option and it is worse: a rail is the navigation, so a
     work missing from it is a work with no way to it. */
    /* ⚠⚠ AND IT SITS ABOVE THE CREDIT RATHER THAN BESIDE IT. Owner's call
     2026-09-10, chosen from four drawn comps of this band. Reserving a column
     for the credit was the previous attempt: it stopped the overlap and left
     the rail shoved into the right-hand half with a hole beside it, and it
     still could not fit thirty-five thumbs. Lifting the rail one row gives it
     the WHOLE width and gives the credit the floor, so neither has to yield.
     ⚠ It also fixes the credit for free: `sizeCredit()` measures whether the
     two share a band, and once they do not it hands the credit the page — so
     the line stops wrapping without anyone capping it. */
  bottom: calc(var(--rail-inset-y, max(3vh, 20px))
               + var(--credit-band, 62px));
  /* ⚠⚠ BOTH EDGES PINNED, NOT `right` PLUS A max-width. With only `right` set
     the box is shrink-to-fit and the browser decides its width from content and
     available space — which is exactly the ambiguity that let 35 thumbs keep
     spilling past the left edge after the max-width was added. Pinning `left`
     and `right` makes the box precisely the viewport minus its insets, so the
     overflow has nowhere to go but the scroller.
     ⚠ `safe flex-end` keeps a SHORT rail hugging the right, which is where it
     has always lived, and falls back to flex-start the moment it overflows so
     the first thumb stays reachable — the same lesson as the grid's
     `safe center`: an alignment that overflows hides its own start. */
  left:  var(--rail-inset-x, max(3vw, 22px));
  justify-content: safe flex-end;
  overflow-x: auto;
  overflow-y: hidden;
  scrollbar-width: thin;
  scrollbar-color: rgba(255, 255, 255, 0.22) transparent;
  /* the thumbs must not shrink to fit — they scroll instead */
  flex-shrink: 0;
}
#rail > * { flex-shrink: 0; }
/* a matted sequence: the rail centred under the photograph (presets.js → rail-under, 2026-09-13); `safe` keeps a
   long rail's first thumb reachable, as `safe flex-end` does for the corner */
.rail-under body:not(.section) #rail { justify-content: safe center; }
#rail::-webkit-scrollbar { height: 6px; }
#rail::-webkit-scrollbar-track { background: transparent; }
#rail::-webkit-scrollbar-thumb {
  background: rgba(255, 255, 255, 0.22);
  border-radius: 3px;
}

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);
  /* ⚠ Centred, so it overflows BOTH edges — the same trap, twice as bad.
     ⚠ It sits in the MIDDLE of the page, not the bottom band, so the credit is
     not beside it and it keeps the full width.
     ⚠ `left: 50%` + `translateX(-50%)` here, so this one IS shrink-to-fit on
     purpose and takes a max-width rather than a pinned pair. */
  max-width: calc(100vw - max(3vw, 22px) * 2);
  justify-content: safe center;
}
.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 {
  /* ⚠⚠ A CELL NEVER OUTGROWS THE SCREEN. --cell-size is a fixed length (152px), and a declared count of columns
     does not wrap: three or four across on a phone ran off the right edge, the last column half-shown (the owner,
     2026-09-13, on gallery-wall and darkroom). --cell is the size actually drawn — the preset's own, or the width
     the grid has divided by its columns, whichever is SMALLER — so a grid that fits is exactly as it was.
     ⚠ Its own name, because --cell-size cannot be redefined in terms of itself. A grid of auto-fit declares no
     count and wraps by itself; 1 is only the fallback that keeps the calc valid. */
  --cell: min(var(--cell-size, 154px),
              calc((100vw - 2 * var(--credit-inset-x, 5vw) - (var(--grid-columns, 1) - 1) * var(--cell-gap, 18px)) / var(--grid-columns, 1)));
  grid-template-columns: repeat(var(--grid-columns, auto-fit), var(--cell));
}
.fam-grid #grid > .cell { width: var(--cell); height: var(--cell); }

/* 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) * 2 + var(--cell-gap, 18px));
  height: calc(var(--cell) * 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;
  /* ⚠ One arrangement is 800:550; a sheet holding more works than its
     arrangement has frames repeats it DOWN the page, one arrangement-height per
     repeat. --sheet-pages is set by gallery.html from the same count it uses
     to rescale every frame's y and h — one number, two readers. */
  aspect-ratio: 800 / calc(550 * var(--sheet-pages, 1));
  /* ⚠ FIXED WINDOW (2026-09-13): one sheet is as tall as the window allows — a sheet never scrolls; more works
     go onto the next sheet, reached by the menu above it (loupe.js → #sheets). --sheet-pages is now always 1. */
  max-width: calc((100svh - 190px) * 800 / 550);
  min-height: 0;
  padding: 0;
  align-content: initial;
  justify-content: initial;
}
/* ⚠⚠ THE CREDIT'S BAND — owner's call 2026-09-10, from two drawn comps. A
   sheet repeats down the page, so its works never clear the pinned credit the
   way a grid's last row does; at 1200px centred the margin beside it was too
   narrow for the credit's 160px floor below a ~1900px window, and the credit
   ran into the first photograph ("forty-six" over the leftmost frame).
   ⚠ So the sheet leaves, on each side, the credit's own inset + a 220px
   column + the 44px gutter sizeCredit() keeps — and the credit stays bottom
   left, where every gallery family puts it. The cost is width: 859px at a
   1480px window, 747 at 1360, 408 at 1000.
   ⚠ `.credit-top` is the OTHER position, the artist's to choose in build mode
   (presets.js → `credit`): the credit becomes a heading over the sheet and
   scrolls with it, and the sheet takes its full width back.
   ⚠ BELOW 900px THERE IS NO ROOM FOR A BAND at all (at 400px it would leave
   the sheet no width), so a narrow window takes the top position regardless. */
.fam-sheet #grid {
  width: min(1200px, calc(100vw - 2 * (max(3vw, 22px) + 220px + 44px)));
}
.fam-sheet.credit-top #grid { width: min(94vw, 1200px); }
.fam-sheet.credit-top #plate-text {
  position: relative;
  left: auto; right: auto; top: auto; bottom: auto;
  max-width: none;
  width: min(94vw, 1200px);
  box-sizing: border-box;
  margin: max(10vh, 80px) auto 12px;
  /* aligned with the sheet's first photographs, which start 4% in */
  padding-inline: calc(min(94vw, 1200px) * 0.04);
}
@media (max-width: 900px) {
  .fam-sheet #grid { width: min(94vw, 1200px); }
  .fam-sheet #plate-text {
    position: relative;
    left: auto; right: auto; top: auto; bottom: auto;
    max-width: none;
    width: min(94vw, 1200px);
    box-sizing: border-box;
    margin: max(10vh, 80px) auto 12px;
    padding-inline: calc(min(94vw, 1200px) * 0.04);
  }
}
/* ⚠ 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 WAY BACK TO THE PRESETS — Layer 2, 2026-09-02. The owner, previewing
 * a gallery from the picker: "When people view a template they need a way to
 * get back from that view ... I have been clicking on loupe in the upper
 * right, but that takes me back to the landing page." The mark's job is to
 * go home and stays so. This is a second, quieter control at the opposite
 * corner, drawn by the page ONLY when the gallery was opened from the picker
 * (`?medium=` in the URL), and it returns to the picker filtered the same
 * way. A customer's gallery never carries the parameter and never shows it. */
#back {
  position: fixed;
  left: var(--credit-inset-x, max(3vw, 22px));
  top:  var(--credit-inset-y, max(3vh, 22px));
  z-index: 4;
  text-decoration: none;
  font-family: var(--font-label, monospace);
  font-size: var(--label-size, 11px);
  letter-spacing: var(--label-tracking, 0.2em);
  text-transform: var(--label-case, lowercase);
  color: var(--ink-dim, #b8ada2);
  text-shadow: var(--chrome-shadow, none);
  opacity: 0;
  transform: translateY(-6px);
  transition: opacity 1.2s ease 0.4s, transform 1.2s ease 0.4s, color 180ms ease;
}
#back:hover, #back:focus-visible { color: var(--ink, #e8dcc9); }
#back:focus-visible { outline: 2px solid var(--accent, #ffb05a); outline-offset: 4px; }
body.ready #back { opacity: 1; transform: none; }
#back[hidden] { display: none; }

/* ⚠⚠ 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. */
/* ⚠⚠ NO LOBE WHERE THERE IS NOTHING TO LIFT — owner's call 2026-09-10, seeing
   it on `contact`: "we can lose the oval faded background behind loupe".
   The scrim exists for ONE reason, live text over a PHOTOGRAPH. Measured
   against LOUPE_BEHIND across all nineteen presets: every grid (9) and every
   sheet (3) draws no plate at all, so the mark there sits on flat ground and
   the lobe lifts nothing — it reads as a smudge behind the one element on the
   page that should be crisp. All seven sequences DO put a full-bleed plate
   behind it, and there it is doing the job it was written for, so it stays.
   ⚠⚠ THE TEST IS THE FRAMING, NOT THE FAMILY, and family was the first answer
   and the wrong one. A MATTED plate is inset — the corner it leaves is flat
   ground, so `gesso` wore a lobe over nothing (owner, 2026-09-10). Only a
   sequence whose plate BLEEDS puts a photograph under the mark, and there are
   exactly three: boardwalk, riso, frieze. Every other preset in the set — all
   nine grids, all three sheets, and the four matted sequences — is flat there.
   ⚠ Through `--scrim-mark`, which the note below already names as the way to
   do this ("Set --scrim-mark to 0 on any ground that should go without").
   ⚠ `.fam-*` and `.f-*` are on the ROOT element (see `.fam-grid body` above),
   and loupe.css loads after base.css, so these beat base's default. */
:root { --scrim-mark: 0; }
.fam-sequence.f-bleed { --scrim-mark: 1; }

/* ⚠⚠ AND NO CORNER SCRIM EITHER, ON THE SAME TEST — owner's call 2026-09-10,
   on `gesso` over the paintings: the grey lobe under the credit "is obscuring
   the text and performing no function without a full bleed." #shade's credit
   lobe exists to keep that line readable over a PHOTOGRAPH; a matted plate is
   inset, so the credit sits on flat ground and the lobe is only a smudge. On
   gesso it was DARKER than the ground: g-wall's scrim is 25 26 27, and only
   the `wall` preset pins a light one (`.p-wall.g-wall` in presets.css).
   ⚠ Every matted or edged sequence — gesso, proof-grey, ledgerlines, paper,
   portfolio, plinth, bolt. The three that bleed (boardwalk, riso, frieze)
   keep it, because there a photograph IS under the credit.
   ⚠ Sequences only. A grid can scroll its cells under the credit, which is
   the job body.section's own #shade does, and that is a separate question.
   ⚠ BUILD MODE: the owner wants this back as a configurable item in the
   editor, offered together with the choice of full bleed — not a fixed rule. */
.fam-sequence:not(.f-bleed) #shade { display: none; }

#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;
  /* ⚠ Its own column, so it never reaches into the grid beside it. The old
     `100vw - 500px` was ~1500px on a wide window — wide enough to run the
     credit line straight under a centred grid, which is exactly what happened
     on `gallery-wall`. */
  max-width: var(--credit-max, var(--credit-col, 360px));
}

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;
  /* ⚠⚠ `safe`, AND IT IS NOT COSMETIC — without it a grid taller than the
     viewport LOSES ITS FIRST ROWS AND THEY CANNOT BE REACHED. Centred content
     that overflows overflows BOTH ways, and nothing scrolls above a container's
     start, so the top of the grid sits off-screen with no way back to it. The
     same goes sideways on a narrow window.
     ⚠ `safe` falls back to `start` on the axis that overflows and changes
     nothing on the axis that fits, so a grid that fits is still centred exactly
     as before.
     ⚠ Found 2026-09-10 by the owner on `case` — three columns with a 2x2 lead,
     so fifteen works make SIX ROWS. It was unreachable code until the museum
     series grew from six works to twenty-two and more: before that no grid was
     ever tall enough to overflow, which is why a layout bug this plain survived
     nineteen presets. */
  align-content: safe center;
  justify-content: safe center;
  grid-template-columns: repeat(auto-fit, var(--cell-size, 154px));
  gap: var(--cell-gap, 18px);
  padding: 14vh var(--credit-inset-x, 5vw) 22vh;
  /* ⚠⚠ THE LAST ROW MUST CLEAR THE STANDING CREDIT, which is `position: fixed`
     — the grid scrolls UNDER it, so `22vh` of a viewport that is never scrolled
     to the end is not a guarantee. A grid of twenty-five at eight across left
     one work in the credit's lap and read as a fault, when a short last row is
     ordinary (owner, 2026-09-10). ⚠ Reserving the band beats capping the count:
     the cap that was here cured it by HIDING WORKS — thirteen of twenty-five on
     a narrow window — which is the same fault as a rail running off the edge.
     ⚠ `--credit-band` is the credit's MEASURED height (sizeCredit), so this
     follows a two-line credit as well as a one-line one. */
  padding-bottom: max(22vh, calc(var(--credit-band, 62px) + 72px));
}

.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;
}

/* the placement (the app, 0043): the point kept in view, and a zoom about that same point — one drawing, the
   editor's and the visitor's; whole shows the picture entire and the cell's own ground is its mat */
.cell[data-fit] img { object-position: var(--cx, 50%) var(--cy, 50%); transform: scale(var(--cz, 1)); transform-origin: var(--cx, 50%) var(--cy, 50%); }
.cell[data-fit="whole"] img { object-fit: contain; }
/* behind a whole picture (the app, 0046 — the owner, 2026-09-14: "some users might just want to see the photo against
   the page background"): the page itself, so nothing of the cell shows around it — no ground, line or shadow — unless
   the gallery keeps the template's mat. A manifest without `mat` (the demo's) keeps the mat, as it always has. */
#grid[data-mat="page"] .cell[data-fit="whole"],
#grid.live[data-mat="page"] .cell[data-fit="whole"]:hover,
#grid.live[data-mat="page"] .cell[data-fit="whole"]:focus-visible { background: transparent; border-color: transparent; box-shadow: none; }

#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 {
  /* ⚠ A containing block for the "Look closer" control, which sits at the
     picture's own corner. The transform below already makes one; this says
     so on purpose, so removing the transform on some theme cannot silently
     drop the control to the page's corner. */
  position: relative;
  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; }

/* ⚠⚠ THE WALL LABEL. A gallery label is a stack, not a sentence: the title and
 * year on one line, then medium, dimensions and edition beneath. `.meta` is a
 * single tracked line and stays exactly that when the dial is off — the counter
 * it has always shown. `.wall` is added only when there is a record to draw.
 *
 * ⚠ Tracking is REMOVED here and kept on the counter. 0.32em is right for
 * `07 / 18`, which is a label in the typographic sense; it is wrong for a
 * title and a medium, which are prose and become unreadable spaced out.
 *
 * ⚠ The title line takes the display face and normal case. A wall label prints
 * a title as the artist wrote it — lowercasing "Valle Crucis" through
 * --label-case would be the chrome overruling the artist, which is the same
 * mistake as putting our name in their title slot. */
#light .meta.wall {
  letter-spacing: normal;
  text-transform: none;
  line-height: 1.5;
  max-width: min(52ch, 88vw);
  margin: 0 auto;
}
#light .meta.wall span { display: block; }

/* ⚠⚠ THE MOUNT GIVES THE LABEL ITS ROOM. `.meta` is fixed to the bottom edge
 * and was sized for one tracked line under a picture allowed 86vh — so a
 * three-line label rode up over the mat and the bottom of the picture on
 * every enlargement tall enough to reach its ceiling. Found by the owner on
 * the Caillebotte, 2026-09-02. The fix is geometric, not a smaller label: a
 * wall label takes a band at the bottom, the mount's bottom margin lifts the
 * centred picture by half of it, and the picture's ceiling drops by all of
 * it, so the mat's bottom edge stays above the label at any viewport.
 * ⚠ `--label-room` is the whole band; a title alone needs less than a
 * record. Both floors are pixels because a label's height is set by type,
 * not by the viewport, and at 800px tall 12vh is under four lines. */
.lb-title  #light .mount { --label-room: max(6vh, 48px); }
.lb-record #light .mount { --label-room: max(12vh, 110px); }
#light .mount { margin-bottom: var(--label-room, 0px); }
#light img   { max-height: calc(var(--enlarge-max-h, 86vh) - var(--label-room, 0px)); }
/* ⚠⚠ THREE LEVELS, NOT TWO, once a maker can lead — 2026-09-09. A museum wall
 * label reads WHO MADE IT, then WHAT IT IS, then WHAT IT IS MADE OF, and each
 * of those is a different weight. With only two levels the maker took the
 * title's face and the title fell in with the dimensions, which is what
 * shipped for one deploy. ⚠ The maker is the largest because that is the wall
 * label's own convention; the title stays in the display face so it does not
 * fall in with the medium; the details stay faint. */
#light .meta.wall .wl-m {
  font-family: var(--font-display, inherit);
  font-size: calc(var(--sub-size, 10px) * 1.32);
  color: var(--ink, #cfc6bd);
}
#light .meta.wall .wl-t {
  font-family: var(--font-display, inherit);
  font-size: calc(var(--sub-size, 10px) * 1.32);
  color: var(--ink, #cfc6bd);
}
/* ⚠ With a maker above it the title steps down a level, so the two bright
   lines do not compete — but it keeps the display face and the full ink, which
   is what separates it from the medium below. */
#light .meta.wall .wl-m + .wl-t { font-size: var(--sub-size, 10px); }
/* the record lines — quieter than the title, and quieter than the work */
#light .meta.wall .wl-d { color: var(--ink-faint, #6e6660); }

/* ---------- look closer ---------- */
/* The deep-zoom control and layer — DEEPZOOM.md. Classes are `closer-`;
 * ⚠ never `.loupe` (this product is loupe, and loupe.css and loupe.js are
 * already here) and never "tile" (the colour swatches). The engine draws the
 * control; closer.js appends the layer to <body>.
 *
 * ⚠⚠ EVERY CONTROL SITS ON ITS OWN DARK TRANSLUCENT DISC, WHATEVER THE THEME.
 * The ground under the layer is the theme's --lit-ground and the picture is
 * whatever the artist made; light type over a white-bordered print is no
 * control at all — the prototype's first cut drew its controls in the
 * paper's cream and on a white-bordered print the 1:1 and Close controls
 * simply were not there. So the discs are fixed dark, the glyph and type
 * fixed light, and only the WORDS follow the theme (--font-label,
 * --label-case), like every other piece of chrome. */

.closer-open {
  position: absolute;
  /* ⚠ --mount-pad is a unitless 0 on bleed framings, which is valid as an
     offset and NOT inside calc() — so the 10px inset is a margin, which an
     absolutely positioned box takes from the edge it is offset from. */
  right:  var(--mount-pad, 0);
  bottom: var(--mount-pad, 0);
  margin: 10px;
  width: 36px;
  height: 36px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.55);
  color: rgba(255, 255, 255, 0.94);
  display: grid;
  place-items: center;
  cursor: zoom-in;
  opacity: 0.6;
  transition: opacity 300ms ease;
  z-index: 1;
  -webkit-tap-highlight-color: transparent;
}
.closer-open:hover,
.closer-open:focus-visible { opacity: 1; outline: none; }
.closer-open:focus-visible { box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85); }
.closer-open svg {
  width: 18px;
  height: 18px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}
/* ⚠ `display: grid` above outranks the UA's [hidden] — the prototype's
   second trap. Explicit, and !important so no later rule un-hides it. */
.closer-open[hidden] { display: none !important; }
@media (pointer: coarse) {
  .closer-open { width: 44px; height: 44px; opacity: 0.8; }
  .closer-open svg { width: 22px; height: 22px; }
}

/* the layer: body-level, outside every transform, above #light (5) AND above
   #edge (6) — the deep view is a viewer, a separate mode, and the frame does
   not follow it into it. Default answer in DEEPZOOM.md. */
.closer {
  position: fixed;
  inset: 0;
  z-index: 7;
  background: var(--lit-ground, #0a090c);
  color: rgba(255, 255, 255, 0.94);
  font-family: var(--font-label, monospace);
  overscroll-behavior: contain;
  touch-action: none;
}
.closer[hidden] { display: none !important; }
.closer-stage {
  position: absolute;
  inset: 0;
  cursor: grab;
}
.closer-stage:active { cursor: grabbing; }
.closer-stage canvas { outline: none; }
/* ⚠ The enlargement, under the canvas, until the view has fully drawn — so the
   layer opens onto the picture already on screen and sharpens. Same fit as
   OpenSeadragon's home (contain, centred); closer.js sets and clears it. */
.closer-under {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
  pointer-events: none;
  opacity: 1;
  transition: opacity 0.35s ease;
}
.closer-under.gone { opacity: 0; }
.closer-under[hidden] { display: none !important; }
@media (prefers-reduced-motion: reduce) { .closer-under { transition: none; } }

.closer-controls {
  position: absolute;
  top: max(3vh, 22px);
  right: max(3vw, 22px);
  display: flex;
  gap: 8px;
  z-index: 1;
  margin: 0;
}
.closer-controls button {
  height: 34px;
  padding: 0 14px;
  border: 0;
  border-radius: 17px;
  background: rgba(0, 0, 0, 0.55);
  color: rgba(255, 255, 255, 0.94);
  font-family: inherit;
  font-size: var(--label-size, 11px);
  letter-spacing: var(--label-tracking, 0.12em);
  text-transform: var(--label-case, none);
  cursor: pointer;
  opacity: 0.8;
  transition: opacity 250ms ease;
  -webkit-tap-highlight-color: transparent;
}
.closer-controls button:hover,
.closer-controls button:focus-visible { opacity: 1; outline: none; }
.closer-controls button:focus-visible { box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85); }
@media (pointer: coarse) {
  .closer-controls button { height: 42px; padding: 0 16px; border-radius: 21px; }
}

/* the first-use hint: once, dismissed by the first gesture or six seconds */
.closer-hint {
  position: absolute;
  left: 0; right: 0;
  bottom: max(2.4vh, 18px);
  text-align: center;
  pointer-events: none;
  z-index: 1;
  margin: 0;
  font-size: var(--label-size, 11px);
  letter-spacing: var(--label-tracking, 0.12em);
  text-transform: var(--label-case, none);
}
.closer-hint span {
  display: inline-block;
  padding: 9px 16px;
  border-radius: 17px;
  background: rgba(0, 0, 0, 0.55);
  color: rgba(255, 255, 255, 0.94);
}
.closer-hint[hidden] { display: none !important; }

/* ⚠ The navigator is drawn by OpenSeadragon inside the stage; these pull its
   box onto the same dark disc language as everything else. Its border and
   region colours are set from tokens in closer.js. */
.closer-stage .navigator {
  border-radius: 3px;
  overflow: hidden;
  box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
}
@media (max-width: 720px) {
  .closer-controls { top: auto; bottom: max(2.4vh, 18px); right: 50%; transform: translateX(50%); }
  .closer-hint { bottom: calc(max(2.4vh, 18px) + 54px); }
}

/* ---------- 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) {
  /* ⚠⚠ A COLUMN THAT SCROLLS — the owner, 2026-09-13, on a phone: "animated
     gallery thumbs on mobile dont scroll to reveal the whole group. you're
     locked to what you see on screen." The desktop rail scrolls SIDEWAYS
     (overflow-x auto, overflow-y hidden, both edges pinned); turned on its
     side here it kept overflow-y: hidden and was centred by a transform with
     no height, so a long series ran off the top and bottom where nothing
     could reach it — the same trap the desktop rail's note describes, rotated.
     Now TOP AND BOTTOM are pinned (so the box is the screen's height, not the
     content's), the scroll is vertical, and `safe center` keeps a short rail
     in the middle while a long one starts at its first work.
     ⚠ `.rail-centre` places its rail with left/top 50% and a transform, at a
     higher specificity — so it is named here too, or it keeps the trap. */
  #rail,
  .rail-centre body:not(.section) #rail {
    flex-direction: column;
    /* below the mark, which holds the top-right corner on every public page */
    top: calc(max(4vh, 24px) + 48px + env(safe-area-inset-top, 0px));
    bottom: calc(max(4vh, 24px) + env(safe-area-inset-bottom, 0px));
    left: auto;
    right: calc(var(--rail-inset-x-sm, 13px) + env(safe-area-inset-right, 0px));
    transform: none;
    max-width: none;
    gap: var(--thumb-gap-sm, 10px);
    justify-content: safe center;
    overflow-x: hidden;
    overflow-y: auto;
    overscroll-behavior: contain;
    -webkit-overflow-scrolling: touch;
    touch-action: pan-y;
  }
  /* the centred rail stays in the middle, as it was — only its scroll changes */
  .rail-centre body:not(.section) #rail { right: auto; left: 50%; transform: translateX(-50%); }

  .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; }
}

/* ===== THE SHEETS MENU — fixed window, 2026-09-13 =====================
 * sheet 1 | sheet 2 | … above the sheet, centred, in the credit's own letters; the one showing is underlined.
 * ⚠ Drawn only when a gallery has more than one sheet (loupe.js adds .sheeted and the nav together). */
#sheets {
  display: flex; justify-content: center; flex-wrap: wrap; gap: 4px 0;
  margin: max(6vh, 48px) auto 18px; position: relative; z-index: 3;
  font-family: var(--font-label, var(--font-display, monospace)); font-size: 12px; letter-spacing: var(--label-tracking, 0.16em); text-transform: uppercase;
  color: var(--ink, #e8dcc9);
}
#sheets button {
  font: inherit; letter-spacing: inherit; text-transform: inherit; color: inherit; background: none; border: 0;
  padding: 4px 14px; cursor: pointer; opacity: 0.62;
}
#sheets button + button { border-left: 1px solid currentColor; }
#sheets button:hover, #sheets button:focus-visible { opacity: 1; }
#sheets button[aria-current] { opacity: 1; text-decoration: underline; text-underline-offset: 5px; }
