/* GRISAILLE, chamber IV. Sections elevation (SECTIONS-ELEVATION-SPEC S0 to S53).

   Why this is a separate file and not an edit to css/grisaille.css: every rule
   here is scoped to the seam or to #studio, and grisaille.css is being worked
   in the same run by the engine owner. Loading after it gives this file the
   cascade position it needs to re-rule the studio block without editing a
   single line another writer owns. Nothing here touches the path, the rail's
   own metrics, the shaders or the quests (S53).

   Register: one light ground from the seam to the footer (S50). Ink on
   lime-white, secondary ink at 0.62, rail and hang line black via --nav-fg. */

/* ---------- A. THE SEAM ---------- */

/* S1. The DOM ground is the WebGL clear colour, by construction, so the
   cross-fade at the end of the path has nothing to cross.

   SHIPPED HALF: this rule. The engine half (one line writing --ground from
   the rendered fog every frame, in frame(), beside the existing --tone write)
   belongs to js/grisaille.js, which this run does not own. Until that line
   lands the fallback is the current value, so this changes nothing and
   regresses nothing; the moment --ground exists the seam closes with no
   further edit here. The fallback is deliberately NOT a tuned approximation
   of the fog: a hand-picked colour would drift the instant TONES changed,
   which is the exact failure S1 exists to remove. */
/* R71a, the tail. The colour stays exactly what it was, --ground with the black
   fallback, so nothing that reads the body's background-color moves. What is added is
   one band of the band's terminal tone pinned to the BOTTOM of the body's box, because
   the closing group is not the last thing in the document: the engine appends three
   inline `.quest-enter` anchors to the body after </main> (MEASURED display inline,
   position static, top 27142 bottom 27159 of a 27161px document at 1440x900), and
   their line box is 26px of the body's own ground below everything this lane paints.
   At the document end that ground is the studio's cream, so the visit ended on a 26px
   cream strip under the black closing group. The anchors and their rules are the
   engine's (css/grisaille.css:703 onward, moved into the HUD by js/grisaille.js:3487),
   so the strip itself is a question for that lane and not an edit here; this band makes
   the strip harmless either way. 30vh at 900 is 270px against the 111px the closing
   group plus the strip actually occupy, and the band's own opaque box covers the rest
   from 140vh above, so the overlap is never seen and no tone on the ramp changes. */
.mode-3d body {
  background-color: var(--ground, var(--black));
  background-image: linear-gradient(var(--black), var(--black));
  background-repeat: no-repeat;
  background-position: 0 100%;
  background-size: 100% 30vh;
}

/* S2. No bone step at the top of the studio. The bone-to-lime-white ramp moves
   to the tail of chamber III, so the document still steps into a brighter room
   and the immersive crossover lands on one flat value. */
.chamber-light { background: linear-gradient(180deg, var(--pewter) 0%, var(--bone) 86%, var(--lime-white) 100%); color: var(--ink); }
.chamber-studio { background: var(--lime-white); color: var(--ink); }

/* S4a. The rail completes: one 700ms swell of the thread's own glow, once per
   session, added by js/sections.js as a class on documentElement. */
@keyframes thread-arrive {
  0%   { filter: drop-shadow(0 0 6px currentColor); }
  45%  { filter: drop-shadow(0 0 14px currentColor); }
  100% { filter: drop-shadow(0 0 6px currentColor); }
}
html.thread-arrived .thread-fill { animation: thread-arrive 700ms ease-out 1; }

/* ---------- A2. CHAMBER III: the finish set's entry statement (R71m) ----------

   This is the first rule in this file that is neither the seam nor #studio nor the
   record band, so the header's claim at the top widens by one element and says so
   here: the element is this lane's (a chapter head, index.html:239) and the only
   other home for it is css/grisaille.css, which another writer owns, so the rule
   follows the markup rather than the chamber.
   Register: the statement is secondary to the lead above it, not a second lead, so
   it takes the lead's colour at the lead's opacity and one step up in size, and the
   measure is in the statement's OWN ch per the G block's law. MEASURED after the edit:
   the 46ch cap renders a 514px box and 45.3 ch at 1440 (the 0.3 percent difference is
   the 0.01em tracking, which CSS ch excludes and the probe includes), 45.3 ch at 1180
   and 900, and 30.5 ch at 390 where the column is narrower than the cap, so every
   width is inside EC1's 28 to 50 band. The statement's x is the head's x and the
   lead's x at all four widths, and 398px of air at 1440 still separates it from the
   first finish. currentColor, so it carries each chamber's ink if the class is ever
   used again in a darker wing. */
.chapter-statement {
  margin-top: 26px; max-width: 46ch;
  font: 300 clamp(16px, 1.25vw, 18px)/1.6 var(--display);
  letter-spacing: 0.01em; color: currentColor; opacity: 0.72;
}

/* ---------- CHAMBER IV: the shared column and the hang line ---------- */

/* R74e. #tile-front left this chamber's SUBTREE in the R72m move and it is
   position: fixed, so nothing moved in flow, but it is still chamber IV's own
   surface: the piece out of the record, on the record's ground, in the record's
   type. It therefore takes chamber IV's tokens from THIS declaration instead of
   from a second copy, so --ease here and the room's light vector at :568 cannot
   drift between the room and the sheet that came out of it. */
#studio, #tile-front {
  /* One column width for every block in IV, so the five left edges agree by
     construction rather than by five hand-kept numbers (S6). */
  /* R33 widened the rail's column from 44px to 104px, and chamber IV's inset was
     derived for the 44px one: MEASURED after that change, the rail's right edge is at
     140px at 1440 and the record, the chapter head and the whole intake form began at
     100px, so the numerals II, III and IV sat on top of the form's own labels (see
     w2-d-inquire). At 390 the rail reaches 62px against a content edge at 58.
     The column now clears it by 12px at 1440 and 10px at 390. The two numbers mirror a
     geometry css/grisaille.css owns, so they are stated with their source rather than
     derived: rail right + air, where rail right is `calc(--pad / 2) + 104` on desktop
     and `4px + 58` on the phone. If the engine moves the rail again this is the line
     that has to move with it, and verify-r35.mjs reads the LIVE rail rect, so a row
     fails loudly rather than a numeral quietly landing on a label. */
  --iv-w: min(1240px, 100% - 192px);
  /* chamber IV's own horizontal insets, declared ONCE here so the handover plate can
     re-derive the viewport's centre from them (see .handover below). `.chamber` in
     css/grisaille.css sets the same two values; these are the ones this chamber uses. */
  --iv-padl: var(--pad); --iv-padr: var(--pad);
  /* The rail's x-centre expressed inside this chamber's own content box:
     rail left is calc(var(--pad) / 2) and it is 44px wide, the chamber's
     content box starts at var(--pad). Derived, not measured, so it cannot
     drift when --pad clamps at a different viewport. */
  --rail-shift: calc(22px - var(--pad) / 2);
  --ease: cubic-bezier(0.2, 0.6, 0.2, 1);
  /* S50: chamber IV is one light register, so the nav foreground is pinned
     black here and the hang line inherits it through currentColor (S5). */
  --nav-fg: #000000;
}

#studio .chapter-head,
#studio #index-grid,
#studio .studio-note,
#studio .studio-close,
#studio .inquire,
#studio .site-foot { width: var(--iv-w); max-width: none; margin-inline: auto; }

/* S4b, S5. The thread's continuation in a new axis. Left terminus on the rail's
   own x-centre, same 2px weight, same currentColor, same glow; no drawn corner
   (F.3). Full width at frame 0 without JS; the draw exists only where a script
   can complete it (S19's pattern). */
.hang-line {
  height: 2px; background: currentColor; color: var(--nav-fg);
  margin-left: var(--rail-shift);
  width: calc(var(--iv-w) + (100% - var(--iv-w)) / 2 - var(--rail-shift));
  transform: scaleX(1); transform-origin: left;
  transition: transform 900ms var(--ease), filter 1200ms linear;
}
/* S3, R4a. The gate is `sections-ready`, a class js/sections.js adds only once
   its observer is attached, NOT the head-set `js` class at index.html:13. The
   head class means "a script tag executed", which is not the same claim as
   "the thing that draws this line is alive": a 404, a blocking extension or a
   throw above that line used to leave the hang line collapsed to nothing with
   no error anywhere. Every hidden-until-revealed state in this file is keyed
   to `sections-ready` for that reason. Layout that needs no script (the
   subgrid block below) stays on `js`, which is the correct question for it. */
html.sections-ready .hang-line:not(.is-drawn) { transform: scaleX(0); }
.hang-line.is-drawn { filter: drop-shadow(0 0 10px currentColor); }
.hang-line.is-drawn.is-cool { filter: drop-shadow(0 0 0 currentColor); }

/* ---------- B. THE RECORD ---------- */

#studio .chapter-head { margin-top: 14vh; }

/* S17. The artist's own note on availability. It explains every Sold badge in
   the field below and is his sentence, not ours. R66b moved it out of the chapter
   head to the record it explains, so the air above it is its own now: 12vh reads
   as the same pause the head used to give it and keeps the gap to the first group
   label (113px, MEASURED before the move) unchanged. */
/* R71q. The note's BOX is in the column list at the top of this file now, and the
   34em measure moved onto the paragraph, because the two jobs were fighting: the
   measure was the only thing placing the note, so it sat at the chamber's own padding
   edge, 96.0px left of the column every other block in chapter IV starts at (MEASURED
   at 1440: note x=56.0 against #index-grid x=152.0; 900: 36.0 against 132.0; 1180:
   47.2 against 143.2; 390: 58.0 against 72.0). A box on the column with the measure on
   its paragraph keeps the line length a blockquote wants and puts the note's first
   character where the grid's first character is: delta 0.0px at all four widths, the
   paragraph 48 ch at 1440 and 28.3 ch at 390, which is the number the --iv-w comment
   at :489 derived this column's right relief from and is inside EC1's 28 to 50 band. */
.studio-note { margin-top: 12vh; }
.studio-note p { font: 300 clamp(17px, 1.8vw, 21px)/1.5 var(--display); color: var(--ink); max-width: 34em; }
#studio blockquote cite {
  display: block; margin-top: 14px; font-style: normal;
  font: 11px/1.4 var(--utility); letter-spacing: 0.2em; text-transform: uppercase;
  color: var(--ink); opacity: 0.62;
}

/* #index-grid stops being the grid and becomes the record container: the
   panel's click delegation and .is-receded both key off it by id, so it keeps
   its name (S0.6) while the two register groups below do the laying out. */
#index-grid { display: block; margin-bottom: 0; }
.studio-group { margin: 0 0 13vh; }
.studio-group:last-child { margin-bottom: 0; }

/* S15, S6. The group label sits ON its rule, which is the hang line's weight
   and colour at 0.35, not a second motif. */
.group-label {
  display: flex; align-items: center; gap: 20px;
  margin: 0 0 34px; font: 11px/1 var(--utility);
  letter-spacing: 0.22em; text-transform: uppercase; font-weight: 400; color: var(--ink);
}
.group-label::after { content: ''; flex: 1 1 auto; height: 2px; background: currentColor; opacity: 0.35; }

/* S13, S14. Real proportions, and a shared eye line per row. The subgrid is the
   mechanism for the hang: it puts every image in a row into one shared row
   band, so centring inside that band gives ragged tops and bottoms over a
   common centre. Without subgrid the field degrades to a bottom-aligned grid,
   which is still uncropped and still readable. */
.studio-field {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(212px, 1fr));
  gap: 34px 24px; align-items: end;
}
@supports (grid-template-rows: subgrid) {
  html.js .studio-field { align-items: stretch; }
  html.js .studio-field > figure { grid-row: span 2; display: grid; grid-template-rows: subgrid; }
  html.js .studio-field > figure > .tile-open { align-self: center; }
}

#studio .index-grid figure { position: relative; aspect-ratio: auto; overflow: visible; background: none; }
#studio .index-grid .tile-open { aspect-ratio: var(--ar, 3 / 4); width: 100%; height: auto; display: block; }
#studio .index-grid img { width: 100%; height: 100%; object-fit: contain; transition: none; }

/* S16. Captions are permanent, below the piece, and carry the record: name,
   series, dimensions, availability, only where the record has the field. The
   separators are generated, but the spaces between the spans are real text, so
   the panel (which reads figcaption.textContent) gets a readable string. */
#studio .index-grid figcaption {
  position: static; inset: auto; opacity: 1; background: none;
  color: var(--ink); padding: 14px 0 0; transition: none;
  font: 300 15px/1.35 var(--display); letter-spacing: 0; text-transform: none;
}
.cap-n { display: block; }
.cap-m {
  display: block; margin-top: 6px; opacity: 0.62;
  font: 10px/1.5 var(--utility); letter-spacing: 0.14em; text-transform: uppercase;
}
/* the separator between the name and the meta line exists for the panel's
   textContent read, not for the eye: on the tile the two are already on
   separate lines */
.cap-sep { display: none; }

/* S18, S19, S3. The Rise, in DOM. The ghost exists only under
   html.sections-ready, so nothing is ever hidden from a visitor whose script
   did not run OR whose script failed to load: this is the frame-0 law and the
   scroll-entrances-strand-content failure closed by one rule. The class is
   added by js/sections.js after its observer is attached, so the ghost state
   cannot be reached unless the engine that removes it is already running.
   The cast shadow is the load-bearing part; it is carried by the image box so
   it never outlines the caption. */
#studio .index-grid figure,
#studio .portrait {
  transition: opacity 720ms var(--ease), transform 720ms var(--ease), filter 720ms var(--ease);
  transition-delay: min(calc(var(--i, 0) * 40ms), 320ms);
}
html.sections-ready #studio .index-grid figure:not(.is-risen),
html.sections-ready #studio .portrait:not(.is-risen) {
  opacity: 0; transform: translateY(14px);
  filter: grayscale(1) contrast(0.82) brightness(1.06);
}
#studio .index-grid .tile-open,
#studio .portrait img {
  box-shadow: 0 18px 40px -22px rgba(16, 16, 18, 0.55);
  transition: transform 220ms var(--ease), box-shadow 720ms var(--ease);
  transition-delay: 0s, min(calc(var(--i, 0) * 40ms), 320ms);
}
html.sections-ready #studio .index-grid figure:not(.is-risen) .tile-open,
html.sections-ready #studio .portrait:not(.is-risen) img { box-shadow: 0 0 0 rgba(16, 16, 18, 0); }

/* S23. Relief responding to light, not a zoom. The crop-zoom goes away with the
   crop; hover lifts the piece and deepens its shadow. Hover states only where
   a real pointer exists, so a tap never leaves a stuck hover behind. */
#studio .index-grid figure:hover img,
#studio .index-grid figure:focus-within img { transform: none; }
@media (hover: hover) {
  #studio .index-grid figure.is-risen:hover .tile-open {
    transform: translateY(-2px); box-shadow: 0 26px 52px -24px rgba(16, 16, 18, 0.62);
  }
}
#studio .index-grid figure:focus-within .tile-open {
  transform: translateY(-2px); box-shadow: 0 26px 52px -24px rgba(16, 16, 18, 0.62);
}
#studio .tile-open:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

/* S24. In a light room 0.25 reads as broken. No filter on a 24 child container. */
#studio .index-grid.is-receded { opacity: 0.32; filter: none; }

