/* moore archives — recreated from the 2004 Flash source.
   Geometry, type and timings are lifted verbatim from shell.swf and
   kodachromes.swf; see tools/ for the extractors that produced them. */

/* ---------------------------------------------------------------- fonts
   Subsets rebuilt from the DefineFont2 glyph outlines embedded in the SWFs.
   Metrics are uniform (upm 1024, ascent 760, descent 240, no line gap) so the
   baseline offset used by .t below is exact. They also carry the kerning
   reconstructed from the SWF's own DefineText advances.

   The display Caslon in the original is set in italic; the caption body is
   roman. Only the faces the SWFs actually embedded exist here. */
@font-face { font-family: "Caslon 224 Black";  src: url("../fonts/caslon224-black-italic.woff2")  format("woff2"); font-style: italic; font-display: block; }
@font-face { font-family: "Caslon 224 Medium"; src: url("../fonts/caslon224-medium.woff2")        format("woff2"); font-display: block; }
@font-face { font-family: "Caslon 224 Medium"; src: url("../fonts/caslon224-medium-italic.woff2") format("woff2"); font-style: italic; font-display: block; }
@font-face { font-family: "Caslon 224 Book";   src: url("../fonts/caslon224-book.woff2")          format("woff2"); font-display: block; }
@font-face { font-family: "Caslon 224 Book";   src: url("../fonts/caslon224-book-italic.woff2")   format("woff2"); font-style: italic; font-display: block; }
@font-face { font-family: "Akzidenz BE";       src: url("../fonts/akzidenz-be.woff2")             format("woff2"); font-display: block; }
@font-face { font-family: "Akzidenz BE Cn";    src: url("../fonts/akzidenz-be-cn.woff2")          format("woff2"); font-display: block; }
@font-face { font-family: "Helvetica Neue 45"; src: url("../fonts/helvetica-neue-45.woff2")       format("woff2"); font-display: block; }
/* **The one face that did not come out of a SWF**, and the only one with a
   character set rather than a subset: 327 glyphs of Latin against the 81 of
   the face it replaces. It sets the archive's *prose* and nothing else — see
   `--prose` below. SIL OFL 1.1; source and licence in `tools/fonts-src/`,
   built by `fonts.libre`. The italic is declared and is not downloaded until
   something on the page is italic, which today nothing is. */
@font-face { font-family: "Libre Caslon";      src: url("../fonts/librecaslontext.woff2")        format("woff2"); font-weight: 400 700; font-display: block; }
@font-face { font-family: "Libre Caslon";      src: url("../fonts/librecaslontext-italic.woff2") format("woff2"); font-weight: 400 700; font-style: italic; font-display: block; }

:root {
  --stage-w: 800;
  /* Back to the 2004 stage. It was taken to the 2005 build's 622 so those
     sections would fit at their own coordinates; they fit at 550 too, once
     each sheet is centred in the band rather than placed at its original y,
     and 622 left every page with room going spare at the top. `--drop` and
     `--grid-drop` fall to zero on their own from this. */
  --stage-h: 550;
  --paper: #cccccc;          /* SetBackgroundColor from both SWFs */
  --ink: #000;
  --rule: #333;              /* the 1px nav divider strokes */
  /* line-height:1 puts the baseline at half-leading + ascent from the top:
     (1 - 1000/1024)/2 + 760/1024 = 0.753906em */
  --baseline-offset: 0.753906em;
  /* Frame counts from the SWF timelines, at its 20 fps — but taken down by
     about a third. The 2004 timings were authored for a file that had to
     stream over a modem, where a slow dissolve covered the wait; with the
     enlargements primed on hover there is nothing left to cover, and the
     original 700ms sheet return in particular reads as lag rather than as
     grace. The proportions between them are kept.
       thumbs in   14 frames  700 -> 420
       thumbs out  13 frames  650 -> 200   (see below)
       enlarge      9 frames  450 -> 300
       caption                200 -> 150 */
  --t-thumbs-in: 420ms;
  /* The SWF took 13 frames to clear the sheet while the enlargement arrived
     in 9, so thumbnails were still dissolving behind a photograph that had
     already landed — it reads as lag. Clear first, and quickly. */
  --t-thumbs-out: 240ms;
  --t-enlarge: 420ms;
  --t-dim: 350ms;
  /* ⚠ **Coming back is faster than going down — 2026-08-27.** Dimming happens
     while the reader is moving and is not waited on; *un*-dimming happens
     after they have stopped, and is. The owner asked to halve the time a sheet
     takes to reach full strength once the pointer rests between frames: that
     is `gallery.roll`'s `restMs`, and this fade.

     ⚠ **Both were halved and only the wait should have been.** At 175ms the
     sheet came back with a snap; the owner, having felt it: *"I like that
     later one to be the way it was, 350."* So the *wait* stays halved at 500ms
     and the *fade* is 350ms again — what was slow was the stillness before
     anything happened, not the coming back.

     **The variable stays even though it now equals `--t-dim`**, because the
     two directions turned out to be separable and worth being able to tune
     apart. Only the return uses it; the dimmed rules keep `--t-dim`. */
  --t-undim: 350ms;
  --t-caption: 300ms;        /* the annotation rides in with the photograph */
  /* Flash tweened linearly, and a linear opacity ramp is what made this read
     as blocky: it starts and stops at full speed, so there is no sense of
     anything having been set in motion. These are the two curves the whole
     enlargement uses.

     `glide` is a decelerating curve — quick off the mark, long settle — which
     is what lets the sheet clear early while the photograph is still easing
     in, keeping the ordering the note above is about. `ease-out-soft` is
     gentler still, for the thing that leaves.

     Duration matters as much as shape: 300ms of movement is about 18 frames,
     and the eye reads the end of a short ramp as a stop rather than a
     settle. 420 buys enough frames to feel continuous without dragging. */
  --ease-glide: cubic-bezier(.16, .68, .3, 1);
  --ease-soft:  cubic-bezier(.4, 0, .5, 1);
  /* how far the enlargement travels as it arrives. Small on purpose — this is
     a photograph coming to rest, not a card being dealt. */
  --enlarge-rise: 10px;
  --t-flip: 560ms;           /* turning a print over to read its back */
  /* ...and stepping along a long card, which slides rather than turns. Slower
     than a cross-fade would be because the eye is following something across
     the box rather than watching it appear in place. */
  --t-step: 420ms;
  /* ---- the slideshow. Four motions and four speeds, which is the whole of
     its pacing; `stage.js` reads all four off here rather than holding its own
     copies, for the reason the stage size is read rather than declared.

     They are not variations on one number. `--t-lead` carries the archive to
     the photographs and back and is by far the slowest — it is a change of
     mode, not of picture. `--t-dissolve` is between one photograph and the
     next. `--t-veil` is the wash of paper going over or off, half the lead,
     because at the lead's own speed the arrival read as slower than it is.
     The splash's small cover photograph has **one duration for each leg**,
     because the two legs do different work and are not bounded by the same
     thing. `--t-cover-out` has a ceiling: the cover has to be out of the way
     before the first photograph is halfway up — at the lead's 1500 that is
     750ms — or there are two photographs on a page that only ever had one.
     `--t-cover-in` has no ceiling at all, since it plays after everything else
     has finished and there is nothing left for it to compete with; it is a
     judgement about how the page should settle, not a constraint.

     Both were 400ms when they were one dial, chosen against a moving
     photograph. They are slower now at the owner's ear. */
  --t-lead: 1500ms;
  --t-dissolve: 700ms;
  --t-veil: 750ms;
  --t-cover-out: 700ms;
  --t-cover-in: 600ms;
  /* ...and a third, which is a different act on the same element: the two
     above take the whole cover *away* when the slideshow goes full screen,
     while this is one cover dissolving into the next on the splash. Read by
     `stage.js` as well as by CSS, so the script knows when the layers have
     finished and it is safe to hand the photograph down to the base one. */
  --t-cover-swap: 900ms;
  /* how long a slide holds before the next begins, and how long the pointer
     must rest on the mark before a preview starts. Read by stage.js; nothing
     in CSS uses them, and they live here so the pacing is stated in one place. */
  /* the preview's cadence and the default. The reader's three choices —
     2, 5 and 10 seconds — live in stage.js beside the control that sets
     them; this is the one a page starts at. */
  --t-hold: 2000ms;
  --t-carousel-hover: 120ms;
  --dim-alpha: 0.2;
  --nav-off: .38;            /* a nav label that is not the one you want */
  /* The stage grew from the 2004 550 to the 2005 622 so the sections rebuilt
     from the 2005 build fit at their own coordinates. Everything that belongs
     to the *bottom* of the page — the watermark, the nav, its dividing rules,
     the sheet pager — is placed by an absolute y taken from the 2004 SWF, so
     without this it would float 72px up from the edge it was designed to sit
     on. It travels down with the stage instead. */
  --drop: calc(var(--stage-h) - 550);
  /* The sheets keep their 2004 coordinates too, so the taller stage opened all
     of the new space *below* them: the grid sat where it always had while the
     nav moved 72px away from it. Half the growth goes above and half below,
     which lands these sheets within a few pixels of where the 2005 sections
     put theirs — they run about 80 from the top and 90 from the nav.

     Only the Kodachrome sheet needs it. The computed sheets took the space
     into their own area instead (sections.AREA), so they fill it rather than
     merely sitting lower in it; the 2005 sections are placed at coordinates
     authored for this stage and were already where they belong. */
  --grid-drop: calc(var(--drop) / 2);
}

* { box-sizing: border-box; }

html { background: var(--paper); }

body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font-family: "Caslon 224 Book", Georgia, "Times New Roman", serif;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

a { color: inherit; }

.skip {
  position: absolute; left: -9999px; top: 0;
  background: #fff; padding: .5rem 1rem; z-index: 100;
}
.skip:focus { left: 0; }

/* --------------------------------------------------------------- stage
   The original is a fixed 800 x 550 composition. On wide viewports we keep it
   exactly and scale the whole thing; below --break we hand the same DOM over
   to a flow layout (see the media query at the end). */
.viewport {
  display: flex;
  justify-content: center;
  padding: 24px 0;
}

.stage {
  position: relative;
  width: calc(var(--stage-w) * 1px);
  height: calc(var(--stage-h) * 1px);
  flex: none;
  background: var(--paper) url("../img/paper.jpg") 0 0 / 100% 100% no-repeat;
  transform: scale(var(--s, 1));
  transform-origin: top center;
  box-shadow: 0 2px 24px rgb(0 0 0 / .18);
}

/* Absolutely positioned text, placed by its typographic baseline.
   --tr carries any per-field tracking the SWF applied on top of the font's
   own advances; the fonts themselves carry the kerning. */
.t {
  position: absolute;
  left: calc(var(--x) * 1px);
  top: calc(var(--bl) * 1px - var(--baseline-offset));
  font-size: calc(var(--sz) * 1px);
  line-height: 1;
  white-space: nowrap;
  letter-spacing: calc(var(--tr, 0) * 1px);
  font-kerning: normal;
  margin: 0;
}

.f-caslon-black  { font-family: "Caslon 224 Black", Georgia, serif; }
.f-caslon-medium { font-family: "Caslon 224 Medium", Georgia, serif; }
.f-caslon-book   { font-family: "Caslon 224 Book", Georgia, serif; }
.f-akzidenz      { font-family: "Akzidenz BE", "Helvetica Neue", Arial, sans-serif; }
.f-akzidenz-cn   { font-family: "Akzidenz BE Cn", "Helvetica Neue", Arial, sans-serif; }
.f-helvetica45   { font-family: "Helvetica Neue 45", "Helvetica Neue", Arial, sans-serif; }
.is-i            { font-style: italic; }

/* --------------------------------------------------- the top nav
   One square per section, along the top where the masthead leaves the stage
   empty; the one for the page you are on is drawn larger. Its colour is the
   section's own — the same the foot nav label wears, so the two marks agree.

   Right-aligned in the generator rather than here, so the row grows leftward
   as pages are added and can never reach the masthead. */
.topnav { margin: 0; padding: 0; }
.topnav-item {
  position: absolute;
  left: calc(var(--x) * 1px);
  top: calc(var(--y) * 1px);
  width: calc(var(--s) * 1px);
  height: calc(var(--s) * 1px);
  background: var(--on);
  display: block;
  opacity: var(--nav-off);
  transition: opacity var(--t-dim) linear;
}
a.topnav-item:focus-visible { opacity: 1; }
a.topnav-item:focus-visible { outline: 1px solid var(--ink); outline-offset: 3px; }
/* the page you are on: larger, and at full strength */
.topnav-item.is-here { opacity: 1; }

/* The same sticky states the foot nav carries, driven by the same code —
   mooreStage.linkNavs lights a square and its label together, so pointing at
   either row brings both up and steps the rest back.

   The dim rule outranks `.is-here` on purpose, two class-level selectors
   against three: while you are pointing at one section, the one you are
   actually in stands back with the others, exactly as the foot nav's own
   comment describes. It returns to full strength the moment you let go.

   Without scripting there is no sticky and no pairing, so plain hover stands
   in for this row alone — the same states, released as the pointer leaves. */
.topnav[data-lit] .topnav-item { opacity: var(--nav-off); }
.topnav .topnav-item[data-lit] { opacity: 1; }

html:not(.js) a.topnav-item:hover { opacity: 1; }

/* ----------------------------------------------- the Log in control
   Top right of the landing page, and only there. Since 2026-09-02 the public
   splash makes its offer under the nav instead — `.invite`, below — and this
   corner is the signed-in state's alone, saying who was let in; on a phone
   the `.login-foot` copies still wear this class. A label in the archive's own
   type with the browser's native password dialog behind it. The gate is at the
   host, so following the link is the whole mechanism — see CONTRIB.md phase 1
   and nginx/default.conf in streamrD/homepage.

   **Placed by its right edge, alone on this stage.** Everything else here is
   fixed 2004 wording whose width the generator knows, so it emits a left. This
   slot has a second state whose text nginx substitutes per request — inside
   the gated build it says whoever authenticated — and a left solved for one
   username strands every other one. `--rx` is the distance in from the stage's
   right edge; the browser measures the string it actually got.

   Set in the splash key's own face and size, at the same `--nav-off` a nav
   label wears when it is not the one you want. It is furniture for two people
   out of every reader the site has, so being quiet is the requirement and
   being findable is the constraint. */
.login {
  position: absolute;
  right: calc(var(--rx) * 1px);
  top: calc(var(--bl) * 1px - var(--baseline-offset));
  font-size: calc(var(--sz) * 1px);
  line-height: 1;
  white-space: nowrap;
  font-kerning: normal;
  color: var(--ink);
  text-decoration: none;
  opacity: var(--nav-off);
  transition: opacity var(--t-dim) linear;
}
/* Only the offer lights up. The signed-in state is a span — basic auth has no
   log out, so there is nothing there to press, and a hover that brightened it
   would promise one. */
a.login:hover, a.login:focus-visible { opacity: 1; }
a.login:focus-visible { outline: 1px solid var(--ink); outline-offset: 4px; }
/* The target, not the ink: 11.6px of type in the corner of a stage that is
   drawn through a transform. Four hit areas on this site have already been too
   small. A pseudo-element rather than padding, because padding would move the
   type and this label is positioned to the hundredth. */
a.login::after { content: ''; position: absolute; inset: -11px -13px -9px; }

/* ------------------------------------------ the invitation under the nav
   The public splash's offer since 2026-09-02, at the owner's word: not a
   label in the corner but a sentence saying what is behind the door —
   `gen_site.INVITE_TEXT` — centred two nav rows below the nav's second row,
   in the band the stage had empty there. The city line's own Caslon italic,
   two sizes below it, so it reads as the same voice as *Detroit, Michigan*
   and plainly defers to the ten collections above it. It went live as
   spaced Akzidenz capitals first and the owner reversed that the same day —
   *"I don't want it to dominate what is already there"* — so `--tr`, px of
   tracking in the unit every `.t` run uses, is 0 now and the rule keeps it
   in case the capitals come back.

   Centred by the browser, not by a solved `--x`: the sentence is fixed but
   the cut is not — italic where the grafts are in, roman where the licensed
   original was not there to graft from — and `text-align` is right in both.
   `padding-left` puts back whatever tracking the last letter carries on its
   right, so the ink is centred and not the box.

   Standing at .45 rather than `--nav-off`'s .38: chosen off the comp, and
   it is an invitation rather than a nav label standing back. Brightens to
   full on hover, the way the corner did. */
.invite {
  position: absolute;
  left: 0;
  right: 0;
  top: calc(var(--bl) * 1px - var(--baseline-offset));
  font-size: calc(var(--sz) * 1px);
  line-height: 1;
  letter-spacing: calc(var(--tr, 0) * 1px);
  padding-left: calc(var(--tr, 0) * 1px);
  text-align: center;
  white-space: nowrap;
  font-kerning: normal;
  color: var(--ink);
  text-decoration: none;
  opacity: .45;
  transition: opacity var(--t-dim) linear;
}
a.invite:hover, a.invite:focus-visible { opacity: 1; }
a.invite:focus-visible { outline: 1px solid var(--ink); outline-offset: 4px; }
/* A full-width box would make the whole band the target, and a reader
   pointing at the paper beside the words would light them. The hit area is
   the ink's own, with the same margins the corner's has: an inline-block
   the width of the sentence, and the pseudo drawn against it. */
.invite > span { position: relative; display: inline-block; }
a.invite > span::after { content: ''; position: absolute; inset: -11px -13px -9px; }

/* The line under the invitation — `gen_site.inspired_html`. The invitation's
   family in roman, two sizes smaller, centred on the same axis a line below
   it, at the invitation's opacity. Only the clause is the link, and only
   the clause lights up; the hit area is the ink's own, as the invitation's
   is. */
.inspired {
  position: absolute;
  left: 0;
  right: 0;
  top: calc(var(--bl) * 1px - var(--baseline-offset));
  margin: 0;
  font-size: calc(var(--sz) * 1px);
  line-height: 1;
  text-align: center;
  white-space: nowrap;
  font-kerning: normal;
  color: var(--ink);
  opacity: .45;
  transition: opacity var(--t-dim) linear;
}
.inspired > span { position: relative; display: inline-block; }
.inspired a { color: inherit; text-decoration: none; border-bottom: 1px solid currentColor;
  position: relative; }
.inspired a::after { content: ''; position: absolute; inset: -9px -10px -8px; }
.inspired a:focus-visible { outline: 1px solid var(--ink); outline-offset: 4px; }
.inspired:has(a:hover), .inspired:has(a:focus-visible) { opacity: 1; }

/* ⚠ **The foot copy of the unlock control does not exist above the
   breakpoint.** Every page but the splash carries one so that a phone can
   offer it at the bottom of a sheet — see `gen_site.login_html(foot=True)` —
   and up here it would draw at the *splash's* corner coordinates, over
   whatever that page keeps in its own corner, on nineteen pages. Turned back
   on inside the 859px block and nowhere else.

   ⚠ **0-2-0 through `.stage`, and 0-1-0 was not enough.** The element carries
   both `login` and `login-foot`, and the mobile block sets `.login { display:
   block }` at the same 0-1-0 *later in the file* — so a bare `.login-foot`
   hide lost on source order, the control drew anyway, and the rule that was
   supposed to bring it back was dead. Found by mutating that rule and watching
   the check stay green: a rule you can delete without consequence is not the
   rule doing the work. Both halves are 0-2-0 now, so each one can be broken
   and seen to break. */
.stage .login-foot { display: none; }

/* Screen-reader names. The squares carry no text, and a colour is not a label. */
.vh {
  position: absolute; width: 1px; height: 1px; margin: -1px;
  padding: 0; border: 0; clip-path: inset(50%); overflow: hidden;
  white-space: nowrap;
}

/* It slides in on the first page of a visit and never again — see stage.js.
   A nav that re-animates on every page is furniture calling attention to
   itself; once is an introduction. */
@keyframes topnav-in {
  from { opacity: 0; transform: translateY(-9px); }
}
html[data-nav-intro] .topnav-item {
  animation: topnav-in 520ms var(--ease-glide) backwards;
}
/* a short stagger left to right, so the row arrives rather than appears */
html[data-nav-intro] .topnav-item:nth-child(1) { animation-delay: 0ms; }
html[data-nav-intro] .topnav-item:nth-child(2) { animation-delay: 45ms; }
html[data-nav-intro] .topnav-item:nth-child(3) { animation-delay: 90ms; }
html[data-nav-intro] .topnav-item:nth-child(4) { animation-delay: 135ms; }
html[data-nav-intro] .topnav-item:nth-child(5) { animation-delay: 180ms; }
html[data-nav-intro] .topnav-item:nth-child(6) { animation-delay: 225ms; }
html[data-nav-intro] .topnav-item:nth-child(7) { animation-delay: 270ms; }
html[data-nav-intro] .topnav-item:nth-child(8) { animation-delay: 315ms; }
html[data-nav-intro] .topnav-item:nth-child(9) { animation-delay: 360ms; }
html[data-nav-intro] .topnav-item:nth-child(10) { animation-delay: 405ms; }
@media (prefers-reduced-motion: reduce) {
  html[data-nav-intro] .topnav-item { animation: none; }
}

/* On the splash the dividers are these instead: a small square in the
   section's own colour, before each label. It separates two labels as the rule
   did, and it says what the squares along the top of every other page are. */
.nav-sq {
  position: absolute;
  left: calc(var(--x) * 1px);
  top: calc(var(--y) * 1px);
  width: calc(var(--s) * 1px);
  height: calc(var(--s) * 1px);
  background: var(--on);
  /* Full strength, like the splash labels themselves — the splash has no
     current section to pick out, so nothing there is faded. These are a key
     and they stay one; they do not join the roll-over, because dimming a
     legend while you read the thing it explains is backwards. */
  opacity: 1;
}

/* the 1px vertical strokes that separate the nav labels */
.tick {
  position: absolute;
  left: calc(var(--x) * 1px);
  top: calc((var(--y1) + var(--drop)) * 1px);
  width: 1px;
  height: calc((var(--y2) - var(--y1)) * 1px);
  background: var(--rule);
}

/* --------------------------------------------------------------- home */
.home-title { margin: 0; font-weight: normal; }
.home-title .t { color: var(--ink); }

.home-cover {
  position: absolute;
  left: calc(var(--x) * 1px);
  top: calc(var(--y) * 1px);
  width: calc(var(--w) * 1px);
  height: calc(var(--h) * 1px);
  display: block;
}

/* The splash's cover, which cycles.
   ----------------------------------------------------------------------
   `.home-cover` is the box and the positioning context; the two `.cover-shot`
   layers fill it and the dots hang below it. The box is solved from the
   photographs' own aspect in `gen_site.cover_box` — 231.8 x 166.3 against
   2004's 207.8 x 166.3 — so **nothing here crops or stretches**: the picture
   and its rectangle are the same shape, which is the whole point of the
   choice that was made. `object-fit` is deliberately absent. If it ever needs
   to appear, the box and the file have stopped agreeing and the fix is in the
   build, not here. */
.cover-shot {
  display: block;
  width: 100%;
  height: 100%;
}

/* The incoming photograph, above the outgoing one.
   **Only the top layer fades**, and this is not the obvious way to write it.
   Fading both at once is what the slideshow does, and it dips: at the midpoint
   both are near half opacity and a quarter of the kraft shows through the
   pair. See *Three things about the fades* in INTERACTIONS.md. Here the
   outgoing layer simply sits still at full opacity and the new one covers it,
   so the cross-dissolve has no hole in the middle of it. */
.cover-in {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity var(--t-cover-swap, 900ms) ease;
}
.home-cover[data-swapping] .cover-in { opacity: 1; }

/* The dots, below the photograph and outside its box.
   Hidden until the reader is on the cover — `:hover` for a pointer,
   `:focus-within` so a keyboard reader tabbing to a dot can see the row they
   have arrived in. They are `pointer-events: none` while hidden so an
   invisible control can never take a click.

   Hovering a *dot* still counts as hovering `.home-cover`, even though the row
   sits outside the box: `:hover` matches an ancestor of whatever is under the
   pointer regardless of where the child is painted. That is what stops the row
   vanishing as the reader reaches for it. */
.cover-dots {
  position: absolute;
  /* **The row's box touches the photograph, and that is the whole trick.**
     It was `left: 50%` with a translate and a 7px `margin-top`, which left a
     7px band of bare paper between the picture and the dots and a gutter
     either side of a 208px row inside a 232px box. Every one of those is
     outside `.home-cover`, so reaching down for a dot crossed ground that
     belongs to nothing, `:hover` dropped, and the row faded out from under
     the pointer — reported, exactly right, as "they are not usable as nav
     elements".
     Full width, no gap, and the 7px is `padding` inside the element instead
     of `margin` outside it. The strip below the picture is now continuous
     with it, so the pointer never leaves the cover on the way down. */
  left: 0;
  right: 0;
  top: 100%;
  padding-top: 7px;
  display: flex;
  justify-content: center;
  /* **No gap, and that is the fix rather than the oversight.** Each dot is a
     6px mark inside a 16px target, so the separation is already there as
     padding; adding 6px of `gap` on top made the row 280px wide inside a
     231.8px box — it hung 24px past the photograph on each side, which reads
     as a row belonging to something else. Butted up, thirteen dots are 208px
     and sit inside the picture, and the targets touch rather than leaving
     dead 6px lanes between them, which is better for hitting one. */
  gap: 0;
  opacity: 0;
  pointer-events: none;
  transition: opacity 220ms ease;
}
.home-cover:hover .cover-dots,
.home-cover:focus-within .cover-dots {
  opacity: 1;
  pointer-events: auto;
}

