/* loupe — the engine.
 *
 * No appearance decisions live here. Every color, 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 color.
 */

* { 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,
.fam-scroll body,
.fam-row body,
.fam-story 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); }
/* ⚠ AND THE PICTURE FITS THAT STAGE, not the window (the owner, 2026-09-15, on gesso: "part of the photo looks like it is getting
   cut off"): a mat is sized from 100vmin, but the stage stops above the band — at 1000×562 a 472px picture in a 361px stage, its top
   56px off the screen. The side is the smaller of the window's short edge and the stage's own height, inset the same way. */
.f-matted.fam-sequence body:not(.section) .plate img {
  --plate-side: min(calc(100vmin - 2 * var(--plate-inset, 0) * 1vmin),
                    calc(100vh - var(--bottom-band, 0px) - 2 * var(--plate-inset, 0) * 1vmin));
  width: var(--plate-side); height: var(--plate-side);
  margin: calc(var(--plate-side) / -2) 0 0 calc(var(--plate-side) / -2);
}

.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 color 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 color 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 colors — 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 color, 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 :is(#grid, .loupe-grid).live .cell:hover img,
.duotone :is(#grid, .loupe-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 :is(#grid, .loupe-grid).live .cell:hover::after,
.duotone :is(#grid, .loupe-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.
   The other four — Grid, Sheet, Scroll and Row — all render into #grid, and
   what separates them is this stylesheet. Scroll and Row are at the foot of
   the section. */

/* ===== 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.
 */


/* a widened work: its own band, full width of the grid, never cropped */
.fam-grid :is(#grid, .loupe-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 :is(#grid, .loupe-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 :is(#grid, .loupe-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 COLORS. 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 :is(#grid, .loupe-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 :is(#grid, .loupe-grid) {
  width: min(1200px, calc(100vw - 2 * (max(3vw, 22px) + 220px + 44px)));
}
.fam-sheet.credit-top :is(#grid, .loupe-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 :is(#grid, .loupe-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);
  }
}
/* ⚠⚠ ON A PHONE, THE WORKS FILL IT (2026-09-18; loupe.js → fitSheet). Only the box the shown sheet's works occupy is
   drawn (--bx --by --bw --bh, percentages of the 800:550 stage), each frame re-placed inside it, and that box is as
   large as the screen allows — the width less a 12px edge, the height less the heading above it. The arrangement's
   breathing room around its works is simply not drawn. ⚠ NOT BY ENLARGING THE STAGE AND SHIFTING IT: tried first, and a
   stage reaching past the screen's edges made the phone widen the whole page (390 → 504px) — nothing here may be
   wider than the screen. A phone is a narrow window (upright) or a short one (on its side); a desktop is untouched. */
@media (max-width: 900px), (max-height: 520px) {
  html.fam-sheet :is(#grid, .loupe-grid) {
    --fit-w: calc(100vw - 24px);
    --fit-h: calc(100svh - var(--sheet-head, 150px));
    width: min(var(--fit-w), var(--fit-h) * 800 * var(--bw, 100) / (550 * var(--bh, 100)));
    max-width: none;
    aspect-ratio: calc(800 * var(--bw, 100)) / calc(550 * var(--bh, 100));
    margin: 0 auto 24px;
  }
  /* ⚠ html. — this block sits ABOVE the stage's own placement rule below, and at equal weight the later one won:
     the box was drawn and the frames kept their stage places inside it */
  html.fam-sheet :is(#grid, .loupe-grid) > .cell {
    left:   calc((var(--fx, 4) - var(--bx, 0)) / var(--bw, 100) * 100%);
    top:    calc((var(--fy, 6) - var(--by, 0)) / var(--bh, 100) * 100%);
    width:  calc(var(--fw, 22) / var(--bw, 100) * 100%);
    height: calc(var(--fh, 30) / var(--bh, 100) * 100%);
  }
}
/* ON ITS SIDE the heading moves BESIDE the works — the title, the name and the sheets in a column at the left — so the
   whole height goes to the works: measured, the sheet began 213px down a 300px screen, and with the heading folded
   above it the works were still only 212px tall. Width is what a phone on its side has to spare. */
@media (max-height: 520px) and (orientation: landscape) {
  html.fam-sheet :is(#grid, .loupe-grid) {
    --sheet-head: 24px; --fit-w: calc(100vw - 260px);
    --w: min(var(--fit-w), var(--fit-h) * 800 * var(--bw, 100) / (550 * var(--bh, 100)));
    width: var(--w);
    margin: 12px 0 12px max(236px, calc((100vw - var(--w)) / 2));
  }
  html.fam-sheet #plate-text { position: fixed !important; left: 16px !important; top: 14px !important; width: 210px !important;
    margin: 0 !important; padding: 0 !important; z-index: 4; }
  html.fam-sheet #sheets { position: fixed; left: 16px; top: 96px; width: 210px; margin: 0; flex-direction: column;
    align-items: flex-start; gap: 6px; }
  html.fam-sheet #sheets button { padding: 2px 0; }
  html.fam-sheet #sheets button + button { border-left: 0; }
}
/* ⚠ 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 :is(#grid, .loupe-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 :is(#grid, .loupe-grid) > .band { position: absolute; display: block; }


/* ===== SCROLL — continuously, in order, down ===========================
 *
 * ⚠⚠ THE FAMILY THAT ANSWERS THE AXIS A THIRD WAY. Grid and Sheet show all of
 * them; Sequence shows one and replaces it. Here the works are all present,
 * laid end to end down one column, and the VISITOR'S OWN SCROLL is the
 * transport — two works are partly visible at every boundary and nothing ever
 * steps. presets.js → LOUPE_FAMILIES carries the reasoning and the naming.
 *
 * ⚠⚠ TWO TRACKS, AND THE MARGIN IS THE POINT. The record sits BESIDE its work,
 * never under it: underneath, a caption in a continuous column belongs to
 * nothing in particular by the time you reach it. `lb-none` drops the margin
 * track entirely rather than leaving an empty gutter — a bare column of work is
 * a real setting, not a broken one.
 *
 * ⚠ The work is `height: auto` and takes the picture's own ratio. It must never
 * be `object-fit: cover`: a Scroll that crops is a Grid with extra scrolling,
 * which is not a family.
 */
.fam-scroll :is(#grid, .loupe-grid) {
  --label-gap: clamp(16px, 2.4vw, 34px);
  --margin-col: clamp(88px, 11vw, 168px);
  display: grid;
  grid-template-columns: var(--margin-col) minmax(0, 1fr);
  column-gap: var(--label-gap);
  row-gap: var(--scroll-gap, 11vh);
  align-content: start;
  justify-content: center;
  width: min(var(--scroll-measure, 1040px), 92vw);
  margin-inline: auto;
  padding: 12vh 0 max(22vh, calc(var(--credit-band, 62px) + 72px));
  min-height: 100vh;
}
/* no record asked for: one track, and the column narrows because nothing sits
   beside it any more */
.fam-scroll.lb-none :is(#grid, .loupe-grid) {
  --margin-col: 0px;
  --label-gap: 0px;
  grid-template-columns: minmax(0, 1fr);
  width: min(var(--scroll-measure, 880px), 92vw);
}