/* ---------- S25. The piece steps forward, on a light ground ---------- */

/* R74e. The sheet's CSS has ONE home after R72m, and this is it. Below, verbatim,
   is the base block css/grisaille.css carries at 1092 to 1113 (the brief's 1039 to
   1048 is stale: MEASURED against the file). It is copied, not moved, because the
   engine owns that file; the engine deletes its copy once `git log -1 --
   css/sections.css` shows this commit, and until then lint.mjs (R72k) is the guard
   against the duplicate. Copying the base first means the deletion is a functional
   no-op instead of a moment where the sheet has no position, no scrim and no panel.
   Only the base block is copied: the engine's own @media (max-width: 720px) sheet
   rules and its R70q inquiry footer are NOT in the range it deletes, so duplicating
   them here would fight a rule that is staying.
   Half of that last sentence expired on 2026-09-11 with R77 b1: the R70q inquiry
   footer IS now in the range the engine deletes, so this file adopts it in its own
   phone block (search `R77 b1`). The other two rules in the engine's phone block
   were already dead when it was written, which is measured in that same note. */
.tile-front { position: fixed; inset: 0; z-index: 55; display: grid; place-items: center; padding: var(--pad); }
.tile-front[hidden] { display: none; }
.tile-front-scrim { position: absolute; inset: 0; background: rgba(5, 5, 6, 0.86); }
.tile-front-panel {
  position: relative; z-index: 1; max-width: min(1100px, 92vw); max-height: 88vh;
  display: flex; flex-direction: column; align-items: center; gap: 22px;
  overflow-y: auto; padding: clamp(20px, 4vw, 40px);
}
.tile-front-img { max-width: 100%; max-height: 62vh; width: auto; height: auto; object-fit: contain; }
.tile-front-text { max-width: 46em; text-align: center; }
.tile-front-caption {
  font: 10px/1.3 var(--utility); letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--silver); margin: 0 0 14px;
}
.tile-front-copy { font: 300 clamp(17px, 2vw, 21px)/1.55 var(--display); font-variation-settings: var(--reg-text); font-optical-sizing: none; color: var(--lime-white); margin: 0; }
.tile-front-close {
  all: unset; position: absolute; top: -6px; right: -6px; z-index: 2;
  height: 44px; display: grid; place-items: center; cursor: pointer; padding: 0 18px;
  font: 11px/1 var(--utility); letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--lime-white); border: 1px solid var(--pewter); border-radius: 999px;
}
.tile-front-close:hover, .tile-front-close:focus-visible { border-color: var(--lime-white); outline: 2px solid currentColor; outline-offset: 2px; }

/* the one thing the sheet used to get from the chamber it sat in rather than from a
   rule: .chamber-studio (:52) declares `color: var(--ink)` and the sheet inherited
   it. Every text node in the panel already names its own colour, so this is the
   wrapper's floor (.tile-front-text carries none) and the guarantee that nothing in
   a light panel can fall back to the dark register's ink. */
#tile-front { color: var(--ink); }

#tile-front .tile-front-scrim { background: rgba(247, 245, 241, 0.94); }
#tile-front .tile-front-copy { color: var(--ink); }
#tile-front .tile-front-caption { color: var(--ink); opacity: 0.62; }
#tile-front .tile-front-materials {
  margin: 0 0 16px; color: var(--ink); opacity: 0.62;
  font: 10px/1.6 var(--utility); letter-spacing: 0.14em; text-transform: uppercase;
}
#tile-front .tile-front-materials:empty { display: none; }
/* the close control used to sit at top: -6px inside a panel whose overflow-y is
   auto, so the scroll box clipped its own dismiss affordance. Sticky at the top
   of the panel's flex column keeps it whole, keeps it reachable while long copy
   scrolls, and keeps it inside the dialog the focus trap owns. That is loop 2's
   decision and it stands: the phone sheet overflows its 92vh cap on 23 of the 24
   works (measured, by 23px to 384px), so a dismiss control that scrolled away
   would be gone on nearly every one of them.
   That 92vh was true when it was measured and is not true today, which is recorded
   here rather than quietly fixed. R74e copied the base .tile-front-panel rule into
   THIS file, the later stylesheet, so at equal specificity (0,1,0) the base now beats
   css/grisaille.css's phone override and the live phone cap is the base 88vh.
   MEASURED 2026-09-11 at 390x844: max-height 742.72px (88vh of 844) and row-gap 22px,
   identical with the engine's phone rules present and with them stripped, so both of
   those overrides are already dead and the overflow figures above are conservative
   rather than wrong. Restoring them is a behaviour change on every phone sheet and
   every green phone row this wave was measured at 88vh, so it is named as a gap in
   work/loop3-2026-09-09/notes/sections.md instead of taken here.
   `flex: none` is new and it is not cosmetic: the panel is a column flex box at
   a definite height, so it SHRANK this item from the shared rule's 44px to its
   11px content box on a phone, rendering 81x13. Measured with the control both
   first and last in the DOM, so the compression is the column's doing and not an
   order effect. A 13px tall dismiss control is not a tap target, and R66k's
   squared CLOSE has to be a control before it is a shape: 81x46 on all 24 works
   now, at both viewports. */
#tile-front .tile-front-close {
  position: sticky; top: 0; right: auto; align-self: flex-end; flex: none;
  color: var(--ink); border: 1px solid var(--ink); background: var(--lime-white);
  /* R66k, critic 23: "CLOSE is a rounded pill". The 999px comes from the shared
     .tile-front-close rule, which R74e brought into this file above (it was the
     engine's, and the square was stated here because that file was another lane's);
     the square stays stated here, where the studio's own register is. Nothing else in
     this half is a pill: the chips, the tiles and the inquiry control are all squared,
     so the one radius in the room was the one control that is not part of the work. */
  border-radius: 0;
}
#tile-front .tile-front-close:hover, #tile-front .tile-front-close:focus-visible { border-color: var(--ink); }

/* R71e(ii), critic 6. The gutter has to read as ONE tone with the sheet open and it
   did not. The scrim is 6 percent transparent, so whatever is behind reaches the eye
   at 6 percent, and what is behind is not only the receded grid. MEASURED at 1440x900
   in 3D mode over all 24 works, a 17px lattice across the whole gutter with the
   panel's rect plus 6px excluded (6740 samples per work): floor rgb(232, 231, 227),
   ceiling rgb(247, 246, 242), a 13 to 15 level spread depending on the run, and 5 of
   the 24 works over the clause's 6. The arithmetic names the leak exactly: 247 times
   0.94 is 232.2, so the floor is the scrim over BLACK. At 390 the same sweep reads 5
   to 10 levels from one run to the next, which is the tell: the gutter's tone was a
   function of wherever the canvas happened to be, so the clause failed
   intermittently there and reliably at 1440.
   The fix states the sheet's own ground instead of budgeting a passthrough. The
   scrim's declared value does not move (S25.1 holds it at rgba(247, 245, 241, 0.94),
   and it is still the full-bleed dismiss surface); what changes is what sits UNDER
   it, which is now the room's own paper rather than the canvas. One tone by
   construction: measured delta 0 levels, 1769 samples at 390 and 6646 at 1440, 0 of
   24 over 6. It also settles R66k's third clause outright, which the phone block
   below had to argue from a 1.92 percent passthrough: the grid behind now measures
   1.000:1 against its unscrimmed 17.118:1 at 1440 and 2.153:1 at 390, which is 0.0
   percent of the contrast above 1 rather than at most half. */
#tile-front { background: var(--lime-white); }

/* R71g, critic 8. With the gutter at one tone the plate had no step off it: the panel
   declares no background and no border, so plate and ground were the same paper,
   measured 1.000:1 at 390 and 1.055:1 at 1440 at the panel's own top edge, under the
   clause's 1.15:1 on 24 of 24 at both viewports. The tone branch cannot be taken
   upward: the ground is lime-white at 0.9148 relative luminance and 1.15:1 lighter
   than that needs a luminance above 1, while 1.15:1 darker is a gray card under every
   work. So this is the clause's other branch, a rule of 1px or more at 3:1, in the
   record's own FOLIO ink: 1px solid #5B5F66 measures 5.932:1 against the painted
   ground on all 24 at both viewports, 0 under 3:1, sampled as the darkest device
   pixel in a 5px band straddling the panel's top edge against the mode of the band
   10px outside it. A hairline mount is also what a sheet on paper has, and the ink is
   the one the catalogue lines already use. box-sizing is border-box globally
   (css/grisaille.css:128), so the phone panel's box does not grow: 350x743 before and
   after, which leaves S64's action row exactly where it was. */
#tile-front .tile-front-panel { border: 1px solid var(--folio-ink); }
/* A definite box before the image byte arrives: js/sections.js copies the
   tile's own --ar onto this element, so the FLIP can measure the destination
   on the first frame instead of waiting for a load event, and the box does not
   change when the image finally decodes. */
#tile-front .tile-front-panel { overflow-x: hidden; }
#tile-front .tile-front-img {
  height: 56vh; width: auto; max-width: 100%; max-height: 56vh;
  transform-origin: top left;
}

/* ---------- C. THE HAND ---------- */

/* S31, S35. One centred column: portrait at scale, then the statement, then the
   credentials. The portrait rises like a piece of work; the message layer is at
   full opacity at frame 0 and never animates. */
#studio .studio-close {
  display: grid; grid-template-columns: 1fr; justify-items: center;
  text-align: center; padding-top: 18vh; margin-bottom: 0; gap: 0;
  align-items: start;
}
#studio .portrait { max-width: min(760px, 78vw); margin: 0; }
#studio .portrait img { width: 100%; }
.studio-statement { margin: 7vh 0 0; max-width: 22em; }
.studio-statement p {
  font: 300 clamp(28px, 4.2vw, 58px)/1.28 var(--display);
  color: var(--ink); letter-spacing: 0.005em;
}
#studio .credentials { margin: 5vh 0 0; max-width: 36em; }
#studio .credentials p { font: 300 clamp(17px, 1.9vw, 22px)/1.5 var(--display); max-width: none; }
.credential-quote { margin: 26px 0 0; }
.credential-quote p { font: 300 clamp(16px, 1.7vw, 20px)/1.55 var(--display); color: var(--ink); opacity: 0.86; }

/* ---------- D. INQUIRE ---------- */

/* S46. The hairline is the group rule's weight and colour, not a border of its
   own invention. S31's 16vh separates it from the closer. */
#studio .inquire {
  margin-top: 16vh; padding: 10vh 0 12vh;
  border-top: 2px solid rgba(16, 16, 18, 0.35);
}
.inquire-note { margin-top: 30px; max-width: 40em; }
.inquire-note p { font: 300 clamp(17px, 1.9vw, 21px)/1.5 var(--display); color: var(--ink); }
.inquire-quiet { margin-top: 24px; }
.inquire-quiet p { font-size: clamp(16px, 1.6vw, 19px); opacity: 0.86; }

/* S40 to S45. A real structured intake that composes a mailto and claims
   nothing. No backend, so no confirmation. */
.inquire-form { margin-top: 8vh; display: grid; gap: 32px; max-width: 44em; }
/* Pitch P8: the caret, the radios and the select take the page's ink, not system blue. */
.inquire-form { caret-color: var(--ink); accent-color: var(--ink); }
.inquire-chips { border: 0; padding: 0; margin: 0; min-inline-size: 0; }
.inquire-chips legend {
  padding: 0 0 18px; font: 11px/1 var(--utility);
  letter-spacing: 0.22em; text-transform: uppercase; color: var(--ink); opacity: 0.62;
}
.chip-row { display: flex; flex-wrap: wrap; gap: 10px; }
.chip { position: relative; display: inline-block; }
.chip input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }
.chip span {
  display: flex; align-items: center; justify-content: center;
  min-height: 48px; padding: 0 22px; border: 1px solid rgba(16, 16, 18, 0.22);
  font: 11px/1.3 var(--utility); letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink);
  transition: background 160ms ease, color 160ms ease, border-color 160ms ease;
}
.chip input:checked + span { background: var(--ink); color: var(--lime-white); border-color: var(--ink); }
.chip input:focus-visible + span { outline: 2px solid var(--silver); outline-offset: 2px; }

.field { display: grid; gap: 8px; }
.field > label {
  font: 11px/1 var(--utility); letter-spacing: 0.2em;
  text-transform: uppercase; color: var(--ink); opacity: 0.62;
}
.field input, .field textarea {
  width: 100%; min-height: 48px; padding: 12px 0;
  font: 300 16px/1.5 var(--display); color: var(--ink);
  background: none; border: 0; border-bottom: 1px solid rgba(16, 16, 18, 0.22);
  border-radius: 0; -webkit-appearance: none; appearance: none;
  transition: border-color 160ms ease;
}
.field textarea { resize: vertical; overflow-x: hidden; }
.field input:focus, .field textarea:focus { border-bottom-color: var(--ink); }
.field-msg { margin: 0; font: 12px/1.5 var(--utility); color: var(--ink); opacity: 0.8; }
.field-msg:empty { display: none; }
/* Pitch P7. The no-mail-app line under the submit button: the address selects whole on
   one tap or click, so it can be copied without a mail client. */
.iq-fallback { margin-top: 12px; }
.iq-fallback a { color: inherit; text-underline-offset: 3px; user-select: all; -webkit-user-select: all; }

/* S42. The contact facts are static text, never mail links: on a private demo a
   stray click must not open a message to the client's own inbox. The form's
   target is the one repointable attribute on #inquire. */
.inquire-facts {
  margin-top: 7vh; display: flex; flex-wrap: wrap; gap: 6px 30px;
  font: 12px/1.7 var(--utility); letter-spacing: 0.06em; color: var(--ink); opacity: 0.72;
}
.inquire-facts p { margin: 0; }
.inquire-facts a { color: inherit; text-underline-offset: 3px; }

#studio .site-foot { border-top: 1px solid rgba(16, 16, 18, 0.14); }

/* ---------- E. THE RAIL IN IV (S48) ---------- */

/* Numeral IV expands into its two sub-stops while the visitor is inside it, the
   idiom already shipped on the quest strip. Absolutely positioned so the rail's
   own flex distribution of the five ticks is untouched (S53). */
.tick-subs {
  position: absolute; left: 50%; transform: translateX(-50%);
  display: grid; gap: 11px; justify-items: center; z-index: 3;
}
.tick-subs a { position: relative; display: grid; place-items: center; width: 22px; height: 22px; color: inherit; text-decoration: none; }
.tick-subs i {
  width: 7px; height: 7px; border-radius: 50%; border: 1px solid currentColor;
  opacity: 0.5; transition: opacity 200ms ease, background-color 200ms ease;
}
.tick-subs a[aria-current="true"] i { background-color: currentColor; opacity: 1; }
.tick-subs b {
  position: absolute; left: 26px; white-space: nowrap; font-weight: 400;
  font: 10px/1 var(--utility); letter-spacing: 0.16em; text-transform: uppercase;
  background: var(--lime-white); color: var(--ink); padding: 7px 10px;
  opacity: 0; pointer-events: none; transition: opacity 180ms ease;
}
.tick-subs a:hover b, .tick-subs a:focus-visible b { opacity: 1; }

/* ---------- MOBILE (S21, S27, S37, S44) ---------- */