.cover-dot {
  width: 6px;
  height: 6px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--ink);
  opacity: .28;
  cursor: pointer;
  transition: opacity 160ms ease;
  /* The mark is 6px and the target is 16 — a 6px circle is well under the
     smallest thing a finger or a hurried cursor reliably hits, and four hit
     areas on this site have already been too small. The padding does the
     work and `background-clip` keeps it from painting. */
  box-sizing: content-box;
  padding: 5px;
  background-clip: content-box;
}
.cover-dot:hover { opacity: .6; }
.cover-dot[aria-current="true"] { opacity: 1; }

/* **While a swap runs, the dots move on the photograph's clock.**
   160ms is right for hover — a control should answer a pointer at once — and
   wrong for the marker, which was snapping to the new dot while the picture
   took 900ms to arrive. The mark is set as the dissolve *begins* now (see
   `mark` in stage.js), so borrowing the same duration and the same easing here
   makes the old dot dim and the new one come up in step with the photographs
   themselves.
   One value drives both, which is the point: matching two numbers by hand is
   how they drift apart on the next change. */
.home-cover[data-swapping] .cover-dot {
  transition-duration: var(--t-cover-swap, 900ms);
}

/* The cover, opened full screen.
   ----------------------------------------------------------------------
   **A sibling of `.viewport`, never a child of `.stage`.** The stage is drawn
   through a transform, which makes it the containing block for `position:
   fixed` inside it — a layer saying `inset: 0` in there would cover the
   scaled stage rather than the window. See `gen_site.cover_full_html`.

   The ground is the archive's own kraft rather than the black a lightbox
   usually reaches for. These photographs already carry their slide mounts, so
   a black ground would put a second black frame around a black frame; the
   paper is what the rest of the site lays a photograph on, and it keeps the
   full-screen view inside the same material world as the page behind it. */
.cover-full {
  /* **Absolute, inside the stage — never fixed over the window.** It was fixed
     for one afternoon and painted kraft across the whole browser, which broke
     the rule the whole design rests on: nothing outside the project's own
     rectangle. The stage's transform is exactly what makes `absolute` right
     here — the layer is in stage units, scales with everything else, and
     `inset: 0` means the paper's own edges. */
  position: absolute;
  inset: 0;
  z-index: 40;
  display: flex;
  align-items: center;
  justify-content: center;
  /* **No padding: the covers are cut to the canvas's own 16:11 now**, so the
     photograph fills the stage exactly, edge to edge, which is what the owner
     re-exported them at 2800 x 1925 to do. A margin here would have made that
     work pointless — the photograph would sit inside the paper rather than
     being it. Restore a value only if the set stops matching the canvas. */
  padding: 0;
  background: var(--paper) url("../img/paper.jpg") 0 0 / 100% 100% no-repeat;
  cursor: zoom-out;
  opacity: 0;
  transition: opacity var(--t-cover-swap, 900ms) ease;
}
/* **`display: flex` above beats `[hidden]`, and that is not a detail.** The
   browser's own rule is `[hidden] { display: none }` at UA specificity, which
   a class selector overrides — so the layer was laid over the entire page from
   the moment it loaded: invisible at `opacity: 0`, and swallowing every click
   and painting its `zoom-out` cursor across the whole splash. Reported as "a
   magnifying glass with a minus sign when I hover anywhere, no fullscreen on
   click", which is exactly what an invisible sheet on top of everything does.

   `pointer-events` is the second half rather than a spare: `display` cannot
   transition, so the layer has to be laid out for its fade in and out, and
   during those 900ms it must not be catching clicks meant for the page under
   it either. */
.cover-full[hidden] { display: none; }
.cover-full:not([data-open]) { pointer-events: none; }
.cover-full[data-open] { opacity: 1; pointer-events: auto; }
.cover-full img {
  max-width: 100%;
  max-height: 100%;
  /* The photograph, whole. `contain` is the whole point of the view: the
     landing page shows these at 232 units and this is the one place they are
     seen at their own size, so nothing here may crop. */
  object-fit: contain;
  cursor: zoom-out;
}

/* The cover invites the click that opens it. Only where there is something to
   open — the no-JavaScript fallback is a plain `<img class="home-cover">` with
   no layers, and offering a pointer for a click that does nothing is worse
   than offering none. */
.home-cover .cover-shot { cursor: zoom-in; }

/* A hairline round the black-and-white covers.
   ----------------------------------------------------------------------
   The colour slides carry their own black mount; the prints have no edge of
   their own and dissolve into the kraft without one. Which covers get it is
   `build.COVER_EDGES`, keyed by source filename.

   **`outline` rather than `border`, and the reason is layout.** A border is
   part of the box, so a 1px one would make the photograph 2px narrower than
   the rectangle solved for it and the cover would no longer meet its own
   edges. An outline is painted outside the flow and changes nothing; the
   negative offset draws it just inside the picture, on top of it, so the line
   is on the photograph rather than around it.

   **And it is drawn here rather than baked into the file** because the same
   cover is painted anywhere between 242 and 1400 css px — 5.8x. A line baked
   into a 2800px export reads as 0.09px on the splash and 0.5px enlarged; to
   look like 1px on the splash it has to be 11.6px in the file, which is 6px
   thick once opened. There is no single baked width that is right in both
   places, and one CSS pixel is one CSS pixel in all of them. */
.cover-edge {
  outline: 1px solid rgba(38, 32, 24, .55);
  outline-offset: -1px;
}
.cover-dot:focus-visible {
  outline: 2px solid var(--section-ink, var(--ink));
  outline-offset: 1px;
}

.home-who { margin: 0; }

.nav { margin: 0; padding: 0; }

.nav-item {
  position: absolute;
  left: calc(var(--x) * 1px);
  top: calc((var(--bl) + var(--drop)) * 1px - var(--baseline-offset));
  font-size: calc(var(--sz) * 1px);
  line-height: 1;
  white-space: nowrap;
  letter-spacing: calc(var(--tr, 0) * 1px);
  font-kerning: normal;
  text-decoration: none;
  color: var(--ink);
  background: none;
  border: 0;
  padding: 0;
}

/* The whole site's interaction language is the SWF's: things fade, nothing
   gets decorated. So the nav carries no underline — the section you are in is
   simply the one at full strength, the rest sit back, and hovering brings one
   forward on the same 350ms linear ramp the thumbnails use. */
.nav-item {
  /* Faded well back, so the section you are in reads first. The active label
     is coloured rather than merely full-strength (gen_site.SECTION_COLOUR),
     and colour alone is a weak signal at 12px, so the gap either side of it
     does the rest of the work. */
  opacity: var(--nav-off);
  transition: opacity var(--t-dim) linear, color var(--t-dim) linear;
}
/* A band of the same height below each label, which lights it too.

   The labels are 12px of type in a single row: reaching one means aiming at
   it, and the row is narrow enough that the aim is the whole difficulty. The
   box grows downward instead — `--hit`, one nav-bar height, written per item
   by gen_site.nav_hits — so the paper under a label belongs to it.

   Nothing moves. The label is placed by its baseline from the top, so padding
   added below extends the box without touching the type. And `sticky`
   measures its release bound from these same rectangles, which means the band
   counts as inside the nav rather than as having left it.

   Per item rather than a flat rule because the splash sets its nav in two
   rows 10.4 apart: a full 11.6 there would lay the first row's box over the
   top of the second row's labels, and pointing at one would light the other.
   nav_hits caps each row at the room beneath it.

   Scoped to `.nav`, so the sheet pager — which is `.nav-item` too, but sits
   in `<p class="sheets">` above the photographs — keeps its own hit area. */
/* ...and the same band **above**, which is the half a reader reaches for
   coming down the page rather than up from the foot. It needs the padding and
   an equal negative margin together: the label is positioned absolutely from
   its baseline, so padding alone would push the type down the page. The
   margin takes the growth back out and the box extends upward around type
   that has not moved — the trick the navigator's arrows use, for the same
   reason.
   `nav_hits` gives the two bands separately and caps the upper one by what
   the row above has already taken downward, so the splash's two rows touch
   without overlapping. */
.nav .nav-item {
  padding-bottom: calc(var(--hit, 0) * 1px);
  padding-top: calc(var(--hit-up, 0) * 1px);
  margin-top: calc(var(--hit-up, 0) * -1px);
}

/* ...except the pager, which is not bottom furniture: its y is measured from
   the foot of the deepest sheet, so it has already followed the content and
   must not be dropped a second time. */
.sheet-item { top: calc(var(--bl) * 1px - var(--baseline-offset)); }
.sheet-tick { top: calc(var(--y1) * 1px); }

a.nav-item { cursor: pointer; }
a.nav-item:hover,
a.nav-item:focus-visible { opacity: 1; color: var(--on, var(--ink)); }
a.nav-item:focus-visible { outline: 1px solid var(--ink); outline-offset: 4px; }

.nav-item[aria-current="page"] { opacity: 1; color: var(--on, var(--ink)); }

/* The splash has no current section, so nothing there is holding the eye and
   there is no reason to hold the rest back: the whole list stands at full
   strength until you reach for it. */
.nav-home .nav-item { opacity: 1; }

/* Both navs roll over *stickily*, like the contact sheets: the label under
   the pointer keeps its weight and takes its section's colour, the rest drop
   away — and rolling off does nothing. It stays lit until another label takes
   over or the pointer leaves the row. The labels are far enough apart that
   releasing on the way past made the whole row flare between every pair.
   mooreStage.stickyNav drives the two data attributes.

   These come after the aria-current rule on purpose: while you are pointing
   at one label, the section you are actually in steps back with the others.

   Without scripting there is no sticky to be had, so plain hover stands in —
   the same states, released the moment the pointer leaves a label. */
.nav[data-lit] .nav-item { opacity: var(--nav-off); }
.nav .nav-item[data-lit],
.nav .nav-item:focus-visible { opacity: 1; color: var(--on, var(--ink)); }

html:not(.js) .nav:hover .nav-item { opacity: var(--nav-off); }
html:not(.js) .nav .nav-item:hover { opacity: 1; color: var(--on, var(--ink)); }

/* categories the 2004 original named but never published */
.nav-item[aria-disabled="true"] { opacity: .24; cursor: default; }

/* a section that runs to more than one contact sheet */
.sheets { margin: 0; }
/* The pager belongs to the contact sheet, so it goes when the sheet goes.
   The rule between the two labels is a *child* of .sheets, not a sibling of
   it — written with `~` it was never matched, and the divider stayed hanging
   in the middle of the enlargement with nothing either side of it. */
#grid[data-state="out"] ~ .sheets .sheet-item,
#grid[data-state="out"] ~ .sheets .tick { opacity: 0; pointer-events: none; }
.sheets .sheet-item,
.sheets .tick { transition: opacity var(--t-dim) linear; }

/* the wordmark, reduced to nav size, linking home */
.masthead {
  opacity: .62;
  transition: opacity var(--t-dim) linear;
  text-decoration: none;
  color: var(--ink);
}
/* The link's own target. Everything inside the anchor is absolutely
   positioned, so the anchor has no box and only the glyphs took the pointer —
   which left the gap between the two lines dead, and every gap between words
   with it. This is one solid rectangle over both lines, transparent, and
   inside the anchor so pressing it follows the link.

   It is not `.masthead` given a box: the runs position against the nearest
   positioned ancestor, which is the stage, so making the anchor positioned
   would re-base all of them. See `gen_site.masthead_html`. */
/* ⚠ **The masthead's third line does not exist above the breakpoint.**
   `masthead_html` writes a run carrying the splash's subtitle so that a sheet
   can wear the splash's three-line treatment on a phone. Every run in that
   anchor is absolutely positioned against the stage, so without this it would
   paint across the desktop masthead at line two's coordinates. It is turned
   back on inside the 859px block and nowhere else. */
.masthead .mast-subline { display: none; }

/* ⚠ **The long form of a section's name does not exist above the breakpoint.**
   `gen_site.MOBILE_TITLE` writes a second run — `world war two` beside `wwii` —
   and on the stage the title is a watermark lying along the foot at 65px, where
   the short name is what belongs. Both runs are absolutely positioned at the
   same coordinates, so without this they would print on top of each other.
   Scoped through `.gallery-title` so the rule that shows it below the
   breakpoint can match specificity and win on order — the `.mast-subline`
   lesson, applied before it could bite a second time. */
.gallery-title .title-long { display: none; }

.mast-hit {
  position: absolute;
  left: calc(var(--x) * 1px); top: calc(var(--y) * 1px);
  width: calc(var(--w) * 1px); height: calc(var(--h) * 1px);
}
.masthead:hover, .masthead:focus-visible { opacity: 1; }
.masthead:focus-visible { outline: 1px solid var(--ink); outline-offset: 4px; }

/* ------------------------------------------------------------ gallery */
.gallery-title { margin: 0; font-weight: normal; }
/* The watermark sits its baseline on the very bottom edge, which is where it
   was before the stage grew — see --drop.
   
   So its descenders hang past the paper, over the page behind it. That is
   deliberate and the owner chose it: pulling the line up by its descender
   depth to tuck the tails inside was tried and rejected. It means .stage must
   not clip — see the note on overflow there. */
.gallery-title .t {
  top: calc((var(--bl) + var(--drop)) * 1px - var(--baseline-offset));
}
.gallery-title .t { opacity: var(--title-alpha, .102); }

.grid { margin: 0; padding: 0; list-style: none; }

.thumb {
  position: absolute;
  left: calc(var(--x) * 1px);
  top: calc(var(--y) * 1px);
  width: calc(var(--w) * 1px);
  height: calc(var(--h) * 1px);
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  opacity: 1;
  /* the *return* to full strength — see `--t-undim` */
  transition: opacity var(--t-undim) linear;
}
/* `.shot` is the *painted picture*, not the cell it sits in.

   It used to fill the cell and let `object-fit: contain` letterbox the
   photograph inside it, which meant the element box and the picture were
   different rectangles on every frame whose shape did not match its cell. That
   is the trap the old turned-up corner fell into — it had to be inset by
   however much letterbox each axis happened to carry, and could only be
   checked by eye. Sizing the box to the picture instead means anything hung
   off a corner is hung off the photograph's own corner, on any sheet. */
.shot {
  position: relative; display: block;
  aspect-ratio: var(--ar);
  max-width: 100%; max-height: 100%;
  margin: auto;
}
/* `contain`, not a stretch. The 2004 Kodachrome thumbs were 5:4 like their
   cell, so a stretch was invisible; the re-scans are mounted to the slide's
   own 1.42 and would be squashed by one. A frame narrower than its cell now
   letterboxes and sits centred, which is also what lets a re-scanned frame
   and a 2004 one share a sheet without either being distorted. */
.shot img { display: block; width: 100%; height: 100%; object-fit: contain; }

/* A print with writing on the back used to be marked by a turned-up corner
   drawn over its enlargement. It is gone, at the owner's request — the
   wording on the control says the same thing without drawing on the
   photograph, and the corner was the one mark here that imitated damage.

   A thumbnail with more frames behind it is marked: a
   single small corner rule set off its lower right — one card behind, showing at
   the corner. Three cascading rules were tried and were too busy at 60-100px;
   so was one drawn large. It wants to be noticed on the second look, not the
   first, so the arms stay about 5px whatever the thumbnail. A verso is not marked
   on the sheet at all — you find the writing by turning the print over, which
   is what the control is for.

   It hangs off `.shot`, which is the painted picture rather than the cell, so
   it finds the photograph's own corner whatever shape the frame is. The white
   halo is what lets one ink read on a dark photograph and on kraft paper
   alike; without it the mark disappears into half the archive. */
/* `--mark-dx` / `--mark-dy` pull the mark back toward the ink, and exist for
   one case: a **cutout**. `.shot` is the picture's own box, which for a
   rectangular print is also where its ink stops — but a deckled print scanned
   against nothing has an empty box corner, and the mark then floats. wwii 5's
   nearest ink is 3.9 units in on each axis, so it hung about nine units from
   the photograph instead of five. Per-frame, from `sections.MARK_NUDGE`, and
   measured off the file's own alpha channel. */
.thumb[data-others] .shot::after {
  content: "";
  position: absolute;
  right: calc((-5 + var(--mark-dx, 0)) * 1px);
  bottom: calc((-5 + var(--mark-dy, 0)) * 1px);
  width: 6px; height: 6px;
  border-right: 1px solid var(--section-ink, var(--ink));
  border-bottom: 1px solid var(--section-ink, var(--ink));
  pointer-events: none;
  opacity: .85;
  transition: opacity var(--t-dim) linear;
}
.thumb[data-others]:hover .shot::after,
.thumb[data-others][data-hovered] .shot::after { opacity: 1; }

/* rolling over one thumbnail fades every other one down to 20%.
   ⚠ The dim keeps `--t-dim`: a transition is read from the state being moved
   *to*, so this rule governs going down and `.thumb` governs coming back. */
.grid[data-hover] .thumb:not([data-hovered]) {
  opacity: var(--dim-alpha);
  transition: opacity var(--t-dim) linear;
}

.thumb:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
/* ...but not when the script moved the focus itself, which is what returning
   from an enlargement does. The stage is scaled up to 1.75, so this outline
   lands as a heavy black box around one frame of a quiet sheet — the same
   complaint the viewer's own ring drew, and the same answer. */
html[data-quiet] .thumb:focus-visible { outline: none; }

/* ------------------------------------------------- adaptive contact sheet
   The Kodachromes grid is hand-placed at the coordinates the SWF used. The
   seven sections that were never published hold between 13 and 64 frames in
   both orientations, so their sheets compute a square cell that fits the same
   area (see tools/sections.py) and let each frame sit inside it at its own
   aspect ratio — which is what a real contact sheet looks like. */
.grid--auto {
  position: absolute;
  left: calc(var(--gx) * 1px);
  top: calc(var(--gy) * 1px);
  display: grid;
  grid-template-columns: repeat(var(--cols), calc(var(--cell) * 1px));
  /* fix the row height so a row of nothing but placeholders still lines up */
  grid-auto-rows: calc(var(--cell) * 1px);
  gap: calc(var(--gap) * 1px);
}
/* A sheet that ends short and is not waiting on more photographs centres its
   last row rather than standing placeholders in it. The grid's columns are
   fixed tracks, so `justify-content` on the container would move every row —
   each item of the short row is offset instead. `left` and not `transform`,
   because the thumbnails already animate on transform as an enlargement
   opens, and because the roll-over measures their rects. */
/* One width for every frame on the sheet, with the height left to follow the
   photograph — `thumb_width` on the sheet.

   A square cell with `contain` already gives a 1.44 print and a square one the
   same width, but the square one is half as tall again, and on the wedding
   sheet those four squares took the page over. Fixing the width and letting
   the depth vary is what a strip of contact prints looks like, and it holds
   for a portrait too, which `contain` would otherwise pinch narrow. The cell
   is unchanged, so the columns still line up. */
/* Each row hugs its own tallest photograph rather than standing a square cell
   deep — see sections.row_depths. `align-items: center` matters as much as the
   row sizing: a grid item stretches to its row by default, which is what made
   the placeholders full-cell deep while the photographs beside them were half
   that. */
.grid--auto.grid--uniform {
  grid-auto-rows: min-content;
  /* Centre every item in its own track, vertically and horizontally. A
     placeholder *is* the grid item, so with a width of its own it sat at the
     left of its cell while the photographs beside it were centred in theirs,
     and the columns did not line up. */
  align-items: center; justify-items: center;
}
.grid--auto.grid--uniform > li {
  display: flex; align-items: center; justify-content: center;
}
.grid--auto.grid--uniform .thumb {
  width: calc(var(--cell) * var(--thumb-w, 1) * 1px);
  height: auto;
}
.grid--auto.grid--uniform .shot img { width: 100%; height: auto; }
/* an empty cell is a photograph that has not arrived, so it takes the same
   width as one and the sheet's typical shape */
.grid--auto.grid--uniform .slot {
  width: calc(var(--cell) * var(--thumb-w, 1) * 1px);
  height: auto; max-height: none; aspect-ratio: var(--slot-ar, 1);
}

/* ------------------------------------------------------- a banded sheet
   A sheet read in two or more column groups with air between them, each able
   to carry a heading. Fotron sheet 3 is the first: three columns of Fotron
   prints and two of photographs that were kept in the same album and are not
   Fotron prints at all.

   The gutter is a column of the grid held open (sections.build derives it from
   `bands`), so the sheet still solves inside the contact-sheet area and the
   columns stay square. The headings are the grid's own first row, which is why
   `grid-template-rows` names it: row one takes the height of its line and
   every row after it comes from `grid-auto-rows`, still one cell deep. */
.grid--banded { grid-template-rows: min-content; }
/* Not a placeholder. A placeholder says a photograph is coming; these cells
   are the gutter and the spare at the end of a finished sheet. `visibility`
   and not `display`, because the cell still has to hold its column open. */
.band-gap { visibility: hidden; }
/* ⚠ **The span needs the stronger selector, and `.band-head` alone was not
   it — 2026-08-24.** A banded sheet is also an auto sheet, so it carries both
   classes, and `.grid--auto > li` sets `grid-column: span var(--span-c, 1)` at
   0-1-1 against this rule's 0-1-0. The heads therefore took **one column
   each** and `--span: 3` was read and thrown away: `Fotron prints` sat over
   column 0, the hidden gutter took column 1, and `Also in the album` landed on
   column 2 — over the third column of the band it does not label, while its
   own nine photographs sat away to the right. The owner reported it as the
   heading having moved left and the photographs right, which is exactly what
   two rules disagreeing about one property looks like from the page.

   **The fallback is what made it silent.** `var(--span-c, 1)` has an answer
   for an element that has never heard of `--span-c`, so nothing was invalid
   and nothing warned; the head simply became a one-column cell. A default that
   is *plausible* for the element it lands on is the hardest kind to see. */
.grid--banded > .band-head { grid-column: span var(--span); }
.band-head {
  grid-column: span var(--span);
  align-self: end;
  /* **Painted down toward its photographs, not laid out lower.** A grid has
     one row gap and no way to make the first one smaller, so the heading is
     nudged with `position: relative` — which moves the ink and leaves the
     track alone — and sections.build lifts the whole grid by the same amount.
     The heading lands where it would have; the photographs come up to meet it.
     `top` and not `transform`, for the reason the short row uses `left`. */
  position: relative;
  top: calc(var(--head-pull, 0) * 1px);
  /* **Akzidenz BE roman, and the choice is not decorative.** The condensed cut
     this site sets its nav in carries capitals only, so a heading with a
     lower-case letter in it would be served from Arial and read as bad
     spacing rather than as a missing glyph. This face covers both cases. */
  font-family: "Akzidenz BE", "Helvetica Neue", Arial, sans-serif;
  font-size: 8px;
  line-height: 1;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--ink);
  opacity: .62;
  padding-bottom: 3px;
  border-bottom: 1px solid currentColor;
}
/* The photographs a band ends short on sit under the columns of their own
   group rather than in the middle of the page — `centre_last`, scoped to the
   band. Half a cell for every column the row is short, which is the sheet-wide
   `--nudge` on `.row-centred` read against one band's column count instead of
   the whole grid's. `left` and not `transform`: gallery.js measures these
   rects to open an enlargement, and a transform would move what it reads. */
.band-centre { position: relative; left: calc(var(--band-nudge, 0) * 1px); }

/* the Kodachrome sheet places every thumbnail itself, so the drop goes on the
   thumbnails rather than on the container */
.grid--placed .thumb,
.grid--placed .slot { top: calc((var(--y) + var(--grid-drop)) * 1px); }

/* **The span goes on the grid item, and the grid item is the `<li>`.** Setting
   it on `.thumb` inside makes the picture wider and tells the grid nothing, so
   the next photograph is placed in a track the wide one is already painting
   over — which is exactly what the first cut of this did, and it looked like a
   packing bug rather than a selector one. The `<li>` is what `gen_site` puts
   `--span-c` / `--span-r` on; custom properties inherit, so the thumb inside
   reads the same two numbers to size itself. */
.grid--auto > li {
  grid-column: span var(--span-c, 1);
  grid-row: span var(--span-r, 1);
}
.grid--auto > .row-centred { position: relative; left: calc(var(--nudge, 0) * 1px); }
/* when the short row leads, the frame after it starts a row of its own —
   otherwise auto-placement fills the gap the short row left */
.grid--auto > .row-break { grid-column-start: 1; }

/* `--scale` is a per-cell size nudge, written by gen_site off the sheet's
   `scale` and absent everywhere else, so the default of 1 is what every other
   frame in the archive gets and nothing moves. The negative margin is what
   makes it grow about its own centre rather than out of its top-left corner:
   a grid track here is a fixed cell, so an item wider than one would otherwise
   hang off the right and push nothing. Margin and not `transform` — the
   thumbnails already animate on transform as an enlargement opens, and
   gallery.js measures their rects to do it. */
/* `--dx` / `--dy` move a cell off the centre of its own slot, as a fraction of
   the cell — the companion to `--scale`, which only resizes about that centre.
   Written by gen_site off the sheet's `offset` and absent everywhere else.

   It is spent on the margins rather than on a transform for exactly the reason
   `--scale` is: gallery.js measures these rects to open an enlargement out of
   them, and a transform would move the rect it measures. Each pair sums to the
   same total it had — `+dx` on the left is `-dx` on the right — so the cell
   moves inside its grid track and the track, and everything after it, does
   not. */
/* `--span-c` / `--span-r` let one cell take two slots or four — `span` on the
   sheet. **The grid does the work.** These are fixed tracks with a `gap`, so a
   `span` is a thing CSS grid already understands and everything after it flows
   around the wide cell on its own; `sections.pack` reimplements the placement
   only so the *build* can solve how deep the sheet comes out.

   The slot a spanning cell sits in is n tracks *plus the gaps between them* —
   `2 * cell + gap`, not `2 * cell` — which is the arithmetic to get wrong
   here. `--slot-w` / `--slot-h` name it once so the width and the two margins
   cannot disagree about it. */