.fam-scroll :is(#grid, .loupe-grid) > .cell {
  width: 100%;
  height: auto;
  overflow: visible;
  /* the framing's own mat, exactly as the enlargement wears it. f-bleed sets
     --mount-pad: 0 and --mount-bg: transparent, so bleed needs no rule here. */
  padding: var(--mount-pad, 0);
  background: var(--mount-bg, transparent);
  border: var(--mount-border, 0);
  border-radius: 0;
}
.fam-scroll.f-bleed :is(#grid, .loupe-grid) > .cell { box-shadow: none; }
.fam-scroll :is(#grid, .loupe-grid) > .cell img {
  width: 100%;
  height: auto;
  object-fit: contain;
}
/* ⚠ A HAIRLINE BETWEEN WORKS, never above the first. `.cell ~ .cell` is every
   work that has one before it, which is the rule stated exactly; counting with
   :first-of-type would not work because a cell is a <button> or an <a>
   depending on whether the work links somewhere.
   ⚠ It lives in the GAP, so it follows --scroll-gap and cannot drift from it,
   and it reaches back across the margin track so the rule spans the measure
   rather than just the picture. Matted only: on a bleed ground there is nothing
   for a rule to sit on and it would read as a scratch on the photograph. */
.fam-scroll:not(.f-bleed) :is(#grid, .loupe-grid) > .cell ~ .cell::before {
  content: '';
  position: absolute;
  top: calc(var(--scroll-gap, 11vh) / -2);
  left: calc(-1 * (var(--margin-col) + var(--label-gap)));
  right: 0;
  border-top: 1px solid var(--edge-color, rgba(127, 127, 127, 0.28));
}

/* ===== STORY — continuously, in order, alone or side by side (2026-10-12, `fieldbook`) ===========
 *
 * ⚠⚠ THE INVERSION OF A GRID, at the owner's word: "predominantly full size images flowing down the page, alternating
 * with rows of two to four across". Each stop down the page holds one work or a row of two to four; which is which is
 * decided by presets.js → LOUPE_ROWS_OF and written on each cell by LOUPE_LAY_ROWS — its picture's shape `--ar`, the
 * sum of its row's shapes `--sum`, how many share the row `--n`, and `data-first` / `data-last`. Everything else is here.
 * ⚠ ONE HEIGHT ACROSS A ROW, NOTHING CROPPED: a work's width is the row's width shared out by shape (`--ar / --sum`), so
 *   every picture in it stands as tall as the others, and each keeps its whole frame.
 * ⚠ EVERY STOP IS THE COLUMN'S WIDTH, so the edges line up all the way down (see EVEN EDGES below).
 * ⚠ NO WRAPPER AROUND A ROW. `#grid > .cell` stays true for everything that reads it (the layout editor, the cards, the
 *   enlargement). A row ends because its first and last cells take the margins that make the line 1px short of the
 *   page, so no next work fits beside it and the next stop starts a new line.
 * ⚠⚠ TWO CLASSES, ALWAYS (`.fam-story.story-rows`): every rule here must outweigh the generic cell rules LATER in this
 *   file at one id and two or three classes — the caption's box (`.lb-under … > .pic`, a fixed height), a picture on its
 *   own background (`[data-ext][data-fit="whole"]`, drawn absolute in a sized box) — and does by its second class, not
 *   by where it stands.
 */
.fam-story.story-rows :is(#grid, .loupe-grid) > .cell {
  height: auto;
  overflow: visible;
  padding: var(--mount-pad, 0);
  background: var(--mount-bg, transparent);
  border: var(--mount-border, 0);
  border-radius: 0;
  box-shadow: none;
}
.fam-story.story-rows :is(#grid, .loupe-grid) > .cell img { width: 100%; height: auto; object-fit: contain; }
.fam-story.story-rows :is(#grid, .loupe-grid) {
  --row-gap: var(--story-gap, clamp(4px, 0.6vw, 10px));
  --side: 0px;
  --cap: calc(100vh - 2 * var(--row-gap));
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  align-content: flex-start;
  gap: var(--row-gap);
  grid-template-columns: none;
  width: 100%;
  box-sizing: border-box;
  margin: 0;
  padding: 0 var(--side) max(10vh, 72px);
  min-height: 0;
}
/* ⚠⚠ THE SIDES — margin | gutter | edge (2026-10-12, the owner: "a narrow white margin maybe a couple hundred pixels on
   each side. Going right to the edge can be an option, making a small gutter can be an option"). Every width below is
   the column's (`100%` of the padded box), so a row shares the column and a picture alone fills it. The cover alone
   ignores the sides. On a phone a margin is a gutter's 16px — a couple hundred pixels would leave the picture nothing.
   ⚠ 8vw, not the 15vw it shipped with (the owner, the same evening: "a bit wider on each side" → "106px") — and only the
   page before loupe.js has laid it: layRows writes the margin the recipe carries (`margin`), and a square's fit. */
.fam-story.story-rows.side-margin :is(#grid, .loupe-grid) { --side: clamp(16px, 8vw, 240px); --row-gap: var(--story-gap, clamp(6px, 0.9vw, 14px)); }
.fam-story.story-rows.side-gutter :is(#grid, .loupe-grid) { --side: var(--row-gap); }
.fam-story.story-rows.side-edge :is(#grid, .loupe-grid) { --side: 0px; }
@media (max-width: 600px) { .fam-story.story-rows.side-margin :is(#grid, .loupe-grid) { --side: 16px; } }
.fam-story.story-rows :is(#grid, .loupe-grid) > .cell {
  --rw: calc(100% - 1px);
  flex: 0 0 auto;
  width: calc((var(--rw) - (var(--n, 1) - 1) * var(--row-gap)) * var(--ar, 1.5) / var(--sum, 1.5));
  min-width: 0;
}
/* ⚠⚠ EVEN EDGES (the owner, 2026-10-12: "the default should be clean even left and right edges") — every stop is the
   column's width. Two exceptions: an upright alone (`data-hold`, by the artist's word or left over) and, on `story-whole`,
   every stop, held to the window's height and centred; and a row of landscapes (`data-bleed`) out to the page's edges. */
.fam-story.story-rows.story-whole :is(#grid, .loupe-grid) > .cell,
.fam-story.story-rows :is(#grid, .loupe-grid) > .cell[data-hold] {
  --rw: min(100% - 1px, var(--cap) * var(--sum, 1.5) + (var(--n, 1) - 1) * var(--row-gap));
}
/* ⚠ only through a MARGIN: with a gutter the page is the owner's "perfectly even moving down" (2026-10-12), and a row
   reaching past an 8px gutter would only break its edges */
.fam-story.story-rows.side-margin :is(#grid, .loupe-grid) > .cell[data-bleed] { --rw: calc(100% + 2 * var(--side) - 1px); }
.fam-story.story-rows :is(#grid, .loupe-grid) > .cell[data-first] { margin-left: calc((100% - var(--rw)) / 2 - 0.5px); }
.fam-story.story-rows :is(#grid, .loupe-grid) > .cell[data-last] { margin-right: calc((100% - var(--rw)) / 2 - 0.5px); }
/* ⚠ A CAPTION UNDER EACH WORK (`lb-under`, where a record is asked for): the grid's picture box is a fixed height
   (`--cell-h`) — here the picture's own shape is the height, as it is everywhere in a Scroll, and the frame is the
   picture itself. Outweighs `.lb-under … > .pic` by a class, wherever it stands in this file. */
.fam-story.story-rows :is(#grid, .loupe-grid) > .cell > .pic {
  height: auto;
  border: 0;
  border-radius: 0;
  background: none;
  box-shadow: none;
}
/* ⚠ the picture's own shape before it arrives, so a row is its height at once and the cover's name stands where it will */
.fam-story.story-rows :is(#grid, .loupe-grid) > .cell > .pic > img { width: 100%; height: auto; aspect-ratio: var(--ar, 1.5); }
.fam-story.story-rows :is(#grid, .loupe-grid) > .cell > .wl { display: block; padding: 8px 0 calc(var(--row-gap) * 2); }
/* ⚠⚠ NEVER CROPPED — what this family IS (the owner, 2026-10-12: "full size images go here, no crops that is what
   distinguishes the story family"). A crop placed on the same works for another template (a grid's `--cz`, `--cx`) is
   not drawn here: the picture is whole and unmoved, and its cell does not clip. Outweighs `:is(.cell, .thumb)[data-fit]
   img` and the zoom's clip by an id and classes. */
.fam-story.story-rows :is(#grid, .loupe-grid) > .cell[data-fit] img { transform: none; object-position: 50% 50%; object-fit: contain; }
.fam-story.story-rows :is(#grid, .loupe-grid) > .cell[data-zoom][data-fit] { overflow: visible; }
/* ⚠ NO HAIRLINE: the editor offers none on a Story, and a cell the engine marks `fill` drew one round the picture AND its
   caption on the visitor's page only (the review before landing) — a hairline kept for another template is kept, not drawn */
.fam-story.story-rows :is(#grid, .loupe-grid)[data-hairline] > .cell::after { content: none; }
.fam-story.story-rows :is(#grid, .loupe-grid) > .cell[data-ext][data-fit="whole"],
.fam-story.story-rows :is(#grid, .loupe-grid) > .cell[data-ext][data-fit="whole"] > .pic { overflow: visible; container-type: normal; }
.fam-story.story-rows :is(#grid, .loupe-grid) > .cell[data-ext][data-fit="whole"] img:not(.arr-ghost) {
  position: static; width: 100%; height: auto; transform: none;
}
/* the name over the works, as flush's (`credit-top` below) — the pictures run to both edges, so there is no corner */
.fam-story.story-rows.credit-top :is(#grid, .loupe-grid) { padding-top: 0; }
/* ⚠⚠ THE COVER (`story-cover`, 2026-10-12, the owner: "usually has full bleed image at the top with the title of the
   story"). The first work, across the WHOLE page whatever the sides, at its own shape — never cropped, which is what
   this family is — so a wide one may stand taller than the window and is scrolled through, as the first page of a
   picture story is. An upright or a square would then be two windows tall, so it is held to the window's height and
   centred on a band of the cover's ground as wide as the page. The gallery's name stands over it, at the foot of the
   first window (`--cover-h`, measured by loupe.js → layRows) — see `.story-cover #plate-text`. */
.fam-story.story-rows.story-cover :is(#grid, .loupe-grid) > .cell[data-cover] {
  width: calc(100% + 2 * var(--side) - 1px);
  margin: 0 calc(-1 * var(--side)) calc(var(--row-gap) * 2);
}
/* ⚠ the band is the CELL's, and the picture is found as a descendant: a cell has a `.pic` box only where a caption
   goes under it (lb-under), and is the picture's own frame otherwise — written first for the box alone, the colors
   cover (a square, no captions) ran 1319px tall */
.fam-story.story-rows.story-cover :is(#grid, .loupe-grid) > .cell[data-cover][data-upright] { background: var(--cover-ground, #141414); }
.fam-story.story-rows.story-cover :is(#grid, .loupe-grid) > .cell[data-cover][data-upright] > .pic { background: none; }
.fam-story.story-rows.story-cover :is(#grid, .loupe-grid) > .cell[data-cover][data-upright] img {
  display: block; width: auto; height: min(100vh, 100vw / var(--ar, 1)); max-width: 100%; margin-inline: auto;   /* on a phone, no taller than the picture across the screen */
}

/* ===== ROW — continuously, in order, across ============================
 *
 * ⚠⚠ THE FAMILY THAT USES THE FACT THAT WORK COMES IN DIFFERENT SHAPES. Every
 * work stands at ONE HEIGHT and takes whatever width its own proportion asks
 * for, so a panorama gets the room it needs and an upright gives it back — with
 * nobody placing anything, which is what separates this from a Sheet.
 * ⚠ Therefore it can never crop. `object-fit: cover` would make every width the
 * same and delete the family.
 *
 * ⚠ Two rows flowing BY COLUMN, so a label sits under its own work. The engine
 * appends cell-then-label for exactly this reason (loupe.js).
 */
.fam-row :is(#grid, .loupe-grid) {
  /* ⚠⚠ THE HEIGHT IS WHAT IS LEFT, NOT A FRACTION OF THE WINDOW. `58vh` ignored
     the two things standing under the band — the label row and the STANDING
     CREDIT, which is position: fixed — so on a short window the record under the
     first work sat straight on top of "paintings · art institute of chicago".
     A band that must not collide has to be sized from the room it actually has.
     ⚠ BOTH BANDS ARE MEASURED, not guessed — `--credit-band` by sizeCredit and
     `--label-band` by sizeRowBand, both in loupe.js. A label row is as tall as
     the longest record in the series, so no constant can be right for it: a
     guessed 96px put a five-line Seurat record 95px below the window and gave a
     horizontal band a vertical scrollbar.
     ⚠⚠ AND THE MAT COUNTS. `--mount-pad` sits above AND below the picture inside
     the cell, so a matted Row is 44px taller than its pictures and the band ran
     off the window by exactly that. Every band standing between the top of the
     window and the credit is subtracted here, once each, and the padding below
     reserves the credit and nothing else — subtracting the credit in BOTH places
     is the double-count this replaced. */
  --row-h: min(var(--row-height, 520px),
               calc(90vh - var(--credit-band, 62px) - var(--label-band, 92px)
                    - 2 * var(--mount-pad, 0px) - 38px));
  display: grid;
  /* ⚠⚠ `none` FIRST, AND WITHOUT IT NOTHING BELOW DOES ANYTHING. `#grid` declares
     `grid-template-columns: repeat(auto-fit, var(--cell-size))` — EXPLICIT tracks
     — and `grid-auto-columns` sizes only the IMPLICIT ones. So every work landed
     in a 152px explicit track with its picture hanging out of it, the widths all
     equal, which is the one thing this family exists not to do. Clearing the
     explicit tracks is what lets a column be as wide as its work. */
  grid-template-columns: none;
  grid-auto-flow: column;
  grid-auto-columns: max-content;
  /* ⚠⚠ `auto auto`, NOT `var(--row-h) auto`, AND THE HEIGHT GOES ON THE IMAGE.
     A column sized by `max-content` has to ask its contents how wide they are;
     an image at `height: 100%` answers "as wide as my ratio makes me, once you
     tell me my height" — and the height came from a track whose size was being
     computed. That circle resolves to ZERO and the whole band drew nothing on a
     ground that looked perfectly fine, which is the worst shape of layout bug.
     ⚠ Giving the IMAGE a definite height (--row-h is min() of two lengths, so it
     is one) breaks the circle: the width falls out of the ratio, max-content has
     a real answer, and the row is as wide as the works make it. */
  grid-template-rows: auto auto;
  /* ⚠ `safe`, for the reason `#grid` already gives at length: centred content
     that overflows overflows BOTH ways, and nothing scrolls above a container's
     start. Here it is the label row that overflows — a work with a four-line
     record — and without `safe` the band climbs off the top of the window. */
  align-content: safe center;
  justify-content: safe start;
  align-items: end;
  column-gap: var(--cell-gap, 26px);
  row-gap: 14px;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  padding: 10vh var(--credit-inset-x, 5vw) calc(var(--credit-band, 62px) + 24px);
  min-height: 100vh;
}
.fam-row.lb-none :is(#grid, .loupe-grid) { grid-template-rows: auto; align-items: center; }
/* ⚠ bleed means CONTINUOUS here: no gutter, no mat, one unbroken band. That is
   the whole difference between `ribbon` and `predella`, and it is one token. */
.fam-row.f-bleed :is(#grid, .loupe-grid) { column-gap: 0; }

.fam-row :is(#grid, .loupe-grid) > .cell {
  height: auto;
  /* ⚠ `max-content`, NOT `auto`. A grid item at `width: auto` STRETCHES to its
     track, and the track here is sized `max-content` — so the track asked the
     item how wide it wanted to be and the item answered "as wide as the track",
     which resolves to the base `.cell`'s fixed --cell-size. Every work drew at
     152px with its picture hanging out of it. Naming the size the item actually
     wants is what breaks that: the image's definite height gives it a width,
     and the track takes it. */
  width: max-content;
  overflow: visible;
  padding: var(--mount-pad, 0);
  background: var(--mount-bg, transparent);
  border: var(--mount-border, 0);
  border-radius: 0;
}
.fam-row.f-bleed :is(#grid, .loupe-grid) > .cell { box-shadow: none; }
.fam-row :is(#grid, .loupe-grid) > .cell img {
  height: var(--row-h);
  width: auto;
  max-width: none;
  object-fit: contain;
}

/* ===== the wall label, for both =======================================
 * ⚠ THE SAME THREE ROLES THE ENLARGEMENT USES — wl-m · wl-t · wl-d — because it
 * is the same record through the same works.js call. One document model, two
 * renderers; styled by ROLE and never by position, which is the correction the
 * enlargement's own label already carries.
 */
.wl {
  opacity: 0;
  transition: opacity var(--fade, 700ms) ease;
  font-family: var(--font-label, inherit);
  color: var(--ink-faint, rgba(127, 127, 127, 0.8));
  line-height: 1.75;
}
.wl.in { opacity: 1; }
.wl span { display: block; }
.wl .wl-m {
  font-size: var(--sub-size, 10px);
  letter-spacing: var(--label-tracking, 0.08em);
  text-transform: var(--label-case, none);
  color: var(--ink-dim, inherit);
}
/* ⚠ `--wl-title-scale` — a MULTIPLE of the size the type dial already set, 1 by default, so this changes nothing
   anywhere until something sets it. A wing sets it from its record (the owner, 2026-09-20: "control the size of the
   text underneath the thumbs … to prevent some titles from wrapping onto a second line"); a title that is the only
   part a door's label carries is the one label that can be sized on its own without disturbing a work's record. */
.wl .wl-t {
  font-family: var(--font-display, inherit);
  font-size: calc(var(--title-size, 15px) * var(--wl-title-scale, 1));
  letter-spacing: var(--title-tracking, 0.04em);
  text-transform: var(--title-case, none);
  color: var(--ink, inherit);
  margin-bottom: 4px;
}
.wl .wl-m + .wl-t { font-size: calc(var(--sub-size, 12px) * var(--wl-title-scale, 1)); }
/* ⚠ what follows a colon in a name is one piece: on the first line while the name fits, down whole when it does not
   (loupe.js → wallLabel, 2026-09-22) */
.wl .wl-t .wl-rest { display: inline-block; max-width: 100%; vertical-align: baseline; }
#plate-text .t .wl-rest { display: inline-block; max-width: 100%; vertical-align: baseline; }   /* the credit keeps the same rule (2026-09-22) */
#plate-text .t .nw { white-space: nowrap; }   /* a hyphenated word moves down whole (loupe.js → colonRest, 2026-09-24) */
.wl .wl-d {
  font-size: var(--label-size, 11px);
  letter-spacing: var(--label-tracking, 0.06em);
}
/* ⚠⚠ THE LABEL UNDER EACH WORK, IN A GRID — `lb-under` (2026-09-19; the owner: "is there a change we could make to
   the template itself to move it toward what we thought it was?"). A museum wall is a grid with a card under each work,
   and until now the label dial did nothing on a grid but change the enlargement.
   ⚠ THE FRAME MOVES TO THE PICTURE. A grid cell is the frame today — its border, its ground, its shadow — and it clips
   what is in it, so a card inside would be cut off. Here the cell becomes the pair (picture, then card) and the picture
   keeps the frame, at the cell's own size. Nothing about the PLACEMENT changes: --cx/--cy/--cz still act on the image.
   ⚠ The picture is still square and still clips, so a zoom is still a crop. */
.lb-under :is(#grid, .loupe-grid) > .cell {
  height: auto;
  overflow: visible;
  background: none;
  border: 0;
  border-radius: 0;
  box-shadow: none;
  text-align: left;
}
/* ⚠⚠ THE PICTURE'S OWN BOX carries the frame and DOES THE CLIPPING (loupe.js → picbox). `overflow: hidden` on an <img>
   does nothing — a transformed image paints outside its box whatever the image says — so a zoom went straight over the
   record beneath it until this box existed. */
.lb-under :is(#grid, .loupe-grid) > .cell > .pic {
  display: block;
  position: relative;
  overflow: hidden;
  height: var(--cell-h, var(--cell, var(--cell-size, 154px)));          /* the frame's own shape (grid.css → --cell-h) */
  border: var(--thumb-border, 1px solid rgba(255, 255, 255, 0.16));
  border-radius: var(--thumb-radius, 2px);
  /* ⚠ `--pic-bg` — this picture's OWN ground, where one was read from its edges (loupe.js → THE GROUND OF THE
     PICTURE'S OWN BOX). Unset everywhere but there, so the template's `--thumb-bg` is still what a box wears. */
  background: var(--pic-bg, var(--thumb-bg, #0a0809));
  box-shadow: var(--thumb-shadow, 0 8px 30px rgba(0, 0, 0, 0.65));
}
.lb-under :is(#grid, .loupe-grid) > .cell > .pic > img { width: 100%; height: 100%; }
/* ⚠⚠ A PICTURE STANDING ON ITS OWN CONTINUED GROUND IS ONE OBJECT WITH IT (2026-09-20 — the owner, of cloth on
   deckle: "there is clearly a line demarcating where the edge of the original gray border was and our attempt to
   replicate it"). Found by measuring the rendered box, not by reading: the ground matched the photograph's border
   colour exactly at the seam (#86888c against #87888c at the top, #9d9ca1 both at the middle) and the line was
   still there. TWO TREATMENTS SIT ON THE PICTURE AND NOT ON ITS GROUND:
     1. `--thumb-filter` — deckle desaturates a photograph by 8%, so the picture is saturate(0.92) of the colour the
        ground wears at full strength. A small, perfectly uniform step along a straight edge, which is exactly the
        shape an eye reads as a drawn line.
     2. `drop-shadow` — on deckle the shadow goes on the PICTURE rather than the cell, so it follows the
        picture's rectangle (presets.css → THE SHADOW GOES ON THE PICTURE). Against the template's own ground that
        is the point; against a ground made to continue the picture, it draws the picture's outline onto it.
   So where a door carries its own ground, the treatment moves out to the box: the box is desaturated with the
   picture in it, and casts the one shadow. `.pic` already carries `--thumb-shadow` as a box-shadow, so nothing is
   lost — deckle in fact stops drawing TWO shadows for these.
   ⚠ It must OUTRANK the family rule it overrides, never replace it: deckle's own `filter` on the picture is
   (0,1,3,1) and this is (0,1,4,1), in the file that is linked after. Deleting the narrow rule to make the broad one
   apply is the fault SIBLINGS.md records twice. */
/* ⚠ `:is(.fam-grid, .fam-sheet)` since 2026-09-21 (later): a SHEET cell has a `.pic` too wherever it is not
   matted — a whole picture zoomed past 100%, which shows ground on one axis and had the same seam. `:is()` takes
   the specificity of its most specific argument, so this still outranks deckle's own (1,3,1) as it must. */
:is(.fam-grid, .fam-sheet) :is(#grid, .loupe-grid) > .cell[data-ground] > .pic {
  filter: var(--thumb-filter, none);
}
:is(.fam-grid, .fam-sheet) :is(#grid, .loupe-grid) > .cell[data-ground] > .pic > img {
  filter: none;
}
/* ⚠⚠ A CELL THAT SPANS ROWS GIVES ITS SURPLUS TO THE PICTURE (2026-09-19; the owner, of cloth's Navajo rug: "could we
   adjust the cropping for navajo so it's portrait, to close down some of that white space?"). Every row is one height
   now, so a work spanning two of them owns twice that plus the gap — but its picture was still the SQUARE two cells
   make, and the difference came out as ground below the label. The cell is a column: the label takes what it needs and
   the picture takes the rest, so a lead comes out PORTRAIT, which is also the shape most work that earns a lead has.
   ⚠ It never goes below the square it had — `min-height` — so this can only ever close space, never crop tighter than
   the layout already did. An ordinary one-row cell keeps its square exactly as before. */
.lb-under :is(#grid, .loupe-grid) > .cell { display: flex; flex-direction: column; }
/* ⚠⚠ ONE ROW IS ONE ROW HERE TOO (2026-10-10, the owner: "set it to any size the other photos can take"). These rules are
   for a cell that spans rows, and they read `[data-tall]` as *spans* — but since 0103 the engine writes ONE as well, so a
   work set back to *rows: one* under a label took `(tall − 1)` rows plus half a cell: its picture HALF a cell tall (152 × 76
   on placard), on every grid with a label under each work. And a lead that says how many rows it takes is sized by the
   `[data-tall]` rules, not the lead's own: a lead given one row is one row tall (and two columns wide until it says one),
   given three it is three. `:where()`
   adds nothing to the weight, so every rule here outranks what it did before exactly as it did. */
.lb-under.grid-hero :is(#grid, .loupe-grid) > .cell:first-child:not(:where([data-tall])),
.lb-under :is(#grid, .loupe-grid) > .cell[data-tall]:not(:where([data-tall="1"])) { align-self: stretch; }
/* ⚠⚠ …AND IT COMES DOWN TO THE MIDDLE OF THE PICTURE BESIDE IT (the owner, 2026-09-19: "I'd like the cropping for
   navajo to be a little shorter. Its bottom should come to the middle of the uzbekistan image"). Filling the whole
   span was too tall — a lead that runs the full two rows reads as a different page from the grid it leads. Ending
   level with the MIDDLE of the work in the last row it spans is a relationship rather than a number: the lead is
   plainly taller, plainly portrait, and plainly still part of the same wall.
   ⚠ `--row-h` is the measured row height (loupe.js → sizeRows), so this follows whatever the labels turn out to need. */
.lb-under.grid-hero :is(#grid, .loupe-grid) > .cell:first-child:not(:where([data-tall])) > .pic {
  flex: 0 0 auto;
  height: calc(var(--row-h, var(--cell-h)) + var(--cell-gap, 18px) + var(--cell-h) / 2);
}
.lb-under :is(#grid, .loupe-grid) > .cell[data-tall]:not(:where([data-tall="1"])) > .pic {
  flex: 0 0 auto;
  height: calc((var(--row-h, var(--cell-h)) + var(--cell-gap, 18px)) * (var(--tall, 1) - 1) + var(--cell-h) / 2);
}
/* ⚠⚠ …AND ON A WING IT FILLS THE ROWS IT SPANS INSTEAD (2026-09-20 — the owner: "why is the grid structure
   different for the gallery than for the wing? … I would hope we can even out the arrangement in wing to match").
   Measured on the page: gallery-wall's hero picture ended at 283px while the pictures beside it ended at 359 — short
   by exactly half a cell, the `+ var(--cell) / 2` above — and the hero's own label then sat in the gap with a hole
   below it. The two rules above are right for what they were made for: a LEAD WORK among its own series, plainly
   taller and plainly still part of the same wall (the owner's own call on cloth's Navajo rug, 2026-09-19). A wing's
   cells are not that. They are peers, each a door into a gallery, and the only thing a short first cell says there
   is that the grid is broken.
   ⚠ The picture GROWS to fill what the label leaves, which lands its bottom edge exactly level with the pictures
   beside it — the arithmetic: the span is 2 rows + a gap, the label takes its own height, and what is left is
   2×picture + label + gap, which is where row two's picture ends.
   ⚠ It adds a class rather than deleting the narrow rules, and outranks them on `.all-doors` (SIBLINGS.md: a pin
   must outrank its family; deleting the narrow rule to make the broad one apply is the fault recorded twice). */
.all-doors.lb-under.grid-hero :is(#grid, .loupe-grid) > .cell:first-child:not(:where([data-tall])) > .pic,
.all-doors.lb-under :is(#grid, .loupe-grid) > .cell[data-tall]:not(:where([data-tall="1"])) > .pic {
  flex: 1 1 auto;
  height: auto;
  min-height: 0;
}

/* ⚠⚠ NO ONE FIELD MAY RUN AWAY WITH THE WALL (2026-09-19; the owner, of `cloth`: "why is there so much space between
   rows?"). Every row is as tall as the tallest label, so ONE long field sets the height of the whole gallery: the
   Marblehead sampler's medium is thirty words — "Wool, plain weave; embroidered with silk floss and silk yarns in back,
   chain, Cretan, individual back, individual running, Roman, satin, stem, and surface satin stitches; laid work,
   couching, and Roumanian couching" — and it alone pushed every one of cloth's rows to 590px under a 152px picture.
   ⚠ A WALL CARD IS ABBREVIATED BY CONVENTION. The whole record is still on the enlargement and on the work's own page;
   what hangs beside the work is a few lines. The maker and each detail are held to two lines each; the TITLE is never
   clamped, because a title is the one thing a label must say in full.
   ⚠ This bounds the label; it does not choose what is in it. Choosing the fields is the artist's (the owner: "we should
   offer tooling to customize what parts get included vs just an all or nothing switch") and is still to build. */
.lb-under :is(#grid, .loupe-grid) > .cell > .wl :is(.wl-m, .wl-d) {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  overflow: hidden;
}
/* the card: under its own work, never wider than it, and never wider than a comfortable line
   ⚠ `break-word` (2026-10-16): a single word wider than its column breaks inside the column rather than running on under
   the next work, which is what every door name on a wing did on the owner's phone (the rule below has the rest) */
.lb-under :is(#grid, .loupe-grid) > .cell > .wl {
  display: block;
  margin-top: 10px;
  max-width: 34ch;
  white-space: normal;
  text-align: left;
  overflow-wrap: break-word;
}
/* ⚠⚠ A WING ON A PHONE IS TWO ACROSS (2026-10-16; the owner, from his iPhone on loupe examples' photographs wing: "minor
   issues with text formatting and overlap on mobile"). A door's name is a gallery's name in the template's title face —
   gallery-wall's is capitals tracked 0.2em — and four across left it a 66px column at 393px: EQUIVALENTS ran under the door
   beside it, the next name was cut off by the one after, on every row, and the demo's own wing ran 10px past the screen.
   Two across give a name 145px, where every word on the eight wings sets on its line. Under 560px, where flush and inset
   already go to two (presets.css, loupe.js → fillColumns), and only with two doors or more, so a wing of one stays centred.
   ⚠ A GALLERY KEEPS ITS COUNT: a work's record is set in a reading face and wraps inside three across (placard, measured
   the same day). ⚠ A door given three or four columns takes the whole row, as on a wall that fills the page (.grid-fill). */
@media (max-width: 560px) {
  .fam-grid.all-doors.lb-under .loupe-grid:has(> .cell + .cell) { --grid-columns: 2; }
  .fam-grid.all-doors.lb-under :is(#grid, .loupe-grid) > .cell:is([data-wide="3"], [data-wide="4"]) {
    grid-column: 1 / -1; width: auto; justify-self: stretch;
  }
}
/* ⚠ THE NUMBER STAYS ON THE PICTURE, not over the card — it is positioned against the cell, which is now the pair */
.lb-under :is(#grid, .loupe-grid) > .cell > .n { top: 4px; }

/* in the margin: set against the work, which reads as a caption rather than as
   a second column of text */
.fam-scroll :is(#grid, .loupe-grid) > .wl { text-align: right; align-self: start; padding-top: 2px; }
/* under the column, and never wider than a comfortable line however wide the
   work standing above it is
   ⚠ 80ch, not 34ch (2026-10-09): 34ch was 261px under a 560px picture, so shelf broke "Grandfather Mountain from the
   Blue Ridge Parkway, 2009" into three lines with half the picture's width still empty beside them. The column is the
   work's own width, so a label now runs as wide as its picture and stops only under a panorama. */
.fam-row :is(#grid, .loupe-grid) > .wl { max-width: 80ch; align-self: start; }

/* ⚠⚠ A WORK'S LABEL IS A CAPTION (2026-10-09; the owner, of placard: "The large size continued with large spacing
   between the lines strikes me as a mistake rather than intentional", and of shelf: "the issue is longer text forcing
   multiple lines with that font treatment"). The label under or beside each work took the GALLERY NAME'S size and
   tracking (`--title-size`, set for one name a page on one line) and `.wl`'s 1.75 line-height (set for 10px record
   lines), so every title stood as large as the credit and a wrapped one showed a line's worth of air between its
   lines. Measured on his Valle Crucis: placard 18px titles 31.5px apart, rows 292px under a 152px picture; shelf 22px
   at 0.1em, 38.5px apart.
   ⚠ The fix is the printed card's own setting, and it is the one he picked from a mock ("a"): the template's SAME
   faces, the title a step above the lines under it (`--wl-title-size` per type, dials.css), the lines close, the year
   tucked right under. The credit is the one large title on the page again.
   ⚠ NOT ON A WING (`:not(.all-doors)`). A door's name is a gallery's name, sized by the owner as a share of the
   template's title (`--wl-title-scale`, 2026-09-20) — his dial, which these rules would otherwise outrank and silence.
   ⚠ The maker case is restated because this outranks the base `.wl-m + .wl-t`: with a maker above it the title still
   steps down to the maker's size, as it does everywhere else. */
.lb-under:not(.all-doors) :is(#grid, .loupe-grid) > .cell > .wl,
:is(.fam-row, .fam-scroll):not(.all-doors) :is(#grid, .loupe-grid) > .wl { line-height: 1.4; }
.lb-under:not(.all-doors) :is(#grid, .loupe-grid) > .cell > .wl .wl-t,
:is(.fam-row, .fam-scroll):not(.all-doors) :is(#grid, .loupe-grid) > .wl .wl-t {
  font-size: var(--wl-title-size, var(--title-size, 15px));
  letter-spacing: var(--wl-title-tracking, var(--title-tracking, 0.04em));
  font-weight: var(--wl-title-weight, normal);
  line-height: 1.3;
  margin-bottom: 3px;
}
.lb-under:not(.all-doors) :is(#grid, .loupe-grid) > .cell > .wl .wl-m + .wl-t,
:is(.fam-row, .fam-scroll):not(.all-doors) :is(#grid, .loupe-grid) > .wl .wl-m + .wl-t { font-size: var(--sub-size, 12px); }
.lb-under:not(.all-doors) :is(#grid, .loupe-grid) > .cell > .wl .wl-d,
:is(.fam-row, .fam-scroll):not(.all-doors) :is(#grid, .loupe-grid) > .wl .wl-d { font-size: var(--wl-detail-size, var(--label-size, 11px)); }

/* ⚠ ON A PHONE Scroll loses its margin track: 88px beside a 320px work is not a
   margin, it is a second column. The record then sits ABOVE its work rather
   than below it — a grid flows in DOM order and `order` would move every label
   past every work, not each past its own, so above is the honest answer and it
   reads as a heading, which is what a wall label is. Row keeps its band and
   scrolls, which is what a band is for. */
@media (max-width: 640px) {
  .fam-scroll :is(#grid, .loupe-grid) {
    --margin-col: 0px;
    --label-gap: 0px;
    grid-template-columns: minmax(0, 1fr);
    row-gap: var(--scroll-gap, 7vh);
    width: 92vw;
  }
  .fam-scroll :is(#grid, .loupe-grid) > .wl { text-align: left; padding-top: 0; }
  .fam-row :is(#grid, .loupe-grid) { --row-h: min(var(--row-height, 520px), 46vh); }
}

/* ---------- 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. */
/* ⚠⚠ THE DEMO'S CHROME CORNER (2026-09-20 — the owner: "make room for it in the lower left where it belongs by
   putting the template name … up top on the left with the link back to the templates page", and "title stays
   largest, subtitle (left part of second line) becomes artist name, and what was the template name becomes a
   subtitle"). Two lines that rhyme with the bottom left's pair: the way back, and under it what this page is
   wearing. ⚠ It replaces `#back`'s own fixed position rather than adding a second fixed thing to the same corner —
   two elements anchored to one corner is how they end up on top of each other at some width nobody checked. */
#chrome-left {
  position: fixed;
  left: var(--credit-inset-x, max(3vw, 22px));
  top:  var(--credit-inset-y, max(3vh, 22px));
  z-index: 4;
  display: flex;
  /* ⚠ SIDE BY SIDE, not stacked (the owner, 2026-09-20: "let's put the template name to the right of the templates
     link … not below"). One line of chrome across the top rather than a block of two — the bottom left is where a
     stacked pair belongs, and having a second one up here made the corner read as a heading. */
  flex-direction: row;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0 10px;
}
/* ⚠⚠ THE DOTS SEPARATE PLACES, AND WHAT THIS PAGE IS WEARING COMES LAST (2026-09-21, later — the owner: "Why is
   'contact' in the middle here as opposed to being the rightmost breadcrumb?"). The order is the way back to the
   picker, then the room above this gallery, then the template's name — so the line reads left to right as depth,
   and the one item that is NOT a step is the one that trails.
   ⚠ A separator only where something stands before it: a page opened outside the picker must not begin with a
   dot, and neither must a gallery in no wing. `~` rather than `+` because the wing's crumb may or may not be
   between them. */
:is(#back, #inwing) ~ #wearing::before,
#back ~ #inwing::before { content: "\00b7"; margin-right: 10px; opacity: 0.6; }
#inwing {
  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;
}
#inwing:hover, #inwing:focus-visible { color: var(--ink, #e8dcc9); }
#inwing:focus-visible { outline: 2px solid var(--accent, #ffb05a); outline-offset: 4px; }
body.ready #inwing { opacity: 1; transform: none; }
#inwing[hidden] { display: none; }
/* ⚠ the template's name, the subtitle of the way back. Dimmer and a shade smaller than the link above it, so the
   corner reads as one thing with an order to it rather than two labels of equal weight. */
#wearing {
  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-faint, rgba(127, 127, 127, 0.8));
  text-shadow: var(--chrome-shadow, none);
  opacity: 0;
  transform: translateY(-6px);
  transition: opacity 1.2s ease 0.4s, transform 1.2s ease 0.4s;
}
body.ready #wearing { opacity: 1; transform: none; }
#wearing[hidden] { display: none; }

#back {
  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 `placard` preset pins a light one (`.p-placard.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 thumbnail standing in while the picture loads (loupe.js, 2026-09-15): blurred, so it reads as the picture arriving
   rather than as a soft photograph, and held at the work's own shape (--shot) so nothing jumps when the picture lands */
#light .mount[style*="--shot"] { aspect-ratio: var(--shot); }
#light-img.rough { filter: blur(14px); transform: scale(1.02); }
#light .mount[style*="--shot"] #light-img.rough { width: 100%; height: 100%; object-fit: cover; }

/* ---------- 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));
}

/* ⚠ THE CREDIT AT THE FOOT OF A GRID (sizeCredit ④, 2026-09-15) — when the gutter beside the works is too narrow to read
   in, the credit leaves the corner for the flow after #grid, under the last row; the grid gives up the band it kept for it */
/* ⚠ the room under it is PADDING, not margin (2026-09-29, the owner, of works on paper on contact: "the title of this page is
   stuck way down at the bottom and I can't expose it even by scrolling all the way down"): the page is one window tall and
   scrolls, and Chrome leaves a last child's bottom MARGIN out of how far it scrolls — so the credit sat flush on the
   window's edge, under the browser's own link bubble. Padding is part of the block, so the scroll reaches past it. */
.credit-after #plate-text { position: relative; left: auto; bottom: auto; margin: 0 var(--credit-inset-x, max(3vw, 22px)); padding-bottom: var(--credit-inset-y, max(3vh, 22px)); }
.credit-after :is(#grid, .loupe-grid) { min-height: calc(100vh - var(--credit-band, 62px) - 2 * var(--credit-inset-y, max(3vh, 22px))); padding-bottom: 5vh; }
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); }
/* ⚠ …and a name with nowhere to go is drawn as a SPAN (loupe.js), so it keeps the name's weight and drops the
   things that promise a destination: the rule underneath it and the click it would have eaten. */
#plate-text span.who {
  pointer-events: none;
  border-bottom: 0;
  padding-bottom: 0;
}

#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; }
/* the way across, under the credit (loupe.js → `across`): the credit's size and ink, its links quiet until hovered */
#plate-text .a { color: var(--ink-dim, #b8ada2); font-size: var(--sub-size, 10px); margin-top: 0.5em; }
#plate-text .a a { color: inherit; text-decoration: none; border-bottom: 1px solid transparent; transition: color 200ms ease, border-color 200ms ease; }
#plate-text .a a:hover, #plate-text .a a:focus-visible { color: var(--accent, #e8dcc9); border-bottom-color: currentColor; }
#plate-text .a span { opacity: 0.7; }
#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; }
/* …and the way across under the credit (2026-09-29 — the owner, on the comps: "the links don't work when clicked") */
#plate-text .a 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 color 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, body.backdrop #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, body.backdrop.ready #backdrop { opacity: var(--backdrop, 0.2); }
body.section.lit   #backdrop, body.backdrop.lit #backdrop { opacity: 0; }

/* ⚠⚠ HOW A GRID MEASURES ITSELF MOVED TO grid.css (2026-09-19) — the columns, --cell, a cell's size, the hero
   and a work given more of the row. The templates page draws its cards from the same file, so a card and the
   page it stands for cannot measure differently. A page that renders the engine links grid.css after this file;
   `min-height: 100vh` stays here, because only a real page fills a window. */
:is(#grid, .loupe-grid) { min-height: 100vh; }
/* the plural s after a year, under any capitals: 1400s, not 1400S (loupe.js → plurals, 2026-09-22) */
.pl { text-transform: none; }
/* a range of years is one piece (loupe.js → plurals, 2026-09-24): 900–1550 never breaks at its dash */
.nb { white-space: nowrap; }
/* ⚠⚠ …AND BOTH STAY IN THE LINE (2026-09-24, the owner, of a glass wing's doors THE MIDDLE AGES: THE 1500 / S: "strange wrapping
   behavior with the s at the end of 1400s and 1500s"). A label draws each of its PARTS as a block (`.wl span`), and the
   enlargement each part as a clamped box (`#light .meta.wall span`) — and both rules reach the two spans the years put INSIDE a
   part, so the plural s became a block of its own and started a line. They outrank the broad rules rather than narrowing them
   (PLACEMENT.md → fault 12): (0,2,0) over (0,1,1), and (1,3,0) · (1,4,0) over (1,2,1) · (1,3,1). */
.wl :is(.pl, .nb),
#light .meta.wall :is(.pl, .nb),
#light .meta.wall.open :is(.pl, .nb) { display: inline; overflow: visible; -webkit-line-clamp: none; line-clamp: none; }
/* ⚠ A TITLE'S LINES COME OUT EVEN (2026-09-24, the owner, of a gallery title whose last line held one word): the browser
   evens a title's lines rather than filling the first and leaving what is over, so JEWELRY FROM COLOMBIA, 900–1550 is two
   lines of like length. Where a browser does not know `balance`, the title wraps as it always did. */
#plate-text .t { text-wrap: balance; }
/* ⚠ THE STACK (2026-09-22): a work with several photographs. On the card, how many, small in the corner — the picture is
   unchanged. In the enlargement, a strip of the work's photographs under the picture; the one showing is outlined. */
:is(#grid, .loupe-grid) > .band { position: relative; }        /* so its stack mark has a corner to sit in */
.cell > .stk, .band > .stk, .cell > .pic > .stk { position: absolute; right: 6px; top: 6px; z-index: 3; min-width: 18px; height: 18px; padding: 0 5px; box-sizing: border-box;
  border-radius: 9px; background: rgba(0,0,0,0.55); color: #fff; font: 10px/18px -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
  letter-spacing: 0.04em; text-align: center; pointer-events: none; }
/* ⚠ WHAT THE NUMBER COUNTS, SAID BY A MARK (2026-09-24; the owner, of a 13 and an 18 on Jewelry from Colombia: "I'm
   trying to figure out why '13' and '18' are appearing" → "a small icon of some kind signifying a stack" → A, the mark
   and the count). Moore's own (moore-archives → `.turn-mark`): three cards seen from above, the front one crisp and the
   two behind it falling back down and to the right — drawn here at the pill's size, before the number. */
.cell > .stk, .band > .stk, .cell > .pic > .stk { display: inline-flex; align-items: center; gap: 8px; padding: 0 6px 0 5px; }   /* 8: the cards behind stand 4px out */
.stk .stk-i { position: relative; flex: none; width: 9px; height: 7px; margin: -3px 0 0; border: 1px solid currentColor; box-sizing: border-box; }
.stk .stk-i::before, .stk .stk-i::after { content: ""; position: absolute; inset: -1px; border: 1px solid currentColor; }
.stk .stk-i::before { transform: translate(2px, 2px); opacity: 0.55; }
.stk .stk-i::after  { transform: translate(4px, 4px); opacity: 0.3; }
#light .strip { position: absolute; left: max(3vw, 22px); bottom: calc(max(2.4vh, 18px) + 3.6em); z-index: 3; display: flex; gap: 6px; max-width: calc(100vw - 2 * max(3vw, 22px)); overflow-x: auto; }
#light .strip[hidden] { display: none; }
#light .strip button { flex: none; width: 44px; height: 44px; padding: 0; border: 2px solid transparent; border-radius: 4px; background: rgba(0,0,0,0.35); cursor: pointer; opacity: 0.75; }
#light .strip button img { display: block; width: 100%; height: 100%; object-fit: cover; border-radius: 2px; }
#light .strip button[aria-pressed="true"] { border-color: rgba(255,255,255,0.9); opacity: 1; }
#light .strip button:hover, #light .strip button:focus-visible { opacity: 1; outline: none; }
#light .meta .wl-view { opacity: 0.8; }
/* ⚠⚠ THE GRID'S OWN INSET — LOST 2026-09-19, PUT BACK 2026-09-22 (the owner, on a room of Sargent's paintings: "the top of
   the image hugs the top of the page exactly"). When how a grid measures itself moved to grid.css, `#grid`'s padding did not
   travel, and for three days every grid gallery — on the site and in the app — began flush with the top of the window and ran
   its last row under the standing credit. It is `.fam-grid #grid` ON PURPOSE, narrower than the `#grid` it was: a card on the
   templates page is a `.loupe-grid` and takes no inset, scroll and row set their own below, and a sheet's numbers were measured
   without one (PLACEMENT.md), so a sheet is not touched here.
   ⚠⚠ 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. `--credit-band` is the credit's MEASURED height (sizeCredit), so
   this follows a two-line credit as well as a one-line one. `body.arranging` replaces the bottom with the editor's bar's room. */
.fam-grid #grid { padding: 14vh var(--credit-inset-x, 5vw) max(22vh, calc(var(--credit-band, 62px) + 72px)); }
/* ⚠⚠ THE NAME OVER THE WORKS, ON A GRID (`credit-top`, 2026-10-10 — `flush`, whose works run to both edges). The credit
   has always stood in the bottom corner with the works scrolling under it and their last row clearing it; a grid that
   fills the width has no corner to give it, so it becomes the heading over the first row and scrolls with the page, as
   a sheet's does (`.fam-sheet.credit-top` above). The grid then needs no band at its foot — only room to end. */
.fam-grid.credit-top #plate-text {
  position: relative;
  left: auto; right: auto; top: auto; bottom: auto;
  max-width: none;
  margin: max(7vh, 56px) max(var(--credit-inset-x, 5vw), 14px) 18px;   /* never to the very edge, even where the works are */
}
/* …and over a Story (`fieldbook`), whose cover runs to both edges — the same heading, its own rule so flush's weight is
   untouched */
.fam-story.story-rows.credit-top #plate-text {
  position: relative;
  left: auto; right: auto; top: auto; bottom: auto;
  max-width: none;
  margin: max(7vh, 56px) max(var(--credit-inset-x, 5vw), 14px) 18px;   /* never to the very edge, even where the works are */
}
/* ⚠ …and with a cover (`story-cover`), the name stands OVER the first picture, at the foot of the first window or of the picture,
   whichever comes first, in the light ink and shadow of a title set on a photograph — whatever the page's own ink */
.fam-story.story-rows.story-cover.credit-top #plate-text {
  position: absolute;
  left: max(var(--credit-inset-x, 5vw), 18px); right: max(var(--credit-inset-x, 5vw), 18px);
  top: calc(min(var(--cover-h, 100vh), 100vh) - max(6vh, 32px));
  bottom: auto;
  margin: 0;
  transform: translateY(-100%);
  color: #fff;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45), 0 2px 26px rgba(0, 0, 0, 0.55);
  pointer-events: none;
}
.fam-story.story-rows.story-cover.credit-top #plate-text :is(.t, .s, a, i, span) { color: inherit; }
.fam-story.story-rows.story-cover.credit-top #plate-text .t > :is(a, i) { opacity: 0.72; }
.fam-story.story-rows.story-cover.credit-top #plate-text a { pointer-events: auto; }
/* ⚠ a long trail of names wraps rather than widen the page (the review before landing: 347px wide at 320) */
.fam-story.story-rows.story-cover.credit-top #plate-text :is(.t, .s) { overflow-wrap: anywhere; }
/* ⚠ AND THE PAGE'S OWN LINKS, while the cover is under them (`over-cover`, loupe.js → layRows): dark ink on a photograph
   could not be read — the way back and the loupe mark take the title's light ink and shadow until the cover has gone by */
.fam-story.story-rows.story-cover.over-cover :is(#chrome-left, #home),
.fam-story.story-rows.story-cover.over-cover :is(#chrome-left, #home) :is(a, span, i, b) {
  color: #fff;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45), 0 2px 18px rgba(0, 0, 0, 0.5);
}
/* …and the ring a keyboard's focus draws, by the same reason (the review before landing: 1.7:1 over Valle Crucis) */
.fam-story.story-rows.story-cover.over-cover :is(#chrome-left, #home) :focus-visible,
.fam-story.story-rows.story-cover.over-cover #home:focus-visible,
.fam-story.story-rows.story-cover.credit-top #plate-text a:focus-visible { outline-color: #fff; }
.fam-story.story-rows.story-cover.credit-top #plate-text .t { font-size: clamp(28px, 4.4vw, 64px); letter-spacing: 0.01em; line-height: 1.08; }
.fam-grid.credit-top #grid { padding: 0 var(--credit-inset-x, 5vw) max(10vh, 72px); align-content: start; }   /* the rows begin under the name, never centred far below it */
/* ⚠ A WORK GIVEN MORE COLUMNS THAN A `fill` GRID HAS AT THIS WIDTH takes them all (the review before landing: three across
   on a phone of two made the page 586px wide at 390). loupe.js → fillColumns says how many there are; at #grid weight, over
   the span rule in grid.css. */
.grid-fill #grid.loupe-grid[data-cols="2"] > .cell:is([data-wide="3"], [data-wide="4"]),
.grid-fill #grid.loupe-grid[data-cols="3"] > .cell[data-wide="4"] { grid-column: 1 / -1; width: auto; justify-self: stretch; }

.cell {
  position: relative;
  width: var(--cell-size, 154px);
  height: var(--cell-size, 154px);
  padding: 0;
  /* ⚠⚠ A CELL MAY BE AN <a> OR A <button> — thumbNode() draws a link for an item that names where it goes and a
     button for one that enlarges — and every rule here is keyed on the CLASS so the two draw identically. These two
     declarations are what a button gave for free and a link does not: the browser's own link color (#0000ee) and
     underline apply to ANY text the cell contains. The number in the corner sets both itself, so nothing showed;
     the wall label under a work does not, and a door carrying one would have come out blue and underlined the day
     the first one was made. Added 2026-09-20 with the fix that let a cell be a link again. */
  color: inherit;
  text-decoration: none;
  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
   ⚠⚠ …AND A RAIL'S THUMBNAIL IS A BOX WITH A PICTURE IN IT TOO (2026-09-21, later — the owner, of `paper` drawing
   the prints: "is there a way I could tweak a few of the thumbnail crops shown in this gallery?"). A SEQUENCE
   keeps its works on the rail, and the whole placement already reached them: `gallery.html` reads the record for
   a template with no arrangement, and `thumbNode` builds a rail thumbnail exactly as it builds a cell — same
   attributes, same vars. Only this rule was written `.cell`, so the numbers landed on the node and nothing moved.
   Measured on the live page before it was widened: `data-fit="whole"`, the point at 20% 80% and a 1.4 zoom set on
   a rail thumbnail left `object-fit: cover` and `object-position: 50% 50%` exactly as they were.
   ⚠ It is a WIDENING of the same rule, not a second one: one drawing of the placement, the editor's and the
   visitor's, and now the rail's. */
:is(.cell, .thumb)[data-fit] img { object-position: var(--cx, 50%) var(--cy, 50%); transform: scale(var(--cz, 1)); transform-origin: var(--cx, 50%) var(--cy, 50%); }
/* ⚠⚠ AND THE SLOT CLIPS IT (2026-09-17). A zoom is a crop, and a crop that is not clipped is not a crop: on a family
   whose cell does not clip — `deckle`'s, so its edge and shadow are not cut — the scaled picture simply grew over the
   works beside it. `data-zoom` is set by the engine only when the zoom is greater than 1, so every other cell keeps the
   overflow its family asked for. */
/* ⚠ TWO ATTRIBUTES, ON PURPOSE: `.fam-grid.p-deckle #grid > .cell` is one id and three classes, so a single attribute
   here loses to it and the cell stayed unclipped. This ties, and loupe.css is linked after presets.css — the same
   argument, and the same reason, as the deckle rule's own note. */
:is(#grid, .loupe-grid) > .cell[data-zoom][data-fit] { overflow: hidden; }
:is(.cell, .thumb)[data-fit="whole"] img { object-fit: contain; }
/* ⚠⚠ ITS OWN BACKGROUND, CARRIED ON (the app, 0102; loupe.js → ext; extend.py). The image is the photograph in the middle of
   a canvas `--ea` times as wide and `--eb` times as tall. Drawn in a box that many times the cell's size, centred, and
   CONTAINED, it takes exactly the scale the photograph alone takes in the cell — contain picks min(W·a/(w·a), H·b/(h·b)) =
   min(W/w, H/h) — so the photograph stands where a whole picture stands and the cell is filled with its own background.
   The zoom scales it about the centre, as it does any whole picture. The cell clips what lies past it.
   ⚠ ONE rule for every box that holds a whole picture (PLACEMENT.md §2): the plain cell and the `.pic` under a label clip
   already, and a sheet frame on its own background is never matted (loupe.js). Its specificity carries the id so it outranks
   `.lb-under … > .pic > img` (1,3,2), which sizes the picture to its box and would otherwise pin the canvas to it. */
/* ⚠⚠ …BUT NEVER MOVE A SHEET'S FRAME (2026-09-24, the owner, of `album` on Jewelry from the Middle Ages: the frames "should be
   larger" — they were not smaller, they had left their places). A sheet's frame is placed ABSOLUTELY on its sheet; this rule
   gave every frame on its own background `position: relative`, so each fell back into the page's flow and stacked down it,
   one frame's height below the last. A frame is positioned already, which is all the picture inside needs; a grid's cell is
   not, and takes it here. PLACEMENT.md §3 → fault 0b. */
:is(#grid, .loupe-grid) > .cell[data-ext][data-fit="whole"] { overflow: hidden; }
:is(#grid, .loupe-grid) > .cell[data-ext][data-fit="whole"]:not([data-sheet]) { position: relative; }
/* ⚠⚠ …AND IT MOVES WHERE IT WAS DRAGGED (2026-09-24, the owner, of a tea set at 130% on its own background: "I am not able to
   drag it up to center the photograph"). It was scaled about the centre and nothing read `x` and `y`, so the editor's drag
   was recorded and never drawn. Now the photograph stands exactly where the same x, y and size put a whole picture without
   its background — offset from the centre by `(x − ½) × (the box − the photograph)` on each axis, which is 0 at 50/50 — and
   never further than the canvas reaches past it, so the box is always its own background (extend.py → geometry).
   ⚠ That needs the BOX's width and height together (which axis the photograph fills), and a translate's percentages are the
   picture's own, one axis each — so the box that holds the picture is a size container. Safe in every box this rule
   reaches, because none takes its size from the picture: the picture is absolute; a `.pic` has its height set; a sheet's
   frame is placed; a rail thumbnail is fixed. NOT the labelled cell itself, whose label does size it — its `.pic` instead. */
:is(#grid, .loupe-grid) > .cell[data-ext][data-fit="whole"]:not(:has(> .pic)),
:is(#grid, .loupe-grid) > .cell[data-ext][data-fit="whole"] > .pic,
.thumb[data-ext][data-fit="whole"] { container-type: size; }
:is(#grid, .loupe-grid) > .cell[data-ext][data-fit="whole"] img:not(.arr-ghost), .thumb[data-ext][data-fit="whole"] img {
  --ew: calc(min(100cqw, 100cqh * var(--ar, 1)) * var(--cz, 1));      /* the photograph, as drawn */
  --eh: calc(min(100cqh, 100cqw / var(--ar, 1)) * var(--cz, 1));
  --ex: max(0px, (var(--ea, 1) * var(--ew) - 100cqw) / 2);            /* how far the canvas lets it travel */
  --ey: max(0px, (var(--eb, 1) * var(--eh) - 100cqh) / 2);
  --edx: clamp(-1 * var(--ex), var(--pan-x, 0) * (100cqw - var(--ew)), var(--ex));
  --edy: clamp(-1 * var(--ey), var(--pan-y, 0) * (100cqh - var(--eh)), var(--ey));
}
:is(#grid, .loupe-grid) > .cell[data-ext][data-fit="whole"] img:not(.arr-ghost) {
  position: absolute; left: 50%; top: 50%;
  width: calc(var(--ea, 1) * 100%); height: calc(var(--eb, 1) * 100%); max-width: none; max-height: none;
  object-fit: contain; object-position: 50% 50%;
  transform: translate(calc(-50% + var(--edx, 0px)), calc(-50% + var(--edy, 0px))) scale(var(--cz, 1)); transform-origin: 50% 50%;
}
/* ⚠ A zoom is a crop on the rail too, and nothing has to be added for it: `.thumb` already carries
   `overflow: hidden` of its own, which is what a cell only gets when `data-zoom` asks for it. */
/* ⚠⚠ …AND ITS OWN BACKGROUND ON THE RAIL (2026-09-24, the owner, trying `boardwalk` on Jewelry from Byzantium: "the
   croppings of the thumbnails I had set were lost"). Nothing was lost: the works on their own backgrounds were drawn
   from the EXTENDED picture, and the rule above that sizes it was written for a grid's cell alone, so a rail thumbnail
   drew the whole canvas shrunk to fit — every object small, with bars where the canvas's shape was not the thumbnail's.
   The same geometry, on the rail's own box (PLACEMENT.md §2: the row that was left out). */
.thumb[data-ext][data-fit="whole"] img {
  position: absolute; left: 50%; top: 50%;
  width: calc(var(--ea, 1) * 100%); height: calc(var(--eb, 1) * 100%); max-width: none; max-height: none;
  object-fit: contain; object-position: 50% 50%;
  transform: translate(calc(-50% + var(--edx, 0px)), calc(-50% + var(--edy, 0px))) scale(var(--cz, 1)); transform-origin: 50% 50%;
}
/* ⚠ and a HOVER keeps the placement: the rail's hover set `transform: scale(1.07)` over the picture, which threw away a
   crop's own zoom (and, above, the centring) for as long as the pointer was on it. The hover's scale is now on top of
   the placement's, not instead of it. */
#rail.live .thumb[data-fit]:hover img, #rail.live .thumb[data-fit]:focus-visible img {
  transform: scale(calc(var(--cz, 1) * var(--thumb-hover-scale, 1.07))); }
#rail.live .thumb[data-ext][data-fit="whole"]:hover img, #rail.live .thumb[data-ext][data-fit="whole"]:focus-visible img {
  transform: translate(calc(-50% + var(--edx, 0px)), calc(-50% + var(--edy, 0px))) scale(calc(var(--cz, 1) * var(--thumb-hover-scale, 1.07))); }
/* 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. */
:is(#grid, .loupe-grid)[data-mat="page"] .cell[data-fit="whole"],
:is(#grid, .loupe-grid).live[data-mat="page"] .cell[data-fit="whole"]:hover,
:is(#grid, .loupe-grid).live[data-mat="page"] .cell[data-fit="whole"]:focus-visible { background: transparent; border-color: transparent; box-shadow: none; }
/* THE FRAME'S SHAPE (the app, 0054 — the owner, 2026-09-15: "what if I want to change the shape of its cell, say from rectangle
   to square?" → "choose from a short list"): a work on a sheet may take a frame of its own proportion — square, 4:3, 3:2, 3:4,
   2:3 — drawn as large as its slot allows and centred in it; the SLOT stays the arrangement's, so the sheet stays composed.
   The slot becomes a size container, its chrome moves to the shaped frame (::before), and the picture fills that frame —
   inside a `.shape` box that clips it, so a zoom stays in the frame as it stays in an unshaped slot. Behind the frame,
   inside the slot, is the page. --shape is width over height. */
/* ⚠⚠ EVERY FAMILY, NOT THE SHEET ALONE (a report from the owner's own Colors, 2026-09-17: on `deckle`, `shelf` and
   `broadside` the first work was drawn enormous and lay over its neighbours). The shaped frame's markup — the `.shape`
   box the engine wraps the picture in — was emitted for a work with `data-shape` in ANY family, and styled in none but
   the sheet: an unstyled inline span does not clip, so the picture took its natural size and a placement zoom
   (`--cz: 1.5` on that work) grew it further, out of its slot and across the cells beside it. The slot must clip a
   shaped frame exactly as it clips an unshaped one, in every family.
   ⚠ A SIZE CONTAINER NEEDS A DEFINITE SIZE, which is why this is not one rule: grid and sheet slots have one (both
   sides), a row's has a height and takes its width from the work, and a scroll's has neither — its height IS the
   picture. Each gets the same frame by the measure its own family has. */
.fam-sheet :is(#grid, .loupe-grid) > [data-shape] { container-type: size; background: transparent; border-color: transparent; box-shadow: none; }
.fam-sheet :is(#grid, .loupe-grid) > [data-shape]::before, .fam-sheet :is(#grid, .loupe-grid) > [data-shape] > .shape {
  position: absolute; box-sizing: border-box;
  width: min(100cqw, 100cqh * var(--shape, 1)); height: min(100cqh, 100cqw / var(--shape, 1));
  left: calc((100cqw - min(100cqw, 100cqh * var(--shape, 1))) / 2); top: calc((100cqh - min(100cqh, 100cqw / var(--shape, 1))) / 2); }
.fam-sheet :is(#grid, .loupe-grid) > [data-shape]::before { content: ''; 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, 0 8px 30px rgba(0, 0, 0, 0.65)); }
/* ⚠⚠ IN EVERY OTHER FAMILY THE WRAPPER LEAVES THE LAYOUT. `display: contents` drops the `.shape` box out of the box
   tree, so the picture is laid out by the cell's own rules — exactly as an unshaped work in the same slot, clipped the
   way that slot clips, with the placement's zoom held inside it. Two shapes were tried and refused before this one: a
   size container (`container-type: size`) makes a cell whose height comes from its picture resolve `100cqh` to zero —
   measured on `deckle` — and an `aspect-ratio` box with an absolutely-placed picture left the frame empty. The sheet's
   own rules above are untouched, because that is where a proportioned frame was drawn and judged.
   ⚠ So a shape chosen for a work is honoured on a SHEET and ignored elsewhere, which is what these templates did before
   shapes existed. Honouring it in the other families is a drawing question, not a fix. */
/* ⚠⚠ AND THE SHEET'S OWN BOX, PUT BACK. Deleted by accident when the rule above was written, and the fault reached the
   owner within the hour: on `scatter` a shaped work's picture was laid out by the CELL rather than by its frame, so the
   crop and *closer · farther · centre* moved only the ghost behind it — "changes to the cropping of the photo are
   presented behind the photo but not applying to the image". Never widen a rule by deleting the narrow one it replaces. */
.fam-sheet :is(#grid, .loupe-grid) > [data-shape] > .shape { overflow: hidden; border-radius: var(--thumb-radius, 2px); display: block; }
.fam-sheet :is(#grid, .loupe-grid) > [data-shape] > .shape > img { width: 100%; height: 100%; object-fit: cover; display: block; }
:is(#grid, .loupe-grid) > [data-shape] > .shape { display: contents; }
:is(#grid, .loupe-grid)[data-mat="page"] > [data-shape]::before { background: transparent; border-color: transparent; box-shadow: none; }
/* ⚠⚠ A MAT IS ALWAYS EVEN (0080; the owner, 2026-09-18: "mats should always be even. There's no scenario I can think of
   where that would not be the case, ie., where someone would want white showing on the top and bottom of an image but
   not the sides"). So nothing here colors what a frame's shape leaves over around a whole picture — on an app gallery
   (whose manifest says `mat`; the demo's does not, and keeps its template's cells) that leftover is always the page,
   whatever the mat. The rule for `page` above stays as it was; this one is beside it, for every mat. */
:is(#grid, .loupe-grid)[data-mat] .cell[data-fit="whole"]:not([data-matted]),
:is(#grid, .loupe-grid).live[data-mat] .cell[data-fit="whole"]:not([data-matted]):hover,
:is(#grid, .loupe-grid).live[data-mat] .cell[data-fit="whole"]:not([data-matted]):focus-visible { background: transparent; border-color: transparent; box-shadow: none; }
/* ⚠⚠ THE MAT IN THE PICTURE'S OWN SHAPE (the owner: "a mat to be created around the thumb in the exact shape of the
   thumb itself", then "b"). On a sheet, a whole picture at 100% or less sits in a frame that HUGS it: the picture as
   large as its place allows, times --cz, and around it a border whose width is what the zoom gave up, measured on the
   picture's shorter side and the same on all four sides. At 100% the border is nothing and the frame is the picture;
   `farther` trades picture for mat. A shape chosen for the frame is kept, and returns when the picture is closer than
   100%. The slot is a size container so the frame is sized from it; --ar is the picture's own width over height
   (loupe.js, from the manifest). The picture fills its box exactly — nothing cropped — and the scale the placement
   draws elsewhere is not drawn here: the size IS the zoom. */
:is(#grid, .loupe-grid)[data-mat="white"] { --mat-ground: #f6f4ef; }
:is(#grid, .loupe-grid)[data-mat="bone"] { --mat-ground: #e8e0cf; }
:is(#grid, .loupe-grid)[data-mat="black"] { --mat-ground: #0c0b0a; }
.fam-sheet :is(#grid, .loupe-grid) > .cell[data-matted] { container-type: size; background: transparent; border-color: transparent; box-shadow: none;
  --cw: min(100cqw, 100cqh * var(--ar, 1.5)); --ch: min(100cqh, 100cqw / var(--ar, 1.5));
  --pw: calc(var(--cz, 1) * var(--cw)); --ph: calc(var(--cz, 1) * var(--ch));
  --m: calc((1 - var(--cz, 1)) * min(var(--cw), var(--ch)) / 2); }
.fam-sheet :is(#grid, .loupe-grid) > .cell[data-matted]::before { content: ''; position: absolute; box-sizing: border-box;
  width: calc(var(--pw) + 2 * var(--m)); height: calc(var(--ph) + 2 * var(--m));
  left: calc((100cqw - var(--pw)) / 2 - var(--m)); top: calc((100cqh - var(--ph)) / 2 - var(--m));
  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, 0 8px 30px rgba(0, 0, 0, 0.65)); }
.fam-sheet :is(#grid, .loupe-grid) > .cell[data-matted] > .shape, .fam-sheet :is(#grid, .loupe-grid) > .cell[data-matted]:not([data-shape]) > img {
  position: absolute; box-sizing: border-box; display: block; width: var(--pw); height: var(--ph);
  left: calc((100cqw - var(--pw)) / 2); top: calc((100cqh - var(--ph)) / 2); }
.fam-sheet :is(#grid, .loupe-grid) > .cell[data-matted] img { transform: none; object-fit: cover; }
:is(#grid, .loupe-grid)[data-mat="page"] > .cell[data-matted]::before { background: transparent; border-color: transparent; box-shadow: none; }
/* ⚠⚠ A SHEET DOOR'S OWN GROUND, AND IT WAS NOT DRAWN ON THE PAGE AT ALL (the owner, 2026-09-21: "could you check
   the 'its own edges' tooling again?").
   ⚠ `--pic-bg` — the colours read from a picture's own edges and continued outward — had exactly ONE reader,
   `.lb-under … > .cell > .pic`, and a SHEET door that is matted has no `.pic` box (loupe.js → picbox excludes it,
   because the mat owns its picture's box already). So on a wing the bench drew the ground and the live page drew
   nothing: measured on both, `--pic-bg` set on the cell in each, `background-image: none` on the page.
   ⚠⚠ THE CONTENT BOX, NOT THE PADDING BOX. `dn-under` gives the cell a `padding-bottom` for the door's name, so
   the picture fills the CONTENT box while a background would paint over the PADDING box — measured on `blown`:
   the cell 360×240, the picture 360×207, so the run was stretched 16% too tall and drifted out of register with
   the picture it continues, worst at the foot. It also bled a band of the door's grey behind the name, where
   every other door's name sits on the wall.
   ⚠ Two selectors on purpose: `[data-matted]` sets `background: transparent` at the same weight as the first of
   these, and a tie would be settled by source order alone. */
/* ⚠⚠ AND THE THIRD SHAPE: A GRID CELL WITH NO LABEL UNDER IT (2026-09-21, later — found while giving the grid &
   sequence tab the same ground control the other tabs have, and measured on `contact`: `data-ground` and a
   `--pic-bg` of #c2410c left the cell painted its template's own #5c5c5c, because NOTHING READ THE PROPERTY).
   `--pic-bg` has exactly three readers and each is a different box: `.pic` where a label sits under the picture,
   the cell where a sheet mats it, and — until now — nowhere at all for `lb-none`, which is five of the twelve
   grid templates (gallery-wall, darkroom, case, contact, salon).
   ⚠ `:not(.lb-under)` is the whole condition, because `lb-under` is exactly what makes loupe.js build a `.pic`
   box (→ picbox). Painting the cell as well where one exists would put the ground behind the LABEL too.
   ⚠ The filter moves out with it, for the same reason it moves for a matted picture: a uniform step along a
   straight edge is the shape an eye reads as a drawn line. */
.fam-grid:not(.lb-under) :is(#grid, .loupe-grid) > .cell[data-ground] {
  background: var(--pic-bg, var(--thumb-bg, #0a0809));
  background-origin: content-box;
  background-clip: content-box;
  filter: var(--thumb-filter, none);
}
.fam-grid:not(.lb-under) :is(#grid, .loupe-grid) > .cell[data-ground] img { filter: none; }
.fam-grid:not(.lb-under) :is(#grid, .loupe-grid).live > .cell[data-ground]:hover,
.fam-grid:not(.lb-under) :is(#grid, .loupe-grid).live > .cell[data-ground]:focus-visible { filter: var(--thumb-filter-hover, none); }
.fam-sheet :is(#grid, .loupe-grid) > .cell[data-ground],
.fam-sheet :is(#grid, .loupe-grid) > .cell[data-ground][data-matted] {
  background: var(--pic-bg, var(--thumb-bg, #0a0809));
  background-origin: content-box;
  background-clip: content-box;
}
/* ⚠⚠ …AND A MATTED PICTURE ON ITS OWN GROUND IS ONE OBJECT WITH IT TOO (2026-09-21, later — the owner, of a Sheet
   frame he had just set to *its own edges, continued*: "this is creating hairlines between the image and the
   background"). MEASURED ON THE PAGE, both causes, and both are the ones 2026-09-20 already named for a door —
   the fix simply never reached the shape that has NO `.pic` box to move them to:
     1. `::before` — the frame — is drawn at the PICTURE's size (measured 184.25 × 141.141 against a 286-wide
        cell), so its 1px `--thumb-border` ran exactly down the join between the picture and its own ground.
        It now takes the WHOLE box, which is the object the frame belongs around, and its `--thumb-bg` goes: the
        ground is what stands behind the picture there, and a background painted over it would hide it.
     2. `--thumb-filter` sat on the picture — `saturate(0.92)` on scatter — while the ground beside it was at
        full strength. A small, perfectly uniform step along a straight edge is the shape an eye reads as a drawn
        line. It moves out to the cell, which holds the ground and the picture together, so both are treated at
        once; the picture itself is then left alone, on hover as well.
   ⚠ A matted cell is `.fam-sheet`'s alone (the geometry above), and it never has a `.pic` — `picbox` in loupe.js
   excludes it, because the mat owns its picture's box already. That is exactly why 2026-09-20's rule, which moves
   the treatment onto `.pic`, could not reach this one.
   ⚠ It must OUTRANK what it overrides, never replace it (SIBLINGS.md records that fault twice): these are
   (1,4,x) against `.cell img` at (0,1,1) and the hover at (1,3,1), so the picture stays untreated under the
   cursor too. */
.fam-sheet :is(#grid, .loupe-grid) > .cell[data-ground][data-matted]::before {
  left: 0; top: 0; width: 100cqw; height: 100cqh;
  background: transparent;
}
.fam-sheet :is(#grid, .loupe-grid) > .cell[data-ground][data-matted] { filter: var(--thumb-filter, none); }
.fam-sheet :is(#grid, .loupe-grid) > .cell[data-ground][data-matted] img { filter: none; }
.fam-sheet :is(#grid, .loupe-grid).live > .cell[data-ground][data-matted]:hover,
.fam-sheet :is(#grid, .loupe-grid).live > .cell[data-ground][data-matted]:focus-visible { filter: var(--thumb-filter-hover, none); }

/* ⚠⚠ A CROP IN A MAT OF ITS OWN (0090; the owner, 2026-09-19: "the mat was supposed to be just around the cropping"). A
   picture that FILLS its frame, set below 100%: the frame is the slot's own shape at --cz of it — the crop — and the mat
   hugs it, even on all four sides, the rest of the slot left empty. The geometry above, with the slot's proportion where a
   whole picture's own would be; the picture covers its frame, placed at its point. */
.fam-sheet :is(#grid, .loupe-grid) > .cell[data-matted][data-fit="fill"] { --cw: 100cqw; --ch: 100cqh; }
/* … and CROPPED INTO, whole or filled, without changing its size in the mat: the picture scaled about its point, and clipped
   back to its own frame — the clip is drawn before the scale, so it is set inside by the same factor. */
.fam-sheet :is(#grid, .loupe-grid) > .cell[data-matted][data-cin] img { transform: scale(var(--cin)); transform-origin: var(--cx, 50%) var(--cy, 50%);
  clip-path: inset(calc(var(--cy, 50%) * (1 - 1 / var(--cin))) calc((100% - var(--cx, 50%)) * (1 - 1 / var(--cin)))
                   calc((100% - var(--cy, 50%)) * (1 - 1 / var(--cin))) calc(var(--cx, 50%) * (1 - 1 / var(--cin)))); }
/* ⚠⚠ A MAT AROUND A CROPPED PICTURE (0089; the owner, 2026-09-19: "what if I wanted to put a mat around a photo we present as
   a zoomed in crop?" → "everywhere, put it in the real editor too"). The frame stays the slot; inside it, an even border of
   --inset times the slot's SHORTER side — the same on all four sides, never a share of each (the owner, the same morning:
   "the proportions of the mat need to be even") — and the picture fills what is left, cropped and zoomed as placed, the
   box clipping its zoom. The frame's own ground is the mat, or the gallery's mat color; `page` leaves the ground. */
.fam-sheet :is(#grid, .loupe-grid) > .cell[data-inset] { container-type: size; --m: calc(var(--inset, 0) * min(100cqw, 100cqh)); }
.fam-sheet :is(#grid, .loupe-grid) > .cell[data-inset] > .inset { position: absolute; inset: var(--m); overflow: hidden; display: block; }
.fam-sheet :is(#grid, .loupe-grid) > .cell[data-inset] > .inset > img { width: 100%; height: 100%; object-fit: cover; }
:is(#grid, .loupe-grid) > [data-inset] > .inset { display: contents; }
:is(#grid, .loupe-grid)[data-mat="page"] > .cell[data-inset] { background: transparent; border-color: transparent; box-shadow: none; }
:is(#grid, .loupe-grid):is([data-mat="white"], [data-mat="bone"], [data-mat="black"]) > .cell[data-inset] { background: var(--mat-ground); border-color: transparent; }
:is(#grid, .loupe-grid):is([data-mat="white"], [data-mat="bone"], [data-mat="black"]) > .cell[data-matted]::before { background: var(--mat-ground); border-color: transparent; }
/* ⚠ A HAIRLINE AT THE PICTURE'S OWN EDGE (0081; the owner, 2026-09-18: "hairlines around an image could be yet another
   setting" → "none dark light"). One pixel, drawn over the picture's edge wherever that edge is KNOWN: a picture that
   fills its frame (the cell), a shaped frame on a sheet (the frame), a picture in a mat (the picture's box, so the line
   sits between picture and mat). A whole picture loose in a frame of another shape gets none — there the frame's edge
   is not the picture's, and a line there would be exactly the uneven look the mats were made even to end. */
:is(#grid, .loupe-grid)[data-hairline="dark"] { --hair: rgba(12, 10, 8, 0.72); }
:is(#grid, .loupe-grid)[data-hairline="light"] { --hair: rgba(255, 255, 255, 0.8); }
:is(#grid, .loupe-grid)[data-hairline] > .cell[data-fit="fill"]::after,
:is(#grid, .loupe-grid)[data-hairline] > .cell[data-matted]::after,
/* ⚠ …and a whole picture ON ITS OWN BACKGROUND (2026-09-24, the owner: "the hairline setting here does not apply to some
   photos"): it fills its frame edge to edge, so the frame's edge is the picture's now */
:is(#grid, .loupe-grid)[data-hairline] > .cell[data-ext][data-fit="whole"]::after,
/* ⚠ …and a whole picture on `deckle` (2026-09-24, the owner, of the cuttings: "selecting an outline on this page only outlines
   the image in the bottom right"): deckle's cell takes its picture's own shape, so the cell's edge IS the picture's — the
   one family where "loose in a frame of another shape" never happens — at ONE column and ONE row: given more, the cell is
   the rows' and columns' and the picture stands loose in it again */
.p-deckle :is(#grid, .loupe-grid)[data-hairline] > .cell[data-fit="whole"]:not([data-tall]:not([data-tall="1"])):not([data-wide]:not([data-wide="1"]))::after,
.fam-sheet :is(#grid, .loupe-grid)[data-hairline] > .cell[data-shape]::after { content: ''; position: absolute; inset: 0; z-index: 1; pointer-events: none;
  box-shadow: inset 0 0 0 1px var(--hair); border-radius: var(--thumb-radius, 2px); }
.fam-sheet :is(#grid, .loupe-grid)[data-hairline] > .cell[data-shape]:not([data-matted])::after { inset: auto; box-sizing: border-box;
  width: min(100cqw, 100cqh * var(--shape, 1)); height: min(100cqh, 100cqw / var(--shape, 1));
  left: calc((100cqw - min(100cqw, 100cqh * var(--shape, 1))) / 2); top: calc((100cqh - min(100cqh, 100cqw / var(--shape, 1))) / 2); }
.fam-sheet :is(#grid, .loupe-grid)[data-hairline] > .cell[data-matted]::after { inset: auto; width: var(--pw); height: var(--ph);
  left: calc((100cqw - var(--pw)) / 2); top: calc((100cqh - var(--ph)) / 2); border-radius: 0; }
.fam-sheet :is(#grid, .loupe-grid)[data-hairline] > .cell[data-inset]::after { inset: var(--m); border-radius: 0; }

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

:is(#grid, .loupe-grid).live .cell:hover,
:is(#grid, .loupe-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;
}

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

/* ⚠⚠ ON A WING THE HOVER BELONGS TO THE PICTURE, NOT THE WHOLE DOOR (the owner, 2026-09-20: "Not a fan of the
   onhover covering the title and th image on wings. It's also very dark. More subtle, and just on the image?").
   `.cell .n` is absolutely positioned against the CELL and inset `4px 0 0 0`, so it covers everything in it. On a
   gallery the cell IS the picture and that is right. On a wing `lb-under` puts the door's NAME inside the cell
   under its picture, so the same scrim ran straight over the name — measured: a 152x152 picture in a 180px cell,
   with the scrim 180px tall and reaching `rgba(25 26 27 / 0.72)`.
   ⚠ THE NUMBER GOES WITH IT, and it was already unreadable: `--n-ink` is dark on a light template, so on
   `gallery-wall` a dark number sat on a dark scrim. A door on a wing is identified by its NAME, which is drawn
   under it — the index adds nothing a visitor can use.
   ⚠ A PIN THAT OUTRANKS ITS FAMILY (SIBLINGS.md, and the pin above at `.all-doors.lb-under`): this ADDS a class
   rather than deleting the narrow rule, so `.all-doors` beats the family rule on specificity and a gallery's own
   hover is untouched.
   ⚠ `pointer-events: none` — every door is a link, and a scrim that ate the click would be a door that does not open. */
.all-doors :is(#grid, .loupe-grid).live .cell:hover .n,
.all-doors :is(#grid, .loupe-grid).live .cell:focus-visible .n { opacity: 0; }

.all-doors :is(#grid, .loupe-grid) > .cell > .pic::after {
  content: "";
  position: absolute;
  inset: auto 0 0 0;
  height: 45%;
  background: linear-gradient(transparent, rgba(var(--scrim-rgb, 0 0 0) / 0.22));
  opacity: 0;
  transition: opacity 240ms ease;
  pointer-events: none;
  z-index: 1;
}
.all-doors :is(#grid, .loupe-grid).live > .cell:hover > .pic::after,
.all-doors :is(#grid, .loupe-grid).live > .cell:focus-visible > .pic::after { opacity: 1; }

/* ══ A DOOR ON A SHEET WING SAYS WHICH GALLERY IT OPENS ══════════════════════════════════════════════════════
   The owner, 2026-09-20: "I am also seeing 'THE GALLERIES' as titles for the indiv galleries … replacing their
   actual titles like valle crucis"; then, of eight of his twenty wings, the name "is never drawn" — thirteen
   doors showed a bare number. Two ways were drawn as comps on the live page and he chose: 2026-09-21, "over or
   under should be configurable, both for me in setting the layouts on /themes as well as for artists designing
   their own wings", then "it should default to under".
   ⚠ THE DIAL IS `name` — under (the default) · on. It resolves in presets.js and the CLASS is added by loupe.js,
   which is the only place that knows a wing from a gallery. `dn-under` / `dn-over` are the engine's own names,
   kept from the comps that were drawn and judged under them; the words the artist reads are *under it · on it*.
   ⚠ A Sheet cell is absolutely placed by its frame, and that frame is the only box a Sheet gives a door.

   ⚠⚠ BOTH VARIANTS MUST PUT THE FRAME'S HEIGHT BACK. `lb-under` sets `height: auto; overflow: visible` — right
   for a GRID, where the cell grows around picture and label. A Sheet cell's height IS the frame the artist placed,
   so it has to be restored or the cell collapses. Measured on the live comps 2026-09-21, `colorway` sheet 1,
   three frames each declared at `--fh: 45.455`: with the restore, 253 · 253 · 253 · 225 · 225 — every frame where
   it was placed; without it, 152 · 190 · 205 · 0 · 231, and `paintings` (a MATTED WHOLE picture) at ZERO. */
.dn-under.fam-sheet :is(#grid, .loupe-grid) > .cell,
.dn-over.fam-sheet :is(#grid, .loupe-grid) > .cell {
  /* ⚠⚠ THE HEIGHT, AND NOTHING ELSE. This also said `overflow: visible` — `lb-under`'s own value, restated here
     "so the name is not cut off" — and that RESTATEMENT BROKE THE ZOOM CLIP (the owner, 2026-09-21: "the
     closer/farther button … expands the thumbnail outside its bounding box", with a door painting over the one
     beside it). `:is(#grid, .loupe-grid) > .cell[data-zoom][data-fit] { overflow: hidden }` is the narrow rule that
     makes a zoom a crop, at (1,3,0); this selector is ALSO (1,3,0) and comes later in the file, so it won.
     ⚠ Saying nothing about overflow is the fix: `lb-under`'s `visible` still applies at (1,2,0), and the zoom rule
     still outranks it, exactly as on every other family. The name was never at risk either way — `.wl` is absolute
     at the bottom of the PADDING box, which is inside what `overflow` clips to.
     ⚠⚠ THE RULE THIS BROKE IS THE ONE IN STATUS.md's TRAPS: never widen a rule by overriding the narrow one. */
  height: calc(var(--fh, 30) * 1%);
}

/* ⚠⚠ UNDER THE FRAME — AND THE CONTENT BOX IS WHAT GIVES UP THE LINE. `box-sizing: border-box` with a
   `padding-bottom` shrinks the cell's CONTENT box while the frame keeps the size and the place the artist gave it,
   and every one of the five shapes a Sheet cell can take then lands correctly without knowing a name exists:
     · a plain cell   — the `.pic` box is `height: 100%` of the content box
     · `data-matted`  — `container-type: size` measures the CONTENT box, so `100cqh` is already the reduced
                        height and the mat centres itself in it
     · `data-shape`, `data-inset` — their own inner boxes are absolute/`inset` within it
   ⚠⚠ THE FIRST TRY RESTRUCTURED THE CELL into `grid-template-rows: 1fr auto` and was WRONG IN TWO CELLS OF FIVE
   (the owner, 2026-09-20: "is this correct? The spacing looks off"): `colors` carries `data-zoom` and painted
   264px through a 218px row, and `paintings` is `data-matted`, whose `container-type: size` fought the rows until
   the label measured 230px tall. A Sheet cell has five shapes, and a rule that knows about one of them is a rule
   that breaks the other four. A padding is the one change all five already understand. */
.dn-under.fam-sheet :is(#grid, .loupe-grid) > .cell {
  box-sizing: border-box;
  padding-bottom: 2.1em;
}
/* ⚠ the generic `.lb-under … > .pic` above gives this box its border, its ground (`--pic-bg`, the door's own read
   edges) and its clipping, at a GRID CELL'S fixed height; only the height is a sheet's own business. */
.dn-under.fam-sheet :is(#grid, .loupe-grid) > .cell > .pic { height: 100%; }
/* the name, on the line the padding made, inside the frame
   ⚠⚠ NO BAND OF WALL BEHIND IT, and that is what the picture box bought. The comp painted `var(--ground)` here to
   stop a zoomed picture transforming straight over the name — but `--ground` is the WING'S, never the door's, so a
   door wearing its own edges got a stripe of the wrong colour across its foot (measured on the live comp: door
   #4d4d4d, band #8f2620). Now the `.pic` box clips the zoom, so there is nothing to hide and no colour to guess. */
.dn-under.fam-sheet :is(#grid, .loupe-grid) > .cell > .wl {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 2.1em;
  /* ⚠ A BLOCK, NOT A FLEX BOX, and the line-height is what centres the name in it. `text-overflow: ellipsis` acts
     on the block that OVERFLOWS, and a flex container's overflowing child is the `.wl-t` span rather than any
     inline text — so on a flex `.wl` a long name was cut off mid-letter with no ellipsis ("light studies" on
     colorway's third frame). As a block the span is inline content of the thing that clips, and it elides. */
  display: block; line-height: 2.1em;
  margin: 0; padding: 0 2px;
  max-width: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  text-align: left; opacity: 1;
}

/* ON THE PICTURE'S FOOT. Nothing the artist placed moves; the name is carried on the work itself, on the same
   scrim the hover already uses, so the two read as one idea rather than two. */
.dn-over.fam-sheet :is(#grid, .loupe-grid) > .cell > .pic { height: 100%; }
.dn-over.fam-sheet :is(#grid, .loupe-grid) > .cell > .wl {
  position: absolute; inset: auto 0 0 0; z-index: 3;
  display: block; margin: 0; max-width: none; opacity: 1;
  padding: 26px 10px 8px; text-align: center; white-space: normal;
  color: #fff;
  background: linear-gradient(transparent, rgba(var(--scrim-rgb, 0 0 0) / 0.62));
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
.dn-over.fam-sheet :is(#grid, .loupe-grid) > .cell > .wl :is(.wl-m, .wl-t, .wl-d) { color: inherit; }

/* ---------- 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);
  /* the secondary ink, as the standing credit's line (#plate-text .s): the faint one measured 3.4–3.7:1 at 10px on every
     artist's site (W2.9, UX-20) */
  color: var(--ink-dim, #b8ada2);
  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;
}
/* ⚠⚠ EACH PART OF THE RECORD IS TWO LINES, AND THE REST IS A CLICK AWAY (2026-09-21, later — the owner, of a
   museum tapestry whose record ran to 614 characters: "is there a rule we can set to flag text that gets so long
   it impinges upon the image"). His call, on two drawn pages: measure so nothing can ever overlap (loupe.js →
   fitLabel), AND cap each part, so a long one costs the work almost nothing. Measured on that work: the label
   257px and the picture 377px tall if every word is shown; clamped, the label is 138px and the picture 496px —
   9% smaller rather than 31%.
   ⚠ PER PART, not on the block: the offender is nearly always ONE field — there, a 354-character materials
   paragraph beside a 16-character size. Clamping the block would have cut the dimensions off to make room for the
   weave analysis; clamping each part keeps every part present.
   ⚠ The `…` is the whole affordance, so it must be the browser's own: `line-clamp` draws it at the end of the
   last line it keeps, in the text's own face, and no width has to be guessed.
   ⚠ OPEN IS NOT UNLIMITED — the band stops growing at 46vh (loupe.js) and the label scrolls inside it, so a
   record an artist pastes can never leave the work a sliver. */
#light .meta.wall span {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: var(--label-lines, 2);
  line-clamp: var(--label-lines, 2);
  overflow: hidden;
}
#light .meta.wall.more { cursor: pointer; }
#light .meta.wall.open { max-height: 46vh; overflow-y: auto; cursor: pointer; }
#light .meta.wall.open span { display: block; overflow: visible; -webkit-line-clamp: none; line-clamp: none; }
/* the one ring the enlargement draws — it is a button while there is more to read */
#light .meta.wall:focus-visible { outline: 2px solid var(--accent, rgba(255, 214, 160, 0.75)); outline-offset: 6px; }

/* ⚠⚠ 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-dim, #b8ada2); }   /* the year, medium and size: the faint ink was 3.3–3.7:1 (W2.9) */

/* ⚠⚠ ‹ › AND × ON THE ENLARGEMENT (2026-10-08, scrapbook's gallery review): a visitor with a mouse could not step through a
   gallery — the only ways on were the arrow keys and a swipe, and any click closed it. The same disc as look closer, for
   the same reason (a fixed dark disc and a light glyph read on any ground), at the window's edges on the dark. Children
   of #light, so they fade with it and take its pointer-events: nothing to press while it is closed. */
.light-step,
.light-close {
  position: fixed;
  z-index: 4;
  width: 40px;
  height: 40px;
  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: pointer;
  opacity: 0.6;
  transition: opacity 300ms ease;
  -webkit-tap-highlight-color: transparent;
}
.light-step { top: 50%; margin-top: -20px; }
/* ⚠ in from the edge by the notch where a phone held sideways has one (as #plate-text), and past f-edge's line at 20px */
.light-step.prev { left: calc(max(2.5vw, 28px) + env(safe-area-inset-left, 0px)); }
.light-step.next { right: calc(max(2.5vw, 28px) + env(safe-area-inset-right, 0px)); }
.light-close { top: calc(max(3vh, 18px) + env(safe-area-inset-top, 0px)); right: calc(max(3vw, 18px) + env(safe-area-inset-right, 0px)); }
.light-step:hover, .light-close:hover,
.light-step:focus-visible, .light-close:focus-visible { opacity: 1; outline: none; }
.light-step:focus-visible, .light-close:focus-visible { box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.9), 0 0 0 4px rgba(0, 0, 0, 0.55); }   /* white, edged dark: it reads on any ground (W2.9, UX-20) */
.light-step svg, .light-close svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.light-step[hidden], .light-close[hidden] { display: none !important; }   /* `display: grid` outranks the UA's [hidden] */
@media (pointer: coarse) {
  .light-step, .light-close { width: 44px; height: 44px; opacity: 0.8; }
  .light-step { margin-top: -22px; }
  .light-step svg, .light-close svg { width: 22px; height: 22px; }
}
/* ⚠ a phone held upright swipes (2026-09-13): ‹ › would stand on the picture's own edges there, so only × is drawn */
@media (pointer: coarse) and (max-width: 720px) { .light-step { display: none; } }
/* the picture itself never closes the enlargement (2026-10-08); the dark around it does, so only the dark says zoom-out */
#light .mount { cursor: default; }

/* ---------- 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 color 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.9), 0 0 0 4px rgba(0, 0, 0, 0.55); }   /* white, edged dark: it reads on any ground (W2.9, UX-20) */
.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 colors 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); }
}

/* ⚠ the example site's bar steps back while the layer is open (W2.8): above the page (z-index 40, _example.html), it covered
   the layer's controls at its top on a desktop — a gallery, a work's own page, a page of words, Home */
html[data-closer] .example-bar { visibility: hidden; }

/* ---------- 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 :is(#grid, .loupe-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; }

/* ⚠ THE EXAMPLE SITE'S BAR STEPS BACK WHILE A PICTURE IS OPEN (W2.8, UX-22): it stands above the page (z-index 40, the app's
   _example.html), and on a phone it covered the enlargement's × — a tap there opened the credits instead of closing. The
   deep zoom's layer likewise: its rule is in the look-closer section, so closer-layer.css carries it to every page. */
body.lit .example-bar { visibility: hidden; }

/* ⚠⚠ A KEYBOARD'S PLACE IS ALWAYS SEEN (W2.9, UX-20; WCAG 2.4.7): a focused cell changed only a 1px border, and on the
   artists' default mat (`page`) not even that — the rules above take it away with the hover. A ring outside the cell, in the
   template's own ink (made to read on its ground, as the accent is not on a light one), for the keyboard only: a pointer's
   hover keeps its own look. */
:is(#grid, .loupe-grid).live .cell:focus-visible,
#rail.live .thumb:focus-visible { outline: 2px solid var(--ink, #e8dcc9); outline-offset: 3px; }
/* ⚠ (the skeptic) a focused cell above the cells after it, which flush draws over it; inside the thumb on a rail, which
   clips everything outside its own height; inside a Story's cover, which fills the window */
:is(#grid, .loupe-grid).live .cell:focus-visible { z-index: 1; }
#rail.live .thumb:focus-visible,
.fam-story :is(#grid, .loupe-grid).live .cell:focus-visible { outline-offset: -4px; }