@media (max-width: 720px) {
  /* rail left is 4px and it is 40px wide here, against a 58px content inset */
  /* R33 widened the rail's CONTAINER from 44px to 58px here, but the thread inside
     it did not move: css/grisaille.css:801 keeps `left: 4px` and :273 puts the svg at
     the container's own left edge with the track at x=22 of its user space. So the
     thread's x is 4 + 22 = 26, and this chamber's content box starts at the 58px
     mobile padding: 26 - 58 = -32. It was -34, which is where the thread sat when the
     container was 44px wide and the two happened to agree. */
  /* the 28px the column gives up on the left to clear the rail is taken back on the
     RIGHT, where nothing is competing for it: `.chamber` reserves 24px there and the
     rail is nowhere near it. Without this the studio note and the inquire note fall to
     26.6 ch, under EC1's 28 ch floor for this viewport, purely because the column
     narrowed. Measured rather than estimated twice: Fraunces' ch is 0.62em here, not
     0.60, so 12px of relief gave 27.7 ch and 6px gives 298px and 28.3 ch. */
  #studio { --rail-shift: -32px; --iv-w: min(1240px, 100% - 28px); --iv-padl: 58px; --iv-padr: 6px; padding-right: var(--iv-padr); }

  /* S21. No filter layer anywhere in IV on a phone: this is the jank generator
     in this design, and it buys nothing at this size. R36g S6 moved the record's
     reveal from the figure onto `.tile-open`, so the suppression follows it there. */
  .hang-line, .hang-line.is-drawn, .hang-line.is-drawn.is-cool { filter: none; }
  html.sections-ready #studio .index-grid figure:not(.is-risen),
  html.sections-ready #studio .index-grid figure:not(.is-risen) .tile-open,
  html.sections-ready #studio .portrait:not(.is-risen) { filter: none; transform: translateY(10px); }
  #studio .index-grid figure, #studio .portrait {
    transition: opacity 720ms var(--ease), transform 720ms var(--ease);
    transition-delay: min(calc(var(--i, 0) * 40ms), 160ms);
  }
  /* the resting shadow is applied statically rather than transitioned */
  html.sections-ready #studio .index-grid figure:not(.is-risen) .tile-open,
  html.sections-ready #studio .portrait:not(.is-risen) img { box-shadow: 0 18px 40px -22px rgba(16, 16, 18, 0.55); }
  #studio .index-grid .tile-open, #studio .portrait img { transition: none; }

  /* S27. Two columns of real work, not postage stamps of a surface. */
  /* S27. Still two columns of real work, and the minimum track had to come down to
     keep them: chamber IV's column narrowed by 28px at 390 to clear the rail R33
     widened, and at minmax(150px) that tipped the auto-fill from 2 tracks to 1
     (MEASURED: columns=1, narrowest image 296px, one tile per row). 134px puts it back
     at 2 with a 143px tile against the 157px it had. */
  .studio-field {
    grid-template-columns: repeat(auto-fill, minmax(134px, 1fr));
    /* the bleed grows with the inset the column lost, and the arithmetic
       (18px against a 298px column) puts the tile back at 153px, over S27's 150px
       floor, and the field's right edge lands at 388 of a 390px viewport. */
    gap: 22px 10px; width: calc(100% + 18px); margin-right: -18px;
  }
  .group-label { gap: 14px; margin-bottom: 24px; }
  .studio-group { margin-bottom: 10vh; }
  #studio .index-grid figcaption { font-size: 14px; padding-top: 10px; }

  /* S37, S44 */
  /* B3/A9. Reserve the chrome's own height above the record so a caption box
     never shares pixels with the wordmark. Derived from the chrome, not chosen:
     css/grisaille.css:603 sets its mobile padding to 16px and :155 sets the
     wordmark to 12px/1, so the box is 16 + 12 + 16. */
  #studio .index-grid { padding-top: calc(2 * 16px + 12px); }
  /* B9. The deck's rule: a display statement left-aligns below 720px, where a
     centred rag at this size reads as a poster rather than as a sentence. */
  .studio-statement, .studio-statement p { text-align: left; }
  .studio-statement p { margin-inline: 0; }
  #studio .studio-close { padding-top: 12vh; }
  #studio .portrait { max-width: 100%; }
  .studio-statement { margin-top: 5vh; }
  #studio .inquire { margin-top: 12vh; }
  .inquire-form { gap: 26px; }
  .chip-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
  .chip span { padding: 0 12px; text-align: center; }
  #studio .inquire .button { display: block; width: 100%; text-align: center; padding: 20px 24px; }
  .inquire-facts { gap: 4px 20px; }
}

/* ---------- REDUCED MOTION (S11, S22, S51) ---------- */

/* Everything resolves at its end state. Nothing is removed, nothing is left in
   a pre-reveal state, and the hang line is still drawn because it is structure. */
@media (prefers-reduced-motion: reduce) {
  .hang-line,
  #studio .index-grid figure,
  #studio .index-grid .tile-open,
  #studio .index-grid figcaption,
  #studio .portrait,
  #studio .portrait img,
  .chip span,
  .field input,
  .field textarea,
  .tick-subs i,
  .tick-subs b { transition: none; }
  .hang-line, .hang-line.is-drawn, .hang-line.is-drawn.is-cool { filter: none; }
  html.thread-arrived .thread-fill { animation: none; }
}

/* R27, amended. The 13 path pieces and the portrait are wrapped in <picture>
   so a narrow viewport can be handed the -800 tier outright: srcset and sizes
   alone cannot do it, because at 390 CSS px the slot is 308px and a DPR-3
   phone correctly resolves that to 924px and takes the 1200w candidate. A
   media condition is not a claim about layout, so it states the real rule:
   below 1024px this document ships the small tier.
   display:contents removes the wrapper from the box tree, so `.piece img`
   margin auto centring, `.portrait img` percentage height and every other
   shipped rule in css/grisaille.css resolve against the figure exactly as they
   did before the wrapper existed. Without it the picture is an inline box and
   auto margins stop centring. Audited rule by rule against the live tree.
   The engine is untouched by design: js/grisaille.js:496 reads
   img.getAttribute('src'), not currentSrc, so its texture and displacement
   tiers still come from the <img>, which is why mammatus keeps its lossless
   PNG there and offers WebP only through <source>. */
picture { display: contents; }

/* ---------- F. THE SEAM, THE ROOM'S LIGHT, THE DEPTH TIERS (R23 to R25) ----------

   One idea, three expressions: chamber IV is a lit room, and the visitor walks
   through it. The light has a direction, the works cast their own real depth in
   it, and the layers that are not works sit at their own distance. js/sections.js
   publishes two measured scalars (--rec, --pd); every law below is here, in one
   place, so it is legible with the script blocked and cannot drift between the
   two files. */

/* #tile-front joins this list for ONE declaration, the room's light (R35c: the
   fronted piece takes the room's --lx/--ly vector at 1.6x the throw). js/sections.js
   writes the live --rec and --pd INLINE on #studio (:190, sp = studio.style), and
   inheritance only runs downward, so after R72m the sheet reads the AUTHORED pair
   declared below, -0.341/0.280, the mid-record rest rake. Disclosed as a deviation:
   the shadow on the open sheet no longer tracks the live rake (at 1440x900 the live
   offset runs 0 to -29.8px over the record and the rest rake is -16.4px) and the
   desktop pointer no longer rakes it (+/-3.1px of that offset). Restoring it costs
   40 B of js/sections.js (front.style = studio.style.cssText in the open observer)
   against 21 B of headroom under the 6000 B ceiling, which no lane may raise (R70p),
   so it is a question for the manager rather than a silent regression. */
#studio, #tile-front {
  /* the engine's own constants, mirrored. --wu is W.SCROLL_VH / SCROLL_VH_MOBILE
     and --frame-margin is W.FRAME_MARGIN (js/grisaille.js:79, :139-141). They
     exist twice by necessity, so they get a drift check, not a promise. */
  --wu: 3.80vh;
  --frame-margin: 1.14;

  /* R24. The room's light. --rec is the record's own progress, published by
     js/sections.js quantised to 32 steps; --pd is the desktop pointer's rake.
     Both have authored values here, so with the script blocked the room is lit
     at the mid-record rake (-0.34, 0.28) rather than lit from nowhere: the
     frame-0 law applied to a physical property, not only to text.
     Sweep: --lx runs -0.62 at the first row to 0.00 at Inquire, because raking
     light is for reading relief and flat light is for reading and writing, and
     a studio changes its light for the task. --ly rises to 0.40 overhead on the
     same journey. -0.62 * 30px = 18.6px, which is the shipped 18px shadow. */
  --rec: 0.45;
  --pd: 0;
  --lx: calc(-0.62 + 0.62 * var(--rec) + var(--pd) * 0.36);
  --ly: calc(0.28 + 0.218 * (var(--rec) - 0.45));
  --light-throw: 30px;

  /* the REST light, normalised to unit length, for the 24 static shadows. The
     live vector never reaches them: box-shadow is a paint property and writing
     it on 24 large images every frame is the jank S29 exists to prevent. The
     tiles are sculpted BY the room's light; they do not each carry a copy. */
  --rx: -0.772;
  --ry: 0.636;
  /* px of shadow per stated inch of depth. Derived from one constraint: the
     deepest work in the record (8 in) must keep its shadow tip inside the 24px
     column gutter, never on its neighbour. 8 * 2.25 = 18px of throw. */
  --sk: 2.25px;
  /* CUT BY THE MANAGER 2026-09-04, after reading seam and record frames at
     1440: the hairline crossed Penumbra 07, Rorschach 01 and the portrait at
     the eye line, and a line through a client's artwork reads as a rendering
     artifact on a large monitor. The mechanism stays wired and costs nothing
     at 0; row lighting (.at-eye), which is the part that earned its place, is
     untouched. Restore by setting this to 0.08. */
  --horizon-max: 0;
  --pk-portrait: 7.5vh;

  /* R36d, the LIGHT ground's ghost floor, taken early because R35a authors the first
     surface that needs one. It is an rgb mixed from the ground, never an alpha: an
     alpha ghost over a photograph picks up the photograph, which is how a caption ends
     up at a different contrast on every tile. 0.38 of ink on lime white measures
     2.46:1 (VERIFIED by the type panel). R36d sets the floor at 0.50, which is
     mix(#101012, #F7F5F1, 50%) = #848382 and measures 3.46:1: better than 2.46 but
     still under the 4.5:1 that S50 requires of every text node in chamber IV, and the
     ghost lands on two REAL sentences here (the group labels and the closer), not on
     decoration. 0.50 was chosen to beat 2.46, not against a gate. The floor is 0.62,
     mix(#101012, #F7F5F1, 62%) = #686764, the lightest mix that clears 4.5:1 with room
     (5.19:1), and the step to ink at 17.4:1 is still plainly a step. Disclosed as a
     correction to R36d's number rather than an exception to it. */
  --ghost-light: #686764;
}

/* R23a. The overlap. Chamber IV is pulled up INTO the stage's fade window and
   raised above the canvas, so its arrival happens inside the dissolve instead
   of after it. Measured before this rule: the hang line's observer fires 4.5
   world units BEFORE the stage even starts fading, so the ruled 900ms draw ran
   behind an opaque canvas and had never been seen at either viewport.
   The engine writes --seam-overlap in px. Until it does, the fallback is the
   same quantity in the engine's own constants: (STAGE_FADE_START 9.7 minus
   STAGE_FADE_END 3.2) world units at --wu each, which is 24.7vh = 222px at
   1440x900 and 19.24vh = 162px at 390x844, the two ruled figures exactly. */
.mode-3d #studio {
  position: relative;
  z-index: 2;
  padding-top: 0;
  /* R66b, R70f. TWO fade windows, not one. One window was the plate's: it put the
     chamber's top edge 403px below where the crossing comes to rest at 1440x900
     and 298px below it at 390x844 (MEASURED at the gesture crossing, both
     viewports, no qaexpose), so the first screenful past the seam was a wordless
     plate and the title stood 1,384px further down the document. The second
     window is the chapter head's, and the window is the right unit for it rather
     than a tuned number: the head is 137px tall at 1440x900 and 79px at 390x844,
     both inside one window (219.7px and 159.9px live), so the head can never be
     lifted above the dissolve that reveals it. The stage covers this chamber
     until the fade begins, which is why content may live here at all (R23a).
     It also fixes the chapter jump: `End` aims at this box (js/grisaille.js:3329),
     and the box now starts at the head instead of 981px above it. */
  margin-top: calc(-2 * var(--seam-overlap, calc(6.5 * var(--wu))));
}

/* and the head is the chamber's first block in immersive mode, so its own 14vh of
   air would be the chamber's (margins collapse through a zero padding-top) and
   would cancel the second window exactly. In document mode the chamber keeps the
   .chamber padding and the air is the head's again. */
.mode-3d #studio .chapter-head { margin-top: 0; }

/* R23b. The handover plate, at opacity 1, unanimated. Nothing fades in and
   nothing translates; the room leaves and the plate stays. Pitch R11 (2026-09-23):
   the plate is no longer the last piece of the path repeated. It is a living still
   of the Chagrin Falls fireplace relief, a work the path does not show, so the cut
   is from the last finish to the room it was made for. Only its SIZE still comes
   from the last piece, and that is exact because both are 3:4.
   The box comes from the engine (--handover-w/h, its own derived framing). The
   fallback is that same derivation in CSS: 100vw / FRAME_MARGIN against the
   height the stage-clear frame actually leaves, at the piece's own 600/800
   aspect. It resolves to 445x593 at 1440x900 and 342x456 at 390x844, which are
   the ruled static fallbacks, reached rather than typed. */
.handover { display: none; }
.mode-3d .handover {
  display: block;
  /* the plate is full-bleed media and its width is the camera's, not the
     column's, so it is centred on the VIEWPORT rather than inside chamber IV's
     padding. `margin: 0 auto` cannot do that: an auto margin on a box wider
     than its container computes to 0, which is how 342px landed 10px past the
     right edge of a 390px screen. left 50% plus a half-width translate centres
     it whatever the padding is. */
  /* centred on the VIEWPORT, exactly, and not on the containing block. `left: 50%`
     resolves against the CONTENT box, so it only centred on the viewport while this
     chamber's two insets happened to be equal; at 390 they are 58 and 6, and the plate
     sat 17px right of centre and then 2px PAST the right edge, which is a horizontal
     scrollbar on a page that must not have one (MEASURED: scrollWidth 392 against a
     390 viewport, and the plate was the only element over the line).
     Half the inset difference is exactly the correction: flowLeft + 50% + (padR - padL)
     / 2 - w/2 resolves to (viewport - w) / 2 for any pair of insets, with no vw and so
     no scrollbar error. */
  position: relative; left: calc(50% + (var(--iv-padr) - var(--iv-padl)) / 2);
  transform: translateX(-50%);
  /* Pitch R11: + 52px for the caption's third line (.ho-note, two lines of 13/1.5
     plus its 8px gap). Without it the absolute caption ran 39px (1440x900) and 43px
     (390x844) past the hang line below it (MEASURED, notes/PAGE.md P3). */
  margin: 0 0 calc(7vh + 52px);
  /* RULED 2026-09-04 against the engine's live export (592.1 x 789.5 at
     1440x900, 342.1 x 456.1 at 390x844). The fallback is not two typed pairs
     but the law those pairs come from: the piece fills 1/FRAME_MARGIN of
     whichever viewport dimension limits it, at its own 600/800 aspect. That
     yields 592.1 x 789.5 and 342.1 x 456.1 exactly, and stays right at every
     other viewport instead of only at the two that were measured. */
  --plate-h: calc(min(100vh, 100vw / 0.75) / var(--frame-margin));
  width: var(--handover-w, calc(var(--plate-h) * 0.75));
  height: var(--handover-h, var(--plate-h));
}
/* A4. The plate's ground is the LIVE fog colour, not a fixed white. The engine
   writes --ground every frame (the same variable the seam rule at the top of
   this file consumes, S1), so at the dissolve midpoint the plate's ground IS
   the fog beside it and it becomes the room's white exactly as the room does.
   Before this the box matched the engine's framing and the tone never did, so
   for the frames where the fog is still mid-gray the plate read as a pasted
   rectangle. Falls back to the chamber ground when the engine has not written. */