.grid--auto .thumb {
  --slot-w: calc(var(--cell) * var(--span-c, 1)
                 + var(--gap) * (var(--span-c, 1) - 1));
  --slot-h: calc(var(--cell) * var(--span-r, 1)
                 + var(--gap) * (var(--span-r, 1) - 1));
  position: static;
  width: calc(var(--slot-w) * var(--scale, 1) * 1px);
  height: calc(var(--slot-h) * var(--scale, 1) * 1px);
  /* `--dx` / `--dy` stay a fraction of the **cell**, not of the slot: a nudge
     is a nudge whatever the cell is spanning, and keying it to the slot would
     silently double it the day a photograph is widened. */
  margin:
    calc((1 - var(--scale, 1)) / 2 * var(--slot-h) * 1px
         + var(--dy, 0) * var(--cell) * 1px)
    calc((1 - var(--scale, 1)) / 2 * var(--slot-w) * 1px
         - var(--dx, 0) * var(--cell) * 1px)
    calc((1 - var(--scale, 1)) / 2 * var(--slot-h) * 1px
         - var(--dy, 0) * var(--cell) * 1px)
    calc((1 - var(--scale, 1)) / 2 * var(--slot-w) * 1px
         + var(--dx, 0) * var(--cell) * 1px);
  display: flex;
  align-items: center;
  justify-content: center;
}
.grid--auto .shot img { object-fit: contain; }

/* **The picture scales up to its cell, instead of stopping at the thumbnail's
   own size.** `.shot` is the painted picture and takes its size from the
   image, so as a `flex: 0 1 auto` item it could only ever *shrink*: a cell
   drawn larger than the 150px derivative — anything spanning two slots, or
   scaled much past 1.4x — held its picture at 150 and left paper around it.
   On the page it read as the span not working.

   Reported 2026-08-22 against barbara sheets 1 and 2, and the fault is as old
   as `span` itself. What made it visible was the rest of the sheet growing
   when the gutter was stated: the 1x1 cells are smaller than 150 and had
   always filled correctly, so only the spanning and enlarged ones were left
   behind, which is exactly the comparison that showed it up.

   **Neither obvious fix works.** `max-width`/`max-height` clamp downward only.
   `width: 100%` breaks the ratio whenever the height is the binding axis — and
   the ratio is load-bearing here, because `.thumb[data-others] .shot::after`
   hangs the stack marker off the *photograph's* corner rather than the cell's.

   So the fit is computed rather than negotiated: the larger size that still
   fits both axes, which is what `object-fit: contain` would work out, from the
   slot the cell already states and the ratio it already carries. Measured
   against Chrome cell by cell on both sheets — spans, scales and both binding
   axes — before it was written here.

   Not on `.grid--uniform`: that sheet gives every frame one width and has its
   own rule above. */
.grid--auto:not(.grid--uniform) .shot {
  /* **The width of the cell, and the height follows the photograph** — not a
     `contain` fit inside it. That is what the layout editor actually draws,
     and it is not what the editor's own stylesheet says: `.cell img` there is
     `max-width: 100%; max-height: 100%`, and in its grid cell the max-height
     silently does not bind. So the tool fits to width and lets a tall print
     hang below its cell — the hero of sheet 1 is drawn 159x226 in a 159x159
     cell, and `8.png` 75x156 in a 76 square, twice its own cell.

     **A landscape print is identical either way**, which is exactly why only
     *some* photographs looked wrong: 11 of sheet 1's 26 cells and 6 of sheet
     2's 13 are portrait and were drawn about 38% smaller here than in the
     tool the owner arranged them in. Reported three times before it was
     measured in both instruments side by side rather than off screenshots.

     The overflow is centred, as `align-items: center` on `.thumb` gives it and
     `place-items: center` gives it there, so a print grows equally above and
     below its cell and the arrangement reads the same in both. */
  --fit-w: calc(var(--slot-w) * var(--scale, 1));
  width: calc(var(--fit-w) * 1px);
  height: calc(var(--fit-w) / (var(--ar, 1)) * 1px);
  max-width: none;
  max-height: none;
  flex: none;
  /* **The excess hangs below, and `margin: auto` is what does it.** A flex
     item larger than its line has negative free space, where the spec resolves
     an auto margin to zero and aligns the item to the start — so an over-tall
     picture grows downward from its cell's top rather than out of both edges.

     **That is the tool's behaviour too, and it is worth saying why**, because
     the tool looks like it should centre: `.cell` there is `display: grid;
     place-items: center`, but its implicit row is `auto`, so the row sizes to
     the 226-tall picture and starts at the cell's top. Centring the item
     inside a row that tall puts it back at the top of the cell. Measured in
     both instruments: the hero's picture begins exactly at its own cell's top
     in each.

     This was briefly `margin: 0` and it lifted every portrait by half its
     overflow — 14 to 34 units — into the row above, which is what overlapping
     photographs on sheets 1 and 2 were. */
}

/* An empty cell on the Kodachrome sheet, waiting on a photograph. That grid
   is hand-placed at the SWF's own coordinates rather than solved, so unlike
   the section sheets' slots below this one is positioned, and gen_site gives
   it the cards' shape and centres it in the cell. */
.grid:not(.grid--auto) .slot {
  position: absolute;
  left: calc(var(--x) * 1px);
  top: calc(var(--y) * 1px);
  width: calc(var(--w) * 1px);
  height: calc(var(--h) * 1px);
  opacity: 1;
  transition: opacity var(--t-dim) linear;
}

/* An empty cell, waiting on a photograph. Square unless the sheet has a
   standard shape of its own — Landscapes is all 5:4 slides — in which case a
   placeholder takes the same rectangle as the photographs beside it. */
.grid--auto .slot {
  width: calc(var(--cell) * 1px);
  aspect-ratio: var(--slot-ar, 1);
  height: auto;
  max-height: calc(var(--cell) * 1px);
  align-self: center;
  opacity: 1;
  transition: opacity var(--t-dim) linear;
}
.grid[data-hover] .slot {
  opacity: var(--dim-alpha);
  transition: opacity var(--t-dim) linear;
}

/* the grid fades out as a whole while an enlargement fades in */
/* `will-change` keeps the sheet and the enlargement on their own compositor
   layers, so neither has to be rasterised on the first frame of the
   transition — which is where a dropped frame shows most. Two layers only;
   this is not a hint worth spreading around. */
.grid { opacity: 1; transition: opacity var(--t-thumbs-out) var(--ease-soft);
        will-change: opacity; }
.grid[data-state="out"] { opacity: 0; pointer-events: none; }
.grid[data-state="in"]  { transition-duration: var(--t-thumbs-in); }

/* On the way out the sheet is one fading layer and nothing more. Without
   this, the 60-odd dimmed siblings each animate back to full opacity as the
   container disappears — brightening while they vanish, and a pile of
   concurrent transitions for something nobody can see. */
.grid[data-state="out"] .thumb,
.grid[data-state="out"] .slot { transition: none; }

/* ------------------------------------------------------------- the reel
   One row of photographs longer than the page, seen through a window that
   clips it at the paper's own edges. The 6 x 9 cm negatives sheet is the
   only one; see `reel` in tools/sections.py for the layout and `reel()` in
   gallery.js for the movement.

   The window is exactly the row and no more. A transparent box any larger
   would sit over paper the reader has to click through — the masthead, the
   foot nav, this section's own album link — and this site positions nearly
   everything absolutely, so "the thing you can see is not the thing you can
   press" cuts both ways. */
/* Its own custom properties are named apart from the frames' `--x/--y/--w/--h`
   on purpose: they inherit, and a `.thumb` gets its four inline but a `.slot`
   would not — so a plain `--h` here would have been the row's height standing
   in for a placeholder's. There are no placeholders on this sheet today, which
   is exactly the kind of quiet the next curation would break. */
.reel {
  position: absolute;
  left: 0; top: calc(var(--reel-y) * 1px);
  width: 100%; height: calc(var(--reel-h) * 1px);
  /* Native scrolling is the *default*, not the fallback of last resort: with
     no JavaScript, and on a touch screen where there is no pointer to sweep
     with, this is the whole of the interaction and it works. The sweep in
     gallery.js replaces it only where a fine pointer can drive it, by setting
     `data-reel="sweep"` below. */
  overflow-x: auto; overflow-y: hidden;
  scrollbar-width: none;
}
.reel::-webkit-scrollbar { display: none; }
/* Swept rather than scrolled: the track is moved by transform, which paints
   without laying out, and the window stops being a scroller so the two
   mechanisms can never both be live at once. */
.reel[data-reel="sweep"] { overflow-x: hidden; }
.reel[data-reel="sweep"] .grid {
  transform: translateX(calc(var(--sx, 0) * 1px));
  /* the track moves on every pointermove, so it wants its own layer as much
     as the fade does — `.grid` already asks for one, on opacity */
  will-change: opacity, transform;
}
/* The row itself. Its width is real — the frames are laid out at their true
   coordinates, not transformed into place — which is what lets the window
   scroll natively when nothing is driving it. */
.reel .grid {
  position: absolute; left: 0; top: 0;
  width: calc(var(--reel-w, 100) * 1px); height: 100%;
}
/* A frame on this sheet stands out of the seven or eight on the paper, not
   out of a sheet of twenty — so the sheet's own 0.2 all but erases its
   neighbours here. This one fades to 0.45, where they stay legible as
   photographs while the one you are on is plainly the one you are on. */
.reel .grid { --dim-alpha: .45; }

/* The enlargement glides up into place rather than simply appearing. The
   transform is on the whole layer, so the photograph, its annotation and the
   controls all arrive together as one composition — and it is a transform, so
   the browser can run it on the compositor without repainting anything.
   `.viewer-photo` below establishes the 3D context for the flip; this
   translate sits above that and does not disturb it. */
.viewer {
  position: absolute;
  inset: 0;
  opacity: 0;
  pointer-events: none;
  transform: translate3d(0, var(--enlarge-rise), 0);
  transition: opacity var(--t-enlarge) var(--ease-glide),
              transform var(--t-enlarge) var(--ease-glide);
  will-change: opacity, transform;
}
/* Open, but still transparent to the pointer: only what is drawn takes it.
   The viewer is `inset: 0`, so it lies over the whole stage, and taking the
   pointer everywhere meant it swallowed the top nav — which sits *before* it
   in the markup and so paints beneath it. The foot nav comes after and was
   unaffected, which is why only the top squares went dead while an
   enlargement was open. Both navs are meant to work from here.

   Fixing it here rather than with a z-index on the nav: the fault is a
   transparent box capturing clicks it never draws in, and there is nothing to
   capture — the viewer closes on the photograph, the hint or Escape, and has
   no backdrop handler. Nothing falls through to the sheet either, since the
   grid is already `pointer-events: none` while the viewer is open.

   The children are re-enabled only under `[data-open]`. `pointer-events` is
   inherited, so with the rule scoped this way a closed viewer's buttons stay
   untouchable rather than becoming live targets over the contact sheet. */
.viewer[data-open] {
  opacity: 1;
  transform: translate3d(0, 0, 0);
}
/* ⚠ **The sideways axis belongs to the swipe, and the browser has to be told
   before the finger moves.** The viewer is `overflow-y: auto`, so a touch on
   it starts an argument the browser settles by watching the first few pixels
   — and when it decides the gesture is a scroll it fires `pointercancel` and
   the swipe handler never hears another word. That is the whole of *"it
   sometimes works and sometimes doesn't"*: the answer depended on how
   straight the swipe started.

   `pan-y` keeps vertical scrolling native, which the caption needs on a long
   card. **`pinch-zoom` is kept deliberately** — this is an archive of
   photographs and pinching one open is a reasonable thing to want; `pan-y`
   alone would have taken it away as a side effect of fixing the swipe. */
.viewer { touch-action: pan-y pinch-zoom; }
.viewer[data-open] .viewer-photo,
.viewer[data-open] .viewer-caption,
.viewer[data-open] .viewer-hint,
.viewer[data-open] .viewer-turn,
.viewer[data-open] .viewer-flip,
.viewer[data-open] .viewer-nav { pointer-events: auto; }
/* Stepping with the arrow keys keeps the enlargement open, so there is
   nothing to travel — only the picture inside changes. Re-running the rise on
   every step would jog the whole composition sideways of the reader. */
.viewer[data-step] { transition-duration: var(--t-caption); }
.viewer[data-step], .viewer[data-step][data-open] { transform: none; }

/* ⚠ **A viewer that has been closed holds no picture.** `close` sets
   `data-blank` and `paintRecto` lifts it, so the moment between opening a
   different photograph and its frame decoding shows paper rather than the
   *previous* photograph fading in. `visibility` and not `display`, because the
   box must keep its measured size — `.flip` carries the aspect ratio that
   gives an enlargement its height on a phone, and collapsing it here would be
   the `350 x 0` fault of 2026-08-26 in a new place. */
.viewer-photo[data-blank] .face-recto { visibility: hidden; }

.viewer-photo {
  position: absolute;
  left: calc(var(--x) * 1px);
  top: calc(var(--y) * 1px);
  width: calc(var(--w) * 1px);
  height: calc(var(--h) * 1px);
  display: block;
  padding: 0;
  border: 0;
  background: none;
  cursor: zoom-out;
}
.viewer-photo:focus-visible { outline: none; }
.viewer-photo:focus-visible .flip { outline: 2px solid var(--ink); outline-offset: 3px; }
/* ...but not when we moved the focus there ourselves */
html[data-quiet] .viewer-photo:focus-visible .flip { outline: none; }

/* ------------------------------------------------------------- the flip
   Seventeen prints in the archive were scanned on both sides, because
   somebody wrote on the back. They are one object, so the viewer turns them
   over rather than listing the two scans separately. */
.flip {
  position: relative;
  display: block;
  /* the card is the photograph's own shape, so the focus ring hugs the print
     and the turn happens about the print's axis rather than the frame box's */
  aspect-ratio: var(--ar, 5 / 4);
  width: auto;
  height: 100%;
  max-width: 100%;
  max-height: 100%;
  transform-style: preserve-3d;
  transition: transform var(--t-flip) cubic-bezier(.42, 0, .2, 1);
}
.viewer-photo {
  perspective: 1600px;
  display: flex;
  align-items: center;
  justify-content: center;
}
.viewer[data-face="verso"] .flip { transform: rotateY(180deg); }

/* ------------------------------------------------------ the strip
   Past `gallery.NAV_MIN` a card stops turning and starts sliding. Turning is
   right for a verso — one object, two sides, and rotating it is what you would
   do holding the print — and fine for a stack of five. Twenty-seven is a
   sequence, and rotating a card twenty-six times to walk along a line is a lot
   of motion for a step; the axis of the rotation also keeps saying "this has
   another side", which stops being true after the second frame.

   The frames sit in a row and the row moves, one width per press. It is the
   most literal answer to "you are somewhere in a run": the neighbour arrives
   and the old frame leaves, and after a few presses there is a real sense of a
   strip running past.

   **Three cells, not one per frame.** The wedding card is twenty-seven, and a
   track that long would hold twenty-seven full-size photographs in the
   document to show one of them. The window carries the previous, the current
   and the next; after each slide it is silently recentred and repainted, which
   is invisible because it happens with the transition off. */
.viewer-strip { display: none; position: absolute; inset: 0; overflow: hidden; }
.viewer[data-mode="strip"] .flip { display: none; }
.viewer[data-mode="strip"] .viewer-strip { display: block; }
.strip-track {
  position: absolute; inset: 0; display: flex;
  transform: translateX(-100%);       /* the middle cell is the one you see */
  transition: transform var(--t-step) var(--ease-glide);
}
/* the recentring step: move without animating, then let the transition back */
.strip-track[data-still] { transition: none; }
.strip-cell {
  flex: 0 0 100%; display: flex; align-items: center; justify-content: center;
}
/* Each frame sits centred in a cell the size of the whole box rather than in a
   card its own shape, because a strip can only slide cleanly if the cells are
   one width.

   The 2px is not padding taste, it is a clip. The stage is drawn through a CSS
   transform at a fractional scale, so the `overflow: hidden` boundary lands on
   a fraction of a device pixel — and a frame wide enough to fill its cell
   touches that boundary exactly, which let a 1px sliver of the *neighbouring*
   photograph paint on the paper beside the one you are looking at. Keeping
   every image a hair inside its cell means nothing is ever on the seam.
   It costs at most 2px of a 450px box on the widest frames. */
.strip-cell img {
  max-width: calc(100% - 2px); max-height: 100%; display: block;
}

.face {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;      /* the two sides rarely share a shape */
  backface-visibility: hidden;
}
.face-verso { transform: rotateY(180deg); }
.face-verso:not([src]), .face-verso[src=""] { display: none; }

.viewer-controls { position: static; margin: 0; }

/* Sections with no annotations: the frame is centred in the stage and sized
   to fit either orientation, rather than sharing the width with a caption. */
.viewer--plain .viewer-caption { display: none; }
.viewer--plain .viewer-hint { left: 175px; top: 478px; }
.viewer--plain .viewer-turn,
.viewer--plain .viewer-nav { left: auto; right: 175px; top: 478px; }
/* ...and the series title with them. **The whole row moves on a section page**
   — down 14 and in to 175 either side — and a title left on the Kodachrome
   geometry sat one line above the controls it belongs to, which is exactly how
   it looked: a caption floating under the photograph rather than a label on
   the row. The gap it centres in moves too, so the numbers are derived from
   the two controls' own edges here rather than repeated. */
.viewer--plain .viewer-series {
  top: 478px;
  left: 293px;                /* the hint ends at 292.7 on this row */
  right: 262px;               /* 800 - 538, the navigator's own left edge here */
}

/* The SWF snapped the annotation on at the frame the photograph finished
   fading in — a 450ms hold and then a pop, which reads as a stall. It now
   comes up with the photograph instead. */
.viewer-caption {
  position: absolute;
  left: calc(var(--cx) * 1px);
  top: calc(var(--cy) * 1px);
  width: calc(var(--cw) * 1px);
  font-size: 12px;
  line-height: 13.7px;
  opacity: 0;
  /* a shade behind the photograph, so the picture leads and the words settle
     onto it rather than the two arriving as one flat block */
  transition: opacity var(--t-caption) var(--ease-glide) 90ms;
}
.viewer[data-open] .viewer-caption { opacity: 1; }
/* stepping between slides swaps the text in place, quicker still */
.viewer[data-step] .viewer-caption { transition-duration: 120ms; }
/* **16px, matching the album pages**, at the owner's word on 2026-08-15 —
   "we want that header styling throughout the site". It was 12px against a
   body of 11.4, so the headline was six tenths of a pixel larger than the
   words under it and read as an emphasised first line rather than as a title.
   `.page-text h2` was already 17 and `.entry h2` 15; this was the last one
   still sitting on the body's own size.

   **The one thing to watch here that the album pages could not show is
   overflow.** This column has a top and a width and no height: it grows
   downward, and below it lie the viewer's own controls at 464. A caption is
   the family's writing and some of them are long — 87 characters of headline
   on `early` sheet 2 frame 32, 700 characters of body on `wwii` 12 — so a
   heading that takes one more line pushes everything under it down. Both were
   rendered at 16px before this shipped; see the note in INTERACTIONS.md. */
.viewer-caption h2 {
  margin: 0 0 6.5px;
  font: italic normal 16px/17.5px "Caslon 224 Black", Georgia, serif;
}
/* ------------------------------------------------- the archive's own prose
   **One rule, three places, and deliberately not the `body` default.** The
   caption body, the about and bios copy and the album's text are the only
   things on this site that a member of the family writes; everything else is
   2004's own words, reproduced. So they are the only things set in a face
   with a full character set, and the split is exactly the line drawn in
   `CONTRIB.md`: *the reproduction is the chrome.*

   The headline above this stays Caslon 224 Black Italic, and so do the splash,
   the masthead, both navs and the section watermarks — which share the
   `Caslon 224 Book` file with what this rule replaces, so nothing is dropped
   from the build.

   **95%, and the number was arrived at by looking.** Libre sets 25% denser
   and 7% wider than Caslon 224 Book at the same pixel size — measured as ink
   over the area the line occupies, not guessed from x-heights — which does
   two things, both of them bad here. It closes the gap between the headline
   and the body until the body looks like the louder of the two, which is how
   this was first reported; and it added 17 lines across the archive's 111
   caption bodies, in a column whose width is fixed by a 2004 stage. Taking
   the body to 95% pulls back most of both. The **line height does not
   change** — it stays the SWF's own 13.7 — so the effect is a little more
   air between the lines as well as a smaller letter, which is what a denser
   face wants. See `concepts/caption_face.py`. */
:root { --prose: "Libre Caslon", Georgia, "Times New Roman", serif;
        --prose-size: 0.95em; }
.viewer-caption p, .page-text p, .album-text p {
  font-family: var(--prose);
  font-size: var(--prose-size);
}
.viewer-caption p { margin: 0 0 13.7px; }
.viewer-caption p:last-child { margin-bottom: 0; }
/* **A caption sized by the owner.** `--asz` scales the block's own type so a
   long caption can hold one page instead of being turned; it is set per
   caption, because two voices on one photograph are two different lengths.
   Absent unless he has chosen one, and then the fitting loop leaves it alone —
   `data-asz="set"` says the decision is his. The heading comes with it, or a
   shrunk caption would sit under a title that had not moved. */
/* **Scaled off `--prose-size`, not off `1em`.** `1em` here is the column's
   own 12px, while a caption's paragraphs are set at `--prose-size` — 11.4px —
   so `1em * 0.88` came out at 10.56 where the field previewing it said 10.03,
   and at full size the rule would have *raised* the type from 11.4 to 12
   before any shrinking began. The two have to agree exactly, or the control
   lies about its own result.

   Paragraphs only. A caption's heading is short and is not what overruns the
   column; scaling it would need a second base and give the same disagreement
   in the other direction. */
.cap[data-asz] p {
  font-size: calc(var(--prose-size, 11.4px) * var(--asz, 1));
  line-height: calc(var(--prose-size, 11.4px) * var(--asz, 1) * 1.2);
}

/* **A caption that runs to more than one page.** The album column's pager,
   one column over — see `.album-pager` — and deliberately the same object: the
   same drawn arrows, the same `1/2`, the same face. A reader who has met one
   has met the other.

   `.viewer-caption p.cap-pager` and not a bare class, because
   `.viewer-caption p` is 0-1-1 and sets `--prose`; the album's pager spent an
   afternoon set in Libre Caslon while declaring Akzidenz for exactly this
   reason, and `audit_faces.py` is what noticed. Third time in this file. */
.viewer-caption p.cap-pager {
  position: absolute; right: 0; bottom: 0;
  margin: 0; display: flex; align-items: center; justify-content: flex-end;
  gap: 7px;
  font: normal 9px/1 "Akzidenz BE Cn", "Helvetica Neue", Arial, sans-serif;
  letter-spacing: .07em; color: var(--ink); opacity: .55;
}
.cap-pager:hover { opacity: .85; }
.cap-pager button {
  appearance: none; background: none; border: 0; padding: 2px 4px; margin: 0;
  font: inherit; line-height: 1; color: inherit; cursor: pointer;
  display: flex; align-items: center;
}
.cap-pager button svg { display: block; width: 15px; height: 9px; }
.cap-pager button:hover { opacity: 1; }
.cap-at { font-variant-numeric: tabular-nums; }

/* ------------------------------------------------ who wrote this caption
   **An attribution is a field, not punctuation somebody typed** — one click in
   the edit box, and everything a reader sees about it is decided here. That is
   the point of storing it apart: the dash, the gap and the weight can be
   changed for every caption at once, and none of it can be half-deleted while
   editing. Added 2026-08-17 at the owner's word.

   Right-aligned, because it is a signature rather than a sentence and the eye
   should not mistake it for the next line of the paragraph. A little quieter
   than the body and in the body's own face: it is the family's writing too,
   and a second typeface here would announce it far more loudly than it
   deserves. The gap above is a little over one line, which is what separates
   it from prose without floating free of it.

   **The seven captions ending in a typed attribution are not this.** (The
   name is deliberately not quoted here: this stylesheet is served to the
   public and `leakcheck` reads it, so a comment naming the family's writing
   is a leak like any other — a small one, and the only one it found outside
   the markup.) They are prose
   typed by the 2005 author, inside the body, and they stay prose. If they are
   ever migrated to the field, this rule is what they will look like. */
/* -------------------------------------- several captions on one photograph
   **One is shown and the rest are offered**, which is the owner's own answer
   to a column that cannot grow: a line at the foot of a caption saying
   somebody else wrote about this photograph too, and one press to read them.
   This site's TURN OVER gesture applied to words. Chosen off
   `concepts/stacked_captions.py`, 2026-08-17.

   Every caption is wrapped in `.cap` even when there is only one — 99 of the
   archive's 99 captioned frames have exactly one, and the alternative was two
   DOM shapes for the scripts to tell apart. */
.viewer-caption .cap[hidden] { display: none; }

/* **`.viewer-caption p.alsoby`, not `.alsoby`.** The rule above sets `--prose`
   on every `p` in here and is 0-1-1; a bare class is 0-1-0 and loses, so the
   line came out in Libre Caslon while claiming Akzidenz. Found in the comp,
   and `document.fonts.check` is what said so — `fontFamily` reports the
   declared stack and will happily name a face that never loaded. */
