:root {
  --fade:            3000ms;
  --fade-overlay:    400ms;
  --interval:        5000ms;
  --page-pad:        2.35rem;   /* two live consumers: the year strip and the copy stage below it */

  --header-h:        5.8rem;
  --title-size:      3rem;
  --title-track:     -0.01em;
  --title-weight:    470;

  --sitenav-above:   1.15rem;
  --sitenav-below:   0rem;     /* Home overrides this; see body:has(.carousel) below */
  --sitenav-size:    0.9rem;
  --sitenav-track:   0.015em;
  --nav-item-gap:    0.9rem;
  --nav-group-gap:   2.6rem;
  --nav-here-weight: 600;

  /* Ground pill: calibrated in header-spacing-prototype.html. Its own
     saved :root values are stale -- the panel drives them live without
     writing back, so the file opens at whatever they were last left, not
     at what was settled. These are the settled values, not the file's. */
  --pill-before:      2.6rem;
  --pill-w:           20px;
  --pill-h:           13px;
  --pill-nudge:       0px;
  --pill-opacity:     0.85;
  --pill-border-tone: 0.2;
  --swatch-light:     hsl(23 0% 99%);
  --swatch-dark:      hsl(23 0% 14%);

  /* secnav-*, not nav-*: the site nav above already owns --nav-item-gap
     and --nav-here-weight in this same :root. The prototypes kept the
     month row's --nav-* set and the site nav's variables in separate
     files, where the overlap was harmless; one file needs the two
     components under different prefixes. */
  --secnav-top:      0.25rem;
  --secnav-h:        2rem;
  --secnav-size:     0.72rem;
  --secnav-track:    0.04em;
  --secnav-gap:      1.45rem;
  --secnav-jump-pad: 0.7rem;
  --secnav-pad-top:  0.45rem;
  --secnav-rule:     0.11rem;
  --secnav-below:    0rem;

  --copy-size:       0.9rem;
  --copy-weight:     350;
  --copy-track:      0em;
  --copy-measure:    26.5rem;
  --copy-above:      2.1rem;
  --copy-align:      left;
  --line-gap:        0.65rem;
  --block-gap:       1rem;

  /* Footer text: size is being tried at 0.86rem, its own token rather
     than var(--copy-size) -- a deliberate first divergence from reading
     copy, not a duplicate of it. Weight and letter-spacing stay on
     --copy-weight/--copy-track; no separate footer tokens for those
     unless a reason to diverge on them actually turns up.

     Equal to --lb-base-size on purpose, not by coincidence: it puts
     footer text in the same visual band as lightbox captions and
     controls. Kept as its own token rather than reading --lb-base-size
     directly, because the two govern different components (page chrome
     vs. lightbox) that may legitimately diverge later -- sharing one
     token would wrongly imply that changing one should always change
     the other. */
  --footer-size:     0.86rem;

  --ex-size:         1;
  --ex-weight:       300;
  --ex-track:        0.005em;
  --date-tone:       80%;
  --venue-weight:    500;
  --link-tone:       100%;
  --link-offset:     0.28em;
  --link-style:      dotted;

  /* Thumbnail date, Views and Shifts: one shared decision, not two
     separately-declared knobs that happened to agree. --date-gap (the
     grid-specific margin above each date) stays declared inside .views
     and .shifts individually -- a layout value, not this. */
  --date-size:       0.55rem;
  --date-track:      0.03em;

  --cal-width:       44rem;
  --cal-top:         0.9rem;
  --tick-len:        0.7rem;
  --tick-w:          1px;
  --tick-active-add: 1px;
  --tick-rest:       0.15;
  --tick-tone:       0%;
  --tick-active-tone:0%;
  --tick-active-op:  0.75;
  --end-size:        0.44rem;
  --end-box-w:       calc(var(--end-size) * 0.8);   /* the triangle's own rendered width; named once, read by .calend's borders and .calticks's inset alike */
  --end-w:           1px;
  --end-tone:        0.75;
  /* Half the triangle's own width, inward: with the ticks living in their
     own inset container (.calticks below), the arrow no longer needs to
     clear a boundary tick by pushing outward -- its only job now is
     landing its outer edge on the margin. A formula, not a copied
     literal (-0.176rem), because both values come from the same
     measurement (--end-box-w) and must not be able to drift apart. See
     docs/DECISIONS.md, "Year strip: arrows and ticks". */
  --end-offset:      calc(var(--end-box-w) * -0.5);
  /* Clearance between a boundary tick and its arrow's apex, added to the
     tick track's inset on both sides so the two cannot drift apart. The
     arrows stay flush with the margin and the ticks stay centred on their
     dates; this is the only value that buys the gap, and it costs twice
     itself in year-track width. Measured at 2.2px: about 2px of clear
     space with an ordinary boundary tick, and about 1.5px with a current
     one, which thickens around its fixed centre. */
  --tick-end-clear:  2.2px;

  --p-band-below:    1.9rem;

  --bg-hue: 23;
  --bg-sat: 0%;
  --bg-l: 99%;
  --bg: hsl(var(--bg-hue) var(--bg-sat) var(--bg-l));
  --line: hsl(var(--bg-hue) var(--bg-sat) 90%);
  --color-dark: #38352f;
  --color-darker: #2b2926;
  --mute-light: hsl(37 8% 47%);  /* ~4.6:1 on the light field. #9c968b was 2.9:1 */
  --color-mute: var(--mute-light);

  /* Lightbox: shared across every page that has one. Identical values on
     Views and Shifts per DECISIONS.md, "Shared lightbox module:
     settled values" and "Lightbox caption and button typography". */
  --lb-fade:      1200ms;
  --lb-open:      600ms;
  --lb-idle:      1600ms;
  --lb-bg:        1;
  /* The backdrop follows the page ground rather than carrying its own
     colour, so one set of values drives page and overlay together. Tracks
     html.dark automatically, since --bg-hue/--bg-sat/--bg-l already do. */
  --lb-bg-hsl:    var(--bg-hue) var(--bg-sat) var(--bg-l);
  /* Caption and every lightbox control (Close; Zoom/Seq on Shifts; the
     doc-modal Close, which reuses this same set) read one base size --
     there was never a structural reason for the caption to carry its own
     knob a hundredth of a rem off the buttons', just two components
     calibrated separately that happened to land close.

     Equal to --footer-size (declared above) on purpose -- see that
     token's own comment. Deliberately not the same token: this one
     governs the lightbox, --footer-size governs page chrome, and the two
     may legitimately diverge later. */
  --lb-base-size:  0.86rem;
  --lb-cap-weight: 300;
  --lb-cap-track:  0.01em;
  --lb-cap-gap:    1.2rem;
  --lb-cap-to-btn: 2.5rem;
  --lb-btn-track:  0.13em;
  --lb-btn-pad:    0.3rem;   /* vertical only; every padding rule writes var(--lb-btn-pad) 0.9rem */
  /* Close keeps line-height: normal, which sets its label 3px lower in
     its box than Zoom's or Sequence's (line-height: 1). Taken off the
     space above a first-position Close, so its label sits the same
     distance below the caption as theirs. Measured, not derived. */
  --lb-close-lift: 0.1875rem;
  --lb-cap:         #4a463f;
  --lb-btn-hot:     #16150f;
  /* Same resting colour as .lb.chrome-below's own --lb-btn (Views'
     Close), reused rather than shared: --lb-btn is deliberately scoped to
     that one lightbox mode, not global, so the doc modal gets its own
     copy of the same calibrated value instead of reaching across. */
  --doc-modal-btn:  rgba(40,38,34,0.6);

  /* A Shift is three panoramas with a gap between them, and the
     panorama's own aspect ratio is a fact about the source material, not
     about either page. Shared by the Shifts grid now and its lightbox
     later. */
  --pano-aspect: 2.4;
  --band-gap-pct: 0.5;
  --band-gap-floor: 2px;
  --band-time-size: 0.79rem;
  --band-time-track: 0.025em;
  --band-time-gap: 0.3rem;
  --band-time-below: 0.8rem;
}

/* On html, not body. --bg is composed from --bg-l at :root, so its computed
   value is fixed there. Overriding --bg-l further down the tree changes
   nothing, which shows as pale text on a pale background. Home is the only
   page that toggles this class, following prefers-color-scheme; About and
   Image Use are documents and are not part of the viewing-surround system. */
html.dark {
  --bg-hue: 23;
  --bg-sat: 0%;
  --bg-l: 14%;
  --bg: hsl(23 0% 14%);
  --line: hsl(var(--bg-hue) var(--bg-sat) 22%);
  --color-dark: #f2efea;
  --color-darker: #fbf9f6;
  --color-mute: #b5b0a7;
  --lb-cap:         #ddd;
  --lb-btn-hot:     #fff;
  --doc-modal-btn:  rgba(230,230,230,0.55);
}

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
/* Reusable utility: visually hidden but present for assistive tech and
   keyboard order, standard clip-based technique. No production consumer
   needed one until the Home signup's email label. */
.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
/* The gutter keeps the page from shifting width as it grows. Stable in
   every state -- neither the lightbox nor the doc modals change it. The
   reserved strip is painted by html's own background below, which is
   what the property's spec intends and is why it reads as page colour
   rather than as content showing through. See DECISIONS.md, "Scrollbar
   gutter", for why this was dropped around an overlay at one point and
   isn't any more. */
html {
  background: var(--bg);
  scrollbar-gutter: stable;
  scroll-behavior: smooth;
}
/* Pins the footer to the bottom of the viewport when a page's content is
   shorter than the window (Home, Fields, About, Image Use all do this on
   a tall enough viewport), and falls back to plain flow when it's taller.
   No page wraps its own content in one element -- Home in particular has
   three top-level siblings, not one -- so this works without touching any
   page template: .site-footer's own margin-top: auto below does the
   pinning; nothing here needs flex-grow.

   Two min-height declarations, not one: 100vh is sized with the mobile
   URL bar hidden, so a page can run taller than what's actually visible
   when the bar is showing, leaving a sliver under the footer. 100dvh
   tracks the real, current viewport. Older browsers stop at the first
   line and never see the second; current ones apply both in order and
   the later one wins. */
body {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
  min-height: 100dvh;
  background: var(--bg);
  color: var(--color-dark);
  font-family: 'IBM Plex Sans', system-ui, sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}

/* ============ keyboard focus ============
   One focus signal for the whole site, on every focusable element:
   a 2px #005FCC band touching the element (box-shadow) with a 1px
   #FFFFFF band outside it (outline, offset past the shadow). Two
   contrasting bands rather than one neutral line, so that at least one
   stays visible on the light ground, the dark ground, a pale sky, a
   dark building, and where a thumbnail sits 2px from the next. The
   colours are fixed: they do not follow the ground or --color-mute.
   Both properties are layout-neutral and follow border-radius (the
   pill). The outline half is kept deliberately: forced-colours mode
   drops box-shadow and keeps outline, recoloured by the system.

   :focus-visible is the only trigger, except the Fields carousel button,
   whose keyboard intent is decided by its own script (.keyboard-focus,
   see the carousel section). Containers that take programmatic focus
   (.lb, .doc-modal) opt out in their own sections, and the footer's
   modal openers keep the ring off one programmatic refocus after a tap
   (.pointer-return, see the footer section). Where a ring
   would be clipped, the container makes invisible room (the site-nav
   strip, the month and season rows, the doc-modal panel); the two
   full-bleed carousel buttons draw the same two bands inward. */
:focus-visible,
.keyboard-focus {
  box-shadow: 0 0 0 2px #005fcc;
  outline: 1px solid #ffffff;
  outline-offset: 2px;
}

/* ============ header ============
   One partial, used on every page. The wordmark is the only route home
   and must be a real link.

   6rem header, title bottom-aligned, zero bottom padding: this puts the
   title exactly --header-h minus its own line box below the page top
   (3rem, with --title-size 3rem and line-height 1). The site nav row
   directly below supplies the space under the title, via its own
   --sitenav-above padding, not the header's. */
.site-header {
  height: var(--header-h);
  display: flex; align-items: flex-end; justify-content: center;
  padding: 0 1rem;
}
.site-title {
  font-family: 'Oswald', sans-serif;
  font-weight: var(--title-weight);
  font-size: var(--title-size);
  letter-spacing: var(--title-track);
  margin: 0;
  line-height: 1;   /* without this the inherited leading eats the space above */
}
.site-title a {
  color: var(--color-darker);
  text-decoration: none;
}

/* Follows the grid's own column tiers (1019px, 639px), not a phone-only
   breakpoint of its own, since the header is shared across every page.
   Home, About and Image Use have no columns to track, but the resulting
   widths read fine there regardless. The header block tightens as a
   whole at each tier, rather than the title shrinking inside a box that
   stays --header-h. */
@media (max-width: 1019px) {
  :root {
    --header-h: 4.75rem;
    --title-size: 2.55rem;
  }
}
@media (max-width: 639px) {
  :root { --title-size: 2.3rem; }
  .site-header { height: 3.8rem; }
}

/* ============ site nav ============
   A flat row of the principal destinations, in two groups: the work
   (Views, Shifts, Fields) and the second group (Show and Prints, on
   every page). About lives in the footer
   alongside Image Use and Contact. Grouping is carried by spacing alone,
   --nav-group-gap between the groups and --nav-item-gap within one, per
   DECISIONS.md, "Site navigation". The current item carries
   [aria-current="page"], which production already needs for
   accessibility, so styling keys off that directly rather than a
   separate class saying the same thing.

   Overflow protection is `safe center` alone, not a breakpoint. It keeps
   plain centring on an overflowing row from pushing content off both ends
   while only one direction scrolls, and falls back to start alignment on
   its own once the row genuinely no longer fits -- nothing else has to
   force that. The prototypes also carried an explicit flex-start override
   at this breakpoint, redundant with what safe center already did, masked
   there because their calibration data always overflowed. Today's shorter
   row does not always overflow, which is what exposed the redundancy; see
   DECISIONS.md, "Site navigation". */
.site-nav {
  display: flex;
  justify-content: center;
  padding: var(--sitenav-above) 1rem var(--sitenav-below);
  font-family: 'IBM Plex Sans', sans-serif;
  font-size: var(--sitenav-size);
  letter-spacing: var(--sitenav-track);
}

/* The destination words, in their groups, as one strip that scrolls
   sideways when they outgrow the width -- the month row's vocabulary:
   native scrolling, no bar, overscroll contained, nothing shrinks. It
   is a flex item that may shrink (min-width 0, so the scroll container
   can be narrower than its words) but never grows, so while the words
   fit it is exactly their width and the row centres words and pill
   together as it always has; only when width runs out does the strip
   fill what the pill leaves and start to scroll. Where it rests then is
   the nav script's business (site-nav.njk). Padding on all four sides,
   pulled back by the equal negative margins, so the 3px focus ring on
   a word is not clipped by the strip's own overflow edge: 4px above
   and below, 5px at each end. The ring needs 3px; the rest absorbs
   rounding, since a word's edge is snapped to the nearest pixel and
   the scroll clip to the pixel inside it, which can cost 1.5px at an
   end. The inline padding counts in scrollWidth as well as
   clientWidth, so the overflow and centring arithmetic in
   site-nav.njk is unchanged. */
.site-nav-strip {
  display: flex;
  flex: 0 1 auto;
  min-width: 0;
  gap: var(--nav-group-gap);
  padding: 4px 5px;
  margin: -4px -5px;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
}
.site-nav-strip::-webkit-scrollbar { display: none; }

/* Item-to-item gap within a group; the group-to-group gap is
   --nav-group-gap on the strip. */
.site-nav .navgrp {
  display: flex;
  flex: 0 0 auto;
  gap: var(--nav-item-gap);
}

.site-nav a {
  color: var(--color-mute);
  text-decoration: none;
  transition: color 0.15s ease;
}
.site-nav a[aria-current="page"] {
  color: var(--color-darker);
  font-weight: var(--nav-here-weight);
}
.site-nav a:hover,
.site-nav a:focus-visible { color: var(--color-dark); }

/* One pill, at the right end of the same centred row, in flow as the
   strip's sibling -- so the whole row (both groups plus the pill) is
   what gets centred while it fits, per DECISIONS.md, "Light and dark
   mode pills", and the pill stays put at the row's right end while the
   strip scrolls. --pill-before is its distance from the last word
   (the strip's edge), the same as when it closed the last group. It
   shows the ground it will switch to, not the one you're in: a dark
   swatch while light, a light swatch while dark. No label, no selected
   state -- there's nothing to select between, only a single action to
   take. The border exists only so a light swatch has an edge on a light
   ground; it's mixed toward --bg, not toward transparent, so it holds as
   a real line even at a low tone. */
.mode-pill {
  position: relative;
  flex: 0 0 auto;
  align-self: center;
  top: var(--pill-nudge);
  margin-left: var(--pill-before);
  width: var(--pill-w);
  height: var(--pill-h);
  padding: 0;
  border-radius: 999px;
  border: 1px solid color-mix(in srgb, var(--color-mute) calc(var(--pill-border-tone) * 100%), var(--bg));
  background: var(--swatch-dark);
  cursor: pointer;
  opacity: var(--pill-opacity);
  transition: opacity 0.15s ease;
}
html.dark .mode-pill { background: var(--swatch-light); }
.mode-pill:hover { opacity: 1; }
/* The shared ring is drawn as part of the pill, so the pill's resting
   0.85 would dim it; hover already goes to 1 the same way. */