.handover { background: var(--ground, var(--lime-white)); }
/* Pitch P4 (critic item 14). On a phone the plate was centred on the viewport at the
   engine's 342px width, so its left 46px sat under the rail: numerals III and IV, three
   rail plates and the end circle drew over the photograph (MEASURED at the seam rest,
   390x844: rail parts x 6 to 70, plate x 24 to 366). Now that the plate is its own work
   and not the last piece's frame, its box can yield to the rail: it starts at 76px
   (the widest rail part, the numeral buttons, ends at 70px), keeps its 3:4, and is never
   wider than the engine's box. Viewport x = the content box's left (--iv-padl) plus
   this offset, so the padding cancels out whatever it is. */
@media (max-width: 720px) {
  .mode-3d .handover {
    left: calc(76px - var(--iv-padl)); transform: none;
    width: min(var(--handover-w, 342px), 100vw - 88px);
    height: auto; aspect-ratio: 3 / 4;
  }
}
.handover img, .handover video {
  width: 100%; height: 100%; display: block; object-fit: contain;
  box-shadow: calc(var(--lx) * var(--light-throw)) calc(var(--ly) * var(--light-throw)) 60px -30px rgba(16, 16, 18, 0.5);
  transition: box-shadow 220ms var(--ease);
}

/* R35a. The caption the HUD's last two lines land in. Measured before this change:
   the plate arrived uncaptioned and the record's first caption was four screens
   further down (current/d-handover-1 against d-handover-5), so the one frame the
   whole traversal hands over to said nothing about what was hanging in it.
   The two sizes are the record's own (.cap-m for the series, .cap-n for the title),
   so this IS the record's first caption rather than a label that resembles one; the
   ORDER is the HUD's (series over title), because the read is those two lines
   settling out of the fog onto the wall, not a new caption being written.
   Left-aligned on the plate's own left edge and 14px under it, clear of the
   acceptance floor of plate bottom + 12px. */
.handover figcaption {
  position: absolute; left: 0; top: calc(100% + 14px);
  white-space: nowrap; color: var(--ink);
}
.ho-series {
  display: block; margin-bottom: 6px;
  font: 10px/1.5 var(--utility); letter-spacing: 0.14em; text-transform: uppercase;
}
.ho-title { display: block; font: 300 15px/1.35 var(--display); }
/* Pitch R11. Peter's own sentence under the plate, in the record's folio register
   (the size .cap-m actually renders at in the record, see FOLIO below), sentence
   case, wrapping inside a measure because the caption row itself is nowrap. It
   inherits the caption's colour, so it takes ink on the same clock as the two
   lines above it. */
.ho-note {
  display: block; margin-top: 8px; max-width: 34ch; white-space: normal;
  font: 400 13px/1.5 var(--utility); letter-spacing: 0.01em;
}

/* Ghost to ink, on the hang line's own clock. The 900ms delay is not a chosen number:
   js/sections.js hands the hang line `is-cool` exactly 900ms after `is-drawn`, so the
   caption takes ink as the thread stops glowing, one event, two consequences.
   Written the S19 way: the ghost exists ONLY under sections-ready, so with the script
   blocked the caption is at ink at frame 0 and never waits for anything. */
html.sections-ready .handover figcaption {
  color: var(--ghost-light);
  transition: color 400ms var(--ease) 900ms;
}
html.sections-ready.thread-arrived .handover figcaption { color: var(--ink); }

/* A12/B8. The rail carries IV, and its own sub-stops, 40px from this numeral
   (measured at 390 in current/m-studio-rest): two circled IVs on one screen. The
   chapter head drops its numeral wherever the rail is actually there to carry it,
   which is scripted immersive mode and nowhere else. The words below it are
   untouched, so a no-JS or document-mode visitor still gets the full chapter head. */
html.js .mode-3d #studio .chapter-head .numeral { display: none; }

/* R25 rank 1. The light bed: the chamber's own fog-to-light ramp, lagging the
   record by a ratio of 0.36 (0.18 on a phone). The visitor reads the light as
   standing still while they move into it, which is what walking into a lit room
   looks like. Oversized by more than twice the peak so no edge is ever exposed;
   its last stop is the chamber's own ground, so its lower edge is invisible. */
/* R35a, the band at the seam. Measured before this change (mock-second-half/current/
   d-handover-3, m-handover-2): the dissolve crossed a HARD horizontal edge, fog white
   above it and the bed's pewter below it, and it was the ugliest frame in this half.
   Two causes, both here. (1) The bed's first stop was a FIXED pewter while the fog
   beside it was near lime-white, so the two grounds could not agree at any moment of
   the fade. It now starts at the LIVE fog colour the engine writes every frame
   (js/grisaille.js:3182), the same variable the ground rule at the top of this file
   and the plate's own background already consume, so at every frame of the dissolve
   the bed BEGINS at the colour beside it and no edge can exist. (2) The bed's top edge
   sat at a fixed -10vh, which is INSIDE the fade window (the engine's own seam overlap
   is 222px at 1440x900). It now starts exactly at the overlap, so the edge is above
   the window rather than inside it. Height follows the top so the bed's lower reach is
   unchanged: 110vh + overlap resolves to the shipped 120vh at the fallback. */
.light-bed {
  position: absolute; left: 0; right: 0; z-index: -2;
  top: calc(-1 * var(--seam-overlap, 10vh));
  height: calc(110vh + var(--seam-overlap, 10vh));
  background: linear-gradient(180deg, var(--ground, var(--pewter)) 0%, var(--bone) 46%, var(--lime-white) 100%);
  pointer-events: none; will-change: transform;
}

/* The room gradient, desktop only, and it is the one layer the pointer moves.
   4vmax of travel against a 60vmax radius moves the falloff by 6.6% of its own
   scale: felt as the room breathing, never seen as an element sliding. Peak
   tint is 5.5% pewter, which takes ink on lime white from 17.4:1 to 16.4:1.
   No mix-blend-mode: a plain alpha layer behind the content. */
.room-light { display: none; }
@media (hover: hover) and (pointer: fine) and (min-width: 721px) {
  .room-light {
    display: block; position: fixed; inset: -12vmax; z-index: -1; pointer-events: none;
    background: radial-gradient(60vmax 60vmax at 50% 40%, rgba(247, 245, 241, 0) 0%, rgba(99, 104, 111, 0.055) 100%);
    transform: translate3d(calc(var(--lx) * -4vmax), calc(var(--ly) * -3vmax), 0);
    transition: transform 220ms var(--ease);
    will-change: transform;
  }
}

/* R25. The eye line. In a real gallery the row's shared centre is a horizon: it
   stays at your eye height while you walk and every work passes through it.
   0.08 black on lime white is 1.10:1, at the threshold of visibility, which is
   where a structural rule belongs: a hairline that competes with 24 photographs
   turns a record into a wireframe. It carries no text and is aria-hidden.
   TO CUT IT: set --horizon-max to 0. That is the whole switch. */
.horizon {
  position: fixed; left: 0; right: 0; top: 50vh; height: 1px; z-index: 1;
  background: currentColor; color: var(--nav-fg); opacity: 0; pointer-events: none;
}
html.sections-ready .horizon {
  opacity: calc(var(--horizon-max) * clamp(0, min(var(--rec) * 8.33, (1 - var(--rec)) * 8.33), 1));
}

/* R24 + A.M3. A piece's cast shadow is its own stated depth, under the room's
   rest light. Those depths run 1.75in to 8in, a 4.6x range that every one of
   the 24 tiles used to answer with the same 18px shadow; the playbook names a
   ratio that is uniform across unrelated elements as the cheap read.
   --dz is transcribed into index.html from each figure's own data-materials, so
   it is correct at frame 0, correct with the script blocked, and checkable
   against the artist's own catalogue line rather than against taste.
   The calc has to live on the tile, not on #studio: a custom property resolves
   where it is declared, so --dz read at #studio would always be the default. */
#studio .index-grid figure { --dz: 0; }
#studio #studio-fine-art .tile-open {
  box-shadow: calc(var(--rx) * var(--dz) * var(--sk)) calc(var(--ry) * var(--dz) * var(--sk)) 40px -22px rgba(16, 16, 18, 0.55);
}
/* The nine finishes cast no directional shadow at all, and that is the strongest
   single read in the record: a plaster finish is not an object standing off the
   wall, it IS the wall. Their record carries no depth because there is none. */
#studio #studio-surfaces .tile-open { box-shadow: 0 1px 4px -2px rgba(16, 16, 18, 0.35); }

/* a work takes light as it passes the horizon: one step deeper on the same
   vector, over 400ms. It is a state, not an animation, so reduced motion keeps
   it and only drops the transition.
   R35b: the step was 1.35x, which is 18px of throw becoming 24px on the deepest
   work and less than 3px on the shallowest, and the critics were right that in a
   STILL at 1440 nothing marked the row at all. 1.9x takes the deepest work's throw
   to 34px on the same vector, with the alpha stepping 0.62 to 0.70 and the blur
   opening with the throw. Same direction, same law, one step further: the row is
   lit, it is not selected. */
#studio #studio-fine-art figure.at-eye .tile-open {
  box-shadow: calc(var(--rx) * var(--dz) * var(--sk) * 1.9) calc(var(--ry) * var(--dz) * var(--sk) * 1.9) 60px -26px rgba(16, 16, 18, 0.7);
}
/* the finishes keep 0 on the x axis, because a finish IS the wall and that read is
   the strongest single thing in the record. What they gain is depth without
   direction: the sample lifts off the wall it is mounted on. */
#studio #studio-surfaces figure.at-eye .tile-open { box-shadow: 0 3px 10px -3px rgba(16, 16, 18, 0.5); }

/* R35b, the edge light. A cast shadow says where the light is NOT; at the eye line
   the work should also show where it IS. One inset hairline on the two edges the
   room's light actually falls on (--lx is negative and --ly positive all the way
   through the record, so the light is up and to the left, so the lit edges are the
   left and the top), in the room's own lime white at 0.55. It is on a pseudo
   element rather than on the image because a second box-shadow on the img would
   fight the tile's own depth shadow for the same paint.
   The button and the image share one box (the button carries the piece's own
   aspect-ratio and the img fills it), so the hairline lands on the photograph's
   edge and not on a container around it. */
#studio .index-grid .tile-open { position: relative; }
#studio .index-grid .tile-open::after {
  content: ''; position: absolute; inset: 0; pointer-events: none;
  box-shadow: inset 0 0 0 rgba(247, 245, 241, 0);
  transition: box-shadow 400ms var(--ease);
}
#studio .index-grid figure.at-eye .tile-open::after { box-shadow: inset 1px 1px 0 rgba(247, 245, 241, 0.55); }

/* A10 + B10, the two grid holes, both closed by arithmetic rather than by a hand
   list, so adding or removing a piece can never reopen one.
   A10: nine finishes in a five-column auto-fill grid is 5 + 4, and the trailing
   hole reads as a missing tenth sample. Three columns of three fills exactly, and
   it is the right read anyway: a finish sample is a material at scale, not a
   thumbnail. The slot goes 229px to 397px ((1240 - 2 x 24) / 3), which is why the
   nine `sizes` attributes in index.html now name 397px.
   B10 and the phone half of A10: at two columns any odd-length group leaves a hole
   in its last row. `:last-child:nth-child(odd)` is true of exactly the last item of
   an odd-length list, so the last tile takes the width: the fifteenth fine-art work
   and the ninth finish (the in-situ room, which wants the width) and the fifth
   inquiry chip (Sample requests, which becomes the quiet full-width last row). */
@media (min-width: 721px) {
  #studio-surfaces .studio-field { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 720px) {
  #studio .studio-field > figure:last-child:nth-child(odd) { grid-column: 1 / -1; }
  .chip-row .chip:last-child:nth-child(odd) { grid-column: 1 / -1; }
}

/* R37e / B2-9. On a 1.6 Mbps link the reveal observer fired on four tiles that had
   no pixels yet: figure opacity 0.96 to 0.99 with `img.complete` false and
   `naturalWidth` 0, and because the img carried no background the record read as
   blank white rectangles on a lime-white ground (VERIFIED by the audit at
   c5-F3-throttled-mobile.png; five of eight still incomplete at +8.6 s).
   Two halves, and both are needed. This half is the tone plate: every tile paints
   its own CHAMBER's tone under the image, so an undecoded tile is a hung blank in
   the record's own grey rather than a hole in the page. It costs nothing, it is
   present at frame 0 and with the script blocked, and because the button carries
   the piece's real aspect-ratio the plate is already the right box before a byte
   arrives. The other half is the decode gate in js/sections.js: the reveal never
   runs ahead of the image. */
/* The tone is PLASTER, not the chamber's own black, and that is S50 winning a real
   argument: fifteen graphite rectangles in a lime-white room is a dark ground in
   chamber IV, which S50 forbids, and it also flashes DARKER than the near-black
   photographs it stands in for rather than lighter. Two plaster tones instead, one
   per register, so the record still reads as two kinds of thing while an image is
   in flight: the works sit on pewter, the finishes on bone. */
#studio #studio-fine-art .tile-open { background: var(--pewter); }
#studio #studio-surfaces .tile-open { background: var(--bone); }
#studio .index-grid .tile-open { transition: transform 220ms var(--ease), box-shadow 400ms var(--ease); }

/* S23, lifted: the hover shadow is light-derived too, so a pointer that rakes
   the room also rakes the piece it is over. Same selector as the shipped rule,
   later in the cascade. */
@media (hover: hover) {
  #studio .index-grid figure.is-risen:hover .tile-open {
    transform: translateY(-2px);
    box-shadow: calc(var(--lx) * var(--light-throw)) calc(var(--ly) * var(--light-throw)) 52px -24px rgba(16, 16, 18, 0.62);
  }
}

/* R25 rank 2. The closer. Every work on this site rises out of the wall toward
   the visitor; the portrait is the one image that sits BEHIND the opening, the
   way a window does, so it moves slower than its own frame. Applied to exactly
   one image on the site. The overscale is derived from the peak, not chosen, so
   the photograph can never expose ground at either extreme: peak is
   0.25 * viewport * 0.30, which is 7.5vh, so the image is 2 x 7.5vh taller than
   its frame and starts 7.5vh high. The figure keeps the cast shadow (the img is
   inside an overflow-hidden box now, which would clip it). */