.viewer-caption p.alsoby {
  margin: 26px 0 0;
  font: normal 9px/1.5 "Akzidenz BE Cn", "Helvetica Neue", Arial, sans-serif;
  letter-spacing: .07em;
  text-transform: uppercase;
  color: var(--ink);
  opacity: .55;
}
/* **`text-transform: inherit` is not optional.** A button does not inherit it
   from the UA stylesheet, so the name rendered mixed-case — and this face has
   no lowercase at all, so every letter of it came from Georgia. The oldest
   fault in this project wearing its usual disguise. */
.viewer-caption p.alsoby button {
  appearance: none; background: none; border: 0; padding: 0;
  font: inherit; letter-spacing: inherit; text-transform: inherit;
  color: inherit; text-decoration: underline; cursor: pointer;
}
.viewer-caption p.alsoby button:hover,
.viewer-caption p.alsoby button:focus-visible { opacity: 1; }

.viewer-caption p.credit {
  margin: 16px 0 0;
  text-align: right;
  opacity: .72;
  font-style: italic;
}
/* **Who is in the photograph.** Below the caption and quieter than it: the
   caption is what somebody wrote, this is a list of facts, and a row of names
   set at the caption's own weight would read as a sentence somebody meant.
   The label is the condensed nav face at the size the `alsoby` switch uses,
   so the two pieces of furniture under a caption match each other. */
.viewer-caption p.who {
  margin: 14px 0 0;
  /* Bounded by `edit_src.fitWho`, which sets `max-height` from the room the
     column actually has left. Scrolls rather than truncates: every name stays
     present and clickable. Without this the line grows over WRITE/EDIT
     CAPTION and ADD YOURS, which are then painted and unpressable. */
  overflow-y: auto;
  font: normal 9px/1.6 "Akzidenz BE Cn", "Helvetica Neue", Arial, sans-serif;
  letter-spacing: .06em;
  text-transform: uppercase;
  opacity: .82;
}
.viewer-caption p.who b {
  font-weight: normal;
  opacity: .55;
  margin-right: .5em;
}
/* A name that leads somewhere is underlined like any other link in the
   column; one that does not — the person was deleted from the bios store —
   is the same words without the rule under them, which is exactly what it
   means. */
.viewer-caption p.who span { opacity: .85; }
/* A URL is one long unbreakable word in a 228px column, so it has to be
   allowed to break rather than push the column wider than the stage. */
.viewer-caption, .entry p { overflow-wrap: anywhere; }
/* The site decorates nothing — but a link inside running text is the one
   place where the reader has to be told, and fading is not a signal you can
   see in a single word. An underline, kept off the baseline. */
.viewer-caption a, .entry a {
  color: inherit;
  text-decoration: underline;
  text-decoration-thickness: from-font;
  text-underline-offset: 2px;
}
.viewer-caption a:hover, .entry a:hover { opacity: .7; }

/* sits in the empty band between the photograph and the nav rule */
.viewer-hint, .viewer-turn, .viewer-nav, .viewer-flip {
  position: absolute;
  left: 55.4px;
  top: 464px;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  cursor: pointer;
  font: normal 10px/1.4 "Akzidenz BE Cn", "Helvetica Neue", Arial, sans-serif;
  letter-spacing: .08em;
  text-transform: uppercase;
  opacity: .45;
}
/* Under the right-hand edge of the enlargement, not the right-hand edge of
   the stage. The Kodachrome enlargement sits at x 55.4 and is 450 wide, so
   its far edge is 505.4 and this is the 800-wide stage less that. The hint
   opposite already sits on 55.4, the picture's near edge, so the two now
   bracket the photograph instead of straddling the caption column as well.
   `.viewer--plain` needs no such correction: its box is centred, and 175 is
   already both its inset and its far edge. */
.viewer-turn, .viewer-nav {
  left: auto; right: 294.6px; text-align: right;
  display: inline-flex; align-items: center; gap: .62em;
}

/* The back of the photograph, on the one kind of card that has both — several
   photographs *and* something written on the back of one of them. The turn
   control keeps stepping through the photographs; this turns the one you are
   looking at over.

   Placed by measurement, not by guess. The stack control is 88.49 wide with
   its left edge on 416.91, and that edge does not move when the count appears
   because the count hangs out of the flow — so there is a fixed edge to sit
   beside. 401 leaves 15.9px of paper between the two, and the hint opposite
   ends at 173.13, so this can grow leftward as far as its wording ever needs
   without meeting anything.

   Right-anchored with the arrow last, for the same reason the turn control
   puts its mark first: TURN OVER and TURN BACK are not the same width, and
   whichever end is anchored is the end that stays still. The arrow is the
   anchor here, so the label grows away from it and nothing on the row moves. */
.viewer-flip {
  left: auto; right: 401px; text-align: right;
  display: inline-flex; align-items: center; gap: .62em;
}
.viewer-flip .turn-arrow { display: block;
  transition: transform var(--t-dim) var(--ease-glide); }
.viewer-flip:hover .turn-arrow,
.viewer-flip:focus-visible .turn-arrow { transform: translateX(2.6px); }
.flip-label { display: inline-block; text-align: right; }
/* the plain sections put their controls on the stage's own inset; this keeps
   the same 106.4px of separation from the turn control that 401 does.

   **`top` as well as `right`, and it was missing until 2026-08-16.** The row
   moves down 14 on a plain section — the hint, the turn and the navigator all
   take 478 — and this rule set only the horizontal, so the flip stayed on 464
   and sat a line above the control it is spaced against. It never showed:
   before the layout became per-frame, no plain sheet had a frame that was both
   a two-sided print and a card, so TURN OVER and ANOTHER FRAME were never on
   screen together here. `wwii 5` is both, and the moment that sheet's
   uncaptioned frames took the plain layout the two controls overlapped. The
   horizontal separation in this rule was reasoned about; the vertical was
   simply not noticed. */
.viewer--plain .viewer-flip { left: auto; right: 281.4px; top: 478px; }

/* The control for a card of several frames wears the same mark its thumbnail
   does — three cards seen from above, the front one crisp and the two behind
   it falling back. One idea doing both jobs: the sheet says "there are more of
   these", the enlargement says "here is the next one". A verso keeps the plain
   wording; it is one object with two sides, not a set. */
.turn-mark { display: none; position: relative; width: 16px; height: 12px;
             border: 1px solid currentColor; flex: none; }
.turn-mark::before, .turn-mark::after {
  content: ""; position: absolute; inset: -1px;
  border: 1px solid currentColor;
}
.turn-mark::before { transform: translate(3px, 3px); opacity: .5; }
.turn-mark::after  { transform: translate(6px, 6px); opacity: .26; }
.viewer-turn[data-stack] .turn-mark,
.viewer-nav .turn-mark,
.album-turn .turn-mark { display: block; }

/* ...and how far through the set you are, once you have turned it once */
.turn-label { display: inline-block; text-align: left; }

/* A print with a back to read gets an arrow rather than the stack mark: it is
   one object turning over, not a set being stepped through. The gap opens a
   little on hover, so the control moves before the photograph does. */
.turn-arrow { display: none; width: 15px; height: 9px; flex: none; }
.turn-arrow svg { display: block; width: 100%; height: 100%; }
.viewer-turn:not([data-stack]) .turn-arrow { display: block; }
/* The arrow leads away a little as you point at it — the control moving
   before the photograph does. It travels by `transform`, which paints without
   laying out: widening the flex `gap` instead, as this did, made the row
   wider, and the row is anchored on its right, so "TURN OVER" jumped 2.6px
   leftward the instant the pointer arrived and back again as it left. The
   same fault the stacked card's control was cured of — a control that
   flinches when you reach for it.

   `will-change` is deliberately absent: this is 15px of SVG. */
.viewer-turn:not([data-stack]) .turn-arrow {
  transition: transform var(--t-dim) var(--ease-glide);
}
.viewer-turn:not([data-stack]):hover .turn-arrow,
.viewer-turn:not([data-stack]):focus-visible .turn-arrow {
  transform: translateX(2.6px);
}
/* The count hangs off the control's right, out of the flow. That is the whole
   mechanism, and it is doing two jobs at once.

   It cannot move the mark. The control is anchored on its right, so anything
   in the row that changes width walks the mark sideways — which the count did
   twice over: 13px when "2/5" first appeared, and a further 0.8px on any turn
   crossing 1, because `1` is narrower than the other digits here.
   (`font-variant-numeric: tabular-nums` is declared below and is **inert** —
   these are 40-glyph subsets lifted out of the SWF with no `tnum` feature in
   them, carrying Flash's own proportional advances. It is left for a face that
   would honour it; it is not what holds the width.) Out of the flow, the count
   contributes no width at all, so none of that can happen however wide it gets.

   And it leaves the wording where it was. Reserving room for the count inside
   the row would have held the control's right edge on the photograph's own
   505.4 but stopped the *ink* 19.4px short of it, over blank paper, until the
   card was turned — breaking the bracket with the hint opposite, which starts
   exactly on 55.4. This way the wording ends on 505.4 as it always did, and
   the count crosses that edge once turned: 511.6 to 524.8, which is 2.7px
   inside the annotation column's left edge at 522.1. Nothing collides — the
   deepest annotation stops at y 397.9 and this sits at 464, 66px of clear
   paper below it. The comp that settled it, with all three candidates at true
   geometry, is tools/concepts/turn-count-space.html.

   `margin-left` here must match the flex `gap` above, since it is standing in
   for the gap the count no longer gets from the row. `top: 0` rather than any
   centring: the count and the label are both 10px/1.4, so identical box
   heights put their baselines on each other exactly. */
/* **The count paints at the control's own strength, because opacity on a
   child multiplies.** It used to say `.62`, which reads as "a little quieter
   than the words" and is not what it did: the count is inside the button, so
   .62 of the button's own `--turn-rest` put it on the paper at **.446 against
   the wording's .72**, and on hover at .62 against 1. The two never matched at
   any moment, and every change moved them by different amounts — which is what
   made the number read as a separate thing that happened to be nearby, and
   what made its fades look awkward beside a label that was not fading.

   At 1 it inherits the button exactly: same strength at rest, same lift on
   hover, same drop under `[data-waiting]`, all of it carried by the parent
   rather than restated here. The transition stays for the one moment the count
   has of its own — its first appearance, when the card is turned once — so it
   eases in rather than popping.

   **It is already one control for the pointer**, and that was checked rather
   than assumed: the count is a DOM child of the button, so hovering it makes
   `.viewer-turn:hover` true and lifts the whole thing, and a press on the
   number advances the card. Hover and clicks follow the tree, not the box —
   the same property the mark's label relies on. What was wrong here was only
   ever the paint. */
.turn-count { position: absolute; left: 100%; top: 0; margin-left: .62em;
              font-variant-numeric: tabular-nums; opacity: 0;
              transition: opacity var(--t-dim) linear; }
.viewer-turn[data-counting] .turn-count { opacity: 1; }
.viewer-hint:hover, .viewer-hint:focus-visible,
.viewer-turn:hover, .viewer-turn:focus-visible,
.viewer-nav:hover, .viewer-nav:focus-within,
.viewer-flip:hover, .viewer-flip:focus-visible { opacity: 1; }
.viewer-turn[hidden], .viewer-nav[hidden],
.viewer-flip[hidden] { display: none; }
/* The turn now waits for the incoming frame rather than rotating over a file
   that has not arrived. Warming the card on open makes that wait almost never
   happen — but when it does, the press has to look received, or the button
   reads as dead. Deliberately not a spinner: it is usually one frame long. */
.viewer-turn[data-waiting] { opacity: .45; }
.viewer-nav[data-waiting] { opacity: .45; }

/* ------------------------------------------------------- the navigator
   **In the section's own colour, not the ink.** Everything else that names
   where you are wears it — the active foot label, the current top square, the
   corner rule on a stacked thumbnail — and this control is doing the same job
   at the other end of the page: it says which of twenty-seven you are looking
   at. In black it read as furniture belonging to the browser rather than to
   the section. `--section-ink` is set on the stage per page and is already
   darkened to NAV_CONTRAST, so it is legible at 10px; the fallback keeps a
   page that somehow has none from losing the control altogether.

   A card long enough that "another frame" stops describing it. Past
   `gallery.NAV_MIN` you are not looking at one more photograph — you are
   somewhere in a run of twenty-seven and may want to go back — so the control
   becomes a back arrow, the count, and a forward arrow, around the same
   three-card mark the short cards wear.

   `.viewer-turn` is untouched and still serves the verso and the short stack.
   Two elements rather than one that changes shape: those two are styled by
   different rules and each has been broken by a change meant for the other,
   and the navigator needs two press targets, which a `<button>` cannot hold.

   No wording at all, and that is not only a graphic decision. This is set in
   Akzidenz Grotesk BE Cn — a 40-glyph subset lifted out of the 2004 SWF — and
   it has no uppercase `X`, so "NEXT" cannot be set at all without grafting a
   glyph from the licensed original. FORWARD and BACK exist and were drawn as
   an option; arrows won on the page. See tools/concepts/navigator.py. */
/* The arrow is 15 x 9 of hairline and the button used to be exactly that big —
   nine pixels of target, which asks the reader to aim. It now carries a pad of
   paper on every side: `padding` grows the hit area and an equal negative
   `margin` takes the growth back out of the flow, so the row's geometry is
   unchanged and the mark beside it does not move. The pad is invisible; only
   the cursor knows it is there. */
.viewer-nav { color: var(--section-ink, var(--ink)); }
.nav-step {
  appearance: none; background: none; border: 0; color: inherit;
  cursor: pointer; flex: none; display: block;
  padding: 9px 8px; margin: -9px -8px;
}
/* ⚠ **A thumb needs more than a cursor does — the owner, 2026-09-08:**
   *"clicking through the 50th anniversary stack ... if my click on the next
   arrow wasn't exact it would close down the whole stack and move to show the
   next image from the sheet. Can we make the area more generous?"*

   Measured at 390px before changing anything: the target was **31 x 27**, and
   `elementFromPoint` ten pixels outside it in any direction answered `IMG` —
   the photograph. So a near miss did one of two things, both of which he saw:
   a clean tap fell through to the picture, which closes the enlargement; and
   a tap with any drift read as a swipe, which steps to the next frame *on the
   sheet*. Neither is a bug in what it hit. The target was too small to hit.

   **`pointer: coarse` rather than a width**, because this is a fact about the
   input device and not about the window: a small window on a laptop still has
   a cursor, and a tablet at 900px still has a thumb. `MOBILE.md`'s treatments
   are chosen by width because they are about layout; this is not.

   The growth is vertical where the room is — the row is thin and there is
   nothing above or below it but the photograph, which this should win over —
   and modest sideways, where the two arrows have 16px between them and the
   count sits just to the left. **Padding grows the target and an equal
   negative margin takes the growth back out of the flow**, which is the trick
   the rule above already uses: the row's geometry does not move, so nothing
   drawn beside it shifts. */
@media (pointer: coarse) {
  /* ⚠ **Second pass, 2026-09-08.** 37 x 43 was not enough — the owner, still
     being thrown out of a stack: *"could we be even a little more generous
     with the spacing around those arrows or the swipe sensitivity? I am still
     having trouble keeping from navigating out of the stack as I view."*
     Measured again at 390px: the row itself is only **84 x 14**, so the two
     arrows are islands with photograph on every side, and a photograph is the
     control that closes the enlargement.
     **The gap is opened first and the targets widened into it**, because 37
     was capped by the 10px left between them and two targets that touch would
     make *next* sometimes mean *previous* — worse than one that is small. */
  /* **Treatment B, chosen off a three-up comp on the real page, 2026-09-08.**
     The owner: *"we have a tremendous amount of screen real estate to work
     with here."* He is right, and it reframes the fix: the trouble was never
     only the size of the target, it was that a **15 x 9 glyph inside a 47 x 49
     target** gives a thumb nothing to aim at — the pad is invisible, so every
     hit outside the arrow is a surprise, which is how he found it (tapping
     below the arrow and having it work). The arrow is drawn at the size it is
     actually hit at now: 24 x 14 in a 64 x 64 box, a fifth of the target
     rather than a sixth, with the count moved clear of both. */
  .viewer-turn, .viewer-nav { gap: 2.2em; font-size: 1.15em; }
  .nav-step { padding: 25px 20px; margin: -25px -20px; }
  .nav-arrow { width: 24px; height: 14px; }
}
.nav-arrow { display: block; width: 15px; height: 9px; }
.nav-arrow svg { display: block; width: 100%; height: 100%; }
/* The same gesture the verso's arrow has — it leads away as you reach for it.
   **On the arrow, not on the button.** Putting it on the button moved the hit
   area out from under the pointer, which un-hovered it, which moved it back: a
   flicker at the trailing edge, and a click there landing on nothing. The
   verso's note says this travels "by transform, which paints without laying
   out" — the same reasoning applies to what is moved as to how, and a target
   that moves is no better than a row that moves. */
.nav-step .nav-arrow { transition: transform var(--t-dim) var(--ease-glide); }
.nav-step[data-dir="1"]:hover .nav-arrow,
.nav-step[data-dir="1"]:focus-visible .nav-arrow  { transform: translateX(2.6px); }
.nav-step[data-dir="-1"]:hover .nav-arrow,
.nav-step[data-dir="-1"]:focus-visible .nav-arrow { transform: translateX(-2.6px); }
/* An end of the run that does not wrap would dim its arrow here. This one
   wraps — 27/27 goes on to 1/27, as the short cards always have — so neither
   arrow is ever dead and neither is ever dimmed. */

/* The count sits *between* the arrows, so unlike `.turn-count` it is in the
   flow and its width is load-bearing. `1/27` and `10/27` are not the same
   width — `tabular-nums` is declared on the old count and is inert, these
   subsets carrying Flash's own proportional advances with no `tnum` feature —
   so stepping 9 -> 10 would gain a digit and shove the back arrow leftward.
   That is the fault this project has now found five times.

   The fix is a sizer holding the widest string the card can reach, written by
   gallery.js since only it knows how long the card is. `visibility: hidden`
   and not `display: none`: it has to take part in layout, which is the entire
   job. The real count is laid over it, right-aligned so the slash and the
   total sit still while only the leading digit changes. */
.nav-count { position: relative; display: inline-block; opacity: .62;
             flex: none; text-align: right; }
.nav-sizer { visibility: hidden; font-style: normal; }
.nav-at { position: absolute; right: 0; top: 0; font-weight: inherit; }

/* ------------------------------------------- the second frame, announcing
   A card that carries more than one frame has to say so, and this control
   used to say it by arriving: the letters started far apart and closed to
   their resting tracking as they faded up, over 900ms. `@keyframes turn-in`,
   removed — `tools/concepts/turn-arrival.html` is the comp that chose it and
   is now a record rather than a description.

   It went because it predates the mark. When the wording was all there was,
   the movement was the only thing announcing a second frame; the three-card
   mark beside it now does that job standing still, and the animation was
   left saying the same thing twice.

   It also moved the mark, which is the reason it went when it did. The
   control is anchored on its right, so opening the tracking made it 156px
   wide settling to 95 — the mark sliding 61px rightward on every open.
   Measured on the page, not reasoned about; the count below was moving it
   too, and neither was visible until both were looked for.

   It rests in the section's colour — the same ink the nav gives the page you
   are on, from gen_site.nav_ink() — and a little stronger than the hint
   opposite, because the colour is the point and .45 mutes it to nearly
   nothing. `--turn-rest` is the one number to change if it wants to be
   quieter. */
:root { --turn-rest: .72; }
/* **The navigator belongs in this rule and was missing from it**, which is
   the fault reported on 2026-08-16 as "the stack mark and navigator is grayed
   out (still works though)". It is the long card's card stepper — the same
   control wearing a different shape past six frames — so it took the section
   colour from the block below and not the .72 that exists *because* section
   colour at .45 mutes to nearly nothing. Sitting inches from the pin, which
   is the same blue at full strength, it read as that control disabled.

   It went unseen because only a long card wears one and the archive has two.
   A rule that names controls individually will keep doing this; the honest
   fix is that these three are one family and are now written as one. */
.viewer-turn, .viewer-flip, .viewer-nav {
  opacity: var(--turn-rest);
  color: var(--section-ink, inherit);
}

/* What a long card is a series *of*, in the paper between the hint and the
   navigator.

   **There is far more room here than there looks to be** — 245 stage units
   between the hint's right edge at 293 and the navigator's left at 538 — and
   the three titles this archive wants measure 96 to 152. It reads as tight
   because the row is otherwise anchored hard to its two ends.

   Centred in that gap rather than butted against either control: it belongs to
   neither, and a title growing rightward from the hint would eventually reach
   the navigator while one growing leftward from the navigator would never sit
   still, since the titles are different lengths and that control is
   right-anchored for exactly that reason.

   Quieter than both — it is a label rather than a control, and nothing here is
   clickable, so it must not read as something to press. */
/* **Two rows, two gaps, and this rule used to carry the wrong one.** The
   numbers above — 293 to 538 — are the *plain* row's: the hint ends at 293
   there and the navigator's left edge is 538. On a **captioned** frame the row
   is a different one. Measured 2026-08-18, on the frame that showed it: the
   hint ends at **173** and the navigator sits at **418 to 505**, because it is
   anchored to the caption column's side of the stage rather than the paper's.
   So a band drawn at 293-538 straddles the navigator, and a title centred in
   it lands on top of the arrows — 74 units of overlap on the longest of the
   six, the card at 1950-1980 sheet 1 cell 17.

   ⚠ **That card used to be named here and is not any more — 2026-08-25.** The
   sentence quoted its title, which is the family's writing: two first names
   and the month they married. **A CSS comment ships to the public**, so it sat
   in the bytes of every page on the site — and `leakcheck` could not see it,
   because the same title was published as `data-series` and the check carried
   an exemption for exactly that phrase. The owner made the series titles
   private, the exemption came out with them, and this was the first thing the
   check found without it. A frame is named by its address here now, which is
   the build's own word and nobody's writing.

   **It was invisible until a caption was written.** `gallery.js` toggles
   `.viewer--plain` on *"this frame has no words"*, so every series in this
   archive was drawn on the plain row, where 293-538 is exactly right. The
   owner captioned the wedding stack on 2026-08-18 and the frame moved to the
   other row for the first time.

   The captioned gap is the same 245 units — 173 to 418 — so the band moves
   rather than shrinks: flush with the hint as the plain row is, stopping 10
   short of the navigator as the plain row does. The navigator is a fixed 87
   units wide whatever the count says, because the label reserves its width,
   so that clearance does not move as the reader steps through a stack. */
.viewer-series {
  position: absolute;
  left: 173px;                /* the hint's right edge on a captioned frame */
  right: 392px;               /* 800 - 408, ten short of the navigator at 418 */
  top: 464px;
  margin: 0;
  text-align: center;
  font: normal 10px/1.4 "Akzidenz BE Cn", "Helvetica Neue", Arial, sans-serif;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--ink);
  opacity: .32;
  pointer-events: none;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.viewer-series[hidden] { display: none; }

/* ------------------------------------------- a section's landing page
   The object the photographs came out of, on a page of its own before the
   contact sheet. It borrows the enlargement's geometry exactly — `CAP_BOX`
   for the photograph, `CAP_TEXT` for the column, the control on the row at
   y 464 — because a landing page that invented its own vocabulary would be a
   different site for one page.

   The control sits where the *hint* does rather than where the turn control
   does, on the left of its row. It is the only thing on that row, and the
   turn control's position is right-anchored against a photograph's edge that
   is not there here; put on the right it floated with nothing opposite it. */
.album-shot {
  position: absolute;
  left: calc(var(--x) * 1px); top: calc(var(--y) * 1px);
  width: calc(var(--w) * 1px); height: calc(var(--h) * 1px);
  display: block;
  /* one view crossing to the next; see `.album-turn` below for why it is a
     dissolve and not the card's turn */
  transition: opacity var(--t-dissolve, 700ms) linear;
}
.album-shot[data-off] { opacity: 0; }

/* ------------------------------------------------- more than one view of it
   **An object has more than one side, and one photograph shows one of them.**
   The owner intends to add the interior, the sleeve and a page spread, so the
   landing photograph is a small stack rather than a single picture.

   **A dissolve, not the card's turn.** A turn says "there is another side to
   this print" — it is the verso's own gesture, and `TURN OVER` is how the
   archive says so. (⚠ This sentence used to end "and the fold in its corner
   means writing on the back". There is no fold: a drawn dog-ear was an early
   way of marking a verso and was dropped because a cut-out print scanned
   against nothing has an empty box corner, so it floated off the paper rather
   than sitting on it. See *Landmines* in `CLAUDE.md`.) These are separate
   photographs *of one object*
   taken from different positions, which is what a stacked card is, and
   nothing here is the back of anything. Turning between them would be a
   sentence the site can say and does not mean. It also keeps this page clear
   of `shapeAsItTurns`, which is where every turn bug in this archive has
   lived, and which belongs to `gallery.js` — a script this page does not load.

   The mark and the count are the stack's own vocabulary, in the section's ink,
   at the size `.album-go` sets across the bottom of this page. */
/* **Under the photograph's own lower right corner**, at the owner's word on
   2026-08-15 — the control belongs to the object it turns, and across the page
   at the right end of the text column it read as belonging to the writing.

   `--r` and `--y` come from `album_html`, computed off the **lead view's**
   drawn box, so this is the picture's actual corner rather than a guess at
   where a 4:3 photograph ends. That matters because the box is different on
   every section: the negatives' album photograph is square and reaches 597
   where fotron's reaches 437.

   **And it stays on the lead view's corner when the view changes.** Moving it
   to each photograph's own corner as they swap would be a control that moves
   in answer to being used — the fault the navigator's arrows, the reel's
   frames and the slideshow's speed ticks have each had once, and the one this
   file is most practised at. The corner it sits under is the composition's,
   not any single photograph's.

   Its padded target begins seven above its ink and so reaches a little over
   the picture's bottom edge; that costs nothing, the photograph carrying no
   handler of its own on this page. It clears `.album-go` horizontally by the
   width of the picture, the two being at opposite ends of it. */