.mode-pill:focus-visible { opacity: 1; }
/* Invisible, larger hit area -- the visible swatch stays 20x13, but a
   click anywhere within a pseudo-element this size still lands on the
   button itself, since generated content is part of its own hit-test
   surface. Roughly the 44px touch-target floor, not the visual size. */
.mode-pill::before {
  content: "";
  position: absolute;
  inset: -16px -10px;
}

/* Home sits directly above the carousel, which needs more air below the
   row than the grid and document pages do. Scoped by :has(.carousel)
   since Home's content has no wrapping page class the way Views and
   Shifts do. Fields is the same situation -- the nav sits directly
   above a full-bleed frame with no month row or mode pills between them
   -- so it borrows Home's own calibrated value rather than an invented
   one. Not yet checked by eye against the Fields frame specifically.
   Show and the Nuit Blanche page took the same gap while their lead
   photograph sat directly under the nav; they open with the page switch
   now, Shifts' season row, and keep the default the way Shifts does.
   Prints took the same gap while a Field opened it;
   text-first again, it has the document pages' 3.5rem instead. */
body:has(.carousel),
body:has(.field) { --sitenav-below: 1.5rem; }

/* Phones: the nav sits on the same horizontal frame as the page's own
   content and the footer, --page-pad's phone value (1.1rem, set at :root
   in the shared 767px tier below; the footer keeps a literal equal to
   it). So when the strip rests at its start, Views begins on the
   content's left edge, and the pill ends on its right edge. A larger
   nav-only gutter (1.5rem) used to sit here; superseded. */
@media (max-width: 767px) {
  .site-nav { padding-left: var(--page-pad); padding-right: var(--page-pad); }
}

/* ============ section nav (month row / season bar) ============
   Views' month row and Shifts' season bar are the same component:
   a sticky row of links tracking the current section on scroll, ported
   from prototypes/views-prototype.html (JS from line 989) and
   intervals-prototype.html (JS from line 2061), which carried duplicate
   --nav-* and --snav-* variable sets with identical values. One set here
   (--secnav-*), consumed by both classes, per the shared-lightbox-token
   precedent. Sits outside <main>, as a sibling, so it can't be scoped
   under .views/.shifts the way .lb-imgwrap couldn't be either.

   Short-viewport height is Views' only in the prototypes; Shifts
   never got it, which DECISIONS.md records as drift. Both classes share
   it now. Phone-width padding, ported the same way at first, turned out
   to double up with the padding .views/.shifts already supply now
   that the row is nested inside main, so it was dropped rather than
   shared.

   The Show page switch (Corridor / Nuit Blanche, partials/show-switch.njk)
   is a .season-nav too: the same rules, two fixed links, the current page
   by aria-current below. What is its own (placement, and the gap to the
   lead photograph) is in "show". */
.month-nav,
.season-nav {
  margin-top: var(--secnav-top);
  /* Independent of --head-above, which sets the gap above every
     subsequent heading mid-scroll -- this is the space under the row
     itself, on arrival, before the first heading's own --first-top
     applies. */
  margin-bottom: var(--secnav-below);
  position: sticky;
  top: 0;
  z-index: 100;
  background: var(--bg);
  height: var(--secnav-h);
  display: flex;
  /* flex-start, not center: centring split --secnav-h's extra room
     evenly above and below the labels. This keeps the space below where
     it was and puts all of the added room above instead, held off the
     top edge by padding-top rather than flush against it. */
  align-items: flex-start;
  padding-top: var(--secnav-pad-top);
  /* plain centring makes the leading items unreachable once the row
     overflows, because it pushes content off both ends and only one
     direction scrolls. safe falls back to flex-start where unsupported */
  justify-content: safe center;
  gap: var(--secnav-gap);
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
  /* 6px at each end, pulled back by the margins, so the 3px focus ring
     on the first or last label is not clipped by the row's own scroll
     edge. The ring needs 3px; a label's edge is snapped to the nearest
     pixel and the scroll clip to the pixel inside it (up to 1.5px
     between them), and each label's negative margin-right (the
     letter-spacing pull-back below) ends the scrollable area another
     fraction short of its box. The rows are sticky with their own
     background, and the 6px it now reaches into the gutter is the same
     colour. offsetLeft and clientWidth both grow by the padding, so
     section-nav.js's centring arithmetic is unchanged. */
  padding-left: 6px;
  padding-right: 6px;
  margin-left: -6px;
  margin-right: -6px;
}
.month-nav::-webkit-scrollbar,
.season-nav::-webkit-scrollbar { display: none; }

.month-nav a,
.season-nav a {
  flex: 0 0 auto;   /* stops the labels being squeezed once the row scrolls */
  font-family: 'IBM Plex Sans', sans-serif;
  font-size: var(--secnav-size);
  letter-spacing: var(--secnav-track);
  text-transform: uppercase;
  color: var(--color-mute);
  text-decoration: none;
  padding: 0 0 var(--secnav-rule);
  /* letter-spacing adds a gap after the last character as well as
     between characters, so the element box is wider than the glyphs and
     the underline overhangs on the right. Pulls that back without
     changing the gap between labels. */
  margin-right: calc(var(--secnav-track) * -1);
  border-bottom: 1px solid transparent;
  transition: color 0.15s ease, border-color 0.15s ease;
}
.month-nav a:hover, .month-nav a:focus-visible,
.season-nav a:hover, .season-nav a:focus-visible { color: var(--color-dark); }

/* The current section, which section-nav.js marks .active as the reader
   scrolls; or, on the Show page switch, the current page, marked
   [aria-current="page"] the way the site nav marks its own. No month or
   season link ever carries aria-current. */
.month-nav a.active,
.season-nav a.active,
.season-nav a[aria-current="page"] {
  color: var(--color-darker);
  border-bottom-color: var(--color-darker);
}

@media (max-height: 500px) {
  :root { --secnav-h: 2.4rem; }
}

/* ============ footer ============
   One row: copyright on the left, links on the right, no separators.
   Text matches body copy exactly (size, weight, family) rather than
   shrinking to a caption size. Constrained to --copy-measure, the same
   width the body copy above it reads at, rather than the full page. */
.site-footer {
  /* Consumes the leftover space above it in the body's flex column when
     the page is shorter than the viewport, which is what pins it to the
     bottom rather than letting it end wherever the content ends. On a
     page tall enough to fill the viewport on its own, there's no leftover
     space to consume and this does nothing -- plain flow either way. */
  margin-top: auto;
  padding: 3.5rem 1rem 1.5rem;
}

/* ============ page tail ============
   Home, Show and Prints end with the newsletter, and the page layout
   renders it in this one block between the page's content and the
   footer (page-tail.njk, opted into with `signup: true`). The block
   owns the whole page-end relationship. margin-top: auto: in the body's
   column it takes any spare viewport height on a short page, so the
   spare sits above the newsletter and never between newsletter and
   footer. padding-top 4rem: the minimum between the last content and
   the newsletter, the same on every page, because it is here and not
   on the page. The gutter is the site's own --page-pad, 1.1rem on a
   phone, which is what the pages gave the newsletter before it moved
   here. The newsletter inside carries no margin of its own; the footer
   follows with its own padding, its auto margin off since the tail has
   taken the spare. Pages without the tail are untouched: the footer
   pins itself as before. */
.page-tail {
  margin-top: auto;
  padding: 4rem var(--page-pad) 0;
}
.page-tail .signup { margin: 0 auto; }
.page-tail + .site-footer { margin-top: 0; }
.footer-row {
  max-width: var(--copy-measure);
  margin: 0 auto;
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1.5rem;
}
.site-footer p, .site-footer nav {
  margin: 0;
  font-size: var(--footer-size);
  font-weight: var(--copy-weight);
  letter-spacing: var(--copy-track);
}
.site-footer .copyright { color: var(--color-mute); }
.site-footer nav {
  display: flex;
  gap: 1.2rem;
}
.site-footer nav a {
  color: var(--color-mute);
  text-decoration: none;
}
/* The page you're already on: About on /about/, Image Use on /image-use/.
   Same treatment as the site nav's own current-page styling, and the
   reason that link skips the modal script entirely -- see doc-modals.njk. */
.site-footer nav a[aria-current="page"] {
  color: var(--color-darker);
  font-weight: var(--nav-here-weight);
}
.site-footer nav a:hover,
.site-footer nav a:focus-visible:not(.pointer-return) {
  color: var(--color-dark);
  text-decoration: underline;
  text-decoration-style: var(--link-style);
  text-underline-offset: var(--link-offset);
}
/* Keyboard focus on About and Image Use is :focus-visible's, like every
   other link. The one thing it gets wrong is doc-modal.js's own close()
   handing focus back to the link that opened the modal: a programmatic
   .focus() call, on some mobile browsers treated as keyboard-like even
   when the modal was opened and closed by a tap. doc-modal.js marks that
   one refocus pointer-return when the last input was a pointer, and
   clears the mark on the next key press, pointer press or blur; the
   ring stays off only while the mark is on. */
.site-footer nav a.pointer-return:focus-visible { outline: none; box-shadow: none; }

/* ============ doc modal ============
   About and Image Use as an overlay over the current page -- the same
   relationship the lightbox has to the Views/Shifts grids, reusing
   its backdrop and open/close timing (--lb-open, --lb-bg-hsl, --lb-bg)
   rather than inventing a second overlay language. Two independent
   instances (#aboutModal, #imageUseModal), not one shell with swapped
   content: there are exactly two of these, ever, so a generic N-item
   content-swap system would be more machinery than the job needs.

   No visible title inside either panel: the pages underneath have none
   (About and Image Use introduce no typographic personality of their
   own), so the modal doesn't add one either. aria-label carries the name
   for assistive technology. */
.doc-modal {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 1000;
  cursor: pointer;
  opacity: 0;
  transition: opacity var(--lb-open) ease;
  background: hsl(var(--lb-bg-hsl) / var(--lb-bg));
  padding: 2rem 1.5rem;
}
.doc-modal.open { display: grid; place-items: center; }
.doc-modal.in { opacity: 1; }
.doc-modal:focus { outline: none; box-shadow: none; }

.doc-modal-panel {
  display: flex;
  flex-direction: column;
  align-items: center;
  max-height: 100%;
  overflow-y: auto;
  /* Close is the panel's last child, flush with its scroll edge: room
     for its 3px focus ring plus the pixel that rounding can take,
     pulled back so the centred panel does not move. */
  padding-bottom: 4px;
  margin-bottom: -4px;
}
.doc-modal-panel .doc-measure { align-self: stretch; }
/* Cursor follows doc-modal.js's own hit tests, toggled on every pointer
   move, rather than a selector-based rule: the hand while close-hot
   (backdrop), the ordinary arrow inside the document box (.doc-measure
   plus 12px: paragraph gaps, leading, the blank beside a short line, the
   margin), and the I-beam only over the rendered text itself (text-hot,
   glyph runs read live). The same three cursors the Fields and lightbox
   captions show. .doc-modal-close keeps its own explicit cursor
   regardless, since it's a control the hit test excludes on purpose. */
.doc-modal:not(.close-hot) { cursor: default; }
.doc-modal.text-hot { cursor: text; }

.doc-modal-close {
  margin-top: var(--lb-cap-to-btn);
  background: none;
  border: 0;
  color: var(--doc-modal-btn);
  cursor: pointer;
  font-family: 'IBM Plex Sans', sans-serif;
  font-size: var(--lb-base-size);
  letter-spacing: var(--lb-btn-track);
  text-transform: uppercase;
  padding: var(--lb-btn-pad) 0.9rem;
  transition: color 0.15s ease;
}
.doc-modal-close:hover,
.doc-modal-close:focus-visible { color: var(--lb-btn-hot); }
/* Matches .lb.close-hot .lb-close: the pointer is somewhere a click would
   close the modal (the backdrop, not the panel), so Close lights up to
   say so before the click lands. */
.doc-modal.close-hot .doc-modal-close { color: var(--lb-btn-hot); }

/* ============ carousel ============
   Full bleed. No scrollbar-gutter, which would reserve a strip down the
   right edge and break the bleed. Aspect ratio matches the source files
   exactly (2400 x 1000), so the frame never crops. */
.carousel {
  position: relative;
  width: 100%;
  aspect-ratio: 2400 / 1000;
  overflow: hidden;
  background: hsl(var(--bg-hue) var(--bg-sat) calc(var(--bg-l) - 3%));
}
.carousel figure {
  position: absolute; inset: 0; margin: 0;
  opacity: 0;
  transition: opacity var(--fade) ease-in-out;
}
.carousel figure.active { opacity: 1; }
.carousel img {
  width: 100%; height: 100%;
  object-fit: contain;
  display: block;
}

/* Shared with Fields' own frame: same pause/play state indicator over a
   full-bleed image, same asset paths (overlaySrc in each page's script),
   same behaviour. */
.carousel .overlay,
.field .overlay {
  position: absolute; inset: 0;
  pointer-events: none; z-index: 10;
  display: none; opacity: 0;
  transition: opacity var(--fade-overlay) ease-in-out;
}
.carousel .overlay.leaving { transition-duration: var(--fade); }
.carousel .overlay img,
.field .overlay img { width: 100%; height: 100%; object-fit: contain; }