/* The width is DEFINITE, and that is the whole point of this line. Measured
   defect: .studio-close is a justify-items:center grid, so the figure sized to
   max-content; its only child is an img at width:100%, which is a percentage
   against a width that does not exist yet, so before decode the figure was 0
   wide, the aspect-ratio resolved to 0 high, and .studio-close grew by 1140px
   at 1440x900 (462px at 390x844) the moment the portrait finally decoded. That
   growth is what moved the #inquire anchor out from under a cold hash landing.
   A definite width lets aspect-ratio reserve the full box at first layout, so
   the image arriving changes nothing. max-width keeps the narrow-container
   behaviour the mobile block already had. */
#studio .portrait {
  width: min(760px, 78vw); max-width: 100%;
  overflow: hidden; aspect-ratio: 1067 / 1600;
}
#studio .portrait img {
  height: calc(100% + 2 * var(--pk-portrait));
  margin-top: calc(-1 * var(--pk-portrait));
  object-fit: cover; will-change: transform;
}
#studio .portrait {
  box-shadow: calc(var(--lx) * var(--light-throw)) calc(var(--ly) * var(--light-throw)) 60px -30px rgba(16, 16, 18, 0.5);
  transition: box-shadow 220ms var(--ease), opacity 720ms var(--ease), transform 720ms var(--ease), filter 720ms var(--ease);
}
html.sections-ready #studio .portrait:not(.is-risen) { box-shadow: 0 0 0 rgba(16, 16, 18, 0); }

/* S15, lifted: the group labels carry scale, because they are the only
   structural words in a 24-piece record and they were 11px. 52px is the largest
   step below the closer's 58px statement, which must stay the largest type in
   chamber IV; 30px is the floor because the statement's floor is 28px. Tracking
   tightens as size grows. The words are unchanged and the rule they sit on is
   unchanged, so S6 holds and the tie to numerals I and III is intact. */
.group-label {
  font: 300 clamp(30px, 4vw, 52px)/1.06 var(--display);
  letter-spacing: -0.012em; text-transform: none; font-weight: 300;
  will-change: transform;
}

/* A.M6. The field underline draws left to right on focus: the hang line's own
   mechanism, same origin, same axis, same easing, shortened to a UI duration.
   The hairline goes on .field, not on the input, because an input cannot carry
   a pseudo-element. */
.field { position: relative; }
.field::after {
  content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 1px;
  background: var(--ink); transform: scaleX(0); transform-origin: left;
  transition: transform 260ms var(--ease);
}
.field:focus-within::after { transform: scaleX(1); }

@media (max-width: 720px) {
  #studio { --wu: 2.96vh; --sk: 0.94px; --pk-portrait: 1.625vh; }
  /* the light bed halves its ratio on a phone (playbook rule 7: vertical
     parallax competes with the scroll gesture itself), so it needs half the
     slack. The pointer never exists here, so the room gradient does not either
     and the light is scroll-driven only, reaching exactly one consumer. */
  .light-bed { top: -5vh; height: 110vh; }
  .group-label { letter-spacing: -0.006em; }
}

@media (prefers-reduced-motion: reduce) {
  /* everything resolves at its end state: the light is static at its authored
     rest value, the tiers sit at 1.0, and the rows still take light because
     .at-eye is a state, not an animation. The !important is the one honest use
     of it here: a transform written inline before the visitor turned reduced
     motion on can only be overridden this way. */
  .light-bed, #studio .portrait img, .group-label { transform: none !important; }
  /* the ramp above is keyed to html.sections-ready, so this has to match that
     specificity or it silently loses and the horizon sits at full strength */
  html.sections-ready .horizon { opacity: 0; }
  .handover img, .handover video, #studio .portrait, #studio .index-grid .tile-open, .room-light, .field::after { transition: none; }
  /* R35a. Same specificity as the ghost rule above, or the caption sits at the ghost
     forever: reduced motion removes the passage, never the end state. */
  html.sections-ready .handover figcaption { color: var(--ink); transition: none; }
}

/* S18 holds: the cast shadow still ARRIVES with the reveal. The depth rules
   above carry two ids, so they out-specify the shipped pre-reveal suppression
   at :181-182 and the ghost tile was landing with a full shadow. Restated here
   at matching specificity, and desktop only, because the phone deliberately
   applies its resting shadow statically (S21: no transitioned shadow layer on
   a phone) and under the depth law that resting shadow IS the derived one. */
@media (min-width: 721px) {
  html.sections-ready #studio #studio-fine-art figure:not(.is-risen) .tile-open,
  html.sections-ready #studio #studio-surfaces figure:not(.is-risen) .tile-open {
    box-shadow: 0 0 0 rgba(16, 16, 18, 0);
  }
}

/* ---------- G. MEASURE, AND THE TYPE REGISTERS (W5 polish) ----------

   MEASURE. A measure belongs on the element that carries the type, expressed in
   that element's own ch. Measured before this block, at 1440x900 AND 1920x1080:

     .studio-statement p    352px   58px type    9.9ch   TEN lines
     .studio-note p         544px   21px type   41.7ch   2 lines
     #studio .credentials p 576px   22px type   42.2ch   3 lines
     .credential-quote p    576px   22px type   42.2ch   3 lines
     .inquire-note p        640px   21px type   49.1ch   3 lines
     .inquire-quiet p       640px   19px type   54.3ch   1 line

   Every one of them caps on its WRAPPER in em, and a wrapper's em is the
   inherited 16px, not the type's own size. Four of the five survive it because
   their type is 17 to 22px, close enough to 16 that the number lands somewhere
   sane by luck. The closing statement does not: its 22em resolves to 352px while
   its type is 58px, so the emotional peak of chapter IV renders as a ten-line
   ribbon under ten characters wide, and it gets WORSE as the screen grows,
   because the box is frozen while the type keeps scaling. Invisible on a phone,
   which is why a mobile walkthrough never caught it.
   The caps move onto the paragraphs in ch. The four healthy blocks keep the
   measure they render today, to the character, so this is a mechanism fix and
   not a redesign; only the statement changes, to a display measure. */
.studio-note, .studio-statement, #studio .credentials, .inquire-note { max-width: none; }
/* R39a / R40c, EC1. The running paragraphs in chamber IV measured 42.0 ch at 1440,
   three under the 45 ch floor, and the three of them moved together because they were
   set to one number. 48 ch is inside the band with room either side, and it is the
   same arithmetic that put the Inquire paragraphs at 49 and 46 ch, which the harness
   already reads as proof the band is reachable here. Below 1024 these render at 30 to
   34 ch on their own, inside the 28 to 50 band, so they need no second value. */
.studio-note p { max-width: 48ch; }
#studio .credentials p, .credential-quote p { max-width: 48ch; }
.inquire-note p { max-width: 49ch; }
.inquire-quiet p { max-width: 54ch; }
/* The closer statement is the ONE paragraph the band cannot reach, and it is not a
   build defect, it is the running-copy rule meeting display type. The arithmetic, at
   the shipped STATEMENT sizes R36a pins (58px desktop, 28px phone) and Fraunces' own
   0.60em ch: 45 ch at 58px wants 1,566px in a 1,240px column, and 28 ch at 28px wants
   470px in a 308px one. It is reachable only by dropping the statement under the 52px
   group labels, which inverts chamber IV's type hierarchy to satisfy a rule written
   for prose. R39a already exempts labels, FOLIO lines, short captions and chips
   because they are not running copy; a one-sentence display statement is the same
   category and the list simply does not name it. Widened here from 26 ch to 32 ch,
   which is a real gain (the sentence goes from 3.8 lines to 3.1) and stays inside the
   20 to 40 ch a display measure wants; the residual is disclosed in SECTIONS-LOG.md
   for the manager, not worked around. */
.studio-statement p { max-width: 32ch; margin-inline: auto; }

/* THE REGISTERS. Fraunces has two cuts with the seam between opsz 72 and 88, and
   the shipped rules never ask for either axis, so every role here resolves in
   the TEXT cut at SOFT 0: "The Studio" at 100px, the group labels at 52px and
   the statement at 58px all render in the cut meant for 24px prose.
   Three registers, exactly the deck's own derivation (css/quest.css:88-118);
   a fourth would be a costume. The deck defines DISPLAY, STATEMENT and TEXT and
   deliberately no numeral role, so the catalogue captions take TEXT with
   everything else.
   Declared here, at the END of the last stylesheet in the cascade, and never
   through the `font` shorthand: the shorthand resets font-variation-settings to
   normal (CSS Fonts 4), which is the reset the quest lane measured. The
   property inherits, so #studio carries the page default and roles override.
   INERT until css/grisaille.css's @font-face points at the variable subset,
   which is the engine builder's item; index.html already preloads that file. */
/* The separator and the availability token travel as one unbreakable unit, so
   the caption wraps EARLIER instead of stranding "SOLD" behind a dangling
   middot: 13 of the 15 tiles that carry a token did that at 1440x900. A nowrap
   wrapper rather than a non-breaking space, because the tile-front panel reads
   figcaption.textContent and an nbsp would silently rewrite that string.
   It is an <i>, not a <span>, for one mechanical reason: sections-pass walks
   `.cap-m > span` to source every caption field against studio-copy-draft.md,
   and a span here reads as a field whose text is "· Sold", which is not in the
   draft and should not be. The same neutral-inline idiom the rail's own
   tick-subs template already uses. */
.cap-hold { white-space: nowrap; font-style: normal; }

#studio, #tile-front { font-variation-settings: 'opsz' 24, 'SOFT' 20, 'WONK' 0; }
/* the shorthand resets this property wherever it is declared, so inheriting the
   default from #studio is not enough: every role carrying its own `font`
   shorthand has to be named, which is the same reset the quest lane measured. */
.studio-note p, #studio .credentials p, .credential-quote p,
.inquire-note p, .inquire-quiet p, .inquire-facts, #studio blockquote cite,
#studio .index-grid figcaption, .cap-n, .cap-m,
#tile-front .tile-front-copy, #tile-front .tile-front-caption, #tile-front .tile-front-materials,
.field > label, .field input, .field textarea, .chip span, .tick-subs b {
  font-variation-settings: 'opsz' 24, 'SOFT' 20, 'WONK' 0;
}
#studio h2, .group-label { font-variation-settings: 'opsz' 144, 'SOFT' 32, 'WONK' 1; }
.studio-statement p { font-variation-settings: 'opsz' 72, 'SOFT' 32, 'WONK' 1; }

/* ---------- H. THE SECOND HALF (R35: the piece you act on, the inquiry that
   knows, the descent back to the threshold) ----------

   One idea: the visit ends the way it began, in the dark, and on the way down it
   shows you the path you actually walked. Everything below is built from what the
   page already holds: the thirteen rail figures and their frozen captions, the six
   tones, the thread's own weight, the rail's lateral grammar. No new asset, no new
   font, and two new strings in the whole half, both of them chrome. */

/* A named ink for the FOLIO roles, which used to be `opacity: 0.62` on ink. It is
   the SAME rendered colour on this ground, to the byte (mix(#101012, #F7F5F1, 62%)),
   so nothing moves; what changes is that it can no longer pick up whatever is
   behind it. An alpha over a photograph is a different colour on every tile, which
   is exactly how a caption ends up at a different contrast per piece. 5.19:1. */
#studio { --folio-ink: #686764; }

/* ---------- R35c. The piece steps forward, and you can act on it ---------- */

/* the fronted piece takes the ROOM's light, not a generic drop shadow: the same
   --lx/--ly vector every tile in the record is lit by, at 1.6x the throw because
   the piece is now off the wall and in front of you. The pointer rakes it, exactly
   as it rakes the room, because the light is one variable and this is one room. */
#tile-front .tile-front-img {
  box-shadow: calc(var(--lx) * var(--light-throw) * 1.6) calc(var(--ly) * var(--light-throw) * 1.6) 80px -36px rgba(16, 16, 18, 0.5);
  transition: box-shadow 220ms var(--ease);
}

/* the panel's text in the record's own grammar rather than a 10px uppercase strip:
   the name at the record's display size, the catalogue line under it, then the
   materials, then the studio's paragraph at a reading measure. Same words, same
   order, same registers as the tile it came out of. */
/* the shipped rule is 10px uppercase tracked-out silver, which is the HUD's register
   rather than the record's, so the name has to be reset on all three axes and not
   only resized: `font` does not touch text-transform or letter-spacing. */
#tile-front .tile-front-caption {
  font: 300 clamp(20px, 2.2vw, 26px)/1.25 var(--display); color: var(--ink);
  opacity: 1; margin: 0; letter-spacing: 0; text-transform: none;
}
#tile-front .tile-front-meta {
  margin: 6px 0 0; color: var(--folio-ink);
  font: 10px/1.5 var(--utility); letter-spacing: 0.14em; text-transform: uppercase;
}
#tile-front .tile-front-meta:empty { display: none; }
#tile-front .tile-front-materials { margin: 22px 0 0; color: var(--folio-ink); font: 12px/1.6 var(--utility); letter-spacing: 0.04em; text-transform: none; }
#tile-front .tile-front-copy { margin: 18px 0 0; font: 300 20px/1.55 var(--display); max-width: 48ch; }

/* The three lines take ink when the panel opens. This is motion answering the
   visitor's own click, not an entrance: the words are ALREADY THERE at the ghost
   floor on frame 1, so nothing is produced by the animation. Keyed on the panel
   being open so it replays per piece rather than once per page. */
@keyframes ink-n { from { color: var(--ghost-light); } }
@keyframes ink-f { from { color: var(--ghost-light); } }
/* the COPY is deliberately not in this set. S25.2 rules the panel's paragraph at
   7:1 on its ground and the ghost floor measures 3.5:1, so a paragraph that enters
   through the ghost is a paragraph that spends 360ms illegible. The catalogue lines
   above it take the ink; the reading matter is simply there. */
#tile-front:not([hidden]) .tile-front-caption { animation: ink-n 360ms var(--ease) both; }
#tile-front:not([hidden]) .tile-front-meta,
#tile-front:not([hidden]) .tile-front-materials { animation: ink-f 360ms var(--ease) both; }
#tile-front:not([hidden]) .tile-front-meta { animation-delay: 40ms; }
#tile-front:not([hidden]) .tile-front-materials { animation-delay: 60ms; }

/* A.M3's own layout, and it is what makes the ACTION reachable: in the shipped
   centred column the image takes 62vh and the words run under it, so the one control
   the panel offers sat below the panel's own scroll fold at 1440x900 (VERIFIED in
   w1-d-front). Two columns at desktop, the piece on the left at the size it deserves
   and the record's lines beside it, bottom aligned so the name sits on the picture's
   own baseline. The phone keeps the shipped stack, where there is only one column to
   have. */