.album-turn {
  position: absolute;
  right: calc(var(--r, 60) * 1px); top: calc(var(--y, 464) * 1px);
  display: inline-flex; align-items: center; gap: .62em;
  background: none; border: 0; cursor: pointer;
  color: var(--section-ink, inherit);
  font: normal 10px/1.4 "Akzidenz BE Cn", "Helvetica Neue", Arial, sans-serif;
  letter-spacing: .08em; text-transform: uppercase;
  opacity: var(--turn-rest);
  padding: 8px 10px; margin: -8px -10px;   /* target, not drawing — see below */
}
.album-turn[hidden] { display: none; }
.album-turn:hover, .album-turn:focus-visible { opacity: 1; }
.album-turn:focus-visible { outline: 1px solid var(--ink); outline-offset: 2px; }
/* The mark itself is `.turn-mark`, drawn once above and shown by whichever
   control is wearing it — a stacked enlargement, the navigator, and now this.
   Redrawing it here at the same size would have been a second copy of a shape
   this file already has, and the two would drift; the display rule it is
   listed in is the whole of the change. */
/* ------------------------------------------- the album column, and its floor
   **`--asz` scales every size in this column at once**, and it exists because
   the column has a floor it had no idea about: the foot nav sits at 498 and
   the column starts at 125, so 373px is the whole budget. A longer description
   simply ran the "also" line — the one carrying the link to the other album —
   underneath the navigation, 40px of overlap, which reads as the link being
   cut off. Reported exactly that way on 2026-08-17.

   Every size below is expressed against it so that one number moves the lot in
   proportion; `stage.js` steps it down until the column clears the nav, and an
   album may carry a size of its own from the store to overrule that. Default 1
   is the design as it was drawn, so a page that fits is a page unchanged. */
.album-text {
  position: absolute;
  left: calc(var(--x) * 1px); top: calc(var(--y) * 1px);
  width: calc(var(--w) * 1px);
  /* **The column has a fixed depth now, and the divider is firm.** It ran to
     whatever length the description happened to be, so a longer one pushed the
     footnote and the closing line down the page and eventually under the
     navigation. The owner's answer, 2026-08-17: *"set that divider and make it
     firm, offering multiple page views for descriptions that need to be
     longer."* So the foot is pinned to the bottom of this box, the description
     gets the space above it, and text that will not fit is paged rather than
     pushed. `--floor` is where the navigation begins. */
  height: calc((var(--floor, 490) - var(--y)) * 1px);
  /* **Declared here so there is one of it.** `stage.js` reads this property to
     work out what is left for the foot; while it was only ever a `var()`
     fallback the script found nothing and fell back to its own copy of the
     number, so the divider could be moved here and the foot would go on being
     measured against the old one. A number written twice is a number that will
     disagree with itself, which this project has proved often enough. */
  --body-h: 225;
  font-size: calc(12px * var(--asz, 1));
  line-height: calc(13.7px * var(--asz, 1));
}
/* The description's own box: everything above the divider, and no more of it
   than there is room for. `--foot-h` is measured by `stage.js`, because the
   footnote is prose and its depth is not knowable here. */
/* **The divider is set, not derived.** Deriving it from the footnote's own
   depth put the divider wherever that page's footnote happened to end — on
   fotron that left the family's writing 168px and the site's own voice 197,
   which is the wrong way round and gave every paragraph a page to itself.

   `--body-h` is the description's box and it is the same on every album page,
   which is what "firm" means. The foot takes what is below it and shrinks to
   fit if its own prose is long: it is the secondary voice, and it is the one
   that should give way.

   **225 is measured rather than picked, and the first guess of 215 was one
   pixel wrong in the way that matters.** The column runs 125 to the floor at
   490, so 365 to divide. The two feet are 173px and 92px at full size, and the
   two descriptions 344 and 216. A divider at 225 leaves the foot 140 — enough
   for fotron's at 0.82, the step it already wanted — and clears the negatives'
   description by nine pixels, so the page that fits stays on one page. At 215
   it did not: 216 against 215 put a two-paragraph description onto two pages
   with sixty pixels of unused paper below it, which reads as a fault rather
   than as a feature. A description longer than 225 pages, which is the whole
   point; one that fits should never be told it does not. */
.album-body {
  position: absolute; left: 0; right: 0; top: 0;
  height: calc(var(--body-h, 225) * 1px);
  overflow: hidden;
}
.album-foot {
  position: absolute; left: 0; right: 0;
  top: calc(var(--body-h, 225) * 1px);
}
/* **There is deliberately no rule here hiding a paragraph on another page**,
   and the one that was here for an afternoon is worth a sentence. It read
   `.album-body[data-paged] p[data-page]:not([data-page="0"])` — meant as the
   answer for a reader with no scripting, except that `data-paged` is set by
   `stage.js` and by nothing else, so it could not fire until the very thing it
   was covering for had run. What it did instead was beat the pager: turning a
   paragraph back on sets `style.display = ''`, which removes the inline
   declaration rather than winning against a stylesheet, so page 2 measured
   correctly, said `2/2`, and drew an empty column. Paging is `stage.js`'s
   alone; without it the column holds every paragraph and clips at the divider,
   which is the same answer this site gives everywhere else. */

/* The pager, above the divider and against the column's right edge — where the
   card navigator's own count sits on an enlargement, and saying the same thing
   in the same words. It appears only when there is a second page. */
/* **`.album-text p.album-pager`, and this is the third time today.** The
   shared `.viewer-caption p, .page-text p, .album-text p` rule is 0-1-1 and
   sets `--prose`; a bare `.album-pager` is 0-1-0 and loses, so the whole pager
   was being set in Libre Caslon while declaring Akzidenz. `audit_faces.py` is
   what said so — it asks which face actually resolved rather than which one
   was asked for, and `getComputedStyle().fontFamily` would have reported the
   declared stack and agreed with the mistake. */
.album-text p.album-pager {
  position: absolute; right: 0;
  top: calc((var(--body-h, 225) - 12) * 1px);
  margin: 0; display: flex; align-items: center; gap: 7px;
  font: normal 9px/1 "Akzidenz BE Cn", "Helvetica Neue", Arial, sans-serif;
  letter-spacing: .07em; color: var(--ink); opacity: .55;
}
.album-pager:hover { opacity: .85; }
.album-pager button {
  appearance: none; background: none; border: 0; padding: 2px 4px; margin: 0;
  font: inherit; line-height: 1; color: inherit; cursor: pointer;
  display: flex; align-items: center;
}
/* **The site's own drawn arrow, not a chevron from a font.** `‹` and `›` are
   in none of the eight extracted subsets and none of the fallbacks either, so
   they were being served from Georgia — the fault this project keeps finding,
   caught here by the audit before it shipped. `_ARROW`/`_ARROW_BACK` are the
   same two paths the TURN OVER control and every `album-go` link already use,
   so the pager points the way the rest of the site points. */
.album-pager button svg { display: block; width: 15px; height: 9px; }
.album-pager button:hover { opacity: 1; }
/* **The count is set in the furniture face and that face has only `/` and the
   digits** — no letters are needed, which is why this reads `1/2` rather than
   "page 1 of 2". The same constraint gave the card navigator its wording. */
.album-at { font-variant-numeric: tabular-nums; }
/* **16px against the body's 11.4**, chosen off `concepts/album-type.html`,
   which drew five sizes side by side at true size out of the shipped
   stylesheet and the shipped column markup.

   It was 12px — six tenths of a pixel above the body — and so the heading was
   carrying the whole distinction on weight and slope. That reads as emphasis
   inside a paragraph rather than as a title, and it was reported exactly that
   way: *more contrast in size between the headers and body text*.

   **The face does not change, and that was the other half of the decision.**
   Libre Caslon arrived for *body* copy on 2026-08-14 and the headings stayed
   in the extracted 2004 cut deliberately, because that cut sets every caption
   headline in the archive. Setting this one in Libre Caslon bold italic was
   drawn as option E and is visibly a different design — narrower, more
   upright — which would have made these two pages' headings unlike every
   other headline on the site to buy a family match with the paragraph below.
   The contrast problem was a size problem the whole time.

   `.viewer-caption h2` is untouched at 12px. It is the same face doing the
   same job, but a caption headline sits directly under a photograph rather
   than at the top of a column, and it lands on several hundred enlargements —
   so it wants its own comp and its own decision. */
.album-text h2 {
  margin: 0 0 calc(6.5px * var(--asz, 1));
  font: italic normal calc(16px * var(--asz, 1))/calc(17.5px * var(--asz, 1))
        "Caslon 224 Black", Georgia, serif;
}
.album-text p { margin: 0 0 calc(13.7px * var(--asz, 1)); }
.album-text p:last-child { margin-bottom: 0; }

/* **Links in this column had no rule at all**, which nobody had noticed
   because until 2026-08-15 no album page contained one. `site.css` gives the
   off-baseline underline to `.viewer-caption a` and `.entry a` and stopped
   there, so the first link written here would have come out in the browser's
   own blue, underlined at the baseline, on a page of kraft and Caslon. Same
   rule, same three declarations; this is the third place that needs it. */
.album-text a {
  color: inherit;
  text-decoration: underline;
  text-decoration-thickness: from-font;
  text-underline-offset: 2px;
}
.album-text a:hover { opacity: .7; }

/* ------------------------------------------------------------ the footnote
   **Two voices in one column, and the reader has to be able to tell.**
   Everything above is the family's own writing; this is the site saying what
   a Fotron was, or what Kodak sold these books for. A hairline and a step down
   in size are what separate them — run into the body it would read as
   something a Moore had said, which is the one ambiguity an archive cannot
   afford.

   The rule is the nav's own hairline colour at the weight the ticks use, and
   the block is `--turn-rest` like every other secondary thing on this page, so
   it sits under the paragraphs rather than beside them. */
/* ------------------------------------------- the About page's two texts
   `AUTH.md` step 6. One page, two blocks: what a stranger reads and, below a
   hairline, what somebody with the password reads. The second is absent from
   the public tree entirely — `gen_site` draws no markup for it — so this rule
   never paints there.

   **Ordinary flow, and that is the whole reason these are not `.album-text`.**
   That class is `position: absolute` off `--x`/`--y`, written for a 207.9px
   column beside a photograph; borrowed here, both blocks collapsed to nothing
   and sat on top of each other. This page's text is a 400px measure in normal
   flow and wants none of it.

   The hairline is the album footnote's, at the same weight and colour, because
   it is doing the same job: marking where one voice ends and another begins.
   What it is *not* is a heading — at the owner's word 2026-08-18, the family
   half *"can read as a continuation... just a place to put info we don't want
   others to see"*. So no label, no "members only", nothing that would tell a
   public reader there is a half they are not getting. */
.about-family {
  margin-top: 17px;
  padding-top: 11px;
  border-top: 1px solid rgba(58, 50, 42, .22);
}

.album-note {
  margin-top: calc(17px * var(--asz, 1) * var(--foot-asz, 1));
  padding-top: calc(11px * var(--asz, 1) * var(--foot-asz, 1));
  border-top: 1px solid rgba(58, 50, 42, .22);
  /* **Both scales, and the second is why `--foot-asz` did nothing at first.**
     These are absolute pixels, so setting `font-size` on the parent in `em`
     left them exactly where they were — the foot went on overflowing at every
     step of a loop that appeared to be working. */
  font-size: calc(10.5px * var(--asz, 1) * var(--foot-asz, 1));
  line-height: calc(12.6px * var(--asz, 1) * var(--foot-asz, 1));
  opacity: .78;
}
.album-note p { margin: 0 0 calc(8px * var(--asz, 1) * var(--foot-asz, 1)); }
.album-note p:last-child { margin-bottom: 0; }

/* The citation, and the only outbound link on the site. It wears the section's
   ink and the same arrow as the album's own forward link, so it reads as
   leaving rather than as emphasis — see `album_note` in gen_site.py for why it
   is a footnote and what it costs a page built to run off a USB stick. */
/* **In the ink, not in the section's colour**, which is the correction the
   first render forced. Both of these were `--section-ink` and they sit one
   under the other, so the foot of the column came out as a four-line block of
   mulberry directly beneath the family's own two paragraphs in black — the
   citation and the way out reading as the loudest things on a page whose
   subject is neither. Colour is this site's way of saying *which section you
   are in*; it is not emphasis, and using it as emphasis inverts the hierarchy
   of the one column where two voices already have to be told apart.

   So the words are the ink and the underline carries the link, exactly as
   `.viewer-caption a` does — and only the arrow keeps the section's colour,
   which is enough to say these lead away. `.album-go` stays coloured because
   it is the page's own action and has the row to itself. */
.album-cite a, .album-also a {
  display: inline; color: inherit;
  text-decoration: underline;
  text-decoration-thickness: from-font;
  text-underline-offset: 2px;
}
.album-cite a:hover, .album-also a:hover { opacity: .7; }
/* `.turn-arrow` is `display: none` in this file and every control that wears
   one turns it on — which is why these two shipped their first render with no
   arrow at all and nothing in the markup to say so. Inline, so it sits at the
   end of the sentence's last line rather than on a line of its own. */
.album-cite .turn-arrow, .album-also .turn-arrow {
  display: inline-block; vertical-align: baseline;
  margin-left: .45em; transform: translateY(1px);
  color: var(--section-ink, inherit);
  transition: transform var(--t-dim) var(--ease-glide);
}
.album-cite a:hover .turn-arrow,
.album-also a:hover .turn-arrow { transform: translate(2.6px, 1px); }

/* **Where the 6 x 9 cm negatives went.** They left the navigation on
   2026-08-15 and this line is the whole of how a reader reaches them — so it
   is deliberately not a footnote: it is above the rule in weight and below the
   body in size, because it is neither the family's writing nor the site's
   gloss but a way out of the page. */
/* **`.album-text p.album-also`, and the bare class is why the lines ran into
   one another.** `.viewer-caption p, .page-text p, .album-text p` sets
   `--prose-size` at 0-1-1; a bare `.album-also` is 0-1-0 and loses. So the
   `font-size` here never applied and the `line-height` — which nothing else
   sets — always did: 11.4px type on 9.88px leading, a ratio of 0.87, which is
   lines overlapping rather than merely tight. It was reported as *"compressed
   together too much vertically"* and that is exactly what it was.

   **The same trap as `.viewer-caption p.alsoby`**, one column over and three
   days apart, and worth recognising by its shape: a rule that half works is a
   specificity loss, because the properties nobody else claims go on landing
   while the contested one silently does not.

   1.32 rather than the note's 1.2: this is a sentence of running prose at a
   size where the body's own leading reads tight, and it is the one line on
   the page a reader is meant to follow out of it. */
.album-text p.album-also {
  margin-top: calc(15px * var(--asz, 1) * var(--foot-asz, 1));
  font-size: calc(11px * var(--asz, 1) * var(--foot-asz, 1));
  line-height: calc(14.5px * var(--asz, 1) * var(--foot-asz, 1));
}
/* ...and clear of the footnote when both are present, because they are not the
   same kind of sentence and ran together as one purple block on the first
   render. The note ends the site's own voice; this begins a way out. */
/* 0-3-1, so it beats the 0-2-1 above; the plain `.album-note + .album-also`
   this replaced was 0-2-0 and had just been overtaken by the fix above it. */
.album-text .album-note + p.album-also { margin-top: 19px; }
.album-go {
  position: absolute; left: 55.4px; top: 464px;
  display: inline-flex; align-items: center; gap: .62em;
  color: var(--section-ink, inherit); text-decoration: none;
  font: normal 10px/1.4 "Akzidenz BE Cn", "Helvetica Neue", Arial, sans-serif;
  letter-spacing: .08em; text-transform: uppercase;
  opacity: var(--turn-rest);
}
.album-go:hover, .album-go:focus-visible { opacity: 1; }
.album-go .turn-arrow {
  display: block;
  transition: transform var(--t-dim) var(--ease-glide);
}
.album-go:hover .turn-arrow,
.album-go:focus-visible .turn-arrow { transform: translateX(2.6px); }

/* ------------------------------------------- about / bios (unwritten yet)
   Two sections the 2004 project named and never filled in. Same stage, a
   text column set like the annotations, and for bios a grid of placeholders
   waiting on portrait crops. */
.page-text {
  position: absolute;
  left: 74.5px;
  top: 92px;
  width: calc(var(--w) * 1px);
  font-size: 12px;
  line-height: 13.7px;
}
.page-text h2 {
  margin: 0 0 13.7px;
  font: italic normal 17px/20px "Caslon 224 Black", Georgia, serif;
}
.page-text p { margin: 0 0 13.7px; }
.page-text p:last-child { margin-bottom: 0; }

.bio-grid {
  position: absolute;
  left: calc(var(--gx) * 1px);
  top: calc(var(--gy) * 1px);
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  grid-template-columns: repeat(var(--cols), calc(var(--cell) * 1px));
  gap: calc(var(--gap) * 1px);
}
.bio-grid li {
  width: calc(var(--cell) * 1px);
  height: calc(var(--cell) * 1px);
  box-shadow: 0 1px 3px rgb(0 0 0 / .28);
}


/* The about and bios pages. They used to start at opacity 0 and be revealed
   by `html[data-unlocked] .page` once the client-side gate lifted; that gate
   is gone — the real one is nginx basic auth at the host — so anything that
   reaches this markup has already been let in, and it is simply visible.

   Removing the gate's rules without this one left the pages rendering at
   opacity 0: the headings and the bios swatches were all present in the
   markup and painted nothing. */
.page { opacity: 1; }

/* ------------------------------------------------------------ loading
   The original preloader: "loading photographs" over five squares that fade
   up in sequence (sprite 4 in both SWFs). */
.loader {
  position: fixed;
  inset: 0;
  display: grid;
  place-content: center;
  gap: 18px;
  justify-items: center;
  background: var(--paper) url("../img/paper.jpg") center / cover no-repeat;
  z-index: 50;
  transition: opacity 300ms linear;
}
.loader.is-done { opacity: 0; pointer-events: none; }
.loader p {
  margin: 0;
  font: normal 20px/1 "Akzidenz BE", "Helvetica Neue", Arial, sans-serif;
}
.loader-dots { display: flex; gap: 13px; }
.loader-dots i {
  width: 5px; height: 4px; background: #000; display: block;
  animation: dot 1.6s linear infinite;
  animation-delay: calc(var(--i) * .1s);
}
@keyframes dot { 0%, 60%, 100% { opacity: 0; } 25% { opacity: 1; } }

.stage { transition: opacity 250ms linear; }
.js .stage { opacity: 0; }
.js .stage.is-ready { opacity: 1; }

/* The preloader only exists when scripting can dismiss it. The [hidden] rule
   has to come last: it is what takes the fixed, full-viewport overlay out of
   the page once boot() finishes, and without it the faded-out loader keeps
   swallowing every click. */
.loader { display: none; }
.js .loader { display: grid; }
.js .loader[hidden] { display: none; }

/* ------------------------------------------------ annotations as markup
   section.entries is the real content: the enlargement and its caption for
   every slide. The gallery script clones from it, so with JS on it is only a
   source, never something the reader sees. */
.js .entries { display: none; }
.entries {
  background: var(--paper) url("../img/paper.jpg") center top / 800px auto repeat;
  padding: 48px 20px 64px;
}
.entry, .entries > * { max-width: 46rem; margin-inline: auto; }
.entries img { max-width: 100%; height: auto; display: block; }
.entry { margin: 0 0 48px; }
.entry h2 { font: italic normal 15px/1.4 "Caslon 224 Black", Georgia, serif; margin: 16px 0 8px; }
.entry p { margin: 0 0 1em; font-size: 15px; line-height: 1.5; }

html:not(.js) .grid[data-state="out"] { opacity: 1; }
html:not(.js) .viewer { display: none; }

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 1ms !important;
    transition-duration: 1ms !important;
  }
}

/* ------------------------------------------------------------ narrow
   Same markup, reflowed. The 800px composition can't shrink below roughly
   860px without the 12px type becoming unreadable, so under that we drop the
   absolute positioning entirely. */