.pausebtn {
  position: absolute; inset: 0; z-index: 11;
  width: 100%; height: 100%;
  background: none; border: 0; padding: 0; cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
/* :focus-visible's own heuristic isn't trusted here at all, in either
   direction: fields.njk moves focus to #pausebtn programmatically both
   on open and on close, and the browser's own judgment of whether a
   given focus "looks keyboard-like" has proven unreliable across both a
   pointer-triggered close (used to show a ring it shouldn't) and a
   window/tab regaining focus (could leave a ring showing indefinitely,
   with no further interaction to clear it). .keyboard-focus is added and
   removed entirely by fields.njk's own syncPausebtnRing(), armed only by
   an actual Tab press and consumed the moment #pausebtn is focused --
   see its own comment there. */
/* The button fills the frame with no room outside it, so the shared
   two-tone ring is drawn inward: the 1px white band at the edge, the 2px
   blue band inside it -- the same bands in the same order, mirrored.
   Applies on Home through :focus-visible (a click never focuses the
   button there, see index.njk); on Fields only through .keyboard-focus,
   with the native match silenced, because fields.njk moves focus to the
   button programmatically on open and close and decides the ring itself
   (see its syncPausebtnRing). */
.pausebtn:focus-visible,
.pausebtn.keyboard-focus {
  box-shadow: inset 0 0 0 3px #005fcc;
  outline: 1px solid #ffffff;
  outline-offset: -1px;
}
.fields .pausebtn:focus-visible:not(.keyboard-focus) { outline: none; box-shadow: none; }

/* ============ year strip ============
   Replaces pagination dots. A horizontal line is the year. Image ticks
   hang below it. Positions are real days, so gaps and clusters are true. */
/* width: 100%, not just max-width + margin: 0 auto: body became a flex
   column for footer pinning, and Flexbox suppresses a flex item's
   default full-width stretch on any axis where it has an auto margin --
   which this max-width + auto-margin centering pattern always has,
   horizontally. Without an explicit width the item falls back to
   shrink-to-fit sizing instead of filling the container, which is what
   collapsed Shifts' grid to a single narrow column. An explicit
   width sidesteps the rule rather than fighting it: max-width still
   clamps it and the auto margins still centre the clamped box, exactly
   as before body was a flex container. Same fix, same reason, on every
   other top-level element using this pattern -- .doc, .views,
   .shifts below. */
.calwrap {
  width: 100%;
  max-width: var(--cal-width);
  margin: var(--cal-top) auto 0;
  padding: 0 var(--page-pad);
}
.calstrip { position: relative; height: var(--tick-len); }

/* Anchored to the strip's own ends (left: 0%/100% below), not inset from
   them -- --end-offset lands the outer edge flush with the page margin,
   and nothing moves it off that line. Clearance from a boundary tick is
   the tick track's job instead: see the inset on .calticks below.
   Inward-pointing triangles:
   CSS shapes rather than a glyph, so tone and size still apply and no
   system renders them as colour emoji.
   See docs/DECISIONS.md, "Year strip: arrows and ticks". */
.calend {
  position: absolute; top: 50%;
  transform: translate(calc(-50% + var(--end-shift)), -50%);
  opacity: var(--end-tone);
  color: var(--color-mute);
  width: 0; height: 0; background: none;
  border-top: calc(var(--end-size) * 0.55) solid transparent;
  border-bottom: calc(var(--end-size) * 0.55) solid transparent;
}
.calend[data-side="start"] {
  --end-shift: calc(-1 * var(--end-offset));
  border-left: var(--end-box-w) solid currentColor;
}
.calend[data-side="end"] {
  --end-shift: var(--end-offset);
  border-right: var(--end-box-w) solid currentColor;
}

/* Where the ticks live: inset from .calstrip's own ends by one arrow's
   width plus the clearance above, so the year spans between the arrows
   rather than running underneath them and a January 1 or December 31 mark
   keeps visible air from the apex beside it. The whole end treatment sits
   here: the arrows do not move for it, and no mark is special-cased, so a
   boundary tick is centred on its date and thickens symmetrically when
   current exactly like an interior one. The inset is the box model, not a
   computed percentage -- .calmark's left:<pct>% (unchanged) resolves
   against this box automatically at whatever width it renders, so nothing
   here reads a rendered width or recomputes on resize. See
   docs/DECISIONS.md, "Year strip: arrows and ticks". */
.calticks {
  position: absolute;
  top: 0;
  bottom: 0;
  left: calc(var(--end-box-w) + var(--tick-end-clear));
  right: calc(var(--end-box-w) + var(--tick-end-clear));
}

/* Record, not a control: no cursor, no hover, no focus ring, no tap
   target. A plain visual mark, styled the same whether it is the one
   just placed or one from an hour ago. */
.calmark {
  position: absolute;
  top: 0;
  width: var(--tick-w); min-width: 1px;
  height: var(--tick-len);
  background: color-mix(in srgb, var(--color-mute) var(--tick-tone), var(--color-dark));
  border: 0; padding: 0; margin: 0;
  transform: translateX(-50%);
  opacity: var(--tick-rest);
  transition: opacity var(--fade) ease-in-out,
              background var(--fade) ease-in-out,
              width var(--fade) ease-in-out;
}
.calmark.active {
  width: calc(var(--tick-w) + var(--tick-active-add));
  opacity: var(--tick-active-op);
  background: color-mix(in srgb, var(--color-mute) var(--tick-active-tone), var(--color-darker));
}

/* ============ copy ============
   Three blocks. Lines inside a block sit at --line-gap; the blocks are
   separated by --block-gap, so the middle line breathes on both sides.

   padding: var(--page-pad), not a literal: the year strip in .calwrap
   above reads the same variable, so the strip's edges and the copy's
   share one source of value rather than two numbers kept equal by hand.
   They drifted apart exactly that way once already -- see the phone
   breakpoint below, where only this rule's own literal used to move. */
.stage { padding: 0 var(--page-pad); }

.summary {
  margin: var(--copy-above) auto 0;
  max-width: var(--copy-measure);
  text-align: var(--copy-align);
}
.summary .blk { margin: 0 0 var(--block-gap); }
.summary .blk:last-child { margin-bottom: 0; }
/* One control for both. A wrapped line and an authored line break sit the
   same distance apart: line-height carries the gap, so there is no margin
   to add on top of it. */
.summary .ln {
  display: block;
  margin-bottom: 0;
  font-size: var(--copy-size);
  font-weight: var(--copy-weight);
  letter-spacing: var(--copy-track);
  line-height: calc(1em + var(--line-gap));
  color: var(--color-dark);
}
.summary .ex .ln {
  font-size: calc(var(--copy-size) * var(--ex-size));
  font-weight: var(--ex-weight);
  letter-spacing: var(--ex-track);
}
/* the date line sits back from the venue line */
.summary .ex .ln:first-child { font-weight: var(--venue-weight); }
.summary .ex .ln:last-child {
  color: color-mix(in srgb, var(--color-mute) var(--date-tone), var(--color-dark));
}
/* text-decoration rather than border-bottom: the spans inside are block
   level, so a border on the inline <a> draws nothing. Only the venue line
   carries the underline; the date line is inside the link target but has
   no link affordance.

   .back-link (the signup confirmation pages' return link) shares this
   same treatment rather than a second copy -- same color-mix underline
   tone, same hover/focus brightening, same focus ring. It has no .ln
   spans to target, so it joins on the anchor itself instead of the
   venue-line span. */
.summary .ex a,
.back-link a,
.show .doc-measure a {
  color: inherit;
  text-decoration: none;
}
.summary .ex a .ln:first-child,
.back-link a,
.show .doc-measure a {
  text-decoration: underline;
  text-decoration-style: var(--link-style);
  text-decoration-thickness: 1px;
  text-underline-offset: var(--link-offset);
  text-decoration-color: color-mix(in srgb, var(--color-mute) var(--link-tone), var(--color-dark));
  transition: text-decoration-color 0.2s;
}
.summary .ex a .ln:first-child { width: max-content; max-width: 100%; }
/* The link holds two block lines, so as an inline it splits into three
   fragments (two of them empty) and the shared focus ring lands on those
   as ticks. One block, shrink-wrapped to its longer line, gives the ring
   one box; the lines wrap and sit exactly as before. */
.summary .ex a { display: block; width: max-content; max-width: 100%; }
.summary .ex a:hover .ln:first-child, .summary .ex a:focus-visible .ln:first-child,
.back-link a:hover, .back-link a:focus-visible,
.show .doc-measure a:hover, .show .doc-measure a:focus-visible {
  text-decoration-color: var(--color-dark);
}

/* ============ Home signup ============
   No shared precedent -- values tuned in
   prototypes/signup-form-prototype.html, build sf-12. Shares .summary's
   own measure and centring (max-width: var(--copy-measure), auto
   margins) so it reads as another quiet block in the reading column, not
   a separate designed section: no heading, same left edge as the locked
   copy above it. Every colour is color-mix(...--color-dark/
   --color-mute..., transparent) rather than a fixed grey, so the form
   inverts with the ground the same way the rest of the page already
   does. */
.signup {
  max-width: var(--copy-measure);
  margin: 4rem auto 0;
}
.signup .signup-support {
  margin: 0 0 0.5rem;
  font-size: var(--copy-size);
  font-weight: var(--copy-weight);
  letter-spacing: var(--copy-track);
  line-height: calc(1em + var(--line-gap));
  color: var(--color-dark);
}
/* width: 100% so the row can shrink on a narrow phone the same way the
   copy column above it does, not so it visibly stretches -- neither
   control grows past its own width (see flex-grow: 0 on the field and
   button below), so with room to spare the form simply stops at its own
   width and leaves the rest of the row empty, which reads the same as
   fit-content since nothing is drawn there. margin: 0, not auto, keeps
   its left edge on .summary's rather than centring independently. */
.signup-form {
  display: flex;
  align-items: stretch;
  gap: 0.4rem;
  width: 100%;
  margin: 0;
}

/* Both controls are a bottom rule over a faint tint: one strength, one
   thickness, one curve, one fill, read off --rule-now and --fill-now
   rather than declared twice. The radius is 0, so the rule stops dead at
   each end and the tint keeps square corners -- one value governing both,
   since border-radius rounds the tinted box and curls the rule's ends
   together. Hover and ordinary focus add the same amount to the resting
   strength (0.35 -> 0.7), so a mouse and a keyboard produce the same
   gesture. The tint is mixed from --color-dark like every other colour
   here, so it darkens on the light ground and lightens on the dark one
   rather than being a fixed grey that only works on one. */
.signup-form input[type=email],
.signup-form button {
  --rule-now: 0.35;
  --fill-now: 0.03;
  font-family: 'IBM Plex Sans', sans-serif;
  font-size: var(--copy-size);
  letter-spacing: var(--copy-track);
  height: 2.2rem;
  background: color-mix(in srgb, var(--color-dark) calc(var(--fill-now) * 100%), transparent);
  border: 0;
  border-radius: 0;
  border-bottom: 1px solid color-mix(in srgb, var(--color-dark) calc(var(--rule-now) * 100%), transparent);
}

/* Preferred width, not fixed, in both directions: flex-basis lets the
   field shrink below 17rem at narrow widths instead of overflowing the
   row, and grow past it when the column has room to spare -- the button's
   own flex: 0 0 auto below keeps it at its intrinsic (label + padding)
   width, so the field is what absorbs the difference either way. That is
   what lands the button's right edge on the copy column's own right edge,
   the same line the copy above and the footer below already sit on,
   rather than leaving the row short of it. min-width is a small floor,
   not 0, so the field can shrink a long way without disappearing.
   flex-grow was 0 until sf-19, which reversed it: 17rem is the width the
   field starts from now, not the width it stops at. */
.signup-form input[type=email] {
  flex: 1 1 17rem;
  min-width: 4rem;
  width: 0;
  font-weight: var(--copy-weight);
  color: var(--color-dark);
  padding: 0 0.85rem;
}
.signup-form input[type=email]::placeholder {
  color: color-mix(in srgb, var(--color-mute) 55%, transparent);
  opacity: 1;
}
/* Typed text stays at full strength on focus -- the rule and the tint are
   what move. The shared focus ring sits on top of this, not in place of
   it; a text input can match :focus-visible after a mouse click as well
   (the browser's own heuristic, not fought here). */
.signup-form input[type=email]:hover,
.signup-form input[type=email]:focus { --rule-now: 0.7; }
/* Focus only, unlike the rule above: the tint's job is to say "type here",
   so once you are in the field it is in the way. Hovering is not yet
   typing, so the tint stays until the cursor actually lands. */
.signup-form input[type=email]:focus { --fill-now: 0; }

/* The button has no width of its own -- its label plus this padding is
   its width, and that same width is what the rule underneath runs. */
.signup-form button {
  --label-now: 0.85;
  flex: 0 0 auto;
  font-weight: 500;
  color: color-mix(in srgb, var(--color-dark) calc(var(--label-now) * 100%), transparent);
  padding: 0 1.2rem;
  text-align: center;
  cursor: pointer;
}
/* Hover and keyboard focus get the same rule strength as the field's own
   focus (0.7). Label tone would land at 0.85 + 0.35 = 1.2 by the same
   arithmetic; hard-set to 1 (100%) here instead of left to color-mix's
   own clamping, since 100% is the specified ceiling, not an accident of
   the maths overshooting it. The tint deepens rather than clearing, the
   opposite of the field's: this one answers the cursor instead of
   getting out of its way, so it is an absolute value, not an addition. */
.signup-form button:hover,
.signup-form button:focus {
  --rule-now: 0.7;
  --label-now: 1;
  --fill-now: 0.12;
}

/* ============ document pages (About, Image Use) ============
   Vertical, single column, text led. Not part of the light/dark viewing
   surround system, so no dark-mode wiring here.

   Same split as .stage/.summary on Home: .doc is the responsive outer
   gutter (padding only, no measure), .doc-measure is the actual content
   box, capped at --copy-measure with zero padding of its own so
   border-box sizing can't eat into the measure it is supposed to be. */
.doc {
  /* width: 100%: see .calwrap above, same flex-stretch-suppression fix. */
  width: 100%;
  margin: 3.5rem auto 2.5rem;
  padding: 0 1.6rem;
}
.doc-measure {
  max-width: var(--copy-measure);
  margin: 0 auto;
  font-size: var(--copy-size);
  font-weight: var(--copy-weight);
  letter-spacing: var(--copy-track);
  /* Same formula Home/Fields' .ln uses, not a separately-tuned multiplier
     -- the two used to land within a third of a pixel of each other
     (1.7 x 0.9rem vs. 1em + 0.65rem) by coincidence, not by sharing
     anything. One reading-text line-height mechanism now. */
  line-height: calc(1em + var(--line-gap));
}
.doc-measure p { margin: 0 0 1rem; }
.doc-measure p:last-child { margin-bottom: 0; }

/* Heading on the two signup confirmation pages only, matching the site's
   established Oswald section-heading treatment -- same values as
   .views/.shifts .month-heading (see PAGES.md, "Month headings and
   dates"). Copied rather than shared via --head-*, since those tokens are
   scoped to the grid pages they were tuned for. No line-height override
   there either, so none here -- .doc-measure's reading-copy line-height
   is for the paragraphs beside it, not this.

   margin-bottom is the heading-to-back-link gap, the default case on
   /subscribed/, where the heading is followed directly by the return
   link. Settled by eye in prototypes/signup-spacing-prototype.html. */
.signup-confirmation h1 {
  font-family: 'Oswald', sans-serif;
  font-weight: 410;
  font-size: 1.5rem;
  letter-spacing: 0.02em;
  line-height: normal;
  color: var(--color-darker);
  margin: 0 0 1.7rem;
}
/* On /check-email/, the explanatory line belongs to the heading rather
   than sitting the standard paragraph distance from it, so the gap
   tightens well below the 1.7rem default above. Excludes .back-link so
   /subscribed/ keeps the full separation, which is what reads as "a
   separate second beat" there.

   A literal value, not var(--head-below): that token is scoped inside
   .views/.shifts and never reaches this page. Referencing it here
   previously left the declaration invalid at computed-value time, so
   this gap was silently collapsing to 0 rather than the intended
   distance -- found while measuring the prototype's starting values,
   fixed here. */
.signup-confirmation h1:has(+ p:not(.back-link)) { margin-bottom: 0.15rem; }

/* The explanatory paragraph's own gap down to the return link -- same
   1.7rem as the heading-to-link gap above, settled as one number rather
   than two coincidentally-equal ones, since both read as the same pause
   before the quiet exit line. Scoped to .signup-confirmation so
   .doc-measure's generic 1rem paragraph rhythm (About, Image Use) stays
   untouched. */
.signup-confirmation p:has(+ .back-link) { margin-bottom: 1.7rem; }

/* Both utility pages have no header or nav above them, so .doc's own
   3.5rem top margin (calibrated assuming those sit above it) reads as
   too close to the edge here. Settled by eye in
   prototypes/signup-spacing-prototype.html; the narrower value at the
   site's existing 767px tier lives with that ladder below, not a new
   query here. Scoped to .signup-confirmation so About and Image Use,
   which do have header and nav, are untouched. */
.doc.signup-confirmation { margin-top: 7rem; }

/* The 404 page (src/404.njk): the same bare shell as the confirmation
   pages and the same placement -- content top 7rem (5rem on phones, in
   the ladder below), the message-to-return-link gap 1.7rem -- under its
   own name rather than .signup-confirmation, which is those two pages'
   own and carries their heading rules. Same values, stated here so the
   two do not drift apart by accident; nothing else of theirs applies,
   since the 404 has no heading. */
.doc.not-found { margin-top: 7rem; }
.not-found p:has(+ .back-link) { margin-bottom: 1.7rem; }

/* ---------- phone width, 767px: the site's normal width-only tier ----------
   Same threshold as the Views/Shifts grids and the shared chrome
   above -- these rules were previously bundled into the portrait-only block
   below them, but each one's own stated reason is lack of horizontal room,
   not the viewport's shape, so they belong on the ladder everyone else
   already uses rather than gated by orientation. Split out of that block
   rather than rewritten: values are unchanged, only the trigger. */
@media (max-width: 767px) {
  /* --page-pad itself, not .stage's own padding: .calwrap reads the same
     variable (see :root and .stage above), so overriding it here moves
     the year strip's edges and the copy's together. Overriding .stage
     directly, as this rule used to, is exactly how the two drifted apart
     in the first place. Same number Views/Shifts already use for
     their own --pad-phone at this same width. */
  :root { --page-pad: 1.1rem; }
  /* .summary gets its inset from .stage's padding on Home, since .stage
     wraps it there. Fields has no such wrapper -- .field-swap and .summary
     sit directly in .fields, unwrapped, so its own .summary reads flush to
     the viewport edge at this width instead of sitting inset like Home's
     copy does. Scoped to this breakpoint only, and to padding alone,
     because .summary's max-width already keeps it centred with plenty of
     room to spare at every wider width; padding here would just shrink the
     measure for no reason. The frame and the dates row above it are left
     alone -- both are meant to run edge to edge. */
  .fields .summary { padding: 0 var(--page-pad); }
  /* .site-footer's padding is not otherwise responsive and isn't part of
     the --page-pad system above, so its left/right inset is a plain
     literal, kept numerically equal to --page-pad's value here by hand
     rather than by reference. If --page-pad's phone value ever changes,
     this needs updating too, or the footer row's left edge falls out of
     step with the copy above it again. padding-top is a separate concern
     (a real phone's visible height, not width) and stays in the portrait
     block below. */
  .site-footer { padding-left: 1.1rem; padding-right: 1.1rem; }
  /* The real narrow-screen summary-copy mechanism, shared by Home and
     Fields: .summary .ln and .summary .ex .ln both read --copy-size
     directly, and --copy-size is a custom property, so scoping it here
     reaches them by ordinary inheritance -- no redirect needed for
     either, unlike the font-size:0.94em rule this replaces, which set a
     property .ln always overrides with its own explicit value and so
     never actually reached the rendered text (traced in session; see
     git history for the two commits, seven minutes apart, where the
     wrap was fixed by narrowing .stage's padding and the ineffective
     type-shrink was added right after as a separate, never-rendered
     decision). Kept equal to the root value on purpose: this pass makes
     the mechanism real without changing what anything looks like. A
     smaller narrow-screen value is a visual decision for later, judged
     by eye against an actually-rendered size for the first time. */
  .summary { --copy-size: 0.9rem; }
  /* Stays one row, same relationship as desktop (copyright left, links
     right): flex-direction, align-items and justify-content are unchanged
     from the base rule. Only the gaps tighten here -- the narrower inset
     (set above) does the rest. --footer-size dropping to 0.86rem gave
     the nav row enough spare room to loosen its own gap from 0.5rem to
     0.65rem, while .footer-row's own gap stays at 0.9rem; 0.65rem is the
     current judged value, not derived from a specific measured width. No
     separators -- the site doesn't use them, space carries the
     distinction instead. */
  .footer-row { gap: 0.9rem; }
  .site-footer nav { gap: 0.65rem; }
  .footer-row .copyright,
  .footer-row nav a {
    white-space: nowrap;
  }
  /* Signup confirmation pages' content top position at this width --
     desktop value and reasoning sit with .doc.signup-confirmation in the
     "document pages" section above. Settled by eye in
     prototypes/signup-spacing-prototype.html. */
  .doc.signup-confirmation { margin-top: 5rem; }
  .doc.not-found { margin-top: 5rem; }
}

/* ---------- portrait phones, 600px: an explicit exception, not the site's
   general phone breakpoint ----------
   A 12:5 panorama is about 160pt tall here, so it can only ever be a band.
   Giving it air below makes that a decision rather than a squeeze. No rule
   for air above any more -- the gap above the band is whatever
   --sitenav-below already gives every page with a full-bleed frame,
   matching Fields rather than adding a second, Home-only layer on top of
   it.

   These two rules are visually calibrated against the narrow Home
   panorama specifically, and the calibration was done looking at a tall,
   narrow, portrait screen -- but that doesn't yet prove portrait/aspect
   ratio is the correct permanent trigger, only that it's the untested one.
   A narrow landscape window has never been judged by eye against these
   values, so they stay here, retained as visual exceptions pending that
   test, rather than promoted to settled aspect-driven rules or moved to
   the width-only tier above with everything else that shared this block. */
@media (max-width: 600px) and (orientation: portrait) {
  .calwrap { margin-top: 1.2rem; }
  .summary { margin-top: var(--p-band-below); }
  /* Copy size is no longer this block's concern: .summary's own
     --copy-size, scoped in the shared 767px tier above, already reaches
     .ln and .ex .ln by inheritance at this width too (767 and this
     600px+portrait test overlap; nothing here needs to touch it again).
     The redirect that used to live here for --p-copy-size is gone with
     that variable -- see :root above. */
  /* padding-top down from 3.5rem to 1.5rem (matching the bottom, rather
     than a third number) at this width only: Home's own content plus the
     desktop 3.5rem no longer fits inside a real phone's visible height
     without a short scroll to reach the footer. A fixed-size headless
     browser doesn't reproduce this -- Home measures well under the
     viewport in that environment at every phone size tried, which points
     at mobile browser chrome (the address bar) taking real space a
     static test window doesn't have to give up, not at anything
     measurable in markup or layout here. 1.5rem is a starting value, not
     a derived one; if a visible scroll remains on the device where this
     was reported, the next step down is 1rem, in the same place. A
     height claim, not a width one -- kept behind the portrait test here
     rather than moved above with padding-left/right, though max-height
     would likely describe it better; not changed as part of this pass.

     Effect on other pages, since this selector is unscoped and shared: on
     Views and Shifts the footer sits at the end of a long scroll,
     not pinned, so this only tightens the gap after the last grid row.
     On Fields, About, and Image Use the footer is pinned by its own
     margin-top: auto (content shorter than the viewport), so the same
     reduction shows as less air above the footer row, not a layout
     change. Confirmed directly: footer .getBoundingClientRect() is
     unpinned (sits below page content that already exceeds the viewport)
     on the first two, and pinned (its own bottom equals the viewport's)
     on the other three. */
  .site-footer { padding-top: 1.5rem; }
  /* After the page tail the footer keeps the desktop newsletter-to-footer
     gap instead of the tighter phone footer spacing other pages use. */
  .page-tail + .site-footer { padding-top: 3.5rem; }
}
@media (max-width: 600px) and (orientation: landscape) {
  .site-header { height: 3.2rem; }
  .site-title { font-size: 1.4rem; }
}

@media (prefers-reduced-motion: reduce) {
  .carousel figure, .carousel .overlay, .calmark { transition-duration: 1ms; }
}

/* ============ home: engaged state ============
   Clicking the carousel stops it in place as a manual viewer (index.njk,
   engage/disengage). Fields' stopped state around a Views presentation:
   the chrome fade, the zones, the cursors, and the Close highlight are
   the "fields: lightbox state" rules below, restated for Home rather
   than shared, so neither page can move the other; the date line, Close,
   and the image fade are the Views lightbox's.

   --home-chrome-fade is --fld-chrome-fade's value, not a new one. It
   lives on body for the same reason that one does: the header, nav, page
   tail, and footer are the carousel's siblings, not its descendants. The
   list below is every body-level block outside the viewer, which is why
   .page-tail is on it: the newsletter used to sit inside .stage and fade
   with it, and moving to the shared tail (page-tail.njk) put it outside
   every name here. Opacity with visibility, never display, so nothing
   leaves flow and nothing moves; visibility waits for the fade on the way
   out and is instant on the way back, as on Fields. The ground pill fades
   as part of the nav. */
body:has(.carousel) { --home-chrome-fade: 600ms; }
body:has(.carousel) .site-header,
body:has(.carousel) .site-nav,
body:has(.carousel) .calwrap,
body:has(.carousel) .stage,
body:has(.carousel) .page-tail,
body:has(.carousel) .site-footer {
  transition: opacity var(--home-chrome-fade) ease;
}
body.home-engaged .site-header,
body.home-engaged .site-nav,
body.home-engaged .calwrap,
body.home-engaged .stage,
body.home-engaged .page-tail,
body.home-engaged .site-footer {
  opacity: 0;
  visibility: hidden;
  transition: opacity var(--home-chrome-fade) ease, visibility 0s var(--home-chrome-fade);
}

/* The space around the image closes, so it reads as clickable. The
   zones sit above #pausebtn (z-index 11); the button's own cursor stops
   promising a click where the left zone is hidden. */
body.home-engaged { cursor: pointer; }
.carousel .lb-zone { z-index: 12; }
body.home-engaged .pausebtn { cursor: default; }
/* Idle cursor hide over the image, as .lb.idle (views: lightbox). */
body.home-idle .carousel .lb-zone,
body.home-idle .carousel .pausebtn { cursor: none; }
/* The pause graphic is ambient state: hidden while engaged even when Home
   is paused underneath, and back on exit if it still is. !important
   because togglePause() sets its opacity inline; the change runs on the
   overlay's own transition. */
body.home-engaged .carousel .overlay { opacity: 0 !important; }

/* The Views single fade while engaged: --lb-fade and the ghost's own
   easing (.lb-ghost). Reduced motion keeps Home's 1ms. */
body.home-engaged .carousel figure {
  transition-duration: var(--lb-fade);
  transition-timing-function: ease;
}
@media (prefers-reduced-motion: reduce) {
  body.home-engaged .carousel figure { transition-duration: 1ms; }
}

/* The date line and Close. .home-swap has no height of its own, so it
   changes nothing in flow; the block inside is absolutely positioned
   from the image's bottom edge and fades in over the space the strip and
   copy occupy, the way #fieldStoppedInfo covers the Fields note. z-index
   lifts it above the strip's positioned track while that fades out.
   --lb-btn is scoped to the Views lightbox, so Home carries its own copy
   of the same values, as .fields does. */
.home-swap {
  position: relative;
  --lb-btn: rgba(40,38,34,0.6);
}
html.dark .home-swap { --lb-btn: rgba(230,230,230,0.55); }
.home-stopped-info {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  z-index: 1;
  opacity: 0;
  visibility: hidden;
  transition: opacity var(--home-chrome-fade) ease, visibility 0s var(--home-chrome-fade);
}
body.home-engaged .home-stopped-info {
  opacity: 1;
  visibility: visible;
  transition: opacity var(--home-chrome-fade) ease, visibility 0s;
}
/* Centred below the caption at the viewers' first-control gap. */
.home-stopped-info .lb-close {
  display: block;
  width: fit-content;
  margin: calc(var(--lb-cap-to-btn) - var(--lb-btn-pad) - var(--lb-close-lift)) auto 0;
}
body.home-close-hot .home-stopped-info .lb-close { color: var(--lb-btn-hot); }

/* ============ views: grid ============
   Structural values from docs/PAGES.md, "Views page > Settled values".
   This is the grid only: month row, mode pills, and the lightbox are a
   later pass, so nothing here is interactive yet.

   --page-pad shadows the site-wide 2.35rem Home value on purpose. Same
   name, two jobs; see DECISIONS.md open decision 4. Scoping it inside
   .views rather than redeclaring it at :root keeps Home's year strip
   unaffected. */
.views {
  --active-cap: 1700px;
  --cols: 3;
  --gap-x: 0.15rem;
  --gap-y: 0.6rem;
  --gap-y-phone: 0.6rem;
  --pad-phone: 1.1rem;
  --page-pad: 3rem;
  --head-size: 1.5rem;
  --head-weight: 410;
  --head-track: 0.02em;
  --head-above: 2rem;
  --head-below: 0.6rem;
  /* The space above the first month section. It was tuned as the gap
     under the month row on arrival; the page copy sits between them now,
     so this is the gap that has to separate the copy from January and the
     photographs. Retuned by eye for that neighbour, and deliberately
     larger than the gap above the copy: the row and the copy are one
     entrance, the grid is the next thing. Shifts carries the same value
     in its own --first-top, for the same reason; the two are separate
     declarations, so they have to be kept in step by hand. */
  --first-top: 2.1rem;
  --date-gap: 0.05rem;

  /* width: 100%: see .calwrap above, same flex-stretch-suppression fix. */
  width: 100%;
  max-width: var(--active-cap);
  margin: 0 auto;
  /* 1rem below the last row, not 6rem: the footer's own 3.5rem top
     padding sits under it, so the last date label reaches the footer
     text at 4.5rem, in step with the copy pages, rather than 9.5rem.
     Views and Shifts carry the same value. */
  padding: 0 var(--page-pad) 1rem;
}

/* The two grid pages are where .summary sits under a row of chrome rather
   than under a full-bleed frame, and --copy-above (2.1rem) was judged for
   that other position. Here the section row and the copy read as one page
   entrance, so the copy sits closer to the row than the shared value puts
   it. Settled on Views and shared with Shifts by selector rather than
   restated, the way the section-nav tokens already are: one number, one
   place, so the two entrances cannot drift apart. Written as margin-top
   rather than a scoped --copy-above, because the portrait block in the
   shared copy section sets .summary's margin-top to --p-band-below
   outright: a custom property would never reach a portrait phone, which is
   exactly where this gap is worth having. Home and Fields are untouched.
   The Show page switch is the same row with a photograph under it in
   place of the copy, on Show and the Nuit Blanche page; the photograph
   row takes the same gap by the same selector (see "show"). */
.views .summary,
.shifts .summary,
.show > .show-switch + :is(.show-photo, .show-grid) { margin-top: 1.5rem; }

.views .month-section { padding-top: var(--head-above); }
/* first-of-type, not first-child: .month-nav is now main's actual first
   child, so it can inherit --active-cap and --page-pad from .views
   rather than rendering full-bleed as a sibling outside it. first-of-type
   still finds the first section regardless. */
.views .month-section:first-of-type { padding-top: var(--first-top); }

.views .month-heading {
  font-family: 'Oswald', sans-serif;
  font-weight: var(--head-weight);
  font-size: var(--head-size);
  letter-spacing: var(--head-track);
  color: var(--color-darker);
  margin: 0 0 var(--head-below);
  /* without this, jumping to a month tucks its heading under the sticky
     row instead of landing below it */
  scroll-margin-top: calc(var(--secnav-h) + var(--secnav-jump-pad));
}

.views .grid {
  display: grid;
  grid-template-columns: repeat(var(--cols), 1fr);
  column-gap: var(--gap-x);
  row-gap: var(--gap-y);
}

.views .item {
  display: block;
  border: 0;
  padding: 0;
  background: none;
  cursor: pointer;
  text-align: left;
  font: inherit;
  color: inherit;
}

.views .item img {
  width: 100%;
  height: auto;
  display: block;
}

.views .item .date {
  display: block;
  margin-top: var(--date-gap);
  font-size: var(--date-size);
  letter-spacing: var(--date-track);
  color: var(--color-mute);
  font-variant-numeric: tabular-nums;
}

/* Three thresholds, not one: 1020 and 640 govern column count, 767 governs
   padding and row-gap. The two systems are independent in the prototype
   and stay that way here.

   --page-pad: var(--pad-phone), not a literal on padding-left/right:
   Shifts' own phone breakpoint below already reads that way, so this
   is one mechanism for the same number rather than two. --pad-phone is
   1.1rem, matching Home's own phone inset -- edge to edge was tried (see
   docs/DECISIONS.md, "Superseded") and the images read better inset. The
   month heading and each thumbnail's date need no rule of their own at
   this width: they take the same inset from the grid's own padding, as
   they did before that experiment. */
@media (max-width: 1019px) {
  .views { --cols: 2; }
}
@media (max-width: 767px) {
  .views { --page-pad: var(--pad-phone); }
  .views .grid { row-gap: var(--gap-y-phone); }
}
@media (max-width: 639px) {
  .views { --cols: 1; }
}

/* ============ views: lightbox ============
   Ported from prototypes/views-prototype.html, minus the control panel,
   minus what DECISIONS.md overrode. Three kinds of change from a literal
   port, not a straight copy:

   1. Visibility is class-only (no [hidden]), per "Lightbox reconciliation
      between Views and Shifts". .lb goes from display:none to
      display:block on .open, rather than toggling a hidden attribute.
   2. The close focus-visible outline and the idle-cursor-hide rules below
      don't exist anywhere in the Views prototype and were authored
      fresh from Shifts' version, per "Shared lightbox module: settled
      values" (Close; Idle cursor hide).
   3. body.lb-sharpen and .lb-zone:focus are dropped: sharpen has no toggle
      to ship, and a span zone can't take focus, so the reset it existed
      for is unreachable.

   --lb-w, --lb-inset, --lb-chrome, and --lb-btn are scoped to
   .lb.chrome-below: Views-only values with no Shifts equivalent
   (confirmed while wiring Shifts -- it has its own sizing mechanism and
   its own button colour mechanism, color-mix rather than a flat colour).
   --lb-inset collides in name with Shifts' own side-room variable; see
   the .stacked override in the Shifts lightbox section below, which
   wins on specificity rather than source order.

   --lb-cap and --lb-btn-hot used to live here too, scoped on the guess
   that Shifts might need different values. Checked directly against
   the Intervals prototype: identical, light and dark. Promoted to :root
   / html.dark below, where the shared caption and button typography
   tokens already live. */
.lb.chrome-below {
  --lb-w: 2400px;
  --lb-inset: 0rem;
  --lb-chrome: 7rem;   /* room reserved below the image for caption and close */
  /* Air below Close. One value for a single panorama shown by itself,
     a View or a Shift in sequence view, declared here where both
     read it (Shifts' .lb.stacked used to declare it alone). Applied
     by body:has(.views) .lb-figure below and by .lb.stacked .lb-figure. */
  --lb-below-close: 2.9rem;
  --lb-btn: rgba(40,38,34,0.6);
  /* .lb-caption .date reads --lb-cap-tone unconditionally (a shared rule,
     also consumed by Shifts and Fields, both of which define it).
     Views never did -- the declaration was invalid at computed-value
     time and opacity, not being inherited, fell back to its own initial
     value, 1, which is what this states directly now. No visual change;
     removes a reliance on that fallback continuing to resolve the same
     way. */
  --lb-cap-tone: 1;
}
html.dark .lb.chrome-below {
  --lb-btn: rgba(230,230,230,0.55);
}

.lb {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 1000;
  cursor: pointer;
  opacity: 0;
  transition: opacity var(--lb-open) ease;
  background: hsl(var(--lb-bg-hsl) / var(--lb-bg));
  /* The overlay scrolls when a work's complete presentation is taller
     than the viewport, on every page: the photograph is width-led (see
     the width rule below) and its caption and controls follow it, so on
     a short or landscape viewport the block can run past the bottom and
     the way to Close is to scroll, the same as the stacked Shift.
     Previously only .lb.stacked scrolled, and Views' own image shrank to
     keep its block inside 100vh instead. The bar is hidden, as it was
     on Shifts: the image is the only thing to look at. */
  overflow-y: auto;
  overscroll-behavior-y: contain;
  scrollbar-width: none;
}
.lb::-webkit-scrollbar { display: none; }
/* Block, not grid: the figure below does the centring (safe, so a block
   taller than the viewport starts at the top and scrolls rather than
   losing its top edge), which is what .lb.stacked already did for
   Shifts; one mechanism now, since Views can overflow too. */
.lb.open { display: block; }
.lb.in { opacity: 1; }
.lb:focus { outline: none; box-shadow: none; }

/* #lbWork > img, not .lb.chrome-below img: the descendant form also
   matches Shifts' band images, buried inside .stack, and at (0,2,1) it
   outranks their own .band-p img sizing at (0,1,1). The ghost needs its
   own copy for the same reason -- during a crossfade the outgoing image
   moves to .lb-ghost, a sibling of #lbWork, and the direct-child rule
   above wouldn't reach it there. Shifts' ghost needs no equivalent:
   its sizing comes from .band-p img, a plain class selector that reaches
   any depth, ghost or not. */
.lb.chrome-below #lbWork > img,
.lb.chrome-below .lb-ghost > img {
  display: block;
  /* Width-led: the file's own width or the viewport's, less the inset.
     No viewport-height term any more -- it used to shrink the image so
     that image, caption and Close fit inside 100vh on a short or
     landscape viewport, at the photograph's expense. The photograph
     keeps its width and the overlay scrolls to the chrome instead (see
     .lb above), the same rule ordinary Sequence follows. Fit, where a
     page has it, is the mode for holding a work inside the viewport. */
  width: min(
    var(--lb-w),
    calc(100vw - var(--lb-inset) * 2)
  );
  height: auto;
  /* Fixed, known ratio, not read from the file: every panorama is 12:5,
     so the box's size is settled from CSS the instant it exists, not
     dependent on this particular image having decoded yet. Without it,
     .lb-imgwrap (shrink-to-fit, no explicit width of its own) collapses
     toward zero height for a frame on advance, taking the ghost with it
     -- the ghost is height: 100% of the same wrapper, so it collapses
     too even though its own image is already decoded. See DECISIONS.md,
     "Shared lightbox module: settled values". */
  aspect-ratio: var(--pano-aspect);
}
.lb img { filter: url(#lbSharpen); }
/* Safari (and every browser on iPhone and iPad, which all use Safari's
   engine) runs this SVG filter on the processor rather than the graphics
   chip, recalculating it for every frame of a crossfade: Views fades
   stutter and Shifts, three filtered frames at once, skip the fade
   entirely. There the photographs are shown unsharpened instead. The
   class is set by the inline script in base.njk. */
html.webkit-render .lb img { filter: none; }

.lb-figure {
  margin: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  min-height: 100%;
  /* safe centring, or a figure taller than the viewport is clipped at
     the top with no way to scroll back up to it */
  justify-content: safe center;
}
.lb-imgwrap { position: relative; display: block; }

/* Views: air below Close, so that when a short viewport scrolls the
   overlay to its end (see .lb above) Close does not sit flush on the
   bottom edge. --lb-below-close, the same 2.9rem a Shift in sequence
   view has: a View and a sequence-view Shift are the same thing, one
   panorama shown by itself, and read the same way. Scoped by page, the
   way --sitenav-below is, because the Show photograph ring shares
   .lb.chrome-below without .stacked and keeps its own geometry. Where
   the whole block fits, the padding is part of what gets centred, which
   moves the image up by half of it. */
body:has(.views) .lb-figure { padding-bottom: var(--lb-below-close); }

.lb-caption {
  margin-top: var(--lb-cap-gap);
  color: var(--lb-cap);
  font-size: var(--lb-base-size);
  font-weight: var(--lb-cap-weight);
  letter-spacing: var(--lb-cap-track);
  line-height: 1;
  font-variant-numeric: tabular-nums;
  text-align: center;
}

/* Caption to Close less the button's own top padding, the gap every
   viewer's first control takes (.lb-fit, .fields .lb-close). The stacked
   Close, second or third, overrides this with its own gap below. */
.lb-close {
  margin-top: calc(var(--lb-cap-to-btn) - var(--lb-btn-pad) - var(--lb-close-lift));
  background: none;
  border: 0;
  color: var(--lb-btn);
  cursor: pointer;
  font-family: 'IBM Plex Sans', sans-serif;
  font-size: var(--lb-base-size);
  letter-spacing: var(--lb-btn-track);
  text-transform: uppercase;
  padding: var(--lb-btn-pad) 0.9rem;
  transition: color 0.15s ease;
}
.lb-close:hover,
.lb-close:focus-visible,
.lb.close-hot .lb-close { color: var(--lb-btn-hot); }
/* Over the caption's interaction box (lightbox.js, inCaptionBox: the
   caption's content box plus 12px) the overlay is not backdrop: close-hot
   off, the ordinary arrow, and the I-beam only over the rendered text
   itself (caption-text). Set by the module's own pointermove, the same
   way close-hot is, so the cursor never disagrees with what a click
   there does. */
.lb.caption-hot { cursor: default; }
.lb.caption-hot.caption-text { cursor: text; }

/* The image itself is the navigation: left half back, right half forward.
   The 0% fallback is Views' whole story (no centre zone exists here);
   a Shifts zoom mode would drive --lb-centre instead of this rule. */
.lb-zone {
  position: absolute;
  top: 0;
  bottom: 0;
  width: calc((100% - var(--lb-centre, 0%)) / 2);
  background: none;
  border: 0;
  padding: 0;
  appearance: none;
}
.lb-zone-prev {
  left: 0;
  cursor: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%3E%3Cpath%20d%3D%22M12.5%204.5%20L6.5%2010%20L12.5%2015.5%22%20fill%3D%22none%22%20stroke%3D%22rgba%280%2C0%2C0%2C0.5%29%22%20stroke-width%3D%223.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3Cpath%20d%3D%22M12.5%204.5%20L6.5%2010%20L12.5%2015.5%22%20fill%3D%22none%22%20stroke%3D%22%23ffffff%22%20stroke-width%3D%221.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E") 10 10, pointer;
}
.lb-zone-next {
  right: 0;
  cursor: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%3E%3Cpath%20d%3D%22M7.5%204.5%20L13.5%2010%20L7.5%2015.5%22%20fill%3D%22none%22%20stroke%3D%22rgba%280%2C0%2C0%2C0.5%29%22%20stroke-width%3D%223.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3Cpath%20d%3D%22M7.5%204.5%20L13.5%2010%20L7.5%2015.5%22%20fill%3D%22none%22%20stroke%3D%22%23ffffff%22%20stroke-width%3D%221.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E") 10 10, pointer;
}

/* The outgoing content is left on top and faded out, revealing the incoming
   one already in place beneath it. A true crossfade rather than a fade to
   black. */
.lb-ghost {
  display: block;
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  opacity: 1;
  pointer-events: none;
  transition: opacity var(--lb-fade) ease;
}
.lb-ghost.out { opacity: 0; }

/* Idle cursor hide: ships on both pages per DECISIONS.md, "Shared lightbox
   module: settled values". Authored here from Shifts' version; nothing
   to port from Views, which never had this feature. */
.lb.idle .lb-imgwrap,
.lb.idle .lb-zone,
.lb.idle .lb-zone-centre,
.lb.idle img { cursor: none; }

@media (max-width: 767px) {
  /* full-bleed on phones: the gain over a one-column thumbnail is only
     worth a transition if the image actually reaches the edges */
  .lb.chrome-below #lbWork > img,
  .lb.chrome-below .lb-ghost > img { width: 100vw; }
  .lb-caption { padding: 0 1rem; }
}

/* ============ shifts: stacked work ============
   What a Shift looks like, used by the grid thumbnail and the
   lightbox's #lbWork alike, so these are shared rather than scoped to
   .shifts -- the lightbox include sits outside <main class="shifts">,
   so a .shifts-scoped rule never reaches it. .band-time is
   CSS-invisible here (display:none) and aria-hidden regardless; nothing
   in the current CSS or JS sets it to anything else, on the grid or
   inside the lightbox -- its own typographic rule below is unreachable
   as the site stands today. Left as found; not this pass's decision. */
.stack {
  display: flex;
  flex-direction: column;
  /* cqw, not %: a percentage row-gap resolves against the container's
     height, which here is indefinite, so it would silently come out as
     zero. The container has to be an ancestor of the stack, not the
     stack itself, since container units never read the element they are
     written on -- .item in the grid, .lb-imgwrap in the lightbox. */
  gap: max(var(--band-gap-floor), calc(var(--band-gap-pct) * 1cqw));
}

.band-slot { display: block; }
.band-slot:last-child .band-time { margin-bottom: 0; }

.band-p {
  display: block;
  aspect-ratio: var(--pano-aspect);
  overflow: hidden;
}
.band-p img {
  width: 100%;
  height: 100%;
  display: block;
  object-fit: cover;
}

.band-time {
  display: none;
  margin-top: var(--band-time-gap);
  margin-bottom: var(--band-time-below);
  text-align: center;
  font-family: 'IBM Plex Sans', sans-serif;
  font-weight: 300;
  font-size: var(--band-time-size);
  letter-spacing: var(--band-time-track);
  color: var(--lb-cap);
  font-variant-numeric: tabular-nums;
  line-height: 1;
}

/* ============ shifts: grid ============
   Structural values from prototypes/intervals-prototype.html, minus the
   control panel, minus what DECISIONS.md overrode. Month markers, the
   exhibited-first grouping mode, and the season bar are not part of this
   pass; see "Grid breaks on Shifts" for why. */
.shifts {
  --active-cap: 1700px;
  --cols: 3;
  --gap-x: 1.3rem;
  --gap-y: 2.1rem;
  --page-pad: 3rem;   /* shadows the Home value; see DECISIONS.md open decision 4 */
  --gap-x-phone: 0.6rem;
  --gap-y-phone: 2.25rem;
  --pad-phone: 1.1rem;
  /* The space above the first season section, the gap that separates the
     page copy from the first grid. Views' settled value, shared as a
     number rather than by reference because each grid page declares its
     own --first-top; see .views for the reasoning. It was 0.6rem when the
     season bar and the first heading were neighbours. */
  --first-top: 2.1rem;
  --head-size: 1.5rem;
  --head-above: 2rem;
  --head-below: 0.6rem;
  --head-weight: 410;
  --head-track: 0.02em;
  --date-gap: 0.05rem;

  /* width: 100%: see .calwrap above, same flex-stretch-suppression fix.
     This is the one that visibly collapsed to a single narrow column. */
  width: 100%;
  max-width: var(--active-cap);
  margin: 0 auto;
  /* 1rem below the last row, not 6rem: the footer's own 3.5rem top
     padding sits under it, so the last date label reaches the footer
     text at 4.5rem, in step with the copy pages, rather than 9.5rem.
     Views and Shifts carry the same value. */
  padding: 0 var(--page-pad) 1rem;
}

.shifts .section { padding-top: var(--head-above); }
/* first-of-type, not first-child: .season-nav is now main's actual first
   child, so it can inherit --active-cap and --page-pad from .shifts
   rather than rendering full-bleed as a sibling outside it. first-of-type
   still finds the first section regardless. */
.shifts .section:first-of-type { padding-top: var(--first-top); }

.shifts .month-heading {
  font-family: 'Oswald', sans-serif;
  font-weight: var(--head-weight);
  font-size: var(--head-size);
  letter-spacing: var(--head-track);
  color: var(--color-darker);
  margin: 0 0 var(--head-below);
  /* without this, jumping to a season tucks its heading under the sticky
     row instead of landing below it */
  scroll-margin-top: calc(var(--secnav-h) + var(--secnav-jump-pad));
}

.shifts .grid {
  display: grid;
  grid-template-columns: repeat(var(--cols), 1fr);
  column-gap: var(--gap-x);
  row-gap: var(--gap-y);
}

.shifts .item {
  display: block;
  border: 0;
  padding: 0;
  background: none;
  cursor: pointer;
  text-align: left;
  font: inherit;
  color: inherit;
  overflow: hidden;
}

.shifts .item { container-type: inline-size; }

.shifts .item .date {
  display: block;
  margin-top: var(--date-gap);
  font-size: var(--date-size);
  letter-spacing: var(--date-track);
  color: var(--color-mute);
  font-variant-numeric: tabular-nums;
}

/* Grid items paint atomically, so where the shared focus ring reaches
   across the 2px gap into the next photograph it would be painted under
   that photograph. Lifting the focused item draws it on top. */
.views .item:focus-visible,
.shifts .item:focus-visible,
.show .item:focus-visible { z-index: 1; }

/* Three thresholds, not one: 1019 and 639 govern column count, matching
   Views' own tiers, 767 governs gap and page padding. The two systems
   are independent on Views and stay that way here. */
@media (max-width: 1019px) {
  .shifts { --cols: 2; }
}

/* --pad-phone is 1.1rem, matching Home's own phone inset, same mechanism
   as Views above. Edge to edge was tried here too and reversed for
   the same reason -- see docs/DECISIONS.md, "Superseded". The season
   heading and each thumbnail's date need no rule of their own: they take
   the same inset from the grid's own padding. */
@media (max-width: 767px) {
  .shifts {
    --gap-x: var(--gap-x-phone);
    --gap-y: var(--gap-y-phone);
    --page-pad: var(--pad-phone);
  }
}

@media (max-width: 639px) {
  .shifts { --cols: 1; }
}

/* ============ shifts: lightbox ============
   Ported from prototypes/intervals-prototype.html, minus the control
   panel, minus what DECISIONS.md overrode. Chrome is below-only now (see
   "Lightbox caption on Shifts"), so Shifts shares .lb.chrome-below
   with Views. .stacked marks the element as Shifts' own, for the
   handful of rules that collide with Views' if left unscoped: display
   on .open, .lb-figure's layout, --lb-inset's value, and .lb-close's
   colour and gap. Everything else below (.lb-fit, .lb-seq,
   .lb-zone-centre, --lb-den, --lb-centre, the seq rules) is a class or
   property Views has no equivalent for, so it's written plain,
   matching the prototype.

   Not ported: syncFitChrome, --lb-fit-chrome, --lb-fit-extra, and their
   call sites -- dead under fit-full (see "Shifts lightbox sizing").
   body.lb-width and body.fit-full don't ship as toggles either; both are
   permanently on in the settled state, so their effect is written
   directly into the rules below rather than gated behind a body class
   nothing sets. --lb-room's only job was choosing between --lb-gutter
   and --lb-inset for chrome-side vs chrome-below; chrome-side never
   happens, so the indirection goes and the formulas read --lb-inset
   directly. */
.lb.stacked {
  --lb-cap-tone: 1;
  --lb-cap-align: center;
  --lb-pad-top: 0rem;
  --lb-btn-gap: 1.1rem;
  --lb-btn-gap-2: 1.1rem;
  --lb-btn-tone: 0.37;
  --lb-btn-tone-hot: 1;
  /* 0% by default now, not 20% -- the centre zone only exists when Zoom
     does (see the media query just below this block), matching
     Views' own 0% fallback for a page with no centre zone at all
     (comment above .lb-zone). Restored to 20% there when the same
     geometry test that shows .lb-fit passes. */
  --lb-centre: 0%;
  --lb-centre-fit: 33%;
  --lb-times-gap: 0.8rem;
  --lb-times-between: 0.45rem;
  --lb-times-size: 0.94;      /* multiple of the date size */
  --lb-times-weight: 200;
  --lb-times-track: 0.04em;
  --lb-times-tone: 0.78;
  --lb-exh: #8d877e;
  --lb-exh-gap: 1rem;
  --lb-exh-size: 0.94;        /* multiple of the date size */
  --lb-exh-weight: 400;
  --lb-exh-track: 0.02em;
  --lb-exh-tone: 1;
  --lb-seq-time-weight: 300;
  --lb-seq-time-tone: 1;
}
/* Scrolling, the hidden bar, display:block on .open and the figure's
   safe centring used to be declared here for Shifts alone; they are
   the overlay's own now (.lb and .lb-figure above), since Views scrolls
   the same way. Only the stacked figure's padding is still its own. */

/* Same test as .lb-fit's own display rule further down (441/500 and
   605px; see that rule's comment for the --lb-den derivation) -- kept
   as a second, separate copy rather than one shared block because this
   one has to stay before .lb.fit below to win their tie (both are
   (0,2,0); source order decides, per "Shifts lightbox sizing" in
   DECISIONS.md). With --lb-centre restored to 20% here, .lb-zone-centre
   (width: var(--lb-centre)) regains a real width to click, and
   .lb-zone's own calc((100% - var(--lb-centre, 0%)) / 2) narrows prev/
   next to make room for it -- the same pair of effects unwinding
   together that .lb.seq already relies on at 0% below. Below this
   floor, --lb-centre stays 0%: the centre zone has zero width (nothing
   to tap), and prev/next expand to fill the image between them, same
   shape as Views' page-wide 0% fallback. */
@media (min-aspect-ratio: 441/500) and (min-height: 605px) {
  .lb.stacked { --lb-centre: 20%; }
}

.lb.chrome-below.stacked { --lb-inset: 1.5rem; }

.lb.stacked .lb-figure {
  padding: var(--lb-pad-top) 0 var(--lb-below-close);
}

/* Stack height is 3 bands plus 2 gaps, and the gap is a share of the
   width, so height = width x den. Solving for width avoids referring to
   the gap, which would be a custom-property cycle and resolve to
   nothing. */
.lb.stacked { --lb-den: calc(3 / var(--pano-aspect) + 2 * var(--band-gap-pct) / 100); }

/* Width mode, permanently on: the window sets the size. */
.lb.stacked:not(.fit) { --lb-w-calc: 100vw; }

/* Fit mode: the whole work on screen, no scroll. */
.lb.fit {
  --lb-w-calc: min(
    calc(100vw - var(--lb-inset) * 2),
    calc(100vh / var(--lb-den))
  );
}
.lb.fit .lb-figure { justify-content: safe start; }

/* container-type here, not on the shared .lb-imgwrap rule: it implies
   contain, which treats the box's content as contributing nothing to its
   intrinsic width. .lb-imgwrap has an explicit width right above, so
   there's nothing left for that to break. On Views, .lb-imgwrap has
   no explicit width and depends on shrink-to-fit around its image;
   containment broke that sizing and was fixed by moving container-type
   here, scoped to the one page whose width doesn't depend on it. See
   DECISIONS.md, "Shared lightbox module: settled values". */
.lb.stacked .lb-imgwrap { width: var(--lb-w-calc); container-type: inline-size; }
.lb-imgwrap .stack { width: 100%; }

/* Shifts' own button colour: color-mix from --lb-btn-hot and a tone
   percentage, not the flat --lb-btn Views uses. Confirmed different
   from Views' calibrated value against the prototype directly, not
   just a different way of writing the same thing, so kept as its own
   mechanism. */
.lb.stacked .lb-close {
  color: color-mix(in srgb, var(--lb-btn-hot) calc(var(--lb-btn-tone) * 100%), transparent);
  /* Close is the third button here, not the first, so its gap is the
     button-to-button value, not the caption-to-first-button one
     .lb-close uses elsewhere. */
  margin-top: calc(var(--lb-btn-gap-2) - var(--lb-btn-pad) * 2);
}
.lb.stacked .lb-close:hover,
.lb.stacked .lb-close:focus-visible,
.lb.stacked.close-hot .lb-close {
  color: color-mix(in srgb, var(--lb-btn-hot) calc(var(--lb-btn-tone-hot) * 100%), transparent);
}

.lb-fit,
.lb-seq {
  cursor: pointer;
  color: color-mix(in srgb, var(--lb-btn-hot) calc(var(--lb-btn-tone) * 100%), transparent);
  font-family: 'IBM Plex Sans', sans-serif;
  font-size: var(--lb-base-size);
  letter-spacing: var(--lb-btn-track);
  text-transform: uppercase;
  padding: var(--lb-btn-pad) 0.9rem;
  line-height: 1;
  border: 0;
  background: none;
  transition: color 0.15s ease;
}
.lb-fit:hover, .lb-fit:focus-visible,
.lb-seq:hover, .lb-seq:focus-visible {
  color: color-mix(in srgb, var(--lb-btn-hot) calc(var(--lb-btn-tone-hot) * 100%), transparent);
}
.lb-fit { margin-top: calc(var(--lb-cap-to-btn) - var(--lb-btn-pad)); }
/* Seq defaults to the first-button gap (--lb-cap-to-btn, same formula as
   .lb-fit above), not the smaller button-to-button one: with Zoom hidden
   by the media query below, Seq IS the first button, and nothing else
   would carry that gap once Zoom stops being visible -- .lb-fit's own
   margin-top is moot on a hidden element, and .lb.seq's own guard for
   sequence view (below) only fires once .lb.seq is actually applied, not
   whenever Zoom happens to be absent. Narrowed back to --lb-btn-gap
   inside the same media query .lb-fit's display rule uses, since Zoom
   showing again puts Seq second. */
.lb-seq { margin-top: calc(var(--lb-cap-to-btn) - var(--lb-btn-pad)); }

/* Zoom only earns its place when Fit actually saves meaningful width and
   the result still holds up as a photograph -- an aspect-ratio/height
   test derived from the same --lb-den arithmetic that already drives
   .lb.fit's own sizing above, not a device breakpoint.

   --lb-den = 3/--pano-aspect + 2*--band-gap-pct/100. With the current
   --pano-aspect: 2.4 and --band-gap-pct: 0.5, --lb-den = 1.26. Fit's
   height branch (100vh/--lb-den) beats its width branch
   (100vw - 2*--lb-inset) somewhere close to viewport aspect ratio
   1/1.26 = 0.7937 -- the stack's own natural shape -- shifted slightly
   by the inset subtracted from the width side, which this threshold
   doesn't need to track precisely: requiring Fit to save at least 10%
   of full width (K: 0.90) pushes the aspect-ratio floor to
   1/(0.90*1.26) = 0.882 (441/500), comfortably clear of that shift
   either way.

   min-height: 605px comes from requiring the same height-branch fit
   width (100vh/--lb-den) to clear a 480px floor for still reading as a
   photograph rather than a strip: 480*1.26 = 604.8px.

   Both are literal numbers, not var()s, because media queries can't
   read custom properties -- if --pano-aspect, --band-gap-pct, the 0.90
   materiality factor, or the 480px floor ever change, these two need
   recalculating by hand. Provisional; Steven is judging both by eye. */
.lb-fit { display: none; }
@media (min-aspect-ratio: 441/500) and (min-height: 605px) {
  .lb-fit { display: inline-block; }
  /* Zoom visible again means Seq is back to being the second button. */
  .lb-seq { margin-top: calc(var(--lb-btn-gap) - var(--lb-btn-pad)); }
}

/* The image itself is the navigation, with a narrow zone through the
   middle that toggles zoom. No tint or outline on any of them: a
   rectangle appearing over the work is worse than not knowing where the
   edge is. The cursor is the whole affordance. */
.lb-zone-centre {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 50%;
  transform: translateX(-50%);
  width: var(--lb-centre);
  cursor: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%3E%3Cpath%20d%3D%22M3%203%20L8%208%22%20fill%3D%22none%22%20stroke%3D%22rgba%280%2C0%2C0%2C0.5%29%22%20stroke-width%3D%223.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3Cpath%20d%3D%22M8%204.5%20L8%208%20L4.5%208%22%20fill%3D%22none%22%20stroke%3D%22rgba%280%2C0%2C0%2C0.5%29%22%20stroke-width%3D%223.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3Cpath%20d%3D%22M17%2017%20L12%2012%22%20fill%3D%22none%22%20stroke%3D%22rgba%280%2C0%2C0%2C0.5%29%22%20stroke-width%3D%223.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3Cpath%20d%3D%22M12%2015.5%20L12%2012%20L15.5%2012%22%20fill%3D%22none%22%20stroke%3D%22rgba%280%2C0%2C0%2C0.5%29%22%20stroke-width%3D%223.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3Cpath%20d%3D%22M3%203%20L8%208%22%20fill%3D%22none%22%20stroke%3D%22%23ffffff%22%20stroke-width%3D%221.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3Cpath%20d%3D%22M8%204.5%20L8%208%20L4.5%208%22%20fill%3D%22none%22%20stroke%3D%22%23ffffff%22%20stroke-width%3D%221.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3Cpath%20d%3D%22M17%2017%20L12%2012%22%20fill%3D%22none%22%20stroke%3D%22%23ffffff%22%20stroke-width%3D%221.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3Cpath%20d%3D%22M12%2015.5%20L12%2012%20L15.5%2012%22%20fill%3D%22none%22%20stroke%3D%22%23ffffff%22%20stroke-width%3D%221.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E") 10 10, pointer;
}

/* Zoomed in the image is far narrower, so the same percentage would be a
   much smaller target. .lb.fit ties with .lb.stacked at (0,2,0), so this
   has to come after it to win. */
.lb.fit { --lb-centre: var(--lb-centre-fit); }

/* zoomed in already, so the middle offers the way back out */
.lb.fit .lb-zone-centre { cursor: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%20viewBox%3D%220%200%2020%2020%22%3E%3Cpath%20d%3D%22M8%208%20L3%203%22%20fill%3D%22none%22%20stroke%3D%22rgba%280%2C0%2C0%2C0.5%29%22%20stroke-width%3D%223.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3Cpath%20d%3D%22M3%206.5%20L3%203%20L6.5%203%22%20fill%3D%22none%22%20stroke%3D%22rgba%280%2C0%2C0%2C0.5%29%22%20stroke-width%3D%223.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3Cpath%20d%3D%22M12%2012%20L17%2017%22%20fill%3D%22none%22%20stroke%3D%22rgba%280%2C0%2C0%2C0.5%29%22%20stroke-width%3D%223.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3Cpath%20d%3D%22M17%2013.5%20L17%2017%20L13.5%2017%22%20fill%3D%22none%22%20stroke%3D%22rgba%280%2C0%2C0%2C0.5%29%22%20stroke-width%3D%223.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3Cpath%20d%3D%22M8%208%20L3%203%22%20fill%3D%22none%22%20stroke%3D%22%23ffffff%22%20stroke-width%3D%221.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3Cpath%20d%3D%22M3%206.5%20L3%203%20L6.5%203%22%20fill%3D%22none%22%20stroke%3D%22%23ffffff%22%20stroke-width%3D%221.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3Cpath%20d%3D%22M12%2012%20L17%2017%22%20fill%3D%22none%22%20stroke%3D%22%23ffffff%22%20stroke-width%3D%221.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3Cpath%20d%3D%22M17%2013.5%20L17%2017%20L13.5%2017%22%20fill%3D%22none%22%20stroke%3D%22%23ffffff%22%20stroke-width%3D%221.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E") 10 10, pointer; }

/* .lb.idle .lb-zone-centre (idle-hide, above) and .lb.fit .lb-zone-centre
   (just above) both land on cursor at (0,3,0) and can both be on at once,
   so idle never hid the cursor here in fit mode -- source order let fit's
   custom cursor win regardless. Fixed with a compound selector rather than
   reordering: (0,4,0) outranks both unconditionally, so it doesn't depend
   on staying below .lb.fit .lb-zone-centre in the file. See DECISIONS.md,
   "Shifts lightbox sizing". */
.lb.idle.fit .lb-zone-centre { cursor: none; }

/* .lb.seq and .lb.fit tie too, and both can be on the element at once;
   already recorded in DECISIONS.md, "Shifts lightbox sizing" -- same
   fix, same reason, has to come after .lb.fit to win. */
.lb.seq { --lb-centre: 0%; }
.lb.seq .lb-zone-centre { display: none; }

/* ---------- Sequence view ---------- */

.lb.seq .stack {
  position: relative;
  display: block;
  aspect-ratio: var(--pano-aspect);
}
.lb.seq .band-slot {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity var(--lb-fade) ease;
}
.lb.seq .band-slot.on { opacity: 1; }
.lb.seq-instant .band-slot { transition: none; }
.lb.seq .band-p { height: 100%; }
.lb.seq .lb-figure { justify-content: safe center; }
.lb.seq { --lb-den: calc(1 / var(--pano-aspect)); }
.lb.seq .lb-fit { display: none; }

/* A compound selector, not .lb.seq alone relying on coming after .lb.fit
   in the file to win their tie: this file has already had two silent
   failures from a position-resolved tie (the idle-cursor-hide and
   --lb-centre fixes above), so a fit+seq width formula that could go
   quiet again if the rules ever move is worth the extra specificity.
   --lb-inset is dropped entirely, not carried over at a reduced value:
   it's a decision about the three-band stack's own side margins, not
   about a single panorama, which is what Views' own lightbox already
   shows edge to edge. See DECISIONS.md, "Shifts lightbox sizing". */
.lb.fit.seq { --lb-w-calc: min(100vw, calc(100vh / var(--lb-den))); }

/* With Zoom hidden, Seq is the first visible button, so it takes the
   caption-to-first-button gap instead of its normal button-to-button one.
   The prototype writes this as .lb.seq .lb-below .lb-seq; the include
   never renders .lb-below in below mode, so that selector doesn't match
   here. .lb.seq .lb-seq is the equivalent for the flat markup. */
.lb.seq .lb-seq {
  margin-top: calc(var(--lb-cap-to-btn) - var(--lb-btn-pad));
}

/* ---------- Caption content ---------- */

.lb-caption .date {
  display: block;
  font-size: var(--lb-base-size);
  font-weight: var(--lb-cap-weight);
  letter-spacing: var(--lb-cap-track);
  opacity: var(--lb-cap-tone);
}
.lb-caption .inline-time {
  font-weight: var(--lb-seq-time-weight);
  opacity: var(--lb-seq-time-tone);
}
.lb-caption .times {
  display: block;
  margin-top: var(--lb-times-gap);
  font-size: calc(var(--lb-base-size) * var(--lb-times-size));
  font-weight: var(--lb-times-weight);
  letter-spacing: var(--lb-times-track);
  opacity: var(--lb-times-tone);
  line-height: 1;
}
/* Stacked, the caption has the same shape as the work: three moments
   read top to bottom in the order they were scrolled through. */
.lb-caption .times.stacked .t { display: block; }
.lb-caption .times.stacked .t + .t { margin-top: var(--lb-times-between); }
.lb-caption .exh {
  display: block;
  margin-top: var(--lb-exh-gap);
  font-size: calc(var(--lb-base-size) * var(--lb-exh-size));
  font-weight: var(--lb-exh-weight);
  letter-spacing: var(--lb-exh-track);
  color: var(--lb-exh);
  opacity: var(--lb-exh-tone);
  line-height: 1;
}

@media (max-width: 767px) {
  .lb.chrome-below.stacked { --lb-inset: 0.75rem; }
  /* On a phone the work always starts at the top, at the figure's own
     --lb-pad-top, whether or not it fits: a stack that happens to be
     shorter than a tall viewport would otherwise sit in the middle of it
     with an empty band above, and there is nothing on a phone for that
     centring to say. Desktop and tablet keep safe center for the
     fits-or-scrolls behaviour above. Sequence view is excluded: one
     panorama at a time is what Views shows, and Views centres, so the
     .lb.seq figure rule above keeps its safe center here too. Fit mode
     is already start-aligned. */
  .lb.stacked:not(.seq) .lb-figure { justify-content: safe start; }
}

/* ============ fields ============
   Ported from prototypes/fields-prototype.html, minus the control panel,
   minus the header/site-nav/mode-pill values it carried as its own stale,
   independently drifted copy of shared chrome (Fields inherits the real
   .site-header/.site-nav rules above like every other page). No grid: a
   single frame the page's own script keeps redrawing, closer in shape to
   Home's carousel than to the Views/Shifts grids. Clicking it opens
   a lightbox STATE rather than an overlay -- see docs/DECISIONS.md,
   "Fields lightbox state". The chrome-fade and close-button rules for
   that state are their own section below; what follows here is the frame
   itself.

   --fld-fade/--fld-stagger/--fld-hold are their own names, not --fade:
   --fade is already Home's carousel transition (3000ms) and a second,
   unrelated 900ms value under the same name at :root would silently win
   or lose depending on source order rather than page. Same reasoning as
   --secnav-* elsewhere in this file. Declared on .fields, not :root, so
   Home's value is untouched -- same pattern as .views/.shifts
   shadowing --page-pad.

   --lb-btn is normally scoped to .lb.chrome-below (Views' own Close).
   #fieldsClose reuses the .lb-close class outside any .lb ancestor, so it
   needs its own copy of the same calibrated colour, the same way
   --doc-modal-btn does above. --lb-cap-tone, the --lb-times-* family, and
   --lb-below-close are the same situation: scoped to .lb.stacked
   (Shifts) rather than :root, so #fieldCaption's reuse of
   .lb-caption .times.stacked below, and the space-below-Close rule
   further down, need their own copies of Shifts' calibrated values
   too. */
.fields {
  --fld-fade:    900ms;
  --fld-stagger: 450ms;
  --fld-hold:    7000ms;
  --dates-above: 0.15rem;
  --dates-size:  0.72rem;
  --dates-track: 0.015em;
  /* The gap between a slot's date and its time -- the same relationship
     --lb-times-col-gap (0.7em, below) gives the stopped state's own
     date/time pairs, but not the same token: that value was calibrated
     for one centred, single-column caption, not five competing slots
     sharing a full-bleed row. This row only ever renders at >=768px
     (.fields .dates is hidden below that), where each of the five flex
     slots has well over 100px to work with against a ~95px-wide
     "YYYY-MM-DD HH:MM" string at --dates-size -- width isn't the binding
     constraint here, so 0.5em is chosen for a visible jump from the old
     uncontrolled space-character gap while staying clearly under 0.7em,
     rather than for fitting a tight column. */
  --dates-gap:   0.5em;
  --lb-btn:      rgba(40,38,34,0.6);
  --lb-cap-tone:      1;
  --lb-times-gap:     0.8rem;
  --lb-times-between: 0.45rem;
  --lb-times-size:    0.94;
  --lb-times-weight:  200;
  --lb-times-track:   0.04em;
  --lb-times-tone:    0.78;
  --lb-below-close:   2.9rem;
  /* Not shared with Shifts: its own times are time-only, one shared
     date already sitting on the line above. A Field's five sources each
     carry their own date, so its list needs a column to put it in --
     this token has no Shifts equivalent to read instead. */
  --lb-times-col-gap: 0.7em;
}
html.dark .fields { --lb-btn: rgba(230,230,230,0.55); }

/* 12:5, the same ratio as a panorama, so the frame sits at the same size a
   panorama would. var(--pano-aspect), not a literal 2400/1000: it is the
   same fact about the source material Shifts' lightbox already reads
   from that token, not a Fields-specific number. Background formula
   matches .carousel's exactly, by design -- both are a full-bleed frame
   waiting on its first image. */
.fields .field {
  position: relative;
  width: 100%;
  aspect-ratio: var(--pano-aspect);
  overflow: hidden;
  background: hsl(var(--bg-hue) var(--bg-sat) calc(var(--bg-l) - 3%));
}

/* The five slices are the image. The pause button is a sibling rather than
   a child, because an ARIA img role flattens its descendants and would
   hide the button from assistive technology. */
.fields .slots {
  position: absolute; inset: 0;
  display: flex;
}

.fields .slot {
  position: relative;
  flex: 1 1 0;
  min-width: 0;
  overflow: hidden;
}
/* Five slots across, so the panorama is five times the slot width and
   slides left by whole slots. Nothing is scaled or cropped vertically,
   which is why the buildings register across the seams. Every slot loads
   the full 2400px panorama; there is no per-slice export. */
.fields .slot img {
  position: absolute;
  top: 0; left: 0;
  height: 100%;
  width: 500%;
  max-width: none;
  transform: translateX(calc(var(--n) * -20%));
  opacity: 0;
  transition: opacity var(--fld-fade) ease-in-out;
}
.fields .slot img.on { opacity: 1; }

/* ============ fields: dates ============
   One under each slice, set at the slice's left edge, so the date reads as
   the label of the slice that starts there. A Field has five moments and
   no single date. */
.fields .dates {
  display: flex;
  width: 100%;
  margin-top: var(--dates-above);
  font-size: var(--dates-size);
  letter-spacing: var(--dates-track);
  color: var(--color-mute);
  font-variant-numeric: tabular-nums;
  /* The five spans below are empty until their own slot's reveal sets
     its text (revealSlot()/finishNow() in fields.njk); blockified as
     flex items, an empty span has no line box and so no height at all,
     regardless of line-height, until text actually arrives -- 1lh
     reserves that line's height from the first frame instead, using
     this element's own computed line-height directly rather than a
     measured pixel value, so it stays correct if the type ever
     changes. */
  min-height: 1lh;
}
.fields .dates > span {
  flex: 1 1 0;
  min-width: 0;
  display: flex;
  gap: var(--dates-gap);
  transition: opacity var(--fld-fade) ease-in-out;
}
/* Guards the dormant showTime=false path: if .tm is ever set to the empty
   string, display:none drops it from the flex layout entirely, so gap
   stops reserving space for it instead of leaving a trailing gap after
   a lone date. No effect today -- showTime is always true, so .tm always
   has content. */
.fields .dates > span .tm:empty {
  display: none;
}

/* Wraps to two lines at this width and loses the per-slice alignment
   that gives it meaning -- the lightbox state's own caption already
   carries the same five dates, better. Hidden outright rather than
   just faded: #fieldSwap has nothing else in flow (#fieldStoppedInfo
   is absolutely positioned), so removing .dates collapses it to zero
   height, and .summary's own margin-top becomes the whole gap between
   the frame and the copy, with nothing to add on top of it. Desktop
   keeps the row. Placed here, after .fields .dates' own base rule,
   rather than alongside it: the two share specificity (0,0,2), so the
   later one wins regardless of which media query matches -- sitting
   this after the base rule, instead of before it, is what makes it
   actually apply at this width rather than being silently overridden
   by the unconditional display: flex.

   Width-driven (the wrap is about horizontal room for five columns of
   text), so this now uses the site's normal 767px tier rather than the
   portrait-only test .summary's own margin-top still uses above --
   the two used to share one gate and now don't. In the resulting gap
   (a landscape Fields view between 601 and 767px wide, uncommon but
   possible in a resized window) dates still hide correctly here, but
   .summary's margin-top stays at its desktop value rather than the
   slightly smaller --p-band-below, since that rule's own portrait test
   doesn't match. A few millimetres' difference in one uncommon window
   shape, not a layout break -- noted rather than chased. */
@media (max-width: 767px) {
  .fields .dates { display: none; }
}

/* No CSS-level reduced-motion override here, unlike the carousel above:
   Fields' own script already sets --fld-fade/--fld-stagger to a shorter,
   synchronized 300ms/0ms under reduce (not snapped to instant), matching
   the prototype's own design -- "the five slots change together and
   shorter," not a hard cut. A transition-duration override here would
   fight that. */

/* ============ fields: lightbox state ============
   Entering fades the header, nav and footer in place -- opacity only,
   never display:none -- because their boxes hold the Field's position:
   body:has(.field) above reserves 1.5rem below the nav for exactly this
   frame, and removing either from flow would shift the Field and its
   dates. Opacity alone would still leave their links focusable while
   invisible, so visibility comes with it, delayed on the way out until
   the fade finishes and instant on the way back in, so reopening the
   chrome isn't gated behind its own delay. The ground pill lives inside
   .site-nav, so it fades for free as a descendant.

   --fld-chrome-fade lives on body:has(.field), not .fields, because
   .site-header/.site-nav/.site-footer are .fields' siblings under body,
   not its descendants -- a variable declared on .fields alone would
   never reach them.

   body.fields-lb-open is toggled by fields.njk's own script, not by the
   shared lightbox module -- there is no overlay here to hand this to. */
body:has(.field) { --fld-chrome-fade: 600ms; }
body:has(.field) .site-header,
body:has(.field) .site-nav,
body:has(.field) .site-footer,
body:has(.field) .summary {
  transition: opacity var(--fld-chrome-fade) ease;
}
body.fields-lb-open .site-header,
body.fields-lb-open .site-nav,
body.fields-lb-open .site-footer,
body.fields-lb-open .summary {
  opacity: 0;
  visibility: hidden;
  transition: opacity var(--fld-chrome-fade) ease, visibility 0s var(--fld-chrome-fade);
}
/* .summary (fields.njk's own note about the form, shown only in normal
   browsing) stays in flow the whole time, exactly like the header, nav,
   and footer above -- it never needs to leave flow, since
   #fieldStoppedInfo covers it by being absolutely positioned on top of
   it, not by anything .summary itself has to do. */
/* The footer used to also collapse here (max-height:0). Dropped:
   collapsing has no transition of its own to give it, so it snapped the
   instant fields-lb-open changed, clipping the footer's still-mostly-
   opaque content away before its own opacity fade had done anything --
   a blink the header and nav don't have, precisely because they only
   ever fade. Now that nothing in this state changes size at all (see
   docs/DECISIONS.md, "The Fields info swap"), a footer holding its full
   height while invisible costs nothing worth a blink to avoid -- it
   fades exactly like the header and nav above, nothing else. */

/* .site-footer's own margin-top: auto (styles.css:185, pinning it to the
   viewport bottom on a page shorter than the window) is left unmodified
   here, deliberately -- a neutralizing override was tried once and
   reverted; see docs/DECISIONS.md, "The Fields info swap" for the full
   history, since it's tied to the same defects that entry covers. */

/* The empty space around the Field closes the state the same as clicking
   the Field itself does (fields.njk's document-level click handler), so
   it reads as clickable the same way: inherited onto everything, since
   .pausebtn and .lb-close already set their own cursor regardless and
   the faded header/nav/footer aren't hit-testable to show it on. */
body.fields-lb-open { cursor: pointer; }

/* The Field's two halves are the navigation while the state is open:
   the same click zones and chevron cursors Views and Shifts use
   (.lb-zone, .lb-zone-prev, .lb-zone-next above), reused as they are.
   Spans, not buttons, aria-hidden, never in the Tab order; the arrow
   keys are the keyboard route, as there. Both carry `hidden` while the
   state is closed, and the left one while there is no viewed Field to
   go back to (fields.njk, syncZones), so that half
   falls through to #pausebtn. Above #pausebtn (z-index 11) so the click
   reaches the zone. The Field itself no longer closes on a pointer
   click while open -- only the space around it does, per the body rule
   above -- so the button's own cursor stops promising one where the
   zone is hidden. */
.fields .field .lb-zone { z-index: 12; }
body.fields-lb-open .pausebtn { cursor: default; }
/* Idle cursor hide over the Field, as .lb.idle (views: lightbox). */
body.fields-idle .field .lb-zone,
body.fields-idle .field .pausebtn { cursor: none; }

/* ---------- fields: the info swap (dates <-> caption) ----------
   A pure crossfade, no JavaScript involved: .dates and #fieldStoppedInfo
   (the caption and Close, one block -- see its own comment below) are
   both keyed to body.fields-lb-open exactly the way .site-header/
   .site-nav/.site-footer/.summary already are, opacity and visibility
   only, reversing natively from whatever value a mid-transition click
   catches them at, the same way those already do. See docs/DECISIONS.md,
   "The Fields info swap".

   #fieldStoppedInfo is absolutely positioned, so it never occupies flow
   space of its own in either state -- #fieldSwap's own height is always
   just .dates' natural size, whether the state is open or closed, which
   is what keeps the Field and everything below #fieldSwap (.summary,
   the footer) from ever moving: there is no height here for anything to
   push against. overflow:hidden and the height transition #fieldSwap
   used to carry are both gone with it -- nothing needs clipping when
   nothing's height is changing. */
.fields .field-swap {
  position: relative;
}
.fields .dates {
  transition: opacity var(--fld-chrome-fade) ease;
}
body.fields-lb-open .fields .dates {
  opacity: 0;
  visibility: hidden;
  transition: opacity var(--fld-chrome-fade) ease, visibility 0s var(--fld-chrome-fade);
}
/* Sits over whatever #fieldSwap's own (short, .dates-sized) box doesn't
   cover -- top:0/left:0 lines its own --lb-cap-gap-anchored caption up
   exactly where it's always sat relative to the Field, and being taller
   than that box, it reaches on down over .summary's own region once
   revealed. No overflow to worry about escaping into: absolutely
   positioned elements never participate in margin collapse in the first
   place, so #fieldCaption's own top margin was never at risk of
   escaping anywhere once it's in here. Positioned elements also paint
   above static ones regardless of DOM order, which is what puts this
   above .summary (a later, but non-positioned, sibling) with no z-index
   needed. Inverse of .dates' polarity above: hidden by default, shown
   while open -- so the visibility delay sits on the opposite rule,
   keeping it interactive-until-fully-faded on the way out and
   interactive-from-the-first-frame on the way in, same idea as the
   header/nav/footer's own pattern, just mirrored. #fieldsClose (its own
   rule, below) carries no opacity or visibility of its own any more --
   both inherit visually and via CSS inheritance from this, since there's
   no clip boundary left for a separate timing to protect it from. */
.fields .field-stopped-info {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  opacity: 0;
  visibility: hidden;
  transition: opacity var(--fld-chrome-fade) ease, visibility 0s var(--fld-chrome-fade);
}
body.fields-lb-open .fields .field-stopped-info {
  opacity: 1;
  visibility: visible;
  transition: opacity var(--fld-chrome-fade) ease, visibility 0s;
}

/* One block, not two independently-toggled elements: #fieldsClose used
   to sit beside #fieldCaption rather than inside this, each getting its
   own display/opacity from swapFieldInfo() directly. Splitting Close
   out that way meant its own box and its own top margin entered and
   left flow in a single frame of their own, separate from the
   caption's -- the same kind of discontinuity this whole rewrite exists
   to remove, and it showed up as a further, smaller jump in the
   footer's own measured position once the caption/dates swap itself
   stopped jumping. Folding Close inside also matches the two as they
   actually read: one stopped-state block, not a caption that happens to
   have a button positioned after it.

   #fieldCaption reuses .lb-caption, .lb-caption .date, and
   .lb-caption .times.stacked verbatim -- the exact markup shape
   Shifts' own caption() builds, just with "Field 06" standing in for
   the date line and the five source dates standing in for the times.
   --lb-cap/--lb-base-size/--lb-cap-weight/--lb-cap-track are :root-scoped
   already, so the identifier line matches Views and Shifts for
   free; --lb-cap-tone and the --lb-times-* family needed Fields-scoped
   copies instead, added above alongside --lb-btn. The frame-to-caption
   gap comes from .lb-caption's own margin-top (--lb-cap-gap), unaltered
   -- the same relationship Views and Shifts already use between
   their image and its caption, now doing the same job here; kept as its
   own value rather than matched to .dates' own --dates-above (the two
   don't line up while briefly overlapping mid-swap), since the two
   elements' own settled positions matter more than a perfect overlap
   during 600ms of crossfade. */

/* Each source is a date and a time, not one string to read left to
   right: two columns instead, date right-aligned, time left-aligned,
   the gap between them doing the job a separator mark would otherwise
   be there only to fake. .t becomes display:contents -- it stops
   existing as a box, so its own children (.d, .tm) become the grid's
   actual items instead of one item each holding both -- which is also
   why the vertical rhythm moves from .t + .t's margin-top (a box that
   no longer exists to carry it) to row-gap on the grid itself; the
   column-gap alongside it is --lb-times-col-gap, not --lb-times-gap,
   which is a different measurement (identifier line to the list as a
   whole, still doing that job unchanged). width: fit-content and
   margin-inline: auto centre the two columns as one block under the
   identifier, the same way .fields .lb-close centres itself below.
   Neither column restates the font-size/weight/opacity already set on
   .times -- both inherit it unchanged, which is the whole point: three
   levels of hierarchy already exist on the page (Field 06, the sources,
   Close), and a fourth inside each line was not asked for. */
.fields .times.stacked {
  display: grid;
  grid-template-columns: max-content max-content;
  column-gap: var(--lb-times-col-gap);
  row-gap: var(--lb-times-between);
  width: fit-content;
  margin-inline: auto;
}
.fields .times.stacked .t { display: contents; }
.fields .times.stacked .d { text-align: right; }
.fields .times.stacked .tm { text-align: left; }

/* The close button, inside #fieldStoppedInfo now: display:block is its
   own intrinsic-type fix, unconditional rather than toggled -- .lb-close
   is inline-block by default (a bare button), and centring it as a
   block via the auto margins below needs an actual block, not the
   full-width block plain display:block would produce on something that
   wasn't already inline-block. Whether it's shown or hidden is entirely
   #fieldStoppedInfo's own doing now (see above); nothing here decides
   that. Same class Views and Shifts use, positioned the way
   theirs sits below the work: centred, --lb-cap-to-btn below whatever
   comes before it -- here, #fieldCaption rather than a plain caption.
   width: fit-content so the auto margins have something to centre.

   No padding-bottom on .fields below this any more (there was one,
   matching Shifts' own --lb-below-close): that gap was calibrated
   for when nothing followed Close, and stacked on top of .site-footer's
   own 56px top padding once the footer stopped collapsing (see its own
   comment above), it read as more empty space below Close than either
   value was ever meant to add up to alone. Dropped rather than reduced
   -- the footer's own top padding, the same 56px every other page on
   the site already meets its footer with, is the gap now. */
.fields .lb-close {
  display: block;
  width: fit-content;
  /* calc(var(--lb-cap-to-btn) - var(--lb-btn-pad)), matching .lb-fit
     (Shifts' own first button) rather than Views' .lb-close,
     which uses --lb-cap-to-btn unmodified. Found to differ by measuring
     both live: Fields' caption is Shifts' shape (identifier line plus
     a stacked list), so the gap below it follows Shifts' first-button
     pattern, not Views' single-button one. Less --lb-close-lift, so the
     label itself lands where Shifts' first label does (:root). */
  margin: calc(var(--lb-cap-to-btn) - var(--lb-btn-pad) - var(--lb-close-lift)) auto 0;
  /* No opacity or visibility of its own: both inherit from
     #fieldStoppedInfo (above) -- visually, since opacity compounds
     through a subtree regardless of a child's own value, and via
     ordinary CSS inheritance for visibility -- so Close fades and
     tabs in and out as part of the same block as the caption, not on a
     timing of its own. */
  transition: color 0.15s ease;
}
/* Matches .lb.close-hot .lb-close: Close lights while the pointer is
   somewhere a click would close the state. #fieldsClose has no .lb
   ancestor for the shared rule to key on, so fields.njk's own pointermove
   sets body.fields-close-hot from the same inCaptionBox() test the hand
   cursor and the click use -- off over the caption box, the Field's own
   zones and #pausebtn, and Close itself (which keeps its :hover). */
body.fields-close-hot .fields .lb-close { color: var(--lb-btn-hot); }

/* ============ show ============
   Placement and temporal structure of the show, as one sequence: lead
   photograph, Visit, Nuit Blanche, Works, then photographs, pairs and
   their caption lines, signup. The Nuit Blanche page (/show/nuit-blanche/,
   main.show.show-nuit-blanche) is the same sequence of the same parts, with
   a time under each photograph, and shares every rule here. Text reads in the same column as About and Image Use: the .doc
   gutter and .doc-measure column are reused as they are, so the measure
   is theirs, not a copy. The page's own job is the vertical rhythm,
   which .doc's own margins (tuned for a page that is one text block)
   would otherwise scatter between every section, so they are zeroed
   here and each block carries its own gap above. The page is still in
   development and is judged with the real site around it.

   Two widths on the page and no more: the text column, --copy-measure's
   26.5rem (424px), and one image column, --show-w, 864px, about twice
   the text column, wider than it but well short of the page. A
   photograph on its own takes the image column whole, whatever its
   ratio; a pair shares it as a grid of two, so the two are visibly
   smaller without introducing a third width. The
   image column is built the way the Views grid's is -- capped, inset by
   the gutter, centred -- with --page-pad redeclared in this scope the
   same way .shifts redeclares it. Above the lead is the page switch,
   Shifts' season row, at its own distance under the nav; the lead sits
   1.5rem under the switch, Shifts' gap from its season row to the copy,
   shared by selector with .shifts .summary, not a margin of this page's. */
.show {
  --show-w: 864px;
  --page-pad: 3rem;
  --pad-phone: 1.1rem;
  /* The vertical gaps, each the lower row's margin-top, named by what
     the two rows are: a photo row (a photograph on its own, .show-photo,
     or a pair, .show-grid), a caption line (.show-note: the Nuit Blanche
     line, the bookmark line, the 99-days line), or section text
     (.show-visit, .show-works: Visit, Works, the Nuit Blanche page's
     heading). Settled by eye in the Show sequence table
     (prototypes/show-sequence.html), October 2026; phone values in the
     767px tier below, the Nuit Blanche page's own after this block.
       --show-gap                a photo row under section text: 3.6rem,
                                 the page's section pace, as before
       --show-gap-photo          a photo row under a photo row
       --show-gap-caption        a caption line under a photo row
       --show-gap-after-caption  a photo row under a caption line
       --show-gap-section        section text under a photo row
     Text under a caption line, and Back to Show, keep the text gap,
     2.8rem (below). On the Nuit Blanche page a photo row's time captions
     are inside it, so the gap under the row is measured from them.
     --show-grid-gap, between the two photographs of a pair: the Shifts
     grid's own horizontal gap, .shifts --gap-x, so two photographs side
     by side are spaced the same here as two Shifts are there. 0.6rem on
     a phone, the Shifts phone value, in the tier below; the breakpoint
     is 767px on both pages. The two are kept in step by hand: --gap-x
     is declared in the .shifts scope and does not reach this one. */
  --show-gap: 3.6rem;
  --show-gap-photo: 2.8rem;
  --show-gap-caption: 1.2rem;
  --show-gap-after-caption: 3.8rem;
  --show-gap-section: 2.8rem;
  --show-grid-gap: 1.3rem;

  /* width: 100%: see .calwrap above, same flex-stretch-suppression fix. */
  width: 100%;
}
/* The Nuit Blanche page: its heading sits closer under the photograph
   above it, and each photograph's time sits --show-time-gap below it. */
.show.show-nuit-blanche {
  --show-gap-section: 2.05rem;
  --show-time-gap: 0.4rem;
}
/* The page ends with the shared page tail (the newsletter), which owns
   its own gap; the text blocks here carry only their gaps above. */
.show .doc { margin: 0; }

/* The two small headings, Visit and Works: reading size and line-height,
   the venue line's weight from the Home exhibition block, no size step.
   The 0.25rem below is the heading-to-content gap. */
.show h2 {
  font-size: var(--copy-size);
  font-weight: var(--venue-weight);
  letter-spacing: var(--copy-track);
  line-height: calc(1em + var(--line-gap));
  margin: 0 0 0.25rem;
}

/* The text gap, 2.8rem, above every text block: what remains wherever
   the named gaps below don't apply (text under a caption line, a first
   block), and Back to Show (.show-back), which is text, not a caption
   line, under the Nuit Blanche page's last photograph. Works copy to the
   dates is .doc-measure's own 1rem paragraph margin; the dates take the
   reading line-height and nothing more. */
.show .show-visit,
.show .show-works,
.show .show-note,
.show .show-back { margin-top: 2.8rem; }

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

/* Every photograph is a button opening the lightbox, same reset as
   .views .item. Scoped here rather than shared: that rule is Views'. */
.show .item {
  display: block;
  width: 100%;
  border: 0;
  padding: 0;
  background: none;
  cursor: pointer;
  text-align: left;
  font: inherit;
  color: inherit;
}
/* The file at the field's width, its own ratio from the width and
   height attributes; no crop. */
.show .item img {
  display: block;
  width: 100%;
  height: auto;
}

/* The image column: a major photograph on its own, or the supporting
   grid. Same gutter, same centring; the cap is the column's width plus
   the gutter both sides, so it measures its full width at desktop and
   clamps with the page below it. margin: 0 auto, not left/right alone:
   a photograph is a <figure>, whose browser default carries 1em above
   and below. The lead's gap above is the switch's (below). */
.show .show-photo,
.show .show-grid {
  width: 100%;
  max-width: calc(var(--show-w) + 2 * var(--page-pad));
  margin: var(--show-gap) auto 0;
  padding: 0 var(--page-pad);
}
/* The page switch, main's first child: a .season-nav, styled entirely by
   "section nav" above. On Shifts the row sits inside .shifts' side
   padding and its own -6px margins (room for the focus ring) reach back
   into it; main.show has no side padding, each photo row carrying its
   own, so the same -6px would push the row past the screen's edges and
   scroll the page sideways. Here the margins carry the inset instead:
   --page-pad less the same 6px, which puts the row where Shifts' sits,
   at both --page-pad values. The lead photograph's 1.5rem under it is
   shared with .shifts .summary; see the copy section. */
.show > .show-switch {
  margin-left: calc(var(--page-pad) - 6px);
  margin-right: calc(var(--page-pad) - 6px);
}
.show .show-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--show-grid-gap);
}

/* The named gaps (see .show above). Each rule names the row above it
   with the sibling combinator, which also outranks the base margins
   (0,3,0 against 0,2,0); the lead, under the page switch rather than a
   row, takes the switch's 1.5rem the same way. */
.show > :is(.show-photo, .show-grid) + :is(.show-photo, .show-grid) { margin-top: var(--show-gap-photo); }
.show > :is(.show-photo, .show-grid) + .show-note { margin-top: var(--show-gap-caption); }
.show > .show-note + :is(.show-photo, .show-grid) { margin-top: var(--show-gap-after-caption); }
.show > :is(.show-photo, .show-grid) + :is(.show-visit, .show-works) { margin-top: var(--show-gap-section); }

/* The Nuit Blanche page's times, one under each photograph. The element
   carries .lb-caption, the lightbox caption's type (--lb-base-size,
   --lb-cap-weight, --lb-cap-track, line-height 1, tabular numbers,
   --lb-cap), and this rule sets it under its photograph: on the
   photograph's left edge rather than centred, without .lb-caption's
   phone side padding, --show-time-gap below the photograph. A div, so
   no margin of its own below it. Under a pair the two times are a second
   grid row with no row gap, each under its own photograph. */
.show .show-time {
  margin-top: var(--show-time-gap);
  padding: 0;
  text-align: left;
}
.show .show-grid:has(> .show-time) { row-gap: 0; }

/* Lightbox work. The installation photographs are 4:3 and 16:9 (the
   Nuit Blanche photographs, 3:2, fall between and are contained the same
   way), and a box that took each photograph's own ratio changed height as
   the ring advanced, moving the picture's edges and Close between one
   photograph and the next. So the box is one stage, the same for all
   nine at a given viewport: as tall as a 4:3 photograph can be here
   (the height limit, or the width's 3/4), and as wide as a 16:9
   photograph can be at that height (the width limit, or the height's
   16/9). Each photograph is contained in it, centred: a 4:3 fills the
   height, a 16:9 fills the width, and each is exactly the size it
   would be in a box of its own ratio, with the overlay ground showing
   where the other ratio would have been. The box has its size from
   CSS the instant it exists, so the ghost crossfade never sees a
   collapsed wrapper, and now the ghost and the incoming photograph
   share one box (same reasoning as Views' aspect-ratio note). The
   stage's measures (--stage-w, --stage-h, --stage-w-fit) are declared
   on the ring below, where the navigation zones read them too. */
.lb.chrome-below #lbWork > .show-work,
.lb.chrome-below .lb-ghost > .show-work {
  display: block;
  width: var(--stage-w-fit);
  height: var(--stage-h);
}
.show-work > img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
  /* Bottom-centred, not centred: where a 16:9 photograph does not fill
     the stage's height, the unused room sits above it, so the visible
     photograph always ends at the stage's bottom edge and the distance
     from picture to Close is the declared one. */
  object-position: 50% 100%;
  /* No #lbSharpen here: .lb img applies that convolution to every
     lightbox image for the panoramas, which are downsampled from far
     larger frames. The installation photographs were sharpened for
     their size in Lightroom and are shown as they are. Same
     specificity as .lb img; this rule is later in the file and wins.
     The Shift bands this overlay also shows (.band-p img) keep it. */
  filter: none;
}

/* Two rings through one overlay: the Shift viewer appends Zoom,
   Sequence, and the centre zone to this page's lightbox at load, the
   same way it does on Shifts, but here the overlay also shows the
   installation photographs, which have no use for them. `stacked` is
   on only while a Shift is the open work, so the three exist only
   then. Where Shifts is the whole page the class is permanent and
   this never applies. Outranks .lb-fit's own display rule (0,1,0) in
   the geometry media query, which is the point. */
.lb:not(.stacked) .lb-fit,
.lb:not(.stacked) .lb-seq,
.lb:not(.stacked) .lb-zone-centre { display: none; }

@media (max-width: 767px) {
  /* The grid stays two across on a phone, the four details at about
     half the inset width each, with a tighter gap in step with the
     narrower cells: the Shifts grid's own phone value, --gap-x-phone.
     Stacking them full width was tried (as a grid of six) and made the
     lower page a stack of equal photographs; two across is what keeps
     the details reading as details. */
  .show {
    --page-pad: var(--pad-phone);
    --show-grid-gap: 0.6rem;
    --show-gap-photo: 2rem;
    --show-gap-caption: 0.8rem;
    --show-gap-after-caption: 3.6rem;
  }
  .show.show-nuit-blanche { --show-gap-section: 2.6rem; }
  /* One content edge on a phone: the text blocks take the same gutter as
     the photographs, --page-pad's phone value, instead of .doc's own
     1.6rem, so Visit, Works, and the signup sit on the lead's edge the
     way the Views grid sits on its own. Scoped here; .doc itself and its
     use on About and Image Use are untouched. Above this width the
     column is narrower than the page and centres, so the gutter never
     binds and nothing changes. */
  .show .doc { padding: 0 var(--page-pad); }
  /* full-bleed on phones, as Views' own rule above: the stage's
     available width is the whole viewport */
  body:has(.show) .lb:not(.stacked) { --stage-w: 100vw; }
}

/* The photograph ring's chrome, on this page's shared overlay and not
   on the Shift ring (.stacked), which has its own. Declared distances,
   not leftovers: the stage to Close's box, Close's box itself (its
   rendered height, line-height normal plus its own padding), and the
   air below it, the same 2.9rem the stacked Shift shows at the end of
   its scroll. The height reservation the stage subtracts is exactly
   their sum, so where the stage is height-limited the block is the
   viewport's height and the figure's centring has nothing left to
   hand out: the stage starts at the top edge, and every gap is the
   one written here. Where the stage is width-limited (portrait) the
   shorter block centres as before, and the air above and below is
   centring alone; there is no top inset. The caption is empty on this
   ring, so its margin is taken out of the geometry. */
body:has(.show) .lb:not(.stacked) {
  --show-lb-above: 2rem;
  --show-lb-close: 1.725rem;
  --show-lb-below: 2.9rem;
  --lb-chrome: calc(var(--show-lb-above) + var(--show-lb-close) + var(--show-lb-below));
  /* The stage: as tall as a 4:3 can be here, as wide as a 16:9 can be
     at that height (see the .show-work rule above). Declared here so
     the zones below share the same numbers. */
  --stage-w: calc(100vw - var(--lb-inset) * 2);
  --stage-h: min(calc(100vh - var(--lb-chrome)), calc(var(--stage-w) * 3 / 4));
  --stage-w-fit: min(var(--lb-w), var(--stage-w), calc(var(--stage-h) * 16 / 9));
}
body:has(.show) .lb:not(.stacked) .lb-figure { padding-bottom: var(--show-lb-below); }
body:has(.show) .lb:not(.stacked) .lb-caption { margin-top: 0; }
body:has(.show) .lb:not(.stacked) .lb-close { margin-top: var(--show-lb-above); }

/* The Nuit Blanche page's ring shows each photograph's time in the
   caption, so its chrome gives the caption Views' room: --lb-cap-gap
   above it, its one line (line-height 1, so --lb-base-size tall), and
   Views' caption-to-Close gap in place of the stage-to-Close one. The
   reservation the stage subtracts grows by the same, so the block still
   fits the viewport where the stage is height-limited. Show's own ring,
   with no caption, is unchanged. Same specificity as the rules above,
   later in the file. */
body:has(.show-nuit-blanche) .lb:not(.stacked) {
  --show-lb-cap: calc(var(--lb-cap-gap) + var(--lb-base-size));
  --show-lb-above: calc(var(--lb-cap-to-btn) - var(--lb-btn-pad) - var(--lb-close-lift));
  --lb-chrome: calc(var(--show-lb-cap) + var(--show-lb-above) + var(--show-lb-close) + var(--show-lb-below));
}
body:has(.show-nuit-blanche) .lb:not(.stacked) .lb-caption { margin-top: var(--lb-cap-gap); }

/* The stage is layout, not a target. The two navigation zones, which
   the shared rule stretches over the whole wrap, are laid here over the
   painted photograph alone: its size is the contain arithmetic (the
   ratio the page writes on the wrap as --work-ratio against the
   stage), bottom-aligned and centred as the image is. The wrap, the
   stage and the image give up pointer events, so a click on the ground
   inside the stage, above a 16:9 or beside a 4:3, reaches .lb-figure
   and the module's own backdrop handler closes, exactly as a click
   outside the stage does; the zones keep theirs. Hover follows: over
   the photograph the pointer is the zone's arrow, over any ground it is
   the backdrop's and Close lights. Keyboard, swipe and the crossfade
   ghost are untouched. */
body:has(.show) .lb:not(.stacked) .lb-imgwrap,
body:has(.show) .lb:not(.stacked) .show-work,
body:has(.show) .lb:not(.stacked) .show-work > img { pointer-events: none; }
body:has(.show) .lb:not(.stacked) .lb-zone {
  --painted-w: min(var(--stage-w-fit), calc(var(--stage-h) * var(--work-ratio, 1)));
  --painted-h: min(var(--stage-h), calc(var(--stage-w-fit) / var(--work-ratio, 1)));
  top: calc(var(--stage-h) - var(--painted-h));
  width: calc(var(--painted-w) / 2);
  pointer-events: auto;
}
body:has(.show) .lb:not(.stacked) .lb-zone-prev { left: calc((var(--stage-w-fit) - var(--painted-w)) / 2); }
body:has(.show) .lb:not(.stacked) .lb-zone-next { right: calc((var(--stage-w-fit) - var(--painted-w)) / 2); }

/* ============ prints ============
   Availability and ordering, text only. Reads in the
   same column as About, Image Use, and the Show text blocks (.doc gutter,
   .doc-measure column); the shared measure, copy size, weight, and
   line-height are consumed as they are. What is Prints' own is the
   vertical structure, begun in prototypes/prints-prototype.html and
   revised on the rendered page, written here in rem at the 16px root the
   prototype's px values assume: the opening, Views and Fields,
   Shifts with the pigment print paragraph, then Ordering, each section
   at the same 30px gap with no larger break, then the shared signup at
   45px. No group headings. Every gap below is box to box, the way the
   prototype measured it, and where two margins meet they collapse, so
   each value is set on the one element that owns that gap.

   Rules that read as copies of Show's (the heading's type, the
   link) are deliberately not shared selectors: Show is still
   being judged with the real site around it, and neither page should
   move when the other's values do. */

/* Nav to the opening copy: 3rem, this page's own, a half step under
   .doc's shared 3.5rem top margin (which About and Image Use keep);
   --sitenav-below is 0 here, so the 48px is the whole gap. No bottom
   margin: the shared page tail (the newsletter) follows with its own. */
.prints .doc { margin: 3rem auto 0; }

/* Paragraph gap 7px, replacing .doc-measure's 1rem for this page. The
   last paragraph carries none: main is a flex item of the body, so a
   trailing margin would stay inside it and add to the page tail's own
   gap rather than collapse away. */
.prints .doc-measure p { margin: 0 0 0.4375rem; }
.prints .doc-measure p:last-child { margin-bottom: 0; }

/* Section heading: reading size, weight 500, the reading line-height;
   30px above (the gap between every section, Ordering included), 5px
   to its first line. */
.prints h2 {
  font-size: var(--copy-size);
  font-weight: var(--venue-weight);
  letter-spacing: var(--copy-track);
  line-height: calc(1em + var(--line-gap));
  margin: 1.875rem 0 0.3125rem;
}
/* Unused: no heading carries .group since the Materials section and its
   64px break were removed, so this rule is inert. */
.prints h2.group { margin-top: 4rem; }

/* Size and price rows: a plain list laid out as two columns, size then
   price, the same display:contents grid the Fields caption uses for its
   date and time pairs, so each row is one list item and the columns
   align without a table. No rules, no boxes. Rows sit at the reading
   line-height with no extra gap; the column gap is the prototype's
   40px. 6px from the form line above: less than the paragraph gap, so
   it is set on that paragraph rather than on the list, where the
   paragraph's 7px would win the collapse. */
.prints .doc-measure p:has(+ .sizes) { margin-bottom: 0.375rem; }
.prints .sizes {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: max-content max-content;
  column-gap: 2.5rem;
  width: fit-content;
  font-variant-numeric: tabular-nums;
}
.prints .sizes li { display: contents; }
/* A paragraph after a size list begins a new thought, not another row:
   the section gap, 30px, the same as a heading's own top margin. */
.prints .sizes + p { margin-top: 1.875rem; }

.prints .doc-measure a {
  color: inherit;
  text-decoration: underline;
  text-decoration-style: var(--link-style);
  text-decoration-thickness: 1px;
  text-underline-offset: var(--link-offset);
  text-decoration-color: color-mix(in srgb, var(--color-mute) var(--link-tone), var(--color-dark));
  transition: text-decoration-color 0.2s;
}
.prints .doc-measure a:hover, .prints .doc-measure a:focus-visible {
  text-decoration-color: var(--color-dark);
}

@media (max-width: 767px) {
  /* One content edge on a phone: the copy takes --page-pad's phone value
     (1.1rem, set at :root in the shared 767px tier above), the same
     gutter the Views and Shifts grids, Home's copy, Show's
     text, and the footer already sit on, instead of .doc's own 1.6rem.
     Scoped here, as Show's is; .doc itself and its use on About
     and Image Use are untouched. Above this width the column is
     narrower than the page and centres, so the gutter never binds and
     nothing changes. */
  .prints .doc { padding: 0 var(--page-pad); }
}