@media (min-width: 1024px) {
  /* R66k, critic 23: "The enlarged artwork is 378 px wide against a text block of
     about 470 px. Take the image to at least half the frame width."
     MECHANISM of the 378: the base rule above sets `height: 56vh`, and this block only
     raised the CAP to 72vh, so the image stayed 504 px tall at 1440x900 and its width
     is 504 times the photograph's own aspect, 0.75 for the tile the probe opens: 378.
     The image was never width driven, so no column share could have changed it.
     THE FIX is to drive the image by height, 82vh, and to let the first column be
     sized BY the image (auto) instead of by a fraction of the panel. One dimension is
     set and the other stays auto, so the element box is the photograph, never a wider
     box with the picture letterboxed inside it: the ratio cannot be gamed. All 24
     works are portrait (aspect 0.66 to 0.80 measured off their own width and height
     attributes), so the panel is as wide as the work plus one fixed measure:
     frame = image + 32 gap + 380 text + 48 padding, which makes the ratio
     image / (image + 460) and puts every one of the 24 at or above 0.5 (the tallest,
     0.66, is 487 px wide at 738 px tall and reads 0.514; the 0.75 the probe opens is
     553 px and reads 0.546).
     The text measure pays for it: 380 px instead of 48ch, about 38 characters at the
     panel's 21 px display size. That is the ruling's own arithmetic rather than a
     taste call, since half the frame for the picture leaves the other half to carry
     the gap and the padding too, and a museum label reads narrow.
     Vertical: 92vh of panel less 48 px of padding always clears 82vh of picture
     (0.92h minus 48 is above 0.82h for any viewport over 480 px tall), so the panel
     does not scroll at any desktop height and the inquiry control stays in view,
     which is what this block was written for in the first place. */
  #tile-front .tile-front-panel {
    display: grid; grid-template-columns: auto minmax(0, 380px); grid-template-rows: auto 1fr;
    gap: 32px; align-items: end; max-width: 92vw; max-height: 92vh; padding: 24px;
  }
  #tile-front .tile-front-img {
    height: 82vh; width: auto; max-width: none; max-height: 82vh;
    margin-left: auto; grid-column: 1; grid-row: 1 / span 2;
  }
  #tile-front .tile-front-text { text-align: left; max-width: 380px; grid-column: 2; grid-row: 2; padding-bottom: 6px; }
  /* R70n moved this control to LAST in the DOM; the row and column keep it exactly
     where it was drawn, top right of the second column, so the reading order changed
     and the picture did not move. */
  #tile-front .tile-front-close { grid-column: 2; grid-row: 1; justify-self: end; }
  /* R66k's third clause, "blur or fully mute the grid behind", is the OR's second
     branch and it is already true, so nothing is added here. The blur branch is not
     available in this file at all: row S0.5 (work/qa/sections-pass.mjs:190) bans the
     backdrop blur property here by a raw string match, ratified by the judge on
     2026-09-03 as part of the sections budget's mechanism ("dependency free, one
     observer, no backdrop blur, no scroll linked timeline"), and a whole viewport
     compositing layer over a live WebGL canvas is what that ban exists to prevent.
     The mute branch is arithmetic off two other locked rows: S25.1 holds the scrim at
     rgba(247, 245, 241, 0.94) and S24 holds the receded grid at opacity 0.32, so the
     grid reaches the eye at 0.32 times 0.06, which is 1.92 percent: a tile that spans
     the full 0 to 255 range behind the scrim moves the composite by under 5 levels of
     255, below the perceptual floor, and 5 of 255 is the same delta the workspace's
     own change gate memory records as imperceptible. */
}

/* the one thing you can do with a piece. A 48px target in the CHROME register, on
   the record's own hairline, sitting under the studio's paragraph where a decision
   is made rather than beside the picture where it would compete with it. */
.tile-front-inquire {
  display: inline-flex; align-items: center; min-height: 48px; margin-top: 22px;
  color: var(--ink); text-decoration: none;
  border-bottom: 1px solid currentColor;
  font: 11px/1 var(--utility); letter-spacing: 0.2em; text-transform: uppercase;
}
.tile-front-inquire:hover { border-bottom-color: var(--folio-ink); }

/* ---------- R35d. The portrait joins the room ---------- */

/* it is already a riser, so it already takes the eye line; it simply had no state
   for it. One step deeper on the same vector as every work in the record, at the
   portrait's own scale. */
#studio .portrait.at-eye {
  box-shadow: calc(var(--lx) * var(--light-throw) * 1.35) calc(var(--ly) * var(--light-throw) * 1.35) 80px -34px rgba(16, 16, 18, 0.6);
}

/* B12. A cite is the attribution of the sentence above it, so it shares that
   sentence's axis by inheritance rather than by five rules that can disagree. */
#studio blockquote cite { text-align: inherit; }

/* ---------- R35e. Inquire ---------- */

/* A16. The native resize grabber is a 16px OS widget in the corner of a field
   whose whole design is one hairline; it also lets a visitor drag the form into a
   shape nobody designed. `field-sizing: content` replaces it with the behaviour it
   was standing in for, the box growing with the message, and where that is not
   supported the four rows are the floor they always were. */
.field textarea { resize: none; min-height: 4lh; }
@supports (field-sizing: content) { .field textarea { field-sizing: content; max-height: 40vh; } }

/* Regarding. The chips are the works the visitor stayed with, in the record's own
   display register with a CHROME removal glyph: this is a list of PIECES, not a
   row of filters, so it does not borrow the category chips' uppercase costume. */
.regarding { border: 0; padding: 0; margin: 0; min-inline-size: 0; }
.regarding legend {
  padding: 0 0 18px; font: 11px/1 var(--utility);
  letter-spacing: 0.22em; text-transform: uppercase; color: var(--folio-ink);
}
.regarding-row { display: flex; flex-wrap: wrap; gap: 10px; }
.regarding-chip {
  display: inline-flex; align-items: center; gap: 12px;
  min-height: 44px; padding: 0 14px;
  border: 1px solid rgba(16, 16, 18, 0.28); background: none; cursor: pointer;
  font: 300 15px/1 var(--display); color: var(--ink);
  transition: border-color 160ms ease, color 160ms ease;
}
.regarding-chip::after {
  content: '\00d7'; font: 11px/1 var(--utility); color: var(--folio-ink);
}
.regarding-chip:hover { border-color: var(--ink); }
.regarding-chip:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }

/* R65c, the add control. A native select on purpose: it is the one control here the
   visitor may meet thirteen times, the platform already solves it on a phone, and a
   hand built listbox would be the largest piece of JavaScript in a file whose budget
   exists to keep the record cheap. It takes the chip's own frame so the two read as
   one pair rather than as a form widget parked beside a designed row. 16px and 48px
   are the phone's floors, applied at every size because one control is simpler than
   two. The label sits above it in the legend's register, quiet, because the chips are
   the subject and this is the way back in. */
.regarding-add-l {
  display: block; margin: 22px 0 8px; font: 11px/1 var(--utility);
  letter-spacing: 0.22em; text-transform: uppercase; color: var(--folio-ink);
}
.regarding-add {
  min-height: 48px; padding: 0 14px; max-width: 22em; width: 100%;
  border: 1px solid rgba(16, 16, 18, 0.28); border-radius: 0; background: none;
  font: 300 16px/1 var(--display); color: var(--ink);
  transition: border-color 160ms ease;
}
.regarding-add:hover { border-color: var(--ink); }
.regarding-add:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }

/* the after note. Same blockquote, same words, new place: it answers "what happens
   when I press this", so it belongs under the press. */
.inquire-after { margin-top: 26px; }

/* press feedback, and it is feedback rather than decoration: the button is the one
   irreversible control on the page and it should be felt. 120ms, the shortest step
   on the scale, on transform only. */
#studio .inquire .button { transition: transform 120ms var(--ease), background 160ms ease, color 160ms ease; }
#studio .inquire .button:active { transform: scale(0.985); }

@media (max-width: 720px) {
  /* the panel's action becomes a full-width control, the register the rest of the
     form's controls already use at this size */
  .tile-front-inquire { display: flex; justify-content: center; border: 1px solid var(--ink); padding: 0 16px; }

  /* R77 b1. The sheet's one action is the sheet's FOOTER, and the rule that pins it
     now lives with the rest of the sheet's CSS: these four declarations are adopted
     verbatim from css/grisaille.css:1251 to 1254 (the brief's 1245 to 1248 is stale,
     MEASURED against the file), which R77 b2 deletes once `git log -1 --
     css/sections.css` shows this commit. Nothing about the behaviour moves.
     The deletion is not a no-op, so the adoption is not bookkeeping. MEASURED at
     390x844 dsf3, headless chromium, the engine's three phone .tile-front rules
     stripped from a scratch copy outside the repo and everything else byte identical:
     with the rule the action row sits at y 724, 308x48, elementFromPoint returns the
     control on all three sampled works; without it the row leaves the viewport on the
     two whose copy overruns the cap (rorschach-05 y 1029 and mottled-metal y 854,
     both against innerHeight 844, elementFromPoint null), and only darkmatter-01,
     whose panel overflows by 0, is unaffected.
     The ground stays var(--lime-white), which is #tile-front's own ground at 391,
     so the pinned action row and the pinned dismiss read as one pair rather than as
     two inventions.
     The selector carries #tile-front because that is this file's handle on the sheet,
     and at (1,1,0) it outranks the engine's (0,2,0) copy while that copy is still in
     the tree: position, the 0px bottom, the z-index and the ground all measure the
     same before the deletion and after it. */
  #tile-front .tile-front-inquire {
    position: sticky; bottom: 0; z-index: 2;
    background: var(--lime-white);
  }
  .regarding-row { display: grid; grid-template-columns: 1fr; }
  .regarding-chip { justify-content: space-between; }
}

@media (prefers-reduced-motion: reduce) {
  #tile-front:not([hidden]) .tile-front-caption,
  #tile-front:not([hidden]) .tile-front-meta,
  #tile-front:not([hidden]) .tile-front-materials { animation: none; }
  #tile-front .tile-front-img, #studio .inquire .button, .regarding-chip { transition: none; }
}

/* ---------- R35f. THE DESCENT ----------

   The one signature moment of the second half is its ENDING. The visit closes by
   going back down to the threshold's black along the thread, and on the way the
   thread draws the serpentine the visitor actually walked, with the works they
   stayed with hung on it. Nothing here is a new mechanism: it is the six tones, the
   thread's own 2px weight, the rail's lateral grammar and the thirteen frozen
   captions, turned ninety degrees.

   ONE NUMBER SETS THE COMPOSITION: --rn-u, the interval between adjacent nodes.
   Every constraint the band has is a multiple of it, so a viewport sets one value
   and the collisions cannot come back one screen at a time:
     same-side neighbours are 2u apart and must clear one thumbnail block
       (96px image + 8px + up to two caption lines = 137px desktop, 123px phone)
     a chapter break is 3u and its label must clear the blocks either side of it
   Hence 78px desktop and 76px phone, and hence a band that runs about 196vh rather
   than the 140vh R35f names. Those two rulings are in tension at 13 lit nodes and
   only one of them is visible to a visitor: nobody reads a vh number, everybody
   reads a caption sitting on a thumbnail. The ruled figures survive as the floor
   below (min-height), which is what they bind when few nodes are lit. Disclosed in
   work/loop2-2026-09-04/SECTIONS-LOG.md section B2. */

.record-band {
  /* the band is a SIBLING of #studio, not a child of it, and that is the whole of
     its full bleed: `.chamber` carries 18vh var(--pad) of padding (58px on the left
     at 390 for the rail, 24px on the right), so a band inside chamber IV was inset
     by 56px at 1440 and asymmetrically at 390, and escaping that needs a negative
     margin mirroring a padding another file owns. Outside it there is nothing to
     escape. In immersive mode it takes the same paint layer chamber IV takes, so it
     is above the canvas for the same reason (R23a). */
  position: relative; z-index: 2;
  --ease: cubic-bezier(0.2, 0.6, 0.2, 1);
  /* R65b. The unit the whole band is measured in, and it is no longer a constant:
     the plates and the spacing between them have to hold one ratio at every width or
     they collide, so both are read off the viewport and the ratio is arithmetic
     instead of a number somebody checked once at 1440.
     The generator places the nodes 4.76 percent apart down the track and alternates
     sides, so two plates can only meet if they are SAME-side neighbours, 9.52 percent
     apart, which is 1.905 * --rn-u. A plate is 3:4, so it is 1.333 * --rn-thumb tall.
     R71n corrects the arithmetic that stood here, MEASURED with all 13 nodes lit:
     the clearance is not plate against spacing, because the CAPTION hangs under the
     plate and belongs to the same block. At the old 152px unit and 200px plate the
     worst same-side neighbours OVERLAPPED by 4.7px at 1440 (mammatus to emanare-01, a
     294.2px block against 289.5px of pitch) while the line this replaces claimed 24px
     of air, and the overlap was worse at every narrower width measured: -27.4px at
     1180, -32.1px at 900, -24.3px at 390. The caption is 19.5px for one line and does
     not shrink as the plate grows, which is the term that was missing.
     The pair below is the smallest that clears the ruling's own plate at 1440: 240px
     wide and 320px tall, a 347.5px block against 365.7px of pitch, measured at +17.2px
     on the left chain and +17.3px on the right, 13 of 13 plates 240px or wider, the
     painted x extent 953.5px of 1440 (0.662, was 873.5 and 0.607), the left plate edge
     at 243.3px clearing the 140px rail reserve by 103.3px, 0 label overlaps. The whole
     cost is band height, because --rn-u feeds nothing but --rn-h: the band goes from
     3181px to 3971px at 1440 and the document from 30641px to 31431px.
     RESIDUAL, stated rather than hidden: a caption that wraps to two lines adds 39px
     the coefficient does not carry, so between 721px and about 1100px the worst pair
     still overlaps (-19.4px at 900, where powder-room-02 wraps; +8.7px at 1180, where
     it does not). Closing that class means reserving two lines in the ratio, which is
     about 15.1vw against 16.7vw of plate and a 4340px band at 1440, a composition call
     and not a lane call. It is under questions in
     work/loop3-2026-09-09/notes/sections.md with that arithmetic. */
  --rn-u: min(192px, 13.3vw);
  --rn-h: calc(20 * var(--rn-u));
  /* the nav column's own box plus air, READ from css/grisaille.css rather than from
     R35i's estimate: the rail is 104px wide at left calc(--pad / 2) since R33 (:801
     and the .js .thread-rail rule), so it reaches 132px at 1440 where R35i assumed
     84px. 140px clears it at every width where the centring below does not already. */
  --rn-rail: 140px;
  /* The serpentine's lateral amplitude. ONE number per viewport, the SVG's own inset,
     and the node offset is DERIVED from it, because the two must agree exactly or the
     nodes float off the curve they are supposed to sit on: the path's x=31.25 maps to
     `inset + 0.3125 * (100 - 2 * inset)` of the track, which rearranges to the calc
     below. Measured reason for the phone value: at the desktop amplitude the
     Installations label's right edge reached 168px against a curve whose left extreme
     is 154px at 390 (VERIFIED, verify-r35.log), so the label crossed the thread. The
     phone pulls the curve inward instead of shrinking the label, because the label is
     the one piece of DISPLAY type in the band and shrinking it is a worse trade. */
  --rn-inset: 0%;
  --rn-dx: calc(18.75% - var(--rn-inset) * 0.375);
  --rn-thumb: min(240px, 16.7vw);
  min-height: 140vh;
  background: var(--black);
  /* the band is the end of the document, so it owns the space under the form
     outright: no top rule, no gap, the light simply starts going out */
  margin-top: 0;
}