@media (max-width: 859px) {
  .viewport { padding: 0; }

  /* The paper goes on the page rather than on the stage, so it runs under the
     safe-area inset as well. With the texture on .stage alone, the band iOS
     reserves for the notch showed the html background — two grey bars down
     the sides of a landscape phone. */
  html, body {
    background: var(--paper) url("../img/paper.jpg") center top / 800px auto repeat;
  }

  .stage {
    width: 100%;
    height: auto;
    transform: none;
    box-shadow: none;
    background: none;
    min-height: 100svh;
    /* pad back off the notch, but never less than the layout's own margin */
    padding: 32px max(20px, env(safe-area-inset-right))
             calc(40px + env(safe-area-inset-bottom))
             max(20px, env(safe-area-inset-left));
    display: flex;
    flex-direction: column;
    gap: 18px;
  }

  .t, .nav-item, .home-cover, .thumb, .viewer-photo,
  .viewer-caption, .viewer-hint {
    position: static;
    width: auto;
    height: auto;
    white-space: normal;
  }

  /* ⚠ **`UNLOCK CAPTIONS` comes back, and the rule that hid it was reasoning
     about the wrong door.** It used to say: there is no editing on a phone —
     CONTRIB.md settled that, the surface is a multi-line control on an
     800 x 550 stage — so offering the door to a room with nothing in it would
     be worse than quiet.

     That was true when the control meant *signing in to write*. It stopped
     being true when the reader tier arrived: `unlock.html` is answered by
     nginx, which authenticates and redirects to **`read/` or `edit/`
     depending on who signed in**. Hiding it withheld the *reading* because
     the *writing* was unusable — and reading the family's captions is exactly
     what somebody does on a phone. The owner found it missing, 2026-08-26:
     *"not seeing an 'UNLOCK CAPTIONS' option here."*

     ⚠ **`relative`, never `static`, and this is the `.home-cover` trap in its
     second form.** `a.login::after` is the hit area — `position: absolute;
     inset: -11px -13px -9px` — and against a `static` parent it resolves to
     the nearest positioned ancestor, which here is the **stage**. A
     transparent rectangle the size of the splash, over the top of every
     control on it. `relative` keeps the pseudo's context; `inset: auto`
     undoes the desktop coordinates the way `.home-cover` needs it. */
  /* ...and the foot copy every other page carries comes back here, at the
     owner's word 2026-08-26: *"for the mobile version, let's put the unlock
     captions option at the bottom of every sheet/page."* A reader who has just
     scrolled a sheet and found the captions missing is looking at the bottom
     of it, not at the front page. 0-2-0, so it out-ranks the blanket hide
     above without depending on source order. */
  .stage .login-foot { display: block; }

  .login {
    /* said out loud rather than left to the default: the rule this replaces
       was `display: none`, and a reader coming to this block should see the
       reversal rather than infer it from an absence */
    display: block;
    position: relative;
    inset: auto;
    order: 7;
    font-size: 11px;
    align-self: flex-start;
  }

  /* The splash's invitation takes the corner label's place in the column —
     the slot after the nav — and stays centred under it as it is on the
     stage. `relative`, for the reason `.login` gives above: the hit pseudo
     resolves against the nearest positioned ancestor, and `static` would
     hand it the whole stage. Allowed to wrap: a narrow phone cannot hold the
     sentence on one line at any size worth reading. */
  .invite {
    position: relative;
    inset: auto;
    order: 7;
    font-size: 13px;
    align-self: center;
    white-space: normal;
    line-height: 1.5;
    /* two even lines rather than a sentence and an orphaned word, where a
       narrow phone has to break it at all */
    text-wrap: balance;
  }
  /* the top-left line follows the invitation into the column, centred under
     it, and gives up the corner's fixed column for the phone's width */
  .inspired {
    position: relative;
    inset: auto;
    max-width: 22em;
    order: 8;
    white-space: normal;
    font-size: 11px;
    align-self: center;
    text-align: center;
    line-height: 1.5;
    text-wrap: balance;
    margin-top: 6px;
  }

  /* ...but the cover has layers inside it, so it cannot go `static`: the
     incoming photograph is `inset: 0` against it, and against a static parent
     that resolves to the *stage*, which would paint one cover across the whole
     page. `relative` keeps it the positioning context while the rule above
     still hands its size back to the flow. Its height then comes from the base
     layer, which is why that one is a real image in the document and not a
     background. */
  /* ⚠ **Relative, and therefore `left` and `top` still apply.** `.home-cover`
     is `position: absolute` above with `left: calc(var(--x) * 1px)` — 299 on
     the splash — and the rule below deliberately gives it `relative` back so
     its inner layers keep a positioning context. Relative *keeps the offsets*:
     the portrait sat 299px into a 390px screen, ran 249px off the right edge,
     and left a 258px band of bare paper above it where its `top` had pushed it
     down. The owner reported all three as one thing — *"the portrait
     overlapping the nav and cut off at the right edge"*.

     `inset: auto` is the whole fix and has to be said explicitly: the reset
     further up handles the elements it takes to `static`, and this one is the
     single exception that goes back to `relative`. */
  .home-cover { position: relative; inset: auto; }
  .cover-shot { height: auto; }

  /* ⚠ **The colour key goes, because there is nothing left for it to key.**
     Eleven `.nav-sq` swatches sit beside the nav, absolutely positioned at
     their splash coordinates, `aria-hidden` and purely decorative: each one is
     the colour its nav label lights up in. Down here the nav is a wrapped list
     of plain words with no colour in it at all, so the legend explains nothing
     — and it was scattering eleven loose squares across the middle of the
     page, three of them off the right edge, which is what the owner saw first.

     The same call `.cover-dots` and `.tick` already make above: an affordance
     whose referent is not on this screen is noise, not information. */
  .nav-sq { display: none; }
  /* ...but not the incoming layer, which is what gives the box its height in
     the first place only for the base one. `height: auto` on a replaced
     element beats the stretch that `inset: 0` would otherwise produce, and an
     `<img>` with no `src` yet is 0 tall — so the overlay measured 340 x 0 and
     the cross-fade had nothing to paint on. The photograph still changed,
     because the base takes it at the end of the swap; it simply cut instead of
     dissolving, on the one layout where nobody would have thought to look. */
  .cover-in { height: 100%; }

  /* The dot row is a pointer affordance and there is no pointer here. The
     cycle still runs; there is simply nothing to reveal, and a row that can
     never be hovered would be a permanently invisible control taking up the
     space under the photograph. */
  .cover-dots { display: none; }

  /* The runs are separate elements, so keep the parent's font-size real —
     the whitespace between them is what supplies the word space. */
  /* the masthead flows as two lines, as it does on the stage */
  /* ⚠ **A sheet wears the splash's masthead, not a shrunken version of its
     own.** The owner, 2026-08-26: *"the beautiful 3-line title and subtitle
     treatment for the landing page should persist exactly as it stands on the
     landing page to all the other pages. The version on the other pages needs
     work, if we tried to keep it, and it doesn't take that much less space
     than the one on the main site."*

     The desktop masthead is a 13px wordmark over a 6.3px line of two names and
     a place — furniture in the corner of a stage. Reflowed onto a phone it was
     neither one thing nor the other. So below the breakpoint it is rebuilt
     into the three lines the splash draws:

         moore archives                             the wordmark, big
         Prints, Slides and Negatives, 1900–1980    `.mast-sub`, mobile-only
         Detroit, Michigan                          `.mast-place`

     ⚠ **Two runs are dropped rather than restyled, and both would otherwise
     say something twice.** `.mast-dates` is `1900–1980`, which the subtitle
     line already ends with; `.mast-name` is the two names, which the splash
     does not put in its masthead either — it sets them under the photograph.
     They stay in the markup, so `leakcheck`'s reading of `MAST_NAMES_TEXT`
     and the screen-reader `aria-label` are untouched; only the drawing goes.

     The sizes are `.home-title`'s and `.home-sub`'s exactly, including the
     clamp that sizes the wordmark off the shorter axis as well as the wider
     one — so the two pages agree at every width, which is what was asked. */
  .masthead { order: 0; display: block; margin-bottom: 4px; }
  /* the spans are butted together because the stage positions each one, so
     reflowing them needs the word spaces putting back */
  .masthead .t { display: inline; font-size: 15px; }
  .masthead .t + .t { margin-left: .28em; }
  .masthead .mast-line { display: block; }
  .masthead .mast-line + .mast-line { margin-top: 2px; }

  .masthead { text-decoration: none; }
  /* ⚠ **0-2-0, to match the rule that hides it above the breakpoint.**
     Written as `.mast-subline` this was **dead** — `.masthead .mast-line`
     below already sets `display: block`, so the line showed whatever this said
     and deleting it changed nothing. It was caught by mutating it and watching
     the check stay green: a rule that cannot be broken is not the rule doing
     the work, and leaving it there would have been a comment claiming an
     effect it did not have. */
  .masthead .mast-subline { display: block; }
  .masthead .mast-dates, .masthead .mast-name { display: none; }

  /* line one — the wordmark, at the splash's own size */
  .masthead .mast-line:first-child .t {
    font-size: clamp(30px, min(9.5vw, 13vh), 65px);
  }
  /* line two — the subtitle, and line three — the place */
  .masthead .mast-sub { font-size: 15px; }
  .masthead .mast-place { font-size: 13px; }

  /* The same 8px the splash's three lines sit at, arrived at the same way:
     these are `<span>`s rather than `<p>`s so there is no default margin to
     zero, only the 2px above to replace. */
  .masthead .mast-line + .mast-line { margin-top: 6px; }

  /* ⚠ **`display: none` does not remove a run from `+`.** `.masthead .t + .t`
     puts back the word space the stage's coordinates used to supply, and the
     place run is still the adjacent sibling of the hidden names — so it drew
     one indent in from the two lines above it, which the splash does not do.
     Written as `.t.mast-place` to match that rule's specificity and win on
     order rather than by piling on selectors. */
  .masthead .t.mast-place { margin-left: 0; }

  /* ⚠ **Full strength, because down here it is the page's masthead and not a
     mark in the corner.** `.masthead` carries `opacity: var(--nav-off)` — the
     0.38 a nav label wears when it is not the one you want — which is right
     for 13px of furniture beside a contact sheet and wrong for the title of
     the page. The splash's own is at full strength and the owner asked for
     *exactly* the splash's treatment. Hover and focus already resolve to 1,
     so nothing is lost by starting there. */
  .masthead { opacity: 1; }

  /* Sized off the shorter axis as well as the wider one. On width alone a
     landscape phone — 852 by 393 — took the 65px ceiling on the shortest
     screen it will ever be shown on, and the splash ran 1.7 screens deep. */
  .home-title { order: 1; font-size: clamp(30px, min(9.5vw, 13vh), 65px); }
  .home-title .t { display: inline; font-size: inherit; }

  .home-sub  { order: 2; font-size: 15px; }

  /* ⚠ **The three header lines are one thing and were being spaced as three.**
     Reported by the owner from his handset, 2026-08-26: *"There's too much
     room between the three Header lines."* Measured at 390px, title to
     subtitle was **33px** and subtitle to place **46px** — for a title, its
     subtitle and its place, which read as a single masthead.

     The stage's `gap` is 18 and was not the whole of it. `.home-sub` and
     `.home-place` are `<p>`s carrying the browser's **default 1em margin** —
     15px and 13px at their own sizes — which above the breakpoint is inert,
     because up there each run is absolutely positioned and margins do nothing.
     Down here the stage is a flow column and the margin *adds to* the gap.
     The same shape as every other fault this layout has had: a property that
     was asleep while the element was positioned wakes up when it is not.

     Zeroed, then pulled to 8px with a negative top margin — the trick the
     nav's own hit bands use, and for the same reason: `gap` cannot be set per
     item. Everything else on the splash keeps the 18. */
  .home-sub, .home-place { margin: 0; }
  .home-sub, .home-place { margin-top: -10px; }
  /* **The place needs an order or it goes to the top.** Above the breakpoint
     its run is absolutely positioned and this wrapper collapses to nothing, so
     the `<p>` is invisible there and easy to forget; here the stage is a flow
     column ordered by hand, and an unstyled child takes `order: 0` and leads
     the page. It belongs between the subtitle and the cover, which is where it
     sits on the stage. */
  .home-place { order: 3; font-size: 13px; }
  .home-place .t { display: inline; font-size: inherit; }
  .home-cover { order: 4; width: 100%; max-width: 340px; height: auto; margin: 8px 0; }
  .home-who  { order: 5; font-size: 15px; }
  .home-who .t { display: block; font-size: inherit; }
  .home-who .t + .t { font-size: 13px; }
  .nav-home  { order: 6; }

  /* ⚠ **20% off the splash's clamp, at the owner's word, 2026-08-26** —
     *"we may need... to reduce the size of the section titles like early
     photos, by about 20%."* Every term is scaled, not just the ceiling: the
     floor and both viewport terms move together, so the reduction holds at
     every width rather than only on a screen wide enough to reach 65px.
     30 -> 24, 9.5vw -> 7.6vw, 13vh -> 10.4vh, 65 -> 52.

     It is deliberately **not** the same clamp as `.home-title` any more. The
     splash's wordmark is the site's name and the only large thing on the page;
     a sheet now carries that wordmark *as well*, above its own title, and two
     lines at the same size read as two mastheads rather than as a name and a
     section. */
  .gallery-title { order: 1; font-size: clamp(24px, min(7.6vw, 10.4vh), 52px); }

  /* ⚠ **...and down here the words are the page's heading, so they are
     spelled out.** The owner, 2026-08-26: *"let's change `wwii` (on mobile
     only) to `world war two`."* Four letters read as texture along the foot of
     a stage and as an abbreviation at the top of a phone, between `before the
     war` and `after the war` spelled out in full.

     ⚠ **0-3-0, through `.t`, and not 0-2-0.** Written as
     `.gallery-title .title-wide` this lost: `.gallery-title .t` below sets
     `display: inline` at the same specificity and later in the file, so both
     names drew, one after the other — *wwii world war two* across the top of
     the page. Matching on the class the losing rule uses puts this a step
     above it and takes source order out of the argument entirely. The third
     time in one day that an equal-specificity rule has quietly not applied. */
  .gallery-title .t.title-wide { display: none; }
  .gallery-title .t.title-long { display: inline; }
  .gallery-title .t { display: inline; font-size: inherit; opacity: 1; }
  .grid { order: 2; }
  .nav-gallery { order: 3; }

  .tick { display: none; }

  /* ⚠ **The blanket hide above is right for the foot nav and wrong for the
     sheet pager.** On the stage every nav label is divided from the next by a
     1px `.tick` stroke placed at its own `--x`. Down here the foot `.nav`
     becomes a flex row with a 14px gap, so the strokes have nothing left to
     divide and go — and the pager was carried along by a rule that was not
     thinking about it. `.sheets` stayed `display: block`, its items went
     `static` with the rest of `.nav-item`, and the only thing between two of
     them was the word space in the markup: **4.4px**, measured at 390px. Five
     labels read as one run. Reported by the owner, 2026-08-26 — *"the
     navigation menus for moving from sheet to sheet are very close
     together."*

     It gets the mark back rather than a wider gap alone, because the mark is
     what says these are separate places rather than one long phrase. It is
     the same `.tick` element the stage draws, laid out by flow instead of by
     coordinate, and it keeps `--rule` — so it holds its own strength while
     the labels fade to `--nav-off` around it, exactly as it does above.

     7px either side is the stage's own spacing, not a number chosen here: on
     `barbara` the tick sits at `--x: 112.9` between a label ending at 106.2
     and one beginning at 121.1. */
  .sheets {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 6px 7px;
  }
  /* 0-2-0 against `.tick`'s 0-1-0, so this is what wins. `flex: none` because
     a 1px box in a flex row is otherwise a candidate for shrinking to
     nothing, and 9px is the stroke's height on the stage (500.4 - 492.1,
     rounded up) rather than a guess at what looks right. */
  .sheets .sheet-tick {
    display: block;
    position: static;
    height: 9px;
    flex: none;
  }

  .nav {
    display: flex;
    flex-wrap: wrap;
    gap: 6px 14px;
    border-top: 1px solid var(--rule);
    padding-top: 12px;
    margin-top: 8px;
  }
  .nav-item { font-size: 12px; }
  /* ⚠ **The stage's hit bands came down here as padding, and they are not
     the same on every label.** On the stage a label's tap target is padded
     by `--hit` and `--hit-up`, and the two rows of the splash nav carry them
     the opposite way round — the upper row reaches up, the lower row reaches
     down. Laid out by flow those paddings made labels 29px and 35px tall in
     one row, and put the rows at different pitches: the owner, 2026-09-05,
     from his phone — *"spacing anomalies in the menu"*. One padding for
     every label here, and the rows fall into step. 0-2-0, over the stage
     rule's 0-2-0 by source order. */
  .nav .nav-item { padding: 5px 0; margin: 0; line-height: 1; }   /* margin too: the stage pulls each label up by its own --hit-up */

  .page-text, .bio-grid {
    position: static;
    width: auto;
    margin-bottom: 24px;
  }
  /* ⚠ **The decorative title leads here as it does on every sheet, and that
     makes the page's own heading a second copy of it.** The owner, 2026-08-26:
     *"on the other pages the large decorative header is at the top, but for
     this page it's currently on the bottom... 'About the project' can remain
     for desktop as it's functional there, given that the decorative element in
     that scenario is at the bottom of the page and not redundant."*

     Both text pages say it twice — `about the project` over *About the
     project*, `bios` over *Bios* — so this is not scoped to one of them. The
     order matches the sheets exactly: masthead 0, the title 1, the content 2,
     the nav 3, which is why the number is 2 and not something local to this
     page. Above the breakpoint nothing moves: the title keeps its place on the
     foot of the stage and the heading goes on doing its job.

     ⚠ **The order goes on `.page`, not on `.page-text`.** The text column is a
     child of `.page`, and `.page` is the flex item the *stage* orders — so an
     order set on the column moved it within a box that was already in the
     wrong place, and the title stayed at the foot while the check said the
     number had been applied. The stage's children are masthead, `.page`,
     the title and the nav. */
  .page { order: 2; }
  .page-text h2 { display: none; }
  .page-text { font-size: 15px; line-height: 1.5; }
  .page-text h2 { font-size: 20px; line-height: 1.3; }
  .page-text p { margin-bottom: 1em; }
  .page { display: flex; flex-direction: column; }
  .bio-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
    gap: 12px;
  }
  .bio-grid li { width: auto; height: auto; aspect-ratio: 1; }

  .grid, .grid--auto {
    position: static;
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
    gap: 10px;
  }
  /* the column count is fluid down here, so there is no known short row */
  .grid--auto > .row-centred { left: 0; }
  .grid--auto > .row-break { grid-column-start: auto; }
  /* Reflowed below 860px the tracks are fluid, so a thumb takes its width
     from the grid and its depth from its own footprint — a 2x1 is twice as
     wide as it is tall here exactly as it is above. The `span`s still apply;
     it is only the fixed pixel sizing that goes. */
  .grid--auto .thumb { width: auto; height: auto;
    aspect-ratio: var(--span-c, 1) / var(--span-r, 1); }
  .grid--auto .shot { height: auto; max-height: 100%; }
  .grid--auto .slot { width: auto; height: auto; aspect-ratio: var(--slot-ar, 1); }
  .grid:not(.grid--auto) .slot {
    position: static; width: auto; height: auto;
    aspect-ratio: var(--slot-ar, 1.42);
  }
  /* The reel is an idea about a fixed 800px stage: a row wider than the page,
     moved by a pointer across it. Down here there is neither — the stage
     reflows to the width of the phone and there may be no pointer at all — so
     the window stops being a window and its row becomes the same reflowed
     grid every other sheet is.

     Every one of these needs the attribute selector to bite: the rules it is
     undoing are written `.reel[data-reel="sweep"]`, and a bare `.reel` here
     loses to them on specificity. That is exactly what shipped in the first
     draft — the row kept its absolute position while the rest of the page
     reflowed, and twenty-one photographs came down on top of the foot nav. */
  .reel, .reel[data-reel] {
    position: static; width: auto; height: auto; overflow: visible;
    /* ...and it inherits the sheet's place in the column, because down here
       the flex child of `.stage` is the window and no longer the grid inside
       it. Without this the reel takes the default `order: 0`, ties with the
       masthead and wins on source order — the photographs above the name of
       the archive. */
    order: 2;
  }
  .reel .grid, .reel[data-reel] .grid {
    position: static; width: auto; height: auto; transform: none;
  }

  .grid[data-state="out"] { opacity: 1; pointer-events: auto; }
  .grid[data-hover] .thumb:not([data-hovered]) { opacity: 1; }
  .thumb img { width: 100%; height: auto; }
  .shot { height: auto; }

  /* ⚠ **`<picture>` must not become a box.** The phone is served a different
     file through a `<source>`, which means every thumbnail's `<img>` now has a
     wrapper around it — and every rule on this page that sizes a thumbnail is
     written for `.shot > img`'s *parent* being `.shot`. `display: contents`
     takes the wrapper out of the layout and leaves the image exactly where it
     was, so nothing above had to be re-aimed. */
  .shot picture { display: contents; }

  /* ------------------------------------------ the two phone treatments
     Measured at 390px: twenty photographs in **seventeen widths** on
     `beforethewar`, against one width and one height on Kodachromes — which
     is the only page the owner said looked right, and he named the reason:
     *"the images are uniform and there's no transparency around the image."*

     ⚠ **It is two treatments and not one.** The sheets are not in the same
     condition, so one rule cannot fix both — and reaching for the crop
     everywhere would re-cut the two sections that were already even.
     `gen_site.phone_mode` measures each sheet's own photographs and writes
     `data-phone` on the grid; nothing here decides anything.

     Both of these sit below `.thumb img` and `.shot` above, which is load
     bearing: those two are the general case in this same block and would
     otherwise take the height back off the cropped variant at equal
     specificity. Source order is the whole of what separates them. */

  /* ⚠ **The nudge has to go, and it is the reason `barbara` looked thrown
     about on a phone long before any of this.** `.grid--auto .thumb` places a
     cell off the centre of its slot with *margins in desktop cell pixels* —
     `--dx: -0.185` of a cell 374 wide is 69px — and the mobile block overrode
     that rule's width and height and left its margin alone. The nudges
     therefore survived into a layout whose cells are 170px, where they are not
     a nudge but a shove: eleven distinct widths dragged across each other.

     It read as *artful scatter* while the photographs were small and different
     sizes, which is why it went unreported; making them one size is what
     turned it into overlap and made it finally visible. **The fix is not for
     the damage I did — it is for the fault that was already there.** */
  .grid[data-phone] .thumb { margin: 0; }
  /* ...and the same fault wearing a different property. `.band-centre` shifts
     a cell sideways by `--band-nudge` **pixels**, chosen against a stage 800
     wide, which on `fotron-3` staggered every row into the one beside it.
     Anything that positions a cell in the desktop's own units has to be given
     back here; these two are all of them on a treated sheet, and the check
     that says so is `no two thumbnails overlap` rather than a reading of this
     file. */
  .grid[data-phone] .band-centre,
  .grid[data-phone] > .band-centre { left: 0; }
  /* ⚠ **And the rows themselves, which is the one that hid.** `.grid--auto`
     sets `grid-auto-rows: calc(var(--cell) * 1px)` — 103px on `barbara-3`,
     from a stage 800 wide. The cells reflow to 170 and the *tracks* stayed at
     103, so every row was laid 57px into the one below it.

     ⚠ **It read as a tight, even, gapless sheet**, which is very close to what
     was being asked for, and I passed over it twice looking at renders. What
     found it was asserting that no two thumbnails may overlap; one width and
     one height had been true throughout. A check on size cannot see a fault in
     position — see `HARDENING.md`. */
  .grid[data-phone] { grid-auto-rows: min-content; }

  /* SIZING — a sheet whose photographs already agree with each other.
     Fill the track and keep the whole frame: there is nothing to crop
     toward, and cropping what is even loses image for nothing.

     ⚠ **`> li` carries the width, and that is where the first attempt went
     wrong.** `.grid--auto.grid--uniform` sets `justify-items: center`, which
     makes every item shrink to fit — so `width: 100%` on the thumbnail
     resolved against a list item that had already collapsed to the file's own
     150px, and landscapes went to 150 inside a track of 170 while reporting
     success. The item has to be told to take the track before anything inside
     it can fill it. Measured, not reasoned: 78 -> 150 -> 170. */
  .grid[data-phone="fill"] > li { width: 100%; }
  .grid[data-phone="fill"] .thumb,
  .grid[data-phone="fill"] .slot { width: 100%; height: auto; }
  /* ⚠ **And the cell stops standing a square deep.** `.grid--auto .thumb`
     above gives every cell `--span-c / --span-r`, which defaults to 1 / 1 —
     so a landscape photograph 170 wide and 119 tall sat in a box 170 deep and
     left 51px of bare paper under every frame. That is the owner's own
     complaint about the negatives, and it was about to survive the fix that
     was supposed to answer it. Measured, not read off the rule: 180px of row
     pitch for 119px of photograph.

     Safe because no `fill` sheet spans cells — checked across all six — and a
     sheet that agreed with itself is precisely one nobody had to give a
     bigger cell to. */
  .grid[data-phone="fill"] .thumb { aspect-ratio: auto; }
  /* ⚠ **One shape for the sheet, not each frame's own — *nearly* even reads
     as uneven.** Letting every frame keep its aspect was the first version of
     this rule and the owner caught it at once: the negatives run 0.656 to
     0.710, so two of them side by side at 170px stand up to eight pixels
     apart at the foot, and the eye reads the mismatch long before it can name
     it. Kodachromes is one aspect exactly, which is the whole of why he says
     it looks right.

     `--phone-ar` is the sheet's own median, from `gen_site.phone_ar`, and
     `cover` fills it. The cost was measured before it was chosen: **4.7% off
     one axis on the worst frame of the worst sheet, under 1% on the average
     one.** `--crop-at` steers which sliver goes where a frame has a pin; the
     zoom is deliberately *not* applied here, because these sheets are meant
     to stay full-frame and a zoom would take a second bite. */
  .grid[data-phone="fill"] .shot {
    width: 100%; height: auto;
    aspect-ratio: var(--phone-ar, var(--ar));
  }
  .grid[data-phone="fill"] .shot img {
    width: 100%; height: 100%;
    object-fit: cover;
    object-position: var(--crop-at, 50% 50%);
  }

  /* SHAPING — a sheet whose photographs disagree. One square cell, and the
     frame's own pin crop decides what survives it.

     `--crop-at` and `--crop-z` are the hand-triaged pin, restated for CSS by
     `gen_site.crop_vars`. A frame that never got one falls back to the middle
     of the picture, which is where a photographer usually put the subject —
     it is a weaker answer than a chosen point, not a wrong one.

     `object-fit: cover` already fills the square; the scale on top of it is
     the pin's own zoom, and it means the same thing it means behind the
     archive — fill by this much more. `.shot` clips what that pushes out. */
  .grid[data-phone="crop"] > li { display: block; width: 100%; }
  .grid[data-phone="crop"] .thumb {
    position: static;
    width: 100%; height: auto;
    aspect-ratio: 1;
    overflow: hidden;
  }
  .grid[data-phone="crop"] .shot {
    display: block;
    width: 100%; height: 100%;
    aspect-ratio: auto;
    overflow: hidden;
  }
  .grid[data-phone="crop"] .shot img {
    width: 100%; height: 100%;
    object-fit: cover;
    object-position: var(--crop-at, 50% 50%);
    transform: scale(var(--crop-z, 1));
    transform-origin: var(--crop-at, 50% 50%);
  }
  /* a photograph that has not arrived takes the same square as one that has */
  .grid[data-phone="crop"] .slot {
    width: 100%; height: auto; aspect-ratio: 1;
  }

  /* ⚠ **A cell that took two slots takes one here.** `--span-c` / `--span-r`
     let one photograph stand twice the size of its neighbours on a sheet 800
     wide, which is a composition. At two columns it is simply the one frame
     that does not match, and on `barbara` it came out 350 square against
     twenty-five at 170 — the sheet disagreeing with itself, which is the whole
     of what this treatment exists to end. */
  .grid[data-phone="crop"] > li { grid-column: auto; grid-row: auto; }

  /* ------------------------------------------- a banded sheet, in order
     ⚠ **A band is a column group, and there are no column groups left here.**
     `fotron-3` is read as three columns of Fotron prints and three of
     photographs that are *not* Fotron prints at all, with a gutter between —
     a distinction the archive makes deliberately. Reflowed, the cell run is
     row-major across both bands, so the two groups came down the page three
     and three alternately under a pair of headings that had both floated to
     the top, and the gutter held open a third column 50px wide with five
     photographs squeezed into it.

     `--ord` is `gen_site.band_of` — each band's own cells, in the order they
     were laid out, after that band's heading. The grouping is restated, not
     invented: it is the one the desktop sheet already asserts.

     ⚠ **`[data-phone]` on these three is specificity, not decoration.** The
     square rule above is `.grid[data-phone="crop"] > li` — two classes and a
     type, 0-2-1 — and `.grid--banded > .band-head` is 0-2-0, so the heading
     took `grid-column: auto` from a rule meant for photographs and the gutter
     took `display: block` from the same one. Both rules matched, both were in
     the sheet, and the computed values were the other rule's: exactly the
     shape of fault this file has paid for before, where a heading loses a
     property to a rule that was never thinking about headings. */
  .grid--banded[data-phone] > li { order: var(--ord, 0); }
  .grid--banded[data-phone] > .band-head {
    grid-column: 1 / -1;
    /* `--head-pull` drops the heading toward photographs that came up to meet
       it — an arrangement in a grid whose rows this layout no longer has */
    top: 0;
  }
  /* the gutter has nothing left to hold open once the tracks are fluid */
  .grid--banded[data-phone] > .band-gap { display: none; }

  /* ------------------------------------------ a section's landing page
     ⚠ **These three pages were never given a phone layout at all** —
     `barbara-album`, `fotron-album`, `negatives-album`. Not one `.album-`
     rule appeared below the breakpoint, so the photograph, its writing and
     its two controls stayed pinned at their coordinates on the 800 x 550
     stage while everything around them reflowed: the owner found the cover
     photograph lying across the title and the nav, and called it *"a jumble
     of elements"*.

     They join the column the stage already is, in the order they are read
     in — the page's name, the object, what the family wrote about it, and
     the way through to the photographs. `.stage` is a flex column here and
     an unordered child takes `order: 0` and leads the page, which is why
     each of these is given one rather than left to source order. */
  .album-shot, .album-text, .album-go, .album-turn {
    position: static;
    left: auto; right: auto; top: auto;
    width: auto; height: auto;
  }
  /* **All four take `order: 2`, and that is deliberate rather than lazy.**
     Equal `order` falls back to source order, which here is already the
     reading order — the object, the control that turns it, what the family
     wrote about it, then the way through to the photographs. Giving them
     2/3/4/5 instead would have put them either side of `.nav-gallery`, which
     is `order: 3` for every other page on the site, and the navigation landed
     between the photograph and its own caption. One number for the group
     keeps them together and leaves the nav where it already was: last. */
  .album-shot {
    order: 2;
    width: 100%; max-width: 460px; height: auto;
    margin: 4px 0 2px;
  }
  .album-turn { order: 2; align-self: flex-start; margin: 0; padding: 8px 0; }
  .album-text {
    order: 2;
    /* ⚠ **The desktop column has a *fixed depth* and pages anything that
       will not fit** — see `--floor` above, which exists so a long
       description cannot push the footnote under the navigation. There is no
       floor on a phone and nothing below to push into, so the box takes its
       own height and the pager has nothing left to do. */
    min-height: 0; max-height: none;
  }
  /* ⚠ **Static, not just `height: auto`.** `.album-body` and `.album-foot`
     are `position: absolute` against the column, so leaving them positioned
     would have given `.album-text` no height at all — the same fault as the
     sliding enlargement, on the same afternoon. */
  .album-text .album-body, .album-text .album-foot {
    position: static;
    height: auto; max-height: none; overflow: visible;
  }
  .album-text .album-foot { margin-top: 18px; }
  .album-text p.album-pager { display: none; }
  .album-go { order: 2; align-self: flex-start; margin-top: 4px; }

  /* ------------------------------------------------- the nav, once only
     Two navs is a decision that reads on an 800 x 550 stage, where the
     sections run along the top and the collections along the foot and neither
     is ever more than a glance away. Reflowed into a column they become the
     same list twice, once as ten colour chips with no labels and once as
     words — and the chips land in the middle of the page on the way past.
     The foot nav stays because it is the one with names on it. */
  .topnav { display: none; }

  /* --------------------------------------- no pinning a photograph here
     The pin exists to see a photograph *larger than the enlargement draws
     it* — the viewer's box is 787px at most where a pinned photograph on a
     wide window is drawn about 1400. A phone has no wide window, so the
     control offers a room with nothing in it. The crops it was triaged for
     are still doing work up above; it is only the gesture that goes. */
  .pin-btn, .pin-layer { display: none; }

  /* the enlargement becomes a full-screen sheet */
  .viewer {
    position: fixed;
    inset: 0;
    background: var(--paper) url("../img/paper.jpg") center / 800px auto repeat;
    overflow-y: auto;
    padding: 20px;
    display: none;
  }
  .viewer[data-open] { display: block; }
  /* ⚠ **The enlargement has to be told it is on top.** It is `position: fixed`
     and was `z-index: auto`, so the sheet behind it went on painting through
     it: the owner saw the masthead — *"the text from the top left corner of
     the desktop site"* — lying across the top of the photograph he had just
     opened. A full-screen sheet that does not out-rank the page it covers is
     not a sheet, it is a transparency. */
  .viewer { z-index: 100; }
  .viewer-photo { width: 100%; margin-bottom: 16px; perspective: 1200px; }

  /* ⚠ **A card that slides had no height at all — the 50th anniversary.**
     `.flip` carries an `aspect-ratio` and that is what gives an enlargement
     its height here, where no rule sets one. A long card does not use `.flip`:
     `data-mode="strip"` hides it and shows `.viewer-strip`, which is
     `position: absolute; inset: 0` and so gives its parent nothing. The box
     measured **350 x 0**, the photographs were loaded and drawn at 0 x 0, and
     the owner reported the twelve frames of Florence and Frank's evening as
     *"no images showing there — only the blank kraft paper bkgd"*.

     `--w` / `--h` are already on the element: the window's own proportions on
     the 800 x 550 stage, which is the shape this is a window on to. So the
     box takes them, and the strip inside it has something to be absolute
     against. */
  .viewer[data-mode="strip"] .viewer-photo { aspect-ratio: var(--w) / var(--h); }
  .flip { position: relative; }
  .face { position: absolute; }
  .flip { width: 100%; height: auto; max-height: none; }
  .face { height: 100%; }
  /* ⚠ **The stepper takes a row of its own, and `wrap` is what gives it
     one.** Making `.viewer-nav` static put it into this flex row beside BACK
     TO THE CONTACT SHEET, which at 390px wrapped that label onto two lines and
     crowded five things onto one — worse than the marooning it fixed, and
     found by looking rather than by measuring, since every number was fine.
     They are two kinds of act anyway: BACK leaves the photograph, the stepper
     moves within it, and a row that mixes them reads as one menu of five. */
  .viewer-controls { display: flex; gap: 20px; margin-top: 20px;
                     flex-wrap: wrap; }
  .viewer-nav, .viewer-turn { width: 100%; justify-content: center; }
  /* ⚠⚠ **`.viewer-nav` belongs in this list and was missing from it.** The
     hint and the turn were given a phone layout when this block was written;
     the navigator — the control a *stack* is stepped with — was not, so it
     kept `.viewer--plain`'s `top: 478px`. That is a coordinate on the 800 x
     550 stage, and on a reflowed phone page the photograph ends around 323,
     so the row sat marooned 155px below it in open paper with no relation to
     anything. The owner, from his own handset: *"we should move the whole
     cluster up though so it doesn't look like a mistake so far down from the
     photo."* It was not placed there — it was never placed at all. */
  .viewer-hint, .viewer-turn, .viewer-nav {
    position: static; text-align: left;
  }
  .viewer-caption { opacity: 1; transition: none; font-size: 14px; line-height: 1.45; }
  .viewer-caption h2 { font-size: 14px; line-height: 1.45; margin-bottom: 8px; }
  .viewer-caption p { margin-bottom: 1em; }
  .viewer-hint { margin-top: 20px; opacity: .6; }
}

/* ------------------------------------------------- a phone held sideways
   Short and wide: 852 by 393 on the phone this was found on. The flow layout
   above is built for a narrow column, so laid out sideways it stacked a 65px
   title, a 340px photograph and the nav into 1.7 screens of scrolling while
   using half the width.

   The splash becomes two columns — the writing beside the photograph, which
   is roughly where the fixed stage puts them anyway — and the whole thing
   fits one screen. Keyed on height rather than orientation: a short window on
   a laptop has the same problem, and `orientation` would not catch it. */
@media (max-width: 859px) and (max-height: 560px) {
  .stage { padding-top: 20px; padding-bottom: 20px; gap: 10px; }

  .home-title { font-size: clamp(26px, 11vh, 44px); }
  .home-sub   { font-size: 13px; }

  /* the two columns: everything textual on the left, the cover on the right */
  .stage:has(.home-cover) {
    display: grid;
    grid-template-columns: 1fr minmax(200px, 38%);
    /* every text row sized to its content, and one flexible row before the
       nav to take up the slack. Putting the slack anywhere earlier drove the
       two names apart, which is what a `1fr` in the middle of the stack does. */
    grid-template-rows: auto auto auto auto auto 1fr auto;
    column-gap: 26px;
    row-gap: 0;
    align-content: start;
  }
  .stage:has(.home-cover) > .home-title { grid-column: 1; margin-bottom: 6px; }
  .stage:has(.home-cover) > .home-sub   { grid-column: 1; margin-bottom: 6px; }
  .stage:has(.home-cover) > .home-place { grid-column: 1; margin-bottom: 14px; }
  .stage:has(.home-cover) > .home-who   { grid-column: 1; margin-bottom: 8px; }
  .stage:has(.home-cover) > .home-cover {
    grid-column: 2;
    grid-row: 1 / span 6;
    align-self: center;
    justify-self: end;
    max-width: 100%;
    max-height: 78svh;
    width: auto;
    object-fit: contain;
    margin: 0;
  }
  .stage:has(.home-cover) > .nav-home { grid-column: 1 / -1; align-self: end; }

  .home-who { font-size: 13px; }
  .home-who .t + .t { font-size: 11px; }

  /* the sheets want more columns across a wide short screen, not bigger ones */
  .grid, .grid--auto { grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); }
  .nav { padding-top: 8px; margin-top: 4px; }
}

/* --------------------------------------------------------- a pinned photograph
   A reader can pin one photograph behind the whole archive and leave it there
   while they walk around it — and take it down again from any page. Two things
   at once: something of the site a reader makes their own, and a way of seeing
   one photograph much larger than the enlargement draws it.

   Three states, on <html data-pin>:

     (absent)  nothing pinned
     front     just pinned, over everything, to be looked at
     back      behind the archive, where it stays until unpinned

   `stage.js` owns the layer and the two controls, because a pin has to survive
   on to the splash and the text pages, which never load `gallery.js`; the
   button that *offers* it is in `gallery.js`, which is the only script that
   knows which enlargement is open. Nothing here exists without scripting, and
   nothing is lost without it.

   **Drawn in a layer, not as the stage's background.** A background cannot be
   scaled past `cover`, and past cover is exactly where this has to go: these
   are scans of prints, and a print's own cream margin sat on the page as a
   border round the photograph. The picture is inset by half the overscan, so
   `--pin-zoom: 1.3` grows it 30% and loses 15% off each side. Measured on wwii
   frame 8; at 16% the margin was still a pale band down both edges. What it
   costs is a quarter of the picture, which is the trade — no border, or the
   whole photograph. */
/* **The ground behind a pinned photograph is the paper, not a dark field.**
   Forty-one published frames are cutouts — prints scanned against nothing, so
   the deckled edge is the object's own shape — and against near-black their
   transparent surround read as a heavy border somebody had failed to crop.
   On kraft it reads as what it is: the print lying on the page. It is also
   what every other transparent frame on this site already sits on. */
.pin-layer {
  position: absolute; inset: 0; overflow: hidden; z-index: 0;
  display: none;
  background: var(--paper) url("../img/paper.jpg") 0 0 / 100% 100% no-repeat;
}
html[data-pin] .pin-layer { display: block; }
/* ⚠ **A zoomed pin can only be moved along one axis, and that is open.**
   `cover` fits one axis of this element exactly, so `--pin-focus` has no
   overflow to move the picture through along that axis — and zooming does not
   help, because the element and the picture grow together. Which axis is
   locked follows from the frame's shape against 800/550: wider than 1.455 and
   it cannot be moved up or down, squarer or taller and it cannot be moved left
   or right. Reported 2026-08-22 while triaging barbara.

   **Not fixed, and the reason is arithmetic rather than difficulty**: 240 of
   `PIN`'s 358 entries carry both a zoom and a focal point, and the clean fix
   changes what a focus percentage means wherever the zoom is above 1. See
   *a zoomed pin can only be moved along one axis* in `INTERACTIONS.md` for the
   backward-compatible route and what has to be verified before taking it.
   ⚠ Whatever is done here must be done to `concepts/pin_triage.py` in the same
   breath: it previews this rule and shares this geometry on purpose. */
.pin-img {
  position: absolute;
  inset: calc((1 - var(--pin-zoom, 1)) * 50%);
  background-image: var(--pin);
  background-size: cover;
  background-position: var(--pin-focus, 50% 50%);
  background-repeat: no-repeat;
}
/* **Half this archive is portrait**, and filling a landscape canvas with a
   tall frame shows about a third of it — centred, so a standing figure comes
   out as a torso. Those are shown whole instead, at the canvas's full height
   with paper either side: nothing is cut, and it is still about 1.5x the size
   the enlargement draws it. Which photographs fill and which are shown whole
   was decided by looking at all 219; see `PIN` in tools/sections.py. */
/* **`.pin-still`, not `.pin-img`.** The pin and the slideshow now share one
   layer, so this rule's `.pin-img` would reach the slideshow's pair as well —
   and `data-pin-fit` is the *pinned* photograph's decision, set on the stage by
   `paint()` and never by the slideshow, which carries its own `data-fit` per
   image. Left unscoped, pinning a portrait would letterbox every photograph the
   slideshow drew after it. */
.stage[data-pin-fit="contain"] .pin-still {
  inset: 0; background-size: contain;
}
/* over everything, at the moment it is pinned. The stage's furniture is all
   positioned and takes its paint order from the DOM, so lifting this one layer
   above it is the whole of it. */
html[data-pin="front"] .pin-layer { z-index: 40; }

/* Type over a photograph. The site sets 12px ink on pale kraft, and over an
   arbitrary picture a nav label is simply gone — so once the photograph is
   behind, a wash of the paper colour goes over it and under the type. It costs
   contrast in the photograph, which is the thing you pinned it to look at, and
   that is the cheaper of the two losses: a page you cannot navigate is worse
   than a photograph you cannot quite read. */
html[data-pin="back"] .pin-layer::after {
  content: ''; position: absolute; inset: 0; pointer-events: none;
  background: rgba(198, 178, 138, .62);
}

/* **The splash gives up its own photograph to a pinned one.** The landing
   page carries a single small cover photograph in the middle of the stage, and
   with something pinned behind it there are two photographs on a page that
   only ever had one — the reader's choice competing with ours, and the cover
   sitting square in the middle of it. It comes back the moment they unpin.

   `visibility`, not `display`: the cover is absolutely positioned here so it
   costs nothing either way, but below the breakpoint it is a flex item in a
   flowed column, and removing it there would close the gap and shift the page.
   Hidden it holds its place. */
html[data-pin] .home-cover { visibility: hidden; }

.pin-btn, .unpin, .pin-send {
  position: absolute;
  font-family: "Akzidenz BE Cn", "Helvetica Neue", Arial, sans-serif;
  font-size: 10px; line-height: 1; letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--section-ink, var(--ink)); background: none;
  border: 0; cursor: pointer;
}
/* On the enlargement's own row, anchored to the right margin — the left of
   that row is `.viewer-hint` at x 55, and the two sets of letters drew
   straight through each other when this sat there. */
/* **It shares the row's leading as well as its `top`.** The shared rule above
   sets `line-height: 1`, and the row opposite — the hint and the turn control —
   is `10px/1.4`. Two labels told to start at the same height do not put their
   ink at the same height if one carries half a line of air above it and the
   other carries none: this sat **2px high on every page in the archive**, too
   little to see on its own and the reason the three had never quite shared a
   line. Matching the leading is what aligns the letters; matching `top` only
   ever aligned the boxes. */
.pin-btn {
  right: 60px; top: 464px; display: none; text-align: right;
  line-height: 1.4;
  /* **The box stays exactly the size it was, and the four pixels the leading
     added come back out of the bottom** — because the bottom is where the foot
     nav is. At `8px 10px` the taller line ran the target to 500 against the
     first nav label's own hit box at 498: two pixels of overlap, which is the
     fault `.unpin` was moved to 470 to escape and is not one to reintroduce
     for the sake of a tidier declaration. `8px 10px 4px` puts the box back at
     470-496, the 26px it has always been, with the ink now on the row's
     baseline. Measured against the nav rather than reasoned about. */
  padding: 8px 10px 4px; margin: -8px -10px -4px;
}
/* **And it follows the row down when the row moves.** A viewer with no
   caption column takes `.viewer--plain`, which drops the hint and the turn
   control from 464 to 478 so they sit under the picture rather than under a
   column that is not there. That instruction was written for those two and
   never for this one, so on every uncaptioned frame the pin was stranded
   **16px above the row it belongs to** — the 2px above, plus the 14 the others
   had moved. Spotted by the owner on 1950-1980, which is a sheet where almost
   nothing is captioned; on a captioned page the fault is invisible because the
   row never moves. */
.viewer--plain ~ .pin-btn { top: 478px; }
.viewer[data-open] ~ .pin-btn[data-ok] { display: block; }
html[data-pin] .pin-btn { display: none; }

/* These two sit *on* the photograph, so they carry their own paper — nothing
   else on this site has to stay legible over an unknown picture. */
.unpin, .pin-send {
  display: none; z-index: 41;
  background: rgba(232, 222, 197, .92); color: #3a322a;
  padding: 7px 11px;
}
/* **Clear of the foot nav, which it used to sit on.** At 489 this box ran
   489-513 and the last nav label runs 510-534 at x 675-713 — three pixels of
   overlap on one axis and eighteen on the other, over a label whose own hit
   band reaches further still. At 470 it ends at 494 and clears the nav by 16.
   One position for both states, so the control does not move when the
   photograph is sent behind. */
html[data-pin] .unpin { display: block; right: 60px; top: 470px; }
html[data-pin="front"] .pin-send {
  display: block; left: 50%; top: 470px; transform: translateX(-50%);
}
/* ⚠ **And `hidden` has to be able to win — 2026-08-24.** `stage.js` withholds
   this control on a document, and the UA's `[hidden] { display: none }` is
   0-1-0 against the 0-2-0 rule above it: setting the property would have done
   nothing at all, silently, with the element still painted across the typing
   it was withheld to clear. This is the fault `HARDENING.md` opens with, in
   the other direction — there a control was `hidden === false` and invisible;
   here it would have been `hidden === true` and visible. The rule that hides
   a thing must outrank the rule that shows it. */
html[data-pin="front"] .pin-send[hidden] { display: none; }
.pin-btn:focus-visible, .unpin:focus-visible, .pin-send:focus-visible {
  outline: 1px solid var(--ink); outline-offset: 3px;
}

/* ----------------------------------------------------- the slideshow mark
   **This block was settled in `concepts/carousel.py` and never reached this
   file**, and the whole control shipped without it — so `.carousel-btn` fell
   back to the UA stylesheet: `position: static`, so it sat at the hover
   region's own origin (21px up and left of where it belongs, which on the
   splash is outside the paper); `appearance: auto`, so it drew as a grey
   system button with a 2px outset border; and the mark inside it 0 x 0,
   because an inline SVG with a viewBox and no dimensions has no size until
   something gives it one. The icon was not there at all. Reported as "our
   slideshow icon isn't there and would be off canvas if it was".

   Everything downstream of it — the label positioning off `--pad`, the two
   `opacity: 1` state rules, the `z-index: 41` while held — was here and
   correct, hanging off rules that did not exist. **A comp is not the site**:
   `carousel.py` copies a built page and injects its own stylesheet, so every
   screenshot of this control through the whole of its design was of CSS that
   was never shipped.

   The wrapper has to *be* the stage. Absolutely positioned with auto offsets
   it falls at its static position instead — on a section sheet that is the
   stage's own top left by luck, because every child there is positioned and
   nothing takes up flow, and on the splash it is 20px off it. Then
   `pointer-events` has to come back on for the button alone, or a transparent
   box over the whole stage swallows the sheet. */
.carousel {
  position: absolute; inset: 0; z-index: 30; pointer-events: none;
  color: var(--section-ink, var(--ink));
}
/* **`content-box`, and it has to be said.** Line 99 of this file is
   `* { box-sizing: border-box }`, so a button given an explicit width *and*
   the grow-the-target padding gets the padding taken out of the drawing: the
   18px mark came out 4px, a dot. Neither `.pin-btn` nor the navigator's
   arrows hit this, because neither sets a width; this is the first control
   here that sizes its own glyph. */
.carousel-btn {
  position: absolute; display: block; box-sizing: content-box;
  left: calc(var(--x) * 1px); top: calc(var(--y) * 1px);
  width: calc(var(--s) * 1px); height: calc(var(--s) * 1px);
  padding: calc(var(--pad) * 1px); margin: calc(var(--pad) * -1px);
  color: inherit; background: none; border: 0; cursor: pointer;
  pointer-events: auto;
  opacity: .82;
  transition: opacity var(--t-veil, 750ms) linear;
}
.carousel-btn:hover { opacity: 1; }
html[data-carousel-on] .carousel-btn { opacity: 1; }
.carousel-btn .mark { display: block; width: 100%; height: 100%; }
.carousel-btn:focus-visible { outline: 1px solid var(--ink); outline-offset: 2px; }

/* ------------------------------------------------------- the speed ticks
   Three small marks in a row, one lit, in the section's ink — the section
   squares' own vocabulary, at a third of their size. Only while it runs.

   They are 6px of drawing inside a 16px target, and the row is laid out on a
   16px pitch so **the targets sit edge to edge and never overlap**. That is
   the fifth time a control here has needed its hit area drawn separately from
   its ink, and the first time three of them have had to share a line. */
/* **One generous region holds the mark and the ticks together**, and it is the
   whole fix for the fault this control shipped with. Gated on the mark alone,
   the ticks appeared the moment the pointer rested on it — and then reaching
   for them *destroyed* them: moving off the mark fires its `pointerleave`,
   which ends the preview, which fades the photograph out over `--t-lead` and
   takes the ticks with it. Reported as "they fade out on me before I do
   anything", which is exactly what it looks like.

   Same family as the navigator's arrows sliding out from under the cursor and
   the reel's frames walking away from it: a control that moves or vanishes in
   answer to being approached. The answer here is that the mark and the ticks
   are **one control**, so the hover region is one box around both of them with
   room to spare, and crossing the gap between them never leaves it.

   **It takes the pointer, so it must not swallow anything.** A transparent box
   over paper the reader has to click through is the fault at the other end —
   see the note on the reel's window. This one covers about 100 x 44 of a
   corner that holds nothing else, and a click on its bare paper is forwarded
   to the same handler the photograph uses, so nothing is lost inside it. */
/* **The mark and the ticks are *inside* it, and that is the whole point.**
   The first attempt made the region a sibling of both — so moving the pointer
   onto a tick counted as *leaving* the region, `pointerleave` fired, and the
   preview ended exactly as before. `pointerleave` does not fire for
   descendants; it fires for everything else. A group that does not contain
   what it is grouping is not a group.

   So the region is the positioned parent and the mark and the ticks are laid
   out inside it. It is always in the document, because the mark has to be
   visible when nothing is running — but it only **takes the pointer** while
   the slideshow is committed, so neither an idle page nor a preview has a
   transparent box over its corner. */
.carousel-hover {
  position: absolute;
  left: calc(var(--x) * 1px); top: calc(var(--y) * 1px);
  width: calc(var(--w) * 1px); height: calc(var(--h) * 1px);
  pointer-events: none;
}
/* **...but only once there is something in it to reach for.** The region is
   195 x 60 around an 18px mark, and the ticks and the PIN control that justify
   that size are both gated on `data-carousel-on` — which arrives on the press,
   not on the hover. So a *preview* was holding a box of empty paper 149px wide
   to the right of the mark, protecting a journey to two controls that are not
   on the page; the reader moved off the mark and the photograph stayed up,
   with nothing visible to say why. Reported 2026-08-15 alongside the veil.

   `data-carousel-on` rather than `data-carousel`, so the generous box comes on
   at exactly the moment its contents do. While a preview runs the mark's own
   32px target is the whole control, which is what it looks like. */
html[data-carousel-on] .carousel-hover { pointer-events: auto; }
.carousel-speed {
  position: absolute; display: none;
  left: calc(var(--x) * 1px); top: calc(var(--y) * 1px);
  transform: translateY(-50%);
  white-space: nowrap;
}
/* **Committed only — a preview does not get a speed control.** Two reasons,
   and the second is the one that matters. Nobody adjusts the pacing of a
   glance. And **committing had no visible signal at all**: the photograph is
   already up from the hover, so pressing the mark only made it *stay*, which
   is invisible — the press read as dead. The ticks arriving *are* the
   feedback, and the mark coming to full strength beside them is the second
   half of it. */
html[data-carousel-on] .carousel-speed { display: block; }

/* The forward control. Same line as the ticks, same ink, same "only once it is
   committed" rule — and a real target rather than 9px of type: `padding` grows
   it and the negative `margin` takes the growth back out, which is the trick
   every control here has ended up needing. */
.carousel-pin {
  position: absolute; display: none; box-sizing: content-box;
  left: calc(var(--x) * 1px); top: calc(var(--y) * 1px);
  transform: translateY(-50%);
  padding: 6px 8px; margin: -6px -8px;
  border: 0; background: none; color: inherit;
  font-size: 9px; line-height: 1; letter-spacing: .06em;
  white-space: nowrap; cursor: pointer; pointer-events: auto;
  opacity: .62; transition: opacity 160ms linear;
}
html[data-carousel-on] .carousel-pin { display: block; }
.carousel-pin:hover { opacity: 1; }
.carousel-pin:focus-visible { outline: 1px solid var(--ink); outline-offset: 2px; }
/* held is a full-screen state, and full screen has already been pinned */
html[data-carousel-held] .carousel-pin { display: none; }
/* `color: inherit` — a `<button>` gets `color: buttontext` from the UA
   stylesheet, which *overrides inheritance*, so `currentColor` in the tick's
   own `::before` came out the browser's grey rather than the section's ink.
   `.carousel-btn` says `inherit` for the same reason; this one was missed and
   is only visible on a page whose section colour is strong. */
.speed-tick {
  position: relative;                 /* the label hangs off it */
  display: inline-block; box-sizing: content-box; vertical-align: middle;
  width: 6px; height: 6px; padding: 5px; margin: 0;
  border: 0; background: none; color: inherit;
  cursor: pointer; pointer-events: auto;
  opacity: .5; transition: opacity 160ms linear;
}
.speed-tick::before {
  content: ''; display: block; width: 6px; height: 6px;
  background: currentColor;
}
/* **The labels come up together, on hovering the row.** Together because the
   question a reader has is not "what is this one" but "what are my choices" —
   one label at a time would make them hunt. They sit above the ticks so the
   pointer never covers the thing it is asking about, and they are out of the
   flow so revealing them moves nothing. */
.speed-label {
  position: absolute; bottom: 100%; left: 50%;
  transform: translateX(-50%);
  font-size: 9px; line-height: 1; letter-spacing: .06em;
  color: var(--section-ink, var(--ink));
  opacity: 0; pointer-events: none;
  transition: opacity 160ms linear;
}
.carousel-speed:hover .speed-label,
.speed-tick:focus-visible .speed-label { opacity: 1; }