/* the ground, full bleed and exactly as tall as the track, so an element's
   percentage down the track IS its percentage along the ramp at every viewport.
   The foot below sits on the band's own black, which is where the ramp ends. */
.record-band::before {
  content: ''; position: absolute; left: 0; right: 0; top: 0; height: var(--rn-h);
  /* the ramp is SOLVED, not chosen, and it arrives on the element itself from
     gen-band.mjs (--rn-ramp) so this file holds no second copy of it. Between ground
     luminance 0.163 and 0.20 neither ink clears 4.5:1, so where the ramp crosses
     that window is a constraint, not a taste call; the generator maximises the worst
     element's contrast under an 8 percent minimum separation between tone stops, so
     it cannot buy contrast by turning the ramp into an edge. The fallback below is
     the six tones evenly placed: correct-looking, and never used while the block is
     generated. */
  background: var(--rn-ramp, linear-gradient(180deg,
    var(--lime-white) 0%, var(--bone) 14%, var(--pewter) 34%,
    var(--steel) 42%, var(--graphite) 76%, var(--black) 100%));
  pointer-events: none;
}

.record-track {
  position: relative; z-index: 1;
  height: var(--rn-h);
  /* R65b. 640px of a 1440px frame put the serpentine in the middle third and the
     plates in the gutter beside it; the path IS the artwork here, so the column is
     the frame minus the rail's reserve and the page's own padding, capped where the
     curve would stop reading as one gesture. 1,100px at 1440 leaves 170px each side,
     which is the same air the track already kept on the right. */
  width: min(1100px, 100% - var(--rn-rail) - var(--pad));
  /* centred, but never left of the rail's column: max() is the whole rule */
  margin: 0 0 0 max(var(--rn-rail), calc(50% - 550px));
}

/* the serpentine. `preserveAspectRatio: none` lets the curve's lateral amplitude
   stay a PERCENTAGE of the column, which is what a plan-view miniature is, while
   its vertical positions stay in the track's own coordinates; `non-scaling-stroke`
   is what keeps the weight at the thread's 2px under that non-uniform scale.
   `pathLength="1"` normalises the dash pair, so progress is 0 to 1 with nothing
   measuring the path. Drawn COMPLETE at frame 0: --end defaults to 1 here and
   js/record.js only ever writes a value it already had. */
/* the width and height are STATED, not implied by the four offsets: an <svg> is a
   replaced element, so `left/right/top/bottom` with `width: auto` resolves to its
   intrinsic size (300x150 for a viewBox with no width attribute) and the curve
   collapsed to one lazy S over the whole band with the nodes nowhere near it. */
.record-thread {
  position: absolute; top: 0; left: var(--rn-inset);
  width: calc(100% - 2 * var(--rn-inset)); height: 100%; overflow: visible;
}
.record-thread-p { fill: none; stroke: url(#rt-ink); stroke-width: 2; }
/* The draw is a CLIP, not a dash offset, and the reason is measured rather than
   preferred. Under `preserveAspectRatio: none` the curve is scaled 6.4x across and
   1.56x down, and `vector-effect: non-scaling-stroke` (what holds the stroke at the
   thread's own 2px through that) moves the dash pattern into screen space while
   `pathLength` normalises the user space. The two disagree: the shipped result was a
   path that stopped about 55 percent of the way down at frame 0 (VERIFIED, computed
   stroke-dasharray resolved to "1px" against a 1174-unit path), and a path that is
   not complete at frame 0 fails R35f outright. An inset clip is position-driven in
   exactly the same way, needs no length measurement, defaults to fully revealed, and
   reveals the thread top-down, which is the direction of travel. */
.record-thread { clip-path: inset(0 0 calc((1 - var(--end, 1)) * 100%) 0); }

/* ink. Each element carries its own, assigned by the generator from the ground
   colour at its own y, because `html.at-end` cannot own this: with js/record.js
   blocked that class never arrives and every word on the lower half would be ink
   on black. Static, correct with no script, and gated at 4.5:1 by the generator. */
.record-label, .record-node, .record-zero { color: var(--ink); }
.record-label.on-dark, .record-node.on-dark, .record-zero.on-dark { color: var(--lime-white); }

/* the chapter labels, in the record's own DISPLAY register. They sit in the break
   between chapters, where the curve is crossing the column's centre, which is the
   one place in the band that is guaranteed clear of both the stroke and a
   thumbnail. */
.record-label {
  position: absolute; left: 0; top: var(--y); transform: translateY(-50%);
  margin: 0; max-width: 46%;
  font: 300 clamp(26px, 3.4vw, 40px)/1.02 var(--display);
  font-variation-settings: 'opsz' 144, 'SOFT' 32, 'WONK' 1;
  letter-spacing: -0.01em;
}

/* a node. A DOM element rather than an SVG circle: a circle under the curve's
   non-uniform scale would render as an ellipse, and it could carry neither the
   slug nor a hit box. The dot's fill is the GROUND at its own y, so an unlit stop
   is a hole in the thread; a lit one fills with the ink. */
.record-node {
  position: absolute; top: var(--y); left: calc(50% - var(--rn-dx));
  transform: translate(-50%, -50%);
  width: 13px; height: 13px;
}
.record-node[data-side="r"] { left: calc(50% + var(--rn-dx)); }
.record-dot {
  display: block; width: 100%; height: 100%; border-radius: 50%;
  border: 1px solid currentColor; background: var(--g);
  transition: background-color 400ms var(--ease);
}
.record-node.is-lit .record-dot { background: currentColor; }

/* the work you stayed with, hanging off its own stop, on the OUTER side so two
   thumbnails can never share a lane: the nodes alternate, so adjacent ones are
   already on opposite sides and only same-side neighbours (2u apart) can meet. */
.record-thumb { display: none; position: absolute; top: 50%; transform: translateY(-50%); width: var(--rn-thumb); }
.record-node.is-lit .record-thumb { display: block; }
.record-node[data-side="l"] .record-thumb { right: calc(100% + 24px); text-align: right; }
.record-node[data-side="r"] .record-thumb { left: calc(100% + 24px); text-align: left; }
/* R65b: the plate's size is its container's, so one variable moves it at every
   width and the served width/height attributes (72x96, the same 3:4) still describe
   the ratio the box resolves to, which is what keeps this out of layout shift.
   R65e: the record is the gray traverse's own closing band, from the threshold's
   black to the studio's white, and one warm plate in it read as a colour cast rather
   than as a work. The treatment is render time and applies to all thirteen, not to a
   hand-picked one: a list of exceptions is the next defect, and on the twelve already
   near neutral it changes almost nothing. The artist's bytes are untouched (R65e's
   own clause), and S21's filter ban is scoped to #studio, which this band is not
   inside. */
.record-thumb img {
  display: block; width: 100%; height: auto; aspect-ratio: 3 / 4; object-fit: cover;
  filter: grayscale(1);
  box-shadow: -12px 8px 40px -22px rgba(5, 5, 6, 0.8);
}
.record-node[data-side="l"] .record-thumb img { margin-left: auto; }
.record-thumb-t {
  display: block; margin-top: 8px;
  font: 12px/1.35 var(--utility); letter-spacing: 0.04em;
}

/* the way back. The rail tick's own circle, at the rail tick's own size and type,
   because it IS that control: numeral 0, accessible name Threshold, in a 44px hit
   box the visible circle sits inside. */
.record-zero {
  position: absolute; left: 50%; top: var(--y); transform: translate(-50%, -50%);
  display: grid; place-items: center; width: 44px; height: 44px;
  text-decoration: none;
}
.record-zero span {
  display: grid; place-items: center; width: 30px; height: 30px;
  border: 1px solid currentColor; border-radius: 50%;
  font: 300 13px/1 var(--display); letter-spacing: 0.06em;
  transition: transform 200ms ease;
}
.record-zero:hover span, .record-zero:focus-visible span { transform: scale(1.12); }

/* the hub. Centred, because it is the last thing on the page and there is nothing
   left to align to. */
.record-foot {
  /* centred in the space BESIDE the rail, not in the viewport: at 390 a full-width
     foot starts at x=0 and the rail's own column reaches 62px, and a centred line
     inside it only clears by chance. The same reserve the track uses. */
  border-top: 0; padding: 4vh 0 6vh;
  width: min(640px, 100% - 2 * var(--rn-rail)); margin-inline: auto;
  display: block; text-align: center; color: var(--lime-white);
  font-size: 12px; letter-spacing: 0.06em;
}
.record-foot nav { display: flex; justify-content: center; gap: 22px; margin-top: 14px; }
/* R65d. The ask was 2,122px above the last frame at scroll end (measured: #inquire
   top -2122, bottom -731 at scrollY 24525 of 25736 at 1440x900), so the one thing a
   collector is on this page to do required travelling back up the whole descent. The
   resolution is the LAST thing on the document rather than a bar that follows the
   visitor down it: the band ends on the two moves it leaves open, the numeral 0 back
   to the threshold and this, forward to the ask. It lives here, after the generated
   block, because the band's own markup is written by gen-band.mjs and a hand edit
   inside those markers is lost the next time it runs.
   The foot above gives back the 6vh this takes, so the closing air is unchanged. */
.record-ask {
  /* the box hugs the word, so the focus ring is the word's own box and not a
     1,440px bar: this element sits OUTSIDE .record-band, where the band's --rn-rail
     does not resolve, and a width written against it would silently drop. */
  display: block; width: max-content; max-width: 80%; margin: 0 auto;
  text-align: center; text-decoration: none; color: var(--lime-white);
  font: 300 clamp(22px, 2.6vw, 34px)/1 var(--display); letter-spacing: 0.04em;
}
.record-ask::after {
  content: ''; display: block; width: 30px; height: 1px; margin: 18px auto 0;
  background: currentColor; transition: width 240ms var(--ease);
}
.record-ask:hover::after, .record-ask:focus-visible::after { width: 84px; }
.record-ask:focus-visible { outline: 2px solid var(--lime-white); outline-offset: 8px; }
/* R71a, the closing ground. Two numbers, both measured in 3D MODE at the document
   end, which is the mode the defect lives in and the one an earlier W3 probe missed
   (headless chromium fails the fps gate and falls back to document mode, where
   --ground is unset and the body is already black, so it read a clean 18.71:1):
     the ask's lime white ink on the ground it actually sat on, 1.05:1 at 1440x900
     (rgb(247,245,241) on rgb(242,240,236), 34,715 painted samples) and 1.00:1 at
     390x844 (rgb(247,245,241) on rgb(247,245,241), 43,680 samples);
     the blank below the lowest ink, 133px at 1440x900 and 117px at 390x844, against
     the clause ceiling of 64px.
   One cause under both: the band's black is the band's own box, and everything after
   it fell back to the body's --ground, which the engine writes per frame from the fog
   and which is the studio's cream at the document end. The ask's old 12vh bottom
   margin (108px at 900, 101px at 844) was the blank; the cream under it was the
   ground. So the fix is one box rather than two patches: this wrapper paints the
   band's terminal tone for the whole closing group and carries the air as padding, so
   the ground reaches the document's last pixel and the air is the frame's own margin.
   The ask still lands inside the last viewport at scroll end.
   var(--black) and not a second literal: the band's own `background: var(--black)` at
   :1262 is the tone this continues, and one token keeps them from drifting apart. */
.record-end {
  background: var(--black);
  /* the air is stated, not inherited: 3.5vh reads 31.5px at 900 and 29.5px at 844, and
     the clamp holds it between 28px and 38px. The clause's number is the air below the
     closing mark, and the engine's 26px tail strip is part of that air even though the
     strip is not this lane's box, so the stated total is 57.5px at 1440x900 and 55.5px
     at 390x844, against a ceiling of 64px; if the engine takes its anchors out of flow
     the total becomes this padding alone. */
  padding-bottom: clamp(28px, 3.5vh, 38px);
}
.record-foot a { color: inherit; }
.record-foot a:hover { color: var(--silver); }

/* R35f nav flip, the site's existing two-stop idiom and no second mechanism.
   js/record.js adds `at-end` on documentElement when the band's midpoint crosses
   the middle of the viewport. The rules are written through `body` because
   `--nav-fg` is set on body by `.tone-light, .at-studio` and a bare `html.at-end`
   would lose to it; specificity, not novelty. */
html.at-end body { --nav-fg: var(--lime-white); }
/* R69n. This rule used to read `background: none; color: var(--lime-white)` against
   `.mode-3d.at-studio`, which stripped the engine's cream scrim and then depended on a
   ground it did not paint. at-end fires at the band's MIDPOINT (js/record.js:173), and
   the ramp behind the header is still mid grey there, so the header spent the whole
   window between the midpoint and the dark end as lime white on grey: MEASURED worst
   3.86:1 at scrollY 25028 of 26261 at 1440x900 and 5.31:1 at 20129 of 20564 at 390x844,
   24 offsets over the last 12 percent of the document, load1 3.38. That is CP25's own
   3.64:1 at 25008. E38 passes on this tree only because its ten evenly spaced offsets
   straddle the window (24947 with at-end off, 26261 with the band's black behind), so
   the dip falls between two samples: the row is a sampling guard, not a proof of the
   state.
   One source, not two: the at-end state now owns its ink AND its ground, in the same
   gradient shape the engine uses for the cream one at css/grisaille.css:202, so the
   contrast stops depending on the fog. The wordmark's box bottom sits at 54 percent of
   the header's box (MEASURED box y 36 height 12 in a header 88 tall), inside the 55
   percent full alpha stop, so the scrim is at full strength under every glyph. 0.94 of
   rgb(5,5,6) over the worst ground the window holds (cream rgb(247,245,241)) composites
   to rgb(20,20,19), and lime white on that is 16.9:1; over the band's own black it is
   18.71:1. The literal rgba mirrors --black the way the engine's literal mirrors
   --lime-white, because a gradient stop cannot take alpha from a hex token.
   `.at-studio` is gone from the selector on purpose: at-end means the visitor is in the
   band whatever the camera says, and one state should not need a second class to be
   true. Specificity still beats the engine's (0,3,2) against (0,3,0). */
html.at-end body.mode-3d .chrome-top {
  color: var(--lime-white);
  background: linear-gradient(180deg, rgba(5,5,6,0.94) 55%, rgba(5,5,6,0));
}

@media (max-width: 720px) {
  /* the phone rail is 58px wide at left 4px (css/grisaille.css:801), so it reaches
     62px; 72px clears it. It narrows to 14px while parked, and the reserve is sized
     for the wide state because that is the one that can collide. */
  /* R65b on a phone: the curve is pulled inward (--rn-inset) and the rail reserve is
     smaller, so the room beside a node is about 107px, not 200. The plate takes that
     room (54px before). R71n then raised the unit from 84px to 102px for the same
     reason the desktop pair moved: the longest title (the Residence title then; Pitch R10
     replaced that stop with "Figurative mural, Gates Mills", one character longer, so
     the unit stays and the overlap numbers below were not re-measured) wraps to three
     lines here, 54px of caption under a 133.3px plate, so the block is 193.3px against
     160.0px of pitch and the worst same-side neighbours OVERLAPPED by 24.3px. At 102px
     the worst measured gap is +10.0px on the left chain and +19.0px on the right, and
     the track grows from 1680px to 2040px. The plate stays 100px because the room
     beside the curve is 107px; the ruling's 240px is a 1440 number. A four line title
     would close this gap again, which is the same residual the desktop block states. */
  .record-band { --rn-u: 102px; --rn-rail: 72px; --rn-thumb: 100px; --rn-inset: 18%; min-height: 120vh; }
  .record-node[data-side="l"] .record-thumb { right: calc(100% + 16px); }
  .record-node[data-side="r"] .record-thumb { left: calc(100% + 16px); }
  .record-thumb-t { font-size: 11px; margin-top: 6px; }
  /* 34 percent, not 42: at 42 the Installations label's right edge measured 168px
     against a curve whose left extreme is 175px at 390, a 7px gap where the row asks
     for 8. Measured, then set. */
  .record-label { max-width: 34%; }
  /* the hub on one row. At the desktop tracking the three links measure 246px against
     a 246px box and "Fine Art" broke across two lines, which is the one place on the
     page where a two-word chapter name must not (MEASURED in w3-m-band-end). 10px at
     0.12em with a 14px gap is 243px, which fits the 246px box the rail's own reserve
     leaves, so the box does not have to give up its clearance to hold the hub. */
  .record-foot { padding-bottom: 10vh; }
  .record-foot nav { gap: 14px; }
  .record-foot a { font-size: 10px; letter-spacing: 0.12em; white-space: nowrap; }
}

@media (prefers-reduced-motion: reduce) {
  /* drawn and lit instantly: the dash is already at 1 by default and the two
     transitions are the only motion in the band */
  .record-dot, .record-zero span { transition: none; }
}

/* R35f, the last line of the visit: overscroll below the band is the body's black,
   so the page ends where it began. `--ground` is written per frame by the engine and
   at the studio it is lime white, so the body's own box is light down there; giving
   the ROOT a background stops the body's from propagating to the canvas, and the
   rubber band then shows the threshold's black at both ends of the document. The
   body still paints --ground over its own box, so nothing else changes. */
html { background: var(--black); }

/* ---------- I. THE FIVE REGISTERS (R36a, R36g) ----------

   One voice, five roles, no sixth. Before this block chamber IV resolved every
   Fraunces role in the face's TEXT cut at SOFT 0 (section G names the measurement),
   and twenty separate tracked-caps declarations did the work of two. The map:

     DISPLAY    Fraunces 300, opsz 144 SOFT 32 WONK 1, leading 1.02 (1.06 under 40px),
                tracking -0.006em above 60px and 0 below, text-wrap balance.
                Here: the studio h2 (100/44), the Inquire line (72/34), the group
                labels (52/30), the band's chapter labels.
     STATEMENT  opsz 72 SOFT 32 WONK 1, leading 1.28 at 58. Here: the closer, alone.
     TEXT       opsz 24 SOFT 20 WONK 0, leading 1.5 to 1.6. Here: the notes, the
                credentials, the tile name, the panel's paragraph, the inputs.
     FOLIO      Schibsted 400, 13/12, leading 1.5, tracking 0.01em, SENTENCE CASE,
                and a NAMED ink. Here: every cite, the tile meta, the chapter lead,
                the contact facts, the footer line, the band's thumbnail titles.
     CHROME     Schibsted 400, 11 to 12, caps, tracking 0.16 to 0.22em. Here: the
                button, the chips, the field labels, both legends, the band's links.

   FOLIO is the change with teeth. Those lines were 10 to 12px UPPERCASE at 0.14 to
   0.2em tracking with `opacity: 0.62`, which is three separate mistakes: it made
   catalogue data read as chrome, it set the smallest type on the page in the case that
   is hardest to read, and an alpha over a photograph is a different colour on every
   tile. Sentence case, one named ink, one size. */

#studio, .record-band, #tile-front { --folio-ink: #5B5F66; }

/* DISPLAY */
#studio .chapter-head h2 {
  font: 300 clamp(44px, 7vw, 100px)/1.02 var(--display);
  font-variation-settings: 'opsz' 144, 'SOFT' 32, 'WONK' 1;
  letter-spacing: -0.006em; text-wrap: balance;
}
.group-label {
  font: 300 clamp(30px, 4vw, 52px)/1.06 var(--display);
  font-variation-settings: 'opsz' 144, 'SOFT' 32, 'WONK' 1;
  letter-spacing: 0; text-transform: none; font-weight: 300; text-wrap: balance;
}
/* R36g S4. The break is set by MEASURE, not by a <br> and not by new words, and 16ch
   is measured rather than guessed: rendered at the shipped sizes, "Commissions and" is
   468px and "Commissions and consultations" is 824px at 1440 (ch 40px), and 221px
   against 389px at 390 (ch 19px). 16ch is 640px and 304px, inside both windows, so the
   same single value drops the line exactly where the ruling wants it at both viewports,
   "Commissions and / consultations by inquiry.", and keeps doing it at every size the
   clamp produces because the cap is in the type's own ch. 27ch was my first value and
   it was 1,080px, wide enough to fit the whole first clause: measured, then fixed.
   `balance` is deliberately NOT used here: it would re-even the two lines and undo it. */
#studio .inquire-line {
  font: 300 clamp(34px, 5vw, 72px)/1.02 var(--display);
  font-variation-settings: 'opsz' 144, 'SOFT' 32, 'WONK' 1;
  letter-spacing: -0.006em; max-width: 16ch; text-wrap: pretty;
}
.record-label { text-wrap: balance; }