/* The mark says what pressing it will do, on the same line as the speed
   labels and revealed the same way. The two can never appear together: one
   answers a hover on the mark and the other a hover on the ticks, and the
   pointer is only ever on one of them.

   **Left-aligned with the mark rather than centred on it.** At 9px these run
   47-52px against an 18px mark, and on the splash the mark sits 19px from the
   edge of the paper — centred, the label would hang off the stage. Aligning
   the left edges puts it in the same place on every page and reads as a
   caption for the control rather than a tooltip floating over it. */
.carousel-label {
  /* `--pad` and not 0: `left: 0` is the button's padding edge, which is the
     *target's* left, seven pixels outside the drawing. Aligning to the ink is
     what makes it read as a caption for the mark rather than as a box that
     happens to be near it. */
  position: absolute; bottom: 100%; left: calc(var(--pad) * 1px);
  font-size: 9px; line-height: 1; letter-spacing: .06em;
  white-space: nowrap;
  /* **The target is the band above the mark, not the ink of the word.**
     Making the label interactive was not enough on its own: above the button's
     top edge only the word itself was part of the control, leaving 7px of dead
     paper to its left and 5px to its right — directly over the mark. A pointer
     travelling up towards the *start* of START left the control on the way and
     the word faded out from under it, which is how this was reported the
     second time.

     `padding` grows the box and an equal negative `margin` takes the growth
     back out, so the ink does not move — the same trick the nav labels, the
     navigator's arrows and the mark itself all use. The left padding is
     `--pad`, which is exactly the offset that put the word over the drawing
     rather than the target, so the box now spans the button's full width; and
     the bottom padding overlaps the button's top edge so the two boxes meet
     rather than merely abut, which no amount of hit-testing at a fractional
     stage scale can be trusted to get right. */
  padding: 4px 5px 4px calc(var(--pad) * 1px);
  margin: -4px -5px -4px calc(var(--pad) * -1px);
  color: var(--section-ink, var(--ink));
  opacity: 0; pointer-events: none;
  transition: opacity 160ms linear;
}
/* **The word is part of the control, and it has to take the pointer to be
   one.** The label sits at `bottom: 100%` — *outside* the button's box — so
   reaching for it left the button, ended `:hover`, and faded away the thing
   being reached for. The fourth time in this project a control has moved or
   vanished in answer to being approached, after the navigator's arrows, the
   reel's frames and the speed ticks. Reported as people thinking they had to
   click START and finding it would not let them.

   Hover follows the *tree*, not the box: the label is a descendant of the
   button, so once it takes pointer events the button stays `:hover` while the
   pointer is on the word, and a click there bubbles to the button's own
   handler. Two lines, no script.

   **And `pointer-events` is paired with the opacity, deliberately.** An
   element at `opacity: 0` still takes clicks, so `pointer-events: auto` on the
   base rule would leave a 50 x 9 invisible target on bare paper that starts
   and stops the slideshow — the fault at the other end, which the note on the
   hover region already warns about. It is interactive exactly when it is
   visible, and inert the rest of the time. */
.carousel-btn:hover .carousel-label,
.carousel-btn:focus-visible .carousel-label { opacity: 1; pointer-events: auto; }
/* `display`, not `opacity` — a hidden name should be out of the accessibility
   tree as well as off the paper, so the button is called exactly one thing. */
/* Three wordings, one shown. **The invariant is that the label always says
   what the press will do** — which is what settles the awkward question the
   held state raises. In full screen the mark is normally under the layer and
   offers nothing at all; while held it comes back, and the only useful thing
   it can do there is start the thing moving again. So while held it says
   RESUME and resumes, and stopping outright is Escape. That is not a third
   meaning bolted on, it is the same invariant applied to a third state. */
.carousel-label .l-stop, .carousel-label .l-resume { display: none; }
html[data-carousel-on] .carousel-label .l-set { display: none; }
html[data-carousel-on] .carousel-label .l-stop { display: inline; }
html[data-carousel-held] .carousel-label .l-stop { display: none; }
html[data-carousel-held] .carousel-label .l-resume { display: inline; }

/* **Held is the only time the mark is visible in full screen.** `front` puts
   the layer at `z-index: 40` and the mark at 30, so it goes under the
   photograph — which is the whole point of that state. While held it lifts
   above, in the section's ink, and its *presence* is the sign: there is
   nothing else on a full-bleed photograph that could carry one, and at ten
   seconds a held slideshow and a running one are otherwise identical for ten
   seconds. It goes away again the moment it resumes. */
html[data-carousel-held] .carousel { z-index: 41; }
html[data-carousel-held] .carousel-btn { opacity: 1; }
/* ...but only the mark. Lifting the whole control brought the speed ticks up
   with it, and a speed is a meaningless thing to offer about something that is
   not advancing — three squares on a photograph, saying nothing. */
html[data-carousel-held] .carousel-speed { display: none; }

/* **No ground behind it — the ink alone, on the photograph.** Eight candidates
   were drawn at true size over the archive's own hardest frames, chosen by
   measuring mean luminance and standard deviation rather than by eye:
   `tools/concepts/held_ground.py` has all of them, including the flat paper
   chit `.unpin` and `.pin-send` use for this same job. The owner picked none
   of them.

   It is the right call for the seven photographs in ten where it reads, and it
   is the one arrangement that does not put a widget on a photograph somebody
   is looking at. **The case it did not cover is a pale frame**: the section
   inks are mid-tones and over `wwii 5` at a mean luminance of 210 the mark was
   close to gone.

   **A halo now covers it, chosen 2026-08-14 — and it is the first time the
   real control had been looked at.** The eight grounds were judged on a comp
   that carried its own copy of the control's CSS, and those rules had never
   reached this file: the shipped mark was a grey UA button with its glyph
   drawn 0 x 0. `concepts/held_ground.py` links this stylesheet now, draws each
   cell in its own section's ink, and shows the mark alone as well as with the
   label — because the label is `opacity: 0` until the pointer arrives, so the
   common case is the mark by itself and a ground only has to cover that most
   of the time.

   **Why a halo and not any of the boxes.** It thickens the ink's own outline
   instead of laying a panel on a photograph somebody is looking at: no edge to
   sit crooked, nothing to reconcile between the mark's width and the word's,
   and nothing to look like a widget. Two drop-shadows rather than one — a
   tight opaque ring to separate the stroke, and a wider soft one to lift it
   off a busy frame — because a single blur strong enough for `wwii 5` read as
   a smudge on the dark frames. */
html[data-carousel-held] .carousel-btn .mark {
  filter: drop-shadow(0 0 1.5px rgba(232, 222, 197, 1))
          drop-shadow(0 0 4px rgba(232, 222, 197, .85));
}
html[data-carousel-held] .carousel-label {
  text-shadow: 0 0 2px rgba(232, 222, 197, 1),
               0 0 5px rgba(232, 222, 197, .85);
}
/* **Held only, and that is the whole scope.** Every other state has the mark
   on paper, where a paper-coloured halo is invisible at best and a smear at
   worst — and in full screen without `held` the mark is under the layer
   entirely. */
html[data-carousel-held] .carousel-label { left: 0; }
.speed-tick:hover { opacity: .8; }
.speed-tick[data-on] { opacity: 1; }

/* The fourth hit area in this project was the nav labels and the third was the
   navigator's arrows; both were the drawing and nothing else. `padding` grows
   the target and an equal negative `margin` takes the growth back out of the
   flow, so nothing round it moves — the same trick, in the same units. */
/* ...and the masthead's own target beside it, since the whole point of
   measuring off `.mast-hit` is that these two must not overlap. */

/* **Who owns the layer.** The spec's rule was `html[data-pin] .carousel-btn
   { display: none }`, which is right for a reader's pinned photograph and
   wrong for the carousel's own — the slideshow *is* `data-pin`, so as written
   it hides the mark the moment it starts and the veiled state has no control
   to return to. `data-carousel` says which of the two owns it. */
html[data-pin]:not([data-carousel]) .carousel-btn { display: none; }
html[data-carousel] .pin-btn,
html[data-carousel] .unpin,
html[data-carousel] .pin-send { display: none !important; }

/* **An enlargement is not a place to be offered a slideshow.** The reader is
   already looking at one photograph; a second, half-erased one arriving around
   it is noise, and the viewer being transparent is what makes it conspicuous
   there. It is also where the fault of 2026-08-15 was reported from, twice.

   **`:not([data-carousel-on])`, so this only withholds the *offer*.** Ambient
   is something a reader deliberately started and is meant to keep running
   while they browse — including while they read a photograph — and hiding its
   mark there would leave a slideshow going with no visible way to stop it.
   What goes away is the thing you can trip over by resting a pointer; what
   stays is the control for something you chose. Decided with the owner
   2026-08-15.

   The general sibling combinator works because both are children of `#stage`
   and `stage.js` appends `.carousel` after the viewer — the same shape
   `.viewer[data-open] ~ .pin-btn` already relies on. `stage.js` ends a preview
   that is running when the enlargement opens; a rule cannot, and an element
   that goes `display: none` under the pointer is not a dependable way to hear
   about it. */
html:not([data-carousel-on]) .viewer[data-open] ~ .carousel { display: none; }
/* front is the looking-at-it state. The mark is not hidden by a rule — the
   layer is `z-index: 40` there and the mark is 30, so it goes under the
   photograph and comes back with it. Nothing snaps, which is the whole point
   of the fades below. */

/* ==================================================== the fades
   Three motions, deliberately three durations. `--t-lead` carries the archive
   to the photographs and back and is the slow one; `--t-veil` is half of it
   and carries the wash of paper; `--t-dissolve` is between one photograph and
   the next. */

/* `.pin-layer` is `display: none` until `html[data-pin]`, and display does not
   transition — so the layer needs an opacity of its own, and `data-lead` is
   set one frame after the layer exists so the browser has painted the
   transparent state before the transition is asked for. */
html[data-carousel] .pin-layer {
  opacity: 0;
  transition: opacity var(--t-lead, 1500ms) linear;
}
html[data-carousel][data-lead] .pin-layer { opacity: 1; }

/* **The veil has to be painted in both states.** `site.css` draws it as
   `html[data-pin="back"] .pin-layer::after`, and that rule simply stops
   matching when the state goes to `front` — there is nothing left to
   transition and it snaps off. Painted always, carried on opacity, it eases
   both ways. Same colour, same geometry; only the switch is different. */
html[data-carousel] .pin-layer::after {
  content: ''; position: absolute; inset: 0; pointer-events: none;
  background: rgba(198, 178, 138, .62);
  opacity: 0;
  transition: opacity var(--t-veil, 750ms) linear;
}
/* **And it must not be gated on `data-lead`**, which is the fault reported on
   2026-08-15 as *"the photograph fades in and then straight out again"* — and
   the same fault, from the other end, as *"I hovered off and it stayed"*.

   The veil is a child of the layer, so it sits *inside* the layer's own opacity
   and multiplies against it rather than riding it. Gate it on `data-lead` and
   the two run on different clocks — 750ms against the lead's 1500 — and the
   photograph's visible strength is `layer x (1 - .62 x veil)`, which is not
   monotone in either direction:

     coming in    375ms .17   750ms .19   1125ms .29   1500ms .38
     going out    375ms .52   560ms .53    750ms .50   1500ms 0

   Arriving, the wash outruns the layer, so the photograph climbs for 375ms and
   then sits flat until 750 while the tan floods in — it reads as arriving and
   washing back out. Leaving, the wash lifts in half the time the layer takes to
   go, so the photograph **brightens 39% above where it was sitting** and holds
   there for half a second before it fades — which reads as not leaving at all.

   The veil needs a transition of its own only for the back<->front toggle,
   where the layer stays up and only the wash changes. On the lead in and out it
   should already be at rest: `data-carousel` is set at the instant the layer
   becomes `display: block`, so the veil is at 1 on the first computed style and
   nothing transitions; and it stays "back" for the whole `--t-lead` of the exit,
   so the wash rides the layer down. Visible strength is then `.38 x layer` — a
   straight line, both ways. */
html[data-carousel="back"] .pin-layer::after { opacity: 1; }

/* The splash gives up its cover photograph to a pinned one — `site.css` does
   that on `visibility`, which is right for an instant swap and cannot be
   eased. Over a two-second lead it blinked out while the photograph was still
   arriving. It holds its place either way, so opacity costs nothing.

   **Its own duration, and much quicker than the lead.** Carried on `--t-lead`
   it hung about in the middle of the stage for the whole arrival — two
   photographs at once on a page that only ever had one, which is the exact
   thing `site.css` hides it to prevent, drawn out over a second and a half
   instead of happening at once. It gets out of the way first and comes back
   last. Both legs are on their own dials because the right speed is a
   judgement: too quick and it snaps, too slow and it competes.

   **And the two legs are not symmetrical.** Going in it leaves at once, so the
   middle of the stage is clear before the photograph is halfway up. Coming
   back it waits for the photograph to be *gone* — not merely nearly gone — and
   only then fades up.

   **The wait is not a delay, and it cannot be one.** The first version carried
   `transition-delay: var(--t-cover-wait)`, computed as `--t-lead` less this
   duration so the cover landed exactly as the photograph finished leaving.
   Two things were wrong with it. `--t-cover-wait` was **only ever set by
   `concepts/carousel.py`'s own switcher** — nothing in this file or in
   `stage.js` defined it — so the shipped page took the `0ms` fallback and the
   cover faded up over the first 400ms of a 1500ms exit, while the layer was
   still opaque above it. It then sat at full opacity behind the layer until
   the layer cleared and appeared all at once. Reported as popping in abruptly,
   which is exactly what a completed fade behind a curtain looks like.

   And a delay could not have fixed it either: `stop()` takes `data-carousel`
   off at `--t-lead`, so a rule gated on that attribute stops matching at the
   very moment a `--t-lead` delay would have started animating. The transition
   would never run and the cover would snap in at full strength — the same pop,
   arrived at from the other side.

   So the wait *is* the attribute. The transition lives on the base rule, which
   always matches; `html[data-carousel]` holds the cover at zero for as long as
   the slideshow owns the layer; and when `stop()` removes it — at the instant
   the photograph has finished dissolving — opacity goes 0 → 1 and fades on its
   own duration, in the clear, with nothing else on the stage moving. Going in
   is the same rule read the other way: the attribute arrives, the cover
   leaves at once.

   **One duration per leg, and the cascade picks which.** A transition is run
   with the timing declared on the state it is going *to*, so the two legs can
   simply carry their own: leaving, the new state is the `[data-carousel]` rule
   and its `--t-cover-out` governs; returning, the attribute is gone, the base
   rule applies again and `--t-cover-in` governs. No script, no class, and no
   way for one leg to be set and the other forgotten. */
.home-cover { transition: opacity var(--t-cover-in, 600ms) linear; }
html[data-carousel] .home-cover {
  visibility: visible; opacity: 0;
  transition-duration: var(--t-cover-out, 700ms);
}

/* ------------------------------------------------------- the dissolve
   `background-image` does not animate, so a cross-fade needs two layers. Both
   carry the shipped `.pin-img` class and therefore the shipped geometry; what
   moves here is the *composition* — fit, zoom and focus are on `#stage` in
   `site.css`, which is correct for one photograph and impossible for two.
   Mid-dissolve there are always two, each composed by hand for its own frame. */
.pin-img { transition: opacity var(--t-dissolve, 700ms) linear; }
.pin-img[data-off] { opacity: 0; }
.pin-img[data-fit="contain"] { inset: 0; background-size: contain; }

/* Inherits the pin layer's own reasoning: a fixed-canvas idea, and the stage
   reflows to a flowed column here. The no-hover case therefore fails safely
   without a rule of its own. */
@media (max-width: 859px) {
  .carousel { display: none !important; }
}

/* Below the breakpoint the stage reflows to a flowed column and the layer's
   absolute inset would cover it; a pinned photograph is a fixed-canvas idea
   and simply does not apply there. The slideshow inherits that, and with it
   the no-hover case fails safely without a rule of its own. */
@media (max-width: 859px) {
  .carousel { display: none !important; }
  .pin-layer { display: none !important; }
  .pin-btn, .pin-send { display: none !important; }
  html[data-pin] .unpin { position: static; display: inline-block; margin: 8px 0; }
  /* ...and the splash keeps its cover here, since there is no pinned
     photograph to give way to: a reader who pins on a wide window and then
     narrows it would otherwise find the landing page missing its photograph
     for no visible reason. */
  html[data-pin] .home-cover { visibility: visible; }
}

/* ============================================================ LOOK CLOSER
   The deep-zoom layer — `DEEPZOOM.md`, deep.js. Two things live here: the
   control on the enlargement that offers it, and the layer itself.

   The control is a loupe at the photograph's own bottom-right corner — the
   convention every collection site uses, from the Getty to the Rijksmuseum:
   a small monochrome glyph over the image, quiet until pointed at, with
   *Enlarge* or *Zoom* for its name. It is placed by gallery.js from the
   photograph's box on every change of face or shape, since the box moves
   with the card it is drawing. It went in first as a line of type under
   *Back to the contact sheet*, where it crowded a full row and — sitting
   inside the foot nav's upward hit band — took no clicks at all. */
.viewer-closer {
  position: absolute;
  width: 18px;
  height: 18px;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: rgba(20, 18, 15, .48);
  color: #f1ead9;
  cursor: pointer;
  display: grid;
  place-items: center;
  opacity: .55;
  transition: opacity 200ms linear;
  z-index: 3;
}
.viewer-closer svg { width: 11px; height: 11px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; }
/* `display: grid` above out-ranks the browser's own `[hidden] { display:
   none }`, so without this the loupe drew on every photograph, pyramid or
   not — seen on a phone, under a frame that has none, sitting on the
   caption. Said explicitly, at the specificity that wins. */
.viewer-closer[hidden] { display: none; }

/* **The share control, beside the magnifier.** Everything about it is the
   magnifier's — 18px round, the same translucent ground, the same 11px glyph
   at stroke 1.7 — because they are two of a kind: things you do *to* the
   photograph in front of you, drawn on it rather than in the furniture below.
   `.viewer-closer`'s own rules are inherited by listing both selectors above
   rather than by copying them, so the pair cannot drift apart.
   `placeCorner` in gallery.js lays them out right to left along the picture's
   bottom edge; nothing here positions them. */
.viewer-share {
  position: absolute; width: 18px; height: 18px; margin: 0; padding: 0;
  border: 0; border-radius: 50%; background: rgba(20, 18, 15, .48);
  color: #f1ead9; cursor: pointer; display: grid; place-items: center;
  opacity: .55; transition: opacity 200ms linear; z-index: 3;
}
.viewer-share svg { width: 11px; height: 11px; fill: none;
  stroke: currentColor; stroke-width: 1.7; stroke-linecap: round;
  stroke-linejoin: round; }
.viewer-share[hidden] { display: none; }
/* ⚠⚠ **Without this the button is painted and dead.** `.viewer` is
   `pointer-events: none` at rest and re-enables each control *by name*; a new
   one not in that list takes no clicks and the press falls through to the
   photograph, which closes the enlargement. It is a landmine in `CLAUDE.md`
   and it has caught this project before. */
.viewer[data-open] .viewer-share { pointer-events: auto; }
.viewer-share:hover, .viewer-share:focus-visible { opacity: 1; }
.viewer-share:focus-visible { outline: 1px solid #f1ead9; outline-offset: 2px; }

/* **What it says when it has done something**, in the body face and not the
   furniture's: `TM.covers` refuses the wording in Akzidenz's closed subset,
   and a URL in the fallback below needs characters no extracted face has. */
.share-say {
  /* **Fixed, and on `document.body`** — `hangOff` in gallery.js says why
     it is not inside the viewer. right-aligned to the picture’s corner without anybody
     measuring it — see `placeCorner`. */
  transform: translateX(-100%);
  position: fixed; z-index: 40; pointer-events: none;
  font: 12px/1.3 "Libre Caslon Text", Georgia, serif;
  color: #f1ead9; background: rgba(20, 18, 15, .72);
  padding: 4px 8px; border-radius: 2px; white-space: nowrap;
  opacity: 0; transition: opacity 180ms linear;
}
.share-say[data-on] { opacity: 1; }
/* ⚠ **The fallback, and it is not decoration.** `navigator.clipboard` does
   not exist outside a secure context, so on a `file://` copy or plain http
   the copy silently does nothing — `MOBILE.md` records that costing the owner
   a whole triage pass on a control that looked dead and was not. Where
   neither the share sheet nor the clipboard is available the address is put
   on the page, selected, for somebody to copy by hand. */
.share-box {
  /* **Fixed, and on `document.body`** — see `hangOff`. right-aligned to the picture’s corner without anybody
     measuring it — see `placeCorner`. */
  transform: translateX(-100%);
  position: fixed; z-index: 41; display: flex; gap: 6px; align-items: center;
  background: rgba(20, 18, 15, .82); padding: 6px; border-radius: 3px;
}
.share-box input {
  font: 12px/1.3 ui-monospace, Menlo, monospace; width: 15em;
  border: 0; border-radius: 2px; padding: 4px 6px;
  background: #f1ead9; color: #2b2b2b;
}
.share-box[hidden] { display: none; }
/* `.viewer` is `pointer-events: none` at rest and hands them back to its
   controls one by one when open; the loupe has to be named there too, or
   the click falls through it to the photograph beneath — which closes the
   enlargement, the exact opposite of what was pressed. */
.viewer[data-open] .viewer-closer { pointer-events: auto; }
.viewer-closer:hover, .viewer-closer:focus-visible { opacity: 1; }
.viewer-closer:focus-visible { outline: 1px solid #f1ead9; outline-offset: 2px; }

/* The layer: the whole window, a dark neutral ground that is not the
   paper — this is the one place on the site that is not the site — and the
   photograph fitted into it by OpenSeadragon. Above the loader (50) and
   everything else. */
html[data-deep] { overflow: hidden; }
/* ⚠ **Above the phone's enlargement, which is 100.** The layer went in at
   60 — over the loader's 50 and everything on the stage — and on a phone the
   enlargement is a full-screen fixed sheet at `z-index: 100`, so the layer
   opened *under* it: the loupe appeared to do nothing, and a cold `#n/closer`
   from the story page landed on the enlargement. The owner, 2026-09-04:
   *"Deep zoom button is not functioning currently on mobile."* 110 outranks
   the sheet and the skip link, the only other 100 in the file. */
.deep {
  position: fixed;
  inset: 0;
  z-index: 110;
  background: #1c1a17;
  color: #ece5d6;
  -webkit-user-select: none;
  user-select: none;
}
.deep-stage { position: absolute; inset: 0; }
.deep-stage:focus-visible { outline: none; }
.deep-stage canvas { outline: none; }
.deep-title {
  position: absolute;
  left: 24px;
  top: 16px;
  right: 24px;
  margin: 0;
  font: italic 15px/1.3 "Libre Caslon", Georgia, serif;
  opacity: .85;
  pointer-events: none;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  z-index: 2;
  right: auto;
  max-width: calc(100% - 48px);
  padding: 4px 10px;
  background: rgba(28, 26, 23, .78);
  border-radius: 3px;
}
.deep-controls {
  position: absolute;
  left: 18px;
  bottom: 14px;
  margin: 0;
  display: flex;
  gap: 4px;
  z-index: 2;
  /* on its own dark ground: the photograph under it is as often white
     paper as anything, and light type on white paper is no control at all */
  padding: 0 6px;
  background: rgba(28, 26, 23, .78);
  border-radius: 3px;
}
.deep-controls button {
  appearance: none;
  background: none;
  border: 0;
  color: inherit;
  cursor: pointer;
  padding: 10px 9px;
  font: normal 11px/1 "Akzidenz BE Cn", "Helvetica Neue", Arial, sans-serif;
  letter-spacing: .1em;
  text-transform: uppercase;
  opacity: .62;
  transition: opacity 200ms linear;
}
.deep-controls button:hover, .deep-controls button:focus-visible { opacity: 1; }
.deep-controls button:focus-visible { outline: 1px solid currentColor; outline-offset: 2px; }
.deep-hint {
  position: absolute;
  left: 50%;
  bottom: 56px;
  transform: translateX(-50%);
  margin: 0;
  padding: 8px 14px;
  font: italic 14px/1.3 "Libre Caslon", Georgia, serif;
  color: #ece5d6;
  background: rgba(0, 0, 0, .5);
  border-radius: 3px;
  opacity: .9;
  pointer-events: none;
  white-space: nowrap;
  z-index: 2;
}
/* OpenSeadragon's navigator carries its own inline styles for position and
   colour (set from deep.js); this only keeps it off the controls and gives
   it a little air from the corner. */
.deep .navigator { margin: 0 18px 18px 0 !important; box-sizing: content-box; }

@media (max-width: 859px) {
  /* Bigger on a phone, both of them — the same reasoning as the stack
     arrows: a thumb needs more than a cursor does. */
  .viewer-closer, .viewer-share { width: 28px; height: 28px; opacity: .7; }
  .viewer-closer svg, .viewer-share svg { width: 15px; height: 15px; }
  .deep-title { font-size: 13px; left: 16px; right: 16px; top: 12px; }
  .deep-controls { left: 8px; bottom: max(8px, env(safe-area-inset-bottom)); }
  .deep-controls button { padding: 12px 8px; font-size: 11px; }
  .deep-hint { bottom: 64px; font-size: 13px; white-space: normal; text-align: center; max-width: 80vw; }
  .deep .navigator { margin: 0 10px 56px 0 !important; }
}
@media (prefers-reduced-motion: reduce) {
  .deep-controls button { transition: none; }
}