/* STATEMENT */
.studio-statement p {
  font: 300 clamp(28px, 4.2vw, 58px)/1.28 var(--display);
  font-variation-settings: 'opsz' 72, 'SOFT' 32, 'WONK' 1;
  letter-spacing: 0; text-wrap: pretty;
}

/* TEXT */
.studio-note p, #studio .credentials p, .credential-quote p, .inquire-note p,
.inquire-quiet p, #tile-front .tile-front-copy, .cap-n {
  font-variation-settings: 'opsz' 24, 'SOFT' 20, 'WONK' 0;
  text-wrap: pretty;
}
#studio .index-grid figcaption { font: 300 16px/1.4 var(--display); }
.cap-n { display: block; }
.field input, .field textarea { font: 300 17px/1.5 var(--display); font-variation-settings: 'opsz' 24, 'SOFT' 20, 'WONK' 0; }

/* FOLIO. One declaration for every line that carries catalogue data rather than
   speech, and the ink is named rather than faded. */
#studio blockquote cite, .cap-m, #tile-front .tile-front-meta, #tile-front .tile-front-materials,
.inquire-facts, #studio .chapter-lead, .record-thumb-t, .record-foot p, .record-foot > span {
  font: 400 13px/1.5 var(--utility);
  letter-spacing: 0.01em; text-transform: none; opacity: 1;
  color: var(--folio-ink);
}
/* the band's own FOLIO lines take the ink the generator assigned to their node,
   because the ground under them is not the room's */
.record-thumb-t, .record-foot p { color: inherit; }
#studio .chapter-lead { max-width: 48ch; }
.cap-m { margin-top: 5px; }
/* Pitch P8 tabular figures removed by the manager (2026-09-23): in this face tabular-nums also
   widened commas and periods (8.39 px against 3.58 px, notes/P10.md), so "1.75 in." read spaced out. */
/* S3. Three lines, one shape for all 24: name, then series and dimensions, then
   availability. The leading middot before the availability token is out of the markup
   (it separated two things that are no longer on one line), and `.cap-hold` is the
   block that makes the third line exist. Where the frozen record has no series, no
   dimensions or no availability, those lines are simply absent: nothing is invented
   and nothing is padded. */
.cap-hold { display: block; white-space: nowrap; font-style: normal; }
.cap-a { color: var(--folio-ink); }

/* CHROME */
.field > label, .inquire-chips legend, .regarding legend {
  font: 400 11px/1 var(--utility); letter-spacing: 0.2em; text-transform: uppercase;
  color: var(--folio-ink); opacity: 1;
}
.chip span, #studio .inquire .button, .record-foot a, .tile-front-inquire {
  font-family: var(--utility); font-weight: 400; letter-spacing: 0.18em; text-transform: uppercase;
}
.record-foot a { font-size: 11px; letter-spacing: 0.18em; }

/* R36e / R36g S6. Only the IMAGE rises. The reveal used to sit on the figure, which
   carried the caption up with it and faded it in: R36e rules that tile captions never
   reveal, and S6 that the caption stays on the wall while the work comes off it. So
   the ghost moves onto `.tile-open` and the figcaption is simply there, at full ink,
   at frame 0, in every motion mode. It also makes the record legible during the whole
   reveal, which a record is for. */
html.sections-ready #studio .index-grid figure:not(.is-risen) { opacity: 1; transform: none; filter: none; }
html.sections-ready #studio .index-grid figure:not(.is-risen) .tile-open {
  opacity: 0; transform: translateY(14px);
  filter: grayscale(1) contrast(0.82) brightness(1.06);
}
#studio .index-grid .tile-open {
  transition: opacity 720ms var(--ease), transform 220ms var(--ease), filter 720ms var(--ease), box-shadow 400ms var(--ease);
  transition-delay: min(calc(var(--i, 0) * 40ms), 320ms), 0s, min(calc(var(--i, 0) * 40ms), 320ms), min(calc(var(--i, 0) * 40ms), 320ms);
}
#studio .index-grid figcaption { transition: none; }

/* R36d/e. The group labels and the closer statement are the two surfaces in chamber IV
   that DO take ink on the eye line, on the light ground's own ghost floor. Everything
   else in the record is at ink permanently. The floor is an rgb mix of the ground, not
   an alpha: 0.38 of ink on lime white measures 2.46:1, and 0.50 is #848382 at 3.46:1,
   which is the ruled floor for a transient state on this ground. */
html.sections-ready .group-label:not(.at-eye),
html.sections-ready .studio-statement:not(.at-eye) p { color: var(--ghost-light); }
.group-label, .studio-statement p { transition: color 400ms var(--ease); }

@media (max-width: 720px) {
  /* S21 again, and it has to be HERE rather than in the mobile block above: the
     reveal moved onto `.tile-open` in this section, which is later in the cascade at
     equal specificity, so the earlier suppression stopped matching and the phone was
     animating grayscale on 24 buttons again (MEASURED at three partial values
     mid-transition). One rule, in the same section as the thing it suppresses. */
  html.sections-ready #studio .index-grid figure:not(.is-risen) .tile-open {
    filter: none; transform: translateY(10px);
  }
  #studio .index-grid .tile-open { transition: opacity 720ms var(--ease), transform 220ms var(--ease), box-shadow 400ms var(--ease); }
  #studio .index-grid figcaption { font-size: 15px; }
  #studio blockquote cite, .cap-m, #tile-front .tile-front-meta, #tile-front .tile-front-materials,
  .inquire-facts, #studio .chapter-lead, .record-thumb-t, .record-foot p { font-size: 12px; }
  /* no second value: 16ch is 304px here, inside the 221 to 389 window, and the 308px
     column would force the same break on its own anyway */
}

@media (prefers-reduced-motion: reduce) {
  .group-label, .studio-statement p, #studio .index-grid .tile-open { transition: none; }
  html.sections-ready .group-label:not(.at-eye),
  html.sections-ready .studio-statement:not(.at-eye) p { color: var(--ink); }
}

/* ---------- R38e / P6. The record leaves the render budget when it is not on screen ----------

   Chamber IV is 24 photographs and 24 captions at the end of a 25,000px document, and
   until the visitor arrives none of it needs laying out or painting. `content-visibility:
   auto` skips both while a group is outside the viewport and its rendering neighbourhood.

   It goes on the two GROUPS and not on the 24 figures, and that is deliberate: under
   `@supports (grid-template-rows: subgrid)` each figure is a subgrid item whose rows are
   sized by the field, and size containment on a subgrid item would take that sizing away
   from the grid that owns it. The groups are plain blocks in normal flow, so containing
   them costs the layout nothing and still skips every tile inside.

   THE SCROLLBAR STAYS HONEST, which is the acceptance and the whole risk: a wrong
   intrinsic size makes the document's height change the moment the group first renders,
   and at the end of a 25,000px track that is a jump under the visitor's thumb. So the
   two heights are MEASURED, not estimated (2116 and 1844 at 1440x900, 2929 and 1656 at
   390x844), and each is written with the `auto` keyword, which means "use the size this
   element was last rendered at, and fall back to the measured figure only before that
   has ever happened". The inline axis is `none`: the width comes from the column and
   must keep coming from the column at every viewport in between.

   Scoped to `.mode-3d`, and that is a ruling about where the cost actually is rather
   than a way around a failing number. In immersive mode a WebGL loop is competing for
   every frame and skipping 24 tiles is real budget; in the document and JS-off paths
   there is no canvas, the record's layout is a one-time cost, and the intrinsic sizes
   measured for the immersive layout do not describe that document at all: unscoped, the
   first render of the record moved the scroll height by 1,270px at 1440 and 2,683px at
   390 with JS off, which is the jump under the thumb this rule exists to avoid. Scoped,
   it is 12px and 1px in the mode that has it, and zero in the modes that do not. */
@supports (content-visibility: auto) {
  .mode-3d .studio-group {
    content-visibility: auto;
    contain-intrinsic-size: auto none auto 2100px;
  }
  .mode-3d #studio-surfaces { contain-intrinsic-size: auto none auto 1850px; }
}
@media (max-width: 720px) {
  @supports (content-visibility: auto) {
    .mode-3d .studio-group { contain-intrinsic-size: auto none auto 2930px; }
    .mode-3d #studio-surfaces { contain-intrinsic-size: auto none auto 1660px; }
  }
}

/* ---------- Pitch P10 Q3: the artist's own words under a work ----------
   One short quote per work, the last child of its figcaption, after the series
   and title spans. Exact caption text from his published posts; the source URL
   rides on q[cite] and in an HTML comment above each figcaption.
   Why this cannot touch the walk: the engine reads only .p-series, .p-title,
   img, .quest-enter and data attributes from a figure.piece (js/grisaille.js
   build, the chamber list and gotoPiece), never the figcaption's whole text,
   and in immersive mode chambers I to III are clipped to 1px by grisaille.css.
   So this line is the DOCUMENT layer's (3D off, or no WebGL), and it is also in
   the accessibility tree in both modes, which is where a caption belongs.
   Register: the record's FOLIO for the words (400 13px/1.5 utility, the size
   .ho-note and .cap-m render at), sentence case, capped at 56ch; the name takes
   the series label's own register (.p-series) so the line reads as a wall label:
   the words, then who said them.
   The caption row wraps only where a quote is present; every other figcaption
   keeps its shipped one-row layout. The measure cap is padding, not max-width,
   on purpose: flex line breaking uses the item's clamped size, so a max-width
   would let a 56ch item sit on the title's row at desktop widths instead of
   starting its own line. */
.piece figcaption:has(.p-words) { flex-wrap: wrap; row-gap: 10px; }
.p-words {
  flex: 0 0 100%; box-sizing: border-box;
  padding-inline-end: max(0px, calc(100% - 56ch));
  font: 400 13px/1.5 var(--utility); letter-spacing: 0.01em;
  font-variation-settings: normal; text-wrap: pretty;
}
.p-words q { font-style: normal; }
.p-by {
  font: 11px/1 var(--utility); letter-spacing: 0.2em; text-transform: uppercase;
  opacity: 0.66; white-space: nowrap; margin-left: 6px;
}
